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,
|
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,
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user