feat: Sprint 1 core tracker + SuperOAuth PKCE E2E
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 31s
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 31s
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.
This commit is contained in:
@@ -1,20 +1,49 @@
|
||||
<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' },
|
||||
{ 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' },
|
||||
{ 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;
|
||||
@@ -33,23 +62,72 @@
|
||||
|
||||
async function incrementProgress(item: any) {
|
||||
const newProgress = item.progress + 1;
|
||||
await api.updateProgress(item.id, newProgress);
|
||||
item.progress = newProgress;
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
const total = item.work?.totalEpisodes || item.work?.totalChapters;
|
||||
if (total && newProgress >= total) {
|
||||
item.status = 'completed';
|
||||
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} ?`)) return;
|
||||
await api.removeFromList(item.id);
|
||||
items = items.filter((i) => i.id !== item.id);
|
||||
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(() => {
|
||||
loadList();
|
||||
if (userStore.value) loadList();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -57,136 +135,204 @@
|
||||
<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}
|
||||
{#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>
|
||||
|
||||
{#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>
|
||||
{: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}
|
||||
</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;
|
||||
}
|
||||
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;
|
||||
.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>
|
||||
|
||||
Reference in New Issue
Block a user