anydrop/web/src/components/CloudSharePanel.tsx
ordinarthur 48ace8af34
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 39s
feat(web): native share sheet for created + saved links
Adds a "Share link" button that invokes navigator.share() so iOS
home-screen PWA users (and any platform with the Web Share API)
get the system share sheet — AirDrop, Messages, Mail, WhatsApp…
Falls back to copy-only on platforms without support.

Wired on both CloudSharePanel (just-created link) and Settings →
Shared links (re-share an existing one).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 15:31:38 +02:00

339 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useRef, useState } 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; password: string | null }
| { kind: "error"; message: string };
const EXPIRY_CHOICES: { label: string; days: number }[] = [
{ label: "1 day", days: 1 },
{ label: "7 days", days: 7 },
{ label: "30 days", days: 30 },
];
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 [stage, setStage] = useState<Stage>({ kind: "idle" });
const [file, setFile] = useState<File | null>(null);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [expiryDays, setExpiryDays] = useState(7);
const [copied, setCopied] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const reset = () => {
setStage({ kind: "idle" });
setFile(null);
setEmail("");
setPassword("");
setExpiryDays(7);
setShowAdvanced(false);
};
const handleSend = async () => {
if (!file) return;
setStage({ kind: "uploading", loaded: 0, total: file.size });
try {
const result = await sendCloud(file, {
deviceId,
recipientEmail: email.trim() || undefined,
password: password.trim() || undefined,
expiresInDays: expiryDays,
onProgress: (loaded, total) => setStage({ kind: "uploading", loaded, total }),
});
setStage({
kind: "done",
shareUrl: result.shareUrl,
fileName: file.name,
expiresAt: result.expiresAt,
password: password.trim() || null,
});
} catch (err) {
setStage({ kind: "error", message: err instanceof Error ? err.message : "unknown" });
}
};
const copy = (value: string) => {
navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
const canShare = typeof navigator !== "undefined" && typeof navigator.share === "function";
const share = async (url: string, fileName: string) => {
try {
await navigator.share({
title: `AnyDrop — ${fileName}`,
text: "File shared via AnyDrop",
url,
});
} catch {
// User canceled the share sheet, or the browser rejected — nothing to do.
}
};
return (
<div className="paper-panel p-4 sm:p-5">
{stage.kind === "idle" && (
<>
<input
ref={fileRef}
type="file"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) setFile(f);
}}
/>
{file ? (
<div className="paper-panel-deep rounded-sm px-4 py-3 mb-4">
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1 mr-3">
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-ink-muted">
File
</div>
<div className="text-sm text-ink mt-0.5 truncate">{file.name}</div>
</div>
<span className="font-mono text-xs text-ink-muted whitespace-nowrap">
{formatSize(file.size)}
</span>
</div>
<button
onClick={() => fileRef.current?.click()}
className="mt-3 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-6 sm:py-8 mb-4
flex flex-col items-center gap-1.5
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-lg sm:text-xl text-ink">Choose a file</span>
<span className="text-xs text-ink-muted">Up to 2 GB · 7 days default</span>
</button>
)}
<button
onClick={() => setShowAdvanced((v) => !v)}
className="mb-3 font-mono text-[10px] uppercase tracking-[0.22em] text-ink-muted
hover:text-ink transition-colors"
>
{showAdvanced ? " Hide options" : "+ Email, password, expiry"}
</button>
{showAdvanced && (
<div className="space-y-3 mb-4 pb-4 border-b border-paper-edge">
<Field label="Recipient email">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="friend@example.com"
className={inputCls}
/>
</Field>
<Field label="Password">
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Leave blank for none"
minLength={4}
className={inputCls}
/>
</Field>
<Field label="Expires in">
<div className="flex gap-2">
{EXPIRY_CHOICES.map((c) => (
<button
key={c.days}
onClick={() => setExpiryDays(c.days)}
className={`flex-1 py-2 text-xs rounded-sm border transition-colors duration-fast ease-crisp ${
expiryDays === c.days
? "border-ink text-ink bg-paper-deep"
: "border-paper-edge text-ink-muted hover:text-ink"
}`}
>
{c.label}
</button>
))}
</div>
</Field>
</div>
)}
<button
onClick={handleSend}
disabled={!file}
className="w-full py-3 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 & upload
</button>
<p className="mt-3 text-[11px] text-ink-faint leading-relaxed">
Sealed in your browser. Only the link holder
{password && " + password"} can open it.
</p>
</>
)}
{stage.kind === "uploading" && (
<>
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-ink-muted mb-2">
Uploading ciphertext
</div>
<p className="font-display text-lg sm:text-xl text-ink mb-4">
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 flex-col sm:flex-row sm:items-start gap-4 mb-4">
<div className="self-center shrink-0 bg-paper p-2 border border-paper-edge rounded-sm">
<QRCodeSVG
value={stage.shareUrl}
size={128}
bgColor="#F5F0E6"
fgColor="#2563EB"
/>
</div>
<div className="min-w-0 flex-1">
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-ok">
Ready to share
</div>
<div className="font-display text-lg text-ink mt-1 tracking-tight truncate">
{stage.fileName}
</div>
<div className="text-xs text-ink-muted mt-1.5">
Expires {new Date(stage.expiresAt).toLocaleDateString()} · one download
</div>
</div>
</div>
<div className="flex gap-2">
{canShare && (
<button
onClick={() => share(stage.shareUrl, stage.fileName)}
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"
>
Share link
</button>
)}
<button
onClick={() => copy(stage.shareUrl)}
className={`py-2.5 text-sm font-medium rounded-sm transition-colors duration-fast ease-crisp ${
canShare
? "flex-1 border border-paper-edge hover:border-ink text-ink"
: "w-full bg-ink text-paper hover:bg-signal"
}`}
>
{copied ? "Copied ✓" : "Copy link"}
</button>
</div>
<p className="mt-2 font-mono text-[11px] text-ink-faint break-all">
{stage.shareUrl}
</p>
{stage.password && (
<div className="mt-3 paper-panel-deep rounded-sm px-3 py-2.5">
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-ink-muted">
Password (share separately)
</div>
<div className="flex items-center justify-between mt-1">
<code className="font-mono text-sm text-ink">{stage.password}</code>
<button
onClick={() => copy(stage.password!)}
className="text-xs text-ink-muted hover:text-ink transition-colors"
>
Copy
</button>
</div>
</div>
)}
<button
onClick={reset}
className="mt-4 text-xs text-ink-muted hover:text-ink transition-colors"
>
Send another
</button>
</>
)}
{stage.kind === "error" && (
<>
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-fail">Failed</div>
<h3 className="font-display text-lg text-ink mt-1 mb-2">
Could not complete the transfer
</h3>
<p className="font-mono text-xs text-ink-muted">{stage.message}</p>
<button
onClick={reset}
className="mt-4 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>
);
}
const inputCls =
"w-full px-3 py-2 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";
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-ink-muted mb-1.5">
{label}
</div>
{children}
</label>
);
}