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>
|
<!DOCTYPE html>
|
||||||
<html lang="fr" class="dark">
|
<html lang="fr">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="AnyDrop — Partage de fichiers instantané, peer-to-peer, sans compte" />
|
<meta name="description" content="AnyDrop — Instant, peer-to-peer file and text transfer, universal across platforms." />
|
||||||
<meta name="theme-color" content="#6366f1" />
|
<meta name="theme-color" content="#F5F0E6" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>AnyDrop</title>
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<title>AnyDrop — Universal transfer</title>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-slate-950 text-white antialiased">
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@ -19,7 +19,6 @@ export default function DevicePairingPanel({
|
|||||||
const [inputCode, setInputCode] = useState("");
|
const [inputCode, setInputCode] = useState("");
|
||||||
const [pairError, setPairError] = useState<string | null>(null);
|
const [pairError, setPairError] = useState<string | null>(null);
|
||||||
|
|
||||||
// When modal opens in "show" mode, always request a fresh code
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showModal && mode === "show") {
|
if (showModal && mode === "show") {
|
||||||
let gid = groupId;
|
let gid = groupId;
|
||||||
@ -32,7 +31,6 @@ export default function DevicePairingPanel({
|
|||||||
}
|
}
|
||||||
}, [showModal, mode]);
|
}, [showModal, mode]);
|
||||||
|
|
||||||
// Detect pair-code-not-found errors
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (error && error.includes("appairage")) {
|
if (error && error.includes("appairage")) {
|
||||||
setPairError(error);
|
setPairError(error);
|
||||||
@ -51,11 +49,9 @@ export default function DevicePairingPanel({
|
|||||||
if (inputCode.length >= 6) {
|
if (inputCode.length >= 6) {
|
||||||
setPairError(null);
|
setPairError(null);
|
||||||
onResolveCode(inputCode);
|
onResolveCode(inputCode);
|
||||||
// Don't close — wait for response or error
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto-close on successful pairing (groupId changed after resolve)
|
|
||||||
const currentGroupId = useProfileStore((s) => s.groupId);
|
const currentGroupId = useProfileStore((s) => s.groupId);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode === "enter" && showModal && currentGroupId && currentGroupId !== groupId) {
|
if (mode === "enter" && showModal && currentGroupId && currentGroupId !== groupId) {
|
||||||
@ -67,68 +63,78 @@ export default function DevicePairingPanel({
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setMode("show"); setShowModal(true); }}
|
onClick={() => { setMode("show"); setShowModal(true); }}
|
||||||
className="flex-1 flex flex-col items-center justify-center gap-1.5 px-3 py-3
|
className="paper-panel px-4 py-4 flex flex-col items-start gap-1
|
||||||
border border-slate-700 hover:border-brand-500 rounded-xl
|
hover:border-ink transition-colors duration-fast ease-crisp
|
||||||
text-slate-300 hover:text-white transition-all
|
text-left"
|
||||||
bg-slate-900/30 hover:bg-slate-900/50"
|
|
||||||
>
|
>
|
||||||
<span className="text-lg">📲</span>
|
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-ink-muted">
|
||||||
<span className="text-xs font-medium text-center leading-tight">Appairer</span>
|
Pair device
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-ink">Link your own →</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showModal && (
|
{showModal && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-6"
|
className="fixed inset-0 z-50 bg-ink/40 flex items-center justify-center p-4"
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="bg-slate-900 border border-slate-700 rounded-2xl p-6 max-w-sm w-full
|
className="paper-panel shadow-lift rounded-sm p-6 max-w-sm w-full"
|
||||||
text-center space-y-5"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
<div className="text-xs uppercase tracking-[0.2em] text-ink-muted">
|
||||||
|
Pair device
|
||||||
|
</div>
|
||||||
|
<h3 className="font-display text-2xl text-ink mt-1 mb-5">
|
||||||
|
Link your devices
|
||||||
|
</h3>
|
||||||
|
|
||||||
{/* Tab switcher */}
|
{/* Tab switcher */}
|
||||||
<div className="flex gap-2 bg-slate-800 rounded-xl p-1">
|
<div className="flex border border-paper-edge rounded-sm overflow-hidden mb-5">
|
||||||
<button
|
<button
|
||||||
onClick={() => setMode("show")}
|
onClick={() => setMode("show")}
|
||||||
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors
|
className={`flex-1 py-2 text-xs uppercase tracking-[0.15em] transition-colors
|
||||||
${mode === "show" ? "bg-brand-500 text-white" : "text-slate-400 hover:text-white"}`}
|
${mode === "show"
|
||||||
|
? "bg-ink text-paper"
|
||||||
|
: "bg-paper text-ink-muted hover:text-ink"}`}
|
||||||
>
|
>
|
||||||
Mon code
|
Show code
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setMode("enter"); setPairError(null); }}
|
onClick={() => { setMode("enter"); setPairError(null); }}
|
||||||
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors
|
className={`flex-1 py-2 text-xs uppercase tracking-[0.15em] transition-colors
|
||||||
${mode === "enter" ? "bg-brand-500 text-white" : "text-slate-400 hover:text-white"}`}
|
${mode === "enter"
|
||||||
|
? "bg-ink text-paper"
|
||||||
|
: "bg-paper text-ink-muted hover:text-ink"}`}
|
||||||
>
|
>
|
||||||
Rejoindre
|
Enter code
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mode === "show" ? (
|
{mode === "show" ? (
|
||||||
<>
|
<>
|
||||||
<p className="text-sm text-slate-400">
|
<p className="text-sm text-ink-muted mb-5">
|
||||||
Entrez ce code sur votre autre appareil
|
Enter this code on your other device.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{pairCode ? (
|
{pairCode ? (
|
||||||
<p className="text-4xl font-mono font-bold text-brand-400 tracking-[0.3em]">
|
<p className="font-mono text-4xl text-signal tracking-[0.3em] text-center">
|
||||||
{pairCode}
|
{pairCode}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex justify-center py-2">
|
<div className="flex justify-center py-3">
|
||||||
<div className="w-6 h-6 border-2 border-brand-400 border-t-transparent rounded-full animate-spin" />
|
<div className="w-5 h-5 border border-signal border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-xs text-slate-500">
|
<p className="text-xs text-ink-muted mt-5 leading-relaxed text-center">
|
||||||
Le code expire dans 5 minutes.
|
Code expires in 5 minutes. Pairing is permanent.
|
||||||
L'appairage est permanent.
|
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="text-sm text-slate-400">
|
<p className="text-sm text-ink-muted mb-4">
|
||||||
Entrez le code affiché sur l'autre appareil
|
Enter the code shown on the other device.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
@ -141,35 +147,37 @@ export default function DevicePairingPanel({
|
|||||||
placeholder="ABC123"
|
placeholder="ABC123"
|
||||||
maxLength={6}
|
maxLength={6}
|
||||||
autoFocus
|
autoFocus
|
||||||
className="w-full px-4 py-3 bg-slate-800 border border-slate-600
|
className="w-full px-4 py-3 bg-paper border border-paper-edge
|
||||||
rounded-xl text-white text-2xl text-center font-mono tracking-[0.3em]
|
rounded-sm text-ink text-2xl text-center font-mono tracking-[0.3em]
|
||||||
placeholder:text-slate-700 focus:outline-none focus:border-brand-500"
|
placeholder:text-ink-faint focus:outline-none focus:border-ink
|
||||||
|
transition-colors duration-fast ease-crisp"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") handleSubmitCode();
|
if (e.key === "Enter") handleSubmitCode();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{pairError && (
|
{pairError && (
|
||||||
<p className="text-sm text-red-400">{pairError}</p>
|
<p className="text-sm text-fail mt-3">{pairError}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmitCode}
|
onClick={handleSubmitCode}
|
||||||
disabled={inputCode.length < 6}
|
disabled={inputCode.length < 6}
|
||||||
className="w-full py-3 bg-brand-500 hover:bg-brand-400 disabled:opacity-30
|
className="mt-5 w-full py-2.5 bg-ink text-paper rounded-sm text-sm
|
||||||
disabled:cursor-not-allowed rounded-xl text-white font-medium
|
font-medium hover:bg-signal transition-colors duration-fast
|
||||||
transition-colors"
|
ease-crisp disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
Appairer
|
Pair
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
className="text-sm text-slate-500 hover:text-slate-300 transition-colors"
|
className="mt-6 w-full py-2.5 border border-paper-edge hover:border-ink
|
||||||
|
text-sm text-ink rounded-sm transition-colors duration-fast ease-crisp"
|
||||||
>
|
>
|
||||||
Fermer
|
Close
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -56,7 +56,6 @@ export default function DropZone({ onFilesSelected, disabled }: DropZoneProps) {
|
|||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
onFilesSelected(files);
|
onFilesSelected(files);
|
||||||
}
|
}
|
||||||
// Reset to allow re-selecting the same file
|
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -68,26 +67,29 @@ export default function DropZone({ onFilesSelected, disabled }: DropZoneProps) {
|
|||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className={`
|
className={`
|
||||||
border-2 border-dashed rounded-2xl p-8
|
border border-dashed rounded-sm px-6 py-8
|
||||||
flex flex-col items-center justify-center gap-3
|
flex flex-col items-center justify-center gap-2
|
||||||
transition-all duration-200 cursor-pointer
|
transition-colors duration-fast ease-crisp cursor-pointer
|
||||||
min-h-[160px]
|
|
||||||
${isDragging
|
${isDragging
|
||||||
? "border-brand-400 bg-brand-500/10 scale-[1.02]"
|
? "border-signal bg-signal-quiet"
|
||||||
: disabled
|
: disabled
|
||||||
? "border-slate-700 bg-slate-900/50 cursor-not-allowed opacity-50"
|
? "border-paper-edge bg-paper-deep/40 cursor-not-allowed opacity-60"
|
||||||
: "border-slate-700 hover:border-slate-500 bg-slate-900/30 hover:bg-slate-900/50"
|
: "border-paper-edge bg-paper hover:border-ink hover:bg-paper-deep/40"
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<div className="text-4xl">{isDragging ? "📥" : "📁"}</div>
|
<span className="font-mono text-[10px] uppercase tracking-[0.2em] text-ink-muted">
|
||||||
<p className="text-slate-400 text-sm text-center">
|
{isDragging ? "Release" : "Drop"}
|
||||||
|
</span>
|
||||||
|
<p className="font-display text-xl text-ink">
|
||||||
{disabled
|
{disabled
|
||||||
? "Sélectionnez d'abord un appareil"
|
? "Select a device first"
|
||||||
: isDragging
|
: isDragging
|
||||||
? "Déposez vos fichiers ici"
|
? "Release to send"
|
||||||
: "Glissez des fichiers ici ou cliquez pour sélectionner"
|
: "Drop files here"}
|
||||||
}
|
</p>
|
||||||
|
<p className="text-xs text-ink-muted">
|
||||||
|
or click to choose
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
|||||||
@ -10,20 +10,28 @@ interface PeerAvatarProps {
|
|||||||
size?: "sm" | "md" | "lg";
|
size?: "sm" | "md" | "lg";
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEVICE_ICONS: Record<DeviceType, string> = {
|
const DEVICE_GLYPH: Record<DeviceType, string> = {
|
||||||
phone: "📱",
|
phone: "phone",
|
||||||
tablet: "📱",
|
tablet: "tablet",
|
||||||
laptop: "💻",
|
laptop: "laptop",
|
||||||
desktop: "🖥️",
|
desktop: "desk",
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
sm: { container: "w-12 h-12", icon: "text-xl", img: "w-12 h-12" },
|
sm: { container: "w-12 h-12", label: "text-[10px]" },
|
||||||
md: { container: "w-16 h-16", icon: "text-2xl", img: "w-16 h-16" },
|
md: { container: "w-16 h-16", label: "text-[11px]" },
|
||||||
lg: { container: "w-20 h-20", icon: "text-3xl", img: "w-20 h-20" },
|
lg: { container: "w-20 h-20", label: "text-xs" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PeerAvatar({ displayName, deviceType, avatar, online = true, onClick, isSelected, size = "md" }: PeerAvatarProps) {
|
export default function PeerAvatar({
|
||||||
|
displayName,
|
||||||
|
deviceType,
|
||||||
|
avatar,
|
||||||
|
online = true,
|
||||||
|
onClick,
|
||||||
|
isSelected,
|
||||||
|
size = "md",
|
||||||
|
}: PeerAvatarProps) {
|
||||||
const s = sizeClasses[size];
|
const s = sizeClasses[size];
|
||||||
const isOffline = !online;
|
const isOffline = !online;
|
||||||
|
|
||||||
@ -31,9 +39,9 @@ export default function PeerAvatar({ displayName, deviceType, avatar, online = t
|
|||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`
|
className={`
|
||||||
flex flex-col items-center gap-2 group cursor-pointer
|
group flex flex-col items-center gap-2.5
|
||||||
transition-transform duration-200 hover:scale-105
|
transition-transform duration-fast ease-crisp
|
||||||
${isOffline ? "opacity-50" : ""}
|
${isOffline ? "opacity-60" : ""}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@ -41,33 +49,40 @@ export default function PeerAvatar({ displayName, deviceType, avatar, online = t
|
|||||||
className={`
|
className={`
|
||||||
${s.container}
|
${s.container}
|
||||||
rounded-full flex items-center justify-center overflow-hidden
|
rounded-full flex items-center justify-center overflow-hidden
|
||||||
transition-all duration-200
|
border transition-all duration-fast ease-crisp
|
||||||
${isSelected
|
${isSelected
|
||||||
? "ring-2 ring-brand-400 ring-offset-2 ring-offset-slate-950"
|
? "border-signal ring-1 ring-signal"
|
||||||
: ""
|
: "border-paper-edge group-hover:border-ink"
|
||||||
}
|
}
|
||||||
${avatar ? "" : isSelected ? "bg-brand-500" : "bg-slate-800 hover:bg-slate-700"}
|
${avatar ? "" : "bg-paper"}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{avatar ? (
|
{avatar ? (
|
||||||
<img
|
<img
|
||||||
src={avatar}
|
src={avatar}
|
||||||
alt={displayName}
|
alt=""
|
||||||
className={`${s.img} rounded-full object-cover ${isOffline ? "grayscale" : ""}`}
|
className={`w-full h-full object-cover ${isOffline ? "grayscale" : ""}`}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className={s.icon}>{DEVICE_ICONS[deviceType]}</span>
|
<span className="font-mono text-[10px] uppercase tracking-widest text-ink-muted">
|
||||||
|
{DEVICE_GLYPH[deviceType]}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Online/offline indicator */}
|
<span
|
||||||
<div
|
|
||||||
className={`
|
className={`
|
||||||
absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 rounded-full border-2 border-slate-950
|
absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border border-paper
|
||||||
${online ? "bg-green-500" : "bg-slate-500"}
|
${online ? "bg-ok" : "bg-ink-faint"}
|
||||||
`}
|
`}
|
||||||
|
aria-label={online ? "online" : "offline"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-xs transition-colors max-w-[80px] truncate ${isOffline ? "text-slate-500" : "text-slate-300 group-hover:text-white"}`}>
|
<span
|
||||||
|
className={`
|
||||||
|
${s.label} max-w-[88px] truncate transition-colors
|
||||||
|
${isSelected ? "text-ink" : isOffline ? "text-ink-faint" : "text-ink-muted group-hover:text-ink"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
{displayName}
|
{displayName}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -11,18 +11,18 @@ export default function PeerList({ onPeerSelect }: PeerListProps) {
|
|||||||
|
|
||||||
if (peers.length === 0) {
|
if (peers.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-slate-500">
|
<div className="paper-panel px-6 py-10 flex flex-col items-center text-center">
|
||||||
<div className="text-5xl mb-4">📡</div>
|
<div className="w-10 h-10 rounded-full border border-paper-edge flex items-center justify-center mb-4">
|
||||||
<p className="text-lg font-medium">En attente d'appareils...</p>
|
<span className="w-2 h-2 rounded-full bg-signal animate-pulse" />
|
||||||
<p className="text-sm mt-2 text-center max-w-xs">
|
</div>
|
||||||
Ouvrez AnyDrop sur un autre appareil connecté au même Wi-Fi,
|
<p className="font-display text-lg text-ink">Listening for devices</p>
|
||||||
ou appairez vos appareils ci-dessous.
|
<p className="text-sm text-ink-muted mt-2 max-w-xs leading-relaxed">
|
||||||
|
Open AnyDrop on another device on this network, or pair a device below.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort: online first, then offline
|
|
||||||
const sorted = [...peers].sort((a, b) => {
|
const sorted = [...peers].sort((a, b) => {
|
||||||
const aOnline = a.online !== false ? 1 : 0;
|
const aOnline = a.online !== false ? 1 : 0;
|
||||||
const bOnline = b.online !== false ? 1 : 0;
|
const bOnline = b.online !== false ? 1 : 0;
|
||||||
@ -30,18 +30,20 @@ export default function PeerList({ onPeerSelect }: PeerListProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap justify-center gap-6 py-8">
|
<div className="paper-panel px-4 py-6">
|
||||||
{sorted.map((peer) => (
|
<div className="flex flex-wrap justify-center gap-6">
|
||||||
<PeerAvatar
|
{sorted.map((peer) => (
|
||||||
key={peer.peerId}
|
<PeerAvatar
|
||||||
displayName={peer.displayName}
|
key={peer.peerId}
|
||||||
deviceType={peer.deviceType}
|
displayName={peer.displayName}
|
||||||
avatar={peer.avatar}
|
deviceType={peer.deviceType}
|
||||||
online={peer.online !== false}
|
avatar={peer.avatar}
|
||||||
isSelected={selectedPeerId === peer.peerId}
|
online={peer.online !== false}
|
||||||
onClick={() => onPeerSelect(peer.peerId)}
|
isSelected={selectedPeerId === peer.peerId}
|
||||||
/>
|
onClick={() => onPeerSelect(peer.peerId)}
|
||||||
))}
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState, useRef } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { useProfileStore } from "../stores/useProfileStore";
|
import { useProfileStore } from "../stores/useProfileStore";
|
||||||
|
|
||||||
const MAX_AVATAR_SIZE = 80_000; // ~80KB after base64
|
const MAX_AVATAR_SIZE = 80_000;
|
||||||
|
|
||||||
function resizeImage(file: File, maxSize: number): Promise<string> {
|
function resizeImage(file: File, maxSize: number): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@ -10,7 +10,6 @@ function resizeImage(file: File, maxSize: number): Promise<string> {
|
|||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
// Crop to square, max 128px
|
|
||||||
const side = Math.min(img.width, img.height);
|
const side = Math.min(img.width, img.height);
|
||||||
const sx = (img.width - side) / 2;
|
const sx = (img.width - side) / 2;
|
||||||
const sy = (img.height - side) / 2;
|
const sy = (img.height - side) / 2;
|
||||||
@ -21,7 +20,6 @@ function resizeImage(file: File, maxSize: number): Promise<string> {
|
|||||||
const ctx = canvas.getContext("2d")!;
|
const ctx = canvas.getContext("2d")!;
|
||||||
ctx.drawImage(img, sx, sy, side, side, 0, 0, outSize, outSize);
|
ctx.drawImage(img, sx, sy, side, side, 0, 0, outSize, outSize);
|
||||||
|
|
||||||
// Try JPEG at decreasing quality until small enough
|
|
||||||
let quality = 0.8;
|
let quality = 0.8;
|
||||||
let dataUrl = canvas.toDataURL("image/jpeg", quality);
|
let dataUrl = canvas.toDataURL("image/jpeg", quality);
|
||||||
while (dataUrl.length > maxSize && quality > 0.2) {
|
while (dataUrl.length > maxSize && quality > 0.2) {
|
||||||
@ -30,7 +28,6 @@ function resizeImage(file: File, maxSize: number): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dataUrl.length > maxSize) {
|
if (dataUrl.length > maxSize) {
|
||||||
// Shrink further
|
|
||||||
outSize = 64;
|
outSize = 64;
|
||||||
canvas.width = outSize;
|
canvas.width = outSize;
|
||||||
canvas.height = outSize;
|
canvas.height = outSize;
|
||||||
@ -70,28 +67,45 @@ export default function ProfileSetup({ onDone, isEditing }: ProfileSetupProps) {
|
|||||||
onDone();
|
onDone();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const wrapperClass = isEditing
|
||||||
|
? "fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4"
|
||||||
|
: "min-h-screen flex items-center justify-center p-4";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4">
|
<div className={wrapperClass}>
|
||||||
<div className="bg-slate-900 border border-slate-700 rounded-2xl p-6 w-full max-w-sm shadow-2xl">
|
<div className="paper-panel shadow-lift rounded-sm p-8 w-full max-w-sm">
|
||||||
<h2 className="text-lg font-semibold text-white text-center mb-6">
|
{!isEditing && (
|
||||||
{isEditing ? "Modifier le profil" : "Votre appareil"}
|
<>
|
||||||
|
<h1 className="font-display text-4xl leading-none tracking-tight text-ink text-center">
|
||||||
|
AnyDrop
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 mb-6 text-xs uppercase tracking-[0.2em] text-ink-muted text-center">
|
||||||
|
Universal transfer
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="text-xs uppercase tracking-[0.2em] text-ink-muted">
|
||||||
|
{isEditing ? "Edit" : "Set up"}
|
||||||
|
</div>
|
||||||
|
<h2 className="font-display text-2xl text-ink mt-1 mb-6">
|
||||||
|
Your device
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<div className="flex justify-center mb-6">
|
<div className="flex justify-center mb-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => fileRef.current?.click()}
|
onClick={() => fileRef.current?.click()}
|
||||||
className="relative w-24 h-24 rounded-full bg-slate-800 hover:bg-slate-700
|
className="relative w-24 h-24 rounded-full bg-paper
|
||||||
flex items-center justify-center overflow-hidden
|
flex items-center justify-center overflow-hidden
|
||||||
transition-colors border-2 border-dashed border-slate-600 hover:border-brand-400"
|
transition-colors duration-fast ease-crisp
|
||||||
|
border border-dashed border-paper-edge hover:border-ink"
|
||||||
>
|
>
|
||||||
{preview ? (
|
{preview ? (
|
||||||
<img src={preview} alt="Avatar" className="w-full h-full object-cover rounded-full" />
|
<img src={preview} alt="Avatar" className="w-full h-full object-cover rounded-full" />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center text-slate-400">
|
<span className="font-mono text-[10px] uppercase tracking-widest text-ink-muted">
|
||||||
<span className="text-2xl">📷</span>
|
Photo
|
||||||
<span className="text-[10px] mt-1">Photo</span>
|
</span>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
@ -103,40 +117,41 @@ export default function ProfileSetup({ onDone, isEditing }: ProfileSetupProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Remove photo */}
|
|
||||||
{preview && (
|
{preview && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setPreview(null)}
|
onClick={() => setPreview(null)}
|
||||||
className="block mx-auto mb-4 text-xs text-slate-500 hover:text-red-400 transition-colors"
|
className="block mx-auto mb-4 text-xs text-ink-muted hover:text-signal transition-colors"
|
||||||
>
|
>
|
||||||
Supprimer la photo
|
Remove photo
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Name */}
|
{/* Name */}
|
||||||
<div className="mb-6">
|
<div className="mt-5 mb-6">
|
||||||
<label className="block text-xs text-slate-400 mb-1.5">Nom de l'appareil</label>
|
<label className="block text-xs uppercase tracking-[0.15em] text-ink-muted mb-2">
|
||||||
|
Device name
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
maxLength={30}
|
maxLength={30}
|
||||||
placeholder="ex: iPhone d'Arthur"
|
placeholder="e.g. Arthur's iPhone"
|
||||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-700 rounded-xl
|
className="w-full px-3 py-2.5 bg-paper border border-paper-edge rounded-sm
|
||||||
text-white text-sm placeholder:text-slate-500
|
text-ink text-sm placeholder:text-ink-faint
|
||||||
focus:outline-none focus:border-brand-500 transition-colors"
|
focus:outline-none focus:border-ink transition-colors
|
||||||
|
duration-fast ease-crisp"
|
||||||
autoFocus
|
autoFocus
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
|
onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Submit */}
|
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
className="w-full py-2.5 bg-brand-600 hover:bg-brand-500 text-white
|
className="w-full py-2.5 bg-ink text-paper rounded-sm text-sm font-medium
|
||||||
rounded-xl text-sm font-medium transition-colors"
|
hover:bg-signal transition-colors duration-fast ease-crisp"
|
||||||
>
|
>
|
||||||
{isEditing ? "Enregistrer" : "C'est parti"}
|
{isEditing ? "Save" : "Continue →"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -24,13 +24,14 @@ export default function PublicRoomPanel({ onCreateRoom }: PublicRoomPanelProps)
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className="flex-1 flex flex-col items-center justify-center gap-1.5 px-3 py-3
|
className="paper-panel px-4 py-4 flex flex-col items-start gap-1
|
||||||
border border-slate-700 hover:border-brand-500 rounded-xl
|
hover:border-ink transition-colors duration-fast ease-crisp
|
||||||
text-slate-300 hover:text-white transition-all
|
text-left"
|
||||||
bg-slate-900/30 hover:bg-slate-900/50"
|
|
||||||
>
|
>
|
||||||
<span className="text-lg">🔗</span>
|
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-ink-muted">
|
||||||
<span className="text-xs font-medium text-center leading-tight">Lien public</span>
|
Public link
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-ink">Send to anyone →</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showModal && (
|
{showModal && (
|
||||||
@ -53,58 +54,66 @@ function PublicRoomModal({
|
|||||||
url: string | null;
|
url: string | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const copyToClipboard = () => {
|
const copyToClipboard = () => {
|
||||||
if (url) navigator.clipboard.writeText(url);
|
if (!url) return;
|
||||||
|
navigator.clipboard.writeText(url);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-6"
|
className="fixed inset-0 z-50 bg-ink/40 flex items-center justify-center p-4"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="bg-slate-900 border border-slate-700 rounded-2xl p-6 max-w-sm w-full
|
className="paper-panel shadow-lift rounded-sm p-6 max-w-sm w-full"
|
||||||
text-center space-y-4"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<h3 className="text-lg font-semibold text-white">Lien public</h3>
|
<div className="text-xs uppercase tracking-[0.2em] text-ink-muted">
|
||||||
|
Public link
|
||||||
|
</div>
|
||||||
|
<h3 className="font-display text-2xl text-ink mt-1 mb-5">
|
||||||
|
Receive from anyone
|
||||||
|
</h3>
|
||||||
|
|
||||||
{!code || !url ? (
|
{!code || !url ? (
|
||||||
<p className="text-sm text-slate-400">Création en cours...</p>
|
<p className="text-sm text-ink-muted">Generating…</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center mb-5">
|
||||||
<div className="bg-white p-3 rounded-xl">
|
<div className="bg-paper p-3 border border-paper-edge rounded-sm">
|
||||||
<QRCodeSVG value={url} size={180} />
|
<QRCodeSVG value={url} size={180} bgColor="#F5F0E6" fgColor="#1A1714" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<p className="text-3xl font-mono font-bold text-brand-400 tracking-widest">
|
<p className="font-mono text-3xl font-medium text-signal tracking-[0.3em]">
|
||||||
{code.toUpperCase()}
|
{code.toUpperCase()}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={copyToClipboard}
|
onClick={copyToClipboard}
|
||||||
className="text-sm text-slate-400 hover:text-white transition-colors
|
className="text-xs text-ink-muted hover:text-ink transition-colors
|
||||||
underline underline-offset-4 decoration-slate-600"
|
font-mono break-all"
|
||||||
>
|
>
|
||||||
{url}
|
{copied ? "Copied ✓" : url}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-slate-500">
|
<p className="text-xs text-ink-muted mt-5 leading-relaxed">
|
||||||
Partagez ce lien pour recevoir des fichiers de n'importe qui.
|
Anyone with this code or link can send you files. Expires in 10 minutes.
|
||||||
Expire dans 10 minutes.
|
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-6 py-2 bg-slate-800 hover:bg-slate-700 rounded-xl
|
className="mt-6 w-full py-2.5 border border-paper-edge hover:border-ink
|
||||||
text-sm text-slate-300 transition-colors"
|
text-sm text-ink rounded-sm transition-colors duration-fast ease-crisp"
|
||||||
>
|
>
|
||||||
Fermer
|
Close
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import type { IncomingRequest } from "../stores/useStore";
|
import type { IncomingRequest } from "../stores/useStore";
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
function formatSize(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes} o`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} Go`;
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReceiveDialogProps {
|
interface ReceiveDialogProps {
|
||||||
@ -15,57 +15,64 @@ interface ReceiveDialogProps {
|
|||||||
|
|
||||||
export default function ReceiveDialog({ request, onAccept, onReject }: ReceiveDialogProps) {
|
export default function ReceiveDialog({ request, onAccept, onReject }: ReceiveDialogProps) {
|
||||||
const totalSize = request.files.reduce((sum, f) => sum + f.size, 0);
|
const totalSize = request.files.reduce((sum, f) => sum + f.size, 0);
|
||||||
|
const isTextOnly = request.text && request.files.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4">
|
||||||
<div className="bg-slate-900 border border-slate-700 rounded-2xl p-6 w-full max-w-md shadow-2xl">
|
<div className="paper-panel shadow-lift rounded-sm p-6 w-full max-w-md">
|
||||||
<h2 className="text-lg font-semibold text-white mb-2">
|
<div className="text-xs uppercase tracking-[0.2em] text-ink-muted">
|
||||||
Transfert entrant
|
Incoming
|
||||||
|
</div>
|
||||||
|
<h2 className="font-display text-2xl text-ink mt-1">
|
||||||
|
{isTextOnly ? "Text received" : "Transfer request"}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-slate-400 mb-4">
|
<p className="text-sm text-ink-muted mt-2">
|
||||||
<span className="text-brand-400 font-medium">{request.displayName}</span> veut vous envoyer :
|
From <span className="text-ink">{request.displayName}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{request.files.length > 0 && (
|
{request.files.length > 0 && (
|
||||||
<div className="bg-slate-800/50 rounded-xl p-3 mb-4 space-y-2 max-h-40 overflow-y-auto">
|
<div className="mt-5 divide-y divide-paper-edge border-t border-b border-paper-edge max-h-40 overflow-y-auto">
|
||||||
{request.files.map((file) => (
|
{request.files.map((file) => (
|
||||||
<div key={file.id} className="flex items-center justify-between text-sm">
|
<div key={file.id} className="py-2.5 flex items-center justify-between text-sm">
|
||||||
<span className="text-white truncate mr-2">{file.name}</span>
|
<span className="text-ink truncate mr-3">{file.name}</span>
|
||||||
<span className="text-slate-500 text-xs whitespace-nowrap">{formatSize(file.size)}</span>
|
<span className="font-mono text-xs text-ink-faint whitespace-nowrap">
|
||||||
|
{formatSize(file.size)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{request.files.length > 1 && (
|
{request.files.length > 1 && (
|
||||||
<div className="border-t border-slate-700 pt-2 flex justify-between text-xs text-slate-400">
|
<div className="py-2 flex justify-between text-xs">
|
||||||
<span>{request.files.length} fichiers</span>
|
<span className="text-ink-muted">{request.files.length} files</span>
|
||||||
<span>{formatSize(totalSize)}</span>
|
<span className="font-mono text-ink-muted">{formatSize(totalSize)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{request.text && (
|
{request.text && (
|
||||||
<div className="bg-slate-800/50 rounded-xl p-3 mb-4">
|
<div className="mt-5 bg-paper-deep border border-paper-edge rounded-sm p-3">
|
||||||
<p className="text-xs text-slate-500 mb-1">Texte :</p>
|
<div className="text-xs uppercase tracking-[0.2em] text-ink-muted mb-1.5">Text</div>
|
||||||
<p className="text-sm text-white whitespace-pre-wrap break-words max-h-32 overflow-y-auto">
|
<p className="text-sm text-ink whitespace-pre-wrap break-words max-h-32 overflow-y-auto">
|
||||||
{request.text}
|
{request.text}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3 mt-6">
|
||||||
<button
|
<button
|
||||||
onClick={onReject}
|
onClick={onReject}
|
||||||
className="flex-1 px-4 py-2.5 border border-slate-600 text-slate-300
|
className="flex-1 px-4 py-2.5 border border-paper-edge text-ink-muted
|
||||||
hover:bg-slate-800 rounded-xl text-sm font-medium transition-colors"
|
hover:text-ink hover:border-ink rounded-sm text-sm font-medium
|
||||||
|
transition-colors duration-fast ease-crisp"
|
||||||
>
|
>
|
||||||
Refuser
|
Decline
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onAccept}
|
onClick={onAccept}
|
||||||
className="flex-1 px-4 py-2.5 bg-brand-600 hover:bg-brand-500
|
className="flex-1 px-4 py-2.5 bg-ink text-paper rounded-sm text-sm font-medium
|
||||||
text-white rounded-xl text-sm font-medium transition-colors"
|
hover:bg-signal transition-colors duration-fast ease-crisp"
|
||||||
>
|
>
|
||||||
{request.text && request.files.length === 0 ? "Copier" : "Accepter"}
|
{isTextOnly ? "Copy" : "Accept"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -17,35 +17,45 @@ export default function TextShareModal({ onSend, onClose }: TextShareModalProps)
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
<div
|
||||||
<div className="bg-slate-900 border border-slate-700 rounded-2xl p-6 w-full max-w-md shadow-2xl">
|
className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4"
|
||||||
<h2 className="text-lg font-semibold text-white mb-4">Envoyer du texte</h2>
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="paper-panel shadow-lift rounded-sm p-6 w-full max-w-md"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="text-xs uppercase tracking-[0.2em] text-ink-muted">
|
||||||
|
Compose
|
||||||
|
</div>
|
||||||
|
<h2 className="font-display text-2xl text-ink mt-1 mb-5">Send text</h2>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<textarea
|
<textarea
|
||||||
autoFocus
|
autoFocus
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => setText(e.target.value)}
|
onChange={(e) => setText(e.target.value)}
|
||||||
placeholder="Tapez votre message, lien, ou texte..."
|
placeholder="Message, link, snippet…"
|
||||||
className="w-full h-32 bg-slate-800 border border-slate-600 rounded-xl p-3 text-white
|
className="w-full h-32 bg-paper border border-paper-edge rounded-sm p-3
|
||||||
placeholder-slate-500 resize-none focus:outline-none focus:ring-2
|
text-sm text-ink placeholder:text-ink-faint resize-none
|
||||||
focus:ring-brand-500 focus:border-transparent"
|
focus:outline-none focus:border-ink transition-colors
|
||||||
|
duration-fast ease-crisp"
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-end gap-3 mt-4">
|
<div className="flex justify-end gap-3 mt-5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2 text-sm text-slate-400 hover:text-white transition-colors"
|
className="px-3 py-2 text-sm text-ink-muted hover:text-ink transition-colors"
|
||||||
>
|
>
|
||||||
Annuler
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!text.trim()}
|
disabled={!text.trim()}
|
||||||
className="px-6 py-2 bg-brand-600 hover:bg-brand-500 disabled:opacity-50
|
className="px-5 py-2 bg-ink text-paper text-sm font-medium rounded-sm
|
||||||
disabled:cursor-not-allowed text-white text-sm font-medium
|
hover:bg-signal transition-colors duration-fast ease-crisp
|
||||||
rounded-xl transition-colors"
|
disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
Envoyer
|
Send →
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -1,64 +1,65 @@
|
|||||||
import { useStore } from "../stores/useStore";
|
import { useStore } from "../stores/useStore";
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
function formatSize(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes} o`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} Go`;
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TransferProgress() {
|
export default function TransferProgress() {
|
||||||
const transfers = useStore((s) => s.transfers);
|
const transfers = useStore((s) => s.transfers);
|
||||||
const removeTransfer = useStore((s) => s.removeTransfer);
|
const removeTransfer = useStore((s) => s.removeTransfer);
|
||||||
|
|
||||||
const activeTransfers = transfers.filter((t) => t.status !== "done" || Date.now() < Date.now()); // Show all for now
|
if (transfers.length === 0) {
|
||||||
|
return (
|
||||||
if (activeTransfers.length === 0) return null;
|
<div className="mt-4 text-sm text-ink-muted">
|
||||||
|
No transfers yet.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="mt-4 divide-y divide-paper-edge border-t border-b border-paper-edge">
|
||||||
<h3 className="text-sm font-medium text-slate-400 uppercase tracking-wider">
|
{transfers.map((transfer) => (
|
||||||
Transferts
|
|
||||||
</h3>
|
|
||||||
{activeTransfers.map((transfer) => (
|
|
||||||
<div
|
<div
|
||||||
key={transfer.id}
|
key={transfer.id}
|
||||||
className="bg-slate-800/50 rounded-xl p-4 flex items-center gap-4"
|
className="py-3 flex items-center gap-4"
|
||||||
>
|
>
|
||||||
<div className="text-2xl">
|
<span className="font-mono text-[10px] uppercase tracking-[0.2em] text-ink-muted w-12 shrink-0">
|
||||||
{transfer.direction === "send" ? "📤" : "📥"}
|
{transfer.direction === "send" ? "Out" : "In"}
|
||||||
</div>
|
</span>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm text-white truncate">{transfer.fileName}</p>
|
<p className="text-sm text-ink truncate">{transfer.fileName}</p>
|
||||||
<p className="text-xs text-slate-500">{formatSize(transfer.fileSize)}</p>
|
<p className="text-xs text-ink-faint mt-0.5">{formatSize(transfer.fileSize)}</p>
|
||||||
{transfer.status === "transferring" && (
|
{transfer.status === "transferring" && (
|
||||||
<div className="mt-2 h-1.5 bg-slate-700 rounded-full overflow-hidden">
|
<div className="mt-2 h-px bg-paper-edge overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-brand-500 rounded-full transition-all duration-300"
|
className="h-full bg-signal transition-all duration-300"
|
||||||
style={{ width: `${Math.round(transfer.progress * 100)}%` }}
|
style={{ width: `${Math.round(transfer.progress * 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right w-20 shrink-0">
|
||||||
{transfer.status === "pending" && (
|
{transfer.status === "pending" && (
|
||||||
<span className="text-xs text-yellow-400">En attente</span>
|
<span className="text-xs text-warn">Waiting</span>
|
||||||
)}
|
)}
|
||||||
{transfer.status === "transferring" && (
|
{transfer.status === "transferring" && (
|
||||||
<span className="text-xs text-brand-400">
|
<span className="font-mono text-xs text-signal">
|
||||||
{Math.round(transfer.progress * 100)}%
|
{Math.round(transfer.progress * 100)}%
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{transfer.status === "done" && (
|
{transfer.status === "done" && (
|
||||||
<button
|
<button
|
||||||
onClick={() => removeTransfer(transfer.id)}
|
onClick={() => removeTransfer(transfer.id)}
|
||||||
className="text-xs text-green-400 hover:text-green-300"
|
className="text-xs text-ok hover:text-ink transition-colors"
|
||||||
>
|
>
|
||||||
Terminé ✓
|
Done ✓
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{transfer.status === "error" && (
|
{transfer.status === "error" && (
|
||||||
<span className="text-xs text-red-400">Erreur</span>
|
<span className="text-xs text-fail">Failed</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
44
web/src/design/tokens.ts
Normal file
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 base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
html,
|
||||||
body {
|
body {
|
||||||
@apply min-h-screen;
|
background: #f5f0e6;
|
||||||
|
color: #1a1714;
|
||||||
|
font-family: "Inter", "Söhne", system-ui, -apple-system, sans-serif;
|
||||||
|
font-feature-settings: "ss01", "cv11";
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
/* Paper texture — very subtle noise, 2% opacity */
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='200' height='200'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.1 0 0 0 0 0.09 0 0 0 0 0.08 0 0 0 0.55 0'/></filter><rect width='100%' height='100%' filter='url(%23n)' opacity='0.18'/></svg>");
|
||||||
|
mix-blend-mode: multiply;
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
#root {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4 {
|
||||||
|
font-family: "Fraunces", "GT Sectra", Georgia, serif;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
code,
|
||||||
|
pre,
|
||||||
|
.mono {
|
||||||
|
font-family: "JetBrains Mono", "Berkeley Mono", ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
/* Selection: signal on quiet paper */
|
||||||
|
::selection {
|
||||||
|
background: #7a2320;
|
||||||
|
color: #f5f0e6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
/* Thin ruled line — evokes paper stationery */
|
||||||
|
.rule {
|
||||||
|
border-bottom: 1px solid #dcd3be;
|
||||||
|
}
|
||||||
|
.rule-strong {
|
||||||
|
border-bottom: 1px solid #1a1714;
|
||||||
|
}
|
||||||
|
/* Envelope: subtle diagonal flap hint used on panels */
|
||||||
|
.paper-panel {
|
||||||
|
background: #f5f0e6;
|
||||||
|
border: 1px solid #dcd3be;
|
||||||
|
box-shadow:
|
||||||
|
0 1px 0 rgba(26, 23, 20, 0.04),
|
||||||
|
0 1px 3px rgba(26, 23, 20, 0.06);
|
||||||
|
}
|
||||||
|
.paper-panel-deep {
|
||||||
|
background: #ebe4d4;
|
||||||
|
border: 1px solid #dcd3be;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -137,7 +137,7 @@ export function createFileReceiver(callbacks: FileReceiver) {
|
|||||||
case "file-end": {
|
case "file-end": {
|
||||||
const file = receiving.get(msg.id);
|
const file = receiving.get(msg.id);
|
||||||
if (file) {
|
if (file) {
|
||||||
const blob = new Blob(file.chunks, { type: file.mime });
|
const blob = new Blob(file.chunks as BlobPart[], { type: file.mime });
|
||||||
callbacks.onComplete(msg.id, blob, file.name);
|
callbacks.onComplete(msg.id, blob, file.name);
|
||||||
receiving.delete(msg.id);
|
receiving.delete(msg.id);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,7 +33,7 @@ export async function setupPushNotifications(
|
|||||||
const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey);
|
const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey);
|
||||||
subscription = await registration.pushManager.subscribe({
|
subscription = await registration.pushManager.subscribe({
|
||||||
userVisibleOnly: true,
|
userVisibleOnly: true,
|
||||||
applicationServerKey,
|
applicationServerKey: applicationServerKey as BufferSource,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -128,7 +128,7 @@ export async function appendReceivedChunk(
|
|||||||
const store = await tx(STORE_CHUNKS, "readwrite");
|
const store = await tx(STORE_CHUNKS, "readwrite");
|
||||||
const existing = await reqToPromise(store.get(transferId));
|
const existing = await reqToPromise(store.get(transferId));
|
||||||
const oldBlob: Blob = existing?.blob ?? new Blob();
|
const oldBlob: Blob = existing?.blob ?? new Blob();
|
||||||
const newBlob = new Blob([oldBlob, chunk]);
|
const newBlob = new Blob([oldBlob, chunk as BlobPart]);
|
||||||
await reqToPromise(store.put({ transferId, blob: newBlob }));
|
await reqToPromise(store.put({ transferId, blob: newBlob }));
|
||||||
return newBlob.size;
|
return newBlob.size;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import { useSignaling } from "../hooks/useSignaling";
|
import { useSignaling } from "../hooks/useSignaling";
|
||||||
import { useStore } from "../stores/useStore";
|
import { useStore } from "../stores/useStore";
|
||||||
import { useProfileStore } from "../stores/useProfileStore";
|
import { useProfileStore } from "../stores/useProfileStore";
|
||||||
@ -22,8 +23,16 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function HomeConnected() {
|
function HomeConnected() {
|
||||||
const { sendFiles, sendText, acceptTransfer, rejectTransfer, createPublicRoom, wakePeer, requestPairCode, resolvePairCode } =
|
const {
|
||||||
useSignaling();
|
sendFiles,
|
||||||
|
sendText,
|
||||||
|
acceptTransfer,
|
||||||
|
rejectTransfer,
|
||||||
|
createPublicRoom,
|
||||||
|
wakePeer,
|
||||||
|
requestPairCode,
|
||||||
|
resolvePairCode,
|
||||||
|
} = useSignaling();
|
||||||
|
|
||||||
const peers = useStore((s) => s.peers);
|
const peers = useStore((s) => s.peers);
|
||||||
const selectedPeerId = useStore((s) => s.selectedPeerId);
|
const selectedPeerId = useStore((s) => s.selectedPeerId);
|
||||||
@ -36,11 +45,10 @@ function HomeConnected() {
|
|||||||
|
|
||||||
const { deviceName, avatar } = useProfileStore();
|
const { deviceName, avatar } = useProfileStore();
|
||||||
const [showProfileEdit, setShowProfileEdit] = useState(false);
|
const [showProfileEdit, setShowProfileEdit] = useState(false);
|
||||||
const [wakingDeviceId, setWakingDeviceId] = useState<string | null>(null);
|
const [, setWakingDeviceId] = useState<string | null>(null);
|
||||||
|
|
||||||
const handlePeerSelect = useCallback(
|
const handlePeerSelect = useCallback(
|
||||||
(peerId: string) => {
|
(peerId: string) => {
|
||||||
// Check if this is an offline peer
|
|
||||||
const peer = peers.find((p) => p.peerId === peerId);
|
const peer = peers.find((p) => p.peerId === peerId);
|
||||||
if (peer && peer.online === false && peer.deviceId) {
|
if (peer && peer.online === false && peer.deviceId) {
|
||||||
setSelectedPeerId(peerId);
|
setSelectedPeerId(peerId);
|
||||||
@ -87,82 +95,113 @@ function HomeConnected() {
|
|||||||
}, [incomingRequest, rejectTransfer]);
|
}, [incomingRequest, rejectTransfer]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950">
|
<div className="min-h-screen">
|
||||||
<div className="max-w-lg mx-auto px-4 py-8">
|
<div className="max-w-xl mx-auto px-5 sm:px-8 pt-10 pb-24">
|
||||||
{/* Header */}
|
{/* Masthead */}
|
||||||
<header className="text-center mb-8">
|
<header className="flex items-start justify-between pb-8 mb-10 rule">
|
||||||
<h1 className="text-3xl font-bold text-white mb-1">
|
<div>
|
||||||
Any<span className="text-brand-400">Drop</span>
|
<h1 className="font-display text-4xl leading-none tracking-tight text-ink">
|
||||||
</h1>
|
AnyDrop
|
||||||
<p className="text-slate-500 text-sm">Partage instantané, sans compte</p>
|
</h1>
|
||||||
|
<p className="mt-3 text-xs uppercase tracking-[0.2em] text-ink-muted">
|
||||||
{/* Profile badge — tap to edit */}
|
Universal transfer · Peer to peer
|
||||||
<button
|
</p>
|
||||||
onClick={() => setShowProfileEdit(true)}
|
</div>
|
||||||
className="mt-3 inline-flex items-center gap-2 px-3 py-1.5 rounded-full
|
<DeviceChip
|
||||||
bg-slate-800/50 hover:bg-slate-800 transition-colors group"
|
name={deviceName}
|
||||||
>
|
avatar={avatar}
|
||||||
{avatar ? (
|
onEdit={() => setShowProfileEdit(true)}
|
||||||
<img src={avatar} alt="" className="w-5 h-5 rounded-full object-cover" />
|
/>
|
||||||
) : (
|
|
||||||
<span className="text-sm">📱</span>
|
|
||||||
)}
|
|
||||||
<span className="text-sm text-slate-400 group-hover:text-white transition-colors">
|
|
||||||
{deviceName}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] text-slate-600">✎</span>
|
|
||||||
</button>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Error banner */}
|
{/* Error */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/30 rounded-xl text-sm text-red-400 flex items-center justify-between">
|
<div className="mb-8 px-4 py-3 border border-fail/40 bg-signal-quiet flex items-center justify-between rounded-sm">
|
||||||
<span>{error}</span>
|
<span className="text-sm text-ink">{error}</span>
|
||||||
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-300 ml-2">
|
<button
|
||||||
✕
|
onClick={() => setError(null)}
|
||||||
|
className="ml-3 text-ink-muted hover:text-ink text-lg leading-none"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
>
|
||||||
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 1. Appareils disponibles */}
|
{/* Direct transfer */}
|
||||||
<section className="mb-6">
|
<section className="mb-14">
|
||||||
<PeerList onPeerSelect={handlePeerSelect} />
|
<SectionLabel>Direct</SectionLabel>
|
||||||
|
<SectionTitle>Send to a device nearby</SectionTitle>
|
||||||
|
<SectionLead>
|
||||||
|
Devices on the same network appear below. Transfers stay peer-to-peer and never touch the server.
|
||||||
|
</SectionLead>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<PeerList onPeerSelect={handlePeerSelect} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<DevicePairingPanel
|
||||||
|
onRequestCode={requestPairCode}
|
||||||
|
onResolveCode={resolvePairCode}
|
||||||
|
/>
|
||||||
|
<PublicRoomPanel onCreateRoom={createPublicRoom} />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 2. Appairage + Lien public */}
|
{/* Composer — appears when a peer is selected */}
|
||||||
<section className="mb-4 flex gap-3">
|
|
||||||
<DevicePairingPanel onRequestCode={requestPairCode} onResolveCode={resolvePairCode} />
|
|
||||||
<PublicRoomPanel onCreateRoom={createPublicRoom} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 3. Envoi fichiers + texte (seulement quand un peer est sélectionné) */}
|
|
||||||
{selectedPeerId && (
|
{selectedPeerId && (
|
||||||
<section className="mb-6 space-y-3">
|
<section className="mb-14">
|
||||||
<DropZone onFilesSelected={handleFilesSelected} />
|
<SectionLabel>Compose</SectionLabel>
|
||||||
<button
|
<SectionTitle>Drop files or send text</SectionTitle>
|
||||||
onClick={() => setShowTextModal(true)}
|
|
||||||
className="w-full flex items-center justify-center gap-2 px-4 py-3
|
<div className="mt-6 space-y-3">
|
||||||
border border-slate-700 hover:border-brand-500 rounded-xl
|
<DropZone onFilesSelected={handleFilesSelected} />
|
||||||
text-slate-300 hover:text-white transition-all
|
<button
|
||||||
bg-slate-900/30 hover:bg-slate-900/50"
|
onClick={() => setShowTextModal(true)}
|
||||||
>
|
className="w-full text-left px-4 py-3 border border-paper-edge bg-paper
|
||||||
<span className="text-lg">💬</span>
|
hover:bg-paper-deep transition-colors duration-fast ease-crisp
|
||||||
<span className="text-sm font-medium">Envoyer du texte</span>
|
rounded-sm text-sm text-ink"
|
||||||
</button>
|
>
|
||||||
|
Send text instead →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 4. Transferts en cours */}
|
{/* Activity */}
|
||||||
<section className="mb-6">
|
<section className="mb-14">
|
||||||
|
<SectionLabel>Activity</SectionLabel>
|
||||||
<TransferProgress />
|
<TransferProgress />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Phase 2 teaser — "Via AnyDrop" cloud relay */}
|
||||||
|
<section className="mb-14">
|
||||||
|
<div className="paper-panel-deep px-5 py-5 flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs uppercase tracking-[0.2em] text-ink-muted">
|
||||||
|
Coming soon
|
||||||
|
</div>
|
||||||
|
<h3 className="font-display text-xl mt-2 text-ink">Via AnyDrop</h3>
|
||||||
|
<p className="text-sm text-ink-muted mt-1 leading-relaxed">
|
||||||
|
Send to an email address. The file is held, encrypted, for seven days — the server never sees the key.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-xs text-ink-faint uppercase tracking-widest shrink-0 pt-1">
|
||||||
|
Q2
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="text-center text-xs text-slate-600 mt-12">
|
<footer className="pt-8 mt-14 rule flex items-center justify-between text-xs text-ink-muted">
|
||||||
<p>Peer-to-peer · Chiffré · Aucun fichier ne transite par le serveur</p>
|
<span>End-to-end encrypted · Nothing transits the server</span>
|
||||||
<a href="/settings" className="inline-block mt-2 text-slate-700 hover:text-slate-500 transition-colors">
|
<Link
|
||||||
Account
|
to="/settings"
|
||||||
</a>
|
className="text-ink hover:text-signal transition-colors duration-fast"
|
||||||
|
>
|
||||||
|
Account →
|
||||||
|
</Link>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -189,3 +228,65 @@ function HomeConnected() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DeviceChip({
|
||||||
|
name,
|
||||||
|
avatar,
|
||||||
|
onEdit,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
avatar: string | null;
|
||||||
|
onEdit: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onEdit}
|
||||||
|
className="group flex items-center gap-2.5 px-3 py-2 border border-paper-edge
|
||||||
|
hover:border-ink transition-colors duration-fast ease-crisp
|
||||||
|
bg-paper rounded-sm"
|
||||||
|
>
|
||||||
|
{avatar ? (
|
||||||
|
<img
|
||||||
|
src={avatar}
|
||||||
|
alt=""
|
||||||
|
className="w-5 h-5 rounded-full object-cover border border-paper-edge"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="w-5 h-5 rounded-full bg-paper-deep border border-paper-edge
|
||||||
|
flex items-center justify-center text-[10px] text-ink-muted"
|
||||||
|
>
|
||||||
|
◦
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-sm text-ink truncate max-w-[120px]">{name}</span>
|
||||||
|
<span className="text-xs text-ink-faint group-hover:text-ink transition-colors">
|
||||||
|
edit
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<h2 className="font-display text-2xl text-ink mt-2 tracking-tight">
|
||||||
|
{children}
|
||||||
|
</h2>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionLead({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-ink-muted mt-2 leading-relaxed max-w-md">
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -75,93 +75,106 @@ function JoinRoomConnected({ code }: { code?: string }) {
|
|||||||
}, [incomingRequest, rejectTransfer]);
|
}, [incomingRequest, rejectTransfer]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950">
|
<div className="min-h-screen">
|
||||||
<div className="max-w-lg mx-auto px-4 py-8">
|
<div className="max-w-xl mx-auto px-5 sm:px-8 pt-10 pb-24">
|
||||||
{/* Header */}
|
<header className="flex items-start justify-between pb-8 mb-10 rule">
|
||||||
<header className="text-center mb-8">
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-white mb-1">
|
<div className="text-xs uppercase tracking-[0.2em] text-ink-muted">
|
||||||
Any<span className="text-brand-400">Drop</span>
|
Public room
|
||||||
</h1>
|
</div>
|
||||||
<p className="text-slate-500 text-sm">
|
<h1 className="font-display text-3xl leading-none tracking-tight text-ink mt-2">
|
||||||
Room <span className="text-brand-300 font-mono font-bold">{code?.toUpperCase()}</span>
|
AnyDrop
|
||||||
</p>
|
</h1>
|
||||||
|
<p className="font-mono text-xs uppercase tracking-[0.25em] text-signal mt-3">
|
||||||
|
{code?.toUpperCase()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowProfileEdit(true)}
|
onClick={() => setShowProfileEdit(true)}
|
||||||
className="mt-3 inline-flex items-center gap-2 px-3 py-1.5 rounded-full
|
className="group flex items-center gap-2.5 px-3 py-2 border border-paper-edge
|
||||||
bg-slate-800/50 hover:bg-slate-800 transition-colors group"
|
hover:border-ink transition-colors duration-fast ease-crisp
|
||||||
|
bg-paper rounded-sm"
|
||||||
>
|
>
|
||||||
{avatar ? (
|
{avatar ? (
|
||||||
<img src={avatar} alt="" className="w-5 h-5 rounded-full object-cover" />
|
<img
|
||||||
|
src={avatar}
|
||||||
|
alt=""
|
||||||
|
className="w-5 h-5 rounded-full object-cover border border-paper-edge"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm">📱</span>
|
<span className="w-5 h-5 rounded-full bg-paper-deep border border-paper-edge" />
|
||||||
)}
|
)}
|
||||||
<span className="text-sm text-slate-400 group-hover:text-white transition-colors">
|
<span className="text-sm text-ink truncate max-w-[120px]">{deviceName}</span>
|
||||||
{deviceName}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] text-slate-600">✎</span>
|
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Error banner */}
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/30 rounded-xl text-sm text-red-400 flex items-center justify-between">
|
<div className="mb-8 px-4 py-3 border border-fail/40 bg-signal-quiet flex items-center justify-between rounded-sm">
|
||||||
<span>{error}</span>
|
<span className="text-sm text-ink">{error}</span>
|
||||||
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-300 ml-2">
|
<button
|
||||||
✕
|
onClick={() => setError(null)}
|
||||||
|
className="ml-3 text-ink-muted hover:text-ink text-lg leading-none"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
>
|
||||||
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Peer list */}
|
<section className="mb-12">
|
||||||
<section className="mb-6">
|
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
|
||||||
|
Room peers
|
||||||
|
</div>
|
||||||
|
<h2 className="font-display text-2xl text-ink mt-2 mb-5 tracking-tight">
|
||||||
|
Who's here
|
||||||
|
</h2>
|
||||||
<PeerList onPeerSelect={handlePeerSelect} />
|
<PeerList onPeerSelect={handlePeerSelect} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Drop zone */}
|
|
||||||
<section className="mb-6">
|
|
||||||
<DropZone onFilesSelected={handleFilesSelected} disabled={!selectedPeerId} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Text share button */}
|
|
||||||
{selectedPeerId && (
|
{selectedPeerId && (
|
||||||
<section className="mb-6">
|
<section className="mb-12">
|
||||||
<button
|
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
|
||||||
onClick={() => setShowTextModal(true)}
|
Compose
|
||||||
className="w-full flex items-center justify-center gap-2 px-4 py-3
|
</div>
|
||||||
border border-slate-700 hover:border-brand-500 rounded-xl
|
<h2 className="font-display text-2xl text-ink mt-2 mb-5 tracking-tight">
|
||||||
text-slate-300 hover:text-white transition-all
|
Drop files or send text
|
||||||
bg-slate-900/30 hover:bg-slate-900/50"
|
</h2>
|
||||||
>
|
<div className="space-y-3">
|
||||||
<span className="text-lg">💬</span>
|
<DropZone onFilesSelected={handleFilesSelected} />
|
||||||
<span className="text-sm font-medium">Envoyer du texte</span>
|
<button
|
||||||
</button>
|
onClick={() => setShowTextModal(true)}
|
||||||
|
className="w-full text-left px-4 py-3 border border-paper-edge bg-paper
|
||||||
|
hover:bg-paper-deep transition-colors duration-fast ease-crisp
|
||||||
|
rounded-sm text-sm text-ink"
|
||||||
|
>
|
||||||
|
Send text instead →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Transfer progress */}
|
<section className="mb-12">
|
||||||
<section className="mb-6">
|
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
|
||||||
|
Activity
|
||||||
|
</div>
|
||||||
<TransferProgress />
|
<TransferProgress />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Back to home */}
|
<footer className="pt-8 mt-14 rule flex items-center justify-between text-xs text-ink-muted">
|
||||||
<section className="text-center">
|
<span>End-to-end encrypted · Nothing transits the server</span>
|
||||||
<a href="/" className="text-sm text-slate-500 hover:text-slate-300 transition-colors">
|
<a
|
||||||
← Retour à l'accueil
|
href="/"
|
||||||
|
className="text-ink hover:text-signal transition-colors duration-fast"
|
||||||
|
>
|
||||||
|
Home →
|
||||||
</a>
|
</a>
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="text-center text-xs text-slate-600 mt-12">
|
|
||||||
<p>Peer-to-peer · Chiffré · Aucun fichier ne transite par le serveur</p>
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Profile edit modal */}
|
|
||||||
{showProfileEdit && (
|
{showProfileEdit && (
|
||||||
<ProfileSetup isEditing onDone={() => setShowProfileEdit(false)} />
|
<ProfileSetup isEditing onDone={() => setShowProfileEdit(false)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Modals */}
|
|
||||||
{showTextModal && selectedPeerId && (
|
{showTextModal && selectedPeerId && (
|
||||||
<TextShareModal
|
<TextShareModal
|
||||||
onSend={handleSendText}
|
onSend={handleSendText}
|
||||||
|
|||||||
@ -5,14 +5,13 @@ import { useProfileStore } from "../stores/useProfileStore";
|
|||||||
export default function Pair() {
|
export default function Pair() {
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { setGroupId, isSetUp } = useProfileStore();
|
const { setGroupId } = useProfileStore();
|
||||||
|
|
||||||
const groupId = params.get("g");
|
const groupId = params.get("g");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (groupId) {
|
if (groupId) {
|
||||||
setGroupId(groupId);
|
setGroupId(groupId);
|
||||||
// Small delay so user sees the confirmation
|
|
||||||
const t = setTimeout(() => navigate("/", { replace: true }), 1500);
|
const t = setTimeout(() => navigate("/", { replace: true }), 1500);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}
|
}
|
||||||
@ -20,18 +19,29 @@ export default function Pair() {
|
|||||||
|
|
||||||
if (!groupId) {
|
if (!groupId) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950 flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center px-4">
|
||||||
<p className="text-slate-400">Lien d'appairage invalide.</p>
|
<div className="paper-panel px-6 py-5 text-center">
|
||||||
|
<div className="text-xs uppercase tracking-[0.22em] text-fail">Invalid</div>
|
||||||
|
<p className="mt-2 text-sm text-ink">This pairing link is malformed.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950 flex flex-col items-center justify-center gap-4">
|
<div className="min-h-screen flex items-center justify-center px-4">
|
||||||
<div className="text-5xl">✅</div>
|
<div className="paper-panel px-8 py-8 max-w-sm w-full text-center">
|
||||||
<h1 className="text-xl font-bold text-white">Appareil appairé !</h1>
|
<div className="text-xs uppercase tracking-[0.22em] text-ok">Paired</div>
|
||||||
<p className="text-slate-400 text-sm">Vos appareils se verront automatiquement.</p>
|
<h1 className="font-display text-2xl text-ink mt-2 tracking-tight">
|
||||||
<p className="text-slate-600 text-xs">Redirection...</p>
|
Device linked
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-ink-muted mt-3 leading-relaxed">
|
||||||
|
Your devices will recognize each other automatically from now on.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-ink-faint mt-6 font-mono uppercase tracking-widest">
|
||||||
|
Redirecting…
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,50 +37,77 @@ export default function Settings() {
|
|||||||
}, [user, profile.isSetUp, profile.deviceId, profile.deviceName, profile.deviceType, profile.avatar, devices, setDevices]);
|
}, [user, profile.isSetUp, profile.deviceId, profile.deviceName, profile.deviceType, profile.avatar, devices, setDevices]);
|
||||||
|
|
||||||
if (!loaded || loading) {
|
if (!loaded || loading) {
|
||||||
return <SettingsShell><p className="text-slate-400">Loading…</p></SettingsShell>;
|
return (
|
||||||
|
<SettingsShell>
|
||||||
|
<p className="text-sm text-ink-muted">Loading…</p>
|
||||||
|
</SettingsShell>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return <SettingsShell><SignInForm initialError={error} /></SettingsShell>;
|
return (
|
||||||
|
<SettingsShell>
|
||||||
|
<SignInForm initialError={error} />
|
||||||
|
</SettingsShell>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsShell>
|
<SettingsShell>
|
||||||
{signedIn && (
|
{signedIn && (
|
||||||
<div className="mb-4 rounded-lg bg-emerald-500/10 border border-emerald-500/30 px-4 py-3 text-sm text-emerald-200">
|
<div className="mb-8 px-4 py-3 border border-ok/40 bg-paper-deep rounded-sm text-sm text-ink">
|
||||||
Signed in successfully.
|
Signed in successfully.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section className="mb-8">
|
<section className="mb-12">
|
||||||
<h2 className="text-xs uppercase tracking-wider text-slate-500 mb-2">Account</h2>
|
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
|
||||||
<div className="rounded-xl bg-slate-900/60 border border-slate-800 p-4">
|
Account
|
||||||
<div className="text-sm text-slate-400">Email</div>
|
</div>
|
||||||
<div className="text-slate-100">{user.email}</div>
|
<h2 className="font-display text-2xl text-ink mt-2 mb-5 tracking-tight">
|
||||||
<div className="mt-3 text-xs text-slate-500">Plan: <span className="text-slate-300">{user.plan}</span></div>
|
Your identity
|
||||||
|
</h2>
|
||||||
|
<div className="paper-panel px-5 py-4">
|
||||||
|
<div className="text-xs uppercase tracking-[0.15em] text-ink-muted">Email</div>
|
||||||
|
<div className="text-ink mt-1">{user.email}</div>
|
||||||
|
<div className="mt-4 pt-4 border-t border-paper-edge flex items-baseline justify-between">
|
||||||
|
<div className="text-xs uppercase tracking-[0.15em] text-ink-muted">Plan</div>
|
||||||
|
<div className="font-mono text-xs text-ink uppercase tracking-widest">
|
||||||
|
{user.plan}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-8">
|
<section className="mb-12">
|
||||||
<h2 className="text-xs uppercase tracking-wider text-slate-500 mb-2">Devices</h2>
|
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
|
||||||
<div className="rounded-xl bg-slate-900/60 border border-slate-800 divide-y divide-slate-800">
|
Devices
|
||||||
|
</div>
|
||||||
|
<h2 className="font-display text-2xl text-ink mt-2 mb-5 tracking-tight">
|
||||||
|
Linked devices
|
||||||
|
</h2>
|
||||||
|
<div className="paper-panel divide-y divide-paper-edge">
|
||||||
{devices.length === 0 && (
|
{devices.length === 0 && (
|
||||||
<div className="px-4 py-6 text-sm text-slate-500">No devices linked yet.</div>
|
<div className="px-5 py-6 text-sm text-ink-muted">No devices linked yet.</div>
|
||||||
)}
|
)}
|
||||||
{devices.map((d) => {
|
{devices.map((d) => {
|
||||||
const isCurrent = d.deviceId === profile.deviceId;
|
const isCurrent = d.deviceId === profile.deviceId;
|
||||||
return (
|
return (
|
||||||
<div key={d.id} className="flex items-center justify-between px-4 py-3">
|
<div key={d.id} className="flex items-center justify-between px-5 py-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-slate-100 flex items-center gap-2">
|
<div className="text-ink flex items-center gap-3">
|
||||||
{d.name}
|
<span>{d.name}</span>
|
||||||
{isCurrent && (
|
{isCurrent && (
|
||||||
<span className="text-[10px] uppercase tracking-wider bg-indigo-500/20 text-indigo-300 px-1.5 py-0.5 rounded">
|
<span className="font-mono text-[10px] uppercase tracking-[0.2em]
|
||||||
|
text-signal border border-signal/40 bg-signal-quiet
|
||||||
|
px-1.5 py-0.5 rounded-sm">
|
||||||
this device
|
this device
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-slate-500">{d.type} · linked {new Date(d.linkedAt).toLocaleDateString()}</div>
|
<div className="text-xs text-ink-muted mt-1">
|
||||||
|
{d.type} · linked {new Date(d.linkedAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!isCurrent && (
|
{!isCurrent && (
|
||||||
<button
|
<button
|
||||||
@ -88,9 +115,9 @@ export default function Settings() {
|
|||||||
await unlinkDevice(d.id);
|
await unlinkDevice(d.id);
|
||||||
setDevices(devices.filter((x) => x.id !== d.id));
|
setDevices(devices.filter((x) => x.id !== d.id));
|
||||||
}}
|
}}
|
||||||
className="text-xs text-slate-400 hover:text-rose-400"
|
className="text-xs text-ink-muted hover:text-signal transition-colors"
|
||||||
>
|
>
|
||||||
Remove
|
Unlink
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -101,7 +128,7 @@ export default function Settings() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => signOut()}
|
onClick={() => signOut()}
|
||||||
className="text-sm text-slate-400 hover:text-rose-400"
|
className="text-sm text-ink-muted hover:text-signal transition-colors"
|
||||||
>
|
>
|
||||||
Sign out
|
Sign out
|
||||||
</button>
|
</button>
|
||||||
@ -116,24 +143,34 @@ function SignInForm({ initialError }: { initialError: string | null }) {
|
|||||||
|
|
||||||
if (submitted) {
|
if (submitted) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl bg-slate-900/60 border border-slate-800 p-6 text-center">
|
<div className="paper-panel px-6 py-8 text-center">
|
||||||
<div className="text-xl mb-2">📬</div>
|
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
|
||||||
<h2 className="text-lg text-slate-100 mb-2">Check your inbox</h2>
|
Check your inbox
|
||||||
<p className="text-sm text-slate-400">
|
</div>
|
||||||
If an account exists for <span className="text-slate-200">{email}</span>, a sign-in link is on its way. It expires in 15 minutes.
|
<h2 className="font-display text-2xl text-ink mt-2 mb-3">
|
||||||
|
A sign-in link is on its way
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-ink-muted leading-relaxed">
|
||||||
|
If an account exists for <span className="text-ink">{email}</span>, we
|
||||||
|
sent a link. It expires in 15 minutes.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl bg-slate-900/60 border border-slate-800 p-6">
|
<div className="paper-panel px-6 py-7">
|
||||||
<h2 className="text-lg text-slate-100 mb-2">Sign in</h2>
|
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
|
||||||
<p className="text-sm text-slate-400 mb-4">
|
Sign in
|
||||||
Optional. Signing in lets you sync your profile and devices across browsers. Your transfers stay peer-to-peer.
|
</div>
|
||||||
|
<h2 className="font-display text-2xl text-ink mt-2 mb-3 tracking-tight">
|
||||||
|
Sync across browsers
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-ink-muted mb-6 leading-relaxed">
|
||||||
|
Optional. Signing in keeps your profile and devices together. Transfers stay peer-to-peer.
|
||||||
</p>
|
</p>
|
||||||
{initialError && (
|
{initialError && (
|
||||||
<div className="mb-4 rounded-lg bg-rose-500/10 border border-rose-500/30 px-3 py-2 text-xs text-rose-200">
|
<div className="mb-4 px-3 py-2 border border-fail/40 bg-signal-quiet text-xs text-ink rounded-sm">
|
||||||
{initialError === "invalid_or_expired"
|
{initialError === "invalid_or_expired"
|
||||||
? "That link has expired or already been used."
|
? "That link has expired or already been used."
|
||||||
: "Something went wrong. Please try again."}
|
: "Something went wrong. Please try again."}
|
||||||
@ -159,14 +196,19 @@ function SignInForm({ initialError }: { initialError: string | null }) {
|
|||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
className="bg-slate-950 border border-slate-700 rounded-lg px-3 py-2 text-slate-100 placeholder-slate-500 focus:outline-none focus:border-indigo-500"
|
className="bg-paper border border-paper-edge rounded-sm px-3 py-2.5
|
||||||
|
text-ink text-sm placeholder:text-ink-faint
|
||||||
|
focus:outline-none focus:border-ink transition-colors
|
||||||
|
duration-fast ease-crisp"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="bg-indigo-500 hover:bg-indigo-400 disabled:opacity-50 text-white font-medium rounded-lg px-4 py-2"
|
className="bg-ink text-paper text-sm font-medium rounded-sm px-4 py-2.5
|
||||||
|
hover:bg-signal transition-colors duration-fast ease-crisp
|
||||||
|
disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{submitting ? "Sending…" : "Send sign-in link"}
|
{submitting ? "Sending…" : "Send sign-in link →"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@ -175,13 +217,18 @@ function SignInForm({ initialError }: { initialError: string | null }) {
|
|||||||
|
|
||||||
function SettingsShell({ children }: { children: React.ReactNode }) {
|
function SettingsShell({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-950 text-slate-100 px-4 py-10">
|
<div className="min-h-screen">
|
||||||
<div className="max-w-lg mx-auto">
|
<div className="max-w-xl mx-auto px-5 sm:px-8 pt-10 pb-24">
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<header className="flex items-center justify-between pb-8 mb-10 rule">
|
||||||
<Link to="/" className="text-sm text-slate-400 hover:text-slate-200">← Back</Link>
|
<Link
|
||||||
<h1 className="text-xl font-semibold">Account</h1>
|
to="/"
|
||||||
|
className="text-sm text-ink-muted hover:text-ink transition-colors duration-fast"
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</Link>
|
||||||
|
<h1 className="font-display text-xl text-ink tracking-tight">Account</h1>
|
||||||
<div className="w-10" />
|
<div className="w-10" />
|
||||||
</div>
|
</header>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -10,33 +10,29 @@ interface SharedData {
|
|||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read shared files/text from the cache stashed by the service worker */
|
|
||||||
async function readSharedData(): Promise<SharedData> {
|
async function readSharedData(): Promise<SharedData> {
|
||||||
const result: SharedData = { files: [], text: "" };
|
const result: SharedData = { files: [], text: "" };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cache = await caches.open("share-target");
|
const cache = await caches.open("share-target");
|
||||||
|
|
||||||
// Read metadata
|
|
||||||
const metaResponse = await cache.match("/share-target-meta");
|
const metaResponse = await cache.match("/share-target-meta");
|
||||||
if (!metaResponse) return result;
|
if (!metaResponse) return result;
|
||||||
|
|
||||||
const meta = await metaResponse.json();
|
const meta = await metaResponse.json();
|
||||||
result.text = meta.text || "";
|
result.text = meta.text || "";
|
||||||
|
|
||||||
// Read files
|
|
||||||
for (let i = 0; i < (meta.count || 0); i++) {
|
for (let i = 0; i < (meta.count || 0); i++) {
|
||||||
const fileResponse = await cache.match(`/share-target-file/${i}`);
|
const fileResponse = await cache.match(`/share-target-file/${i}`);
|
||||||
if (!fileResponse) continue;
|
if (!fileResponse) continue;
|
||||||
|
|
||||||
const blob = await fileResponse.blob();
|
const blob = await fileResponse.blob();
|
||||||
const fileName = decodeURIComponent(
|
const fileName = decodeURIComponent(
|
||||||
fileResponse.headers.get("X-File-Name") || `fichier-${i}`,
|
fileResponse.headers.get("X-File-Name") || `file-${i}`,
|
||||||
);
|
);
|
||||||
result.files.push(new File([blob], fileName, { type: blob.type }));
|
result.files.push(new File([blob], fileName, { type: blob.type }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up cache
|
|
||||||
await caches.delete("share-target");
|
await caches.delete("share-target");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[share] Failed to read shared data:", err);
|
console.error("[share] Failed to read shared data:", err);
|
||||||
@ -57,7 +53,6 @@ export default function Share() {
|
|||||||
|
|
||||||
function ShareConnected() {
|
function ShareConnected() {
|
||||||
const { sendFiles, sendText } = useSignaling();
|
const { sendFiles, sendText } = useSignaling();
|
||||||
const peers = useStore((s) => s.peers);
|
|
||||||
const setSelectedPeerId = useStore((s) => s.setSelectedPeerId);
|
const setSelectedPeerId = useStore((s) => s.setSelectedPeerId);
|
||||||
|
|
||||||
const [shared, setShared] = useState<SharedData | null>(null);
|
const [shared, setShared] = useState<SharedData | null>(null);
|
||||||
@ -90,67 +85,72 @@ function ShareConnected() {
|
|||||||
const hasText = !!shared?.text;
|
const hasText = !!shared?.text;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950">
|
<div className="min-h-screen">
|
||||||
<div className="max-w-lg mx-auto px-4 py-8">
|
<div className="max-w-xl mx-auto px-5 sm:px-8 pt-10 pb-24">
|
||||||
{/* Header */}
|
<header className="pb-8 mb-10 rule">
|
||||||
<header className="text-center mb-6">
|
<div className="text-xs uppercase tracking-[0.2em] text-ink-muted">
|
||||||
<h1 className="text-3xl font-bold text-white mb-1">
|
Share sheet
|
||||||
Any<span className="text-brand-400">Drop</span>
|
</div>
|
||||||
|
<h1 className="font-display text-3xl leading-none tracking-tight text-ink mt-2">
|
||||||
|
Send via AnyDrop
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{!shared ? (
|
|
||||||
<p className="text-slate-500 text-sm mt-4">Chargement...</p>
|
|
||||||
) : sent ? (
|
|
||||||
<div className="mt-6">
|
|
||||||
<div className="text-4xl mb-3">✓</div>
|
|
||||||
<p className="text-brand-400 font-medium">Envoi en cours</p>
|
|
||||||
<a
|
|
||||||
href="/"
|
|
||||||
className="inline-block mt-4 text-sm text-slate-500 hover:text-slate-300 transition-colors"
|
|
||||||
>
|
|
||||||
Retour à l'accueil
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p className="text-slate-400 text-sm mt-4">Envoyer à quel appareil ?</p>
|
|
||||||
|
|
||||||
{/* What's being shared */}
|
|
||||||
<div className="mt-3 inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-slate-800/50 text-xs text-slate-400">
|
|
||||||
{fileCount > 0 && (
|
|
||||||
<span>
|
|
||||||
📎 {fileCount} {fileCount > 1 ? "fichiers" : "fichier"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{hasText && !fileCount && <span>💬 Texte</span>}
|
|
||||||
{hasText && fileCount > 0 && <span>+ texte</span>}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Peer list — tap to send immediately */}
|
{!shared ? (
|
||||||
{!sent && shared && (
|
<p className="text-sm text-ink-muted">Loading…</p>
|
||||||
<section>
|
) : sent ? (
|
||||||
{peers.length === 0 ? (
|
<div className="paper-panel px-6 py-8 text-center">
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
|
<div className="text-xs uppercase tracking-[0.22em] text-ok">Sending</div>
|
||||||
<div className="text-5xl mb-4">📡</div>
|
<h2 className="font-display text-2xl text-ink mt-2 tracking-tight">
|
||||||
<p className="text-lg font-medium">En attente d'appareils...</p>
|
Transfer on its way
|
||||||
<p className="text-sm mt-2 text-center max-w-xs">
|
</h2>
|
||||||
Ouvrez AnyDrop sur l'appareil destinataire.
|
<a
|
||||||
</p>
|
href="/"
|
||||||
|
className="inline-block mt-5 text-sm text-ink hover:text-signal transition-colors"
|
||||||
|
>
|
||||||
|
Back home →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<section className="mb-10">
|
||||||
|
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
|
||||||
|
Payload
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div className="mt-3 paper-panel px-4 py-3 inline-flex items-center gap-3
|
||||||
<div className="flex flex-wrap justify-center gap-6 py-8">
|
text-sm text-ink">
|
||||||
<PeerList onPeerSelect={handlePeerSelect} />
|
{fileCount > 0 && (
|
||||||
|
<span className="font-mono text-xs uppercase tracking-widest text-ink-muted">
|
||||||
|
{fileCount} {fileCount > 1 ? "files" : "file"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{hasText && !fileCount && (
|
||||||
|
<span className="font-mono text-xs uppercase tracking-widest text-ink-muted">
|
||||||
|
Text
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{hasText && fileCount > 0 && (
|
||||||
|
<span className="font-mono text-xs uppercase tracking-widest text-ink-muted">
|
||||||
|
+ text
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</section>
|
||||||
</section>
|
|
||||||
|
<section>
|
||||||
|
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
|
||||||
|
Pick a device
|
||||||
|
</div>
|
||||||
|
<h2 className="font-display text-2xl text-ink mt-2 mb-5 tracking-tight">
|
||||||
|
Who should receive this?
|
||||||
|
</h2>
|
||||||
|
<PeerList onPeerSelect={handlePeerSelect} />
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Footer */}
|
<footer className="pt-8 mt-14 rule text-xs text-ink-muted">
|
||||||
<footer className="text-center text-xs text-slate-600 mt-12">
|
End-to-end encrypted · Nothing transits the server
|
||||||
<p>Peer-to-peer · Chiffré · Aucun fichier ne transite par le serveur</p>
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,22 +1,59 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||||
darkMode: "class",
|
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
brand: {
|
paper: "#F5F0E6",
|
||||||
50: "#eef2ff",
|
"paper-deep": "#EBE4D4",
|
||||||
100: "#e0e7ff",
|
"paper-edge": "#DCD3BE",
|
||||||
200: "#c7d2fe",
|
ink: "#1A1714",
|
||||||
300: "#a5b4fc",
|
"ink-muted": "#6B635A",
|
||||||
400: "#818cf8",
|
"ink-faint": "#A89F93",
|
||||||
500: "#6366f1",
|
signal: "#7A2320",
|
||||||
600: "#4f46e5",
|
"signal-quiet": "#F3E2E0",
|
||||||
700: "#4338ca",
|
ok: "#3E6B4A",
|
||||||
800: "#3730a3",
|
warn: "#8B6914",
|
||||||
900: "#312e81",
|
fail: "#8A3324",
|
||||||
},
|
},
|
||||||
|
fontFamily: {
|
||||||
|
display: ['"Fraunces"', '"GT Sectra"', "Georgia", "serif"],
|
||||||
|
sans: ['"Inter"', '"Söhne"', "system-ui", "-apple-system", "sans-serif"],
|
||||||
|
mono: ['"JetBrains Mono"', '"Berkeley Mono"', "ui-monospace", "monospace"],
|
||||||
|
},
|
||||||
|
fontSize: {
|
||||||
|
// Paper scale — ratio 1.25, capped hard
|
||||||
|
xs: ["12px", { lineHeight: "1.45" }],
|
||||||
|
sm: ["14px", { lineHeight: "1.5" }],
|
||||||
|
base: ["15px", { lineHeight: "1.55" }],
|
||||||
|
lg: ["17px", { lineHeight: "1.5" }],
|
||||||
|
xl: ["22px", { lineHeight: "1.35" }],
|
||||||
|
"2xl": ["32px", { lineHeight: "1.2" }],
|
||||||
|
"3xl": ["48px", { lineHeight: "1.05" }],
|
||||||
|
"4xl": ["72px", { lineHeight: "1.0", letterSpacing: "-0.02em" }],
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
none: "0",
|
||||||
|
sm: "2px",
|
||||||
|
DEFAULT: "4px",
|
||||||
|
md: "4px",
|
||||||
|
lg: "6px",
|
||||||
|
pill: "999px",
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
paper:
|
||||||
|
"0 1px 0 rgba(26,23,20,0.04), 0 1px 3px rgba(26,23,20,0.06)",
|
||||||
|
lift:
|
||||||
|
"0 2px 6px rgba(26,23,20,0.08), 0 8px 24px rgba(26,23,20,0.08)",
|
||||||
|
},
|
||||||
|
transitionTimingFunction: {
|
||||||
|
paper: "cubic-bezier(0.16, 1, 0.3, 1)",
|
||||||
|
crisp: "cubic-bezier(0.2, 0.0, 0.0, 1.0)",
|
||||||
|
},
|
||||||
|
transitionDuration: {
|
||||||
|
fast: "160ms",
|
||||||
|
base: "320ms",
|
||||||
|
paper: "480ms",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user