feat: shared-links history in /settings with revoke + copy
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 39s
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:
parent
bdfa11a2bf
commit
c46b23b8ec
33
web/src/lib/localTransferKeys.ts
Normal file
33
web/src/lib/localTransferKeys.ts
Normal 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);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<SharedLinksSection />
|
||||
|
||||
<section className="mb-12">
|
||||
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
|
||||
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 }) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user