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:
60
.gitea/workflows/deploy.yml
Normal file
60
.gitea/workflows/deploy.yml
Normal 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
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.env
|
||||
.DS_Store
|
||||
66
LICENSE
Normal file
66
LICENSE
Normal 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
20
backend/.env.example
Normal 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
4
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
dist/
|
||||
node_modules/
|
||||
.env
|
||||
*.js.map
|
||||
8
backend/nest-cli.json
Normal file
8
backend/nest-cli.json
Normal 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
5709
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
backend/package.json
Normal file
38
backend/package.json
Normal 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
25
backend/src/app.module.ts
Normal 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 {}
|
||||
50
backend/src/auth/auth.guard.ts
Normal file
50
backend/src/auth/auth.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
8
backend/src/auth/auth.module.ts
Normal file
8
backend/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthGuard } from './auth.guard';
|
||||
|
||||
@Module({
|
||||
providers: [AuthGuard],
|
||||
exports: [AuthGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
16
backend/src/config/data-source.ts
Normal file
16
backend/src/config/data-source.ts
Normal 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,
|
||||
});
|
||||
17
backend/src/config/database.config.ts
Normal file
17
backend/src/config/database.config.ts
Normal 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,
|
||||
});
|
||||
9
backend/src/health/health.controller.ts
Normal file
9
backend/src/health/health.controller.ts
Normal 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' };
|
||||
}
|
||||
}
|
||||
7
backend/src/health/health.module.ts
Normal file
7
backend/src/health/health.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
105
backend/src/list/list.controller.ts
Normal file
105
backend/src/list/list.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
20
backend/src/list/list.module.ts
Normal file
20
backend/src/list/list.module.ts
Normal 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 {}
|
||||
137
backend/src/list/list.service.ts
Normal file
137
backend/src/list/list.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
60
backend/src/list/user-work.entity.ts
Normal file
60
backend/src/list/user-work.entity.ts
Normal 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
22
backend/src/main.ts
Normal 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();
|
||||
69
backend/src/migrations/1711360000000-Init.ts
Normal file
69
backend/src/migrations/1711360000000-Init.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
43
backend/src/user/user.controller.ts
Normal file
43
backend/src/user/user.controller.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
39
backend/src/user/user.entity.ts
Normal file
39
backend/src/user/user.entity.ts
Normal 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;
|
||||
}
|
||||
14
backend/src/user/user.module.ts
Normal file
14
backend/src/user/user.module.ts
Normal 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 {}
|
||||
61
backend/src/user/user.service.ts
Normal file
61
backend/src/user/user.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
115
backend/src/work/anilist.service.ts
Normal file
115
backend/src/work/anilist.service.ts
Normal 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>;
|
||||
}
|
||||
}
|
||||
19
backend/src/work/work.controller.ts
Normal file
19
backend/src/work/work.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
66
backend/src/work/work.entity.ts
Normal file
66
backend/src/work/work.entity.ts
Normal 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;
|
||||
}
|
||||
14
backend/src/work/work.module.ts
Normal file
14
backend/src/work/work.module.ts
Normal 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 {}
|
||||
53
backend/src/work/work.service.ts
Normal file
53
backend/src/work/work.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
4
backend/tsconfig.build.json
Normal file
4
backend/tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
|
||||
}
|
||||
27
backend/tsconfig.json
Normal file
27
backend/tsconfig.json
Normal 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
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