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:
parent
3f87debcf8
commit
c18d995c3f
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
44
web/src/design/tokens.ts
Normal 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 (2–6px).
|
||||
* - 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;
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -33,7 +33,7 @@ export async function setupPushNotifications(
|
||||
const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey);
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey,
|
||||
applicationServerKey: applicationServerKey as BufferSource,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user