feat(web): Paper & Envelope design system

Replace generic slate/indigo dark theme with a custom editorial
direction: warm paper neutrals, oxblood signal, Fraunces serif
display, Inter body, JetBrains Mono for codes. SVG paper-texture
noise overlay and thin rules across the app.

Refactored: Home, Settings, JoinRoom, Pair, Share, plus every
modal and panel (DropZone, DevicePairingPanel, PublicRoomPanel,
ProfileSetup, TextShareModal, ReceiveDialog, TransferProgress,
PeerList, PeerAvatar).

Also drops three pre-existing Uint8Array/BlobPart strictness
errors so the production build is green again.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-04-20 10:49:15 +02:00
parent 3f87debcf8
commit c18d995c3f
21 changed files with 858 additions and 467 deletions

View File

@ -1,14 +1,16 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="fr" class="dark"> <html lang="fr">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="AnyDrop — Partage de fichiers instantané, peer-to-peer, sans compte" /> <meta name="description" content="AnyDrop — Instant, peer-to-peer file and text transfer, universal across platforms." />
<meta name="theme-color" content="#6366f1" /> <meta name="theme-color" content="#F5F0E6" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>AnyDrop</title> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<title>AnyDrop — Universal transfer</title>
</head> </head>
<body class="bg-slate-950 text-white antialiased"> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>

View File

@ -19,7 +19,6 @@ export default function DevicePairingPanel({
const [inputCode, setInputCode] = useState(""); const [inputCode, setInputCode] = useState("");
const [pairError, setPairError] = useState<string | null>(null); const [pairError, setPairError] = useState<string | null>(null);
// When modal opens in "show" mode, always request a fresh code
useEffect(() => { useEffect(() => {
if (showModal && mode === "show") { if (showModal && mode === "show") {
let gid = groupId; let gid = groupId;
@ -32,7 +31,6 @@ export default function DevicePairingPanel({
} }
}, [showModal, mode]); }, [showModal, mode]);
// Detect pair-code-not-found errors
useEffect(() => { useEffect(() => {
if (error && error.includes("appairage")) { if (error && error.includes("appairage")) {
setPairError(error); setPairError(error);
@ -51,11 +49,9 @@ export default function DevicePairingPanel({
if (inputCode.length >= 6) { if (inputCode.length >= 6) {
setPairError(null); setPairError(null);
onResolveCode(inputCode); onResolveCode(inputCode);
// Don't close — wait for response or error
} }
}; };
// Auto-close on successful pairing (groupId changed after resolve)
const currentGroupId = useProfileStore((s) => s.groupId); const currentGroupId = useProfileStore((s) => s.groupId);
useEffect(() => { useEffect(() => {
if (mode === "enter" && showModal && currentGroupId && currentGroupId !== groupId) { if (mode === "enter" && showModal && currentGroupId && currentGroupId !== groupId) {
@ -67,68 +63,78 @@ export default function DevicePairingPanel({
<> <>
<button <button
onClick={() => { setMode("show"); setShowModal(true); }} onClick={() => { setMode("show"); setShowModal(true); }}
className="flex-1 flex flex-col items-center justify-center gap-1.5 px-3 py-3 className="paper-panel px-4 py-4 flex flex-col items-start gap-1
border border-slate-700 hover:border-brand-500 rounded-xl hover:border-ink transition-colors duration-fast ease-crisp
text-slate-300 hover:text-white transition-all text-left"
bg-slate-900/30 hover:bg-slate-900/50"
> >
<span className="text-lg">📲</span> <span className="font-mono text-[10px] uppercase tracking-[0.22em] text-ink-muted">
<span className="text-xs font-medium text-center leading-tight">Appairer</span> Pair device
</span>
<span className="text-sm text-ink">Link your own </span>
</button> </button>
{showModal && ( {showModal && (
<div <div
className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-6" className="fixed inset-0 z-50 bg-ink/40 flex items-center justify-center p-4"
onClick={handleClose} onClick={handleClose}
> >
<div <div
className="bg-slate-900 border border-slate-700 rounded-2xl p-6 max-w-sm w-full className="paper-panel shadow-lift rounded-sm p-6 max-w-sm w-full"
text-center space-y-5"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="text-xs uppercase tracking-[0.2em] text-ink-muted">
Pair device
</div>
<h3 className="font-display text-2xl text-ink mt-1 mb-5">
Link your devices
</h3>
{/* Tab switcher */} {/* Tab switcher */}
<div className="flex gap-2 bg-slate-800 rounded-xl p-1"> <div className="flex border border-paper-edge rounded-sm overflow-hidden mb-5">
<button <button
onClick={() => setMode("show")} onClick={() => setMode("show")}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors className={`flex-1 py-2 text-xs uppercase tracking-[0.15em] transition-colors
${mode === "show" ? "bg-brand-500 text-white" : "text-slate-400 hover:text-white"}`} ${mode === "show"
? "bg-ink text-paper"
: "bg-paper text-ink-muted hover:text-ink"}`}
> >
Mon code Show code
</button> </button>
<button <button
onClick={() => { setMode("enter"); setPairError(null); }} onClick={() => { setMode("enter"); setPairError(null); }}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors className={`flex-1 py-2 text-xs uppercase tracking-[0.15em] transition-colors
${mode === "enter" ? "bg-brand-500 text-white" : "text-slate-400 hover:text-white"}`} ${mode === "enter"
? "bg-ink text-paper"
: "bg-paper text-ink-muted hover:text-ink"}`}
> >
Rejoindre Enter code
</button> </button>
</div> </div>
{mode === "show" ? ( {mode === "show" ? (
<> <>
<p className="text-sm text-slate-400"> <p className="text-sm text-ink-muted mb-5">
Entrez ce code sur votre autre appareil Enter this code on your other device.
</p> </p>
{pairCode ? ( {pairCode ? (
<p className="text-4xl font-mono font-bold text-brand-400 tracking-[0.3em]"> <p className="font-mono text-4xl text-signal tracking-[0.3em] text-center">
{pairCode} {pairCode}
</p> </p>
) : ( ) : (
<div className="flex justify-center py-2"> <div className="flex justify-center py-3">
<div className="w-6 h-6 border-2 border-brand-400 border-t-transparent rounded-full animate-spin" /> <div className="w-5 h-5 border border-signal border-t-transparent rounded-full animate-spin" />
</div> </div>
)} )}
<p className="text-xs text-slate-500"> <p className="text-xs text-ink-muted mt-5 leading-relaxed text-center">
Le code expire dans 5 minutes. Code expires in 5 minutes. Pairing is permanent.
L'appairage est permanent.
</p> </p>
</> </>
) : ( ) : (
<> <>
<p className="text-sm text-slate-400"> <p className="text-sm text-ink-muted mb-4">
Entrez le code affiché sur l'autre appareil Enter the code shown on the other device.
</p> </p>
<input <input
@ -141,35 +147,37 @@ export default function DevicePairingPanel({
placeholder="ABC123" placeholder="ABC123"
maxLength={6} maxLength={6}
autoFocus autoFocus
className="w-full px-4 py-3 bg-slate-800 border border-slate-600 className="w-full px-4 py-3 bg-paper border border-paper-edge
rounded-xl text-white text-2xl text-center font-mono tracking-[0.3em] rounded-sm text-ink text-2xl text-center font-mono tracking-[0.3em]
placeholder:text-slate-700 focus:outline-none focus:border-brand-500" placeholder:text-ink-faint focus:outline-none focus:border-ink
transition-colors duration-fast ease-crisp"
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") handleSubmitCode(); if (e.key === "Enter") handleSubmitCode();
}} }}
/> />
{pairError && ( {pairError && (
<p className="text-sm text-red-400">{pairError}</p> <p className="text-sm text-fail mt-3">{pairError}</p>
)} )}
<button <button
onClick={handleSubmitCode} onClick={handleSubmitCode}
disabled={inputCode.length < 6} disabled={inputCode.length < 6}
className="w-full py-3 bg-brand-500 hover:bg-brand-400 disabled:opacity-30 className="mt-5 w-full py-2.5 bg-ink text-paper rounded-sm text-sm
disabled:cursor-not-allowed rounded-xl text-white font-medium font-medium hover:bg-signal transition-colors duration-fast
transition-colors" ease-crisp disabled:opacity-30 disabled:cursor-not-allowed"
> >
Appairer Pair
</button> </button>
</> </>
)} )}
<button <button
onClick={handleClose} onClick={handleClose}
className="text-sm text-slate-500 hover:text-slate-300 transition-colors" className="mt-6 w-full py-2.5 border border-paper-edge hover:border-ink
text-sm text-ink rounded-sm transition-colors duration-fast ease-crisp"
> >
Fermer Close
</button> </button>
</div> </div>
</div> </div>

View File

@ -56,7 +56,6 @@ export default function DropZone({ onFilesSelected, disabled }: DropZoneProps) {
if (files.length > 0) { if (files.length > 0) {
onFilesSelected(files); onFilesSelected(files);
} }
// Reset to allow re-selecting the same file
e.target.value = ""; e.target.value = "";
}; };
@ -68,26 +67,29 @@ export default function DropZone({ onFilesSelected, disabled }: DropZoneProps) {
onDrop={handleDrop} onDrop={handleDrop}
onClick={handleClick} onClick={handleClick}
className={` className={`
border-2 border-dashed rounded-2xl p-8 border border-dashed rounded-sm px-6 py-8
flex flex-col items-center justify-center gap-3 flex flex-col items-center justify-center gap-2
transition-all duration-200 cursor-pointer transition-colors duration-fast ease-crisp cursor-pointer
min-h-[160px]
${isDragging ${isDragging
? "border-brand-400 bg-brand-500/10 scale-[1.02]" ? "border-signal bg-signal-quiet"
: disabled : disabled
? "border-slate-700 bg-slate-900/50 cursor-not-allowed opacity-50" ? "border-paper-edge bg-paper-deep/40 cursor-not-allowed opacity-60"
: "border-slate-700 hover:border-slate-500 bg-slate-900/30 hover:bg-slate-900/50" : "border-paper-edge bg-paper hover:border-ink hover:bg-paper-deep/40"
} }
`} `}
> >
<div className="text-4xl">{isDragging ? "📥" : "📁"}</div> <span className="font-mono text-[10px] uppercase tracking-[0.2em] text-ink-muted">
<p className="text-slate-400 text-sm text-center"> {isDragging ? "Release" : "Drop"}
</span>
<p className="font-display text-xl text-ink">
{disabled {disabled
? "Sélectionnez d'abord un appareil" ? "Select a device first"
: isDragging : isDragging
? "Déposez vos fichiers ici" ? "Release to send"
: "Glissez des fichiers ici ou cliquez pour sélectionner" : "Drop files here"}
} </p>
<p className="text-xs text-ink-muted">
or click to choose
</p> </p>
<input <input
ref={inputRef} ref={inputRef}

View File

@ -10,20 +10,28 @@ interface PeerAvatarProps {
size?: "sm" | "md" | "lg"; size?: "sm" | "md" | "lg";
} }
const DEVICE_ICONS: Record<DeviceType, string> = { const DEVICE_GLYPH: Record<DeviceType, string> = {
phone: "📱", phone: "phone",
tablet: "📱", tablet: "tablet",
laptop: "💻", laptop: "laptop",
desktop: "🖥️", desktop: "desk",
}; };
const sizeClasses = { const sizeClasses = {
sm: { container: "w-12 h-12", icon: "text-xl", img: "w-12 h-12" }, sm: { container: "w-12 h-12", label: "text-[10px]" },
md: { container: "w-16 h-16", icon: "text-2xl", img: "w-16 h-16" }, md: { container: "w-16 h-16", label: "text-[11px]" },
lg: { container: "w-20 h-20", icon: "text-3xl", img: "w-20 h-20" }, lg: { container: "w-20 h-20", label: "text-xs" },
}; };
export default function PeerAvatar({ displayName, deviceType, avatar, online = true, onClick, isSelected, size = "md" }: PeerAvatarProps) { export default function PeerAvatar({
displayName,
deviceType,
avatar,
online = true,
onClick,
isSelected,
size = "md",
}: PeerAvatarProps) {
const s = sizeClasses[size]; const s = sizeClasses[size];
const isOffline = !online; const isOffline = !online;
@ -31,9 +39,9 @@ export default function PeerAvatar({ displayName, deviceType, avatar, online = t
<button <button
onClick={onClick} onClick={onClick}
className={` className={`
flex flex-col items-center gap-2 group cursor-pointer group flex flex-col items-center gap-2.5
transition-transform duration-200 hover:scale-105 transition-transform duration-fast ease-crisp
${isOffline ? "opacity-50" : ""} ${isOffline ? "opacity-60" : ""}
`} `}
> >
<div className="relative"> <div className="relative">
@ -41,33 +49,40 @@ export default function PeerAvatar({ displayName, deviceType, avatar, online = t
className={` className={`
${s.container} ${s.container}
rounded-full flex items-center justify-center overflow-hidden rounded-full flex items-center justify-center overflow-hidden
transition-all duration-200 border transition-all duration-fast ease-crisp
${isSelected ${isSelected
? "ring-2 ring-brand-400 ring-offset-2 ring-offset-slate-950" ? "border-signal ring-1 ring-signal"
: "" : "border-paper-edge group-hover:border-ink"
} }
${avatar ? "" : isSelected ? "bg-brand-500" : "bg-slate-800 hover:bg-slate-700"} ${avatar ? "" : "bg-paper"}
`} `}
> >
{avatar ? ( {avatar ? (
<img <img
src={avatar} src={avatar}
alt={displayName} alt=""
className={`${s.img} rounded-full object-cover ${isOffline ? "grayscale" : ""}`} className={`w-full h-full object-cover ${isOffline ? "grayscale" : ""}`}
/> />
) : ( ) : (
<span className={s.icon}>{DEVICE_ICONS[deviceType]}</span> <span className="font-mono text-[10px] uppercase tracking-widest text-ink-muted">
{DEVICE_GLYPH[deviceType]}
</span>
)} )}
</div> </div>
{/* Online/offline indicator */} <span
<div
className={` className={`
absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 rounded-full border-2 border-slate-950 absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border border-paper
${online ? "bg-green-500" : "bg-slate-500"} ${online ? "bg-ok" : "bg-ink-faint"}
`} `}
aria-label={online ? "online" : "offline"}
/> />
</div> </div>
<span className={`text-xs transition-colors max-w-[80px] truncate ${isOffline ? "text-slate-500" : "text-slate-300 group-hover:text-white"}`}> <span
className={`
${s.label} max-w-[88px] truncate transition-colors
${isSelected ? "text-ink" : isOffline ? "text-ink-faint" : "text-ink-muted group-hover:text-ink"}
`}
>
{displayName} {displayName}
</span> </span>
</button> </button>

View File

@ -11,18 +11,18 @@ export default function PeerList({ onPeerSelect }: PeerListProps) {
if (peers.length === 0) { if (peers.length === 0) {
return ( return (
<div className="flex flex-col items-center justify-center py-12 text-slate-500"> <div className="paper-panel px-6 py-10 flex flex-col items-center text-center">
<div className="text-5xl mb-4">📡</div> <div className="w-10 h-10 rounded-full border border-paper-edge flex items-center justify-center mb-4">
<p className="text-lg font-medium">En attente d'appareils...</p> <span className="w-2 h-2 rounded-full bg-signal animate-pulse" />
<p className="text-sm mt-2 text-center max-w-xs"> </div>
Ouvrez AnyDrop sur un autre appareil connecté au même Wi-Fi, <p className="font-display text-lg text-ink">Listening for devices</p>
ou appairez vos appareils ci-dessous. <p className="text-sm text-ink-muted mt-2 max-w-xs leading-relaxed">
Open AnyDrop on another device on this network, or pair a device below.
</p> </p>
</div> </div>
); );
} }
// Sort: online first, then offline
const sorted = [...peers].sort((a, b) => { const sorted = [...peers].sort((a, b) => {
const aOnline = a.online !== false ? 1 : 0; const aOnline = a.online !== false ? 1 : 0;
const bOnline = b.online !== false ? 1 : 0; const bOnline = b.online !== false ? 1 : 0;
@ -30,18 +30,20 @@ export default function PeerList({ onPeerSelect }: PeerListProps) {
}); });
return ( return (
<div className="flex flex-wrap justify-center gap-6 py-8"> <div className="paper-panel px-4 py-6">
{sorted.map((peer) => ( <div className="flex flex-wrap justify-center gap-6">
<PeerAvatar {sorted.map((peer) => (
key={peer.peerId} <PeerAvatar
displayName={peer.displayName} key={peer.peerId}
deviceType={peer.deviceType} displayName={peer.displayName}
avatar={peer.avatar} deviceType={peer.deviceType}
online={peer.online !== false} avatar={peer.avatar}
isSelected={selectedPeerId === peer.peerId} online={peer.online !== false}
onClick={() => onPeerSelect(peer.peerId)} isSelected={selectedPeerId === peer.peerId}
/> onClick={() => onPeerSelect(peer.peerId)}
))} />
))}
</div>
</div> </div>
); );
} }

View File

@ -1,7 +1,7 @@
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { useProfileStore } from "../stores/useProfileStore"; import { useProfileStore } from "../stores/useProfileStore";
const MAX_AVATAR_SIZE = 80_000; // ~80KB after base64 const MAX_AVATAR_SIZE = 80_000;
function resizeImage(file: File, maxSize: number): Promise<string> { function resizeImage(file: File, maxSize: number): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -10,7 +10,6 @@ function resizeImage(file: File, maxSize: number): Promise<string> {
img.onload = () => { img.onload = () => {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
// Crop to square, max 128px
const side = Math.min(img.width, img.height); const side = Math.min(img.width, img.height);
const sx = (img.width - side) / 2; const sx = (img.width - side) / 2;
const sy = (img.height - side) / 2; const sy = (img.height - side) / 2;
@ -21,7 +20,6 @@ function resizeImage(file: File, maxSize: number): Promise<string> {
const ctx = canvas.getContext("2d")!; const ctx = canvas.getContext("2d")!;
ctx.drawImage(img, sx, sy, side, side, 0, 0, outSize, outSize); ctx.drawImage(img, sx, sy, side, side, 0, 0, outSize, outSize);
// Try JPEG at decreasing quality until small enough
let quality = 0.8; let quality = 0.8;
let dataUrl = canvas.toDataURL("image/jpeg", quality); let dataUrl = canvas.toDataURL("image/jpeg", quality);
while (dataUrl.length > maxSize && quality > 0.2) { while (dataUrl.length > maxSize && quality > 0.2) {
@ -30,7 +28,6 @@ function resizeImage(file: File, maxSize: number): Promise<string> {
} }
if (dataUrl.length > maxSize) { if (dataUrl.length > maxSize) {
// Shrink further
outSize = 64; outSize = 64;
canvas.width = outSize; canvas.width = outSize;
canvas.height = outSize; canvas.height = outSize;
@ -70,28 +67,45 @@ export default function ProfileSetup({ onDone, isEditing }: ProfileSetupProps) {
onDone(); onDone();
}; };
const wrapperClass = isEditing
? "fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4"
: "min-h-screen flex items-center justify-center p-4";
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"> <div className={wrapperClass}>
<div className="bg-slate-900 border border-slate-700 rounded-2xl p-6 w-full max-w-sm shadow-2xl"> <div className="paper-panel shadow-lift rounded-sm p-8 w-full max-w-sm">
<h2 className="text-lg font-semibold text-white text-center mb-6"> {!isEditing && (
{isEditing ? "Modifier le profil" : "Votre appareil"} <>
<h1 className="font-display text-4xl leading-none tracking-tight text-ink text-center">
AnyDrop
</h1>
<p className="mt-2 mb-6 text-xs uppercase tracking-[0.2em] text-ink-muted text-center">
Universal transfer
</p>
</>
)}
<div className="text-xs uppercase tracking-[0.2em] text-ink-muted">
{isEditing ? "Edit" : "Set up"}
</div>
<h2 className="font-display text-2xl text-ink mt-1 mb-6">
Your device
</h2> </h2>
{/* Avatar */} {/* Avatar */}
<div className="flex justify-center mb-6"> <div className="flex justify-center mb-3">
<button <button
onClick={() => fileRef.current?.click()} onClick={() => fileRef.current?.click()}
className="relative w-24 h-24 rounded-full bg-slate-800 hover:bg-slate-700 className="relative w-24 h-24 rounded-full bg-paper
flex items-center justify-center overflow-hidden flex items-center justify-center overflow-hidden
transition-colors border-2 border-dashed border-slate-600 hover:border-brand-400" transition-colors duration-fast ease-crisp
border border-dashed border-paper-edge hover:border-ink"
> >
{preview ? ( {preview ? (
<img src={preview} alt="Avatar" className="w-full h-full object-cover rounded-full" /> <img src={preview} alt="Avatar" className="w-full h-full object-cover rounded-full" />
) : ( ) : (
<div className="flex flex-col items-center text-slate-400"> <span className="font-mono text-[10px] uppercase tracking-widest text-ink-muted">
<span className="text-2xl">📷</span> Photo
<span className="text-[10px] mt-1">Photo</span> </span>
</div>
)} )}
</button> </button>
<input <input
@ -103,40 +117,41 @@ export default function ProfileSetup({ onDone, isEditing }: ProfileSetupProps) {
/> />
</div> </div>
{/* Remove photo */}
{preview && ( {preview && (
<button <button
onClick={() => setPreview(null)} onClick={() => setPreview(null)}
className="block mx-auto mb-4 text-xs text-slate-500 hover:text-red-400 transition-colors" className="block mx-auto mb-4 text-xs text-ink-muted hover:text-signal transition-colors"
> >
Supprimer la photo Remove photo
</button> </button>
)} )}
{/* Name */} {/* Name */}
<div className="mb-6"> <div className="mt-5 mb-6">
<label className="block text-xs text-slate-400 mb-1.5">Nom de l'appareil</label> <label className="block text-xs uppercase tracking-[0.15em] text-ink-muted mb-2">
Device name
</label>
<input <input
type="text" type="text"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
maxLength={30} maxLength={30}
placeholder="ex: iPhone d'Arthur" placeholder="e.g. Arthur's iPhone"
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-700 rounded-xl className="w-full px-3 py-2.5 bg-paper border border-paper-edge rounded-sm
text-white text-sm placeholder:text-slate-500 text-ink text-sm placeholder:text-ink-faint
focus:outline-none focus:border-brand-500 transition-colors" focus:outline-none focus:border-ink transition-colors
duration-fast ease-crisp"
autoFocus autoFocus
onKeyDown={(e) => e.key === "Enter" && handleSubmit()} onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
/> />
</div> </div>
{/* Submit */}
<button <button
onClick={handleSubmit} onClick={handleSubmit}
className="w-full py-2.5 bg-brand-600 hover:bg-brand-500 text-white className="w-full py-2.5 bg-ink text-paper rounded-sm text-sm font-medium
rounded-xl text-sm font-medium transition-colors" hover:bg-signal transition-colors duration-fast ease-crisp"
> >
{isEditing ? "Enregistrer" : "C'est parti"} {isEditing ? "Save" : "Continue →"}
</button> </button>
</div> </div>
</div> </div>

View File

@ -24,13 +24,14 @@ export default function PublicRoomPanel({ onCreateRoom }: PublicRoomPanelProps)
<> <>
<button <button
onClick={handleClick} onClick={handleClick}
className="flex-1 flex flex-col items-center justify-center gap-1.5 px-3 py-3 className="paper-panel px-4 py-4 flex flex-col items-start gap-1
border border-slate-700 hover:border-brand-500 rounded-xl hover:border-ink transition-colors duration-fast ease-crisp
text-slate-300 hover:text-white transition-all text-left"
bg-slate-900/30 hover:bg-slate-900/50"
> >
<span className="text-lg">🔗</span> <span className="font-mono text-[10px] uppercase tracking-[0.22em] text-ink-muted">
<span className="text-xs font-medium text-center leading-tight">Lien public</span> Public link
</span>
<span className="text-sm text-ink">Send to anyone </span>
</button> </button>
{showModal && ( {showModal && (
@ -53,58 +54,66 @@ function PublicRoomModal({
url: string | null; url: string | null;
onClose: () => void; onClose: () => void;
}) { }) {
const [copied, setCopied] = useState(false);
const copyToClipboard = () => { const copyToClipboard = () => {
if (url) navigator.clipboard.writeText(url); if (!url) return;
navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}; };
return ( return (
<div <div
className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-6" className="fixed inset-0 z-50 bg-ink/40 flex items-center justify-center p-4"
onClick={onClose} onClick={onClose}
> >
<div <div
className="bg-slate-900 border border-slate-700 rounded-2xl p-6 max-w-sm w-full className="paper-panel shadow-lift rounded-sm p-6 max-w-sm w-full"
text-center space-y-4"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<h3 className="text-lg font-semibold text-white">Lien public</h3> <div className="text-xs uppercase tracking-[0.2em] text-ink-muted">
Public link
</div>
<h3 className="font-display text-2xl text-ink mt-1 mb-5">
Receive from anyone
</h3>
{!code || !url ? ( {!code || !url ? (
<p className="text-sm text-slate-400">Création en cours...</p> <p className="text-sm text-ink-muted">Generating</p>
) : ( ) : (
<> <>
<div className="flex justify-center"> <div className="flex justify-center mb-5">
<div className="bg-white p-3 rounded-xl"> <div className="bg-paper p-3 border border-paper-edge rounded-sm">
<QRCodeSVG value={url} size={180} /> <QRCodeSVG value={url} size={180} bgColor="#F5F0E6" fgColor="#1A1714" />
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="text-center space-y-2">
<p className="text-3xl font-mono font-bold text-brand-400 tracking-widest"> <p className="font-mono text-3xl font-medium text-signal tracking-[0.3em]">
{code.toUpperCase()} {code.toUpperCase()}
</p> </p>
<button <button
onClick={copyToClipboard} onClick={copyToClipboard}
className="text-sm text-slate-400 hover:text-white transition-colors className="text-xs text-ink-muted hover:text-ink transition-colors
underline underline-offset-4 decoration-slate-600" font-mono break-all"
> >
{url} {copied ? "Copied ✓" : url}
</button> </button>
</div> </div>
<p className="text-xs text-slate-500"> <p className="text-xs text-ink-muted mt-5 leading-relaxed">
Partagez ce lien pour recevoir des fichiers de n'importe qui. Anyone with this code or link can send you files. Expires in 10 minutes.
Expire dans 10 minutes.
</p> </p>
</> </>
)} )}
<button <button
onClick={onClose} onClick={onClose}
className="px-6 py-2 bg-slate-800 hover:bg-slate-700 rounded-xl className="mt-6 w-full py-2.5 border border-paper-edge hover:border-ink
text-sm text-slate-300 transition-colors" text-sm text-ink rounded-sm transition-colors duration-fast ease-crisp"
> >
Fermer Close
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,10 +1,10 @@
import type { IncomingRequest } from "../stores/useStore"; import type { IncomingRequest } from "../stores/useStore";
function formatSize(bytes: number): string { function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} o`; if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`; if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} Go`; return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
} }
interface ReceiveDialogProps { interface ReceiveDialogProps {
@ -15,57 +15,64 @@ interface ReceiveDialogProps {
export default function ReceiveDialog({ request, onAccept, onReject }: ReceiveDialogProps) { export default function ReceiveDialog({ request, onAccept, onReject }: ReceiveDialogProps) {
const totalSize = request.files.reduce((sum, f) => sum + f.size, 0); const totalSize = request.files.reduce((sum, f) => sum + f.size, 0);
const isTextOnly = request.text && request.files.length === 0;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4">
<div className="bg-slate-900 border border-slate-700 rounded-2xl p-6 w-full max-w-md shadow-2xl"> <div className="paper-panel shadow-lift rounded-sm p-6 w-full max-w-md">
<h2 className="text-lg font-semibold text-white mb-2"> <div className="text-xs uppercase tracking-[0.2em] text-ink-muted">
Transfert entrant Incoming
</div>
<h2 className="font-display text-2xl text-ink mt-1">
{isTextOnly ? "Text received" : "Transfer request"}
</h2> </h2>
<p className="text-sm text-slate-400 mb-4"> <p className="text-sm text-ink-muted mt-2">
<span className="text-brand-400 font-medium">{request.displayName}</span> veut vous envoyer : From <span className="text-ink">{request.displayName}</span>
</p> </p>
{request.files.length > 0 && ( {request.files.length > 0 && (
<div className="bg-slate-800/50 rounded-xl p-3 mb-4 space-y-2 max-h-40 overflow-y-auto"> <div className="mt-5 divide-y divide-paper-edge border-t border-b border-paper-edge max-h-40 overflow-y-auto">
{request.files.map((file) => ( {request.files.map((file) => (
<div key={file.id} className="flex items-center justify-between text-sm"> <div key={file.id} className="py-2.5 flex items-center justify-between text-sm">
<span className="text-white truncate mr-2">{file.name}</span> <span className="text-ink truncate mr-3">{file.name}</span>
<span className="text-slate-500 text-xs whitespace-nowrap">{formatSize(file.size)}</span> <span className="font-mono text-xs text-ink-faint whitespace-nowrap">
{formatSize(file.size)}
</span>
</div> </div>
))} ))}
{request.files.length > 1 && ( {request.files.length > 1 && (
<div className="border-t border-slate-700 pt-2 flex justify-between text-xs text-slate-400"> <div className="py-2 flex justify-between text-xs">
<span>{request.files.length} fichiers</span> <span className="text-ink-muted">{request.files.length} files</span>
<span>{formatSize(totalSize)}</span> <span className="font-mono text-ink-muted">{formatSize(totalSize)}</span>
</div> </div>
)} )}
</div> </div>
)} )}
{request.text && ( {request.text && (
<div className="bg-slate-800/50 rounded-xl p-3 mb-4"> <div className="mt-5 bg-paper-deep border border-paper-edge rounded-sm p-3">
<p className="text-xs text-slate-500 mb-1">Texte :</p> <div className="text-xs uppercase tracking-[0.2em] text-ink-muted mb-1.5">Text</div>
<p className="text-sm text-white whitespace-pre-wrap break-words max-h-32 overflow-y-auto"> <p className="text-sm text-ink whitespace-pre-wrap break-words max-h-32 overflow-y-auto">
{request.text} {request.text}
</p> </p>
</div> </div>
)} )}
<div className="flex gap-3"> <div className="flex gap-3 mt-6">
<button <button
onClick={onReject} onClick={onReject}
className="flex-1 px-4 py-2.5 border border-slate-600 text-slate-300 className="flex-1 px-4 py-2.5 border border-paper-edge text-ink-muted
hover:bg-slate-800 rounded-xl text-sm font-medium transition-colors" hover:text-ink hover:border-ink rounded-sm text-sm font-medium
transition-colors duration-fast ease-crisp"
> >
Refuser Decline
</button> </button>
<button <button
onClick={onAccept} onClick={onAccept}
className="flex-1 px-4 py-2.5 bg-brand-600 hover:bg-brand-500 className="flex-1 px-4 py-2.5 bg-ink text-paper rounded-sm text-sm font-medium
text-white rounded-xl text-sm font-medium transition-colors" hover:bg-signal transition-colors duration-fast ease-crisp"
> >
{request.text && request.files.length === 0 ? "Copier" : "Accepter"} {isTextOnly ? "Copy" : "Accept"}
</button> </button>
</div> </div>
</div> </div>

View File

@ -17,35 +17,45 @@ export default function TextShareModal({ onSend, onClose }: TextShareModalProps)
}; };
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"> <div
<div className="bg-slate-900 border border-slate-700 rounded-2xl p-6 w-full max-w-md shadow-2xl"> className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4"
<h2 className="text-lg font-semibold text-white mb-4">Envoyer du texte</h2> onClick={onClose}
>
<div
className="paper-panel shadow-lift rounded-sm p-6 w-full max-w-md"
onClick={(e) => e.stopPropagation()}
>
<div className="text-xs uppercase tracking-[0.2em] text-ink-muted">
Compose
</div>
<h2 className="font-display text-2xl text-ink mt-1 mb-5">Send text</h2>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<textarea <textarea
autoFocus autoFocus
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
placeholder="Tapez votre message, lien, ou texte..." placeholder="Message, link, snippet…"
className="w-full h-32 bg-slate-800 border border-slate-600 rounded-xl p-3 text-white className="w-full h-32 bg-paper border border-paper-edge rounded-sm p-3
placeholder-slate-500 resize-none focus:outline-none focus:ring-2 text-sm text-ink placeholder:text-ink-faint resize-none
focus:ring-brand-500 focus:border-transparent" focus:outline-none focus:border-ink transition-colors
duration-fast ease-crisp"
/> />
<div className="flex justify-end gap-3 mt-4"> <div className="flex justify-end gap-3 mt-5">
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="px-4 py-2 text-sm text-slate-400 hover:text-white transition-colors" className="px-3 py-2 text-sm text-ink-muted hover:text-ink transition-colors"
> >
Annuler Cancel
</button> </button>
<button <button
type="submit" type="submit"
disabled={!text.trim()} disabled={!text.trim()}
className="px-6 py-2 bg-brand-600 hover:bg-brand-500 disabled:opacity-50 className="px-5 py-2 bg-ink text-paper text-sm font-medium rounded-sm
disabled:cursor-not-allowed text-white text-sm font-medium hover:bg-signal transition-colors duration-fast ease-crisp
rounded-xl transition-colors" disabled:opacity-30 disabled:cursor-not-allowed"
> >
Envoyer Send
</button> </button>
</div> </div>
</form> </form>

View File

@ -1,64 +1,65 @@
import { useStore } from "../stores/useStore"; import { useStore } from "../stores/useStore";
function formatSize(bytes: number): string { function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} o`; if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`; if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} Go`; return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
} }
export default function TransferProgress() { export default function TransferProgress() {
const transfers = useStore((s) => s.transfers); const transfers = useStore((s) => s.transfers);
const removeTransfer = useStore((s) => s.removeTransfer); const removeTransfer = useStore((s) => s.removeTransfer);
const activeTransfers = transfers.filter((t) => t.status !== "done" || Date.now() < Date.now()); // Show all for now if (transfers.length === 0) {
return (
if (activeTransfers.length === 0) return null; <div className="mt-4 text-sm text-ink-muted">
No transfers yet.
</div>
);
}
return ( return (
<div className="space-y-2"> <div className="mt-4 divide-y divide-paper-edge border-t border-b border-paper-edge">
<h3 className="text-sm font-medium text-slate-400 uppercase tracking-wider"> {transfers.map((transfer) => (
Transferts
</h3>
{activeTransfers.map((transfer) => (
<div <div
key={transfer.id} key={transfer.id}
className="bg-slate-800/50 rounded-xl p-4 flex items-center gap-4" className="py-3 flex items-center gap-4"
> >
<div className="text-2xl"> <span className="font-mono text-[10px] uppercase tracking-[0.2em] text-ink-muted w-12 shrink-0">
{transfer.direction === "send" ? "📤" : "📥"} {transfer.direction === "send" ? "Out" : "In"}
</div> </span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{transfer.fileName}</p> <p className="text-sm text-ink truncate">{transfer.fileName}</p>
<p className="text-xs text-slate-500">{formatSize(transfer.fileSize)}</p> <p className="text-xs text-ink-faint mt-0.5">{formatSize(transfer.fileSize)}</p>
{transfer.status === "transferring" && ( {transfer.status === "transferring" && (
<div className="mt-2 h-1.5 bg-slate-700 rounded-full overflow-hidden"> <div className="mt-2 h-px bg-paper-edge overflow-hidden">
<div <div
className="h-full bg-brand-500 rounded-full transition-all duration-300" className="h-full bg-signal transition-all duration-300"
style={{ width: `${Math.round(transfer.progress * 100)}%` }} style={{ width: `${Math.round(transfer.progress * 100)}%` }}
/> />
</div> </div>
)} )}
</div> </div>
<div className="text-right"> <div className="text-right w-20 shrink-0">
{transfer.status === "pending" && ( {transfer.status === "pending" && (
<span className="text-xs text-yellow-400">En attente</span> <span className="text-xs text-warn">Waiting</span>
)} )}
{transfer.status === "transferring" && ( {transfer.status === "transferring" && (
<span className="text-xs text-brand-400"> <span className="font-mono text-xs text-signal">
{Math.round(transfer.progress * 100)}% {Math.round(transfer.progress * 100)}%
</span> </span>
)} )}
{transfer.status === "done" && ( {transfer.status === "done" && (
<button <button
onClick={() => removeTransfer(transfer.id)} onClick={() => removeTransfer(transfer.id)}
className="text-xs text-green-400 hover:text-green-300" className="text-xs text-ok hover:text-ink transition-colors"
> >
Terminé Done
</button> </button>
)} )}
{transfer.status === "error" && ( {transfer.status === "error" && (
<span className="text-xs text-red-400">Erreur</span> <span className="text-xs text-fail">Failed</span>
)} )}
</div> </div>
</div> </div>

44
web/src/design/tokens.ts Normal file
View File

@ -0,0 +1,44 @@
/**
* AnyDrop design tokens Paper & Envelope direction.
*
* Principles:
* - Paper-warm neutrals + ink black, never slate/grey-blue.
* - ONE signature accent (oxblood). Use sparingly accent earns its rarity.
* - Serif display + neutral sans body. Type does the heavy lifting, not color.
* - Shadows are textural, not dramatic. Radii stay sharp (26px).
* - Motion curves favor paper physics (ease-out-expo), never bounce.
*/
export const color = {
paper: "#F5F0E6",
paperDeep: "#EBE4D4",
paperEdge: "#DCD3BE",
ink: "#1A1714",
inkMuted: "#6B635A",
inkFaint: "#A89F93",
signal: "#7A2320",
signalQuiet: "#F3E2E0",
ok: "#3E6B4A",
warn: "#8B6914",
fail: "#8A3324",
} as const;
export const font = {
display: `"Fraunces", "GT Sectra", Georgia, serif`,
sans: `"Inter", "Söhne", system-ui, -apple-system, sans-serif`,
mono: `"JetBrains Mono", "Berkeley Mono", ui-monospace, monospace`,
} as const;
export const motion = {
fast: "160ms cubic-bezier(0.2, 0.0, 0.0, 1.0)",
base: "320ms cubic-bezier(0.2, 0.0, 0.0, 1.0)",
paper: "480ms cubic-bezier(0.16, 1, 0.3, 1)",
} as const;
export const shadow = {
paper:
"0 1px 0 rgba(26, 23, 20, 0.04), 0 1px 3px rgba(26, 23, 20, 0.06)",
lift:
"0 2px 6px rgba(26, 23, 20, 0.08), 0 8px 24px rgba(26, 23, 20, 0.08)",
} as const;

View File

@ -1,9 +1,77 @@
@import url("https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap");
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base { @layer base {
:root {
color-scheme: light;
}
html,
body { body {
@apply min-h-screen; background: #f5f0e6;
color: #1a1714;
font-family: "Inter", "Söhne", system-ui, -apple-system, sans-serif;
font-feature-settings: "ss01", "cv11";
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
/* Paper texture — very subtle noise, 2% opacity */
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='200' height='200'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.1 0 0 0 0 0.09 0 0 0 0 0.08 0 0 0 0.55 0'/></filter><rect width='100%' height='100%' filter='url(%23n)' opacity='0.18'/></svg>");
mix-blend-mode: multiply;
opacity: 0.35;
}
#root {
position: relative;
z-index: 1;
min-height: 100vh;
}
h1,
h2,
h3,
h4 {
font-family: "Fraunces", "GT Sectra", Georgia, serif;
font-weight: 500;
letter-spacing: -0.01em;
}
code,
pre,
.mono {
font-family: "JetBrains Mono", "Berkeley Mono", ui-monospace, monospace;
}
/* Selection: signal on quiet paper */
::selection {
background: #7a2320;
color: #f5f0e6;
}
}
@layer utilities {
/* Thin ruled line — evokes paper stationery */
.rule {
border-bottom: 1px solid #dcd3be;
}
.rule-strong {
border-bottom: 1px solid #1a1714;
}
/* Envelope: subtle diagonal flap hint used on panels */
.paper-panel {
background: #f5f0e6;
border: 1px solid #dcd3be;
box-shadow:
0 1px 0 rgba(26, 23, 20, 0.04),
0 1px 3px rgba(26, 23, 20, 0.06);
}
.paper-panel-deep {
background: #ebe4d4;
border: 1px solid #dcd3be;
} }
} }

View File

@ -137,7 +137,7 @@ export function createFileReceiver(callbacks: FileReceiver) {
case "file-end": { case "file-end": {
const file = receiving.get(msg.id); const file = receiving.get(msg.id);
if (file) { if (file) {
const blob = new Blob(file.chunks, { type: file.mime }); const blob = new Blob(file.chunks as BlobPart[], { type: file.mime });
callbacks.onComplete(msg.id, blob, file.name); callbacks.onComplete(msg.id, blob, file.name);
receiving.delete(msg.id); receiving.delete(msg.id);
} }

View File

@ -33,7 +33,7 @@ export async function setupPushNotifications(
const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey); const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey);
subscription = await registration.pushManager.subscribe({ subscription = await registration.pushManager.subscribe({
userVisibleOnly: true, userVisibleOnly: true,
applicationServerKey, applicationServerKey: applicationServerKey as BufferSource,
}); });
} }

View File

@ -128,7 +128,7 @@ export async function appendReceivedChunk(
const store = await tx(STORE_CHUNKS, "readwrite"); const store = await tx(STORE_CHUNKS, "readwrite");
const existing = await reqToPromise(store.get(transferId)); const existing = await reqToPromise(store.get(transferId));
const oldBlob: Blob = existing?.blob ?? new Blob(); const oldBlob: Blob = existing?.blob ?? new Blob();
const newBlob = new Blob([oldBlob, chunk]); const newBlob = new Blob([oldBlob, chunk as BlobPart]);
await reqToPromise(store.put({ transferId, blob: newBlob })); await reqToPromise(store.put({ transferId, blob: newBlob }));
return newBlob.size; return newBlob.size;
} }

View File

@ -1,4 +1,5 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { Link } from "react-router-dom";
import { useSignaling } from "../hooks/useSignaling"; import { useSignaling } from "../hooks/useSignaling";
import { useStore } from "../stores/useStore"; import { useStore } from "../stores/useStore";
import { useProfileStore } from "../stores/useProfileStore"; import { useProfileStore } from "../stores/useProfileStore";
@ -22,8 +23,16 @@ export default function Home() {
} }
function HomeConnected() { function HomeConnected() {
const { sendFiles, sendText, acceptTransfer, rejectTransfer, createPublicRoom, wakePeer, requestPairCode, resolvePairCode } = const {
useSignaling(); sendFiles,
sendText,
acceptTransfer,
rejectTransfer,
createPublicRoom,
wakePeer,
requestPairCode,
resolvePairCode,
} = useSignaling();
const peers = useStore((s) => s.peers); const peers = useStore((s) => s.peers);
const selectedPeerId = useStore((s) => s.selectedPeerId); const selectedPeerId = useStore((s) => s.selectedPeerId);
@ -36,11 +45,10 @@ function HomeConnected() {
const { deviceName, avatar } = useProfileStore(); const { deviceName, avatar } = useProfileStore();
const [showProfileEdit, setShowProfileEdit] = useState(false); const [showProfileEdit, setShowProfileEdit] = useState(false);
const [wakingDeviceId, setWakingDeviceId] = useState<string | null>(null); const [, setWakingDeviceId] = useState<string | null>(null);
const handlePeerSelect = useCallback( const handlePeerSelect = useCallback(
(peerId: string) => { (peerId: string) => {
// Check if this is an offline peer
const peer = peers.find((p) => p.peerId === peerId); const peer = peers.find((p) => p.peerId === peerId);
if (peer && peer.online === false && peer.deviceId) { if (peer && peer.online === false && peer.deviceId) {
setSelectedPeerId(peerId); setSelectedPeerId(peerId);
@ -87,82 +95,113 @@ function HomeConnected() {
}, [incomingRequest, rejectTransfer]); }, [incomingRequest, rejectTransfer]);
return ( return (
<div className="min-h-screen bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950"> <div className="min-h-screen">
<div className="max-w-lg mx-auto px-4 py-8"> <div className="max-w-xl mx-auto px-5 sm:px-8 pt-10 pb-24">
{/* Header */} {/* Masthead */}
<header className="text-center mb-8"> <header className="flex items-start justify-between pb-8 mb-10 rule">
<h1 className="text-3xl font-bold text-white mb-1"> <div>
Any<span className="text-brand-400">Drop</span> <h1 className="font-display text-4xl leading-none tracking-tight text-ink">
</h1> AnyDrop
<p className="text-slate-500 text-sm">Partage instantané, sans compte</p> </h1>
<p className="mt-3 text-xs uppercase tracking-[0.2em] text-ink-muted">
{/* Profile badge — tap to edit */} Universal transfer · Peer to peer
<button </p>
onClick={() => setShowProfileEdit(true)} </div>
className="mt-3 inline-flex items-center gap-2 px-3 py-1.5 rounded-full <DeviceChip
bg-slate-800/50 hover:bg-slate-800 transition-colors group" name={deviceName}
> avatar={avatar}
{avatar ? ( onEdit={() => setShowProfileEdit(true)}
<img src={avatar} alt="" className="w-5 h-5 rounded-full object-cover" /> />
) : (
<span className="text-sm">📱</span>
)}
<span className="text-sm text-slate-400 group-hover:text-white transition-colors">
{deviceName}
</span>
<span className="text-[10px] text-slate-600"></span>
</button>
</header> </header>
{/* Error banner */} {/* Error */}
{error && ( {error && (
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/30 rounded-xl text-sm text-red-400 flex items-center justify-between"> <div className="mb-8 px-4 py-3 border border-fail/40 bg-signal-quiet flex items-center justify-between rounded-sm">
<span>{error}</span> <span className="text-sm text-ink">{error}</span>
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-300 ml-2"> <button
onClick={() => setError(null)}
className="ml-3 text-ink-muted hover:text-ink text-lg leading-none"
aria-label="Dismiss"
>
×
</button> </button>
</div> </div>
)} )}
{/* 1. Appareils disponibles */} {/* Direct transfer */}
<section className="mb-6"> <section className="mb-14">
<PeerList onPeerSelect={handlePeerSelect} /> <SectionLabel>Direct</SectionLabel>
<SectionTitle>Send to a device nearby</SectionTitle>
<SectionLead>
Devices on the same network appear below. Transfers stay peer-to-peer and never touch the server.
</SectionLead>
<div className="mt-8">
<PeerList onPeerSelect={handlePeerSelect} />
</div>
<div className="mt-6 grid grid-cols-1 sm:grid-cols-2 gap-3">
<DevicePairingPanel
onRequestCode={requestPairCode}
onResolveCode={resolvePairCode}
/>
<PublicRoomPanel onCreateRoom={createPublicRoom} />
</div>
</section> </section>
{/* 2. Appairage + Lien public */} {/* Composer — appears when a peer is selected */}
<section className="mb-4 flex gap-3">
<DevicePairingPanel onRequestCode={requestPairCode} onResolveCode={resolvePairCode} />
<PublicRoomPanel onCreateRoom={createPublicRoom} />
</section>
{/* 3. Envoi fichiers + texte (seulement quand un peer est sélectionné) */}
{selectedPeerId && ( {selectedPeerId && (
<section className="mb-6 space-y-3"> <section className="mb-14">
<DropZone onFilesSelected={handleFilesSelected} /> <SectionLabel>Compose</SectionLabel>
<button <SectionTitle>Drop files or send text</SectionTitle>
onClick={() => setShowTextModal(true)}
className="w-full flex items-center justify-center gap-2 px-4 py-3 <div className="mt-6 space-y-3">
border border-slate-700 hover:border-brand-500 rounded-xl <DropZone onFilesSelected={handleFilesSelected} />
text-slate-300 hover:text-white transition-all <button
bg-slate-900/30 hover:bg-slate-900/50" onClick={() => setShowTextModal(true)}
> className="w-full text-left px-4 py-3 border border-paper-edge bg-paper
<span className="text-lg">💬</span> hover:bg-paper-deep transition-colors duration-fast ease-crisp
<span className="text-sm font-medium">Envoyer du texte</span> rounded-sm text-sm text-ink"
</button> >
Send text instead
</button>
</div>
</section> </section>
)} )}
{/* 4. Transferts en cours */} {/* Activity */}
<section className="mb-6"> <section className="mb-14">
<SectionLabel>Activity</SectionLabel>
<TransferProgress /> <TransferProgress />
</section> </section>
{/* Phase 2 teaser — "Via AnyDrop" cloud relay */}
<section className="mb-14">
<div className="paper-panel-deep px-5 py-5 flex items-start justify-between gap-4">
<div>
<div className="text-xs uppercase tracking-[0.2em] text-ink-muted">
Coming soon
</div>
<h3 className="font-display text-xl mt-2 text-ink">Via AnyDrop</h3>
<p className="text-sm text-ink-muted mt-1 leading-relaxed">
Send to an email address. The file is held, encrypted, for seven days the server never sees the key.
</p>
</div>
<div className="font-mono text-xs text-ink-faint uppercase tracking-widest shrink-0 pt-1">
Q2
</div>
</div>
</section>
{/* Footer */} {/* Footer */}
<footer className="text-center text-xs text-slate-600 mt-12"> <footer className="pt-8 mt-14 rule flex items-center justify-between text-xs text-ink-muted">
<p>Peer-to-peer · Chiffré · Aucun fichier ne transite par le serveur</p> <span>End-to-end encrypted · Nothing transits the server</span>
<a href="/settings" className="inline-block mt-2 text-slate-700 hover:text-slate-500 transition-colors"> <Link
Account to="/settings"
</a> className="text-ink hover:text-signal transition-colors duration-fast"
>
Account
</Link>
</footer> </footer>
</div> </div>
@ -189,3 +228,65 @@ function HomeConnected() {
</div> </div>
); );
} }
function DeviceChip({
name,
avatar,
onEdit,
}: {
name: string;
avatar: string | null;
onEdit: () => void;
}) {
return (
<button
onClick={onEdit}
className="group flex items-center gap-2.5 px-3 py-2 border border-paper-edge
hover:border-ink transition-colors duration-fast ease-crisp
bg-paper rounded-sm"
>
{avatar ? (
<img
src={avatar}
alt=""
className="w-5 h-5 rounded-full object-cover border border-paper-edge"
/>
) : (
<span
className="w-5 h-5 rounded-full bg-paper-deep border border-paper-edge
flex items-center justify-center text-[10px] text-ink-muted"
>
</span>
)}
<span className="text-sm text-ink truncate max-w-[120px]">{name}</span>
<span className="text-xs text-ink-faint group-hover:text-ink transition-colors">
edit
</span>
</button>
);
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
{children}
</div>
);
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<h2 className="font-display text-2xl text-ink mt-2 tracking-tight">
{children}
</h2>
);
}
function SectionLead({ children }: { children: React.ReactNode }) {
return (
<p className="text-sm text-ink-muted mt-2 leading-relaxed max-w-md">
{children}
</p>
);
}

View File

@ -75,93 +75,106 @@ function JoinRoomConnected({ code }: { code?: string }) {
}, [incomingRequest, rejectTransfer]); }, [incomingRequest, rejectTransfer]);
return ( return (
<div className="min-h-screen bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950"> <div className="min-h-screen">
<div className="max-w-lg mx-auto px-4 py-8"> <div className="max-w-xl mx-auto px-5 sm:px-8 pt-10 pb-24">
{/* Header */} <header className="flex items-start justify-between pb-8 mb-10 rule">
<header className="text-center mb-8"> <div>
<h1 className="text-3xl font-bold text-white mb-1"> <div className="text-xs uppercase tracking-[0.2em] text-ink-muted">
Any<span className="text-brand-400">Drop</span> Public room
</h1> </div>
<p className="text-slate-500 text-sm"> <h1 className="font-display text-3xl leading-none tracking-tight text-ink mt-2">
Room <span className="text-brand-300 font-mono font-bold">{code?.toUpperCase()}</span> AnyDrop
</p> </h1>
<p className="font-mono text-xs uppercase tracking-[0.25em] text-signal mt-3">
{code?.toUpperCase()}
</p>
</div>
<button <button
onClick={() => setShowProfileEdit(true)} onClick={() => setShowProfileEdit(true)}
className="mt-3 inline-flex items-center gap-2 px-3 py-1.5 rounded-full className="group flex items-center gap-2.5 px-3 py-2 border border-paper-edge
bg-slate-800/50 hover:bg-slate-800 transition-colors group" hover:border-ink transition-colors duration-fast ease-crisp
bg-paper rounded-sm"
> >
{avatar ? ( {avatar ? (
<img src={avatar} alt="" className="w-5 h-5 rounded-full object-cover" /> <img
src={avatar}
alt=""
className="w-5 h-5 rounded-full object-cover border border-paper-edge"
/>
) : ( ) : (
<span className="text-sm">📱</span> <span className="w-5 h-5 rounded-full bg-paper-deep border border-paper-edge" />
)} )}
<span className="text-sm text-slate-400 group-hover:text-white transition-colors"> <span className="text-sm text-ink truncate max-w-[120px]">{deviceName}</span>
{deviceName}
</span>
<span className="text-[10px] text-slate-600"></span>
</button> </button>
</header> </header>
{/* Error banner */}
{error && ( {error && (
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/30 rounded-xl text-sm text-red-400 flex items-center justify-between"> <div className="mb-8 px-4 py-3 border border-fail/40 bg-signal-quiet flex items-center justify-between rounded-sm">
<span>{error}</span> <span className="text-sm text-ink">{error}</span>
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-300 ml-2"> <button
onClick={() => setError(null)}
className="ml-3 text-ink-muted hover:text-ink text-lg leading-none"
aria-label="Dismiss"
>
×
</button> </button>
</div> </div>
)} )}
{/* Peer list */} <section className="mb-12">
<section className="mb-6"> <div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
Room peers
</div>
<h2 className="font-display text-2xl text-ink mt-2 mb-5 tracking-tight">
Who's here
</h2>
<PeerList onPeerSelect={handlePeerSelect} /> <PeerList onPeerSelect={handlePeerSelect} />
</section> </section>
{/* Drop zone */}
<section className="mb-6">
<DropZone onFilesSelected={handleFilesSelected} disabled={!selectedPeerId} />
</section>
{/* Text share button */}
{selectedPeerId && ( {selectedPeerId && (
<section className="mb-6"> <section className="mb-12">
<button <div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
onClick={() => setShowTextModal(true)} Compose
className="w-full flex items-center justify-center gap-2 px-4 py-3 </div>
border border-slate-700 hover:border-brand-500 rounded-xl <h2 className="font-display text-2xl text-ink mt-2 mb-5 tracking-tight">
text-slate-300 hover:text-white transition-all Drop files or send text
bg-slate-900/30 hover:bg-slate-900/50" </h2>
> <div className="space-y-3">
<span className="text-lg">💬</span> <DropZone onFilesSelected={handleFilesSelected} />
<span className="text-sm font-medium">Envoyer du texte</span> <button
</button> onClick={() => setShowTextModal(true)}
className="w-full text-left px-4 py-3 border border-paper-edge bg-paper
hover:bg-paper-deep transition-colors duration-fast ease-crisp
rounded-sm text-sm text-ink"
>
Send text instead
</button>
</div>
</section> </section>
)} )}
{/* Transfer progress */} <section className="mb-12">
<section className="mb-6"> <div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
Activity
</div>
<TransferProgress /> <TransferProgress />
</section> </section>
{/* Back to home */} <footer className="pt-8 mt-14 rule flex items-center justify-between text-xs text-ink-muted">
<section className="text-center"> <span>End-to-end encrypted · Nothing transits the server</span>
<a href="/" className="text-sm text-slate-500 hover:text-slate-300 transition-colors"> <a
Retour à l'accueil href="/"
className="text-ink hover:text-signal transition-colors duration-fast"
>
Home
</a> </a>
</section>
{/* Footer */}
<footer className="text-center text-xs text-slate-600 mt-12">
<p>Peer-to-peer · Chiffré · Aucun fichier ne transite par le serveur</p>
</footer> </footer>
</div> </div>
{/* Profile edit modal */}
{showProfileEdit && ( {showProfileEdit && (
<ProfileSetup isEditing onDone={() => setShowProfileEdit(false)} /> <ProfileSetup isEditing onDone={() => setShowProfileEdit(false)} />
)} )}
{/* Modals */}
{showTextModal && selectedPeerId && ( {showTextModal && selectedPeerId && (
<TextShareModal <TextShareModal
onSend={handleSendText} onSend={handleSendText}

View File

@ -5,14 +5,13 @@ import { useProfileStore } from "../stores/useProfileStore";
export default function Pair() { export default function Pair() {
const [params] = useSearchParams(); const [params] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { setGroupId, isSetUp } = useProfileStore(); const { setGroupId } = useProfileStore();
const groupId = params.get("g"); const groupId = params.get("g");
useEffect(() => { useEffect(() => {
if (groupId) { if (groupId) {
setGroupId(groupId); setGroupId(groupId);
// Small delay so user sees the confirmation
const t = setTimeout(() => navigate("/", { replace: true }), 1500); const t = setTimeout(() => navigate("/", { replace: true }), 1500);
return () => clearTimeout(t); return () => clearTimeout(t);
} }
@ -20,18 +19,29 @@ export default function Pair() {
if (!groupId) { if (!groupId) {
return ( return (
<div className="min-h-screen bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950 flex items-center justify-center"> <div className="min-h-screen flex items-center justify-center px-4">
<p className="text-slate-400">Lien d'appairage invalide.</p> <div className="paper-panel px-6 py-5 text-center">
<div className="text-xs uppercase tracking-[0.22em] text-fail">Invalid</div>
<p className="mt-2 text-sm text-ink">This pairing link is malformed.</p>
</div>
</div> </div>
); );
} }
return ( return (
<div className="min-h-screen bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950 flex flex-col items-center justify-center gap-4"> <div className="min-h-screen flex items-center justify-center px-4">
<div className="text-5xl"></div> <div className="paper-panel px-8 py-8 max-w-sm w-full text-center">
<h1 className="text-xl font-bold text-white">Appareil appairé !</h1> <div className="text-xs uppercase tracking-[0.22em] text-ok">Paired</div>
<p className="text-slate-400 text-sm">Vos appareils se verront automatiquement.</p> <h1 className="font-display text-2xl text-ink mt-2 tracking-tight">
<p className="text-slate-600 text-xs">Redirection...</p> Device linked
</h1>
<p className="text-sm text-ink-muted mt-3 leading-relaxed">
Your devices will recognize each other automatically from now on.
</p>
<p className="text-xs text-ink-faint mt-6 font-mono uppercase tracking-widest">
Redirecting
</p>
</div>
</div> </div>
); );
} }

View File

@ -37,50 +37,77 @@ export default function Settings() {
}, [user, profile.isSetUp, profile.deviceId, profile.deviceName, profile.deviceType, profile.avatar, devices, setDevices]); }, [user, profile.isSetUp, profile.deviceId, profile.deviceName, profile.deviceType, profile.avatar, devices, setDevices]);
if (!loaded || loading) { if (!loaded || loading) {
return <SettingsShell><p className="text-slate-400">Loading</p></SettingsShell>; return (
<SettingsShell>
<p className="text-sm text-ink-muted">Loading</p>
</SettingsShell>
);
} }
if (!user) { if (!user) {
return <SettingsShell><SignInForm initialError={error} /></SettingsShell>; return (
<SettingsShell>
<SignInForm initialError={error} />
</SettingsShell>
);
} }
return ( return (
<SettingsShell> <SettingsShell>
{signedIn && ( {signedIn && (
<div className="mb-4 rounded-lg bg-emerald-500/10 border border-emerald-500/30 px-4 py-3 text-sm text-emerald-200"> <div className="mb-8 px-4 py-3 border border-ok/40 bg-paper-deep rounded-sm text-sm text-ink">
Signed in successfully. Signed in successfully.
</div> </div>
)} )}
<section className="mb-8"> <section className="mb-12">
<h2 className="text-xs uppercase tracking-wider text-slate-500 mb-2">Account</h2> <div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
<div className="rounded-xl bg-slate-900/60 border border-slate-800 p-4"> Account
<div className="text-sm text-slate-400">Email</div> </div>
<div className="text-slate-100">{user.email}</div> <h2 className="font-display text-2xl text-ink mt-2 mb-5 tracking-tight">
<div className="mt-3 text-xs text-slate-500">Plan: <span className="text-slate-300">{user.plan}</span></div> Your identity
</h2>
<div className="paper-panel px-5 py-4">
<div className="text-xs uppercase tracking-[0.15em] text-ink-muted">Email</div>
<div className="text-ink mt-1">{user.email}</div>
<div className="mt-4 pt-4 border-t border-paper-edge flex items-baseline justify-between">
<div className="text-xs uppercase tracking-[0.15em] text-ink-muted">Plan</div>
<div className="font-mono text-xs text-ink uppercase tracking-widest">
{user.plan}
</div>
</div>
</div> </div>
</section> </section>
<section className="mb-8"> <section className="mb-12">
<h2 className="text-xs uppercase tracking-wider text-slate-500 mb-2">Devices</h2> <div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
<div className="rounded-xl bg-slate-900/60 border border-slate-800 divide-y divide-slate-800"> Devices
</div>
<h2 className="font-display text-2xl text-ink mt-2 mb-5 tracking-tight">
Linked devices
</h2>
<div className="paper-panel divide-y divide-paper-edge">
{devices.length === 0 && ( {devices.length === 0 && (
<div className="px-4 py-6 text-sm text-slate-500">No devices linked yet.</div> <div className="px-5 py-6 text-sm text-ink-muted">No devices linked yet.</div>
)} )}
{devices.map((d) => { {devices.map((d) => {
const isCurrent = d.deviceId === profile.deviceId; const isCurrent = d.deviceId === profile.deviceId;
return ( return (
<div key={d.id} className="flex items-center justify-between px-4 py-3"> <div key={d.id} className="flex items-center justify-between px-5 py-4">
<div> <div>
<div className="text-slate-100 flex items-center gap-2"> <div className="text-ink flex items-center gap-3">
{d.name} <span>{d.name}</span>
{isCurrent && ( {isCurrent && (
<span className="text-[10px] uppercase tracking-wider bg-indigo-500/20 text-indigo-300 px-1.5 py-0.5 rounded"> <span className="font-mono text-[10px] uppercase tracking-[0.2em]
text-signal border border-signal/40 bg-signal-quiet
px-1.5 py-0.5 rounded-sm">
this device this device
</span> </span>
)} )}
</div> </div>
<div className="text-xs text-slate-500">{d.type} · linked {new Date(d.linkedAt).toLocaleDateString()}</div> <div className="text-xs text-ink-muted mt-1">
{d.type} · linked {new Date(d.linkedAt).toLocaleDateString()}
</div>
</div> </div>
{!isCurrent && ( {!isCurrent && (
<button <button
@ -88,9 +115,9 @@ export default function Settings() {
await unlinkDevice(d.id); await unlinkDevice(d.id);
setDevices(devices.filter((x) => x.id !== d.id)); setDevices(devices.filter((x) => x.id !== d.id));
}} }}
className="text-xs text-slate-400 hover:text-rose-400" className="text-xs text-ink-muted hover:text-signal transition-colors"
> >
Remove Unlink
</button> </button>
)} )}
</div> </div>
@ -101,7 +128,7 @@ export default function Settings() {
<button <button
onClick={() => signOut()} onClick={() => signOut()}
className="text-sm text-slate-400 hover:text-rose-400" className="text-sm text-ink-muted hover:text-signal transition-colors"
> >
Sign out Sign out
</button> </button>
@ -116,24 +143,34 @@ function SignInForm({ initialError }: { initialError: string | null }) {
if (submitted) { if (submitted) {
return ( return (
<div className="rounded-xl bg-slate-900/60 border border-slate-800 p-6 text-center"> <div className="paper-panel px-6 py-8 text-center">
<div className="text-xl mb-2">📬</div> <div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
<h2 className="text-lg text-slate-100 mb-2">Check your inbox</h2> Check your inbox
<p className="text-sm text-slate-400"> </div>
If an account exists for <span className="text-slate-200">{email}</span>, a sign-in link is on its way. It expires in 15 minutes. <h2 className="font-display text-2xl text-ink mt-2 mb-3">
A sign-in link is on its way
</h2>
<p className="text-sm text-ink-muted leading-relaxed">
If an account exists for <span className="text-ink">{email}</span>, we
sent a link. It expires in 15 minutes.
</p> </p>
</div> </div>
); );
} }
return ( return (
<div className="rounded-xl bg-slate-900/60 border border-slate-800 p-6"> <div className="paper-panel px-6 py-7">
<h2 className="text-lg text-slate-100 mb-2">Sign in</h2> <div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
<p className="text-sm text-slate-400 mb-4"> Sign in
Optional. Signing in lets you sync your profile and devices across browsers. Your transfers stay peer-to-peer. </div>
<h2 className="font-display text-2xl text-ink mt-2 mb-3 tracking-tight">
Sync across browsers
</h2>
<p className="text-sm text-ink-muted mb-6 leading-relaxed">
Optional. Signing in keeps your profile and devices together. Transfers stay peer-to-peer.
</p> </p>
{initialError && ( {initialError && (
<div className="mb-4 rounded-lg bg-rose-500/10 border border-rose-500/30 px-3 py-2 text-xs text-rose-200"> <div className="mb-4 px-3 py-2 border border-fail/40 bg-signal-quiet text-xs text-ink rounded-sm">
{initialError === "invalid_or_expired" {initialError === "invalid_or_expired"
? "That link has expired or already been used." ? "That link has expired or already been used."
: "Something went wrong. Please try again."} : "Something went wrong. Please try again."}
@ -159,14 +196,19 @@ function SignInForm({ initialError }: { initialError: string | null }) {
placeholder="you@example.com" placeholder="you@example.com"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
className="bg-slate-950 border border-slate-700 rounded-lg px-3 py-2 text-slate-100 placeholder-slate-500 focus:outline-none focus:border-indigo-500" className="bg-paper border border-paper-edge rounded-sm px-3 py-2.5
text-ink text-sm placeholder:text-ink-faint
focus:outline-none focus:border-ink transition-colors
duration-fast ease-crisp"
/> />
<button <button
type="submit" type="submit"
disabled={submitting} disabled={submitting}
className="bg-indigo-500 hover:bg-indigo-400 disabled:opacity-50 text-white font-medium rounded-lg px-4 py-2" className="bg-ink text-paper text-sm font-medium rounded-sm px-4 py-2.5
hover:bg-signal transition-colors duration-fast ease-crisp
disabled:opacity-40"
> >
{submitting ? "Sending…" : "Send sign-in link"} {submitting ? "Sending…" : "Send sign-in link"}
</button> </button>
</form> </form>
</div> </div>
@ -175,13 +217,18 @@ function SignInForm({ initialError }: { initialError: string | null }) {
function SettingsShell({ children }: { children: React.ReactNode }) { function SettingsShell({ children }: { children: React.ReactNode }) {
return ( return (
<div className="min-h-screen bg-slate-950 text-slate-100 px-4 py-10"> <div className="min-h-screen">
<div className="max-w-lg mx-auto"> <div className="max-w-xl mx-auto px-5 sm:px-8 pt-10 pb-24">
<div className="mb-6 flex items-center justify-between"> <header className="flex items-center justify-between pb-8 mb-10 rule">
<Link to="/" className="text-sm text-slate-400 hover:text-slate-200"> Back</Link> <Link
<h1 className="text-xl font-semibold">Account</h1> to="/"
className="text-sm text-ink-muted hover:text-ink transition-colors duration-fast"
>
Back
</Link>
<h1 className="font-display text-xl text-ink tracking-tight">Account</h1>
<div className="w-10" /> <div className="w-10" />
</div> </header>
{children} {children}
</div> </div>
</div> </div>

View File

@ -10,33 +10,29 @@ interface SharedData {
text: string; text: string;
} }
/** Read shared files/text from the cache stashed by the service worker */
async function readSharedData(): Promise<SharedData> { async function readSharedData(): Promise<SharedData> {
const result: SharedData = { files: [], text: "" }; const result: SharedData = { files: [], text: "" };
try { try {
const cache = await caches.open("share-target"); const cache = await caches.open("share-target");
// Read metadata
const metaResponse = await cache.match("/share-target-meta"); const metaResponse = await cache.match("/share-target-meta");
if (!metaResponse) return result; if (!metaResponse) return result;
const meta = await metaResponse.json(); const meta = await metaResponse.json();
result.text = meta.text || ""; result.text = meta.text || "";
// Read files
for (let i = 0; i < (meta.count || 0); i++) { for (let i = 0; i < (meta.count || 0); i++) {
const fileResponse = await cache.match(`/share-target-file/${i}`); const fileResponse = await cache.match(`/share-target-file/${i}`);
if (!fileResponse) continue; if (!fileResponse) continue;
const blob = await fileResponse.blob(); const blob = await fileResponse.blob();
const fileName = decodeURIComponent( const fileName = decodeURIComponent(
fileResponse.headers.get("X-File-Name") || `fichier-${i}`, fileResponse.headers.get("X-File-Name") || `file-${i}`,
); );
result.files.push(new File([blob], fileName, { type: blob.type })); result.files.push(new File([blob], fileName, { type: blob.type }));
} }
// Clean up cache
await caches.delete("share-target"); await caches.delete("share-target");
} catch (err) { } catch (err) {
console.error("[share] Failed to read shared data:", err); console.error("[share] Failed to read shared data:", err);
@ -57,7 +53,6 @@ export default function Share() {
function ShareConnected() { function ShareConnected() {
const { sendFiles, sendText } = useSignaling(); const { sendFiles, sendText } = useSignaling();
const peers = useStore((s) => s.peers);
const setSelectedPeerId = useStore((s) => s.setSelectedPeerId); const setSelectedPeerId = useStore((s) => s.setSelectedPeerId);
const [shared, setShared] = useState<SharedData | null>(null); const [shared, setShared] = useState<SharedData | null>(null);
@ -90,67 +85,72 @@ function ShareConnected() {
const hasText = !!shared?.text; const hasText = !!shared?.text;
return ( return (
<div className="min-h-screen bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950"> <div className="min-h-screen">
<div className="max-w-lg mx-auto px-4 py-8"> <div className="max-w-xl mx-auto px-5 sm:px-8 pt-10 pb-24">
{/* Header */} <header className="pb-8 mb-10 rule">
<header className="text-center mb-6"> <div className="text-xs uppercase tracking-[0.2em] text-ink-muted">
<h1 className="text-3xl font-bold text-white mb-1"> Share sheet
Any<span className="text-brand-400">Drop</span> </div>
<h1 className="font-display text-3xl leading-none tracking-tight text-ink mt-2">
Send via AnyDrop
</h1> </h1>
{!shared ? (
<p className="text-slate-500 text-sm mt-4">Chargement...</p>
) : sent ? (
<div className="mt-6">
<div className="text-4xl mb-3"></div>
<p className="text-brand-400 font-medium">Envoi en cours</p>
<a
href="/"
className="inline-block mt-4 text-sm text-slate-500 hover:text-slate-300 transition-colors"
>
Retour à l'accueil
</a>
</div>
) : (
<>
<p className="text-slate-400 text-sm mt-4">Envoyer à quel appareil ?</p>
{/* What's being shared */}
<div className="mt-3 inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-slate-800/50 text-xs text-slate-400">
{fileCount > 0 && (
<span>
📎 {fileCount} {fileCount > 1 ? "fichiers" : "fichier"}
</span>
)}
{hasText && !fileCount && <span>💬 Texte</span>}
{hasText && fileCount > 0 && <span>+ texte</span>}
</div>
</>
)}
</header> </header>
{/* Peer list — tap to send immediately */} {!shared ? (
{!sent && shared && ( <p className="text-sm text-ink-muted">Loading</p>
<section> ) : sent ? (
{peers.length === 0 ? ( <div className="paper-panel px-6 py-8 text-center">
<div className="flex flex-col items-center justify-center py-16 text-slate-500"> <div className="text-xs uppercase tracking-[0.22em] text-ok">Sending</div>
<div className="text-5xl mb-4">📡</div> <h2 className="font-display text-2xl text-ink mt-2 tracking-tight">
<p className="text-lg font-medium">En attente d'appareils...</p> Transfer on its way
<p className="text-sm mt-2 text-center max-w-xs"> </h2>
Ouvrez AnyDrop sur l'appareil destinataire. <a
</p> href="/"
className="inline-block mt-5 text-sm text-ink hover:text-signal transition-colors"
>
Back home
</a>
</div>
) : (
<>
<section className="mb-10">
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
Payload
</div> </div>
) : ( <div className="mt-3 paper-panel px-4 py-3 inline-flex items-center gap-3
<div className="flex flex-wrap justify-center gap-6 py-8"> text-sm text-ink">
<PeerList onPeerSelect={handlePeerSelect} /> {fileCount > 0 && (
<span className="font-mono text-xs uppercase tracking-widest text-ink-muted">
{fileCount} {fileCount > 1 ? "files" : "file"}
</span>
)}
{hasText && !fileCount && (
<span className="font-mono text-xs uppercase tracking-widest text-ink-muted">
Text
</span>
)}
{hasText && fileCount > 0 && (
<span className="font-mono text-xs uppercase tracking-widest text-ink-muted">
+ text
</span>
)}
</div> </div>
)} </section>
</section>
<section>
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
Pick a device
</div>
<h2 className="font-display text-2xl text-ink mt-2 mb-5 tracking-tight">
Who should receive this?
</h2>
<PeerList onPeerSelect={handlePeerSelect} />
</section>
</>
)} )}
{/* Footer */} <footer className="pt-8 mt-14 rule text-xs text-ink-muted">
<footer className="text-center text-xs text-slate-600 mt-12"> End-to-end encrypted · Nothing transits the server
<p>Peer-to-peer · Chiffré · Aucun fichier ne transite par le serveur</p>
</footer> </footer>
</div> </div>
</div> </div>

View File

@ -1,22 +1,59 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
darkMode: "class",
theme: { theme: {
extend: { extend: {
colors: { colors: {
brand: { paper: "#F5F0E6",
50: "#eef2ff", "paper-deep": "#EBE4D4",
100: "#e0e7ff", "paper-edge": "#DCD3BE",
200: "#c7d2fe", ink: "#1A1714",
300: "#a5b4fc", "ink-muted": "#6B635A",
400: "#818cf8", "ink-faint": "#A89F93",
500: "#6366f1", signal: "#7A2320",
600: "#4f46e5", "signal-quiet": "#F3E2E0",
700: "#4338ca", ok: "#3E6B4A",
800: "#3730a3", warn: "#8B6914",
900: "#312e81", fail: "#8A3324",
}, },
fontFamily: {
display: ['"Fraunces"', '"GT Sectra"', "Georgia", "serif"],
sans: ['"Inter"', '"Söhne"', "system-ui", "-apple-system", "sans-serif"],
mono: ['"JetBrains Mono"', '"Berkeley Mono"', "ui-monospace", "monospace"],
},
fontSize: {
// Paper scale — ratio 1.25, capped hard
xs: ["12px", { lineHeight: "1.45" }],
sm: ["14px", { lineHeight: "1.5" }],
base: ["15px", { lineHeight: "1.55" }],
lg: ["17px", { lineHeight: "1.5" }],
xl: ["22px", { lineHeight: "1.35" }],
"2xl": ["32px", { lineHeight: "1.2" }],
"3xl": ["48px", { lineHeight: "1.05" }],
"4xl": ["72px", { lineHeight: "1.0", letterSpacing: "-0.02em" }],
},
borderRadius: {
none: "0",
sm: "2px",
DEFAULT: "4px",
md: "4px",
lg: "6px",
pill: "999px",
},
boxShadow: {
paper:
"0 1px 0 rgba(26,23,20,0.04), 0 1px 3px rgba(26,23,20,0.06)",
lift:
"0 2px 6px rgba(26,23,20,0.08), 0 8px 24px rgba(26,23,20,0.08)",
},
transitionTimingFunction: {
paper: "cubic-bezier(0.16, 1, 0.3, 1)",
crisp: "cubic-bezier(0.2, 0.0, 0.0, 1.0)",
},
transitionDuration: {
fast: "160ms",
base: "320ms",
paper: "480ms",
}, },
}, },
}, },