All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 39s
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>
339 lines
12 KiB
TypeScript
339 lines
12 KiB
TypeScript
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>
|
||
);
|
||
}
|