From 108f021bd8dd5d51840222f2ce7b1726fd5cdfa3 Mon Sep 17 00:00:00 2001 From: Tetardtek Date: Wed, 25 Mar 2026 02:43:36 +0100 Subject: [PATCH] feat: Sprint 1 core tracker + SuperOAuth PKCE E2E MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/.gitignore | 1 + backend/package-lock.json | 31 ++ backend/package.json | 2 + backend/src/auth/auth.guard.ts | 12 +- backend/src/main.ts | 3 + frontend/src/lib/auth.ts | 11 +- frontend/src/lib/components/Toast.svelte | 47 +++ frontend/src/lib/stores/toast.svelte.ts | 16 + frontend/src/lib/stores/user.svelte.ts | 57 +++ frontend/src/routes/+layout.svelte | 62 ++-- frontend/src/routes/+page.svelte | 3 +- frontend/src/routes/callback/+page.svelte | 14 +- frontend/src/routes/list/+page.svelte | 426 +++++++++++++++------- frontend/src/routes/login/+page.svelte | 96 +++++ frontend/src/routes/profile/+page.svelte | 203 +++++------ frontend/src/routes/search/+page.svelte | 267 +++++++++++--- 16 files changed, 918 insertions(+), 333 deletions(-) create mode 100644 frontend/src/lib/components/Toast.svelte create mode 100644 frontend/src/lib/stores/toast.svelte.ts create mode 100644 frontend/src/lib/stores/user.svelte.ts create mode 100644 frontend/src/routes/login/+page.svelte diff --git a/backend/.gitignore b/backend/.gitignore index 87c76d2..46f4bab 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -2,3 +2,4 @@ dist/ node_modules/ .env *.js.map +*.tsbuildinfo diff --git a/backend/package-lock.json b/backend/package-lock.json index f417964..df0bba5 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index 77f62bd..77e740b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/auth/auth.guard.ts b/backend/src/auth/auth.guard.ts index 36ab059..2a6f5a2 100644 --- a/backend/src/auth/auth.guard.ts +++ b/backend/src/auth/auth.guard.ts @@ -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; } diff --git a/backend/src/main.ts b/backend/src/main.ts index 96b3a81..cf3a250 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -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, diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 51bba1b..c4e92ed 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -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 { .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 { 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({ diff --git a/frontend/src/lib/components/Toast.svelte b/frontend/src/lib/components/Toast.svelte new file mode 100644 index 0000000..904aae6 --- /dev/null +++ b/frontend/src/lib/components/Toast.svelte @@ -0,0 +1,47 @@ + + +{#if toasts.items.length > 0} +
+ {#each toasts.items as msg (msg.id)} +
+ {msg.text} +
+ {/each} +
+{/if} + + diff --git a/frontend/src/lib/stores/toast.svelte.ts b/frontend/src/lib/stores/toast.svelte.ts new file mode 100644 index 0000000..812c3d2 --- /dev/null +++ b/frontend/src/lib/stores/toast.svelte.ts @@ -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); +} diff --git a/frontend/src/lib/stores/user.svelte.ts b/frontend/src/lib/stores/user.svelte.ts new file mode 100644 index 0000000..f4ddee0 --- /dev/null +++ b/frontend/src/lib/stores/user.svelte.ts @@ -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(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; +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index b2c4ab6..03e5130 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,21 +1,11 @@ @@ -29,7 +28,7 @@
- + Commencer Explorer
diff --git a/frontend/src/routes/callback/+page.svelte b/frontend/src/routes/callback/+page.svelte index 28af684..4b93a32 100644 --- a/frontend/src/routes/callback/+page.svelte +++ b/frontend/src/routes/callback/+page.svelte @@ -1,12 +1,18 @@ @@ -57,136 +135,204 @@ Ma Liste — Sakuin
-
-

Ma Liste

- -
- {#each tabs as tab} - - {/each} +{#if !userStore.value && !userStore.loading} + - - {#if loading} -

Chargement...

- {:else if items.length === 0} -

Liste vide. Chercher des oeuvres

- {:else} -
- {#each items as item} -
- {#if item.work?.posterUrl} - - {/if} -
-

{item.work?.titleRomaji}

-
- - {item.status} - - {#if item.score} - ★ {item.score}/10 - {/if} -
-
- - {item.progress} / {item.work?.totalEpisodes || item.work?.totalChapters || '?'} - - -
-
- -
-
+{:else} +
+
+

Ma Liste

+
+ +
+ +
+
+
+ +
+ {#each tabs as tab} + {/each}
- {/if} -
+ + {#if loading} +
+
+ Chargement... +
+ {:else if sortedItems().length === 0} +
+

Liste vide.

+ Rechercher des oeuvres +
+ {:else if viewMode === 'list'} +
+ {#each sortedItems() as item (item.id)} + {@const pct = getProgressPercent(item)} +
+ {#if item.work?.posterUrl} + + {/if} +
+
+

{item.work?.titleRomaji}

+ +
+ +
+
+ + {item.progress} / {item.work?.totalEpisodes || item.work?.totalChapters || '?'} + + +
+
+
+
+
+ +
+
+ {#if editingScore === item.id} + e.key === 'Enter' && saveScore(item)} + /> + + + {:else} + + {/if} +
+ +
+
+
+ {/each} +
+ {:else} +
+ {#each sortedItems() as item (item.id)} +
+ {#if item.work?.posterUrl} + + {/if} +
+

{item.work?.titleRomaji}

+ {item.progress}/{item.work?.totalEpisodes || item.work?.totalChapters || '?'} + {#if item.score} + ★ {item.score} + {/if} +
+
+ {/each} +
+ {/if} +
+{/if} diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte new file mode 100644 index 0000000..717125a --- /dev/null +++ b/frontend/src/routes/login/+page.svelte @@ -0,0 +1,96 @@ + + + + Connexion — Sakuin + + + + + diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte index dda2e09..3fdef6d 100644 --- a/frontend/src/routes/profile/+page.svelte +++ b/frontend/src/routes/profile/+page.svelte @@ -1,21 +1,22 @@ @@ -23,129 +24,109 @@ Profil — Sakuin -{#if loading} -

Chargement...

-{:else if !user} -

Non connecté.

-{:else} +{#if !userStore.value && !userStore.loading} + +{:else if userStore.value} + {@const user = userStore.value} {@const xp = xpToNextLevel(user.xp)} + {@const grade = getGrade(user.level)}
-
- {#if user.avatarUrl} - - {/if} -
-

{user.username}

-
- Level {user.level} - {user.xp} XP +
+
+
+ {#if user.avatarUrl} + + {:else} +
{user.username.charAt(0).toUpperCase()}
+ {/if} + Lv.{user.level}
-
-
+
+

{user.username}

+ {grade.name} +
+
+
+
+ {xp.current} / {xp.needed} XP — Total : {user.xp} XP +
- {xp.current} / {xp.needed} XP
{#if user.stats} +

Statistiques

-
+
{user.stats.total} - Total + Oeuvres
-
- {user.stats.watching || 0} - En cours (anime) -
-
- {user.stats.reading || 0} - En cours (manga) -
-
+
{user.stats.completed || 0} - Complétés + Complétées
-
+
+ {user.stats.watching || 0} + Anime en cours +
+
+ {user.stats.reading || 0} + Manga en cours +
+
{user.stats.episodesWatched || 0} Épisodes vus
-
+
{user.stats.chaptersRead || 0} Chapitres lus
+
+ {user.stats.planTo || 0} + À voir/lire +
+
+ {user.stats.dropped || 0} + Abandonnés +
{/if}
{/if} diff --git a/frontend/src/routes/search/+page.svelte b/frontend/src/routes/search/+page.svelte index 677ff32..eec7f4d 100644 --- a/frontend/src/routes/search/+page.svelte +++ b/frontend/src/routes/search/+page.svelte @@ -1,51 +1,84 @@ @@ -57,37 +90,57 @@

Rechercher

+
- {#if loading} -

Recherche...

+ {#if loading && results.length === 0} +
+
+ Recherche... +
{:else if results.length > 0}

{total} résultat{total > 1 ? 's' : ''}

- {#each results as media} + {#each results as media (media.id)} + {@const isAdded = addedIds.has(media.id)}
{#if media.coverImage?.large} - {media.title.romaji} + {media.title.romaji} + {:else} +
+ No img +
{/if}
-

{media.title.romaji}

- {#if media.title.english && media.title.english !== media.title.romaji} -

{media.title.english}

- {/if} +
+

{media.title.romaji}

+ {#if media.title.english && media.title.english !== media.title.romaji} +

{media.title.english}

+ {/if} +
- {media.type} + + {media.type === 'ANIME' ? 'Anime' : 'Manga'} + + {#if media.status} + {formatStatus(media.status)} + {/if} {#if media.episodes} {media.episodes} ép. {/if} @@ -97,24 +150,43 @@

{stripHtml(media.description)}

- {#each (media.genres || []).slice(0, 3) as genre} + {#each (media.genres || []).slice(0, 4) as genre} {genre} {/each}
- - + {#if isAdded} + ✓ Ajouté + {:else} + + + {/if}
{/each}
- {:else if query.length >= 2} -

Aucun résultat.

+ + {#if hasNextPage} +
+ +
+ {/if} + {:else if query.length >= 2 && !loading} +
+

Aucun résultat pour "{query}"

+

Essaie un autre titre ou change le filtre.

+
+ {:else} +
+

Commence à taper pour rechercher un anime ou manga.

+
{/if}
@@ -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; + } + }