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
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:
113
frontend/src/routes/+layout.svelte
Normal file
113
frontend/src/routes/+layout.svelte
Normal 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>
|
||||
90
frontend/src/routes/+page.svelte
Normal file
90
frontend/src/routes/+page.svelte
Normal 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>
|
||||
44
frontend/src/routes/callback/+page.svelte
Normal file
44
frontend/src/routes/callback/+page.svelte
Normal 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>
|
||||
192
frontend/src/routes/list/+page.svelte
Normal file
192
frontend/src/routes/list/+page.svelte
Normal 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>
|
||||
151
frontend/src/routes/profile/+page.svelte
Normal file
151
frontend/src/routes/profile/+page.svelte
Normal 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>
|
||||
207
frontend/src/routes/search/+page.svelte
Normal file
207
frontend/src/routes/search/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user