From dbd500b0b57a42c92ecc67df474ffd83d2926682 Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Mon, 20 Apr 2026 11:54:05 +0200 Subject: [PATCH] feat: simpler Home + password-protected cloud links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../db/migrations/0002_typical_northstar.sql | 1 + .../src/db/migrations/meta/0002_snapshot.json | 559 ++++++++++++++++++ server/src/db/migrations/meta/_journal.json | 7 + server/src/db/schema.ts | 1 + server/src/http/transfers.ts | 52 +- web/src/components/CloudSharePanel.tsx | 461 ++++++++------- web/src/components/Composer.tsx | 105 ++++ web/src/components/PeerList.tsx | 4 +- web/src/lib/api.ts | 12 +- web/src/lib/sendCloud.ts | 11 +- web/src/pages/Home.tsx | 189 ++---- web/src/pages/Receive.tsx | 132 ++++- 12 files changed, 1154 insertions(+), 380 deletions(-) create mode 100644 server/src/db/migrations/0002_typical_northstar.sql create mode 100644 server/src/db/migrations/meta/0002_snapshot.json create mode 100644 web/src/components/Composer.tsx diff --git a/server/src/db/migrations/0002_typical_northstar.sql b/server/src/db/migrations/0002_typical_northstar.sql new file mode 100644 index 0000000..ab730fc --- /dev/null +++ b/server/src/db/migrations/0002_typical_northstar.sql @@ -0,0 +1 @@ +ALTER TABLE "transfers" ADD COLUMN "password_hash" text; \ No newline at end of file diff --git a/server/src/db/migrations/meta/0002_snapshot.json b/server/src/db/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..fcc1d1b --- /dev/null +++ b/server/src/db/migrations/meta/0002_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/server/src/db/migrations/meta/_journal.json b/server/src/db/migrations/meta/_journal.json index c4fcd35..a688a6e 100644 --- a/server/src/db/migrations/meta/_journal.json +++ b/server/src/db/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1776675064100, "tag": "0001_loving_yellowjacket", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1776677642835, + "tag": "0002_typical_northstar", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 0503509..7547b64 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -104,6 +104,7 @@ export const transfers = pgTable( expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), firstDownloadAt: timestamp("first_download_at", { withTimezone: true }), deletedAt: timestamp("deleted_at", { withTimezone: true }), + passwordHash: text("password_hash"), }, (t) => ({ senderIdx: index("transfers_sender_idx").on(t.senderUserId), diff --git a/server/src/http/transfers.ts b/server/src/http/transfers.ts index a1b12b0..b4c133e 100644 --- a/server/src/http/transfers.ts +++ b/server/src/http/transfers.ts @@ -1,4 +1,4 @@ -import { createHash, randomUUID } from "node:crypto"; +import { createHash, randomBytes, randomUUID, scryptSync, timingSafeEqual } from "node:crypto"; import { Hono } from "hono"; import { and, desc, eq, gt, isNull, or, sql } from "drizzle-orm"; 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); } +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 * Create a transfer. Body: @@ -87,6 +105,14 @@ transferRoutes.post("/transfers", async (c) => { 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 key = storageKey(id); @@ -103,6 +129,7 @@ transferRoutes.post("/transfers", async (c) => { sizeBytes, maxDownloads, expiresAt, + passwordHash, }) .returning(); @@ -140,6 +167,7 @@ transferRoutes.get("/transfers/:id", async (c) => { maxDownloads: row.maxDownloads, downloadCount: row.downloadCount, expiresAt: row.expiresAt, + requiresPassword: row.passwordHash !== null, }); }); @@ -152,6 +180,28 @@ transferRoutes.post("/transfers/:id/consume", async (c) => { const id = c.req.param("id"); 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 .update(transfers) .set({ diff --git a/web/src/components/CloudSharePanel.tsx b/web/src/components/CloudSharePanel.tsx index 583b8e2..990fdec 100644 --- a/web/src/components/CloudSharePanel.tsx +++ b/web/src/components/CloudSharePanel.tsx @@ -1,4 +1,4 @@ -import { useState, useRef } from "react"; +import { useRef, useState } from "react"; import { QRCodeSVG } from "qrcode.react"; import { sendCloud } from "../lib/sendCloud"; import { useProfileStore } from "../stores/useProfileStore"; @@ -6,9 +6,15 @@ import { useProfileStore } from "../stores/useProfileStore"; type Stage = | { kind: "idle" } | { 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 }; +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 { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; @@ -18,256 +24,287 @@ function formatSize(bytes: number): string { export default function CloudSharePanel() { const deviceId = useProfileStore((s) => s.deviceId); - const [showModal, setShowModal] = useState(false); - - return ( - <> - - - {showModal && ( - setShowModal(false)} - /> - )} - - ); -} - -function CloudShareModal({ - deviceId, - onClose, -}: { - deviceId: string; - onClose: () => void; -}) { const [stage, setStage] = useState({ kind: "idle" }); - const [pickedFile, setPickedFile] = useState(null); + const [file, setFile] = useState(null); const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [expiryDays, setExpiryDays] = useState(7); const [copied, setCopied] = useState(false); + const [showAdvanced, setShowAdvanced] = useState(false); const fileRef = useRef(null); + const reset = () => { + setStage({ kind: "idle" }); + setFile(null); + setEmail(""); + setPassword(""); + setExpiryDays(7); + setShowAdvanced(false); + }; + const handleSend = async () => { - if (!pickedFile) return; - setStage({ kind: "uploading", loaded: 0, total: pickedFile.size }); + if (!file) return; + setStage({ kind: "uploading", loaded: 0, total: file.size }); try { - const result = await sendCloud(pickedFile, { + const result = await sendCloud(file, { deviceId, recipientEmail: email.trim() || undefined, + password: password.trim() || undefined, + expiresInDays: expiryDays, onProgress: (loaded, total) => setStage({ kind: "uploading", loaded, total }), }); setStage({ kind: "done", shareUrl: result.shareUrl, - fileName: pickedFile.name, + fileName: file.name, expiresAt: result.expiresAt, + password: password.trim() || null, }); } catch (err) { - const msg = err instanceof Error ? err.message : "unknown"; - setStage({ kind: "error", message: msg }); + setStage({ kind: "error", message: err instanceof Error ? err.message : "unknown" }); } }; - const handleCopy = () => { - if (stage.kind !== "done") return; - navigator.clipboard.writeText(stage.shareUrl); + const copy = (value: string) => { + navigator.clipboard.writeText(value); setCopied(true); setTimeout(() => setCopied(false), 1500); }; return ( -
-
e.stopPropagation()} - > -
- Via AnyDrop -
-

- Send to anyone -

+
+ {stage.kind === "idle" && ( + <> + { + const f = e.target.files?.[0]; + if (f) setFile(f); + }} + /> - {stage.kind === "idle" && ( - <> - { - const f = e.target.files?.[0]; - if (f) setPickedFile(f); - }} - /> - - {pickedFile ? ( -
-
- File + {file ? ( +
+
+
+
+ File +
+
{file.name}
-
- {pickedFile.name} - - {formatSize(pickedFile.size)} - -
- + + {formatSize(file.size)} +
- ) : ( - )} +
+ ) : ( + + )} - - setEmail(e.target.value)} - placeholder="friend@example.com" - 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 mb-5" + + + {showAdvanced && ( +
+ + setEmail(e.target.value)} + placeholder="friend@example.com" + className={inputCls} + /> + + + + setPassword(e.target.value)} + placeholder="Leave blank for none" + minLength={4} + className={inputCls} + /> + + + +
+ {EXPIRY_CHOICES.map((c) => ( + + ))} +
+
+
+ )} + + + +

+ Sealed in your browser. Only the link holder + {password && " + password"} can open it. +

+ + )} + + {stage.kind === "uploading" && ( + <> +
+ Uploading ciphertext +
+

+ Sealing and uploading… +

+
+
0 ? Math.round((stage.loaded / stage.total) * 100) : 0}%`, + }} /> +
+

+ {formatSize(stage.loaded)} / {formatSize(stage.total)} +

+ + )} -

- Your file is encrypted locally, then stored on AnyDrop for 7 days. The key never leaves - your browser — only the link's #fragment holds it. -

- -
- - -
- - )} - - {stage.kind === "uploading" && ( - <> -
- Uploading ciphertext -
-

- Sealing and uploading… -

-
-
0 ? Math.round((stage.loaded / stage.total) * 100) : 0}%`, - }} + {stage.kind === "done" && ( + <> +
+
+
-

- {formatSize(stage.loaded)} / {formatSize(stage.total)} -

- - )} - - {stage.kind === "done" && ( - <> -
-
- +
+
+ Ready to share +
+
+ {stage.fileName} +
+
+ Expires {new Date(stage.expiresAt).toLocaleDateString()} · one download
-
Ready to share
-

- {stage.fileName} -

- -

- {stage.shareUrl} -

-

- Expires {new Date(stage.expiresAt).toLocaleDateString()}. One download by default — - anyone who has the link can fetch it once. -

- - - )} +
- {stage.kind === "error" && ( - <> -
Failed
-

- Could not complete the transfer -

-

{stage.message}

- - - )} -
+ + +

+ {stage.shareUrl} +

+ + {stage.password && ( +
+
+ Password (share separately) +
+
+ {stage.password} + +
+
+ )} + + + + )} + + {stage.kind === "error" && ( + <> +
Failed
+

+ Could not complete the transfer +

+

{stage.message}

+ + + )}
); } + +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 ( + + ); +} diff --git a/web/src/components/Composer.tsx b/web/src/components/Composer.tsx new file mode 100644 index 0000000..f61d0e1 --- /dev/null +++ b/web/src/components/Composer.tsx @@ -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([]); + 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 ( +
+
+
+
+ Send to +
+
+ {recipientLabel} +
+
+ +
+ + {files.length === 0 ? ( + setFiles(f)} /> + ) : ( +
+
+ + {files.length} file{files.length > 1 ? "s" : ""} + + +
+
    + {files.map((f, i) => ( +
  • + {f.name} + + {formatSize(f.size)} + +
  • + ))} +
+
+ )} + +