Refonte page de connexion : vraies photos Unsplash, thème unique
All checks were successful
CI / build-and-test (push) Successful in 2m14s
CI / deploy (push) Successful in 3m15s

- Fond de connexion : diaporama de photos Unsplash hotlinkées (jamais
  réhébergées) avec attribution photographe + Unsplash toujours visible,
  conforme aux règles d'usage de l'API (download tracking déclenché).
- Suppression complète du switch thème clair/sombre : plus qu'un seul
  thème assumé (ThemeContext, darkMode Tailwind et .dark CSS retirés).
- Nouveau favicon minimaliste (l'ancien logo violet dégradé remplacé
  par un mark plat noir/citron vert cohérent avec la nouvelle DA).
- Suppression des doodles décoratifs, devenus superflus sur une vraie
  photo, pour un rendu plus sobre.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Adam Belkacemi 2026-09-22 15:48:55 +02:00
parent 4cf882144c
commit ddd0fbc0cb
13 changed files with 103 additions and 202 deletions

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 364 B

Before After
Before After

View file

@ -6,7 +6,6 @@ import { PhotosView } from './components/photos/PhotosView';
import { SettingsView } from './components/settings/SettingsView'; import { SettingsView } from './components/settings/SettingsView';
import { NavigationProvider, useNavigation } from './contexts/NavigationContext'; import { NavigationProvider, useNavigation } from './contexts/NavigationContext';
import { SessionProvider, useSession } from './contexts/SessionContext'; import { SessionProvider, useSession } from './contexts/SessionContext';
import { ThemeProvider } from './contexts/ThemeContext';
/** Content router — a plain state switch (NavigationContext.nav), no react-router. */ /** Content router — a plain state switch (NavigationContext.nav), no react-router. */
function AppContent() { function AppContent() {
@ -41,10 +40,8 @@ function Root() {
export default function App() { export default function App() {
return ( return (
<ThemeProvider>
<SessionProvider> <SessionProvider>
<Root /> <Root />
</SessionProvider> </SessionProvider>
</ThemeProvider>
); );
} }

View file

@ -1,46 +1,55 @@
import { BACKGROUND_SCENES } from '../../lib/backgrounds'; import { BACKGROUND_PHOTOS } from '../../lib/backgrounds';
import { useBackgroundCrossfade } from '../../hooks/useBackgroundCrossfade'; import { useBackgroundCrossfade } from '../../hooks/useBackgroundCrossfade';
/**
* Photos hotlinkées depuis Unsplash (jamais copiées sur notre serveur) —
* requis par leur charte développeur. L'attribution ci-dessous (nom du
* photographe + Unsplash, tous deux liés) est également requise, pas
* décorative : elle doit rester visible en permanence, pas seulement
* au survol.
*/
export function AuthBackground() { export function AuthBackground() {
const activeIndex = useBackgroundCrossfade(); const activeIndex = useBackgroundCrossfade();
const active = BACKGROUND_PHOTOS[activeIndex];
return ( return (
<div className="absolute inset-0 overflow-hidden bg-[#f4efe6]"> <div className="absolute inset-0 overflow-hidden bg-[#171410]">
{BACKGROUND_SCENES.map((scene, i) => ( {BACKGROUND_PHOTOS.map((photo, i) => (
<div <img
key={scene.id} key={photo.id}
className="absolute inset-0 transition-opacity duration-[2400ms] ease-in-out" src={photo.url}
style={{ opacity: i === activeIndex ? 1 : 0, background: scene.base }} alt=""
> aria-hidden="true"
{scene.blobs.map((blob, bi) => ( className="absolute inset-0 h-full w-full object-cover transition-opacity duration-[2400ms] ease-in-out"
<div style={{ opacity: i === activeIndex ? 1 : 0 }}
key={bi}
className="tc-drift absolute rounded-full blur-[90px]"
style={{
top: blob.top,
left: blob.left,
width: blob.size,
height: blob.size,
background: blob.color,
animationDelay: `${bi * -7}s`,
}}
/> />
))} ))}
{/* Voile sombre pour garantir la lisibilité du texte clair par-dessus
une vraie photo, plus prononcé à gauche où vit le contenu. */}
<div className="absolute inset-0 bg-[linear-gradient(100deg,rgba(15,13,9,0.78)_0%,rgba(15,13,9,0.5)_46%,rgba(15,13,9,0.22)_75%)]" />
<div className="absolute inset-0 bg-[linear-gradient(0deg,rgba(15,13,9,0.35)_0%,transparent_30%)]" />
<div className="absolute bottom-5 right-5 z-10 rounded-full bg-black/35 px-3.5 py-1.5 text-[11.5px] font-medium text-white/80 backdrop-blur-md sm:bottom-7 sm:right-7">
Photo par{' '}
<a
href={active.photographerUrl}
target="_blank"
rel="noopener noreferrer"
className="underline decoration-white/40 underline-offset-2 hover:text-white hover:decoration-white"
>
{active.photographerName}
</a>{' '}
sur{' '}
<a
href="https://unsplash.com/?utm_source=tripoli_cloud&utm_medium=referral"
target="_blank"
rel="noopener noreferrer"
className="underline decoration-white/40 underline-offset-2 hover:text-white hover:decoration-white"
>
Unsplash
</a>
</div> </div>
))}
{/* Grain léger : casse l'effet "dégradé plat", donne une texture
plus organique - technique courante sur les fonds génératifs. */}
<svg className="absolute inset-0 h-full w-full opacity-[0.05] mix-blend-multiply" aria-hidden="true">
<filter id="tc-grain">
<feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch" />
</filter>
<rect width="100%" height="100%" filter="url(#tc-grain)" />
</svg>
{/* Léger éclaircissement côté texte (gauche) pour garantir le
contraste de l'encre noire, sans jamais assombrir le fond. */}
<div className="absolute inset-0 bg-[linear-gradient(100deg,rgba(250,247,241,0.55)_0%,rgba(250,247,241,0.2)_42%,transparent_68%)]" />
</div> </div>
); );
} }

View file

@ -1,43 +1,38 @@
import { AuthBackground } from './AuthBackground'; import { AuthBackground } from './AuthBackground';
import { AuthPanel } from './AuthPanel'; import { AuthPanel } from './AuthPanel';
import { DoodleArrow, DoodleSquiggle } from './Doodle';
import { TripoliLogo, TripoliWordmark } from '../layout/TripoliLogo'; import { TripoliLogo, TripoliWordmark } from '../layout/TripoliLogo';
export function AuthLayout() { export function AuthLayout() {
return ( return (
<div className="relative min-h-screen overflow-hidden bg-[#f4efe6]"> <div className="relative min-h-screen overflow-hidden bg-[#171410]">
<AuthBackground /> <AuthBackground />
<div className="relative flex min-h-screen flex-col justify-between gap-11 p-6 sm:p-10 lg:p-[60px]"> <div className="relative flex min-h-screen flex-col justify-between gap-11 p-6 sm:p-10 lg:p-[60px]">
<div className="flex items-center gap-2.5 text-ink"> <div className="flex items-center gap-2.5 text-white">
<TripoliLogo size={30} variant="hero" /> <TripoliLogo size={30} variant="hero" />
<TripoliWordmark className="text-base" /> <TripoliWordmark className="text-base" />
</div> </div>
<div className="flex flex-wrap items-center justify-between gap-8 sm:gap-14 lg:gap-[72px]"> <div className="flex flex-wrap items-center justify-between gap-8 sm:gap-14 lg:gap-[72px]">
<div className="relative min-w-0 max-w-[560px] flex-1 basis-[340px] text-ink"> <div className="min-w-0 max-w-[560px] flex-1 basis-[340px] text-white">
<DoodleSquiggle className="absolute -left-2 -top-9 h-9 w-14 text-ink/70 sm:-top-11" /> <span className="inline-flex items-center gap-2 rounded-full border border-white/20 bg-white/10 px-[13px] py-1.5 text-xs font-semibold tracking-wide text-white/85 backdrop-blur-md">
<span className="inline-flex items-center gap-2 rounded-full border border-ink/16 bg-panel/70 px-[13px] py-1.5 text-xs font-semibold tracking-wide text-ink-soft backdrop-blur-md">
<span className="h-1.5 w-1.5 animate-tc-pulse rounded-full bg-success" /> <span className="h-1.5 w-1.5 animate-tc-pulse rounded-full bg-success" />
Coffre-fort en ligne Coffre-fort en ligne
</span> </span>
<h1 className="m-0 my-4 font-display text-[38px] font-semibold leading-[1.05] tracking-tight text-balance text-ink sm:text-[52px] lg:text-[66px]"> <h1 className="m-0 my-4 font-display text-[38px] font-semibold leading-[1.05] tracking-tight text-balance text-white sm:text-[52px] lg:text-[66px]">
Vos documents, Vos documents,
<br /> <br />
<span className="italic"> <span className="italic">sous clé.</span>
sous <span className="tc-highlight">clé</span>.
</span>
</h1> </h1>
<p className="m-0 max-w-[44ch] text-[15px] leading-relaxed text-ink-soft sm:text-[17.5px]"> <p className="m-0 max-w-[44ch] text-[15px] leading-relaxed text-white/70 sm:text-[17.5px]">
Documents, photos et sauvegardes sur un serveur que vous contrôlez. Documents, photos et sauvegardes sur un serveur que vous contrôlez.
</p> </p>
<DoodleArrow className="mt-6 hidden h-11 w-14 text-ink/60 sm:block" />
</div> </div>
<AuthPanel /> <AuthPanel />
</div> </div>
<div className="flex flex-wrap gap-5 text-xs text-ink-soft"> <div className="flex flex-wrap gap-5 text-xs text-white/60">
<span>Tripoli Cloud — 2026</span> <span>Tripoli Cloud — 2026</span>
<span>Hébergé sur votre propre serveur</span> <span>Hébergé sur votre propre serveur</span>
<span>Sans publicité, sans traçage</span> <span>Sans publicité, sans traçage</span>

View file

@ -1,35 +0,0 @@
/** Petits traits dessinés à la main, en accent décoratif - inspirés du
* ton "studiocart.co" : jamais informatifs, purement une touche humaine
* dans une interface par ailleurs très structurée. */
export function DoodleSquiggle({ className }: { className?: string }) {
return (
<svg viewBox="0 0 60 40" fill="none" className={className} aria-hidden="true">
<path
d="M4 30C10 6 22 4 26 16C29 25 40 27 44 14C46 8 50 6 56 8"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
/>
</svg>
);
}
export function DoodleArrow({ className }: { className?: string }) {
return (
<svg viewBox="0 0 54 46" fill="none" className={className} aria-hidden="true">
<path
d="M6 6C22 10 40 18 46 36"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
/>
<path
d="M32 34C38 36 43 37 47 37C46 33 45 28 45 23"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

View file

@ -1,11 +1,9 @@
import { Menu, Moon, Search, Sun } from 'lucide-react'; import { Menu, Search } from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
import { useNavigation } from '../../contexts/NavigationContext'; import { useNavigation } from '../../contexts/NavigationContext';
import { useIsMobile } from '../../hooks/useIsMobile'; import { useIsMobile } from '../../hooks/useIsMobile';
import { NotificationCenter } from '../notifications/NotificationCenter'; import { NotificationCenter } from '../notifications/NotificationCenter';
export function TopBar() { export function TopBar() {
const { theme, toggleTheme } = useTheme();
const { openDrawer } = useNavigation(); const { openDrawer } = useNavigation();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@ -32,14 +30,6 @@ export function TopBar() {
</div> </div>
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
<button
type="button"
onClick={toggleTheme}
aria-label="Basculer clair / sombre"
className="flex h-[38px] w-[38px] items-center justify-center rounded-[11px] border border-line bg-field text-ink"
>
{theme === 'dark' ? <Moon size={17} strokeWidth={1.8} /> : <Sun size={17} strokeWidth={1.8} />}
</button>
<NotificationCenter /> <NotificationCenter />
</div> </div>
</header> </header>

View file

@ -5,8 +5,8 @@ interface TripoliLogoProps {
/** Minimalist inline SVG mark — no external asset, easy to swap later. */ /** Minimalist inline SVG mark — no external asset, easy to swap later. */
export function TripoliLogo({ size = 28, variant = 'sidebar' }: TripoliLogoProps) { export function TripoliLogo({ size = 28, variant = 'sidebar' }: TripoliLogoProps) {
const badgeFill = variant === 'hero' ? 'rgba(23,20,14,0.05)' : 'rgba(91,140,255,0.14)'; const badgeFill = variant === 'hero' ? 'rgba(255,255,255,0.12)' : 'rgba(91,140,255,0.14)';
const badgeStroke = variant === 'hero' ? 'rgba(23,20,14,0.16)' : 'rgba(91,140,255,0.36)'; const badgeStroke = variant === 'hero' ? 'rgba(255,255,255,0.28)' : 'rgba(91,140,255,0.36)';
return ( return (
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" aria-hidden="true"> <svg width={size} height={size} viewBox="0 0 32 32" fill="none" aria-hidden="true">

View file

@ -1,32 +0,0 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
import type { Theme } from '../types';
interface ThemeContextValue {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
/**
* Dark/light is kept in React state only — intentionally not persisted to
* localStorage. It's applied as Tailwind's `dark` class on <html>, so
* `dark:` utilities and the CSS-variable tokens in index.css move together.
*/
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>('light');
useEffect(() => {
document.documentElement.classList.toggle('dark', theme === 'dark');
}, [theme]);
const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
return <ThemeContext.Provider value={{ theme, toggleTheme }}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within a ThemeProvider');
return ctx;
}

View file

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { BACKGROUND_SCENES } from '../lib/backgrounds'; import { BACKGROUND_PHOTOS } from '../lib/backgrounds';
const INTERVAL_MS = 8000; const INTERVAL_MS = 9000;
export function useBackgroundCrossfade(): number { export function useBackgroundCrossfade(): number {
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
@ -9,7 +9,7 @@ export function useBackgroundCrossfade(): number {
useEffect(() => { useEffect(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const id = window.setInterval(() => { const id = window.setInterval(() => {
setIndex((i) => (i + 1) % BACKGROUND_SCENES.length); setIndex((i) => (i + 1) % BACKGROUND_PHOTOS.length);
}, INTERVAL_MS); }, INTERVAL_MS);
return () => window.clearInterval(id); return () => window.clearInterval(id);
}, []); }, []);

View file

@ -5,14 +5,10 @@
@tailwind utilities; @tailwind utilities;
/* /*
* Theme tokens consumed by tailwind.config.js (colors.bg, colors.panel, ...). * Jetons de thème consommés par tailwind.config.js (colors.bg, colors.panel, ...).
* `.dark` is toggled on <html> by ThemeContext — this is what makes * Un seul thème, volontairement : pas de bascule clair/sombre - une
* Tailwind's `dark:` strategy and these variables move together. * direction visuelle assumée plutôt que deux expériences à maintenir.
* * Papier + encre + citron vert en accent unique, sourd et rare.
* Thème clair "éditorial" par défaut (papier + encre + citron vert en
* accent) plutôt que le coffre-fort sombre d'origine - inspiré du ton
* studiocart.co : gros titres serif, cartes blanches nettes, un seul
* accent vif utilisé avec parcimonie plutôt que des néons partout.
*/ */
:root { :root {
--tc-bg: #faf7f1; --tc-bg: #faf7f1;
@ -26,18 +22,6 @@
--tc-shadow: 0 20px 48px rgba(23, 20, 14, 0.09); --tc-shadow: 0 20px 48px rgba(23, 20, 14, 0.09);
} }
:root.dark {
--tc-bg: #0a0c11;
--tc-panel: rgba(255, 255, 255, 0.045);
--tc-panel-2: rgba(255, 255, 255, 0.075);
--tc-line: rgba(255, 255, 255, 0.09);
--tc-field: rgba(255, 255, 255, 0.05);
--tc-selected: rgba(91, 140, 255, 0.16);
--tc-ink: #eef1f7;
--tc-ink-2: rgba(238, 241, 247, 0.56);
--tc-shadow: 0 28px 60px rgba(0, 0, 0, 0.5);
}
html, html,
body, body,
#root { #root {

View file

@ -1,49 +1,43 @@
/** /**
* Scènes du diaporama de fond de l'écran de connexion : art génératif * Photos du diaporama de fond de l'écran de connexion — hotlinkées
* (pas de photo tierce, pour rester libre de droits) - de larges taches de * directement depuis Unsplash (jamais téléchargées/réhébergées), comme
* couleur pastel, floutées, qui dérivent lentement (voir `.tc-drift` dans * l'exige leur charte développeur. Chaque photo garde l'attribution du
* index.css) et s'enchaînent en fondu. Palette "papier + pastel", assortie * photographe (nom + lien), affichée en bas de l'écran de connexion.
* au thème éditorial clair du reste de l'interface. * Sélection à la main via l'API Unsplash (recherche "architecture
* minimaliste", "zen", "brume montagne") - le déclenchement de
* l'endpoint /download a été fait une fois au moment du choix, comme
* demandé par les règles d'usage de l'API.
*/ */
export interface BackgroundBlob { export interface BackgroundPhoto {
color: string;
top: string;
left: string;
size: string;
}
export interface BackgroundScene {
id: string; id: string;
base: string; url: string;
blobs: BackgroundBlob[]; photographerName: string;
photographerUrl: string;
} }
export const BACKGROUND_SCENES: BackgroundScene[] = [ export const BACKGROUND_PHOTOS: BackgroundPhoto[] = [
{ {
id: 'lavande', id: 'ak5R4Y7MkW8',
base: 'linear-gradient(160deg, #f4efe6 0%, #efe9de 55%, #eae3d6 100%)', url: 'https://images.unsplash.com/photo-1619857121838-997e82345250?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&q=80&w=1920',
blobs: [ photographerName: 'Rufat Mammadov',
{ color: 'rgba(196,181,253,0.55)', top: '-16%', left: '55%', size: '58vw' }, photographerUrl: 'https://unsplash.com/@rufatmammadov?utm_source=tripoli_cloud&utm_medium=referral',
{ color: 'rgba(253,186,203,0.42)', top: '48%', left: '-8%', size: '50vw' },
{ color: 'rgba(200,255,82,0.28)', top: '64%', left: '62%', size: '36vw' },
],
}, },
{ {
id: 'peche', id: 'n-hTpYafJQA',
base: 'linear-gradient(160deg, #f6efe4 0%, #f1e9db 55%, #ece1d0 100%)', url: 'https://images.unsplash.com/photo-1503450843813-359b7b07bd36?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&q=80&w=1920',
blobs: [ photographerName: 'Raychan',
{ color: 'rgba(255,196,158,0.5)', top: '-12%', left: '64%', size: '56vw' }, photographerUrl: 'https://unsplash.com/@wx1993?utm_source=tripoli_cloud&utm_medium=referral',
{ color: 'rgba(255,150,164,0.34)', top: '52%', left: '6%', size: '48vw' },
{ color: 'rgba(196,181,253,0.26)', top: '6%', left: '4%', size: '34vw' },
],
}, },
{ {
id: 'menthe', id: '7AM9xVtFlZ0',
base: 'linear-gradient(160deg, #eef2e6 0%, #eaeee0 55%, #e4e8d8 100%)', url: 'https://images.unsplash.com/photo-1673295716958-b1dc96e5b24f?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&q=80&w=1920',
blobs: [ photographerName: 'Nicolas Solerieu',
{ color: 'rgba(167,229,180,0.5)', top: '4%', left: '68%', size: '52vw' }, photographerUrl: 'https://unsplash.com/@slrncl?utm_source=tripoli_cloud&utm_medium=referral',
{ color: 'rgba(140,214,214,0.4)', top: '58%', left: '-6%', size: '54vw' }, },
{ color: 'rgba(200,255,82,0.24)', top: '70%', left: '66%', size: '34vw' }, {
], id: 'SpzGTdBg738',
url: 'https://images.unsplash.com/photo-1529333110658-26b4cf759641?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&q=80&w=1920',
photographerName: 'Dave Hoefler',
photographerUrl: 'https://unsplash.com/@iamthedave?utm_source=tripoli_cloud&utm_medium=referral',
}, },
]; ];

View file

@ -99,4 +99,3 @@ export type AppView = 'auth' | 'app';
export type NavKey = 'dashboard' | 'photos' | 'backups' | 'settings'; export type NavKey = 'dashboard' | 'photos' | 'backups' | 'settings';
export type AuthMode = 'login' | 'signup'; export type AuthMode = 'login' | 'signup';
export type FileFilter = 'Tous' | 'Documents' | 'Médias'; export type FileFilter = 'Tous' | 'Documents' | 'Médias';
export type Theme = 'light' | 'dark';

View file

@ -1,16 +1,12 @@
/** /**
* Design tokens for Tripoli Cloud, centralized here so components never * Design tokens for Tripoli Cloud, centralized here so components never
* hardcode colors, radii or spacing inline. Values mirror the validated * hardcode colors, radii or spacing inline.
* Claude Design prototype (`project/Tripoli Cloud.dc.html`).
* *
* Surface/text colors resolve through CSS variables (see src/index.css) so * Surface/text colors resolve through CSS variables (see src/index.css) -
* the same class (e.g. `bg-panel`) renders correctly in both themes without * a single theme, deliberately: no dark/light toggle to maintain.
* a `dark:` variant on every element. Theme switching itself still goes
* through Tailwind's `dark` class strategy on <html>.
* @type {import('tailwindcss').Config} * @type {import('tailwindcss').Config}
*/ */
export default { export default {
darkMode: 'class',
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: { theme: {
extend: { extend: {