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>
<html lang="fr" class="dark">
<html lang="fr">
<head>
<meta charset="UTF-8" />
<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="theme-color" content="#6366f1" />
<meta name="description" content="AnyDrop — Instant, peer-to-peer file and text transfer, universal across platforms." />
<meta name="theme-color" content="#F5F0E6" />
<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>
<body class="bg-slate-950 text-white antialiased">
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,35 +17,45 @@ export default function TextShareModal({ onSend, onClose }: TextShareModalProps)
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-slate-900 border border-slate-700 rounded-2xl p-6 w-full max-w-md shadow-2xl">
<h2 className="text-lg font-semibold text-white mb-4">Envoyer du texte</h2>
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4"
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}>
<textarea
autoFocus
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Tapez votre message, lien, ou texte..."
className="w-full h-32 bg-slate-800 border border-slate-600 rounded-xl p-3 text-white
placeholder-slate-500 resize-none focus:outline-none focus:ring-2
focus:ring-brand-500 focus:border-transparent"
placeholder="Message, link, snippet…"
className="w-full h-32 bg-paper border border-paper-edge rounded-sm p-3
text-sm text-ink placeholder:text-ink-faint resize-none
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
type="button"
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
type="submit"
disabled={!text.trim()}
className="px-6 py-2 bg-brand-600 hover:bg-brand-500 disabled:opacity-50
disabled:cursor-not-allowed text-white text-sm font-medium
rounded-xl transition-colors"
className="px-5 py-2 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"
>
Envoyer
Send
</button>
</div>
</form>

View File

@ -1,64 +1,65 @@
import { useStore } from "../stores/useStore";
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} o`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} Go`;
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(1)} GB`;
}
export default function TransferProgress() {
const transfers = useStore((s) => s.transfers);
const removeTransfer = useStore((s) => s.removeTransfer);
const activeTransfers = transfers.filter((t) => t.status !== "done" || Date.now() < Date.now()); // Show all for now
if (activeTransfers.length === 0) return null;
if (transfers.length === 0) {
return (
<div className="mt-4 text-sm text-ink-muted">
No transfers yet.
</div>
);
}
return (
<div className="space-y-2">
<h3 className="text-sm font-medium text-slate-400 uppercase tracking-wider">
Transferts
</h3>
{activeTransfers.map((transfer) => (
<div className="mt-4 divide-y divide-paper-edge border-t border-b border-paper-edge">
{transfers.map((transfer) => (
<div
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">
{transfer.direction === "send" ? "📤" : "📥"}
</div>
<span className="font-mono text-[10px] uppercase tracking-[0.2em] text-ink-muted w-12 shrink-0">
{transfer.direction === "send" ? "Out" : "In"}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{transfer.fileName}</p>
<p className="text-xs text-slate-500">{formatSize(transfer.fileSize)}</p>
<p className="text-sm text-ink truncate">{transfer.fileName}</p>
<p className="text-xs text-ink-faint mt-0.5">{formatSize(transfer.fileSize)}</p>
{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
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)}%` }}
/>
</div>
)}
</div>
<div className="text-right">
<div className="text-right w-20 shrink-0">
{transfer.status === "pending" && (
<span className="text-xs text-yellow-400">En attente</span>
<span className="text-xs text-warn">Waiting</span>
)}
{transfer.status === "transferring" && (
<span className="text-xs text-brand-400">
<span className="font-mono text-xs text-signal">
{Math.round(transfer.progress * 100)}%
</span>
)}
{transfer.status === "done" && (
<button
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>
)}
{transfer.status === "error" && (
<span className="text-xs text-red-400">Erreur</span>
<span className="text-xs text-fail">Failed</span>
)}
</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 components;
@tailwind utilities;
@layer base {
:root {
color-scheme: light;
}
html,
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": {
const file = receiving.get(msg.id);
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);
receiving.delete(msg.id);
}

View File

@ -33,7 +33,7 @@ export async function setupPushNotifications(
const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey);
subscription = await registration.pushManager.subscribe({
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 existing = await reqToPromise(store.get(transferId));
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 }));
return newBlob.size;
}

View File

@ -1,4 +1,5 @@
import { useCallback, useState } from "react";
import { Link } from "react-router-dom";
import { useSignaling } from "../hooks/useSignaling";
import { useStore } from "../stores/useStore";
import { useProfileStore } from "../stores/useProfileStore";
@ -22,8 +23,16 @@ export default function Home() {
}
function HomeConnected() {
const { sendFiles, sendText, acceptTransfer, rejectTransfer, createPublicRoom, wakePeer, requestPairCode, resolvePairCode } =
useSignaling();
const {
sendFiles,
sendText,
acceptTransfer,
rejectTransfer,
createPublicRoom,
wakePeer,
requestPairCode,
resolvePairCode,
} = useSignaling();
const peers = useStore((s) => s.peers);
const selectedPeerId = useStore((s) => s.selectedPeerId);
@ -36,11 +45,10 @@ function HomeConnected() {
const { deviceName, avatar } = useProfileStore();
const [showProfileEdit, setShowProfileEdit] = useState(false);
const [wakingDeviceId, setWakingDeviceId] = useState<string | null>(null);
const [, setWakingDeviceId] = useState<string | null>(null);
const handlePeerSelect = useCallback(
(peerId: string) => {
// Check if this is an offline peer
const peer = peers.find((p) => p.peerId === peerId);
if (peer && peer.online === false && peer.deviceId) {
setSelectedPeerId(peerId);
@ -87,82 +95,113 @@ function HomeConnected() {
}, [incomingRequest, rejectTransfer]);
return (
<div className="min-h-screen bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950">
<div className="max-w-lg mx-auto px-4 py-8">
{/* Header */}
<header className="text-center mb-8">
<h1 className="text-3xl font-bold text-white mb-1">
Any<span className="text-brand-400">Drop</span>
</h1>
<p className="text-slate-500 text-sm">Partage instantané, sans compte</p>
{/* Profile badge — tap to edit */}
<button
onClick={() => setShowProfileEdit(true)}
className="mt-3 inline-flex items-center gap-2 px-3 py-1.5 rounded-full
bg-slate-800/50 hover:bg-slate-800 transition-colors group"
>
{avatar ? (
<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>
<div className="min-h-screen">
<div className="max-w-xl mx-auto px-5 sm:px-8 pt-10 pb-24">
{/* Masthead */}
<header className="flex items-start justify-between pb-8 mb-10 rule">
<div>
<h1 className="font-display text-4xl leading-none tracking-tight text-ink">
AnyDrop
</h1>
<p className="mt-3 text-xs uppercase tracking-[0.2em] text-ink-muted">
Universal transfer · Peer to peer
</p>
</div>
<DeviceChip
name={deviceName}
avatar={avatar}
onEdit={() => setShowProfileEdit(true)}
/>
</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">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-300 ml-2">
<div className="mb-8 px-4 py-3 border border-fail/40 bg-signal-quiet flex items-center justify-between rounded-sm">
<span className="text-sm text-ink">{error}</span>
<button
onClick={() => setError(null)}
className="ml-3 text-ink-muted hover:text-ink text-lg leading-none"
aria-label="Dismiss"
>
×
</button>
</div>
)}
{/* 1. Appareils disponibles */}
<section className="mb-6">
<PeerList onPeerSelect={handlePeerSelect} />
{/* Direct transfer */}
<section className="mb-14">
<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>
{/* 2. Appairage + Lien public */}
<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é) */}
{/* Composer — appears when a peer is selected */}
{selectedPeerId && (
<section className="mb-6 space-y-3">
<DropZone onFilesSelected={handleFilesSelected} />
<button
onClick={() => setShowTextModal(true)}
className="w-full flex items-center justify-center gap-2 px-4 py-3
border border-slate-700 hover:border-brand-500 rounded-xl
text-slate-300 hover:text-white transition-all
bg-slate-900/30 hover:bg-slate-900/50"
>
<span className="text-lg">💬</span>
<span className="text-sm font-medium">Envoyer du texte</span>
</button>
<section className="mb-14">
<SectionLabel>Compose</SectionLabel>
<SectionTitle>Drop files or send text</SectionTitle>
<div className="mt-6 space-y-3">
<DropZone onFilesSelected={handleFilesSelected} />
<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>
)}
{/* 4. Transferts en cours */}
<section className="mb-6">
{/* Activity */}
<section className="mb-14">
<SectionLabel>Activity</SectionLabel>
<TransferProgress />
</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 className="text-center text-xs text-slate-600 mt-12">
<p>Peer-to-peer · Chiffré · Aucun fichier ne transite par le serveur</p>
<a href="/settings" className="inline-block mt-2 text-slate-700 hover:text-slate-500 transition-colors">
Account
</a>
<footer className="pt-8 mt-14 rule flex items-center justify-between text-xs text-ink-muted">
<span>End-to-end encrypted · Nothing transits the server</span>
<Link
to="/settings"
className="text-ink hover:text-signal transition-colors duration-fast"
>
Account
</Link>
</footer>
</div>
@ -189,3 +228,65 @@ function HomeConnected() {
</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]);
return (
<div className="min-h-screen bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950">
<div className="max-w-lg mx-auto px-4 py-8">
{/* Header */}
<header className="text-center mb-8">
<h1 className="text-3xl font-bold text-white mb-1">
Any<span className="text-brand-400">Drop</span>
</h1>
<p className="text-slate-500 text-sm">
Room <span className="text-brand-300 font-mono font-bold">{code?.toUpperCase()}</span>
</p>
<div className="min-h-screen">
<div className="max-w-xl mx-auto px-5 sm:px-8 pt-10 pb-24">
<header className="flex items-start justify-between pb-8 mb-10 rule">
<div>
<div className="text-xs uppercase tracking-[0.2em] text-ink-muted">
Public room
</div>
<h1 className="font-display text-3xl leading-none tracking-tight text-ink mt-2">
AnyDrop
</h1>
<p className="font-mono text-xs uppercase tracking-[0.25em] text-signal mt-3">
{code?.toUpperCase()}
</p>
</div>
<button
onClick={() => setShowProfileEdit(true)}
className="mt-3 inline-flex items-center gap-2 px-3 py-1.5 rounded-full
bg-slate-800/50 hover:bg-slate-800 transition-colors group"
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" />
<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">
{deviceName}
</span>
<span className="text-[10px] text-slate-600"></span>
<span className="text-sm text-ink truncate max-w-[120px]">{deviceName}</span>
</button>
</header>
{/* Error banner */}
{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">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-300 ml-2">
<div className="mb-8 px-4 py-3 border border-fail/40 bg-signal-quiet flex items-center justify-between rounded-sm">
<span className="text-sm text-ink">{error}</span>
<button
onClick={() => setError(null)}
className="ml-3 text-ink-muted hover:text-ink text-lg leading-none"
aria-label="Dismiss"
>
×
</button>
</div>
)}
{/* Peer list */}
<section className="mb-6">
<section className="mb-12">
<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} />
</section>
{/* Drop zone */}
<section className="mb-6">
<DropZone onFilesSelected={handleFilesSelected} disabled={!selectedPeerId} />
</section>
{/* Text share button */}
{selectedPeerId && (
<section className="mb-6">
<button
onClick={() => setShowTextModal(true)}
className="w-full flex items-center justify-center gap-2 px-4 py-3
border border-slate-700 hover:border-brand-500 rounded-xl
text-slate-300 hover:text-white transition-all
bg-slate-900/30 hover:bg-slate-900/50"
>
<span className="text-lg">💬</span>
<span className="text-sm font-medium">Envoyer du texte</span>
</button>
<section className="mb-12">
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
Compose
</div>
<h2 className="font-display text-2xl text-ink mt-2 mb-5 tracking-tight">
Drop files or send text
</h2>
<div className="space-y-3">
<DropZone onFilesSelected={handleFilesSelected} />
<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>
)}
{/* Transfer progress */}
<section className="mb-6">
<section className="mb-12">
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
Activity
</div>
<TransferProgress />
</section>
{/* Back to home */}
<section className="text-center">
<a href="/" className="text-sm text-slate-500 hover:text-slate-300 transition-colors">
Retour à l'accueil
<footer className="pt-8 mt-14 rule flex items-center justify-between text-xs text-ink-muted">
<span>End-to-end encrypted · Nothing transits the server</span>
<a
href="/"
className="text-ink hover:text-signal transition-colors duration-fast"
>
Home
</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>
</div>
{/* Profile edit modal */}
{showProfileEdit && (
<ProfileSetup isEditing onDone={() => setShowProfileEdit(false)} />
)}
{/* Modals */}
{showTextModal && selectedPeerId && (
<TextShareModal
onSend={handleSendText}

View File

@ -5,14 +5,13 @@ import { useProfileStore } from "../stores/useProfileStore";
export default function Pair() {
const [params] = useSearchParams();
const navigate = useNavigate();
const { setGroupId, isSetUp } = useProfileStore();
const { setGroupId } = useProfileStore();
const groupId = params.get("g");
useEffect(() => {
if (groupId) {
setGroupId(groupId);
// Small delay so user sees the confirmation
const t = setTimeout(() => navigate("/", { replace: true }), 1500);
return () => clearTimeout(t);
}
@ -20,18 +19,29 @@ export default function Pair() {
if (!groupId) {
return (
<div className="min-h-screen bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950 flex items-center justify-center">
<p className="text-slate-400">Lien d'appairage invalide.</p>
<div className="min-h-screen flex items-center justify-center px-4">
<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>
);
}
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="text-5xl"></div>
<h1 className="text-xl font-bold text-white">Appareil appairé !</h1>
<p className="text-slate-400 text-sm">Vos appareils se verront automatiquement.</p>
<p className="text-slate-600 text-xs">Redirection...</p>
<div className="min-h-screen flex items-center justify-center px-4">
<div className="paper-panel px-8 py-8 max-w-sm w-full text-center">
<div className="text-xs uppercase tracking-[0.22em] text-ok">Paired</div>
<h1 className="font-display text-2xl text-ink mt-2 tracking-tight">
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>
);
}

View File

@ -37,50 +37,77 @@ export default function Settings() {
}, [user, profile.isSetUp, profile.deviceId, profile.deviceName, profile.deviceType, profile.avatar, devices, setDevices]);
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) {
return <SettingsShell><SignInForm initialError={error} /></SettingsShell>;
return (
<SettingsShell>
<SignInForm initialError={error} />
</SettingsShell>
);
}
return (
<SettingsShell>
{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.
</div>
)}
<section className="mb-8">
<h2 className="text-xs uppercase tracking-wider text-slate-500 mb-2">Account</h2>
<div className="rounded-xl bg-slate-900/60 border border-slate-800 p-4">
<div className="text-sm text-slate-400">Email</div>
<div className="text-slate-100">{user.email}</div>
<div className="mt-3 text-xs text-slate-500">Plan: <span className="text-slate-300">{user.plan}</span></div>
<section className="mb-12">
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
Account
</div>
<h2 className="font-display text-2xl text-ink mt-2 mb-5 tracking-tight">
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>
</section>
<section className="mb-8">
<h2 className="text-xs uppercase tracking-wider text-slate-500 mb-2">Devices</h2>
<div className="rounded-xl bg-slate-900/60 border border-slate-800 divide-y divide-slate-800">
<section className="mb-12">
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
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 && (
<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) => {
const isCurrent = d.deviceId === profile.deviceId;
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 className="text-slate-100 flex items-center gap-2">
{d.name}
<div className="text-ink flex items-center gap-3">
<span>{d.name}</span>
{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
</span>
)}
</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>
{!isCurrent && (
<button
@ -88,9 +115,9 @@ export default function Settings() {
await unlinkDevice(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>
)}
</div>
@ -101,7 +128,7 @@ export default function Settings() {
<button
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
</button>
@ -116,24 +143,34 @@ function SignInForm({ initialError }: { initialError: string | null }) {
if (submitted) {
return (
<div className="rounded-xl bg-slate-900/60 border border-slate-800 p-6 text-center">
<div className="text-xl mb-2">📬</div>
<h2 className="text-lg text-slate-100 mb-2">Check your inbox</h2>
<p className="text-sm text-slate-400">
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.
<div className="paper-panel px-6 py-8 text-center">
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
Check your inbox
</div>
<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>
</div>
);
}
return (
<div className="rounded-xl bg-slate-900/60 border border-slate-800 p-6">
<h2 className="text-lg text-slate-100 mb-2">Sign in</h2>
<p className="text-sm text-slate-400 mb-4">
Optional. Signing in lets you sync your profile and devices across browsers. Your transfers stay peer-to-peer.
<div className="paper-panel px-6 py-7">
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
Sign in
</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>
{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"
? "That link has expired or already been used."
: "Something went wrong. Please try again."}
@ -159,14 +196,19 @@ function SignInForm({ initialError }: { initialError: string | null }) {
placeholder="you@example.com"
value={email}
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
type="submit"
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>
</form>
</div>
@ -175,13 +217,18 @@ function SignInForm({ initialError }: { initialError: string | null }) {
function SettingsShell({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-slate-950 text-slate-100 px-4 py-10">
<div className="max-w-lg mx-auto">
<div className="mb-6 flex items-center justify-between">
<Link to="/" className="text-sm text-slate-400 hover:text-slate-200"> Back</Link>
<h1 className="text-xl font-semibold">Account</h1>
<div className="min-h-screen">
<div className="max-w-xl mx-auto px-5 sm:px-8 pt-10 pb-24">
<header className="flex items-center justify-between pb-8 mb-10 rule">
<Link
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>
</header>
{children}
</div>
</div>

View File

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

View File

@ -1,22 +1,59 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
darkMode: "class",
theme: {
extend: {
colors: {
brand: {
50: "#eef2ff",
100: "#e0e7ff",
200: "#c7d2fe",
300: "#a5b4fc",
400: "#818cf8",
500: "#6366f1",
600: "#4f46e5",
700: "#4338ca",
800: "#3730a3",
900: "#312e81",
},
paper: "#F5F0E6",
"paper-deep": "#EBE4D4",
"paper-edge": "#DCD3BE",
ink: "#1A1714",
"ink-muted": "#6B635A",
"ink-faint": "#A89F93",
signal: "#7A2320",
"signal-quiet": "#F3E2E0",
ok: "#3E6B4A",
warn: "#8B6914",
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",
},
},
},