feat: shared-links history in /settings with revoke + copy
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 39s

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-04-20 12:26:04 +02:00
parent bdfa11a2bf
commit c46b23b8ec
3 changed files with 196 additions and 2 deletions

View File

@ -0,0 +1,33 @@
const STORAGE_KEY = "anydrop:transfer_keys";
type StoredKeys = Record<string, string>;
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);
}

View File

@ -12,6 +12,7 @@ import {
getTransferHead, getTransferHead,
type TransferHead, type TransferHead,
} from "./api"; } from "./api";
import { saveTransferKey } from "./localTransferKeys";
export interface SendCloudOptions { export interface SendCloudOptions {
recipientEmail?: string; recipientEmail?: string;
@ -56,7 +57,10 @@ export async function sendCloud(
const origin = const origin =
typeof window === "undefined" ? "https://anydrop.arthurbarre.fr" : window.location.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 { return {
transferId: created.transferId, transferId: created.transferId,

View File

@ -2,7 +2,17 @@ import { useEffect, useState } from "react";
import { Link, useSearchParams } from "react-router-dom"; import { Link, useSearchParams } from "react-router-dom";
import { useAuthStore } from "../stores/useAuthStore"; import { useAuthStore } from "../stores/useAuthStore";
import { useProfileStore } from "../stores/useProfileStore"; 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() { export default function Settings() {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
@ -79,6 +89,8 @@ export default function Settings() {
</div> </div>
</section> </section>
<SharedLinksSection />
<section className="mb-12"> <section className="mb-12">
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted"> <div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
Devices 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<LinkItem[] | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
useEffect(() => {
listInboxTransfers()
.then((list) => {
const sent = list
.filter((t) => t.direction === "sent")
.map<LinkItem>((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 (
<section className="mb-12">
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
Links
</div>
<h2 className="font-display text-2xl text-ink mt-2 mb-5 tracking-tight">
Shared links
</h2>
<div className="paper-panel divide-y divide-paper-edge">
{items === null && (
<div className="px-5 py-6 text-sm text-ink-muted">Loading</div>
)}
{items !== null && items.length === 0 && (
<div className="px-5 py-6 text-sm text-ink-muted">
No shared links yet. Create one from the home page.
</div>
)}
{items?.map((item) => {
const status = itemStatus(item);
const dimmed = status !== "active";
return (
<div
key={item.id}
className={`px-5 py-4 ${dimmed ? "opacity-60" : ""}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="text-ink text-sm truncate">
{item.filename ?? "Encrypted file"}
</div>
<div className="font-mono text-[11px] text-ink-muted mt-1 flex flex-wrap gap-x-3 gap-y-1">
<span>{formatSize(item.sizeBytes)}</span>
<span>
{item.downloadCount}/{item.maxDownloads} downloads
</span>
<span>
{status === "expired"
? "expired"
: status === "consumed"
? "done"
: `expires ${formatExpiry(item.expiresAt)}`}
</span>
</div>
</div>
<div className="flex flex-col items-end gap-1.5 shrink-0">
{item.keyFrag && status === "active" && (
<button
onClick={() => onCopy(item)}
className="text-xs text-ink-muted hover:text-ink transition-colors"
>
{copiedId === item.id ? "Copied ✓" : "Copy link"}
</button>
)}
<button
onClick={() => onRevoke(item.id)}
className="text-xs text-ink-muted hover:text-signal transition-colors"
>
Revoke
</button>
</div>
</div>
</div>
);
})}
</div>
</section>
);
}
function SettingsShell({ children }: { children: React.ReactNode }) { function SettingsShell({ children }: { children: React.ReactNode }) {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">