Skip to content

Commit 5f12d59

Browse files
Sober (flat) popups + per-link Open Graph share previews
UI: - Reworked all dialogs/toasts to a flat, sober style (variant 1): neutral surfaces, thin borders, no coloured glow, accent kept minimal. Softened the global look — flat primary buttons, neutral elevation, calmer wordmark, fainter background tint. - Restyled the Remote-Upload page to the dark theme (FR) and note that fetched files land in the Remote-Upload folder. Share link previews: - /s/[token] is now a server wrapper that emits per-link Open Graph / Twitter metadata, so pasting a link into Discord/Slack/X shows a rich card. Public image shares preview the image itself; other files and folders get a generated branded OG card (/s/[token]/opengraph-image). Code/sign-in-protected links never leak their contents, and all share links are noindex.
1 parent 61d98ce commit 5f12d59

10 files changed

Lines changed: 278 additions & 81 deletions

File tree

apps/web/src/app/(app)/drive/page.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -804,8 +804,11 @@ function ErrorLine({ msg }: { msg: string }) {
804804

805805
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
806806
return (
807-
<div className="fixed inset-0 z-50 grid place-items-center bg-black/60 p-4 backdrop-blur-sm" onClick={onClose}>
808-
<div className="card w-full max-w-md border-white/10 bg-ink-850" onClick={(e) => e.stopPropagation()}>
807+
<div className="fixed inset-0 z-50 grid place-items-center bg-black/60 p-4" onClick={onClose}>
808+
<div
809+
className="w-full max-w-md rounded-xl border border-white/[0.08] bg-[#111118] p-[18px] shadow-xl"
810+
onClick={(e) => e.stopPropagation()}
811+
>
809812
{children}
810813
</div>
811814
</div>

apps/web/src/app/(app)/remote/page.tsx

Lines changed: 34 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -45,48 +45,46 @@ export default function RemotePage() {
4545

4646
return (
4747
<div className="space-y-6">
48-
<div className="card space-y-3">
49-
<div>
50-
<h1 className="text-lg font-semibold">Remote-Upload</h1>
51-
<p className="text-sm text-neutral-500">
52-
Paste a link and the server downloads it directly — your own connection stays free.
53-
Files are antivirus-scanned and encrypted at rest.
54-
</p>
55-
</div>
56-
<form onSubmit={submit} className="flex gap-2">
48+
<div>
49+
<h1 className="text-2xl font-semibold tracking-tight text-white">Remote-Upload</h1>
50+
<p className="mt-1 text-sm text-zinc-500">
51+
Collez un lien : le serveur le télécharge directement (votre connexion reste libre).
52+
Les fichiers sont analysés et chiffrés au repos, puis rangés dans votre dossier{' '}
53+
<span className="font-medium text-zinc-300">Remote-Upload</span>.
54+
</p>
55+
</div>
56+
57+
<div className="card">
58+
<form onSubmit={submit} className="flex flex-col gap-2 sm:flex-row">
5759
<input
5860
className="input"
5961
type="url"
60-
placeholder="https://example.com/large-file.zip"
62+
placeholder="https://example.com/gros-fichier.zip"
6163
value={url}
6264
onChange={(e) => setUrl(e.target.value)}
6365
required
6466
/>
6567
<button className="btn-primary whitespace-nowrap" disabled={busy}>
66-
{busy ? 'Queuing…' : 'Fetch'}
68+
{busy ? 'En file…' : 'Télécharger'}
6769
</button>
6870
</form>
69-
{error && <p className="text-sm text-red-600">{error}</p>}
71+
{error && <p className="mt-2 text-sm text-red-300">{error}</p>}
7072
</div>
7173

72-
<div className="card p-0">
73-
<div className="border-b border-neutral-100 px-4 py-2 text-sm font-medium dark:border-neutral-800">
74-
Jobs
75-
</div>
74+
<div>
75+
<h2 className="mb-2 text-sm font-medium text-zinc-400">Tâches</h2>
7676
{jobs.length === 0 ? (
77-
<p className="p-6 text-center text-sm text-neutral-400">No remote uploads yet.</p>
77+
<div className="card py-10 text-center text-sm text-zinc-500">Aucun téléchargement distant pour l’instant.</div>
7878
) : (
79-
<div className="divide-y divide-neutral-100 dark:divide-neutral-800">
79+
<div className="space-y-2">
8080
{jobs.map((job) => (
81-
<div key={job.id} className="flex items-center justify-between gap-3 px-4 py-3">
81+
<div key={job.id} className="row">
8282
<div className="min-w-0">
83-
<p className="truncate text-sm">{job.sourceUrl}</p>
84-
{job.error && <p className="text-xs text-red-600">{job.error}</p>}
83+
<p className="truncate text-sm text-zinc-200">{job.sourceUrl}</p>
84+
{job.error && <p className="truncate text-xs text-red-300">{job.error}</p>}
8585
</div>
86-
<div className="flex items-center gap-3 whitespace-nowrap text-xs">
87-
{job.sizeBytes != null && (
88-
<span className="text-neutral-400">{formatBytes(job.sizeBytes)}</span>
89-
)}
86+
<div className="flex shrink-0 items-center gap-3 whitespace-nowrap text-xs">
87+
{job.sizeBytes != null && <span className="text-zinc-500">{formatBytes(job.sizeBytes)}</span>}
9088
<JobStatus status={job.status} />
9189
</div>
9290
</div>
@@ -100,10 +98,16 @@ export default function RemotePage() {
10098

10199
function JobStatus({ status }: { status: PublicRemoteJob['status'] }) {
102100
const map: Record<PublicRemoteJob['status'], string> = {
103-
QUEUED: 'bg-neutral-100 text-neutral-600',
104-
RUNNING: 'bg-amber-100 text-amber-700',
105-
DONE: 'bg-green-100 text-green-700',
106-
FAILED: 'bg-red-100 text-red-700',
101+
QUEUED: 'bg-white/[0.06] text-zinc-400',
102+
RUNNING: 'bg-amber-500/10 text-amber-300',
103+
DONE: 'bg-emerald-500/10 text-emerald-300',
104+
FAILED: 'bg-red-500/10 text-red-300',
105+
};
106+
const label: Record<PublicRemoteJob['status'], string> = {
107+
QUEUED: 'En file',
108+
RUNNING: 'En cours',
109+
DONE: 'Terminé',
110+
FAILED: 'Échec',
107111
};
108-
return <span className={`rounded px-2 py-0.5 uppercase ${map[status]}`}>{status}</span>;
112+
return <span className={`rounded px-2 py-0.5 font-medium uppercase tracking-wide ${map[status]}`}>{label[status]}</span>;
109113
}

apps/web/src/app/globals.css

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,8 @@ body {
1313

1414
body {
1515
@apply bg-ink-950 text-zinc-200 antialiased;
16-
/* subtle violet radial glow in the corners for depth */
17-
background-image:
18-
radial-gradient(60rem 60rem at 100% -10%, rgba(139, 92, 246, 0.06), transparent 60%),
19-
radial-gradient(50rem 50rem at -10% 110%, rgba(139, 92, 246, 0.05), transparent 60%);
16+
/* very faint, single-corner tint — kept minimal for a sober feel */
17+
background-image: radial-gradient(70rem 70rem at 100% -20%, rgba(139, 92, 246, 0.03), transparent 60%);
2018
background-attachment: fixed;
2119
}
2220

@@ -39,7 +37,7 @@ body {
3937
@apply inline-flex items-center justify-center gap-2 rounded-lg px-3.5 py-2 text-sm font-medium transition-all duration-150 disabled:cursor-not-allowed disabled:opacity-50;
4038
}
4139
.btn-primary {
42-
@apply btn bg-accent text-white shadow-sm hover:bg-accent-hover hover:shadow-glow;
40+
@apply btn bg-accent text-white hover:bg-accent-hover;
4341
}
4442
.btn-ghost {
4543
@apply btn border border-white/10 bg-white/[0.03] text-zinc-200 hover:border-white/20 hover:bg-white/[0.06];

apps/web/src/app/s/[token]/ShareClient.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* shared folder, and respects the link's access mode (public / code / sign-in required),
77
* expiry, and download-disabled flag.
88
*/
9-
import { use, useCallback, useEffect, useState } from 'react';
9+
import { useCallback, useEffect, useState } from 'react';
1010
import Link from 'next/link';
1111
import { Download } from 'lucide-react';
1212
import type { PublicShareView, ShareEntry } from '@opencoperlock/shared/client';
@@ -23,8 +23,7 @@ function fileUrl(token: string, fileId: string, opts: { inline?: boolean; code?:
2323
return `${API_URL}/s/${token}/file/${fileId}${qs ? `?${qs}` : ''}`;
2424
}
2525

26-
export default function SharePage({ params }: { params: Promise<{ token: string }> }) {
27-
const { token } = use(params);
26+
export default function ShareClient({ token }: { token: string }) {
2827
const [view, setView] = useState<PublicShareView | null>(null);
2928
const [code, setCode] = useState('');
3029
const [submittedCode, setSubmittedCode] = useState<string | undefined>(undefined);
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { ImageResponse } from 'next/og';
2+
import { fetchShareView, kindLabel } from '@/lib/shareMeta';
3+
4+
// Branded Open Graph card for a share link (folders + non-image files; public images use the
5+
// image itself, set in generateMetadata). 1200×630 PNG.
6+
export const runtime = 'edge';
7+
export const alt = 'Partage OpenCoperLock';
8+
export const size = { width: 1200, height: 630 };
9+
export const contentType = 'image/png';
10+
11+
export default async function ShareOgImage({ params }: { params: Promise<{ token: string }> }) {
12+
const { token } = await params;
13+
const view = await fetchShareView(token);
14+
15+
let heading = 'Partage';
16+
let title = 'Fichier partagé';
17+
let subtitle = 'Partagé via OpenCoperLock';
18+
19+
if (!view || view.expired) {
20+
heading = 'Lien de partage';
21+
title = 'Lien indisponible';
22+
subtitle = 'Ce lien est invalide ou a expiré';
23+
} else if (view.requiresCode || view.requiresAuth) {
24+
heading = 'Partage protégé';
25+
title = view.requiresAuth ? 'Réservé aux comptes' : 'Protégé par un code';
26+
subtitle = 'Ouvrez le lien pour y accéder';
27+
} else if (view.file) {
28+
heading = kindLabel(view.file.kind);
29+
title = view.file.name;
30+
subtitle = 'Partagé via OpenCoperLock';
31+
} else if (view.entries) {
32+
const n = view.entries.length;
33+
heading = 'Dossier partagé';
34+
title = `${n} fichier${n > 1 ? 's' : ''}`;
35+
subtitle = 'Partagé via OpenCoperLock';
36+
}
37+
38+
return new ImageResponse(
39+
(
40+
<div
41+
style={{
42+
width: '100%',
43+
height: '100%',
44+
display: 'flex',
45+
flexDirection: 'column',
46+
justifyContent: 'space-between',
47+
padding: '72px',
48+
background: '#0a0a0f',
49+
color: 'white',
50+
fontFamily: 'sans-serif',
51+
}}
52+
>
53+
{/* Brand */}
54+
<div style={{ display: 'flex', fontSize: 30, fontWeight: 700, letterSpacing: -0.5 }}>
55+
<span style={{ color: '#71717a' }}>Open</span>
56+
<span style={{ color: 'white' }}>Coper</span>
57+
<span style={{ color: '#a78bfa' }}>Lock</span>
58+
</div>
59+
60+
{/* Main */}
61+
<div style={{ display: 'flex', flexDirection: 'column' }}>
62+
<div
63+
style={{
64+
alignSelf: 'flex-start',
65+
fontSize: 22,
66+
color: '#a78bfa',
67+
border: '1px solid rgba(167,139,250,0.4)',
68+
borderRadius: 8,
69+
padding: '6px 14px',
70+
marginBottom: 24,
71+
}}
72+
>
73+
{heading}
74+
</div>
75+
<div
76+
style={{
77+
fontSize: 60,
78+
fontWeight: 700,
79+
letterSpacing: -1,
80+
lineHeight: 1.1,
81+
maxWidth: 1000,
82+
overflow: 'hidden',
83+
}}
84+
>
85+
{title.length > 70 ? `${title.slice(0, 70)}…` : title}
86+
</div>
87+
<div style={{ marginTop: 20, fontSize: 28, color: '#a1a1aa' }}>{subtitle}</div>
88+
</div>
89+
90+
{/* Footer */}
91+
<div style={{ fontSize: 22, color: '#52525b' }}>copper.forgenet.fr · par Forge Network</div>
92+
</div>
93+
),
94+
size,
95+
);
96+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import type { Metadata } from 'next';
2+
import { formatBytes } from '@opencoperlock/shared/client';
3+
import { fetchShareView, kindLabel, shareInlineUrl } from '@/lib/shareMeta';
4+
import ShareClient from './ShareClient';
5+
6+
/**
7+
* Server wrapper for a share link. It exists so we can emit per-link Open Graph metadata —
8+
* a rich preview card (and, for public images, the image itself) when the link is pasted
9+
* into Discord/Slack/X — while the interactive recipient UI stays a client component.
10+
*
11+
* Share links are always `noindex`: previews are fine, search-engine indexing is not.
12+
*/
13+
export async function generateMetadata({ params }: { params: Promise<{ token: string }> }): Promise<Metadata> {
14+
const { token } = await params;
15+
const view = await fetchShareView(token);
16+
17+
const base: Metadata = { robots: { index: false, follow: false } };
18+
19+
if (!view || view.expired) {
20+
return { ...base, title: 'Lien de partage', description: 'Ce lien de partage est invalide ou a expiré.' };
21+
}
22+
23+
// Protected links never reveal their contents in a preview.
24+
if (view.requiresCode || view.requiresAuth) {
25+
const why = view.requiresAuth ? 'réservé aux comptes' : 'protégé par un code';
26+
return {
27+
...base,
28+
title: 'Partage protégé',
29+
description: `Ce lien est ${why}. Ouvrez-le pour y accéder.`,
30+
openGraph: {
31+
title: 'Partage protégé · OpenCoperLock',
32+
description: `Ce lien est ${why}.`,
33+
},
34+
};
35+
}
36+
37+
// Single file.
38+
if (view.file) {
39+
const f = view.file;
40+
const desc = `${kindLabel(f.kind)} · ${formatBytes(f.sizeBytes)} — partagé via OpenCoperLock`;
41+
// For a public image, the image itself makes the best preview; otherwise a branded card.
42+
const images = f.kind === 'image' ? [shareInlineUrl(token, f.fileId)] : [`/s/${token}/opengraph-image`];
43+
return {
44+
...base,
45+
title: f.name,
46+
description: desc,
47+
openGraph: { title: f.name, description: desc, images },
48+
twitter: { card: 'summary_large_image', title: f.name, description: desc, images },
49+
};
50+
}
51+
52+
// Folder.
53+
const n = view.entries?.length ?? 0;
54+
const title = `Dossier partagé (${n} fichier${n > 1 ? 's' : ''})`;
55+
return {
56+
...base,
57+
title,
58+
description: `${n} fichier${n > 1 ? 's' : ''} partagé${n > 1 ? 's' : ''} via OpenCoperLock.`,
59+
openGraph: { title, description: `Partagé via OpenCoperLock.`, images: [`/s/${token}/opengraph-image`] },
60+
twitter: { card: 'summary_large_image', title, images: [`/s/${token}/opengraph-image`] },
61+
};
62+
}
63+
64+
export default async function SharePage({ params }: { params: Promise<{ token: string }> }) {
65+
const { token } = await params;
66+
return <ShareClient token={token} />;
67+
}
Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
/**
2-
* Stylised brand wordmark. "Open" in muted zinc, "Coper" in white, "Lock" in the violet
3-
* accent with a soft glow — distinctive without a separate logo glyph beside it.
2+
* Stylised brand wordmark. "Open" in muted zinc, "Coper" in white, "Lock" in a restrained
3+
* violet — distinctive without a separate logo glyph or glow, to keep the sober look.
44
*/
55
export function Wordmark({ className = '' }: { className?: string }) {
66
return (
77
<span className={`select-none text-lg font-semibold tracking-tight ${className}`}>
8-
<span className="text-zinc-400">Open</span>
8+
<span className="text-zinc-500">Open</span>
99
<span className="text-white">Coper</span>
10-
<span className="bg-gradient-to-r from-violet-400 to-fuchsia-400 bg-clip-text text-transparent [text-shadow:0_0_18px_rgba(139,92,246,0.35)]">
11-
Lock
12-
</span>
10+
<span className="text-violet-400">Lock</span>
1311
</span>
1412
);
1513
}

0 commit comments

Comments
 (0)