feat: simpler Home + password-protected cloud links
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:
ordinarthur 2026-04-20 11:54:05 +02:00
parent 6ba5401c4d
commit dbd500b0b5
12 changed files with 1154 additions and 380 deletions

View File

@ -0,0 +1 @@
ALTER TABLE "transfers" ADD COLUMN "password_hash" text;

View 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": {}
}
}

View File

@ -15,6 +15,13 @@
"when": 1776675064100,
"tag": "0001_loving_yellowjacket",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1776677642835,
"tag": "0002_typical_northstar",
"breakpoints": true
}
]
}

View File

@ -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),

View File

@ -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({

View File

@ -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 (
<>
<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 [pickedFile, setPickedFile] = useState<File | null>(null);
const [file, setFile] = useState<File | null>(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<HTMLInputElement>(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 (
<div
className="fixed inset-0 z-50 bg-ink/40 flex items-center justify-center p-4"
onClick={onClose}
>
<div
className="paper-panel shadow-lift rounded-sm p-6 max-w-md w-full"
onClick={(e) => e.stopPropagation()}
>
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
Via AnyDrop
</div>
<h3 className="font-display text-2xl text-ink mt-1 mb-5 tracking-tight">
Send to anyone
</h3>
<div className="paper-panel p-4 sm:p-5">
{stage.kind === "idle" && (
<>
<input
ref={fileRef}
type="file"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) setFile(f);
}}
/>
{stage.kind === "idle" && (
<>
<input
ref={fileRef}
type="file"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
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
{file ? (
<div className="paper-panel-deep rounded-sm px-4 py-3 mb-4">
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1 mr-3">
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-ink-muted">
File
</div>
<div className="text-sm text-ink mt-0.5 truncate">{file.name}</div>
</div>
<div className="flex items-center justify-between mt-1">
<span className="text-sm text-ink truncate mr-3">{pickedFile.name}</span>
<span className="font-mono text-xs text-ink-muted whitespace-nowrap">
{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>
<span className="font-mono text-xs text-ink-muted whitespace-nowrap">
{formatSize(file.size)}
</span>
</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-8 mb-4
flex flex-col items-center gap-2
transition-colors duration-fast ease-crisp"
className="mt-3 text-xs text-ink-muted hover:text-ink transition-colors"
>
<span className="font-mono text-[10px] uppercase tracking-[0.2em] text-ink-muted">
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>
Pick another
</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">
Recipient email <span className="text-ink-faint normal-case tracking-normal">(optional)</span>
</label>
<input
type="email"
value={email}
onChange={(e) => 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"
<button
onClick={() => setShowAdvanced((v) => !v)}
className="mb-3 font-mono text-[10px] uppercase tracking-[0.22em] text-ink-muted
hover:text-ink transition-colors"
>
{showAdvanced ? " Hide options" : "+ Email, password, expiry"}
</button>
{showAdvanced && (
<div className="space-y-3 mb-4 pb-4 border-b border-paper-edge">
<Field label="Recipient email">
<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">
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.
</p>
<div className="flex gap-3">
<button
onClick={onClose}
className="flex-1 py-2.5 border border-paper-edge hover:border-ink
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}%`,
}}
{stage.kind === "done" && (
<>
<div className="flex flex-col sm:flex-row sm:items-start gap-4 mb-4">
<div className="self-center shrink-0 bg-paper p-2 border border-paper-edge rounded-sm">
<QRCodeSVG
value={stage.shareUrl}
size={128}
bgColor="#F5F0E6"
fgColor="#1A1714"
/>
</div>
<p className="mt-3 font-mono text-xs text-ink-muted">
{formatSize(stage.loaded)} / {formatSize(stage.total)}
</p>
</>
)}
{stage.kind === "done" && (
<>
<div className="flex justify-center mb-5">
<div className="bg-paper p-3 border border-paper-edge rounded-sm">
<QRCodeSVG
value={stage.shareUrl}
size={180}
bgColor="#F5F0E6"
fgColor="#1A1714"
/>
<div className="min-w-0 flex-1">
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-ok">
Ready to share
</div>
<div className="font-display text-lg text-ink mt-1 tracking-tight truncate">
{stage.fileName}
</div>
<div className="text-xs text-ink-muted mt-1.5">
Expires {new Date(stage.expiresAt).toLocaleDateString()} · one download
</div>
</div>
<div className="text-xs uppercase tracking-[0.22em] text-ok">Ready to share</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>
</>
)}
</div>
{stage.kind === "error" && (
<>
<div className="text-xs uppercase tracking-[0.22em] text-fail">Failed</div>
<h3 className="font-display text-xl text-ink mt-1 mb-3">
Could not complete the transfer
</h3>
<p className="font-mono text-xs text-ink-muted">{stage.message}</p>
<button
onClick={() => setStage({ kind: "idle" })}
className="mt-5 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>
<button
onClick={() => copy(stage.shareUrl)}
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-2 font-mono text-[11px] text-ink-faint break-all">
{stage.shareUrl}
</p>
{stage.password && (
<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 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>
);
}
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>
);
}

View 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>
);
}

View File

@ -11,13 +11,13 @@ export default function PeerList({ onPeerSelect }: PeerListProps) {
if (peers.length === 0) {
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">
<span className="w-2 h-2 rounded-full bg-signal animate-pulse" />
</div>
<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">
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>
</div>
);

View File

@ -81,6 +81,7 @@ export interface TransferHead {
maxDownloads: number;
downloadCount: number;
expiresAt: string;
requiresPassword: boolean;
}
export interface InboxTransfer {
@ -104,6 +105,7 @@ export async function createTransfer(input: {
maxDownloads?: number;
expiresInDays?: number;
deviceId?: string;
password?: string;
}): Promise<CreateTransferResponse> {
const res = await call("/api/transfers", {
method: "POST",
@ -125,8 +127,14 @@ export async function getTransferHead(id: string): Promise<TransferHead> {
return (await res.json()) as TransferHead;
}
export async function consumeTransfer(id: string): Promise<{ downloadUrl: string }> {
const res = await call(`/api/transfers/${encodeURIComponent(id)}/consume`, { method: "POST" });
export async function consumeTransfer(
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) {
const body = await res.json().catch(() => ({}));
throw new Error((body as { error?: string }).error ?? `consume failed: ${res.status}`);

View File

@ -18,6 +18,7 @@ export interface SendCloudOptions {
expiresInDays?: number;
maxDownloads?: number;
deviceId?: string;
password?: string;
onProgress?: (loaded: number, total: number) => void;
}
@ -48,6 +49,7 @@ export async function sendCloud(
maxDownloads: options.maxDownloads,
expiresInDays: options.expiresInDays,
deviceId: options.deviceId,
password: options.password,
});
await uploadWithProgress(created.uploadUrl, encryptedBody, options.onProgress);
@ -124,11 +126,14 @@ export async function receiveCloud(
transferId: string,
key: Uint8Array,
metadata: TransferMetadata,
onProgress?: (loaded: number, total: number) => void,
options: {
password?: string;
onProgress?: (loaded: number, total: number) => void;
} = {},
): 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);
}

View File

@ -4,56 +4,38 @@ import { useSignaling } from "../hooks/useSignaling";
import { useStore } from "../stores/useStore";
import { useProfileStore } from "../stores/useProfileStore";
import PeerList from "../components/PeerList";
import DropZone from "../components/DropZone";
import TransferProgress from "../components/TransferProgress";
import TextShareModal from "../components/TextShareModal";
import ReceiveDialog from "../components/ReceiveDialog";
import PublicRoomPanel from "../components/PublicRoomPanel";
import DevicePairingPanel from "../components/DevicePairingPanel";
import ProfileSetup from "../components/ProfileSetup";
import CloudSharePanel from "../components/CloudSharePanel";
import Composer from "../components/Composer";
export default function Home() {
const isSetUp = useProfileStore((s) => s.isSetUp);
if (!isSetUp) {
return <ProfileSetup onDone={() => {}} />;
}
if (!isSetUp) return <ProfileSetup onDone={() => {}} />;
return <HomeConnected />;
}
function HomeConnected() {
const {
sendFiles,
sendText,
acceptTransfer,
rejectTransfer,
createPublicRoom,
wakePeer,
requestPairCode,
resolvePairCode,
} = useSignaling();
const { sendFiles, sendText, acceptTransfer, rejectTransfer, wakePeer } = useSignaling();
const peers = useStore((s) => s.peers);
const selectedPeerId = useStore((s) => s.selectedPeerId);
const showTextModal = useStore((s) => s.showTextModal);
const incomingRequest = useStore((s) => s.incomingRequest);
const error = useStore((s) => s.error);
const setSelectedPeerId = useStore((s) => s.setSelectedPeerId);
const setShowTextModal = useStore((s) => s.setShowTextModal);
const setError = useStore((s) => s.setError);
const { deviceName, avatar } = useProfileStore();
const [showProfileEdit, setShowProfileEdit] = useState(false);
const [, setWakingDeviceId] = useState<string | null>(null);
const selectedPeer = peers.find((p) => p.peerId === selectedPeerId) ?? null;
const handlePeerSelect = useCallback(
(peerId: string) => {
const peer = peers.find((p) => p.peerId === peerId);
if (peer && peer.online === false && peer.deviceId) {
setSelectedPeerId(peerId);
setWakingDeviceId(peer.deviceId);
wakePeer(peer.deviceId);
return;
}
@ -62,7 +44,7 @@ function HomeConnected() {
[selectedPeerId, setSelectedPeerId, peers, wakePeer],
);
const handleFilesSelected = useCallback(
const handleSendFiles = useCallback(
(files: File[]) => {
if (!selectedPeerId) return;
sendFiles(selectedPeerId, files);
@ -79,33 +61,30 @@ function HomeConnected() {
);
const handleAcceptTransfer = useCallback(() => {
if (incomingRequest) {
if (incomingRequest.text && incomingRequest.files.length === 0) {
navigator.clipboard?.writeText(incomingRequest.text).catch(() => {});
useStore.getState().setIncomingRequest(null);
} else {
acceptTransfer(incomingRequest.peerId);
}
if (!incomingRequest) return;
if (incomingRequest.text && incomingRequest.files.length === 0) {
navigator.clipboard?.writeText(incomingRequest.text).catch(() => {});
useStore.getState().setIncomingRequest(null);
} else {
acceptTransfer(incomingRequest.peerId);
}
}, [incomingRequest, acceptTransfer]);
const handleRejectTransfer = useCallback(() => {
if (incomingRequest) {
rejectTransfer(incomingRequest.peerId);
}
if (incomingRequest) rejectTransfer(incomingRequest.peerId);
}, [incomingRequest, rejectTransfer]);
return (
<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 */}
<header className="flex items-start justify-between pb-8 mb-10 rule">
<div>
<h1 className="font-display text-4xl leading-none tracking-tight text-ink">
<header className="flex items-center justify-between gap-3 pb-6 mb-8 sm:pb-8 sm:mb-10 rule">
<div className="min-w-0">
<h1 className="font-display text-3xl sm:text-4xl leading-none tracking-tight text-ink">
AnyDrop
</h1>
<p className="mt-3 text-xs uppercase tracking-[0.2em] text-ink-muted">
Universal transfer · Peer to peer
<p className="mt-2 text-[10px] sm:text-xs uppercase tracking-[0.2em] text-ink-muted">
Peer to peer · Encrypted
</p>
</div>
<DeviceChip
@ -115,9 +94,8 @@ function HomeConnected() {
/>
</header>
{/* 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>
<button
onClick={() => setError(null)}
@ -129,91 +107,61 @@ function HomeConnected() {
</div>
)}
{/* Direct transfer */}
<section className="mb-14">
<SectionLabel>Direct</SectionLabel>
<SectionTitle>Send to a device nearby</SectionTitle>
<SectionLead>
Devices on the same network appear below. Transfers stay peer-to-peer and never touch the server.
</SectionLead>
<div className="mt-8">
<PeerList onPeerSelect={handlePeerSelect} />
{/* Nearby — AirDrop flow */}
<section className="mb-10 sm:mb-12">
<div className="flex items-baseline justify-between mb-4">
<SectionLabel>Nearby</SectionLabel>
<span className="font-mono text-[10px] text-ink-faint uppercase tracking-widest">
{peers.length} device{peers.length === 1 ? "" : "s"}
</span>
</div>
<div className="mt-6 grid grid-cols-1 sm:grid-cols-2 gap-3">
<DevicePairingPanel
onRequestCode={requestPairCode}
onResolveCode={resolvePairCode}
{selectedPeer ? (
<Composer
recipientLabel={selectedPeer.displayName}
onSendFiles={handleSendFiles}
onSendText={handleSendText}
onCancel={() => setSelectedPeerId(null)}
/>
<PublicRoomPanel onCreateRoom={createPublicRoom} />
</div>
) : (
<PeerList onPeerSelect={handlePeerSelect} />
)}
</section>
{/* Composer — appears when a peer is selected */}
{selectedPeerId && (
<section className="mb-14">
<SectionLabel>Compose</SectionLabel>
<SectionTitle>Drop files or send text</SectionTitle>
<div className="mt-6 space-y-3">
<DropZone onFilesSelected={handleFilesSelected} />
<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>
)}
{/* Cloud — WeTransfer flow */}
<section className="mb-10 sm:mb-12">
<SectionLabel>Share a link</SectionLabel>
<p className="text-sm text-ink-muted mt-2 mb-4 leading-relaxed">
Sealed in your browser and held for you to share as a link, QR code, or email.
</p>
<CloudSharePanel />
</section>
{/* Activity */}
<section className="mb-14">
<section className="mb-10 sm:mb-12">
<SectionLabel>Activity</SectionLabel>
<TransferProgress />
</section>
{/* 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 className="mt-3">
<TransferProgress />
</div>
</section>
{/* Footer */}
<footer className="pt-8 mt-14 rule flex items-center justify-between text-xs text-ink-muted">
<span>End-to-end encrypted · Nothing transits the server</span>
<Link
to="/settings"
className="text-ink hover:text-signal transition-colors duration-fast"
>
Account
</Link>
<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">
<span>Nothing transits the server</span>
<div className="flex items-center gap-4">
<Link to="/pair" className="text-ink-muted hover:text-ink transition-colors duration-fast">
Pair across networks
</Link>
<Link to="/settings" className="text-ink hover:text-signal transition-colors duration-fast">
Account
</Link>
</div>
</footer>
</div>
{/* Profile edit modal */}
{showProfileEdit && (
<ProfileSetup isEditing onDone={() => setShowProfileEdit(false)} />
)}
{/* Modals */}
{showTextModal && selectedPeerId && (
<TextShareModal
onSend={handleSendText}
onClose={() => setShowTextModal(false)}
/>
)}
{incomingRequest && (
<ReceiveDialog
request={incomingRequest}
@ -237,7 +185,7 @@ function DeviceChip({
return (
<button
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
bg-paper rounded-sm"
>
@ -255,9 +203,8 @@ function DeviceChip({
</span>
)}
<span className="text-sm text-ink truncate max-w-[120px]">{name}</span>
<span className="text-xs text-ink-faint group-hover:text-ink transition-colors">
edit
<span className="text-xs sm:text-sm text-ink truncate max-w-[80px] sm:max-w-[120px]">
{name}
</span>
</button>
);
@ -270,19 +217,3 @@ function SectionLabel({ children }: { children: React.ReactNode }) {
</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>
);
}

View File

@ -11,7 +11,7 @@ type Stage =
| { kind: "loading" }
| { kind: "missing-key" }
| { kind: "error"; message: string }
| { kind: "preview"; preview: ReceivedTransferPreview }
| { kind: "preview"; preview: ReceivedTransferPreview; password: string; passwordError: string | null }
| { kind: "downloading"; loaded: number; total: number }
| { kind: "done"; fileName: string };
@ -61,7 +61,9 @@ export default function Receive() {
}
previewTransfer(id, k)
.then((preview) => setStage({ kind: "preview", preview }))
.then((preview) =>
setStage({ kind: "preview", preview, password: "", passwordError: null }),
)
.catch((err) => {
const msg = err instanceof Error ? err.message : "unknown";
setStage({ kind: "error", message: msg });
@ -70,35 +72,59 @@ export default function Receive() {
const accept = async () => {
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 });
try {
const file = await receiveCloud(id, key, stage.preview.metadata, (loaded, total) => {
setStage({ kind: "downloading", loaded, total });
const file = await receiveCloud(id, key, stage.preview.metadata, {
password: needPw ? stage.password.trim() : undefined,
onProgress: (loaded, total) => setStage({ kind: "downloading", loaded, total }),
});
triggerDownload(file);
setStage({ kind: "done", fileName: stage.preview.metadata.name });
} catch (err) {
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 });
}
};
return (
<div className="min-h-screen">
<div className="max-w-xl mx-auto px-5 sm:px-8 pt-10 pb-24">
<header className="pb-8 mb-10 rule">
<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-6 mb-8 sm:pb-8 sm:mb-10 rule">
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
Via AnyDrop
</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
</h1>
</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">
<span>End-to-end encrypted · The server never sees the key</span>
<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</span>
<a
href="/"
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") {
return <p className="text-sm text-ink-muted">Decrypting preview</p>;
}
if (stage.kind === "missing-key") {
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>
<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
</h2>
<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."
: "Something went wrong.";
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>
<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">
{stage.message}
</p>
@ -153,31 +187,67 @@ function ReceiveBody({ stage, onAccept }: { stage: Stage; onAccept: () => void }
if (stage.kind === "preview") {
const { metadata, head } = stage.preview;
const remainingDownloads = head.maxDownloads - head.downloadCount;
const remaining = head.maxDownloads - head.downloadCount;
return (
<div className="paper-panel px-6 py-6">
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">Ready to download</div>
<h2 className="font-display text-2xl text-ink mt-2 mb-5 tracking-tight">
<div className="paper-panel p-5 sm:p-6">
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
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}
</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>
<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>
</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>
</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">
{head.downloadCount}/{head.maxDownloads}
</dd>
</div>
</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
onClick={onAccept}
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
</button>
<p className="mt-4 text-xs text-ink-muted leading-relaxed text-center">
{remainingDownloads === 1
<p className="mt-3 text-xs text-ink-muted leading-relaxed text-center">
{remaining === 1
? "This is the last available download."
: `${remainingDownloads} downloads remaining.`}
: `${remaining} downloads remaining.`}
</p>
</div>
);
@ -198,9 +268,9 @@ function ReceiveBody({ stage, onAccept }: { stage: Stage; onAccept: () => void }
if (stage.kind === "downloading") {
const pct = stage.total > 0 ? Math.round((stage.loaded / stage.total) * 100) : 0;
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>
<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
</h2>
<div className="h-px bg-paper-edge overflow-hidden">
@ -217,14 +287,14 @@ function ReceiveBody({ stage, onAccept }: { stage: Stage; onAccept: () => void }
}
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>
<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
</h2>
<p className="text-sm text-ink-muted leading-relaxed">
<span className="text-ink">{stage.fileName}</span> has been decrypted in your browser and
downloaded. The ciphertext on AnyDrop is being purged.
downloaded.
</p>
</div>
);