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,
|
||||
"tag": "0001_loving_yellowjacket",
|
||||
"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(),
|
||||
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),
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
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>
|
||||
);
|
||||
|
||||
@ -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}`);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user