diff --git a/web/src/lib/localTransferKeys.ts b/web/src/lib/localTransferKeys.ts new file mode 100644 index 0000000..4035426 --- /dev/null +++ b/web/src/lib/localTransferKeys.ts @@ -0,0 +1,33 @@ +const STORAGE_KEY = "anydrop:transfer_keys"; + +type StoredKeys = Record; + +function readAll(): StoredKeys { + if (typeof localStorage === "undefined") return {}; + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}") as StoredKeys; + } catch { + return {}; + } +} + +function writeAll(keys: StoredKeys): void { + if (typeof localStorage === "undefined") return; + localStorage.setItem(STORAGE_KEY, JSON.stringify(keys)); +} + +export function saveTransferKey(transferId: string, keyFrag: string): void { + const all = readAll(); + all[transferId] = keyFrag; + writeAll(all); +} + +export function getTransferKey(transferId: string): string | null { + return readAll()[transferId] ?? null; +} + +export function removeTransferKey(transferId: string): void { + const all = readAll(); + delete all[transferId]; + writeAll(all); +} diff --git a/web/src/lib/sendCloud.ts b/web/src/lib/sendCloud.ts index 85c0dc1..72bb13b 100644 --- a/web/src/lib/sendCloud.ts +++ b/web/src/lib/sendCloud.ts @@ -12,6 +12,7 @@ import { getTransferHead, type TransferHead, } from "./api"; +import { saveTransferKey } from "./localTransferKeys"; export interface SendCloudOptions { recipientEmail?: string; @@ -56,7 +57,10 @@ export async function sendCloud( const origin = typeof window === "undefined" ? "https://anydrop.arthurbarre.fr" : window.location.origin; - const shareUrl = `${origin}/r/${created.transferId}#k=${keyToFragment(key)}`; + const keyFrag = keyToFragment(key); + const shareUrl = `${origin}/r/${created.transferId}#k=${keyFrag}`; + + saveTransferKey(created.transferId, keyFrag); return { transferId: created.transferId, diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 99c67ef..8881359 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -2,7 +2,17 @@ import { useEffect, useState } from "react"; import { Link, useSearchParams } from "react-router-dom"; import { useAuthStore } from "../stores/useAuthStore"; import { useProfileStore } from "../stores/useProfileStore"; -import { registerDevice, requestMagicLink, unlinkDevice } from "../lib/api"; +import { + deleteTransfer, + listInboxTransfers, + registerDevice, + requestMagicLink, + unlinkDevice, + type InboxTransfer, +} from "../lib/api"; +import { fragmentToKey } from "../lib/cloudTransfer"; +import { openMetadata } from "../lib/cloudTransfer"; +import { getTransferKey, removeTransferKey } from "../lib/localTransferKeys"; export default function Settings() { const user = useAuthStore((s) => s.user); @@ -79,6 +89,8 @@ export default function Settings() { + +
Devices @@ -215,6 +227,151 @@ function SignInForm({ initialError }: { initialError: string | null }) { ); } +type LinkItem = InboxTransfer & { + filename: string | null; + keyFrag: string | null; +}; + +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).getTime(); + const now = Date.now(); + if (d <= now) return "expired"; + const mins = Math.round((d - now) / 60_000); + if (mins < 60) return `in ${mins}m`; + const hours = Math.round(mins / 60); + if (hours < 24) return `in ${hours}h`; + const days = Math.round(hours / 24); + return `in ${days}d`; +} + +function itemStatus(t: LinkItem): "active" | "expired" | "consumed" { + if (new Date(t.expiresAt).getTime() <= Date.now()) return "expired"; + if (t.downloadCount >= t.maxDownloads) return "consumed"; + return "active"; +} + +function SharedLinksSection() { + const [items, setItems] = useState(null); + const [copiedId, setCopiedId] = useState(null); + + useEffect(() => { + listInboxTransfers() + .then((list) => { + const sent = list + .filter((t) => t.direction === "sent") + .map((t) => { + const keyFrag = getTransferKey(t.id); + let filename: string | null = null; + if (keyFrag) { + try { + const key = fragmentToKey(keyFrag); + filename = openMetadata(key, t.encryptedMetadata).name; + } catch { + filename = null; + } + } + return { ...t, filename, keyFrag }; + }); + setItems(sent); + }) + .catch(() => setItems([])); + }, []); + + const onRevoke = async (id: string) => { + if (!confirm("Revoke this link? The file becomes unavailable and is deleted from storage.")) return; + try { + await deleteTransfer(id); + removeTransferKey(id); + setItems((cur) => (cur ? cur.filter((i) => i.id !== id) : cur)); + } catch { + // swallow; UI stays as-is + } + }; + + const onCopy = (item: LinkItem) => { + if (!item.keyFrag) return; + const url = `${window.location.origin}/r/${item.id}#k=${item.keyFrag}`; + navigator.clipboard.writeText(url); + setCopiedId(item.id); + setTimeout(() => setCopiedId((v) => (v === item.id ? null : v)), 1500); + }; + + return ( +
+
+ Links +
+

+ Shared links +

+
+ {items === null && ( +
Loading…
+ )} + {items !== null && items.length === 0 && ( +
+ No shared links yet. Create one from the home page. +
+ )} + {items?.map((item) => { + const status = itemStatus(item); + const dimmed = status !== "active"; + return ( +
+
+
+
+ {item.filename ?? "Encrypted file"} +
+
+ {formatSize(item.sizeBytes)} + + {item.downloadCount}/{item.maxDownloads} downloads + + + {status === "expired" + ? "expired" + : status === "consumed" + ? "done" + : `expires ${formatExpiry(item.expiresAt)}`} + +
+
+
+ {item.keyFrag && status === "active" && ( + + )} + +
+
+
+ ); + })} +
+
+ ); +} + function SettingsShell({ children }: { children: React.ReactNode }) { return (