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
backend/.gitignore
vendored
1
backend/.gitignore
vendored
@@ -2,3 +2,4 @@ dist/
|
||||
node_modules/
|
||||
.env
|
||||
*.js.map
|
||||
*.tsbuildinfo
|
||||
|
||||
31
backend/package-lock.json
generated
31
backend/package-lock.json
generated
@@ -16,6 +16,7 @@
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"mysql2": "^3.20.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
@@ -24,6 +25,7 @@
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.16",
|
||||
"@nestjs/schematics": "^11.0.9",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.5.0",
|
||||
"ts-node": "^10.9.2",
|
||||
@@ -1176,6 +1178,16 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookie-parser": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
|
||||
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/eslint": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
||||
@@ -2325,6 +2337,25 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser": {
|
||||
"version": "1.4.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "0.7.2",
|
||||
"cookie-signature": "1.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser/node_modules/cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"mysql2": "^3.20.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
@@ -29,6 +30,7 @@
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.16",
|
||||
"@nestjs/schematics": "^11.0.9",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.5.0",
|
||||
"ts-node": "^10.9.2",
|
||||
|
||||
@@ -42,7 +42,17 @@ export class AuthGuard implements CanActivate {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
const body = await res.json();
|
||||
if (!body?.success || !body?.data?.user) return null;
|
||||
|
||||
const user = body.data.user;
|
||||
const avatar = body.data.linkedProviders?.[0]?.avatar || null;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
nickname: user.nickname || user.email,
|
||||
avatar,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
app.use(cookieParser());
|
||||
|
||||
app.enableCors({
|
||||
origin: process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||
credentials: true,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const OAUTH_URL = import.meta.env.VITE_OAUTH_URL || 'https://superoauth.tetardtek.com';
|
||||
const CLIENT_ID = import.meta.env.VITE_OAUTH_CLIENT_ID || 'sakuin';
|
||||
|
||||
export type Provider = 'discord' | 'github' | 'google' | 'twitch';
|
||||
|
||||
function generateCodeVerifier(): string {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
@@ -19,7 +21,7 @@ async function generateCodeChallenge(verifier: string): Promise<string> {
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export async function login() {
|
||||
export async function login(provider: Provider) {
|
||||
const verifier = generateCodeVerifier();
|
||||
const challenge = await generateCodeChallenge(verifier);
|
||||
|
||||
@@ -31,17 +33,18 @@ export async function login() {
|
||||
redirect_uri: `${window.location.origin}/callback`,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
scope: 'openid profile',
|
||||
scope: 'openid profile email',
|
||||
provider,
|
||||
});
|
||||
|
||||
window.location.href = `${OAUTH_URL}/authorize?${params}`;
|
||||
window.location.href = `${OAUTH_URL}/oauth/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`, {
|
||||
const res = await fetch(`${OAUTH_URL}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
||||
47
frontend/src/lib/components/Toast.svelte
Normal file
47
frontend/src/lib/components/Toast.svelte
Normal file
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { getToasts } from '$lib/stores/toast.svelte';
|
||||
|
||||
const toasts = getToasts();
|
||||
</script>
|
||||
|
||||
{#if toasts.items.length > 0}
|
||||
<div class="toast-container">
|
||||
{#each toasts.items as msg (msg.id)}
|
||||
<div class="toast" class:success={msg.type === 'success'} class:error={msg.type === 'error'}>
|
||||
{msg.text}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.toast {
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
animation: slideIn 0.2s ease;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.success {
|
||||
background: var(--success);
|
||||
color: #000;
|
||||
}
|
||||
.error {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
16
frontend/src/lib/stores/toast.svelte.ts
Normal file
16
frontend/src/lib/stores/toast.svelte.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
let messages = $state<{ id: number; text: string; type: 'success' | 'error' }[]>([]);
|
||||
let nextId = 0;
|
||||
|
||||
export function getToasts() {
|
||||
return {
|
||||
get items() { return messages; },
|
||||
};
|
||||
}
|
||||
|
||||
export function toast(text: string, type: 'success' | 'error' = 'success') {
|
||||
const id = nextId++;
|
||||
messages = [...messages, { id, text, type }];
|
||||
setTimeout(() => {
|
||||
messages = messages.filter((m) => m.id !== id);
|
||||
}, 3000);
|
||||
}
|
||||
57
frontend/src/lib/stores/user.svelte.ts
Normal file
57
frontend/src/lib/stores/user.svelte.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { api } from '$lib/api';
|
||||
|
||||
interface UserStats {
|
||||
total: number;
|
||||
watching: number;
|
||||
reading: number;
|
||||
completed: number;
|
||||
dropped: number;
|
||||
planTo: number;
|
||||
episodesWatched: number;
|
||||
chaptersRead: number;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
avatarUrl: string | null;
|
||||
xp: number;
|
||||
level: number;
|
||||
stats: UserStats;
|
||||
}
|
||||
|
||||
let user = $state<User | null>(null);
|
||||
let loading = $state(true);
|
||||
let loaded = $state(false);
|
||||
|
||||
export function getUser() {
|
||||
return {
|
||||
get value() { return user; },
|
||||
get loading() { return loading; },
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadUser() {
|
||||
if (loaded) return user;
|
||||
loading = true;
|
||||
try {
|
||||
user = await api.me();
|
||||
loaded = true;
|
||||
} catch {
|
||||
user = null;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function refreshUser() {
|
||||
loaded = false;
|
||||
return loadUser();
|
||||
}
|
||||
|
||||
export function clearUser() {
|
||||
user = null;
|
||||
loaded = false;
|
||||
loading = false;
|
||||
}
|
||||
@@ -1,21 +1,11 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { login, logout } from '$lib/auth';
|
||||
import { logout } from '$lib/auth';
|
||||
import { getUser, loadUser } from '$lib/stores/user.svelte';
|
||||
import Toast from '$lib/components/Toast.svelte';
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
const userStore = getUser();
|
||||
|
||||
$effect(() => {
|
||||
loadUser();
|
||||
@@ -28,19 +18,19 @@
|
||||
|
||||
<div class="nav-links">
|
||||
<a href="/search">Rechercher</a>
|
||||
{#if user}
|
||||
{#if userStore.value}
|
||||
<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 userStore.value.avatarUrl}
|
||||
<img src={userStore.value.avatarUrl} alt="" class="avatar" />
|
||||
{/if}
|
||||
<span class="username">{user.username}</span>
|
||||
<span class="level badge badge-accent">Lv.{user.level}</span>
|
||||
<span class="username">{userStore.value.username}</span>
|
||||
<span class="level badge badge-accent">Lv.{userStore.value.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>
|
||||
{:else if !userStore.loading}
|
||||
<a href="/login" class="btn-primary" style="text-decoration:none">Connexion</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -50,6 +40,14 @@
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<Toast />
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container footer-inner">
|
||||
<span>索引 Sakuin — ton index manga & anime</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
.navbar {
|
||||
background: var(--bg-secondary);
|
||||
@@ -109,5 +107,27 @@
|
||||
.main-content {
|
||||
padding-top: 2rem;
|
||||
padding-bottom: 4rem;
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
.footer {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 1rem 0;
|
||||
}
|
||||
.footer-inner {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.nav-links {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.nav-links a {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.username {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { login } from '$lib/auth';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -29,7 +28,7 @@
|
||||
</div>
|
||||
|
||||
<div class="cta">
|
||||
<button class="btn-primary btn-lg" onclick={login}>Commencer</button>
|
||||
<a href="/login" class="btn-primary btn-lg" style="text-decoration:none">Commencer</a>
|
||||
<a href="/search" class="btn-ghost btn-lg">Explorer</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { handleCallback } from '$lib/auth';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
let error = $state('');
|
||||
let processing = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const code = page.url.searchParams.get('code');
|
||||
if (!browser || processing) return;
|
||||
processing = true;
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get('code');
|
||||
|
||||
if (!code) {
|
||||
error = 'Code manquant dans la callback.';
|
||||
return;
|
||||
@@ -14,8 +20,8 @@
|
||||
|
||||
handleCallback(code)
|
||||
.then((tokens) => {
|
||||
document.cookie = `access_token=${tokens.access_token}; path=/; max-age=${tokens.expires_in || 3600}; SameSite=Lax`;
|
||||
goto('/list');
|
||||
document.cookie = `access_token=${tokens.access_token}; path=/; max-age=${tokens.expires_in || 3600}; SameSite=Lax; Secure`;
|
||||
window.location.href = '/list';
|
||||
})
|
||||
.catch((e) => {
|
||||
error = e.message || 'Erreur de connexion.';
|
||||
|
||||
@@ -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>
|
||||
|
||||
96
frontend/src/routes/login/+page.svelte
Normal file
96
frontend/src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import { login, type Provider } from '$lib/auth';
|
||||
|
||||
const providers: { id: Provider; label: string; color: string; icon: string }[] = [
|
||||
{ id: 'discord', label: 'Discord', color: '#5865F2', icon: '🎮' },
|
||||
{ id: 'github', label: 'GitHub', color: '#333', icon: '🐙' },
|
||||
{ id: 'google', label: 'Google', color: '#4285F4', icon: '🔍' },
|
||||
{ id: 'twitch', label: 'Twitch', color: '#9146FF', icon: '📺' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Connexion — Sakuin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="login-page">
|
||||
<div class="login-card card">
|
||||
<h1>索引 <span class="accent">Sakuin</span></h1>
|
||||
<p class="subtitle">Connecte-toi pour tracker tes anime & manga.</p>
|
||||
|
||||
<div class="providers">
|
||||
{#each providers as provider}
|
||||
<button
|
||||
class="provider-btn"
|
||||
style="--provider-color: {provider.color}"
|
||||
onclick={() => login(provider.id)}
|
||||
>
|
||||
<span class="provider-icon">{provider.icon}</span>
|
||||
<span>Continuer avec {provider.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class="footer-text">
|
||||
Pas de mot de passe — connexion via tes comptes existants.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.login-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: calc(100vh - 200px);
|
||||
}
|
||||
.login-card {
|
||||
padding: 2.5rem;
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.accent {
|
||||
color: var(--accent);
|
||||
}
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.providers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.provider-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: var(--provider-color);
|
||||
color: #fff;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
}
|
||||
.provider-btn:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.provider-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.footer-text {
|
||||
margin-top: 1.5rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,21 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { getUser } from '$lib/stores/user.svelte';
|
||||
|
||||
let user = $state<any>(null);
|
||||
let loading = $state(true);
|
||||
const userStore = getUser();
|
||||
|
||||
$effect(() => {
|
||||
api.me()
|
||||
.then((data) => { user = data; })
|
||||
.catch(() => { user = null; })
|
||||
.finally(() => { loading = false; });
|
||||
});
|
||||
|
||||
function xpToNextLevel(xp: number): { current: number; needed: number; pct: number } {
|
||||
function xpToNextLevel(xp: number): { current: number; needed: number; pct: number; level: 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) };
|
||||
return { current, needed: 500, pct: Math.round((current / 500) * 100), level };
|
||||
}
|
||||
|
||||
function getGrade(level: number): { name: string; color: string } {
|
||||
if (level >= 20) return { name: 'Légende Otaku', color: '#fbbf24' };
|
||||
if (level >= 15) return { name: 'Sensei', color: '#c084fc' };
|
||||
if (level >= 10) return { name: 'Mangavore', color: '#34d399' };
|
||||
if (level >= 5) return { name: 'Otaku', color: '#60a5fa' };
|
||||
if (level >= 2) return { name: 'Apprenti', color: '#a0a0b8' };
|
||||
return { name: 'Débutant', color: '#6b6b80' };
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -23,129 +24,109 @@
|
||||
<title>Profil — Sakuin</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if loading}
|
||||
<p>Chargement...</p>
|
||||
{:else if !user}
|
||||
<p>Non connecté.</p>
|
||||
{:else}
|
||||
{#if !userStore.value && !userStore.loading}
|
||||
<div class="login-prompt">
|
||||
<h2>Profil</h2>
|
||||
<p>Connecte-toi pour voir ton profil.</p>
|
||||
<a href="/login" class="btn-primary btn-lg" style="text-decoration:none">Connexion</a>
|
||||
</div>
|
||||
{:else if userStore.value}
|
||||
{@const user = userStore.value}
|
||||
{@const xp = xpToNextLevel(user.xp)}
|
||||
{@const grade = getGrade(user.level)}
|
||||
<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 class="profile-card card">
|
||||
<div class="profile-header">
|
||||
<div class="avatar-wrap">
|
||||
{#if user.avatarUrl}
|
||||
<img src={user.avatarUrl} alt="" class="avatar-lg" />
|
||||
{:else}
|
||||
<div class="avatar-lg avatar-placeholder">{user.username.charAt(0).toUpperCase()}</div>
|
||||
{/if}
|
||||
<span class="level-badge">Lv.{user.level}</span>
|
||||
</div>
|
||||
<div class="xp-bar">
|
||||
<div class="xp-fill" style="width: {xp.pct}%"></div>
|
||||
<div class="profile-info">
|
||||
<h2>{user.username}</h2>
|
||||
<span class="grade" style="color: {grade.color}">{grade.name}</span>
|
||||
<div class="xp-section">
|
||||
<div class="xp-bar">
|
||||
<div class="xp-fill" style="width: {xp.pct}%"></div>
|
||||
</div>
|
||||
<span class="xp-text">{xp.current} / {xp.needed} XP — Total : {user.xp} XP</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="xp-detail">{xp.current} / {xp.needed} XP</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if user.stats}
|
||||
<h3 class="section-title">Statistiques</h3>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-card card">
|
||||
<span class="stat-value">{user.stats.total}</span>
|
||||
<span class="stat-label">Total</span>
|
||||
<span class="stat-label">Oeuvres</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">
|
||||
<div class="stat-card card">
|
||||
<span class="stat-value">{user.stats.completed || 0}</span>
|
||||
<span class="stat-label">Complétés</span>
|
||||
<span class="stat-label">Complétées</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card card">
|
||||
<span class="stat-value">{user.stats.watching || 0}</span>
|
||||
<span class="stat-label">Anime en cours</span>
|
||||
</div>
|
||||
<div class="stat-card card">
|
||||
<span class="stat-value">{user.stats.reading || 0}</span>
|
||||
<span class="stat-label">Manga en cours</span>
|
||||
</div>
|
||||
<div class="stat-card card">
|
||||
<span class="stat-value">{user.stats.episodesWatched || 0}</span>
|
||||
<span class="stat-label">Épisodes vus</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card card">
|
||||
<span class="stat-value">{user.stats.chaptersRead || 0}</span>
|
||||
<span class="stat-label">Chapitres lus</span>
|
||||
</div>
|
||||
<div class="stat-card card">
|
||||
<span class="stat-value">{user.stats.planTo || 0}</span>
|
||||
<span class="stat-label">À voir/lire</span>
|
||||
</div>
|
||||
<div class="stat-card card">
|
||||
<span class="stat-value">{user.stats.dropped || 0}</span>
|
||||
<span class="stat-label">Abandonnés</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);
|
||||
.profile { max-width: 700px; margin: 0 auto; }
|
||||
.login-prompt { text-align: center; padding: 4rem 0; }
|
||||
.login-prompt p { color: var(--text-secondary); margin: 0.5rem 0 1.5rem; }
|
||||
.btn-lg { padding: 0.75rem 2rem; font-size: 1rem; }
|
||||
|
||||
.profile-card { padding: 1.5rem; }
|
||||
.profile-header { display: flex; gap: 1.5rem; align-items: center; }
|
||||
.avatar-wrap { position: relative; flex-shrink: 0; }
|
||||
.avatar-lg { width: 80px; height: 80px; border-radius: 50%; border: 3px solid var(--accent); }
|
||||
.avatar-placeholder { width: 80px; height: 80px; border-radius: 50%; border: 3px solid var(--accent); background: var(--bg-secondary); display: flex; align-items: center; justify-content: center; font-size: 2rem; font-weight: 700; color: var(--accent); }
|
||||
.level-badge { position: absolute; bottom: -4px; right: -4px; background: var(--accent); color: #fff; font-size: 0.65rem; font-weight: 700; padding: 0.15rem 0.4rem; border-radius: 9999px; }
|
||||
.profile-info { flex: 1; }
|
||||
.profile-info h2 { margin-bottom: 0.15rem; font-size: 1.4rem; }
|
||||
.grade { font-size: 0.85rem; font-weight: 600; display: block; margin-bottom: 0.6rem; }
|
||||
.xp-section { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.xp-bar { width: 100%; max-width: 280px; height: 8px; background: var(--bg-secondary); border-radius: 4px; overflow: hidden; }
|
||||
.xp-fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--gold)); border-radius: 4px; transition: width 0.4s; }
|
||||
.xp-text { font-size: 0.72rem; color: var(--text-muted); }
|
||||
|
||||
.section-title { margin: 2rem 0 1rem; font-size: 1rem; color: var(--text-secondary); }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 0.75rem; }
|
||||
.stat-card { padding: 1rem; text-align: center; }
|
||||
.stat-value { display: block; font-size: 1.75rem; font-weight: 700; color: var(--accent); }
|
||||
.stat-label { font-size: 0.78rem; color: var(--text-secondary); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.profile-header { flex-direction: column; text-align: center; }
|
||||
.xp-bar { max-width: 100%; }
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,51 +1,84 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { toast } from '$lib/stores/toast.svelte';
|
||||
import { getUser } from '$lib/stores/user.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
const userStore = getUser();
|
||||
|
||||
let query = $state('');
|
||||
let typeFilter = $state<string>('');
|
||||
let results = $state<any[]>([]);
|
||||
let loading = $state(false);
|
||||
let total = $state(0);
|
||||
let hasNextPage = $state(false);
|
||||
let currentPage = $state(1);
|
||||
let addedIds = $state<Set<number>>(new Set());
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
function onInput() {
|
||||
clearTimeout(debounceTimer);
|
||||
if (query.trim().length < 2) {
|
||||
results = [];
|
||||
total = 0;
|
||||
return;
|
||||
}
|
||||
currentPage = 1;
|
||||
debounceTimer = setTimeout(() => doSearch(), 400);
|
||||
}
|
||||
|
||||
async function doSearch() {
|
||||
function onFilterChange() {
|
||||
if (query.trim().length >= 2) {
|
||||
currentPage = 1;
|
||||
doSearch();
|
||||
}
|
||||
}
|
||||
|
||||
async function doSearch(page = 1) {
|
||||
loading = true;
|
||||
try {
|
||||
const res = await api.search(query, typeFilter || undefined);
|
||||
results = res.media;
|
||||
const res = await api.search(query, typeFilter || undefined, page);
|
||||
results = page === 1 ? res.media : [...results, ...res.media];
|
||||
total = res.total;
|
||||
hasNextPage = res.hasNextPage;
|
||||
currentPage = page;
|
||||
} catch (e) {
|
||||
console.error('Search failed:', e);
|
||||
toast('Erreur de recherche', 'error');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addToList(anilistId: number, status: string) {
|
||||
if (!userStore.value) {
|
||||
toast('Connecte-toi pour ajouter à ta liste', 'error');
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.addToList(anilistId, status);
|
||||
alert('Ajouté !');
|
||||
addedIds = new Set([...addedIds, anilistId]);
|
||||
const label = status === 'plan_to' ? 'À voir/lire' : 'Ma liste';
|
||||
toast(`Ajouté à "${label}" !`);
|
||||
} catch (e: any) {
|
||||
if (e.message?.includes('Unauthorized')) {
|
||||
alert('Connecte-toi pour ajouter à ta liste.');
|
||||
} else {
|
||||
alert(e.message);
|
||||
}
|
||||
toast(e.message || 'Erreur', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function stripHtml(html: string | null): string {
|
||||
if (!html) return '';
|
||||
return html.replace(/<[^>]*>/g, '').slice(0, 200);
|
||||
return html.replace(/<[^>]*>/g, '').slice(0, 200) + '...';
|
||||
}
|
||||
|
||||
function formatStatus(status: string | null): string {
|
||||
const map: Record<string, string> = {
|
||||
RELEASING: 'En cours',
|
||||
FINISHED: 'Terminé',
|
||||
NOT_YET_RELEASED: 'À venir',
|
||||
CANCELLED: 'Annulé',
|
||||
HIATUS: 'Hiatus',
|
||||
};
|
||||
return status ? map[status] || status : '';
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -57,37 +90,57 @@
|
||||
<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}>
|
||||
<div class="search-input-wrap">
|
||||
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="One Piece, Frieren, Berserk..."
|
||||
bind:value={query}
|
||||
oninput={onInput}
|
||||
class="search-input"
|
||||
/>
|
||||
</div>
|
||||
<select bind:value={typeFilter} onchange={onFilterChange}>
|
||||
<option value="">Tout</option>
|
||||
<option value="ANIME">Anime</option>
|
||||
<option value="MANGA">Manga</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="status">Recherche...</p>
|
||||
{#if loading && results.length === 0}
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<span>Recherche...</span>
|
||||
</div>
|
||||
{:else if results.length > 0}
|
||||
<p class="status">{total} résultat{total > 1 ? 's' : ''}</p>
|
||||
<div class="results-grid">
|
||||
{#each results as media}
|
||||
{#each results as media (media.id)}
|
||||
{@const isAdded = addedIds.has(media.id)}
|
||||
<div class="card result-card">
|
||||
{#if media.coverImage?.large}
|
||||
<img src={media.coverImage.large} alt={media.title.romaji} class="poster" />
|
||||
<img src={media.coverImage.large} alt={media.title.romaji} class="poster" loading="lazy" />
|
||||
{:else}
|
||||
<div class="poster poster-placeholder">
|
||||
<span>No img</span>
|
||||
</div>
|
||||
{/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="card-header">
|
||||
<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>
|
||||
<div class="meta">
|
||||
<span class="badge badge-accent">{media.type}</span>
|
||||
<span class="badge" class:badge-accent={media.type === 'ANIME'} class:badge-manga={media.type === 'MANGA'}>
|
||||
{media.type === 'ANIME' ? 'Anime' : 'Manga'}
|
||||
</span>
|
||||
{#if media.status}
|
||||
<span class="meta-status">{formatStatus(media.status)}</span>
|
||||
{/if}
|
||||
{#if media.episodes}
|
||||
<span>{media.episodes} ép.</span>
|
||||
{/if}
|
||||
@@ -97,24 +150,43 @@
|
||||
</div>
|
||||
<p class="synopsis">{stripHtml(media.description)}</p>
|
||||
<div class="genres">
|
||||
{#each (media.genres || []).slice(0, 3) as genre}
|
||||
{#each (media.genres || []).slice(0, 4) 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>
|
||||
{#if isAdded}
|
||||
<span class="added-badge">✓ Ajouté</span>
|
||||
{:else}
|
||||
<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>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if query.length >= 2}
|
||||
<p class="status">Aucun résultat.</p>
|
||||
|
||||
{#if hasNextPage}
|
||||
<div class="load-more">
|
||||
<button class="btn-ghost" onclick={() => doSearch(currentPage + 1)} disabled={loading}>
|
||||
{loading ? 'Chargement...' : 'Voir plus'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if query.length >= 2 && !loading}
|
||||
<div class="empty">
|
||||
<p>Aucun résultat pour "{query}"</p>
|
||||
<p class="hint">Essaie un autre titre ou change le filtre.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty">
|
||||
<p class="hint">Commence à taper pour rechercher un anime ou manga.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -125,26 +197,58 @@
|
||||
}
|
||||
h2 {
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.search-input {
|
||||
.search-input-wrap {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.search-input {
|
||||
width: 100%;
|
||||
font-size: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
padding: 0.75rem 1rem 0.75rem 2.5rem;
|
||||
}
|
||||
.status {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.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); }
|
||||
}
|
||||
.results-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.result-card {
|
||||
display: flex;
|
||||
@@ -156,36 +260,63 @@
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.poster-placeholder {
|
||||
background: var(--bg-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.card-body {
|
||||
padding: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.card-body h3 {
|
||||
font-size: 1rem;
|
||||
.card-header h3 {
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.title-en {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.meta-status {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.badge-manga {
|
||||
background: var(--gold);
|
||||
color: #000;
|
||||
}
|
||||
.synopsis {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.4;
|
||||
flex: 1;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.genres {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
gap: 0.3rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.genre-tag {
|
||||
@@ -193,15 +324,51 @@
|
||||
color: var(--text-muted);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
align-items: center;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
.btn-sm {
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.added-badge {
|
||||
color: var(--success);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.load-more {
|
||||
text-align: center;
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 3rem 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.hint {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.poster {
|
||||
width: 90px;
|
||||
min-height: 130px;
|
||||
}
|
||||
.card-body {
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
.card-header h3 {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.synopsis {
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user