init: scaffold complet Sakuin — backend NestJS + frontend SvelteKit + CI/CD + deploy VPS
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 29s

Backend: 5 modules (auth, user, work, list, health), AniList GraphQL proxy,
SuperOAuth PKCE introspection, XP system, migrations TypeORM.
Frontend: SvelteKit adapter-node, PWA manifest, dark theme, pages home/search/list/profile/callback.
Infra: CI/CD Gitea vps-runner, Apache vhost SSL, pm2 sakuin-backend + sakuin-frontend, port 4002.
License: BSL 1.1 (Apache 2.0 en 2028).
This commit is contained in:
2026-03-25 01:43:32 +01:00
commit f1cff74d83
56 changed files with 9891 additions and 0 deletions

23
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
frontend/.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

3
frontend/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

42
frontend/README.md Normal file
View File

@@ -0,0 +1,42 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# recreate this project
npx sv@0.13.0 create --template minimal --types ts --install npm .
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

1746
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
frontend/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "frontend",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"svelte": "^5.54.0",
"svelte-check": "^4.4.2",
"typescript": "^5.9.3",
"vite": "^7.3.1"
},
"dependencies": {
"@sveltejs/adapter-node": "^5.5.4"
}
}

114
frontend/src/app.css Normal file
View File

@@ -0,0 +1,114 @@
:root {
--bg-primary: #0f0f1a;
--bg-secondary: #1a1a2e;
--bg-card: #16213e;
--bg-hover: #1e2a4a;
--text-primary: #e4e4f0;
--text-secondary: #a0a0b8;
--text-muted: #6b6b80;
--accent: #c084fc;
--accent-hover: #a855f7;
--gold: #fbbf24;
--success: #34d399;
--danger: #f87171;
--border: #2a2a40;
--radius: 8px;
--radius-lg: 12px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
color: var(--accent-hover);
}
button {
cursor: pointer;
border: none;
border-radius: var(--radius);
font-size: 0.875rem;
font-weight: 500;
padding: 0.5rem 1rem;
transition: all 0.15s ease;
}
.btn-primary {
background: var(--accent);
color: #fff;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-ghost {
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
}
.btn-ghost:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
input, select {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-primary);
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
outline: none;
transition: border-color 0.15s;
}
input:focus, select:focus {
border-color: var(--accent);
}
.card {
background: var(--bg-card);
border-radius: var(--radius-lg);
border: 1px solid var(--border);
overflow: hidden;
transition: transform 0.15s, border-color 0.15s;
}
.card:hover {
transform: translateY(-2px);
border-color: var(--accent);
}
.badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
}
.badge-gold {
background: var(--gold);
color: #000;
}
.badge-accent {
background: var(--accent);
color: #fff;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}

13
frontend/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

14
frontend/src/app.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#0f0f1a" />
<link rel="manifest" href="/manifest.json" />
<link rel="icon" href="/favicon.png" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

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

@@ -0,0 +1,69 @@
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:4002/api';
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(`${API_URL}${path}`, {
...options,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
if (!res.ok) {
const error = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(error.message || `API error: ${res.status}`);
}
return res.json();
}
export const api = {
// Health
health: () => request<{ status: string }>('/health'),
// Search (public)
search: (q: string, type?: string, page = 1) => {
const params = new URLSearchParams({ q, page: String(page) });
if (type) params.set('type', type);
return request<{ media: any[]; total: number; hasNextPage: boolean }>(
`/works/search?${params}`,
);
},
// User
me: () => request<any>('/user/me'),
// List
getList: (status?: string) => {
const params = status ? `?status=${status}` : '';
return request<any[]>(`/list${params}`);
},
addToList: (anilistId: number, status: string) =>
request<any>('/list', {
method: 'POST',
body: JSON.stringify({ anilistId, status }),
}),
updateProgress: (id: number, progress: number) =>
request<any>(`/list/${id}/progress`, {
method: 'PUT',
body: JSON.stringify({ progress }),
}),
updateStatus: (id: number, status: string) =>
request<any>(`/list/${id}/status`, {
method: 'PUT',
body: JSON.stringify({ status }),
}),
setScore: (id: number, score: number) =>
request<any>(`/list/${id}/score`, {
method: 'PUT',
body: JSON.stringify({ score }),
}),
removeFromList: (id: number) =>
request<void>(`/list/${id}`, { method: 'DELETE' }),
};

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

67
frontend/src/lib/auth.ts Normal file
View File

@@ -0,0 +1,67 @@
const OAUTH_URL = import.meta.env.VITE_OAUTH_URL || 'https://superoauth.tetardtek.com';
const CLIENT_ID = import.meta.env.VITE_OAUTH_CLIENT_ID || 'sakuin';
function generateCodeVerifier(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
async function generateCodeChallenge(verifier: string): Promise<string> {
const data = new TextEncoder().encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
export async function login() {
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
sessionStorage.setItem('pkce_verifier', verifier);
const params = new URLSearchParams({
client_id: CLIENT_ID,
response_type: 'code',
redirect_uri: `${window.location.origin}/callback`,
code_challenge: challenge,
code_challenge_method: 'S256',
scope: 'openid profile',
});
window.location.href = `${OAUTH_URL}/authorize?${params}`;
}
export async function handleCallback(code: string): Promise<any> {
const verifier = sessionStorage.getItem('pkce_verifier');
if (!verifier) throw new Error('No PKCE verifier found');
const res = await fetch(`${OAUTH_URL}/api/v1/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
client_id: CLIENT_ID,
code,
redirect_uri: `${window.location.origin}/callback`,
code_verifier: verifier,
}),
});
if (!res.ok) throw new Error('Token exchange failed');
const tokens = await res.json();
sessionStorage.removeItem('pkce_verifier');
return tokens;
}
export function logout() {
document.cookie = 'access_token=; Max-Age=0; path=/';
window.location.href = '/';
}

View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@@ -0,0 +1,113 @@
<script lang="ts">
import '../app.css';
import { login, logout } from '$lib/auth';
let { children } = $props();
let user = $state<any>(null);
let loading = $state(true);
async function loadUser() {
try {
const { api } = await import('$lib/api');
user = await api.me();
} catch {
user = null;
} finally {
loading = false;
}
}
$effect(() => {
loadUser();
});
</script>
<nav class="navbar">
<div class="container nav-inner">
<a href="/" class="logo">索引 <span>Sakuin</span></a>
<div class="nav-links">
<a href="/search">Rechercher</a>
{#if user}
<a href="/list">Ma Liste</a>
<a href="/profile">Profil</a>
<div class="user-badge">
{#if user.avatarUrl}
<img src={user.avatarUrl} alt="" class="avatar" />
{/if}
<span class="username">{user.username}</span>
<span class="level badge badge-accent">Lv.{user.level}</span>
<button class="btn-ghost btn-sm" onclick={logout}>Déconnexion</button>
</div>
{:else if !loading}
<button class="btn-primary" onclick={login}>Connexion</button>
{/if}
</div>
</div>
</nav>
<main class="container main-content">
{@render children()}
</main>
<style>
.navbar {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
padding: 0.75rem 0;
position: sticky;
top: 0;
z-index: 100;
}
.nav-inner {
display: flex;
align-items: center;
justify-content: space-between;
}
.logo {
font-size: 1.25rem;
font-weight: 700;
color: var(--text-primary);
}
.logo span {
color: var(--accent);
}
.nav-links {
display: flex;
align-items: center;
gap: 1rem;
}
.nav-links a {
color: var(--text-secondary);
font-size: 0.875rem;
font-weight: 500;
}
.nav-links a:hover {
color: var(--text-primary);
}
.user-badge {
display: flex;
align-items: center;
gap: 0.5rem;
}
.avatar {
width: 28px;
height: 28px;
border-radius: 50%;
}
.username {
font-size: 0.875rem;
font-weight: 500;
}
.level {
font-size: 0.7rem;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
.main-content {
padding-top: 2rem;
padding-bottom: 4rem;
}
</style>

View File

@@ -0,0 +1,90 @@
<script lang="ts">
import { login } from '$lib/auth';
</script>
<svelte:head>
<title>Sakuin — Ton index manga & anime</title>
</svelte:head>
<div class="hero">
<h1>索引 <span class="accent">Sakuin</span></h1>
<p class="tagline">Ton catalogue manga & anime — track, collectionne, compare.</p>
<div class="features">
<div class="feature-card">
<span class="icon">📚</span>
<h3>Track</h3>
<p>Suis ta progression épisode par épisode, chapitre par chapitre.</p>
</div>
<div class="feature-card">
<span class="icon">🏆</span>
<h3>Gamifié</h3>
<p>Gagne de l'XP, débloque des grades et des titres uniques.</p>
</div>
<div class="feature-card">
<span class="icon">👥</span>
<h3>Compare</h3>
<p>Partage ta collection et compare avec tes potes.</p>
</div>
</div>
<div class="cta">
<button class="btn-primary btn-lg" onclick={login}>Commencer</button>
<a href="/search" class="btn-ghost btn-lg">Explorer</a>
</div>
</div>
<style>
.hero {
text-align: center;
padding: 4rem 0;
}
h1 {
font-size: 3rem;
font-weight: 800;
margin-bottom: 0.5rem;
}
.accent {
color: var(--accent);
}
.tagline {
color: var(--text-secondary);
font-size: 1.125rem;
margin-bottom: 3rem;
}
.features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.5rem;
margin-bottom: 3rem;
}
.feature-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.5rem;
text-align: center;
}
.feature-card .icon {
font-size: 2rem;
display: block;
margin-bottom: 0.75rem;
}
.feature-card h3 {
margin-bottom: 0.5rem;
font-size: 1rem;
}
.feature-card p {
color: var(--text-secondary);
font-size: 0.85rem;
}
.cta {
display: flex;
gap: 1rem;
justify-content: center;
}
.btn-lg {
padding: 0.75rem 2rem;
font-size: 1rem;
}
</style>

View File

@@ -0,0 +1,44 @@
<script lang="ts">
import { handleCallback } from '$lib/auth';
import { goto } from '$app/navigation';
import { page } from '$app/state';
let error = $state('');
$effect(() => {
const code = page.url.searchParams.get('code');
if (!code) {
error = 'Code manquant dans la callback.';
return;
}
handleCallback(code)
.then((tokens) => {
document.cookie = `access_token=${tokens.access_token}; path=/; max-age=${tokens.expires_in || 3600}; SameSite=Lax`;
goto('/list');
})
.catch((e) => {
error = e.message || 'Erreur de connexion.';
});
});
</script>
<div class="callback">
{#if error}
<p class="error">{error}</p>
<a href="/">Retour</a>
{:else}
<p>Connexion en cours...</p>
{/if}
</div>
<style>
.callback {
text-align: center;
padding: 4rem;
}
.error {
color: var(--danger);
margin-bottom: 1rem;
}
</style>

View File

@@ -0,0 +1,192 @@
<script lang="ts">
import { api } from '$lib/api';
let items = $state<any[]>([]);
let loading = $state(true);
let activeTab = $state('all');
const tabs = [
{ key: 'all', label: 'Tout' },
{ key: 'watching', label: 'En cours (anime)' },
{ key: 'reading', label: 'En cours (manga)' },
{ key: 'completed', label: 'Complétés' },
{ key: 'plan_to', label: 'À voir/lire' },
{ key: 'dropped', label: 'Abandonnés' },
];
async function loadList() {
loading = true;
try {
const status = activeTab === 'all' ? undefined : activeTab;
items = await api.getList(status);
} catch {
items = [];
} finally {
loading = false;
}
}
function switchTab(key: string) {
activeTab = key;
loadList();
}
async function incrementProgress(item: any) {
const newProgress = item.progress + 1;
await api.updateProgress(item.id, newProgress);
item.progress = newProgress;
const total = item.work?.totalEpisodes || item.work?.totalChapters;
if (total && newProgress >= total) {
item.status = 'completed';
}
}
async function remove(item: any) {
if (!confirm(`Retirer ${item.work?.titleRomaji} ?`)) return;
await api.removeFromList(item.id);
items = items.filter((i) => i.id !== item.id);
}
$effect(() => {
loadList();
});
</script>
<svelte:head>
<title>Ma Liste — Sakuin</title>
</svelte:head>
<div class="list-page">
<h2>Ma Liste</h2>
<div class="tabs">
{#each tabs as tab}
<button
class="tab"
class:active={activeTab === tab.key}
onclick={() => switchTab(tab.key)}
>
{tab.label}
</button>
{/each}
</div>
{#if loading}
<p class="status">Chargement...</p>
{:else if items.length === 0}
<p class="status">Liste vide. <a href="/search">Chercher des oeuvres</a></p>
{:else}
<div class="list-grid">
{#each items as item}
<div class="card list-card" class:gold={item.status === 'completed'}>
{#if item.work?.posterUrl}
<img src={item.work.posterUrl} alt="" class="poster" />
{/if}
<div class="card-body">
<h3>{item.work?.titleRomaji}</h3>
<div class="meta">
<span class="badge" class:badge-gold={item.status === 'completed'} class:badge-accent={item.status !== 'completed'}>
{item.status}
</span>
{#if item.score}
<span class="score">{item.score}/10</span>
{/if}
</div>
<div class="progress-bar">
<span>
{item.progress} / {item.work?.totalEpisodes || item.work?.totalChapters || '?'}
</span>
<button class="btn-ghost btn-xs" onclick={() => incrementProgress(item)}>+1</button>
</div>
<div class="actions">
<button class="btn-ghost btn-xs" onclick={() => remove(item)}>Retirer</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.list-page {
max-width: 900px;
margin: 0 auto;
}
h2 {
margin-bottom: 1rem;
}
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.tab {
background: var(--bg-secondary);
color: var(--text-secondary);
border: 1px solid var(--border);
padding: 0.4rem 0.8rem;
font-size: 0.8rem;
border-radius: var(--radius);
}
.tab.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.list-grid {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.list-card {
display: flex;
}
.list-card.gold {
border-color: var(--gold);
box-shadow: 0 0 12px rgba(251, 191, 36, 0.15);
}
.poster {
width: 80px;
min-height: 110px;
object-fit: cover;
flex-shrink: 0;
}
.card-body {
padding: 0.75rem;
flex: 1;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.card-body h3 {
font-size: 0.9rem;
}
.meta {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
}
.score {
color: var(--gold);
font-weight: 600;
}
.progress-bar {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
color: var(--text-secondary);
}
.actions {
display: flex;
gap: 0.5rem;
}
.btn-xs {
padding: 0.15rem 0.4rem;
font-size: 0.7rem;
}
</style>

View File

@@ -0,0 +1,151 @@
<script lang="ts">
import { api } from '$lib/api';
let user = $state<any>(null);
let loading = $state(true);
$effect(() => {
api.me()
.then((data) => { user = data; })
.catch(() => { user = null; })
.finally(() => { loading = false; });
});
function xpToNextLevel(xp: number): { current: number; needed: number; pct: number } {
const level = Math.floor(xp / 500) + 1;
const base = (level - 1) * 500;
const current = xp - base;
return { current, needed: 500, pct: Math.round((current / 500) * 100) };
}
</script>
<svelte:head>
<title>Profil — Sakuin</title>
</svelte:head>
{#if loading}
<p>Chargement...</p>
{:else if !user}
<p>Non connecté.</p>
{:else}
{@const xp = xpToNextLevel(user.xp)}
<div class="profile">
<div class="profile-header">
{#if user.avatarUrl}
<img src={user.avatarUrl} alt="" class="avatar-lg" />
{/if}
<div>
<h2>{user.username}</h2>
<div class="level-info">
<span class="badge badge-accent">Level {user.level}</span>
<span class="xp-text">{user.xp} XP</span>
</div>
<div class="xp-bar">
<div class="xp-fill" style="width: {xp.pct}%"></div>
</div>
<span class="xp-detail">{xp.current} / {xp.needed} XP</span>
</div>
</div>
{#if user.stats}
<div class="stats-grid">
<div class="stat-card">
<span class="stat-value">{user.stats.total}</span>
<span class="stat-label">Total</span>
</div>
<div class="stat-card">
<span class="stat-value">{user.stats.watching || 0}</span>
<span class="stat-label">En cours (anime)</span>
</div>
<div class="stat-card">
<span class="stat-value">{user.stats.reading || 0}</span>
<span class="stat-label">En cours (manga)</span>
</div>
<div class="stat-card">
<span class="stat-value">{user.stats.completed || 0}</span>
<span class="stat-label">Complétés</span>
</div>
<div class="stat-card">
<span class="stat-value">{user.stats.episodesWatched || 0}</span>
<span class="stat-label">Épisodes vus</span>
</div>
<div class="stat-card">
<span class="stat-value">{user.stats.chaptersRead || 0}</span>
<span class="stat-label">Chapitres lus</span>
</div>
</div>
{/if}
</div>
{/if}
<style>
.profile {
max-width: 700px;
margin: 0 auto;
}
.profile-header {
display: flex;
gap: 1.5rem;
align-items: center;
margin-bottom: 2rem;
}
.avatar-lg {
width: 80px;
height: 80px;
border-radius: 50%;
border: 3px solid var(--accent);
}
h2 {
margin-bottom: 0.25rem;
}
.level-info {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.4rem;
}
.xp-text {
color: var(--text-muted);
font-size: 0.8rem;
}
.xp-bar {
width: 200px;
height: 6px;
background: var(--bg-secondary);
border-radius: 3px;
overflow: hidden;
margin-bottom: 0.2rem;
}
.xp-fill {
height: 100%;
background: var(--accent);
border-radius: 3px;
transition: width 0.3s;
}
.xp-detail {
font-size: 0.7rem;
color: var(--text-muted);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1rem;
}
.stat-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.25rem;
text-align: center;
}
.stat-value {
display: block;
font-size: 1.75rem;
font-weight: 700;
color: var(--accent);
}
.stat-label {
font-size: 0.8rem;
color: var(--text-secondary);
}
</style>

View File

@@ -0,0 +1,207 @@
<script lang="ts">
import { api } from '$lib/api';
let query = $state('');
let typeFilter = $state<string>('');
let results = $state<any[]>([]);
let loading = $state(false);
let total = $state(0);
let debounceTimer: ReturnType<typeof setTimeout>;
function onInput() {
clearTimeout(debounceTimer);
if (query.trim().length < 2) {
results = [];
return;
}
debounceTimer = setTimeout(() => doSearch(), 400);
}
async function doSearch() {
loading = true;
try {
const res = await api.search(query, typeFilter || undefined);
results = res.media;
total = res.total;
} catch (e) {
console.error('Search failed:', e);
} finally {
loading = false;
}
}
async function addToList(anilistId: number, status: string) {
try {
await api.addToList(anilistId, status);
alert('Ajouté !');
} catch (e: any) {
if (e.message?.includes('Unauthorized')) {
alert('Connecte-toi pour ajouter à ta liste.');
} else {
alert(e.message);
}
}
}
function stripHtml(html: string | null): string {
if (!html) return '';
return html.replace(/<[^>]*>/g, '').slice(0, 200);
}
</script>
<svelte:head>
<title>Rechercher — Sakuin</title>
</svelte:head>
<div class="search-page">
<h2>Rechercher</h2>
<div class="search-bar">
<input
type="text"
placeholder="One Piece, Frieren, Berserk..."
bind:value={query}
oninput={onInput}
class="search-input"
/>
<select bind:value={typeFilter} onchange={doSearch}>
<option value="">Tout</option>
<option value="ANIME">Anime</option>
<option value="MANGA">Manga</option>
</select>
</div>
{#if loading}
<p class="status">Recherche...</p>
{:else if results.length > 0}
<p class="status">{total} résultat{total > 1 ? 's' : ''}</p>
<div class="results-grid">
{#each results as media}
<div class="card result-card">
{#if media.coverImage?.large}
<img src={media.coverImage.large} alt={media.title.romaji} class="poster" />
{/if}
<div class="card-body">
<h3>{media.title.romaji}</h3>
{#if media.title.english && media.title.english !== media.title.romaji}
<p class="title-en">{media.title.english}</p>
{/if}
<div class="meta">
<span class="badge badge-accent">{media.type}</span>
{#if media.episodes}
<span>{media.episodes} ép.</span>
{/if}
{#if media.chapters}
<span>{media.chapters} ch.</span>
{/if}
</div>
<p class="synopsis">{stripHtml(media.description)}</p>
<div class="genres">
{#each (media.genres || []).slice(0, 3) as genre}
<span class="genre-tag">{genre}</span>
{/each}
</div>
<div class="actions">
<button class="btn-primary btn-sm" onclick={() => addToList(media.id, media.type === 'ANIME' ? 'watching' : 'reading')}>
+ Ma liste
</button>
<button class="btn-ghost btn-sm" onclick={() => addToList(media.id, 'plan_to')}>
À voir
</button>
</div>
</div>
</div>
{/each}
</div>
{:else if query.length >= 2}
<p class="status">Aucun résultat.</p>
{/if}
</div>
<style>
.search-page {
max-width: 900px;
margin: 0 auto;
}
h2 {
margin-bottom: 1.5rem;
}
.search-bar {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.search-input {
flex: 1;
font-size: 1rem;
padding: 0.75rem 1rem;
}
.status {
color: var(--text-muted);
margin-bottom: 1rem;
font-size: 0.85rem;
}
.results-grid {
display: flex;
flex-direction: column;
gap: 1rem;
}
.result-card {
display: flex;
overflow: hidden;
}
.poster {
width: 120px;
min-height: 170px;
object-fit: cover;
flex-shrink: 0;
}
.card-body {
padding: 1rem;
flex: 1;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.card-body h3 {
font-size: 1rem;
}
.title-en {
color: var(--text-muted);
font-size: 0.8rem;
}
.meta {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
color: var(--text-secondary);
}
.synopsis {
color: var(--text-secondary);
font-size: 0.8rem;
line-height: 1.4;
flex: 1;
}
.genres {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.genre-tag {
background: var(--bg-secondary);
color: var(--text-muted);
padding: 0.1rem 0.4rem;
border-radius: 4px;
font-size: 0.7rem;
}
.actions {
display: flex;
gap: 0.5rem;
margin-top: 0.25rem;
}
.btn-sm {
padding: 0.3rem 0.6rem;
font-size: 0.75rem;
}
</style>

View File

@@ -0,0 +1,13 @@
{
"name": "Sakuin",
"short_name": "Sakuin",
"description": "Ton index manga & anime — gamifié, partageable.",
"start_url": "/",
"display": "standalone",
"background_color": "#0f0f1a",
"theme_color": "#0f0f1a",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}

View File

@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

24
frontend/svelte.config.js Normal file
View File

@@ -0,0 +1,24 @@
import adapter from '@sveltejs/adapter-node';
import { relative, sep } from 'node:path';
/** @type {import('@sveltejs/kit').Config} */
const config = {
compilerOptions: {
// defaults to rune mode for the project, execept for `node_modules`. Can be removed in svelte 6.
runes: ({ filename }) => {
const relativePath = relative(import.meta.dirname, filename);
const pathSegments = relativePath.toLowerCase().split(sep);
const isExternalLibrary = pathSegments.includes('node_modules');
return isExternalLibrary ? undefined : true;
}
},
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter()
}
};
export default config;

20
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}

6
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});