feat: simpler Home + password-protected cloud links
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 51s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 51s
Home is now two sections, no clutter: - Nearby — tap a device on the same Wi-Fi and the composer takes its place (drop files, optionally write a note, send). Pair-across-networks and public-room panels moved off the main page into the footer as a single discrete link (/pair). - Share a link — inline WeTransfer-style card. Pick a file, optionally expand email / password / expiry, upload. Result shows QR + copy link + the password to share out-of-band. The password gate is a server-side access control layer on top of the existing E2E encryption: scrypt-hashed on create, verified on consume. The encryption key still lives only in the URL fragment; the password does not participate in the crypto. - server: password_hash column (migration 0002), scrypt+timingSafeEqual verify on /consume, requiresPassword surfaced on the HEAD response. - web: Composer merges drop zone + text note into one surface; Receive shows a password prompt when requiresPassword is true and recovers in place on a wrong attempt. - responsive: mobile-first paddings, DeviceChip shrinks on narrow widths, no 2-col grids on the main page. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
6ba5401c4d
commit
dbd500b0b5
1
server/src/db/migrations/0002_typical_northstar.sql
Normal file
1
server/src/db/migrations/0002_typical_northstar.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "transfers" ADD COLUMN "password_hash" text;
|
||||||
559
server/src/db/migrations/meta/0002_snapshot.json
Normal file
559
server/src/db/migrations/meta/0002_snapshot.json
Normal file
@ -0,0 +1,559 @@
|
|||||||
|
{
|
||||||
|
"id": "006000af-0a3b-4b14-8391-1adbd1aba3c4",
|
||||||
|
"prevId": "50199a15-ea37-4c61-beee-71f2d99cd292",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.magic_links": {
|
||||||
|
"name": "magic_links",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"token_hash": {
|
||||||
|
"name": "token_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"used_at": {
|
||||||
|
"name": "used_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"magic_links_token_hash_unique": {
|
||||||
|
"name": "magic_links_token_hash_unique",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "token_hash",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"magic_links_email_idx": {
|
||||||
|
"name": "magic_links_email_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "email",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"token_hash": {
|
||||||
|
"name": "token_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_agent": {
|
||||||
|
"name": "user_agent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"ip_hash": {
|
||||||
|
"name": "ip_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"last_used_at": {
|
||||||
|
"name": "last_used_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"sessions_token_hash_unique": {
|
||||||
|
"name": "sessions_token_hash_unique",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "token_hash",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"sessions_user_idx": {
|
||||||
|
"name": "sessions_user_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sessions_user_id_users_id_fk": {
|
||||||
|
"name": "sessions_user_id_users_id_fk",
|
||||||
|
"tableFrom": "sessions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.transfers": {
|
||||||
|
"name": "transfers",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"storage_key": {
|
||||||
|
"name": "storage_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"sender_user_id": {
|
||||||
|
"name": "sender_user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"sender_device_id": {
|
||||||
|
"name": "sender_device_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"recipient_user_id": {
|
||||||
|
"name": "recipient_user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"recipient_email_hash": {
|
||||||
|
"name": "recipient_email_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"encrypted_metadata": {
|
||||||
|
"name": "encrypted_metadata",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"size_bytes": {
|
||||||
|
"name": "size_bytes",
|
||||||
|
"type": "bigint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"max_downloads": {
|
||||||
|
"name": "max_downloads",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"download_count": {
|
||||||
|
"name": "download_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"first_download_at": {
|
||||||
|
"name": "first_download_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"deleted_at": {
|
||||||
|
"name": "deleted_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"transfers_sender_idx": {
|
||||||
|
"name": "transfers_sender_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "sender_user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"transfers_recipient_idx": {
|
||||||
|
"name": "transfers_recipient_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "recipient_user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"transfers_expires_idx": {
|
||||||
|
"name": "transfers_expires_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "expires_at",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"transfers_sender_user_id_users_id_fk": {
|
||||||
|
"name": "transfers_sender_user_id_users_id_fk",
|
||||||
|
"tableFrom": "transfers",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"sender_user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"transfers_recipient_user_id_users_id_fk": {
|
||||||
|
"name": "transfers_recipient_user_id_users_id_fk",
|
||||||
|
"tableFrom": "transfers",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"recipient_user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.user_devices": {
|
||||||
|
"name": "user_devices",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"device_id": {
|
||||||
|
"name": "device_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"avatar": {
|
||||||
|
"name": "avatar",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"linked_at": {
|
||||||
|
"name": "linked_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"last_seen_at": {
|
||||||
|
"name": "last_seen_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"user_devices_user_device_unique": {
|
||||||
|
"name": "user_devices_user_device_unique",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "device_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"user_devices_user_id_users_id_fk": {
|
||||||
|
"name": "user_devices_user_id_users_id_fk",
|
||||||
|
"tableFrom": "user_devices",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.users": {
|
||||||
|
"name": "users",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"plan": {
|
||||||
|
"name": "plan",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'free'"
|
||||||
|
},
|
||||||
|
"stripe_customer_id": {
|
||||||
|
"name": "stripe_customer_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_email_unique": {
|
||||||
|
"name": "users_email_unique",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "email",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,6 +15,13 @@
|
|||||||
"when": 1776675064100,
|
"when": 1776675064100,
|
||||||
"tag": "0001_loving_yellowjacket",
|
"tag": "0001_loving_yellowjacket",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1776677642835,
|
||||||
|
"tag": "0002_typical_northstar",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -104,6 +104,7 @@ export const transfers = pgTable(
|
|||||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||||
firstDownloadAt: timestamp("first_download_at", { withTimezone: true }),
|
firstDownloadAt: timestamp("first_download_at", { withTimezone: true }),
|
||||||
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
||||||
|
passwordHash: text("password_hash"),
|
||||||
},
|
},
|
||||||
(t) => ({
|
(t) => ({
|
||||||
senderIdx: index("transfers_sender_idx").on(t.senderUserId),
|
senderIdx: index("transfers_sender_idx").on(t.senderUserId),
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { createHash, randomUUID } from "node:crypto";
|
import { createHash, randomBytes, randomUUID, scryptSync, timingSafeEqual } from "node:crypto";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { and, desc, eq, gt, isNull, or, sql } from "drizzle-orm";
|
import { and, desc, eq, gt, isNull, or, sql } from "drizzle-orm";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
@ -32,6 +32,24 @@ function isValidUuid(v: string): boolean {
|
|||||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PW_MIN_LEN = 4;
|
||||||
|
const PW_MAX_LEN = 128;
|
||||||
|
|
||||||
|
function hashPassword(password: string): string {
|
||||||
|
const salt = randomBytes(16);
|
||||||
|
const hash = scryptSync(password, salt, 32);
|
||||||
|
return `${salt.toString("hex")}:${hash.toString("hex")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyPassword(password: string, stored: string): boolean {
|
||||||
|
const [saltHex, hashHex] = stored.split(":");
|
||||||
|
if (!saltHex || !hashHex) return false;
|
||||||
|
const salt = Buffer.from(saltHex, "hex");
|
||||||
|
const expected = Buffer.from(hashHex, "hex");
|
||||||
|
const actual = scryptSync(password, salt, expected.length);
|
||||||
|
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/transfers
|
* POST /api/transfers
|
||||||
* Create a transfer. Body:
|
* Create a transfer. Body:
|
||||||
@ -87,6 +105,14 @@ transferRoutes.post("/transfers", async (c) => {
|
|||||||
if (match.length > 0) recipientUserId = match[0].id;
|
if (match.length > 0) recipientUserId = match[0].id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let passwordHash: string | null = null;
|
||||||
|
if (typeof body.password === "string" && body.password.length > 0) {
|
||||||
|
if (body.password.length < PW_MIN_LEN || body.password.length > PW_MAX_LEN) {
|
||||||
|
return c.json({ error: "invalid_password" }, 400);
|
||||||
|
}
|
||||||
|
passwordHash = hashPassword(body.password);
|
||||||
|
}
|
||||||
|
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const key = storageKey(id);
|
const key = storageKey(id);
|
||||||
|
|
||||||
@ -103,6 +129,7 @@ transferRoutes.post("/transfers", async (c) => {
|
|||||||
sizeBytes,
|
sizeBytes,
|
||||||
maxDownloads,
|
maxDownloads,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
|
passwordHash,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@ -140,6 +167,7 @@ transferRoutes.get("/transfers/:id", async (c) => {
|
|||||||
maxDownloads: row.maxDownloads,
|
maxDownloads: row.maxDownloads,
|
||||||
downloadCount: row.downloadCount,
|
downloadCount: row.downloadCount,
|
||||||
expiresAt: row.expiresAt,
|
expiresAt: row.expiresAt,
|
||||||
|
requiresPassword: row.passwordHash !== null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -152,6 +180,28 @@ transferRoutes.post("/transfers/:id/consume", async (c) => {
|
|||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
if (!isValidUuid(id)) return c.json({ error: "not_found" }, 404);
|
if (!isValidUuid(id)) return c.json({ error: "not_found" }, 404);
|
||||||
|
|
||||||
|
let body: any = {};
|
||||||
|
try {
|
||||||
|
body = await c.req.json();
|
||||||
|
} catch {
|
||||||
|
// no body is fine — only required when password-protected
|
||||||
|
}
|
||||||
|
|
||||||
|
const [head] = await db
|
||||||
|
.select({ passwordHash: transfers.passwordHash })
|
||||||
|
.from(transfers)
|
||||||
|
.where(eq(transfers.id, id))
|
||||||
|
.limit(1);
|
||||||
|
if (!head) return c.json({ error: "not_found" }, 404);
|
||||||
|
|
||||||
|
if (head.passwordHash) {
|
||||||
|
const password = typeof body?.password === "string" ? body.password : "";
|
||||||
|
if (!password) return c.json({ error: "password_required" }, 401);
|
||||||
|
if (!verifyPassword(password, head.passwordHash)) {
|
||||||
|
return c.json({ error: "invalid_password" }, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.update(transfers)
|
.update(transfers)
|
||||||
.set({
|
.set({
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useRef } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { QRCodeSVG } from "qrcode.react";
|
import { QRCodeSVG } from "qrcode.react";
|
||||||
import { sendCloud } from "../lib/sendCloud";
|
import { sendCloud } from "../lib/sendCloud";
|
||||||
import { useProfileStore } from "../stores/useProfileStore";
|
import { useProfileStore } from "../stores/useProfileStore";
|
||||||
@ -6,9 +6,15 @@ import { useProfileStore } from "../stores/useProfileStore";
|
|||||||
type Stage =
|
type Stage =
|
||||||
| { kind: "idle" }
|
| { kind: "idle" }
|
||||||
| { kind: "uploading"; loaded: number; total: number }
|
| { kind: "uploading"; loaded: number; total: number }
|
||||||
| { kind: "done"; shareUrl: string; fileName: string; expiresAt: string }
|
| { kind: "done"; shareUrl: string; fileName: string; expiresAt: string; password: string | null }
|
||||||
| { kind: "error"; message: string };
|
| { kind: "error"; message: string };
|
||||||
|
|
||||||
|
const EXPIRY_CHOICES: { label: string; days: number }[] = [
|
||||||
|
{ label: "1 day", days: 1 },
|
||||||
|
{ label: "7 days", days: 7 },
|
||||||
|
{ label: "30 days", days: 30 },
|
||||||
|
];
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
function formatSize(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
@ -18,256 +24,287 @@ function formatSize(bytes: number): string {
|
|||||||
|
|
||||||
export default function CloudSharePanel() {
|
export default function CloudSharePanel() {
|
||||||
const deviceId = useProfileStore((s) => s.deviceId);
|
const deviceId = useProfileStore((s) => s.deviceId);
|
||||||
const [showModal, setShowModal] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowModal(true)}
|
|
||||||
className="paper-panel px-4 py-4 flex flex-col items-start gap-1
|
|
||||||
hover:border-ink transition-colors duration-fast ease-crisp
|
|
||||||
text-left"
|
|
||||||
>
|
|
||||||
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-ink-muted">
|
|
||||||
Cloud drop
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-ink">Send to anyone →</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showModal && (
|
|
||||||
<CloudShareModal
|
|
||||||
deviceId={deviceId}
|
|
||||||
onClose={() => setShowModal(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CloudShareModal({
|
|
||||||
deviceId,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
deviceId: string;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const [stage, setStage] = useState<Stage>({ kind: "idle" });
|
const [stage, setStage] = useState<Stage>({ kind: "idle" });
|
||||||
const [pickedFile, setPickedFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [expiryDays, setExpiryDays] = useState(7);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setStage({ kind: "idle" });
|
||||||
|
setFile(null);
|
||||||
|
setEmail("");
|
||||||
|
setPassword("");
|
||||||
|
setExpiryDays(7);
|
||||||
|
setShowAdvanced(false);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
if (!pickedFile) return;
|
if (!file) return;
|
||||||
setStage({ kind: "uploading", loaded: 0, total: pickedFile.size });
|
setStage({ kind: "uploading", loaded: 0, total: file.size });
|
||||||
try {
|
try {
|
||||||
const result = await sendCloud(pickedFile, {
|
const result = await sendCloud(file, {
|
||||||
deviceId,
|
deviceId,
|
||||||
recipientEmail: email.trim() || undefined,
|
recipientEmail: email.trim() || undefined,
|
||||||
|
password: password.trim() || undefined,
|
||||||
|
expiresInDays: expiryDays,
|
||||||
onProgress: (loaded, total) => setStage({ kind: "uploading", loaded, total }),
|
onProgress: (loaded, total) => setStage({ kind: "uploading", loaded, total }),
|
||||||
});
|
});
|
||||||
setStage({
|
setStage({
|
||||||
kind: "done",
|
kind: "done",
|
||||||
shareUrl: result.shareUrl,
|
shareUrl: result.shareUrl,
|
||||||
fileName: pickedFile.name,
|
fileName: file.name,
|
||||||
expiresAt: result.expiresAt,
|
expiresAt: result.expiresAt,
|
||||||
|
password: password.trim() || null,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : "unknown";
|
setStage({ kind: "error", message: err instanceof Error ? err.message : "unknown" });
|
||||||
setStage({ kind: "error", message: msg });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopy = () => {
|
const copy = (value: string) => {
|
||||||
if (stage.kind !== "done") return;
|
navigator.clipboard.writeText(value);
|
||||||
navigator.clipboard.writeText(stage.shareUrl);
|
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 1500);
|
setTimeout(() => setCopied(false), 1500);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="paper-panel p-4 sm:p-5">
|
||||||
className="fixed inset-0 z-50 bg-ink/40 flex items-center justify-center p-4"
|
{stage.kind === "idle" && (
|
||||||
onClick={onClose}
|
<>
|
||||||
>
|
<input
|
||||||
<div
|
ref={fileRef}
|
||||||
className="paper-panel shadow-lift rounded-sm p-6 max-w-md w-full"
|
type="file"
|
||||||
onClick={(e) => e.stopPropagation()}
|
className="hidden"
|
||||||
>
|
onChange={(e) => {
|
||||||
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
|
const f = e.target.files?.[0];
|
||||||
Via AnyDrop
|
if (f) setFile(f);
|
||||||
</div>
|
}}
|
||||||
<h3 className="font-display text-2xl text-ink mt-1 mb-5 tracking-tight">
|
/>
|
||||||
Send to anyone
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{stage.kind === "idle" && (
|
{file ? (
|
||||||
<>
|
<div className="paper-panel-deep rounded-sm px-4 py-3 mb-4">
|
||||||
<input
|
<div className="flex items-center justify-between">
|
||||||
ref={fileRef}
|
<div className="min-w-0 flex-1 mr-3">
|
||||||
type="file"
|
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-ink-muted">
|
||||||
className="hidden"
|
File
|
||||||
onChange={(e) => {
|
</div>
|
||||||
const f = e.target.files?.[0];
|
<div className="text-sm text-ink mt-0.5 truncate">{file.name}</div>
|
||||||
if (f) setPickedFile(f);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{pickedFile ? (
|
|
||||||
<div className="paper-panel-deep border-paper-edge rounded-sm px-4 py-3 mb-4">
|
|
||||||
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-ink-muted">
|
|
||||||
File
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between mt-1">
|
<span className="font-mono text-xs text-ink-muted whitespace-nowrap">
|
||||||
<span className="text-sm text-ink truncate mr-3">{pickedFile.name}</span>
|
{formatSize(file.size)}
|
||||||
<span className="font-mono text-xs text-ink-muted whitespace-nowrap">
|
</span>
|
||||||
{formatSize(pickedFile.size)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => fileRef.current?.click()}
|
|
||||||
className="mt-2 text-xs text-ink-muted hover:text-ink transition-colors"
|
|
||||||
>
|
|
||||||
Pick another
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => fileRef.current?.click()}
|
onClick={() => fileRef.current?.click()}
|
||||||
className="w-full border border-dashed border-paper-edge hover:border-ink
|
className="mt-3 text-xs text-ink-muted hover:text-ink transition-colors"
|
||||||
bg-paper rounded-sm px-4 py-8 mb-4
|
|
||||||
flex flex-col items-center gap-2
|
|
||||||
transition-colors duration-fast ease-crisp"
|
|
||||||
>
|
>
|
||||||
<span className="font-mono text-[10px] uppercase tracking-[0.2em] text-ink-muted">
|
Pick another
|
||||||
Pick
|
|
||||||
</span>
|
|
||||||
<span className="font-display text-xl text-ink">Choose a file</span>
|
|
||||||
<span className="text-xs text-ink-muted">Up to 2 GB</span>
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
className="w-full border border-dashed border-paper-edge hover:border-ink
|
||||||
|
bg-paper rounded-sm px-4 py-6 sm:py-8 mb-4
|
||||||
|
flex flex-col items-center gap-1.5
|
||||||
|
transition-colors duration-fast ease-crisp"
|
||||||
|
>
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-[0.2em] text-ink-muted">
|
||||||
|
Pick
|
||||||
|
</span>
|
||||||
|
<span className="font-display text-lg sm:text-xl text-ink">Choose a file</span>
|
||||||
|
<span className="text-xs text-ink-muted">Up to 2 GB · 7 days default</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<label className="block text-xs uppercase tracking-[0.15em] text-ink-muted mb-1.5">
|
<button
|
||||||
Recipient email <span className="text-ink-faint normal-case tracking-normal">(optional)</span>
|
onClick={() => setShowAdvanced((v) => !v)}
|
||||||
</label>
|
className="mb-3 font-mono text-[10px] uppercase tracking-[0.22em] text-ink-muted
|
||||||
<input
|
hover:text-ink transition-colors"
|
||||||
type="email"
|
>
|
||||||
value={email}
|
{showAdvanced ? "− Hide options" : "+ Email, password, expiry"}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
</button>
|
||||||
placeholder="friend@example.com"
|
|
||||||
className="w-full px-3 py-2.5 bg-paper border border-paper-edge rounded-sm
|
{showAdvanced && (
|
||||||
text-ink text-sm placeholder:text-ink-faint
|
<div className="space-y-3 mb-4 pb-4 border-b border-paper-edge">
|
||||||
focus:outline-none focus:border-ink transition-colors
|
<Field label="Recipient email">
|
||||||
duration-fast ease-crisp mb-5"
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="friend@example.com"
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Password">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Leave blank for none"
|
||||||
|
minLength={4}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Expires in">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{EXPIRY_CHOICES.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.days}
|
||||||
|
onClick={() => setExpiryDays(c.days)}
|
||||||
|
className={`flex-1 py-2 text-xs rounded-sm border transition-colors duration-fast ease-crisp ${
|
||||||
|
expiryDays === c.days
|
||||||
|
? "border-ink text-ink bg-paper-deep"
|
||||||
|
: "border-paper-edge text-ink-muted hover:text-ink"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{c.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!file}
|
||||||
|
className="w-full py-3 bg-ink text-paper text-sm font-medium rounded-sm
|
||||||
|
hover:bg-signal transition-colors duration-fast ease-crisp
|
||||||
|
disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Encrypt & upload →
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="mt-3 text-[11px] text-ink-faint leading-relaxed">
|
||||||
|
Sealed in your browser. Only the link holder
|
||||||
|
{password && " + password"} can open it.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stage.kind === "uploading" && (
|
||||||
|
<>
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-ink-muted mb-2">
|
||||||
|
Uploading ciphertext
|
||||||
|
</div>
|
||||||
|
<p className="font-display text-lg sm:text-xl text-ink mb-4">
|
||||||
|
Sealing and uploading…
|
||||||
|
</p>
|
||||||
|
<div className="h-px bg-paper-edge overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-signal transition-all duration-200"
|
||||||
|
style={{
|
||||||
|
width: `${stage.total > 0 ? Math.round((stage.loaded / stage.total) * 100) : 0}%`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 font-mono text-xs text-ink-muted">
|
||||||
|
{formatSize(stage.loaded)} / {formatSize(stage.total)}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="text-xs text-ink-muted leading-relaxed mb-5">
|
{stage.kind === "done" && (
|
||||||
Your file is encrypted locally, then stored on AnyDrop for 7 days. The key never leaves
|
<>
|
||||||
your browser — only the link's <code className="mono text-ink">#fragment</code> holds it.
|
<div className="flex flex-col sm:flex-row sm:items-start gap-4 mb-4">
|
||||||
</p>
|
<div className="self-center shrink-0 bg-paper p-2 border border-paper-edge rounded-sm">
|
||||||
|
<QRCodeSVG
|
||||||
<div className="flex gap-3">
|
value={stage.shareUrl}
|
||||||
<button
|
size={128}
|
||||||
onClick={onClose}
|
bgColor="#F5F0E6"
|
||||||
className="flex-1 py-2.5 border border-paper-edge hover:border-ink
|
fgColor="#1A1714"
|
||||||
text-sm text-ink rounded-sm transition-colors
|
|
||||||
duration-fast ease-crisp"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleSend}
|
|
||||||
disabled={!pickedFile}
|
|
||||||
className="flex-1 py-2.5 bg-ink text-paper text-sm font-medium rounded-sm
|
|
||||||
hover:bg-signal transition-colors duration-fast ease-crisp
|
|
||||||
disabled:opacity-30 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Encrypt & send →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{stage.kind === "uploading" && (
|
|
||||||
<>
|
|
||||||
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted mb-2">
|
|
||||||
Uploading ciphertext
|
|
||||||
</div>
|
|
||||||
<p className="font-display text-xl text-ink mb-5">
|
|
||||||
Sealing and uploading…
|
|
||||||
</p>
|
|
||||||
<div className="h-px bg-paper-edge overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full bg-signal transition-all duration-200"
|
|
||||||
style={{
|
|
||||||
width: `${stage.total > 0 ? Math.round((stage.loaded / stage.total) * 100) : 0}%`,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 font-mono text-xs text-ink-muted">
|
<div className="min-w-0 flex-1">
|
||||||
{formatSize(stage.loaded)} / {formatSize(stage.total)}
|
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-ok">
|
||||||
</p>
|
Ready to share
|
||||||
</>
|
</div>
|
||||||
)}
|
<div className="font-display text-lg text-ink mt-1 tracking-tight truncate">
|
||||||
|
{stage.fileName}
|
||||||
{stage.kind === "done" && (
|
</div>
|
||||||
<>
|
<div className="text-xs text-ink-muted mt-1.5">
|
||||||
<div className="flex justify-center mb-5">
|
Expires {new Date(stage.expiresAt).toLocaleDateString()} · one download
|
||||||
<div className="bg-paper p-3 border border-paper-edge rounded-sm">
|
|
||||||
<QRCodeSVG
|
|
||||||
value={stage.shareUrl}
|
|
||||||
size={180}
|
|
||||||
bgColor="#F5F0E6"
|
|
||||||
fgColor="#1A1714"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs uppercase tracking-[0.22em] text-ok">Ready to share</div>
|
</div>
|
||||||
<h3 className="font-display text-xl text-ink mt-1 mb-3 tracking-tight">
|
|
||||||
{stage.fileName}
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
onClick={handleCopy}
|
|
||||||
className="w-full py-2.5 bg-ink text-paper text-sm font-medium rounded-sm
|
|
||||||
hover:bg-signal transition-colors duration-fast ease-crisp"
|
|
||||||
>
|
|
||||||
{copied ? "Copied ✓" : "Copy link"}
|
|
||||||
</button>
|
|
||||||
<p className="mt-3 font-mono text-xs text-ink-muted break-all">
|
|
||||||
{stage.shareUrl}
|
|
||||||
</p>
|
|
||||||
<p className="mt-4 text-xs text-ink-muted leading-relaxed">
|
|
||||||
Expires {new Date(stage.expiresAt).toLocaleDateString()}. One download by default —
|
|
||||||
anyone who has the link can fetch it once.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="mt-5 w-full py-2.5 border border-paper-edge hover:border-ink
|
|
||||||
text-sm text-ink rounded-sm transition-colors
|
|
||||||
duration-fast ease-crisp"
|
|
||||||
>
|
|
||||||
Done
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{stage.kind === "error" && (
|
<button
|
||||||
<>
|
onClick={() => copy(stage.shareUrl)}
|
||||||
<div className="text-xs uppercase tracking-[0.22em] text-fail">Failed</div>
|
className="w-full py-2.5 bg-ink text-paper text-sm font-medium rounded-sm
|
||||||
<h3 className="font-display text-xl text-ink mt-1 mb-3">
|
hover:bg-signal transition-colors duration-fast ease-crisp"
|
||||||
Could not complete the transfer
|
>
|
||||||
</h3>
|
{copied ? "Copied ✓" : "Copy link"}
|
||||||
<p className="font-mono text-xs text-ink-muted">{stage.message}</p>
|
</button>
|
||||||
<button
|
|
||||||
onClick={() => setStage({ kind: "idle" })}
|
<p className="mt-2 font-mono text-[11px] text-ink-faint break-all">
|
||||||
className="mt-5 w-full py-2.5 bg-ink text-paper text-sm font-medium rounded-sm
|
{stage.shareUrl}
|
||||||
hover:bg-signal transition-colors duration-fast ease-crisp"
|
</p>
|
||||||
>
|
|
||||||
Try again
|
{stage.password && (
|
||||||
</button>
|
<div className="mt-3 paper-panel-deep rounded-sm px-3 py-2.5">
|
||||||
</>
|
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-ink-muted">
|
||||||
)}
|
Password (share separately)
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center justify-between mt-1">
|
||||||
|
<code className="font-mono text-sm text-ink">{stage.password}</code>
|
||||||
|
<button
|
||||||
|
onClick={() => copy(stage.password!)}
|
||||||
|
className="text-xs text-ink-muted hover:text-ink transition-colors"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="mt-4 text-xs text-ink-muted hover:text-ink transition-colors"
|
||||||
|
>
|
||||||
|
Send another →
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stage.kind === "error" && (
|
||||||
|
<>
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-fail">Failed</div>
|
||||||
|
<h3 className="font-display text-lg text-ink mt-1 mb-2">
|
||||||
|
Could not complete the transfer
|
||||||
|
</h3>
|
||||||
|
<p className="font-mono text-xs text-ink-muted">{stage.message}</p>
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="mt-4 w-full py-2.5 bg-ink text-paper text-sm font-medium rounded-sm
|
||||||
|
hover:bg-signal transition-colors duration-fast ease-crisp"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
"w-full px-3 py-2 bg-paper border border-paper-edge rounded-sm text-ink text-sm " +
|
||||||
|
"placeholder:text-ink-faint focus:outline-none focus:border-ink transition-colors " +
|
||||||
|
"duration-fast ease-crisp";
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-ink-muted mb-1.5">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
105
web/src/components/Composer.tsx
Normal file
105
web/src/components/Composer.tsx
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import DropZone from "./DropZone";
|
||||||
|
|
||||||
|
interface ComposerProps {
|
||||||
|
recipientLabel: string;
|
||||||
|
onSendFiles: (files: File[]) => void;
|
||||||
|
onSendText: (text: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Composer({
|
||||||
|
recipientLabel,
|
||||||
|
onSendFiles,
|
||||||
|
onSendText,
|
||||||
|
onCancel,
|
||||||
|
}: ComposerProps) {
|
||||||
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
|
||||||
|
const hasContent = files.length > 0 || text.trim().length > 0;
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
if (files.length > 0) onSendFiles(files);
|
||||||
|
if (text.trim().length > 0) onSendText(text.trim());
|
||||||
|
setFiles([]);
|
||||||
|
setText("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="paper-panel p-4 sm:p-5 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-ink-muted">
|
||||||
|
Send to
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-ink mt-0.5 truncate max-w-[220px] sm:max-w-none">
|
||||||
|
{recipientLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="text-xs text-ink-muted hover:text-ink transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{files.length === 0 ? (
|
||||||
|
<DropZone onFilesSelected={(f) => setFiles(f)} />
|
||||||
|
) : (
|
||||||
|
<div className="paper-panel-deep rounded-sm px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-ink-muted">
|
||||||
|
{files.length} file{files.length > 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setFiles([])}
|
||||||
|
className="text-xs text-ink-muted hover:text-ink transition-colors"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{files.map((f, i) => (
|
||||||
|
<li key={i} className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-sm text-ink truncate">{f.name}</span>
|
||||||
|
<span className="font-mono text-xs text-ink-muted whitespace-nowrap">
|
||||||
|
{formatSize(f.size)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
placeholder="Or write a note…"
|
||||||
|
rows={2}
|
||||||
|
className="w-full resize-none bg-paper border border-paper-edge rounded-sm
|
||||||
|
px-3 py-2.5 text-sm text-ink placeholder:text-ink-faint
|
||||||
|
focus:outline-none focus:border-ink transition-colors
|
||||||
|
duration-fast ease-crisp"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!hasContent}
|
||||||
|
className="w-full py-3 bg-ink text-paper text-sm font-medium rounded-sm
|
||||||
|
hover:bg-signal transition-colors duration-fast ease-crisp
|
||||||
|
disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Send →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -11,13 +11,13 @@ export default function PeerList({ onPeerSelect }: PeerListProps) {
|
|||||||
|
|
||||||
if (peers.length === 0) {
|
if (peers.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="paper-panel px-6 py-10 flex flex-col items-center text-center">
|
<div className="paper-panel px-5 sm:px-6 py-8 sm:py-10 flex flex-col items-center text-center">
|
||||||
<div className="w-10 h-10 rounded-full border border-paper-edge flex items-center justify-center mb-4">
|
<div className="w-10 h-10 rounded-full border border-paper-edge flex items-center justify-center mb-4">
|
||||||
<span className="w-2 h-2 rounded-full bg-signal animate-pulse" />
|
<span className="w-2 h-2 rounded-full bg-signal animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
<p className="font-display text-lg text-ink">Listening for devices</p>
|
<p className="font-display text-lg text-ink">Listening for devices</p>
|
||||||
<p className="text-sm text-ink-muted mt-2 max-w-xs leading-relaxed">
|
<p className="text-sm text-ink-muted mt-2 max-w-xs leading-relaxed">
|
||||||
Open AnyDrop on another device on this network, or pair a device below.
|
Open AnyDrop on another device on the same Wi-Fi. It shows up here automatically.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -81,6 +81,7 @@ export interface TransferHead {
|
|||||||
maxDownloads: number;
|
maxDownloads: number;
|
||||||
downloadCount: number;
|
downloadCount: number;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
|
requiresPassword: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InboxTransfer {
|
export interface InboxTransfer {
|
||||||
@ -104,6 +105,7 @@ export async function createTransfer(input: {
|
|||||||
maxDownloads?: number;
|
maxDownloads?: number;
|
||||||
expiresInDays?: number;
|
expiresInDays?: number;
|
||||||
deviceId?: string;
|
deviceId?: string;
|
||||||
|
password?: string;
|
||||||
}): Promise<CreateTransferResponse> {
|
}): Promise<CreateTransferResponse> {
|
||||||
const res = await call("/api/transfers", {
|
const res = await call("/api/transfers", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@ -125,8 +127,14 @@ export async function getTransferHead(id: string): Promise<TransferHead> {
|
|||||||
return (await res.json()) as TransferHead;
|
return (await res.json()) as TransferHead;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function consumeTransfer(id: string): Promise<{ downloadUrl: string }> {
|
export async function consumeTransfer(
|
||||||
const res = await call(`/api/transfers/${encodeURIComponent(id)}/consume`, { method: "POST" });
|
id: string,
|
||||||
|
password?: string,
|
||||||
|
): Promise<{ downloadUrl: string }> {
|
||||||
|
const res = await call(`/api/transfers/${encodeURIComponent(id)}/consume`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(password ? { password } : {}),
|
||||||
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
throw new Error((body as { error?: string }).error ?? `consume failed: ${res.status}`);
|
throw new Error((body as { error?: string }).error ?? `consume failed: ${res.status}`);
|
||||||
|
|||||||
@ -18,6 +18,7 @@ export interface SendCloudOptions {
|
|||||||
expiresInDays?: number;
|
expiresInDays?: number;
|
||||||
maxDownloads?: number;
|
maxDownloads?: number;
|
||||||
deviceId?: string;
|
deviceId?: string;
|
||||||
|
password?: string;
|
||||||
onProgress?: (loaded: number, total: number) => void;
|
onProgress?: (loaded: number, total: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,6 +49,7 @@ export async function sendCloud(
|
|||||||
maxDownloads: options.maxDownloads,
|
maxDownloads: options.maxDownloads,
|
||||||
expiresInDays: options.expiresInDays,
|
expiresInDays: options.expiresInDays,
|
||||||
deviceId: options.deviceId,
|
deviceId: options.deviceId,
|
||||||
|
password: options.password,
|
||||||
});
|
});
|
||||||
|
|
||||||
await uploadWithProgress(created.uploadUrl, encryptedBody, options.onProgress);
|
await uploadWithProgress(created.uploadUrl, encryptedBody, options.onProgress);
|
||||||
@ -124,11 +126,14 @@ export async function receiveCloud(
|
|||||||
transferId: string,
|
transferId: string,
|
||||||
key: Uint8Array,
|
key: Uint8Array,
|
||||||
metadata: TransferMetadata,
|
metadata: TransferMetadata,
|
||||||
onProgress?: (loaded: number, total: number) => void,
|
options: {
|
||||||
|
password?: string;
|
||||||
|
onProgress?: (loaded: number, total: number) => void;
|
||||||
|
} = {},
|
||||||
): Promise<File> {
|
): Promise<File> {
|
||||||
const { downloadUrl } = await consumeTransfer(transferId);
|
const { downloadUrl } = await consumeTransfer(transferId, options.password);
|
||||||
|
|
||||||
const ciphertext = await downloadWithProgress(downloadUrl, onProgress);
|
const ciphertext = await downloadWithProgress(downloadUrl, options.onProgress);
|
||||||
return openFile(key, ciphertext, metadata);
|
return openFile(key, ciphertext, metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,56 +4,38 @@ import { useSignaling } from "../hooks/useSignaling";
|
|||||||
import { useStore } from "../stores/useStore";
|
import { useStore } from "../stores/useStore";
|
||||||
import { useProfileStore } from "../stores/useProfileStore";
|
import { useProfileStore } from "../stores/useProfileStore";
|
||||||
import PeerList from "../components/PeerList";
|
import PeerList from "../components/PeerList";
|
||||||
import DropZone from "../components/DropZone";
|
|
||||||
import TransferProgress from "../components/TransferProgress";
|
import TransferProgress from "../components/TransferProgress";
|
||||||
import TextShareModal from "../components/TextShareModal";
|
|
||||||
import ReceiveDialog from "../components/ReceiveDialog";
|
import ReceiveDialog from "../components/ReceiveDialog";
|
||||||
import PublicRoomPanel from "../components/PublicRoomPanel";
|
|
||||||
import DevicePairingPanel from "../components/DevicePairingPanel";
|
|
||||||
import ProfileSetup from "../components/ProfileSetup";
|
import ProfileSetup from "../components/ProfileSetup";
|
||||||
import CloudSharePanel from "../components/CloudSharePanel";
|
import CloudSharePanel from "../components/CloudSharePanel";
|
||||||
|
import Composer from "../components/Composer";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const isSetUp = useProfileStore((s) => s.isSetUp);
|
const isSetUp = useProfileStore((s) => s.isSetUp);
|
||||||
|
if (!isSetUp) return <ProfileSetup onDone={() => {}} />;
|
||||||
if (!isSetUp) {
|
|
||||||
return <ProfileSetup onDone={() => {}} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <HomeConnected />;
|
return <HomeConnected />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function HomeConnected() {
|
function HomeConnected() {
|
||||||
const {
|
const { sendFiles, sendText, acceptTransfer, rejectTransfer, wakePeer } = useSignaling();
|
||||||
sendFiles,
|
|
||||||
sendText,
|
|
||||||
acceptTransfer,
|
|
||||||
rejectTransfer,
|
|
||||||
createPublicRoom,
|
|
||||||
wakePeer,
|
|
||||||
requestPairCode,
|
|
||||||
resolvePairCode,
|
|
||||||
} = useSignaling();
|
|
||||||
|
|
||||||
const peers = useStore((s) => s.peers);
|
const peers = useStore((s) => s.peers);
|
||||||
const selectedPeerId = useStore((s) => s.selectedPeerId);
|
const selectedPeerId = useStore((s) => s.selectedPeerId);
|
||||||
const showTextModal = useStore((s) => s.showTextModal);
|
|
||||||
const incomingRequest = useStore((s) => s.incomingRequest);
|
const incomingRequest = useStore((s) => s.incomingRequest);
|
||||||
const error = useStore((s) => s.error);
|
const error = useStore((s) => s.error);
|
||||||
const setSelectedPeerId = useStore((s) => s.setSelectedPeerId);
|
const setSelectedPeerId = useStore((s) => s.setSelectedPeerId);
|
||||||
const setShowTextModal = useStore((s) => s.setShowTextModal);
|
|
||||||
const setError = useStore((s) => s.setError);
|
const setError = useStore((s) => s.setError);
|
||||||
|
|
||||||
const { deviceName, avatar } = useProfileStore();
|
const { deviceName, avatar } = useProfileStore();
|
||||||
const [showProfileEdit, setShowProfileEdit] = useState(false);
|
const [showProfileEdit, setShowProfileEdit] = useState(false);
|
||||||
const [, setWakingDeviceId] = useState<string | null>(null);
|
|
||||||
|
const selectedPeer = peers.find((p) => p.peerId === selectedPeerId) ?? null;
|
||||||
|
|
||||||
const handlePeerSelect = useCallback(
|
const handlePeerSelect = useCallback(
|
||||||
(peerId: string) => {
|
(peerId: string) => {
|
||||||
const peer = peers.find((p) => p.peerId === peerId);
|
const peer = peers.find((p) => p.peerId === peerId);
|
||||||
if (peer && peer.online === false && peer.deviceId) {
|
if (peer && peer.online === false && peer.deviceId) {
|
||||||
setSelectedPeerId(peerId);
|
setSelectedPeerId(peerId);
|
||||||
setWakingDeviceId(peer.deviceId);
|
|
||||||
wakePeer(peer.deviceId);
|
wakePeer(peer.deviceId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -62,7 +44,7 @@ function HomeConnected() {
|
|||||||
[selectedPeerId, setSelectedPeerId, peers, wakePeer],
|
[selectedPeerId, setSelectedPeerId, peers, wakePeer],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFilesSelected = useCallback(
|
const handleSendFiles = useCallback(
|
||||||
(files: File[]) => {
|
(files: File[]) => {
|
||||||
if (!selectedPeerId) return;
|
if (!selectedPeerId) return;
|
||||||
sendFiles(selectedPeerId, files);
|
sendFiles(selectedPeerId, files);
|
||||||
@ -79,33 +61,30 @@ function HomeConnected() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleAcceptTransfer = useCallback(() => {
|
const handleAcceptTransfer = useCallback(() => {
|
||||||
if (incomingRequest) {
|
if (!incomingRequest) return;
|
||||||
if (incomingRequest.text && incomingRequest.files.length === 0) {
|
if (incomingRequest.text && incomingRequest.files.length === 0) {
|
||||||
navigator.clipboard?.writeText(incomingRequest.text).catch(() => {});
|
navigator.clipboard?.writeText(incomingRequest.text).catch(() => {});
|
||||||
useStore.getState().setIncomingRequest(null);
|
useStore.getState().setIncomingRequest(null);
|
||||||
} else {
|
} else {
|
||||||
acceptTransfer(incomingRequest.peerId);
|
acceptTransfer(incomingRequest.peerId);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [incomingRequest, acceptTransfer]);
|
}, [incomingRequest, acceptTransfer]);
|
||||||
|
|
||||||
const handleRejectTransfer = useCallback(() => {
|
const handleRejectTransfer = useCallback(() => {
|
||||||
if (incomingRequest) {
|
if (incomingRequest) rejectTransfer(incomingRequest.peerId);
|
||||||
rejectTransfer(incomingRequest.peerId);
|
|
||||||
}
|
|
||||||
}, [incomingRequest, rejectTransfer]);
|
}, [incomingRequest, rejectTransfer]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<div className="max-w-xl mx-auto px-5 sm:px-8 pt-10 pb-24">
|
<div className="max-w-xl mx-auto px-4 sm:px-8 pt-6 sm:pt-10 pb-16 sm:pb-24">
|
||||||
{/* Masthead */}
|
{/* Masthead */}
|
||||||
<header className="flex items-start justify-between pb-8 mb-10 rule">
|
<header className="flex items-center justify-between gap-3 pb-6 mb-8 sm:pb-8 sm:mb-10 rule">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h1 className="font-display text-4xl leading-none tracking-tight text-ink">
|
<h1 className="font-display text-3xl sm:text-4xl leading-none tracking-tight text-ink">
|
||||||
AnyDrop
|
AnyDrop
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-3 text-xs uppercase tracking-[0.2em] text-ink-muted">
|
<p className="mt-2 text-[10px] sm:text-xs uppercase tracking-[0.2em] text-ink-muted">
|
||||||
Universal transfer · Peer to peer
|
Peer to peer · Encrypted
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<DeviceChip
|
<DeviceChip
|
||||||
@ -115,9 +94,8 @@ function HomeConnected() {
|
|||||||
/>
|
/>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Error */}
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-8 px-4 py-3 border border-fail/40 bg-signal-quiet flex items-center justify-between rounded-sm">
|
<div className="mb-6 px-4 py-3 border border-fail/40 bg-signal-quiet flex items-center justify-between rounded-sm">
|
||||||
<span className="text-sm text-ink">{error}</span>
|
<span className="text-sm text-ink">{error}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setError(null)}
|
onClick={() => setError(null)}
|
||||||
@ -129,91 +107,61 @@ function HomeConnected() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Direct transfer */}
|
{/* Nearby — AirDrop flow */}
|
||||||
<section className="mb-14">
|
<section className="mb-10 sm:mb-12">
|
||||||
<SectionLabel>Direct</SectionLabel>
|
<div className="flex items-baseline justify-between mb-4">
|
||||||
<SectionTitle>Send to a device nearby</SectionTitle>
|
<SectionLabel>Nearby</SectionLabel>
|
||||||
<SectionLead>
|
<span className="font-mono text-[10px] text-ink-faint uppercase tracking-widest">
|
||||||
Devices on the same network appear below. Transfers stay peer-to-peer and never touch the server.
|
{peers.length} device{peers.length === 1 ? "" : "s"}
|
||||||
</SectionLead>
|
</span>
|
||||||
|
|
||||||
<div className="mt-8">
|
|
||||||
<PeerList onPeerSelect={handlePeerSelect} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
{selectedPeer ? (
|
||||||
<DevicePairingPanel
|
<Composer
|
||||||
onRequestCode={requestPairCode}
|
recipientLabel={selectedPeer.displayName}
|
||||||
onResolveCode={resolvePairCode}
|
onSendFiles={handleSendFiles}
|
||||||
|
onSendText={handleSendText}
|
||||||
|
onCancel={() => setSelectedPeerId(null)}
|
||||||
/>
|
/>
|
||||||
<PublicRoomPanel onCreateRoom={createPublicRoom} />
|
) : (
|
||||||
</div>
|
<PeerList onPeerSelect={handlePeerSelect} />
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Composer — appears when a peer is selected */}
|
{/* Cloud — WeTransfer flow */}
|
||||||
{selectedPeerId && (
|
<section className="mb-10 sm:mb-12">
|
||||||
<section className="mb-14">
|
<SectionLabel>Share a link</SectionLabel>
|
||||||
<SectionLabel>Compose</SectionLabel>
|
<p className="text-sm text-ink-muted mt-2 mb-4 leading-relaxed">
|
||||||
<SectionTitle>Drop files or send text</SectionTitle>
|
Sealed in your browser and held for you to share as a link, QR code, or email.
|
||||||
|
</p>
|
||||||
<div className="mt-6 space-y-3">
|
<CloudSharePanel />
|
||||||
<DropZone onFilesSelected={handleFilesSelected} />
|
</section>
|
||||||
<button
|
|
||||||
onClick={() => setShowTextModal(true)}
|
|
||||||
className="w-full text-left px-4 py-3 border border-paper-edge bg-paper
|
|
||||||
hover:bg-paper-deep transition-colors duration-fast ease-crisp
|
|
||||||
rounded-sm text-sm text-ink"
|
|
||||||
>
|
|
||||||
Send text instead →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Activity */}
|
{/* Activity */}
|
||||||
<section className="mb-14">
|
<section className="mb-10 sm:mb-12">
|
||||||
<SectionLabel>Activity</SectionLabel>
|
<SectionLabel>Activity</SectionLabel>
|
||||||
<TransferProgress />
|
<div className="mt-3">
|
||||||
</section>
|
<TransferProgress />
|
||||||
|
|
||||||
{/* Cloud relay — encrypted hand-off via AnyDrop */}
|
|
||||||
<section className="mb-14">
|
|
||||||
<SectionLabel>Via AnyDrop</SectionLabel>
|
|
||||||
<SectionTitle>Send to someone who isn't here</SectionTitle>
|
|
||||||
<SectionLead>
|
|
||||||
Sealed in your browser, held on AnyDrop for seven days. The key rides in the link — the server never sees it.
|
|
||||||
</SectionLead>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<CloudSharePanel />
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Footer */}
|
<footer className="pt-6 mt-10 sm:pt-8 sm:mt-14 rule flex flex-col sm:flex-row sm:items-center gap-3 sm:justify-between text-xs text-ink-muted">
|
||||||
<footer className="pt-8 mt-14 rule flex items-center justify-between text-xs text-ink-muted">
|
<span>Nothing transits the server</span>
|
||||||
<span>End-to-end encrypted · Nothing transits the server</span>
|
<div className="flex items-center gap-4">
|
||||||
<Link
|
<Link to="/pair" className="text-ink-muted hover:text-ink transition-colors duration-fast">
|
||||||
to="/settings"
|
Pair across networks →
|
||||||
className="text-ink hover:text-signal transition-colors duration-fast"
|
</Link>
|
||||||
>
|
<Link to="/settings" className="text-ink hover:text-signal transition-colors duration-fast">
|
||||||
Account →
|
Account →
|
||||||
</Link>
|
</Link>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Profile edit modal */}
|
|
||||||
{showProfileEdit && (
|
{showProfileEdit && (
|
||||||
<ProfileSetup isEditing onDone={() => setShowProfileEdit(false)} />
|
<ProfileSetup isEditing onDone={() => setShowProfileEdit(false)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Modals */}
|
|
||||||
{showTextModal && selectedPeerId && (
|
|
||||||
<TextShareModal
|
|
||||||
onSend={handleSendText}
|
|
||||||
onClose={() => setShowTextModal(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{incomingRequest && (
|
{incomingRequest && (
|
||||||
<ReceiveDialog
|
<ReceiveDialog
|
||||||
request={incomingRequest}
|
request={incomingRequest}
|
||||||
@ -237,7 +185,7 @@ function DeviceChip({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onEdit}
|
onClick={onEdit}
|
||||||
className="group flex items-center gap-2.5 px-3 py-2 border border-paper-edge
|
className="group shrink-0 flex items-center gap-2 px-2.5 py-1.5 border border-paper-edge
|
||||||
hover:border-ink transition-colors duration-fast ease-crisp
|
hover:border-ink transition-colors duration-fast ease-crisp
|
||||||
bg-paper rounded-sm"
|
bg-paper rounded-sm"
|
||||||
>
|
>
|
||||||
@ -255,9 +203,8 @@ function DeviceChip({
|
|||||||
◦
|
◦
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-sm text-ink truncate max-w-[120px]">{name}</span>
|
<span className="text-xs sm:text-sm text-ink truncate max-w-[80px] sm:max-w-[120px]">
|
||||||
<span className="text-xs text-ink-faint group-hover:text-ink transition-colors">
|
{name}
|
||||||
edit
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@ -270,19 +217,3 @@ function SectionLabel({ children }: { children: React.ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<h2 className="font-display text-2xl text-ink mt-2 tracking-tight">
|
|
||||||
{children}
|
|
||||||
</h2>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SectionLead({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<p className="text-sm text-ink-muted mt-2 leading-relaxed max-w-md">
|
|
||||||
{children}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -11,7 +11,7 @@ type Stage =
|
|||||||
| { kind: "loading" }
|
| { kind: "loading" }
|
||||||
| { kind: "missing-key" }
|
| { kind: "missing-key" }
|
||||||
| { kind: "error"; message: string }
|
| { kind: "error"; message: string }
|
||||||
| { kind: "preview"; preview: ReceivedTransferPreview }
|
| { kind: "preview"; preview: ReceivedTransferPreview; password: string; passwordError: string | null }
|
||||||
| { kind: "downloading"; loaded: number; total: number }
|
| { kind: "downloading"; loaded: number; total: number }
|
||||||
| { kind: "done"; fileName: string };
|
| { kind: "done"; fileName: string };
|
||||||
|
|
||||||
@ -61,7 +61,9 @@ export default function Receive() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
previewTransfer(id, k)
|
previewTransfer(id, k)
|
||||||
.then((preview) => setStage({ kind: "preview", preview }))
|
.then((preview) =>
|
||||||
|
setStage({ kind: "preview", preview, password: "", passwordError: null }),
|
||||||
|
)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
const msg = err instanceof Error ? err.message : "unknown";
|
const msg = err instanceof Error ? err.message : "unknown";
|
||||||
setStage({ kind: "error", message: msg });
|
setStage({ kind: "error", message: msg });
|
||||||
@ -70,35 +72,59 @@ export default function Receive() {
|
|||||||
|
|
||||||
const accept = async () => {
|
const accept = async () => {
|
||||||
if (!id || !key || stage.kind !== "preview") return;
|
if (!id || !key || stage.kind !== "preview") return;
|
||||||
|
const needPw = stage.preview.head.requiresPassword;
|
||||||
|
if (needPw && !stage.password.trim()) {
|
||||||
|
setStage({ ...stage, passwordError: "Password required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
setStage({ kind: "downloading", loaded: 0, total: stage.preview.head.sizeBytes });
|
setStage({ kind: "downloading", loaded: 0, total: stage.preview.head.sizeBytes });
|
||||||
try {
|
try {
|
||||||
const file = await receiveCloud(id, key, stage.preview.metadata, (loaded, total) => {
|
const file = await receiveCloud(id, key, stage.preview.metadata, {
|
||||||
setStage({ kind: "downloading", loaded, total });
|
password: needPw ? stage.password.trim() : undefined,
|
||||||
|
onProgress: (loaded, total) => setStage({ kind: "downloading", loaded, total }),
|
||||||
});
|
});
|
||||||
triggerDownload(file);
|
triggerDownload(file);
|
||||||
setStage({ kind: "done", fileName: stage.preview.metadata.name });
|
setStage({ kind: "done", fileName: stage.preview.metadata.name });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : "unknown";
|
const msg = err instanceof Error ? err.message : "unknown";
|
||||||
|
if (msg === "invalid_password" || msg === "password_required") {
|
||||||
|
// Recover back to the preview with an error, keeping the user in flow.
|
||||||
|
setStage({
|
||||||
|
kind: "preview",
|
||||||
|
preview: stage.preview,
|
||||||
|
password: stage.password,
|
||||||
|
passwordError: "Incorrect password",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
setStage({ kind: "error", message: msg });
|
setStage({ kind: "error", message: msg });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<div className="max-w-xl mx-auto px-5 sm:px-8 pt-10 pb-24">
|
<div className="max-w-xl mx-auto px-4 sm:px-8 pt-8 sm:pt-10 pb-16 sm:pb-24">
|
||||||
<header className="pb-8 mb-10 rule">
|
<header className="pb-6 mb-8 sm:pb-8 sm:mb-10 rule">
|
||||||
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
|
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
|
||||||
Via AnyDrop
|
Via AnyDrop
|
||||||
</div>
|
</div>
|
||||||
<h1 className="font-display text-4xl leading-none tracking-tight text-ink mt-2">
|
<h1 className="font-display text-3xl sm:text-4xl leading-none tracking-tight text-ink mt-2">
|
||||||
You've been sent something
|
You've been sent something
|
||||||
</h1>
|
</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<ReceiveBody stage={stage} onAccept={accept} />
|
<ReceiveBody
|
||||||
|
stage={stage}
|
||||||
|
onAccept={accept}
|
||||||
|
onPasswordChange={(pw) => {
|
||||||
|
if (stage.kind === "preview") {
|
||||||
|
setStage({ ...stage, password: pw, passwordError: null });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<footer className="pt-8 mt-14 rule flex items-center justify-between text-xs text-ink-muted">
|
<footer className="pt-6 mt-10 sm:pt-8 sm:mt-14 rule flex items-center justify-between text-xs text-ink-muted">
|
||||||
<span>End-to-end encrypted · The server never sees the key</span>
|
<span>End-to-end encrypted</span>
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
className="text-ink hover:text-signal transition-colors duration-fast"
|
className="text-ink hover:text-signal transition-colors duration-fast"
|
||||||
@ -111,16 +137,24 @@ export default function Receive() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReceiveBody({ stage, onAccept }: { stage: Stage; onAccept: () => void }) {
|
function ReceiveBody({
|
||||||
|
stage,
|
||||||
|
onAccept,
|
||||||
|
onPasswordChange,
|
||||||
|
}: {
|
||||||
|
stage: Stage;
|
||||||
|
onAccept: () => void;
|
||||||
|
onPasswordChange: (pw: string) => void;
|
||||||
|
}) {
|
||||||
if (stage.kind === "loading") {
|
if (stage.kind === "loading") {
|
||||||
return <p className="text-sm text-ink-muted">Decrypting preview…</p>;
|
return <p className="text-sm text-ink-muted">Decrypting preview…</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stage.kind === "missing-key") {
|
if (stage.kind === "missing-key") {
|
||||||
return (
|
return (
|
||||||
<div className="paper-panel px-6 py-6">
|
<div className="paper-panel p-5 sm:p-6">
|
||||||
<div className="text-xs uppercase tracking-[0.22em] text-fail">Missing key</div>
|
<div className="text-xs uppercase tracking-[0.22em] text-fail">Missing key</div>
|
||||||
<h2 className="font-display text-2xl text-ink mt-2 mb-3">
|
<h2 className="font-display text-xl sm:text-2xl text-ink mt-2 mb-3">
|
||||||
This link is incomplete
|
This link is incomplete
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-ink-muted leading-relaxed">
|
<p className="text-sm text-ink-muted leading-relaxed">
|
||||||
@ -141,9 +175,9 @@ function ReceiveBody({ stage, onAccept }: { stage: Stage; onAccept: () => void }
|
|||||||
? "This transfer has already been downloaded."
|
? "This transfer has already been downloaded."
|
||||||
: "Something went wrong.";
|
: "Something went wrong.";
|
||||||
return (
|
return (
|
||||||
<div className="paper-panel px-6 py-6">
|
<div className="paper-panel p-5 sm:p-6">
|
||||||
<div className="text-xs uppercase tracking-[0.22em] text-fail">Unavailable</div>
|
<div className="text-xs uppercase tracking-[0.22em] text-fail">Unavailable</div>
|
||||||
<h2 className="font-display text-2xl text-ink mt-2">{pretty}</h2>
|
<h2 className="font-display text-xl sm:text-2xl text-ink mt-2">{pretty}</h2>
|
||||||
<p className="font-mono text-xs text-ink-faint mt-3 uppercase tracking-widest">
|
<p className="font-mono text-xs text-ink-faint mt-3 uppercase tracking-widest">
|
||||||
{stage.message}
|
{stage.message}
|
||||||
</p>
|
</p>
|
||||||
@ -153,31 +187,67 @@ function ReceiveBody({ stage, onAccept }: { stage: Stage; onAccept: () => void }
|
|||||||
|
|
||||||
if (stage.kind === "preview") {
|
if (stage.kind === "preview") {
|
||||||
const { metadata, head } = stage.preview;
|
const { metadata, head } = stage.preview;
|
||||||
const remainingDownloads = head.maxDownloads - head.downloadCount;
|
const remaining = head.maxDownloads - head.downloadCount;
|
||||||
return (
|
return (
|
||||||
<div className="paper-panel px-6 py-6">
|
<div className="paper-panel p-5 sm:p-6">
|
||||||
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">Ready to download</div>
|
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
|
||||||
<h2 className="font-display text-2xl text-ink mt-2 mb-5 tracking-tight">
|
Ready to download
|
||||||
|
</div>
|
||||||
|
<h2 className="font-display text-xl sm:text-2xl text-ink mt-2 mb-5 tracking-tight break-words">
|
||||||
{metadata.name}
|
{metadata.name}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<dl className="grid grid-cols-3 gap-4 border-t border-b border-paper-edge py-4">
|
<dl className="grid grid-cols-3 gap-3 border-t border-b border-paper-edge py-4">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-xs uppercase tracking-[0.15em] text-ink-muted">Size</dt>
|
<dt className="text-[10px] sm:text-xs uppercase tracking-[0.15em] text-ink-muted">
|
||||||
|
Size
|
||||||
|
</dt>
|
||||||
<dd className="font-mono text-sm text-ink mt-1">{formatSize(metadata.size)}</dd>
|
<dd className="font-mono text-sm text-ink mt-1">{formatSize(metadata.size)}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-xs uppercase tracking-[0.15em] text-ink-muted">Expires</dt>
|
<dt className="text-[10px] sm:text-xs uppercase tracking-[0.15em] text-ink-muted">
|
||||||
|
Expires
|
||||||
|
</dt>
|
||||||
<dd className="text-sm text-ink mt-1">{formatExpiry(head.expiresAt)}</dd>
|
<dd className="text-sm text-ink mt-1">{formatExpiry(head.expiresAt)}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-xs uppercase tracking-[0.15em] text-ink-muted">Downloads</dt>
|
<dt className="text-[10px] sm:text-xs uppercase tracking-[0.15em] text-ink-muted">
|
||||||
|
Downloads
|
||||||
|
</dt>
|
||||||
<dd className="font-mono text-sm text-ink mt-1">
|
<dd className="font-mono text-sm text-ink mt-1">
|
||||||
{head.downloadCount}/{head.maxDownloads}
|
{head.downloadCount}/{head.maxDownloads}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
{head.requiresPassword && (
|
||||||
|
<div className="mt-5">
|
||||||
|
<label className="block">
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-ink-muted mb-1.5">
|
||||||
|
Password
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={stage.password}
|
||||||
|
onChange={(e) => onPasswordChange(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") onAccept();
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
className="w-full px-3 py-2.5 bg-paper border border-paper-edge rounded-sm
|
||||||
|
text-ink text-sm placeholder:text-ink-faint
|
||||||
|
focus:outline-none focus:border-ink transition-colors
|
||||||
|
duration-fast ease-crisp"
|
||||||
|
/>
|
||||||
|
{stage.passwordError && (
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-[0.15em] text-fail mt-1.5">
|
||||||
|
{stage.passwordError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={onAccept}
|
onClick={onAccept}
|
||||||
className="mt-6 w-full py-3 bg-ink text-paper text-sm font-medium rounded-sm
|
className="mt-6 w-full py-3 bg-ink text-paper text-sm font-medium rounded-sm
|
||||||
@ -186,10 +256,10 @@ function ReceiveBody({ stage, onAccept }: { stage: Stage; onAccept: () => void }
|
|||||||
Download & decrypt →
|
Download & decrypt →
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p className="mt-4 text-xs text-ink-muted leading-relaxed text-center">
|
<p className="mt-3 text-xs text-ink-muted leading-relaxed text-center">
|
||||||
{remainingDownloads === 1
|
{remaining === 1
|
||||||
? "This is the last available download."
|
? "This is the last available download."
|
||||||
: `${remainingDownloads} downloads remaining.`}
|
: `${remaining} downloads remaining.`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -198,9 +268,9 @@ function ReceiveBody({ stage, onAccept }: { stage: Stage; onAccept: () => void }
|
|||||||
if (stage.kind === "downloading") {
|
if (stage.kind === "downloading") {
|
||||||
const pct = stage.total > 0 ? Math.round((stage.loaded / stage.total) * 100) : 0;
|
const pct = stage.total > 0 ? Math.round((stage.loaded / stage.total) * 100) : 0;
|
||||||
return (
|
return (
|
||||||
<div className="paper-panel px-6 py-6">
|
<div className="paper-panel p-5 sm:p-6">
|
||||||
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">Downloading</div>
|
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">Downloading</div>
|
||||||
<h2 className="font-display text-2xl text-ink mt-2 mb-5">
|
<h2 className="font-display text-xl sm:text-2xl text-ink mt-2 mb-5">
|
||||||
Pulling the ciphertext…
|
Pulling the ciphertext…
|
||||||
</h2>
|
</h2>
|
||||||
<div className="h-px bg-paper-edge overflow-hidden">
|
<div className="h-px bg-paper-edge overflow-hidden">
|
||||||
@ -217,14 +287,14 @@ function ReceiveBody({ stage, onAccept }: { stage: Stage; onAccept: () => void }
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="paper-panel px-6 py-6">
|
<div className="paper-panel p-5 sm:p-6">
|
||||||
<div className="text-xs uppercase tracking-[0.22em] text-ok">Done</div>
|
<div className="text-xs uppercase tracking-[0.22em] text-ok">Done</div>
|
||||||
<h2 className="font-display text-2xl text-ink mt-2 mb-3">
|
<h2 className="font-display text-xl sm:text-2xl text-ink mt-2 mb-3">
|
||||||
Saved locally
|
Saved locally
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-ink-muted leading-relaxed">
|
<p className="text-sm text-ink-muted leading-relaxed">
|
||||||
<span className="text-ink">{stage.fileName}</span> has been decrypted in your browser and
|
<span className="text-ink">{stage.fileName}</span> has been decrypted in your browser and
|
||||||
downloaded. The ciphertext on AnyDrop is being purged.
|
downloaded.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user