Compare commits

..

No commits in common. "6ba5401c4d45afdf335343195a6a739c65460877" and "3f87debcf804f95e6d7635c81f983da1f9ea408c" have entirely different histories.

42 changed files with 469 additions and 4252 deletions

View File

@ -16,20 +16,6 @@
# --from-literal=SESSION_SECRET="$SESSION_SECRET" \
# --from-literal=DATABASE_URL="$DATABASE_URL"
#
# # MinIO — reuses the shared cluster MinIO in the `minio` namespace.
# # Create a scoped user + policy on MinIO (one-shot), then store its
# # credentials here. Don't use the MinIO root account.
# # kubectl -n minio exec deploy/minio -- sh -c '
# # mc alias set local http://localhost:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD"
# # mc mb --ignore-existing local/transfers
# # mc anonymous set none local/transfers
# # mc admin user add local anydrop <STRONG_SECRET>
# # # Attach a policy scoped to the transfers bucket only.
# # '
# kubectl -n anydrop create secret generic minio-credentials \
# --from-literal=access_key="anydrop" \
# --from-literal=secret_key="<STRONG_SECRET>"
#
# Rotate by replacing the secret and restarting the pods:
# kubectl -n anydrop rollout restart deployment/anydrop-server
# ---------------------------------------------------------------------------
@ -53,14 +39,3 @@ type: Opaque
stringData:
SESSION_SECRET: CHANGE_ME_64_BYTE_RANDOM_STRING
DATABASE_URL: postgres://anydrop:CHANGE_ME@postgres.anydrop.svc.cluster.local:5432/anydrop
---
apiVersion: v1
kind: Secret
metadata:
name: minio-credentials
namespace: anydrop
type: Opaque
stringData:
access_key: CHANGE_ME_ACCESS_KEY
secret_key: CHANGE_ME_SECRET_KEY

View File

@ -15,14 +15,6 @@ data:
SMTP_SECURE: "false"
SMTP_TLS_REJECT_UNAUTHORIZED: "false"
SMTP_FROM: "AnyDrop <noreply@anydrop.arthurbarre.fr>"
# Phase 2 — encrypted cloud relay (shared MinIO in the `minio` namespace,
# exposed publicly via Traefik as minio.arthurbarre.fr). The browser uses
# presigned URLs signed against this host, so server and client must see
# the same hostname.
S3_ENDPOINT: "https://minio.arthurbarre.fr"
S3_REGION: "us-east-1"
S3_BUCKET: "transfers"
S3_FORCE_PATH_STYLE: "true"
---
apiVersion: apps/v1
@ -59,17 +51,6 @@ spec:
name: anydrop-server-config
- secretRef:
name: anydrop-app-secrets
env:
- name: S3_ACCESS_KEY
valueFrom:
secretKeyRef:
name: minio-credentials
key: access_key
- name: S3_SECRET_KEY
valueFrom:
secretKeyRef:
name: minio-credentials
key: secret_key
livenessProbe:
httpGet:
path: /health

1231
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -15,8 +15,6 @@
},
"dependencies": {
"@anydrop/shared": "workspace:*",
"@aws-sdk/client-s3": "^3.1032.0",
"@aws-sdk/s3-request-presigner": "^3.1032.0",
"@hono/node-server": "^1.13.7",
"drizzle-orm": "^0.45.2",
"hono": "^4.6.14",

View File

@ -1,22 +0,0 @@
CREATE TABLE "transfers" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"storage_key" text NOT NULL,
"sender_user_id" uuid,
"sender_device_id" text,
"recipient_user_id" uuid,
"recipient_email_hash" text,
"encrypted_metadata" text NOT NULL,
"size_bytes" bigint NOT NULL,
"max_downloads" integer DEFAULT 1 NOT NULL,
"download_count" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"first_download_at" timestamp with time zone,
"deleted_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "transfers" ADD CONSTRAINT "transfers_sender_user_id_users_id_fk" FOREIGN KEY ("sender_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "transfers" ADD CONSTRAINT "transfers_recipient_user_id_users_id_fk" FOREIGN KEY ("recipient_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "transfers_sender_idx" ON "transfers" USING btree ("sender_user_id");--> statement-breakpoint
CREATE INDEX "transfers_recipient_idx" ON "transfers" USING btree ("recipient_user_id");--> statement-breakpoint
CREATE INDEX "transfers_expires_idx" ON "transfers" USING btree ("expires_at");

View File

@ -1,553 +0,0 @@
{
"id": "50199a15-ea37-4c61-beee-71f2d99cd292",
"prevId": "a3d4d541-ef82-42cb-b317-1b27aca7bff6",
"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
}
},
"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

@ -8,13 +8,6 @@
"when": 1776644472089,
"tag": "0000_init",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1776675064100,
"tag": "0001_loving_yellowjacket",
"breakpoints": true
}
]
}

View File

@ -1,4 +1,4 @@
import { pgTable, text, timestamp, uuid, uniqueIndex, index, bigint, integer } from "drizzle-orm/pg-core";
import { pgTable, text, timestamp, uuid, uniqueIndex, index } from "drizzle-orm/pg-core";
export const users = pgTable(
"users",
@ -70,52 +70,8 @@ export const userDevices = pgTable(
}),
);
/**
* Encrypted cloud relay transfers (Phase 2).
*
* The server is intentionally blind to the content:
* - object key in MinIO holds the ciphertext only
* - encryptedMetadata holds filename/mime/size (AEAD sealed with the same key)
* - the symmetric key NEVER reaches the server; it lives in the URL fragment
* (#k=...) and is only ever handled by sender and recipient clients
*
* Columns the server legitimately needs:
* - id + storage key (routing)
* - senderUserId (so the sender's /inbox can list their sends)
* - recipientUserId (nullable set when sending to a known user, lets /inbox
* surface incoming cloud relays)
* - sizeBytes (enforce plan quotas; ciphertext size, no content leak)
* - maxDownloads / downloadCount / expiresAt / consumedAt (lifecycle)
*/
export const transfers = pgTable(
"transfers",
{
id: uuid("id").primaryKey().defaultRandom(),
storageKey: text("storage_key").notNull(),
senderUserId: uuid("sender_user_id").references(() => users.id, { onDelete: "set null" }),
senderDeviceId: text("sender_device_id"),
recipientUserId: uuid("recipient_user_id").references(() => users.id, { onDelete: "set null" }),
recipientEmailHash: text("recipient_email_hash"),
encryptedMetadata: text("encrypted_metadata").notNull(),
sizeBytes: bigint("size_bytes", { mode: "number" }).notNull(),
maxDownloads: integer("max_downloads").notNull().default(1),
downloadCount: integer("download_count").notNull().default(0),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
firstDownloadAt: timestamp("first_download_at", { withTimezone: true }),
deletedAt: timestamp("deleted_at", { withTimezone: true }),
},
(t) => ({
senderIdx: index("transfers_sender_idx").on(t.senderUserId),
recipientIdx: index("transfers_recipient_idx").on(t.recipientUserId),
expiresIdx: index("transfers_expires_idx").on(t.expiresAt),
}),
);
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Session = typeof sessions.$inferSelect;
export type MagicLink = typeof magicLinks.$inferSelect;
export type UserDevice = typeof userDevices.$inferSelect;
export type Transfer = typeof transfers.$inferSelect;
export type NewTransfer = typeof transfers.$inferInsert;

View File

@ -2,7 +2,6 @@ import { Hono } from "hono";
import { cors } from "hono/cors";
import { authRoutes } from "./auth.js";
import { meRoutes } from "./me.js";
import { transferRoutes } from "./transfers.js";
export function buildApp() {
const app = new Hono();
@ -13,7 +12,7 @@ export function buildApp() {
cors({
origin: corsOrigin,
credentials: true,
allowHeaders: ["Content-Type", "X-Device-Id"],
allowHeaders: ["Content-Type"],
allowMethods: ["GET", "POST", "DELETE", "OPTIONS"],
}),
);
@ -22,7 +21,6 @@ export function buildApp() {
app.route("/api/auth", authRoutes);
app.route("/api", meRoutes);
app.route("/api", transferRoutes);
app.notFound((c) => c.json({ error: "not_found" }, 404));
app.onError((err, c) => {

View File

@ -1,244 +0,0 @@
import { createHash, randomUUID } from "node:crypto";
import { Hono } from "hono";
import { and, desc, eq, gt, isNull, or, sql } from "drizzle-orm";
import { db } from "../db/client.js";
import { transfers, users } from "../db/schema.js";
import { resolveSession } from "./session.js";
import { rateLimit } from "./middleware.js";
import { deleteObject, presignDownload, presignUpload } from "../storage/s3.js";
export const transferRoutes = new Hono();
const UPLOAD_TTL_SECONDS = 15 * 60;
const DOWNLOAD_TTL_SECONDS = 10 * 60;
const DEFAULT_EXPIRY_DAYS = 7;
const MAX_EXPIRY_DAYS = 30;
const MAX_METADATA_LEN = 8_192;
const MAX_SIZE_BYTES_FREE = 2 * 1024 * 1024 * 1024;
const MAX_MAX_DOWNLOADS = 100;
transferRoutes.use("/transfers", rateLimit(30));
transferRoutes.use("/transfers/*", rateLimit(60));
function storageKey(id: string): string {
return `t/${id.slice(0, 2)}/${id}`;
}
function hashEmail(email: string): string {
return createHash("sha256").update(email.toLowerCase().trim()).digest("hex");
}
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);
}
/**
* POST /api/transfers
* Create a transfer. Body:
* {
* sizeBytes: number, // ciphertext size
* encryptedMetadata: string, // base64 AEAD-sealed JSON {name, mime, size}
* recipientEmail?: string, // hashed before persist; used for /inbox routing
* maxDownloads?: number, // default 1
* expiresInDays?: number // default 7, cap 30
* }
* Returns: { transferId, uploadUrl, storageKey, expiresAt }
*/
transferRoutes.post("/transfers", async (c) => {
const user = await resolveSession(c);
const senderDeviceId = c.req.header("x-device-id") ?? null;
let body: any;
try {
body = await c.req.json();
} catch {
return c.json({ error: "invalid_body" }, 400);
}
const sizeBytes = Number(body.sizeBytes);
if (!Number.isInteger(sizeBytes) || sizeBytes <= 0 || sizeBytes > MAX_SIZE_BYTES_FREE) {
return c.json({ error: "invalid_size" }, 400);
}
const encryptedMetadata = typeof body.encryptedMetadata === "string" ? body.encryptedMetadata : "";
if (!encryptedMetadata || encryptedMetadata.length > MAX_METADATA_LEN) {
return c.json({ error: "invalid_metadata" }, 400);
}
const maxDownloads = Number.isInteger(body.maxDownloads)
? Math.max(1, Math.min(MAX_MAX_DOWNLOADS, body.maxDownloads))
: 1;
const expiresInDays = Number.isInteger(body.expiresInDays)
? Math.max(1, Math.min(MAX_EXPIRY_DAYS, body.expiresInDays))
: DEFAULT_EXPIRY_DAYS;
const expiresAt = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000);
let recipientUserId: string | null = null;
let recipientEmailHash: string | null = null;
if (typeof body.recipientEmail === "string" && body.recipientEmail.trim()) {
const email = body.recipientEmail.trim().toLowerCase();
recipientEmailHash = hashEmail(email);
const match = await db
.select({ id: users.id })
.from(users)
.where(eq(users.email, email))
.limit(1);
if (match.length > 0) recipientUserId = match[0].id;
}
const id = randomUUID();
const key = storageKey(id);
const [row] = await db
.insert(transfers)
.values({
id,
storageKey: key,
senderUserId: user?.id ?? null,
senderDeviceId,
recipientUserId,
recipientEmailHash,
encryptedMetadata,
sizeBytes,
maxDownloads,
expiresAt,
})
.returning();
const uploadUrl = await presignUpload(key, sizeBytes, UPLOAD_TTL_SECONDS);
return c.json(
{
transferId: row.id,
uploadUrl,
expiresAt: row.expiresAt,
},
201,
);
});
/**
* GET /api/transfers/:id
* Head-style: returns metadata + a presigned download URL. Does NOT yet
* bump the download counter that's what POST /consume is for, so the
* recipient client can poll metadata before committing.
*/
transferRoutes.get("/transfers/:id", async (c) => {
const id = c.req.param("id");
if (!isValidUuid(id)) return c.json({ error: "not_found" }, 404);
const [row] = await db.select().from(transfers).where(eq(transfers.id, id)).limit(1);
if (!row || row.deletedAt) return c.json({ error: "not_found" }, 404);
if (row.expiresAt < new Date()) return c.json({ error: "expired" }, 410);
if (row.downloadCount >= row.maxDownloads) return c.json({ error: "consumed" }, 410);
return c.json({
transferId: row.id,
encryptedMetadata: row.encryptedMetadata,
sizeBytes: row.sizeBytes,
maxDownloads: row.maxDownloads,
downloadCount: row.downloadCount,
expiresAt: row.expiresAt,
});
});
/**
* POST /api/transfers/:id/consume
* Atomically increments downloadCount and returns a presigned GET URL.
* Prevents two recipients from concurrently claiming the last slot.
*/
transferRoutes.post("/transfers/:id/consume", async (c) => {
const id = c.req.param("id");
if (!isValidUuid(id)) return c.json({ error: "not_found" }, 404);
const [row] = await db
.update(transfers)
.set({
downloadCount: sql`${transfers.downloadCount} + 1`,
firstDownloadAt: sql`coalesce(${transfers.firstDownloadAt}, now())`,
})
.where(
and(
eq(transfers.id, id),
isNull(transfers.deletedAt),
gt(transfers.expiresAt, new Date()),
sql`${transfers.downloadCount} < ${transfers.maxDownloads}`,
),
)
.returning();
if (!row) return c.json({ error: "not_available" }, 410);
const downloadUrl = await presignDownload(row.storageKey, DOWNLOAD_TTL_SECONDS);
return c.json({ downloadUrl, expiresInSeconds: DOWNLOAD_TTL_SECONDS });
});
/**
* GET /api/transfers
* List the authenticated user's inbox (things sent TO them) and outbox
* (things they sent). Signed-in only.
*/
transferRoutes.get("/transfers", async (c) => {
const user = await resolveSession(c);
if (!user) return c.json({ error: "unauthenticated" }, 401);
const rows = await db
.select({
id: transfers.id,
sizeBytes: transfers.sizeBytes,
encryptedMetadata: transfers.encryptedMetadata,
createdAt: transfers.createdAt,
expiresAt: transfers.expiresAt,
maxDownloads: transfers.maxDownloads,
downloadCount: transfers.downloadCount,
firstDownloadAt: transfers.firstDownloadAt,
senderUserId: transfers.senderUserId,
recipientUserId: transfers.recipientUserId,
})
.from(transfers)
.where(
and(
isNull(transfers.deletedAt),
or(
eq(transfers.senderUserId, user.id),
eq(transfers.recipientUserId, user.id),
),
),
)
.orderBy(desc(transfers.createdAt))
.limit(50);
return c.json({
transfers: rows.map((r) => ({
...r,
direction: r.senderUserId === user.id ? "sent" : "received",
})),
});
});
/**
* DELETE /api/transfers/:id
* Sender can revoke. Marks deleted, purges the blob asynchronously.
*/
transferRoutes.delete("/transfers/:id", async (c) => {
const user = await resolveSession(c);
if (!user) return c.json({ error: "unauthenticated" }, 401);
const id = c.req.param("id");
if (!isValidUuid(id)) return c.json({ error: "not_found" }, 404);
const [row] = await db
.update(transfers)
.set({ deletedAt: new Date() })
.where(and(eq(transfers.id, id), eq(transfers.senderUserId, user.id), isNull(transfers.deletedAt)))
.returning();
if (!row) return c.json({ error: "not_found" }, 404);
deleteObject(row.storageKey).catch((err) =>
console.error("[transfers] delete blob failed:", row.storageKey, err),
);
return c.body(null, 204);
});

View File

@ -20,7 +20,6 @@ import {
wakeDevice,
} from "./push.js";
import { buildApp } from "./http/app.js";
import { startCleanupLoop } from "./storage/cleanup.js";
const PORT = parseInt(process.env.PORT || "3001", 10);
const BASE_URL = process.env.BASE_URL || "http://localhost:5173";
@ -495,9 +494,3 @@ function handleLeave(client: Client): void {
httpServer.listen(PORT, () => {
console.log(`AnyDrop signaling server running on port ${PORT}`);
});
if (process.env.S3_ACCESS_KEY && process.env.S3_SECRET_KEY) {
startCleanupLoop();
} else {
console.log("[cleanup] S3 credentials not set, skipping transfer cleanup loop");
}

View File

@ -1,56 +0,0 @@
import { lt, or, and, isNull, sql } from "drizzle-orm";
import { db } from "../db/client.js";
import { transfers } from "../db/schema.js";
import { deleteObject } from "./s3.js";
const CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
/**
* Sweep the transfers table:
* - purge MinIO blobs for transfers whose download quota is hit
* (so the bytes stop costing us storage as soon as they're useless)
* - purge MinIO blobs + rows for transfers past their expiration window
*
* Run on an interval from the server process. Idempotent safe to run
* concurrently because we filter on `deleted_at IS NULL` and mark it set
* before issuing the S3 delete.
*/
export async function runCleanup(): Promise<void> {
const now = new Date();
const expired = await db
.update(transfers)
.set({ deletedAt: now })
.where(
and(
isNull(transfers.deletedAt),
or(
lt(transfers.expiresAt, now),
sql`${transfers.downloadCount} >= ${transfers.maxDownloads}`,
),
),
)
.returning({ id: transfers.id, storageKey: transfers.storageKey });
if (expired.length === 0) return;
console.log(`[cleanup] purging ${expired.length} expired/consumed transfers`);
await Promise.all(
expired.map((t) =>
deleteObject(t.storageKey).catch((err) =>
console.error(`[cleanup] failed to delete ${t.storageKey}:`, err),
),
),
);
}
export function startCleanupLoop(): () => void {
runCleanup().catch((err) => console.error("[cleanup] initial run failed:", err));
const interval = setInterval(() => {
runCleanup().catch((err) => console.error("[cleanup] interval run failed:", err));
}, CLEANUP_INTERVAL_MS);
interval.unref();
return () => clearInterval(interval);
}

View File

@ -1,81 +0,0 @@
import {
S3Client,
DeleteObjectCommand,
PutObjectCommand,
GetObjectCommand,
HeadObjectCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const BUCKET = process.env.S3_BUCKET ?? "transfers";
const ENDPOINT = process.env.S3_ENDPOINT ?? "http://minio:9000";
const REGION = process.env.S3_REGION ?? "us-east-1";
const ACCESS_KEY = process.env.S3_ACCESS_KEY ?? "";
const SECRET_KEY = process.env.S3_SECRET_KEY ?? "";
const FORCE_PATH_STYLE = (process.env.S3_FORCE_PATH_STYLE ?? "true") === "true";
let client: S3Client | null = null;
export function getS3Client(): S3Client {
if (client) return client;
if (!ACCESS_KEY || !SECRET_KEY) {
throw new Error("S3_ACCESS_KEY and S3_SECRET_KEY must be set");
}
client = new S3Client({
endpoint: ENDPOINT,
region: REGION,
credentials: { accessKeyId: ACCESS_KEY, secretAccessKey: SECRET_KEY },
forcePathStyle: FORCE_PATH_STYLE,
});
return client;
}
export function getBucket(): string {
return BUCKET;
}
/**
* Presigned PUT URL for upload. The client PUTs the ciphertext directly
* to MinIO the server never touches the bytes.
*/
export async function presignUpload(
storageKey: string,
sizeBytes: number,
ttlSeconds: number,
): Promise<string> {
const cmd = new PutObjectCommand({
Bucket: BUCKET,
Key: storageKey,
ContentLength: sizeBytes,
ContentType: "application/octet-stream",
});
return getSignedUrl(getS3Client(), cmd, { expiresIn: ttlSeconds });
}
/**
* Presigned GET URL for download. Recipient fetches the ciphertext
* directly from MinIO and decrypts client-side.
*/
export async function presignDownload(
storageKey: string,
ttlSeconds: number,
): Promise<string> {
const cmd = new GetObjectCommand({
Bucket: BUCKET,
Key: storageKey,
});
return getSignedUrl(getS3Client(), cmd, { expiresIn: ttlSeconds });
}
export async function deleteObject(storageKey: string): Promise<void> {
await getS3Client().send(new DeleteObjectCommand({ Bucket: BUCKET, Key: storageKey }));
}
export async function objectExists(storageKey: string): Promise<boolean> {
try {
await getS3Client().send(new HeadObjectCommand({ Bucket: BUCKET, Key: storageKey }));
return true;
} catch {
return false;
}
}

View File

@ -1,16 +1,14 @@
<!DOCTYPE html>
<html lang="fr">
<html lang="fr" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="AnyDrop — Instant, peer-to-peer file and text transfer, universal across platforms." />
<meta name="theme-color" content="#F5F0E6" />
<meta name="description" content="AnyDrop — Partage de fichiers instantané, peer-to-peer, sans compte" />
<meta name="theme-color" content="#6366f1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<title>AnyDrop — Universal transfer</title>
<title>AnyDrop</title>
</head>
<body>
<body class="bg-slate-950 text-white antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>

View File

@ -11,8 +11,6 @@
},
"dependencies": {
"@anydrop/shared": "workspace:*",
"@noble/ciphers": "^2.2.0",
"@noble/hashes": "^2.2.0",
"events": "^3.3.0",
"process": "^0.11.10",
"qrcode.react": "^4.2.0",

View File

@ -4,8 +4,6 @@ import JoinRoom from "./pages/JoinRoom";
import Share from "./pages/Share";
import Pair from "./pages/Pair";
import Settings from "./pages/Settings";
import Receive from "./pages/Receive";
import Inbox from "./pages/Inbox";
export default function App() {
return (
@ -14,8 +12,6 @@ export default function App() {
<Route path="/share" element={<Share />} />
<Route path="/pair" element={<Pair />} />
<Route path="/settings" element={<Settings />} />
<Route path="/inbox" element={<Inbox />} />
<Route path="/r/:id" element={<Receive />} />
<Route path="/:code" element={<JoinRoom />} />
</Routes>
);

View File

@ -1,273 +0,0 @@
import { useState, useRef } from "react";
import { QRCodeSVG } from "qrcode.react";
import { sendCloud } from "../lib/sendCloud";
import { useProfileStore } from "../stores/useProfileStore";
type Stage =
| { kind: "idle" }
| { kind: "uploading"; loaded: number; total: number }
| { kind: "done"; shareUrl: string; fileName: string; expiresAt: string }
| { kind: "error"; message: string };
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 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 [email, setEmail] = useState("");
const [copied, setCopied] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const handleSend = async () => {
if (!pickedFile) return;
setStage({ kind: "uploading", loaded: 0, total: pickedFile.size });
try {
const result = await sendCloud(pickedFile, {
deviceId,
recipientEmail: email.trim() || undefined,
onProgress: (loaded, total) => setStage({ kind: "uploading", loaded, total }),
});
setStage({
kind: "done",
shareUrl: result.shareUrl,
fileName: pickedFile.name,
expiresAt: result.expiresAt,
});
} catch (err) {
const msg = err instanceof Error ? err.message : "unknown";
setStage({ kind: "error", message: msg });
}
};
const handleCopy = () => {
if (stage.kind !== "done") return;
navigator.clipboard.writeText(stage.shareUrl);
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>
{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
</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>
</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"
>
<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>
</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"
/>
<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}%`,
}}
/>
</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>
</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>
</>
)}
{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>
</div>
);
}

View File

@ -19,6 +19,7 @@ export default function DevicePairingPanel({
const [inputCode, setInputCode] = useState("");
const [pairError, setPairError] = useState<string | null>(null);
// When modal opens in "show" mode, always request a fresh code
useEffect(() => {
if (showModal && mode === "show") {
let gid = groupId;
@ -31,6 +32,7 @@ export default function DevicePairingPanel({
}
}, [showModal, mode]);
// Detect pair-code-not-found errors
useEffect(() => {
if (error && error.includes("appairage")) {
setPairError(error);
@ -49,9 +51,11 @@ export default function DevicePairingPanel({
if (inputCode.length >= 6) {
setPairError(null);
onResolveCode(inputCode);
// Don't close — wait for response or error
}
};
// Auto-close on successful pairing (groupId changed after resolve)
const currentGroupId = useProfileStore((s) => s.groupId);
useEffect(() => {
if (mode === "enter" && showModal && currentGroupId && currentGroupId !== groupId) {
@ -63,78 +67,68 @@ export default function DevicePairingPanel({
<>
<button
onClick={() => { setMode("show"); 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"
className="flex-1 flex flex-col items-center justify-center gap-1.5 px-3 py-3
border border-slate-700 hover:border-brand-500 rounded-xl
text-slate-300 hover:text-white transition-all
bg-slate-900/30 hover:bg-slate-900/50"
>
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-ink-muted">
Pair device
</span>
<span className="text-sm text-ink">Link your own </span>
<span className="text-lg">📲</span>
<span className="text-xs font-medium text-center leading-tight">Appairer</span>
</button>
{showModal && (
<div
className="fixed inset-0 z-50 bg-ink/40 flex items-center justify-center p-4"
className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-6"
onClick={handleClose}
>
<div
className="paper-panel shadow-lift rounded-sm p-6 max-w-sm w-full"
className="bg-slate-900 border border-slate-700 rounded-2xl p-6 max-w-sm w-full
text-center space-y-5"
onClick={(e) => e.stopPropagation()}
>
<div className="text-xs uppercase tracking-[0.2em] text-ink-muted">
Pair device
</div>
<h3 className="font-display text-2xl text-ink mt-1 mb-5">
Link your devices
</h3>
{/* Tab switcher */}
<div className="flex border border-paper-edge rounded-sm overflow-hidden mb-5">
<div className="flex gap-2 bg-slate-800 rounded-xl p-1">
<button
onClick={() => setMode("show")}
className={`flex-1 py-2 text-xs uppercase tracking-[0.15em] transition-colors
${mode === "show"
? "bg-ink text-paper"
: "bg-paper text-ink-muted hover:text-ink"}`}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors
${mode === "show" ? "bg-brand-500 text-white" : "text-slate-400 hover:text-white"}`}
>
Show code
Mon code
</button>
<button
onClick={() => { setMode("enter"); setPairError(null); }}
className={`flex-1 py-2 text-xs uppercase tracking-[0.15em] transition-colors
${mode === "enter"
? "bg-ink text-paper"
: "bg-paper text-ink-muted hover:text-ink"}`}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors
${mode === "enter" ? "bg-brand-500 text-white" : "text-slate-400 hover:text-white"}`}
>
Enter code
Rejoindre
</button>
</div>
{mode === "show" ? (
<>
<p className="text-sm text-ink-muted mb-5">
Enter this code on your other device.
<p className="text-sm text-slate-400">
Entrez ce code sur votre autre appareil
</p>
{pairCode ? (
<p className="font-mono text-4xl text-signal tracking-[0.3em] text-center">
<p className="text-4xl font-mono font-bold text-brand-400 tracking-[0.3em]">
{pairCode}
</p>
) : (
<div className="flex justify-center py-3">
<div className="w-5 h-5 border border-signal border-t-transparent rounded-full animate-spin" />
<div className="flex justify-center py-2">
<div className="w-6 h-6 border-2 border-brand-400 border-t-transparent rounded-full animate-spin" />
</div>
)}
<p className="text-xs text-ink-muted mt-5 leading-relaxed text-center">
Code expires in 5 minutes. Pairing is permanent.
<p className="text-xs text-slate-500">
Le code expire dans 5 minutes.
L'appairage est permanent.
</p>
</>
) : (
<>
<p className="text-sm text-ink-muted mb-4">
Enter the code shown on the other device.
<p className="text-sm text-slate-400">
Entrez le code affiché sur l'autre appareil
</p>
<input
@ -147,37 +141,35 @@ export default function DevicePairingPanel({
placeholder="ABC123"
maxLength={6}
autoFocus
className="w-full px-4 py-3 bg-paper border border-paper-edge
rounded-sm text-ink text-2xl text-center font-mono tracking-[0.3em]
placeholder:text-ink-faint focus:outline-none focus:border-ink
transition-colors duration-fast ease-crisp"
className="w-full px-4 py-3 bg-slate-800 border border-slate-600
rounded-xl text-white text-2xl text-center font-mono tracking-[0.3em]
placeholder:text-slate-700 focus:outline-none focus:border-brand-500"
onKeyDown={(e) => {
if (e.key === "Enter") handleSubmitCode();
}}
/>
{pairError && (
<p className="text-sm text-fail mt-3">{pairError}</p>
<p className="text-sm text-red-400">{pairError}</p>
)}
<button
onClick={handleSubmitCode}
disabled={inputCode.length < 6}
className="mt-5 w-full py-2.5 bg-ink text-paper rounded-sm text-sm
font-medium hover:bg-signal transition-colors duration-fast
ease-crisp disabled:opacity-30 disabled:cursor-not-allowed"
className="w-full py-3 bg-brand-500 hover:bg-brand-400 disabled:opacity-30
disabled:cursor-not-allowed rounded-xl text-white font-medium
transition-colors"
>
Pair
Appairer
</button>
</>
)}
<button
onClick={handleClose}
className="mt-6 w-full py-2.5 border border-paper-edge hover:border-ink
text-sm text-ink rounded-sm transition-colors duration-fast ease-crisp"
className="text-sm text-slate-500 hover:text-slate-300 transition-colors"
>
Close
Fermer
</button>
</div>
</div>

View File

@ -56,6 +56,7 @@ export default function DropZone({ onFilesSelected, disabled }: DropZoneProps) {
if (files.length > 0) {
onFilesSelected(files);
}
// Reset to allow re-selecting the same file
e.target.value = "";
};
@ -67,29 +68,26 @@ export default function DropZone({ onFilesSelected, disabled }: DropZoneProps) {
onDrop={handleDrop}
onClick={handleClick}
className={`
border border-dashed rounded-sm px-6 py-8
flex flex-col items-center justify-center gap-2
transition-colors duration-fast ease-crisp cursor-pointer
border-2 border-dashed rounded-2xl p-8
flex flex-col items-center justify-center gap-3
transition-all duration-200 cursor-pointer
min-h-[160px]
${isDragging
? "border-signal bg-signal-quiet"
? "border-brand-400 bg-brand-500/10 scale-[1.02]"
: disabled
? "border-paper-edge bg-paper-deep/40 cursor-not-allowed opacity-60"
: "border-paper-edge bg-paper hover:border-ink hover:bg-paper-deep/40"
? "border-slate-700 bg-slate-900/50 cursor-not-allowed opacity-50"
: "border-slate-700 hover:border-slate-500 bg-slate-900/30 hover:bg-slate-900/50"
}
`}
>
<span className="font-mono text-[10px] uppercase tracking-[0.2em] text-ink-muted">
{isDragging ? "Release" : "Drop"}
</span>
<p className="font-display text-xl text-ink">
<div className="text-4xl">{isDragging ? "📥" : "📁"}</div>
<p className="text-slate-400 text-sm text-center">
{disabled
? "Select a device first"
? "Sélectionnez d'abord un appareil"
: isDragging
? "Release to send"
: "Drop files here"}
</p>
<p className="text-xs text-ink-muted">
or click to choose
? "Déposez vos fichiers ici"
: "Glissez des fichiers ici ou cliquez pour sélectionner"
}
</p>
<input
ref={inputRef}

View File

@ -10,28 +10,20 @@ interface PeerAvatarProps {
size?: "sm" | "md" | "lg";
}
const DEVICE_GLYPH: Record<DeviceType, string> = {
phone: "phone",
tablet: "tablet",
laptop: "laptop",
desktop: "desk",
const DEVICE_ICONS: Record<DeviceType, string> = {
phone: "📱",
tablet: "📱",
laptop: "💻",
desktop: "🖥️",
};
const sizeClasses = {
sm: { container: "w-12 h-12", label: "text-[10px]" },
md: { container: "w-16 h-16", label: "text-[11px]" },
lg: { container: "w-20 h-20", label: "text-xs" },
sm: { container: "w-12 h-12", icon: "text-xl", img: "w-12 h-12" },
md: { container: "w-16 h-16", icon: "text-2xl", img: "w-16 h-16" },
lg: { container: "w-20 h-20", icon: "text-3xl", img: "w-20 h-20" },
};
export default function PeerAvatar({
displayName,
deviceType,
avatar,
online = true,
onClick,
isSelected,
size = "md",
}: PeerAvatarProps) {
export default function PeerAvatar({ displayName, deviceType, avatar, online = true, onClick, isSelected, size = "md" }: PeerAvatarProps) {
const s = sizeClasses[size];
const isOffline = !online;
@ -39,9 +31,9 @@ export default function PeerAvatar({
<button
onClick={onClick}
className={`
group flex flex-col items-center gap-2.5
transition-transform duration-fast ease-crisp
${isOffline ? "opacity-60" : ""}
flex flex-col items-center gap-2 group cursor-pointer
transition-transform duration-200 hover:scale-105
${isOffline ? "opacity-50" : ""}
`}
>
<div className="relative">
@ -49,40 +41,33 @@ export default function PeerAvatar({
className={`
${s.container}
rounded-full flex items-center justify-center overflow-hidden
border transition-all duration-fast ease-crisp
transition-all duration-200
${isSelected
? "border-signal ring-1 ring-signal"
: "border-paper-edge group-hover:border-ink"
? "ring-2 ring-brand-400 ring-offset-2 ring-offset-slate-950"
: ""
}
${avatar ? "" : "bg-paper"}
${avatar ? "" : isSelected ? "bg-brand-500" : "bg-slate-800 hover:bg-slate-700"}
`}
>
{avatar ? (
<img
src={avatar}
alt=""
className={`w-full h-full object-cover ${isOffline ? "grayscale" : ""}`}
alt={displayName}
className={`${s.img} rounded-full object-cover ${isOffline ? "grayscale" : ""}`}
/>
) : (
<span className="font-mono text-[10px] uppercase tracking-widest text-ink-muted">
{DEVICE_GLYPH[deviceType]}
</span>
<span className={s.icon}>{DEVICE_ICONS[deviceType]}</span>
)}
</div>
<span
{/* Online/offline indicator */}
<div
className={`
absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border border-paper
${online ? "bg-ok" : "bg-ink-faint"}
absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 rounded-full border-2 border-slate-950
${online ? "bg-green-500" : "bg-slate-500"}
`}
aria-label={online ? "online" : "offline"}
/>
</div>
<span
className={`
${s.label} max-w-[88px] truncate transition-colors
${isSelected ? "text-ink" : isOffline ? "text-ink-faint" : "text-ink-muted group-hover:text-ink"}
`}
>
<span className={`text-xs transition-colors max-w-[80px] truncate ${isOffline ? "text-slate-500" : "text-slate-300 group-hover:text-white"}`}>
{displayName}
</span>
</button>

View File

@ -11,18 +11,18 @@ 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="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.
<div className="flex flex-col items-center justify-center py-12 text-slate-500">
<div className="text-5xl mb-4">📡</div>
<p className="text-lg font-medium">En attente d'appareils...</p>
<p className="text-sm mt-2 text-center max-w-xs">
Ouvrez AnyDrop sur un autre appareil connecté au même Wi-Fi,
ou appairez vos appareils ci-dessous.
</p>
</div>
);
}
// Sort: online first, then offline
const sorted = [...peers].sort((a, b) => {
const aOnline = a.online !== false ? 1 : 0;
const bOnline = b.online !== false ? 1 : 0;
@ -30,8 +30,7 @@ export default function PeerList({ onPeerSelect }: PeerListProps) {
});
return (
<div className="paper-panel px-4 py-6">
<div className="flex flex-wrap justify-center gap-6">
<div className="flex flex-wrap justify-center gap-6 py-8">
{sorted.map((peer) => (
<PeerAvatar
key={peer.peerId}
@ -44,6 +43,5 @@ export default function PeerList({ onPeerSelect }: PeerListProps) {
/>
))}
</div>
</div>
);
}

View File

@ -1,7 +1,7 @@
import { useState, useRef } from "react";
import { useProfileStore } from "../stores/useProfileStore";
const MAX_AVATAR_SIZE = 80_000;
const MAX_AVATAR_SIZE = 80_000; // ~80KB after base64
function resizeImage(file: File, maxSize: number): Promise<string> {
return new Promise((resolve, reject) => {
@ -10,6 +10,7 @@ function resizeImage(file: File, maxSize: number): Promise<string> {
img.onload = () => {
URL.revokeObjectURL(url);
const canvas = document.createElement("canvas");
// Crop to square, max 128px
const side = Math.min(img.width, img.height);
const sx = (img.width - side) / 2;
const sy = (img.height - side) / 2;
@ -20,6 +21,7 @@ function resizeImage(file: File, maxSize: number): Promise<string> {
const ctx = canvas.getContext("2d")!;
ctx.drawImage(img, sx, sy, side, side, 0, 0, outSize, outSize);
// Try JPEG at decreasing quality until small enough
let quality = 0.8;
let dataUrl = canvas.toDataURL("image/jpeg", quality);
while (dataUrl.length > maxSize && quality > 0.2) {
@ -28,6 +30,7 @@ function resizeImage(file: File, maxSize: number): Promise<string> {
}
if (dataUrl.length > maxSize) {
// Shrink further
outSize = 64;
canvas.width = outSize;
canvas.height = outSize;
@ -67,45 +70,28 @@ export default function ProfileSetup({ onDone, isEditing }: ProfileSetupProps) {
onDone();
};
const wrapperClass = isEditing
? "fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4"
: "min-h-screen flex items-center justify-center p-4";
return (
<div className={wrapperClass}>
<div className="paper-panel shadow-lift rounded-sm p-8 w-full max-w-sm">
{!isEditing && (
<>
<h1 className="font-display text-4xl leading-none tracking-tight text-ink text-center">
AnyDrop
</h1>
<p className="mt-2 mb-6 text-xs uppercase tracking-[0.2em] text-ink-muted text-center">
Universal transfer
</p>
</>
)}
<div className="text-xs uppercase tracking-[0.2em] text-ink-muted">
{isEditing ? "Edit" : "Set up"}
</div>
<h2 className="font-display text-2xl text-ink mt-1 mb-6">
Your device
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4">
<div className="bg-slate-900 border border-slate-700 rounded-2xl p-6 w-full max-w-sm shadow-2xl">
<h2 className="text-lg font-semibold text-white text-center mb-6">
{isEditing ? "Modifier le profil" : "Votre appareil"}
</h2>
{/* Avatar */}
<div className="flex justify-center mb-3">
<div className="flex justify-center mb-6">
<button
onClick={() => fileRef.current?.click()}
className="relative w-24 h-24 rounded-full bg-paper
className="relative w-24 h-24 rounded-full bg-slate-800 hover:bg-slate-700
flex items-center justify-center overflow-hidden
transition-colors duration-fast ease-crisp
border border-dashed border-paper-edge hover:border-ink"
transition-colors border-2 border-dashed border-slate-600 hover:border-brand-400"
>
{preview ? (
<img src={preview} alt="Avatar" className="w-full h-full object-cover rounded-full" />
) : (
<span className="font-mono text-[10px] uppercase tracking-widest text-ink-muted">
Photo
</span>
<div className="flex flex-col items-center text-slate-400">
<span className="text-2xl">📷</span>
<span className="text-[10px] mt-1">Photo</span>
</div>
)}
</button>
<input
@ -117,41 +103,40 @@ export default function ProfileSetup({ onDone, isEditing }: ProfileSetupProps) {
/>
</div>
{/* Remove photo */}
{preview && (
<button
onClick={() => setPreview(null)}
className="block mx-auto mb-4 text-xs text-ink-muted hover:text-signal transition-colors"
className="block mx-auto mb-4 text-xs text-slate-500 hover:text-red-400 transition-colors"
>
Remove photo
Supprimer la photo
</button>
)}
{/* Name */}
<div className="mt-5 mb-6">
<label className="block text-xs uppercase tracking-[0.15em] text-ink-muted mb-2">
Device name
</label>
<div className="mb-6">
<label className="block text-xs text-slate-400 mb-1.5">Nom de l'appareil</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={30}
placeholder="e.g. Arthur's iPhone"
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"
placeholder="ex: iPhone d'Arthur"
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-700 rounded-xl
text-white text-sm placeholder:text-slate-500
focus:outline-none focus:border-brand-500 transition-colors"
autoFocus
onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
/>
</div>
{/* Submit */}
<button
onClick={handleSubmit}
className="w-full py-2.5 bg-ink text-paper rounded-sm text-sm font-medium
hover:bg-signal transition-colors duration-fast ease-crisp"
className="w-full py-2.5 bg-brand-600 hover:bg-brand-500 text-white
rounded-xl text-sm font-medium transition-colors"
>
{isEditing ? "Save" : "Continue →"}
{isEditing ? "Enregistrer" : "C'est parti"}
</button>
</div>
</div>

View File

@ -24,14 +24,13 @@ export default function PublicRoomPanel({ onCreateRoom }: PublicRoomPanelProps)
<>
<button
onClick={handleClick}
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"
className="flex-1 flex flex-col items-center justify-center gap-1.5 px-3 py-3
border border-slate-700 hover:border-brand-500 rounded-xl
text-slate-300 hover:text-white transition-all
bg-slate-900/30 hover:bg-slate-900/50"
>
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-ink-muted">
Public link
</span>
<span className="text-sm text-ink">Send to anyone </span>
<span className="text-lg">🔗</span>
<span className="text-xs font-medium text-center leading-tight">Lien public</span>
</button>
{showModal && (
@ -54,66 +53,58 @@ function PublicRoomModal({
url: string | null;
onClose: () => void;
}) {
const [copied, setCopied] = useState(false);
const copyToClipboard = () => {
if (!url) return;
navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
if (url) navigator.clipboard.writeText(url);
};
return (
<div
className="fixed inset-0 z-50 bg-ink/40 flex items-center justify-center p-4"
className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-6"
onClick={onClose}
>
<div
className="paper-panel shadow-lift rounded-sm p-6 max-w-sm w-full"
className="bg-slate-900 border border-slate-700 rounded-2xl p-6 max-w-sm w-full
text-center space-y-4"
onClick={(e) => e.stopPropagation()}
>
<div className="text-xs uppercase tracking-[0.2em] text-ink-muted">
Public link
</div>
<h3 className="font-display text-2xl text-ink mt-1 mb-5">
Receive from anyone
</h3>
<h3 className="text-lg font-semibold text-white">Lien public</h3>
{!code || !url ? (
<p className="text-sm text-ink-muted">Generating</p>
<p className="text-sm text-slate-400">Création en cours...</p>
) : (
<>
<div className="flex justify-center mb-5">
<div className="bg-paper p-3 border border-paper-edge rounded-sm">
<QRCodeSVG value={url} size={180} bgColor="#F5F0E6" fgColor="#1A1714" />
<div className="flex justify-center">
<div className="bg-white p-3 rounded-xl">
<QRCodeSVG value={url} size={180} />
</div>
</div>
<div className="text-center space-y-2">
<p className="font-mono text-3xl font-medium text-signal tracking-[0.3em]">
<div className="space-y-2">
<p className="text-3xl font-mono font-bold text-brand-400 tracking-widest">
{code.toUpperCase()}
</p>
<button
onClick={copyToClipboard}
className="text-xs text-ink-muted hover:text-ink transition-colors
font-mono break-all"
className="text-sm text-slate-400 hover:text-white transition-colors
underline underline-offset-4 decoration-slate-600"
>
{copied ? "Copied ✓" : url}
{url}
</button>
</div>
<p className="text-xs text-ink-muted mt-5 leading-relaxed">
Anyone with this code or link can send you files. Expires in 10 minutes.
<p className="text-xs text-slate-500">
Partagez ce lien pour recevoir des fichiers de n'importe qui.
Expire dans 10 minutes.
</p>
</>
)}
<button
onClick={onClose}
className="mt-6 w-full py-2.5 border border-paper-edge hover:border-ink
text-sm text-ink rounded-sm transition-colors duration-fast ease-crisp"
className="px-6 py-2 bg-slate-800 hover:bg-slate-700 rounded-xl
text-sm text-slate-300 transition-colors"
>
Close
Fermer
</button>
</div>
</div>

View File

@ -1,10 +1,10 @@
import type { IncomingRequest } from "../stores/useStore";
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(1)} GB`;
if (bytes < 1024) return `${bytes} o`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} Go`;
}
interface ReceiveDialogProps {
@ -15,64 +15,57 @@ interface ReceiveDialogProps {
export default function ReceiveDialog({ request, onAccept, onReject }: ReceiveDialogProps) {
const totalSize = request.files.reduce((sum, f) => sum + f.size, 0);
const isTextOnly = request.text && request.files.length === 0;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4">
<div className="paper-panel shadow-lift rounded-sm p-6 w-full max-w-md">
<div className="text-xs uppercase tracking-[0.2em] text-ink-muted">
Incoming
</div>
<h2 className="font-display text-2xl text-ink mt-1">
{isTextOnly ? "Text received" : "Transfer request"}
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-slate-900 border border-slate-700 rounded-2xl p-6 w-full max-w-md shadow-2xl">
<h2 className="text-lg font-semibold text-white mb-2">
Transfert entrant
</h2>
<p className="text-sm text-ink-muted mt-2">
From <span className="text-ink">{request.displayName}</span>
<p className="text-sm text-slate-400 mb-4">
<span className="text-brand-400 font-medium">{request.displayName}</span> veut vous envoyer :
</p>
{request.files.length > 0 && (
<div className="mt-5 divide-y divide-paper-edge border-t border-b border-paper-edge max-h-40 overflow-y-auto">
<div className="bg-slate-800/50 rounded-xl p-3 mb-4 space-y-2 max-h-40 overflow-y-auto">
{request.files.map((file) => (
<div key={file.id} className="py-2.5 flex items-center justify-between text-sm">
<span className="text-ink truncate mr-3">{file.name}</span>
<span className="font-mono text-xs text-ink-faint whitespace-nowrap">
{formatSize(file.size)}
</span>
<div key={file.id} className="flex items-center justify-between text-sm">
<span className="text-white truncate mr-2">{file.name}</span>
<span className="text-slate-500 text-xs whitespace-nowrap">{formatSize(file.size)}</span>
</div>
))}
{request.files.length > 1 && (
<div className="py-2 flex justify-between text-xs">
<span className="text-ink-muted">{request.files.length} files</span>
<span className="font-mono text-ink-muted">{formatSize(totalSize)}</span>
<div className="border-t border-slate-700 pt-2 flex justify-between text-xs text-slate-400">
<span>{request.files.length} fichiers</span>
<span>{formatSize(totalSize)}</span>
</div>
)}
</div>
)}
{request.text && (
<div className="mt-5 bg-paper-deep border border-paper-edge rounded-sm p-3">
<div className="text-xs uppercase tracking-[0.2em] text-ink-muted mb-1.5">Text</div>
<p className="text-sm text-ink whitespace-pre-wrap break-words max-h-32 overflow-y-auto">
<div className="bg-slate-800/50 rounded-xl p-3 mb-4">
<p className="text-xs text-slate-500 mb-1">Texte :</p>
<p className="text-sm text-white whitespace-pre-wrap break-words max-h-32 overflow-y-auto">
{request.text}
</p>
</div>
)}
<div className="flex gap-3 mt-6">
<div className="flex gap-3">
<button
onClick={onReject}
className="flex-1 px-4 py-2.5 border border-paper-edge text-ink-muted
hover:text-ink hover:border-ink rounded-sm text-sm font-medium
transition-colors duration-fast ease-crisp"
className="flex-1 px-4 py-2.5 border border-slate-600 text-slate-300
hover:bg-slate-800 rounded-xl text-sm font-medium transition-colors"
>
Decline
Refuser
</button>
<button
onClick={onAccept}
className="flex-1 px-4 py-2.5 bg-ink text-paper rounded-sm text-sm font-medium
hover:bg-signal transition-colors duration-fast ease-crisp"
className="flex-1 px-4 py-2.5 bg-brand-600 hover:bg-brand-500
text-white rounded-xl text-sm font-medium transition-colors"
>
{isTextOnly ? "Copy" : "Accept"}
{request.text && request.files.length === 0 ? "Copier" : "Accepter"}
</button>
</div>
</div>

View File

@ -17,45 +17,35 @@ export default function TextShareModal({ onSend, onClose }: TextShareModalProps)
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4"
onClick={onClose}
>
<div
className="paper-panel shadow-lift rounded-sm p-6 w-full max-w-md"
onClick={(e) => e.stopPropagation()}
>
<div className="text-xs uppercase tracking-[0.2em] text-ink-muted">
Compose
</div>
<h2 className="font-display text-2xl text-ink mt-1 mb-5">Send text</h2>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-slate-900 border border-slate-700 rounded-2xl p-6 w-full max-w-md shadow-2xl">
<h2 className="text-lg font-semibold text-white mb-4">Envoyer du texte</h2>
<form onSubmit={handleSubmit}>
<textarea
autoFocus
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Message, link, snippet…"
className="w-full h-32 bg-paper border border-paper-edge rounded-sm p-3
text-sm text-ink placeholder:text-ink-faint resize-none
focus:outline-none focus:border-ink transition-colors
duration-fast ease-crisp"
placeholder="Tapez votre message, lien, ou texte..."
className="w-full h-32 bg-slate-800 border border-slate-600 rounded-xl p-3 text-white
placeholder-slate-500 resize-none focus:outline-none focus:ring-2
focus:ring-brand-500 focus:border-transparent"
/>
<div className="flex justify-end gap-3 mt-5">
<div className="flex justify-end gap-3 mt-4">
<button
type="button"
onClick={onClose}
className="px-3 py-2 text-sm text-ink-muted hover:text-ink transition-colors"
className="px-4 py-2 text-sm text-slate-400 hover:text-white transition-colors"
>
Cancel
Annuler
</button>
<button
type="submit"
disabled={!text.trim()}
className="px-5 py-2 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"
className="px-6 py-2 bg-brand-600 hover:bg-brand-500 disabled:opacity-50
disabled:cursor-not-allowed text-white text-sm font-medium
rounded-xl transition-colors"
>
Send
Envoyer
</button>
</div>
</form>

View File

@ -1,65 +1,64 @@
import { useStore } from "../stores/useStore";
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(1)} GB`;
if (bytes < 1024) return `${bytes} o`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} Go`;
}
export default function TransferProgress() {
const transfers = useStore((s) => s.transfers);
const removeTransfer = useStore((s) => s.removeTransfer);
if (transfers.length === 0) {
return (
<div className="mt-4 text-sm text-ink-muted">
No transfers yet.
</div>
);
}
const activeTransfers = transfers.filter((t) => t.status !== "done" || Date.now() < Date.now()); // Show all for now
if (activeTransfers.length === 0) return null;
return (
<div className="mt-4 divide-y divide-paper-edge border-t border-b border-paper-edge">
{transfers.map((transfer) => (
<div className="space-y-2">
<h3 className="text-sm font-medium text-slate-400 uppercase tracking-wider">
Transferts
</h3>
{activeTransfers.map((transfer) => (
<div
key={transfer.id}
className="py-3 flex items-center gap-4"
className="bg-slate-800/50 rounded-xl p-4 flex items-center gap-4"
>
<span className="font-mono text-[10px] uppercase tracking-[0.2em] text-ink-muted w-12 shrink-0">
{transfer.direction === "send" ? "Out" : "In"}
</span>
<div className="text-2xl">
{transfer.direction === "send" ? "📤" : "📥"}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-ink truncate">{transfer.fileName}</p>
<p className="text-xs text-ink-faint mt-0.5">{formatSize(transfer.fileSize)}</p>
<p className="text-sm text-white truncate">{transfer.fileName}</p>
<p className="text-xs text-slate-500">{formatSize(transfer.fileSize)}</p>
{transfer.status === "transferring" && (
<div className="mt-2 h-px bg-paper-edge overflow-hidden">
<div className="mt-2 h-1.5 bg-slate-700 rounded-full overflow-hidden">
<div
className="h-full bg-signal transition-all duration-300"
className="h-full bg-brand-500 rounded-full transition-all duration-300"
style={{ width: `${Math.round(transfer.progress * 100)}%` }}
/>
</div>
)}
</div>
<div className="text-right w-20 shrink-0">
<div className="text-right">
{transfer.status === "pending" && (
<span className="text-xs text-warn">Waiting</span>
<span className="text-xs text-yellow-400">En attente</span>
)}
{transfer.status === "transferring" && (
<span className="font-mono text-xs text-signal">
<span className="text-xs text-brand-400">
{Math.round(transfer.progress * 100)}%
</span>
)}
{transfer.status === "done" && (
<button
onClick={() => removeTransfer(transfer.id)}
className="text-xs text-ok hover:text-ink transition-colors"
className="text-xs text-green-400 hover:text-green-300"
>
Done
Terminé
</button>
)}
{transfer.status === "error" && (
<span className="text-xs text-fail">Failed</span>
<span className="text-xs text-red-400">Erreur</span>
)}
</div>
</div>

View File

@ -1,44 +0,0 @@
/**
* AnyDrop design tokens Paper & Envelope direction.
*
* Principles:
* - Paper-warm neutrals + ink black, never slate/grey-blue.
* - ONE signature accent (oxblood). Use sparingly accent earns its rarity.
* - Serif display + neutral sans body. Type does the heavy lifting, not color.
* - Shadows are textural, not dramatic. Radii stay sharp (26px).
* - Motion curves favor paper physics (ease-out-expo), never bounce.
*/
export const color = {
paper: "#F5F0E6",
paperDeep: "#EBE4D4",
paperEdge: "#DCD3BE",
ink: "#1A1714",
inkMuted: "#6B635A",
inkFaint: "#A89F93",
signal: "#7A2320",
signalQuiet: "#F3E2E0",
ok: "#3E6B4A",
warn: "#8B6914",
fail: "#8A3324",
} as const;
export const font = {
display: `"Fraunces", "GT Sectra", Georgia, serif`,
sans: `"Inter", "Söhne", system-ui, -apple-system, sans-serif`,
mono: `"JetBrains Mono", "Berkeley Mono", ui-monospace, monospace`,
} as const;
export const motion = {
fast: "160ms cubic-bezier(0.2, 0.0, 0.0, 1.0)",
base: "320ms cubic-bezier(0.2, 0.0, 0.0, 1.0)",
paper: "480ms cubic-bezier(0.16, 1, 0.3, 1)",
} as const;
export const shadow = {
paper:
"0 1px 0 rgba(26, 23, 20, 0.04), 0 1px 3px rgba(26, 23, 20, 0.06)",
lift:
"0 2px 6px rgba(26, 23, 20, 0.08), 0 8px 24px rgba(26, 23, 20, 0.08)",
} as const;

View File

@ -1,77 +1,9 @@
@import url("https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap");
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
color-scheme: light;
}
html,
body {
background: #f5f0e6;
color: #1a1714;
font-family: "Inter", "Söhne", system-ui, -apple-system, sans-serif;
font-feature-settings: "ss01", "cv11";
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
/* Paper texture — very subtle noise, 2% opacity */
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='200' height='200'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.1 0 0 0 0 0.09 0 0 0 0 0.08 0 0 0 0.55 0'/></filter><rect width='100%' height='100%' filter='url(%23n)' opacity='0.18'/></svg>");
mix-blend-mode: multiply;
opacity: 0.35;
}
#root {
position: relative;
z-index: 1;
min-height: 100vh;
}
h1,
h2,
h3,
h4 {
font-family: "Fraunces", "GT Sectra", Georgia, serif;
font-weight: 500;
letter-spacing: -0.01em;
}
code,
pre,
.mono {
font-family: "JetBrains Mono", "Berkeley Mono", ui-monospace, monospace;
}
/* Selection: signal on quiet paper */
::selection {
background: #7a2320;
color: #f5f0e6;
}
}
@layer utilities {
/* Thin ruled line — evokes paper stationery */
.rule {
border-bottom: 1px solid #dcd3be;
}
.rule-strong {
border-bottom: 1px solid #1a1714;
}
/* Envelope: subtle diagonal flap hint used on panels */
.paper-panel {
background: #f5f0e6;
border: 1px solid #dcd3be;
box-shadow:
0 1px 0 rgba(26, 23, 20, 0.04),
0 1px 3px rgba(26, 23, 20, 0.06);
}
.paper-panel-deep {
background: #ebe4d4;
border: 1px solid #dcd3be;
@apply min-h-screen;
}
}

View File

@ -67,82 +67,3 @@ export async function unlinkDevice(id: string): Promise<void> {
export async function signOut(): Promise<void> {
await call("/api/auth/logout", { method: "POST" });
}
export interface CreateTransferResponse {
transferId: string;
uploadUrl: string;
expiresAt: string;
}
export interface TransferHead {
transferId: string;
encryptedMetadata: string;
sizeBytes: number;
maxDownloads: number;
downloadCount: number;
expiresAt: string;
}
export interface InboxTransfer {
id: string;
sizeBytes: number;
encryptedMetadata: string;
createdAt: string;
expiresAt: string;
maxDownloads: number;
downloadCount: number;
firstDownloadAt: string | null;
senderUserId: string | null;
recipientUserId: string | null;
direction: "sent" | "received";
}
export async function createTransfer(input: {
sizeBytes: number;
encryptedMetadata: string;
recipientEmail?: string;
maxDownloads?: number;
expiresInDays?: number;
deviceId?: string;
}): Promise<CreateTransferResponse> {
const res = await call("/api/transfers", {
method: "POST",
body: JSON.stringify(input),
headers: input.deviceId ? { "X-Device-Id": input.deviceId } : {},
});
if (!res.ok) throw new Error(`createTransfer failed: ${res.status}`);
return (await res.json()) as CreateTransferResponse;
}
export async function getTransferHead(id: string): Promise<TransferHead> {
const res = await call(`/api/transfers/${encodeURIComponent(id)}`);
if (res.status === 404) throw new Error("transfer_not_found");
if (res.status === 410) {
const body = await res.json().catch(() => ({}));
throw new Error((body as { error?: string }).error ?? "transfer_gone");
}
if (!res.ok) throw new Error(`getTransferHead failed: ${res.status}`);
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" });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error((body as { error?: string }).error ?? `consume failed: ${res.status}`);
}
return (await res.json()) as { downloadUrl: string };
}
export async function listInboxTransfers(): Promise<InboxTransfer[]> {
const res = await call("/api/transfers");
if (res.status === 401) return [];
if (!res.ok) throw new Error(`listTransfers failed: ${res.status}`);
const body = (await res.json()) as { transfers: InboxTransfer[] };
return body.transfers;
}
export async function deleteTransfer(id: string): Promise<void> {
const res = await call(`/api/transfers/${encodeURIComponent(id)}`, { method: "DELETE" });
if (!res.ok && res.status !== 204) throw new Error(`deleteTransfer failed: ${res.status}`);
}

View File

@ -1,132 +0,0 @@
import { xchacha20poly1305 } from "@noble/ciphers/chacha.js";
import { randomBytes } from "@noble/ciphers/utils.js";
/**
* Client-side encryption for cloud relay transfers.
*
* Threat model: the server (AnyDrop backend + MinIO) is honest-but-curious.
* It stores ciphertext and serves it on demand via presigned URLs, but must
* never be able to read filenames, mime types, or file content.
*
* Construction:
* - One random 32-byte key per transfer (XChaCha20-Poly1305).
* - The key lives in the URL fragment (#k=<base64url>), so browsers never
* send it to our server (fragments are not part of HTTP requests).
* - File content sealed with nonce_1; metadata (JSON {name, mime, size})
* sealed with nonce_2 both under the same key.
*
* Why XChaCha20-Poly1305:
* - 24-byte nonce is safe to randomize (birthday collisions negligible).
* - Fast in JS. Single AEAD primitive covers confidentiality + integrity.
*/
const NONCE_BYTES = 24;
export interface EncryptedBlob {
/** base64url of nonce || ciphertext || tag (as produced by xchacha20poly1305) */
ciphertext: Uint8Array;
nonce: Uint8Array;
}
export interface TransferMetadata {
name: string;
mime: string;
size: number;
}
export interface SealedTransfer {
key: Uint8Array;
encryptedBody: Uint8Array;
encryptedMetadata: string;
}
function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
const out = new Uint8Array(a.length + b.length);
out.set(a, 0);
out.set(b, a.length);
return out;
}
function bytesToB64Url(b: Uint8Array): string {
let s = "";
for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function b64UrlToBytes(s: string): Uint8Array {
const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4));
const raw = atob(s.replace(/-/g, "+").replace(/_/g, "/") + pad);
const out = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
}
export function generateTransferKey(): Uint8Array {
return randomBytes(32);
}
export function keyToFragment(key: Uint8Array): string {
return bytesToB64Url(key);
}
export function fragmentToKey(fragment: string): Uint8Array {
const key = b64UrlToBytes(fragment);
if (key.length !== 32) throw new Error("invalid transfer key");
return key;
}
function sealBlob(key: Uint8Array, plaintext: Uint8Array): Uint8Array {
const nonce = randomBytes(NONCE_BYTES);
const cipher = xchacha20poly1305(key, nonce);
const ct = cipher.encrypt(plaintext);
return concat(nonce, ct);
}
function openBlob(key: Uint8Array, sealed: Uint8Array): Uint8Array {
if (sealed.length < NONCE_BYTES + 16) throw new Error("ciphertext too short");
const nonce = sealed.subarray(0, NONCE_BYTES);
const ct = sealed.subarray(NONCE_BYTES);
const cipher = xchacha20poly1305(key, nonce);
return cipher.decrypt(ct);
}
/**
* Encrypts file body and metadata under the same fresh key.
* Returns everything the caller needs:
* - key: put into URL fragment for the recipient
* - encryptedBody: upload to MinIO via presigned PUT
* - encryptedMetadata: base64url string to send with the POST /api/transfers request
*/
export async function sealFile(file: File): Promise<SealedTransfer> {
const key = generateTransferKey();
const buf = new Uint8Array(await file.arrayBuffer());
const encryptedBody = sealBlob(key, buf);
const metadata: TransferMetadata = {
name: file.name,
mime: file.type || "application/octet-stream",
size: file.size,
};
const metadataBytes = new TextEncoder().encode(JSON.stringify(metadata));
const encryptedMetadata = bytesToB64Url(sealBlob(key, metadataBytes));
return { key, encryptedBody, encryptedMetadata };
}
export function openMetadata(
key: Uint8Array,
encryptedMetadataB64: string,
): TransferMetadata {
const sealed = b64UrlToBytes(encryptedMetadataB64);
const plaintext = openBlob(key, sealed);
return JSON.parse(new TextDecoder().decode(plaintext));
}
export function openFile(
key: Uint8Array,
encryptedBody: Uint8Array,
metadata: TransferMetadata,
): File {
const plaintext = openBlob(key, encryptedBody);
return new File([plaintext as BlobPart], metadata.name, { type: metadata.mime });
}

View File

@ -137,7 +137,7 @@ export function createFileReceiver(callbacks: FileReceiver) {
case "file-end": {
const file = receiving.get(msg.id);
if (file) {
const blob = new Blob(file.chunks as BlobPart[], { type: file.mime });
const blob = new Blob(file.chunks, { type: file.mime });
callbacks.onComplete(msg.id, blob, file.name);
receiving.delete(msg.id);
}

View File

@ -33,7 +33,7 @@ export async function setupPushNotifications(
const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey);
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey as BufferSource,
applicationServerKey,
});
}

View File

@ -1,156 +0,0 @@
import {
sealFile,
openMetadata,
openFile,
keyToFragment,
fragmentToKey,
type TransferMetadata,
} from "./cloudTransfer";
import {
createTransfer,
consumeTransfer,
getTransferHead,
type TransferHead,
} from "./api";
export interface SendCloudOptions {
recipientEmail?: string;
expiresInDays?: number;
maxDownloads?: number;
deviceId?: string;
onProgress?: (loaded: number, total: number) => void;
}
export interface SendCloudResult {
transferId: string;
shareUrl: string;
expiresAt: string;
}
/**
* End-to-end cloud send:
* 1. Encrypt the file + metadata locally under a fresh random key.
* 2. Register the transfer with the server sending only ciphertext
* metadata and ciphertext size. Receive a presigned PUT URL.
* 3. Upload ciphertext directly to MinIO (the server never sees it).
* 4. Return a share URL with the key in the fragment.
*/
export async function sendCloud(
file: File,
options: SendCloudOptions = {},
): Promise<SendCloudResult> {
const { key, encryptedBody, encryptedMetadata } = await sealFile(file);
const created = await createTransfer({
sizeBytes: encryptedBody.length,
encryptedMetadata,
recipientEmail: options.recipientEmail,
maxDownloads: options.maxDownloads,
expiresInDays: options.expiresInDays,
deviceId: options.deviceId,
});
await uploadWithProgress(created.uploadUrl, encryptedBody, options.onProgress);
const origin =
typeof window === "undefined" ? "https://anydrop.arthurbarre.fr" : window.location.origin;
const shareUrl = `${origin}/r/${created.transferId}#k=${keyToFragment(key)}`;
return {
transferId: created.transferId,
shareUrl,
expiresAt: created.expiresAt,
};
}
function uploadWithProgress(
url: string,
body: Uint8Array,
onProgress?: (loaded: number, total: number) => void,
): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", url);
xhr.setRequestHeader("Content-Type", "application/octet-stream");
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total);
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve();
else reject(new Error(`upload failed: ${xhr.status}`));
};
xhr.onerror = () => reject(new Error("upload network error"));
xhr.send(body as BlobPart);
});
}
export interface ReceivedTransferPreview {
head: TransferHead;
metadata: TransferMetadata;
}
export function parseKeyFromLocation(): Uint8Array | null {
if (typeof window === "undefined") return null;
const hash = window.location.hash;
if (!hash.startsWith("#")) return null;
const params = new URLSearchParams(hash.slice(1));
const k = params.get("k");
if (!k) return null;
try {
return fragmentToKey(k);
} catch {
return null;
}
}
/**
* Fetch (but don't consume) the transfer metadata lets the recipient
* UI render "you're about to accept X MB from sender Y" before committing
* a download slot.
*/
export async function previewTransfer(
transferId: string,
key: Uint8Array,
): Promise<ReceivedTransferPreview> {
const head = await getTransferHead(transferId);
const metadata = openMetadata(key, head.encryptedMetadata);
return { head, metadata };
}
/**
* Claim a download slot, fetch the ciphertext, decrypt, return a File.
*/
export async function receiveCloud(
transferId: string,
key: Uint8Array,
metadata: TransferMetadata,
onProgress?: (loaded: number, total: number) => void,
): Promise<File> {
const { downloadUrl } = await consumeTransfer(transferId);
const ciphertext = await downloadWithProgress(downloadUrl, onProgress);
return openFile(key, ciphertext, metadata);
}
function downloadWithProgress(
url: string,
onProgress?: (loaded: number, total: number) => void,
): Promise<Uint8Array> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("GET", url);
xhr.responseType = "arraybuffer";
xhr.onprogress = (e) => {
if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total);
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(new Uint8Array(xhr.response as ArrayBuffer));
} else {
reject(new Error(`download failed: ${xhr.status}`));
}
};
xhr.onerror = () => reject(new Error("download network error"));
xhr.send();
});
}

View File

@ -128,7 +128,7 @@ export async function appendReceivedChunk(
const store = await tx(STORE_CHUNKS, "readwrite");
const existing = await reqToPromise(store.get(transferId));
const oldBlob: Blob = existing?.blob ?? new Blob();
const newBlob = new Blob([oldBlob, chunk as BlobPart]);
const newBlob = new Blob([oldBlob, chunk]);
await reqToPromise(store.put({ transferId, blob: newBlob }));
return newBlob.size;
}

View File

@ -1,5 +1,4 @@
import { useCallback, useState } from "react";
import { Link } from "react-router-dom";
import { useSignaling } from "../hooks/useSignaling";
import { useStore } from "../stores/useStore";
import { useProfileStore } from "../stores/useProfileStore";
@ -11,7 +10,6 @@ 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";
export default function Home() {
const isSetUp = useProfileStore((s) => s.isSetUp);
@ -24,16 +22,8 @@ export default function Home() {
}
function HomeConnected() {
const {
sendFiles,
sendText,
acceptTransfer,
rejectTransfer,
createPublicRoom,
wakePeer,
requestPairCode,
resolvePairCode,
} = useSignaling();
const { sendFiles, sendText, acceptTransfer, rejectTransfer, createPublicRoom, wakePeer, requestPairCode, resolvePairCode } =
useSignaling();
const peers = useStore((s) => s.peers);
const selectedPeerId = useStore((s) => s.selectedPeerId);
@ -46,10 +36,11 @@ function HomeConnected() {
const { deviceName, avatar } = useProfileStore();
const [showProfileEdit, setShowProfileEdit] = useState(false);
const [, setWakingDeviceId] = useState<string | null>(null);
const [wakingDeviceId, setWakingDeviceId] = useState<string | null>(null);
const handlePeerSelect = useCallback(
(peerId: string) => {
// Check if this is an offline peer
const peer = peers.find((p) => p.peerId === peerId);
if (peer && peer.online === false && peer.deviceId) {
setSelectedPeerId(peerId);
@ -96,108 +87,82 @@ function HomeConnected() {
}, [incomingRequest, rejectTransfer]);
return (
<div className="min-h-screen">
<div className="max-w-xl mx-auto px-5 sm:px-8 pt-10 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">
AnyDrop
<div className="min-h-screen bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950">
<div className="max-w-lg mx-auto px-4 py-8">
{/* Header */}
<header className="text-center mb-8">
<h1 className="text-3xl font-bold text-white mb-1">
Any<span className="text-brand-400">Drop</span>
</h1>
<p className="mt-3 text-xs uppercase tracking-[0.2em] text-ink-muted">
Universal transfer · Peer to peer
</p>
</div>
<DeviceChip
name={deviceName}
avatar={avatar}
onEdit={() => setShowProfileEdit(true)}
/>
<p className="text-slate-500 text-sm">Partage instantané, sans compte</p>
{/* Profile badge — tap to edit */}
<button
onClick={() => setShowProfileEdit(true)}
className="mt-3 inline-flex items-center gap-2 px-3 py-1.5 rounded-full
bg-slate-800/50 hover:bg-slate-800 transition-colors group"
>
{avatar ? (
<img src={avatar} alt="" className="w-5 h-5 rounded-full object-cover" />
) : (
<span className="text-sm">📱</span>
)}
<span className="text-sm text-slate-400 group-hover:text-white transition-colors">
{deviceName}
</span>
<span className="text-[10px] text-slate-600"></span>
</button>
</header>
{/* Error */}
{/* Error banner */}
{error && (
<div className="mb-8 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)}
className="ml-3 text-ink-muted hover:text-ink text-lg leading-none"
aria-label="Dismiss"
>
×
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/30 rounded-xl text-sm text-red-400 flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-300 ml-2">
</button>
</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">
{/* 1. Appareils disponibles */}
<section className="mb-6">
<PeerList onPeerSelect={handlePeerSelect} />
</div>
<div className="mt-6 grid grid-cols-1 sm:grid-cols-2 gap-3">
<DevicePairingPanel
onRequestCode={requestPairCode}
onResolveCode={resolvePairCode}
/>
<PublicRoomPanel onCreateRoom={createPublicRoom} />
</div>
</section>
{/* Composer — appears when a peer is selected */}
{selectedPeerId && (
<section className="mb-14">
<SectionLabel>Compose</SectionLabel>
<SectionTitle>Drop files or send text</SectionTitle>
{/* 2. Appairage + Lien public */}
<section className="mb-4 flex gap-3">
<DevicePairingPanel onRequestCode={requestPairCode} onResolveCode={resolvePairCode} />
<PublicRoomPanel onCreateRoom={createPublicRoom} />
</section>
<div className="mt-6 space-y-3">
{/* 3. Envoi fichiers + texte (seulement quand un peer est sélectionné) */}
{selectedPeerId && (
<section className="mb-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"
className="w-full flex items-center justify-center gap-2 px-4 py-3
border border-slate-700 hover:border-brand-500 rounded-xl
text-slate-300 hover:text-white transition-all
bg-slate-900/30 hover:bg-slate-900/50"
>
Send text instead
<span className="text-lg">💬</span>
<span className="text-sm font-medium">Envoyer du texte</span>
</button>
</div>
</section>
)}
{/* Activity */}
<section className="mb-14">
<SectionLabel>Activity</SectionLabel>
{/* 4. Transferts en cours */}
<section className="mb-6">
<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>
</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="text-center text-xs text-slate-600 mt-12">
<p>Peer-to-peer · Chiffré · Aucun fichier ne transite par le serveur</p>
<a href="/settings" className="inline-block mt-2 text-slate-700 hover:text-slate-500 transition-colors">
Account
</a>
</footer>
</div>
@ -224,65 +189,3 @@ function HomeConnected() {
</div>
);
}
function DeviceChip({
name,
avatar,
onEdit,
}: {
name: string;
avatar: string | null;
onEdit: () => void;
}) {
return (
<button
onClick={onEdit}
className="group flex items-center gap-2.5 px-3 py-2 border border-paper-edge
hover:border-ink transition-colors duration-fast ease-crisp
bg-paper rounded-sm"
>
{avatar ? (
<img
src={avatar}
alt=""
className="w-5 h-5 rounded-full object-cover border border-paper-edge"
/>
) : (
<span
className="w-5 h-5 rounded-full bg-paper-deep border border-paper-edge
flex items-center justify-center text-[10px] text-ink-muted"
>
</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>
</button>
);
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
{children}
</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

@ -1,226 +0,0 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useAuthStore } from "../stores/useAuthStore";
import {
listInboxTransfers,
deleteTransfer,
type InboxTransfer,
} from "../lib/api";
type Tab = "received" | "sent";
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`;
}
function formatRelative(iso: string): string {
const diffMs = new Date(iso).getTime() - Date.now();
const hours = Math.round(diffMs / (60 * 60 * 1000));
if (hours < 0) return "expired";
if (hours < 1) return "under 1h";
if (hours < 24) return `${hours}h`;
return `${Math.round(hours / 24)}d`;
}
export default function Inbox() {
const user = useAuthStore((s) => s.user);
const loaded = useAuthStore((s) => s.loaded);
const loadUser = useAuthStore((s) => s.loadUser);
const [transfers, setTransfers] = useState<InboxTransfer[] | null>(null);
const [tab, setTab] = useState<Tab>("received");
useEffect(() => {
loadUser();
}, [loadUser]);
useEffect(() => {
if (!user) return;
listInboxTransfers().then(setTransfers).catch(() => setTransfers([]));
}, [user]);
return (
<Shell>
{!loaded && <p className="text-sm text-ink-muted">Loading</p>}
{loaded && !user && (
<div className="paper-panel px-6 py-6">
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
Sign in required
</div>
<h2 className="font-display text-2xl text-ink mt-2 mb-3">
Your inbox is tied to an account
</h2>
<p className="text-sm text-ink-muted leading-relaxed mb-5">
Transfers addressed to your email appear here. Anonymous transfers live
only in their share link.
</p>
<Link
to="/settings"
className="inline-block bg-ink text-paper text-sm font-medium rounded-sm
px-4 py-2.5 hover:bg-signal transition-colors duration-fast ease-crisp"
>
Go to Account
</Link>
</div>
)}
{loaded && user && (
<>
<div className="flex gap-6 pb-4 mb-6 rule">
<TabButton active={tab === "received"} onClick={() => setTab("received")}>
Received
</TabButton>
<TabButton active={tab === "sent"} onClick={() => setTab("sent")}>
Sent
</TabButton>
</div>
{transfers === null && (
<p className="text-sm text-ink-muted">Loading transfers</p>
)}
{transfers && (
<TransferList
items={transfers.filter((t) => t.direction === tab)}
direction={tab}
onDelete={async (id) => {
await deleteTransfer(id);
setTransfers((prev) => prev?.filter((t) => t.id !== id) ?? null);
}}
/>
)}
</>
)}
</Shell>
);
}
function TransferList({
items,
direction,
onDelete,
}: {
items: InboxTransfer[];
direction: Tab;
onDelete: (id: string) => void | Promise<void>;
}) {
if (items.length === 0) {
return (
<div className="paper-panel px-6 py-10 text-center">
<div className="w-10 h-10 mx-auto mb-4 rounded-full border border-paper-edge flex items-center justify-center">
<span className="w-2 h-2 rounded-full bg-paper-edge" />
</div>
<p className="font-display text-xl text-ink mb-2">
{direction === "received" ? "Nothing in your inbox yet" : "No outbound transfers"}
</p>
<p className="text-sm text-ink-muted leading-relaxed max-w-xs mx-auto">
{direction === "received"
? "Transfers sent to your email will show up here."
: "Files you send via AnyDrop will be listed here."}
</p>
</div>
);
}
return (
<ul className="paper-panel divide-y divide-paper-edge">
{items.map((t) => (
<TransferRow key={t.id} transfer={t} onDelete={onDelete} />
))}
</ul>
);
}
function TransferRow({
transfer,
onDelete,
}: {
transfer: InboxTransfer;
onDelete: (id: string) => void | Promise<void>;
}) {
const remaining = transfer.maxDownloads - transfer.downloadCount;
const isExhausted = remaining <= 0;
const isExpired = new Date(transfer.expiresAt).getTime() < Date.now();
const unavailable = isExhausted || isExpired;
return (
<li className="px-5 py-4 flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-mono text-[10px] uppercase tracking-[0.2em] text-ink-muted">
{transfer.direction === "sent" ? "Outbound" : "Inbound"}
</span>
{unavailable && (
<span className="font-mono text-[10px] uppercase tracking-[0.2em] text-ink-faint">
· {isExpired ? "expired" : "consumed"}
</span>
)}
</div>
<div className="text-sm text-ink">
Sealed transfer
<span className="ml-2 font-mono text-xs text-ink-muted">
{formatSize(transfer.sizeBytes)}
</span>
</div>
<div className="mt-1 font-mono text-[11px] text-ink-faint uppercase tracking-widest">
{transfer.downloadCount}/{transfer.maxDownloads} downloads
{!unavailable && ` · expires in ${formatRelative(transfer.expiresAt)}`}
{transfer.firstDownloadAt && ` · first opened ${new Date(transfer.firstDownloadAt).toLocaleDateString()}`}
</div>
</div>
<button
onClick={() => onDelete(transfer.id)}
className="shrink-0 text-xs text-ink-muted hover:text-signal transition-colors"
>
Delete
</button>
</li>
);
}
function TabButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
className={`text-sm pb-1 -mb-[1px] border-b-2 transition-colors duration-fast ease-crisp ${
active
? "border-ink text-ink"
: "border-transparent text-ink-muted hover:text-ink"
}`}
>
{children}
</button>
);
}
function Shell({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen">
<div className="max-w-xl mx-auto px-5 sm:px-8 pt-10 pb-24">
<header className="flex items-center justify-between pb-8 mb-10 rule">
<Link
to="/"
className="text-sm text-ink-muted hover:text-ink transition-colors duration-fast"
>
Back
</Link>
<h1 className="font-display text-xl text-ink tracking-tight">Inbox</h1>
<div className="w-10" />
</header>
{children}
</div>
</div>
);
}

View File

@ -75,106 +75,93 @@ function JoinRoomConnected({ code }: { code?: string }) {
}, [incomingRequest, rejectTransfer]);
return (
<div className="min-h-screen">
<div className="max-w-xl mx-auto px-5 sm:px-8 pt-10 pb-24">
<header className="flex items-start justify-between pb-8 mb-10 rule">
<div>
<div className="text-xs uppercase tracking-[0.2em] text-ink-muted">
Public room
</div>
<h1 className="font-display text-3xl leading-none tracking-tight text-ink mt-2">
AnyDrop
<div className="min-h-screen bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950">
<div className="max-w-lg mx-auto px-4 py-8">
{/* Header */}
<header className="text-center mb-8">
<h1 className="text-3xl font-bold text-white mb-1">
Any<span className="text-brand-400">Drop</span>
</h1>
<p className="font-mono text-xs uppercase tracking-[0.25em] text-signal mt-3">
{code?.toUpperCase()}
<p className="text-slate-500 text-sm">
Room <span className="text-brand-300 font-mono font-bold">{code?.toUpperCase()}</span>
</p>
</div>
<button
onClick={() => setShowProfileEdit(true)}
className="group flex items-center gap-2.5 px-3 py-2 border border-paper-edge
hover:border-ink transition-colors duration-fast ease-crisp
bg-paper rounded-sm"
className="mt-3 inline-flex items-center gap-2 px-3 py-1.5 rounded-full
bg-slate-800/50 hover:bg-slate-800 transition-colors group"
>
{avatar ? (
<img
src={avatar}
alt=""
className="w-5 h-5 rounded-full object-cover border border-paper-edge"
/>
<img src={avatar} alt="" className="w-5 h-5 rounded-full object-cover" />
) : (
<span className="w-5 h-5 rounded-full bg-paper-deep border border-paper-edge" />
<span className="text-sm">📱</span>
)}
<span className="text-sm text-ink truncate max-w-[120px]">{deviceName}</span>
<span className="text-sm text-slate-400 group-hover:text-white transition-colors">
{deviceName}
</span>
<span className="text-[10px] text-slate-600"></span>
</button>
</header>
{/* Error banner */}
{error && (
<div className="mb-8 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)}
className="ml-3 text-ink-muted hover:text-ink text-lg leading-none"
aria-label="Dismiss"
>
×
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/30 rounded-xl text-sm text-red-400 flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-300 ml-2">
</button>
</div>
)}
<section className="mb-12">
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
Room peers
</div>
<h2 className="font-display text-2xl text-ink mt-2 mb-5 tracking-tight">
Who's here
</h2>
{/* Peer list */}
<section className="mb-6">
<PeerList onPeerSelect={handlePeerSelect} />
</section>
{/* Drop zone */}
<section className="mb-6">
<DropZone onFilesSelected={handleFilesSelected} disabled={!selectedPeerId} />
</section>
{/* Text share button */}
{selectedPeerId && (
<section className="mb-12">
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
Compose
</div>
<h2 className="font-display text-2xl text-ink mt-2 mb-5 tracking-tight">
Drop files or send text
</h2>
<div className="space-y-3">
<DropZone onFilesSelected={handleFilesSelected} />
<section className="mb-6">
<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"
className="w-full flex items-center justify-center gap-2 px-4 py-3
border border-slate-700 hover:border-brand-500 rounded-xl
text-slate-300 hover:text-white transition-all
bg-slate-900/30 hover:bg-slate-900/50"
>
Send text instead
<span className="text-lg">💬</span>
<span className="text-sm font-medium">Envoyer du texte</span>
</button>
</div>
</section>
)}
<section className="mb-12">
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
Activity
</div>
{/* Transfer progress */}
<section className="mb-6">
<TransferProgress />
</section>
<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>
<a
href="/"
className="text-ink hover:text-signal transition-colors duration-fast"
>
Home
{/* Back to home */}
<section className="text-center">
<a href="/" className="text-sm text-slate-500 hover:text-slate-300 transition-colors">
Retour à l'accueil
</a>
</section>
{/* Footer */}
<footer className="text-center text-xs text-slate-600 mt-12">
<p>Peer-to-peer · Chiffré · Aucun fichier ne transite par le serveur</p>
</footer>
</div>
{/* Profile edit modal */}
{showProfileEdit && (
<ProfileSetup isEditing onDone={() => setShowProfileEdit(false)} />
)}
{/* Modals */}
{showTextModal && selectedPeerId && (
<TextShareModal
onSend={handleSendText}

View File

@ -5,13 +5,14 @@ import { useProfileStore } from "../stores/useProfileStore";
export default function Pair() {
const [params] = useSearchParams();
const navigate = useNavigate();
const { setGroupId } = useProfileStore();
const { setGroupId, isSetUp } = useProfileStore();
const groupId = params.get("g");
useEffect(() => {
if (groupId) {
setGroupId(groupId);
// Small delay so user sees the confirmation
const t = setTimeout(() => navigate("/", { replace: true }), 1500);
return () => clearTimeout(t);
}
@ -19,29 +20,18 @@ export default function Pair() {
if (!groupId) {
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="paper-panel px-6 py-5 text-center">
<div className="text-xs uppercase tracking-[0.22em] text-fail">Invalid</div>
<p className="mt-2 text-sm text-ink">This pairing link is malformed.</p>
</div>
<div className="min-h-screen bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950 flex items-center justify-center">
<p className="text-slate-400">Lien d'appairage invalide.</p>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="paper-panel px-8 py-8 max-w-sm w-full text-center">
<div className="text-xs uppercase tracking-[0.22em] text-ok">Paired</div>
<h1 className="font-display text-2xl text-ink mt-2 tracking-tight">
Device linked
</h1>
<p className="text-sm text-ink-muted mt-3 leading-relaxed">
Your devices will recognize each other automatically from now on.
</p>
<p className="text-xs text-ink-faint mt-6 font-mono uppercase tracking-widest">
Redirecting
</p>
</div>
<div className="min-h-screen bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950 flex flex-col items-center justify-center gap-4">
<div className="text-5xl"></div>
<h1 className="text-xl font-bold text-white">Appareil appairé !</h1>
<p className="text-slate-400 text-sm">Vos appareils se verront automatiquement.</p>
<p className="text-slate-600 text-xs">Redirection...</p>
</div>
);
}

View File

@ -1,231 +0,0 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import {
parseKeyFromLocation,
previewTransfer,
receiveCloud,
type ReceivedTransferPreview,
} from "../lib/sendCloud";
type Stage =
| { kind: "loading" }
| { kind: "missing-key" }
| { kind: "error"; message: string }
| { kind: "preview"; preview: ReceivedTransferPreview }
| { kind: "downloading"; loaded: number; total: number }
| { kind: "done"; fileName: string };
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`;
}
function formatExpiry(iso: string): string {
const d = new Date(iso);
const hours = Math.max(0, Math.round((d.getTime() - Date.now()) / (60 * 60 * 1000)));
if (hours < 1) return "in under an hour";
if (hours < 24) return `in ${hours}h`;
const days = Math.round(hours / 24);
return `in ${days} day${days > 1 ? "s" : ""}`;
}
function triggerDownload(file: File): void {
const url = URL.createObjectURL(file);
const a = document.createElement("a");
a.href = url;
a.download = file.name;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
export default function Receive() {
const { id } = useParams<{ id: string }>();
const [stage, setStage] = useState<Stage>({ kind: "loading" });
const [key, setKey] = useState<Uint8Array | null>(null);
useEffect(() => {
const k = parseKeyFromLocation();
if (!k) {
setStage({ kind: "missing-key" });
return;
}
setKey(k);
if (!id) {
setStage({ kind: "error", message: "transfer_not_found" });
return;
}
previewTransfer(id, k)
.then((preview) => setStage({ kind: "preview", preview }))
.catch((err) => {
const msg = err instanceof Error ? err.message : "unknown";
setStage({ kind: "error", message: msg });
});
}, [id]);
const accept = async () => {
if (!id || !key || stage.kind !== "preview") 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 });
});
triggerDownload(file);
setStage({ kind: "done", fileName: stage.preview.metadata.name });
} catch (err) {
const msg = err instanceof Error ? err.message : "unknown";
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="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">
You've been sent something
</h1>
</header>
<ReceiveBody stage={stage} onAccept={accept} />
<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>
<a
href="/"
className="text-ink hover:text-signal transition-colors duration-fast"
>
AnyDrop
</a>
</footer>
</div>
</div>
);
}
function ReceiveBody({ stage, onAccept }: { stage: Stage; onAccept: () => 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="text-xs uppercase tracking-[0.22em] text-fail">Missing key</div>
<h2 className="font-display text-2xl text-ink mt-2 mb-3">
This link is incomplete
</h2>
<p className="text-sm text-ink-muted leading-relaxed">
The decryption key lives in the URL fragment (after the <code className="mono text-ink">#</code>).
It looks like it was stripped in transit. Ask the sender to share the full link again.
</p>
</div>
);
}
if (stage.kind === "error") {
const pretty =
stage.message === "transfer_not_found"
? "This transfer no longer exists."
: stage.message === "expired"
? "This transfer has expired."
: stage.message === "consumed" || stage.message === "not_available"
? "This transfer has already been downloaded."
: "Something went wrong.";
return (
<div className="paper-panel px-6 py-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>
<p className="font-mono text-xs text-ink-faint mt-3 uppercase tracking-widest">
{stage.message}
</p>
</div>
);
}
if (stage.kind === "preview") {
const { metadata, head } = stage.preview;
const remainingDownloads = 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">
{metadata.name}
</h2>
<dl className="grid grid-cols-3 gap-4 border-t border-b border-paper-edge py-4">
<div>
<dt className="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>
<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>
<dd className="font-mono text-sm text-ink mt-1">
{head.downloadCount}/{head.maxDownloads}
</dd>
</div>
</dl>
<button
onClick={onAccept}
className="mt-6 w-full py-3 bg-ink text-paper text-sm font-medium rounded-sm
hover:bg-signal transition-colors duration-fast ease-crisp"
>
Download & decrypt
</button>
<p className="mt-4 text-xs text-ink-muted leading-relaxed text-center">
{remainingDownloads === 1
? "This is the last available download."
: `${remainingDownloads} downloads remaining.`}
</p>
</div>
);
}
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="text-xs uppercase tracking-[0.22em] text-ink-muted">Downloading</div>
<h2 className="font-display text-2xl text-ink mt-2 mb-5">
Pulling the ciphertext
</h2>
<div className="h-px bg-paper-edge overflow-hidden">
<div
className="h-full bg-signal transition-all duration-200"
style={{ width: `${pct}%` }}
/>
</div>
<p className="mt-3 font-mono text-xs text-ink-muted">
{formatSize(stage.loaded)} / {formatSize(stage.total)} · {pct}%
</p>
</div>
);
}
return (
<div className="paper-panel px-6 py-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">
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.
</p>
</div>
);
}

View File

@ -37,77 +37,50 @@ export default function Settings() {
}, [user, profile.isSetUp, profile.deviceId, profile.deviceName, profile.deviceType, profile.avatar, devices, setDevices]);
if (!loaded || loading) {
return (
<SettingsShell>
<p className="text-sm text-ink-muted">Loading</p>
</SettingsShell>
);
return <SettingsShell><p className="text-slate-400">Loading</p></SettingsShell>;
}
if (!user) {
return (
<SettingsShell>
<SignInForm initialError={error} />
</SettingsShell>
);
return <SettingsShell><SignInForm initialError={error} /></SettingsShell>;
}
return (
<SettingsShell>
{signedIn && (
<div className="mb-8 px-4 py-3 border border-ok/40 bg-paper-deep rounded-sm text-sm text-ink">
<div className="mb-4 rounded-lg bg-emerald-500/10 border border-emerald-500/30 px-4 py-3 text-sm text-emerald-200">
Signed in successfully.
</div>
)}
<section className="mb-12">
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
Account
</div>
<h2 className="font-display text-2xl text-ink mt-2 mb-5 tracking-tight">
Your identity
</h2>
<div className="paper-panel px-5 py-4">
<div className="text-xs uppercase tracking-[0.15em] text-ink-muted">Email</div>
<div className="text-ink mt-1">{user.email}</div>
<div className="mt-4 pt-4 border-t border-paper-edge flex items-baseline justify-between">
<div className="text-xs uppercase tracking-[0.15em] text-ink-muted">Plan</div>
<div className="font-mono text-xs text-ink uppercase tracking-widest">
{user.plan}
</div>
</div>
<section className="mb-8">
<h2 className="text-xs uppercase tracking-wider text-slate-500 mb-2">Account</h2>
<div className="rounded-xl bg-slate-900/60 border border-slate-800 p-4">
<div className="text-sm text-slate-400">Email</div>
<div className="text-slate-100">{user.email}</div>
<div className="mt-3 text-xs text-slate-500">Plan: <span className="text-slate-300">{user.plan}</span></div>
</div>
</section>
<section className="mb-12">
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
Devices
</div>
<h2 className="font-display text-2xl text-ink mt-2 mb-5 tracking-tight">
Linked devices
</h2>
<div className="paper-panel divide-y divide-paper-edge">
<section className="mb-8">
<h2 className="text-xs uppercase tracking-wider text-slate-500 mb-2">Devices</h2>
<div className="rounded-xl bg-slate-900/60 border border-slate-800 divide-y divide-slate-800">
{devices.length === 0 && (
<div className="px-5 py-6 text-sm text-ink-muted">No devices linked yet.</div>
<div className="px-4 py-6 text-sm text-slate-500">No devices linked yet.</div>
)}
{devices.map((d) => {
const isCurrent = d.deviceId === profile.deviceId;
return (
<div key={d.id} className="flex items-center justify-between px-5 py-4">
<div key={d.id} className="flex items-center justify-between px-4 py-3">
<div>
<div className="text-ink flex items-center gap-3">
<span>{d.name}</span>
<div className="text-slate-100 flex items-center gap-2">
{d.name}
{isCurrent && (
<span className="font-mono text-[10px] uppercase tracking-[0.2em]
text-signal border border-signal/40 bg-signal-quiet
px-1.5 py-0.5 rounded-sm">
<span className="text-[10px] uppercase tracking-wider bg-indigo-500/20 text-indigo-300 px-1.5 py-0.5 rounded">
this device
</span>
)}
</div>
<div className="text-xs text-ink-muted mt-1">
{d.type} · linked {new Date(d.linkedAt).toLocaleDateString()}
</div>
<div className="text-xs text-slate-500">{d.type} · linked {new Date(d.linkedAt).toLocaleDateString()}</div>
</div>
{!isCurrent && (
<button
@ -115,9 +88,9 @@ export default function Settings() {
await unlinkDevice(d.id);
setDevices(devices.filter((x) => x.id !== d.id));
}}
className="text-xs text-ink-muted hover:text-signal transition-colors"
className="text-xs text-slate-400 hover:text-rose-400"
>
Unlink
Remove
</button>
)}
</div>
@ -128,7 +101,7 @@ export default function Settings() {
<button
onClick={() => signOut()}
className="text-sm text-ink-muted hover:text-signal transition-colors"
className="text-sm text-slate-400 hover:text-rose-400"
>
Sign out
</button>
@ -143,34 +116,24 @@ function SignInForm({ initialError }: { initialError: string | null }) {
if (submitted) {
return (
<div className="paper-panel px-6 py-8 text-center">
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
Check your inbox
</div>
<h2 className="font-display text-2xl text-ink mt-2 mb-3">
A sign-in link is on its way
</h2>
<p className="text-sm text-ink-muted leading-relaxed">
If an account exists for <span className="text-ink">{email}</span>, we
sent a link. It expires in 15 minutes.
<div className="rounded-xl bg-slate-900/60 border border-slate-800 p-6 text-center">
<div className="text-xl mb-2">📬</div>
<h2 className="text-lg text-slate-100 mb-2">Check your inbox</h2>
<p className="text-sm text-slate-400">
If an account exists for <span className="text-slate-200">{email}</span>, a sign-in link is on its way. It expires in 15 minutes.
</p>
</div>
);
}
return (
<div className="paper-panel px-6 py-7">
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
Sign in
</div>
<h2 className="font-display text-2xl text-ink mt-2 mb-3 tracking-tight">
Sync across browsers
</h2>
<p className="text-sm text-ink-muted mb-6 leading-relaxed">
Optional. Signing in keeps your profile and devices together. Transfers stay peer-to-peer.
<div className="rounded-xl bg-slate-900/60 border border-slate-800 p-6">
<h2 className="text-lg text-slate-100 mb-2">Sign in</h2>
<p className="text-sm text-slate-400 mb-4">
Optional. Signing in lets you sync your profile and devices across browsers. Your transfers stay peer-to-peer.
</p>
{initialError && (
<div className="mb-4 px-3 py-2 border border-fail/40 bg-signal-quiet text-xs text-ink rounded-sm">
<div className="mb-4 rounded-lg bg-rose-500/10 border border-rose-500/30 px-3 py-2 text-xs text-rose-200">
{initialError === "invalid_or_expired"
? "That link has expired or already been used."
: "Something went wrong. Please try again."}
@ -196,19 +159,14 @@ function SignInForm({ initialError }: { initialError: string | null }) {
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="bg-paper border border-paper-edge rounded-sm px-3 py-2.5
text-ink text-sm placeholder:text-ink-faint
focus:outline-none focus:border-ink transition-colors
duration-fast ease-crisp"
className="bg-slate-950 border border-slate-700 rounded-lg px-3 py-2 text-slate-100 placeholder-slate-500 focus:outline-none focus:border-indigo-500"
/>
<button
type="submit"
disabled={submitting}
className="bg-ink text-paper text-sm font-medium rounded-sm px-4 py-2.5
hover:bg-signal transition-colors duration-fast ease-crisp
disabled:opacity-40"
className="bg-indigo-500 hover:bg-indigo-400 disabled:opacity-50 text-white font-medium rounded-lg px-4 py-2"
>
{submitting ? "Sending…" : "Send sign-in link"}
{submitting ? "Sending…" : "Send sign-in link"}
</button>
</form>
</div>
@ -217,18 +175,13 @@ function SignInForm({ initialError }: { initialError: string | null }) {
function SettingsShell({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen">
<div className="max-w-xl mx-auto px-5 sm:px-8 pt-10 pb-24">
<header className="flex items-center justify-between pb-8 mb-10 rule">
<Link
to="/"
className="text-sm text-ink-muted hover:text-ink transition-colors duration-fast"
>
Back
</Link>
<h1 className="font-display text-xl text-ink tracking-tight">Account</h1>
<div className="min-h-screen bg-slate-950 text-slate-100 px-4 py-10">
<div className="max-w-lg mx-auto">
<div className="mb-6 flex items-center justify-between">
<Link to="/" className="text-sm text-slate-400 hover:text-slate-200"> Back</Link>
<h1 className="text-xl font-semibold">Account</h1>
<div className="w-10" />
</header>
</div>
{children}
</div>
</div>

View File

@ -10,29 +10,33 @@ interface SharedData {
text: string;
}
/** Read shared files/text from the cache stashed by the service worker */
async function readSharedData(): Promise<SharedData> {
const result: SharedData = { files: [], text: "" };
try {
const cache = await caches.open("share-target");
// Read metadata
const metaResponse = await cache.match("/share-target-meta");
if (!metaResponse) return result;
const meta = await metaResponse.json();
result.text = meta.text || "";
// Read files
for (let i = 0; i < (meta.count || 0); i++) {
const fileResponse = await cache.match(`/share-target-file/${i}`);
if (!fileResponse) continue;
const blob = await fileResponse.blob();
const fileName = decodeURIComponent(
fileResponse.headers.get("X-File-Name") || `file-${i}`,
fileResponse.headers.get("X-File-Name") || `fichier-${i}`,
);
result.files.push(new File([blob], fileName, { type: blob.type }));
}
// Clean up cache
await caches.delete("share-target");
} catch (err) {
console.error("[share] Failed to read shared data:", err);
@ -53,6 +57,7 @@ export default function Share() {
function ShareConnected() {
const { sendFiles, sendText } = useSignaling();
const peers = useStore((s) => s.peers);
const setSelectedPeerId = useStore((s) => s.setSelectedPeerId);
const [shared, setShared] = useState<SharedData | null>(null);
@ -85,72 +90,67 @@ function ShareConnected() {
const hasText = !!shared?.text;
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="text-xs uppercase tracking-[0.2em] text-ink-muted">
Share sheet
</div>
<h1 className="font-display text-3xl leading-none tracking-tight text-ink mt-2">
Send via AnyDrop
<div className="min-h-screen bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950">
<div className="max-w-lg mx-auto px-4 py-8">
{/* Header */}
<header className="text-center mb-6">
<h1 className="text-3xl font-bold text-white mb-1">
Any<span className="text-brand-400">Drop</span>
</h1>
</header>
{!shared ? (
<p className="text-sm text-ink-muted">Loading</p>
<p className="text-slate-500 text-sm mt-4">Chargement...</p>
) : sent ? (
<div className="paper-panel px-6 py-8 text-center">
<div className="text-xs uppercase tracking-[0.22em] text-ok">Sending</div>
<h2 className="font-display text-2xl text-ink mt-2 tracking-tight">
Transfer on its way
</h2>
<div className="mt-6">
<div className="text-4xl mb-3"></div>
<p className="text-brand-400 font-medium">Envoi en cours</p>
<a
href="/"
className="inline-block mt-5 text-sm text-ink hover:text-signal transition-colors"
className="inline-block mt-4 text-sm text-slate-500 hover:text-slate-300 transition-colors"
>
Back home
Retour à l'accueil
</a>
</div>
) : (
<>
<section className="mb-10">
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
Payload
</div>
<div className="mt-3 paper-panel px-4 py-3 inline-flex items-center gap-3
text-sm text-ink">
{fileCount > 0 && (
<span className="font-mono text-xs uppercase tracking-widest text-ink-muted">
{fileCount} {fileCount > 1 ? "files" : "file"}
</span>
)}
{hasText && !fileCount && (
<span className="font-mono text-xs uppercase tracking-widest text-ink-muted">
Text
</span>
)}
{hasText && fileCount > 0 && (
<span className="font-mono text-xs uppercase tracking-widest text-ink-muted">
+ text
</span>
)}
</div>
</section>
<p className="text-slate-400 text-sm mt-4">Envoyer à quel appareil ?</p>
<section>
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
Pick a device
{/* What's being shared */}
<div className="mt-3 inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-slate-800/50 text-xs text-slate-400">
{fileCount > 0 && (
<span>
📎 {fileCount} {fileCount > 1 ? "fichiers" : "fichier"}
</span>
)}
{hasText && !fileCount && <span>💬 Texte</span>}
{hasText && fileCount > 0 && <span>+ texte</span>}
</div>
<h2 className="font-display text-2xl text-ink mt-2 mb-5 tracking-tight">
Who should receive this?
</h2>
<PeerList onPeerSelect={handlePeerSelect} />
</section>
</>
)}
</header>
<footer className="pt-8 mt-14 rule text-xs text-ink-muted">
End-to-end encrypted · Nothing transits the server
{/* Peer list — tap to send immediately */}
{!sent && shared && (
<section>
{peers.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
<div className="text-5xl mb-4">📡</div>
<p className="text-lg font-medium">En attente d'appareils...</p>
<p className="text-sm mt-2 text-center max-w-xs">
Ouvrez AnyDrop sur l'appareil destinataire.
</p>
</div>
) : (
<div className="flex flex-wrap justify-center gap-6 py-8">
<PeerList onPeerSelect={handlePeerSelect} />
</div>
)}
</section>
)}
{/* Footer */}
<footer className="text-center text-xs text-slate-600 mt-12">
<p>Peer-to-peer · Chiffré · Aucun fichier ne transite par le serveur</p>
</footer>
</div>
</div>

View File

@ -1,59 +1,22 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
darkMode: "class",
theme: {
extend: {
colors: {
paper: "#F5F0E6",
"paper-deep": "#EBE4D4",
"paper-edge": "#DCD3BE",
ink: "#1A1714",
"ink-muted": "#6B635A",
"ink-faint": "#A89F93",
signal: "#7A2320",
"signal-quiet": "#F3E2E0",
ok: "#3E6B4A",
warn: "#8B6914",
fail: "#8A3324",
brand: {
50: "#eef2ff",
100: "#e0e7ff",
200: "#c7d2fe",
300: "#a5b4fc",
400: "#818cf8",
500: "#6366f1",
600: "#4f46e5",
700: "#4338ca",
800: "#3730a3",
900: "#312e81",
},
fontFamily: {
display: ['"Fraunces"', '"GT Sectra"', "Georgia", "serif"],
sans: ['"Inter"', '"Söhne"', "system-ui", "-apple-system", "sans-serif"],
mono: ['"JetBrains Mono"', '"Berkeley Mono"', "ui-monospace", "monospace"],
},
fontSize: {
// Paper scale — ratio 1.25, capped hard
xs: ["12px", { lineHeight: "1.45" }],
sm: ["14px", { lineHeight: "1.5" }],
base: ["15px", { lineHeight: "1.55" }],
lg: ["17px", { lineHeight: "1.5" }],
xl: ["22px", { lineHeight: "1.35" }],
"2xl": ["32px", { lineHeight: "1.2" }],
"3xl": ["48px", { lineHeight: "1.05" }],
"4xl": ["72px", { lineHeight: "1.0", letterSpacing: "-0.02em" }],
},
borderRadius: {
none: "0",
sm: "2px",
DEFAULT: "4px",
md: "4px",
lg: "6px",
pill: "999px",
},
boxShadow: {
paper:
"0 1px 0 rgba(26,23,20,0.04), 0 1px 3px rgba(26,23,20,0.06)",
lift:
"0 2px 6px rgba(26,23,20,0.08), 0 8px 24px rgba(26,23,20,0.08)",
},
transitionTimingFunction: {
paper: "cubic-bezier(0.16, 1, 0.3, 1)",
crisp: "cubic-bezier(0.2, 0.0, 0.0, 1.0)",
},
transitionDuration: {
fast: "160ms",
base: "320ms",
paper: "480ms",
},
},
},