feat(frontend): scaffold Tailwind design system + routing + auth callback

- Tailwind v3 + PostCSS + autoprefixer
- BrowserRouter with Layout shell (Header, theme toggle dark/light)
- Pages: HomePage, CallbackPage (SuperOAuth callback handler)
- hooks/useAuth.ts + lib/api.ts (API client base)
- styles/index.css (Tailwind directives)
- Theme persisted in localStorage (od-theme)
This commit is contained in:
2026-03-14 07:15:19 +01:00
parent f3e392ff1b
commit 25733ee3db
15 changed files with 1304 additions and 4 deletions

3
frontend/.env.example Normal file
View File

@@ -0,0 +1,3 @@
# URL complète d'autorisation OAuth — SuperOAuth
# Format : https://superoauth.tetardtek.com/oauth/authorize?client_id=XXX&redirect_uri=http://localhost:5173/callback&response_type=token
VITE_SUPEROAUTH_AUTHORIZE_URL=

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,9 @@
"@types/react": "^18.3.1", "@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.27",
"postcss": "^8.5.8",
"tailwindcss": "^3.4.19",
"typescript": "^5.4.3", "typescript": "^5.4.3",
"vite": "^5.2.8" "vite": "^5.2.8"
} }

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -1,9 +1,32 @@
import { useState, useEffect } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Layout from './components/layout/Layout';
import HomePage from './pages/HomePage';
import CallbackPage from './pages/CallbackPage';
type Theme = 'dark' | 'light';
function App() { function App() {
const [theme, setTheme] = useState<Theme>(() => {
return (localStorage.getItem('od-theme') as Theme) ?? 'dark';
});
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('od-theme', theme);
}, [theme]);
const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
return ( return (
<main> <BrowserRouter>
<h1>OriginsDigital v2</h1> <Routes>
<p>Refonte en cours.</p> <Route element={<Layout theme={theme} onToggleTheme={toggleTheme} />}>
</main> <Route path="/" element={<HomePage />} />
<Route path="/callback" element={<CallbackPage />} />
</Route>
</Routes>
</BrowserRouter>
); );
} }

View File

@@ -0,0 +1,70 @@
import { Link } from 'react-router-dom';
import type { User } from '../../hooks/useAuth';
interface HeaderProps {
theme: 'dark' | 'light';
onToggleTheme: () => void;
user: User | null;
}
export default function Header({ theme, onToggleTheme, user }: HeaderProps) {
const loginUrl = import.meta.env.VITE_SUPEROAUTH_AUTHORIZE_URL;
return (
<header className="border-b border-od-border bg-od-surface">
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between px-4">
{/* Logo */}
<Link to="/" className="flex items-center gap-2 group">
<span className="font-mono text-xs font-bold text-od-accent tracking-widest group-hover:text-od-accent-dim transition-colors">
OD
</span>
<span className="text-sm font-semibold text-od-text">
OriginsDigital
</span>
</Link>
{/* Navigation */}
<nav className="flex gap-6">
<Link
to="/"
className="text-sm text-od-muted hover:text-od-text transition-colors"
>
Accueil
</Link>
<Link
to="/videos"
className="text-sm text-od-muted hover:text-od-text transition-colors"
>
Vidéos
</Link>
</nav>
{/* Right — thème + auth */}
<div className="flex items-center gap-4">
<button
onClick={onToggleTheme}
aria-label="Changer le thème"
className="font-mono text-xs text-od-muted hover:text-od-text transition-colors"
>
{theme === 'dark' ? '◑' : '◐'}
</button>
{user ? (
<span className="font-mono text-xs text-od-accent">
{user.nickname}
</span>
) : (
<a
href={loginUrl}
className="rounded border border-od-border px-3 py-1 font-mono text-xs text-od-muted hover:border-od-accent hover:text-od-accent transition-colors"
>
Connexion
</a>
)}
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,25 @@
import { Outlet } from 'react-router-dom';
import Header from './Header';
import { useAuth } from '../../hooks/useAuth';
interface LayoutProps {
theme: 'dark' | 'light';
onToggleTheme: () => void;
}
export default function Layout({ theme, onToggleTheme }: LayoutProps) {
const { user, loading } = useAuth();
return (
<div className="min-h-screen bg-od-bg text-od-text">
<Header
theme={theme}
onToggleTheme={onToggleTheme}
user={loading ? null : user}
/>
<main className="mx-auto max-w-5xl px-4 py-8">
<Outlet />
</main>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { useState, useEffect } from 'react';
import { apiFetch } from '../lib/api';
export interface User {
id: number;
email: string;
nickname: string;
}
interface AuthState {
user: User | null;
loading: boolean;
}
export function useAuth(): AuthState {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
apiFetch<User>('/profile')
.then((u) => { if (!cancelled) setUser(u); })
.catch(() => { if (!cancelled) setUser(null); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, []);
return { user, loading };
}

18
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,18 @@
const BASE = '/api';
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
credentials: 'include', // transmet le cookie httpOnly automatiquement
...init,
headers: {
'Content-Type': 'application/json',
...init?.headers,
},
});
if (!res.ok) {
throw new Error(`API ${res.status}: ${path}`);
}
return res.json() as Promise<T>;
}

View File

@@ -1,5 +1,6 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import "./styles/index.css";
import App from "./App"; import App from "./App";
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(

View File

@@ -0,0 +1,47 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { apiFetch } from '../lib/api';
export default function CallbackPage() {
const navigate = useNavigate();
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const token = params.get('token');
// Pas de token dans l'URL → retour silencieux
if (!token) {
navigate('/', { replace: true });
return;
}
// Envoie le token au backend → backend valide + pose le cookie httpOnly
apiFetch<void>('/auth/session', {
method: 'POST',
body: JSON.stringify({ token }),
})
.then(() => navigate('/', { replace: true }))
.catch(() => setError("Échec de l'authentification. Réessaie."));
}, [navigate]);
if (error) {
return (
<div className="flex flex-col items-center gap-4 pt-20">
<p className="text-od-crit">{error}</p>
<a
href="/"
className="font-mono text-xs text-od-muted hover:text-od-text transition-colors"
>
Retour à l'accueil
</a>
</div>
);
}
return (
<div className="flex items-center justify-center pt-20">
<p className="font-mono text-sm text-od-muted">Connexion en cours</p>
</div>
);
}

View File

@@ -0,0 +1,56 @@
export default function HomePage() {
return (
<div className="flex flex-col gap-10">
{/* Hero */}
<section className="border-b border-od-border pb-8">
<h1 className="text-2xl font-semibold text-od-text">
Vidéos & formations
</h1>
<p className="mt-2 text-sm text-od-muted">
Contenu libre et premium connecte-toi pour accéder aux formations complètes.
</p>
</section>
{/* Accès libre */}
<section>
<h2 className="mb-4 font-mono text-xs uppercase tracking-widest text-od-muted">
Accès libre
</h2>
<div className="grid gap-4 sm:grid-cols-2">
<VideoCardPlaceholder tier="free" />
<VideoCardPlaceholder tier="free" />
</div>
</section>
{/* Premium */}
<section>
<h2 className="mb-4 font-mono text-xs uppercase tracking-widest text-od-accent">
Premium
</h2>
<div className="grid gap-4 sm:grid-cols-2">
<VideoCardPlaceholder tier="premium" />
<VideoCardPlaceholder tier="premium" />
</div>
</section>
</div>
);
}
function VideoCardPlaceholder({ tier }: { tier: 'free' | 'premium' }) {
return (
<div className="flex flex-col gap-3 rounded border border-od-border bg-od-surface p-4">
{/* Thumbnail */}
<div className="h-28 rounded bg-od-surface-hi" />
{/* Title skeleton */}
<div className="h-3 w-3/4 rounded bg-od-surface-hi" />
<div className="h-2 w-1/2 rounded bg-od-surface-hi" />
{tier === 'premium' && (
<span className="self-start rounded border border-od-accent px-2 py-0.5 font-mono text-xs text-od-accent">
Premium
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,40 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ─── Void Dark (défaut) ─────────────────────────────────────────────────── */
:root,
[data-theme="dark"] {
--od-bg: #0a0a0d; /* fond principal — quasi-noir cool */
--od-surface: #111115; /* panneaux, cartes */
--od-surface-hi: #191920; /* survol, éléments élevés */
--od-border: #222228; /* séparateurs subtils */
--od-text: #dddde8; /* texte principal */
--od-muted: #62626e; /* texte secondaire, labels */
--od-accent: #d4a853; /* or chaud — premium */
--od-accent-dim: #a07830; /* survol accent */
--od-crit: #d95f5f; /* erreurs */
--od-ok: #5fc875; /* succès */
}
/* ─── Void Light ─────────────────────────────────────────────────────────── */
[data-theme="light"] {
--od-bg: #f2f2f5;
--od-surface: #ffffff;
--od-surface-hi: #e8e8ee;
--od-border: #d0d0da;
--od-text: #14141a;
--od-muted: #6a6a78;
--od-accent: #a07830;
--od-accent-dim: #7a5c20;
--od-crit: #c04040;
--od-ok: #3aa855;
}
/* ─── Base ───────────────────────────────────────────────────────────────── */
body {
background-color: var(--od-bg);
color: var(--od-text);
font-family: 'Inter', ui-sans-serif, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}

9
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_SUPEROAUTH_AUTHORIZE_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -0,0 +1,31 @@
import type { Config } from 'tailwindcss';
// Design system "Void" — palette custom OriginsDigital
// Les couleurs sont définies comme variables CSS dans src/styles/index.css
// → thème sombre/clair géré via data-theme="dark|light" sur <html>
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
od: {
bg: 'var(--od-bg)',
surface: 'var(--od-surface)',
'surface-hi': 'var(--od-surface-hi)',
border: 'var(--od-border)',
text: 'var(--od-text)',
muted: 'var(--od-muted)',
accent: 'var(--od-accent)',
'accent-dim': 'var(--od-accent-dim)',
crit: 'var(--od-crit)',
ok: 'var(--od-ok)',
},
},
fontFamily: {
sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
mono: ['"JetBrains Mono"', '"Fira Code"', 'ui-monospace', 'monospace'],
},
},
},
plugins: [],
} satisfies Config;