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-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.4.3",
|
||||
"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() {
|
||||
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 (
|
||||
<main>
|
||||
<h1>OriginsDigital — v2</h1>
|
||||
<p>Refonte en cours.</p>
|
||||
</main>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<Layout theme={theme} onToggleTheme={toggleTheme} />}>
|
||||
<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 { createRoot } from "react-dom/client";
|
||||
import "./styles/index.css";
|
||||
import App from "./App";
|
||||
|
||||
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