mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 22:39:41 +02:00
Merge pull request #28 from Memo-2023/claude/skill-tree-app-planning-CO4xH
Add SkillTree app with backend API and web frontend
This commit is contained in:
commit
5e92a52b8e
62 changed files with 4446 additions and 1 deletions
82
.github/workflows/ci.yml
vendored
82
.github/workflows/ci.yml
vendored
|
|
@ -68,6 +68,8 @@ jobs:
|
|||
telegram-stats-bot: ${{ steps.changes.outputs.telegram-stats-bot }}
|
||||
nutriphi-backend: ${{ steps.changes.outputs.nutriphi-backend }}
|
||||
nutriphi-web: ${{ steps.changes.outputs.nutriphi-web }}
|
||||
skilltree-backend: ${{ steps.changes.outputs.skilltree-backend }}
|
||||
skilltree-web: ${{ steps.changes.outputs.skilltree-web }}
|
||||
any-changes: ${{ steps.changes.outputs.any-changes }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
|
@ -100,6 +102,8 @@ jobs:
|
|||
echo "telegram-stats-bot=true" >> $GITHUB_OUTPUT
|
||||
echo "nutriphi-backend=true" >> $GITHUB_OUTPUT
|
||||
echo "nutriphi-web=true" >> $GITHUB_OUTPUT
|
||||
echo "skilltree-backend=true" >> $GITHUB_OUTPUT
|
||||
echo "skilltree-web=true" >> $GITHUB_OUTPUT
|
||||
echo "any-changes=true" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
|
@ -136,6 +140,8 @@ jobs:
|
|||
echo "telegram-stats-bot=true" >> $GITHUB_OUTPUT
|
||||
echo "nutriphi-backend=true" >> $GITHUB_OUTPUT
|
||||
echo "nutriphi-web=true" >> $GITHUB_OUTPUT
|
||||
echo "skilltree-backend=true" >> $GITHUB_OUTPUT
|
||||
echo "skilltree-web=true" >> $GITHUB_OUTPUT
|
||||
echo "any-changes=true" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
|
@ -319,6 +325,22 @@ jobs:
|
|||
echo "nutriphi-web=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# skilltree-backend
|
||||
SKILLTREE_BACKEND_CHANGED=$(check_pattern "apps/skilltree/apps/backend/|apps/skilltree/packages/")
|
||||
if [ "$COMMON_CHANGED" == "true" ] || [ "$SHARED_AUTH_CHANGED" == "true" ] || [ "$SKILLTREE_BACKEND_CHANGED" == "true" ]; then
|
||||
echo "skilltree-backend=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "skilltree-backend=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# skilltree-web
|
||||
SKILLTREE_WEB_CHANGED=$(check_pattern "apps/skilltree/apps/web/|apps/skilltree/packages/")
|
||||
if [ "$COMMON_CHANGED" == "true" ] || [ "$SHARED_AUTH_CHANGED" == "true" ] || [ "$SHARED_UI_CHANGED" == "true" ] || [ "$SHARED_WEB_CHANGED" == "true" ] || [ "$SKILLTREE_WEB_CHANGED" == "true" ]; then
|
||||
echo "skilltree-web=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "skilltree-web=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# Check if any service needs building
|
||||
if grep -q "=true" $GITHUB_OUTPUT; then
|
||||
echo "any-changes=true" >> $GITHUB_OUTPUT
|
||||
|
|
@ -351,6 +373,8 @@ jobs:
|
|||
echo "| telegram-stats-bot | ${{ steps.changes.outputs.telegram-stats-bot }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| nutriphi-backend | ${{ steps.changes.outputs.nutriphi-backend }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| nutriphi-web | ${{ steps.changes.outputs.nutriphi-web }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| skilltree-backend | ${{ steps.changes.outputs.skilltree-backend }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| skilltree-web | ${{ steps.changes.outputs.skilltree-web }} |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ===========================================
|
||||
# Validation job - runs on PRs
|
||||
|
|
@ -940,3 +964,61 @@ jobs:
|
|||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
build-skilltree-backend:
|
||||
name: Build skilltree-backend
|
||||
runs-on: ubuntu-latest
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.skilltree-backend == 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/metadata-action@v5
|
||||
id: meta
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/skilltree-backend
|
||||
tags: type=raw,value=latest
|
||||
- uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: apps/skilltree/apps/backend/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
build-skilltree-web:
|
||||
name: Build skilltree-web
|
||||
runs-on: ubuntu-latest
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.skilltree-web == 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/metadata-action@v5
|
||||
id: meta
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/skilltree-web
|
||||
tags: type=raw,value=latest
|
||||
- uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: apps/skilltree/apps/web/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
|
|
|||
104
apps/skilltree/CLAUDE.md
Normal file
104
apps/skilltree/CLAUDE.md
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
# SkillTree
|
||||
|
||||
Gamified personal skill tracking app - like an RPG skill tree for real life.
|
||||
|
||||
## Overview
|
||||
|
||||
Track your skills, earn XP through activities, and level up your abilities across different life domains.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Web**: SvelteKit + Svelte 5 + Tailwind CSS
|
||||
- **Storage**: IndexedDB (offline-first, no backend needed)
|
||||
- **State**: Svelte 5 runes (`$state`, `$derived`)
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Start development server (port 5195)
|
||||
pnpm dev:web
|
||||
|
||||
# Or from monorepo root
|
||||
pnpm --filter @skilltree/web dev
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
apps/skilltree/
|
||||
├── apps/
|
||||
│ └── web/ # SvelteKit web app
|
||||
│ ├── src/
|
||||
│ │ ├── lib/
|
||||
│ │ │ ├── components/ # UI components
|
||||
│ │ │ ├── services/ # IndexedDB storage
|
||||
│ │ │ ├── stores/ # Svelte 5 reactive stores
|
||||
│ │ │ └── types/ # TypeScript types
|
||||
│ │ └── routes/ # SvelteKit routes
|
||||
│ └── static/ # Static assets
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### MVP (Current)
|
||||
|
||||
- [x] Skill creation with name, description, and branch
|
||||
- [x] Six skill branches: Intellect, Body, Creativity, Social, Practical, Mindset
|
||||
- [x] XP system with 6 levels (0-5)
|
||||
- [x] Activity logging with XP rewards
|
||||
- [x] Stats overview (total XP, skills, highest level, streak)
|
||||
- [x] Offline-first with IndexedDB
|
||||
- [x] Branch filtering
|
||||
- [x] Recent activities feed
|
||||
|
||||
### Planned
|
||||
|
||||
- [ ] Skill editing
|
||||
- [ ] Skill tree visualization (graph view)
|
||||
- [ ] Skill dependencies/prerequisites
|
||||
- [ ] Achievements/badges
|
||||
- [ ] Data export/import
|
||||
- [ ] Cloud sync (optional)
|
||||
|
||||
## Data Model
|
||||
|
||||
### Skill
|
||||
```typescript
|
||||
interface Skill {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
branch: SkillBranch;
|
||||
parentId: string | null;
|
||||
icon: string;
|
||||
color: string | null;
|
||||
currentXp: number;
|
||||
totalXp: number;
|
||||
level: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Levels
|
||||
|
||||
| Level | Name | XP Required |
|
||||
|-------|---------------|-------------|
|
||||
| 0 | Unbekannt | 0 |
|
||||
| 1 | Anfänger | 100 |
|
||||
| 2 | Fortgeschritten | 500 |
|
||||
| 3 | Kompetent | 1,500 |
|
||||
| 4 | Experte | 4,000 |
|
||||
| 5 | Meister | 10,000 |
|
||||
|
||||
## Branches
|
||||
|
||||
| Branch | Icon | Color | Description |
|
||||
|------------|-----------|---------|--------------------------------|
|
||||
| Intellect | brain | blue | Knowledge, languages, science |
|
||||
| Body | dumbbell | red | Fitness, sports, health |
|
||||
| Creativity | palette | pink | Art, music, writing |
|
||||
| Social | users | purple | Communication, leadership |
|
||||
| Practical | wrench | orange | Crafts, cooking, tech |
|
||||
| Mindset | heart | emerald | Meditation, focus, resilience |
|
||||
67
apps/skilltree/apps/backend/Dockerfile
Normal file
67
apps/skilltree/apps/backend/Dockerfile
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy root workspace files
|
||||
COPY pnpm-workspace.yaml ./
|
||||
COPY package.json ./
|
||||
COPY pnpm-lock.yaml ./
|
||||
|
||||
# Copy shared packages
|
||||
COPY packages/shared-errors ./packages/shared-errors
|
||||
COPY packages/shared-nestjs-auth ./packages/shared-nestjs-auth
|
||||
|
||||
# Copy skilltree backend
|
||||
COPY apps/skilltree/apps/backend ./apps/skilltree/apps/backend
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Build shared packages first
|
||||
WORKDIR /app/packages/shared-errors
|
||||
RUN pnpm build
|
||||
|
||||
WORKDIR /app/packages/shared-nestjs-auth
|
||||
RUN pnpm build
|
||||
|
||||
# Build the backend
|
||||
WORKDIR /app/apps/skilltree/apps/backend
|
||||
RUN pnpm build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
# Install pnpm and postgresql-client for health checks
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate \
|
||||
&& apk add --no-cache postgresql-client
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy everything from builder (including node_modules)
|
||||
COPY --from=builder /app/pnpm-workspace.yaml ./
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/pnpm-lock.yaml ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/packages ./packages
|
||||
COPY --from=builder /app/apps/skilltree/apps/backend ./apps/skilltree/apps/backend
|
||||
|
||||
# Copy entrypoint script
|
||||
COPY apps/skilltree/apps/backend/docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
WORKDIR /app/apps/skilltree/apps/backend
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3024
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3024/health || exit 1
|
||||
|
||||
# Run entrypoint script
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["node", "dist/main.js"]
|
||||
31
apps/skilltree/apps/backend/docker-entrypoint.sh
Normal file
31
apps/skilltree/apps/backend/docker-entrypoint.sh
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "Starting SkillTree Backend..."
|
||||
|
||||
# Wait for PostgreSQL to be ready
|
||||
if [ -n "$DATABASE_URL" ]; then
|
||||
echo "Waiting for PostgreSQL..."
|
||||
|
||||
# Extract host and port from DATABASE_URL
|
||||
DB_HOST=$(echo $DATABASE_URL | sed -n 's/.*@\([^:]*\):.*/\1/p')
|
||||
DB_PORT=$(echo $DATABASE_URL | sed -n 's/.*:\([0-9]*\)\/.*/\1/p')
|
||||
|
||||
# Default to postgres:5432 if extraction fails
|
||||
DB_HOST=${DB_HOST:-postgres}
|
||||
DB_PORT=${DB_PORT:-5432}
|
||||
|
||||
until pg_isready -h "$DB_HOST" -p "$DB_PORT" -U postgres 2>/dev/null; do
|
||||
echo "PostgreSQL is unavailable - sleeping"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "PostgreSQL is ready!"
|
||||
|
||||
# Run database migrations/push
|
||||
echo "Pushing database schema..."
|
||||
pnpm db:push || echo "Schema push completed (may have no changes)"
|
||||
fi
|
||||
|
||||
echo "Starting server..."
|
||||
exec "$@"
|
||||
11
apps/skilltree/apps/backend/drizzle.config.ts
Normal file
11
apps/skilltree/apps/backend/drizzle.config.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import 'dotenv/config';
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/db/schema/index.ts',
|
||||
out: './drizzle',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
});
|
||||
8
apps/skilltree/apps/backend/nest-cli.json
Normal file
8
apps/skilltree/apps/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
|
||||
}
|
||||
}
|
||||
47
apps/skilltree/apps/backend/package.json
Normal file
47
apps/skilltree/apps/backend/package.json
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"name": "@skilltree/backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "SkillTree Backend API",
|
||||
"scripts": {
|
||||
"dev": "nest start --watch",
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:generate": "drizzle-kit generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-nestjs-auth": "workspace:*",
|
||||
"@nestjs/common": "^10.4.9",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.9",
|
||||
"@nestjs/platform-express": "^10.4.9",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-orm": "^0.38.3",
|
||||
"postgres": "^3.4.5",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"prom-client": "^15.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@nestjs/testing": "^11.1.9",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.15.21",
|
||||
"drizzle-kit": "^0.30.2",
|
||||
"jest": "^30.2.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { Controller, Get, Query, Param, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { ActivityService } from './activity.service';
|
||||
|
||||
@Controller('activities')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ActivityController {
|
||||
constructor(private readonly activityService: ActivityService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserData, @Query('limit') limit?: number) {
|
||||
const activities = await this.activityService.findAll(user.userId, limit ?? 50);
|
||||
return { activities };
|
||||
}
|
||||
|
||||
@Get('recent')
|
||||
async getRecent(@CurrentUser() user: CurrentUserData, @Query('limit') limit?: number) {
|
||||
const activities = await this.activityService.getRecent(user.userId, limit ?? 10);
|
||||
return { activities };
|
||||
}
|
||||
|
||||
@Get('skill/:skillId')
|
||||
async findBySkill(@CurrentUser() user: CurrentUserData, @Param('skillId') skillId: string) {
|
||||
const activities = await this.activityService.findBySkill(user.userId, skillId);
|
||||
return { activities };
|
||||
}
|
||||
}
|
||||
10
apps/skilltree/apps/backend/src/activity/activity.module.ts
Normal file
10
apps/skilltree/apps/backend/src/activity/activity.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ActivityController } from './activity.controller';
|
||||
import { ActivityService } from './activity.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ActivityController],
|
||||
providers: [ActivityService],
|
||||
exports: [ActivityService],
|
||||
})
|
||||
export class ActivityModule {}
|
||||
36
apps/skilltree/apps/backend/src/activity/activity.service.ts
Normal file
36
apps/skilltree/apps/backend/src/activity/activity.service.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
import { DATABASE_TOKEN } from '../db/database.module';
|
||||
import { Database } from '../db/connection';
|
||||
import { activities, Activity } from '../db/schema';
|
||||
|
||||
@Injectable()
|
||||
export class ActivityService {
|
||||
constructor(@Inject(DATABASE_TOKEN) private db: Database) {}
|
||||
|
||||
async findAll(userId: string, limit = 50): Promise<Activity[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(activities)
|
||||
.where(eq(activities.userId, userId))
|
||||
.orderBy(desc(activities.timestamp))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
async findBySkill(userId: string, skillId: string): Promise<Activity[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(activities)
|
||||
.where(eq(activities.skillId, skillId))
|
||||
.orderBy(desc(activities.timestamp));
|
||||
}
|
||||
|
||||
async getRecent(userId: string, limit = 10): Promise<Activity[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(activities)
|
||||
.where(eq(activities.userId, userId))
|
||||
.orderBy(desc(activities.timestamp))
|
||||
.limit(limit);
|
||||
}
|
||||
}
|
||||
22
apps/skilltree/apps/backend/src/app.module.ts
Normal file
22
apps/skilltree/apps/backend/src/app.module.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { DatabaseModule } from './db/database.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { MetricsModule } from './metrics';
|
||||
import { SkillModule } from './skill/skill.module';
|
||||
import { ActivityModule } from './activity/activity.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: '.env',
|
||||
}),
|
||||
MetricsModule,
|
||||
DatabaseModule,
|
||||
HealthModule,
|
||||
SkillModule,
|
||||
ActivityModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
38
apps/skilltree/apps/backend/src/db/connection.ts
Normal file
38
apps/skilltree/apps/backend/src/db/connection.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import * as schema from './schema';
|
||||
|
||||
let db: ReturnType<typeof drizzle<typeof schema>> | null = null;
|
||||
let connection: ReturnType<typeof postgres> | null = null;
|
||||
|
||||
export function getDb(connectionString?: string) {
|
||||
if (db) return db;
|
||||
|
||||
const url = connectionString || process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
throw new Error('DATABASE_URL is not defined');
|
||||
}
|
||||
|
||||
connection = postgres(url, {
|
||||
max: 10,
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
});
|
||||
|
||||
db = drizzle(connection, { schema });
|
||||
return db;
|
||||
}
|
||||
|
||||
export function getConnection() {
|
||||
return connection;
|
||||
}
|
||||
|
||||
export async function closeConnection() {
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
connection = null;
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
|
||||
export type Database = ReturnType<typeof getDb>;
|
||||
20
apps/skilltree/apps/backend/src/db/database.module.ts
Normal file
20
apps/skilltree/apps/backend/src/db/database.module.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { Module, Global, OnModuleDestroy } from '@nestjs/common';
|
||||
import { getDb, closeConnection, Database } from './connection';
|
||||
|
||||
export const DATABASE_TOKEN = 'DATABASE';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: DATABASE_TOKEN,
|
||||
useFactory: () => getDb(),
|
||||
},
|
||||
],
|
||||
exports: [DATABASE_TOKEN],
|
||||
})
|
||||
export class DatabaseModule implements OnModuleDestroy {
|
||||
async onModuleDestroy() {
|
||||
await closeConnection();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
timestamp,
|
||||
varchar,
|
||||
text,
|
||||
integer,
|
||||
index,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { skills } from './skills.schema';
|
||||
|
||||
export const activities = pgTable(
|
||||
'activities',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
skillId: uuid('skill_id')
|
||||
.references(() => skills.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
|
||||
// Activity details
|
||||
xpEarned: integer('xp_earned').notNull(),
|
||||
description: varchar('description', { length: 500 }).notNull(),
|
||||
duration: integer('duration'), // in minutes
|
||||
|
||||
// Timestamp
|
||||
timestamp: timestamp('timestamp', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
userIdx: index('activities_user_idx').on(table.userId),
|
||||
skillIdx: index('activities_skill_idx').on(table.skillId),
|
||||
timestampIdx: index('activities_timestamp_idx').on(table.userId, table.timestamp),
|
||||
})
|
||||
);
|
||||
|
||||
export type Activity = typeof activities.$inferSelect;
|
||||
export type NewActivity = typeof activities.$inferInsert;
|
||||
3
apps/skilltree/apps/backend/src/db/schema/index.ts
Normal file
3
apps/skilltree/apps/backend/src/db/schema/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './skills.schema';
|
||||
export * from './activities.schema';
|
||||
export * from './user-stats.schema';
|
||||
52
apps/skilltree/apps/backend/src/db/schema/skills.schema.ts
Normal file
52
apps/skilltree/apps/backend/src/db/schema/skills.schema.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
timestamp,
|
||||
varchar,
|
||||
text,
|
||||
integer,
|
||||
index,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
export type SkillBranch =
|
||||
| 'intellect'
|
||||
| 'body'
|
||||
| 'creativity'
|
||||
| 'social'
|
||||
| 'practical'
|
||||
| 'mindset'
|
||||
| 'custom';
|
||||
|
||||
export const skills = pgTable(
|
||||
'skills',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
|
||||
// Content
|
||||
name: varchar('name', { length: 200 }).notNull(),
|
||||
description: text('description'),
|
||||
branch: varchar('branch', { length: 20 }).notNull().$type<SkillBranch>(),
|
||||
parentId: uuid('parent_id'),
|
||||
icon: varchar('icon', { length: 50 }).default('star'),
|
||||
color: varchar('color', { length: 20 }),
|
||||
|
||||
// Progress
|
||||
currentXp: integer('current_xp').default(0).notNull(),
|
||||
totalXp: integer('total_xp').default(0).notNull(),
|
||||
level: integer('level').default(0).notNull(),
|
||||
|
||||
// Timestamps
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
userIdx: index('skills_user_idx').on(table.userId),
|
||||
branchIdx: index('skills_branch_idx').on(table.userId, table.branch),
|
||||
parentIdx: index('skills_parent_idx').on(table.parentId),
|
||||
levelIdx: index('skills_level_idx').on(table.userId, table.level),
|
||||
})
|
||||
);
|
||||
|
||||
export type Skill = typeof skills.$inferSelect;
|
||||
export type NewSkill = typeof skills.$inferInsert;
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
timestamp,
|
||||
text,
|
||||
integer,
|
||||
date,
|
||||
index,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
export const userStats = pgTable(
|
||||
'user_stats',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull().unique(),
|
||||
|
||||
// Aggregated stats
|
||||
totalXp: integer('total_xp').default(0).notNull(),
|
||||
totalSkills: integer('total_skills').default(0).notNull(),
|
||||
highestLevel: integer('highest_level').default(0).notNull(),
|
||||
streakDays: integer('streak_days').default(0).notNull(),
|
||||
lastActivityDate: date('last_activity_date'),
|
||||
|
||||
// Timestamps
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
userIdx: index('user_stats_user_idx').on(table.userId),
|
||||
})
|
||||
);
|
||||
|
||||
export type UserStat = typeof userStats.$inferSelect;
|
||||
export type NewUserStat = typeof userStats.$inferInsert;
|
||||
13
apps/skilltree/apps/backend/src/health/health.controller.ts
Normal file
13
apps/skilltree/apps/backend/src/health/health.controller.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'skilltree-backend',
|
||||
};
|
||||
}
|
||||
}
|
||||
7
apps/skilltree/apps/backend/src/health/health.module.ts
Normal file
7
apps/skilltree/apps/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 {}
|
||||
102
apps/skilltree/apps/backend/src/main.ts
Normal file
102
apps/skilltree/apps/backend/src/main.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe, Logger } from '@nestjs/common';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { AppModule } from './app.module';
|
||||
import { MetricsService } from './metrics/metrics.service';
|
||||
|
||||
// Normalize route paths to prevent high cardinality
|
||||
function normalizeRoute(path: string): string {
|
||||
return path
|
||||
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ':id')
|
||||
.replace(/\/\d+/g, '/:id');
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new Logger('Bootstrap');
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// Get MetricsService for request tracking
|
||||
const metricsService = app.get(MetricsService);
|
||||
|
||||
// Global Express middleware to track ALL HTTP requests
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.path === '/metrics') {
|
||||
return next();
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const method = req.method;
|
||||
const route = normalizeRoute(req.path);
|
||||
|
||||
res.once('finish', () => {
|
||||
const duration = (Date.now() - startTime) / 1000;
|
||||
metricsService.httpRequestsTotal.inc({
|
||||
method,
|
||||
route,
|
||||
status: res.statusCode.toString(),
|
||||
});
|
||||
metricsService.httpRequestDuration.observe(
|
||||
{ method, route, status: res.statusCode.toString() },
|
||||
duration
|
||||
);
|
||||
});
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// Enable CORS
|
||||
app.enableCors({
|
||||
origin: (origin, callback) => {
|
||||
if (!origin) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedOrigins = process.env.CORS_ORIGINS?.split(',') || [
|
||||
'http://localhost:5173',
|
||||
'http://localhost:5195',
|
||||
'http://localhost:8081',
|
||||
];
|
||||
|
||||
if (process.env.NODE_ENV === 'development' && origin.includes('localhost')) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
logger.warn(`Blocked request from origin: ${origin}`);
|
||||
callback(new Error('Not allowed by CORS'), false);
|
||||
}
|
||||
},
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||
});
|
||||
|
||||
// Global validation pipe
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
transformOptions: {
|
||||
enableImplicitConversion: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// API prefix
|
||||
app.setGlobalPrefix('api/v1', {
|
||||
exclude: ['metrics', 'health'],
|
||||
});
|
||||
|
||||
const port = process.env.PORT || 3024;
|
||||
await app.listen(port);
|
||||
|
||||
logger.log(`SkillTree API is running on: http://localhost:${port}`);
|
||||
logger.log(`Health check: http://localhost:${port}/health`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
3
apps/skilltree/apps/backend/src/metrics/index.ts
Normal file
3
apps/skilltree/apps/backend/src/metrics/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './metrics.module';
|
||||
export * from './metrics.service';
|
||||
export * from './metrics.controller';
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { Controller, Get, Header } from '@nestjs/common';
|
||||
import { MetricsService } from './metrics.service';
|
||||
|
||||
@Controller('metrics')
|
||||
export class MetricsController {
|
||||
constructor(private metricsService: MetricsService) {}
|
||||
|
||||
@Get()
|
||||
@Header('Content-Type', 'text/plain')
|
||||
async getMetrics(): Promise<string> {
|
||||
return this.metricsService.getMetrics();
|
||||
}
|
||||
}
|
||||
11
apps/skilltree/apps/backend/src/metrics/metrics.module.ts
Normal file
11
apps/skilltree/apps/backend/src/metrics/metrics.module.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Module, Global } from '@nestjs/common';
|
||||
import { MetricsController } from './metrics.controller';
|
||||
import { MetricsService } from './metrics.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
controllers: [MetricsController],
|
||||
providers: [MetricsService],
|
||||
exports: [MetricsService],
|
||||
})
|
||||
export class MetricsModule {}
|
||||
37
apps/skilltree/apps/backend/src/metrics/metrics.service.ts
Normal file
37
apps/skilltree/apps/backend/src/metrics/metrics.service.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { collectDefaultMetrics, Counter, Histogram, Registry } from 'prom-client';
|
||||
|
||||
@Injectable()
|
||||
export class MetricsService implements OnModuleInit {
|
||||
private registry: Registry;
|
||||
|
||||
public httpRequestsTotal: Counter;
|
||||
public httpRequestDuration: Histogram;
|
||||
|
||||
constructor() {
|
||||
this.registry = new Registry();
|
||||
|
||||
this.httpRequestsTotal = new Counter({
|
||||
name: 'http_requests_total',
|
||||
help: 'Total number of HTTP requests',
|
||||
labelNames: ['method', 'route', 'status'],
|
||||
registers: [this.registry],
|
||||
});
|
||||
|
||||
this.httpRequestDuration = new Histogram({
|
||||
name: 'http_request_duration_seconds',
|
||||
help: 'Duration of HTTP requests in seconds',
|
||||
labelNames: ['method', 'route', 'status'],
|
||||
buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5],
|
||||
registers: [this.registry],
|
||||
});
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
collectDefaultMetrics({ register: this.registry });
|
||||
}
|
||||
|
||||
async getMetrics(): Promise<string> {
|
||||
return this.registry.metrics();
|
||||
}
|
||||
}
|
||||
17
apps/skilltree/apps/backend/src/skill/dto/add-xp.dto.ts
Normal file
17
apps/skilltree/apps/backend/src/skill/dto/add-xp.dto.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { IsString, IsNumber, IsOptional, Min, Max, MaxLength } from 'class-validator';
|
||||
|
||||
export class AddXpDto {
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(10000)
|
||||
xp: number;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
description: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
duration?: number; // in minutes
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { IsString, IsOptional, IsIn, MaxLength, IsUUID } from 'class-validator';
|
||||
import type { SkillBranch } from '../../db/schema';
|
||||
|
||||
const BRANCHES = ['intellect', 'body', 'creativity', 'social', 'practical', 'mindset', 'custom'] as const;
|
||||
|
||||
export class CreateSkillDto {
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
description?: string;
|
||||
|
||||
@IsIn(BRANCHES)
|
||||
branch: SkillBranch;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
parentId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
icon?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
color?: string;
|
||||
}
|
||||
3
apps/skilltree/apps/backend/src/skill/dto/index.ts
Normal file
3
apps/skilltree/apps/backend/src/skill/dto/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './create-skill.dto';
|
||||
export * from './update-skill.dto';
|
||||
export * from './add-xp.dto';
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { IsString, IsOptional, IsIn, MaxLength, IsUUID } from 'class-validator';
|
||||
import type { SkillBranch } from '../../db/schema';
|
||||
|
||||
const BRANCHES = ['intellect', 'body', 'creativity', 'social', 'practical', 'mindset', 'custom'] as const;
|
||||
|
||||
export class UpdateSkillDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(BRANCHES)
|
||||
branch?: SkillBranch;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
parentId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
icon?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
color?: string;
|
||||
}
|
||||
64
apps/skilltree/apps/backend/src/skill/skill.controller.ts
Normal file
64
apps/skilltree/apps/backend/src/skill/skill.controller.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { SkillService } from './skill.service';
|
||||
import { CreateSkillDto, UpdateSkillDto, AddXpDto } from './dto';
|
||||
|
||||
@Controller('skills')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class SkillController {
|
||||
constructor(private readonly skillService: SkillService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserData, @Query('branch') branch?: string) {
|
||||
if (branch) {
|
||||
const skills = await this.skillService.findByBranch(user.userId, branch);
|
||||
return { skills };
|
||||
}
|
||||
const skills = await this.skillService.findAll(user.userId);
|
||||
return { skills };
|
||||
}
|
||||
|
||||
@Get('stats')
|
||||
async getStats(@CurrentUser() user: CurrentUserData) {
|
||||
const stats = await this.skillService.getUserStats(user.userId);
|
||||
return { stats };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
const skill = await this.skillService.findByIdOrThrow(id, user.userId);
|
||||
return { skill };
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateSkillDto) {
|
||||
const skill = await this.skillService.create(user.userId, dto);
|
||||
return { skill };
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateSkillDto
|
||||
) {
|
||||
const skill = await this.skillService.update(id, user.userId, dto);
|
||||
return { skill };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
await this.skillService.delete(id, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post(':id/xp')
|
||||
async addXp(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: AddXpDto
|
||||
) {
|
||||
const result = await this.skillService.addXp(id, user.userId, dto);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
10
apps/skilltree/apps/backend/src/skill/skill.module.ts
Normal file
10
apps/skilltree/apps/backend/src/skill/skill.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { SkillController } from './skill.controller';
|
||||
import { SkillService } from './skill.service';
|
||||
|
||||
@Module({
|
||||
controllers: [SkillController],
|
||||
providers: [SkillService],
|
||||
exports: [SkillService],
|
||||
})
|
||||
export class SkillModule {}
|
||||
238
apps/skilltree/apps/backend/src/skill/skill.service.ts
Normal file
238
apps/skilltree/apps/backend/src/skill/skill.service.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { DATABASE_TOKEN } from '../db/database.module';
|
||||
import { Database } from '../db/connection';
|
||||
import { skills, activities, userStats, Skill, NewSkill } from '../db/schema';
|
||||
import { CreateSkillDto, UpdateSkillDto, AddXpDto } from './dto';
|
||||
|
||||
// Level thresholds
|
||||
const LEVEL_THRESHOLDS = [0, 100, 500, 1500, 4000, 10000];
|
||||
|
||||
function calculateLevel(xp: number): number {
|
||||
for (let i = LEVEL_THRESHOLDS.length - 1; i >= 0; i--) {
|
||||
if (xp >= LEVEL_THRESHOLDS[i]) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SkillService {
|
||||
constructor(@Inject(DATABASE_TOKEN) private db: Database) {}
|
||||
|
||||
async findAll(userId: string): Promise<Skill[]> {
|
||||
return this.db.select().from(skills).where(eq(skills.userId, userId)).orderBy(desc(skills.totalXp));
|
||||
}
|
||||
|
||||
async findByBranch(userId: string, branch: string): Promise<Skill[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(skills)
|
||||
.where(and(eq(skills.userId, userId), eq(skills.branch, branch as any)))
|
||||
.orderBy(desc(skills.totalXp));
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string): Promise<Skill | null> {
|
||||
const [skill] = await this.db
|
||||
.select()
|
||||
.from(skills)
|
||||
.where(and(eq(skills.id, id), eq(skills.userId, userId)));
|
||||
return skill ?? null;
|
||||
}
|
||||
|
||||
async findByIdOrThrow(id: string, userId: string): Promise<Skill> {
|
||||
const skill = await this.findById(id, userId);
|
||||
if (!skill) {
|
||||
throw new NotFoundException(`Skill with id ${id} not found`);
|
||||
}
|
||||
return skill;
|
||||
}
|
||||
|
||||
async create(userId: string, dto: CreateSkillDto): Promise<Skill> {
|
||||
const newSkill: NewSkill = {
|
||||
userId,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
branch: dto.branch,
|
||||
parentId: dto.parentId,
|
||||
icon: dto.icon ?? 'star',
|
||||
color: dto.color,
|
||||
currentXp: 0,
|
||||
totalXp: 0,
|
||||
level: 0,
|
||||
};
|
||||
|
||||
const [skill] = await this.db.insert(skills).values(newSkill).returning();
|
||||
|
||||
// Update user stats
|
||||
await this.updateUserStats(userId);
|
||||
|
||||
return skill;
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, dto: UpdateSkillDto): Promise<Skill> {
|
||||
await this.findByIdOrThrow(id, userId);
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(skills)
|
||||
.set({
|
||||
...dto,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(skills.id, id), eq(skills.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
await this.findByIdOrThrow(id, userId);
|
||||
|
||||
await this.db.delete(skills).where(and(eq(skills.id, id), eq(skills.userId, userId)));
|
||||
|
||||
// Update user stats
|
||||
await this.updateUserStats(userId);
|
||||
}
|
||||
|
||||
async addXp(
|
||||
id: string,
|
||||
userId: string,
|
||||
dto: AddXpDto
|
||||
): Promise<{ skill: Skill; leveledUp: boolean; newLevel: number }> {
|
||||
const skill = await this.findByIdOrThrow(id, userId);
|
||||
|
||||
const newTotalXp = skill.totalXp + dto.xp;
|
||||
const newCurrentXp = skill.currentXp + dto.xp;
|
||||
const newLevel = calculateLevel(newTotalXp);
|
||||
const leveledUp = newLevel > skill.level;
|
||||
|
||||
// Update skill
|
||||
const [updated] = await this.db
|
||||
.update(skills)
|
||||
.set({
|
||||
totalXp: newTotalXp,
|
||||
currentXp: newCurrentXp,
|
||||
level: newLevel,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(skills.id, id), eq(skills.userId, userId)))
|
||||
.returning();
|
||||
|
||||
// Create activity
|
||||
await this.db.insert(activities).values({
|
||||
userId,
|
||||
skillId: id,
|
||||
xpEarned: dto.xp,
|
||||
description: dto.description,
|
||||
duration: dto.duration,
|
||||
});
|
||||
|
||||
// Update user stats
|
||||
await this.updateUserStats(userId);
|
||||
|
||||
return { skill: updated, leveledUp, newLevel };
|
||||
}
|
||||
|
||||
private async updateUserStats(userId: string): Promise<void> {
|
||||
// Get aggregated stats
|
||||
const userSkills = await this.db.select().from(skills).where(eq(skills.userId, userId));
|
||||
|
||||
const totalXp = userSkills.reduce((sum, s) => sum + s.totalXp, 0);
|
||||
const totalSkills = userSkills.length;
|
||||
const highestLevel = userSkills.reduce((max, s) => Math.max(max, s.level), 0);
|
||||
|
||||
// Get last activity date
|
||||
const [lastActivity] = await this.db
|
||||
.select()
|
||||
.from(activities)
|
||||
.where(eq(activities.userId, userId))
|
||||
.orderBy(desc(activities.timestamp))
|
||||
.limit(1);
|
||||
|
||||
const lastActivityDate = lastActivity?.timestamp
|
||||
? lastActivity.timestamp.toISOString().split('T')[0]
|
||||
: null;
|
||||
|
||||
// Calculate streak
|
||||
const streakDays = await this.calculateStreak(userId);
|
||||
|
||||
// Upsert user stats
|
||||
await this.db
|
||||
.insert(userStats)
|
||||
.values({
|
||||
userId,
|
||||
totalXp,
|
||||
totalSkills,
|
||||
highestLevel,
|
||||
streakDays,
|
||||
lastActivityDate,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: userStats.userId,
|
||||
set: {
|
||||
totalXp,
|
||||
totalSkills,
|
||||
highestLevel,
|
||||
streakDays,
|
||||
lastActivityDate,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async calculateStreak(userId: string): Promise<number> {
|
||||
const allActivities = await this.db
|
||||
.select()
|
||||
.from(activities)
|
||||
.where(eq(activities.userId, userId))
|
||||
.orderBy(desc(activities.timestamp));
|
||||
|
||||
if (allActivities.length === 0) return 0;
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
// Get unique dates
|
||||
const uniqueDates = [
|
||||
...new Set(
|
||||
allActivities.map((a) => {
|
||||
const d = new Date(a.timestamp);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d.getTime();
|
||||
})
|
||||
),
|
||||
].sort((a, b) => b - a); // Newest first
|
||||
|
||||
let streak = 0;
|
||||
let expectedDate = today.getTime();
|
||||
|
||||
for (const date of uniqueDates) {
|
||||
if (date === expectedDate || date === expectedDate - 86400000) {
|
||||
streak++;
|
||||
expectedDate = date - 86400000;
|
||||
} else if (date < expectedDate - 86400000) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return streak;
|
||||
}
|
||||
|
||||
async getUserStats(userId: string) {
|
||||
const [stats] = await this.db.select().from(userStats).where(eq(userStats.userId, userId));
|
||||
|
||||
if (!stats) {
|
||||
// Return default stats
|
||||
return {
|
||||
totalXp: 0,
|
||||
totalSkills: 0,
|
||||
highestLevel: 0,
|
||||
streakDays: 0,
|
||||
lastActivityDate: null,
|
||||
};
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
25
apps/skilltree/apps/backend/tsconfig.json
Normal file
25
apps/skilltree/apps/backend/tsconfig.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
12
apps/skilltree/apps/web/.env.production.example
Normal file
12
apps/skilltree/apps/web/.env.production.example
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# SkillTree Web App - Production Environment Variables
|
||||
# Copy this file to .env.production and fill in the values
|
||||
|
||||
# =============================================================================
|
||||
# REQUIRED
|
||||
# =============================================================================
|
||||
|
||||
# Backend API URL
|
||||
PUBLIC_BACKEND_URL=https://skilltree-api.mana.how
|
||||
|
||||
# Mana Core Auth URL for authentication
|
||||
PUBLIC_MANA_CORE_AUTH_URL=https://auth.mana.how
|
||||
66
apps/skilltree/apps/web/Dockerfile
Normal file
66
apps/skilltree/apps/web/Dockerfile
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Build arguments for SvelteKit static env vars
|
||||
ARG PUBLIC_BACKEND_URL=http://skilltree-backend:3024
|
||||
ARG PUBLIC_MANA_CORE_AUTH_URL=http://mana-core-auth:3001
|
||||
|
||||
# Set as environment variables for build
|
||||
ENV PUBLIC_BACKEND_URL=$PUBLIC_BACKEND_URL
|
||||
ENV PUBLIC_MANA_CORE_AUTH_URL=$PUBLIC_MANA_CORE_AUTH_URL
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy root workspace files
|
||||
COPY pnpm-workspace.yaml ./
|
||||
COPY package.json ./
|
||||
COPY pnpm-lock.yaml ./
|
||||
|
||||
# Copy shared packages needed by skilltree web
|
||||
COPY packages/shared-tailwind ./packages/shared-tailwind
|
||||
COPY packages/shared-theme ./packages/shared-theme
|
||||
COPY packages/shared-utils ./packages/shared-utils
|
||||
|
||||
# Copy skilltree web
|
||||
COPY apps/skilltree/apps/web ./apps/skilltree/apps/web
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Build the web app
|
||||
WORKDIR /app/apps/skilltree/apps/web
|
||||
RUN pnpm exec svelte-kit sync
|
||||
RUN pnpm build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
WORKDIR /app/apps/skilltree/apps/web
|
||||
|
||||
# Copy the pnpm store that symlinks point to
|
||||
COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm
|
||||
|
||||
# Copy the app's node_modules
|
||||
COPY --from=builder /app/apps/skilltree/apps/web/node_modules ./node_modules
|
||||
|
||||
# Copy built application
|
||||
COPY --from=builder /app/apps/skilltree/apps/web/build ./build
|
||||
COPY --from=builder /app/apps/skilltree/apps/web/package.json ./
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5195
|
||||
|
||||
# Set environment variables
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=5195
|
||||
ENV HOST=0.0.0.0
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:5195/health || exit 1
|
||||
|
||||
# Run the app
|
||||
CMD ["node", "build"]
|
||||
37
apps/skilltree/apps/web/package.json
Normal file
37
apps/skilltree/apps/web/package.json
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"name": "@skilltree/web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"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",
|
||||
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^5.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@types/node": "^20.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-tailwind": "workspace:*",
|
||||
"@manacore/shared-theme": "workspace:*",
|
||||
"@manacore/shared-utils": "workspace:*",
|
||||
"idb": "^8.0.0",
|
||||
"lucide-svelte": "^0.556.0",
|
||||
"uuid": "^11.0.0"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
141
apps/skilltree/apps/web/src/app.css
Normal file
141
apps/skilltree/apps/web/src/app.css
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
@import 'tailwindcss';
|
||||
@import '@manacore/shared-tailwind/themes.css';
|
||||
|
||||
:root {
|
||||
/* SkillTree - Emerald/Green Theme (Growth & Progress) */
|
||||
--color-primary: #10b981;
|
||||
--color-primary-hover: #059669;
|
||||
--color-primary-light: #34d399;
|
||||
--color-primary-dark: #047857;
|
||||
|
||||
--color-secondary: #ecfdf5;
|
||||
--color-secondary-hover: #d1fae5;
|
||||
|
||||
--color-accent: #6ee7b7;
|
||||
--color-accent-hover: #34d399;
|
||||
|
||||
/* XP & Level Colors */
|
||||
--color-xp: #fbbf24;
|
||||
--color-xp-glow: rgba(251, 191, 36, 0.4);
|
||||
--color-level-up: #f59e0b;
|
||||
|
||||
/* Skill Levels */
|
||||
--color-level-0: #6b7280;
|
||||
--color-level-1: #3b82f6;
|
||||
--color-level-2: #8b5cf6;
|
||||
--color-level-3: #ec4899;
|
||||
--color-level-4: #f97316;
|
||||
--color-level-5: #fbbf24;
|
||||
|
||||
/* Branch Colors */
|
||||
--color-branch-intellect: #3b82f6;
|
||||
--color-branch-body: #ef4444;
|
||||
--color-branch-creativity: #ec4899;
|
||||
--color-branch-social: #8b5cf6;
|
||||
--color-branch-practical: #f97316;
|
||||
--color-branch-mindset: #10b981;
|
||||
}
|
||||
|
||||
/* Dark mode overrides */
|
||||
:root.dark {
|
||||
--color-secondary: #064e3b;
|
||||
--color-secondary-hover: #065f46;
|
||||
}
|
||||
|
||||
/* Skill card */
|
||||
.skill-card {
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.skill-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px -5px rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
/* XP Progress Bar */
|
||||
.xp-bar {
|
||||
background: linear-gradient(90deg, var(--color-primary) 0%, var(--color-xp) 100%);
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
.xp-bar-container {
|
||||
background: rgba(107, 114, 128, 0.2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Level badge glow animation */
|
||||
@keyframes level-glow {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 5px var(--color-xp-glow);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px var(--color-xp-glow);
|
||||
}
|
||||
}
|
||||
|
||||
.level-badge-glow {
|
||||
animation: level-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Level up animation */
|
||||
@keyframes level-up {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.level-up-animation {
|
||||
animation: level-up 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
/* Branch indicator */
|
||||
.branch-indicator {
|
||||
width: 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Skill tree node */
|
||||
.tree-node {
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.tree-node:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.tree-node.locked {
|
||||
opacity: 0.5;
|
||||
filter: grayscale(0.8);
|
||||
}
|
||||
|
||||
/* Progress ring */
|
||||
.progress-ring {
|
||||
transition: stroke-dashoffset 0.5s ease;
|
||||
}
|
||||
|
||||
/* Add XP button pulse */
|
||||
@keyframes pulse-xp {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 10px rgba(16, 185, 129, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.pulse-xp:hover {
|
||||
animation: pulse-xp 1.5s infinite;
|
||||
}
|
||||
13
apps/skilltree/apps/web/src/app.d.ts
vendored
Normal file
13
apps/skilltree/apps/web/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 {};
|
||||
27
apps/skilltree/apps/web/src/app.html
Normal file
27
apps/skilltree/apps/web/src/app.html
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<!-- Theme Color -->
|
||||
<meta name="theme-color" content="#10b981" />
|
||||
<meta name="msapplication-TileColor" content="#10b981" />
|
||||
|
||||
<!-- PWA -->
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="SkillTree" />
|
||||
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
24
apps/skilltree/apps/web/src/hooks.server.ts
Normal file
24
apps/skilltree/apps/web/src/hooks.server.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* Server Hooks for SvelteKit
|
||||
* - Injects runtime environment variables for client-side use
|
||||
* - Auth is handled client-side via Mana Core Auth
|
||||
*/
|
||||
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
const PUBLIC_MANA_CORE_AUTH_URL_CLIENT =
|
||||
process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_CORE_AUTH_URL || '';
|
||||
const PUBLIC_BACKEND_URL_CLIENT =
|
||||
process.env.PUBLIC_BACKEND_URL_CLIENT || process.env.PUBLIC_BACKEND_URL || '';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
return resolve(event, {
|
||||
transformPageChunk: ({ html }) => {
|
||||
const envScript = `<script>
|
||||
window.__PUBLIC_MANA_CORE_AUTH_URL__ = "${PUBLIC_MANA_CORE_AUTH_URL_CLIENT}";
|
||||
window.__PUBLIC_BACKEND_URL__ = "${PUBLIC_BACKEND_URL_CLIENT}";
|
||||
</script>`;
|
||||
return html.replace('<head>', `<head>${envScript}`);
|
||||
},
|
||||
});
|
||||
};
|
||||
23
apps/skilltree/apps/web/src/lib/api/activities.ts
Normal file
23
apps/skilltree/apps/web/src/lib/api/activities.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { apiClient } from './client';
|
||||
import type { Activity } from '$lib/types';
|
||||
|
||||
interface ActivitiesResponse {
|
||||
activities: Activity[];
|
||||
}
|
||||
|
||||
export async function getActivities(skillId?: string, limit?: number): Promise<Activity[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (skillId) params.append('skillId', skillId);
|
||||
if (limit) params.append('limit', String(limit));
|
||||
const queryString = params.toString() ? `?${params.toString()}` : '';
|
||||
const response = await apiClient.get<ActivitiesResponse>(`/api/v1/activities${queryString}`);
|
||||
return response.activities;
|
||||
}
|
||||
|
||||
export async function getRecentActivities(limit = 10): Promise<Activity[]> {
|
||||
return getActivities(undefined, limit);
|
||||
}
|
||||
|
||||
export async function getSkillActivities(skillId: string): Promise<Activity[]> {
|
||||
return getActivities(skillId);
|
||||
}
|
||||
98
apps/skilltree/apps/web/src/lib/api/client.ts
Normal file
98
apps/skilltree/apps/web/src/lib/api/client.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { browser } from '$app/environment';
|
||||
import { PUBLIC_BACKEND_URL } from '$env/static/public';
|
||||
|
||||
interface ApiOptions {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ApiError {
|
||||
message: string;
|
||||
statusCode: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the backend URL, preferring runtime-injected value in browser
|
||||
* This allows Docker to inject PUBLIC_BACKEND_URL_CLIENT at runtime
|
||||
*/
|
||||
function getBackendUrl(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
const runtimeUrl = (window as Window & { __PUBLIC_BACKEND_URL__?: string })
|
||||
.__PUBLIC_BACKEND_URL__;
|
||||
if (runtimeUrl) {
|
||||
return runtimeUrl;
|
||||
}
|
||||
}
|
||||
return PUBLIC_BACKEND_URL || 'http://localhost:3024';
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private accessToken: string | null = null;
|
||||
|
||||
private get baseUrl(): string {
|
||||
return getBackendUrl();
|
||||
}
|
||||
|
||||
setAccessToken(token: string | null) {
|
||||
this.accessToken = token;
|
||||
}
|
||||
|
||||
getAccessToken(): string | null {
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
async fetch<T>(endpoint: string, options: ApiOptions = {}): Promise<T> {
|
||||
const { method = 'GET', body, headers = {} } = options;
|
||||
|
||||
const requestHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
};
|
||||
|
||||
if (this.accessToken) {
|
||||
requestHeaders['Authorization'] = `Bearer ${this.accessToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}${endpoint}`, {
|
||||
method,
|
||||
headers: requestHeaders,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'An error occurred';
|
||||
try {
|
||||
const errorData = (await response.json()) as ApiError;
|
||||
errorMessage = errorData.message || errorMessage;
|
||||
} catch {
|
||||
errorMessage = response.statusText || errorMessage;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
get<T>(endpoint: string, headers?: Record<string, string>): Promise<T> {
|
||||
return this.fetch<T>(endpoint, { method: 'GET', headers });
|
||||
}
|
||||
|
||||
post<T>(endpoint: string, body?: unknown, headers?: Record<string, string>): Promise<T> {
|
||||
return this.fetch<T>(endpoint, { method: 'POST', body, headers });
|
||||
}
|
||||
|
||||
put<T>(endpoint: string, body?: unknown, headers?: Record<string, string>): Promise<T> {
|
||||
return this.fetch<T>(endpoint, { method: 'PUT', body, headers });
|
||||
}
|
||||
|
||||
delete<T>(endpoint: string, headers?: Record<string, string>): Promise<T> {
|
||||
return this.fetch<T>(endpoint, { method: 'DELETE', headers });
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
80
apps/skilltree/apps/web/src/lib/api/skills.ts
Normal file
80
apps/skilltree/apps/web/src/lib/api/skills.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { apiClient } from './client';
|
||||
import type { Skill, Activity, UserStats, SkillBranch } from '$lib/types';
|
||||
|
||||
interface CreateSkillDto {
|
||||
name: string;
|
||||
description?: string;
|
||||
branch: SkillBranch;
|
||||
parentId?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface UpdateSkillDto {
|
||||
name?: string;
|
||||
description?: string;
|
||||
branch?: SkillBranch;
|
||||
parentId?: string | null;
|
||||
icon?: string;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
interface AddXpDto {
|
||||
xp: number;
|
||||
description: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface AddXpResponse {
|
||||
skill: Skill;
|
||||
activity: Activity;
|
||||
leveledUp: boolean;
|
||||
previousLevel: number;
|
||||
newLevel: number;
|
||||
}
|
||||
|
||||
interface SkillsResponse {
|
||||
skills: Skill[];
|
||||
}
|
||||
|
||||
interface SkillResponse {
|
||||
skill: Skill;
|
||||
}
|
||||
|
||||
interface StatsResponse {
|
||||
stats: UserStats;
|
||||
}
|
||||
|
||||
export async function getSkills(branch?: SkillBranch): Promise<Skill[]> {
|
||||
const queryString = branch ? `?branch=${branch}` : '';
|
||||
const response = await apiClient.get<SkillsResponse>(`/api/v1/skills${queryString}`);
|
||||
return response.skills;
|
||||
}
|
||||
|
||||
export async function getSkill(id: string): Promise<Skill> {
|
||||
const response = await apiClient.get<SkillResponse>(`/api/v1/skills/${id}`);
|
||||
return response.skill;
|
||||
}
|
||||
|
||||
export async function createSkill(data: CreateSkillDto): Promise<Skill> {
|
||||
const response = await apiClient.post<SkillResponse>('/api/v1/skills', data);
|
||||
return response.skill;
|
||||
}
|
||||
|
||||
export async function updateSkill(id: string, data: UpdateSkillDto): Promise<Skill> {
|
||||
const response = await apiClient.put<SkillResponse>(`/api/v1/skills/${id}`, data);
|
||||
return response.skill;
|
||||
}
|
||||
|
||||
export async function deleteSkill(id: string): Promise<void> {
|
||||
await apiClient.delete(`/api/v1/skills/${id}`);
|
||||
}
|
||||
|
||||
export async function addXp(skillId: string, data: AddXpDto): Promise<AddXpResponse> {
|
||||
return await apiClient.post<AddXpResponse>(`/api/v1/skills/${skillId}/xp`, data);
|
||||
}
|
||||
|
||||
export async function getStats(): Promise<UserStats> {
|
||||
const response = await apiClient.get<StatsResponse>('/api/v1/skills/stats');
|
||||
return response.stats;
|
||||
}
|
||||
132
apps/skilltree/apps/web/src/lib/components/AddSkillModal.svelte
Normal file
132
apps/skilltree/apps/web/src/lib/components/AddSkillModal.svelte
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
<script lang="ts">
|
||||
import type { Skill, SkillBranch } from '$lib/types';
|
||||
import { BRANCH_INFO } from '$lib/types';
|
||||
import { X } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onSave: (skill: Partial<Skill>) => Promise<void>;
|
||||
}
|
||||
|
||||
let { onClose, onSave }: Props = $props();
|
||||
|
||||
let name = $state('');
|
||||
let description = $state('');
|
||||
let branch = $state<SkillBranch>('intellect');
|
||||
let saving = $state(false);
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
await onSave({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
branch,
|
||||
});
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onclick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="mx-4 w-full max-w-md rounded-2xl border border-gray-700 bg-gray-800 p-6 shadow-xl">
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-white">Neuer Skill</h2>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-700 hover:text-white"
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label for="name" class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder="z.B. TypeScript"
|
||||
class="w-full rounded-lg border border-gray-600 bg-gray-700 px-4 py-2 text-white placeholder-gray-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label for="description" class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Beschreibung
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
bind:value={description}
|
||||
placeholder="Worum geht es bei diesem Skill?"
|
||||
rows="3"
|
||||
class="w-full rounded-lg border border-gray-600 bg-gray-700 px-4 py-2 text-white placeholder-gray-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Branch -->
|
||||
<div>
|
||||
<label for="branch" class="mb-2 block text-sm font-medium text-gray-300">
|
||||
Kategorie
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#each Object.entries(BRANCH_INFO) as [key, info]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (branch = key as SkillBranch)}
|
||||
class="flex items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors {branch === key
|
||||
? 'border-emerald-500 bg-emerald-500/20 text-white'
|
||||
: 'border-gray-600 bg-gray-700/50 text-gray-300 hover:border-gray-500'}"
|
||||
>
|
||||
<span
|
||||
class="h-3 w-3 rounded-full"
|
||||
style="background-color: {info.color}"
|
||||
></span>
|
||||
{info.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="flex-1 rounded-lg border border-gray-600 bg-transparent px-4 py-2 font-medium text-gray-300 transition-colors hover:bg-gray-700"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!name.trim() || saving}
|
||||
class="flex-1 rounded-lg bg-emerald-600 px-4 py-2 font-medium text-white transition-colors hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Speichern...' : 'Erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
172
apps/skilltree/apps/web/src/lib/components/AddXpModal.svelte
Normal file
172
apps/skilltree/apps/web/src/lib/components/AddXpModal.svelte
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<script lang="ts">
|
||||
import type { Skill } from '$lib/types';
|
||||
import { LEVEL_NAMES } from '$lib/types';
|
||||
import { X, Zap, Clock, Star } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
skill: Skill;
|
||||
onClose: () => void;
|
||||
onSave: (xp: number, description: string, duration?: number) => Promise<void>;
|
||||
}
|
||||
|
||||
let { skill, onClose, onSave }: Props = $props();
|
||||
|
||||
let xp = $state(10);
|
||||
let description = $state('');
|
||||
let duration = $state<number | undefined>(undefined);
|
||||
let saving = $state(false);
|
||||
|
||||
// Quick XP presets
|
||||
const xpPresets = [
|
||||
{ label: '+5', value: 5, desc: 'Kurz geübt' },
|
||||
{ label: '+10', value: 10, desc: 'Normale Session' },
|
||||
{ label: '+25', value: 25, desc: 'Intensive Session' },
|
||||
{ label: '+50', value: 50, desc: 'Großer Fortschritt' },
|
||||
{ label: '+100', value: 100, desc: 'Meilenstein erreicht' },
|
||||
];
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
if (xp <= 0) return;
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
await onSave(xp, description || `+${xp} XP`, duration);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function selectPreset(preset: { value: number; desc: string }) {
|
||||
xp = preset.value;
|
||||
if (!description) {
|
||||
description = preset.desc;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onclick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="mx-4 w-full max-w-md rounded-2xl border border-gray-700 bg-gray-800 p-6 shadow-xl">
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-white">XP hinzufügen</h2>
|
||||
<p class="text-sm text-gray-400">{skill.name} (Lvl {skill.level})</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-700 hover:text-white"
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
<!-- Quick XP Presets -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-300">
|
||||
Schnellauswahl
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each xpPresets as preset}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectPreset(preset)}
|
||||
class="rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors {xp === preset.value
|
||||
? 'border-emerald-500 bg-emerald-500/20 text-emerald-400'
|
||||
: 'border-gray-600 bg-gray-700/50 text-gray-300 hover:border-gray-500'}"
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom XP -->
|
||||
<div>
|
||||
<label for="xp" class="mb-1 block text-sm font-medium text-gray-300">
|
||||
<Zap class="mr-1 inline h-4 w-4 text-yellow-500" />
|
||||
XP Menge
|
||||
</label>
|
||||
<input
|
||||
id="xp"
|
||||
type="number"
|
||||
bind:value={xp}
|
||||
min="1"
|
||||
max="1000"
|
||||
class="w-full rounded-lg border border-gray-600 bg-gray-700 px-4 py-2 text-white placeholder-gray-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label for="description" class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Was hast du gemacht?
|
||||
</label>
|
||||
<input
|
||||
id="description"
|
||||
type="text"
|
||||
bind:value={description}
|
||||
placeholder="z.B. Tutorial abgeschlossen"
|
||||
class="w-full rounded-lg border border-gray-600 bg-gray-700 px-4 py-2 text-white placeholder-gray-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Duration (optional) -->
|
||||
<div>
|
||||
<label for="duration" class="mb-1 block text-sm font-medium text-gray-300">
|
||||
<Clock class="mr-1 inline h-4 w-4 text-gray-400" />
|
||||
Dauer (optional, Minuten)
|
||||
</label>
|
||||
<input
|
||||
id="duration"
|
||||
type="number"
|
||||
bind:value={duration}
|
||||
min="1"
|
||||
placeholder="z.B. 30"
|
||||
class="w-full rounded-lg border border-gray-600 bg-gray-700 px-4 py-2 text-white placeholder-gray-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<div class="rounded-lg bg-gray-700/50 p-3">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-400">Vorschau</span>
|
||||
<span class="font-medium text-emerald-400">+{xp} XP</span>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500">
|
||||
Neuer Stand: {(skill.totalXp + xp).toLocaleString()} XP
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="flex-1 rounded-lg border border-gray-600 bg-transparent px-4 py-2 font-medium text-gray-300 transition-colors hover:bg-gray-700"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={xp <= 0 || saving}
|
||||
class="flex-1 rounded-lg bg-emerald-600 px-4 py-2 font-medium text-white transition-colors hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Speichern...' : 'XP vergeben'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
196
apps/skilltree/apps/web/src/lib/components/EditSkillModal.svelte
Normal file
196
apps/skilltree/apps/web/src/lib/components/EditSkillModal.svelte
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
<script lang="ts">
|
||||
import type { Skill, SkillBranch } from '$lib/types';
|
||||
import { BRANCH_INFO } from '$lib/types';
|
||||
import { X, Trash2 } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
skill: Skill;
|
||||
onClose: () => void;
|
||||
onSave: (updates: Partial<Skill>) => Promise<void>;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
let { skill, onClose, onSave, onDelete }: Props = $props();
|
||||
|
||||
let name = $state(skill.name);
|
||||
let description = $state(skill.description);
|
||||
let branch = $state<SkillBranch>(skill.branch);
|
||||
let saving = $state(false);
|
||||
let showDeleteConfirm = $state(false);
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
await onSave({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
branch,
|
||||
});
|
||||
onClose();
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
onDelete();
|
||||
onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onclick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="mx-4 w-full max-w-md rounded-2xl border border-gray-700 bg-gray-800 p-6 shadow-xl">
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-white">Skill bearbeiten</h2>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-700 hover:text-white"
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showDeleteConfirm}
|
||||
<!-- Delete Confirmation -->
|
||||
<div class="text-center">
|
||||
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-500/20">
|
||||
<Trash2 class="h-8 w-8 text-red-500" />
|
||||
</div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-white">Skill löschen?</h3>
|
||||
<p class="mb-6 text-gray-400">
|
||||
"{skill.name}" und alle zugehörigen Aktivitäten werden unwiderruflich gelöscht.
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onclick={() => (showDeleteConfirm = false)}
|
||||
class="flex-1 rounded-lg border border-gray-600 bg-transparent px-4 py-2 font-medium text-gray-300 transition-colors hover:bg-gray-700"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onclick={confirmDelete}
|
||||
class="flex-1 rounded-lg bg-red-600 px-4 py-2 font-medium text-white transition-colors hover:bg-red-500"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label for="name" class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder="z.B. TypeScript"
|
||||
class="w-full rounded-lg border border-gray-600 bg-gray-700 px-4 py-2 text-white placeholder-gray-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label for="description" class="mb-1 block text-sm font-medium text-gray-300">
|
||||
Beschreibung
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
bind:value={description}
|
||||
placeholder="Worum geht es bei diesem Skill?"
|
||||
rows="3"
|
||||
class="w-full rounded-lg border border-gray-600 bg-gray-700 px-4 py-2 text-white placeholder-gray-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Branch -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-300">
|
||||
Kategorie
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#each Object.entries(BRANCH_INFO) as [key, info]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (branch = key as SkillBranch)}
|
||||
class="flex items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors {branch === key
|
||||
? 'border-emerald-500 bg-emerald-500/20 text-white'
|
||||
: 'border-gray-600 bg-gray-700/50 text-gray-300 hover:border-gray-500'}"
|
||||
>
|
||||
<span
|
||||
class="h-3 w-3 rounded-full"
|
||||
style="background-color: {info.color}"
|
||||
></span>
|
||||
{info.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats (read-only) -->
|
||||
<div class="rounded-lg bg-gray-700/50 p-3">
|
||||
<div class="grid grid-cols-3 gap-4 text-center text-sm">
|
||||
<div>
|
||||
<div class="text-gray-400">Level</div>
|
||||
<div class="font-semibold text-white">{skill.level}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-400">Total XP</div>
|
||||
<div class="font-semibold text-white">{skill.totalXp.toLocaleString()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-400">Erstellt</div>
|
||||
<div class="font-semibold text-white">
|
||||
{new Date(skill.createdAt).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showDeleteConfirm = true)}
|
||||
class="rounded-lg bg-red-600/20 p-2 text-red-400 transition-colors hover:bg-red-600/30"
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 class="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="flex-1 rounded-lg border border-gray-600 bg-transparent px-4 py-2 font-medium text-gray-300 transition-colors hover:bg-gray-700"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!name.trim() || saving}
|
||||
class="flex-1 rounded-lg bg-emerald-600 px-4 py-2 font-medium text-white transition-colors hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Speichern...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
<script lang="ts">
|
||||
import { LEVEL_NAMES } from '$lib/types';
|
||||
import { Star, Trophy, Sparkles } from 'lucide-svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
skillName: string;
|
||||
newLevel: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { skillName, newLevel, onClose }: Props = $props();
|
||||
|
||||
const levelName = LEVEL_NAMES[newLevel] ?? 'Unbekannt';
|
||||
|
||||
// Auto-close after 4 seconds
|
||||
onMount(() => {
|
||||
const timer = setTimeout(onClose, 4000);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
|
||||
function getLevelColor(level: number): string {
|
||||
const colors = [
|
||||
'from-gray-500 to-gray-600',
|
||||
'from-blue-500 to-blue-600',
|
||||
'from-purple-500 to-purple-600',
|
||||
'from-pink-500 to-pink-600',
|
||||
'from-orange-500 to-orange-600',
|
||||
'from-yellow-400 to-yellow-500',
|
||||
];
|
||||
return colors[level] ?? colors[0];
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm"
|
||||
onclick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="celebration-container text-center">
|
||||
<!-- Sparkle effects -->
|
||||
<div class="sparkles">
|
||||
{#each Array(12) as _, i}
|
||||
<div
|
||||
class="sparkle"
|
||||
style="--delay: {i * 0.1}s; --angle: {i * 30}deg"
|
||||
>
|
||||
<Sparkles class="h-6 w-6 text-yellow-400" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="relative z-10">
|
||||
<!-- Trophy icon -->
|
||||
<div class="mx-auto mb-4 flex h-24 w-24 items-center justify-center rounded-full bg-gradient-to-br {getLevelColor(newLevel)} level-up-bounce shadow-lg shadow-yellow-500/30">
|
||||
<Trophy class="h-12 w-12 text-white" />
|
||||
</div>
|
||||
|
||||
<!-- Level up text -->
|
||||
<h2 class="mb-2 text-3xl font-bold text-white level-up-text">
|
||||
LEVEL UP!
|
||||
</h2>
|
||||
|
||||
<!-- Skill name -->
|
||||
<p class="mb-4 text-xl text-gray-300">{skillName}</p>
|
||||
|
||||
<!-- New level badge -->
|
||||
<div class="inline-flex items-center gap-2 rounded-full bg-gradient-to-r {getLevelColor(newLevel)} px-6 py-3 text-lg font-bold text-white shadow-lg">
|
||||
<Star class="h-5 w-5 fill-current" />
|
||||
Level {newLevel} - {levelName}
|
||||
<Star class="h-5 w-5 fill-current" />
|
||||
</div>
|
||||
|
||||
<!-- Stars -->
|
||||
<div class="mt-6 flex justify-center gap-2">
|
||||
{#each Array(newLevel) as _, i}
|
||||
<Star
|
||||
class="h-8 w-8 fill-yellow-400 text-yellow-400 star-pop"
|
||||
style="animation-delay: {0.5 + i * 0.1}s"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Click to close -->
|
||||
<p class="mt-6 text-sm text-gray-500">Klicken zum Schließen</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.celebration-container {
|
||||
position: relative;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.sparkles {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sparkle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
animation: sparkle-fly 1s ease-out forwards;
|
||||
animation-delay: var(--delay);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes sparkle-fly {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) rotate(var(--angle)) translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, -50%) rotate(var(--angle)) translateY(-150px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.level-up-bounce {
|
||||
animation: level-bounce 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes level-bounce {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
70% {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.level-up-text {
|
||||
animation: text-glow 1s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes text-glow {
|
||||
from {
|
||||
text-shadow: 0 0 10px rgba(251, 191, 36, 0.5);
|
||||
}
|
||||
to {
|
||||
text-shadow: 0 0 30px rgba(251, 191, 36, 0.8), 0 0 60px rgba(251, 191, 36, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.star-pop) {
|
||||
opacity: 0;
|
||||
animation: star-pop 0.4s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes star-pop {
|
||||
0% {
|
||||
transform: scale(0) rotate(-180deg);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
109
apps/skilltree/apps/web/src/lib/components/SkillCard.svelte
Normal file
109
apps/skilltree/apps/web/src/lib/components/SkillCard.svelte
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<script lang="ts">
|
||||
import type { Skill } from '$lib/types';
|
||||
import { BRANCH_INFO, LEVEL_NAMES, xpProgress, xpForNextLevel } from '$lib/types';
|
||||
import { Plus, Trash2, Edit, Star } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
skill: Skill;
|
||||
onAddXp: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
let { skill, onAddXp, onEdit, onDelete }: Props = $props();
|
||||
|
||||
const branchInfo = $derived(BRANCH_INFO[skill.branch]);
|
||||
const levelName = $derived(LEVEL_NAMES[skill.level]);
|
||||
const progress = $derived(xpProgress(skill.totalXp, skill.level));
|
||||
const nextLevelXp = $derived(xpForNextLevel(skill.level));
|
||||
const isMaxLevel = $derived(skill.level >= LEVEL_NAMES.length - 1);
|
||||
|
||||
function getLevelColor(level: number): string {
|
||||
const colors = [
|
||||
'text-gray-400',
|
||||
'text-blue-400',
|
||||
'text-purple-400',
|
||||
'text-pink-400',
|
||||
'text-orange-400',
|
||||
'text-yellow-400',
|
||||
];
|
||||
return colors[level] ?? colors[0];
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="skill-card group relative overflow-hidden rounded-xl border border-gray-700 bg-gray-800/50 p-4">
|
||||
<!-- Branch Indicator -->
|
||||
<div
|
||||
class="branch-indicator absolute left-0 top-0 h-full"
|
||||
style="background-color: {branchInfo.color}"
|
||||
></div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-3 flex items-start justify-between pl-3">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-white">{skill.name}</h3>
|
||||
<p class="text-sm text-gray-400">{branchInfo.name}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
{#each Array(skill.level) as _, i}
|
||||
<Star class="h-4 w-4 fill-yellow-500 text-yellow-500" />
|
||||
{/each}
|
||||
{#each Array(5 - skill.level) as _, i}
|
||||
<Star class="h-4 w-4 text-gray-600" />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Level Badge -->
|
||||
<div class="mb-3 pl-3">
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-gray-700/50 px-3 py-1 text-sm {getLevelColor(skill.level)}">
|
||||
Lvl {skill.level} - {levelName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- XP Progress -->
|
||||
<div class="mb-4 pl-3">
|
||||
<div class="mb-1 flex justify-between text-sm">
|
||||
<span class="text-gray-400">XP</span>
|
||||
<span class="text-gray-300">
|
||||
{skill.totalXp.toLocaleString()}
|
||||
{#if !isMaxLevel}
|
||||
/ {nextLevelXp.toLocaleString()}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<div class="xp-bar-container h-2 rounded-full">
|
||||
<div class="xp-bar h-full rounded-full" style="width: {progress}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
{#if skill.description}
|
||||
<p class="mb-4 pl-3 text-sm text-gray-400 line-clamp-2">{skill.description}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2 pl-3">
|
||||
<button
|
||||
onclick={onAddXp}
|
||||
class="pulse-xp flex flex-1 items-center justify-center gap-2 rounded-lg bg-emerald-600/20 px-3 py-2 text-sm font-medium text-emerald-400 transition-colors hover:bg-emerald-600/30"
|
||||
>
|
||||
<Plus class="h-4 w-4" />
|
||||
XP hinzufügen
|
||||
</button>
|
||||
<button
|
||||
onclick={onEdit}
|
||||
class="rounded-lg bg-gray-600/20 p-2 text-gray-400 opacity-0 transition-all hover:bg-gray-600/30 hover:text-white group-hover:opacity-100"
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<Edit class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onclick={onDelete}
|
||||
class="rounded-lg bg-red-600/20 p-2 text-red-400 opacity-0 transition-all hover:bg-red-600/30 group-hover:opacity-100"
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
211
apps/skilltree/apps/web/src/lib/components/SkillTemplates.svelte
Normal file
211
apps/skilltree/apps/web/src/lib/components/SkillTemplates.svelte
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
<script lang="ts">
|
||||
import type { Skill, SkillBranch } from '$lib/types';
|
||||
import { BRANCH_INFO } from '$lib/types';
|
||||
import { X, Plus, Sparkles } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onAddSkill: (skill: Partial<Skill>) => Promise<void>;
|
||||
}
|
||||
|
||||
let { onClose, onAddSkill }: Props = $props();
|
||||
|
||||
interface SkillTemplate {
|
||||
name: string;
|
||||
description: string;
|
||||
branch: SkillBranch;
|
||||
}
|
||||
|
||||
const templates: Record<string, SkillTemplate[]> = {
|
||||
'Web Developer': [
|
||||
{ name: 'HTML & CSS', description: 'Grundlagen der Webentwicklung', branch: 'intellect' },
|
||||
{ name: 'JavaScript', description: 'Die Sprache des Webs', branch: 'intellect' },
|
||||
{ name: 'TypeScript', description: 'Typsicheres JavaScript', branch: 'intellect' },
|
||||
{ name: 'React', description: 'UI-Bibliothek für moderne Apps', branch: 'intellect' },
|
||||
{ name: 'Node.js', description: 'Backend mit JavaScript', branch: 'intellect' },
|
||||
{ name: 'Git', description: 'Versionskontrolle', branch: 'practical' },
|
||||
],
|
||||
'Fitness & Gesundheit': [
|
||||
{ name: 'Krafttraining', description: 'Muskelaufbau und Stärke', branch: 'body' },
|
||||
{ name: 'Ausdauer', description: 'Cardio und Kondition', branch: 'body' },
|
||||
{ name: 'Yoga', description: 'Flexibilität und Balance', branch: 'body' },
|
||||
{ name: 'Ernährung', description: 'Gesunde Essgewohnheiten', branch: 'body' },
|
||||
{ name: 'Schlaf', description: 'Erholsamer Schlaf', branch: 'mindset' },
|
||||
{ name: 'Stressmanagement', description: 'Umgang mit Stress', branch: 'mindset' },
|
||||
],
|
||||
'Kreative Künste': [
|
||||
{ name: 'Zeichnen', description: 'Grundlagen des Zeichnens', branch: 'creativity' },
|
||||
{ name: 'Malen', description: 'Farben und Techniken', branch: 'creativity' },
|
||||
{ name: 'Fotografie', description: 'Bilder einfangen', branch: 'creativity' },
|
||||
{ name: 'Musik', description: 'Instrument spielen', branch: 'creativity' },
|
||||
{ name: 'Schreiben', description: 'Kreatives Schreiben', branch: 'creativity' },
|
||||
{ name: 'Design', description: 'Visuelles Design', branch: 'creativity' },
|
||||
],
|
||||
'Sprachen': [
|
||||
{ name: 'Englisch', description: 'Die Weltsprache', branch: 'intellect' },
|
||||
{ name: 'Spanisch', description: 'Spanisch sprechen', branch: 'intellect' },
|
||||
{ name: 'Französisch', description: 'La langue française', branch: 'intellect' },
|
||||
{ name: 'Japanisch', description: '日本語', branch: 'intellect' },
|
||||
{ name: 'Deutsch', description: 'Deutsche Sprache', branch: 'intellect' },
|
||||
],
|
||||
'Produktivität': [
|
||||
{ name: 'Zeitmanagement', description: 'Zeit effektiv nutzen', branch: 'mindset' },
|
||||
{ name: 'Fokus', description: 'Konzentration verbessern', branch: 'mindset' },
|
||||
{ name: 'Organisation', description: 'Ordnung und Struktur', branch: 'practical' },
|
||||
{ name: 'Kommunikation', description: 'Klar kommunizieren', branch: 'social' },
|
||||
{ name: 'Problemlösung', description: 'Analytisches Denken', branch: 'intellect' },
|
||||
],
|
||||
'Kochen & Haushalt': [
|
||||
{ name: 'Kochen', description: 'Leckere Gerichte zubereiten', branch: 'practical' },
|
||||
{ name: 'Backen', description: 'Süßes und Brot', branch: 'practical' },
|
||||
{ name: 'Haushaltsführung', description: 'Sauberkeit und Ordnung', branch: 'practical' },
|
||||
{ name: 'Gartenarbeit', description: 'Grüner Daumen', branch: 'practical' },
|
||||
{ name: 'Heimwerken', description: 'Reparaturen selbst machen', branch: 'practical' },
|
||||
],
|
||||
};
|
||||
|
||||
let selectedTemplate = $state<string | null>(null);
|
||||
let addedSkills = $state<Set<string>>(new Set());
|
||||
let adding = $state(false);
|
||||
|
||||
async function addSkill(template: SkillTemplate) {
|
||||
if (addedSkills.has(template.name)) return;
|
||||
|
||||
adding = true;
|
||||
try {
|
||||
await onAddSkill(template);
|
||||
addedSkills = new Set([...addedSkills, template.name]);
|
||||
} finally {
|
||||
adding = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addAllFromTemplate(templateName: string) {
|
||||
const skills = templates[templateName];
|
||||
if (!skills) return;
|
||||
|
||||
adding = true;
|
||||
try {
|
||||
for (const skill of skills) {
|
||||
if (!addedSkills.has(skill.name)) {
|
||||
await onAddSkill(skill);
|
||||
addedSkills = new Set([...addedSkills, skill.name]);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
adding = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-black/60 backdrop-blur-sm p-4"
|
||||
onclick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="w-full max-w-2xl rounded-2xl border border-gray-700 bg-gray-800 p-6 shadow-xl my-8">
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Sparkles class="h-6 w-6 text-yellow-500" />
|
||||
<h2 class="text-xl font-bold text-white">Skill-Vorlagen</h2>
|
||||
</div>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-700 hover:text-white"
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="mb-6 text-gray-400">
|
||||
Starte schnell mit vorgefertigten Skill-Sets. Wähle eine Vorlage und füge einzelne Skills oder alle auf einmal hinzu.
|
||||
</p>
|
||||
|
||||
<!-- Template List -->
|
||||
<div class="space-y-4 max-h-[60vh] overflow-y-auto pr-2">
|
||||
{#each Object.entries(templates) as [name, skills]}
|
||||
<div class="rounded-xl border border-gray-700 bg-gray-900/50 overflow-hidden">
|
||||
<!-- Template Header -->
|
||||
<button
|
||||
onclick={() => (selectedTemplate = selectedTemplate === name ? null : name)}
|
||||
class="w-full flex items-center justify-between p-4 text-left hover:bg-gray-800/50 transition-colors"
|
||||
>
|
||||
<div>
|
||||
<h3 class="font-semibold text-white">{name}</h3>
|
||||
<p class="text-sm text-gray-400">{skills.length} Skills</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
addAllFromTemplate(name);
|
||||
}}
|
||||
disabled={adding}
|
||||
class="rounded-lg bg-emerald-600/20 px-3 py-1.5 text-sm font-medium text-emerald-400 transition-colors hover:bg-emerald-600/30 disabled:opacity-50"
|
||||
>
|
||||
Alle hinzufügen
|
||||
</button>
|
||||
<span class="text-gray-500 text-xl">
|
||||
{selectedTemplate === name ? '−' : '+'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Expanded Skills -->
|
||||
{#if selectedTemplate === name}
|
||||
<div class="border-t border-gray-700 p-4 space-y-2">
|
||||
{#each skills as skill}
|
||||
{@const isAdded = addedSkills.has(skill.name)}
|
||||
<div class="flex items-center justify-between rounded-lg bg-gray-800/50 px-3 py-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="h-3 w-3 rounded-full"
|
||||
style="background-color: {BRANCH_INFO[skill.branch].color}"
|
||||
></span>
|
||||
<div>
|
||||
<span class="font-medium text-white">{skill.name}</span>
|
||||
<span class="text-gray-400 text-sm"> - {skill.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => addSkill(skill)}
|
||||
disabled={isAdded || adding}
|
||||
class="rounded-lg p-1.5 transition-colors {isAdded
|
||||
? 'bg-emerald-600/20 text-emerald-400'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
|
||||
>
|
||||
{#if isAdded}
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{:else}
|
||||
<Plus class="h-4 w-4" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="rounded-lg bg-gray-700 px-4 py-2 font-medium text-white transition-colors hover:bg-gray-600"
|
||||
>
|
||||
Fertig
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
<script lang="ts">
|
||||
import { skillStore } from '$lib/stores/skills.svelte';
|
||||
import { Trophy, Zap, Target, Flame } from 'lucide-svelte';
|
||||
</script>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<!-- Total XP -->
|
||||
<div class="rounded-xl border border-gray-700 bg-gray-800/50 p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-yellow-500/20">
|
||||
<Zap class="h-6 w-6 text-yellow-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">Gesamt-XP</p>
|
||||
<p class="text-2xl font-bold text-white">
|
||||
{skillStore.userStats.totalXp.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Skills -->
|
||||
<div class="rounded-xl border border-gray-700 bg-gray-800/50 p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-emerald-500/20">
|
||||
<Target class="h-6 w-6 text-emerald-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">Skills</p>
|
||||
<p class="text-2xl font-bold text-white">
|
||||
{skillStore.userStats.totalSkills}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Highest Level -->
|
||||
<div class="rounded-xl border border-gray-700 bg-gray-800/50 p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-purple-500/20">
|
||||
<Trophy class="h-6 w-6 text-purple-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">Höchstes Level</p>
|
||||
<p class="text-2xl font-bold text-white">
|
||||
{skillStore.userStats.highestLevel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Streak -->
|
||||
<div class="rounded-xl border border-gray-700 bg-gray-800/50 p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-orange-500/20">
|
||||
<Flame class="h-6 w-6 text-orange-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-400">Streak</p>
|
||||
<p class="text-2xl font-bold text-white">
|
||||
{skillStore.userStats.streakDays} Tage
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
232
apps/skilltree/apps/web/src/lib/services/storage.ts
Normal file
232
apps/skilltree/apps/web/src/lib/services/storage.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { openDB, type DBSchema, type IDBPDatabase } from 'idb';
|
||||
import type { Skill, Activity, UserStats } from '$lib/types';
|
||||
|
||||
interface SkillTreeDB extends DBSchema {
|
||||
skills: {
|
||||
key: string;
|
||||
value: Skill;
|
||||
indexes: {
|
||||
'by-branch': string;
|
||||
'by-parent': string | null;
|
||||
'by-level': number;
|
||||
};
|
||||
};
|
||||
activities: {
|
||||
key: string;
|
||||
value: Activity;
|
||||
indexes: {
|
||||
'by-skill': string;
|
||||
'by-timestamp': string;
|
||||
};
|
||||
};
|
||||
stats: {
|
||||
key: 'user-stats';
|
||||
value: UserStats;
|
||||
};
|
||||
}
|
||||
|
||||
const DB_NAME = 'skilltree-db';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
let dbPromise: Promise<IDBPDatabase<SkillTreeDB>> | null = null;
|
||||
|
||||
function getDB(): Promise<IDBPDatabase<SkillTreeDB>> {
|
||||
if (!dbPromise) {
|
||||
dbPromise = openDB<SkillTreeDB>(DB_NAME, DB_VERSION, {
|
||||
upgrade(db) {
|
||||
// Skills store
|
||||
if (!db.objectStoreNames.contains('skills')) {
|
||||
const skillStore = db.createObjectStore('skills', { keyPath: 'id' });
|
||||
skillStore.createIndex('by-branch', 'branch');
|
||||
skillStore.createIndex('by-parent', 'parentId');
|
||||
skillStore.createIndex('by-level', 'level');
|
||||
}
|
||||
|
||||
// Activities store
|
||||
if (!db.objectStoreNames.contains('activities')) {
|
||||
const activityStore = db.createObjectStore('activities', { keyPath: 'id' });
|
||||
activityStore.createIndex('by-skill', 'skillId');
|
||||
activityStore.createIndex('by-timestamp', 'timestamp');
|
||||
}
|
||||
|
||||
// Stats store
|
||||
if (!db.objectStoreNames.contains('stats')) {
|
||||
db.createObjectStore('stats');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
// Skills CRUD
|
||||
export async function getAllSkills(): Promise<Skill[]> {
|
||||
const db = await getDB();
|
||||
return db.getAll('skills');
|
||||
}
|
||||
|
||||
export async function getSkillById(id: string): Promise<Skill | undefined> {
|
||||
const db = await getDB();
|
||||
return db.get('skills', id);
|
||||
}
|
||||
|
||||
export async function getSkillsByBranch(branch: string): Promise<Skill[]> {
|
||||
const db = await getDB();
|
||||
return db.getAllFromIndex('skills', 'by-branch', branch);
|
||||
}
|
||||
|
||||
export async function getChildSkills(parentId: string): Promise<Skill[]> {
|
||||
const db = await getDB();
|
||||
return db.getAllFromIndex('skills', 'by-parent', parentId);
|
||||
}
|
||||
|
||||
export async function saveSkill(skill: Skill): Promise<void> {
|
||||
const db = await getDB();
|
||||
skill.updatedAt = new Date().toISOString();
|
||||
await db.put('skills', skill);
|
||||
}
|
||||
|
||||
export async function deleteSkill(id: string): Promise<void> {
|
||||
const db = await getDB();
|
||||
// Delete skill and all its activities
|
||||
const activities = await db.getAllFromIndex('activities', 'by-skill', id);
|
||||
const tx = db.transaction(['skills', 'activities'], 'readwrite');
|
||||
await Promise.all([
|
||||
tx.objectStore('skills').delete(id),
|
||||
...activities.map((a) => tx.objectStore('activities').delete(a.id)),
|
||||
]);
|
||||
await tx.done;
|
||||
}
|
||||
|
||||
// Activities CRUD
|
||||
export async function getAllActivities(): Promise<Activity[]> {
|
||||
const db = await getDB();
|
||||
return db.getAll('activities');
|
||||
}
|
||||
|
||||
export async function getActivitiesBySkill(skillId: string): Promise<Activity[]> {
|
||||
const db = await getDB();
|
||||
return db.getAllFromIndex('activities', 'by-skill', skillId);
|
||||
}
|
||||
|
||||
export async function getRecentActivities(limit = 10): Promise<Activity[]> {
|
||||
const db = await getDB();
|
||||
const all = await db.getAllFromIndex('activities', 'by-timestamp');
|
||||
return all.reverse().slice(0, limit);
|
||||
}
|
||||
|
||||
export async function saveActivity(activity: Activity): Promise<void> {
|
||||
const db = await getDB();
|
||||
await db.put('activities', activity);
|
||||
}
|
||||
|
||||
export async function deleteActivity(id: string): Promise<void> {
|
||||
const db = await getDB();
|
||||
await db.delete('activities', id);
|
||||
}
|
||||
|
||||
// User Stats
|
||||
export async function getUserStats(): Promise<UserStats> {
|
||||
const db = await getDB();
|
||||
const stats = await db.get('stats', 'user-stats');
|
||||
return (
|
||||
stats ?? {
|
||||
totalXp: 0,
|
||||
totalSkills: 0,
|
||||
highestLevel: 0,
|
||||
streakDays: 0,
|
||||
lastActivityDate: null,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveUserStats(stats: UserStats): Promise<void> {
|
||||
const db = await getDB();
|
||||
await db.put('stats', stats, 'user-stats');
|
||||
}
|
||||
|
||||
// Utility: Recalculate stats from all skills
|
||||
export async function recalculateStats(): Promise<UserStats> {
|
||||
const skills = await getAllSkills();
|
||||
const activities = await getAllActivities();
|
||||
|
||||
const stats: UserStats = {
|
||||
totalXp: skills.reduce((sum, s) => sum + s.totalXp, 0),
|
||||
totalSkills: skills.length,
|
||||
highestLevel: skills.reduce((max, s) => Math.max(max, s.level), 0),
|
||||
streakDays: calculateStreak(activities),
|
||||
lastActivityDate: activities.length > 0 ? activities[activities.length - 1].timestamp : null,
|
||||
};
|
||||
|
||||
await saveUserStats(stats);
|
||||
return stats;
|
||||
}
|
||||
|
||||
function calculateStreak(activities: Activity[]): number {
|
||||
if (activities.length === 0) return 0;
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const sortedDates = activities
|
||||
.map((a) => {
|
||||
const d = new Date(a.timestamp);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d.getTime();
|
||||
})
|
||||
.filter((v, i, a) => a.indexOf(v) === i) // unique dates
|
||||
.sort((a, b) => b - a); // newest first
|
||||
|
||||
let streak = 0;
|
||||
let expectedDate = today.getTime();
|
||||
|
||||
for (const date of sortedDates) {
|
||||
if (date === expectedDate || date === expectedDate - 86400000) {
|
||||
streak++;
|
||||
expectedDate = date - 86400000;
|
||||
} else if (date < expectedDate - 86400000) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return streak;
|
||||
}
|
||||
|
||||
// Export all data (for backup)
|
||||
export async function exportData(): Promise<{
|
||||
skills: Skill[];
|
||||
activities: Activity[];
|
||||
stats: UserStats;
|
||||
}> {
|
||||
const [skills, activities, stats] = await Promise.all([
|
||||
getAllSkills(),
|
||||
getAllActivities(),
|
||||
getUserStats(),
|
||||
]);
|
||||
return { skills, activities, stats };
|
||||
}
|
||||
|
||||
// Import data (restore backup)
|
||||
export async function importData(data: {
|
||||
skills: Skill[];
|
||||
activities: Activity[];
|
||||
stats: UserStats;
|
||||
}): Promise<void> {
|
||||
const db = await getDB();
|
||||
const tx = db.transaction(['skills', 'activities', 'stats'], 'readwrite');
|
||||
|
||||
// Clear existing data
|
||||
await tx.objectStore('skills').clear();
|
||||
await tx.objectStore('activities').clear();
|
||||
|
||||
// Import new data
|
||||
for (const skill of data.skills) {
|
||||
await tx.objectStore('skills').put(skill);
|
||||
}
|
||||
for (const activity of data.activities) {
|
||||
await tx.objectStore('activities').put(activity);
|
||||
}
|
||||
await tx.objectStore('stats').put(data.stats, 'user-stats');
|
||||
|
||||
await tx.done;
|
||||
}
|
||||
212
apps/skilltree/apps/web/src/lib/stores/auth.svelte.ts
Normal file
212
apps/skilltree/apps/web/src/lib/stores/auth.svelte.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
/**
|
||||
* Auth Store - Manages authentication state using Svelte 5 runes
|
||||
* Uses Mana Core Auth
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { initializeWebAuth, type UserData } from '@manacore/shared-auth';
|
||||
import { apiClient } from '$lib/api/client';
|
||||
|
||||
const DEV_AUTH_URL = 'http://localhost:3001';
|
||||
const DEV_BACKEND_URL = 'http://localhost:3024';
|
||||
|
||||
function getAuthUrl(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
|
||||
.__PUBLIC_MANA_CORE_AUTH_URL__;
|
||||
if (injectedUrl) return injectedUrl;
|
||||
return import.meta.env.DEV ? DEV_AUTH_URL : '';
|
||||
}
|
||||
return process.env.PUBLIC_MANA_CORE_AUTH_URL || DEV_AUTH_URL;
|
||||
}
|
||||
|
||||
function getBackendUrl(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string })
|
||||
.__PUBLIC_BACKEND_URL__;
|
||||
if (injectedUrl) return injectedUrl;
|
||||
return import.meta.env.DEV ? DEV_BACKEND_URL : '';
|
||||
}
|
||||
return process.env.PUBLIC_BACKEND_URL || DEV_BACKEND_URL;
|
||||
}
|
||||
|
||||
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
|
||||
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
|
||||
|
||||
function getAuthService() {
|
||||
if (!browser) return null;
|
||||
if (!_authService) {
|
||||
const auth = initializeWebAuth({
|
||||
baseUrl: getAuthUrl(),
|
||||
backendUrl: getBackendUrl(),
|
||||
});
|
||||
_authService = auth.authService;
|
||||
_tokenManager = auth.tokenManager;
|
||||
}
|
||||
return _authService;
|
||||
}
|
||||
|
||||
function getTokenManager() {
|
||||
if (!browser) return null;
|
||||
getAuthService();
|
||||
return _tokenManager;
|
||||
}
|
||||
|
||||
let user = $state<UserData | null>(null);
|
||||
let loading = $state(true);
|
||||
let initialized = $state(false);
|
||||
|
||||
export const authStore = {
|
||||
get user() {
|
||||
return user;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get isAuthenticated() {
|
||||
return !!user;
|
||||
},
|
||||
get initialized() {
|
||||
return initialized;
|
||||
},
|
||||
|
||||
async initialize() {
|
||||
if (initialized) return;
|
||||
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
initialized = true;
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
const authenticated = await authService.isAuthenticated();
|
||||
if (authenticated) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = userData;
|
||||
|
||||
const token = await authService.getAppToken();
|
||||
if (token) {
|
||||
apiClient.setAccessToken(token);
|
||||
}
|
||||
}
|
||||
initialized = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize auth:', error);
|
||||
user = null;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async signIn(email: string, password: string) {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authService.signIn(email, password);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Login failed' };
|
||||
}
|
||||
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = userData;
|
||||
|
||||
const token = await authService.getAppToken();
|
||||
if (token) {
|
||||
apiClient.setAccessToken(token);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
|
||||
async signUp(email: string, password: string) {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server', needsVerification: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.signUp(email, password, undefined, sourceAppUrl);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
|
||||
}
|
||||
|
||||
if (result.needsVerification) {
|
||||
return { success: true, needsVerification: true };
|
||||
}
|
||||
|
||||
const signInResult = await this.signIn(email, password);
|
||||
return { ...signInResult, needsVerification: false };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, error: errorMessage, needsVerification: false };
|
||||
}
|
||||
},
|
||||
|
||||
async signOut() {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
user = null;
|
||||
apiClient.setAccessToken(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await authService.signOut();
|
||||
user = null;
|
||||
apiClient.setAccessToken(null);
|
||||
} catch (error) {
|
||||
console.error('Sign out error:', error);
|
||||
user = null;
|
||||
apiClient.setAccessToken(null);
|
||||
}
|
||||
},
|
||||
|
||||
async resetPassword(email: string) {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authService.forgotPassword(email);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Password reset failed' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
|
||||
async getAccessToken() {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
return null;
|
||||
}
|
||||
return await authService.getAppToken();
|
||||
},
|
||||
|
||||
async getValidToken(): Promise<string | null> {
|
||||
const tokenManager = getTokenManager();
|
||||
if (!tokenManager) {
|
||||
return null;
|
||||
}
|
||||
return await tokenManager.getValidToken();
|
||||
},
|
||||
};
|
||||
303
apps/skilltree/apps/web/src/lib/stores/skills.svelte.ts
Normal file
303
apps/skilltree/apps/web/src/lib/stores/skills.svelte.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
import type { Skill, Activity, UserStats, SkillBranch } from '$lib/types';
|
||||
import { calculateLevel, createDefaultSkill, createActivity, BRANCH_INFO } from '$lib/types';
|
||||
import * as storage from '$lib/services/storage';
|
||||
import * as skillsApi from '$lib/api/skills';
|
||||
import * as activitiesApi from '$lib/api/activities';
|
||||
import { authStore } from './auth.svelte';
|
||||
|
||||
// Reactive state using Svelte 5 runes
|
||||
let skills = $state<Skill[]>([]);
|
||||
let activities = $state<Activity[]>([]);
|
||||
let userStats = $state<UserStats>({
|
||||
totalXp: 0,
|
||||
totalSkills: 0,
|
||||
highestLevel: 0,
|
||||
streakDays: 0,
|
||||
lastActivityDate: null,
|
||||
});
|
||||
let isLoading = $state(true);
|
||||
let initialized = $state(false);
|
||||
let useApi = $state(false);
|
||||
|
||||
// Derived values
|
||||
const skillsByBranch = $derived(() => {
|
||||
const grouped: Record<SkillBranch, Skill[]> = {
|
||||
intellect: [],
|
||||
body: [],
|
||||
creativity: [],
|
||||
social: [],
|
||||
practical: [],
|
||||
mindset: [],
|
||||
custom: [],
|
||||
};
|
||||
for (const skill of skills) {
|
||||
grouped[skill.branch].push(skill);
|
||||
}
|
||||
return grouped;
|
||||
});
|
||||
|
||||
const topSkills = $derived(() => {
|
||||
return [...skills].sort((a, b) => b.totalXp - a.totalXp).slice(0, 5);
|
||||
});
|
||||
|
||||
const recentActivities = $derived(() => {
|
||||
return [...activities]
|
||||
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
||||
.slice(0, 10);
|
||||
});
|
||||
|
||||
const branchStats = $derived(() => {
|
||||
const stats: Record<SkillBranch, { count: number; totalXp: number; avgLevel: number }> =
|
||||
{} as Record<SkillBranch, { count: number; totalXp: number; avgLevel: number }>;
|
||||
for (const branch of Object.keys(BRANCH_INFO) as SkillBranch[]) {
|
||||
const branchSkills = skills.filter((s) => s.branch === branch);
|
||||
stats[branch] = {
|
||||
count: branchSkills.length,
|
||||
totalXp: branchSkills.reduce((sum, s) => sum + s.totalXp, 0),
|
||||
avgLevel:
|
||||
branchSkills.length > 0
|
||||
? branchSkills.reduce((sum, s) => sum + s.level, 0) / branchSkills.length
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
return stats;
|
||||
});
|
||||
|
||||
// Actions
|
||||
async function initialize() {
|
||||
if (initialized) return;
|
||||
|
||||
isLoading = true;
|
||||
try {
|
||||
// Check if user is authenticated
|
||||
if (authStore.isAuthenticated) {
|
||||
useApi = true;
|
||||
const [loadedSkills, loadedActivities, loadedStats] = await Promise.all([
|
||||
skillsApi.getSkills(),
|
||||
activitiesApi.getRecentActivities(50),
|
||||
skillsApi.getStats(),
|
||||
]);
|
||||
skills = loadedSkills;
|
||||
activities = loadedActivities;
|
||||
userStats = loadedStats;
|
||||
} else {
|
||||
// Fallback to IndexedDB for offline/unauthenticated use
|
||||
useApi = false;
|
||||
const [loadedSkills, loadedActivities, loadedStats] = await Promise.all([
|
||||
storage.getAllSkills(),
|
||||
storage.getAllActivities(),
|
||||
storage.getUserStats(),
|
||||
]);
|
||||
skills = loadedSkills;
|
||||
activities = loadedActivities;
|
||||
userStats = loadedStats;
|
||||
}
|
||||
initialized = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize skills store:', error);
|
||||
// On error, try IndexedDB as fallback
|
||||
if (useApi) {
|
||||
try {
|
||||
useApi = false;
|
||||
const [loadedSkills, loadedActivities, loadedStats] = await Promise.all([
|
||||
storage.getAllSkills(),
|
||||
storage.getAllActivities(),
|
||||
storage.getUserStats(),
|
||||
]);
|
||||
skills = loadedSkills;
|
||||
activities = loadedActivities;
|
||||
userStats = loadedStats;
|
||||
} catch (fallbackError) {
|
||||
console.error('Fallback to IndexedDB also failed:', fallbackError);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addSkill(data: Partial<Skill>): Promise<Skill> {
|
||||
if (useApi && authStore.isAuthenticated) {
|
||||
const skill = await skillsApi.createSkill({
|
||||
name: data.name || '',
|
||||
description: data.description,
|
||||
branch: data.branch || 'custom',
|
||||
parentId: data.parentId ?? undefined,
|
||||
icon: data.icon,
|
||||
color: data.color ?? undefined,
|
||||
});
|
||||
skills = [...skills, skill];
|
||||
await updateStats();
|
||||
return skill;
|
||||
} else {
|
||||
const skill = createDefaultSkill(data);
|
||||
await storage.saveSkill(skill);
|
||||
skills = [...skills, skill];
|
||||
await updateStats();
|
||||
return skill;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSkill(id: string, updates: Partial<Skill>): Promise<void> {
|
||||
const index = skills.findIndex((s) => s.id === id);
|
||||
if (index === -1) return;
|
||||
|
||||
if (useApi && authStore.isAuthenticated) {
|
||||
const skill = await skillsApi.updateSkill(id, {
|
||||
name: updates.name,
|
||||
description: updates.description,
|
||||
branch: updates.branch,
|
||||
parentId: updates.parentId,
|
||||
icon: updates.icon,
|
||||
color: updates.color,
|
||||
});
|
||||
skills = [...skills.slice(0, index), skill, ...skills.slice(index + 1)];
|
||||
} else {
|
||||
const updatedSkill = { ...skills[index], ...updates, updatedAt: new Date().toISOString() };
|
||||
await storage.saveSkill(updatedSkill);
|
||||
skills = [...skills.slice(0, index), updatedSkill, ...skills.slice(index + 1)];
|
||||
}
|
||||
await updateStats();
|
||||
}
|
||||
|
||||
async function deleteSkill(id: string): Promise<void> {
|
||||
if (useApi && authStore.isAuthenticated) {
|
||||
await skillsApi.deleteSkill(id);
|
||||
} else {
|
||||
await storage.deleteSkill(id);
|
||||
}
|
||||
skills = skills.filter((s) => s.id !== id);
|
||||
activities = activities.filter((a) => a.skillId !== id);
|
||||
await updateStats();
|
||||
}
|
||||
|
||||
async function addXp(
|
||||
skillId: string,
|
||||
xp: number,
|
||||
description: string,
|
||||
duration?: number
|
||||
): Promise<{ leveledUp: boolean; newLevel: number }> {
|
||||
const index = skills.findIndex((s) => s.id === skillId);
|
||||
if (index === -1) return { leveledUp: false, newLevel: 0 };
|
||||
|
||||
if (useApi && authStore.isAuthenticated) {
|
||||
const result = await skillsApi.addXp(skillId, { xp, description, duration });
|
||||
skills = [...skills.slice(0, index), result.skill, ...skills.slice(index + 1)];
|
||||
activities = [...activities, result.activity];
|
||||
await updateStats();
|
||||
return { leveledUp: result.leveledUp, newLevel: result.newLevel };
|
||||
} else {
|
||||
const skill = skills[index];
|
||||
const newTotalXp = skill.totalXp + xp;
|
||||
const newCurrentXp = skill.currentXp + xp;
|
||||
const newLevel = calculateLevel(newTotalXp);
|
||||
const leveledUp = newLevel > skill.level;
|
||||
|
||||
const updatedSkill: Skill = {
|
||||
...skill,
|
||||
totalXp: newTotalXp,
|
||||
currentXp: newCurrentXp,
|
||||
level: newLevel,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const activity = createActivity(skillId, xp, description, duration);
|
||||
|
||||
await Promise.all([storage.saveSkill(updatedSkill), storage.saveActivity(activity)]);
|
||||
|
||||
skills = [...skills.slice(0, index), updatedSkill, ...skills.slice(index + 1)];
|
||||
activities = [...activities, activity];
|
||||
await updateStats();
|
||||
|
||||
return { leveledUp, newLevel };
|
||||
}
|
||||
}
|
||||
|
||||
async function updateStats(): Promise<void> {
|
||||
if (useApi && authStore.isAuthenticated) {
|
||||
try {
|
||||
userStats = await skillsApi.getStats();
|
||||
} catch {
|
||||
// Calculate locally as fallback
|
||||
userStats = calculateLocalStats();
|
||||
}
|
||||
} else {
|
||||
userStats = await storage.recalculateStats();
|
||||
}
|
||||
}
|
||||
|
||||
function calculateLocalStats(): UserStats {
|
||||
return {
|
||||
totalXp: skills.reduce((sum, s) => sum + s.totalXp, 0),
|
||||
totalSkills: skills.length,
|
||||
highestLevel: skills.reduce((max, s) => Math.max(max, s.level), 0),
|
||||
streakDays: 0,
|
||||
lastActivityDate: activities.length > 0 ? activities[activities.length - 1].timestamp : null,
|
||||
};
|
||||
}
|
||||
|
||||
function getSkill(id: string): Skill | undefined {
|
||||
return skills.find((s) => s.id === id);
|
||||
}
|
||||
|
||||
function getSkillActivities(skillId: string): Activity[] {
|
||||
return activities.filter((a) => a.skillId === skillId);
|
||||
}
|
||||
|
||||
// Reinitialize when auth state changes
|
||||
async function reinitialize() {
|
||||
initialized = false;
|
||||
skills = [];
|
||||
activities = [];
|
||||
userStats = {
|
||||
totalXp: 0,
|
||||
totalSkills: 0,
|
||||
highestLevel: 0,
|
||||
streakDays: 0,
|
||||
lastActivityDate: null,
|
||||
};
|
||||
await initialize();
|
||||
}
|
||||
|
||||
// Export store as object with getters for reactive access
|
||||
export const skillStore = {
|
||||
get skills() {
|
||||
return skills;
|
||||
},
|
||||
get activities() {
|
||||
return activities;
|
||||
},
|
||||
get userStats() {
|
||||
return userStats;
|
||||
},
|
||||
get isLoading() {
|
||||
return isLoading;
|
||||
},
|
||||
get initialized() {
|
||||
return initialized;
|
||||
},
|
||||
get skillsByBranch() {
|
||||
return skillsByBranch;
|
||||
},
|
||||
get topSkills() {
|
||||
return topSkills;
|
||||
},
|
||||
get recentActivities() {
|
||||
return recentActivities;
|
||||
},
|
||||
get branchStats() {
|
||||
return branchStats;
|
||||
},
|
||||
get useApi() {
|
||||
return useApi;
|
||||
},
|
||||
|
||||
initialize,
|
||||
reinitialize,
|
||||
addSkill,
|
||||
updateSkill,
|
||||
deleteSkill,
|
||||
addXp,
|
||||
getSkill,
|
||||
getSkillActivities,
|
||||
};
|
||||
164
apps/skilltree/apps/web/src/lib/types/index.ts
Normal file
164
apps/skilltree/apps/web/src/lib/types/index.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
// Skill Tree Types
|
||||
|
||||
export type SkillBranch =
|
||||
| 'intellect'
|
||||
| 'body'
|
||||
| 'creativity'
|
||||
| 'social'
|
||||
| 'practical'
|
||||
| 'mindset'
|
||||
| 'custom';
|
||||
|
||||
export interface Skill {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
branch: SkillBranch;
|
||||
parentId: string | null;
|
||||
icon: string;
|
||||
color: string | null;
|
||||
currentXp: number;
|
||||
totalXp: number;
|
||||
level: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Activity {
|
||||
id: string;
|
||||
skillId: string;
|
||||
xpEarned: number;
|
||||
description: string;
|
||||
duration: number | null; // minutes
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface UserStats {
|
||||
totalXp: number;
|
||||
totalSkills: number;
|
||||
highestLevel: number;
|
||||
streakDays: number;
|
||||
lastActivityDate: string | null;
|
||||
}
|
||||
|
||||
// Level thresholds (XP needed for each level)
|
||||
export const LEVEL_THRESHOLDS = [0, 100, 500, 1500, 4000, 10000] as const;
|
||||
|
||||
export const LEVEL_NAMES = [
|
||||
'Unbekannt',
|
||||
'Anfänger',
|
||||
'Fortgeschritten',
|
||||
'Kompetent',
|
||||
'Experte',
|
||||
'Meister',
|
||||
] as const;
|
||||
|
||||
export const BRANCH_INFO: Record<
|
||||
SkillBranch,
|
||||
{ name: string; icon: string; color: string; description: string }
|
||||
> = {
|
||||
intellect: {
|
||||
name: 'Intellekt',
|
||||
icon: 'brain',
|
||||
color: 'var(--color-branch-intellect)',
|
||||
description: 'Wissen, Sprachen, Wissenschaft',
|
||||
},
|
||||
body: {
|
||||
name: 'Körper',
|
||||
icon: 'dumbbell',
|
||||
color: 'var(--color-branch-body)',
|
||||
description: 'Fitness, Sport, Gesundheit',
|
||||
},
|
||||
creativity: {
|
||||
name: 'Kreativität',
|
||||
icon: 'palette',
|
||||
color: 'var(--color-branch-creativity)',
|
||||
description: 'Kunst, Musik, Schreiben',
|
||||
},
|
||||
social: {
|
||||
name: 'Sozial',
|
||||
icon: 'users',
|
||||
color: 'var(--color-branch-social)',
|
||||
description: 'Kommunikation, Leadership, Empathie',
|
||||
},
|
||||
practical: {
|
||||
name: 'Praktisch',
|
||||
icon: 'wrench',
|
||||
color: 'var(--color-branch-practical)',
|
||||
description: 'Handwerk, Kochen, Technologie',
|
||||
},
|
||||
mindset: {
|
||||
name: 'Mindset',
|
||||
icon: 'heart',
|
||||
color: 'var(--color-branch-mindset)',
|
||||
description: 'Meditation, Fokus, Resilienz',
|
||||
},
|
||||
custom: {
|
||||
name: 'Eigene',
|
||||
icon: 'star',
|
||||
color: 'var(--color-primary)',
|
||||
description: 'Eigene Kategorien',
|
||||
},
|
||||
};
|
||||
|
||||
// Helper functions
|
||||
export function calculateLevel(xp: number): number {
|
||||
for (let i = LEVEL_THRESHOLDS.length - 1; i >= 0; i--) {
|
||||
if (xp >= LEVEL_THRESHOLDS[i]) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function xpForNextLevel(currentLevel: number): number {
|
||||
if (currentLevel >= LEVEL_THRESHOLDS.length - 1) {
|
||||
return Infinity;
|
||||
}
|
||||
return LEVEL_THRESHOLDS[currentLevel + 1];
|
||||
}
|
||||
|
||||
export function xpProgress(xp: number, level: number): number {
|
||||
if (level >= LEVEL_THRESHOLDS.length - 1) {
|
||||
return 100;
|
||||
}
|
||||
const currentThreshold = LEVEL_THRESHOLDS[level];
|
||||
const nextThreshold = LEVEL_THRESHOLDS[level + 1];
|
||||
const progress = ((xp - currentThreshold) / (nextThreshold - currentThreshold)) * 100;
|
||||
return Math.min(100, Math.max(0, progress));
|
||||
}
|
||||
|
||||
export function createDefaultSkill(partial: Partial<Skill> = {}): Skill {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
name: '',
|
||||
description: '',
|
||||
branch: 'custom',
|
||||
parentId: null,
|
||||
icon: 'star',
|
||||
color: null,
|
||||
currentXp: 0,
|
||||
totalXp: 0,
|
||||
level: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
export function createActivity(
|
||||
skillId: string,
|
||||
xpEarned: number,
|
||||
description: string,
|
||||
duration?: number
|
||||
): Activity {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
skillId,
|
||||
xpEarned,
|
||||
description,
|
||||
duration: duration ?? null,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
32
apps/skilltree/apps/web/src/routes/+layout.svelte
Normal file
32
apps/skilltree/apps/web/src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { skillStore } from '$lib/stores/skills.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let loading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
await skillStore.initialize();
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>SkillTree - Level Up Your Life</title>
|
||||
<meta name="description" content="Track your skills like a game. Level up in real life." />
|
||||
</svelte:head>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex min-h-screen items-center justify-center bg-gray-900">
|
||||
<div class="text-center">
|
||||
<div class="mb-4 text-6xl">🌳</div>
|
||||
<div class="text-xl text-gray-300">Loading SkillTree...</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="min-h-screen bg-gray-900 text-gray-100">
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
311
apps/skilltree/apps/web/src/routes/+page.svelte
Normal file
311
apps/skilltree/apps/web/src/routes/+page.svelte
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
<script lang="ts">
|
||||
import { skillStore } from '$lib/stores/skills.svelte';
|
||||
import { BRANCH_INFO } from '$lib/types';
|
||||
import type { Skill, SkillBranch } from '$lib/types';
|
||||
import SkillCard from '$lib/components/SkillCard.svelte';
|
||||
import AddSkillModal from '$lib/components/AddSkillModal.svelte';
|
||||
import AddXpModal from '$lib/components/AddXpModal.svelte';
|
||||
import EditSkillModal from '$lib/components/EditSkillModal.svelte';
|
||||
import LevelUpCelebration from '$lib/components/LevelUpCelebration.svelte';
|
||||
import StatsOverview from '$lib/components/StatsOverview.svelte';
|
||||
import SkillTemplates from '$lib/components/SkillTemplates.svelte';
|
||||
import {
|
||||
Plus,
|
||||
TreeDeciduous,
|
||||
Zap,
|
||||
Download,
|
||||
Upload,
|
||||
Sparkles,
|
||||
Network,
|
||||
} from 'lucide-svelte';
|
||||
|
||||
// Modal states
|
||||
let showAddSkillModal = $state(false);
|
||||
let showAddXpModal = $state(false);
|
||||
let showEditSkillModal = $state(false);
|
||||
let showTemplatesModal = $state(false);
|
||||
let selectedSkill = $state<Skill | null>(null);
|
||||
let selectedBranch = $state<SkillBranch | 'all'>('all');
|
||||
|
||||
// Level up celebration
|
||||
let showLevelUp = $state(false);
|
||||
let levelUpSkillName = $state('');
|
||||
let levelUpNewLevel = $state(0);
|
||||
|
||||
const filteredSkills = $derived(() => {
|
||||
if (selectedBranch === 'all') return skillStore.skills;
|
||||
return skillStore.skills.filter((s) => s.branch === selectedBranch);
|
||||
});
|
||||
|
||||
function openAddXpModal(skill: Skill) {
|
||||
selectedSkill = skill;
|
||||
showAddXpModal = true;
|
||||
}
|
||||
|
||||
function openEditModal(skill: Skill) {
|
||||
selectedSkill = skill;
|
||||
showEditSkillModal = true;
|
||||
}
|
||||
|
||||
function closeModals() {
|
||||
showAddXpModal = false;
|
||||
showEditSkillModal = false;
|
||||
selectedSkill = null;
|
||||
}
|
||||
|
||||
function triggerLevelUp(skillName: string, newLevel: number) {
|
||||
levelUpSkillName = skillName;
|
||||
levelUpNewLevel = newLevel;
|
||||
showLevelUp = true;
|
||||
}
|
||||
|
||||
async function handleAddXp(xp: number, description: string, duration?: number) {
|
||||
if (!selectedSkill) return;
|
||||
|
||||
const skillName = selectedSkill.name;
|
||||
const result = await skillStore.addXp(selectedSkill.id, xp, description, duration);
|
||||
|
||||
closeModals();
|
||||
|
||||
if (result.leveledUp) {
|
||||
triggerLevelUp(skillName, result.newLevel);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
const { exportData } = await import('$lib/services/storage');
|
||||
const data = await exportData();
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `skilltree-backup-${new Date().toISOString().split('T')[0]}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text);
|
||||
const { importData } = await import('$lib/services/storage');
|
||||
await importData(data);
|
||||
// Reload the store
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Import failed:', error);
|
||||
alert('Import fehlgeschlagen. Bitte überprüfe die Datei.');
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen">
|
||||
<!-- Header -->
|
||||
<header class="border-b border-gray-800 bg-gray-900/80 backdrop-blur-sm sticky top-0 z-40">
|
||||
<div class="mx-auto max-w-7xl px-4 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<TreeDeciduous class="h-8 w-8 text-emerald-500" />
|
||||
<h1 class="text-2xl font-bold text-white">SkillTree</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Tree View -->
|
||||
<a
|
||||
href="/tree"
|
||||
class="rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-800 hover:text-emerald-400"
|
||||
title="Skill-Tree Ansicht"
|
||||
>
|
||||
<Network class="h-5 w-5" />
|
||||
</a>
|
||||
<!-- Templates -->
|
||||
<button
|
||||
onclick={() => (showTemplatesModal = true)}
|
||||
class="rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-800 hover:text-yellow-500"
|
||||
title="Skill-Vorlagen"
|
||||
>
|
||||
<Sparkles class="h-5 w-5" />
|
||||
</button>
|
||||
<!-- Export/Import -->
|
||||
<button
|
||||
onclick={handleExport}
|
||||
class="rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-800 hover:text-white"
|
||||
title="Daten exportieren"
|
||||
>
|
||||
<Download class="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onclick={handleImport}
|
||||
class="rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-800 hover:text-white"
|
||||
title="Daten importieren"
|
||||
>
|
||||
<Upload class="h-5 w-5" />
|
||||
</button>
|
||||
<!-- Add Skill -->
|
||||
<button
|
||||
onclick={() => (showAddSkillModal = true)}
|
||||
class="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 font-medium text-white transition-colors hover:bg-emerald-500"
|
||||
>
|
||||
<Plus class="h-5 w-5" />
|
||||
<span class="hidden sm:inline">Skill hinzufügen</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto max-w-7xl px-4 py-8">
|
||||
<!-- Stats Overview -->
|
||||
<StatsOverview />
|
||||
|
||||
<!-- Branch Filter -->
|
||||
<div class="mb-6 mt-8">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
onclick={() => (selectedBranch = 'all')}
|
||||
class="rounded-full px-4 py-2 text-sm font-medium transition-colors {selectedBranch === 'all'
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'}"
|
||||
>
|
||||
Alle ({skillStore.skills.length})
|
||||
</button>
|
||||
{#each Object.entries(BRANCH_INFO) as [branch, info]}
|
||||
{@const count = skillStore.skills.filter((s) => s.branch === branch).length}
|
||||
{#if count > 0 || branch !== 'custom'}
|
||||
<button
|
||||
onclick={() => (selectedBranch = branch as SkillBranch)}
|
||||
class="rounded-full px-4 py-2 text-sm font-medium transition-colors {selectedBranch === branch
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'}"
|
||||
>
|
||||
{info.name} ({count})
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Skills Grid -->
|
||||
{#if filteredSkills().length === 0}
|
||||
<div class="mt-16 text-center">
|
||||
<div class="mx-auto mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-gray-800">
|
||||
<TreeDeciduous class="h-12 w-12 text-gray-600" />
|
||||
</div>
|
||||
<h2 class="mb-2 text-xl font-semibold text-gray-300">Noch keine Skills</h2>
|
||||
<p class="mb-6 text-gray-500">
|
||||
Füge deinen ersten Skill hinzu und beginne dein Abenteuer!
|
||||
</p>
|
||||
<button
|
||||
onclick={() => (showAddSkillModal = true)}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-6 py-3 font-medium text-white transition-colors hover:bg-emerald-500"
|
||||
>
|
||||
<Plus class="h-5 w-5" />
|
||||
Ersten Skill erstellen
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each filteredSkills() as skill (skill.id)}
|
||||
<SkillCard
|
||||
{skill}
|
||||
onAddXp={() => openAddXpModal(skill)}
|
||||
onEdit={() => openEditModal(skill)}
|
||||
onDelete={() => skillStore.deleteSkill(skill.id)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Recent Activity -->
|
||||
{#if skillStore.recentActivities().length > 0}
|
||||
<div class="mt-12">
|
||||
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-white">
|
||||
<Zap class="h-5 w-5 text-yellow-500" />
|
||||
Letzte Aktivitäten
|
||||
</h2>
|
||||
<div class="space-y-2">
|
||||
{#each skillStore.recentActivities().slice(0, 5) as activity}
|
||||
{@const skill = skillStore.getSkill(activity.skillId)}
|
||||
{#if skill}
|
||||
<div class="flex items-center justify-between rounded-lg bg-gray-800/50 px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-900/50 text-sm font-medium text-emerald-400">
|
||||
+{activity.xpEarned}
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-white">{skill.name}</span>
|
||||
<span class="text-gray-400"> - {activity.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm text-gray-500">
|
||||
{new Date(activity.timestamp).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Modals -->
|
||||
{#if showAddSkillModal}
|
||||
<AddSkillModal
|
||||
onClose={() => (showAddSkillModal = false)}
|
||||
onSave={async (skill) => {
|
||||
await skillStore.addSkill(skill);
|
||||
showAddSkillModal = false;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showAddXpModal && selectedSkill}
|
||||
<AddXpModal
|
||||
skill={selectedSkill}
|
||||
onClose={closeModals}
|
||||
onSave={handleAddXp}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showEditSkillModal && selectedSkill}
|
||||
<EditSkillModal
|
||||
skill={selectedSkill}
|
||||
onClose={closeModals}
|
||||
onSave={async (updates) => {
|
||||
if (selectedSkill) {
|
||||
await skillStore.updateSkill(selectedSkill.id, updates);
|
||||
}
|
||||
}}
|
||||
onDelete={() => {
|
||||
if (selectedSkill) {
|
||||
skillStore.deleteSkill(selectedSkill.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showLevelUp}
|
||||
<LevelUpCelebration
|
||||
skillName={levelUpSkillName}
|
||||
newLevel={levelUpNewLevel}
|
||||
onClose={() => (showLevelUp = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showTemplatesModal}
|
||||
<SkillTemplates
|
||||
onClose={() => (showTemplatesModal = false)}
|
||||
onAddSkill={async (skill) => {
|
||||
await skillStore.addSkill(skill);
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
241
apps/skilltree/apps/web/src/routes/tree/+page.svelte
Normal file
241
apps/skilltree/apps/web/src/routes/tree/+page.svelte
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
<script lang="ts">
|
||||
import { skillStore } from '$lib/stores/skills.svelte';
|
||||
import { BRANCH_INFO, LEVEL_NAMES } from '$lib/types';
|
||||
import type { SkillBranch } from '$lib/types';
|
||||
import { ArrowLeft, Star } from 'lucide-svelte';
|
||||
|
||||
// Group skills by branch for radial layout
|
||||
const branches = Object.keys(BRANCH_INFO) as SkillBranch[];
|
||||
|
||||
// Calculate position for each branch (radial layout)
|
||||
function getBranchPosition(branchIndex: number, total: number) {
|
||||
const angle = (branchIndex / total) * 2 * Math.PI - Math.PI / 2;
|
||||
const radius = 280;
|
||||
return {
|
||||
x: 400 + Math.cos(angle) * radius,
|
||||
y: 400 + Math.sin(angle) * radius,
|
||||
angle: (angle * 180) / Math.PI,
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate skill position within a branch
|
||||
function getSkillPosition(
|
||||
branchIndex: number,
|
||||
skillIndex: number,
|
||||
skillCount: number,
|
||||
total: number
|
||||
) {
|
||||
const branchAngle = (branchIndex / total) * 2 * Math.PI - Math.PI / 2;
|
||||
const spreadAngle = 0.3; // How much skills spread within a branch
|
||||
const baseRadius = 180;
|
||||
const radiusStep = 60;
|
||||
|
||||
// Spread skills in a small arc
|
||||
const skillAngle = branchAngle + (skillIndex - (skillCount - 1) / 2) * (spreadAngle / Math.max(skillCount - 1, 1));
|
||||
const radius = baseRadius + skillIndex * radiusStep * 0.3;
|
||||
|
||||
return {
|
||||
x: 400 + Math.cos(skillAngle) * radius,
|
||||
y: 400 + Math.sin(skillAngle) * radius,
|
||||
};
|
||||
}
|
||||
|
||||
function getLevelColor(level: number): string {
|
||||
const colors = ['#6b7280', '#3b82f6', '#8b5cf6', '#ec4899', '#f97316', '#fbbf24'];
|
||||
return colors[level] ?? colors[0];
|
||||
}
|
||||
|
||||
function getNodeSize(level: number): number {
|
||||
return 24 + level * 6;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Skill Tree View - SkillTree</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen bg-gray-900 text-white">
|
||||
<!-- Header -->
|
||||
<header class="border-b border-gray-800 bg-gray-900/80 backdrop-blur-sm sticky top-0 z-40">
|
||||
<div class="mx-auto max-w-7xl px-4 py-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center gap-2 rounded-lg px-3 py-2 text-gray-400 transition-colors hover:bg-gray-800 hover:text-white"
|
||||
>
|
||||
<ArrowLeft class="h-5 w-5" />
|
||||
Zurück
|
||||
</a>
|
||||
<h1 class="text-xl font-bold">Skill Tree Visualisierung</h1>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="p-4">
|
||||
{#if skillStore.skills.length === 0}
|
||||
<div class="mt-16 text-center">
|
||||
<p class="text-gray-400">Noch keine Skills vorhanden. Erstelle zuerst einige Skills!</p>
|
||||
<a
|
||||
href="/"
|
||||
class="mt-4 inline-block rounded-lg bg-emerald-600 px-4 py-2 font-medium text-white hover:bg-emerald-500"
|
||||
>
|
||||
Skills erstellen
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Legend -->
|
||||
<div class="mb-6 flex flex-wrap justify-center gap-4">
|
||||
{#each Object.entries(BRANCH_INFO) as [branch, info]}
|
||||
{@const count = skillStore.skills.filter((s) => s.branch === branch).length}
|
||||
{#if count > 0}
|
||||
<div class="flex items-center gap-2 rounded-full bg-gray-800 px-3 py-1.5 text-sm">
|
||||
<span class="h-3 w-3 rounded-full" style="background-color: {info.color}"></span>
|
||||
{info.name} ({count})
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Tree SVG -->
|
||||
<div class="flex justify-center overflow-auto">
|
||||
<svg
|
||||
viewBox="0 0 800 800"
|
||||
class="max-w-full"
|
||||
style="min-width: 600px; height: auto; max-height: 80vh;"
|
||||
>
|
||||
<!-- Background circles -->
|
||||
<circle cx="400" cy="400" r="120" fill="none" stroke="#374151" stroke-width="1" stroke-dasharray="4" />
|
||||
<circle cx="400" cy="400" r="200" fill="none" stroke="#374151" stroke-width="1" stroke-dasharray="4" />
|
||||
<circle cx="400" cy="400" r="280" fill="none" stroke="#374151" stroke-width="1" stroke-dasharray="4" />
|
||||
|
||||
<!-- Center node -->
|
||||
<circle cx="400" cy="400" r="50" fill="#10b981" opacity="0.2" />
|
||||
<circle cx="400" cy="400" r="40" fill="#10b981" opacity="0.4" />
|
||||
<circle cx="400" cy="400" r="30" fill="#10b981" />
|
||||
<text x="400" y="405" text-anchor="middle" fill="white" font-size="12" font-weight="bold">
|
||||
YOU
|
||||
</text>
|
||||
|
||||
<!-- Branch lines and labels -->
|
||||
{#each branches as branch, i}
|
||||
{@const pos = getBranchPosition(i, branches.length)}
|
||||
{@const branchSkills = skillStore.skills.filter((s) => s.branch === branch)}
|
||||
{#if branchSkills.length > 0}
|
||||
<!-- Line from center to branch -->
|
||||
<line
|
||||
x1="400"
|
||||
y1="400"
|
||||
x2={pos.x}
|
||||
y2={pos.y}
|
||||
stroke={BRANCH_INFO[branch].color}
|
||||
stroke-width="2"
|
||||
opacity="0.3"
|
||||
/>
|
||||
|
||||
<!-- Branch label -->
|
||||
<text
|
||||
x={pos.x}
|
||||
y={pos.y}
|
||||
text-anchor="middle"
|
||||
fill={BRANCH_INFO[branch].color}
|
||||
font-size="14"
|
||||
font-weight="bold"
|
||||
dy="-20"
|
||||
>
|
||||
{BRANCH_INFO[branch].name}
|
||||
</text>
|
||||
|
||||
<!-- Skills in this branch -->
|
||||
{#each branchSkills as skill, j}
|
||||
{@const skillPos = getSkillPosition(i, j, branchSkills.length, branches.length)}
|
||||
{@const size = getNodeSize(skill.level)}
|
||||
|
||||
<!-- Connection line -->
|
||||
<line
|
||||
x1="400"
|
||||
y1="400"
|
||||
x2={skillPos.x}
|
||||
y2={skillPos.y}
|
||||
stroke={BRANCH_INFO[branch].color}
|
||||
stroke-width="1"
|
||||
opacity="0.2"
|
||||
/>
|
||||
|
||||
<!-- Skill node -->
|
||||
<g class="tree-node cursor-pointer" transform="translate({skillPos.x}, {skillPos.y})">
|
||||
<!-- Glow effect for high level -->
|
||||
{#if skill.level >= 4}
|
||||
<circle
|
||||
r={size + 8}
|
||||
fill={getLevelColor(skill.level)}
|
||||
opacity="0.2"
|
||||
class="animate-pulse"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Node background -->
|
||||
<circle
|
||||
r={size}
|
||||
fill="#1f2937"
|
||||
stroke={getLevelColor(skill.level)}
|
||||
stroke-width="3"
|
||||
/>
|
||||
|
||||
<!-- Level indicator -->
|
||||
<text
|
||||
text-anchor="middle"
|
||||
dy="5"
|
||||
fill={getLevelColor(skill.level)}
|
||||
font-size="14"
|
||||
font-weight="bold"
|
||||
>
|
||||
{skill.level}
|
||||
</text>
|
||||
|
||||
<!-- Skill name (on hover/always for important skills) -->
|
||||
<title>{skill.name} (Level {skill.level} - {skill.totalXp} XP)</title>
|
||||
</g>
|
||||
|
||||
<!-- Skill label -->
|
||||
<text
|
||||
x={skillPos.x}
|
||||
y={skillPos.y + size + 16}
|
||||
text-anchor="middle"
|
||||
fill="#9ca3af"
|
||||
font-size="10"
|
||||
class="pointer-events-none"
|
||||
>
|
||||
{skill.name.length > 12 ? skill.name.slice(0, 12) + '...' : skill.name}
|
||||
</text>
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Level Legend -->
|
||||
<div class="mt-8 flex flex-wrap justify-center gap-4">
|
||||
{#each LEVEL_NAMES as name, level}
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<div
|
||||
class="flex h-6 w-6 items-center justify-center rounded-full border-2 text-xs font-bold"
|
||||
style="border-color: {getLevelColor(level)}; color: {getLevelColor(level)}"
|
||||
>
|
||||
{level}
|
||||
</div>
|
||||
<span class="text-gray-400">{name}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tree-node {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.tree-node:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
</style>
|
||||
21
apps/skilltree/apps/web/static/manifest.json
Normal file
21
apps/skilltree/apps/web/static/manifest.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "SkillTree",
|
||||
"short_name": "SkillTree",
|
||||
"description": "Track your skills like a game. Level up in real life.",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#111827",
|
||||
"theme_color": "#10b981",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
14
apps/skilltree/apps/web/svelte.config.js
Normal file
14
apps/skilltree/apps/web/svelte.config.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
out: 'build',
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
14
apps/skilltree/apps/web/tsconfig.json
Normal file
14
apps/skilltree/apps/web/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
17
apps/skilltree/apps/web/vite.config.ts
Normal file
17
apps/skilltree/apps/web/vite.config.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
server: {
|
||||
port: 5195,
|
||||
strictPort: true,
|
||||
},
|
||||
ssr: {
|
||||
noExternal: ['@manacore/shared-tailwind', '@manacore/shared-theme'],
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['@manacore/shared-tailwind', '@manacore/shared-theme'],
|
||||
},
|
||||
});
|
||||
14
apps/skilltree/package.json
Normal file
14
apps/skilltree/package.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "skilltree",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "SkillTree - Gamified Personal Skill Tracking",
|
||||
"scripts": {
|
||||
"dev": "pnpm run --filter=@skilltree/* --parallel dev",
|
||||
"dev:web": "pnpm --filter @skilltree/web dev"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.0"
|
||||
}
|
||||
|
|
@ -89,7 +89,7 @@ services:
|
|||
SMTP_USER: ${SMTP_USER:-94cde5002@smtp-brevo.com}
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD}
|
||||
SMTP_FROM: ManaCore <noreply@mana.how>
|
||||
CORS_ORIGINS: https://mana.how,https://chat.mana.how,https://todo.mana.how,https://calendar.mana.how,https://clock.mana.how,https://contacts.mana.how,https://storage.mana.how,https://presi.mana.how,https://nutriphi.mana.how
|
||||
CORS_ORIGINS: https://mana.how,https://chat.mana.how,https://todo.mana.how,https://calendar.mana.how,https://clock.mana.how,https://contacts.mana.how,https://storage.mana.how,https://presi.mana.how,https://nutriphi.mana.how,https://skilltree.mana.how
|
||||
# DuckDB Analytics (Business Metrics)
|
||||
DUCKDB_PATH: /data/analytics/metrics.duckdb
|
||||
volumes:
|
||||
|
|
@ -589,6 +589,60 @@ services:
|
|||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# ============================================
|
||||
# SkillTree App (Gamified Skill Tracking)
|
||||
# ============================================
|
||||
|
||||
skilltree-backend:
|
||||
image: ghcr.io/memo-2023/skilltree-backend:latest
|
||||
container_name: skilltree-backend
|
||||
restart: always
|
||||
depends_on:
|
||||
mana-core-auth:
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3024
|
||||
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-manacore123}@postgres:5432/skilltree
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
DB_USER: postgres
|
||||
MANA_CORE_AUTH_URL: http://mana-core-auth:3001
|
||||
CORS_ORIGINS: https://skilltree.mana.how,https://mana.how
|
||||
ports:
|
||||
- "3024:3024"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3024/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
skilltree-web:
|
||||
image: ghcr.io/memo-2023/skilltree-web:latest
|
||||
container_name: skilltree-web
|
||||
restart: always
|
||||
depends_on:
|
||||
skilltree-backend:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 5195
|
||||
PUBLIC_BACKEND_URL: http://skilltree-backend:3024
|
||||
PUBLIC_MANA_CORE_AUTH_URL: http://mana-core-auth:3001
|
||||
PUBLIC_BACKEND_URL_CLIENT: https://skilltree-api.mana.how
|
||||
PUBLIC_MANA_CORE_AUTH_URL_CLIENT: https://auth.mana.how
|
||||
ports:
|
||||
- "5195:5195"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:5195/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# ============================================
|
||||
# Monitoring Stack
|
||||
# ============================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue