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

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:
2026-03-25 01:43:32 +01:00
commit f1cff74d83
56 changed files with 9891 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
name: CI/CD — Build & Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build-and-deploy:
name: Build & Deploy
runs-on: vps-runner
steps:
- uses: actions/checkout@v4
# ── Backend ──────────────────────────────────────────────────────────────
- name: Install & build backend
working-directory: backend
run: |
npm ci
npm run build
- name: Deploy backend
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
mkdir -p /var/www/sakuin/backend
rsync -a --delete backend/dist/ /var/www/sakuin/backend/dist/
rsync -a backend/package.json backend/package-lock.json /var/www/sakuin/backend/
cd /var/www/sakuin/backend && npm ci --omit=dev
- name: Restart pm2
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
su - tetardtek-brain -c 'pm2 reload sakuin-backend --update-env'
# ── Frontend ─────────────────────────────────────────────────────────────
- name: Install & build frontend
working-directory: frontend
env:
VITE_API_URL: https://sakuin.tetardtek.com/api
VITE_OAUTH_URL: https://superoauth.tetardtek.com
VITE_OAUTH_CLIENT_ID: sakuin
run: |
npm ci
npm run build
- name: Deploy frontend
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
mkdir -p /var/www/sakuin/frontend/build
rsync -a --delete frontend/build/ /var/www/sakuin/frontend/build/
# ── Smoke test ───────────────────────────────────────────────────────────
- name: Smoke test API
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
sleep 3
curl -sf http://localhost:4002/api/health | grep -q '"ok"'
echo "✅ API health OK"

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
.env
.DS_Store

66
LICENSE Normal file
View File

@@ -0,0 +1,66 @@
Business Source License 1.1
License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
"Business Source License" is a trademark of MariaDB Corporation Ab.
Parameters
Licensor: Tetardtek
Licensed Work: Sakuin
The Licensed Work is (c) 2026 Tetardtek.
Additional Use Grant: You may make production use of the Licensed Work,
provided such use does not include offering the
Licensed Work to third parties as a hosted or
managed service, where the service provides users
with access to any substantial set of the features
or functionality of the Licensed Work.
Change Date: 2028-03-24
Change License: Apache License, Version 2.0
For information about alternative licensing arrangements for the Licensed Work,
please contact: contact@tetardtek.com
Notice
Business Source License 1.1
Terms
The Licensor hereby grants you the right to copy, modify, create derivative
works, redistribute, and make non-production use of the Licensed Work. The
Licensor may make an Additional Use Grant, above, permitting limited production
use.
Effective on the Change Date, or the fourth anniversary of the first publicly
available distribution of a specific version of the Licensed Work under this
License, whichever comes first, the Licensor hereby grants you rights under the
terms of the Change License, and the rights granted in the paragraph above
terminate.
If your use of the Licensed Work does not comply with the requirements currently
in effect as described in this License, you must purchase a commercial license
from the Licensor, its affiliated entities, or authorized resellers, or you
must refrain from using the Licensed Work.
All copies of the original and modified Licensed Work, and derivative works of
the Licensed Work, are subject to this License. This License applies separately
for each version of the Licensed Work and the Change Date may vary for each
version of the Licensed Work released by Licensor.
You must conspicuously display this License on each original or modified copy
of the Licensed Work. If you receive the Licensed Work in original or modified
form from a third party, the terms and conditions set forth in this License
apply to your use of that work.
Any use of the Licensed Work in violation of this License will automatically
terminate your rights under this License for the current and all other versions
of the Licensed Work.
This License does not grant you any right in any trademark or logo of Licensor
or its affiliates (provided that you may use a trademark or logo of Licensor
as expressly required by this License).
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON AN
"AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, EXPRESS
OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND TITLE.

20
backend/.env.example Normal file
View File

@@ -0,0 +1,20 @@
# Sakuin Backend
PORT=4002
# Database
DB_HOST=localhost
DB_PORT=3306
DB_USERNAME=
DB_PASSWORD=
DB_DATABASE=sakuin
# SuperOAuth
SUPEROAUTH_URL=https://superoauth.tetardtek.com
SUPEROAUTH_CLIENT_ID=sakuin
SUPEROAUTH_CLIENT_SECRET=
# AniList (public API — no auth required for search)
ANILIST_API_URL=https://graphql.anilist.co
# Frontend URL (CORS)
FRONTEND_URL=http://localhost:5173

4
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
dist/
node_modules/
.env
*.js.map

8
backend/nest-cli.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

5709
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
backend/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "sakuin-backend",
"version": "0.1.0",
"description": "Sakuin — manga/anime tracker API",
"license": "BUSL-1.1",
"scripts": {
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:prod": "node dist/main",
"typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js",
"migration:generate": "npm run typeorm -- migration:generate src/migrations/$npm_config_name -d src/config/data-source.ts",
"migration:run": "npm run typeorm -- migration:run -d src/config/data-source.ts",
"migration:revert": "npm run typeorm -- migration:revert -d src/config/data-source.ts"
},
"dependencies": {
"@nestjs/common": "^11.1.17",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.1.17",
"@nestjs/platform-express": "^11.1.17",
"@nestjs/typeorm": "^11.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"mysql2": "^3.20.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"typeorm": "^0.3.28"
},
"devDependencies": {
"@nestjs/cli": "^11.0.16",
"@nestjs/schematics": "^11.0.9",
"@types/express": "^5.0.6",
"@types/node": "^25.5.0",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^6.0.2"
}
}

25
backend/src/app.module.ts Normal file
View File

@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { getDatabaseConfig } from './config/database.config';
import { HealthModule } from './health/health.module';
import { AuthModule } from './auth/auth.module';
import { UserModule } from './user/user.module';
import { WorkModule } from './work/work.module';
import { ListModule } from './list/list.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: getDatabaseConfig,
}),
HealthModule,
AuthModule,
UserModule,
WorkModule,
ListModule,
],
})
export class AppModule {}

View File

@@ -0,0 +1,50 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private readonly configService: ConfigService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractToken(request);
if (!token) {
throw new UnauthorizedException('No token provided');
}
const userInfo = await this.introspect(token);
if (!userInfo) {
throw new UnauthorizedException('Invalid token');
}
request.user = userInfo;
return true;
}
private extractToken(request: any): string | null {
const authHeader = request.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
return authHeader.slice(7);
}
return request.cookies?.access_token || null;
}
private async introspect(token: string): Promise<any> {
const url = this.configService.get('SUPEROAUTH_URL');
try {
const res = await fetch(`${url}/api/v1/user/profile`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { AuthGuard } from './auth.guard';
@Module({
providers: [AuthGuard],
exports: [AuthGuard],
})
export class AuthModule {}

View File

@@ -0,0 +1,16 @@
import { DataSource } from 'typeorm';
import { config } from 'dotenv';
config();
export default new DataSource({
type: 'mysql',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '3306', 10),
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE || 'sakuin',
entities: ['src/**/*.entity.ts'],
migrations: ['src/migrations/*.ts'],
synchronize: false,
});

View File

@@ -0,0 +1,17 @@
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import { ConfigService } from '@nestjs/config';
export const getDatabaseConfig = (
configService: ConfigService,
): TypeOrmModuleOptions => ({
type: 'mysql',
host: configService.get('DB_HOST', 'localhost'),
port: configService.get<number>('DB_PORT', 3306),
username: configService.get('DB_USERNAME'),
password: configService.get('DB_PASSWORD'),
database: configService.get('DB_DATABASE', 'sakuin'),
entities: [__dirname + '/../**/*.entity{.ts,.js}'],
migrations: [__dirname + '/../migrations/*{.ts,.js}'],
synchronize: false,
migrationsRun: true,
});

View File

@@ -0,0 +1,9 @@
import { Controller, Get } from '@nestjs/common';
@Controller('api/health')
export class HealthController {
@Get()
check() {
return { status: 'ok', service: 'sakuin-backend' };
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View File

@@ -0,0 +1,105 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
Req,
UseGuards,
ParseIntPipe,
} from '@nestjs/common';
import { ListService } from './list.service';
import { ListStatus } from './user-work.entity';
import { AuthGuard } from '../auth/auth.guard';
import { UserService } from '../user/user.service';
@Controller('api/list')
@UseGuards(AuthGuard)
export class ListController {
constructor(
private readonly listService: ListService,
private readonly userService: UserService,
) {}
@Get()
async getList(@Req() req: any, @Query('status') status?: ListStatus) {
const user = await this.userService.findOrCreate({
id: req.user.id,
nickname: req.user.nickname,
avatar: req.user.avatar,
});
return this.listService.getUserList(user.id, status);
}
@Post()
async addToList(
@Req() req: any,
@Body() body: { anilistId: number; status: ListStatus },
) {
const user = await this.userService.findOrCreate({
id: req.user.id,
nickname: req.user.nickname,
avatar: req.user.avatar,
});
return this.listService.addToList(user.id, body.anilistId, body.status);
}
@Put(':id/progress')
async updateProgress(
@Req() req: any,
@Param('id', ParseIntPipe) id: number,
@Body() body: { progress: number },
) {
const user = await this.userService.findOrCreate({
id: req.user.id,
nickname: req.user.nickname,
avatar: req.user.avatar,
});
return this.listService.updateProgress(user.id, id, body.progress);
}
@Put(':id/status')
async updateStatus(
@Req() req: any,
@Param('id', ParseIntPipe) id: number,
@Body() body: { status: ListStatus },
) {
const user = await this.userService.findOrCreate({
id: req.user.id,
nickname: req.user.nickname,
avatar: req.user.avatar,
});
return this.listService.updateStatus(user.id, id, body.status);
}
@Put(':id/score')
async setScore(
@Req() req: any,
@Param('id', ParseIntPipe) id: number,
@Body() body: { score: number },
) {
const user = await this.userService.findOrCreate({
id: req.user.id,
nickname: req.user.nickname,
avatar: req.user.avatar,
});
return this.listService.setScore(user.id, id, body.score);
}
@Delete(':id')
async remove(
@Req() req: any,
@Param('id', ParseIntPipe) id: number,
) {
const user = await this.userService.findOrCreate({
id: req.user.id,
nickname: req.user.nickname,
avatar: req.user.avatar,
});
await this.listService.removeFromList(user.id, id);
return { deleted: true };
}
}

View File

@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserWork } from './user-work.entity';
import { ListController } from './list.controller';
import { ListService } from './list.service';
import { WorkModule } from '../work/work.module';
import { UserModule } from '../user/user.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [
TypeOrmModule.forFeature([UserWork]),
WorkModule,
UserModule,
AuthModule,
],
controllers: [ListController],
providers: [ListService],
})
export class ListModule {}

View File

@@ -0,0 +1,137 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserWork, ListStatus } from './user-work.entity';
import { WorkService } from '../work/work.service';
import { UserService } from '../user/user.service';
const XP_ADD_WORK = 10;
const XP_PROGRESS = 5;
const XP_COMPLETE = 100;
const XP_SCORE = 15;
@Injectable()
export class ListService {
constructor(
@InjectRepository(UserWork)
private readonly userWorkRepo: Repository<UserWork>,
private readonly workService: WorkService,
private readonly userService: UserService,
) {}
async getUserList(userId: number, status?: ListStatus) {
const where: any = { userId };
if (status) where.status = status;
return this.userWorkRepo.find({
where,
relations: ['work'],
order: { updatedAt: 'DESC' },
});
}
async addToList(
userId: number,
anilistId: number,
status: ListStatus,
): Promise<UserWork> {
const work = await this.workService.findOrCreateFromAniList(anilistId);
const existing = await this.userWorkRepo.findOne({
where: { userId, workId: work.id },
});
if (existing) {
existing.status = status;
return this.userWorkRepo.save(existing);
}
const userWork = this.userWorkRepo.create({
userId,
workId: work.id,
status,
progress: 0,
});
const saved = await this.userWorkRepo.save(userWork);
await this.userService.addXp(userId, XP_ADD_WORK);
return saved;
}
async updateProgress(
userId: number,
userWorkId: number,
progress: number,
): Promise<UserWork> {
const uw = await this.userWorkRepo.findOne({
where: { id: userWorkId, userId },
relations: ['work'],
});
if (!uw) throw new NotFoundException('Entry not found');
const oldProgress = uw.progress;
uw.progress = progress;
const total = uw.work.totalEpisodes || uw.work.totalChapters;
if (total && progress >= total && uw.status !== ListStatus.COMPLETED) {
uw.status = ListStatus.COMPLETED;
uw.completedAt = new Date();
await this.userService.addXp(userId, XP_COMPLETE);
}
const saved = await this.userWorkRepo.save(uw);
if (progress > oldProgress) {
const delta = progress - oldProgress;
await this.userService.addXp(userId, delta * XP_PROGRESS);
}
return saved;
}
async updateStatus(
userId: number,
userWorkId: number,
status: ListStatus,
): Promise<UserWork> {
const uw = await this.userWorkRepo.findOne({
where: { id: userWorkId, userId },
});
if (!uw) throw new NotFoundException('Entry not found');
uw.status = status;
if (status === ListStatus.COMPLETED) {
uw.completedAt = new Date();
await this.userService.addXp(userId, XP_COMPLETE);
}
return this.userWorkRepo.save(uw);
}
async setScore(
userId: number,
userWorkId: number,
score: number,
): Promise<UserWork> {
const uw = await this.userWorkRepo.findOne({
where: { id: userWorkId, userId },
});
if (!uw) throw new NotFoundException('Entry not found');
const hadScore = uw.score !== null;
uw.score = score;
const saved = await this.userWorkRepo.save(uw);
if (!hadScore) {
await this.userService.addXp(userId, XP_SCORE);
}
return saved;
}
async removeFromList(userId: number, userWorkId: number): Promise<void> {
const uw = await this.userWorkRepo.findOne({
where: { id: userWorkId, userId },
});
if (!uw) throw new NotFoundException('Entry not found');
await this.userWorkRepo.remove(uw);
}
}

View File

@@ -0,0 +1,60 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
CreateDateColumn,
UpdateDateColumn,
Unique,
} from 'typeorm';
import { User } from '../user/user.entity';
import { Work } from '../work/work.entity';
export enum ListStatus {
WATCHING = 'watching',
READING = 'reading',
COMPLETED = 'completed',
DROPPED = 'dropped',
PAUSED = 'paused',
PLAN_TO = 'plan_to',
}
@Entity('user_works')
@Unique(['user', 'work'])
export class UserWork {
@PrimaryGeneratedColumn()
id: number;
@ManyToOne(() => User, (u) => u.userWorks, { onDelete: 'CASCADE' })
user: User;
@Column()
userId: number;
@ManyToOne(() => Work, (w) => w.userWorks, { onDelete: 'CASCADE' })
work: Work;
@Column()
workId: number;
@Column({ type: 'enum', enum: ListStatus })
status: ListStatus;
@Column({ default: 0 })
progress: number;
@Column({ type: 'decimal', precision: 3, scale: 1, nullable: true })
score: number;
@Column({ type: 'datetime', nullable: true })
startedAt: Date;
@Column({ type: 'datetime', nullable: true })
completedAt: Date;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

22
backend/src/main.ts Normal file
View File

@@ -0,0 +1,22 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: process.env.FRONTEND_URL || 'http://localhost:5173',
credentials: true,
});
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, transform: true }),
);
const port = process.env.PORT || 4002;
await app.listen(port);
console.log(`Sakuin backend listening on :${port}`);
}
bootstrap();

View File

@@ -0,0 +1,69 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class Init1711360000000 implements MigrationInterface {
name = 'Init1711360000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE users (
id INT NOT NULL AUTO_INCREMENT,
superOauthId VARCHAR(255) NOT NULL,
username VARCHAR(255) NOT NULL,
avatarUrl VARCHAR(512) NULL,
xp INT NOT NULL DEFAULT 0,
level INT NOT NULL DEFAULT 1,
createdAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updatedAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
UNIQUE INDEX IDX_users_superOauthId (superOauthId),
PRIMARY KEY (id)
) ENGINE=InnoDB
`);
await queryRunner.query(`
CREATE TABLE works (
id INT NOT NULL AUTO_INCREMENT,
anilistId INT NOT NULL,
type ENUM('anime', 'manga') NOT NULL,
titleRomaji VARCHAR(512) NOT NULL,
titleEnglish VARCHAR(512) NULL,
titleNative VARCHAR(512) NULL,
posterUrl VARCHAR(1024) NULL,
synopsis TEXT NULL,
totalEpisodes INT NULL,
totalChapters INT NULL,
status ENUM('releasing', 'finished', 'not_yet_released', 'cancelled', 'hiatus') NULL,
genres JSON NULL,
cachedAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
UNIQUE INDEX IDX_works_anilistId (anilistId),
PRIMARY KEY (id)
) ENGINE=InnoDB
`);
await queryRunner.query(`
CREATE TABLE user_works (
id INT NOT NULL AUTO_INCREMENT,
userId INT NOT NULL,
workId INT NOT NULL,
status ENUM('watching', 'reading', 'completed', 'dropped', 'paused', 'plan_to') NOT NULL,
progress INT NOT NULL DEFAULT 0,
score DECIMAL(3,1) NULL,
startedAt DATETIME NULL,
completedAt DATETIME NULL,
createdAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updatedAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
UNIQUE INDEX IDX_user_works_userId_workId (userId, workId),
INDEX IDX_user_works_userId (userId),
INDEX IDX_user_works_workId (workId),
PRIMARY KEY (id),
CONSTRAINT FK_user_works_userId FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT FK_user_works_workId FOREIGN KEY (workId) REFERENCES works(id) ON DELETE CASCADE
) ENGINE=InnoDB
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TABLE IF EXISTS user_works');
await queryRunner.query('DROP TABLE IF EXISTS works');
await queryRunner.query('DROP TABLE IF EXISTS users');
}
}

View File

@@ -0,0 +1,43 @@
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { UserService } from './user.service';
import { AuthGuard } from '../auth/auth.guard';
@Controller('api/user')
export class UserController {
constructor(private readonly userService: UserService) {}
@Get('me')
@UseGuards(AuthGuard)
async me(@Req() req: any) {
const user = await this.userService.findOrCreate({
id: req.user.id,
nickname: req.user.nickname,
avatar: req.user.avatar,
});
const profile = await this.userService.getProfile(user.id);
const stats = this.computeStats(profile);
return { ...profile, stats };
}
private computeStats(profile: any) {
if (!profile?.userWorks) return { total: 0, watching: 0, reading: 0, completed: 0 };
const works = profile.userWorks;
return {
total: works.length,
watching: works.filter((w: any) => w.status === 'watching').length,
reading: works.filter((w: any) => w.status === 'reading').length,
completed: works.filter((w: any) => w.status === 'completed').length,
dropped: works.filter((w: any) => w.status === 'dropped').length,
planTo: works.filter((w: any) => w.status === 'plan_to').length,
episodesWatched: works
.filter((w: any) => w.work?.type === 'anime')
.reduce((sum: number, w: any) => sum + w.progress, 0),
chaptersRead: works
.filter((w: any) => w.work?.type === 'manga')
.reduce((sum: number, w: any) => sum + w.progress, 0),
};
}
}

View File

@@ -0,0 +1,39 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from 'typeorm';
import { UserWork } from '../list/user-work.entity';
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
superOauthId: string;
@Column()
username: string;
@Column({ nullable: true })
avatarUrl: string;
@Column({ default: 0 })
xp: number;
@Column({ default: 1 })
level: number;
@OneToMany(() => UserWork, (uw) => uw.user)
userWorks: UserWork[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './user.entity';
import { UserController } from './user.controller';
import { UserService } from './user.service';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [TypeOrmModule.forFeature([User]), AuthModule],
controllers: [UserController],
providers: [UserService],
exports: [UserService],
})
export class UserModule {}

View File

@@ -0,0 +1,61 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private readonly userRepo: Repository<User>,
) {}
async findOrCreate(oauthProfile: {
id: string;
nickname: string;
avatar?: string;
}): Promise<User> {
let user = await this.userRepo.findOne({
where: { superOauthId: oauthProfile.id },
});
if (!user) {
user = this.userRepo.create({
superOauthId: oauthProfile.id,
username: oauthProfile.nickname,
avatarUrl: oauthProfile.avatar,
});
return this.userRepo.save(user);
}
if (
user.username !== oauthProfile.nickname ||
user.avatarUrl !== oauthProfile.avatar
) {
user.username = oauthProfile.nickname;
user.avatarUrl = oauthProfile.avatar || user.avatarUrl;
return this.userRepo.save(user);
}
return user;
}
async getProfile(userId: number) {
return this.userRepo.findOne({
where: { id: userId },
relations: ['userWorks', 'userWorks.work'],
});
}
async addXp(userId: number, amount: number): Promise<User> {
const user = await this.userRepo.findOneByOrFail({ id: userId });
user.xp += amount;
user.level = this.calculateLevel(user.xp);
return this.userRepo.save(user);
}
private calculateLevel(xp: number): number {
// Level up every 500 XP — simple for now, tuneable later
return Math.floor(xp / 500) + 1;
}
}

View File

@@ -0,0 +1,115 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
export interface AniListMedia {
id: number;
type: 'ANIME' | 'MANGA';
title: { romaji: string; english: string | null; native: string | null };
coverImage: { large: string } | null;
description: string | null;
episodes: number | null;
chapters: number | null;
status: string | null;
genres: string[];
}
interface AniListSearchResult {
data: {
Page: {
media: AniListMedia[];
pageInfo: { total: number; hasNextPage: boolean };
};
};
}
const SEARCH_QUERY = `
query ($search: String, $type: MediaType, $page: Int, $perPage: Int) {
Page(page: $page, perPage: $perPage) {
pageInfo { total hasNextPage }
media(search: $search, type: $type, sort: POPULARITY_DESC) {
id type
title { romaji english native }
coverImage { large }
description
episodes chapters
status
genres
}
}
}`;
const GET_BY_ID_QUERY = `
query ($id: Int) {
Media(id: $id) {
id type
title { romaji english native }
coverImage { large }
description
episodes chapters
status
genres
}
}`;
@Injectable()
export class AniListService {
private readonly logger = new Logger(AniListService.name);
private readonly apiUrl: string;
constructor(private readonly configService: ConfigService) {
this.apiUrl = this.configService.get(
'ANILIST_API_URL',
'https://graphql.anilist.co',
);
}
async search(
query: string,
type?: 'ANIME' | 'MANGA',
page = 1,
perPage = 20,
): Promise<{ media: AniListMedia[]; total: number; hasNextPage: boolean }> {
const variables: Record<string, any> = { search: query, page, perPage };
if (type) variables.type = type;
const result = await this.request<AniListSearchResult>(
SEARCH_QUERY,
variables,
);
const pageData = result.data.Page;
return {
media: pageData.media,
total: pageData.pageInfo.total,
hasNextPage: pageData.pageInfo.hasNextPage,
};
}
async getById(id: number): Promise<AniListMedia | null> {
try {
const result = await this.request<{ data: { Media: AniListMedia } }>(
GET_BY_ID_QUERY,
{ id },
);
return result.data.Media;
} catch {
return null;
}
}
private async request<T>(query: string, variables: Record<string, any>): Promise<T> {
const res = await fetch(this.apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
});
if (!res.ok) {
const body = await res.text();
this.logger.error(`AniList API error ${res.status}: ${body}`);
throw new Error(`AniList API error: ${res.status}`);
}
return res.json() as Promise<T>;
}
}

View File

@@ -0,0 +1,19 @@
import { Controller, Get, Query } from '@nestjs/common';
import { WorkService } from './work.service';
@Controller('api/works')
export class WorkController {
constructor(private readonly workService: WorkService) {}
@Get('search')
async search(
@Query('q') query: string,
@Query('type') type?: 'ANIME' | 'MANGA',
@Query('page') page?: string,
) {
if (!query || query.trim().length < 2) {
return { media: [], total: 0, hasNextPage: false };
}
return this.workService.search(query.trim(), type, page ? parseInt(page, 10) : 1);
}
}

View File

@@ -0,0 +1,66 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
OneToMany,
} from 'typeorm';
import { UserWork } from '../list/user-work.entity';
export enum WorkType {
ANIME = 'anime',
MANGA = 'manga',
}
export enum WorkStatus {
RELEASING = 'releasing',
FINISHED = 'finished',
NOT_YET_RELEASED = 'not_yet_released',
CANCELLED = 'cancelled',
HIATUS = 'hiatus',
}
@Entity('works')
export class Work {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
anilistId: number;
@Column({ type: 'enum', enum: WorkType })
type: WorkType;
@Column()
titleRomaji: string;
@Column({ nullable: true })
titleEnglish: string;
@Column({ nullable: true })
titleNative: string;
@Column({ nullable: true })
posterUrl: string;
@Column({ type: 'text', nullable: true })
synopsis: string;
@Column({ nullable: true })
totalEpisodes: number;
@Column({ nullable: true })
totalChapters: number;
@Column({ type: 'enum', enum: WorkStatus, nullable: true })
status: WorkStatus;
@Column({ type: 'json', nullable: true })
genres: string[];
@OneToMany(() => UserWork, (uw) => uw.work)
userWorks: UserWork[];
@CreateDateColumn()
cachedAt: Date;
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Work } from './work.entity';
import { WorkController } from './work.controller';
import { WorkService } from './work.service';
import { AniListService } from './anilist.service';
@Module({
imports: [TypeOrmModule.forFeature([Work])],
controllers: [WorkController],
providers: [WorkService, AniListService],
exports: [WorkService],
})
export class WorkModule {}

View File

@@ -0,0 +1,53 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Work, WorkType, WorkStatus } from './work.entity';
import { AniListService } from './anilist.service';
@Injectable()
export class WorkService {
constructor(
@InjectRepository(Work)
private readonly workRepo: Repository<Work>,
private readonly anilist: AniListService,
) {}
async search(query: string, type?: 'ANIME' | 'MANGA', page = 1) {
return this.anilist.search(query, type, page);
}
async findOrCreateFromAniList(anilistId: number): Promise<Work> {
const existing = await this.workRepo.findOne({ where: { anilistId } });
if (existing) return existing;
const media = await this.anilist.getById(anilistId);
if (!media) throw new Error(`AniList media ${anilistId} not found`);
const work = this.workRepo.create({
anilistId: media.id,
type: media.type === 'ANIME' ? WorkType.ANIME : WorkType.MANGA,
titleRomaji: media.title.romaji,
titleEnglish: media.title.english ?? undefined,
titleNative: media.title.native ?? undefined,
posterUrl: media.coverImage?.large ?? undefined,
synopsis: media.description ?? undefined,
totalEpisodes: media.episodes ?? undefined,
totalChapters: media.chapters ?? undefined,
status: this.mapStatus(media.status),
genres: media.genres,
} as Partial<Work>);
return this.workRepo.save(work as Work);
}
private mapStatus(status: string | null): WorkStatus | undefined {
const map: Record<string, WorkStatus> = {
RELEASING: WorkStatus.RELEASING,
FINISHED: WorkStatus.FINISHED,
NOT_YET_RELEASED: WorkStatus.NOT_YET_RELEASED,
CANCELLED: WorkStatus.CANCELLED,
HIATUS: WorkStatus.HIATUS,
};
return status ? map[status] : undefined;
}
}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
}

27
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"strictPropertyInitialization": false,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"ignoreDeprecations": "6.0",
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
}
}
}

23
frontend/.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1 @@
engine-strict=true

3
frontend/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

42
frontend/README.md Normal file
View 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

File diff suppressed because it is too large Load Diff

26
frontend/package.json Normal file
View 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
View 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
View 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
View 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
View 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' }),
};

View 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
View 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 = '/';
}

View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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" }
]
}

View File

@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

24
frontend/svelte.config.js Normal file
View 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
View 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
View File

@@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});