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

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>