init: scaffold complet Sakuin — backend NestJS + frontend SvelteKit + CI/CD + deploy VPS
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 29s
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 29s
Backend: 5 modules (auth, user, work, list, health), AniList GraphQL proxy, SuperOAuth PKCE introspection, XP system, migrations TypeORM. Frontend: SvelteKit adapter-node, PWA manifest, dark theme, pages home/search/list/profile/callback. Infra: CI/CD Gitea vps-runner, Apache vhost SSL, pm2 sakuin-backend + sakuin-frontend, port 4002. License: BSL 1.1 (Apache 2.0 en 2028).
This commit is contained in:
23
frontend/.gitignore
vendored
Normal file
23
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
1
frontend/.npmrc
Normal file
1
frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
3
frontend/.vscode/extensions.json
vendored
Normal file
3
frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["svelte.svelte-vscode"]
|
||||
}
|
||||
42
frontend/README.md
Normal file
42
frontend/README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# sv
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```sh
|
||||
# create a new project
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
To recreate this project with the same configuration:
|
||||
|
||||
```sh
|
||||
# recreate this project
|
||||
npx sv@0.13.0 create --template minimal --types ts --install npm .
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
1746
frontend/package-lock.json
generated
Normal file
1746
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
frontend/package.json
Normal file
26
frontend/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"svelte": "^5.54.0",
|
||||
"svelte-check": "^4.4.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sveltejs/adapter-node": "^5.5.4"
|
||||
}
|
||||
}
|
||||
114
frontend/src/app.css
Normal file
114
frontend/src/app.css
Normal file
@@ -0,0 +1,114 @@
|
||||
:root {
|
||||
--bg-primary: #0f0f1a;
|
||||
--bg-secondary: #1a1a2e;
|
||||
--bg-card: #16213e;
|
||||
--bg-hover: #1e2a4a;
|
||||
--text-primary: #e4e4f0;
|
||||
--text-secondary: #a0a0b8;
|
||||
--text-muted: #6b6b80;
|
||||
--accent: #c084fc;
|
||||
--accent-hover: #a855f7;
|
||||
--gold: #fbbf24;
|
||||
--success: #34d399;
|
||||
--danger: #f87171;
|
||||
--border: #2a2a40;
|
||||
--radius: 8px;
|
||||
--radius-lg: 12px;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 1rem;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
input, select {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-primary);
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
input:focus, select:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
transition: transform 0.15s, border-color 0.15s;
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-gold {
|
||||
background: var(--gold);
|
||||
color: #000;
|
||||
}
|
||||
.badge-accent {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
13
frontend/src/app.d.ts
vendored
Normal file
13
frontend/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
14
frontend/src/app.html
Normal file
14
frontend/src/app.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#0f0f1a" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="icon" href="/favicon.png" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
69
frontend/src/lib/api.ts
Normal file
69
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:4002/api';
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(`${API_URL}${path}`, {
|
||||
...options,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ message: res.statusText }));
|
||||
throw new Error(error.message || `API error: ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// Health
|
||||
health: () => request<{ status: string }>('/health'),
|
||||
|
||||
// Search (public)
|
||||
search: (q: string, type?: string, page = 1) => {
|
||||
const params = new URLSearchParams({ q, page: String(page) });
|
||||
if (type) params.set('type', type);
|
||||
return request<{ media: any[]; total: number; hasNextPage: boolean }>(
|
||||
`/works/search?${params}`,
|
||||
);
|
||||
},
|
||||
|
||||
// User
|
||||
me: () => request<any>('/user/me'),
|
||||
|
||||
// List
|
||||
getList: (status?: string) => {
|
||||
const params = status ? `?status=${status}` : '';
|
||||
return request<any[]>(`/list${params}`);
|
||||
},
|
||||
|
||||
addToList: (anilistId: number, status: string) =>
|
||||
request<any>('/list', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ anilistId, status }),
|
||||
}),
|
||||
|
||||
updateProgress: (id: number, progress: number) =>
|
||||
request<any>(`/list/${id}/progress`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ progress }),
|
||||
}),
|
||||
|
||||
updateStatus: (id: number, status: string) =>
|
||||
request<any>(`/list/${id}/status`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status }),
|
||||
}),
|
||||
|
||||
setScore: (id: number, score: number) =>
|
||||
request<any>(`/list/${id}/score`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ score }),
|
||||
}),
|
||||
|
||||
removeFromList: (id: number) =>
|
||||
request<void>(`/list/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
1
frontend/src/lib/assets/favicon.svg
Normal file
1
frontend/src/lib/assets/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
67
frontend/src/lib/auth.ts
Normal file
67
frontend/src/lib/auth.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
const OAUTH_URL = import.meta.env.VITE_OAUTH_URL || 'https://superoauth.tetardtek.com';
|
||||
const CLIENT_ID = import.meta.env.VITE_OAUTH_CLIENT_ID || 'sakuin';
|
||||
|
||||
function generateCodeVerifier(): string {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return btoa(String.fromCharCode(...array))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
async function generateCodeChallenge(verifier: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(verifier);
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
return btoa(String.fromCharCode(...new Uint8Array(digest)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export async function login() {
|
||||
const verifier = generateCodeVerifier();
|
||||
const challenge = await generateCodeChallenge(verifier);
|
||||
|
||||
sessionStorage.setItem('pkce_verifier', verifier);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
response_type: 'code',
|
||||
redirect_uri: `${window.location.origin}/callback`,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
scope: 'openid profile',
|
||||
});
|
||||
|
||||
window.location.href = `${OAUTH_URL}/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`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: CLIENT_ID,
|
||||
code,
|
||||
redirect_uri: `${window.location.origin}/callback`,
|
||||
code_verifier: verifier,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Token exchange failed');
|
||||
|
||||
const tokens = await res.json();
|
||||
sessionStorage.removeItem('pkce_verifier');
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
document.cookie = 'access_token=; Max-Age=0; path=/';
|
||||
window.location.href = '/';
|
||||
}
|
||||
1
frontend/src/lib/index.ts
Normal file
1
frontend/src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
113
frontend/src/routes/+layout.svelte
Normal file
113
frontend/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,113 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { login, logout } from '$lib/auth';
|
||||
|
||||
let { children } = $props();
|
||||
let user = $state<any>(null);
|
||||
let loading = $state(true);
|
||||
|
||||
async function loadUser() {
|
||||
try {
|
||||
const { api } = await import('$lib/api');
|
||||
user = await api.me();
|
||||
} catch {
|
||||
user = null;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadUser();
|
||||
});
|
||||
</script>
|
||||
|
||||
<nav class="navbar">
|
||||
<div class="container nav-inner">
|
||||
<a href="/" class="logo">索引 <span>Sakuin</span></a>
|
||||
|
||||
<div class="nav-links">
|
||||
<a href="/search">Rechercher</a>
|
||||
{#if user}
|
||||
<a href="/list">Ma Liste</a>
|
||||
<a href="/profile">Profil</a>
|
||||
<div class="user-badge">
|
||||
{#if user.avatarUrl}
|
||||
<img src={user.avatarUrl} alt="" class="avatar" />
|
||||
{/if}
|
||||
<span class="username">{user.username}</span>
|
||||
<span class="level badge badge-accent">Lv.{user.level}</span>
|
||||
<button class="btn-ghost btn-sm" onclick={logout}>Déconnexion</button>
|
||||
</div>
|
||||
{:else if !loading}
|
||||
<button class="btn-primary" onclick={login}>Connexion</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="container main-content">
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.navbar {
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0.75rem 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
.nav-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.logo {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.logo span {
|
||||
color: var(--accent);
|
||||
}
|
||||
.nav-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.nav-links a {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.nav-links a:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.user-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.username {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.level {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.main-content {
|
||||
padding-top: 2rem;
|
||||
padding-bottom: 4rem;
|
||||
}
|
||||
</style>
|
||||
90
frontend/src/routes/+page.svelte
Normal file
90
frontend/src/routes/+page.svelte
Normal file
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { login } from '$lib/auth';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Sakuin — Ton index manga & anime</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="hero">
|
||||
<h1>索引 <span class="accent">Sakuin</span></h1>
|
||||
<p class="tagline">Ton catalogue manga & anime — track, collectionne, compare.</p>
|
||||
|
||||
<div class="features">
|
||||
<div class="feature-card">
|
||||
<span class="icon">📚</span>
|
||||
<h3>Track</h3>
|
||||
<p>Suis ta progression épisode par épisode, chapitre par chapitre.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="icon">🏆</span>
|
||||
<h3>Gamifié</h3>
|
||||
<p>Gagne de l'XP, débloque des grades et des titres uniques.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="icon">👥</span>
|
||||
<h3>Compare</h3>
|
||||
<p>Partage ta collection et compare avec tes potes.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cta">
|
||||
<button class="btn-primary btn-lg" onclick={login}>Commencer</button>
|
||||
<a href="/search" class="btn-ghost btn-lg">Explorer</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
text-align: center;
|
||||
padding: 4rem 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.accent {
|
||||
color: var(--accent);
|
||||
}
|
||||
.tagline {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.125rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
.features {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
.feature-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
.feature-card .icon {
|
||||
font-size: 2rem;
|
||||
display: block;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.feature-card h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.feature-card p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.cta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
}
|
||||
.btn-lg {
|
||||
padding: 0.75rem 2rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
</style>
|
||||
44
frontend/src/routes/callback/+page.svelte
Normal file
44
frontend/src/routes/callback/+page.svelte
Normal file
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import { handleCallback } from '$lib/auth';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
|
||||
let error = $state('');
|
||||
|
||||
$effect(() => {
|
||||
const code = page.url.searchParams.get('code');
|
||||
if (!code) {
|
||||
error = 'Code manquant dans la callback.';
|
||||
return;
|
||||
}
|
||||
|
||||
handleCallback(code)
|
||||
.then((tokens) => {
|
||||
document.cookie = `access_token=${tokens.access_token}; path=/; max-age=${tokens.expires_in || 3600}; SameSite=Lax`;
|
||||
goto('/list');
|
||||
})
|
||||
.catch((e) => {
|
||||
error = e.message || 'Erreur de connexion.';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="callback">
|
||||
{#if error}
|
||||
<p class="error">{error}</p>
|
||||
<a href="/">Retour</a>
|
||||
{:else}
|
||||
<p>Connexion en cours...</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.callback {
|
||||
text-align: center;
|
||||
padding: 4rem;
|
||||
}
|
||||
.error {
|
||||
color: var(--danger);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
192
frontend/src/routes/list/+page.svelte
Normal file
192
frontend/src/routes/list/+page.svelte
Normal file
@@ -0,0 +1,192 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
|
||||
let items = $state<any[]>([]);
|
||||
let loading = $state(true);
|
||||
let activeTab = $state('all');
|
||||
|
||||
const tabs = [
|
||||
{ key: 'all', label: 'Tout' },
|
||||
{ key: 'watching', label: 'En cours (anime)' },
|
||||
{ key: 'reading', label: 'En cours (manga)' },
|
||||
{ key: 'completed', label: 'Complétés' },
|
||||
{ key: 'plan_to', label: 'À voir/lire' },
|
||||
{ key: 'dropped', label: 'Abandonnés' },
|
||||
];
|
||||
|
||||
async function loadList() {
|
||||
loading = true;
|
||||
try {
|
||||
const status = activeTab === 'all' ? undefined : activeTab;
|
||||
items = await api.getList(status);
|
||||
} catch {
|
||||
items = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function switchTab(key: string) {
|
||||
activeTab = key;
|
||||
loadList();
|
||||
}
|
||||
|
||||
async function incrementProgress(item: any) {
|
||||
const newProgress = item.progress + 1;
|
||||
await api.updateProgress(item.id, newProgress);
|
||||
item.progress = newProgress;
|
||||
|
||||
const total = item.work?.totalEpisodes || item.work?.totalChapters;
|
||||
if (total && newProgress >= total) {
|
||||
item.status = 'completed';
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(item: any) {
|
||||
if (!confirm(`Retirer ${item.work?.titleRomaji} ?`)) return;
|
||||
await api.removeFromList(item.id);
|
||||
items = items.filter((i) => i.id !== item.id);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Ma Liste — Sakuin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="list-page">
|
||||
<h2>Ma Liste</h2>
|
||||
|
||||
<div class="tabs">
|
||||
{#each tabs as tab}
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === tab.key}
|
||||
onclick={() => switchTab(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="status">Chargement...</p>
|
||||
{:else if items.length === 0}
|
||||
<p class="status">Liste vide. <a href="/search">Chercher des oeuvres</a></p>
|
||||
{:else}
|
||||
<div class="list-grid">
|
||||
{#each items as item}
|
||||
<div class="card list-card" class:gold={item.status === 'completed'}>
|
||||
{#if item.work?.posterUrl}
|
||||
<img src={item.work.posterUrl} alt="" class="poster" />
|
||||
{/if}
|
||||
<div class="card-body">
|
||||
<h3>{item.work?.titleRomaji}</h3>
|
||||
<div class="meta">
|
||||
<span class="badge" class:badge-gold={item.status === 'completed'} class:badge-accent={item.status !== 'completed'}>
|
||||
{item.status}
|
||||
</span>
|
||||
{#if item.score}
|
||||
<span class="score">★ {item.score}/10</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<span>
|
||||
{item.progress} / {item.work?.totalEpisodes || item.work?.totalChapters || '?'}
|
||||
</span>
|
||||
<button class="btn-ghost btn-xs" onclick={() => incrementProgress(item)}>+1</button>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn-ghost btn-xs" onclick={() => remove(item)}>Retirer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.list-page {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
h2 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tab {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.8rem;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.tab.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.list-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.list-card {
|
||||
display: flex;
|
||||
}
|
||||
.list-card.gold {
|
||||
border-color: var(--gold);
|
||||
box-shadow: 0 0 12px rgba(251, 191, 36, 0.15);
|
||||
}
|
||||
.poster {
|
||||
width: 80px;
|
||||
min-height: 110px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.card-body {
|
||||
padding: 0.75rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.card-body h3 {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.score {
|
||||
color: var(--gold);
|
||||
font-weight: 600;
|
||||
}
|
||||
.progress-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.btn-xs {
|
||||
padding: 0.15rem 0.4rem;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
</style>
|
||||
151
frontend/src/routes/profile/+page.svelte
Normal file
151
frontend/src/routes/profile/+page.svelte
Normal file
@@ -0,0 +1,151 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
|
||||
let user = $state<any>(null);
|
||||
let loading = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
api.me()
|
||||
.then((data) => { user = data; })
|
||||
.catch(() => { user = null; })
|
||||
.finally(() => { loading = false; });
|
||||
});
|
||||
|
||||
function xpToNextLevel(xp: number): { current: number; needed: number; pct: number } {
|
||||
const level = Math.floor(xp / 500) + 1;
|
||||
const base = (level - 1) * 500;
|
||||
const current = xp - base;
|
||||
return { current, needed: 500, pct: Math.round((current / 500) * 100) };
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Profil — Sakuin</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if loading}
|
||||
<p>Chargement...</p>
|
||||
{:else if !user}
|
||||
<p>Non connecté.</p>
|
||||
{:else}
|
||||
{@const xp = xpToNextLevel(user.xp)}
|
||||
<div class="profile">
|
||||
<div class="profile-header">
|
||||
{#if user.avatarUrl}
|
||||
<img src={user.avatarUrl} alt="" class="avatar-lg" />
|
||||
{/if}
|
||||
<div>
|
||||
<h2>{user.username}</h2>
|
||||
<div class="level-info">
|
||||
<span class="badge badge-accent">Level {user.level}</span>
|
||||
<span class="xp-text">{user.xp} XP</span>
|
||||
</div>
|
||||
<div class="xp-bar">
|
||||
<div class="xp-fill" style="width: {xp.pct}%"></div>
|
||||
</div>
|
||||
<span class="xp-detail">{xp.current} / {xp.needed} XP</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if user.stats}
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<span class="stat-value">{user.stats.total}</span>
|
||||
<span class="stat-label">Total</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-value">{user.stats.watching || 0}</span>
|
||||
<span class="stat-label">En cours (anime)</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-value">{user.stats.reading || 0}</span>
|
||||
<span class="stat-label">En cours (manga)</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-value">{user.stats.completed || 0}</span>
|
||||
<span class="stat-label">Complétés</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-value">{user.stats.episodesWatched || 0}</span>
|
||||
<span class="stat-label">Épisodes vus</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-value">{user.stats.chaptersRead || 0}</span>
|
||||
<span class="stat-label">Chapitres lus</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.profile {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.profile-header {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.avatar-lg {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid var(--accent);
|
||||
}
|
||||
h2 {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.level-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.xp-text {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.xp-bar {
|
||||
width: 200px;
|
||||
height: 6px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.xp-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
.xp-detail {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
207
frontend/src/routes/search/+page.svelte
Normal file
207
frontend/src/routes/search/+page.svelte
Normal file
@@ -0,0 +1,207 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
|
||||
let query = $state('');
|
||||
let typeFilter = $state<string>('');
|
||||
let results = $state<any[]>([]);
|
||||
let loading = $state(false);
|
||||
let total = $state(0);
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
function onInput() {
|
||||
clearTimeout(debounceTimer);
|
||||
if (query.trim().length < 2) {
|
||||
results = [];
|
||||
return;
|
||||
}
|
||||
debounceTimer = setTimeout(() => doSearch(), 400);
|
||||
}
|
||||
|
||||
async function doSearch() {
|
||||
loading = true;
|
||||
try {
|
||||
const res = await api.search(query, typeFilter || undefined);
|
||||
results = res.media;
|
||||
total = res.total;
|
||||
} catch (e) {
|
||||
console.error('Search failed:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addToList(anilistId: number, status: string) {
|
||||
try {
|
||||
await api.addToList(anilistId, status);
|
||||
alert('Ajouté !');
|
||||
} catch (e: any) {
|
||||
if (e.message?.includes('Unauthorized')) {
|
||||
alert('Connecte-toi pour ajouter à ta liste.');
|
||||
} else {
|
||||
alert(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stripHtml(html: string | null): string {
|
||||
if (!html) return '';
|
||||
return html.replace(/<[^>]*>/g, '').slice(0, 200);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Rechercher — Sakuin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="search-page">
|
||||
<h2>Rechercher</h2>
|
||||
|
||||
<div class="search-bar">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="One Piece, Frieren, Berserk..."
|
||||
bind:value={query}
|
||||
oninput={onInput}
|
||||
class="search-input"
|
||||
/>
|
||||
<select bind:value={typeFilter} onchange={doSearch}>
|
||||
<option value="">Tout</option>
|
||||
<option value="ANIME">Anime</option>
|
||||
<option value="MANGA">Manga</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="status">Recherche...</p>
|
||||
{:else if results.length > 0}
|
||||
<p class="status">{total} résultat{total > 1 ? 's' : ''}</p>
|
||||
<div class="results-grid">
|
||||
{#each results as media}
|
||||
<div class="card result-card">
|
||||
{#if media.coverImage?.large}
|
||||
<img src={media.coverImage.large} alt={media.title.romaji} class="poster" />
|
||||
{/if}
|
||||
<div class="card-body">
|
||||
<h3>{media.title.romaji}</h3>
|
||||
{#if media.title.english && media.title.english !== media.title.romaji}
|
||||
<p class="title-en">{media.title.english}</p>
|
||||
{/if}
|
||||
<div class="meta">
|
||||
<span class="badge badge-accent">{media.type}</span>
|
||||
{#if media.episodes}
|
||||
<span>{media.episodes} ép.</span>
|
||||
{/if}
|
||||
{#if media.chapters}
|
||||
<span>{media.chapters} ch.</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="synopsis">{stripHtml(media.description)}</p>
|
||||
<div class="genres">
|
||||
{#each (media.genres || []).slice(0, 3) as genre}
|
||||
<span class="genre-tag">{genre}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn-primary btn-sm" onclick={() => addToList(media.id, media.type === 'ANIME' ? 'watching' : 'reading')}>
|
||||
+ Ma liste
|
||||
</button>
|
||||
<button class="btn-ghost btn-sm" onclick={() => addToList(media.id, 'plan_to')}>
|
||||
À voir
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if query.length >= 2}
|
||||
<p class="status">Aucun résultat.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.search-page {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
h2 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.search-input {
|
||||
flex: 1;
|
||||
font-size: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
.status {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.results-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.result-card {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
.poster {
|
||||
width: 120px;
|
||||
min-height: 170px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.card-body {
|
||||
padding: 1rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.card-body h3 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.title-en {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.synopsis {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
flex: 1;
|
||||
}
|
||||
.genres {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.genre-tag {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-muted);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.btn-sm {
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
13
frontend/static/manifest.json
Normal file
13
frontend/static/manifest.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "Sakuin",
|
||||
"short_name": "Sakuin",
|
||||
"description": "Ton index manga & anime — gamifié, partageable.",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0f0f1a",
|
||||
"theme_color": "#0f0f1a",
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
|
||||
]
|
||||
}
|
||||
3
frontend/static/robots.txt
Normal file
3
frontend/static/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
24
frontend/svelte.config.js
Normal file
24
frontend/svelte.config.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
import { relative, sep } from 'node:path';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
compilerOptions: {
|
||||
// defaults to rune mode for the project, execept for `node_modules`. Can be removed in svelte 6.
|
||||
runes: ({ filename }) => {
|
||||
const relativePath = relative(import.meta.dirname, filename);
|
||||
const pathSegments = relativePath.toLowerCase().split(sep);
|
||||
const isExternalLibrary = pathSegments.includes('node_modules');
|
||||
|
||||
return isExternalLibrary ? undefined : true;
|
||||
}
|
||||
},
|
||||
kit: {
|
||||
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||
adapter: adapter()
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
20
frontend/tsconfig.json
Normal file
20
frontend/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||
}
|
||||
6
frontend/vite.config.ts
Normal file
6
frontend/vite.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()]
|
||||
});
|
||||
Reference in New Issue
Block a user