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:
3
frontend/.env.example
Normal file
3
frontend/.env.example
Normal 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=
|
||||||
937
frontend/package-lock.json
generated
937
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
70
frontend/src/components/layout/Header.tsx
Normal file
70
frontend/src/components/layout/Header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
frontend/src/components/layout/Layout.tsx
Normal file
25
frontend/src/components/layout/Layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
frontend/src/hooks/useAuth.ts
Normal file
31
frontend/src/hooks/useAuth.ts
Normal 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
18
frontend/src/lib/api.ts
Normal 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>;
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
47
frontend/src/pages/CallbackPage.tsx
Normal file
47
frontend/src/pages/CallbackPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
frontend/src/pages/HomePage.tsx
Normal file
56
frontend/src/pages/HomePage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
frontend/src/styles/index.css
Normal file
40
frontend/src/styles/index.css
Normal 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
9
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_SUPEROAUTH_AUTHORIZE_URL: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
31
frontend/tailwind.config.ts
Normal file
31
frontend/tailwind.config.ts
Normal 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;
|
||||||
Reference in New Issue
Block a user