Files
Sakuin/frontend/src/routes/list/+page.svelte
Tetardtek 108f021bd8
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 31s
feat: Sprint 1 core tracker + SuperOAuth PKCE E2E
Backend: cookie-parser, auth guard token introspection (SuperOAuth profile API),
fix réponse wrappée { success, data: { user, linkedProviders } }.
Frontend: page login 4 providers PKCE, callback SSR-safe (browser guard),
search améliorée (debounce, pagination, toast, feedback visuel ajout),
list complète (7 tabs, progression inline, score, vue grille/liste, cards goldées),
profil (XP bar, grades, 8 stats), user store partagé, toast system.
2026-03-25 02:43:36 +01:00

339 lines
14 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { api } from '$lib/api';
import { toast } from '$lib/stores/toast.svelte';
import { getUser, refreshUser } from '$lib/stores/user.svelte';
const userStore = getUser();
let items = $state<any[]>([]);
let loading = $state(true);
let activeTab = $state('all');
let viewMode = $state<'list' | 'grid'>('list');
let sortBy = $state<'updated' | 'title' | 'score'>('updated');
let editingScore = $state<number | null>(null);
let scoreInput = $state('');
const tabs = [
{ key: 'all', label: 'Tout', icon: '📋' },
{ key: 'watching', label: 'Anime en cours', icon: '▶️' },
{ key: 'reading', label: 'Manga en cours', icon: '📖' },
{ key: 'completed', label: 'Complétés', icon: '✅' },
{ key: 'plan_to', label: 'À voir/lire', icon: '⏳' },
{ key: 'paused', label: 'En pause', icon: '⏸️' },
{ key: 'dropped', label: 'Abandonnés', icon: '❌' },
];
const statusLabels: Record<string, string> = {
watching: 'En cours',
reading: 'En cours',
completed: 'Complété',
plan_to: 'À voir',
paused: 'En pause',
dropped: 'Abandonné',
};
let sortedItems = $derived(() => {
const sorted = [...items];
if (sortBy === 'title') {
sorted.sort((a, b) => (a.work?.titleRomaji || '').localeCompare(b.work?.titleRomaji || ''));
} else if (sortBy === 'score') {
sorted.sort((a, b) => (b.score || 0) - (a.score || 0));
}
return sorted;
});
async function loadList() {
if (!userStore.value) return;
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;
try {
const updated = await api.updateProgress(item.id, newProgress);
item.progress = updated.progress;
if (updated.status === 'completed') {
item.status = 'completed';
item.completedAt = updated.completedAt;
toast(`${item.work?.titleRomaji} complété ! 🎉`);
}
await refreshUser();
} catch (e: any) {
toast(e.message || 'Erreur', 'error');
}
}
async function changeStatus(item: any, status: string) {
try {
const updated = await api.updateStatus(item.id, status);
item.status = updated.status;
toast(`Statut → ${statusLabels[status]}`);
await refreshUser();
} catch (e: any) {
toast(e.message || 'Erreur', 'error');
}
}
function startEditScore(item: any) {
editingScore = item.id;
scoreInput = item.score?.toString() || '';
}
async function saveScore(item: any) {
const score = parseFloat(scoreInput);
if (isNaN(score) || score < 0 || score > 10) {
toast('Score entre 0 et 10', 'error');
return;
}
try {
await api.setScore(item.id, score);
item.score = score;
editingScore = null;
toast(`Note : ${score}/10 ★`);
await refreshUser();
} catch (e: any) {
toast(e.message || 'Erreur', 'error');
}
}
async function remove(item: any) {
if (!confirm(`Retirer ${item.work?.titleRomaji} de ta liste ?`)) return;
try {
await api.removeFromList(item.id);
items = items.filter((i) => i.id !== item.id);
toast('Retiré de la liste');
} catch (e: any) {
toast(e.message || 'Erreur', 'error');
}
}
function getProgressPercent(item: any): number {
const total = item.work?.totalEpisodes || item.work?.totalChapters;
if (!total) return 0;
return Math.min(100, Math.round((item.progress / total) * 100));
}
$effect(() => {
if (userStore.value) loadList();
});
</script>
<svelte:head>
<title>Ma Liste — Sakuin</title>
</svelte:head>
{#if !userStore.value && !userStore.loading}
<div class="login-prompt">
<h2>Ma Liste</h2>
<p>Connecte-toi pour accéder à ta liste.</p>
<a href="/login" class="btn-primary btn-lg" style="text-decoration:none">Connexion</a>
</div>
{:else}
<div class="list-page">
<div class="list-header">
<h2>Ma Liste</h2>
<div class="list-controls">
<select bind:value={sortBy}>
<option value="updated">Récent</option>
<option value="title">Titre</option>
<option value="score">Note</option>
</select>
<div class="view-toggle">
<button class:active={viewMode === 'list'} onclick={() => viewMode = 'list'} title="Liste"></button>
<button class:active={viewMode === 'grid'} onclick={() => viewMode = 'grid'} title="Grille"></button>
</div>
</div>
</div>
<div class="tabs">
{#each tabs as tab}
<button
class="tab"
class:active={activeTab === tab.key}
onclick={() => switchTab(tab.key)}
>
<span class="tab-icon">{tab.icon}</span>
<span class="tab-label">{tab.label}</span>
{#if activeTab === tab.key && items.length > 0}
<span class="tab-count">{items.length}</span>
{/if}
</button>
{/each}
</div>
{#if loading}
<div class="loading">
<div class="spinner"></div>
<span>Chargement...</span>
</div>
{:else if sortedItems().length === 0}
<div class="empty">
<p>Liste vide.</p>
<a href="/search" class="btn-primary">Rechercher des oeuvres</a>
</div>
{:else if viewMode === 'list'}
<div class="list-items">
{#each sortedItems() as item (item.id)}
{@const pct = getProgressPercent(item)}
<div class="card list-card" class:gold={item.status === 'completed'}>
{#if item.work?.posterUrl}
<img src={item.work.posterUrl} alt="" class="poster" loading="lazy" />
{/if}
<div class="card-body">
<div class="card-top">
<h3>{item.work?.titleRomaji}</h3>
<select
value={item.status}
onchange={(e) => changeStatus(item, (e.target as HTMLSelectElement).value)}
class="status-select"
>
<option value="watching">En cours (anime)</option>
<option value="reading">En cours (manga)</option>
<option value="completed">Complété</option>
<option value="plan_to">À voir/lire</option>
<option value="paused">En pause</option>
<option value="dropped">Abandonné</option>
</select>
</div>
<div class="progress-section">
<div class="progress-info">
<span class="progress-text">
{item.progress} / {item.work?.totalEpisodes || item.work?.totalChapters || '?'}
</span>
<button class="btn-progress" onclick={() => incrementProgress(item)}>+1</button>
</div>
<div class="progress-bar-bg">
<div class="progress-bar-fill" style="width: {pct}%"></div>
</div>
</div>
<div class="card-bottom">
<div class="score-section">
{#if editingScore === item.id}
<input
type="number"
min="0"
max="10"
step="0.5"
bind:value={scoreInput}
class="score-input"
onkeydown={(e) => e.key === 'Enter' && saveScore(item)}
/>
<button class="btn-ghost btn-xs" onclick={() => saveScore(item)}>OK</button>
<button class="btn-ghost btn-xs" onclick={() => editingScore = null}>×</button>
{:else}
<button class="score-btn" onclick={() => startEditScore(item)}>
{item.score ? `★ ${item.score}/10` : '☆ Noter'}
</button>
{/if}
</div>
<button class="btn-ghost btn-xs btn-danger" onclick={() => remove(item)}>Retirer</button>
</div>
</div>
</div>
{/each}
</div>
{:else}
<div class="grid-items">
{#each sortedItems() as item (item.id)}
<div class="card grid-card" class:gold={item.status === 'completed'}>
{#if item.work?.posterUrl}
<img src={item.work.posterUrl} alt="" class="grid-poster" loading="lazy" />
{/if}
<div class="grid-info">
<h4>{item.work?.titleRomaji}</h4>
<span class="grid-progress">{item.progress}/{item.work?.totalEpisodes || item.work?.totalChapters || '?'}</span>
{#if item.score}
<span class="grid-score">{item.score}</span>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
<style>
.list-page { max-width: 900px; margin: 0 auto; }
.list-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.list-header h2 { font-size: 1.5rem; }
.list-controls { display: flex; gap: 0.5rem; align-items: center; }
.view-toggle { display: flex; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
.view-toggle button { background: var(--bg-secondary); border: none; padding: 0.35rem 0.5rem; font-size: 0.85rem; color: var(--text-muted); }
.view-toggle button.active { background: var(--accent); color: #fff; }
.tabs { display: flex; gap: 0.35rem; 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.7rem; font-size: 0.78rem; border-radius: var(--radius); display: flex; align-items: center; gap: 0.3rem; }
.tab.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.tab-icon { font-size: 0.85rem; }
.tab-count { background: rgba(255,255,255,0.2); padding: 0 0.35rem; border-radius: 9999px; font-size: 0.65rem; }
.loading { display: flex; align-items: center; gap: 0.75rem; color: var(--text-muted); padding: 2rem 0; }
.spinner { width: 20px; height: 20px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.empty { text-align: center; padding: 3rem 0; color: var(--text-secondary); }
.empty a { display: inline-block; margin-top: 1rem; }
.login-prompt { text-align: center; padding: 4rem 0; }
.login-prompt p { color: var(--text-secondary); margin: 0.5rem 0 1.5rem; }
.list-items { display: flex; flex-direction: column; gap: 0.6rem; }
.list-card { display: flex; overflow: hidden; }
.list-card.gold { border-color: var(--gold); box-shadow: 0 0 12px rgba(251, 191, 36, 0.12); }
.poster { width: 75px; min-height: 105px; object-fit: cover; flex-shrink: 0; }
.card-body { padding: 0.6rem 0.75rem; flex: 1; display: flex; flex-direction: column; gap: 0.35rem; min-width: 0; }
.card-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 0.5rem; }
.card-top h3 { font-size: 0.88rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.status-select { font-size: 0.7rem; padding: 0.15rem 0.3rem; flex-shrink: 0; }
.progress-section { display: flex; flex-direction: column; gap: 0.2rem; }
.progress-info { display: flex; align-items: center; gap: 0.5rem; }
.progress-text { font-size: 0.8rem; color: var(--text-secondary); }
.btn-progress { background: var(--accent); color: #fff; padding: 0.1rem 0.5rem; font-size: 0.75rem; font-weight: 700; border-radius: 4px; }
.btn-progress:hover { background: var(--accent-hover); }
.progress-bar-bg { height: 4px; background: var(--bg-secondary); border-radius: 2px; overflow: hidden; }
.progress-bar-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.3s; }
.list-card.gold .progress-bar-fill { background: var(--gold); }
.card-bottom { display: flex; justify-content: space-between; align-items: center; }
.score-section { display: flex; align-items: center; gap: 0.3rem; }
.score-btn { background: none; border: none; color: var(--gold); font-size: 0.8rem; padding: 0; cursor: pointer; }
.score-btn:hover { color: var(--accent); }
.score-input { width: 50px; font-size: 0.75rem; padding: 0.15rem 0.3rem; text-align: center; }
.btn-xs { padding: 0.15rem 0.35rem; font-size: 0.7rem; }
.btn-danger { color: var(--danger); border-color: var(--danger); }
.btn-danger:hover { background: var(--danger); color: #fff; }
.btn-lg { padding: 0.75rem 2rem; font-size: 1rem; }
.grid-items { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 0.75rem; }
.grid-card { display: flex; flex-direction: column; }
.grid-card.gold { border-color: var(--gold); }
.grid-poster { width: 100%; aspect-ratio: 2/3; object-fit: cover; }
.grid-info { padding: 0.5rem; }
.grid-info h4 { font-size: 0.78rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 0.2rem; }
.grid-progress { font-size: 0.7rem; color: var(--text-muted); }
.grid-score { font-size: 0.7rem; color: var(--gold); margin-left: 0.3rem; }
@media (max-width: 640px) {
.tab-label { display: none; }
.tab { padding: 0.4rem 0.5rem; }
.poster { width: 60px; min-height: 85px; }
.grid-items { grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); }
}
</style>