diff --git a/.claude/guidelines/authentication.md b/.claude/guidelines/authentication.md index fe885da13..d0f9a5dae 100644 --- a/.claude/guidelines/authentication.md +++ b/.claude/guidelines/authentication.md @@ -32,11 +32,37 @@ All authentication is handled by **Mana Core Auth**, a centralized authenticatio │<──────────────────────│ │ ``` +## User ID Format + +**CRITICAL**: Mana Core Auth uses Better Auth, which generates **non-UUID user IDs**. + +``` +Example user ID: otUe1YrfENPdHnrF3g1vSBfpkQfambCZ +``` + +**Format details:** +- 32 characters +- Base62 alphabet (a-z, A-Z, 0-9) +- ~190 bits of entropy (more than UUID's 122 bits) +- NOT a valid UUID format + +**Database schema implications:** + +```typescript +// CORRECT - use text for user_id +userId: text('user_id').notNull(), + +// WRONG - will cause "invalid input syntax for type uuid" errors +userId: uuid('user_id').notNull(), +``` + +Always use `text` type for `user_id` columns in all database schemas. + ## Token Structure (EdDSA JWT) ```json { - "sub": "user-uuid-123", + "sub": "otUe1YrfENPdHnrF3g1vSBfpkQfambCZ", "email": "user@example.com", "role": "user", "sid": "session-id-456", @@ -47,6 +73,8 @@ All authentication is handled by **Mana Core Auth**, a centralized authenticatio } ``` +**Note**: The `sub` claim contains the Better Auth user ID (not a UUID). + **Important**: Keep claims minimal. Do NOT include: - Credit balance (changes frequently) - Organization data (use API instead) diff --git a/.claude/guidelines/database.md b/.claude/guidelines/database.md index a503f86cd..285803ff3 100644 --- a/.claude/guidelines/database.md +++ b/.claude/guidelines/database.md @@ -101,6 +101,26 @@ src/db/ └── migrations/ # Generated migrations ``` +### User ID Column Type + +**CRITICAL**: Always use `text` for `user_id` columns, NOT `uuid`. + +Mana Core Auth (Better Auth) generates non-UUID user IDs: + +``` +Example: otUe1YrfENPdHnrF3g1vSBfpkQfambCZ +``` + +```typescript +// CORRECT +userId: text('user_id').notNull(), + +// WRONG - causes "invalid input syntax for type uuid" errors +userId: uuid('user_id').notNull(), +``` + +See [Authentication Guidelines](./authentication.md#user-id-format) for details. + ### Table Definition Pattern ```typescript @@ -123,8 +143,10 @@ export const files = pgTable( // Primary key - always UUID with auto-generation id: uuid('id').primaryKey().defaultRandom(), - // Foreign keys - userId: varchar('user_id', { length: 255 }).notNull(), + // User ID - always TEXT (Better Auth uses non-UUID format) + userId: text('user_id').notNull(), + + // Foreign keys to other tables (UUIDs are fine) parentFolderId: uuid('parent_folder_id').references(() => folders.id, { onDelete: 'set null' }), // Required fields diff --git a/.env.development b/.env.development index 92535d6e6..4784d4393 100644 --- a/.env.development +++ b/.env.development @@ -221,6 +221,7 @@ CLOCK_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/clock # ============================================ TODO_BACKEND_PORT=3018 +TODO_BACKEND_URL=http://localhost:3018 TODO_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/todo # ============================================ diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..0cc300b00 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,38 @@ +# CODEOWNERS - Defines code ownership for PR review requirements +# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners + +# ============================================================================= +# Staging & Production Configuration +# ============================================================================= +# These files control production/staging deployments and require team lead review +# to prevent accidental configuration regressions (like HTTP vs HTTPS URLs) + +docker-compose.staging.yml @wuesteon +docker-compose.production.yml @wuesteon +docker/caddy/Caddyfile.staging @wuesteon +docker/caddy/Caddyfile.production @wuesteon + +# ============================================================================= +# CI/CD Workflows +# ============================================================================= +# Changes to deployment pipelines require review + +.github/workflows/cd-*.yml @wuesteon +.github/workflows/ci.yml @wuesteon + +# ============================================================================= +# Core Infrastructure +# ============================================================================= +# Shared packages and services that affect all apps + +services/mana-core-auth/ @wuesteon +packages/shared-nestjs-auth/ @wuesteon +packages/shared-auth/ @wuesteon + +# ============================================================================= +# Workspace Configuration +# ============================================================================= +# Root configuration files that affect the entire monorepo + +pnpm-workspace.yaml @wuesteon +turbo.json @wuesteon diff --git a/.github/workflows/cd-staging-tagged.yml b/.github/workflows/cd-staging-tagged.yml index 5efce798b..978940825 100644 --- a/.github/workflows/cd-staging-tagged.yml +++ b/.github/workflows/cd-staging-tagged.yml @@ -361,15 +361,18 @@ jobs: # Service name matches docker-compose service name (with hyphens) SERVICE_NAME="$IMAGE_NAME" + CONTAINER_NAME="${IMAGE_NAME}-staging" - if docker compose ps -a | grep -q "$IMAGE_NAME"; then - echo "Updating existing service: \$SERVICE_NAME" - docker compose up -d --no-deps --force-recreate \$SERVICE_NAME - else - echo "Service \$SERVICE_NAME not found in compose, starting..." - docker compose up -d \$SERVICE_NAME + # Remove any stale container with the same name (prevents "name already in use" error) + if docker ps -a --format '{{.Names}}' | grep -q "^\$CONTAINER_NAME\$"; then + echo "Removing stale container: \$CONTAINER_NAME" + docker rm -f \$CONTAINER_NAME 2>/dev/null || true fi + # Always use --force-recreate to ensure the new image is used + echo "Deploying service: \$SERVICE_NAME" + docker compose up -d --no-deps --force-recreate \$SERVICE_NAME + # Wait for startup sleep 10 docker compose ps \$SERVICE_NAME @@ -416,17 +419,118 @@ jobs: echo "- **Deployed by**: ${{ github.actor }}" >> $GITHUB_STEP_SUMMARY echo "- **Timestamp**: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" >> $GITHUB_STEP_SUMMARY + # Run database migrations after deploy + migrations: + name: Database Migrations + runs-on: ubuntu-latest + needs: [parse-deployment, deploy] + # Only run for projects with backends (not manacore which is web-only) + if: needs.parse-deployment.outputs.project != 'manacore' + steps: + - name: Setup SSH + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.STAGING_SSH_KEY }} + + - name: Add staging server to known hosts + run: | + mkdir -p ~/.ssh + ssh-keyscan -H ${{ secrets.STAGING_HOST }} >> ~/.ssh/known_hosts + + - name: Run database migrations + env: + PROJECT: ${{ needs.parse-deployment.outputs.project }} + run: | + # Determine service name based on project + case "$PROJECT" in + mana-core-auth) + SERVICE_NAME="mana-core-auth" + ;; + *) + SERVICE_NAME="${PROJECT}-backend" + ;; + esac + + echo "Running database migrations for $SERVICE_NAME..." + + ssh ${{ secrets.STAGING_USER }}@${{ secrets.STAGING_HOST }} << EOF + cd ~/manacore-staging + + echo "=== Database Migration for $SERVICE_NAME ===" + + # Check if service is running + if ! docker compose ps $SERVICE_NAME --format '{{.State}}' 2>/dev/null | grep -q "running"; then + echo "⚠️ Service $SERVICE_NAME is not running, skipping migrations" + exit 0 + fi + + # Migration function with retry logic + run_db_push() { + local service=\$1 + local max_attempts=3 + local timeout=120 # 2 minutes + local attempt=1 + + while [ \$attempt -le \$max_attempts ]; do + echo "[\$service] db:push attempt \$attempt/\$max_attempts..." + + # Try db:push with timeout (staging uses push, not migrate) + if timeout \$timeout docker compose exec -T \$service pnpm run db:push 2>&1; then + echo "✅ [\$service] Database schema pushed successfully" + return 0 + else + exit_code=\$? + if [ \$exit_code -eq 124 ]; then + echo "⚠️ [\$service] db:push timeout after \${timeout}s" + else + echo "⚠️ [\$service] db:push failed with exit code \$exit_code" + fi + + attempt=\$((attempt + 1)) + if [ \$attempt -le \$max_attempts ]; then + wait_time=\$((5 * attempt)) # Backoff: 5s, 10s, 15s + echo " Waiting \${wait_time}s before retry..." + sleep \$wait_time + fi + fi + done + + echo "❌ [\$service] db:push failed after \$max_attempts attempts" + return 1 + } + + # Run db:push for the service + run_db_push $SERVICE_NAME || { + echo "❌ Database migration failed for $SERVICE_NAME" + echo "⚠️ You may need to run migrations manually:" + echo " ssh deploy@\${{ secrets.STAGING_HOST }} 'cd ~/manacore-staging && docker compose exec -T $SERVICE_NAME pnpm run db:push'" + exit 1 + } + + echo "" + echo "✅ Database migrations completed for $SERVICE_NAME" + EOF + + - name: Migration summary + if: always() + run: | + echo "## Database Migrations" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- **Project**: ${{ needs.parse-deployment.outputs.project }}" >> $GITHUB_STEP_SUMMARY + echo "- **Status**: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY + # Notify on completion notify: name: Deployment Complete runs-on: ubuntu-latest - needs: [parse-deployment, build, deploy] + needs: [parse-deployment, build, deploy, migrations] if: always() steps: - name: Deployment notification run: | BUILD_STATUS="${{ needs.build.result }}" DEPLOY_STATUS="${{ needs.deploy.result }}" + MIGRATION_STATUS="${{ needs.migrations.result }}" PROJECT="${{ needs.parse-deployment.outputs.project }}" VERSION="${{ needs.parse-deployment.outputs.version }}" @@ -436,13 +540,21 @@ jobs: echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY echo "| Build | $BUILD_STATUS |" >> $GITHUB_STEP_SUMMARY echo "| Deploy | $DEPLOY_STATUS |" >> $GITHUB_STEP_SUMMARY + echo "| Migrations | $MIGRATION_STATUS |" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "- **Project**: $PROJECT" >> $GITHUB_STEP_SUMMARY echo "- **Version**: $VERSION" >> $GITHUB_STEP_SUMMARY + # Check all stages (migrations can be skipped for web-only projects) if [ "$BUILD_STATUS" == "success" ] && [ "$DEPLOY_STATUS" == "success" ]; then - echo "" >> $GITHUB_STEP_SUMMARY - echo "All apps deployed successfully to staging" >> $GITHUB_STEP_SUMMARY + if [ "$MIGRATION_STATUS" == "success" ] || [ "$MIGRATION_STATUS" == "skipped" ]; then + echo "" >> $GITHUB_STEP_SUMMARY + echo "All stages completed successfully" >> $GITHUB_STEP_SUMMARY + else + echo "" >> $GITHUB_STEP_SUMMARY + echo "⚠️ Migrations failed - database may need manual update" >> $GITHUB_STEP_SUMMARY + exit 1 + fi else echo "" >> $GITHUB_STEP_SUMMARY echo "Some deployments failed - check individual job logs" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/staging-config-check.yml b/.github/workflows/staging-config-check.yml new file mode 100644 index 000000000..cfd9aef20 --- /dev/null +++ b/.github/workflows/staging-config-check.yml @@ -0,0 +1,103 @@ +name: Staging Config Check + +on: + pull_request: + paths: + - 'docker-compose.staging.yml' + - 'docker/caddy/Caddyfile.staging' + +jobs: + check-staging-urls: + name: Validate Staging URLs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check for HTTP IP addresses in _CLIENT URLs + run: | + echo "Checking docker-compose.staging.yml for HTTP IP addresses..." + + # Check that no _CLIENT URLs use HTTP IP addresses + if grep -E '_CLIENT:.*http://[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' docker-compose.staging.yml; then + echo "" + echo "::error::Found HTTP IP addresses in _CLIENT URLs!" + echo "All _CLIENT URLs must use HTTPS staging domains (e.g., https://auth.staging.manacore.ai)" + exit 1 + fi + + echo "No HTTP IP addresses found in _CLIENT URLs" + + - name: Check for non-HTTPS external URLs + run: | + echo "Checking for non-HTTPS external URLs in _CLIENT variables..." + + # Check that _CLIENT URLs use HTTPS (excluding localhost for dev) + VIOLATIONS=$(grep -E '_CLIENT:.*http://' docker-compose.staging.yml | grep -v localhost || true) + + if [ -n "$VIOLATIONS" ]; then + echo "" + echo "::error::Found non-HTTPS URLs in _CLIENT variables!" + echo "$VIOLATIONS" + echo "" + echo "All _CLIENT URLs must use HTTPS for staging domains." + exit 1 + fi + + echo "All _CLIENT URLs use HTTPS" + + - name: Verify required HTTPS domains + run: | + echo "Verifying required HTTPS staging domains are configured..." + + REQUIRED_DOMAINS=( + "https://auth.staging.manacore.ai" + "https://staging.manacore.ai" + ) + + MISSING=0 + for domain in "${REQUIRED_DOMAINS[@]}"; do + if ! grep -q "$domain" docker-compose.staging.yml; then + echo "::warning::Missing required domain: $domain" + MISSING=1 + fi + done + + if [ $MISSING -eq 1 ]; then + echo "" + echo "::warning::Some required staging domains are not configured. Please verify this is intentional." + fi + + echo "Domain verification complete" + + - name: Check CORS origins include HTTPS + run: | + echo "Checking CORS_ORIGINS for HTTPS staging domains..." + + # Extract CORS_ORIGINS lines and check they include staging domains + CORS_LINES=$(grep "CORS_ORIGINS:" docker-compose.staging.yml || true) + + if [ -n "$CORS_LINES" ]; then + # Check if any CORS line has HTTP staging domains (not localhost) + HTTP_CORS=$(echo "$CORS_LINES" | grep -E 'http://[a-z]+\.staging\.manacore\.ai' || true) + + if [ -n "$HTTP_CORS" ]; then + echo "" + echo "::error::Found HTTP (non-HTTPS) staging domains in CORS_ORIGINS!" + echo "$HTTP_CORS" + exit 1 + fi + fi + + echo "CORS origins are correctly configured" + + - name: Summary + run: | + echo "" + echo "======================================" + echo "Staging Configuration Check: PASSED" + echo "======================================" + echo "" + echo "All checks passed:" + echo " - No HTTP IP addresses in _CLIENT URLs" + echo " - All external _CLIENT URLs use HTTPS" + echo " - CORS origins correctly configured" diff --git a/COMMANDS.md b/COMMANDS.md index 058e47e21..b64107c1f 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -9,8 +9,9 @@ pnpm docker:down pnpm dev:calendar:app pnpm dev:todo:full pnpm dev:contacts:full +pnpm dev:clock:full + pnpm dev:chat:app -pnpm dev:clock:app pnpm dev:context:app pnpm dev:manacore:app # Nur ManaCore Web pnpm dev:manacore:backends # Alle 9 Backends für Dashboard-Widgets diff --git a/apps/calendar/apps/backend/src/app.module.ts b/apps/calendar/apps/backend/src/app.module.ts index cc315674e..38844c46d 100644 --- a/apps/calendar/apps/backend/src/app.module.ts +++ b/apps/calendar/apps/backend/src/app.module.ts @@ -5,8 +5,10 @@ import { DatabaseModule } from './db/database.module'; import { HealthModule } from './health/health.module'; import { CalendarModule } from './calendar/calendar.module'; import { EventModule } from './event/event.module'; +import { EventTagModule } from './event-tag/event-tag.module'; import { ReminderModule } from './reminder/reminder.module'; import { ShareModule } from './share/share.module'; +import { NetworkModule } from './network/network.module'; @Module({ imports: [ @@ -19,8 +21,10 @@ import { ShareModule } from './share/share.module'; HealthModule, CalendarModule, EventModule, + EventTagModule, ReminderModule, ShareModule, + NetworkModule, ], }) export class AppModule {} diff --git a/apps/calendar/apps/backend/src/db/schema/calendars.schema.ts b/apps/calendar/apps/backend/src/db/schema/calendars.schema.ts index e4560be55..03da70e7e 100644 --- a/apps/calendar/apps/backend/src/db/schema/calendars.schema.ts +++ b/apps/calendar/apps/backend/src/db/schema/calendars.schema.ts @@ -1,4 +1,4 @@ -import { pgTable, uuid, timestamp, varchar, text, boolean, jsonb } from 'drizzle-orm/pg-core'; +import { pgTable, uuid, text, timestamp, varchar, boolean, jsonb } from 'drizzle-orm/pg-core'; /** * Calendar settings stored in JSONB @@ -16,7 +16,7 @@ export interface CalendarSettings { */ export const calendars = pgTable('calendars', { id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id').notNull(), + userId: text('user_id').notNull(), name: varchar('name', { length: 255 }).notNull(), description: text('description'), color: varchar('color', { length: 7 }).default('#3B82F6'), diff --git a/apps/calendar/apps/backend/src/db/schema/event-tags.schema.ts b/apps/calendar/apps/backend/src/db/schema/event-tags.schema.ts new file mode 100644 index 000000000..43e22848d --- /dev/null +++ b/apps/calendar/apps/backend/src/db/schema/event-tags.schema.ts @@ -0,0 +1,45 @@ +import { pgTable, uuid, text, timestamp, varchar, primaryKey, index } from 'drizzle-orm/pg-core'; +import { events } from './events.schema'; + +/** + * Event tags table - stores user-defined tags with colors + */ +export const eventTags = pgTable( + 'event_tags', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: text('user_id').notNull(), + name: varchar('name', { length: 100 }).notNull(), + color: varchar('color', { length: 7 }).default('#3B82F6'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + userIdx: index('event_tags_user_idx').on(table.userId), + }) +); + +/** + * Event to tags junction table - many-to-many relationship + */ +export const eventToTags = pgTable( + 'event_to_tags', + { + eventId: uuid('event_id') + .notNull() + .references(() => events.id, { onDelete: 'cascade' }), + tagId: uuid('tag_id') + .notNull() + .references(() => eventTags.id, { onDelete: 'cascade' }), + }, + (table) => ({ + pk: primaryKey({ columns: [table.eventId, table.tagId] }), + eventIdx: index('event_to_tags_event_idx').on(table.eventId), + tagIdx: index('event_to_tags_tag_idx').on(table.tagId), + }) +); + +export type EventTag = typeof eventTags.$inferSelect; +export type NewEventTag = typeof eventTags.$inferInsert; +export type EventToTag = typeof eventToTags.$inferSelect; +export type NewEventToTag = typeof eventToTags.$inferInsert; diff --git a/apps/calendar/apps/backend/src/db/schema/events.schema.ts b/apps/calendar/apps/backend/src/db/schema/events.schema.ts index 0318d408a..746e18397 100644 --- a/apps/calendar/apps/backend/src/db/schema/events.schema.ts +++ b/apps/calendar/apps/backend/src/db/schema/events.schema.ts @@ -1,9 +1,9 @@ import { pgTable, uuid, + text, timestamp, varchar, - text, boolean, jsonb, index, @@ -41,7 +41,7 @@ export const events = pgTable( calendarId: uuid('calendar_id') .notNull() .references(() => calendars.id, { onDelete: 'cascade' }), - userId: uuid('user_id').notNull(), + userId: text('user_id').notNull(), // Basic info title: varchar('title', { length: 500 }).notNull(), diff --git a/apps/calendar/apps/backend/src/db/schema/external-calendars.schema.ts b/apps/calendar/apps/backend/src/db/schema/external-calendars.schema.ts index ab3c04c9e..911ba9d61 100644 --- a/apps/calendar/apps/backend/src/db/schema/external-calendars.schema.ts +++ b/apps/calendar/apps/backend/src/db/schema/external-calendars.schema.ts @@ -1,9 +1,9 @@ import { pgTable, uuid, + text, timestamp, varchar, - text, boolean, jsonb, integer, @@ -27,7 +27,7 @@ export interface ExternalCalendarProviderData { */ export const externalCalendars = pgTable('external_calendars', { id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id').notNull(), + userId: text('user_id').notNull(), // Calendar identification name: varchar('name', { length: 255 }).notNull(), diff --git a/apps/calendar/apps/backend/src/db/schema/index.ts b/apps/calendar/apps/backend/src/db/schema/index.ts index a61f7006f..1cf8619a4 100644 --- a/apps/calendar/apps/backend/src/db/schema/index.ts +++ b/apps/calendar/apps/backend/src/db/schema/index.ts @@ -1,6 +1,7 @@ // Calendar Database Schemas export * from './calendars.schema'; export * from './events.schema'; +export * from './event-tags.schema'; export * from './calendar-shares.schema'; export * from './reminders.schema'; export * from './external-calendars.schema'; diff --git a/apps/calendar/apps/backend/src/db/schema/reminders.schema.ts b/apps/calendar/apps/backend/src/db/schema/reminders.schema.ts index 45ec1eb54..a41b486ff 100644 --- a/apps/calendar/apps/backend/src/db/schema/reminders.schema.ts +++ b/apps/calendar/apps/backend/src/db/schema/reminders.schema.ts @@ -1,4 +1,13 @@ -import { pgTable, uuid, timestamp, varchar, integer, boolean, index } from 'drizzle-orm/pg-core'; +import { + pgTable, + uuid, + text, + timestamp, + varchar, + integer, + boolean, + index, +} from 'drizzle-orm/pg-core'; import { events } from './events.schema'; /** @@ -11,7 +20,7 @@ export const reminders = pgTable( eventId: uuid('event_id') .notNull() .references(() => events.id, { onDelete: 'cascade' }), - userId: uuid('user_id').notNull(), + userId: text('user_id').notNull(), // Timing minutesBefore: integer('minutes_before').notNull(), diff --git a/apps/calendar/apps/backend/src/event-tag/dto/create-event-tag.dto.ts b/apps/calendar/apps/backend/src/event-tag/dto/create-event-tag.dto.ts new file mode 100644 index 000000000..a36de228e --- /dev/null +++ b/apps/calendar/apps/backend/src/event-tag/dto/create-event-tag.dto.ts @@ -0,0 +1,12 @@ +import { IsString, IsOptional, MaxLength } from 'class-validator'; + +export class CreateEventTagDto { + @IsString() + @MaxLength(100) + name!: string; + + @IsString() + @IsOptional() + @MaxLength(7) + color?: string; +} diff --git a/apps/calendar/apps/backend/src/event-tag/dto/update-event-tag.dto.ts b/apps/calendar/apps/backend/src/event-tag/dto/update-event-tag.dto.ts new file mode 100644 index 000000000..22718254e --- /dev/null +++ b/apps/calendar/apps/backend/src/event-tag/dto/update-event-tag.dto.ts @@ -0,0 +1,13 @@ +import { IsString, IsOptional, MaxLength } from 'class-validator'; + +export class UpdateEventTagDto { + @IsString() + @IsOptional() + @MaxLength(100) + name?: string; + + @IsString() + @IsOptional() + @MaxLength(7) + color?: string; +} diff --git a/apps/calendar/apps/backend/src/event-tag/event-tag.controller.ts b/apps/calendar/apps/backend/src/event-tag/event-tag.controller.ts new file mode 100644 index 000000000..30cab5b42 --- /dev/null +++ b/apps/calendar/apps/backend/src/event-tag/event-tag.controller.ts @@ -0,0 +1,62 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + UseGuards, + ParseUUIDPipe, + NotFoundException, +} from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { EventTagService } from './event-tag.service'; +import { CreateEventTagDto } from './dto/create-event-tag.dto'; +import { UpdateEventTagDto } from './dto/update-event-tag.dto'; + +@Controller('event-tags') +@UseGuards(JwtAuthGuard) +export class EventTagController { + constructor(private readonly eventTagService: EventTagService) {} + + @Get() + async findAll(@CurrentUser() user: CurrentUserData) { + const tags = await this.eventTagService.findByUserId(user.userId); + return { tags }; + } + + @Get(':id') + async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { + const tag = await this.eventTagService.findById(id, user.userId); + if (!tag) { + throw new NotFoundException('Tag not found'); + } + return { tag }; + } + + @Post() + async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateEventTagDto) { + const tag = await this.eventTagService.create({ + ...dto, + userId: user.userId, + }); + return { tag }; + } + + @Put(':id') + async update( + @CurrentUser() user: CurrentUserData, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateEventTagDto + ) { + const tag = await this.eventTagService.update(id, user.userId, dto); + return { tag }; + } + + @Delete(':id') + async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { + await this.eventTagService.delete(id, user.userId); + return { success: true }; + } +} diff --git a/apps/calendar/apps/backend/src/event-tag/event-tag.module.ts b/apps/calendar/apps/backend/src/event-tag/event-tag.module.ts new file mode 100644 index 000000000..42ee2efbc --- /dev/null +++ b/apps/calendar/apps/backend/src/event-tag/event-tag.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { EventTagController } from './event-tag.controller'; +import { EventTagService } from './event-tag.service'; + +@Module({ + controllers: [EventTagController], + providers: [EventTagService], + exports: [EventTagService], +}) +export class EventTagModule {} diff --git a/apps/calendar/apps/backend/src/event-tag/event-tag.service.ts b/apps/calendar/apps/backend/src/event-tag/event-tag.service.ts new file mode 100644 index 000000000..a9092ddc6 --- /dev/null +++ b/apps/calendar/apps/backend/src/event-tag/event-tag.service.ts @@ -0,0 +1,119 @@ +import { Injectable, Inject, NotFoundException } from '@nestjs/common'; +import { eq, and, inArray } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { Database } from '../db/connection'; +import { eventTags, eventToTags } from '../db/schema'; +import type { EventTag, NewEventTag } from '../db/schema'; + +const DEFAULT_TAGS = [ + { name: 'Arbeit', color: '#3b82f6' }, // blue + { name: 'Persönlich', color: '#22c55e' }, // green + { name: 'Familie', color: '#ec4899' }, // pink + { name: 'Wichtig', color: '#ef4444' }, // red +] as const; + +@Injectable() +export class EventTagService { + constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} + + async findByUserId(userId: string): Promise { + const tags = await this.db.select().from(eventTags).where(eq(eventTags.userId, userId)); + + // Create default tags on first access (when user has no tags yet) + if (tags.length === 0) { + return this.createDefaultTags(userId); + } + + return tags; + } + + private async createDefaultTags(userId: string): Promise { + const tagsToCreate = DEFAULT_TAGS.map((tag) => ({ + userId, + name: tag.name, + color: tag.color, + })); + + return this.db.insert(eventTags).values(tagsToCreate).returning(); + } + + async findById(id: string, userId: string): Promise { + const [tag] = await this.db + .select() + .from(eventTags) + .where(and(eq(eventTags.id, id), eq(eventTags.userId, userId))); + return tag || null; + } + + async create(data: NewEventTag): Promise { + const [tag] = await this.db.insert(eventTags).values(data).returning(); + return tag; + } + + async update(id: string, userId: string, data: Partial): Promise { + const [tag] = await this.db + .update(eventTags) + .set({ ...data, updatedAt: new Date() }) + .where(and(eq(eventTags.id, id), eq(eventTags.userId, userId))) + .returning(); + + if (!tag) { + throw new NotFoundException('Tag not found'); + } + + return tag; + } + + async delete(id: string, userId: string): Promise { + await this.db.delete(eventTags).where(and(eq(eventTags.id, id), eq(eventTags.userId, userId))); + } + + async getTagsForEvent(eventId: string): Promise { + const results = await this.db + .select({ tag: eventTags }) + .from(eventToTags) + .innerJoin(eventTags, eq(eventToTags.tagId, eventTags.id)) + .where(eq(eventToTags.eventId, eventId)); + + return results.map((r) => r.tag); + } + + async getTagIdsForEvent(eventId: string): Promise { + const results = await this.db + .select({ tagId: eventToTags.tagId }) + .from(eventToTags) + .where(eq(eventToTags.eventId, eventId)); + + return results.map((r) => r.tagId); + } + + async setEventTags(eventId: string, tagIds: string[]): Promise { + // Remove existing tags + await this.db.delete(eventToTags).where(eq(eventToTags.eventId, eventId)); + + // Add new tags + if (tagIds.length > 0) { + const values = tagIds.map((tagId) => ({ eventId, tagId })); + await this.db.insert(eventToTags).values(values).onConflictDoNothing(); + } + } + + async addTagToEvent(eventId: string, tagId: string): Promise { + await this.db.insert(eventToTags).values({ eventId, tagId }).onConflictDoNothing(); + } + + async removeTagFromEvent(eventId: string, tagId: string): Promise { + await this.db + .delete(eventToTags) + .where(and(eq(eventToTags.eventId, eventId), eq(eventToTags.tagId, tagId))); + } + + async getTagsByIds(ids: string[], userId: string): Promise { + if (ids.length === 0) return []; + + return this.db + .select() + .from(eventTags) + .where(and(inArray(eventTags.id, ids), eq(eventTags.userId, userId))); + } +} diff --git a/apps/calendar/apps/backend/src/event/dto/create-event.dto.ts b/apps/calendar/apps/backend/src/event/dto/create-event.dto.ts index 590d4a6ce..70a7fbee0 100644 --- a/apps/calendar/apps/backend/src/event/dto/create-event.dto.ts +++ b/apps/calendar/apps/backend/src/event/dto/create-event.dto.ts @@ -6,6 +6,7 @@ import { IsDateString, IsUUID, IsIn, + IsArray, MaxLength, } from 'class-validator'; import type { EventMetadata } from '../../db/schema/events.schema'; @@ -63,4 +64,9 @@ export class CreateEventDto { @IsOptional() @IsObject() metadata?: EventMetadata; + + @IsOptional() + @IsArray() + @IsUUID('4', { each: true }) + tagIds?: string[]; } diff --git a/apps/calendar/apps/backend/src/event/dto/update-event.dto.ts b/apps/calendar/apps/backend/src/event/dto/update-event.dto.ts index c3b44dbfe..ac3b82150 100644 --- a/apps/calendar/apps/backend/src/event/dto/update-event.dto.ts +++ b/apps/calendar/apps/backend/src/event/dto/update-event.dto.ts @@ -73,4 +73,9 @@ export class UpdateEventDto { @IsOptional() @IsObject() metadata?: EventMetadata; + + @IsOptional() + @IsArray() + @IsUUID('4', { each: true }) + tagIds?: string[]; } diff --git a/apps/calendar/apps/backend/src/event/event.module.ts b/apps/calendar/apps/backend/src/event/event.module.ts index 24625ca2d..522607d5c 100644 --- a/apps/calendar/apps/backend/src/event/event.module.ts +++ b/apps/calendar/apps/backend/src/event/event.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { EventController } from './event.controller'; import { EventService } from './event.service'; import { CalendarModule } from '../calendar/calendar.module'; +import { EventTagModule } from '../event-tag/event-tag.module'; @Module({ - imports: [CalendarModule], + imports: [CalendarModule, EventTagModule], controllers: [EventController], providers: [EventService], exports: [EventService], diff --git a/apps/calendar/apps/backend/src/event/event.service.ts b/apps/calendar/apps/backend/src/event/event.service.ts index 1ce7df83d..91eeb3ac4 100644 --- a/apps/calendar/apps/backend/src/event/event.service.ts +++ b/apps/calendar/apps/backend/src/event/event.service.ts @@ -5,13 +5,15 @@ import { Database } from '../db/connection'; import { events, Event, NewEvent } from '../db/schema/events.schema'; import { calendars } from '../db/schema/calendars.schema'; import { CalendarService } from '../calendar/calendar.service'; +import { EventTagService } from '../event-tag/event-tag.service'; import { CreateEventDto, UpdateEventDto, QueryEventsDto } from './dto'; @Injectable() export class EventService { constructor( @Inject(DATABASE_CONNECTION) private db: Database, - private calendarService: CalendarService + private calendarService: CalendarService, + private eventTagService: EventTagService ) {} async queryEvents(userId: string, query: QueryEventsDto): Promise { @@ -104,6 +106,12 @@ export class EventService { }; const [created] = await this.db.insert(events).values(newEvent).returning(); + + // Set tags if provided + if (dto.tagIds && dto.tagIds.length > 0) { + await this.eventTagService.setEventTags(created.id, dto.tagIds); + } + return created; } @@ -115,8 +123,11 @@ export class EventService { await this.calendarService.findByIdOrThrow(dto.calendarId, userId); } + // Handle tags separately + const { tagIds, ...eventData } = dto; + const updateData: Partial = { - ...dto, + ...eventData, startTime: dto.startTime ? new Date(dto.startTime) : undefined, endTime: dto.endTime ? new Date(dto.endTime) : undefined, recurrenceEndDate: dto.recurrenceEndDate ? new Date(dto.recurrenceEndDate) : undefined, @@ -136,6 +147,11 @@ export class EventService { .where(and(eq(events.id, id), eq(events.userId, userId))) .returning(); + // Update tags if provided + if (tagIds !== undefined) { + await this.eventTagService.setEventTags(id, tagIds); + } + return updated; } @@ -173,9 +189,24 @@ export class EventService { .where(and(...conditions)) .orderBy(events.startTime); - return result.map((r) => ({ - ...r.event, - calendar: r.calendar, - })); + // Load tags for all events + const eventsWithCalendar = await Promise.all( + result.map(async (r) => { + const tags = await this.eventTagService.getTagsForEvent(r.event.id); + return { + ...r.event, + calendar: r.calendar, + tags, + }; + }) + ); + + return eventsWithCalendar; + } + + async getEventWithTags(id: string, userId: string) { + const event = await this.findByIdOrThrow(id, userId); + const tags = await this.eventTagService.getTagsForEvent(id); + return { ...event, tags }; } } diff --git a/apps/calendar/apps/backend/src/network/network.controller.ts b/apps/calendar/apps/backend/src/network/network.controller.ts new file mode 100644 index 000000000..e2fd0ee77 --- /dev/null +++ b/apps/calendar/apps/backend/src/network/network.controller.ts @@ -0,0 +1,18 @@ +import { Controller, Get, UseGuards, Headers } from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { NetworkService } from './network.service'; + +@Controller('api/v1/network') +@UseGuards(JwtAuthGuard) +export class NetworkController { + constructor(private readonly networkService: NetworkService) {} + + @Get('graph') + async getGraph( + @CurrentUser() user: CurrentUserData, + @Headers('authorization') authorization?: string + ) { + const accessToken = authorization?.replace('Bearer ', ''); + return this.networkService.getGraph(user.userId, accessToken); + } +} diff --git a/apps/calendar/apps/backend/src/network/network.module.ts b/apps/calendar/apps/backend/src/network/network.module.ts new file mode 100644 index 000000000..719a19f0d --- /dev/null +++ b/apps/calendar/apps/backend/src/network/network.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { NetworkController } from './network.controller'; +import { NetworkService } from './network.service'; + +@Module({ + controllers: [NetworkController], + providers: [NetworkService], + exports: [NetworkService], +}) +export class NetworkModule {} diff --git a/apps/calendar/apps/backend/src/network/network.service.ts b/apps/calendar/apps/backend/src/network/network.service.ts new file mode 100644 index 000000000..beddb942a --- /dev/null +++ b/apps/calendar/apps/backend/src/network/network.service.ts @@ -0,0 +1,178 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { eq } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { Database } from '../db/connection'; +import { events, eventToTags } from '../db/schema'; + +interface Tag { + id: string; + name: string; + color: string | null; +} + +export interface NetworkNode { + id: string; + name: string; + photoUrl: string | null; + company: string | null; + isFavorite: boolean; + tags: Tag[]; + connectionCount: number; +} + +export interface NetworkLink { + source: string; + target: string; + type: 'tag'; + strength: number; + sharedTags: string[]; +} + +export interface NetworkGraphResponse { + nodes: NetworkNode[]; + links: NetworkLink[]; +} + +@Injectable() +export class NetworkService { + private authUrl: string; + + constructor( + @Inject(DATABASE_CONNECTION) private db: Database, + private configService: ConfigService + ) { + this.authUrl = this.configService.get('MANA_CORE_AUTH_URL') || 'http://localhost:3001'; + } + + /** + * Fetch tags from central Tags API + */ + private async fetchTagsByIds(tagIds: string[], accessToken: string): Promise> { + if (tagIds.length === 0) return new Map(); + + try { + const response = await fetch(`${this.authUrl}/api/v1/tags/by-ids?ids=${tagIds.join(',')}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + console.error('Failed to fetch tags from central API:', response.status); + return new Map(); + } + + const tags: Tag[] = await response.json(); + return new Map(tags.map((t) => [t.id, t])); + } catch (error) { + console.error('Error fetching tags from central API:', error); + return new Map(); + } + } + + /** + * Build a network graph of events connected by shared tags + */ + async getGraph(userId: string, accessToken?: string): Promise { + // 1. Get all events for user + const eventsData = await this.db + .select({ + event: events, + }) + .from(events) + .where(eq(events.userId, userId)); + + // 2. Get tag IDs for each event from junction table + const eventTagIdsMap = new Map(); + const allTagIds = new Set(); + + for (const { event } of eventsData) { + const tagRelations = await this.db + .select({ + tagId: eventToTags.tagId, + }) + .from(eventToTags) + .where(eq(eventToTags.eventId, event.id)); + + const tagIds = tagRelations.map((r) => r.tagId); + eventTagIdsMap.set(event.id, tagIds); + tagIds.forEach((id) => allTagIds.add(id)); + } + + // 3. Fetch tag details from central Tags API + let tagsMap = new Map(); + if (accessToken && allTagIds.size > 0) { + tagsMap = await this.fetchTagsByIds(Array.from(allTagIds), accessToken); + } + + // 4. Build tags for each event + const eventTagsMap = new Map(); + for (const { event } of eventsData) { + const tagIds = eventTagIdsMap.get(event.id) || []; + const tags = tagIds.map((id) => tagsMap.get(id)).filter((t): t is Tag => t !== undefined); + eventTagsMap.set(event.id, tags); + } + + // 5. Filter events that have at least one tag + const eventsWithTagsList = eventsData.filter((e) => { + const tags = eventTagsMap.get(e.event.id) || []; + return tags.length > 0; + }); + + // 6. Build nodes + const nodes: NetworkNode[] = eventsWithTagsList.map(({ event }) => { + const tags = eventTagsMap.get(event.id) || []; + return { + id: event.id, + name: event.title, + photoUrl: null, // Events don't have photos + company: event.location || null, // Use location as subtitle + isFavorite: false, + tags, + connectionCount: 0, // Will be calculated below + }; + }); + + // 7. Build links based on shared tags + const links: NetworkLink[] = []; + const connectionCounts = new Map(); + + for (let i = 0; i < nodes.length; i++) { + for (let j = i + 1; j < nodes.length; j++) { + const node1 = nodes[i]; + const node2 = nodes[j]; + + // Find shared tags + const sharedTags = node1.tags + .filter((t1) => node2.tags.some((t2) => t2.id === t1.id)) + .map((t) => t.name); + + if (sharedTags.length > 0) { + // Calculate strength based on number of shared tags + const maxTags = Math.max(node1.tags.length, node2.tags.length); + const strength = Math.round((sharedTags.length / maxTags) * 100); + + links.push({ + source: node1.id, + target: node2.id, + type: 'tag', + strength, + sharedTags, + }); + + // Update connection counts + connectionCounts.set(node1.id, (connectionCounts.get(node1.id) || 0) + 1); + connectionCounts.set(node2.id, (connectionCounts.get(node2.id) || 0) + 1); + } + } + } + + // 8. Update connection counts in nodes + for (const node of nodes) { + node.connectionCount = connectionCounts.get(node.id) || 0; + } + + return { nodes, links }; + } +} diff --git a/apps/calendar/apps/web/Dockerfile b/apps/calendar/apps/web/Dockerfile index 53c964525..e1d6210fa 100644 --- a/apps/calendar/apps/web/Dockerfile +++ b/apps/calendar/apps/web/Dockerfile @@ -36,6 +36,7 @@ COPY packages/shared-subscription-ui ./packages/shared-subscription-ui COPY packages/shared-profile-ui ./packages/shared-profile-ui COPY packages/shared-ui ./packages/shared-ui COPY packages/shared-utils ./packages/shared-utils +COPY packages/shared-tags ./packages/shared-tags # Copy calendar packages and web COPY apps/calendar/packages ./apps/calendar/packages diff --git a/apps/calendar/apps/web/package.json b/apps/calendar/apps/web/package.json index 4f71acf61..535a04307 100644 --- a/apps/calendar/apps/web/package.json +++ b/apps/calendar/apps/web/package.json @@ -17,6 +17,7 @@ "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0", "@tailwindcss/vite": "^4.1.7", + "@types/d3-force": "^3.0.0", "@types/node": "^20.0.0", "prettier": "^3.1.1", "prettier-plugin-svelte": "^3.1.2", @@ -38,12 +39,16 @@ "@manacore/shared-icons": "workspace:*", "@manacore/shared-profile-ui": "workspace:*", "@manacore/shared-subscription-ui": "workspace:*", + "@manacore/shared-tags": "workspace:*", "@manacore/shared-tailwind": "workspace:*", "@manacore/shared-theme": "workspace:*", "@manacore/shared-theme-ui": "workspace:*", "@manacore/shared-ui": "workspace:*", + "@manacore/shared-utils": "workspace:*", "@neodrag/svelte": "^2.3.3", + "d3-force": "^3.0.0", "date-fns": "^4.1.0", + "lucide-svelte": "^0.559.0", "svelte-dnd-action": "^0.9.68", "svelte-i18n": "^4.0.1" }, diff --git a/apps/calendar/apps/web/src/app.css b/apps/calendar/apps/web/src/app.css index 2c0478245..d625b6bc8 100644 --- a/apps/calendar/apps/web/src/app.css +++ b/apps/calendar/apps/web/src/app.css @@ -5,6 +5,8 @@ @source "../../../packages/shared/src"; @source "../../../../../packages/shared-ui/src"; @source "../../../../../packages/shared-theme-ui/src"; +@source "../../../../../packages/shared-theme-ui/src/components"; +@source "../../../../../packages/shared-theme-ui/src/pages"; /* Calendar-specific CSS Variables */ @layer base { diff --git a/apps/calendar/apps/web/src/lib/api/event-tags.ts b/apps/calendar/apps/web/src/lib/api/event-tags.ts new file mode 100644 index 000000000..7ad216b7b --- /dev/null +++ b/apps/calendar/apps/web/src/lib/api/event-tags.ts @@ -0,0 +1,132 @@ +/** + * Event Tags API Client - Uses central Tags API from mana-core-auth + * + * This module wraps the central Tags API to provide backward-compatible + * "event tags" interface for the Calendar app. Tags are now unified + * across all Manacore apps (Todo, Calendar, Contacts). + */ + +import { browser } from '$app/environment'; +import { + createTagsClient, + type Tag, + type CreateTagInput, + type UpdateTagInput, +} from '@manacore/shared-tags'; +import { authStore } from '$lib/stores/auth.svelte'; + +// Re-export Tag as EventTag for backward compatibility +export type EventTag = Tag; +export type CreateEventTagInput = CreateTagInput; +export type UpdateEventTagInput = UpdateTagInput; + +// Get auth URL dynamically at runtime +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__; + return injectedUrl || 'http://localhost:3001'; + } + return 'http://localhost:3001'; +} + +// Lazy-initialized client +let _tagsClient: ReturnType | null = null; + +function getTagsClient() { + if (!browser) return null; + if (!_tagsClient) { + _tagsClient = createTagsClient({ + authUrl: getAuthUrl(), + getToken: async () => { + const token = await authStore.getAccessToken(); + return token || ''; + }, + }); + } + return _tagsClient; +} + +export async function getEventTags() { + const client = getTagsClient(); + if (!client) return { data: null, error: null }; + try { + const tags = await client.getAll(); + return { data: tags, error: null }; + } catch (e) { + return { + data: null, + error: { message: e instanceof Error ? e.message : 'Failed to fetch tags' }, + }; + } +} + +export async function getEventTag(id: string) { + const client = getTagsClient(); + if (!client) return { data: null, error: null }; + try { + const tag = await client.getById(id); + return { data: tag, error: null }; + } catch (e) { + return { + data: null, + error: { message: e instanceof Error ? e.message : 'Failed to fetch tag' }, + }; + } +} + +export async function createEventTag(data: CreateEventTagInput) { + const client = getTagsClient(); + if (!client) return { data: null, error: { message: 'Tags client not available' } }; + try { + const tag = await client.create(data); + return { data: tag, error: null }; + } catch (e) { + return { + data: null, + error: { message: e instanceof Error ? e.message : 'Failed to create tag' }, + }; + } +} + +export async function updateEventTag(id: string, data: UpdateEventTagInput) { + const client = getTagsClient(); + if (!client) return { data: null, error: { message: 'Tags client not available' } }; + try { + const tag = await client.update(id, data); + return { data: tag, error: null }; + } catch (e) { + return { + data: null, + error: { message: e instanceof Error ? e.message : 'Failed to update tag' }, + }; + } +} + +export async function deleteEventTag(id: string) { + const client = getTagsClient(); + if (!client) return { data: null, error: { message: 'Tags client not available' } }; + try { + await client.delete(id); + return { data: { success: true }, error: null }; + } catch (e) { + return { + data: null, + error: { message: e instanceof Error ? e.message : 'Failed to delete tag' }, + }; + } +} + +export async function createDefaultEventTags() { + const client = getTagsClient(); + if (!client) return { data: null, error: null }; + try { + const tags = await client.createDefaults(); + return { data: tags, error: null }; + } catch (e) { + return { + data: null, + error: { message: e instanceof Error ? e.message : 'Failed to create default tags' }, + }; + } +} diff --git a/apps/calendar/apps/web/src/lib/api/network.ts b/apps/calendar/apps/web/src/lib/api/network.ts new file mode 100644 index 000000000..d9ccf04b6 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/api/network.ts @@ -0,0 +1,47 @@ +/** + * Network Graph API Client + */ + +import { fetchApi } from './client'; + +export interface NetworkTag { + id: string; + name: string; + color: string | null; +} + +export interface NetworkNode { + id: string; + name: string; + photoUrl: string | null; + company: string | null; + isFavorite: boolean; + tags: NetworkTag[]; + connectionCount: number; +} + +export interface NetworkLink { + source: string; + target: string; + type: 'tag'; + strength: number; + sharedTags: string[]; +} + +export interface NetworkGraphResponse { + nodes: NetworkNode[]; + links: NetworkLink[]; +} + +export const networkApi = { + /** + * Get the network graph of events connected by shared tags + */ + async getGraph(): Promise { + const result = await fetchApi('/network/graph'); + if (result.error) { + throw result.error; + } + return result.data || { nodes: [], links: [] }; + }, +}; diff --git a/apps/calendar/apps/web/src/lib/api/todos.ts b/apps/calendar/apps/web/src/lib/api/todos.ts new file mode 100644 index 000000000..fa649d829 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/api/todos.ts @@ -0,0 +1,370 @@ +/** + * Cross-App API Client for Todo Backend + * Allows Calendar app to fetch/manage todos from the Todo service + */ + +import { browser } from '$app/environment'; +import { env } from '$env/dynamic/public'; + +const TODO_API_BASE = env.PUBLIC_TODO_BACKEND_URL || 'http://localhost:3018'; + +// ============================================ +// Types (mirrored from @todo/shared for cross-app use) +// ============================================ + +export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent'; +export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled'; + +export interface Subtask { + id: string; + title: string; + isCompleted: boolean; + completedAt?: string | null; + order: number; +} + +export interface Label { + id: string; + userId: string; + name: string; + color: string; + createdAt: string; + updatedAt: string; +} + +export interface Project { + id: string; + userId: string; + name: string; + description?: string | null; + color: string; + icon?: string | null; + order: number; + isArchived: boolean; + isDefault: boolean; + createdAt: string; + updatedAt: string; +} + +export interface TaskMetadata { + notes?: string; + attachments?: string[]; + linkedCalendarEventId?: string | null; + storyPoints?: number | null; + effectiveDuration?: { + value: number; + unit: 'minutes' | 'hours' | 'days'; + } | null; + funRating?: number | null; +} + +export interface Task { + id: string; + projectId?: string | null; + userId: string; + parentTaskId?: string | null; + title: string; + description?: string | null; + dueDate?: string | null; + dueTime?: string | null; + startDate?: string | null; + priority: TaskPriority; + status: TaskStatus; + isCompleted: boolean; + completedAt?: string | null; + order: number; + columnId?: string | null; + columnOrder?: number; + recurrenceRule?: string | null; + recurrenceEndDate?: string | null; + lastOccurrence?: string | null; + subtasks?: Subtask[] | null; + metadata?: TaskMetadata | null; + labels?: Label[]; + project?: Project | null; + createdAt: string; + updatedAt: string; +} + +export interface CreateTaskInput { + title: string; + description?: string; + projectId?: string | null; + dueDate?: string | null; + dueTime?: string | null; + priority?: TaskPriority; + labelIds?: string[]; + subtasks?: Omit[]; + recurrenceRule?: string | null; + metadata?: TaskMetadata; +} + +export interface UpdateTaskInput { + title?: string; + description?: string | null; + projectId?: string | null; + dueDate?: string | null; + dueTime?: string | null; + priority?: TaskPriority; + status?: TaskStatus; + isCompleted?: boolean; + subtasks?: Subtask[] | null; + recurrenceRule?: string | null; + metadata?: TaskMetadata | null; + labelIds?: string[]; +} + +export interface TaskQuery { + projectId?: string; + labelId?: string; + priority?: TaskPriority; + status?: TaskStatus; + isCompleted?: boolean; + dueDateFrom?: string; + dueDateTo?: string; + search?: string; + sortBy?: 'dueDate' | 'priority' | 'createdAt' | 'order'; + sortOrder?: 'asc' | 'desc'; + limit?: number; + offset?: number; +} + +// ============================================ +// API Response Types +// ============================================ + +interface TasksResponse { + tasks: Task[]; +} + +interface TaskResponse { + task: Task; +} + +interface ProjectsResponse { + projects: Project[]; +} + +interface LabelsResponse { + labels: Label[]; +} + +// ============================================ +// API Client +// ============================================ + +type FetchOptions = { + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + body?: unknown; + token?: string; +}; + +async function fetchTodoApi( + endpoint: string, + options: FetchOptions = {} +): Promise<{ data: T | null; error: Error | null }> { + const { method = 'GET', body, token } = options; + + let authToken = token; + if (!authToken && browser) { + authToken = localStorage.getItem('@auth/appToken') || undefined; + } + + try { + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } + + const response = await fetch(`${TODO_API_BASE}/api/v1${endpoint}`, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + return { + data: null, + error: new Error(errorData.message || `Todo API error: ${response.status}`), + }; + } + + // Handle empty responses (204 No Content) + if (response.status === 204) { + return { data: null, error: null }; + } + + const data = await response.json(); + return { data, error: null }; + } catch (error) { + return { + data: null, + error: error instanceof Error ? error : new Error('Failed to connect to Todo service'), + }; + } +} + +// ============================================ +// Helper Functions +// ============================================ + +function buildQueryString(query: TaskQuery): string { + const params = new URLSearchParams(); + Object.entries(query).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + params.append(key, String(value)); + } + }); + const queryString = params.toString(); + return queryString ? `?${queryString}` : ''; +} + +// ============================================ +// Task API Functions +// ============================================ + +export async function getTasks( + query: TaskQuery = {} +): Promise<{ data: Task[] | null; error: Error | null }> { + const queryString = buildQueryString(query); + const result = await fetchTodoApi(`/tasks${queryString}`); + return { + data: result.data?.tasks || null, + error: result.error, + }; +} + +export async function getTask(id: string): Promise<{ data: Task | null; error: Error | null }> { + const result = await fetchTodoApi(`/tasks/${id}`); + return { + data: result.data?.task || null, + error: result.error, + }; +} + +export async function createTask( + data: CreateTaskInput +): Promise<{ data: Task | null; error: Error | null }> { + const result = await fetchTodoApi('/tasks', { + method: 'POST', + body: data, + }); + return { + data: result.data?.task || null, + error: result.error, + }; +} + +export async function updateTask( + id: string, + data: UpdateTaskInput +): Promise<{ data: Task | null; error: Error | null }> { + const result = await fetchTodoApi(`/tasks/${id}`, { + method: 'PUT', + body: data, + }); + return { + data: result.data?.task || null, + error: result.error, + }; +} + +export async function deleteTask(id: string): Promise<{ error: Error | null }> { + const result = await fetchTodoApi(`/tasks/${id}`, { + method: 'DELETE', + }); + return { error: result.error }; +} + +export async function completeTask( + id: string +): Promise<{ data: Task | null; error: Error | null }> { + const result = await fetchTodoApi(`/tasks/${id}/complete`, { + method: 'POST', + }); + return { + data: result.data?.task || null, + error: result.error, + }; +} + +export async function uncompleteTask( + id: string +): Promise<{ data: Task | null; error: Error | null }> { + const result = await fetchTodoApi(`/tasks/${id}/uncomplete`, { + method: 'POST', + }); + return { + data: result.data?.task || null, + error: result.error, + }; +} + +export async function getTodayTasks(): Promise<{ data: Task[] | null; error: Error | null }> { + const result = await fetchTodoApi('/tasks/today'); + return { + data: result.data?.tasks || null, + error: result.error, + }; +} + +export async function getUpcomingTasks(): Promise<{ data: Task[] | null; error: Error | null }> { + const result = await fetchTodoApi('/tasks/upcoming'); + return { + data: result.data?.tasks || null, + error: result.error, + }; +} + +// ============================================ +// Project API Functions +// ============================================ + +export async function getProjects(): Promise<{ data: Project[] | null; error: Error | null }> { + const result = await fetchTodoApi('/projects'); + return { + data: result.data?.projects || null, + error: result.error, + }; +} + +// ============================================ +// Label API Functions +// ============================================ + +export async function getLabels(): Promise<{ data: Label[] | null; error: Error | null }> { + const result = await fetchTodoApi('/labels'); + return { + data: result.data?.labels || null, + error: result.error, + }; +} + +// ============================================ +// Priority Colors Helper +// ============================================ + +export const PRIORITY_COLORS: Record = { + urgent: 'hsl(var(--color-danger))', + high: 'hsl(var(--color-warning))', + medium: 'hsl(var(--color-accent))', + low: 'hsl(var(--color-success))', +}; + +export const PRIORITY_LABELS: Record = { + urgent: 'Dringend', + high: 'Wichtig', + medium: 'Normal', + low: 'Später', +}; + +export const PRIORITY_ORDER: Record = { + urgent: 0, + high: 1, + medium: 2, + low: 3, +}; diff --git a/apps/calendar/apps/web/src/lib/components/agenda/AgendaFilters.svelte b/apps/calendar/apps/web/src/lib/components/agenda/AgendaFilters.svelte new file mode 100644 index 000000000..15500781d --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/agenda/AgendaFilters.svelte @@ -0,0 +1,151 @@ + + +
+
+ + +
+ +
+
+ + +
+
+
+ + diff --git a/apps/calendar/apps/web/src/lib/components/agenda/AgendaItem.svelte b/apps/calendar/apps/web/src/lib/components/agenda/AgendaItem.svelte new file mode 100644 index 000000000..ce0620e0f --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/agenda/AgendaItem.svelte @@ -0,0 +1,217 @@ + + +{#if type === 'event' && event} + +{:else if type === 'todo' && todo} +
+
+ +
+ +
+{/if} + + diff --git a/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte index c0cca55b2..aaf62dd22 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte @@ -3,6 +3,8 @@ import { eventsStore } from '$lib/stores/events.svelte'; import { calendarsStore } from '$lib/stores/calendars.svelte'; import { settingsStore } from '$lib/stores/settings.svelte'; + import { todosStore } from '$lib/stores/todos.svelte'; + import TodoRow from './TodoRow.svelte'; import { goto } from '$app/navigation'; import { format, @@ -405,6 +407,16 @@ {/if} + + {#if todosStore.serviceAvailable && todosStore.getTodosForDay(viewStore.currentDate).length > 0} +
+
+
+ +
+
+ {/if} +
@@ -533,6 +545,16 @@ cursor: pointer; } + /* Todos section */ + .todos-section { + display: flex; + border-bottom: 1px solid hsl(var(--color-border) / 0.5); + } + + .todos-content { + flex: 1; + } + /* Block-style all-day events (displayed as full-day blocks in the grid) */ .all-day-block-event { position: absolute; diff --git a/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte index 1227fdccf..a8b53381d 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte @@ -3,6 +3,8 @@ import { eventsStore } from '$lib/stores/events.svelte'; import { calendarsStore } from '$lib/stores/calendars.svelte'; import { settingsStore } from '$lib/stores/settings.svelte'; + import { todosStore } from '$lib/stores/todos.svelte'; + import TodoDayCell from './TodoDayCell.svelte'; import { goto } from '$app/navigation'; import { format, @@ -265,6 +267,11 @@ {format(day, 'd')} + + {#if todosStore.serviceAvailable} + + {/if} +
{#each getEventsForDay(day) as event} {@const isBeingDragged = isDragging && draggedEvent?.id === event.id} diff --git a/apps/calendar/apps/web/src/lib/components/calendar/TodoDayCell.svelte b/apps/calendar/apps/web/src/lib/components/calendar/TodoDayCell.svelte new file mode 100644 index 000000000..d2e0f44fa --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/calendar/TodoDayCell.svelte @@ -0,0 +1,121 @@ + + +{#if todosForDay.length > 0} +
+ {#each visibleTodos as task (task.id)} + + {/each} + + {#if overflowCount > 0} + +{overflowCount} Aufgaben + {/if} +
+{/if} + + +{#if selectedTask} + +{/if} + + diff --git a/apps/calendar/apps/web/src/lib/components/calendar/TodoRow.svelte b/apps/calendar/apps/web/src/lib/components/calendar/TodoRow.svelte new file mode 100644 index 000000000..e18440e09 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/calendar/TodoRow.svelte @@ -0,0 +1,169 @@ + + +{#if todosForDay.length > 0} +
+ Aufgaben: +
+ {#each visibleTodos as task (task.id)} + + + {/each} + + {#if overflowCount > 0} + + {/if} +
+
+{/if} + + +{#if selectedTask} + +{/if} + + diff --git a/apps/calendar/apps/web/src/lib/components/calendar/TodoSidebarSection.svelte b/apps/calendar/apps/web/src/lib/components/calendar/TodoSidebarSection.svelte new file mode 100644 index 000000000..2d2f28899 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/calendar/TodoSidebarSection.svelte @@ -0,0 +1,292 @@ + + +
+ + + + + + {#if isExpanded} +
+ {#if !todosStore.serviceAvailable} +
+ + Todo-Service nicht erreichbar +
+ {:else if todosStore.loading} +
+
+ Laden... +
+ {:else if displayTodos.length === 0} +
+ + Keine offenen Aufgaben +
+ {:else} +
+ {#each displayTodos as task (task.id)} + handleTaskClick(task)} + /> + {/each} +
+ + {#if totalActiveCount > maxItems} + + {/if} + {/if} + + + {#if showQuickAdd} +
+ +
+ {/if} +
+ {/if} +
+ + +{#if selectedTask} + +{/if} + + diff --git a/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte index ed05c57a1..4b21db563 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte @@ -3,6 +3,8 @@ import { eventsStore } from '$lib/stores/events.svelte'; import { calendarsStore } from '$lib/stores/calendars.svelte'; import { settingsStore } from '$lib/stores/settings.svelte'; + import { todosStore } from '$lib/stores/todos.svelte'; + import TodoRow from './TodoRow.svelte'; import { goto } from '$app/navigation'; import { format, @@ -499,6 +501,18 @@
{/if} + + {#if todosStore.serviceAvailable} +
+
+ {#each days as day} +
+ +
+ {/each} +
+ {/if} +
@@ -651,6 +665,18 @@ cursor: pointer; } + /* Todos row */ + .todos-row { + display: flex; + border-bottom: 1px solid hsl(var(--color-border) / 0.5); + } + + .todos-cell { + flex: 1; + border-left: 1px solid hsl(var(--color-border)); + min-height: 0; + } + /* Block-style all-day events (displayed as full-day blocks in the grid) */ .all-day-block-event { position: absolute; diff --git a/apps/calendar/apps/web/src/lib/components/event/EventDetailModal.svelte b/apps/calendar/apps/web/src/lib/components/event/EventDetailModal.svelte index c2c0661a3..4ec2ee327 100644 --- a/apps/calendar/apps/web/src/lib/components/event/EventDetailModal.svelte +++ b/apps/calendar/apps/web/src/lib/components/event/EventDetailModal.svelte @@ -4,6 +4,7 @@ import { calendarsStore } from '$lib/stores/calendars.svelte'; import { toast } from '$lib/stores/toast'; import EventForm from './EventForm.svelte'; + import { TagBadge } from '@manacore/shared-ui'; import type { CalendarEvent, UpdateEventInput } from '@calendar/shared'; import * as api from '$lib/api/events'; import { format, parseISO } from 'date-fns'; @@ -382,6 +383,30 @@
{/if} + + {#if event.tags && event.tags.length > 0} +
+ + + + + +
+ Tags +
+ {#each event.tags as tag (tag.id)} + + {/each} +
+
+
+ {/if} + {#if event.metadata?.attendees && event.metadata.attendees.length > 0}
@@ -664,4 +689,12 @@ font-size: 0.875rem; color: hsl(var(--color-muted-foreground)); } + + /* Tags display */ + .tags-display { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.25rem; + } diff --git a/apps/calendar/apps/web/src/lib/components/event/EventForm.svelte b/apps/calendar/apps/web/src/lib/components/event/EventForm.svelte index 251df6fd0..c8dcf2d70 100644 --- a/apps/calendar/apps/web/src/lib/components/event/EventForm.svelte +++ b/apps/calendar/apps/web/src/lib/components/event/EventForm.svelte @@ -1,11 +1,15 @@ + +{#if variant === 'dot'} + +{:else if variant === 'badge'} + + {#if showLabel} + {label} + {:else} + {priority.charAt(0).toUpperCase()} + {/if} + +{:else if variant === 'pill'} + + + {#if showLabel} + {label} + {/if} + +{/if} + + diff --git a/apps/calendar/apps/web/src/lib/components/todo/QuickAddTodo.svelte b/apps/calendar/apps/web/src/lib/components/todo/QuickAddTodo.svelte new file mode 100644 index 000000000..573e3794a --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/todo/QuickAddTodo.svelte @@ -0,0 +1,226 @@ + + +{#if showButton && !isExpanded} + +{:else} +
+ + + {#if showButton} + + {/if} + + +
+{/if} + + diff --git a/apps/calendar/apps/web/src/lib/components/todo/TodoCheckbox.svelte b/apps/calendar/apps/web/src/lib/components/todo/TodoCheckbox.svelte new file mode 100644 index 000000000..640043528 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/todo/TodoCheckbox.svelte @@ -0,0 +1,130 @@ + + + + + diff --git a/apps/calendar/apps/web/src/lib/components/todo/TodoDetailModal.svelte b/apps/calendar/apps/web/src/lib/components/todo/TodoDetailModal.svelte new file mode 100644 index 000000000..59b0e6ddb --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/todo/TodoDetailModal.svelte @@ -0,0 +1,625 @@ + + + + + + + + diff --git a/apps/calendar/apps/web/src/lib/components/todo/TodoItem.svelte b/apps/calendar/apps/web/src/lib/components/todo/TodoItem.svelte new file mode 100644 index 000000000..0a24b386d --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/todo/TodoItem.svelte @@ -0,0 +1,287 @@ + + +
+ + +
+
+ {#if showPriority && variant !== 'minimal'} + + {/if} + + {task.title} + + {#if subtaskProgress && variant === 'default'} + + {subtaskProgress.completed}/{subtaskProgress.total} + + {/if} +
+ + {#if variant !== 'minimal'} +
+ {#if showDueDate && dueDateLabel} + + {dueDateLabel} + + {/if} + + {#if showProject && task.project} + + {task.project.name} + + {/if} + + {#if task.labels && task.labels.length > 0 && variant === 'default'} +
+ {#each task.labels.slice(0, 2) as label} + + {label.name} + + {/each} + {#if task.labels.length > 2} + +{task.labels.length - 2} + {/if} +
+ {/if} +
+ {/if} +
+
+ + diff --git a/apps/calendar/apps/web/src/lib/stores/event-tags.svelte.ts b/apps/calendar/apps/web/src/lib/stores/event-tags.svelte.ts new file mode 100644 index 000000000..7bfe1960c --- /dev/null +++ b/apps/calendar/apps/web/src/lib/stores/event-tags.svelte.ts @@ -0,0 +1,114 @@ +/** + * Event Tags Store - Manages event tags using Svelte 5 runes + * + * Uses the central Tags API from mana-core-auth. Tags are now unified + * across all Manacore apps (Todo, Calendar, Contacts). + */ + +import type { EventTag } from '$lib/api/event-tags'; +import * as api from '$lib/api/event-tags'; + +// State +let tags = $state([]); +let loading = $state(false); +let error = $state(null); + +// Helper to safely get tags array (Svelte 5 runes safety) +function getTagsArray(): EventTag[] { + const arr = tags ?? []; + return Array.isArray(arr) ? arr : []; +} + +export const eventTagsStore = { + // Getters + get tags() { + return tags; + }, + get loading() { + return loading; + }, + get error() { + return error; + }, + + /** + * Fetch all tags + */ + async fetchTags() { + loading = true; + error = null; + + const result = await api.getEventTags(); + + if (result.error) { + error = result.error.message; + tags = []; + } else { + tags = result.data || []; + } + + loading = false; + return result; + }, + + /** + * Create a new tag + */ + async createTag(data: api.CreateEventTagInput) { + const result = await api.createEventTag(data); + + if (result.data) { + tags = [...tags, result.data]; + } + + return result; + }, + + /** + * Update a tag + */ + async updateTag(id: string, data: api.UpdateEventTagInput) { + const result = await api.updateEventTag(id, data); + + if (result.data) { + tags = getTagsArray().map((t) => (t.id === id ? result.data! : t)); + } + + return result; + }, + + /** + * Delete a tag + */ + async deleteTag(id: string) { + const result = await api.deleteEventTag(id); + + if (!result.error) { + tags = getTagsArray().filter((t) => t.id !== id); + } + + return result; + }, + + /** + * Get tag by ID + */ + getById(id: string) { + return getTagsArray().find((t) => t.id === id); + }, + + /** + * Get tags by IDs + */ + getByIds(ids: string[]) { + return getTagsArray().filter((t) => ids.includes(t.id)); + }, + + /** + * Clear store + */ + clear() { + tags = []; + error = null; + }, +}; diff --git a/apps/calendar/apps/web/src/lib/stores/network.svelte.ts b/apps/calendar/apps/web/src/lib/stores/network.svelte.ts new file mode 100644 index 000000000..917081c36 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/stores/network.svelte.ts @@ -0,0 +1,370 @@ +/** + * Network Store - Manages network graph state with D3-force simulation + */ + +import { browser } from '$app/environment'; +import { networkApi } from '$lib/api/network'; +import type { NetworkNode, NetworkLink } from '$lib/api/network'; +import { + forceSimulation, + forceLink, + forceManyBody, + forceCenter, + forceCollide, + type Simulation, +} from 'd3-force'; +import type { + SimulationNode as SharedSimulationNode, + SimulationLink as SharedSimulationLink, +} from '@manacore/shared-ui'; + +// Re-export types from shared-ui for convenience +export type SimulationNode = SharedSimulationNode; +export type SimulationLink = SharedSimulationLink; + +// State +let nodes = $state([]); +let links = $state([]); +let loading = $state(false); +let error = $state(null); +let selectedNodeId = $state(null); +let simulation: Simulation | null = null; +let searchQuery = $state(''); +let filterTagId = $state(null); +let filterLocation = $state(null); +let minStrength = $state(0); +let tickCounter = $state(0); +let simulationInitialized = false; +let dataLoaded = false; +let lastDimensions = { width: 0, height: 0 }; + +// Derived state for filtering +const filteredNodes = $derived.by(() => { + let result = nodes; + + // Search filter + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase(); + result = result.filter( + (node) => + node.name.toLowerCase().includes(query) || + node.subtitle?.toLowerCase().includes(query) || + node.tags.some((t) => t.name.toLowerCase().includes(query)) + ); + } + + // Tag filter + if (filterTagId) { + result = result.filter((node) => node.tags.some((t) => t.id === filterTagId)); + } + + // Location filter (uses subtitle field) + if (filterLocation) { + result = result.filter((node) => node.subtitle === filterLocation); + } + + return result; +}); + +const filteredLinks = $derived.by(() => { + const filteredNodeIds = new Set(filteredNodes.map((n) => n.id)); + return links.filter((link) => { + const sourceId = typeof link.source === 'string' ? link.source : link.source.id; + const targetId = typeof link.target === 'string' ? link.target : link.target.id; + // Check if both nodes are visible + if (!filteredNodeIds.has(sourceId) || !filteredNodeIds.has(targetId)) { + return false; + } + // Filter by minimum strength + if (minStrength > 0 && link.strength < minStrength) { + return false; + } + return true; + }); +}); + +// Get unique locations for filter dropdown +const uniqueLocations = $derived.by(() => { + const locations = new Set(); + for (const node of nodes) { + if (node.subtitle) { + locations.add(node.subtitle); + } + } + return Array.from(locations).sort(); +}); + +// Get unique tags for filter dropdown +const uniqueTags = $derived.by(() => { + const tagsMap = new Map(); + for (const node of nodes) { + for (const tag of node.tags) { + if (!tagsMap.has(tag.id)) { + tagsMap.set(tag.id, tag); + } + } + } + return Array.from(tagsMap.values()).sort((a, b) => a.name.localeCompare(b.name)); +}); + +export const networkStore = { + // Getters + get nodes() { + void tickCounter; + return filteredNodes; + }, + get allNodes() { + void tickCounter; + return nodes; + }, + get links() { + void tickCounter; + return filteredLinks; + }, + get allLinks() { + void tickCounter; + return links; + }, + get tick() { + return tickCounter; + }, + get loading() { + return loading; + }, + get error() { + return error; + }, + get selectedNodeId() { + return selectedNodeId; + }, + get selectedNode() { + return nodes.find((n) => n.id === selectedNodeId) || null; + }, + get searchQuery() { + return searchQuery; + }, + get filterTagId() { + return filterTagId; + }, + get filterLocation() { + return filterLocation; + }, + get minStrength() { + return minStrength; + }, + get uniqueLocations() { + return uniqueLocations; + }, + get uniqueTags() { + return uniqueTags; + }, + + /** + * Load network graph data from API + */ + async loadGraph(force = false) { + if (dataLoaded && !force) { + return; + } + + if (loading) { + return; + } + + loading = true; + error = null; + + if (simulation) { + simulation.stop(); + simulation = null; + } + simulationInitialized = false; + + try { + const response = await networkApi.getGraph(); + + // Convert to simulation nodes with subtitle for location + nodes = response.nodes.map((node) => ({ + ...node, + subtitle: node.company, // Map company/location to subtitle + x: undefined, + y: undefined, + vx: undefined, + vy: undefined, + fx: null, + fy: null, + })); + + // Convert to simulation links + links = response.links.map((link) => ({ + source: link.source, + target: link.target, + type: link.type, + strength: link.strength, + sharedTags: link.sharedTags, + })); + + dataLoaded = true; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to load network graph'; + console.error('Failed to load network graph:', e); + } finally { + loading = false; + } + }, + + /** + * Initialize D3 force simulation + */ + initSimulation(width: number, height: number) { + if (!browser) return; + if (nodes.length === 0) return; + if (width <= 0 || height <= 0) return; + + if (simulationInitialized && simulation) { + if ( + Math.abs(lastDimensions.width - width) > 50 || + Math.abs(lastDimensions.height - height) > 50 + ) { + lastDimensions = { width, height }; + this.updateSimulationCenter(width, height); + } + return; + } + + if (simulation) { + simulation.stop(); + } + + lastDimensions = { width, height }; + + const centerX = width / 2; + const centerY = height / 2; + const radius = Math.min(width, height) / 3; + + nodes.forEach((node, i) => { + if (node.x === undefined || node.y === undefined) { + const angle = (i / nodes.length) * 2 * Math.PI; + const r = radius * (0.5 + Math.random() * 0.5); + node.x = centerX + r * Math.cos(angle); + node.y = centerY + r * Math.sin(angle); + } + }); + + simulation = forceSimulation(nodes) + .force( + 'link', + forceLink(links) + .id((d) => d.id) + .distance(100) + .strength(0.5) + ) + .force('charge', forceManyBody().strength(-300)) + .force('center', forceCenter(centerX, centerY)) + .force('collision', forceCollide().radius(50)) + .on('tick', () => { + tickCounter++; + }); + + simulationInitialized = true; + simulation.alpha(1).restart(); + }, + + updateSimulationCenter(width: number, height: number) { + if (simulation) { + simulation.force('center', forceCenter(width / 2, height / 2)); + simulation.alpha(0.3).restart(); + } + }, + + stopSimulation() { + if (simulation) { + simulation.stop(); + simulation = null; + } + simulationInitialized = false; + }, + + reset() { + this.stopSimulation(); + nodes = []; + links = []; + dataLoaded = false; + lastDimensions = { width: 0, height: 0 }; + tickCounter = 0; + }, + + reheatSimulation() { + if (simulation) { + simulation.alpha(0.3).restart(); + } + }, + + fixNode(nodeId: string, x: number, y: number) { + const node = nodes.find((n) => n.id === nodeId); + if (node) { + node.fx = x; + node.fy = y; + } + }, + + releaseNode(nodeId: string) { + const node = nodes.find((n) => n.id === nodeId); + if (node) { + node.fx = null; + node.fy = null; + } + }, + + selectNode(nodeId: string | null) { + selectedNodeId = nodeId; + }, + + setSearch(query: string) { + searchQuery = query; + }, + + setFilterTag(tagId: string | null) { + filterTagId = tagId; + }, + + setFilterLocation(location: string | null) { + filterLocation = location; + }, + + setMinStrength(strength: number) { + minStrength = strength; + }, + + clearFilters() { + searchQuery = ''; + filterTagId = null; + filterLocation = null; + minStrength = 0; + }, + + getConnectedNodes(nodeId: string): SimulationNode[] { + const connectedIds = new Set(); + + for (const link of links) { + const sourceId = typeof link.source === 'string' ? link.source : link.source.id; + const targetId = typeof link.target === 'string' ? link.target : link.target.id; + + if (sourceId === nodeId) { + connectedIds.add(targetId); + } else if (targetId === nodeId) { + connectedIds.add(sourceId); + } + } + + return nodes.filter((n) => connectedIds.has(n.id)); + }, + + getNodeLinks(nodeId: string): SimulationLink[] { + return links.filter((link) => { + const sourceId = typeof link.source === 'string' ? link.source : link.source.id; + const targetId = typeof link.target === 'string' ? link.target : link.target.id; + return sourceId === nodeId || targetId === nodeId; + }); + }, +}; diff --git a/apps/calendar/apps/web/src/lib/stores/statistics.svelte.ts b/apps/calendar/apps/web/src/lib/stores/statistics.svelte.ts new file mode 100644 index 000000000..20f739c87 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/stores/statistics.svelte.ts @@ -0,0 +1,270 @@ +/** + * Calendar Statistics Store - Calculates calendar statistics using Svelte 5 runes + */ + +import type { CalendarEvent, Calendar } from '@calendar/shared'; +import { + startOfDay, + startOfWeek, + endOfWeek, + subDays, + format, + differenceInMinutes, + isToday, + isSameWeek, + parseISO, + eachDayOfInterval, + addDays, +} from 'date-fns'; +import { de } from 'date-fns/locale'; +import type { + HeatmapDataPoint, + TrendDataPoint, + DonutSegment, + ProgressItem, +} from '@manacore/shared-ui'; + +// Types +export interface EventStatusBreakdown { + status: 'confirmed' | 'tentative' | 'cancelled'; + count: number; + percentage: number; + color: string; +} + +const STATUS_COLORS: Record = { + confirmed: '#10B981', // green + tentative: '#F59E0B', // orange + cancelled: '#EF4444', // red +}; + +const STATUS_LABELS: Record = { + confirmed: 'Bestätigt', + tentative: 'Vorläufig', + cancelled: 'Abgesagt', +}; + +// State +let events = $state([]); +let calendars = $state([]); + +export const calendarStatisticsStore = { + // Setters + setEvents(newEvents: CalendarEvent[]) { + events = newEvents; + }, + + setCalendars(newCalendars: Calendar[]) { + calendars = newCalendars; + }, + + // Quick Stats + get totalEvents() { + return events.length; + }, + + get eventsToday() { + return events.filter((e) => { + const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime; + return isToday(startTime); + }).length; + }, + + get eventsThisWeek() { + const now = new Date(); + return events.filter((e) => { + const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime; + return isSameWeek(startTime, now, { weekStartsOn: 1 }); + }).length; + }, + + get upcomingEvents() { + const now = new Date(); + const nextWeek = addDays(now, 7); + return events.filter((e) => { + const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime; + return startTime > now && startTime <= nextWeek; + }).length; + }, + + get busyHoursThisWeek() { + const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 }); + const weekEnd = endOfWeek(new Date(), { weekStartsOn: 1 }); + + let totalMinutes = 0; + + events.forEach((e) => { + if (e.isAllDay) return; // Skip all-day events + + const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime; + const endTime = typeof e.endTime === 'string' ? parseISO(e.endTime) : e.endTime; + + if (startTime >= weekStart && startTime <= weekEnd) { + totalMinutes += differenceInMinutes(endTime, startTime); + } + }); + + return Math.round((totalMinutes / 60) * 10) / 10; // Round to 1 decimal + }, + + get totalCalendars() { + return calendars.length; + }, + + get averageEventDuration() { + const timedEvents = events.filter((e) => !e.isAllDay); + if (timedEvents.length === 0) return 0; + + const totalMinutes = timedEvents.reduce((sum, e) => { + const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime; + const endTime = typeof e.endTime === 'string' ? parseISO(e.endTime) : e.endTime; + return sum + differenceInMinutes(endTime, startTime); + }, 0); + + return Math.round(totalMinutes / timedEvents.length); + }, + + // Activity Heatmap (last 6 months) - based on event creation + get activityHeatmap(): HeatmapDataPoint[] { + const endDate = new Date(); + const startDate = subDays(endDate, 180); + + // Count events per day based on start time + const eventMap = new Map(); + + events.forEach((e) => { + const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime; + const dateKey = format(startTime, 'yyyy-MM-dd'); + eventMap.set(dateKey, (eventMap.get(dateKey) || 0) + 1); + }); + + // Generate all days + const days = eachDayOfInterval({ start: startDate, end: endDate }); + + return days.map((day) => { + const dateKey = format(day, 'yyyy-MM-dd'); + return { + date: dateKey, + count: eventMap.get(dateKey) || 0, + dayOfWeek: day.getDay(), + }; + }); + }, + + // Weekly Trend (last 4 weeks) + get weeklyTrend(): TrendDataPoint[] { + const endDate = new Date(); + const startDate = subDays(endDate, 27); + + const eventMap = new Map(); + + events.forEach((e) => { + const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime; + if (startTime >= startDate && startTime <= endDate) { + const dateKey = format(startTime, 'yyyy-MM-dd'); + eventMap.set(dateKey, (eventMap.get(dateKey) || 0) + 1); + } + }); + + const days = eachDayOfInterval({ start: startDate, end: endDate }); + + return days.map((day) => { + const dateKey = format(day, 'yyyy-MM-dd'); + return { + date: dateKey, + count: eventMap.get(dateKey) || 0, + label: format(day, 'EEE', { locale: de }), + }; + }); + }, + + // Status Breakdown (Donut Chart) + get statusBreakdown(): DonutSegment[] { + const total = events.length; + if (total === 0) return []; + + const counts: Record = { + confirmed: 0, + tentative: 0, + cancelled: 0, + }; + + events.forEach((e) => { + const status = e.status || 'confirmed'; + if (counts[status] !== undefined) { + counts[status]++; + } + }); + + return (['confirmed', 'tentative', 'cancelled'] as const).map((status) => ({ + id: status, + label: STATUS_LABELS[status], + count: counts[status], + percentage: total > 0 ? Math.round((counts[status] / total) * 100) : 0, + color: STATUS_COLORS[status], + })); + }, + + // Calendar Activity (Progress Bars) + get calendarActivity(): ProgressItem[] { + const calendarMap = new Map(); + + // Initialize with all calendars + calendars.forEach((c) => { + calendarMap.set(c.id, { total: 0, thisWeek: 0 }); + }); + + const now = new Date(); + + // Count events per calendar + events.forEach((e) => { + const calendarId = e.calendarId; + const data = calendarMap.get(calendarId) || { total: 0, thisWeek: 0 }; + data.total++; + + const startTime = typeof e.startTime === 'string' ? parseISO(e.startTime) : e.startTime; + if (isSameWeek(startTime, now, { weekStartsOn: 1 })) { + data.thisWeek++; + } + + calendarMap.set(calendarId, data); + }); + + // Convert to array + const result: ProgressItem[] = []; + + calendarMap.forEach((data, calendarId) => { + if (data.total === 0) return; + + const calendar = calendars.find((c) => c.id === calendarId); + + result.push({ + id: calendarId, + name: calendar?.name || 'Unbekannt', + color: calendar?.color || '#6B7280', + total: data.total, + completed: data.thisWeek, + percentage: data.total > 0 ? Math.round((data.thisWeek / data.total) * 100) : 0, + }); + }); + + // Sort by total events descending + return result.sort((a, b) => b.total - a.total); + }, + + // All-day vs Timed events ratio + get allDayRatio() { + const allDay = events.filter((e) => e.isAllDay).length; + const timed = events.filter((e) => !e.isAllDay).length; + return { + allDay, + timed, + allDayPercentage: events.length > 0 ? Math.round((allDay / events.length) * 100) : 0, + }; + }, + + // Recurring events count + get recurringEventsCount() { + return events.filter((e) => e.recurrenceRule).length; + }, +}; diff --git a/apps/calendar/apps/web/src/lib/stores/todos.svelte.ts b/apps/calendar/apps/web/src/lib/stores/todos.svelte.ts new file mode 100644 index 000000000..1311d0971 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/stores/todos.svelte.ts @@ -0,0 +1,405 @@ +/** + * Todos Store - Manages todos from Todo-App using Svelte 5 runes + * Cross-app integration with Todo Backend + */ + +import * as api from '$lib/api/todos'; +import type { + Task, + TaskPriority, + CreateTaskInput, + UpdateTaskInput, + TaskQuery, + Project, + Label, +} from '$lib/api/todos'; +import { PRIORITY_ORDER } from '$lib/api/todos'; +import { + format, + parseISO, + isSameDay, + isToday, + isBefore, + startOfDay, + addDays, + isWithinInterval, +} from 'date-fns'; + +// Re-export types for convenience +export type { Task, TaskPriority, CreateTaskInput, UpdateTaskInput, Project, Label }; + +// State +let todos = $state([]); +let projects = $state([]); +let labels = $state([]); +let loading = $state(false); +let error = $state(null); +let loadedRange = $state<{ start: Date; end: Date } | null>(null); +let serviceAvailable = $state(true); + +export const todosStore = { + // ========== Getters ========== + get todos() { + return todos ?? []; + }, + get projects() { + return projects ?? []; + }, + get labels() { + return labels ?? []; + }, + get loading() { + return loading; + }, + get error() { + return error; + }, + get serviceAvailable() { + return serviceAvailable; + }, + + // ========== Derived Getters ========== + + /** + * Get todos for a specific day + */ + getTodosForDay(date: Date): Task[] { + const currentTodos = todos ?? []; + if (!Array.isArray(currentTodos)) return []; + + return currentTodos.filter((task) => { + if (!task.dueDate || task.isCompleted) return false; + const dueDate = typeof task.dueDate === 'string' ? parseISO(task.dueDate) : task.dueDate; + return isSameDay(dueDate, date); + }); + }, + + /** + * Get todos within a date range + */ + getTodosInRange(start: Date, end: Date): Task[] { + const currentTodos = todos ?? []; + if (!Array.isArray(currentTodos)) return []; + + return currentTodos.filter((task) => { + if (!task.dueDate) return false; + const dueDate = typeof task.dueDate === 'string' ? parseISO(task.dueDate) : task.dueDate; + return isWithinInterval(dueDate, { start, end }); + }); + }, + + /** + * Get today's uncompleted todos + */ + get todaysTodos(): Task[] { + const currentTodos = todos ?? []; + if (!Array.isArray(currentTodos)) return []; + + return currentTodos + .filter((task) => { + if (task.isCompleted) return false; + if (!task.dueDate) return false; + const dueDate = typeof task.dueDate === 'string' ? parseISO(task.dueDate) : task.dueDate; + return isToday(dueDate); + }) + .sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]); + }, + + /** + * Get overdue todos (due before today, not completed) + */ + get overdueTodos(): Task[] { + const currentTodos = todos ?? []; + if (!Array.isArray(currentTodos)) return []; + + const today = startOfDay(new Date()); + + return currentTodos + .filter((task) => { + if (task.isCompleted) return false; + if (!task.dueDate) return false; + const dueDate = typeof task.dueDate === 'string' ? parseISO(task.dueDate) : task.dueDate; + return isBefore(startOfDay(dueDate), today); + }) + .sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]); + }, + + /** + * Get upcoming todos (next 7 days, not including today) + */ + get upcomingTodos(): Task[] { + const currentTodos = todos ?? []; + if (!Array.isArray(currentTodos)) return []; + + const tomorrow = startOfDay(addDays(new Date(), 1)); + const weekFromNow = startOfDay(addDays(new Date(), 7)); + + return currentTodos + .filter((task) => { + if (task.isCompleted) return false; + if (!task.dueDate) return false; + const dueDate = typeof task.dueDate === 'string' ? parseISO(task.dueDate) : task.dueDate; + return isWithinInterval(startOfDay(dueDate), { start: tomorrow, end: weekFromNow }); + }) + .sort((a, b) => { + // First sort by date + const dateA = a.dueDate ? parseISO(a.dueDate as string) : new Date(); + const dateB = b.dueDate ? parseISO(b.dueDate as string) : new Date(); + const dateDiff = dateA.getTime() - dateB.getTime(); + if (dateDiff !== 0) return dateDiff; + // Then by priority + return PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]; + }); + }, + + /** + * Get todos without due date + */ + get unscheduledTodos(): Task[] { + const currentTodos = todos ?? []; + if (!Array.isArray(currentTodos)) return []; + + return currentTodos + .filter((task) => !task.isCompleted && !task.dueDate) + .sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]); + }, + + /** + * Get completed todos + */ + get completedTodos(): Task[] { + const currentTodos = todos ?? []; + if (!Array.isArray(currentTodos)) return []; + + return currentTodos.filter((task) => task.isCompleted); + }, + + /** + * Get combined sidebar todos (overdue + today, sorted by priority) + * Limited to show in sidebar + */ + getSidebarTodos(limit = 5): Task[] { + const overdue = this.overdueTodos; + const today = this.todaysTodos; + + // Combine and sort: overdue first, then today, both by priority + const combined = [...overdue, ...today]; + + return combined.slice(0, limit); + }, + + /** + * Get total count of active todos (not completed) + */ + get activeTodosCount(): number { + const currentTodos = todos ?? []; + if (!Array.isArray(currentTodos)) return 0; + + return currentTodos.filter((task) => !task.isCompleted).length; + }, + + // ========== API Methods ========== + + /** + * Fetch todos for a date range + */ + async fetchTodos(startDate?: Date, endDate?: Date) { + loading = true; + error = null; + + const query: TaskQuery = { + isCompleted: false, + }; + + if (startDate) { + query.dueDateFrom = format(startDate, 'yyyy-MM-dd'); + } + if (endDate) { + query.dueDateTo = format(endDate, 'yyyy-MM-dd'); + } + + const result = await api.getTasks(query); + + if (result.error) { + error = result.error.message; + serviceAvailable = false; + } else { + todos = result.data || []; + serviceAvailable = true; + if (startDate && endDate) { + loadedRange = { start: startDate, end: endDate }; + } + } + + loading = false; + return result; + }, + + /** + * Fetch today's todos (shortcut) + */ + async fetchTodayTodos() { + loading = true; + error = null; + + const result = await api.getTodayTasks(); + + if (result.error) { + error = result.error.message; + serviceAvailable = false; + } else { + // Merge with existing todos (avoid duplicates) + const newTodos = result.data || []; + const existingIds = new Set(todos.map((t) => t.id)); + const uniqueNew = newTodos.filter((t) => !existingIds.has(t.id)); + todos = [...todos, ...uniqueNew]; + serviceAvailable = true; + } + + loading = false; + return result; + }, + + /** + * Fetch upcoming todos (shortcut) + */ + async fetchUpcomingTodos() { + loading = true; + error = null; + + const result = await api.getUpcomingTasks(); + + if (result.error) { + error = result.error.message; + serviceAvailable = false; + } else { + // Merge with existing todos (avoid duplicates) + const newTodos = result.data || []; + const existingIds = new Set(todos.map((t) => t.id)); + const uniqueNew = newTodos.filter((t) => !existingIds.has(t.id)); + todos = [...todos, ...uniqueNew]; + serviceAvailable = true; + } + + loading = false; + return result; + }, + + /** + * Fetch projects + */ + async fetchProjects() { + const result = await api.getProjects(); + + if (!result.error && result.data) { + projects = result.data; + } + + return result; + }, + + /** + * Fetch labels + */ + async fetchLabels() { + const result = await api.getLabels(); + + if (!result.error && result.data) { + labels = result.data; + } + + return result; + }, + + /** + * Create a new todo + */ + async createTodo(data: CreateTaskInput) { + const result = await api.createTask(data); + + if (result.data) { + todos = [...todos, result.data]; + } + + return result; + }, + + /** + * Update a todo + */ + async updateTodo(id: string, data: UpdateTaskInput) { + const result = await api.updateTask(id, data); + + if (result.data) { + todos = todos.map((t) => (t.id === id ? result.data! : t)); + } + + return result; + }, + + /** + * Delete a todo + */ + async deleteTodo(id: string) { + const result = await api.deleteTask(id); + + if (!result.error) { + todos = todos.filter((t) => t.id !== id); + } + + return result; + }, + + /** + * Toggle todo completion + */ + async toggleComplete(id: string) { + const todo = todos.find((t) => t.id === id); + if (!todo) return { data: null, error: new Error('Todo not found') }; + + const result = todo.isCompleted ? await api.uncompleteTask(id) : await api.completeTask(id); + + if (result.data) { + todos = todos.map((t) => (t.id === id ? result.data! : t)); + } + + return result; + }, + + /** + * Get todo by ID + */ + getById(id: string): Task | undefined { + const currentTodos = todos ?? []; + if (!Array.isArray(currentTodos)) return undefined; + + return currentTodos.find((t) => t.id === id); + }, + + /** + * Get project by ID + */ + getProjectById(id: string): Project | undefined { + const currentProjects = projects ?? []; + if (!Array.isArray(currentProjects)) return undefined; + + return currentProjects.find((p) => p.id === id); + }, + + /** + * Clear todos cache + */ + clear() { + todos = []; + loadedRange = null; + }, + + /** + * Check if Todo service is available + */ + async checkServiceHealth(): Promise { + const result = await api.getTasks({ limit: 1 }); + serviceAvailable = !result.error; + return serviceAvailable; + }, +}; diff --git a/apps/calendar/apps/web/src/lib/utils/event-parser.ts b/apps/calendar/apps/web/src/lib/utils/event-parser.ts new file mode 100644 index 000000000..5a7beab9b --- /dev/null +++ b/apps/calendar/apps/web/src/lib/utils/event-parser.ts @@ -0,0 +1,261 @@ +/** + * Event Parser for Calendar App + * + * Extends the base parser with event-specific patterns: + * - Calendar: @CalendarName + * - Duration: für 2 Stunden, 30 min + * - Location: in Berlin, bei Firma XY + */ + +import { + parseBaseInput, + extractAtReference, + combineDateAndTime, + formatDatePreview, + formatTimePreview, +} from '@manacore/shared-utils'; + +export interface ParsedEvent { + title: string; + startTime?: Date; + endTime?: Date; + calendarName?: string; + location?: string; + tagNames: string[]; + isAllDay: boolean; +} + +interface Calendar { + id: string; + name: string; +} + +interface Tag { + id: string; + name: string; +} + +export interface ParsedEventWithIds { + title: string; + startTime?: string; + endTime?: string; + calendarId?: string; + tagIds: string[]; + location?: string; + isAllDay: boolean; +} + +// Duration patterns (event-specific) +const DURATION_PATTERNS: { pattern: RegExp; getMinutes: (match: RegExpMatchArray) => number }[] = [ + // "für X Stunden" or "X Stunden" + { + pattern: /(?:für\s+)?(\d+(?:[.,]\d+)?)\s*(?:stunde?n?|h)\b/i, + getMinutes: (match) => Math.round(parseFloat(match[1].replace(',', '.')) * 60), + }, + // "für X Minuten" or "X min" + { + pattern: /(?:für\s+)?(\d+)\s*(?:minuten?|min)\b/i, + getMinutes: (match) => parseInt(match[1], 10), + }, + // "1,5h" or "1.5h" + { + pattern: /(\d+[.,]\d+)\s*h\b/i, + getMinutes: (match) => Math.round(parseFloat(match[1].replace(',', '.')) * 60), + }, +]; + +// Location patterns (event-specific) +const LOCATION_PATTERNS: RegExp[] = [ + /\bin\s+([^@#!]+?)(?=\s+(?:@|#|!|\d{1,2}[:.]\d{2}|um\s+\d|\d{1,2}\s*uhr)|$)/i, + /\bbei\s+([^@#!]+?)(?=\s+(?:@|#|!|\d{1,2}[:.]\d{2}|um\s+\d|\d{1,2}\s*uhr)|$)/i, +]; + +/** + * Extract duration from text + */ +function extractDuration(text: string): { minutes?: number; remaining: string } { + for (const { pattern, getMinutes } of DURATION_PATTERNS) { + const match = text.match(pattern); + if (match) { + return { + minutes: getMinutes(match), + remaining: text.replace(pattern, '').trim(), + }; + } + } + return { minutes: undefined, remaining: text }; +} + +/** + * Extract location from text + */ +function extractLocation(text: string): { location?: string; remaining: string } { + for (const pattern of LOCATION_PATTERNS) { + const match = text.match(pattern); + if (match) { + return { + location: match[1].trim(), + remaining: text.replace(pattern, '').trim(), + }; + } + } + return { location: undefined, remaining: text }; +} + +/** + * Parse natural language event input + * + * Examples: + * - "Meeting morgen 14 Uhr für 1 Stunde @Arbeit in Büro #wichtig" + * - "Arzttermin Montag 10:30 30 min bei Dr. Müller" + * - "Geburtstag 15.12. ganztägig #privat" + */ +export function parseEventInput(input: string): ParsedEvent { + let text = input.trim(); + + // Check for all-day indicator first + const allDayPattern = /\bganztägig\b|\ball[- ]?day\b/i; + const isAllDay = allDayPattern.test(text); + text = text.replace(allDayPattern, '').trim(); + + // Extract calendar (@CalendarName) - event-specific + const calendarResult = extractAtReference(text); + text = calendarResult.remaining; + const calendarName = calendarResult.value; + + // Extract duration first (before base parser) + const durationResult = extractDuration(text); + text = durationResult.remaining; + const durationMinutes = durationResult.minutes; + + // Extract location (before base parser to avoid conflicts) + const locationResult = extractLocation(text); + text = locationResult.remaining; + const location = locationResult.location; + + // Use base parser for common patterns (date, time, tags) + const base = parseBaseInput(text); + + // Combine date and time for start + const startTime = combineDateAndTime(base.date, base.time); + + // Calculate end time based on duration (default 1 hour) + let endTime: Date | undefined; + if (startTime && !isAllDay) { + const duration = durationMinutes || 60; // Default 1 hour + endTime = new Date(startTime.getTime() + duration * 60 * 1000); + } else if (startTime && isAllDay) { + // All-day events: end time is end of day + endTime = new Date(startTime); + endTime.setHours(23, 59, 59, 999); + } + + return { + title: base.title, + startTime, + endTime, + calendarName, + location, + tagNames: base.tagNames, + isAllDay, + }; +} + +/** + * Resolve calendar and tag names to IDs + */ +export function resolveEventIds( + parsed: ParsedEvent, + calendars: Calendar[], + tags: Tag[] +): ParsedEventWithIds { + let calendarId: string | undefined; + const tagIds: string[] = []; + + // Find calendar by name (case-insensitive) + if (parsed.calendarName) { + const calendar = calendars.find( + (c) => c.name.toLowerCase() === parsed.calendarName!.toLowerCase() + ); + if (calendar) { + calendarId = calendar.id; + } + } + + // Use default calendar if none specified + if (!calendarId && calendars.length > 0) { + const defaultCalendar = calendars.find((c: any) => c.isDefault) || calendars[0]; + calendarId = defaultCalendar.id; + } + + // Find tags by name (case-insensitive) + for (const tagName of parsed.tagNames) { + const tag = tags.find((t) => t.name.toLowerCase() === tagName.toLowerCase()); + if (tag) { + tagIds.push(tag.id); + } + } + + return { + title: parsed.title, + startTime: parsed.startTime?.toISOString(), + endTime: parsed.endTime?.toISOString(), + calendarId, + tagIds, + location: parsed.location, + isAllDay: parsed.isAllDay, + }; +} + +/** + * Format parsed event for preview display + */ +export function formatParsedEventPreview(parsed: ParsedEvent): string { + const parts: string[] = []; + + if (parsed.startTime) { + let dateStr = `📅 ${formatDatePreview(parsed.startTime)}`; + + if (!parsed.isAllDay && parsed.startTime.getHours() !== 0) { + dateStr += ` ${formatTimePreview({ + hours: parsed.startTime.getHours(), + minutes: parsed.startTime.getMinutes(), + })}`; + + // Add duration if end time differs + if (parsed.endTime) { + const durationMs = parsed.endTime.getTime() - parsed.startTime.getTime(); + const durationMins = Math.round(durationMs / 60000); + if (durationMins > 0 && durationMins !== 60) { + if (durationMins >= 60) { + const hours = Math.floor(durationMins / 60); + const mins = durationMins % 60; + dateStr += mins > 0 ? ` (${hours}h ${mins}min)` : ` (${hours}h)`; + } else { + dateStr += ` (${durationMins}min)`; + } + } + } + } + + if (parsed.isAllDay) { + dateStr += ' (Ganztägig)'; + } + + parts.push(dateStr); + } + + if (parsed.location) { + parts.push(`📍 ${parsed.location}`); + } + + if (parsed.calendarName) { + parts.push(`📆 ${parsed.calendarName}`); + } + + if (parsed.tagNames.length > 0) { + parts.push(`🏷️ ${parsed.tagNames.join(', ')}`); + } + + return parts.join(' · '); +} diff --git a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte index 79b0f4509..d97272905 100644 --- a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte @@ -9,14 +9,23 @@ PillDropdownItem, CommandBarItem, QuickAction, + CreatePreview, } from '@manacore/shared-ui'; import { theme } from '$lib/stores/theme'; import { authStore } from '$lib/stores/auth.svelte'; import { userSettings } from '$lib/stores/user-settings.svelte'; import { viewStore } from '$lib/stores/view.svelte'; import { calendarsStore } from '$lib/stores/calendars.svelte'; + import { eventsStore } from '$lib/stores/events.svelte'; + import { eventTagsStore } from '$lib/stores/event-tags.svelte'; import { settingsStore } from '$lib/stores/settings.svelte'; - import { THEME_DEFINITIONS } from '@manacore/shared-theme'; + import { + THEME_DEFINITIONS, + DEFAULT_THEME_VARIANTS, + EXTENDED_THEME_VARIANTS, + } from '@manacore/shared-theme'; + import type { ThemeVariant } from '@manacore/shared-theme'; + import { filterHiddenNavItems } from '@manacore/shared-theme'; import { isSidebarMode as sidebarModeStore, isNavCollapsed as collapsedStore, @@ -27,6 +36,11 @@ import { searchEvents } from '$lib/api/events'; import { format } from 'date-fns'; import { de } from 'date-fns/locale'; + import { + parseEventInput, + resolveEventIds, + formatParsedEventPreview, + } from '$lib/utils/event-parser'; // App switcher items const appItems = getPillAppItems('calendar'); @@ -46,6 +60,7 @@ onclick: () => viewStore.goToToday(), }, { id: 'agenda', label: 'Agenda anzeigen', icon: 'list', href: '/agenda' }, + { id: 'tasks', label: 'Aufgaben anzeigen', icon: 'check-square', href: '/tasks' }, { id: 'settings', label: 'Einstellungen', icon: 'settings', href: '/settings' }, ]; @@ -67,15 +82,73 @@ goto(`/event/${item.id}`); } + // CommandBar Quick-Create handlers + function handleCommandBarParseCreate(query: string): CreatePreview | null { + if (!query.trim()) return null; + + const parsed = parseEventInput(query); + if (!parsed.title) return null; + + return { + title: parsed.title, + subtitle: formatParsedEventPreview(parsed), + }; + } + + async function handleCommandBarCreate(query: string): Promise { + const parsed = parseEventInput(query); + if (!parsed.title) return; + + // Resolve calendar and tag names to IDs + const calendars = calendarsStore.calendars.map((c) => ({ id: c.id, name: c.name })); + const tags = eventTagsStore.tags.map((t) => ({ id: t.id, name: t.name })); + const resolved = resolveEventIds(parsed, calendars, tags); + + // Ensure we have a calendar + if (!resolved.calendarId) { + console.error('No calendar available'); + return; + } + + // Ensure we have start and end times + if (!resolved.startTime) { + // Default to now + 1 hour + const now = new Date(); + resolved.startTime = now.toISOString(); + const end = new Date(now.getTime() + 60 * 60 * 1000); + resolved.endTime = end.toISOString(); + } + + await eventsStore.createEvent({ + calendarId: resolved.calendarId, + title: resolved.title, + startTime: resolved.startTime, + endTime: resolved.endTime || resolved.startTime, + isAllDay: resolved.isAllDay, + location: resolved.location, + tagIds: resolved.tagIds, + }); + } + let isSidebarMode = $state(false); let isCollapsed = $state(false); // Use theme store's isDark directly let isDark = $derived(theme.isDark); + // Get pinned themes from user settings (extended themes only) + let pinnedThemes = $derived( + (userSettings.theme?.pinnedThemes || []).filter((t): t is ThemeVariant => + EXTENDED_THEME_VARIANTS.includes(t as ThemeVariant) + ) + ); + + // Visible themes in PillNav: default + pinned extended + let visibleThemes = $derived([...DEFAULT_THEME_VARIANTS, ...pinnedThemes]); + // Theme variant dropdown items let themeVariantItems = $derived([ - ...theme.variants.map((variant) => ({ + ...visibleThemes.map((variant) => ({ id: variant, label: THEME_DEFINITIONS[variant].label, icon: THEME_DEFINITIONS[variant].icon, @@ -107,16 +180,25 @@ // User email for user dropdown let userEmail = $derived(authStore.user?.email || 'Menü'); - // Navigation items for Calendar - const navItems: PillNavItem[] = [ + // Base navigation items for Calendar + const baseNavItems: PillNavItem[] = [ { href: '/', label: 'Kalender', icon: 'calendar' }, { href: '/agenda', label: 'Agenda', icon: 'list' }, + { href: '/tasks', label: 'Aufgaben', icon: 'check-square' }, + { href: '/tags', label: 'Tags', icon: 'tag' }, + { href: '/statistics', label: 'Statistiken', icon: 'bar-chart-3' }, + { href: '/network', label: 'Netzwerk', icon: 'share-2' }, { href: '/settings', label: 'Einstellungen', icon: 'settings' }, { href: '/feedback', label: 'Feedback', icon: 'chat' }, ]; - // Navigation shortcuts (Ctrl+1-4) - const navRoutes = navItems.map((item) => item.href); + // Navigation items filtered by visibility settings + const navItems = $derived( + filterHiddenNavItems('calendar', baseNavItems, userSettings.nav.hiddenNavItems) + ); + + // Navigation shortcuts (Ctrl+1-4) - use base items for consistent shortcuts + const navRoutes = baseNavItems.map((item) => item.href); function handleKeydown(event: KeyboardEvent) { const target = event.target as HTMLElement; @@ -183,8 +265,9 @@ // Initialize view state viewStore.initialize(); - // Load calendars and user settings + // Load calendars, tags, and user settings await calendarsStore.fetchCalendars(); + await eventTagsStore.fetchTags(); await userSettings.load(); // Redirect to start page if on root and a custom start page is set @@ -266,9 +349,13 @@ onSearch={handleCommandBarSearch} onSelect={handleCommandBarSelect} quickActions={commandBarQuickActions} - placeholder="Termin suchen..." + placeholder="Termin suchen oder erstellen..." emptyText="Keine Termine gefunden" searchingText="Suche..." + onCreate={handleCommandBarCreate} + onParseCreate={handleCommandBarParseCreate} + createText="Als Termin erstellen" + createShortcut="⌘↵" />
diff --git a/apps/calendar/apps/web/src/routes/(app)/+page.svelte b/apps/calendar/apps/web/src/routes/(app)/+page.svelte index 3918aab21..6707d9259 100644 --- a/apps/calendar/apps/web/src/routes/(app)/+page.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/+page.svelte @@ -16,6 +16,7 @@ import YearView from '$lib/components/calendar/YearView.svelte'; import MiniCalendar from '$lib/components/calendar/MiniCalendar.svelte'; import CalendarSidebar from '$lib/components/calendar/CalendarSidebar.svelte'; + import TodoSidebarSection from '$lib/components/calendar/TodoSidebarSection.svelte'; import QuickEventOverlay from '$lib/components/event/QuickEventOverlay.svelte'; import EventDetailModal from '$lib/components/event/EventDetailModal.svelte'; import { CalendarViewSkeleton } from '$lib/components/skeletons'; @@ -130,6 +131,8 @@ + + diff --git a/apps/calendar/apps/web/src/routes/(app)/network/+page.svelte b/apps/calendar/apps/web/src/routes/(app)/network/+page.svelte new file mode 100644 index 000000000..7b26f27d5 --- /dev/null +++ b/apps/calendar/apps/web/src/routes/(app)/network/+page.svelte @@ -0,0 +1,415 @@ + + + + Netzwerk - Kalender + + +
+ +
+ +
+ + + {#if networkStore.error} + + {/if} + + +
+ {#if networkStore.loading} +
+
+

Lade Netzwerk-Graph...

+
+ {:else} + + {/if} +
+ + + {#if networkStore.selectedNode} +
+
+

{networkStore.selectedNode.name}

+ +
+ {#if networkStore.selectedNode.subtitle} +

{networkStore.selectedNode.subtitle}

+ {/if} + {#if networkStore.selectedNode.tags.length > 0} +
+ {#each networkStore.selectedNode.tags as tag} + + {tag.name} + + {/each} +
+ {/if} +
+ {networkStore.selectedNode.connectionCount} Verbindungen +
+ +
+ {/if} +
+ + diff --git a/apps/calendar/apps/web/src/routes/(app)/statistics/+page.svelte b/apps/calendar/apps/web/src/routes/(app)/statistics/+page.svelte new file mode 100644 index 000000000..231dcd38c --- /dev/null +++ b/apps/calendar/apps/web/src/routes/(app)/statistics/+page.svelte @@ -0,0 +1,287 @@ + + + + Statistiken - Kalender + + +
+ + + {#if loading} + + {:else} + +
+ +
+ + +
+ +
+ +
+ + +
+
+ +
+ +
+ +
+
+ + +
+ +
+
+ + +
+
+ Ganztägige Events + + {calendarStatisticsStore.allDayRatio.allDay} + ({calendarStatisticsStore.allDayRatio.allDayPercentage}%) + +
+ +
+ Wiederkehrende Events + {calendarStatisticsStore.recurringEventsCount} +
+ +
+ Events gesamt + {calendarStatisticsStore.totalEvents} +
+
+ {/if} +
+ + diff --git a/apps/calendar/apps/web/src/routes/(app)/tags/+page.svelte b/apps/calendar/apps/web/src/routes/(app)/tags/+page.svelte new file mode 100644 index 000000000..d05568248 --- /dev/null +++ b/apps/calendar/apps/web/src/routes/(app)/tags/+page.svelte @@ -0,0 +1,309 @@ + + + + Tags - Kalender + + +
+ +
+ + + +

Tags

+ +
+ + +
+ + +
+ + {#if eventTagsStore.error} + + {/if} + + + + + {#if !eventTagsStore.loading && eventTagsStore.tags.length > 0} +

+ {eventTagsStore.tags.length} + {eventTagsStore.tags.length === 1 ? 'Tag' : 'Tags'} +

+ {/if} + + {#if !eventTagsStore.loading && eventTagsStore.tags.length === 0 && !searchQuery} +
+ +
+ {/if} +
+ + + + + diff --git a/apps/calendar/apps/web/src/routes/(app)/tasks/+page.svelte b/apps/calendar/apps/web/src/routes/(app)/tasks/+page.svelte new file mode 100644 index 000000000..403dbd875 --- /dev/null +++ b/apps/calendar/apps/web/src/routes/(app)/tasks/+page.svelte @@ -0,0 +1,486 @@ + + + + Aufgaben | Kalender + + +
+ + + + + + +
+ {#if showQuickAdd} + (showQuickAdd = false)} + oncancel={() => (showQuickAdd = false)} + /> + {:else} + + {/if} +
+ + + {#if loading} + + {:else if !todosStore.serviceAvailable} +
+ +

Todo-Service ist nicht erreichbar

+

Bitte versuchen Sie es später erneut

+
+ {:else if groupedItems.length === 0} +
+ +

Keine Einträge gefunden

+

+ {#if !showEvents && !showTodos} + Aktivieren Sie mindestens einen Filter + {:else} + Erstellen Sie eine neue Aufgabe oder ändern Sie den Zeitraum + {/if} +

+
+ {:else} +
+ {#each groupedItems as group} +
+

+ {formatDateHeader(group.date)} + ({group.items.length}) +

+ +
+ {#each group.items as item} + {#if item.type === 'event' && item.event} + handleEventClick(item.event!.id)} + /> + {:else if item.type === 'todo' && item.todo} + handleTodoClick(item.todo!)} + /> + {/if} + {/each} +
+
+ {/each} +
+ {/if} +
+ + +{#if selectedTask} + +{/if} + + diff --git a/apps/calendar/apps/web/src/routes/(app)/themes/+page.svelte b/apps/calendar/apps/web/src/routes/(app)/themes/+page.svelte new file mode 100644 index 000000000..de8a397fa --- /dev/null +++ b/apps/calendar/apps/web/src/routes/(app)/themes/+page.svelte @@ -0,0 +1,19 @@ + + + + Themes | Calendar + + + theme.setVariant(v)} + showModeSelector={true} + currentMode={theme.mode} + onModeChange={(m) => theme.setMode(m)} + showBackButton={true} + onBack={() => goto('/')} +/> diff --git a/apps/calendar/packages/shared/src/types/event.ts b/apps/calendar/packages/shared/src/types/event.ts index 614eeaa1c..8260afb15 100644 --- a/apps/calendar/packages/shared/src/types/event.ts +++ b/apps/calendar/packages/shared/src/types/event.ts @@ -7,6 +7,18 @@ export interface EventAttendee { status?: 'accepted' | 'declined' | 'tentative' | 'pending'; } +/** + * Event tag with color + */ +export interface EventTag { + id: string; + userId: string; + name: string; + color: string; + createdAt: Date | string; + updatedAt: Date | string; +} + /** * How to display all-day events */ @@ -92,6 +104,9 @@ export interface CalendarEvent { // Metadata metadata?: EventMetadata | null; + // Tags (populated when fetched) + tags?: EventTag[]; + createdAt: Date | string; updatedAt: Date | string; } @@ -124,6 +139,7 @@ export interface CreateEventInput { color?: string; status?: EventStatus; metadata?: EventMetadata; + tagIds?: string[]; } /** @@ -144,6 +160,7 @@ export interface UpdateEventInput { color?: string | null; status?: EventStatus; metadata?: EventMetadata; + tagIds?: string[]; } /** diff --git a/apps/chat/apps/backend/Dockerfile b/apps/chat/apps/backend/Dockerfile index 14f33c186..9bec7862f 100644 --- a/apps/chat/apps/backend/Dockerfile +++ b/apps/chat/apps/backend/Dockerfile @@ -14,6 +14,7 @@ COPY pnpm-lock.yaml ./ # Copy shared packages COPY packages/shared-errors ./packages/shared-errors COPY packages/shared-nestjs-auth ./packages/shared-nestjs-auth +COPY packages/shared-storage ./packages/shared-storage # Copy chat backend COPY apps/chat/apps/backend ./apps/chat/apps/backend @@ -28,6 +29,9 @@ RUN pnpm build WORKDIR /app/packages/shared-nestjs-auth RUN pnpm build +WORKDIR /app/packages/shared-storage +RUN pnpm build + # Build the backend WORKDIR /app/apps/chat/apps/backend RUN pnpm build diff --git a/apps/chat/apps/web/src/app.css b/apps/chat/apps/web/src/app.css index d75fc9594..c29749613 100644 --- a/apps/chat/apps/web/src/app.css +++ b/apps/chat/apps/web/src/app.css @@ -6,3 +6,5 @@ @source "../../../../packages/shared-auth-ui/src"; @source "../../../../packages/shared-branding/src"; @source "../../../../packages/shared-theme-ui/src"; +@source "../../../../packages/shared-theme-ui/src/components"; +@source "../../../../packages/shared-theme-ui/src/pages"; diff --git a/apps/chat/apps/web/src/routes/(protected)/+layout.svelte b/apps/chat/apps/web/src/routes/(protected)/+layout.svelte index 320275e76..275071d82 100644 --- a/apps/chat/apps/web/src/routes/(protected)/+layout.svelte +++ b/apps/chat/apps/web/src/routes/(protected)/+layout.svelte @@ -6,7 +6,13 @@ import { authStore } from '$lib/stores/auth.svelte'; import { userSettings } from '$lib/stores/user-settings.svelte'; import { theme } from '$lib/stores/theme'; - import { THEME_DEFINITIONS } from '@manacore/shared-theme'; + import { + THEME_DEFINITIONS, + DEFAULT_THEME_VARIANTS, + EXTENDED_THEME_VARIANTS, + } from '@manacore/shared-theme'; + import type { ThemeVariant } from '@manacore/shared-theme'; + import { filterHiddenNavItems } from '@manacore/shared-theme'; import { isSidebarMode as sidebarModeStore, isNavCollapsed as collapsedStore, @@ -30,10 +36,20 @@ // Use theme store's isDark directly let isDark = $derived(theme.isDark); + // Get pinned themes from user settings (extended themes only) + let pinnedThemes = $derived( + (userSettings.theme?.pinnedThemes || []).filter((t): t is ThemeVariant => + EXTENDED_THEME_VARIANTS.includes(t as ThemeVariant) + ) + ); + + // Visible themes in PillNav: default + pinned extended + let visibleThemes = $derived([...DEFAULT_THEME_VARIANTS, ...pinnedThemes]); + // Theme variant dropdown items let themeVariantItems = $derived([ - // Theme variants - ...theme.variants.map((variant) => ({ + // Theme variants (only default + pinned) + ...visibleThemes.map((variant) => ({ id: variant, label: THEME_DEFINITIONS[variant].label, icon: THEME_DEFINITIONS[variant].icon, @@ -63,8 +79,8 @@ ); let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale)); - // Navigation items for Chat (settings moved to user dropdown) - const navItems: PillNavItem[] = [ + // Base navigation items for Chat (settings moved to user dropdown) + const baseNavItems: PillNavItem[] = [ { href: '/chat', label: 'Chat', icon: 'home' }, { href: '/templates', label: 'Templates', icon: 'document' }, { href: '/spaces', label: 'Spaces', icon: 'building' }, @@ -73,14 +89,19 @@ { href: '/feedback', label: 'Feedback', icon: 'chat' }, ]; + // Navigation items filtered by visibility settings + const navItems = $derived( + filterHiddenNavItems('chat', baseNavItems, userSettings.nav.hiddenNavItems) + ); + // User email for user dropdown let userEmail = $derived(authStore.user?.email); // Check if current page is a chat page (needs full-width layout) let isChatPage = $derived($page.url.pathname.startsWith('/chat')); - // Navigation shortcuts (Ctrl+1-5) - const navRoutes = navItems.map((item) => item.href); + // Navigation shortcuts (Ctrl+1-5) - use base items for consistent shortcuts + const navRoutes = baseNavItems.map((item) => item.href); function handleKeydown(event: KeyboardEvent) { const target = event.target as HTMLElement; diff --git a/apps/clock/apps/backend/src/db/schema/alarms.schema.ts b/apps/clock/apps/backend/src/db/schema/alarms.schema.ts index fa4134970..aea182ed6 100644 --- a/apps/clock/apps/backend/src/db/schema/alarms.schema.ts +++ b/apps/clock/apps/backend/src/db/schema/alarms.schema.ts @@ -1,8 +1,17 @@ -import { pgTable, uuid, varchar, time, boolean, integer, timestamp } from 'drizzle-orm/pg-core'; +import { + pgTable, + uuid, + text, + varchar, + time, + boolean, + integer, + timestamp, +} from 'drizzle-orm/pg-core'; export const alarms = pgTable('alarms', { id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id').notNull(), + userId: text('user_id').notNull(), label: varchar('label', { length: 255 }), time: time('time').notNull(), enabled: boolean('enabled').default(true).notNull(), diff --git a/apps/clock/apps/backend/src/db/schema/presets.schema.ts b/apps/clock/apps/backend/src/db/schema/presets.schema.ts index 680cedf9e..0f5ebf24f 100644 --- a/apps/clock/apps/backend/src/db/schema/presets.schema.ts +++ b/apps/clock/apps/backend/src/db/schema/presets.schema.ts @@ -1,4 +1,4 @@ -import { pgTable, uuid, varchar, integer, jsonb, timestamp } from 'drizzle-orm/pg-core'; +import { pgTable, uuid, text, varchar, integer, jsonb, timestamp } from 'drizzle-orm/pg-core'; export interface PresetSettings { // For pomodoro presets @@ -12,7 +12,7 @@ export interface PresetSettings { export const presets = pgTable('presets', { id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id').notNull(), + userId: text('user_id').notNull(), type: varchar('type', { length: 20 }).notNull(), // 'timer' | 'pomodoro' name: varchar('name', { length: 255 }).notNull(), durationSeconds: integer('duration_seconds').notNull(), diff --git a/apps/clock/apps/backend/src/db/schema/timers.schema.ts b/apps/clock/apps/backend/src/db/schema/timers.schema.ts index 2499698f9..f70308218 100644 --- a/apps/clock/apps/backend/src/db/schema/timers.schema.ts +++ b/apps/clock/apps/backend/src/db/schema/timers.schema.ts @@ -1,8 +1,8 @@ -import { pgTable, uuid, varchar, integer, timestamp } from 'drizzle-orm/pg-core'; +import { pgTable, uuid, text, varchar, integer, timestamp } from 'drizzle-orm/pg-core'; export const timers = pgTable('timers', { id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id').notNull(), + userId: text('user_id').notNull(), label: varchar('label', { length: 255 }), durationSeconds: integer('duration_seconds').notNull(), remainingSeconds: integer('remaining_seconds'), diff --git a/apps/clock/apps/backend/src/db/schema/world-clocks.schema.ts b/apps/clock/apps/backend/src/db/schema/world-clocks.schema.ts index 948f94cc6..b0508865e 100644 --- a/apps/clock/apps/backend/src/db/schema/world-clocks.schema.ts +++ b/apps/clock/apps/backend/src/db/schema/world-clocks.schema.ts @@ -1,8 +1,8 @@ -import { pgTable, uuid, varchar, integer, timestamp } from 'drizzle-orm/pg-core'; +import { pgTable, uuid, text, varchar, integer, timestamp } from 'drizzle-orm/pg-core'; export const worldClocks = pgTable('world_clocks', { id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id').notNull(), + userId: text('user_id').notNull(), timezone: varchar('timezone', { length: 100 }).notNull(), // IANA timezone e.g. 'America/New_York' cityName: varchar('city_name', { length: 255 }).notNull(), sortOrder: integer('sort_order').default(0).notNull(), diff --git a/apps/clock/apps/web/src/app.css b/apps/clock/apps/web/src/app.css index 197179276..6f6ddb00f 100644 --- a/apps/clock/apps/web/src/app.css +++ b/apps/clock/apps/web/src/app.css @@ -5,6 +5,8 @@ @source "../../../packages/shared/src"; @source "../../../../../packages/shared-ui/src"; @source "../../../../../packages/shared-theme-ui/src"; +@source "../../../../../packages/shared-theme-ui/src/components"; +@source "../../../../../packages/shared-theme-ui/src/pages"; /* Clock-specific CSS Variables */ @layer base { diff --git a/apps/clock/apps/web/src/routes/(app)/+layout.svelte b/apps/clock/apps/web/src/routes/(app)/+layout.svelte index cd81eb6cc..d58c91855 100644 --- a/apps/clock/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/clock/apps/web/src/routes/(app)/+layout.svelte @@ -13,7 +13,13 @@ import { theme } from '$lib/stores/theme.svelte'; import { authStore } from '$lib/stores/auth.svelte'; import { userSettings } from '$lib/stores/user-settings.svelte'; - import { THEME_DEFINITIONS } from '@manacore/shared-theme'; + import { + THEME_DEFINITIONS, + DEFAULT_THEME_VARIANTS, + EXTENDED_THEME_VARIANTS, + } from '@manacore/shared-theme'; + import type { ThemeVariant } from '@manacore/shared-theme'; + import { filterHiddenNavItems } from '@manacore/shared-theme'; import { isSidebarMode as sidebarModeStore, isNavCollapsed as collapsedStore, @@ -110,9 +116,19 @@ // Use theme store's isDark directly let isDark = $derived(theme.isDark); + // Get pinned themes from user settings (extended themes only) + let pinnedThemes = $derived( + (userSettings.theme?.pinnedThemes || []).filter((t): t is ThemeVariant => + EXTENDED_THEME_VARIANTS.includes(t as ThemeVariant) + ) + ); + + // Visible themes in PillNav: default + pinned extended + let visibleThemes = $derived([...DEFAULT_THEME_VARIANTS, ...pinnedThemes]); + // Theme variant dropdown items (with SSR fallback) let themeVariantItems = $derived([ - ...(theme.variants || []).map((variant) => ({ + ...visibleThemes.map((variant) => ({ id: variant, label: THEME_DEFINITIONS[variant]?.label || variant, icon: THEME_DEFINITIONS[variant]?.icon || '🎨', @@ -146,8 +162,8 @@ // User email for user dropdown let userEmail = $derived(authStore.user?.email || 'Menü'); - // Navigation items for Clock - const navItems: PillNavItem[] = [ + // Base navigation items for Clock + const baseNavItems: PillNavItem[] = [ { href: '/', label: 'Übersicht', icon: 'home' }, { href: '/alarms', label: 'Wecker', icon: 'bell' }, { href: '/timers', label: 'Timer', icon: 'timer' }, @@ -159,8 +175,13 @@ { href: '/feedback', label: 'Feedback', icon: 'chat' }, ]; - // Navigation shortcuts (Ctrl+1-9) - const navRoutes = navItems.map((item) => item.href); + // Navigation items filtered by visibility settings + const navItems = $derived( + filterHiddenNavItems('clock', baseNavItems, userSettings.nav.hiddenNavItems) + ); + + // Navigation shortcuts (Ctrl+1-9) - use base items for consistent shortcuts + const navRoutes = baseNavItems.map((item) => item.href); function handleKeydown(event: KeyboardEvent) { const target = event.target as HTMLElement; diff --git a/apps/contacts/apps/backend/src/tag/tag.service.ts b/apps/contacts/apps/backend/src/tag/tag.service.ts index 24a79a886..6c5ff86bb 100644 --- a/apps/contacts/apps/backend/src/tag/tag.service.ts +++ b/apps/contacts/apps/backend/src/tag/tag.service.ts @@ -5,12 +5,36 @@ import { Database } from '../db/connection'; import { contactTags, contactToTags } from '../db/schema'; import type { ContactTag, NewContactTag } from '../db/schema'; +const DEFAULT_TAGS = [ + { name: 'Familie', color: '#ec4899' }, // pink + { name: 'Freunde', color: '#22c55e' }, // green + { name: 'Arbeit', color: '#3b82f6' }, // blue + { name: 'Wichtig', color: '#ef4444' }, // red +] as const; + @Injectable() export class TagService { constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} async findByUserId(userId: string): Promise { - return this.db.select().from(contactTags).where(eq(contactTags.userId, userId)); + const tags = await this.db.select().from(contactTags).where(eq(contactTags.userId, userId)); + + // Create default tags on first access (when user has no tags yet) + if (tags.length === 0) { + return this.createDefaultTags(userId); + } + + return tags; + } + + private async createDefaultTags(userId: string): Promise { + const tagsToCreate = DEFAULT_TAGS.map((tag) => ({ + userId, + name: tag.name, + color: tag.color, + })); + + return this.db.insert(contactTags).values(tagsToCreate).returning(); } async findById(id: string, userId: string): Promise { diff --git a/apps/contacts/apps/web/package.json b/apps/contacts/apps/web/package.json index 51d97079f..198edbd17 100644 --- a/apps/contacts/apps/web/package.json +++ b/apps/contacts/apps/web/package.json @@ -31,6 +31,7 @@ }, "dependencies": { "@manacore/shared-auth": "workspace:*", + "@manacore/shared-tags": "workspace:*", "@manacore/shared-auth-ui": "workspace:*", "@manacore/shared-branding": "workspace:*", "@manacore/shared-feedback-service": "workspace:*", diff --git a/apps/contacts/apps/web/src/app.css b/apps/contacts/apps/web/src/app.css index af9e5ab22..0c2d643fa 100644 --- a/apps/contacts/apps/web/src/app.css +++ b/apps/contacts/apps/web/src/app.css @@ -1,6 +1,13 @@ @import "tailwindcss"; @import "@manacore/shared-tailwind/themes.css"; +/* Scan shared packages for Tailwind classes */ +@source "../../../packages/shared/src"; +@source "../../../../../packages/shared-ui/src"; +@source "../../../../../packages/shared-theme-ui/src"; +@source "../../../../../packages/shared-theme-ui/src/components"; +@source "../../../../../packages/shared-theme-ui/src/pages"; + /* Contacts-specific CSS Variables */ @layer base { :root { diff --git a/apps/contacts/apps/web/src/lib/api/contacts.ts b/apps/contacts/apps/web/src/lib/api/contacts.ts index 4e5c406bb..f62c27559 100644 --- a/apps/contacts/apps/web/src/lib/api/contacts.ts +++ b/apps/contacts/apps/web/src/lib/api/contacts.ts @@ -1,5 +1,7 @@ +import { browser } from '$app/environment'; import { authStore } from '$lib/stores/auth.svelte'; import { API_BASE } from './config'; +import { createTagsClient, type Tag } from '@manacore/shared-tags'; async function fetchWithAuth(url: string, options: RequestInit = {}) { const token = await authStore.getAccessToken(); @@ -56,13 +58,8 @@ export interface Contact { updatedAt: string; } -export interface ContactTag { - id: string; - userId: string; - name: string; - color?: string | null; - createdAt: string; -} +// Re-export Tag as ContactTag for backward compatibility +export type ContactTag = Tag; export interface ContactNote { id: string; @@ -150,32 +147,70 @@ export const contactsApi = { }, }; -// Tags API +// Tags API - Uses central Tags API from mana-core-auth +// Contact-tag associations still use the Contacts backend + +// Get auth URL dynamically at runtime +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__; + return injectedUrl || 'http://localhost:3001'; + } + return 'http://localhost:3001'; +} + +// Lazy-initialized tags client +let _tagsClient: ReturnType | null = null; + +function getTagsClient() { + if (!browser) return null; + if (!_tagsClient) { + _tagsClient = createTagsClient({ + authUrl: getAuthUrl(), + getToken: async () => { + const token = await authStore.getAccessToken(); + return token || ''; + }, + }); + } + return _tagsClient; +} + export const tagsApi = { + // Get all tags from central Tags API async list(): Promise<{ tags: ContactTag[] }> { - return fetchWithAuth('/tags'); + const client = getTagsClient(); + if (!client) return { tags: [] }; + const tags = await client.getAll(); + return { tags }; }, + // Create tag via central Tags API async create(data: { name: string; color?: string }): Promise<{ tag: ContactTag }> { - return fetchWithAuth('/tags', { - method: 'POST', - body: JSON.stringify(data), - }); + const client = getTagsClient(); + if (!client) throw new Error('Tags client not available'); + const tag = await client.create(data); + return { tag }; }, + // Update tag via central Tags API async update(id: string, data: { name?: string; color?: string }): Promise<{ tag: ContactTag }> { - return fetchWithAuth(`/tags/${id}`, { - method: 'PATCH', - body: JSON.stringify(data), - }); + const client = getTagsClient(); + if (!client) throw new Error('Tags client not available'); + const tag = await client.update(id, data); + return { tag }; }, + // Delete tag via central Tags API async delete(id: string): Promise<{ success: boolean }> { - return fetchWithAuth(`/tags/${id}`, { - method: 'DELETE', - }); + const client = getTagsClient(); + if (!client) throw new Error('Tags client not available'); + await client.delete(id); + return { success: true }; }, + // Contact-tag associations still use Contacts backend async addToContact(tagId: string, contactId: string): Promise<{ success: boolean }> { return fetchWithAuth(`/tags/${tagId}/contacts/${contactId}`, { method: 'POST', @@ -191,6 +226,14 @@ export const tagsApi = { async getForContact(contactId: string): Promise<{ tagIds: string[] }> { return fetchWithAuth(`/tags/contact/${contactId}`); }, + + // Create default tags via central Tags API + async createDefaults(): Promise<{ tags: ContactTag[] }> { + const client = getTagsClient(); + if (!client) return { tags: [] }; + const tags = await client.createDefaults(); + return { tags }; + }, }; // Notes API diff --git a/apps/contacts/apps/web/src/lib/components/ViewModeToggle.svelte b/apps/contacts/apps/web/src/lib/components/ViewModeToggle.svelte index 45a37d88e..96a9df394 100644 --- a/apps/contacts/apps/web/src/lib/components/ViewModeToggle.svelte +++ b/apps/contacts/apps/web/src/lib/components/ViewModeToggle.svelte @@ -3,9 +3,9 @@ import { viewModeStore, type ViewMode } from '$lib/stores/view-mode.svelte'; const modes: { id: ViewMode; icon: string; label: string }[] = [ + { id: 'alphabet', icon: 'alphabet', label: 'views.alphabet' }, { id: 'list', icon: 'list', label: 'views.list' }, { id: 'grid', icon: 'grid', label: 'views.grid' }, - { id: 'alphabet', icon: 'alphabet', label: 'views.alphabet' }, ]; diff --git a/apps/contacts/apps/web/src/lib/components/views/ContactAlphabetView.svelte b/apps/contacts/apps/web/src/lib/components/views/ContactAlphabetView.svelte index 062be56fa..6fdc07007 100644 --- a/apps/contacts/apps/web/src/lib/components/views/ContactAlphabetView.svelte +++ b/apps/contacts/apps/web/src/lib/components/views/ContactAlphabetView.svelte @@ -74,7 +74,14 @@ function scrollToLetter(letter: string) { const element = document.getElementById(`section-${letter}`); if (element) { - element.scrollIntoView({ behavior: 'smooth', block: 'start' }); + const headerOffset = 100; // Account for sticky header + const elementPosition = element.getBoundingClientRect().top; + const offsetPosition = elementPosition + window.scrollY - headerOffset; + + window.scrollTo({ + top: offsetPosition, + behavior: 'smooth', + }); } } @@ -296,6 +303,10 @@ gap: 0.5rem; } + .section-contacts .alphabet-contact-card:last-child { + margin-bottom: 1rem; + } + .alphabet-contact-card { display: flex; align-items: center; diff --git a/apps/contacts/apps/web/src/lib/stores/custom-themes.svelte.ts b/apps/contacts/apps/web/src/lib/stores/custom-themes.svelte.ts new file mode 100644 index 000000000..1103c1cd1 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/stores/custom-themes.svelte.ts @@ -0,0 +1,15 @@ +/** + * Custom Themes Store - Manages user's custom themes and community themes + */ + +import { createCustomThemesStore } from '@manacore/shared-theme'; +import { authStore } from './auth.svelte'; + +// Auth URL for theme API calls +const MANA_AUTH_URL = 'http://localhost:3001'; + +// Create the custom themes store +export const customThemesStore = createCustomThemesStore({ + authUrl: MANA_AUTH_URL, + getAccessToken: () => authStore.getAccessToken(), +}); diff --git a/apps/contacts/apps/web/src/lib/stores/network.svelte.ts b/apps/contacts/apps/web/src/lib/stores/network.svelte.ts index 58bd3df69..777b6141d 100644 --- a/apps/contacts/apps/web/src/lib/stores/network.svelte.ts +++ b/apps/contacts/apps/web/src/lib/stores/network.svelte.ts @@ -12,25 +12,15 @@ import { forceCenter, forceCollide, type Simulation, - type SimulationNodeDatum, - type SimulationLinkDatum, } from 'd3-force'; +import type { + SimulationNode as SharedSimulationNode, + SimulationLink as SharedSimulationLink, +} from '@manacore/shared-ui'; -// Extended types for D3 simulation -export interface SimulationNode extends NetworkNode, SimulationNodeDatum { - x?: number; - y?: number; - vx?: number; - vy?: number; - fx?: number | null; - fy?: number | null; -} - -export interface SimulationLink extends SimulationLinkDatum { - type: 'tag'; - strength: number; - sharedTags: string[]; -} +// Re-export types from shared-ui for convenience +export type SimulationNode = SharedSimulationNode; +export type SimulationLink = SharedSimulationLink; // State let nodes = $state([]); @@ -42,6 +32,7 @@ let simulation: Simulation | null = null; let searchQuery = $state(''); let filterTagId = $state(null); let filterCompany = $state(null); +let minStrength = $state(0); let tickCounter = $state(0); // Used to trigger reactivity on simulation tick let simulationInitialized = false; let dataLoaded = false; // Prevent double loading @@ -57,7 +48,7 @@ const filteredNodes = $derived.by(() => { result = result.filter( (node) => node.name.toLowerCase().includes(query) || - node.company?.toLowerCase().includes(query) || + node.subtitle?.toLowerCase().includes(query) || node.tags.some((t) => t.name.toLowerCase().includes(query)) ); } @@ -67,9 +58,9 @@ const filteredNodes = $derived.by(() => { result = result.filter((node) => node.tags.some((t) => t.id === filterTagId)); } - // Company filter + // Company filter (uses subtitle field) if (filterCompany) { - result = result.filter((node) => node.company === filterCompany); + result = result.filter((node) => node.subtitle === filterCompany); } return result; @@ -80,16 +71,24 @@ const filteredLinks = $derived.by(() => { return links.filter((link) => { const sourceId = typeof link.source === 'string' ? link.source : link.source.id; const targetId = typeof link.target === 'string' ? link.target : link.target.id; - return filteredNodeIds.has(sourceId) && filteredNodeIds.has(targetId); + // Check if both nodes are visible and strength meets minimum + if (!filteredNodeIds.has(sourceId) || !filteredNodeIds.has(targetId)) { + return false; + } + // Filter by minimum strength + if (minStrength > 0 && link.strength < minStrength) { + return false; + } + return true; }); }); -// Get unique companies for filter dropdown +// Get unique companies for filter dropdown (uses subtitle field) const uniqueCompanies = $derived.by(() => { const companies = new Set(); for (const node of nodes) { - if (node.company) { - companies.add(node.company); + if (node.subtitle) { + companies.add(node.subtitle); } } return Array.from(companies).sort(); @@ -151,6 +150,9 @@ export const networkStore = { get filterCompany() { return filterCompany; }, + get minStrength() { + return minStrength; + }, get uniqueCompanies() { return uniqueCompanies; }, @@ -194,9 +196,10 @@ export const networkStore = { 'links' ); - // Convert to simulation nodes + // Convert to simulation nodes with subtitle for company nodes = response.nodes.map((node) => ({ ...node, + subtitle: node.company, // Map company to subtitle for shared component x: undefined, y: undefined, vx: undefined, @@ -392,6 +395,13 @@ export const networkStore = { filterCompany = company; }, + /** + * Set minimum strength filter + */ + setMinStrength(strength: number) { + minStrength = strength; + }, + /** * Clear all filters */ @@ -399,6 +409,7 @@ export const networkStore = { searchQuery = ''; filterTagId = null; filterCompany = null; + minStrength = 0; }, /** diff --git a/apps/contacts/apps/web/src/lib/stores/settings.svelte.ts b/apps/contacts/apps/web/src/lib/stores/settings.svelte.ts index daf59e1af..f5d679f13 100644 --- a/apps/contacts/apps/web/src/lib/stores/settings.svelte.ts +++ b/apps/contacts/apps/web/src/lib/stores/settings.svelte.ts @@ -59,7 +59,7 @@ export interface ContactsAppSettings { const DEFAULT_SETTINGS: ContactsAppSettings = { // Display Settings - defaultView: 'list', + defaultView: 'alphabet', sortBy: 'name', sortOrder: 'asc', showPhotos: true, diff --git a/apps/contacts/apps/web/src/lib/stores/statistics.svelte.ts b/apps/contacts/apps/web/src/lib/stores/statistics.svelte.ts new file mode 100644 index 000000000..4c2a112f6 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/stores/statistics.svelte.ts @@ -0,0 +1,275 @@ +/** + * Contacts Statistics Store - Calculates contact statistics using Svelte 5 runes + */ + +import type { Contact } from '$lib/api/contacts'; +import { subDays, format, parseISO, isWithinInterval, getMonth, eachDayOfInterval } from 'date-fns'; +import { de } from 'date-fns/locale'; +import type { + HeatmapDataPoint, + TrendDataPoint, + DonutSegment, + ProgressItem, +} from '@manacore/shared-ui'; + +// Types +export interface ContactTag { + id: string; + name: string; + color: string; +} + +// State +let contacts = $state([]); +let tags = $state([]); + +export const contactsStatisticsStore = { + // Setters + setContacts(newContacts: Contact[]) { + contacts = newContacts; + }, + + setTags(newTags: ContactTag[]) { + tags = newTags; + }, + + // Quick Stats + get totalContacts() { + return contacts.length; + }, + + get favoriteContacts() { + return contacts.filter((c) => c.isFavorite).length; + }, + + get archivedContacts() { + return contacts.filter((c) => c.isArchived).length; + }, + + get activeContacts() { + return contacts.filter((c) => !c.isArchived).length; + }, + + get recentlyAdded() { + const weekAgo = subDays(new Date(), 7); + return contacts.filter((c) => { + const createdAt = + typeof c.createdAt === 'string' ? parseISO(c.createdAt) : new Date(c.createdAt); + return createdAt >= weekAgo; + }).length; + }, + + get birthdaysThisMonth() { + const currentMonth = getMonth(new Date()); + return contacts.filter((c) => { + if (!c.birthday) return false; + const birthday = typeof c.birthday === 'string' ? parseISO(c.birthday) : new Date(c.birthday); + return getMonth(birthday) === currentMonth; + }).length; + }, + + get contactsWithEmail() { + return contacts.filter((c) => c.email).length; + }, + + get contactsWithPhone() { + return contacts.filter((c) => c.phone || c.mobile).length; + }, + + // Completeness rate (contacts with email AND phone) + get completenessRate() { + if (contacts.length === 0) return 0; + const complete = contacts.filter((c) => c.email && (c.phone || c.mobile)).length; + return Math.round((complete / contacts.length) * 100); + }, + + // Activity Heatmap (last 6 months) - based on contact creation + get activityHeatmap(): HeatmapDataPoint[] { + const endDate = new Date(); + const startDate = subDays(endDate, 180); + + // Count contacts created per day + const creationMap = new Map(); + + contacts.forEach((c) => { + const createdAt = + typeof c.createdAt === 'string' ? parseISO(c.createdAt) : new Date(c.createdAt); + if (createdAt >= startDate && createdAt <= endDate) { + const dateKey = format(createdAt, 'yyyy-MM-dd'); + creationMap.set(dateKey, (creationMap.get(dateKey) || 0) + 1); + } + }); + + // Generate all days + const days = eachDayOfInterval({ start: startDate, end: endDate }); + + return days.map((day) => { + const dateKey = format(day, 'yyyy-MM-dd'); + return { + date: dateKey, + count: creationMap.get(dateKey) || 0, + dayOfWeek: day.getDay(), + }; + }); + }, + + // Weekly Trend (last 4 weeks) + get weeklyTrend(): TrendDataPoint[] { + const endDate = new Date(); + const startDate = subDays(endDate, 27); + + const creationMap = new Map(); + + contacts.forEach((c) => { + const createdAt = + typeof c.createdAt === 'string' ? parseISO(c.createdAt) : new Date(c.createdAt); + if (createdAt >= startDate && createdAt <= endDate) { + const dateKey = format(createdAt, 'yyyy-MM-dd'); + creationMap.set(dateKey, (creationMap.get(dateKey) || 0) + 1); + } + }); + + const days = eachDayOfInterval({ start: startDate, end: endDate }); + + return days.map((day) => { + const dateKey = format(day, 'yyyy-MM-dd'); + return { + date: dateKey, + count: creationMap.get(dateKey) || 0, + label: format(day, 'EEE', { locale: de }), + }; + }); + }, + + // Contact Status Breakdown (Donut Chart) - Favorites / Active / Archived + get statusBreakdown(): DonutSegment[] { + const total = contacts.length; + if (total === 0) return []; + + const favorites = contacts.filter((c) => c.isFavorite && !c.isArchived).length; + const archived = contacts.filter((c) => c.isArchived).length; + const regular = contacts.filter((c) => !c.isFavorite && !c.isArchived).length; + + return [ + { + id: 'favorites', + label: 'Favoriten', + count: favorites, + percentage: Math.round((favorites / total) * 100), + color: '#F59E0B', // amber + }, + { + id: 'regular', + label: 'Aktiv', + count: regular, + percentage: Math.round((regular / total) * 100), + color: '#10B981', // green + }, + { + id: 'archived', + label: 'Archiviert', + count: archived, + percentage: Math.round((archived / total) * 100), + color: '#6B7280', // gray + }, + ]; + }, + + // Tags Progress (Progress Bars) + get tagProgress(): ProgressItem[] { + // Count contacts per tag + const tagCountMap = new Map(); + + // This requires contacts to have a tags array - we'll estimate from the tag data + // For now, we'll show tags with placeholder counts + // In a real implementation, we'd need contactTags relation data + + const result: ProgressItem[] = tags.map((tag) => ({ + id: tag.id, + name: tag.name, + color: tag.color || '#6B7280', + total: contacts.length, // Total contacts as reference + completed: 0, // Would need contact-tag relation to calculate + percentage: 0, + })); + + return result.sort((a, b) => b.completed - a.completed); + }, + + // Info completeness breakdown + get infoBreakdown(): DonutSegment[] { + const total = contacts.length; + if (total === 0) return []; + + const withEmail = contacts.filter((c) => c.email).length; + const withPhone = contacts.filter((c) => c.phone || c.mobile).length; + const withCompany = contacts.filter((c) => c.company).length; + const withBirthday = contacts.filter((c) => c.birthday).length; + + return [ + { + id: 'email', + label: 'Mit E-Mail', + count: withEmail, + percentage: Math.round((withEmail / total) * 100), + color: '#3B82F6', // blue + }, + { + id: 'phone', + label: 'Mit Telefon', + count: withPhone, + percentage: Math.round((withPhone / total) * 100), + color: '#10B981', // green + }, + { + id: 'company', + label: 'Mit Firma', + count: withCompany, + percentage: Math.round((withCompany / total) * 100), + color: '#8B5CF6', // violet + }, + { + id: 'birthday', + label: 'Mit Geburtstag', + count: withBirthday, + percentage: Math.round((withBirthday / total) * 100), + color: '#EC4899', // pink + }, + ]; + }, + + // Country breakdown + get countryBreakdown(): ProgressItem[] { + const countryMap = new Map(); + + contacts.forEach((c) => { + const country = c.country || 'Unbekannt'; + countryMap.set(country, (countryMap.get(country) || 0) + 1); + }); + + const result: ProgressItem[] = []; + const colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#6B7280']; + let colorIndex = 0; + + countryMap.forEach((count, country) => { + if (country !== 'Unbekannt' || count > 0) { + result.push({ + id: country, + name: country, + color: colors[colorIndex % colors.length], + total: contacts.length, + completed: count, + percentage: Math.round((count / contacts.length) * 100), + }); + colorIndex++; + } + }); + + return result.sort((a, b) => b.completed - a.completed).slice(0, 8); + }, + + // Total tags count + get totalTags() { + return tags.length; + }, +}; diff --git a/apps/contacts/apps/web/src/lib/stores/view-mode.svelte.ts b/apps/contacts/apps/web/src/lib/stores/view-mode.svelte.ts index 3e7a4cff6..1a61b9bbb 100644 --- a/apps/contacts/apps/web/src/lib/stores/view-mode.svelte.ts +++ b/apps/contacts/apps/web/src/lib/stores/view-mode.svelte.ts @@ -10,9 +10,9 @@ export type ViewMode = ContactView; const STORAGE_KEY = 'contacts-view-mode'; -// Get initial mode: current session preference > settings default > 'list' +// Get initial mode: current session preference > settings default > 'alphabet' function getInitialMode(): ViewMode { - if (!browser) return 'list'; + if (!browser) return 'alphabet'; // First check if there's a session-specific preference const sessionMode = sessionStorage.getItem(STORAGE_KEY); @@ -21,7 +21,7 @@ function getInitialMode(): ViewMode { } // Otherwise use the default from settings - return contactsSettings.defaultView || 'list'; + return contactsSettings.defaultView || 'alphabet'; } let mode = $state(getInitialMode()); @@ -43,7 +43,7 @@ export const viewModeStore = { * Reset to default view from settings */ resetToDefault() { - mode = contactsSettings.defaultView || 'list'; + mode = contactsSettings.defaultView || 'alphabet'; if (browser) { sessionStorage.removeItem(STORAGE_KEY); } @@ -61,7 +61,7 @@ export const viewModeStore = { mode = sessionMode; } else { // Use default from settings - mode = contactsSettings.defaultView || 'list'; + mode = contactsSettings.defaultView || 'alphabet'; } }, }; diff --git a/apps/contacts/apps/web/src/lib/utils/contact-parser.ts b/apps/contacts/apps/web/src/lib/utils/contact-parser.ts new file mode 100644 index 000000000..58cd8b1b9 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/utils/contact-parser.ts @@ -0,0 +1,227 @@ +/** + * Contact Parser for Contacts App + * + * Extends the base parser with contact-specific patterns: + * - Company: @CompanyName or bei CompanyName + * - Email: Recognizes email addresses + * - Phone: Recognizes phone numbers + * - Name: First and last name extraction + */ + +import { extractTags, extractAtReference } from '@manacore/shared-utils'; + +export interface ParsedContact { + displayName: string; + firstName?: string; + lastName?: string; + company?: string; + email?: string; + phone?: string; + tagNames: string[]; +} + +interface Tag { + id: string; + name: string; +} + +export interface ParsedContactWithIds { + displayName: string; + firstName?: string; + lastName?: string; + company?: string; + email?: string; + phone?: string; + tagIds: string[]; +} + +// Email pattern +const EMAIL_PATTERN = /\b([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})\b/; + +// Phone patterns (various formats) +const PHONE_PATTERNS: RegExp[] = [ + // International format: +49 123 456789, +49-123-456789 + /\+\d{1,3}[-\s]?\d{2,4}[-\s]?\d{3,}[-\s]?\d*/, + // German format: 0123 456789, 0123/456789 + /\b0\d{2,4}[-\s/]?\d{3,}[-\s]?\d*/, + // Simple format: 123456789 (at least 6 digits) + /\b\d{6,}\b/, +]; + +// Company patterns (alternative to @company) +const COMPANY_PATTERNS: RegExp[] = [ + /\bbei\s+([^@#]+?)(?=\s+(?:@|#|\+|[a-zA-Z0-9._%+-]+@)|$)/i, + /\bvon\s+([^@#]+?)(?=\s+(?:@|#|\+|[a-zA-Z0-9._%+-]+@)|$)/i, +]; + +/** + * Extract email from text + */ +function extractEmail(text: string): { email?: string; remaining: string } { + const match = text.match(EMAIL_PATTERN); + if (match) { + return { + email: match[1], + remaining: text.replace(EMAIL_PATTERN, '').trim(), + }; + } + return { email: undefined, remaining: text }; +} + +/** + * Extract phone number from text + */ +function extractPhone(text: string): { phone?: string; remaining: string } { + for (const pattern of PHONE_PATTERNS) { + const match = text.match(pattern); + if (match) { + return { + phone: match[0].trim(), + remaining: text.replace(pattern, '').trim(), + }; + } + } + return { phone: undefined, remaining: text }; +} + +/** + * Extract company from text (bei/von patterns) + */ +function extractCompanyPattern(text: string): { company?: string; remaining: string } { + for (const pattern of COMPANY_PATTERNS) { + const match = text.match(pattern); + if (match) { + return { + company: match[1].trim(), + remaining: text.replace(pattern, '').trim(), + }; + } + } + return { company: undefined, remaining: text }; +} + +/** + * Extract first and last name from display name + */ +function parseNames(displayName: string): { firstName?: string; lastName?: string } { + const parts = displayName.trim().split(/\s+/); + + if (parts.length === 0) { + return {}; + } + + if (parts.length === 1) { + return { firstName: parts[0] }; + } + + // First part is first name, rest is last name + return { + firstName: parts[0], + lastName: parts.slice(1).join(' '), + }; +} + +/** + * Parse natural language contact input + * + * Examples: + * - "Max Mustermann @ACME Corp max@example.com #kunde #wichtig" + * - "Anna Schmidt bei Google +49 123 456789" + * - "Peter Müller peter@mail.de #privat" + */ +export function parseContactInput(input: string): ParsedContact { + let text = input.trim(); + + // Extract tags first (#tag1 #tag2) + const tagsResult = extractTags(text); + text = tagsResult.remaining; + const tagNames = tagsResult.value || []; + + // Extract company via @CompanyName + const atRefResult = extractAtReference(text); + text = atRefResult.remaining; + let company = atRefResult.value; + + // If no @company, try bei/von patterns + if (!company) { + const companyPatternResult = extractCompanyPattern(text); + text = companyPatternResult.remaining; + company = companyPatternResult.company; + } + + // Extract email + const emailResult = extractEmail(text); + text = emailResult.remaining; + const email = emailResult.email; + + // Extract phone + const phoneResult = extractPhone(text); + text = phoneResult.remaining; + const phone = phoneResult.phone; + + // Clean up multiple spaces and get display name + const displayName = text.replace(/\s+/g, ' ').trim(); + + // Parse first and last name + const { firstName, lastName } = parseNames(displayName); + + return { + displayName, + firstName, + lastName, + company, + email, + phone, + tagNames, + }; +} + +/** + * Resolve tag names to IDs + */ +export function resolveContactIds(parsed: ParsedContact, tags: Tag[]): ParsedContactWithIds { + const tagIds: string[] = []; + + // Find tags by name (case-insensitive) + for (const tagName of parsed.tagNames) { + const tag = tags.find((t) => t.name.toLowerCase() === tagName.toLowerCase()); + if (tag) { + tagIds.push(tag.id); + } + } + + return { + displayName: parsed.displayName, + firstName: parsed.firstName, + lastName: parsed.lastName, + company: parsed.company, + email: parsed.email, + phone: parsed.phone, + tagIds, + }; +} + +/** + * Format parsed contact for preview display + */ +export function formatParsedContactPreview(parsed: ParsedContact): string { + const parts: string[] = []; + + if (parsed.company) { + parts.push(`🏢 ${parsed.company}`); + } + + if (parsed.email) { + parts.push(`📧 ${parsed.email}`); + } + + if (parsed.phone) { + parts.push(`📞 ${parsed.phone}`); + } + + if (parsed.tagNames.length > 0) { + parts.push(`🏷️ ${parsed.tagNames.join(', ')}`); + } + + return parts.join(' · '); +} diff --git a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte index aa5eb3db1..0a8871350 100644 --- a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte @@ -9,11 +9,18 @@ PillDropdownItem, CommandBarItem, QuickAction, + CreatePreview, } from '@manacore/shared-ui'; import { theme } from '$lib/stores/theme'; import { authStore } from '$lib/stores/auth.svelte'; import { userSettings } from '$lib/stores/user-settings.svelte'; - import { THEME_DEFINITIONS } from '@manacore/shared-theme'; + import { + THEME_DEFINITIONS, + DEFAULT_THEME_VARIANTS, + EXTENDED_THEME_VARIANTS, + } from '@manacore/shared-theme'; + import type { ThemeVariant } from '@manacore/shared-theme'; + import { filterHiddenNavItems } from '@manacore/shared-theme'; import { isSidebarMode as sidebarModeStore, isNavCollapsed as collapsedStore, @@ -23,13 +30,21 @@ import { setLocale, supportedLocales } from '$lib/i18n'; import ContactDetailModal from '$lib/components/ContactDetailModal.svelte'; import { contactsStore } from '$lib/stores/contacts.svelte'; - import { contactsApi } from '$lib/api/contacts'; + import { contactsApi, tagsApi } from '$lib/api/contacts'; import { viewModeStore } from '$lib/stores/view-mode.svelte'; import { contactsSettings } from '$lib/stores/settings.svelte'; + import { + parseContactInput, + resolveContactIds, + formatParsedContactPreview, + } from '$lib/utils/contact-parser'; // Search modal state let searchModalOpen = $state(false); + // Tags state for Quick-Create + let availableTags = $state<{ id: string; name: string }[]>([]); + // Check if we're on a contact detail route const contactDetailMatch = $derived($page.url.pathname.match(/^\/contacts\/([0-9a-f-]{36})$/i)); const showContactModal = $derived(!!contactDetailMatch); @@ -46,10 +61,20 @@ // Use theme store's isDark directly let isDark = $derived(theme.isDark); + // Get pinned themes from user settings (extended themes only) + let pinnedThemes = $derived( + (userSettings.theme?.pinnedThemes || []).filter((t): t is ThemeVariant => + EXTENDED_THEME_VARIANTS.includes(t as ThemeVariant) + ) + ); + + // Visible themes in PillNav: default + pinned extended + let visibleThemes = $derived([...DEFAULT_THEME_VARIANTS, ...pinnedThemes]); + // Theme variant dropdown items let themeVariantItems = $derived([ - // Theme variants - ...theme.variants.map((variant) => ({ + // Theme variants (only default + pinned) + ...visibleThemes.map((variant) => ({ id: variant, label: THEME_DEFINITIONS[variant].label, icon: THEME_DEFINITIONS[variant].icon, @@ -82,19 +107,25 @@ // User email for user dropdown (fallback to 'Menü' when not logged in) let userEmail = $derived(authStore.user?.email || 'Menü'); - // Navigation items for Contacts - const navItems: PillNavItem[] = [ + // Base navigation items for Contacts + const baseNavItems: PillNavItem[] = [ { href: '/', label: 'Kontakte', icon: 'users' }, { href: '/tags', label: 'Tags', icon: 'tag' }, { href: '/favorites', label: 'Favoriten', icon: 'heart' }, + { href: '/statistics', label: 'Statistiken', icon: 'bar-chart-3' }, { href: '/network', label: 'Netzwerk', icon: 'share-2' }, { href: '/settings', label: 'Einstellungen', icon: 'settings' }, { href: '/feedback', label: 'Feedback', icon: 'chat' }, { href: '/help', label: 'Hilfe', icon: 'help-circle' }, ]; - // Navigation shortcuts (Ctrl+1-5) - const navRoutes = navItems.map((item) => item.href); + // Navigation items filtered by visibility settings + const navItems = $derived( + filterHiddenNavItems('contacts', baseNavItems, userSettings.nav.hiddenNavItems) + ); + + // Navigation shortcuts (Ctrl+1-5) - use base items for consistent shortcuts + const navRoutes = baseNavItems.map((item) => item.href); function handleKeydown(event: KeyboardEvent) { const target = event.target as HTMLElement; @@ -178,6 +209,47 @@ goto(`/contacts/${item.id}`); } + // CommandBar Quick-Create handlers + function handleCommandBarParseCreate(query: string): CreatePreview | null { + if (!query.trim()) return null; + + const parsed = parseContactInput(query); + if (!parsed.displayName) return null; + + return { + title: parsed.displayName, + subtitle: formatParsedContactPreview(parsed), + }; + } + + async function handleCommandBarCreate(query: string): Promise { + const parsed = parseContactInput(query); + if (!parsed.displayName) return; + + // Resolve tag names to IDs + const resolved = resolveContactIds(parsed, availableTags); + + try { + const contact = await contactsStore.createContact({ + displayName: resolved.displayName, + firstName: resolved.firstName, + lastName: resolved.lastName, + company: resolved.company, + email: resolved.email, + phone: resolved.phone, + }); + + // Add tags to the created contact + if (resolved.tagIds.length > 0 && contact) { + for (const tagId of resolved.tagIds) { + await tagsApi.addToContact(tagId, contact.id); + } + } + } catch (e) { + console.error('Failed to create contact:', e); + } + } + // CommandBar quick actions const commandBarQuickActions: QuickAction[] = [ { @@ -199,9 +271,17 @@ return; } - // Load user settings + // Load user settings and tags await userSettings.load(); + // Load tags for Quick-Create + try { + const tagsResult = await tagsApi.list(); + availableTags = (tagsResult.tags || []).map((t) => ({ id: t.id, name: t.name })); + } catch (e) { + console.error('Failed to load tags:', e); + } + // Initialize contacts settings and view mode contactsSettings.initialize(); viewModeStore.initialize(); @@ -226,6 +306,9 @@
+ + + -
+
{@render children()}
@@ -284,9 +367,13 @@ onSearch={handleCommandBarSearch} onSelect={handleCommandBarSelect} quickActions={commandBarQuickActions} - placeholder="Kontakt suchen..." + placeholder="Kontakt suchen oder erstellen..." emptyText="Keine Kontakte gefunden" searchingText="Suche..." + onCreate={handleCommandBarCreate} + onParseCreate={handleCommandBarParseCreate} + createText="Als Kontakt erstellen" + createShortcut="⌘↵" />
@@ -320,35 +407,44 @@ } .content-wrapper { - max-width: 80rem; + max-width: 900px; margin-left: auto; margin-right: auto; - padding: 2rem 1rem; - } - - /* Settings page has its own padding and max-width */ - .content-wrapper.settings-page { - max-width: none; - padding: 0; + padding: 1rem; } @media (min-width: 640px) { .content-wrapper { - padding-left: 1.5rem; - padding-right: 1.5rem; - } - .content-wrapper.settings-page { - padding: 0; + padding: 1.5rem; } } @media (min-width: 1024px) { .content-wrapper { - padding-left: 2rem; - padding-right: 2rem; + padding: 2rem; } - .content-wrapper.settings-page { - padding: 0; + } + + /* Shadow gradient above pill navigation */ + .nav-shadow-gradient { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 80px; + background: linear-gradient( + to bottom, + hsl(var(--background)) 0%, + hsl(var(--background)) 50%, + hsl(var(--background) / 0) 100% + ); + pointer-events: none; + z-index: 40; + } + + @media (max-width: 768px) { + .nav-shadow-gradient { + height: 90px; } } diff --git a/apps/contacts/apps/web/src/routes/(app)/network/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/network/+page.svelte index 9e828ede8..0508d3e8f 100644 --- a/apps/contacts/apps/web/src/routes/(app)/network/+page.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/network/+page.svelte @@ -1,23 +1,47 @@ @@ -43,9 +109,28 @@
@@ -65,11 +150,23 @@ {/if} -
+
{#if networkStore.loading} {:else} - + {/if}
diff --git a/apps/contacts/apps/web/src/routes/(app)/statistics/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/statistics/+page.svelte new file mode 100644 index 000000000..f288dbd02 --- /dev/null +++ b/apps/contacts/apps/web/src/routes/(app)/statistics/+page.svelte @@ -0,0 +1,280 @@ + + + + Statistiken - Kontakte + + +
+ + + {#if loading} + + {:else} + +
+ +
+ + +
+ +
+ +
+ + +
+
+ +
+ +
+ +
+
+ + +
+
+ +
+ +
+ +
+
+
+ + +
+
+ Aktive Kontakte + {contactsStatisticsStore.activeContacts} +
+ +
+ Archivierte Kontakte + {contactsStatisticsStore.archivedContacts} +
+ +
+ Tags + {contactsStatisticsStore.totalTags} +
+
+ {/if} +
+ + diff --git a/apps/contacts/apps/web/src/routes/(app)/tags/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/tags/+page.svelte index fbc912c4b..7368fc5e5 100644 --- a/apps/contacts/apps/web/src/routes/(app)/tags/+page.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/tags/+page.svelte @@ -3,7 +3,14 @@ import { _ } from 'svelte-i18n'; import { tagsApi } from '$lib/api/contacts'; import type { ContactTag } from '$lib/api/contacts'; - import { TagGridSkeleton } from '$lib/components/skeletons'; + import { + TagList, + TagEditModal, + TagColorPicker, + DEFAULT_TAG_COLOR, + type Tag, + } from '@manacore/shared-ui'; + import { MagnifyingGlass, Plus, CaretLeft } from '@manacore/shared-icons'; let loading = $state(true); let tags = $state([]); @@ -13,9 +20,6 @@ // Modal state let showModal = $state(false); let editingTag = $state(null); - let tagName = $state(''); - let tagColor = $state('#6366f1'); - let saving = $state(false); const filteredTags = $derived.by(() => { if (!searchQuery.trim()) return tags; @@ -23,22 +27,6 @@ return tags.filter((t) => t.name.toLowerCase().includes(query)); }); - const colorOptions = [ - '#ef4444', // red - '#f97316', // orange - '#f59e0b', // amber - '#84cc16', // lime - '#22c55e', // green - '#14b8a6', // teal - '#06b6d4', // cyan - '#3b82f6', // blue - '#6366f1', // indigo - '#8b5cf6', // violet - '#a855f7', // purple - '#ec4899', // pink - '#64748b', // slate - ]; - async function loadTags() { loading = true; error = null; @@ -54,53 +42,48 @@ function openCreateModal() { editingTag = null; - tagName = ''; - tagColor = '#6366f1'; showModal = true; } function openEditModal(tag: ContactTag) { editingTag = tag; - tagName = tag.name; - tagColor = tag.color || '#6366f1'; showModal = true; } function closeModal() { showModal = false; editingTag = null; - tagName = ''; - tagColor = '#6366f1'; } - async function handleSave() { - if (!tagName.trim()) return; - - saving = true; + async function handleSave(name: string, color: string) { error = null; try { if (editingTag) { - const response = await tagsApi.update(editingTag.id, { - name: tagName.trim(), - color: tagColor, - }); + const response = await tagsApi.update(editingTag.id, { name, color }); tags = tags.map((t) => (t.id === editingTag!.id ? response.tag : t)); } else { - const response = await tagsApi.create({ - name: tagName.trim(), - color: tagColor, - }); + const response = await tagsApi.create({ name, color }); tags = [...tags, response.tag]; } closeModal(); } catch (e) { error = e instanceof Error ? e.message : $_('messages.error'); - } finally { - saving = false; } } - async function handleDelete(tag: ContactTag) { + async function handleDelete() { + if (!editingTag) return; + + try { + await tagsApi.delete(editingTag.id); + tags = tags.filter((t) => t.id !== editingTag!.id); + closeModal(); + } catch (e) { + error = e instanceof Error ? e.message : $_('messages.error'); + } + } + + async function handleDeleteFromList(tag: Tag) { if (!confirm($_('tags.confirmDelete', { values: { name: tag.name } }))) return; try { @@ -122,28 +105,17 @@
- - - +

{$_('tags.title')}

- - - + - - - {error}
{/if} - {#if loading} - - {:else if tags.length === 0} -
-
- - - -
-

{$_('tags.noTags')}

-

{$_('tags.createFirst')}

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

{$_('tags.noResults')}

-

{$_('tags.noResultsFor', { values: { query: searchQuery } })}

-
- {:else} -
- {#each filteredTags as tag (tag.id)} -
-
- - - -
-
-

{tag.name}

-
-
- - -
-
- {/each} -
+ + openEditModal(tag as ContactTag)} + onDelete={handleDeleteFromList} + emptyMessage={searchQuery ? $_('tags.noResults') : $_('tags.noTags')} + emptyDescription={searchQuery + ? $_('tags.noResultsFor', { values: { query: searchQuery } }) + : $_('tags.createFirst')} + /> + {#if !loading && tags.length > 0}

{tags.length} {tags.length === 1 ? $_('tags.tagSingular') : $_('tags.tagPlural')}

{/if} + + {#if !loading && tags.length === 0 && !searchQuery} +
+ +
+ {/if}
- -{#if showModal} - -{/if} + + diff --git a/apps/contacts/apps/web/src/routes/(app)/themes/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/themes/+page.svelte new file mode 100644 index 000000000..68bc36719 --- /dev/null +++ b/apps/contacts/apps/web/src/routes/(app)/themes/+page.svelte @@ -0,0 +1,25 @@ + + + + Themes | Contacts + + + theme.setVariant(v)} + showModeSelector={true} + currentMode={theme.mode} + onModeChange={(m) => theme.setMode(m)} + showBackButton={true} + onBack={() => goto('/')} + showCustomThemes={true} + {customThemesStore} + onCreateTheme={() => goto('/themes/editor')} + onEditTheme={(t) => goto(`/themes/editor?id=${t.id}`)} + onCommunityThemes={() => goto('/themes/community')} +/> diff --git a/apps/contacts/apps/web/src/routes/(app)/themes/community/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/themes/community/+page.svelte new file mode 100644 index 000000000..7f4582f95 --- /dev/null +++ b/apps/contacts/apps/web/src/routes/(app)/themes/community/+page.svelte @@ -0,0 +1,29 @@ + + + + Community Themes | Contacts + + + goto('/themes')} + onSelectTheme={(t) => { + // Could open a detail modal here + console.log('Selected theme:', t); + }} +/> diff --git a/apps/contacts/apps/web/src/routes/(app)/themes/editor/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/themes/editor/+page.svelte new file mode 100644 index 000000000..83fb8653b --- /dev/null +++ b/apps/contacts/apps/web/src/routes/(app)/themes/editor/+page.svelte @@ -0,0 +1,75 @@ + + + + {themeId ? 'Theme bearbeiten' : 'Neues Theme'} | Contacts + + + goto('/themes')} + onSave={handleSave} + onPublish={handlePublish} +/> diff --git a/apps/manacore/apps/web/src/app.css b/apps/manacore/apps/web/src/app.css index c1ecbea8d..152f31ace 100644 --- a/apps/manacore/apps/web/src/app.css +++ b/apps/manacore/apps/web/src/app.css @@ -6,6 +6,8 @@ @source "../../../../packages/shared-auth-ui/src"; @source "../../../../packages/shared-branding/src"; @source "../../../../packages/shared-theme-ui/src"; +@source "../../../../packages/shared-theme-ui/src/components"; +@source "../../../../packages/shared-theme-ui/src/pages"; @source "../../../../packages/shared-subscription-ui/src"; @layer base { diff --git a/apps/manacore/apps/web/src/routes/(app)/+layout.svelte b/apps/manacore/apps/web/src/routes/(app)/+layout.svelte index 0aa148f23..1676bda5a 100644 --- a/apps/manacore/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/manacore/apps/web/src/routes/(app)/+layout.svelte @@ -6,7 +6,12 @@ import { locale } from 'svelte-i18n'; import { PillNavigation } from '@manacore/shared-ui'; import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui'; - import { THEME_DEFINITIONS } from '@manacore/shared-theme'; + import { + THEME_DEFINITIONS, + DEFAULT_THEME_VARIANTS, + EXTENDED_THEME_VARIANTS, + } from '@manacore/shared-theme'; + import type { ThemeVariant } from '@manacore/shared-theme'; import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n'; import { setLocale, supportedLocales } from '$lib/i18n'; import { theme } from '$lib/stores/theme'; @@ -30,9 +35,19 @@ // Get theme state let isDark = $derived(theme.isDark); + // Get pinned themes from user settings (extended themes only) + let pinnedThemes = $derived( + (userSettings.theme?.pinnedThemes || []).filter((t): t is ThemeVariant => + EXTENDED_THEME_VARIANTS.includes(t as ThemeVariant) + ) + ); + + // Visible themes in PillNav: default + pinned extended + let visibleThemes = $derived([...DEFAULT_THEME_VARIANTS, ...pinnedThemes]); + // Theme variant dropdown items let themeVariantItems = $derived([ - ...theme.variants.map((variant) => ({ + ...visibleThemes.map((variant) => ({ id: variant, label: THEME_DEFINITIONS[variant].label, icon: THEME_DEFINITIONS[variant].icon, diff --git a/apps/manadeck/apps/web/src/app.css b/apps/manadeck/apps/web/src/app.css index f6ad4cf40..56ff68272 100644 --- a/apps/manadeck/apps/web/src/app.css +++ b/apps/manadeck/apps/web/src/app.css @@ -6,4 +6,6 @@ @source "../../../../packages/shared-auth-ui/src"; @source "../../../../packages/shared-branding/src"; @source "../../../../packages/shared-theme-ui/src"; +@source "../../../../packages/shared-theme-ui/src/components"; +@source "../../../../packages/shared-theme-ui/src/pages"; @source "../../../../packages/shared-subscription-ui/src"; diff --git a/apps/manadeck/apps/web/src/routes/(app)/+layout.svelte b/apps/manadeck/apps/web/src/routes/(app)/+layout.svelte index b51e1351f..a199c8d44 100644 --- a/apps/manadeck/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/manadeck/apps/web/src/routes/(app)/+layout.svelte @@ -12,7 +12,13 @@ } from '$lib/stores/navigation'; import { PillNavigation } from '@manacore/shared-ui'; import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui'; - import { THEME_DEFINITIONS } from '@manacore/shared-theme'; + import { + THEME_DEFINITIONS, + DEFAULT_THEME_VARIANTS, + EXTENDED_THEME_VARIANTS, + } from '@manacore/shared-theme'; + import type { ThemeVariant } from '@manacore/shared-theme'; + import { filterHiddenNavItems } from '@manacore/shared-theme'; import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n'; import { getPillAppItems } from '@manacore/shared-branding'; import { setLocale, supportedLocales } from '$lib/i18n'; @@ -28,16 +34,31 @@ // Get theme state let isDark = $derived(theme.isDark); - // Navigation items for ManaDeck (Mana and Profile are in user dropdown) - const navItems: PillNavItem[] = [ + // Base navigation items for ManaDeck (Mana and Profile are in user dropdown) + const baseNavItems: PillNavItem[] = [ { href: '/decks', label: 'Decks', icon: 'archive' }, { href: '/explore', label: 'Explore', icon: 'search' }, { href: '/progress', label: 'Progress', icon: 'chart' }, ]; + // Navigation items filtered by visibility settings + const navItems = $derived( + filterHiddenNavItems('manadeck', baseNavItems, userSettings.nav.hiddenNavItems) + ); + + // Get pinned themes from user settings (extended themes only) + let pinnedThemes = $derived( + (userSettings.theme?.pinnedThemes || []).filter((t): t is ThemeVariant => + EXTENDED_THEME_VARIANTS.includes(t as ThemeVariant) + ) + ); + + // Visible themes in PillNav: default + pinned extended + let visibleThemes = $derived([...DEFAULT_THEME_VARIANTS, ...pinnedThemes]); + // Theme variant dropdown items let themeVariantItems = $derived([ - ...theme.variants.map((variant) => ({ + ...visibleThemes.map((variant) => ({ id: variant, label: THEME_DEFINITIONS[variant].label, icon: THEME_DEFINITIONS[variant].icon, diff --git a/apps/picture/apps/backend/src/db/schema/batch-generations.schema.ts b/apps/picture/apps/backend/src/db/schema/batch-generations.schema.ts index 7c68115a9..a4cdbe669 100644 --- a/apps/picture/apps/backend/src/db/schema/batch-generations.schema.ts +++ b/apps/picture/apps/backend/src/db/schema/batch-generations.schema.ts @@ -10,7 +10,7 @@ export const batchStatusEnum = pgEnum('batch_status', [ export const batchGenerations = pgTable('batch_generations', { id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id').notNull(), + userId: text('user_id').notNull(), name: text('name'), totalCount: integer('total_count').notNull(), diff --git a/apps/picture/apps/backend/src/db/schema/boards.schema.ts b/apps/picture/apps/backend/src/db/schema/boards.schema.ts index 693be478a..feb3f4449 100644 --- a/apps/picture/apps/backend/src/db/schema/boards.schema.ts +++ b/apps/picture/apps/backend/src/db/schema/boards.schema.ts @@ -2,7 +2,7 @@ import { pgTable, uuid, text, timestamp, boolean, integer } from 'drizzle-orm/pg export const boards = pgTable('boards', { id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id').notNull(), + userId: text('user_id').notNull(), name: text('name').notNull(), description: text('description'), diff --git a/apps/picture/apps/backend/src/db/schema/image-generations.schema.ts b/apps/picture/apps/backend/src/db/schema/image-generations.schema.ts index 093ae48d7..fbe669445 100644 --- a/apps/picture/apps/backend/src/db/schema/image-generations.schema.ts +++ b/apps/picture/apps/backend/src/db/schema/image-generations.schema.ts @@ -11,7 +11,7 @@ export const generationStatusEnum = pgEnum('generation_status', [ export const imageGenerations = pgTable('image_generations', { id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id').notNull(), + userId: text('user_id').notNull(), modelId: uuid('model_id'), batchId: uuid('batch_id'), diff --git a/apps/picture/apps/backend/src/db/schema/image-likes.schema.ts b/apps/picture/apps/backend/src/db/schema/image-likes.schema.ts index 909bb4186..11508e9f2 100644 --- a/apps/picture/apps/backend/src/db/schema/image-likes.schema.ts +++ b/apps/picture/apps/backend/src/db/schema/image-likes.schema.ts @@ -1,4 +1,4 @@ -import { pgTable, uuid, timestamp, unique } from 'drizzle-orm/pg-core'; +import { pgTable, uuid, text, timestamp, unique } from 'drizzle-orm/pg-core'; import { images } from './images.schema'; export const imageLikes = pgTable( @@ -8,7 +8,7 @@ export const imageLikes = pgTable( imageId: uuid('image_id') .notNull() .references(() => images.id, { onDelete: 'cascade' }), - userId: uuid('user_id').notNull(), + userId: text('user_id').notNull(), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), }, (table) => ({ diff --git a/apps/picture/apps/backend/src/db/schema/images.schema.ts b/apps/picture/apps/backend/src/db/schema/images.schema.ts index 5cd7d60bd..f9b9e0f9a 100644 --- a/apps/picture/apps/backend/src/db/schema/images.schema.ts +++ b/apps/picture/apps/backend/src/db/schema/images.schema.ts @@ -2,7 +2,7 @@ import { pgTable, uuid, text, timestamp, boolean, integer } from 'drizzle-orm/pg export const images = pgTable('images', { id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id').notNull(), + userId: text('user_id').notNull(), generationId: uuid('generation_id'), sourceImageId: uuid('source_image_id'), diff --git a/apps/picture/apps/web/src/app.css b/apps/picture/apps/web/src/app.css index 3463ef16a..eb0a6229e 100644 --- a/apps/picture/apps/web/src/app.css +++ b/apps/picture/apps/web/src/app.css @@ -8,5 +8,7 @@ @source '../../../../packages/shared-auth-ui/src'; @source '../../../../packages/shared-branding/src'; @source '../../../../packages/shared-theme-ui/src'; +@source '../../../../packages/shared-theme-ui/src/components'; +@source '../../../../packages/shared-theme-ui/src/pages'; @source '../../../../packages/shared-subscription-ui/src'; @source '../../../../packages/shared-i18n/src'; diff --git a/apps/picture/apps/web/src/routes/app/+layout.svelte b/apps/picture/apps/web/src/routes/app/+layout.svelte index adf04ab18..447101cca 100644 --- a/apps/picture/apps/web/src/routes/app/+layout.svelte +++ b/apps/picture/apps/web/src/routes/app/+layout.svelte @@ -6,7 +6,13 @@ import { locale } from 'svelte-i18n'; import { PillNavigation } from '@manacore/shared-ui'; import type { PillNavItem, PillNavElement, PillDropdownItem } from '@manacore/shared-ui'; - import { THEME_DEFINITIONS } from '@manacore/shared-theme'; + import { + THEME_DEFINITIONS, + DEFAULT_THEME_VARIANTS, + EXTENDED_THEME_VARIANTS, + } from '@manacore/shared-theme'; + import type { ThemeVariant } from '@manacore/shared-theme'; + import { filterHiddenNavItems } from '@manacore/shared-theme'; import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n'; import { getPillAppItems } from '@manacore/shared-branding'; import { setLocale, supportedLocales } from '$lib/i18n'; @@ -88,8 +94,8 @@ } }); - // Navigation items (Mana is in user dropdown via manaHref) - const navItems: PillNavItem[] = [ + // Base navigation items (Mana is in user dropdown via manaHref) + const baseNavItems: PillNavItem[] = [ { href: '/app/gallery', label: 'Galerie', icon: 'home' }, { href: '/app/board', label: 'Moodboards', icon: 'grid' }, { href: '/app/explore', label: 'Entdecken', icon: 'search' }, @@ -99,6 +105,11 @@ { href: '/app/archive', label: 'Archiv', icon: 'archive' }, ]; + // Navigation items filtered by visibility settings + const navItems = $derived( + filterHiddenNavItems('picture', baseNavItems, userSettings.nav.hiddenNavItems) + ); + // View mode options for tab group const viewModeOptions = [ { id: 'single', icon: 'list', title: 'Liste (1)' }, @@ -106,9 +117,19 @@ { id: 'gridSmall', icon: 'gridSmall', title: 'Klein (3)' }, ]; + // Get pinned themes from user settings (extended themes only) + let pinnedThemes = $derived( + (userSettings.theme?.pinnedThemes || []).filter((t): t is ThemeVariant => + EXTENDED_THEME_VARIANTS.includes(t as ThemeVariant) + ) + ); + + // Visible themes in PillNav: default + pinned extended + let visibleThemes = $derived([...DEFAULT_THEME_VARIANTS, ...pinnedThemes]); + // Theme variant dropdown items let themeVariantItems = $derived([ - ...theme.variants.map((variant) => ({ + ...visibleThemes.map((variant) => ({ id: variant, label: THEME_DEFINITIONS[variant].label, icon: THEME_DEFINITIONS[variant].icon, diff --git a/apps/todo/CLAUDE.md b/apps/todo/CLAUDE.md index c12462a3e..affb10e98 100644 --- a/apps/todo/CLAUDE.md +++ b/apps/todo/CLAUDE.md @@ -156,9 +156,11 @@ pnpm preview # Preview build ## Database Schema +> **Note**: `user_id` columns use TEXT type (not UUID) because Mana Core Auth generates non-UUID user IDs. + ### projects - `id` (UUID) - Primary key -- `user_id` (UUID) - Owner +- `user_id` (TEXT) - Owner (Better Auth format) - `name` (VARCHAR) - Project name - `color` (VARCHAR) - Hex color - `icon` (VARCHAR) - Icon name @@ -169,7 +171,7 @@ pnpm preview # Preview build ### tasks - `id` (UUID) - Primary key - `project_id` (UUID) - FK to projects (nullable = Inbox) -- `user_id` (UUID) - Owner +- `user_id` (TEXT) - Owner (Better Auth format) - `title` (VARCHAR) - Task title - `description` (TEXT) - Description - `due_date` (TIMESTAMP) - Due date @@ -182,7 +184,7 @@ pnpm preview # Preview build ### labels - `id` (UUID) - Primary key -- `user_id` (UUID) - Owner +- `user_id` (TEXT) - Owner (Better Auth format) - `name` (VARCHAR) - Label name - `color` (VARCHAR) - Hex color @@ -193,6 +195,7 @@ pnpm preview # Preview build ### reminders - `id` (UUID) - Primary key - `task_id` (UUID) - FK to tasks +- `user_id` (TEXT) - Owner (Better Auth format) - `minutes_before` (INTEGER) - Offset - `type` (VARCHAR) - push/email/both - `status` (VARCHAR) - pending/sent/failed diff --git a/apps/todo/apps/backend/jest.config.js b/apps/todo/apps/backend/jest.config.js new file mode 100644 index 000000000..dcb95fd63 --- /dev/null +++ b/apps/todo/apps/backend/jest.config.js @@ -0,0 +1,16 @@ +/** @type {import('jest').Config} */ +module.exports = { + moduleFileExtensions: ['js', 'json', 'ts'], + rootDir: 'src', + testRegex: '.*\\.spec\\.ts$', + transform: { + '^.+\\.(t|j)s$': 'ts-jest', + }, + collectCoverageFrom: ['**/*.(t|j)s'], + coverageDirectory: '../coverage', + testEnvironment: 'node', + moduleNameMapper: { + '^@todo/shared$': '/../../packages/shared/src', + '^@manacore/shared-nestjs-auth$': '/../../../../../packages/shared-nestjs-auth/src', + }, +}; diff --git a/apps/todo/apps/backend/package.json b/apps/todo/apps/backend/package.json index 59bdc6dcd..fdbcad75d 100644 --- a/apps/todo/apps/backend/package.json +++ b/apps/todo/apps/backend/package.json @@ -9,33 +9,41 @@ "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:seed": "tsx src/db/seed.ts", "db:generate": "drizzle-kit generate" }, "dependencies": { - "@todo/shared": "workspace:*", "@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", "@nestjs/schedule": "^4.1.2", + "@todo/shared": "workspace:*", "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", + "rrule": "^2.8.1", "rxjs": "^7.8.1" }, "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" } diff --git a/apps/todo/apps/backend/src/app.module.ts b/apps/todo/apps/backend/src/app.module.ts index 5c03f584b..3823bdd3f 100644 --- a/apps/todo/apps/backend/src/app.module.ts +++ b/apps/todo/apps/backend/src/app.module.ts @@ -8,6 +8,7 @@ import { TaskModule } from './task/task.module'; import { LabelModule } from './label/label.module'; import { ReminderModule } from './reminder/reminder.module'; import { KanbanModule } from './kanban/kanban.module'; +import { NetworkModule } from './network/network.module'; @Module({ imports: [ @@ -23,6 +24,7 @@ import { KanbanModule } from './kanban/kanban.module'; LabelModule, ReminderModule, KanbanModule, + NetworkModule, ], }) export class AppModule {} diff --git a/apps/todo/apps/backend/src/db/schema/kanban-boards.schema.ts b/apps/todo/apps/backend/src/db/schema/kanban-boards.schema.ts index 1245668e8..1d074c7cc 100644 --- a/apps/todo/apps/backend/src/db/schema/kanban-boards.schema.ts +++ b/apps/todo/apps/backend/src/db/schema/kanban-boards.schema.ts @@ -1,11 +1,20 @@ -import { pgTable, uuid, timestamp, varchar, boolean, integer, index } from 'drizzle-orm/pg-core'; +import { + pgTable, + uuid, + text, + timestamp, + varchar, + boolean, + integer, + index, +} from 'drizzle-orm/pg-core'; import { projects } from './projects.schema'; export const kanbanBoards = pgTable( 'kanban_boards', { id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id').notNull(), + userId: text('user_id').notNull(), projectId: uuid('project_id').references(() => projects.id, { onDelete: 'cascade' }), // Board properties diff --git a/apps/todo/apps/backend/src/db/schema/kanban-columns.schema.ts b/apps/todo/apps/backend/src/db/schema/kanban-columns.schema.ts index 3764e4872..98a8faddd 100644 --- a/apps/todo/apps/backend/src/db/schema/kanban-columns.schema.ts +++ b/apps/todo/apps/backend/src/db/schema/kanban-columns.schema.ts @@ -1,4 +1,13 @@ -import { pgTable, uuid, timestamp, varchar, boolean, integer, index } from 'drizzle-orm/pg-core'; +import { + pgTable, + uuid, + text, + timestamp, + varchar, + boolean, + integer, + index, +} from 'drizzle-orm/pg-core'; import { kanbanBoards } from './kanban-boards.schema'; // Define locally to avoid circular dependency with tasks.schema @@ -8,7 +17,7 @@ export const kanbanColumns = pgTable( 'kanban_columns', { id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id').notNull(), + userId: text('user_id').notNull(), boardId: uuid('board_id') .references(() => kanbanBoards.id, { onDelete: 'cascade' }) .notNull(), diff --git a/apps/todo/apps/backend/src/db/schema/labels.schema.ts b/apps/todo/apps/backend/src/db/schema/labels.schema.ts index 64e1f0bf0..55add9f7f 100644 --- a/apps/todo/apps/backend/src/db/schema/labels.schema.ts +++ b/apps/todo/apps/backend/src/db/schema/labels.schema.ts @@ -1,10 +1,10 @@ -import { pgTable, uuid, timestamp, varchar, index } from 'drizzle-orm/pg-core'; +import { pgTable, uuid, text, timestamp, varchar, index } from 'drizzle-orm/pg-core'; export const labels = pgTable( 'labels', { id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id').notNull(), + userId: text('user_id').notNull(), name: varchar('name', { length: 100 }).notNull(), color: varchar('color', { length: 7 }).default('#6B7280'), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), diff --git a/apps/todo/apps/backend/src/db/schema/projects.schema.ts b/apps/todo/apps/backend/src/db/schema/projects.schema.ts index f1b507042..b45b7e92f 100644 --- a/apps/todo/apps/backend/src/db/schema/projects.schema.ts +++ b/apps/todo/apps/backend/src/db/schema/projects.schema.ts @@ -21,7 +21,7 @@ export const projects = pgTable( 'projects', { id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id').notNull(), + userId: text('user_id').notNull(), name: varchar('name', { length: 255 }).notNull(), description: text('description'), color: varchar('color', { length: 7 }).default('#3B82F6'), diff --git a/apps/todo/apps/backend/src/db/schema/reminders.schema.ts b/apps/todo/apps/backend/src/db/schema/reminders.schema.ts index fdb3f6e7f..63a1e9ce5 100644 --- a/apps/todo/apps/backend/src/db/schema/reminders.schema.ts +++ b/apps/todo/apps/backend/src/db/schema/reminders.schema.ts @@ -1,4 +1,4 @@ -import { pgTable, uuid, timestamp, varchar, integer, index } from 'drizzle-orm/pg-core'; +import { pgTable, uuid, text, timestamp, varchar, integer, index } from 'drizzle-orm/pg-core'; import { tasks } from './tasks.schema'; export type ReminderType = 'push' | 'email' | 'both'; @@ -11,7 +11,7 @@ export const reminders = pgTable( taskId: uuid('task_id') .notNull() .references(() => tasks.id, { onDelete: 'cascade' }), - userId: uuid('user_id').notNull(), + userId: text('user_id').notNull(), // Timing minutesBefore: integer('minutes_before').notNull(), diff --git a/apps/todo/apps/backend/src/db/schema/tasks.schema.ts b/apps/todo/apps/backend/src/db/schema/tasks.schema.ts index 573937582..8d3596fa5 100644 --- a/apps/todo/apps/backend/src/db/schema/tasks.schema.ts +++ b/apps/todo/apps/backend/src/db/schema/tasks.schema.ts @@ -45,7 +45,7 @@ export const tasks = pgTable( { id: uuid('id').primaryKey().defaultRandom(), projectId: uuid('project_id').references(() => projects.id, { onDelete: 'set null' }), - userId: uuid('user_id').notNull(), + userId: text('user_id').notNull(), parentTaskId: uuid('parent_task_id'), // Content diff --git a/apps/todo/apps/backend/src/label/dto/create-label.dto.ts b/apps/todo/apps/backend/src/label/dto/create-label.dto.ts index 5efeb87f3..920537e9a 100644 --- a/apps/todo/apps/backend/src/label/dto/create-label.dto.ts +++ b/apps/todo/apps/backend/src/label/dto/create-label.dto.ts @@ -1,8 +1,10 @@ -import { IsString, IsOptional, MaxLength } from 'class-validator'; +import { IsString, IsOptional, MaxLength, MinLength, IsNotEmpty } from 'class-validator'; export class CreateLabelDto { @IsString() - @MaxLength(100) + @IsNotEmpty({ message: 'Name darf nicht leer sein' }) + @MinLength(1, { message: 'Name muss mindestens 1 Zeichen haben' }) + @MaxLength(100, { message: 'Name darf maximal 100 Zeichen haben' }) name: string; @IsOptional() diff --git a/apps/todo/apps/backend/src/network/network.controller.ts b/apps/todo/apps/backend/src/network/network.controller.ts new file mode 100644 index 000000000..0ae89347c --- /dev/null +++ b/apps/todo/apps/backend/src/network/network.controller.ts @@ -0,0 +1,14 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { NetworkService } from './network.service'; + +@Controller('api/v1/network') +@UseGuards(JwtAuthGuard) +export class NetworkController { + constructor(private readonly networkService: NetworkService) {} + + @Get('graph') + async getGraph(@CurrentUser() user: CurrentUserData) { + return this.networkService.getGraph(user.userId); + } +} diff --git a/apps/todo/apps/backend/src/network/network.module.ts b/apps/todo/apps/backend/src/network/network.module.ts new file mode 100644 index 000000000..719a19f0d --- /dev/null +++ b/apps/todo/apps/backend/src/network/network.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { NetworkController } from './network.controller'; +import { NetworkService } from './network.service'; + +@Module({ + controllers: [NetworkController], + providers: [NetworkService], + exports: [NetworkService], +}) +export class NetworkModule {} diff --git a/apps/todo/apps/backend/src/network/network.service.ts b/apps/todo/apps/backend/src/network/network.service.ts new file mode 100644 index 000000000..1c67c19f0 --- /dev/null +++ b/apps/todo/apps/backend/src/network/network.service.ts @@ -0,0 +1,147 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { eq, inArray } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { Database } from '../db/connection'; +import { tasks, labels, taskLabels, projects } from '../db/schema'; + +export interface NetworkNode { + id: string; + name: string; + photoUrl: string | null; + company: string | null; // Project name as subtitle + isFavorite: boolean; + tags: { id: string; name: string; color: string | null }[]; + connectionCount: number; +} + +export interface NetworkLink { + source: string; + target: string; + type: 'tag'; + strength: number; + sharedTags: string[]; +} + +export interface NetworkGraphResponse { + nodes: NetworkNode[]; + links: NetworkLink[]; +} + +@Injectable() +export class NetworkService { + constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} + + /** + * Build a network graph of tasks connected by shared labels + */ + async getGraph(userId: string): Promise { + // 1. Get all tasks for user + const userTasks = await this.db + .select({ + task: tasks, + }) + .from(tasks) + .where(eq(tasks.userId, userId)); + + // 2. Get all projects for this user (for project names) + const userProjects = await this.db + .select({ + id: projects.id, + name: projects.name, + }) + .from(projects) + .where(eq(projects.userId, userId)); + + const projectMap = new Map(userProjects.map((p) => [p.id, p.name])); + + // 3. Get all labels for all tasks in a single batch query (fix N+1) + const taskIds = userTasks.map(({ task }) => task.id); + const taskLabelsMap = new Map(); + + if (taskIds.length > 0) { + const allTaskLabels = await this.db + .select({ + taskId: taskLabels.taskId, + labelId: labels.id, + labelName: labels.name, + labelColor: labels.color, + }) + .from(taskLabels) + .innerJoin(labels, eq(taskLabels.labelId, labels.id)) + .where(inArray(taskLabels.taskId, taskIds)); + + // Group labels by taskId + for (const row of allTaskLabels) { + const existing = taskLabelsMap.get(row.taskId) || []; + existing.push({ + id: row.labelId, + name: row.labelName, + color: row.labelColor, + }); + taskLabelsMap.set(row.taskId, existing); + } + } + + // 4. Filter tasks that have at least one label + const tasksWithLabels = userTasks.filter((t) => { + const lbls = taskLabelsMap.get(t.task.id) || []; + return lbls.length > 0; + }); + + // 5. Build nodes + const nodes: NetworkNode[] = tasksWithLabels.map(({ task }) => { + const lbls = taskLabelsMap.get(task.id) || []; + const projectName = task.projectId ? projectMap.get(task.projectId) || null : null; + return { + id: task.id, + name: task.title, + photoUrl: null, // Tasks don't have photos + company: projectName, // Use project name as subtitle + isFavorite: false, + tags: lbls, + connectionCount: 0, // Will be calculated below + }; + }); + + // 6. Build links based on shared labels + const links: NetworkLink[] = []; + const connectionCounts = new Map(); + + for (let i = 0; i < nodes.length; i++) { + for (let j = i + 1; j < nodes.length; j++) { + const node1 = nodes[i]; + const node2 = nodes[j]; + + // Find shared labels + const sharedTags = node1.tags + .filter((t1) => node2.tags.some((t2) => t2.id === t1.id)) + .map((t) => t.name); + + if (sharedTags.length > 0) { + // Calculate strength based on number of shared labels + const maxTags = Math.max(node1.tags.length, node2.tags.length); + const strength = Math.round((sharedTags.length / maxTags) * 100); + + links.push({ + source: node1.id, + target: node2.id, + type: 'tag', + strength, + sharedTags, + }); + + // Update connection counts + connectionCounts.set(node1.id, (connectionCounts.get(node1.id) || 0) + 1); + connectionCounts.set(node2.id, (connectionCounts.get(node2.id) || 0) + 1); + } + } + } + + // 7. Update connection counts in nodes + for (const node of nodes) { + node.connectionCount = connectionCounts.get(node.id) || 0; + } + + return { nodes, links }; + } +} diff --git a/apps/todo/apps/backend/src/project/dto/create-project.dto.ts b/apps/todo/apps/backend/src/project/dto/create-project.dto.ts index cf13948b0..266bd7f00 100644 --- a/apps/todo/apps/backend/src/project/dto/create-project.dto.ts +++ b/apps/todo/apps/backend/src/project/dto/create-project.dto.ts @@ -1,9 +1,19 @@ -import { IsString, IsOptional, IsBoolean, MaxLength, IsObject } from 'class-validator'; +import { + IsString, + IsOptional, + IsBoolean, + MaxLength, + MinLength, + IsObject, + IsNotEmpty, +} from 'class-validator'; import type { ProjectSettings } from '../../db/schema/projects.schema'; export class CreateProjectDto { @IsString() - @MaxLength(255) + @IsNotEmpty({ message: 'Name darf nicht leer sein' }) + @MinLength(1, { message: 'Name muss mindestens 1 Zeichen haben' }) + @MaxLength(255, { message: 'Name darf maximal 255 Zeichen haben' }) name: string; @IsOptional() diff --git a/apps/todo/apps/backend/src/task/__tests__/task.service.spec.ts b/apps/todo/apps/backend/src/task/__tests__/task.service.spec.ts new file mode 100644 index 000000000..b5d9756fd --- /dev/null +++ b/apps/todo/apps/backend/src/task/__tests__/task.service.spec.ts @@ -0,0 +1,515 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { TaskService } from '../task.service'; +import { ProjectService } from '../../project/project.service'; +import { DATABASE_CONNECTION } from '../../db/database.module'; + +// Mock database +const mockSelectFrom = jest.fn().mockReturnThis(); +const mockSelectWhere = jest.fn(); + +const mockDb = { + query: { + tasks: { + findMany: jest.fn(), + findFirst: jest.fn(), + }, + taskLabels: { + findMany: jest.fn(), + }, + labels: { + findMany: jest.fn(), + }, + }, + select: jest.fn().mockReturnValue({ + from: mockSelectFrom, + where: mockSelectWhere, + }), + insert: jest.fn().mockReturnThis(), + update: jest.fn().mockReturnThis(), + delete: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + returning: jest.fn(), +}; + +// Mock ProjectService +const mockProjectService = { + findByIdOrThrow: jest.fn(), +}; + +describe('TaskService', () => { + let service: TaskService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TaskService, + { + provide: DATABASE_CONNECTION, + useValue: mockDb, + }, + { + provide: ProjectService, + useValue: mockProjectService, + }, + ], + }).compile(); + + service = module.get(TaskService); + + // Reset all mocks before each test + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('findAll', () => { + it('should return all tasks for a user', async () => { + const userId = 'user-123'; + const mockTasks = [ + { id: 'task-1', title: 'Task 1', userId }, + { id: 'task-2', title: 'Task 2', userId }, + ]; + + mockDb.query.tasks.findMany.mockResolvedValue(mockTasks); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + + const result = await service.findAll(userId); + + expect(result).toHaveLength(2); + expect(result[0].labels).toEqual([]); + expect(result[1].labels).toEqual([]); + }); + + it('should filter by projectId when provided', async () => { + const userId = 'user-123'; + const projectId = 'project-1'; + + mockDb.query.tasks.findMany.mockResolvedValue([]); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + + await service.findAll(userId, { projectId }); + + expect(mockDb.query.tasks.findMany).toHaveBeenCalled(); + }); + + it('should filter by priority when provided', async () => { + const userId = 'user-123'; + + mockDb.query.tasks.findMany.mockResolvedValue([]); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + + await service.findAll(userId, { priority: 'high' }); + + expect(mockDb.query.tasks.findMany).toHaveBeenCalled(); + }); + }); + + describe('findById', () => { + it('should return a task when found', async () => { + const userId = 'user-123'; + const taskId = 'task-1'; + const mockTask = { id: taskId, title: 'Test Task', userId }; + + mockDb.query.tasks.findFirst.mockResolvedValue(mockTask); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + + const result = await service.findById(taskId, userId); + + expect(result).toBeDefined(); + expect(result?.id).toBe(taskId); + expect(result?.labels).toEqual([]); + }); + + it('should return null when task not found', async () => { + mockDb.query.tasks.findFirst.mockResolvedValue(null); + + const result = await service.findById('non-existent', 'user-123'); + + expect(result).toBeNull(); + }); + }); + + describe('findByIdOrThrow', () => { + it('should return a task when found', async () => { + const userId = 'user-123'; + const taskId = 'task-1'; + const mockTask = { id: taskId, title: 'Test Task', userId }; + + mockDb.query.tasks.findFirst.mockResolvedValue(mockTask); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + + const result = await service.findByIdOrThrow(taskId, userId); + + expect(result.id).toBe(taskId); + }); + + it('should throw NotFoundException when task not found', async () => { + mockDb.query.tasks.findFirst.mockResolvedValue(null); + + await expect(service.findByIdOrThrow('non-existent', 'user-123')).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe('create', () => { + it('should create a task with basic fields', async () => { + const userId = 'user-123'; + const dto = { title: 'New Task' }; + const createdTask = { id: 'task-new', title: 'New Task', userId, order: 0 }; + + mockDb.query.tasks.findMany.mockResolvedValue([]); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + mockDb.returning.mockResolvedValue([createdTask]); + + const result = await service.create(userId, dto); + + expect(result.title).toBe('New Task'); + expect(mockDb.insert).toHaveBeenCalled(); + }); + + it('should verify project belongs to user when projectId is provided', async () => { + const userId = 'user-123'; + const projectId = 'project-1'; + const dto = { title: 'New Task', projectId }; + const createdTask = { id: 'task-new', title: 'New Task', userId, projectId, order: 0 }; + + mockProjectService.findByIdOrThrow.mockResolvedValue({ id: projectId, userId }); + mockDb.query.tasks.findMany.mockResolvedValue([]); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + mockDb.returning.mockResolvedValue([createdTask]); + + await service.create(userId, dto); + + expect(mockProjectService.findByIdOrThrow).toHaveBeenCalledWith(projectId, userId); + }); + + it('should calculate order based on existing tasks', async () => { + const userId = 'user-123'; + const dto = { title: 'New Task' }; + const existingTasks = [ + { id: 'task-1', order: 0 }, + { id: 'task-2', order: 1 }, + { id: 'task-3', order: 2 }, + ]; + const createdTask = { id: 'task-new', title: 'New Task', userId, order: 3 }; + + mockDb.query.tasks.findMany.mockResolvedValue(existingTasks); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + mockDb.returning.mockResolvedValue([createdTask]); + + const result = await service.create(userId, dto); + + expect(result.order).toBe(3); + }); + }); + + describe('update', () => { + it('should update a task', async () => { + const userId = 'user-123'; + const taskId = 'task-1'; + const dto = { title: 'Updated Title' }; + const existingTask = { id: taskId, title: 'Original', userId }; + const updatedTask = { id: taskId, title: 'Updated Title', userId }; + + mockDb.query.tasks.findFirst.mockResolvedValue(existingTask); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + mockDb.returning.mockResolvedValue([updatedTask]); + + const result = await service.update(taskId, userId, dto); + + expect(result.title).toBe('Updated Title'); + }); + + it('should throw when task does not exist', async () => { + mockDb.query.tasks.findFirst.mockResolvedValue(null); + + await expect(service.update('non-existent', 'user-123', { title: 'Test' })).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe('delete', () => { + it('should delete a task', async () => { + const userId = 'user-123'; + const taskId = 'task-1'; + const existingTask = { id: taskId, userId }; + + mockDb.query.tasks.findFirst.mockResolvedValue(existingTask); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + + await service.delete(taskId, userId); + + expect(mockDb.delete).toHaveBeenCalled(); + }); + + it('should throw when task does not exist', async () => { + mockDb.query.tasks.findFirst.mockResolvedValue(null); + + await expect(service.delete('non-existent', 'user-123')).rejects.toThrow(NotFoundException); + }); + }); + + describe('complete', () => { + it('should mark a task as completed', async () => { + const userId = 'user-123'; + const taskId = 'task-1'; + const existingTask = { id: taskId, title: 'Test', userId, recurrenceRule: null }; + const completedTask = { ...existingTask, isCompleted: true, status: 'completed' }; + + mockDb.query.tasks.findFirst.mockResolvedValue(existingTask); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + mockDb.returning.mockResolvedValue([completedTask]); + + const result = await service.complete(taskId, userId); + + expect(result.isCompleted).toBe(true); + expect(result.status).toBe('completed'); + }); + + it('should create next occurrence for recurring task', async () => { + const userId = 'user-123'; + const taskId = 'task-1'; + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + const existingTask = { + id: taskId, + title: 'Daily Task', + userId, + recurrenceRule: 'FREQ=DAILY', + dueDate: new Date(), + labels: [], + }; + + const completedTask = { + ...existingTask, + isCompleted: true, + status: 'completed', + completedAt: new Date(), + lastOccurrence: new Date(), + }; + + const newTask = { + id: 'task-new', + title: 'Daily Task', + userId, + recurrenceRule: 'FREQ=DAILY', + dueDate: tomorrow, + isCompleted: false, + status: 'pending', + }; + + // First call for findByIdOrThrow + mockDb.query.tasks.findFirst.mockResolvedValue(existingTask); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + + // For completing the task + mockDb.returning + .mockResolvedValueOnce([newTask]) // For creating new occurrence + .mockResolvedValueOnce([completedTask]); // For completing original + + const result = await service.complete(taskId, userId); + + expect(result.isCompleted).toBe(true); + // Verify that a new task was created + expect(mockDb.insert).toHaveBeenCalled(); + }); + }); + + describe('uncomplete', () => { + it('should mark a task as not completed', async () => { + const userId = 'user-123'; + const taskId = 'task-1'; + const existingTask = { id: taskId, title: 'Test', userId, isCompleted: true }; + const uncompletedTask = { ...existingTask, isCompleted: false, status: 'pending' }; + + mockDb.query.tasks.findFirst.mockResolvedValue(existingTask); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + mockDb.returning.mockResolvedValue([uncompletedTask]); + + const result = await service.uncomplete(taskId, userId); + + expect(result.isCompleted).toBe(false); + expect(result.status).toBe('pending'); + }); + }); + + describe('move', () => { + it('should move a task to a different project', async () => { + const userId = 'user-123'; + const taskId = 'task-1'; + const newProjectId = 'project-2'; + const existingTask = { id: taskId, title: 'Test', userId, projectId: 'project-1' }; + const movedTask = { ...existingTask, projectId: newProjectId }; + + mockProjectService.findByIdOrThrow.mockResolvedValue({ id: newProjectId, userId }); + mockDb.query.tasks.findFirst.mockResolvedValue(existingTask); + mockDb.query.tasks.findMany.mockResolvedValue([]); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + mockDb.returning.mockResolvedValue([movedTask]); + + const result = await service.move(taskId, userId, newProjectId); + + expect(result.projectId).toBe(newProjectId); + expect(mockProjectService.findByIdOrThrow).toHaveBeenCalledWith(newProjectId, userId); + }); + + it('should move a task to inbox (null project)', async () => { + const userId = 'user-123'; + const taskId = 'task-1'; + const existingTask = { id: taskId, title: 'Test', userId, projectId: 'project-1' }; + const movedTask = { ...existingTask, projectId: null }; + + mockDb.query.tasks.findFirst.mockResolvedValue(existingTask); + mockDb.query.tasks.findMany.mockResolvedValue([]); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + mockDb.returning.mockResolvedValue([movedTask]); + + const result = await service.move(taskId, userId, null); + + expect(result.projectId).toBeNull(); + expect(mockProjectService.findByIdOrThrow).not.toHaveBeenCalled(); + }); + }); + + describe('getInboxTasks', () => { + it('should return incomplete tasks', async () => { + const userId = 'user-123'; + const mockTasks = [ + { id: 'task-1', title: 'Task 1', userId, isCompleted: false }, + { id: 'task-2', title: 'Task 2', userId, isCompleted: false }, + ]; + + mockDb.query.tasks.findMany.mockResolvedValue(mockTasks); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + + const result = await service.getInboxTasks(userId); + + expect(result).toHaveLength(2); + expect(result.every((t) => t.isCompleted === false)).toBe(true); + }); + }); + + describe('getTodayTasks', () => { + it('should return tasks due today', async () => { + const userId = 'user-123'; + const today = new Date(); + const mockTasks = [{ id: 'task-1', title: 'Today Task', userId, dueDate: today }]; + + mockDb.query.tasks.findMany.mockResolvedValue(mockTasks); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + + const result = await service.getTodayTasks(userId); + + expect(result).toHaveLength(1); + }); + }); + + describe('getCompletedTasks', () => { + it('should return completed tasks with pagination info', async () => { + const userId = 'user-123'; + const mockTasks = Array(50) + .fill(null) + .map((_, i) => ({ + id: `task-${i}`, + title: `Task ${i}`, + userId, + isCompleted: true, + })); + + mockDb.query.tasks.findMany.mockResolvedValue(mockTasks); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + mockSelectWhere.mockResolvedValue([{ count: 75 }]); + + const result = await service.getCompletedTasks(userId); + + expect(result.tasks).toHaveLength(50); + expect(result.total).toBe(75); + expect(result.hasMore).toBe(true); + }); + + it('should respect custom limit and offset', async () => { + const userId = 'user-123'; + const mockTasks = Array(10) + .fill(null) + .map((_, i) => ({ + id: `task-${i}`, + title: `Task ${i}`, + userId, + isCompleted: true, + })); + + mockDb.query.tasks.findMany.mockResolvedValue(mockTasks); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + mockSelectWhere.mockResolvedValue([{ count: 25 }]); + + const result = await service.getCompletedTasks(userId, 10, 10); + + expect(result.tasks).toHaveLength(10); + expect(result.total).toBe(25); + expect(result.hasMore).toBe(true); // offset 10 + limit 10 = 20 < 25 + }); + + it('should enforce max limit of 100', async () => { + const userId = 'user-123'; + const mockTasks = Array(100) + .fill(null) + .map((_, i) => ({ + id: `task-${i}`, + title: `Task ${i}`, + userId, + isCompleted: true, + })); + + mockDb.query.tasks.findMany.mockResolvedValue(mockTasks); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + mockSelectWhere.mockResolvedValue([{ count: 200 }]); + + // Request 500 tasks, should be capped at 100 + const result = await service.getCompletedTasks(userId, 500, 0); + + expect(result.tasks).toHaveLength(100); + expect(result.hasMore).toBe(true); + }); + }); + + describe('loadTaskLabelsBatch', () => { + it('should batch load labels for multiple tasks', async () => { + const userId = 'user-123'; + const mockTasks = [ + { id: 'task-1', title: 'Task 1', userId }, + { id: 'task-2', title: 'Task 2', userId }, + ]; + + const mockTaskLabels = [ + { taskId: 'task-1', labelId: 'label-1' }, + { taskId: 'task-1', labelId: 'label-2' }, + { taskId: 'task-2', labelId: 'label-1' }, + ]; + + const mockLabels = [ + { id: 'label-1', name: 'Important', color: '#ff0000' }, + { id: 'label-2', name: 'Work', color: '#0000ff' }, + ]; + + mockDb.query.tasks.findMany.mockResolvedValue(mockTasks); + mockDb.query.taskLabels.findMany.mockResolvedValue(mockTaskLabels); + mockDb.query.labels.findMany.mockResolvedValue(mockLabels); + + const result = await service.findAll(userId); + + expect(result[0].labels).toHaveLength(2); + expect(result[1].labels).toHaveLength(1); + // Should only make 2 queries for labels (taskLabels + labels), not N+1 + expect(mockDb.query.taskLabels.findMany).toHaveBeenCalledTimes(1); + expect(mockDb.query.labels.findMany).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/apps/todo/apps/backend/src/task/dto/create-task.dto.ts b/apps/todo/apps/backend/src/task/dto/create-task.dto.ts index 32e7fdd22..2cebd0bdd 100644 --- a/apps/todo/apps/backend/src/task/dto/create-task.dto.ts +++ b/apps/todo/apps/backend/src/task/dto/create-task.dto.ts @@ -4,15 +4,22 @@ import { IsUUID, IsEnum, IsArray, - IsObject, MaxLength, + MinLength, IsDateString, + IsNotEmpty, + ValidateNested, } from 'class-validator'; -import type { TaskPriority, Subtask, TaskMetadata } from '../../db/schema/tasks.schema'; +import { Type } from 'class-transformer'; +import type { TaskPriority } from '../../db/schema/tasks.schema'; +import { CreateSubtaskDto } from './subtask.dto'; +import { TaskMetadataDto } from './metadata.dto'; export class CreateTaskDto { @IsString() - @MaxLength(500) + @IsNotEmpty({ message: 'Titel darf nicht leer sein' }) + @MinLength(1, { message: 'Titel muss mindestens 1 Zeichen haben' }) + @MaxLength(500, { message: 'Titel darf maximal 500 Zeichen haben' }) title: string; @IsOptional() @@ -54,7 +61,9 @@ export class CreateTaskDto { @IsOptional() @IsArray() - subtasks?: Omit[]; + @ValidateNested({ each: true }) + @Type(() => CreateSubtaskDto) + subtasks?: CreateSubtaskDto[]; @IsOptional() @IsArray() @@ -62,6 +71,7 @@ export class CreateTaskDto { labelIds?: string[]; @IsOptional() - @IsObject() - metadata?: TaskMetadata; + @ValidateNested() + @Type(() => TaskMetadataDto) + metadata?: TaskMetadataDto; } diff --git a/apps/todo/apps/backend/src/task/dto/metadata.dto.ts b/apps/todo/apps/backend/src/task/dto/metadata.dto.ts new file mode 100644 index 000000000..f6ba27ba8 --- /dev/null +++ b/apps/todo/apps/backend/src/task/dto/metadata.dto.ts @@ -0,0 +1,58 @@ +import { + IsString, + IsOptional, + IsNumber, + IsArray, + IsUUID, + IsEnum, + Min, + Max, + MaxLength, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class EffectiveDurationDto { + @IsNumber() + @Min(1, { message: 'Dauer muss mindestens 1 sein' }) + @Max(9999, { message: 'Dauer darf maximal 9999 sein' }) + value: number; + + @IsEnum(['minutes', 'hours', 'days'], { message: 'Ungültige Zeiteinheit' }) + unit: 'minutes' | 'hours' | 'days'; +} + +export class TaskMetadataDto { + @IsOptional() + @IsString() + @MaxLength(10000, { message: 'Notizen dürfen maximal 10000 Zeichen haben' }) + notes?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + @MaxLength(500, { each: true }) + attachments?: string[]; + + @IsOptional() + @IsUUID() + linkedCalendarEventId?: string | null; + + @IsOptional() + @IsNumber() + @IsEnum([1, 2, 3, 5, 8, 13, 21], { + message: 'Storypoints müssen Fibonacci-Zahlen sein (1,2,3,5,8,13,21)', + }) + storyPoints?: number | null; + + @IsOptional() + @ValidateNested() + @Type(() => EffectiveDurationDto) + effectiveDuration?: EffectiveDurationDto | null; + + @IsOptional() + @IsNumber() + @Min(1, { message: 'Spaß-Faktor muss mindestens 1 sein' }) + @Max(10, { message: 'Spaß-Faktor darf maximal 10 sein' }) + funRating?: number | null; +} diff --git a/apps/todo/apps/backend/src/task/dto/subtask.dto.ts b/apps/todo/apps/backend/src/task/dto/subtask.dto.ts new file mode 100644 index 000000000..1cdce1a78 --- /dev/null +++ b/apps/todo/apps/backend/src/task/dto/subtask.dto.ts @@ -0,0 +1,48 @@ +import { + IsString, + IsBoolean, + IsOptional, + IsNumber, + MinLength, + MaxLength, + Min, + IsDateString, +} from 'class-validator'; + +export class SubtaskDto { + @IsOptional() + @IsString() + id?: string; + + @IsString() + @MinLength(1, { message: 'Subtask-Titel darf nicht leer sein' }) + @MaxLength(500, { message: 'Subtask-Titel darf maximal 500 Zeichen haben' }) + title: string; + + @IsBoolean() + isCompleted: boolean; + + @IsOptional() + @IsDateString() + completedAt?: string | null; + + @IsNumber() + @Min(0) + order: number; +} + +export class CreateSubtaskDto { + @IsString() + @MinLength(1, { message: 'Subtask-Titel darf nicht leer sein' }) + @MaxLength(500, { message: 'Subtask-Titel darf maximal 500 Zeichen haben' }) + title: string; + + @IsOptional() + @IsBoolean() + isCompleted?: boolean; + + @IsOptional() + @IsNumber() + @Min(0) + order?: number; +} diff --git a/apps/todo/apps/backend/src/task/task.controller.ts b/apps/todo/apps/backend/src/task/task.controller.ts index 579923aa8..01060596a 100644 --- a/apps/todo/apps/backend/src/task/task.controller.ts +++ b/apps/todo/apps/backend/src/task/task.controller.ts @@ -33,9 +33,13 @@ export class TaskController { } @Get('completed') - async getCompleted(@CurrentUser() user: CurrentUserData, @Query('limit') limit?: number) { - const tasks = await this.taskService.getCompletedTasks(user.userId, limit ?? 50); - return { tasks }; + async getCompleted( + @CurrentUser() user: CurrentUserData, + @Query('limit') limit?: number, + @Query('offset') offset?: number + ) { + const result = await this.taskService.getCompletedTasks(user.userId, limit ?? 50, offset ?? 0); + return result; } @Get(':id') diff --git a/apps/todo/apps/backend/src/task/task.service.ts b/apps/todo/apps/backend/src/task/task.service.ts index 4f394bf6d..747990a56 100644 --- a/apps/todo/apps/backend/src/task/task.service.ts +++ b/apps/todo/apps/backend/src/task/task.service.ts @@ -1,11 +1,15 @@ import { Injectable, Inject, NotFoundException } from '@nestjs/common'; -import { eq, and, or, gte, lte, ilike, asc, desc, isNull, SQL } from 'drizzle-orm'; +import { eq, and, or, gte, lte, ilike, asc, desc, isNull, SQL, sql } from 'drizzle-orm'; +import { RRule, RRuleSet, rrulestr } from 'rrule'; import { DATABASE_CONNECTION } from '../db/database.module'; import { type Database } from '../db/connection'; import { tasks, taskLabels, labels, type Task, type NewTask, type Subtask } from '../db/schema'; import { ProjectService } from '../project/project.service'; import { CreateTaskDto, UpdateTaskDto, QueryTasksDto } from './dto'; +// Extended Task type that includes labels (populated after loading from DB) +type TaskWithLabels = Task & { labels: (typeof labels.$inferSelect)[] }; + @Injectable() export class TaskService { constructor( @@ -13,7 +17,7 @@ export class TaskService { private projectService: ProjectService ) {} - async findAll(userId: string, query: QueryTasksDto = {}): Promise { + async findAll(userId: string, query: QueryTasksDto = {}): Promise { const conditions: SQL[] = [eq(tasks.userId, userId)]; if (query.projectId) { @@ -73,11 +77,11 @@ export class TaskService { offset: query.offset, }); - // Load labels for each task - return Promise.all(result.map((task) => this.loadTaskLabels(task))); + // Batch load labels for all tasks (2 queries instead of N+1) + return this.loadTaskLabelsBatch(result); } - async findById(id: string, userId: string): Promise { + async findById(id: string, userId: string): Promise { const result = await this.db.query.tasks.findFirst({ where: and(eq(tasks.id, id), eq(tasks.userId, userId)), }); @@ -86,7 +90,7 @@ export class TaskService { return this.loadTaskLabels(result); } - async findByIdOrThrow(id: string, userId: string): Promise { + async findByIdOrThrow(id: string, userId: string): Promise { const task = await this.findById(id, userId); if (!task) { throw new NotFoundException(`Task with id ${id} not found`); @@ -94,7 +98,7 @@ export class TaskService { return task; } - async create(userId: string, dto: CreateTaskDto): Promise { + async create(userId: string, dto: CreateTaskDto): Promise { // Verify project belongs to user if provided if (dto.projectId) { await this.projectService.findByIdOrThrow(dto.projectId, userId); @@ -139,7 +143,7 @@ export class TaskService { return this.loadTaskLabels(created); } - async update(id: string, userId: string, dto: UpdateTaskDto): Promise { + async update(id: string, userId: string, dto: UpdateTaskDto): Promise { await this.findByIdOrThrow(id, userId); // Verify project belongs to user if changing project @@ -185,13 +189,28 @@ export class TaskService { await this.db.delete(tasks).where(and(eq(tasks.id, id), eq(tasks.userId, userId))); } - async complete(id: string, userId: string): Promise { + async complete(id: string, userId: string): Promise { const task = await this.findByIdOrThrow(id, userId); // If task has recurrence, create next occurrence instead of completing if (task.recurrenceRule) { - // TODO: Implement recurrence handling - // For now, just mark as complete + const nextOccurrence = await this.createNextOccurrence(task, userId); + if (nextOccurrence) { + // Mark current task as completed and update lastOccurrence + const [completed] = await this.db + .update(tasks) + .set({ + isCompleted: true, + status: 'completed', + completedAt: new Date(), + lastOccurrence: new Date(), + updatedAt: new Date(), + }) + .where(and(eq(tasks.id, id), eq(tasks.userId, userId))) + .returning(); + + return this.loadTaskLabels(completed); + } } return this.update(id, userId, { @@ -200,14 +219,155 @@ export class TaskService { }); } - async uncomplete(id: string, userId: string): Promise { + /** + * Validates an RRULE string to prevent abuse (DoS, excessive occurrences). + * Returns true if valid, false if invalid or too complex. + */ + private validateRRule(rruleString: string): boolean { + // Basic length check + if (!rruleString || rruleString.length > 500) { + return false; + } + + try { + const rule = rrulestr(rruleString); + + // Get occurrences for the next 10 years with a limit + // Daily tasks = ~3650/10yrs, hourly would be ~87600 (reject) + const maxOccurrences = 5000; + const tenYearsFromNow = new Date(); + tenYearsFromNow.setFullYear(tenYearsFromNow.getFullYear() + 10); + + const occurrences = rule.between(new Date(), tenYearsFromNow, true, (_, count) => { + // Stop iteration early if we exceed limit + return count < maxOccurrences; + }); + + // Reject if too many occurrences (prevents hourly/minutely abuse) + if (occurrences.length >= maxOccurrences) { + console.warn(`RRULE rejected: too many occurrences (${occurrences.length})`); + return false; + } + + return true; + } catch { + return false; + } + } + + /** + * Creates the next occurrence of a recurring task based on its RRULE. + * Returns the newly created task, or null if no more occurrences should be created. + */ + private async createNextOccurrence( + task: TaskWithLabels, + userId: string + ): Promise { + if (!task.recurrenceRule) return null; + + // Validate RRULE complexity before parsing + if (!this.validateRRule(task.recurrenceRule)) { + console.warn(`Invalid or too complex RRULE for task ${task.id}`); + return null; + } + + try { + // Parse the RRULE string + const rule = rrulestr(task.recurrenceRule); + const now = new Date(); + + // Get the next occurrence after now + const nextDate = rule.after(now, false); + + // Check if we've exceeded the recurrence end date + if (task.recurrenceEndDate) { + const endDate = new Date(task.recurrenceEndDate); + if (!nextDate || nextDate > endDate) { + return null; // No more occurrences + } + } + + if (!nextDate) { + return null; // No more occurrences according to RRULE + } + + // Reset subtasks (mark all as incomplete) + const resetSubtasks: Subtask[] | undefined = task.subtasks?.map((s) => ({ + ...s, + isCompleted: false, + completedAt: null, + })); + + // Create new task for the next occurrence + const newTask: NewTask = { + userId, + projectId: task.projectId, + parentTaskId: task.parentTaskId, + title: task.title, + description: task.description, + dueDate: nextDate, + dueTime: task.dueTime, + startDate: task.startDate + ? this.calculateNextStartDate(task.startDate, task.dueDate, nextDate) + : null, + priority: task.priority ?? 'medium', + status: 'pending', + isCompleted: false, + recurrenceRule: task.recurrenceRule, + recurrenceEndDate: task.recurrenceEndDate, + subtasks: resetSubtasks, + metadata: task.metadata, + order: task.order, + columnId: task.columnId, + columnOrder: task.columnOrder, + }; + + const [created] = await this.db.insert(tasks).values(newTask).returning(); + + // Copy labels from original task + if (task.labels && task.labels.length > 0) { + await this.db.insert(taskLabels).values( + task.labels.map((label) => ({ + taskId: created.id, + labelId: label.id, + })) + ); + } + + return this.loadTaskLabels(created); + } catch (error) { + // If RRULE parsing fails, log and return null + console.error('Failed to parse recurrence rule:', error); + return null; + } + } + + /** + * Calculates the new start date based on the offset between original start and due dates. + */ + private calculateNextStartDate( + originalStartDate: Date | string | null, + originalDueDate: Date | string | null, + nextDueDate: Date + ): Date | null { + if (!originalStartDate || !originalDueDate) return null; + + const start = new Date(originalStartDate); + const due = new Date(originalDueDate); + const diffMs = due.getTime() - start.getTime(); + + // New start date maintains the same offset from the new due date + return new Date(nextDueDate.getTime() - diffMs); + } + + async uncomplete(id: string, userId: string): Promise { return this.update(id, userId, { isCompleted: false, status: 'pending', }); } - async move(id: string, userId: string, projectId: string | null): Promise { + async move(id: string, userId: string, projectId: string | null): Promise { // Verify new project if provided if (projectId) { await this.projectService.findByIdOrThrow(projectId, userId); @@ -247,11 +407,11 @@ export class TaskService { } } - async getInboxTasks(userId: string): Promise { + async getInboxTasks(userId: string): Promise { return this.findAll(userId, { isCompleted: false }); } - async getTodayTasks(userId: string): Promise { + async getTodayTasks(userId: string): Promise { const today = new Date(); today.setHours(0, 0, 0, 0); const tomorrow = new Date(today); @@ -270,10 +430,10 @@ export class TaskService { orderBy: [asc(tasks.dueDate), asc(tasks.order)], }); - return Promise.all(result.map((task) => this.loadTaskLabels(task))); + return this.loadTaskLabelsBatch(result); } - async getUpcomingTasks(userId: string, days: number = 7): Promise { + async getUpcomingTasks(userId: string, days: number = 7): Promise { const today = new Date(); today.setHours(0, 0, 0, 0); const endDate = new Date(today); @@ -289,20 +449,45 @@ export class TaskService { orderBy: [asc(tasks.dueDate), asc(tasks.order)], }); - return Promise.all(result.map((task) => this.loadTaskLabels(task))); + return this.loadTaskLabelsBatch(result); } - async getCompletedTasks(userId: string, limit: number = 50): Promise { - const result = await this.db.query.tasks.findMany({ - where: and(eq(tasks.userId, userId), eq(tasks.isCompleted, true)), - orderBy: [desc(tasks.completedAt)], - limit, - }); + async getCompletedTasks( + userId: string, + limit: number = 50, + offset: number = 0 + ): Promise<{ tasks: TaskWithLabels[]; total: number; hasMore: boolean }> { + // Enforce max limit to prevent abuse + const safeLimit = Math.min(limit, 100); - return Promise.all(result.map((task) => this.loadTaskLabels(task))); + const [result, countResult] = await Promise.all([ + this.db.query.tasks.findMany({ + where: and(eq(tasks.userId, userId), eq(tasks.isCompleted, true)), + orderBy: [desc(tasks.completedAt)], + limit: safeLimit, + offset, + }), + this.db + .select({ count: sql`count(*)::int` }) + .from(tasks) + .where(and(eq(tasks.userId, userId), eq(tasks.isCompleted, true))), + ]); + + const total = countResult[0]?.count ?? 0; + const tasksWithLabels = await this.loadTaskLabelsBatch(result); + + return { + tasks: tasksWithLabels, + total, + hasMore: offset + safeLimit < total, + }; } - async reorder(userId: string, taskIds: string[], projectId?: string | null): Promise { + async reorder( + userId: string, + taskIds: string[], + projectId?: string | null + ): Promise { // Update order for each task const updates = taskIds.map((id, index) => this.db @@ -316,22 +501,66 @@ export class TaskService { return this.findAll(userId, { projectId: projectId ?? undefined }); } + /** + * Loads labels for a single task (used for single task operations). + * For multiple tasks, use loadTaskLabelsBatch instead. + */ private async loadTaskLabels( task: Task ): Promise { - const taskLabelRows = await this.db.query.taskLabels.findMany({ - where: eq(taskLabels.taskId, task.id), - }); + const [result] = await this.loadTaskLabelsBatch([task]); + return result; + } - if (taskLabelRows.length === 0) { - return { ...task, labels: [] }; + /** + * Batch loads labels for multiple tasks in just 2 queries (instead of N+1). + * This significantly improves performance when loading task lists. + */ + private async loadTaskLabelsBatch( + taskList: Task[] + ): Promise<(Task & { labels: (typeof labels.$inferSelect)[] })[]> { + if (taskList.length === 0) { + return []; } - const labelIds = taskLabelRows.map((tl) => tl.labelId); - const taskLabelsData = await this.db.query.labels.findMany({ - where: or(...labelIds.map((id) => eq(labels.id, id))), + const taskIds = taskList.map((t) => t.id); + + // Single query to get all task-label relationships + const allTaskLabels = await this.db.query.taskLabels.findMany({ + where: or(...taskIds.map((id) => eq(taskLabels.taskId, id))), }); - return { ...task, labels: taskLabelsData }; + if (allTaskLabels.length === 0) { + // No labels for any task - return tasks with empty labels array + return taskList.map((task) => ({ ...task, labels: [] })); + } + + // Get unique label IDs + const uniqueLabelIds = [...new Set(allTaskLabels.map((tl) => tl.labelId))]; + + // Single query to get all labels + const allLabels = await this.db.query.labels.findMany({ + where: or(...uniqueLabelIds.map((id) => eq(labels.id, id))), + }); + + // Create a map of labelId -> label for fast lookup + const labelMap = new Map(allLabels.map((l) => [l.id, l])); + + // Create a map of taskId -> labelIds for fast lookup + const taskLabelMap = new Map(); + for (const tl of allTaskLabels) { + const existing = taskLabelMap.get(tl.taskId) || []; + existing.push(tl.labelId); + taskLabelMap.set(tl.taskId, existing); + } + + // Combine tasks with their labels + return taskList.map((task) => { + const labelIds = taskLabelMap.get(task.id) || []; + const taskLabelsData = labelIds + .map((id) => labelMap.get(id)) + .filter((l): l is typeof labels.$inferSelect => l !== undefined); + return { ...task, labels: taskLabelsData }; + }); } } diff --git a/apps/todo/apps/web/Dockerfile b/apps/todo/apps/web/Dockerfile index e29ff1815..2aceb3945 100644 --- a/apps/todo/apps/web/Dockerfile +++ b/apps/todo/apps/web/Dockerfile @@ -36,6 +36,7 @@ COPY packages/shared-subscription-ui ./packages/shared-subscription-ui COPY packages/shared-profile-ui ./packages/shared-profile-ui COPY packages/shared-ui ./packages/shared-ui COPY packages/shared-utils ./packages/shared-utils +COPY packages/shared-tags ./packages/shared-tags # Copy todo packages and web COPY apps/todo/packages ./apps/todo/packages diff --git a/apps/todo/apps/web/package.json b/apps/todo/apps/web/package.json index e6718fd57..5a4f6211b 100644 --- a/apps/todo/apps/web/package.json +++ b/apps/todo/apps/web/package.json @@ -17,6 +17,7 @@ "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0", "@tailwindcss/vite": "^4.1.7", + "@types/d3-force": "^3.0.0", "@types/node": "^20.0.0", "prettier": "^3.1.1", "prettier-plugin-svelte": "^3.1.2", @@ -29,6 +30,8 @@ }, "dependencies": { "@manacore/shared-auth": "workspace:*", + "@manacore/shared-utils": "workspace:*", + "@manacore/shared-tags": "workspace:*", "@manacore/shared-auth-ui": "workspace:*", "@manacore/shared-branding": "workspace:*", "@manacore/shared-feedback-service": "workspace:*", @@ -42,6 +45,7 @@ "@manacore/shared-theme-ui": "workspace:*", "@manacore/shared-ui": "workspace:*", "@todo/shared": "workspace:*", + "d3-force": "^3.0.0", "date-fns": "^4.1.0", "lucide-svelte": "^0.556.0", "svelte-dnd-action": "^0.9.68", diff --git a/apps/todo/apps/web/src/app.css b/apps/todo/apps/web/src/app.css index 957700956..1165d9637 100644 --- a/apps/todo/apps/web/src/app.css +++ b/apps/todo/apps/web/src/app.css @@ -5,6 +5,8 @@ @source "../../../packages/shared/src"; @source "../../../../../packages/shared-ui/src"; @source "../../../../../packages/shared-theme-ui/src"; +@source "../../../../../packages/shared-theme-ui/src/components"; +@source "../../../../../packages/shared-theme-ui/src/pages"; :root { /* Todo App - Purple/Violet Theme */ diff --git a/apps/todo/apps/web/src/app.html b/apps/todo/apps/web/src/app.html index 076d6148e..95592e23e 100644 --- a/apps/todo/apps/web/src/app.html +++ b/apps/todo/apps/web/src/app.html @@ -15,8 +15,8 @@ - - + + diff --git a/apps/todo/apps/web/src/lib/api/labels.ts b/apps/todo/apps/web/src/lib/api/labels.ts index 4eeb3d04c..c19c75373 100644 --- a/apps/todo/apps/web/src/lib/api/labels.ts +++ b/apps/todo/apps/web/src/lib/api/labels.ts @@ -1,39 +1,76 @@ -import { apiClient } from './client'; -import type { Label } from '@todo/shared'; +/** + * Labels API - Uses central Tags API from mana-core-auth + * + * This module wraps the central Tags API to provide backward-compatible + * "labels" interface for the Todo app. Tags and Labels are now unified + * across all Manacore apps. + */ -interface CreateLabelDto { - name: string; - color?: string; +import { browser } from '$app/environment'; +import { + createTagsClient, + type Tag, + type CreateTagInput, + type UpdateTagInput, +} from '@manacore/shared-tags'; +import { authStore } from '$lib/stores/auth.svelte'; + +// Re-export Tag as Label for backward compatibility +export type Label = Tag; + +// Get auth URL dynamically at runtime +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__; + return injectedUrl || 'http://localhost:3001'; + } + return 'http://localhost:3001'; } -interface UpdateLabelDto { - name?: string; - color?: string; -} +// Lazy-initialized client +let _tagsClient: ReturnType | null = null; -interface LabelsResponse { - labels: Label[]; -} - -interface LabelResponse { - label: Label; +function getTagsClient() { + if (!browser) return null; + if (!_tagsClient) { + _tagsClient = createTagsClient({ + authUrl: getAuthUrl(), + getToken: async () => { + const token = await authStore.getAccessToken(); + return token || ''; + }, + }); + } + return _tagsClient; } export async function getLabels(): Promise { - const response = await apiClient.get('/api/v1/labels'); - return response.labels; + const client = getTagsClient(); + if (!client) return []; + return client.getAll(); } -export async function createLabel(data: CreateLabelDto): Promise
diff --git a/apps/todo/apps/web/src/lib/components/form/DurationPicker.svelte b/apps/todo/apps/web/src/lib/components/form/DurationPicker.svelte new file mode 100644 index 000000000..c0be0f92a --- /dev/null +++ b/apps/todo/apps/web/src/lib/components/form/DurationPicker.svelte @@ -0,0 +1,238 @@ + + +
+
+ {#each quickOptions as opt} + + {/each} + + {#if value !== null} + + {/if} +
+ + {#if showCustom} +
+ + +
+ {/if} +
+ + diff --git a/apps/todo/apps/web/src/lib/components/form/FunRatingPicker.svelte b/apps/todo/apps/web/src/lib/components/form/FunRatingPicker.svelte new file mode 100644 index 000000000..2d2fbbbd6 --- /dev/null +++ b/apps/todo/apps/web/src/lib/components/form/FunRatingPicker.svelte @@ -0,0 +1,127 @@ + + +
+
+ {#each Array(10) as _, i} + {@const rating = i + 1} + + {/each} + {#if value !== null} + + {/if} +
+
+ 1 + 5 + 10 +
+
+ + diff --git a/apps/todo/apps/web/src/lib/components/form/PrioritySelector.svelte b/apps/todo/apps/web/src/lib/components/form/PrioritySelector.svelte new file mode 100644 index 000000000..b0bea04b0 --- /dev/null +++ b/apps/todo/apps/web/src/lib/components/form/PrioritySelector.svelte @@ -0,0 +1,70 @@ + + +
+ {#each PRIORITY_OPTIONS as p} + + {/each} +
+ + diff --git a/apps/todo/apps/web/src/lib/components/form/StorypointsSelector.svelte b/apps/todo/apps/web/src/lib/components/form/StorypointsSelector.svelte new file mode 100644 index 000000000..553bc7305 --- /dev/null +++ b/apps/todo/apps/web/src/lib/components/form/StorypointsSelector.svelte @@ -0,0 +1,105 @@ + + +
+ {#each options as sp} + + {/each} + {#if value !== null} + + {/if} +
+ + diff --git a/apps/todo/apps/web/src/lib/components/form/TagSelector.svelte b/apps/todo/apps/web/src/lib/components/form/TagSelector.svelte new file mode 100644 index 000000000..64c3ceff3 --- /dev/null +++ b/apps/todo/apps/web/src/lib/components/form/TagSelector.svelte @@ -0,0 +1,223 @@ + + + + +
+ + + {#if showDropdown} +
e.stopPropagation()} role="listbox"> + {#each labelsStore.labels as tag} + + {/each} + {#if labelsStore.labels.length === 0} +
Keine Tags vorhanden
+ {/if} +
+ {/if} +
+ + diff --git a/apps/todo/apps/web/src/lib/components/form/index.ts b/apps/todo/apps/web/src/lib/components/form/index.ts new file mode 100644 index 000000000..dddf9df4c --- /dev/null +++ b/apps/todo/apps/web/src/lib/components/form/index.ts @@ -0,0 +1,5 @@ +export { default as PrioritySelector } from './PrioritySelector.svelte'; +export { default as StorypointsSelector } from './StorypointsSelector.svelte'; +export { default as DurationPicker } from './DurationPicker.svelte'; +export { default as FunRatingPicker } from './FunRatingPicker.svelte'; +export { default as TagSelector } from './TagSelector.svelte'; diff --git a/apps/todo/apps/web/src/lib/components/kanban/KanbanBoard.svelte b/apps/todo/apps/web/src/lib/components/kanban/KanbanBoard.svelte index 4be0ccf54..a2c8d394f 100644 --- a/apps/todo/apps/web/src/lib/components/kanban/KanbanBoard.svelte +++ b/apps/todo/apps/web/src/lib/components/kanban/KanbanBoard.svelte @@ -1,6 +1,7 @@ @@ -155,7 +187,13 @@ variant="warning" defaultOpen={true} > - + {/if} @@ -167,13 +205,30 @@ variant="default" defaultOpen={true} > - {#if todayTasks.length === 0} -
-

Keine Aufgaben für heute

-
- {:else} - - {/if} + + + + + + @@ -184,39 +239,49 @@ variant="default" defaultOpen={true} > - {#if upcomingCount === 0} -
-

Keine anstehenden Aufgaben

-
- {:else} -
- {#each groupedUpcomingTasks() as group} -
-

- {group.label} ({group.tasks.length}) -

- -
- {/each} -
- {/if} +
+ {#each groupedUpcomingTasks() as group} +
+

+ {group.label} ({group.tasks.length}) +

+ +
+ {/each} + {#if upcomingCount === 0} + + + {/if} +
- + - {#if completedTasks.length === 0} -
-

Noch keine erledigten Aufgaben

-
- {:else} - - {/if} +
{/if} diff --git a/apps/todo/apps/web/src/routes/(app)/network/+page.svelte b/apps/todo/apps/web/src/routes/(app)/network/+page.svelte new file mode 100644 index 000000000..2ffd59b2c --- /dev/null +++ b/apps/todo/apps/web/src/routes/(app)/network/+page.svelte @@ -0,0 +1,396 @@ + + + + Netzwerk - Todo + + +
+ +
+ +
+ + + {#if networkStore.error} + + {/if} + + +
+ {#if networkStore.loading} +
+
+

Lade Netzwerk-Graph...

+
+ {:else} + + {/if} +
+ + + {#if networkStore.selectedNode} +
+
+

{networkStore.selectedNode.name}

+ +
+ {#if networkStore.selectedNode.subtitle} +

{networkStore.selectedNode.subtitle}

+ {/if} + {#if networkStore.selectedNode.tags.length > 0} +
+ {#each networkStore.selectedNode.tags as tag} + + {tag.name} + + {/each} +
+ {/if} +
+ {networkStore.selectedNode.connectionCount} Verbindungen +
+
+ {/if} +
+ + diff --git a/apps/todo/apps/web/src/routes/(app)/settings/+page.svelte b/apps/todo/apps/web/src/routes/(app)/settings/+page.svelte index 44600448a..91c1daa27 100644 --- a/apps/todo/apps/web/src/routes/(app)/settings/+page.svelte +++ b/apps/todo/apps/web/src/routes/(app)/settings/+page.svelte @@ -6,6 +6,7 @@ import { todoSettings, type TodoView, type KanbanCardSize } from '$lib/stores/settings.svelte'; import { projectsStore } from '$lib/stores/projects.svelte'; import type { TaskPriority } from '@todo/shared'; + import { PRIORITY_OPTIONS } from '@todo/shared'; import { SettingsPage, SettingsSection, @@ -20,13 +21,8 @@ GlobalSettingsSection, } from '@manacore/shared-ui'; - // Options for selects - const priorityOptions = [ - { value: 'low', label: 'Niedrig' }, - { value: 'medium', label: 'Mittel' }, - { value: 'high', label: 'Hoch' }, - { value: 'urgent', label: 'Dringend' }, - ]; + // Use shared priority options (without color) + const priorityOptions = PRIORITY_OPTIONS.map((p) => ({ value: p.value, label: p.label })); const viewOptions = [ { value: 'inbox', label: 'Inbox' }, @@ -129,7 +125,20 @@ - + diff --git a/apps/todo/apps/web/src/routes/(app)/tag/[id]/+page.svelte b/apps/todo/apps/web/src/routes/(app)/tag/[id]/+page.svelte new file mode 100644 index 000000000..57741e8a4 --- /dev/null +++ b/apps/todo/apps/web/src/routes/(app)/tag/[id]/+page.svelte @@ -0,0 +1,326 @@ + + + + {tag?.name || 'Tag'} - Todo + + +
+ +
+ + + +
+ {#if tag} +
+
+
+

{tag.name}

+ {:else} +

Tag

+ {/if} +
+ + + +
+ + {#if isLoading} + + {:else if !tag} +
+
+ +
+

Tag nicht gefunden

+

Dieser Tag existiert nicht mehr.

+ Zu den Tags +
+ {:else if tagTasks.length === 0} +
+
+ +
+

Keine Aufgaben

+

+ Es gibt keine Aufgaben mit dem Tag "{tag.name}". +

+ Aufgabe erstellen +
+ {:else} + + {#if incompleteTasks.length > 0} +
+

+ Offen ({incompleteTasks.length}) +

+ +
+ {/if} + + + {#if completedTasks.length > 0} +
+

+ Erledigt ({completedTasks.length}) +

+ +
+ {/if} + +

+ {tagTasks.length} + {tagTasks.length === 1 ? 'Aufgabe' : 'Aufgaben'} +

+ {/if} +
+ + +{#if editingTask} + +{/if} + + diff --git a/apps/todo/apps/web/src/routes/(app)/tags/+page.svelte b/apps/todo/apps/web/src/routes/(app)/tags/+page.svelte new file mode 100644 index 000000000..6eda4a9cb --- /dev/null +++ b/apps/todo/apps/web/src/routes/(app)/tags/+page.svelte @@ -0,0 +1,502 @@ + + + + Tags - Todo + + +
+ +
+ + + +

Tags

+
+ + +
+
+ + + + + +
+ + +
+
+ {#each colorPalette as color} + + {/each} +
+ + + {#if newTagName.trim()} +
+ + {newTagName} + +
+ {/if} +
+
+ + +
+ + +
+ + {#if labelsStore.error} + + {/if} + + + + + {#if !labelsStore.loading && labelsStore.labels.length > 0} +

+ {labelsStore.labels.length} + {labelsStore.labels.length === 1 ? 'Tag' : 'Tags'} +

+ {/if} +
+ + + + + + { + showDeleteConfirm = false; + labelToDelete = null; + }} + onConfirm={confirmDeleteLabel} + variant="danger" + title="Tag löschen?" + message={`Der Tag "${labelToDelete?.name ?? ''}" wird unwiderruflich gelöscht.`} + confirmLabel="Löschen" + cancelLabel="Abbrechen" +/> + + diff --git a/apps/todo/apps/web/src/routes/(app)/themes/+page.svelte b/apps/todo/apps/web/src/routes/(app)/themes/+page.svelte new file mode 100644 index 000000000..f86027293 --- /dev/null +++ b/apps/todo/apps/web/src/routes/(app)/themes/+page.svelte @@ -0,0 +1,19 @@ + + + + Themes | Todo + + + theme.setVariant(v)} + showModeSelector={true} + currentMode={theme.mode} + onModeChange={(m) => theme.setMode(m)} + showBackButton={true} + onBack={() => goto('/')} +/> diff --git a/apps/todo/apps/web/static/sw.js b/apps/todo/apps/web/static/sw.js index 7992ed8ec..f2af67d36 100644 --- a/apps/todo/apps/web/static/sw.js +++ b/apps/todo/apps/web/static/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'todo-v1'; +const CACHE_NAME = 'todo-v2'; const OFFLINE_URL = '/offline.html'; // Assets, die immer gecacht werden sollen @@ -8,23 +8,16 @@ const STATIC_CACHE_URLS = ['/', '/offline.html', '/icons/icon.svg', '/manifest.j const CACHE_STRATEGIES = { // Netzwerk zuerst, dann Cache (für HTML/Navigation) networkFirst: [/\/$/, /\.html$/, /^\/kanban/, /^\/settings/, /^\/mana/, /^\/feedback/], - // Cache zuerst, dann Netzwerk (für Assets) + // Cache zuerst, dann Netzwerk (für Assets) - nur für gebaute Assets, nicht /src/ cacheFirst: [ - /\.css$/, - /\.js$/, + /\/_app\//, // SvelteKit gebaute Assets /\.woff2?$/, /\.ttf$/, /\.otf$/, - /\.svg$/, - /\.png$/, - /\.jpg$/, - /\.jpeg$/, - /\.webp$/, /\.ico$/, - /\/_app\//, ], - // Nur Netzwerk (für API-Calls) - networkOnly: [/\/api\//, /localhost:3018/], + // Nur Netzwerk (für API-Calls und Dev-Server) + networkOnly: [/\/api\//, /localhost:3018/, /^\/src\//, /^\/@/, /^\/node_modules\//], }; // Service Worker Installation diff --git a/apps/todo/packages/shared/src/constants/index.ts b/apps/todo/packages/shared/src/constants/index.ts index ca2bb18bc..784948270 100644 --- a/apps/todo/packages/shared/src/constants/index.ts +++ b/apps/todo/packages/shared/src/constants/index.ts @@ -69,6 +69,9 @@ export const REMINDER_PRESETS = [ { label: '1 week before', minutes: 10080 }, ] as const; +// Re-export task-specific constants (German localized versions) +export * from './task'; + // View types export type ViewType = | 'inbox' diff --git a/apps/todo/packages/shared/src/constants/task.ts b/apps/todo/packages/shared/src/constants/task.ts new file mode 100644 index 000000000..abd33ff9d --- /dev/null +++ b/apps/todo/packages/shared/src/constants/task.ts @@ -0,0 +1,55 @@ +import type { TaskPriority, TaskStatus } from '../types/task'; + +export interface PriorityOption { + value: TaskPriority; + label: string; + color: string; +} + +export interface StatusOption { + value: TaskStatus; + label: string; +} + +export interface RecurrenceOption { + value: string; + label: string; +} + +export const PRIORITY_OPTIONS: PriorityOption[] = [ + { value: 'low', label: 'Später', color: '#22c55e' }, + { value: 'medium', label: 'Normal', color: '#eab308' }, + { value: 'high', label: 'Wichtig', color: '#f97316' }, + { value: 'urgent', label: 'Dringend', color: '#ef4444' }, +]; + +export const STATUS_OPTIONS: StatusOption[] = [ + { value: 'pending', label: 'Offen' }, + { value: 'in_progress', label: 'In Arbeit' }, + { value: 'completed', label: 'Erledigt' }, + { value: 'cancelled', label: 'Abgebrochen' }, +]; + +export const RECURRENCE_OPTIONS: RecurrenceOption[] = [ + { value: '', label: 'Keine Wiederholung' }, + { value: 'FREQ=DAILY', label: 'Täglich' }, + { value: 'FREQ=WEEKLY', label: 'Wöchentlich' }, + { value: 'FREQ=WEEKLY;INTERVAL=2', label: 'Alle 2 Wochen' }, + { value: 'FREQ=MONTHLY', label: 'Monatlich' }, + { value: 'FREQ=YEARLY', label: 'Jährlich' }, +]; + +// Fibonacci sequence for story points +export const STORYPOINT_OPTIONS = [1, 2, 3, 5, 8, 13, 21] as const; + +// Helper to get priority label +export function getPriorityLabel(priority: TaskPriority): string { + const option = PRIORITY_OPTIONS.find((p) => p.value === priority); + return option?.label ?? priority; +} + +// Helper to get status label +export function getStatusLabel(status: TaskStatus): string { + const option = STATUS_OPTIONS.find((s) => s.value === status); + return option?.label ?? status; +} diff --git a/apps/todo/packages/shared/src/types/task.ts b/apps/todo/packages/shared/src/types/task.ts index 74c97cd97..66ea523d4 100644 --- a/apps/todo/packages/shared/src/types/task.ts +++ b/apps/todo/packages/shared/src/types/task.ts @@ -108,6 +108,7 @@ export interface UpdateTaskInput { recurrenceEndDate?: string | null; subtasks?: Subtask[] | null; metadata?: TaskMetadata | null; + labelIds?: string[]; } export interface QueryTasksInput { diff --git a/apps/zitare/apps/backend/src/db/schema/favorites.schema.ts b/apps/zitare/apps/backend/src/db/schema/favorites.schema.ts index 8f328d121..2f95e65e5 100644 --- a/apps/zitare/apps/backend/src/db/schema/favorites.schema.ts +++ b/apps/zitare/apps/backend/src/db/schema/favorites.schema.ts @@ -1,10 +1,10 @@ -import { pgTable, uuid, timestamp, unique, varchar } from 'drizzle-orm/pg-core'; +import { pgTable, uuid, text, timestamp, unique, varchar } from 'drizzle-orm/pg-core'; export const favorites = pgTable( 'favorites', { id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id').notNull(), + userId: text('user_id').notNull(), quoteId: varchar('quote_id', { length: 100 }).notNull(), // References static quote ID from shared package createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), }, diff --git a/apps/zitare/apps/backend/src/db/schema/user-lists.schema.ts b/apps/zitare/apps/backend/src/db/schema/user-lists.schema.ts index c0dfb83da..691e142bb 100644 --- a/apps/zitare/apps/backend/src/db/schema/user-lists.schema.ts +++ b/apps/zitare/apps/backend/src/db/schema/user-lists.schema.ts @@ -2,7 +2,7 @@ import { pgTable, uuid, text, timestamp, jsonb } from 'drizzle-orm/pg-core'; export const userLists = pgTable('user_lists', { id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id').notNull(), + userId: text('user_id').notNull(), name: text('name').notNull(), description: text('description'), quoteIds: jsonb('quote_ids').$type().default([]), // References static quote IDs from shared package diff --git a/apps/zitare/apps/web/src/app.css b/apps/zitare/apps/web/src/app.css index 4ff0d17df..17f8d4fdd 100644 --- a/apps/zitare/apps/web/src/app.css +++ b/apps/zitare/apps/web/src/app.css @@ -4,6 +4,8 @@ /* Scan shared packages for Tailwind classes */ @source "../../../packages/shared-ui/src"; @source "../../../packages/shared-theme-ui/src"; +@source "../../../packages/shared-theme-ui/src/components"; +@source "../../../packages/shared-theme-ui/src/pages"; @source "../../../packages/web-ui/src"; /* Zitare-specific CSS Variables */ diff --git a/apps/zitare/apps/web/src/routes/(app)/+layout.svelte b/apps/zitare/apps/web/src/routes/(app)/+layout.svelte index b6c91713d..77d74aa2f 100644 --- a/apps/zitare/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/zitare/apps/web/src/routes/(app)/+layout.svelte @@ -8,7 +8,13 @@ import { theme } from '$lib/stores/theme'; import { authStore } from '$lib/stores/auth.svelte'; import { userSettings } from '$lib/stores/user-settings.svelte'; - import { THEME_DEFINITIONS } from '@manacore/shared-theme'; + import { + THEME_DEFINITIONS, + DEFAULT_THEME_VARIANTS, + EXTENDED_THEME_VARIANTS, + } from '@manacore/shared-theme'; + import type { ThemeVariant } from '@manacore/shared-theme'; + import { filterHiddenNavItems } from '@manacore/shared-theme'; import { isSidebarMode as sidebarModeStore, isNavCollapsed as collapsedStore, @@ -28,9 +34,19 @@ // Use theme store's isDark directly let isDark = $derived(theme.isDark); + // Get pinned themes from user settings (extended themes only) + let pinnedThemes = $derived( + (userSettings.theme?.pinnedThemes || []).filter((t): t is ThemeVariant => + EXTENDED_THEME_VARIANTS.includes(t as ThemeVariant) + ) + ); + + // Visible themes in PillNav: default + pinned extended + let visibleThemes = $derived([...DEFAULT_THEME_VARIANTS, ...pinnedThemes]); + // Theme variant dropdown items let themeVariantItems = $derived([ - ...theme.variants.map((variant) => ({ + ...visibleThemes.map((variant) => ({ id: variant, label: THEME_DEFINITIONS[variant].label, icon: THEME_DEFINITIONS[variant].icon, @@ -62,8 +78,8 @@ // User email for user dropdown let userEmail = $derived(authStore.user?.email || 'Menü'); - // Navigation items for Zitare - const navItems: PillNavItem[] = [ + // Base navigation items for Zitare + const baseNavItems: PillNavItem[] = [ { href: '/', label: 'Zitate', icon: 'document' }, { href: '/search', label: 'Suche', icon: 'search' }, { href: '/authors', label: 'Autoren', icon: 'users' }, @@ -72,8 +88,13 @@ { href: '/feedback', label: 'Feedback', icon: 'chat' }, ]; - // Navigation shortcuts (Ctrl+1-6) - const navRoutes = navItems.map((item) => item.href); + // Navigation items filtered by visibility settings + const navItems = $derived( + filterHiddenNavItems('zitare', baseNavItems, userSettings.nav.hiddenNavItems) + ); + + // Navigation shortcuts (Ctrl+1-6) - use base items for consistent shortcuts + const navRoutes = baseNavItems.map((item) => item.href); function handleKeydown(event: KeyboardEvent) { const target = event.target as HTMLElement; diff --git a/cicd/DEPLOYMENT.md b/cicd/DEPLOYMENT.md index 0f33ae385..d767cb347 100644 --- a/cicd/DEPLOYMENT.md +++ b/cicd/DEPLOYMENT.md @@ -543,6 +543,20 @@ docker logs chat-backend-staging --tail 200 # - Port conflicts ``` +### Container name conflict error + +If you see: `Error: The container name "/xxx-staging" is already in use` + +```bash +# SSH to server and remove the stale container +ssh deploy@46.224.108.214 +docker rm -f todo-web-staging # Replace with actual container name + +# Then re-run the deployment +``` + +This can happen if a container was created outside of docker-compose or if a previous deployment failed mid-way. + --- ## Managing Tags diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index e8741a4bf..0df3cd1fd 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -74,8 +74,8 @@ services: JWT_SECRET: ${JWT_SECRET} JWT_PUBLIC_KEY: ${JWT_PUBLIC_KEY} JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY} - # CORS - Allow all staging web app origins - CORS_ORIGINS: http://46.224.108.214:3000,http://46.224.108.214:5173,http://46.224.108.214:5186,http://46.224.108.214:5187,http://46.224.108.214:5188,http://localhost:3000,http://localhost:5173,http://localhost:5186,http://localhost:5187,http://localhost:5188 + # CORS - Allow all staging web app origins (HTTPS domains + localhost for dev) + CORS_ORIGINS: https://chat.staging.manacore.ai,https://staging.manacore.ai,https://calendar.staging.manacore.ai,https://clock.staging.manacore.ai,https://todo.staging.manacore.ai,http://localhost:3000,http://localhost:5173,http://localhost:5186,http://localhost:5187,http://localhost:5188 ports: - "3001:3001" healthcheck: @@ -140,9 +140,9 @@ services: # Server-side URLs (Docker internal network) PUBLIC_BACKEND_URL: http://chat-backend:3002 PUBLIC_MANA_CORE_AUTH_URL: http://mana-core-auth:3001 - # Client-side URLs (browser access via public IP) - PUBLIC_BACKEND_URL_CLIENT: http://46.224.108.214:3002 - PUBLIC_MANA_CORE_AUTH_URL_CLIENT: http://46.224.108.214:3001 + # Client-side URLs (browser access via HTTPS staging domains) + PUBLIC_BACKEND_URL_CLIENT: https://chat-api.staging.manacore.ai + PUBLIC_MANA_CORE_AUTH_URL_CLIENT: https://auth.staging.manacore.ai ports: - "3000:3000" healthcheck: @@ -175,14 +175,14 @@ services: PORT: 5173 # Auth URLs PUBLIC_MANA_CORE_AUTH_URL: http://mana-core-auth:3001 - PUBLIC_MANA_CORE_AUTH_URL_CLIENT: http://46.224.108.214:3001 + PUBLIC_MANA_CORE_AUTH_URL_CLIENT: https://auth.staging.manacore.ai # Backend URLs for dashboard widgets PUBLIC_TODO_API_URL: http://todo-backend:3018 - PUBLIC_TODO_API_URL_CLIENT: http://46.224.108.214:3018 + PUBLIC_TODO_API_URL_CLIENT: https://todo-api.staging.manacore.ai PUBLIC_CALENDAR_API_URL: http://calendar-backend:3016 - PUBLIC_CALENDAR_API_URL_CLIENT: http://46.224.108.214:3016 + PUBLIC_CALENDAR_API_URL_CLIENT: https://calendar-api.staging.manacore.ai PUBLIC_CLOCK_API_URL: http://clock-backend:3017 - PUBLIC_CLOCK_API_URL_CLIENT: http://46.224.108.214:3017 + PUBLIC_CLOCK_API_URL_CLIENT: https://clock-api.staging.manacore.ai ports: - "5173:5173" healthcheck: @@ -220,7 +220,7 @@ services: DB_PORT: 5432 DB_USER: ${POSTGRES_USER:-postgres} MANA_CORE_AUTH_URL: http://mana-core-auth:3001 - CORS_ORIGINS: http://46.224.108.214:5188,http://46.224.108.214:5173,http://localhost:5188,http://localhost:5173 + CORS_ORIGINS: https://todo.staging.manacore.ai,https://staging.manacore.ai,http://localhost:5188,http://localhost:5173 ports: - "3018:3018" healthcheck: @@ -249,8 +249,8 @@ services: PORT: 5188 PUBLIC_BACKEND_URL: http://todo-backend:3018 PUBLIC_MANA_CORE_AUTH_URL: http://mana-core-auth:3001 - PUBLIC_BACKEND_URL_CLIENT: http://46.224.108.214:3018 - PUBLIC_MANA_CORE_AUTH_URL_CLIENT: http://46.224.108.214:3001 + PUBLIC_BACKEND_URL_CLIENT: https://todo-api.staging.manacore.ai + PUBLIC_MANA_CORE_AUTH_URL_CLIENT: https://auth.staging.manacore.ai ports: - "5188:5188" healthcheck: @@ -288,7 +288,7 @@ services: DB_PORT: 5432 DB_USER: ${POSTGRES_USER:-postgres} MANA_CORE_AUTH_URL: http://mana-core-auth:3001 - CORS_ORIGINS: http://46.224.108.214:5186,http://46.224.108.214:5173,http://localhost:5186,http://localhost:5173 + CORS_ORIGINS: https://calendar.staging.manacore.ai,https://staging.manacore.ai,http://localhost:5186,http://localhost:5173 ports: - "3016:3016" healthcheck: @@ -317,8 +317,8 @@ services: PORT: 5186 PUBLIC_BACKEND_URL: http://calendar-backend:3016 PUBLIC_MANA_CORE_AUTH_URL: http://mana-core-auth:3001 - PUBLIC_BACKEND_URL_CLIENT: http://46.224.108.214:3016 - PUBLIC_MANA_CORE_AUTH_URL_CLIENT: http://46.224.108.214:3001 + PUBLIC_BACKEND_URL_CLIENT: https://calendar-api.staging.manacore.ai + PUBLIC_MANA_CORE_AUTH_URL_CLIENT: https://auth.staging.manacore.ai ports: - "5186:5186" healthcheck: @@ -356,7 +356,7 @@ services: DB_PORT: 5432 DB_USER: ${POSTGRES_USER:-postgres} MANA_CORE_AUTH_URL: http://mana-core-auth:3001 - CORS_ORIGINS: http://46.224.108.214:5187,http://46.224.108.214:5173,http://localhost:5187,http://localhost:5173 + CORS_ORIGINS: https://clock.staging.manacore.ai,https://staging.manacore.ai,http://localhost:5187,http://localhost:5173 ports: - "3017:3017" healthcheck: @@ -385,8 +385,8 @@ services: PORT: 5187 PUBLIC_BACKEND_URL: http://clock-backend:3017 PUBLIC_MANA_CORE_AUTH_URL: http://mana-core-auth:3001 - PUBLIC_BACKEND_URL_CLIENT: http://46.224.108.214:3017 - PUBLIC_MANA_CORE_AUTH_URL_CLIENT: http://46.224.108.214:3001 + PUBLIC_BACKEND_URL_CLIENT: https://clock-api.staging.manacore.ai + PUBLIC_MANA_CORE_AUTH_URL_CLIENT: https://auth.staging.manacore.ai ports: - "5187:5187" healthcheck: diff --git a/docker/caddy/Caddyfile.staging b/docker/caddy/Caddyfile.staging new file mode 100644 index 000000000..78d90ed12 --- /dev/null +++ b/docker/caddy/Caddyfile.staging @@ -0,0 +1,45 @@ +# ManaCore Staging Reverse Proxy +# Deploy to: ~/Caddyfile on staging server (46.224.108.214) +# Reload with: docker exec caddy caddy reload --config /etc/caddy/Caddyfile + +# Auth service +auth.staging.manacore.ai { + reverse_proxy localhost:3001 +} + +# Chat +chat.staging.manacore.ai { + reverse_proxy localhost:3000 +} +chat-api.staging.manacore.ai { + reverse_proxy localhost:3002 +} + +# ManaCore main +staging.manacore.ai { + reverse_proxy localhost:5173 +} + +# Calendar +calendar.staging.manacore.ai { + reverse_proxy localhost:5186 +} +calendar-api.staging.manacore.ai { + reverse_proxy localhost:3016 +} + +# Clock +clock.staging.manacore.ai { + reverse_proxy localhost:5187 +} +clock-api.staging.manacore.ai { + reverse_proxy localhost:3017 +} + +# Todo +todo.staging.manacore.ai { + reverse_proxy localhost:5188 +} +todo-api.staging.manacore.ai { + reverse_proxy localhost:3018 +} diff --git a/docs/GIT_WORKFLOW.md b/docs/GIT_WORKFLOW.md new file mode 100644 index 000000000..33780e35b --- /dev/null +++ b/docs/GIT_WORKFLOW.md @@ -0,0 +1,293 @@ +# Git Workflow Guide + +Dokumentation des Git-Workflows für das ManaCore Monorepo. + +## Branch-Struktur + +| Branch | Zweck | +| ------------------------------ | ---------------------------------------- | +| `main` | Produktion - stabile Releases | +| `dev` | Entwicklung - Integration aller Features | +| `till-dev`, `{name}-dev` | Persönliche Entwicklungs-Branches | + +## Workflow-Übersicht + +``` +main (Produktion) + ↑ + │ PR (nach Testing) + │ +dev (Integration) + ↑ + │ PR (einzelne Commits behalten) + │ +till-dev (Feature-Entwicklung) +``` + +## Täglicher Entwicklungs-Workflow + +### 1. Auf persönlichem Branch arbeiten + +```bash +# Sicherstellen, dass du auf deinem Branch bist +git checkout till-dev + +# Änderungen committen - kleine, aussagekräftige Commits +git add . +git commit -m "feat(app): add feature X" +git commit -m "fix(app): fix bug Y" +git commit -m "refactor(app): cleanup Z" +``` + +### 2. Regelmäßig mit dev synchronisieren + +Halte deinen Branch aktuell, um große Konflikte zu vermeiden: + +```bash +# Neuesten Stand von dev holen +git fetch origin dev + +# Rebase durchführen +git rebase origin/dev + +# Bei Konflikten: Jeden Commit einzeln lösen +# 1. Konflikte in den angezeigten Dateien lösen +# 2. git add +# 3. git rebase --continue +# 4. Wiederholen bis alle Commits durchlaufen sind + +# Nach erfolgreichem Rebase pushen +git push --force-with-lease +``` + +### 3. Pull Request erstellen + +```bash +gh pr create --base dev --head till-dev \ + --title "feat: summary of changes" \ + --body "## Summary +- Feature 1 +- Feature 2 + +## Test plan +- [ ] Test case 1 +- [ ] Test case 2" +``` + +## Konflikt-Lösung beim Rebase + +### Allgemeiner Ablauf + +Bei einem Rebase werden Commits einzeln auf den neuen Base-Branch angewendet. Konflikte müssen für jeden Commit separat gelöst werden - das gibt mehr Kontext und macht die Lösung einfacher. + +```bash +# Rebase starten +git rebase origin/dev + +# Bei Konflikt: Status prüfen +git status # Zeigt konfliktbehaftete Dateien + +# Konflikte lösen, dann: +git add +git rebase --continue + +# Nächster Commit wird angewendet... +# Wiederholen bis fertig +``` + +### Einfache Konflikte + +```bash +# Unsere Version behalten (die aus dem Feature-Branch) +git checkout --ours path/to/file + +# Ihre Version behalten (die aus dev) +git checkout --theirs path/to/file + +# Nach der Wahl: +git add path/to/file +git rebase --continue +``` + +### pnpm-lock.yaml Konflikte + +Diese Datei sollte nie manuell gemerged werden: + +```bash +# Version aus dev nehmen und neu installieren +git checkout --theirs pnpm-lock.yaml +pnpm install --frozen-lockfile=false +git add pnpm-lock.yaml +git rebase --continue +``` + +### Gelöschte Dateien + +```bash +# Datei wurde in dev gelöscht, aber in deinem Branch modifiziert +git rm path/to/deleted/file +git rebase --continue +``` + +### Rebase abbrechen + +```bash +git rebase --abort # Zurück zum Zustand vor dem Rebase +``` + +## Best Practices + +### Commit Messages + +Verwende [Conventional Commits](https://www.conventionalcommits.org/): + +| Prefix | Verwendung | +| ---------- | ----------------------------- | +| `feat` | Neue Features | +| `fix` | Bug Fixes | +| `docs` | Dokumentation | +| `style` | Formatting (kein Code-Change) | +| `refactor` | Code-Refactoring | +| `test` | Tests hinzufügen/ändern | +| `chore` | Build, CI, Dependencies | + +### Scope (optional) + +``` +feat(contacts): add duplicate detection +fix(calendar): fix event drag and drop +docs(readme): update installation guide +``` + +### Kleine, fokussierte Commits + +- Ein Commit = eine logische Änderung +- Aussagekräftige Commit Messages +- Leichter zu reviewen und bei Problemen zu debuggen +- Einzelne Commits können bei Bedarf reverted werden + +### Branch-Hygiene + +```bash +# Lokale Branches aufräumen +git branch -d feature-branch # Gelöschte Branches entfernen + +# Remote-Tracking-Branches aufräumen +git fetch --prune +``` + +## Beispiel: Kompletter Workflow + +```bash +# 1. Feature entwickeln +git checkout till-dev +git commit -m "feat(network): add D3 force simulation" +git commit -m "feat(network): add zoom and pan controls" +git commit -m "fix(network): fix node positioning on load" +git commit -m "docs(network): add keyboard shortcuts help" + +# 2. Vor PR: Mit dev synchronisieren +git fetch origin dev +git rebase origin/dev +# Konflikte einzeln lösen falls nötig... + +# 3. Pushen +git push --force-with-lease + +# 4. PR erstellen +gh pr create --base dev --head till-dev --title "feat(network): add network graph visualization" + +# 5. Nach Merge: Branch aktualisieren +git fetch origin dev +git checkout till-dev +git reset --hard origin/dev +``` + +## Critical Configuration Files + +### Protected Files (CODEOWNERS) + +The following files are protected via `.github/CODEOWNERS` and require team lead review: + +| File | Reason | +|------|--------| +| `docker-compose.staging.yml` | Staging deployment config | +| `docker-compose.production.yml` | Production deployment config | +| `docker/caddy/Caddyfile.*` | Reverse proxy configuration | +| `.github/workflows/cd-*.yml` | Deployment pipelines | + +### Configuration Conflict Prevention + +**Problem:** When rebasing a long-lived branch, configuration files can accidentally overwrite critical settings (e.g., HTTPS URLs reverted to HTTP). + +**Solution:** Always review configuration files carefully during rebase conflicts: + +```bash +# During rebase, if docker-compose.staging.yml has conflicts: +git diff HEAD -- docker-compose.staging.yml # See what changed + +# Key things to verify: +# 1. _CLIENT URLs use HTTPS staging domains (not HTTP IP addresses) +# 2. CORS_ORIGINS include all HTTPS staging domains +# 3. Environment variables haven't regressed +``` + +### Staging URL Rules + +**NEVER** use HTTP IP addresses for `_CLIENT` variables: + +```yaml +# WRONG - HTTP IP address +PUBLIC_MANA_CORE_AUTH_URL_CLIENT: http://46.224.108.214:3001 + +# CORRECT - HTTPS staging domain +PUBLIC_MANA_CORE_AUTH_URL_CLIENT: https://auth.staging.manacore.ai +``` + +**CI Check:** The `staging-config-check.yml` workflow validates this on every PR that touches `docker-compose.staging.yml`. + +### Rebase Checklist for Config Files + +Before completing a rebase that touched configuration files: + +- [ ] `_CLIENT` URLs use `https://*.staging.manacore.ai` format +- [ ] `CORS_ORIGINS` include all HTTPS staging domains +- [ ] No HTTP IP addresses in client-facing URLs +- [ ] Caddy config matches docker-compose port mappings + +## Troubleshooting + +### "fatal: no rebase in progress" + +Der Rebase wurde bereits abgeschlossen oder abgebrochen. Prüfe mit `git status`. + +### Force-Push wird abgelehnt + +```bash +# --force-with-lease ist sicherer als --force +# Falls es trotzdem fehlschlägt, prüfe ob jemand anderes gepusht hat +git fetch origin +git log origin/till-dev..till-dev +``` + +### Commits verschwunden nach Reset + +```bash +# Git Reflog zeigt alle Aktionen +git reflog + +# Zu einem früheren Zustand zurückkehren +git reset --hard HEAD@{2} +``` + +### Viele Konflikte beim Rebase + +Wenn zu viele Konflikte auftreten: + +1. `git rebase --abort` - Rebase abbrechen +2. Regelmäßiger rebasen (täglich/wöchentlich) +3. Bei sehr alten Branches: Mit dem Team absprechen + +--- + +*Zuletzt aktualisiert: 10.12.2025* diff --git a/docs/MANADECK_POSTGRES_MIGRATION.md b/docs/MANADECK_POSTGRES_MIGRATION.md index 5ffd9d8eb..c6275170b 100644 --- a/docs/MANADECK_POSTGRES_MIGRATION.md +++ b/docs/MANADECK_POSTGRES_MIGRATION.md @@ -246,7 +246,7 @@ import { pgTable, uuid, varchar, text, boolean, timestamp, jsonb } from 'drizzle export const decks = pgTable('decks', { id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id').notNull(), + userId: text('user_id').notNull(), // text, not uuid - Better Auth uses non-UUID IDs title: varchar('title', { length: 255 }).notNull(), description: text('description'), coverImageUrl: text('cover_image_url'), diff --git a/docs/STAGING_SETUP.md b/docs/STAGING_SETUP.md new file mode 100644 index 000000000..0ae580b97 --- /dev/null +++ b/docs/STAGING_SETUP.md @@ -0,0 +1,441 @@ +# Staging Environment Setup Guide + +This document describes the complete staging environment setup for ManaCore apps on Hetzner VPS with HTTPS via Caddy reverse proxy. + +## Overview + +| Component | Details | +|-----------|---------| +| **Server** | Hetzner VPS (46.224.108.214) | +| **Domain** | manacore.ai (Namecheap) | +| **Reverse Proxy** | Caddy (auto-SSL via Let's Encrypt) | +| **Container Runtime** | Docker Compose | +| **SSH Access** | `ssh -i ~/.ssh/hetzner_deploy_key deploy@46.224.108.214` | + +## Architecture + +``` + ┌─────────────────────────────────────────────┐ + │ Hetzner VPS (46.224.108.214) │ + │ │ + Internet │ ┌─────────────────────────────────────┐ │ + │ │ │ Caddy (ports 80/443) │ │ + │ │ │ Auto-SSL via Let's Encrypt │ │ + ▼ │ └──────────────┬──────────────────────┘ │ +┌──────────────┐ │ │ │ +│ Namecheap │ │ ▼ │ +│ DNS Records │────────────────────│ ┌─────────────────────────────────────┐ │ +│ │ │ │ Docker Compose Services │ │ +│ *.staging │ │ │ │ │ +│ A → IP │ │ │ mana-core-auth:3001 │ │ +└──────────────┘ │ │ chat-web:3000 / chat-backend:3002 │ │ + │ │ clock-web:5187 / clock-backend:3017│ │ + │ │ calendar-web:5186 / calendar-api:3016│ │ + │ │ todo-web:5188 / todo-backend:3018 │ │ + │ │ manacore-web:5173 │ │ + │ │ postgres:5432 / redis:6379 │ │ + │ └─────────────────────────────────────┘ │ + └─────────────────────────────────────────────┘ +``` + +## Domain Mapping + +### DNS Configuration (Namecheap) + +| Type | Host | Value | TTL | +|------|------|-------|-----| +| A | `staging` | 46.224.108.214 | Automatic | +| A | `*.staging` | 46.224.108.214 | Automatic | + +The wildcard record `*.staging` enables all subdomains like `auth.staging.manacore.ai`, `clock.staging.manacore.ai`, etc. + +### Staging URLs + +| Service | URL | Internal Port | +|---------|-----|---------------| +| **Auth** | https://auth.staging.manacore.ai | 3001 | +| **ManaCore Web** | https://staging.manacore.ai | 5173 | +| **Chat Web** | https://chat.staging.manacore.ai | 3000 | +| **Chat API** | https://chat-api.staging.manacore.ai | 3002 | +| **Clock Web** | https://clock.staging.manacore.ai | 5187 | +| **Clock API** | https://clock-api.staging.manacore.ai | 3017 | +| **Calendar Web** | https://calendar.staging.manacore.ai | 5186 | +| **Calendar API** | https://calendar-api.staging.manacore.ai | 3016 | +| **Todo Web** | https://todo.staging.manacore.ai | 5188 | +| **Todo API** | https://todo-api.staging.manacore.ai | 3018 | + +## Caddy Reverse Proxy + +### Installation (One-time setup) + +```bash +# SSH into server +ssh -i ~/.ssh/hetzner_deploy_key deploy@46.224.108.214 + +# Create Caddy data directory +mkdir -p ~/caddy_data ~/caddy_config + +# Run Caddy container +docker run -d \ + --name caddy \ + --network host \ + --restart unless-stopped \ + -v ~/Caddyfile:/etc/caddy/Caddyfile \ + -v ~/caddy_data:/data \ + -v ~/caddy_config:/config \ + caddy:2-alpine +``` + +### Configuration + +The Caddyfile is stored at: +- **Server**: `~/Caddyfile` +- **Repo**: `docker/caddy/Caddyfile.staging` + +```caddyfile +# ManaCore Staging Reverse Proxy + +auth.staging.manacore.ai { + reverse_proxy localhost:3001 +} + +chat.staging.manacore.ai { + reverse_proxy localhost:3000 +} + +chat-api.staging.manacore.ai { + reverse_proxy localhost:3002 +} + +staging.manacore.ai { + reverse_proxy localhost:5173 +} + +calendar.staging.manacore.ai { + reverse_proxy localhost:5186 +} + +calendar-api.staging.manacore.ai { + reverse_proxy localhost:3016 +} + +clock.staging.manacore.ai { + reverse_proxy localhost:5187 +} + +clock-api.staging.manacore.ai { + reverse_proxy localhost:3017 +} + +todo.staging.manacore.ai { + reverse_proxy localhost:5188 +} + +todo-api.staging.manacore.ai { + reverse_proxy localhost:3018 +} +``` + +### Updating Caddy Configuration + +```bash +# Copy updated config to server +scp -i ~/.ssh/hetzner_deploy_key docker/caddy/Caddyfile.staging deploy@46.224.108.214:~/Caddyfile + +# Reload Caddy (no downtime) +ssh -i ~/.ssh/hetzner_deploy_key deploy@46.224.108.214 "docker exec caddy caddy reload --config /etc/caddy/Caddyfile" +``` + +### Caddy Management Commands + +```bash +# View logs +docker logs caddy -f + +# Restart Caddy +docker restart caddy + +# Check Caddy status +docker exec caddy caddy validate --config /etc/caddy/Caddyfile +``` + +## SvelteKit Runtime Environment Variables + +### The Problem + +SvelteKit's `$env/static/public` variables are replaced at **build time**. When Docker images are built in CI, the environment variables are baked into the JavaScript bundles. This means containers cannot use different URLs for different environments. + +### The Solution + +Use `$env/dynamic/private` in `hooks.server.ts` to read environment variables at **runtime**, then inject them into the HTML for client-side access. + +### Implementation + +Each SvelteKit web app has a `hooks.server.ts` that: +1. Reads `_CLIENT` environment variables at runtime +2. Injects them into the HTML via ``; + return html.replace('', `${envScript}`); + }, + }); +}; +``` + +### Environment Variable Pattern + +Each web app container receives two sets of URLs: + +| Variable | Purpose | Example | +|----------|---------|---------| +| `PUBLIC_BACKEND_URL` | Server-side (Docker network) | `http://clock-backend:3017` | +| `PUBLIC_BACKEND_URL_CLIENT` | Client-side (browser) | `https://clock-api.staging.manacore.ai` | +| `PUBLIC_MANA_CORE_AUTH_URL` | Server-side auth | `http://mana-core-auth:3001` | +| `PUBLIC_MANA_CORE_AUTH_URL_CLIENT` | Client-side auth | `https://auth.staging.manacore.ai` | + +## Docker Compose Configuration + +### File Locations + +| File | Purpose | +|------|---------| +| `docker-compose.staging.yml` | Staging configuration (repo) | +| `~/manacore-staging/docker-compose.yml` | Server deployment | + +### Key Configuration Sections + +**Web App Environment Variables:** +```yaml +clock-web: + environment: + NODE_ENV: staging + PORT: 5187 + # Server-side URLs (Docker internal network) + PUBLIC_BACKEND_URL: http://clock-backend:3017 + PUBLIC_MANA_CORE_AUTH_URL: http://mana-core-auth:3001 + # Client-side URLs (browser access via HTTPS) + PUBLIC_BACKEND_URL_CLIENT: https://clock-api.staging.manacore.ai + PUBLIC_MANA_CORE_AUTH_URL_CLIENT: https://auth.staging.manacore.ai +``` + +**Backend CORS Configuration:** +```yaml +clock-backend: + environment: + CORS_ORIGINS: https://clock.staging.manacore.ai,https://staging.manacore.ai,http://localhost:5187 +``` + +**Auth Service CORS:** +```yaml +mana-core-auth: + environment: + CORS_ORIGINS: https://chat.staging.manacore.ai,https://staging.manacore.ai,https://calendar.staging.manacore.ai,https://clock.staging.manacore.ai,https://todo.staging.manacore.ai,http://localhost:3000,http://localhost:5173 +``` + +### Syncing Configuration to Server + +```bash +# Copy docker-compose to server +scp -i ~/.ssh/hetzner_deploy_key docker-compose.staging.yml deploy@46.224.108.214:~/manacore-staging/docker-compose.yml + +# Recreate containers with new config +ssh -i ~/.ssh/hetzner_deploy_key deploy@46.224.108.214 "cd ~/manacore-staging && docker compose up -d --force-recreate" +``` + +## Deployment Workflow + +### CI/CD Pipeline + +The GitHub Actions workflow (`.github/workflows/cd-staging.yml`): +1. Builds Docker images on push to `dev` branch +2. Pushes images to GitHub Container Registry (ghcr.io) +3. SSHs into staging server +4. Pulls latest images +5. Restarts containers + +### Manual Deployment + +```bash +# 1. Build and push images (from local) +docker build -t ghcr.io/memo-2023/clock-web:latest -f apps/clock/apps/web/Dockerfile . +docker push ghcr.io/memo-2023/clock-web:latest + +# 2. SSH into server +ssh -i ~/.ssh/hetzner_deploy_key deploy@46.224.108.214 + +# 3. Pull and restart +cd ~/manacore-staging +docker compose pull +docker compose up -d --force-recreate +``` + +### Updating Environment Variables + +1. Edit `docker-compose.staging.yml` locally +2. Copy to server: `scp -i ~/.ssh/hetzner_deploy_key docker-compose.staging.yml deploy@46.224.108.214:~/manacore-staging/docker-compose.yml` +3. Recreate affected containers: `docker compose up -d --force-recreate ` + +## Troubleshooting + +### Mixed Content Errors + +**Symptom:** Browser console shows "Mixed Content: The page was loaded over HTTPS, but requested an insecure resource" + +**Cause:** Client-side JavaScript is calling HTTP URLs instead of HTTPS + +**Solution:** +1. Check `_CLIENT` environment variables in docker-compose.yml +2. Ensure they use `https://` staging domains +3. Recreate web containers: `docker compose up -d --force-recreate ` + +### CORS Errors + +**Symptom:** Browser console shows "Access-Control-Allow-Origin" errors + +**Cause:** Backend CORS_ORIGINS doesn't include the HTTPS staging domain + +**Solution:** +1. Add the HTTPS domain to `CORS_ORIGINS` in docker-compose.yml +2. Recreate backend containers + +### Caddy SSL Certificate Issues + +**Symptom:** Browser shows SSL certificate warning + +**Solution:** +```bash +# Check Caddy logs +docker logs caddy + +# Force certificate renewal +docker exec caddy caddy reload --config /etc/caddy/Caddyfile +``` + +### Container Health Check Failures + +**Symptom:** Container shows "unhealthy" status + +**Solution:** +```bash +# Check container logs +docker logs + +# Check health status +docker inspect | grep -A 20 Health +``` + +## Adding a New App to Staging + +### 1. Update DNS (if needed) + +If using a new subdomain pattern, update Namecheap DNS. The `*.staging` wildcard should cover most cases. + +### 2. Update Caddyfile + +Add entries for web and API: +```caddyfile +newapp.staging.manacore.ai { + reverse_proxy localhost: +} + +newapp-api.staging.manacore.ai { + reverse_proxy localhost: +} +``` + +### 3. Update docker-compose.staging.yml + +Add the new services with proper environment variables: +```yaml +newapp-web: + image: ghcr.io/memo-2023/newapp-web:latest + environment: + PUBLIC_BACKEND_URL: http://newapp-backend: + PUBLIC_MANA_CORE_AUTH_URL: http://mana-core-auth:3001 + PUBLIC_BACKEND_URL_CLIENT: https://newapp-api.staging.manacore.ai + PUBLIC_MANA_CORE_AUTH_URL_CLIENT: https://auth.staging.manacore.ai + ports: + - ":" +``` + +### 4. Implement hooks.server.ts + +Copy the runtime env var pattern from an existing app: +```typescript +import type { Handle } from '@sveltejs/kit'; +import { env } from '$env/dynamic/private'; + +export const handle: Handle = async ({ event, resolve }) => { + const authUrlClient = env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || ''; + const backendUrlClient = env.PUBLIC_BACKEND_URL_CLIENT || ''; + + return resolve(event, { + transformPageChunk: ({ html }) => { + const envScript = ``; + return html.replace('', `${envScript}`); + }, + }); +}; +``` + +### 5. Deploy + +1. Sync Caddyfile: `scp ... Caddyfile.staging deploy@server:~/Caddyfile` +2. Reload Caddy: `docker exec caddy caddy reload --config /etc/caddy/Caddyfile` +3. Sync docker-compose: `scp ... docker-compose.staging.yml deploy@server:~/manacore-staging/docker-compose.yml` +4. Deploy containers: `docker compose up -d` + +## Quick Reference Commands + +```bash +# SSH into server +ssh -i ~/.ssh/hetzner_deploy_key deploy@46.224.108.214 + +# View all containers +docker ps + +# View container logs +docker logs -f + +# Restart a container +docker restart + +# Recreate containers with new config +cd ~/manacore-staging && docker compose up -d --force-recreate + +# Check Caddy SSL certificates +docker exec caddy caddy validate --config /etc/caddy/Caddyfile + +# Test HTTPS endpoint +curl -s https://auth.staging.manacore.ai/api/v1/health + +# Check container env vars +docker exec printenv | grep -E 'CLIENT|CORS' +``` + +## Related Documentation + +- [Local Development Guide](./LOCAL_DEVELOPMENT.md) +- [CI/CD Deployment Guide](./DEPLOYMENT.md) +- [Environment Variables](./ENVIRONMENT_VARIABLES.md) diff --git a/docs/central-services/COMMAND-BAR.md b/docs/central-services/COMMAND-BAR.md new file mode 100644 index 000000000..913ca4c74 --- /dev/null +++ b/docs/central-services/COMMAND-BAR.md @@ -0,0 +1,383 @@ +# Central Command Bar + +Die zentrale Command Bar bietet eine einheitliche Schnellsuche und Navigation über alle Manacore-Apps hinweg. Sie wird mit `Cmd/Ctrl+K` aktiviert und bietet Suche, Quick Actions und Tastatur-Navigation. + +## Architektur + +``` +┌─────────────────────────────────────────────────────────────┐ +│ @manacore/shared-ui │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ CommandBar.svelte │ │ +│ │ - Suche mit Debounce (150ms) │ │ +│ │ - Quick Actions │ │ +│ │ - Tastatur-Navigation │ │ +│ │ - Ergebnis-Anzeige mit Avataren │ │ +│ └─────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ + ┌────────────────────┼────────────────────┐ + │ │ │ + ┌────▼────┐ ┌─────▼────┐ ┌─────▼────┐ + │ Todo │ │ Calendar │ │ Contacts │ + │ │ │ │ │ │ + │ Sucht │ │ Sucht │ │ Sucht │ + │ Tasks │ │ Events │ │ Kontakte │ + └─────────┘ └──────────┘ └──────────┘ +``` + +## Package + +| Package | Beschreibung | +|---------|--------------| +| `@manacore/shared-ui` | CommandBar Svelte-Komponente + TypeScript-Typen | + +## Keyboard Shortcut + +| Shortcut | Aktion | +|----------|--------| +| `Cmd/Ctrl+K` | Command Bar öffnen | +| `↑↓` | Navigation durch Ergebnisse | +| `Enter` | Auswahl bestätigen | +| `Escape` | Schließen | + +## TypeScript Interfaces + +### CommandBarItem + +Ein Suchergebnis: + +```typescript +interface CommandBarItem { + id: string; // Eindeutige ID + title: string; // Haupttext (z.B. Name, Titel) + subtitle?: string; // Untertitel (z.B. Datum, E-Mail) + icon?: string; // Icon-Name + imageUrl?: string; // Avatar/Bild URL + isFavorite?: boolean; // Favorit-Markierung (zeigt Herz-Icon) +} +``` + +### QuickAction + +Eine Schnellaktion (ohne Suche): + +```typescript +interface QuickAction { + id: string; // Eindeutige ID + label: string; // Anzeigetext + icon: string; // Icon-Name + href?: string; // Link-Ziel (optional) + shortcut?: string; // Tastenkürzel-Anzeige + onclick?: () => void; // Click-Handler (alternativ zu href) +} +``` + +### Unterstützte Icons + +Die CommandBar unterstützt folgende eingebaute Icons: + +| Icon-Name | Beschreibung | +|-----------|--------------| +| `plus` | Plus-Zeichen (Neu erstellen) | +| `heart` | Herz (Favoriten) | +| `tag` | Tag/Label | +| `upload` | Upload (Import) | +| `calendar` | Kalender | +| `clock` | Uhr | +| `check` | Häkchen | +| `settings` | Zahnrad (Einstellungen) | +| `list` | Liste | + +## Komponenten-Props + +```typescript +interface Props { + open: boolean; // Sichtbarkeit + onClose: () => void; // Schließen-Handler + onSearch: (query: string) => Promise; // Such-Funktion + onSelect: (item: CommandBarItem) => void; // Auswahl-Handler + quickActions?: QuickAction[]; // Schnellaktionen + placeholder?: string; // Suchfeld-Placeholder + emptyText?: string; // Text bei leeren Ergebnissen + searchingText?: string; // Text während Suche +} +``` + +## Nutzung + +### Basis-Beispiel + +```svelte + + + + + (commandBarOpen = false)} + onSearch={handleSearch} + onSelect={handleSelect} + {quickActions} + placeholder="Suchen..." + emptyText="Keine Ergebnisse" + searchingText="Suche..." +/> +``` + +## App-spezifische Implementierungen + +### Todo App + +```typescript +// Quick Actions +const quickActions: QuickAction[] = [ + { id: 'new', label: 'Neue Aufgabe erstellen', icon: 'plus', href: '/task/new', shortcut: 'N' }, + { id: 'kanban', label: 'Kanban-Board', icon: 'list', href: '/kanban' }, + { id: 'stats', label: 'Statistiken', icon: 'chart', href: '/statistics' }, + { id: 'settings', label: 'Einstellungen', icon: 'settings', href: '/settings' }, +]; + +// Suche: Tasks durchsuchen +async function handleSearch(query: string): Promise { + const tasks = await getTasks({ search: query }); + return tasks.slice(0, 10).map((task) => ({ + id: task.id, + title: task.title, + subtitle: task.isCompleted + ? '✓ Erledigt' + : task.dueDate + ? new Date(task.dueDate).toLocaleDateString('de-DE') + : 'Kein Datum', + })); +} + +// Auswahl: Zu Task navigieren +function handleSelect(item: CommandBarItem) { + goto(`/task/${item.id}`); +} +``` + +### Calendar App + +```typescript +// Quick Actions +const quickActions: QuickAction[] = [ + { id: 'new', label: 'Neuen Termin erstellen', icon: 'plus', href: '/event/new', shortcut: 'N' }, + { id: 'today', label: 'Zu Heute springen', icon: 'calendar', onclick: () => viewStore.goToToday() }, + { id: 'agenda', label: 'Agenda anzeigen', icon: 'list', href: '/agenda' }, + { id: 'settings', label: 'Einstellungen', icon: 'settings', href: '/settings' }, +]; + +// Suche: Events durchsuchen +async function handleSearch(query: string): Promise { + const result = await searchEvents(query); + if (result.error || !result.data) return []; + + return result.data.slice(0, 10).map((event) => ({ + id: event.id, + title: event.title, + subtitle: format(new Date(event.startTime), 'dd. MMM yyyy, HH:mm', { locale: de }), + })); +} + +// Auswahl: Zu Event navigieren +function handleSelect(item: CommandBarItem) { + goto(`/event/${item.id}`); +} +``` + +### Contacts App + +```typescript +// Quick Actions +const quickActions: QuickAction[] = [ + { id: 'new', label: 'Neuen Kontakt erstellen', icon: 'plus', href: '/contacts/new', shortcut: 'N' }, + { id: 'favorites', label: 'Favoriten anzeigen', icon: 'heart', href: '/favorites' }, + { id: 'tags', label: 'Tags verwalten', icon: 'tag', href: '/tags' }, + { id: 'import', label: 'Kontakte importieren', icon: 'upload', href: '/data?tab=import' }, +]; + +// Suche: Kontakte durchsuchen +async function handleSearch(query: string): Promise { + const response = await contactsApi.list({ search: query, limit: 10 }); + return (response.contacts || []).map((contact) => ({ + id: contact.id, + title: contact.displayName || + [contact.firstName, contact.lastName].filter(Boolean).join(' ') || + contact.email || 'Unbekannt', + subtitle: contact.company || contact.email, + imageUrl: contact.photoUrl, + isFavorite: contact.isFavorite, + })); +} + +// Auswahl: Kontakt-Modal öffnen +function handleSelect(item: CommandBarItem) { + goto(`/contacts/${item.id}`); +} +``` + +## Funktionen + +### Suche + +- **Debounce:** 150ms Verzögerung für Performance +- **Loading-State:** Spinner während der Suche +- **Empty State:** Konfigurierbare Meldung bei keinen Ergebnissen +- **Limit:** Ergebnisse werden typischerweise auf 10 begrenzt + +### Quick Actions + +Wenn kein Suchtext eingegeben ist, werden Quick Actions angezeigt: + +- Navigation mit Pfeiltasten +- Ausführung mit Enter +- Keyboard-Shortcuts werden rechts angezeigt + +### Ergebnis-Anzeige + +- **Avatar:** Bild oder Initialen +- **Titel:** Haupttext +- **Untertitel:** Zusatzinfo (grau) +- **Favorit:** Herz-Icon wenn `isFavorite: true` +- **Hover:** Visuelles Feedback bei Maus-Over + +### Keyboard Navigation + +| Taste | Aktion | +|-------|--------| +| `↑` / `↓` | Durch Ergebnisse navigieren | +| `Enter` | Ausgewähltes Element öffnen | +| `Escape` | Command Bar schließen | + +## Styling + +Die Command Bar verwendet ein dunkles Theme mit CSS-Variablen: + +```css +.command-modal { + background: #1a1a1a; + border: 1px solid #333; + border-radius: 12px; + max-width: 560px; +} + +.command-result.selected { + background: #2a2a2a; +} + +.result-avatar { + background: #3b82f6; /* Primary Color */ +} + +.result-favorite { + color: #ef4444; /* Rot für Herz */ +} +``` + +### Animationen + +- **Fade In:** Backdrop erscheint mit 0.15s +- **Slide In:** Modal gleitet von oben mit 0.2s +- **Loading Spinner:** Rotation Animation + +## Integration in Layout + +Typischerweise wird die CommandBar im App-Layout integriert: + +```svelte + + + + + + + + +
+ {@render children()} +
+ + + (commandBarOpen = false)} + onSearch={handleSearch} + onSelect={handleSelect} + {quickActions} +/> +``` + +## Dateien + +### @manacore/shared-ui + +| Datei | Beschreibung | +|-------|--------------| +| `src/command-bar/CommandBar.svelte` | Hauptkomponente | +| `src/command-bar/index.ts` | Exports | +| `src/index.ts` | Package-Export | + +## Vorteile + +- **Einheitliche UX:** Gleiche Interaktion in allen Apps +- **Schnelle Navigation:** Sofortiger Zugriff auf häufige Aktionen +- **Tastatur-fokussiert:** Optimiert für Power-User +- **Flexibel:** App-spezifische Such-Logik und Quick Actions +- **Responsive:** Funktioniert auf Desktop und Mobile + +## Best Practices + +1. **Such-Performance:** Limit auf 10 Ergebnisse setzen +2. **Quick Actions:** Maximal 4-5 Aktionen für Übersichtlichkeit +3. **Sinnvolle Shortcuts:** Mnemonische Kürzel (N=Neu, S=Settings) +4. **Gute Subtitles:** Zusätzliche Info für eindeutige Identifikation +5. **Keyboard First:** Cmd+K sollte immer funktionieren, auch in Input-Feldern diff --git a/docs/central-services/HELP.md b/docs/central-services/HELP.md new file mode 100644 index 000000000..c7c188e15 --- /dev/null +++ b/docs/central-services/HELP.md @@ -0,0 +1,603 @@ +# Central Help System + +Das zentrale Help-System bietet eine einheitliche Hilfeseite für alle Manacore-Apps. Es unterstützt mehrsprachige Inhalte, Volltextsuche, und die Kombination von zentralen und app-spezifischen Inhalten. + +## Architektur + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Zentrale Help-Inhalte │ +│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │ +│ │ FAQ (allgemein)│ │ Features │ │ Changelog │ │ +│ │ - Account │ │ - Theming │ │ - v1.5.0 │ │ +│ │ - Billing │ │ - Tags │ │ - v1.4.0 │ │ +│ │ - Privacy │ │ - Sync │ │ ... │ │ +│ └───────────────┘ └───────────────┘ └───────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ + mergeContent() + │ + ┌─────────────────────┼─────────────────────┐ + │ │ │ + ┌────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐ + │ Todo │ │ Calendar │ │ Contacts │ + │ │ │ │ │ │ + │ + App- │ │ + App- │ │ + App- │ + │ spezif. │ │ spezif. │ │ spezif. │ + │ FAQ │ │ Shortcuts │ │ Features │ + └──────────┘ └───────────┘ └───────────┘ +``` + +## Packages + +| Package | Beschreibung | +|---------|--------------| +| `@manacore/shared-help-types` | TypeScript-Typen und Zod-Schemas | +| `@manacore/shared-help-content` | Content-Loader, Parser, Merger, Suche | +| `@manacore/shared-help-ui` | Svelte 5 UI-Komponenten | +| `@manacore/shared-help-mobile` | React Native Komponenten | + +## Content-Typen + +### FAQ (Frequently Asked Questions) + +```typescript +interface FAQItem { + id: string; + language: 'de' | 'en' | 'fr' | 'it' | 'es'; + question: string; + answer: string; + category: 'general' | 'account' | 'billing' | 'features' | 'technical' | 'privacy'; + featured?: boolean; + tags?: string[]; + relatedFaqs?: string[]; + order?: number; + appSpecific?: boolean; + apps?: string[]; +} +``` + +### Features + +```typescript +interface FeatureItem { + id: string; + language: SupportedLanguage; + title: string; + description: string; + content: string; + icon?: string; + category: 'getting-started' | 'core' | 'advanced' | 'integration'; + available?: boolean; + comingSoon?: boolean; + highlights?: string[]; + learnMoreUrl?: string; +} +``` + +### Keyboard Shortcuts + +```typescript +interface ShortcutsItem { + id: string; + language: SupportedLanguage; + category: 'navigation' | 'editing' | 'general' | 'app-specific'; + title?: string; + shortcuts: Array<{ + shortcut: string; // z.B. "Ctrl+S", "⌘+K" + action: string; + description?: string; + }>; +} +``` + +### Getting Started Guides + +```typescript +interface GettingStartedItem { + id: string; + language: SupportedLanguage; + title: string; + description: string; + content: string; + difficulty: 'beginner' | 'intermediate' | 'advanced'; + estimatedTime?: string; + prerequisites?: string[]; + steps?: Array<{ + title: string; + content: string; + duration?: string; + }>; +} +``` + +### Changelog + +```typescript +interface ChangelogItem { + id: string; + language: SupportedLanguage; + version: string; + title: string; + releaseDate: Date; + type: 'major' | 'minor' | 'patch' | 'beta'; + summary?: string; + content: string; + highlighted?: boolean; + changes?: { + features?: Array<{ title: string; description?: string }>; + improvements?: Array<{ title: string; description?: string }>; + bugfixes?: Array<{ title: string; description?: string }>; + }; + platforms?: string[]; +} +``` + +### Contact Info + +```typescript +interface ContactInfo { + id: string; + language: SupportedLanguage; + title: string; + content: string; + supportEmail?: string; + supportUrl?: string; + discordUrl?: string; + twitterUrl?: string; + documentationUrl?: string; + responseTime?: string; +} +``` + +## Unterstützte Sprachen + +```typescript +type SupportedLanguage = 'en' | 'de' | 'fr' | 'it' | 'es'; +``` + +## Content-Struktur + +### HelpContent Objekt + +```typescript +interface HelpContent { + faq: FAQItem[]; + features: FeatureItem[]; + shortcuts: ShortcutsItem[]; + gettingStarted: GettingStartedItem[]; + changelog: ChangelogItem[]; + contact: ContactInfo | null; +} +``` + +## Content Laden und Mergen + +### Content Merger + +Der Merger kombiniert zentrale und app-spezifische Inhalte: + +```typescript +import { mergeContent, createEmptyContent } from '@manacore/shared-help-content'; + +// Zentrale Inhalte (für alle Apps) +const centralContent: HelpContent = { + faq: [ + { id: 'account-login', question: 'Wie melde ich mich an?', ... }, + { id: 'billing-cancel', question: 'Wie kündige ich?', ... }, + ], + features: [...], + // ... +}; + +// App-spezifische Inhalte +const appContent: Partial = { + faq: [ + { id: 'todo-recurring', question: 'Wie erstelle ich wiederkehrende Tasks?', ... }, + ], + shortcuts: [ + { category: 'app-specific', shortcuts: [...] }, + ], +}; + +// Zusammenführen +const content = mergeContent(centralContent, appContent, { + appId: 'todo', + locale: 'de', + overrideById: true, // App-Content ersetzt zentralen mit gleicher ID +}); +``` + +### Filterung + +Inhalte werden automatisch gefiltert nach: +- **Sprache:** Nur Inhalte der aktuellen Locale +- **App:** `appSpecific: true` Inhalte nur wenn `apps` die aktuelle App enthält +- **Reihenfolge:** Sortiert nach `order` Property + +## Suche + +### Such-Index erstellen + +```typescript +import { buildSearchIndex, search, createSearcher } from '@manacore/shared-help-content'; + +// Index erstellen +const index = buildSearchIndex(content, { + titleWeight: 2.0, + contentWeight: 1.0, + tagsWeight: 1.5, + threshold: 0.3, + minMatchCharLength: 2, +}); + +// Suchen +const results = search(index, 'wiederkehrend', { + limit: 10, + threshold: 0.4, + types: ['faq', 'guide'], // Optional: Nur bestimmte Typen +}); +``` + +### SearchResult + +```typescript +interface SearchResult { + id: string; + type: 'faq' | 'feature' | 'guide' | 'changelog'; + title: string; + excerpt: string; + score: number; + highlight?: string; + item: FAQItem | FeatureItem | GettingStartedItem | ChangelogItem; +} +``` + +## UI-Komponenten + +### HelpPage (Hauptkomponente) + +Vollständige Hilfeseite mit allen Sektionen: + +```svelte + + + goto('/')} + onSectionChange={(section) => console.log(section)} +/> +``` + +### Einzelne Sektionen + +```svelte + + + + + + + + + + + + + + + + + + + + + + navigateToResult(result)} +/> +``` + +## Übersetzungen + +### HelpPageTranslations Interface + +```typescript +interface HelpPageTranslations { + title: string; + subtitle?: string; + searchPlaceholder: string; + sections: { + faq: string; + features: string; + shortcuts: string; + gettingStarted: string; + changelog: string; + contact: string; + }; + search: { + noResults: string; + resultsCount: string; + searching: string; + }; + faq: { + noItems: string; + categories: { + general: string; + account: string; + billing: string; + features: string; + technical: string; + privacy: string; + }; + }; + features: { + noItems: string; + comingSoon: string; + learnMore: string; + }; + shortcuts: { + noItems: string; + }; + gettingStarted: { + noItems: string; + estimatedTime: string; + difficulty: { + beginner: string; + intermediate: string; + advanced: string; + }; + }; + changelog: { + noItems: string; + types: { + major: string; + minor: string; + patch: string; + beta: string; + }; + }; + contact: { + noInfo: string; + email: string; + responseTime: string; + }; + common: { + back: string; + showMore: string; + showLess: string; + }; +} +``` + +### Beispiel (Deutsch) + +```typescript +const translations: HelpPageTranslations = { + title: 'Hilfe', + subtitle: 'Finde Antworten und lerne die App kennen', + searchPlaceholder: 'Suche in der Hilfe...', + sections: { + faq: 'Häufige Fragen', + features: 'Funktionen', + shortcuts: 'Tastaturkürzel', + gettingStarted: 'Erste Schritte', + changelog: 'Neuigkeiten', + contact: 'Kontakt', + }, + search: { + noResults: 'Keine Ergebnisse gefunden', + resultsCount: '{count} Ergebnisse', + searching: 'Suche...', + }, + faq: { + noItems: 'Keine FAQ-Einträge vorhanden', + categories: { + general: 'Allgemein', + account: 'Konto', + billing: 'Abrechnung', + features: 'Funktionen', + technical: 'Technisch', + privacy: 'Datenschutz', + }, + }, + // ... weitere +}; +``` + +## Dateien + +### @manacore/shared-help-types + +| Datei | Beschreibung | +|-------|--------------| +| `src/content.ts` | Content-Typ Definitionen | +| `src/schemas.ts` | Zod-Validierungsschemas | +| `src/search.ts` | Such-bezogene Typen | + +### @manacore/shared-help-content + +| Datei | Beschreibung | +|-------|--------------| +| `src/parser.ts` | Markdown-Parser | +| `src/loader.ts` | Content-Loader für verschiedene Formate | +| `src/merger.ts` | Content-Merger (zentral + app-spezifisch) | +| `src/search.ts` | Volltextsuche mit Fuse.js | + +### @manacore/shared-help-ui + +| Datei | Beschreibung | +|-------|--------------| +| `src/pages/HelpPage.svelte` | Vollständige Hilfeseite | +| `src/components/FAQSection.svelte` | FAQ-Bereich | +| `src/components/FAQItem.svelte` | Einzelner FAQ-Eintrag | +| `src/components/FeaturesOverview.svelte` | Feature-Übersicht | +| `src/components/FeatureCard.svelte` | Feature-Karte | +| `src/components/KeyboardShortcuts.svelte` | Tastaturkürzel | +| `src/components/GettingStartedGuide.svelte` | Erste-Schritte-Guide | +| `src/components/ChangelogSection.svelte` | Changelog-Bereich | +| `src/components/ChangelogEntry.svelte` | Einzelner Changelog-Eintrag | +| `src/components/ContactSection.svelte` | Kontakt-Bereich | +| `src/components/HelpSearch.svelte` | Suchkomponente | + +## Integration in eine App + +### 1. Dependencies + +```json +{ + "dependencies": { + "@manacore/shared-help-types": "workspace:*", + "@manacore/shared-help-content": "workspace:*", + "@manacore/shared-help-ui": "workspace:*" + } +} +``` + +### 2. Content erstellen + +```typescript +// src/lib/help/content.ts +import type { HelpContent } from '@manacore/shared-help-types'; + +export const appHelpContent: Partial = { + faq: [ + { + id: 'calendar-recurring', + language: 'de', + question: 'Wie erstelle ich wiederkehrende Termine?', + answer: 'Öffne einen Termin und aktiviere die Wiederholung...', + category: 'features', + appSpecific: true, + apps: ['calendar'], + }, + ], + shortcuts: [ + { + id: 'calendar-shortcuts', + language: 'de', + category: 'app-specific', + title: 'Kalender-Shortcuts', + shortcuts: [ + { shortcut: 'N', action: 'Neuer Termin' }, + { shortcut: 'T', action: 'Zu Heute springen' }, + { shortcut: 'W', action: 'Wochenansicht' }, + ], + }, + ], +}; +``` + +### 3. Hilfeseite erstellen + +```svelte + + + + goto('/')} +/> +``` + +## App-spezifische Inhalte + +### Markierung + +Inhalte können als app-spezifisch markiert werden: + +```typescript +{ + id: 'todo-labels', + appSpecific: true, + apps: ['todo', 'calendar'], // Nur in diesen Apps sichtbar + // ... +} +``` + +### Override by ID + +Wenn `overrideById: true` (Standard), ersetzt app-spezifischer Content zentralen Content mit gleicher ID: + +```typescript +// Zentral +{ id: 'feature-tags', title: 'Tags allgemein', ... } + +// App-spezifisch +{ id: 'feature-tags', title: 'Tags in Todo', ... } // Ersetzt zentralen Content +``` + +## Vorteile + +- **Wiederverwendbarkeit:** Zentrale FAQs (Account, Billing) nur einmal schreiben +- **Konsistenz:** Einheitliches Look & Feel der Hilfeseite +- **Mehrsprachigkeit:** 5 Sprachen unterstützt +- **Suche:** Integrierte Volltextsuche +- **Flexibilität:** App-spezifische Inhalte können hinzugefügt werden +- **Typ-Sicherheit:** Vollständige TypeScript-Typen und Zod-Validierung diff --git a/docs/central-services/README.md b/docs/central-services/README.md new file mode 100644 index 000000000..ecb1cb5f9 --- /dev/null +++ b/docs/central-services/README.md @@ -0,0 +1,90 @@ +# Central Services + +Dieses Verzeichnis dokumentiert zentrale Services, die von allen Manacore-Apps gemeinsam genutzt werden. Diese Services laufen in `mana-core-auth` und bieten einheitliche APIs. + +## Übersicht + +| Service | Beschreibung | Dokumentation | +|---------|--------------|---------------| +| **Tags** | Einheitliche Tags/Labels für Todo, Calendar, Contacts | [TAGS.md](./TAGS.md) | +| **Theming** | Theme-Varianten, Dark Mode, Accessibility, Custom Themes | [THEMING.md](./THEMING.md) | +| **Help** | Zentrale Hilfeseite mit FAQ, Features, Shortcuts, Changelog | [HELP.md](./HELP.md) | +| **Command Bar** | Globale Schnellsuche und Navigation (Cmd/Ctrl+K) | [COMMAND-BAR.md](./COMMAND-BAR.md) | + +## Architektur-Prinzipien + +### Zentralisierung + +Bestimmte Daten und Funktionen werden zentral in `mana-core-auth` verwaltet: + +- **User-bezogen:** Jeder Service speichert Daten pro User (`userId`) +- **App-übergreifend:** Daten sind in allen Apps verfügbar +- **API-basiert:** Zugriff erfolgt über REST-APIs + +### Soft References + +Da die Apps ihre eigenen Datenbanken haben, können keine Foreign Keys zu mana-core-auth erstellt werden: + +``` +Todo-DB mana-core-auth-DB +┌─────────────────┐ ┌─────────────────┐ +│ task_to_tags │ │ tags │ +│ │ │ │ +│ tag_id ─ ─ ─ ─ ─│─ ─ ─ ─ ▶│ id │ +│ (keine FK) │ │ │ +└─────────────────┘ └─────────────────┘ +``` + +**Konsequenz:** Apps müssen ungültige IDs beim Laden ignorieren. + +### Shared Packages + +Für jeden zentralen Service gibt es ein entsprechendes Client-Package: + +| Service | Package | +|---------|---------| +| Tags | `@manacore/shared-tags` | + +Diese Packages enthalten: +- TypeScript Types +- API Client Klasse +- Helper-Funktionen + +## Lokale Entwicklung + +### Alle zentralen Services starten + +```bash +# Infrastruktur +pnpm docker:up + +# Auth-Service (enthält alle zentralen APIs) +pnpm dev:auth +``` + +### Datenbank-Schema pushen + +```bash +cd services/mana-core-auth +pnpm db:push +``` + +## Shared Packages + +| Package | Beschreibung | +|---------|--------------| +| `@manacore/shared-tags` | Tags Client für zentrale Tags API | +| `@manacore/shared-theme` | Theme Store, A11y Store, User Settings | +| `@manacore/shared-theme-ui` | Svelte UI-Komponenten für Theming | +| `@manacore/shared-help-types` | TypeScript-Typen für Help-Inhalte | +| `@manacore/shared-help-content` | Content-Loader, Parser, Merger, Suche | +| `@manacore/shared-help-ui` | Svelte UI-Komponenten für Hilfeseite | +| `@manacore/shared-help-mobile` | React Native Komponenten für Hilfe | + +## Hinzufügen neuer zentraler Services + +1. **Schema erstellen:** `services/mana-core-auth/src/db/schema/.schema.ts` +2. **Module erstellen:** `services/mana-core-auth/src//` +3. **In app.module.ts registrieren** +4. **Shared Package erstellen:** `packages/shared-/` +5. **Dokumentation schreiben:** `docs/central-services/.md` diff --git a/docs/central-services/TAGS.md b/docs/central-services/TAGS.md new file mode 100644 index 000000000..c7c16accd --- /dev/null +++ b/docs/central-services/TAGS.md @@ -0,0 +1,248 @@ +# Central Tags API + +Das zentrale Tags-System ermöglicht einheitliche Tags/Labels über alle Manacore-Apps hinweg. Ein Tag, der in Todo erstellt wird, ist automatisch auch in Calendar und Contacts verfügbar. + +## Architektur + +``` +┌─────────────────────────────────────────────────────────────┐ +│ mana-core-auth │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ tags Tabelle (zentral) │ │ +│ │ - id, userId, name, color, icon, createdAt │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ │ +│ GET /api/v1/tags │ POST/PUT/DELETE │ +└────────────────────────────┼────────────────────────────────┘ + │ + ┌────────────────────┼────────────────────┐ + │ │ │ + ┌────▼────┐ ┌─────▼────┐ ┌─────▼────┐ + │ Todo │ │ Calendar │ │ Contacts │ + │ │ │ │ │ │ + │ task_ │ │ event_ │ │ contact_ │ + │ to_tags │ │ to_tags │ │ to_tags │ + │ (tagId) │ │ (tagId) │ │ (tagId) │ + └─────────┘ └──────────┘ └──────────┘ +``` + +## API Endpoints + +Alle Endpoints sind unter `http://localhost:3001/api/v1/tags` verfügbar und erfordern einen Bearer Token. + +| Methode | Endpoint | Beschreibung | +|---------|----------|--------------| +| `GET` | `/tags` | Alle Tags des Users abrufen | +| `GET` | `/tags/:id` | Einzelnes Tag abrufen | +| `GET` | `/tags/by-ids?ids=id1,id2` | Mehrere Tags per ID abrufen | +| `POST` | `/tags` | Neues Tag erstellen | +| `POST` | `/tags/defaults` | Default-Tags erstellen | +| `PUT` | `/tags/:id` | Tag aktualisieren | +| `DELETE` | `/tags/:id` | Tag löschen | + +## Tag-Objekt + +```typescript +interface Tag { + id: string; // UUID + userId: string; // User-ID (aus JWT) + name: string; // Tag-Name (max 100 Zeichen) + color: string; // Hex-Farbe (#3B82F6) + icon?: string | null; // Optionales Phosphor-Icon + createdAt: Date; + updatedAt: Date; +} +``` + +## Default-Tags + +Beim Aufruf von `POST /tags/defaults` werden folgende Standard-Tags erstellt: + +| Name | Farbe | Hex | +|------|-------|-----| +| Arbeit | Blau | `#3B82F6` | +| Persönlich | Grün | `#10B981` | +| Familie | Pink | `#EC4899` | +| Wichtig | Rot | `#EF4444` | + +## Client-Nutzung + +### Shared Package + +Das `@manacore/shared-tags` Package stellt einen Client bereit: + +```typescript +import { createTagsClient } from '@manacore/shared-tags'; + +const tagsClient = createTagsClient({ + authUrl: 'http://localhost:3001', + getToken: async () => authStore.getAccessToken(), +}); + +// Alle Tags laden +const tags = await tagsClient.getAll(); + +// Tag erstellen +const newTag = await tagsClient.create({ + name: 'Meeting', + color: '#8B5CF6', +}); + +// Tags per IDs laden +const selectedTags = await tagsClient.getByIds(['id1', 'id2']); + +// Tag aktualisieren +await tagsClient.update('tag-id', { color: '#22C55E' }); + +// Tag löschen +await tagsClient.delete('tag-id'); + +// Default-Tags erstellen +await tagsClient.createDefaults(); +``` + +### In App-Stores + +Die Apps nutzen den Client in ihren Stores: + +**Todo (labels.svelte.ts):** +```typescript +import { createTagsClient, type Tag } from '@manacore/shared-tags'; + +// Label = Tag (Alias für Abwärtskompatibilität) +export type Label = Tag; + +// Client lazy initialisieren +const client = createTagsClient({ ... }); + +export const labelsStore = { + async fetchLabels() { + const labels = await client.getAll(); + // ... + } +}; +``` + +**Calendar (event-tags.ts):** +```typescript +export type EventTag = Tag; +``` + +**Contacts (contacts.ts):** +```typescript +export type ContactTag = Tag; +``` + +## Junction Tables + +Jede App behält ihre eigene Junction-Table für die Zuordnung: + +### Todo: `task_to_tags` +```sql +CREATE TABLE task_to_tags ( + task_id UUID REFERENCES tasks(id) ON DELETE CASCADE, + tag_id UUID NOT NULL, -- Soft reference zu mana-core-auth.tags + PRIMARY KEY (task_id, tag_id) +); +``` + +### Calendar: `event_to_tags` +```sql +CREATE TABLE event_to_tags ( + event_id UUID REFERENCES events(id) ON DELETE CASCADE, + tag_id UUID NOT NULL, -- Soft reference zu mana-core-auth.tags + PRIMARY KEY (event_id, tag_id) +); +``` + +### Contacts: `contact_to_tags` +```sql +CREATE TABLE contact_to_tags ( + contact_id UUID REFERENCES contacts(id) ON DELETE CASCADE, + tag_id UUID NOT NULL, -- Soft reference zu mana-core-auth.tags + PRIMARY KEY (contact_id, tag_id) +); +``` + +**Hinweis:** Da die Tags in einer anderen Datenbank liegen, sind keine Foreign Key Constraints möglich. Die Apps validieren Tag-IDs beim Laden und ignorieren ungültige IDs. + +## Entwicklung & Testing + +### Alle drei Apps gleichzeitig starten + +```bash +pnpm dev:tags-test +``` + +Dieser Befehl: +1. Richtet alle Datenbanken ein (todo, calendar, contacts, auth) +2. Startet alle Services mit farbcodierten Logs + +### Manuelles API-Testing + +```bash +# Token holen +TOKEN=$(curl -s -X POST http://localhost:3001/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email": "test@example.com", "password": "password"}' | jq -r '.accessToken') + +# Tags abrufen +curl http://localhost:3001/api/v1/tags \ + -H "Authorization: Bearer $TOKEN" + +# Tag erstellen +curl -X POST http://localhost:3001/api/v1/tags \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name": "Projekt X", "color": "#F59E0B"}' + +# Default-Tags erstellen +curl -X POST http://localhost:3001/api/v1/tags/defaults \ + -H "Authorization: Bearer $TOKEN" +``` + +## Dateien + +### Backend (mana-core-auth) + +| Datei | Beschreibung | +|-------|--------------| +| `src/db/schema/tags.schema.ts` | Drizzle-Schema für tags-Tabelle | +| `src/tags/tags.module.ts` | NestJS Module | +| `src/tags/tags.controller.ts` | REST Controller | +| `src/tags/tags.service.ts` | Business Logic | +| `src/tags/dto/create-tag.dto.ts` | DTO für Tag-Erstellung | +| `src/tags/dto/update-tag.dto.ts` | DTO für Tag-Update | + +### Shared Package + +| Datei | Beschreibung | +|-------|--------------| +| `packages/shared-tags/src/types.ts` | TypeScript Interfaces | +| `packages/shared-tags/src/client.ts` | TagsClient Klasse | +| `packages/shared-tags/src/index.ts` | Exports | + +### Frontend-Integrationen + +| App | API-Client | Store | +|-----|------------|-------| +| Todo | `src/lib/api/labels.ts` | `src/lib/stores/labels.svelte.ts` | +| Calendar | `src/lib/api/event-tags.ts` | `src/lib/stores/event-tags.svelte.ts` | +| Contacts | `src/lib/api/contacts.ts` (tagsApi) | - | + +## Migration von lokalen Tags + +Wenn eine App vorher eigene Tags hatte: + +1. **Daten exportieren:** Bestehende Tags aus der lokalen Tabelle exportieren +2. **Tags erstellen:** Per API in mana-core-auth erstellen +3. **IDs mappen:** Alte Tag-IDs auf neue IDs mappen +4. **Junction Tables aktualisieren:** Tag-IDs in Junction-Tables ersetzen +5. **Lokale Tabelle löschen:** Alte Tags-Tabelle entfernen + +## Vorteile + +- **Konsistenz:** Ein Tag "Arbeit" ist überall gleich +- **Einheitliche Farben:** Tags sehen in allen Apps identisch aus +- **Weniger Duplikation:** Code und Daten werden geteilt +- **Cross-App Features:** Möglich (z.B. "Zeige alles mit Tag X") diff --git a/docs/central-services/THEMING.md b/docs/central-services/THEMING.md new file mode 100644 index 000000000..53ef158f0 --- /dev/null +++ b/docs/central-services/THEMING.md @@ -0,0 +1,497 @@ +# Central Theming System + +Das zentrale Theming-System ermöglicht einheitliches Aussehen und Benutzereinstellungen über alle Manacore-Apps hinweg. Es besteht aus mehreren Schichten: Theme-Varianten, Light/Dark-Modus, Accessibility-Einstellungen und Custom Themes. + +## Architektur + +``` +┌─────────────────────────────────────────────────────────────┐ +│ mana-core-auth │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ user_settings (JSON-Feld) │ │ +│ │ - theme: { mode, colorScheme, pinnedThemes } │ │ +│ │ - nav: { desktopPosition, sidebarCollapsed } │ │ +│ │ - locale: "de" │ │ +│ │ - general: { startPages, sounds, etc. } │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ custom_themes Tabelle (Community Themes) │ │ +│ │ - lightColors, darkColors, author, downloads, etc. │ │ +│ └─────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ + ┌────────────────────┼────────────────────┐ + │ │ │ + ┌────▼────┐ ┌─────▼────┐ ┌─────▼────┐ + │ Todo │ │ Calendar │ │ Contacts │ + │ │ │ │ │ │ + │ shared- │ │ shared- │ │ shared- │ + │ theme │ │ theme │ │ theme │ + └─────────┘ └──────────┘ └──────────┘ +``` + +## Packages + +| Package | Beschreibung | +|---------|--------------| +| `@manacore/shared-theme` | Theme Store, Types, Utilities, Konstanten | +| `@manacore/shared-theme-ui` | Svelte UI-Komponenten (ThemeSelector, ThemePage, etc.) | + +## Theme-Varianten + +Es gibt 8 vordefinierte Theme-Varianten: + +### Standard-Varianten (PillNav) +| Name | Farbe | Icon | Hue | +|------|-------|------|-----| +| `lume` | Gold ✨ | sparkle | 47 | +| `nature` | Grün 🌿 | leaf | 122 | +| `stone` | Blau-Grau 🪨 | hexagon | 200 | +| `ocean` | Blau 🌊 | waves | 199 | + +### Erweiterte Varianten (Themes-Seite) +| Name | Farbe | Icon | Hue | +|------|-------|------|-----| +| `sunset` | Coral/Orange 🌅 | sun | 15 | +| `midnight` | Violett 🌙 | moon | 260 | +| `rose` | Pink 🌹 | flower | 340 | +| `lavender` | Lavendel 💜 | sparkle | 270 | + +## Theme-Modus + +```typescript +type ThemeMode = 'light' | 'dark' | 'system'; +``` + +- **light**: Heller Modus +- **dark**: Dunkler Modus +- **system**: Folgt der System-Einstellung + +## Color Tokens + +Jede Theme-Variante definiert diese HSL-Farbwerte für Light und Dark: + +```typescript +interface ThemeColors { + primary: string; // Hauptfarbe + primaryForeground: string; // Text auf Primary + secondary: string; // Sekundärfarbe + secondaryForeground: string; + background: string; // Seitenhintergrund + foreground: string; // Haupttext + surface: string; // Karten-Hintergrund + surfaceHover: string; // Hover-Zustand + surfaceElevated: string; // Modals, Dropdowns + muted: string; // Deaktivierte Elemente + mutedForeground: string; + border: string; // Rahmen + borderStrong: string; // Starke Rahmen + error: string; // Fehler-Rot + success: string; // Erfolg-Grün + warning: string; // Warnung-Orange + input: string; // Input-Hintergrund + ring: string; // Focus-Ring +} +``` + +### HSL-Format + +Farben werden als HSL-Strings ohne `hsl()` Wrapper gespeichert: + +```typescript +// Format: "H S% L%" +const gold = '47 95% 58%'; +const darkBlue = '199 100% 18%'; + +// CSS-Verwendung +--color-primary: 47 95% 58%; +background-color: hsl(var(--color-primary)); +``` + +## Store-Nutzung + +### Theme Store + +```typescript +import { createThemeStore } from '@manacore/shared-theme'; + +// Store erstellen +export const theme = createThemeStore({ + appId: 'calendar', + defaultMode: 'system', + defaultVariant: 'ocean', +}); + +// In Komponente initialisieren +onMount(() => { + const cleanup = theme.initialize(); + return cleanup; +}); + +// Zugriff auf State +theme.mode // 'light' | 'dark' | 'system' +theme.variant // 'ocean' | 'nature' | ... +theme.effectiveMode // 'light' | 'dark' (aufgelöst) +theme.isDark // boolean + +// Aktionen +theme.setMode('dark'); +theme.setVariant('nature'); +theme.toggleMode(); // Light ↔ Dark +theme.cycleMode(); // Light → Dark → System → Light +``` + +### App-spezifische Primary Color + +```typescript +export const theme = createThemeStore({ + appId: 'memoro', + primaryColor: { + light: '47 95% 58%', // Gold + dark: '47 95% 58%', + }, +}); +``` + +### User Settings Store (Server-Sync) + +```typescript +import { createUserSettingsStore } from '@manacore/shared-theme'; + +export const userSettings = createUserSettingsStore({ + appId: 'calendar', + authUrl: 'http://localhost:3001', + getAccessToken: () => authStore.getAccessToken(), +}); + +// Laden +await userSettings.load(); + +// Zugriff +userSettings.theme.mode // Theme-Modus +userSettings.theme.colorScheme // Variante +userSettings.nav.desktopPosition // 'top' | 'bottom' +userSettings.locale // 'de' +userSettings.general.soundsEnabled + +// Aktualisieren (speichert auf Server) +await userSettings.updateGlobal({ + theme: { mode: 'dark' } +}); + +// App-spezifische Überschreibung +await userSettings.updateAppOverride({ + theme: { colorScheme: 'nature' } +}); +``` + +## Accessibility (A11y) + +### A11y Store + +```typescript +import { createA11yStore } from '@manacore/shared-theme'; + +export const a11y = createA11yStore({ appId: 'calendar' }); + +// State +a11y.contrast // 'normal' | 'high' +a11y.colorblind // 'none' | 'deuteranopia' | 'protanopia' | 'monochrome' +a11y.reduceMotion // boolean + +// Aktionen +a11y.setContrast('high'); +a11y.setColorblind('deuteranopia'); +a11y.setReduceMotion(true); +a11y.resetAll(); +``` + +### A11y-Optionen + +**Kontrast:** +- `normal`: Standard (WCAG AA 4.5:1) +- `high`: Erhöhter Kontrast (WCAG AAA 7:1) + +**Farbenblindheit:** +- `none`: Keine Anpassung +- `deuteranopia`: Grün-Blindheit (~6% der Männer) +- `protanopia`: Rot-Blindheit (~1% der Männer) +- `monochrome`: Graustufen + +**Reduzierte Bewegung:** +- Respektiert `prefers-reduced-motion` +- Kann manuell überschrieben werden + +## Custom Themes + +### Custom Themes Store + +```typescript +import { createCustomThemesStore } from '@manacore/shared-theme'; + +export const customThemes = createCustomThemesStore({ + authUrl: 'http://localhost:3001', + getAccessToken: () => authStore.getAccessToken(), +}); + +// Eigene Themes laden +await customThemes.loadCustomThemes(); + +// Theme erstellen +const newTheme = await customThemes.createTheme({ + name: 'Mein Theme', + emoji: '🎨', + lightColors: { primary: '200 80% 50%', ... }, + darkColors: { primary: '200 70% 60%', ... }, +}); + +// Community Themes durchsuchen +await customThemes.browseCommunity({ + sort: 'popular', + search: 'dark', +}); + +// Theme herunterladen +await customThemes.downloadTheme(themeId); + +// Theme anwenden +customThemes.applyCustomTheme(theme); +``` + +### Theme Editor + +Der Theme Editor erlaubt das visuelle Erstellen von Themes: + +**Hauptfarben (immer sichtbar):** +- Primary, Background, Surface, Foreground +- Error, Success, Warning + +**Erweiterte Farben (zugeklappt):** +- PrimaryForeground, Secondary, SecondaryForeground +- SurfaceHover, SurfaceElevated, Muted, MutedForeground +- Border, BorderStrong, Input, Ring + +## UI-Komponenten + +### ThemePage + +Vollständige Themes-Seite mit allen Optionen: + +```svelte + + + +``` + +### ThemeSelector + +Dropdown zur Theme-Auswahl: + +```svelte + + + +``` + +### ThemeModeSelector + +Umschalter für Light/Dark/System: + +```svelte + + + +``` + +### ThemeToggle + +Einfacher Dark/Light Toggle: + +```svelte + + + +``` + +### A11ySettings + +Vollständige Accessibility-Einstellungen: + +```svelte + + + +``` + +## CSS-Integration + +### Tailwind CSS + +Die Themes werden als CSS-Variablen angewendet: + +```css +:root { + --color-primary: 199 98% 45%; + --color-background: 199 100% 97%; + --color-foreground: 199 100% 18%; + /* ... weitere Tokens */ +} + +.dark { + --color-primary: 199 98% 48%; + --color-background: 200 25% 7%; + --color-foreground: 0 0% 100%; +} +``` + +In Tailwind: + +```html +
+ +
+``` + +### tailwind.config.js + +```javascript +theme: { + extend: { + colors: { + background: 'hsl(var(--color-background))', + foreground: 'hsl(var(--color-foreground))', + primary: { + DEFAULT: 'hsl(var(--color-primary))', + foreground: 'hsl(var(--color-primary-foreground))', + }, + // ... weitere + } + } +} +``` + +## Dateien + +### @manacore/shared-theme + +| Datei | Beschreibung | +|-------|--------------| +| `src/types.ts` | Alle TypeScript Interfaces | +| `src/constants.ts` | Theme-Definitionen (8 Varianten) | +| `src/store.svelte.ts` | Theme Store Factory | +| `src/a11y-store.svelte.ts` | Accessibility Store | +| `src/a11y-constants.ts` | A11y Konstanten | +| `src/a11y-utils.ts` | A11y Helper | +| `src/user-settings-store.svelte.ts` | Server-Sync Store | +| `src/custom-themes-store.svelte.ts` | Custom Themes Store | +| `src/utils.ts` | Theme Utilities | +| `src/app-routes.ts` | Start-Page Konfiguration | + +### @manacore/shared-theme-ui + +| Datei | Beschreibung | +|-------|--------------| +| `src/ThemeSelector.svelte` | Theme-Dropdown | +| `src/ThemeModeSelector.svelte` | Light/Dark/System Selector | +| `src/ThemeToggle.svelte` | Einfacher Toggle | +| `src/components/ThemeCard.svelte` | Theme-Vorschau Karte | +| `src/components/ThemeGrid.svelte` | Grid von Theme-Karten | +| `src/components/A11ySettings.svelte` | A11y Einstellungen | +| `src/components/editor/` | Theme Editor Komponenten | +| `src/components/community/` | Community Themes Komponenten | +| `src/pages/ThemePage.svelte` | Vollständige Themes-Seite | +| `src/pages/ThemeEditorPage.svelte` | Theme Editor Seite | +| `src/pages/CommunityThemesPage.svelte` | Community Themes | + +## Integration in eine App + +### 1. Dependencies installieren + +```json +{ + "dependencies": { + "@manacore/shared-theme": "workspace:*", + "@manacore/shared-theme-ui": "workspace:*" + } +} +``` + +### 2. Store erstellen + +```typescript +// src/lib/stores/theme.ts +import { createThemeStore, createA11yStore } from '@manacore/shared-theme'; + +export const theme = createThemeStore({ + appId: 'myapp', + defaultVariant: 'ocean', +}); + +export const a11y = createA11yStore({ + appId: 'myapp', +}); +``` + +### 3. Im Root-Layout initialisieren + +```svelte + + + + +``` + +### 4. Themes-Seite hinzufügen + +```svelte + + + + +``` + +## Vorteile + +- **Konsistenz:** Alle Apps sehen einheitlich aus +- **User Experience:** Theme-Einstellungen werden gespeichert +- **Accessibility:** Barrierefreiheit ist eingebaut +- **Anpassbarkeit:** Nutzer können eigene Themes erstellen +- **Community:** Themes können geteilt werden diff --git a/docs/daily-reports/2025-12-10.md b/docs/daily-reports/2025-12-10.md new file mode 100644 index 000000000..d1eaa78bb --- /dev/null +++ b/docs/daily-reports/2025-12-10.md @@ -0,0 +1,347 @@ +# Tagesbericht 10. Dezember 2025 + +**Autor:** Till JS + +Übersicht aller Änderungen der letzten 20 Stunden, nach Priorität sortiert. + +--- + +## Priorität 1: Neue Features + +### Network Graph Visualisierung (Contacts, Calendar, Todo) + +Interaktive Netzwerk-Graphen zur Visualisierung von Verbindungen zwischen Elementen basierend auf gemeinsamen Tags. + +**Commits:** + +- `feat(contacts): add interactive network graph visualization` +- `feat(calendar,todo): add network graph visualization for tags` +- `feat(ui): centralize network graph components in shared-ui` +- `feat(network): add quick wins - keyboard shortcuts, strength filter, better tooltips` +- `feat(contacts): improve network graph UX with zoom-independent labels and larger nodes` +- `fix(network): initialize D3 simulation on page load` + +**Features:** + +- D3.js Force-Simulation für physikbasiertes Layout +- Zoom & Pan mit Maus/Touchpad +- Keyboard Shortcuts: `+/-` Zoom, `0` Reset, `Esc` Deselect, `/` Suche, `F` Fokus +- Filterung nach Tags, Firma/Ort/Projekt, Verbindungsstärke +- Hover-Tooltips mit Verbindungsdetails +- Klick auf Node öffnet Detail-Sidebar (Contacts) oder Info-Panel (Calendar/Todo) +- Doppelklick navigiert zur Detail-Seite +- Shared Components in `@manacore/shared-ui` + +--- + +### Zentrales Tags API (mana-core-auth) + +Neues zentrales Tag-Management über den Auth-Service für app-übergreifende Tags. + +**Commits:** + +- `feat(auth): add central Tags API in mana-core-auth` +- `feat(ui,contacts,todo): centralize tag components and add label management` + +**Features:** + +- CRUD-Endpoints für Tags in mana-core-auth +- Schema: `tags` Tabelle mit userId, name, color, app +- Shared Tag-Komponenten in `@manacore/shared-ui` +- Label-Management in Todo App + +--- + +### Todo App - Umfangreiche Erweiterungen + +Massive Erweiterung der Todo App mit vielen neuen Features. + +**Commits:** + +- `feat(todo): redesign task input and items with glass-pill style` +- `feat(todo): add comprehensive settings page with 20+ preferences` +- `feat(todo): add task edit modal and fix task loading` +- `feat(todo): add task metadata fields and mana page` +- `feat(todo): add statistics page with visualizations` +- `feat(todo): add PWA support with offline capabilities` +- `feat(todo): add multiple kanban boards with task editing features` + +**Features:** + +- **Glass-Pill Design**: Neues modernes UI für Task-Input und Items +- **Settings Page**: 20+ Einstellungen für Personalisierung +- **Task Edit Modal**: Inline-Bearbeitung von Tasks +- **Metadata Fields**: Erweiterte Task-Eigenschaften +- **Mana Page**: Neue Seite für Gamification/Belohnungen +- **Statistics Page**: Visualisierungen mit Charts +- **PWA Support**: Offline-Fähigkeit, Service Worker, Install-Prompt +- **Multiple Kanban Boards**: Mehrere Boards pro User + +--- + +### Contacts App - Erweiterte Funktionen + +**Commits:** + +- `feat(contacts): add duplicate detection, photo upload, and batch operations` +- `feat(contacts): add enhanced favorites page with multiple view modes` +- `feat(contacts): improve alphabet view UX and make it default` +- `feat(contacts): add SearchModal component and help content` +- `feat(contacts): add archive link to settings page` + +**Features:** + +- **Duplikat-Erkennung**: Automatische Erkennung ähnlicher Kontakte +- **Foto-Upload**: Kontaktbilder hochladen +- **Batch-Operationen**: Mehrere Kontakte gleichzeitig bearbeiten +- **Favoriten-Seite**: Grid/List/Cards Ansichtsmodi +- **Alphabet-Ansicht**: Verbesserte UX, jetzt Standard +- **Search Modal**: Schnellsuche mit Tastaturkürzel + +--- + +### Themes Page (Contacts, Todo, Calendar) + +Neue Themes-Seite für Farbschema-Auswahl in allen Apps. + +**Commits:** + +- `feat(themes): add themes page to contacts, todo, and calendar apps` + +**Features:** + +- Auswahl aus vordefinierten Farbthemen +- Live-Preview +- Persistierung der Auswahl + +--- + +### Referral System Frontend + +Integration des Empfehlungssystems in die Web-Apps. + +**Commits:** + +- `feat(referral): integrate referral system frontend` + +**Features:** + +- Referral-Code Anzeige +- Einladungs-Links +- Belohnungs-Tracking + +--- + +### Help System + +Zentralisiertes Hilfesystem mit Shared Packages. + +**Commits:** + +- `feat(help): add centralized help system with shared packages` + +**Features:** + +- `@manacore/shared-help-content`: Hilfetexte +- `@manacore/shared-help-ui`: UI-Komponenten +- `@manacore/shared-help-types`: TypeScript-Typen + +--- + +### CommandBar (Global Search) + +Globale Suche über alle Apps hinweg. + +**Commits:** + +- `feat(shared-ui): add global CommandBar component with search across apps` + +**Features:** + +- `Cmd+K` / `Ctrl+K` öffnet CommandBar +- Suche über alle Inhalte +- Tastaturnavigation + +--- + +## Priorität 2: UI/UX Verbesserungen + +### Skeleton Loaders + +Ladezustands-Anzeigen für bessere UX. + +**Commits:** + +- `feat(ui): add comprehensive skeleton loaders for contacts and todo apps` +- `feat(ui): add skeleton loaders for calendar and clock apps` + +**Features:** + +- SkeletonAvatar, SkeletonCard, SkeletonGrid, SkeletonList, SkeletonRow +- Konsistente Lade-Animation +- App-spezifische Skeleton-Komponenten + +--- + +### Settings Page Verbesserungen + +**Commits:** + +- `feat(ui): add table of contents and sticky pill headers to settings page` +- `fix(settings): unify global settings across all web apps` +- `fix(settings): complete global settings unification for remaining apps` + +**Features:** + +- Inhaltsverzeichnis mit Sprungmarken +- Sticky Section Headers +- Einheitliche Settings-Struktur über alle Apps + +--- + +### PillNavigation Icons + +**Commits:** + +- `feat(ui): add Phosphor Icons to PillNavigation` + +**Features:** + +- Phosphor Icons statt Text-Labels +- Bessere visuelle Unterscheidung + +--- + +### Settings Components Refactoring + +**Commits:** + +- `refactor(shared-ui): convert SettingsSelect from CSS to Tailwind classes` +- `refactor(shared-ui): convert settings components from scoped CSS to Tailwind` + +**Features:** + +- Migration von Scoped CSS zu Tailwind +- Bessere Konsistenz und Wartbarkeit + +--- + +## Priorität 3: Refactoring & Code Quality + +### Groups → Tags Konsolidierung + +**Commits:** + +- `refactor(contacts): consolidate groups into tags feature` +- `fix(contacts): remove groups store dependency from data page` + +**Änderungen:** + +- Groups-Feature entfernt +- Funktionalität in Tags integriert +- Weniger Code-Duplikation + +--- + +### Calendar Tags Integration + +**Commits:** + +- `feat(calendar): add full tags integration with colors` + +**Features:** + +- Farbige Tags für Events +- Filterung nach Tags + +--- + +## Priorität 4: Bugfixes + +### Database Schema Fix + +**Commits:** + +- `fix(todo): use TEXT for user_id columns (Better Auth compatibility)` +- `fix(db): use TEXT for user_id columns across entire codebase` + +**Problem:** Better Auth generiert User-IDs, die nicht UUID-kompatibel sind. +**Lösung:** Alle `user_id` Spalten von UUID auf TEXT umgestellt. + +--- + +### Contacts Settings Page + +**Commits:** + +- `fix(contacts): fix settings page styling and add Tailwind source directives` + +**Problem:** Styling-Probleme auf der Settings-Seite. +**Lösung:** Tailwind source directives hinzugefügt. + +--- + +### Network Graph Simulation + +**Commits:** + +- `fix(network): initialize D3 simulation on page load` + +**Problem:** Network-Graph zeigte keine Nodes an. +**Lösung:** D3 Simulation wird jetzt korrekt beim Laden initialisiert. + +--- + +## Priorität 5: DevOps & CI/CD + +### Deployment Guide + +**Commits:** + +- `docs(cicd): add comprehensive deployment guide with CI/CD architecture` + +**Dokumentation:** + +- CI/CD Pipeline Architektur +- Deployment-Prozesse +- Staging vs. Production + +--- + +### CI/CD Verbesserungen + +**Commits:** + +- `fix(ci): prevent container name conflict in staging deployment` +- `feat(ci): add database migrations step to tagged staging deployments` + +**Änderungen:** + +- Container-Namenskonflikte behoben +- Automatische DB-Migrationen bei Tagged Deployments + +--- + +## Statistik + +| Kategorie | Anzahl | +| ---------------------- | ------ | +| Neue Features | 15 | +| UI/UX Verbesserungen | 8 | +| Refactoring | 5 | +| Bugfixes | 5 | +| DevOps/CI | 3 | +| Dokumentation | 3 | + +**Betroffene Apps:** + +- Todo (7 große Features) +- Contacts (6 große Features) +- Calendar (2 Features) +- Clock (1 Feature) +- Shared UI (8 Komponenten) +- mana-core-auth (1 API) + +--- + +_Autor: Till JS | Generiert am 10.12.2025_ diff --git a/docs/pr-reviews/PR-014-major-update.md b/docs/pr-reviews/PR-014-major-update.md new file mode 100644 index 000000000..6bb024b2d --- /dev/null +++ b/docs/pr-reviews/PR-014-major-update.md @@ -0,0 +1,272 @@ +# Code Review: PR #14 + +**Title:** feat: major update with network graphs, themes, todo extensions, and more +**Author:** Till-JS +**Date:** 2025-12-10 +**Status:** OPEN +**URL:** https://github.com/Memo-2023/manacore-monorepo/pull/14 + +--- + +## Summary + +| Metric | Value | +|--------|-------| +| Files Changed | 382 | +| Additions | +39,514 | +| Deletions | -6,251 | + +--- + +## Overview + +This is a **major feature release** introducing: + +1. **Network Graph Visualization** - D3.js force-directed graphs for Contacts, Calendar, and Todo apps +2. **Central Tags API** - Unified tagging system in mana-core-auth +3. **Custom Themes System** - Theme editor, community gallery, and sharing +4. **Todo App Extensions** - Kanban boards, statistics, settings page, PWA support +5. **Contacts App Features** - Duplicate detection, photo upload, batch operations, favorites views +6. **Help System** - Shared packages for content, UI, and types (`shared-help-content`, `shared-help-ui`, `shared-help-types`, `shared-help-mobile`) +7. **Skeleton Loaders** - Better loading states across apps +8. **CommandBar** - Global search (Cmd+K) +9. **Bug Fixes** - Network graph simulation fixes, database schema TEXT for user_id + +--- + +## Code Quality Analysis + +### Strengths + +#### 1. Excellent Architecture +- Clean separation of concerns with shared packages (`shared-ui`, `shared-theme`, `shared-tags`, `shared-help-*`) +- Proper Svelte 5 runes usage (`$state`, `$derived`, `$effect`) +- Good TypeScript typing throughout + +#### 2. NetworkGraph Component (`packages/shared-ui/src/organisms/network/`) +- Well-structured D3.js integration with `d3-zoom` and `d3-selection` +- Proper zoom/pan handling +- Keyboard shortcuts implemented: + - `+`/`-` for zoom in/out + - `0` to reset zoom + - `Esc` to deselect + - `F` to focus on selected node + - `/` to focus search +- Accessible with `role="button"`, `aria-label`, `tabindex` +- Efficient re-rendering with proper state management + +#### 3. Tags Service (`services/mana-core-auth/src/tags/`) +- Proper validation (duplicate name check before create/update) +- Good use of Drizzle's `returning()` for immediate results +- User-scoped queries with proper authorization (`userId` checks) +- Default tags created for new users + +#### 4. Custom Themes Store (`packages/shared-theme/src/custom-themes-store.svelte.ts`) +- Clean API design with factory function pattern +- Proper state management with Svelte 5 runes +- Good separation of public/authenticated API calls +- CSS variable application for runtime theming + +--- + +### Suggestions for Improvement + +#### 1. Hardcoded German Strings + +**Location:** `packages/shared-ui/src/organisms/network/NetworkGraph.svelte:440-442` + +```svelte +

Keine Verbindungen gefunden

+

Elemente werden verbunden, wenn sie gemeinsame Tags haben.

+``` + +**Recommendation:** Use i18n for user-facing strings to maintain consistency across the monorepo. + +--- + +#### 2. Default Tags in German Only + +**Location:** `services/mana-core-auth/src/tags/tags.service.ts:10-15` + +```typescript +const DEFAULT_TAGS = [ + { name: 'Arbeit', color: '#3B82F6', icon: 'Briefcase' }, + { name: 'Persönlich', color: '#10B981', icon: 'User' }, + { name: 'Familie', color: '#EC4899', icon: 'Heart' }, + { name: 'Wichtig', color: '#EF4444', icon: 'Star' }, +]; +``` + +**Recommendation:** Consider locale-aware default tags or use English defaults that users can customize. + +--- + +#### 3. Database Connection Pattern + +**Location:** `services/mana-core-auth/src/tags/tags.service.ts:21-24` + +```typescript +private getDb() { + const databaseUrl = this.configService.get('database.url'); + return getDb(databaseUrl!); +} +``` + +**Issue:** Using `!` assertion is less safe. + +**Recommendation:** Inject the database connection via NestJS dependency injection instead of calling `getDb()` on every method call. + +--- + +#### 4. Missing Error Boundary Handling + +The NetworkGraph component handles empty states but doesn't have explicit error handling for malformed node/link data. + +**Recommendation:** Add defensive checks for invalid data structures. + +--- + +### Potential Issues + +#### 1. PR Size + +- 382 files is extremely large for a single PR +- Makes code review difficult and increases risk +- Consider splitting into feature branches for easier review and rollback + +#### 2. Database Schema Consistency + +**Location:** `services/mana-core-auth/src/db/schema/tags.schema.ts:11` + +```typescript +userId: text('user_id').notNull(), +``` + +This uses `TEXT` type for user_id. Verify this aligns with how user IDs are stored in other tables (some use `UUID`). + +#### 3. Missing Test Coverage + +This major PR adds significant functionality without visible test changes. Consider adding: +- Unit tests for `TagsService` +- Component tests for `NetworkGraph` +- Integration tests for the themes API + +--- + +### Security Considerations + +#### Authorization Checks ✅ + +- Tag operations properly scope by `userId` +- Custom themes store requires authentication for write operations +- Community theme browsing allows public access (appropriate) + +#### Input Validation + +- DTOs should be reviewed for proper validation (max lengths, format checks) +- Tag color field accepts any 7-char string - consider validating hex format (`/^#[0-9A-Fa-f]{6}$/`) + +#### Environment Files ✅ + +- `.env.development` only removes 6 lines, no secrets added +- No credentials exposed in the diff + +--- + +## New Shared Packages + +| Package | Purpose | +|---------|---------| +| `@manacore/shared-tags` | Client for central tags API | +| `@manacore/shared-help-content` | Markdown content loader and search | +| `@manacore/shared-help-ui` | Svelte help page components | +| `@manacore/shared-help-types` | TypeScript types for help system | +| `@manacore/shared-help-mobile` | React Native help components | +| `@manacore/shared-theme-ui` | Theme editor and community gallery | + +--- + +## Files Changed by Category + +| Category | Count | Notable Changes | +|----------|-------|-----------------| +| Contacts App | ~40 | Duplicates, batch ops, network, favorites | +| Todo App | ~30 | Kanban, statistics, settings, PWA | +| Calendar App | ~25 | Event tags, network graph | +| Shared UI | ~30 | NetworkGraph, skeleton loaders, tags | +| Shared Theme | ~15 | Custom themes store, editor | +| Shared Help | ~35 | Content, UI, types, mobile | +| mana-core-auth | ~15 | Tags API, themes API | +| Archived Apps | ~100 | Documentation cleanup | + +--- + +## Recommendations + +### 1. Split Future PRs + +Consider creating separate PRs for major features: +- Network Graph feature +- Central Tags API +- Custom Themes System +- Help System packages + +### 2. Add i18n + +Replace hardcoded German strings in shared components. + +### 3. Add Tests + +At minimum, add unit tests for: +- `TagsService` (create, update, delete, defaults) +- `ThemesService` (publish, download, rate) +- `NetworkGraph` (props, events, accessibility) + +### 4. Database Migration Plan + +Ensure `tags` and `themes` table migrations are coordinated across environments. + +### 5. Documentation Updates + +The CLAUDE.md files are helpful. Ensure README updates for new packages. + +--- + +## Rating Summary + +| Aspect | Rating | Notes | +|--------|--------|-------| +| Code Quality | ⭐⭐⭐⭐ | Clean, well-structured code | +| Architecture | ⭐⭐⭐⭐⭐ | Excellent use of shared packages | +| Test Coverage | ⭐⭐ | Missing tests for new features | +| PR Size | ⭐⭐ | Too large for single review | +| Security | ⭐⭐⭐⭐ | Good authorization patterns | +| i18n | ⭐⭐⭐ | Some hardcoded German strings | + +--- + +## Conclusion + +This is solid, well-architected code with good separation of concerns. The main concerns are: + +1. **PR size** - Makes review difficult +2. **Missing tests** - New features lack test coverage +3. **Hardcoded strings** - Some German text in shared components + +Consider splitting future releases of this scale into smaller, focused PRs. + +--- + +## Test Plan Checklist + +From the PR description: + +- [ ] Verify network graph loads correctly in Contacts, Calendar, Todo +- [ ] Test theme editor and community themes page +- [ ] Check Todo app new features (kanban, statistics, settings) +- [ ] Verify contacts duplicate detection and batch operations +- [ ] Test skeleton loaders appear during loading states + +--- + +*Review by Claude Code - 2025-12-10* diff --git a/games/figgos/apps/backend/src/db/schema/figure-likes.schema.ts b/games/figgos/apps/backend/src/db/schema/figure-likes.schema.ts index 17d8145d3..575759b43 100644 --- a/games/figgos/apps/backend/src/db/schema/figure-likes.schema.ts +++ b/games/figgos/apps/backend/src/db/schema/figure-likes.schema.ts @@ -1,4 +1,4 @@ -import { pgTable, uuid, timestamp, unique } from 'drizzle-orm/pg-core'; +import { pgTable, uuid, text, timestamp, unique } from 'drizzle-orm/pg-core'; import { relations } from 'drizzle-orm'; import { figures } from './figures.schema'; @@ -9,7 +9,7 @@ export const figureLikes = pgTable( figureId: uuid('figure_id') .notNull() .references(() => figures.id, { onDelete: 'cascade' }), - userId: uuid('user_id').notNull(), + userId: text('user_id').notNull(), createdAt: timestamp('created_at').defaultNow(), }, (table) => ({ diff --git a/games/figgos/apps/backend/src/db/schema/figures.schema.ts b/games/figgos/apps/backend/src/db/schema/figures.schema.ts index cb4306d54..e3335ed5e 100644 --- a/games/figgos/apps/backend/src/db/schema/figures.schema.ts +++ b/games/figgos/apps/backend/src/db/schema/figures.schema.ts @@ -12,7 +12,7 @@ export const figures = pgTable('figures', { isPublic: boolean('is_public').default(true), isArchived: boolean('is_archived').default(false), likes: integer('likes').default(0), - userId: uuid('user_id').notNull(), + userId: text('user_id').notNull(), createdAt: timestamp('created_at').defaultNow(), updatedAt: timestamp('updated_at').defaultNow(), }); diff --git a/games/voxelava/apps/backend/src/db/schema/level-likes.schema.ts b/games/voxelava/apps/backend/src/db/schema/level-likes.schema.ts index 680b1e308..002d7484a 100644 --- a/games/voxelava/apps/backend/src/db/schema/level-likes.schema.ts +++ b/games/voxelava/apps/backend/src/db/schema/level-likes.schema.ts @@ -1,4 +1,4 @@ -import { pgTable, uuid, timestamp, unique } from 'drizzle-orm/pg-core'; +import { pgTable, uuid, text, timestamp, unique } from 'drizzle-orm/pg-core'; import { relations } from 'drizzle-orm'; import { levels } from './levels.schema'; @@ -9,7 +9,7 @@ export const levelLikes = pgTable( levelId: uuid('level_id') .notNull() .references(() => levels.id, { onDelete: 'cascade' }), - userId: uuid('user_id').notNull(), + userId: text('user_id').notNull(), createdAt: timestamp('created_at').defaultNow(), }, (table) => ({ diff --git a/games/voxelava/apps/backend/src/db/schema/level-plays.schema.ts b/games/voxelava/apps/backend/src/db/schema/level-plays.schema.ts index 62a272607..d89d2fffe 100644 --- a/games/voxelava/apps/backend/src/db/schema/level-plays.schema.ts +++ b/games/voxelava/apps/backend/src/db/schema/level-plays.schema.ts @@ -1,4 +1,4 @@ -import { pgTable, uuid, timestamp, real, integer, boolean } from 'drizzle-orm/pg-core'; +import { pgTable, uuid, text, timestamp, real, integer, boolean } from 'drizzle-orm/pg-core'; import { relations } from 'drizzle-orm'; import { levels } from './levels.schema'; @@ -7,7 +7,7 @@ export const levelPlays = pgTable('level_plays', { levelId: uuid('level_id') .notNull() .references(() => levels.id, { onDelete: 'cascade' }), - userId: uuid('user_id'), + userId: text('user_id'), completionTime: real('completion_time'), attempts: integer('attempts').default(1), completed: boolean('completed').default(false), diff --git a/games/voxelava/apps/backend/src/db/schema/levels.schema.ts b/games/voxelava/apps/backend/src/db/schema/levels.schema.ts index 65a7c9d67..b7f832ddc 100644 --- a/games/voxelava/apps/backend/src/db/schema/levels.schema.ts +++ b/games/voxelava/apps/backend/src/db/schema/levels.schema.ts @@ -5,7 +5,7 @@ export const levels = pgTable('levels', { id: uuid('id').primaryKey().defaultRandom(), name: text('name').notNull(), description: text('description'), - userId: uuid('user_id').notNull(), + userId: text('user_id').notNull(), voxelData: jsonb('voxel_data').notNull(), spawnPoint: jsonb('spawn_point').notNull(), worldSize: jsonb('world_size').notNull(), diff --git a/package.json b/package.json index b357c9c86..1676ac859 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "todo:db:push": "pnpm --filter @todo/backend db:push", "todo:db:studio": "pnpm --filter @todo/backend db:studio", "todo:db:seed": "pnpm --filter @todo/backend db:seed", + "dev:tags-test": "./scripts/setup-databases.sh todo && ./scripts/setup-databases.sh calendar && ./scripts/setup-databases.sh contacts && ./scripts/setup-databases.sh auth && concurrently -n auth,todo-be,todo-web,cal-be,cal-web,con-be,con-web -c blue,green,cyan,yellow,magenta,red,white \"pnpm dev:auth\" \"pnpm dev:todo:backend\" \"pnpm dev:todo:web\" \"pnpm dev:calendar:backend\" \"pnpm dev:calendar:web\" \"pnpm dev:contacts:backend\" \"pnpm dev:contacts:web\"", "moodlit:dev": "turbo run dev --filter=moodlit...", "dev:moodlit:mobile": "pnpm --filter @moodlit/mobile dev", "dev:moodlit:web": "pnpm --filter @moodlit/web dev", diff --git a/packages/manadeck-database/src/schema/aiGenerations.ts b/packages/manadeck-database/src/schema/aiGenerations.ts index c24ff0dfc..ec6f1f823 100644 --- a/packages/manadeck-database/src/schema/aiGenerations.ts +++ b/packages/manadeck-database/src/schema/aiGenerations.ts @@ -1,4 +1,4 @@ -import { pgTable, uuid, varchar, text, timestamp, jsonb, index, pgEnum } from 'drizzle-orm/pg-core'; +import { pgTable, uuid, text, varchar, timestamp, jsonb, index, pgEnum } from 'drizzle-orm/pg-core'; import { relations } from 'drizzle-orm'; import { decks } from './decks.js'; @@ -25,7 +25,7 @@ export const aiGenerations = pgTable( 'ai_generations', { id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id').notNull(), + userId: text('user_id').notNull(), deckId: uuid('deck_id').references(() => decks.id, { onDelete: 'set null' }), functionName: varchar('function_name', { length: 100 }).notNull(), prompt: text('prompt').notNull(), diff --git a/packages/manadeck-database/src/schema/cardProgress.ts b/packages/manadeck-database/src/schema/cardProgress.ts index aedf2618a..d036dc219 100644 --- a/packages/manadeck-database/src/schema/cardProgress.ts +++ b/packages/manadeck-database/src/schema/cardProgress.ts @@ -1,6 +1,7 @@ import { pgTable, uuid, + text, integer, timestamp, index, @@ -23,7 +24,7 @@ export const cardProgress = pgTable( 'card_progress', { id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id').notNull(), + userId: text('user_id').notNull(), cardId: uuid('card_id') .notNull() .references(() => cards.id, { onDelete: 'cascade' }), diff --git a/packages/manadeck-database/src/schema/dailyProgress.ts b/packages/manadeck-database/src/schema/dailyProgress.ts index 62e4be18f..20bca5683 100644 --- a/packages/manadeck-database/src/schema/dailyProgress.ts +++ b/packages/manadeck-database/src/schema/dailyProgress.ts @@ -1,10 +1,10 @@ import { pgTable, uuid, + text, date, integer, decimal, - text, timestamp, index, unique, @@ -14,7 +14,7 @@ export const dailyProgress = pgTable( 'daily_progress', { id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id').notNull(), + userId: text('user_id').notNull(), date: date('date').notNull(), cardsStudied: integer('cards_studied').default(0).notNull(), timeSpentMinutes: integer('time_spent_minutes').default(0).notNull(), diff --git a/packages/manadeck-database/src/schema/decks.ts b/packages/manadeck-database/src/schema/decks.ts index 96085c4f9..7644cf09d 100644 --- a/packages/manadeck-database/src/schema/decks.ts +++ b/packages/manadeck-database/src/schema/decks.ts @@ -1,8 +1,8 @@ import { pgTable, uuid, - varchar, text, + varchar, boolean, timestamp, jsonb, @@ -17,7 +17,7 @@ export const decks = pgTable( 'decks', { id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id').notNull(), + userId: text('user_id').notNull(), title: varchar('title', { length: 255 }).notNull(), description: text('description'), coverImageUrl: text('cover_image_url'), diff --git a/packages/manadeck-database/src/schema/studySessions.ts b/packages/manadeck-database/src/schema/studySessions.ts index dd7b2029a..abb0f4c0c 100644 --- a/packages/manadeck-database/src/schema/studySessions.ts +++ b/packages/manadeck-database/src/schema/studySessions.ts @@ -1,4 +1,4 @@ -import { pgTable, uuid, integer, timestamp, index, pgEnum } from 'drizzle-orm/pg-core'; +import { pgTable, uuid, text, integer, timestamp, index, pgEnum } from 'drizzle-orm/pg-core'; import { relations } from 'drizzle-orm'; import { decks } from './decks.js'; @@ -12,7 +12,7 @@ export const studySessions = pgTable( deckId: uuid('deck_id') .notNull() .references(() => decks.id, { onDelete: 'cascade' }), - userId: uuid('user_id').notNull(), + userId: text('user_id').notNull(), mode: studyModeEnum('mode').notNull(), totalCards: integer('total_cards').notNull().default(0), completedCards: integer('completed_cards').notNull().default(0), diff --git a/packages/manadeck-database/src/schema/userStats.ts b/packages/manadeck-database/src/schema/userStats.ts index c0df42432..bcb448fb8 100644 --- a/packages/manadeck-database/src/schema/userStats.ts +++ b/packages/manadeck-database/src/schema/userStats.ts @@ -1,9 +1,9 @@ -import { pgTable, uuid, integer, decimal, date, timestamp, index } from 'drizzle-orm/pg-core'; +import { pgTable, text, integer, decimal, date, timestamp, index } from 'drizzle-orm/pg-core'; export const userStats = pgTable( 'user_stats', { - userId: uuid('user_id').primaryKey(), + userId: text('user_id').primaryKey(), totalWins: integer('total_wins').default(0).notNull(), totalSessions: integer('total_sessions').default(0).notNull(), totalCardsStudied: integer('total_cards_studied').default(0).notNull(), diff --git a/packages/news-database/src/schema/articles.ts b/packages/news-database/src/schema/articles.ts index e9f2e26d5..fd4664d3d 100644 --- a/packages/news-database/src/schema/articles.ts +++ b/packages/news-database/src/schema/articles.ts @@ -29,7 +29,7 @@ export const articles = pgTable( summary: text('summary'), // For user-saved articles - userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }), + userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }), originalUrl: text('original_url'), parsedContent: text('parsed_content'), isArchived: boolean('is_archived').default(false), diff --git a/packages/news-database/src/schema/auth.ts b/packages/news-database/src/schema/auth.ts index ba40bc52d..1e3309b86 100644 --- a/packages/news-database/src/schema/auth.ts +++ b/packages/news-database/src/schema/auth.ts @@ -4,7 +4,7 @@ import { users } from './users'; // Better Auth Sessions export const sessions = pgTable('sessions', { id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id') + userId: text('user_id') .references(() => users.id, { onDelete: 'cascade' }) .notNull(), token: text('token').notNull().unique(), @@ -18,7 +18,7 @@ export const sessions = pgTable('sessions', { // Better Auth Accounts (for OAuth providers) export const accounts = pgTable('accounts', { id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id') + userId: text('user_id') .references(() => users.id, { onDelete: 'cascade' }) .notNull(), providerId: text('provider_id').notNull(), // 'credential', 'google', 'apple', etc. diff --git a/packages/news-database/src/schema/interactions.ts b/packages/news-database/src/schema/interactions.ts index a69d077b9..49bfef56a 100644 --- a/packages/news-database/src/schema/interactions.ts +++ b/packages/news-database/src/schema/interactions.ts @@ -1,6 +1,7 @@ import { pgTable, uuid, + text, timestamp, boolean, real, @@ -15,7 +16,7 @@ export const userArticleInteractions = pgTable( 'user_article_interactions', { id: uuid('id').primaryKey().defaultRandom(), - userId: uuid('user_id') + userId: text('user_id') .references(() => users.id, { onDelete: 'cascade' }) .notNull(), articleId: uuid('article_id') diff --git a/packages/shared-tags/package.json b/packages/shared-tags/package.json new file mode 100644 index 000000000..91e2fb7a9 --- /dev/null +++ b/packages/shared-tags/package.json @@ -0,0 +1,27 @@ +{ + "name": "@manacore/shared-tags", + "version": "0.1.0", + "description": "Shared tags client for Manacore apps - connects to central tags service", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "type": "module", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "type-check": "tsc --noEmit", + "lint": "eslint ." + }, + "dependencies": {}, + "devDependencies": { + "typescript": "^5.9.3" + }, + "keywords": [ + "manacore", + "tags", + "labels" + ], + "license": "MIT" +} diff --git a/packages/shared-tags/src/client.ts b/packages/shared-tags/src/client.ts new file mode 100644 index 000000000..3bdd30efb --- /dev/null +++ b/packages/shared-tags/src/client.ts @@ -0,0 +1,145 @@ +import type { Tag, TagResponse, CreateTagInput, UpdateTagInput } from './types'; + +/** + * Configuration for TagsClient + */ +export interface TagsClientConfig { + /** + * Base URL of mana-core-auth service (e.g., 'http://localhost:3001') + */ + authUrl: string; + + /** + * Function to get the current auth token + */ + getToken: () => Promise | string; +} + +/** + * Client for interacting with the central Tags API in mana-core-auth. + * Used by all Manacore apps to manage user tags. + */ +export class TagsClient { + private authUrl: string; + private getToken: () => Promise | string; + + constructor(config: TagsClientConfig) { + this.authUrl = config.authUrl.replace(/\/$/, ''); // Remove trailing slash + this.getToken = config.getToken; + } + + private async request(path: string, options: RequestInit = {}): Promise { + const token = await this.getToken(); + + const response = await fetch(`${this.authUrl}/api/v1${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + ...options.headers, + }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || `Request failed: ${response.status}`); + } + + // Handle 204 No Content + if (response.status === 204) { + return undefined as T; + } + + return response.json(); + } + + /** + * Get all tags for the current user + */ + async getAll(): Promise { + const tags = await this.request('/tags'); + return tags.map(this.normalizeTag); + } + + /** + * Get a single tag by ID + */ + async getById(id: string): Promise { + try { + const tag = await this.request(`/tags/${id}`); + return tag ? this.normalizeTag(tag) : null; + } catch { + return null; + } + } + + /** + * Get multiple tags by their IDs. + * Useful for resolving tagIds stored in junction tables. + */ + async getByIds(ids: string[]): Promise { + if (ids.length === 0) return []; + + const tags = await this.request(`/tags/by-ids?ids=${ids.join(',')}`); + return tags.map(this.normalizeTag); + } + + /** + * Create a new tag + */ + async create(data: CreateTagInput): Promise { + const tag = await this.request('/tags', { + method: 'POST', + body: JSON.stringify(data), + }); + return this.normalizeTag(tag); + } + + /** + * Update an existing tag + */ + async update(id: string, data: UpdateTagInput): Promise { + const tag = await this.request(`/tags/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }); + return this.normalizeTag(tag); + } + + /** + * Delete a tag + */ + async delete(id: string): Promise { + await this.request(`/tags/${id}`, { + method: 'DELETE', + }); + } + + /** + * Create default tags for the user (if not already created) + */ + async createDefaults(): Promise { + const tags = await this.request('/tags/defaults', { + method: 'POST', + }); + return tags.map(this.normalizeTag); + } + + /** + * Normalize API response to Tag type + */ + private normalizeTag(tag: TagResponse): Tag { + return { + ...tag, + createdAt: new Date(tag.createdAt), + updatedAt: new Date(tag.updatedAt), + }; + } +} + +/** + * Create a TagsClient instance + */ +export function createTagsClient(config: TagsClientConfig): TagsClient { + return new TagsClient(config); +} diff --git a/packages/shared-tags/src/index.ts b/packages/shared-tags/src/index.ts new file mode 100644 index 000000000..70f3c79a5 --- /dev/null +++ b/packages/shared-tags/src/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './client'; diff --git a/packages/shared-tags/src/types.ts b/packages/shared-tags/src/types.ts new file mode 100644 index 000000000..c962bac62 --- /dev/null +++ b/packages/shared-tags/src/types.ts @@ -0,0 +1,39 @@ +/** + * Tag entity from the central mana-core-auth service. + * Used across all Manacore apps (Todo, Calendar, Contacts, etc.) + */ +export interface Tag { + id: string; + userId: string; + name: string; + color: string; + icon?: string | null; + createdAt: Date | string; + updatedAt: Date | string; +} + +/** + * Input for creating a new tag + */ +export interface CreateTagInput { + name: string; + color?: string; + icon?: string; +} + +/** + * Input for updating an existing tag + */ +export interface UpdateTagInput { + name?: string; + color?: string; + icon?: string; +} + +/** + * API response type that might have date strings instead of Date objects + */ +export type TagResponse = Omit & { + createdAt: string; + updatedAt: string; +}; diff --git a/packages/shared-tags/tsconfig.json b/packages/shared-tags/tsconfig.json new file mode 100644 index 000000000..dac5db2a0 --- /dev/null +++ b/packages/shared-tags/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/shared-theme-ui/src/components/ThemeCard.svelte b/packages/shared-theme-ui/src/components/ThemeCard.svelte index c9c5abb8e..005b857ca 100644 --- a/packages/shared-theme-ui/src/components/ThemeCard.svelte +++ b/packages/shared-theme-ui/src/components/ThemeCard.svelte @@ -1,7 +1,21 @@ + {/if} +
{#if definition.icon && themeIcons[definition.icon as keyof typeof themeIcons]} diff --git a/packages/shared-theme-ui/src/components/ThemeGrid.svelte b/packages/shared-theme-ui/src/components/ThemeGrid.svelte index 17029ad3c..80bdd6aa4 100644 --- a/packages/shared-theme-ui/src/components/ThemeGrid.svelte +++ b/packages/shared-theme-ui/src/components/ThemeGrid.svelte @@ -1,6 +1,6 @@ +
- {#each themeData() as theme (theme.variant)} + {#each defaultThemeData() as theme (theme.variant)} {/each}
+ + +{#if showExtendedThemes && extendedThemeData().length > 0} +
+

+ Weitere Themes + (zum Anpinnen in der Navigation) +

+
+ {#each extendedThemeData() as theme (theme.variant)} + onSelect(theme.variant)} + onUnlock={onUnlock ? () => onUnlock(theme.variant) : undefined} + canPin={true} + isPinned={isThemePinned(theme.variant)} + {onTogglePin} + translations={cardTranslations} + /> + {/each} +
+
+{/if} diff --git a/packages/shared-theme-ui/src/components/community/CommunityThemeGallery.svelte b/packages/shared-theme-ui/src/components/community/CommunityThemeGallery.svelte new file mode 100644 index 000000000..907722ead --- /dev/null +++ b/packages/shared-theme-ui/src/components/community/CommunityThemeGallery.svelte @@ -0,0 +1,266 @@ + + +
+ +
+ +
+ + +
+ + +
+ + +
+ + + +
+ + +
+ + {#each commonTags as tag} + + {/each} + + {#if hasActiveFilters} + + {/if} +
+ + +
+ {#if loading} + Lade... + {:else} + {pagination.total} Themes gefunden + {/if} +
+ + +
+ {#if themes.length === 0 && !loading} +
+

Keine Themes gefunden

+

Versuche andere Suchbegriffe oder Filter.

+ {#if hasActiveFilters} + + {/if} +
+ {:else} + {#each themes as theme (theme.id)} + + {/each} + {/if} +
+ + + {#if pagination.totalPages > 1} +
+ + +
+ {pagination.page} + / + {pagination.totalPages} +
+ + +
+ {/if} +
diff --git a/packages/shared-theme-ui/src/components/community/ThemeCommunityCard.svelte b/packages/shared-theme-ui/src/components/community/ThemeCommunityCard.svelte new file mode 100644 index 000000000..f3451e5ee --- /dev/null +++ b/packages/shared-theme-ui/src/components/community/ThemeCommunityCard.svelte @@ -0,0 +1,197 @@ + + +
onSelect?.(theme)} + onkeypress={(e) => e.key === 'Enter' && onSelect?.(theme)} + role="button" + tabindex="0" +> + +
+
+
+
+
+
+
+ + {#if theme.isFeatured} +
+ + Featured +
+ {/if} +
+ + +
+
+ {theme.emoji} +
+

{theme.name}

+ {#if theme.authorName} + von {theme.authorName} + {/if} +
+
+ + {#if theme.description} +

{theme.description}

+ {/if} + + + {#if theme.tags.length > 0} +
+ {#each theme.tags.slice(0, 3) as tag} + {tag} + {/each} + {#if theme.tags.length > 3} + +{theme.tags.length - 3} + {/if} +
+ {/if} + + +
+
+ + {formatCount(theme.downloadCount)} +
+ +
+ {#each [1, 2, 3, 4, 5] as star} + + {/each} + ({theme.ratingCount}) +
+
+ + +
+ {#if showDownload} + + {/if} + + {#if showFavorite} + + {/if} +
+
+
diff --git a/packages/shared-theme-ui/src/components/editor/ColorPicker.svelte b/packages/shared-theme-ui/src/components/editor/ColorPicker.svelte new file mode 100644 index 000000000..6c2e3a216 --- /dev/null +++ b/packages/shared-theme-ui/src/components/editor/ColorPicker.svelte @@ -0,0 +1,349 @@ + + +
+ {#if label} + + {/if} + +
+ +
+ + {#if showHexInput} + + {/if} +
+ +
+ +
+ H + + {hsl.h} +
+ + +
+ S + + {hsl.s}% +
+ + +
+ L + + {hsl.l}% +
+
+
+ + diff --git a/packages/shared-theme-ui/src/components/editor/ThemeEditor.svelte b/packages/shared-theme-ui/src/components/editor/ThemeEditor.svelte new file mode 100644 index 000000000..697aa4391 --- /dev/null +++ b/packages/shared-theme-ui/src/components/editor/ThemeEditor.svelte @@ -0,0 +1,542 @@ + + +
+ +
+
+
+ + +
+ +
+ + +
+
+ +
+ Basis: +
+ {#each THEME_VARIANTS as variantName} + {@const variant = THEME_DEFINITIONS[variantName]} + + {/each} +
+
+
+ + +
+
+

Farben

+
+ + +
+
+ + +
+ {#each MAIN_THEME_COLORS as colorKey} +
+ updateColor(colorKey, value)} + compact + /> +
+ {/each} +
+ + + + + {#if showExtendedColors} +
+ {#each EXTENDED_THEME_COLORS as colorKey} +
+ updateColor(colorKey, value)} + compact + /> +
+ {/each} +
+ {/if} +
+ + +
+ {#if baseVariant} + + {/if} + + {#if showSaveButton && onSave} + + {/if} +
+
+ + diff --git a/packages/shared-theme-ui/src/components/editor/ThemeLivePreview.svelte b/packages/shared-theme-ui/src/components/editor/ThemeLivePreview.svelte new file mode 100644 index 000000000..e4dc49686 --- /dev/null +++ b/packages/shared-theme-ui/src/components/editor/ThemeLivePreview.svelte @@ -0,0 +1,485 @@ + + +
+
+ {title} + {#if onModeChange} +
+ + +
+ {:else} + {mode === 'light' ? 'Hell' : 'Dunkel'} + {/if} +
+ +
+ +
+ +
+ + +
+
+ + +
+ +
+
+
+
+
Max Mustermann
+
Beispiel-Kontakt
+
+ +
+
+

Dies ist eine Vorschau, wie dein Theme in der App aussehen wird.

+
+
+ + +
+
+ + +
+ Erfolgreich + Ausstehend + Fehler +
+ + +
+ +
+
+ + +
+ + + +
+
+
+ + diff --git a/packages/shared-theme-ui/src/index.ts b/packages/shared-theme-ui/src/index.ts index 546dd0e6d..fb0d593bc 100644 --- a/packages/shared-theme-ui/src/index.ts +++ b/packages/shared-theme-ui/src/index.ts @@ -12,8 +12,19 @@ export { default as ThemeGrid } from './components/ThemeGrid.svelte'; export { default as A11ySettings } from './components/A11ySettings.svelte'; export { default as A11yQuickToggles } from './components/A11yQuickToggles.svelte'; +// Theme Editor Components +export { default as ColorPicker } from './components/editor/ColorPicker.svelte'; +export { default as ThemeEditor } from './components/editor/ThemeEditor.svelte'; +export { default as ThemeLivePreview } from './components/editor/ThemeLivePreview.svelte'; + +// Community Theme Components +export { default as ThemeCommunityCard } from './components/community/ThemeCommunityCard.svelte'; +export { default as CommunityThemeGallery } from './components/community/CommunityThemeGallery.svelte'; + // Pages export { default as ThemePage } from './pages/ThemePage.svelte'; +export { default as ThemeEditorPage } from './pages/ThemeEditorPage.svelte'; +export { default as CommunityThemesPage } from './pages/CommunityThemesPage.svelte'; // Types export type { diff --git a/packages/shared-theme-ui/src/pages/CommunityThemesPage.svelte b/packages/shared-theme-ui/src/pages/CommunityThemesPage.svelte new file mode 100644 index 000000000..62d6bf5cc --- /dev/null +++ b/packages/shared-theme-ui/src/pages/CommunityThemesPage.svelte @@ -0,0 +1,312 @@ + + +
+ +
+ {#if onBack} + + {/if} +
+

{title}

+

Entdecke von der Community erstellte Themes

+
+
+ + +
+ +
+ + +
+ {#if activeTab === 'browse'} + + {:else if activeTab === 'favorites'} + {#if store.favorites.length === 0 && !store.loading} +
+ +

Keine Favoriten

+

Themes, die du favorisierst, werden hier angezeigt.

+ +
+ {:else} +
+ {#each store.favorites as theme (theme.id)} + {@const colors = effectiveMode === 'dark' ? theme.darkColors : theme.lightColors} +
onSelectTheme?.(theme)} + onkeypress={(e) => e.key === 'Enter' && onSelectTheme?.(theme)} + role="button" + tabindex="0" + > +
+
+
+
+
+
+
+ {theme.emoji} + {theme.name} +
+
+ + +
+
+
+ {/each} +
+ {/if} + {:else if activeTab === 'downloaded'} + {#if store.downloaded.length === 0 && !store.loading} +
+ +

Keine installierten Themes

+

Themes, die du installierst, werden hier angezeigt.

+ +
+ {:else} +
+ {#each store.downloaded as theme (theme.id)} + {@const colors = effectiveMode === 'dark' ? theme.darkColors : theme.lightColors} +
onSelectTheme?.(theme)} + onkeypress={(e) => e.key === 'Enter' && onSelectTheme?.(theme)} + role="button" + tabindex="0" + > +
+
+
+
+
+
+
+ {theme.emoji} + {theme.name} +
+
+ +
+
+
+ {/each} +
+ {/if} + {/if} +
+
diff --git a/packages/shared-theme-ui/src/pages/ThemeEditorPage.svelte b/packages/shared-theme-ui/src/pages/ThemeEditorPage.svelte new file mode 100644 index 000000000..52623a24b --- /dev/null +++ b/packages/shared-theme-ui/src/pages/ThemeEditorPage.svelte @@ -0,0 +1,276 @@ + + +
+ + + + +
+ +
+ +
+ + +
+
+ {#if displayColors} + (previewMode = m)} + /> + {:else} +
+

Wähle eine Basis-Variante oder passe Farben an, um eine Vorschau zu sehen

+
+ {/if} +
+
+
+
+ + diff --git a/packages/shared-theme-ui/src/pages/ThemePage.svelte b/packages/shared-theme-ui/src/pages/ThemePage.svelte index 0b7ec550a..eac9adee8 100644 --- a/packages/shared-theme-ui/src/pages/ThemePage.svelte +++ b/packages/shared-theme-ui/src/pages/ThemePage.svelte @@ -1,11 +1,29 @@
-
+
{#if showBackButton && onBack}
+ + {#if showCustomThemes && customThemesStore} + + {/if} + {#if showModeSelector && onModeChange} -
+

{t.modeLabel}

@@ -117,20 +207,154 @@
{/if} - -
-

- {t.currentTheme} -

- -
+ + {#if !showCustomThemes || activeTab === 'themes'} + +
+

+ {t.currentTheme} +

+ +
+ {:else if activeTab === 'custom' && customThemesStore} + +
+
+

Meine Themes

+ {#if onCreateTheme} + + {/if} +
+ + {#if customThemesStore.loading} +
Lade...
+ {:else if customThemesStore.customThemes.length === 0} +
+ +

Noch keine eigenen Themes

+

+ Erstelle dein erstes eigenes Theme mit individuellen Farben. +

+ {#if onCreateTheme} + + {/if} +
+ {:else} +
+ {#each customThemesStore.customThemes as theme (theme.id)} +
+ +
+
+
+
+
+
+
+
+ {theme.emoji} +
+

{theme.name}

+ {#if theme.description} +

{theme.description}

+ {/if} +
+ {#if theme.isPublished} + + Veröffentlicht + + {/if} +
+
+ + {#if onEditTheme} + + {/if} + +
+
+
+ {/each} +
+ {/if} +
+ {:else if activeTab === 'community'} + +
+
+ +

Community Themes

+

+ Entdecke Themes, die von anderen Nutzern erstellt wurden. +

+ {#if onCommunityThemes} + + {/if} +
+
+ {/if} {#if showA11ySettings && a11yStore} diff --git a/packages/shared-theme/src/app-routes.ts b/packages/shared-theme/src/app-routes.ts index dcfa573c1..7ace3fb16 100644 --- a/packages/shared-theme/src/app-routes.ts +++ b/packages/shared-theme/src/app-routes.ts @@ -6,15 +6,17 @@ */ /** - * Route definition with i18n label + * Route definition with label */ export interface AppRoute { /** Route path (e.g., '/stopwatch') */ path: string; - /** i18n key for the label (e.g., 'nav.stopwatch') */ - labelKey: string; + /** Display label for the route (e.g., 'Stoppuhr') */ + label: string; /** Optional icon name */ icon?: string; + /** If true, this route cannot be hidden (e.g., Settings, Home) */ + alwaysVisible?: boolean; } /** @@ -37,13 +39,14 @@ export const APP_ROUTES: Record = { appId: 'clock', defaultRoute: '/', availableRoutes: [ - { path: '/', labelKey: 'nav.dashboard', icon: 'home' }, - { path: '/alarms', labelKey: 'nav.alarms', icon: 'alarm' }, - { path: '/timers', labelKey: 'nav.timers', icon: 'timer' }, - { path: '/stopwatch', labelKey: 'nav.stopwatch', icon: 'stopwatch' }, - { path: '/pomodoro', labelKey: 'nav.pomodoro', icon: 'target' }, - { path: '/world-clock', labelKey: 'nav.worldClock', icon: 'globe' }, - { path: '/life', labelKey: 'nav.lifeClock', icon: 'heart' }, + { path: '/', label: 'Dashboard', icon: 'home', alwaysVisible: true }, + { path: '/alarms', label: 'Wecker', icon: 'alarm' }, + { path: '/timers', label: 'Timer', icon: 'timer' }, + { path: '/stopwatch', label: 'Stoppuhr', icon: 'stopwatch' }, + { path: '/pomodoro', label: 'Pomodoro', icon: 'target' }, + { path: '/world-clock', label: 'Weltuhr', icon: 'globe' }, + { path: '/life', label: 'Lebenszeit', icon: 'heart' }, + { path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true }, ], }, @@ -51,8 +54,11 @@ export const APP_ROUTES: Record = { appId: 'calendar', defaultRoute: '/', availableRoutes: [ - { path: '/', labelKey: 'nav.month', icon: 'calendar' }, - { path: '/agenda', labelKey: 'nav.agenda', icon: 'list' }, + { path: '/', label: 'Kalender', icon: 'calendar', alwaysVisible: true }, + { path: '/agenda', label: 'Agenda', icon: 'list' }, + { path: '/tags', label: 'Tags', icon: 'tag' }, + { path: '/network', label: 'Netzwerk', icon: 'share' }, + { path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true }, ], }, @@ -60,20 +66,14 @@ export const APP_ROUTES: Record = { appId: 'contacts', defaultRoute: '/', availableRoutes: [ - { path: '/', labelKey: 'nav.contacts', icon: 'users' }, - { path: '/groups', labelKey: 'nav.groups', icon: 'folder' }, - { path: '/favorites', labelKey: 'nav.favorites', icon: 'star' }, - ], - }, - - mail: { - appId: 'mail', - defaultRoute: '/', - availableRoutes: [ - { path: '/', labelKey: 'nav.inbox', icon: 'inbox' }, - { path: '/sent', labelKey: 'nav.sent', icon: 'send' }, - { path: '/drafts', labelKey: 'nav.drafts', icon: 'file' }, - { path: '/starred', labelKey: 'nav.starred', icon: 'star' }, + { path: '/', label: 'Kontakte', icon: 'users', alwaysVisible: true }, + { path: '/favorites', label: 'Favoriten', icon: 'star' }, + { path: '/tags', label: 'Tags', icon: 'tag' }, + { path: '/archive', label: 'Archiv', icon: 'archive' }, + { path: '/duplicates', label: 'Duplikate', icon: 'copy' }, + { path: '/data', label: 'Import/Export', icon: 'download' }, + { path: '/network', label: 'Netzwerk', icon: 'share' }, + { path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true }, ], }, @@ -81,21 +81,12 @@ export const APP_ROUTES: Record = { appId: 'todo', defaultRoute: '/', availableRoutes: [ - { path: '/', labelKey: 'nav.all', icon: 'list' }, - { path: '/today', labelKey: 'nav.today', icon: 'calendar' }, - { path: '/upcoming', labelKey: 'nav.upcoming', icon: 'clock' }, - { path: '/completed', labelKey: 'nav.completed', icon: 'check' }, - ], - }, - - storage: { - appId: 'storage', - defaultRoute: '/', - availableRoutes: [ - { path: '/', labelKey: 'nav.home', icon: 'home' }, - { path: '/files', labelKey: 'nav.files', icon: 'folder' }, - { path: '/favorites', labelKey: 'nav.favorites', icon: 'star' }, - { path: '/shared', labelKey: 'nav.shared', icon: 'share' }, + { path: '/', label: 'Aufgaben', icon: 'list', alwaysVisible: true }, + { path: '/kanban', label: 'Kanban', icon: 'grid' }, + { path: '/labels', label: 'Labels', icon: 'tag' }, + { path: '/statistics', label: 'Statistiken', icon: 'chart' }, + { path: '/network', label: 'Netzwerk', icon: 'share' }, + { path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true }, ], }, @@ -103,10 +94,13 @@ export const APP_ROUTES: Record = { appId: 'chat', defaultRoute: '/chat', availableRoutes: [ - { path: '/chat', labelKey: 'nav.chat', icon: 'message' }, - { path: '/spaces', labelKey: 'nav.spaces', icon: 'folder' }, - { path: '/templates', labelKey: 'nav.templates', icon: 'file' }, - { path: '/documents', labelKey: 'nav.documents', icon: 'document' }, + { path: '/chat', label: 'Chat', icon: 'message', alwaysVisible: true }, + { path: '/templates', label: 'Vorlagen', icon: 'file' }, + { path: '/spaces', label: 'Spaces', icon: 'folder' }, + { path: '/documents', label: 'Dokumente', icon: 'document' }, + { path: '/archive', label: 'Archiv', icon: 'archive' }, + { path: '/feedback', label: 'Feedback', icon: 'chat' }, + { path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true }, ], }, @@ -114,10 +108,14 @@ export const APP_ROUTES: Record = { appId: 'picture', defaultRoute: '/app/gallery', availableRoutes: [ - { path: '/app/gallery', labelKey: 'nav.gallery', icon: 'image' }, - { path: '/app/generate', labelKey: 'nav.generate', icon: 'sparkle' }, - { path: '/app/board', labelKey: 'nav.board', icon: 'grid' }, - { path: '/app/explore', labelKey: 'nav.explore', icon: 'compass' }, + { path: '/app/gallery', label: 'Galerie', icon: 'image', alwaysVisible: true }, + { path: '/app/board', label: 'Moodboards', icon: 'grid' }, + { path: '/app/explore', label: 'Entdecken', icon: 'compass' }, + { path: '/app/generate', label: 'Generieren', icon: 'sparkle' }, + { path: '/app/upload', label: 'Upload', icon: 'upload' }, + { path: '/app/tags', label: 'Tags', icon: 'tag' }, + { path: '/app/archive', label: 'Archiv', icon: 'archive' }, + { path: '/app/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true }, ], }, @@ -125,9 +123,10 @@ export const APP_ROUTES: Record = { appId: 'manadeck', defaultRoute: '/decks', availableRoutes: [ - { path: '/decks', labelKey: 'nav.decks', icon: 'layers' }, - { path: '/explore', labelKey: 'nav.explore', icon: 'compass' }, - { path: '/progress', labelKey: 'nav.progress', icon: 'trending' }, + { path: '/decks', label: 'Decks', icon: 'layers', alwaysVisible: true }, + { path: '/explore', label: 'Entdecken', icon: 'compass' }, + { path: '/progress', label: 'Fortschritt', icon: 'trending' }, + { path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true }, ], }, @@ -135,24 +134,23 @@ export const APP_ROUTES: Record = { appId: 'zitare', defaultRoute: '/', availableRoutes: [ - { path: '/', labelKey: 'nav.home', icon: 'home' }, - { path: '/quotes', labelKey: 'nav.quotes', icon: 'quote' }, - { path: '/favorites', labelKey: 'nav.favorites', icon: 'star' }, - { path: '/authors', labelKey: 'nav.authors', icon: 'users' }, - { path: '/lists', labelKey: 'nav.lists', icon: 'list' }, + { path: '/', label: 'Zitate', icon: 'quote', alwaysVisible: true }, + { path: '/search', label: 'Suche', icon: 'search' }, + { path: '/authors', label: 'Autoren', icon: 'users' }, + { path: '/favorites', label: 'Favoriten', icon: 'star' }, + { path: '/lists', label: 'Listen', icon: 'list' }, + { path: '/feedback', label: 'Feedback', icon: 'chat' }, + { path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true }, ], }, - presi: { - appId: 'presi', - defaultRoute: '/', - availableRoutes: [{ path: '/', labelKey: 'nav.home', icon: 'home' }], - }, - manacore: { appId: 'manacore', defaultRoute: '/', - availableRoutes: [{ path: '/', labelKey: 'nav.dashboard', icon: 'home' }], + availableRoutes: [ + { path: '/', label: 'Dashboard', icon: 'home', alwaysVisible: true }, + { path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true }, + ], }, }; @@ -199,3 +197,46 @@ export function getAvailableRoutes(appId: string): AppRoute[] { export function getDefaultRoute(appId: string): string { return APP_ROUTES[appId]?.defaultRoute ?? '/'; } + +/** + * Filter hidden navigation items from a list of nav items + * @param appId The app identifier + * @param items Array of nav items with href property + * @param hiddenNavItems Hidden items config (appId -> hidden paths) + * @returns Filtered array with hidden items removed + */ +export function filterHiddenNavItems( + appId: string, + items: T[], + hiddenNavItems: Record = {} +): T[] { + const hidden = hiddenNavItems[appId] || []; + return items.filter((item) => !hidden.includes(item.href)); +} + +/** + * Get routes that can be hidden for a specific app + * (excludes routes marked as alwaysVisible) + * @param appId The app identifier + * @returns Array of routes that can be hidden + */ +export function getHideableRoutes(appId: string): AppRoute[] { + const config = APP_ROUTES[appId]; + return config?.availableRoutes.filter((r) => !r.alwaysVisible) || []; +} + +/** + * Check if a route is hidden for a specific app + * @param appId The app identifier + * @param path The route path + * @param hiddenNavItems Hidden items config + * @returns True if the route is hidden + */ +export function isRouteHidden( + appId: string, + path: string, + hiddenNavItems: Record = {} +): boolean { + const hidden = hiddenNavItems[appId] || []; + return hidden.includes(path); +} diff --git a/packages/shared-theme/src/constants.ts b/packages/shared-theme/src/constants.ts index 7d5d4888e..d3fb7ab2c 100644 --- a/packages/shared-theme/src/constants.ts +++ b/packages/shared-theme/src/constants.ts @@ -8,6 +8,10 @@ export const THEME_VARIANTS: readonly ThemeVariant[] = [ 'nature', 'stone', 'ocean', + 'sunset', + 'midnight', + 'rose', + 'lavender', ] as const; /** @@ -194,6 +198,178 @@ const oceanDark: ThemeColors = { ring: '199 98% 48%', }; +// ============================================================================ +// Extended Themes: Sunset, Midnight, Rose, Lavender +// ============================================================================ + +const sunsetLight: ThemeColors = { + primary: '15 90% 55%', // Coral/Orange + primaryForeground: '0 0% 100%', + secondary: '25 100% 60%', // Warm orange + secondaryForeground: '0 0% 0%', + background: '30 50% 97%', // Warm cream + foreground: '15 50% 20%', // Dark warm brown + surface: '0 0% 100%', + surfaceHover: '30 40% 95%', + surfaceElevated: '0 0% 100%', + muted: '30 30% 93%', + mutedForeground: '15 20% 45%', + border: '30 25% 88%', + borderStrong: '30 30% 75%', + error: '0 72% 55%', + success: '145 63% 42%', + warning: '36 100% 50%', + input: '0 0% 100%', + ring: '15 90% 55%', +}; + +const sunsetDark: ThemeColors = { + primary: '15 85% 58%', // Brighter coral in dark + primaryForeground: '0 0% 0%', + secondary: '25 60% 35%', + secondaryForeground: '0 0% 100%', + background: '15 20% 8%', // Dark with warm tint + foreground: '0 0% 100%', + surface: '15 15% 12%', + surfaceHover: '15 15% 16%', + surfaceElevated: '15 15% 14%', + muted: '15 12% 20%', + mutedForeground: '15 10% 60%', + border: '15 12% 25%', + borderStrong: '15 12% 35%', + error: '0 72% 55%', + success: '145 63% 49%', + warning: '48 100% 50%', + input: '15 20% 14%', + ring: '15 85% 58%', +}; + +const midnightLight: ThemeColors = { + primary: '260 70% 55%', // Deep purple/violet + primaryForeground: '0 0% 100%', + secondary: '270 60% 70%', // Lighter purple + secondaryForeground: '0 0% 0%', + background: '260 30% 97%', // Very light purple tint + foreground: '260 50% 20%', // Dark purple text + surface: '0 0% 100%', + surfaceHover: '260 25% 95%', + surfaceElevated: '0 0% 100%', + muted: '260 20% 93%', + mutedForeground: '260 20% 45%', + border: '260 20% 88%', + borderStrong: '260 25% 75%', + error: '0 72% 55%', + success: '145 63% 42%', + warning: '36 100% 50%', + input: '0 0% 100%', + ring: '260 70% 55%', +}; + +const midnightDark: ThemeColors = { + primary: '260 65% 60%', // Brighter purple in dark + primaryForeground: '0 0% 100%', + secondary: '270 40% 35%', + secondaryForeground: '0 0% 100%', + background: '260 25% 7%', // Deep dark purple + foreground: '0 0% 100%', + surface: '260 20% 11%', + surfaceHover: '260 20% 15%', + surfaceElevated: '260 20% 13%', + muted: '260 15% 19%', + mutedForeground: '260 12% 60%', + border: '260 15% 24%', + borderStrong: '260 15% 34%', + error: '0 72% 55%', + success: '145 63% 49%', + warning: '48 100% 50%', + input: '260 25% 14%', + ring: '260 65% 60%', +}; + +const roseLight: ThemeColors = { + primary: '340 80% 55%', // Pink/Magenta + primaryForeground: '0 0% 100%', + secondary: '350 70% 70%', // Lighter pink + secondaryForeground: '0 0% 0%', + background: '340 40% 97%', // Very light pink tint + foreground: '340 50% 20%', // Dark rose text + surface: '0 0% 100%', + surfaceHover: '340 30% 95%', + surfaceElevated: '0 0% 100%', + muted: '340 25% 93%', + mutedForeground: '340 20% 45%', + border: '340 25% 88%', + borderStrong: '340 30% 75%', + error: '0 72% 55%', + success: '145 63% 42%', + warning: '36 100% 50%', + input: '0 0% 100%', + ring: '340 80% 55%', +}; + +const roseDark: ThemeColors = { + primary: '340 75% 60%', // Brighter pink in dark + primaryForeground: '0 0% 100%', + secondary: '350 45% 35%', + secondaryForeground: '0 0% 100%', + background: '340 20% 8%', // Dark with pink tint + foreground: '0 0% 100%', + surface: '340 15% 12%', + surfaceHover: '340 15% 16%', + surfaceElevated: '340 15% 14%', + muted: '340 12% 20%', + mutedForeground: '340 10% 60%', + border: '340 12% 25%', + borderStrong: '340 12% 35%', + error: '0 72% 55%', + success: '145 63% 49%', + warning: '48 100% 50%', + input: '340 20% 14%', + ring: '340 75% 60%', +}; + +const lavenderLight: ThemeColors = { + primary: '270 60% 60%', // Lavender/Light purple + primaryForeground: '0 0% 100%', + secondary: '280 50% 75%', // Softer purple + secondaryForeground: '0 0% 0%', + background: '270 35% 97%', // Very light lavender + foreground: '270 40% 22%', // Dark lavender text + surface: '0 0% 100%', + surfaceHover: '270 25% 95%', + surfaceElevated: '0 0% 100%', + muted: '270 20% 93%', + mutedForeground: '270 18% 45%', + border: '270 20% 88%', + borderStrong: '270 25% 78%', + error: '0 72% 55%', + success: '145 63% 42%', + warning: '36 100% 50%', + input: '0 0% 100%', + ring: '270 60% 60%', +}; + +const lavenderDark: ThemeColors = { + primary: '270 55% 65%', // Brighter lavender in dark + primaryForeground: '0 0% 0%', + secondary: '280 35% 38%', + secondaryForeground: '0 0% 100%', + background: '270 20% 8%', // Dark with lavender tint + foreground: '0 0% 100%', + surface: '270 15% 12%', + surfaceHover: '270 15% 16%', + surfaceElevated: '270 15% 14%', + muted: '270 12% 20%', + mutedForeground: '270 10% 60%', + border: '270 12% 25%', + borderStrong: '270 12% 35%', + error: '0 72% 55%', + success: '145 63% 49%', + warning: '48 100% 50%', + input: '270 20% 14%', + ring: '270 55% 65%', +}; + /** * Complete theme variant definitions */ @@ -234,6 +410,43 @@ export const THEME_DEFINITIONS: Record = { light: oceanLight, dark: oceanDark, }, + // Extended themes (not in PillNav by default, can be pinned) + sunset: { + name: 'sunset', + label: 'Sunset', + emoji: '🌅', + icon: 'sun', + hue: 15, + light: sunsetLight, + dark: sunsetDark, + }, + midnight: { + name: 'midnight', + label: 'Midnight', + emoji: '🌙', + icon: 'moon', + hue: 260, + light: midnightLight, + dark: midnightDark, + }, + rose: { + name: 'rose', + label: 'Rose', + emoji: '🌹', + icon: 'flower', + hue: 340, + light: roseLight, + dark: roseDark, + }, + lavender: { + name: 'lavender', + label: 'Lavender', + emoji: '💜', + icon: 'sparkle', + hue: 270, + light: lavenderLight, + dark: lavenderDark, + }, }; /** diff --git a/packages/shared-theme/src/custom-themes-store.svelte.ts b/packages/shared-theme/src/custom-themes-store.svelte.ts new file mode 100644 index 000000000..888c6a11a --- /dev/null +++ b/packages/shared-theme/src/custom-themes-store.svelte.ts @@ -0,0 +1,506 @@ +import type { + CustomTheme, + CommunityTheme, + CreateCustomThemeInput, + UpdateCustomThemeInput, + PublishThemeInput, + CommunityThemeQuery, + PaginatedCommunityThemes, + CustomThemesStore, + CustomThemesStoreConfig, + ThemeColors, + EffectiveMode, +} from './types'; +import { isBrowser } from './utils'; + +/** + * Apply a custom theme's colors to the document as CSS variables + */ +function applyCustomThemeToDocument( + colors: ThemeColors, + effectiveMode: EffectiveMode = 'light' +): void { + if (!isBrowser()) return; + + const root = document.documentElement; + + // Apply all color variables + Object.entries(colors).forEach(([key, value]) => { + // Convert camelCase to kebab-case + const cssVar = key.replace(/([A-Z])/g, '-$1').toLowerCase(); + root.style.setProperty(`--${cssVar}`, value); + }); + + // Set mode class + root.classList.remove('light', 'dark'); + root.classList.add(effectiveMode); + + // Mark as custom theme + root.setAttribute('data-custom-theme', 'true'); +} + +/** + * Clear custom theme and revert to standard theme + */ +function clearCustomThemeFromDocument(): void { + if (!isBrowser()) return; + + const root = document.documentElement; + + // Remove custom theme marker + root.removeAttribute('data-custom-theme'); + + // Clear inline styles (CSS vars will fall back to theme variant) + root.style.cssText = ''; +} + +/** + * Create a custom themes store for managing user's custom themes and community themes + * + * @example + * ```typescript + * import { createCustomThemesStore } from '@manacore/shared-theme'; + * import { authStore } from '$lib/stores/auth.svelte'; + * + * export const customThemesStore = createCustomThemesStore({ + * authUrl: import.meta.env.PUBLIC_AUTH_URL, + * getAccessToken: () => authStore.getAccessToken(), + * }); + * ``` + */ +export function createCustomThemesStore(config: CustomThemesStoreConfig): CustomThemesStore { + const { authUrl, getAccessToken } = config; + + // State + let customThemes = $state([]); + let communityThemes = $state([]); + let favorites = $state([]); + let downloaded = $state([]); + let pagination = $state({ page: 1, totalPages: 1, total: 0 }); + let loading = $state(false); + let error = $state(null); + + // Track currently applied custom theme + let appliedThemeId = $state(null); + + /** + * Make an authenticated API request + */ + async function apiRequest(endpoint: string, options: RequestInit = {}): Promise { + const token = await getAccessToken(); + if (!token) { + throw new Error('Not authenticated'); + } + + const url = `${authUrl}${endpoint}`; + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + ...options.headers, + }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || `Request failed: ${response.status}`); + } + + // Handle 204 No Content + if (response.status === 204) { + return undefined as T; + } + + return response.json(); + } + + /** + * Make a public API request (no auth required) + */ + async function publicApiRequest(endpoint: string, options: RequestInit = {}): Promise { + const url = `${authUrl}${endpoint}`; + const token = await getAccessToken(); + + const headers: Record = { + 'Content-Type': 'application/json', + }; + + // Add auth if available (for user-specific data like favorites) + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const response = await fetch(url, { + ...options, + headers: { + ...headers, + ...options.headers, + }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || `Request failed: ${response.status}`); + } + + return response.json(); + } + + // ==================== Custom Theme Operations ==================== + + /** + * Load user's custom themes + */ + async function loadCustomThemes(): Promise { + loading = true; + error = null; + + try { + customThemes = await apiRequest('/api/v1/themes'); + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to load themes'; + throw err; + } finally { + loading = false; + } + } + + /** + * Create a new custom theme + */ + async function createTheme(input: CreateCustomThemeInput): Promise { + loading = true; + error = null; + + try { + const theme = await apiRequest('/api/v1/themes', { + method: 'POST', + body: JSON.stringify(input), + }); + customThemes = [...customThemes, theme]; + return theme; + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to create theme'; + throw err; + } finally { + loading = false; + } + } + + /** + * Update an existing custom theme + */ + async function updateTheme(id: string, input: UpdateCustomThemeInput): Promise { + loading = true; + error = null; + + try { + const theme = await apiRequest(`/api/v1/themes/${id}`, { + method: 'PATCH', + body: JSON.stringify(input), + }); + customThemes = customThemes.map((t) => (t.id === id ? theme : t)); + return theme; + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to update theme'; + throw err; + } finally { + loading = false; + } + } + + /** + * Delete a custom theme + */ + async function deleteTheme(id: string): Promise { + loading = true; + error = null; + + try { + await apiRequest(`/api/v1/themes/${id}`, { + method: 'DELETE', + }); + customThemes = customThemes.filter((t) => t.id !== id); + + // Clear applied theme if it was the deleted one + if (appliedThemeId === id) { + clearCustomTheme(); + } + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to delete theme'; + throw err; + } finally { + loading = false; + } + } + + /** + * Publish a custom theme to the community + */ + async function publishTheme(id: string, input?: PublishThemeInput): Promise { + loading = true; + error = null; + + try { + const communityTheme = await apiRequest(`/api/v1/themes/${id}/publish`, { + method: 'POST', + body: JSON.stringify(input || {}), + }); + + // Update the custom theme's isPublished status + customThemes = customThemes.map((t) => (t.id === id ? { ...t, isPublished: true } : t)); + + return communityTheme; + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to publish theme'; + throw err; + } finally { + loading = false; + } + } + + // ==================== Community Theme Operations ==================== + + /** + * Browse community themes with filtering/sorting + */ + async function browseCommunity(query?: CommunityThemeQuery): Promise { + loading = true; + error = null; + + try { + const params = new URLSearchParams(); + if (query?.page) params.set('page', String(query.page)); + if (query?.limit) params.set('limit', String(query.limit)); + if (query?.sort) params.set('sort', query.sort); + if (query?.search) params.set('search', query.search); + if (query?.authorId) params.set('authorId', query.authorId); + if (query?.featuredOnly) params.set('featuredOnly', 'true'); + if (query?.tags?.length) { + query.tags.forEach((tag) => params.append('tags', tag)); + } + + const queryString = params.toString(); + const endpoint = `/api/v1/community-themes${queryString ? `?${queryString}` : ''}`; + + const result = await publicApiRequest(endpoint); + communityThemes = result.themes; + pagination = { + page: result.page, + totalPages: result.totalPages, + total: result.total, + }; + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to browse community themes'; + throw err; + } finally { + loading = false; + } + } + + /** + * Download/install a community theme + */ + async function downloadTheme(id: string): Promise { + loading = true; + error = null; + + try { + const theme = await apiRequest(`/api/v1/community-themes/${id}/download`, { + method: 'POST', + }); + + // Update download status in community themes list + communityThemes = communityThemes.map((t) => + t.id === id ? { ...t, isDownloaded: true, downloadCount: theme.downloadCount } : t + ); + + // Add to downloaded list if not already there + if (!downloaded.some((t) => t.id === id)) { + downloaded = [...downloaded, theme]; + } + + return theme; + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to download theme'; + throw err; + } finally { + loading = false; + } + } + + /** + * Rate a community theme + */ + async function rateTheme( + id: string, + rating: number + ): Promise<{ averageRating: number; ratingCount: number }> { + error = null; + + try { + const result = await apiRequest<{ averageRating: number; ratingCount: number }>( + `/api/v1/community-themes/${id}/rate`, + { + method: 'POST', + body: JSON.stringify({ rating }), + } + ); + + // Update rating in community themes list + communityThemes = communityThemes.map((t) => + t.id === id + ? { + ...t, + averageRating: result.averageRating, + ratingCount: result.ratingCount, + userRating: rating, + } + : t + ); + + return result; + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to rate theme'; + throw err; + } + } + + /** + * Toggle favorite status for a community theme + */ + async function toggleFavorite(id: string): Promise<{ isFavorited: boolean }> { + error = null; + + try { + const result = await apiRequest<{ isFavorited: boolean }>( + `/api/v1/community-themes/${id}/favorite`, + { method: 'POST' } + ); + + // Update favorite status in community themes list + communityThemes = communityThemes.map((t) => + t.id === id ? { ...t, isFavorited: result.isFavorited } : t + ); + + // Update favorites list + if (result.isFavorited) { + const theme = communityThemes.find((t) => t.id === id); + if (theme && !favorites.some((t) => t.id === id)) { + favorites = [...favorites, { ...theme, isFavorited: true }]; + } + } else { + favorites = favorites.filter((t) => t.id !== id); + } + + return result; + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to toggle favorite'; + throw err; + } + } + + /** + * Load user's favorite themes + */ + async function loadFavorites(): Promise { + loading = true; + error = null; + + try { + favorites = await apiRequest('/api/v1/community-themes/favorites'); + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to load favorites'; + throw err; + } finally { + loading = false; + } + } + + /** + * Load user's downloaded themes + */ + async function loadDownloaded(): Promise { + loading = true; + error = null; + + try { + downloaded = await apiRequest('/api/v1/community-themes/downloaded'); + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to load downloaded themes'; + throw err; + } finally { + loading = false; + } + } + + // ==================== Apply Theme ==================== + + /** + * Apply a custom or community theme to the document + */ + function applyCustomTheme(theme: CustomTheme | CommunityTheme): void { + // Determine effective mode from system or stored preference + const effectiveMode: EffectiveMode = isBrowser() + ? window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light' + : 'light'; + + const colors = effectiveMode === 'dark' ? theme.darkColors : theme.lightColors; + applyCustomThemeToDocument(colors as ThemeColors, effectiveMode); + appliedThemeId = theme.id; + } + + /** + * Clear the applied custom theme and revert to standard theme + */ + function clearCustomTheme(): void { + clearCustomThemeFromDocument(); + appliedThemeId = null; + } + + return { + get customThemes() { + return customThemes; + }, + get communityThemes() { + return communityThemes; + }, + get favorites() { + return favorites; + }, + get downloaded() { + return downloaded; + }, + get pagination() { + return pagination; + }, + get loading() { + return loading; + }, + get error() { + return error; + }, + + // Custom theme operations + loadCustomThemes, + createTheme, + updateTheme, + deleteTheme, + publishTheme, + + // Community theme operations + browseCommunity, + downloadTheme, + rateTheme, + toggleFavorite, + loadFavorites, + loadDownloaded, + + // Apply theme + applyCustomTheme, + clearCustomTheme, + }; +} diff --git a/packages/shared-theme/src/index.ts b/packages/shared-theme/src/index.ts index 1719f7a70..9a5e51864 100644 --- a/packages/shared-theme/src/index.ts +++ b/packages/shared-theme/src/index.ts @@ -28,11 +28,29 @@ export type { StartPageConfig, WeekStartDay, GeneralSettings, + // Custom & Community Themes Types + ThemeColorsInput, + CustomTheme, + CreateCustomThemeInput, + UpdateCustomThemeInput, + CommunityTheme, + CommunityThemeQuery, + PaginatedCommunityThemes, + PublishThemeInput, + ThemeEditorState, + CustomThemesStore, + CustomThemesStoreConfig, } from './types'; // User Settings Constants export { DEFAULT_GLOBAL_SETTINGS, DEFAULT_GENERAL_SETTINGS } from './types'; +// Theme Variant Categories +export { DEFAULT_THEME_VARIANTS, EXTENDED_THEME_VARIANTS } from './types'; + +// Custom Theme Constants +export { MAIN_THEME_COLORS, EXTENDED_THEME_COLORS, THEME_COLOR_LABELS } from './types'; + // Constants export { THEME_VARIANTS, @@ -63,6 +81,9 @@ export { createA11yStore } from './a11y-store.svelte'; // User Settings Store export { createUserSettingsStore } from './user-settings-store.svelte'; +// Custom Themes Store +export { createCustomThemesStore } from './custom-themes-store.svelte'; + // Utils export { isBrowser, @@ -96,4 +117,12 @@ export { // App Routes export type { AppRoute, AppRouteConfig } from './app-routes'; -export { APP_ROUTES, getStartPage, getAvailableRoutes, getDefaultRoute } from './app-routes'; +export { + APP_ROUTES, + getStartPage, + getAvailableRoutes, + getDefaultRoute, + filterHiddenNavItems, + getHideableRoutes, + isRouteHidden, +} from './app-routes'; diff --git a/packages/shared-theme/src/types.ts b/packages/shared-theme/src/types.ts index a65f2d43f..964771bec 100644 --- a/packages/shared-theme/src/types.ts +++ b/packages/shared-theme/src/types.ts @@ -5,9 +5,28 @@ export type ThemeMode = 'light' | 'dark' | 'system'; /** * Theme variant - visual style/color scheme - * All apps share the same 4 variants + * Default variants (shown in PillNav): lume, nature, stone, ocean + * Extended variants (only on themes page, can be pinned): sunset, midnight, rose, lavender */ -export type ThemeVariant = 'lume' | 'nature' | 'stone' | 'ocean'; +export type ThemeVariant = + | 'lume' + | 'nature' + | 'stone' + | 'ocean' + | 'sunset' + | 'midnight' + | 'rose' + | 'lavender'; + +/** + * Default theme variants - always visible in PillNav + */ +export const DEFAULT_THEME_VARIANTS: ThemeVariant[] = ['lume', 'nature', 'stone', 'ocean']; + +/** + * Extended theme variants - only on themes page, can be pinned + */ +export const EXTENDED_THEME_VARIANTS: ThemeVariant[] = ['sunset', 'midnight', 'rose', 'lavender']; /** * Effective mode - the actual rendered mode (resolved from system preference) @@ -221,6 +240,8 @@ export interface NavSettings { desktopPosition: NavPosition; /** Whether sidebar is collapsed */ sidebarCollapsed: boolean; + /** Hidden navigation items per app (appId -> list of hidden paths) */ + hiddenNavItems?: Record; } /** @@ -258,6 +279,8 @@ export interface ThemeSettings { mode: ThemeMode; /** Color scheme / variant */ colorScheme: string; + /** Pinned themes to show in PillNav (extended themes only) */ + pinnedThemes?: ThemeVariant[]; } /** @@ -302,8 +325,8 @@ export const DEFAULT_GENERAL_SETTINGS: GeneralSettings = { * Default global settings */ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { - nav: { desktopPosition: 'top', sidebarCollapsed: false }, - theme: { mode: 'system', colorScheme: 'ocean' }, + nav: { desktopPosition: 'top', sidebarCollapsed: false, hiddenNavItems: {} }, + theme: { mode: 'system', colorScheme: 'ocean', pinnedThemes: [] }, locale: 'de', general: DEFAULT_GENERAL_SETTINGS, }; @@ -343,6 +366,12 @@ export interface UserSettingsStore { setStartPage: (appId: string, path: string) => Promise; /** Update general settings */ updateGeneral: (settings: Partial) => Promise; + /** Get hidden nav items for a specific app */ + getHiddenNavItemsForApp: (appId: string) => string[]; + /** Toggle visibility of a navigation item */ + toggleNavItemVisibility: (appId: string, href: string) => Promise; + /** Set hidden nav items for an app */ + setHiddenNavItems: (appId: string, hiddenHrefs: string[]) => Promise; } /** @@ -356,3 +385,260 @@ export interface UserSettingsStoreConfig { /** Function to get current access token */ getAccessToken: () => Promise; } + +// ============================================================================ +// Custom & Community Themes Types +// ============================================================================ + +/** + * Partial theme colors for API DTOs (some fields optional) + */ +export interface ThemeColorsInput { + primary: HSLValue; + primaryForeground?: HSLValue; + background: HSLValue; + foreground: HSLValue; + surface: HSLValue; + surfaceHover?: HSLValue; + surfaceElevated?: HSLValue; + muted?: HSLValue; + mutedForeground?: HSLValue; + border?: HSLValue; + borderStrong?: HSLValue; + secondary?: HSLValue; + secondaryForeground?: HSLValue; + input?: HSLValue; + ring?: HSLValue; + error: HSLValue; + success: HSLValue; + warning: HSLValue; +} + +/** + * User-created custom theme + */ +export interface CustomTheme { + id: string; + userId: string; + name: string; + description?: string; + emoji: string; + icon: string; + lightColors: ThemeColors; + darkColors: ThemeColors; + baseVariant?: ThemeVariant; + isPublished: boolean; + createdAt: Date; + updatedAt: Date; +} + +/** + * Input for creating a new custom theme + */ +export interface CreateCustomThemeInput { + name: string; + description?: string; + emoji?: string; + icon?: string; + lightColors: ThemeColorsInput; + darkColors: ThemeColorsInput; + baseVariant?: ThemeVariant; +} + +/** + * Input for updating a custom theme + */ +export interface UpdateCustomThemeInput { + name?: string; + description?: string; + emoji?: string; + icon?: string; + lightColors?: ThemeColorsInput; + darkColors?: ThemeColorsInput; + baseVariant?: ThemeVariant; +} + +/** + * Community theme shared publicly + */ +export interface CommunityTheme { + id: string; + authorId?: string; + authorName?: string; + name: string; + description?: string; + emoji: string; + icon: string; + lightColors: ThemeColors; + darkColors: ThemeColors; + baseVariant?: ThemeVariant; + downloadCount: number; + averageRating: number; + ratingCount: number; + status: 'pending' | 'approved' | 'rejected' | 'featured'; + isFeatured: boolean; + tags: string[]; + createdAt: Date; + publishedAt?: Date; + /** User-specific fields (when authenticated) */ + isFavorited?: boolean; + isDownloaded?: boolean; + userRating?: number; +} + +/** + * Query parameters for browsing community themes + */ +export interface CommunityThemeQuery { + page?: number; + limit?: number; + sort?: 'popular' | 'recent' | 'rating' | 'downloads'; + search?: string; + tags?: string[]; + authorId?: string; + featuredOnly?: boolean; +} + +/** + * Paginated response for community themes + */ +export interface PaginatedCommunityThemes { + themes: CommunityTheme[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +/** + * Input for publishing a theme to the community + */ +export interface PublishThemeInput { + tags?: string[]; + description?: string; +} + +/** + * Theme editor state for UI + */ +export interface ThemeEditorState { + /** Theme being edited */ + theme: Partial; + /** Currently editing light or dark colors */ + editingMode: EffectiveMode; + /** Currently selected color key */ + selectedColorKey: keyof ThemeColors | null; + /** Is preview mode active */ + isPreviewing: boolean; + /** Has unsaved changes */ + isDirty: boolean; +} + +/** + * Custom themes store interface + */ +export interface CustomThemesStore { + /** User's custom themes */ + readonly customThemes: CustomTheme[]; + /** Community themes (from current query) */ + readonly communityThemes: CommunityTheme[]; + /** User's favorited themes */ + readonly favorites: CommunityTheme[]; + /** User's downloaded themes */ + readonly downloaded: CommunityTheme[]; + /** Pagination info */ + readonly pagination: { page: number; totalPages: number; total: number }; + /** Loading state */ + readonly loading: boolean; + /** Error state */ + readonly error: string | null; + + // Custom theme operations + loadCustomThemes: () => Promise; + createTheme: (input: CreateCustomThemeInput) => Promise; + updateTheme: (id: string, input: UpdateCustomThemeInput) => Promise; + deleteTheme: (id: string) => Promise; + publishTheme: (id: string, input?: PublishThemeInput) => Promise; + + // Community theme operations + browseCommunity: (query?: CommunityThemeQuery) => Promise; + downloadTheme: (id: string) => Promise; + rateTheme: ( + id: string, + rating: number + ) => Promise<{ averageRating: number; ratingCount: number }>; + toggleFavorite: (id: string) => Promise<{ isFavorited: boolean }>; + loadFavorites: () => Promise; + loadDownloaded: () => Promise; + + // Apply theme + applyCustomTheme: (theme: CustomTheme | CommunityTheme) => void; + clearCustomTheme: () => void; +} + +/** + * Custom themes store configuration + */ +export interface CustomThemesStoreConfig { + /** Auth service base URL */ + authUrl: string; + /** Function to get current access token */ + getAccessToken: () => Promise; + /** Theme store to apply custom themes to */ + themeStore?: ThemeStore; +} + +/** + * Main colors for the simplified editor view + * These are the 7 most important colors users typically want to customize + */ +export const MAIN_THEME_COLORS: (keyof ThemeColors)[] = [ + 'primary', + 'background', + 'surface', + 'foreground', + 'error', + 'success', + 'warning', +]; + +/** + * Extended/advanced colors (collapsed by default in editor) + */ +export const EXTENDED_THEME_COLORS: (keyof ThemeColors)[] = [ + 'primaryForeground', + 'secondary', + 'secondaryForeground', + 'surfaceHover', + 'surfaceElevated', + 'muted', + 'mutedForeground', + 'border', + 'borderStrong', + 'input', + 'ring', +]; + +/** + * Color labels for the editor UI + */ +export const THEME_COLOR_LABELS: Record = { + primary: 'Primary', + primaryForeground: 'Primary Text', + secondary: 'Secondary', + secondaryForeground: 'Secondary Text', + background: 'Background', + foreground: 'Text', + surface: 'Surface', + surfaceHover: 'Surface Hover', + surfaceElevated: 'Elevated Surface', + muted: 'Muted', + mutedForeground: 'Muted Text', + border: 'Border', + borderStrong: 'Border Strong', + error: 'Error', + success: 'Success', + warning: 'Warning', + input: 'Input', + ring: 'Focus Ring', +}; diff --git a/packages/shared-theme/src/user-settings-store.svelte.ts b/packages/shared-theme/src/user-settings-store.svelte.ts index 7a9ee94f6..c2ecabd16 100644 --- a/packages/shared-theme/src/user-settings-store.svelte.ts +++ b/packages/shared-theme/src/user-settings-store.svelte.ts @@ -314,6 +314,46 @@ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSe } } + /** + * Get hidden nav items for a specific app + */ + function getHiddenNavItemsForApp(targetAppId: string): string[] { + return globalSettings.nav.hiddenNavItems?.[targetAppId] || []; + } + + /** + * Toggle visibility of a navigation item for an app + */ + async function toggleNavItemVisibility(targetAppId: string, href: string): Promise { + const currentHidden = getHiddenNavItemsForApp(targetAppId); + const isHidden = currentHidden.includes(href); + + const newHidden = isHidden ? currentHidden.filter((h) => h !== href) : [...currentHidden, href]; + + await setHiddenNavItems(targetAppId, newHidden); + } + + /** + * Set hidden nav items for an app + */ + async function setHiddenNavItems(targetAppId: string, hiddenHrefs: string[]): Promise { + const newHiddenNavItems = { + ...globalSettings.nav.hiddenNavItems, + [targetAppId]: hiddenHrefs, + }; + + // Remove empty arrays + if (hiddenHrefs.length === 0) { + delete newHiddenNavItems[targetAppId]; + } + + await updateGlobal({ + nav: { + hiddenNavItems: newHiddenNavItems, + }, + } as Partial); + } + return { get nav() { return nav; @@ -349,5 +389,8 @@ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSe removeAppOverride, setStartPage, updateGeneral, + getHiddenNavItemsForApp, + toggleNavItemVisibility, + setHiddenNavItems, }; } diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index bcbbd3d5b..f366d440f 100644 --- a/packages/shared-ui/package.json +++ b/packages/shared-ui/package.json @@ -37,6 +37,18 @@ "dependencies": { "@manacore/shared-branding": "workspace:*", "@manacore/shared-icons": "workspace:*", - "@manacore/shared-theme": "workspace:*" + "@manacore/shared-theme": "workspace:*", + "d3-force": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.0", + "d3-zoom": "^3.0.0", + "date-fns": "^4.1.0", + "lucide-svelte": "^0.468.0" + }, + "devDependencies": { + "@types/d3-force": "^3.0.10", + "@types/d3-selection": "^3.0.11", + "@types/d3-transition": "^3.0.9", + "@types/d3-zoom": "^3.0.8" } } diff --git a/packages/shared-ui/src/charts/ActivityHeatmap.svelte b/packages/shared-ui/src/charts/ActivityHeatmap.svelte new file mode 100644 index 000000000..852539046 --- /dev/null +++ b/packages/shared-ui/src/charts/ActivityHeatmap.svelte @@ -0,0 +1,294 @@ + + +
+

{title}

+ +
+ + + {#each monthLabels as label} + + {label.month} + + {/each} + + + {#each DAY_LABELS as label, i} + {#if label} + + {label} + + {/if} + {/each} + + + {#each weeks as week, weekIndex} + {#each week as day, dayIndex} + {#if day.date} + + {formatTooltip(day)} + + {:else} + + {/if} + {/each} + {/each} + +
+ + +
+ Weniger +
+
+
+
+
+
+
+ Mehr +
+
+ + diff --git a/packages/shared-ui/src/charts/DonutChart.svelte b/packages/shared-ui/src/charts/DonutChart.svelte new file mode 100644 index 000000000..643db2d61 --- /dev/null +++ b/packages/shared-ui/src/charts/DonutChart.svelte @@ -0,0 +1,260 @@ + + +
+

{title}

+ +
+
+ + {#each arcs as arc} + (hoveredSegment = arc.id)} + onmouseleave={() => (hoveredSegment = null)} + role="graphics-symbol" + aria-label="{arc.label}: {arc.count}" + > + {arc.label}: {arc.count} ({arc.percentage}%) + + {/each} + + + + {total} + + + {centerLabel} + + +
+ + + {#if showLegend} +
+ {#each data as item} +
(hoveredSegment = item.id)} + onmouseleave={() => (hoveredSegment = null)} + role="button" + tabindex="0" + > + + {item.label} + {item.count} +
+ {/each} +
+ {/if} +
+
+ + diff --git a/packages/shared-ui/src/charts/ProgressBars.svelte b/packages/shared-ui/src/charts/ProgressBars.svelte new file mode 100644 index 000000000..21692ec04 --- /dev/null +++ b/packages/shared-ui/src/charts/ProgressBars.svelte @@ -0,0 +1,192 @@ + + +
+

{title}

+ + {#if sortedData.length === 0} +

{emptyMessage}

+ {:else} +
+ {#each sortedData as item (item.id)} +
+
+
+ + {item.name} +
+ + {item.completed}/{item.total} + +
+ +
+
+ + {#if item.completed > 0} +
+ {/if} + + + {#if item.inProgress && item.inProgress > 0} +
+ {/if} +
+ + {item.percentage}% +
+
+ {/each} +
+ {/if} +
+ + diff --git a/packages/shared-ui/src/charts/StatisticsSkeleton.svelte b/packages/shared-ui/src/charts/StatisticsSkeleton.svelte new file mode 100644 index 000000000..e50ea20dd --- /dev/null +++ b/packages/shared-ui/src/charts/StatisticsSkeleton.svelte @@ -0,0 +1,272 @@ + + +
+ +
+ {#each Array(statCards) as _, i} +
+ +
+ + +
+
+ {/each} +
+ + +
+ +
+
+ +
+
+ {#each Array(7) as _} +
+ {#each Array(12) as _} + + {/each} +
+ {/each} +
+
+ + +
+ +
+
+ +
+
+ {#each Array(7) as _, i} +
+ + +
+ {/each} +
+
+ + +
+
+ +
+
+ +
+
+ {#each Array(legendItems) as _} +
+ + +
+ {/each} +
+
+
+ + +
+
+ +
+
+ {#each Array(progressItems) as _, i} +
+
+ + +
+ +
+ {/each} +
+
+
+ + + {#if showAdditionalStats} +
+ {#each Array(3) as _} +
+ + +
+ {/each} +
+ {/if} +
+ + diff --git a/packages/shared-ui/src/charts/StatsGrid.svelte b/packages/shared-ui/src/charts/StatsGrid.svelte new file mode 100644 index 000000000..49d1ffea9 --- /dev/null +++ b/packages/shared-ui/src/charts/StatsGrid.svelte @@ -0,0 +1,136 @@ + + +
+ {#each visibleItems as item (item.id)} +
+
+ +
+
+ {item.value} + {item.label} +
+
+ {/each} +
+ + diff --git a/packages/shared-ui/src/charts/TrendLineChart.svelte b/packages/shared-ui/src/charts/TrendLineChart.svelte new file mode 100644 index 000000000..0615c490b --- /dev/null +++ b/packages/shared-ui/src/charts/TrendLineChart.svelte @@ -0,0 +1,240 @@ + + +
+

{title}

+ + + + {#each yTicks as tick} + + {/each} + + + + + + + + + + + + + + + + {#each data as point, i} + + {formatTooltip(point)} + + {/each} + + + {#each yTicks as tick} + + {tick} + + {/each} + + + {#each xLabels as label} + + {label.label} + + {/each} + +
+ + diff --git a/packages/shared-ui/src/charts/index.ts b/packages/shared-ui/src/charts/index.ts new file mode 100644 index 000000000..6246fd8a8 --- /dev/null +++ b/packages/shared-ui/src/charts/index.ts @@ -0,0 +1,20 @@ +// Charts - Statistics Visualization Components +export { default as StatsGrid } from './StatsGrid.svelte'; +export { default as ActivityHeatmap } from './ActivityHeatmap.svelte'; +export { default as TrendLineChart } from './TrendLineChart.svelte'; +export { default as DonutChart } from './DonutChart.svelte'; +export { default as ProgressBars } from './ProgressBars.svelte'; +export { default as StatisticsSkeleton } from './StatisticsSkeleton.svelte'; + +// Types +export type { + StatVariant, + StatItem, + HeatmapDataPoint, + TrendDataPoint, + DonutSegment, + ProgressItem, +} from './types'; + +// Constants +export { STAT_VARIANT_COLORS } from './types'; diff --git a/packages/shared-ui/src/charts/types.ts b/packages/shared-ui/src/charts/types.ts new file mode 100644 index 000000000..774b0d993 --- /dev/null +++ b/packages/shared-ui/src/charts/types.ts @@ -0,0 +1,62 @@ +/** + * Shared Types for Chart Components + */ + +import type { Component } from 'svelte'; + +// Stat card variant colors +export type StatVariant = 'success' | 'primary' | 'neutral' | 'danger' | 'info' | 'accent'; + +export const STAT_VARIANT_COLORS: Record = { + success: { bg: 'rgba(16, 185, 129, 0.15)', color: '#10B981' }, + primary: { bg: 'rgba(139, 92, 246, 0.15)', color: '#8B5CF6' }, + neutral: { bg: 'rgba(107, 114, 128, 0.15)', color: '#6B7280' }, + danger: { bg: 'rgba(239, 68, 68, 0.15)', color: '#EF4444' }, + info: { bg: 'rgba(59, 130, 246, 0.15)', color: '#3B82F6' }, + accent: { bg: 'rgba(236, 72, 153, 0.15)', color: '#EC4899' }, +}; + +// StatsGrid types +export interface StatItem { + id: string; + label: string; + value: number | string; + icon: Component; + variant: StatVariant; + /** Optional: only show this stat if condition is true */ + showCondition?: boolean; +} + +// ActivityHeatmap types +export interface HeatmapDataPoint { + date: string; // YYYY-MM-DD format + count: number; + dayOfWeek: number; // 0-6 (Sunday-Saturday) +} + +// TrendLineChart types +export interface TrendDataPoint { + date: string; // YYYY-MM-DD format + count: number; + label?: string; +} + +// DonutChart types +export interface DonutSegment { + id: string; + label: string; + count: number; + percentage: number; + color: string; +} + +// ProgressBars types +export interface ProgressItem { + id: string; + name: string; + color: string; + total: number; + completed: number; + inProgress?: number; + percentage: number; +} diff --git a/packages/shared-ui/src/command-bar/CommandBar.svelte b/packages/shared-ui/src/command-bar/CommandBar.svelte index 71822e8a7..623e1f3c0 100644 --- a/packages/shared-ui/src/command-bar/CommandBar.svelte +++ b/packages/shared-ui/src/command-bar/CommandBar.svelte @@ -1,6 +1,47 @@ + +
+ {#each TAG_COLORS as color} + {@const isSelected = selectedColor?.toLowerCase() === color.hex.toLowerCase()} + + {/each} +
diff --git a/packages/shared-ui/src/molecules/tags/TagEditModal.svelte b/packages/shared-ui/src/molecules/tags/TagEditModal.svelte new file mode 100644 index 000000000..66e5dd99e --- /dev/null +++ b/packages/shared-ui/src/molecules/tags/TagEditModal.svelte @@ -0,0 +1,143 @@ + + + +
+ +
+ +
+ + +
+ + (color = c)} /> +
+ + +
+ +
+ +
+
+ + + {#if usageCount !== undefined && usageCount > 0} +
+ {usageLabel}: {usageCount} +
+ {/if} +
+ + {#snippet footer()} +
+
+ {#if onDelete && tag} + + {/if} +
+
+ + +
+
+ {/snippet} +
diff --git a/packages/shared-ui/src/molecules/tags/TagList.svelte b/packages/shared-ui/src/molecules/tags/TagList.svelte new file mode 100644 index 000000000..cc9dd7d0c --- /dev/null +++ b/packages/shared-ui/src/molecules/tags/TagList.svelte @@ -0,0 +1,166 @@ + + +{#if loading} + +
+ {#each Array(6) as _, i} +
+
+
+
+
+
+ {/each} +
+{:else if tags.length === 0} + +
+
+ + + +
+

{emptyMessage}

+

{emptyDescription}

+
+{:else} + +
+ {#each tags as tag (tag.id)} + {@const color = getTagColor(tag)} +
onClick?.(tag)} + onkeydown={(e) => handleKeyDown(e, tag, 'click')} + role={onClick ? 'button' : undefined} + tabindex={onClick ? 0 : undefined} + > + +
+
+
+ + + + {tag.name} + + + + {#if onEdit || onDelete} +
+ {#if onEdit} + + {/if} + {#if onDelete} + + {/if} +
+ {/if} +
+ {/each} +
+{/if} diff --git a/packages/shared-ui/src/molecules/tags/TagSelector.svelte b/packages/shared-ui/src/molecules/tags/TagSelector.svelte new file mode 100644 index 000000000..ac85df74b --- /dev/null +++ b/packages/shared-ui/src/molecules/tags/TagSelector.svelte @@ -0,0 +1,257 @@ + + + + +
+ +
+ {#each selectedTags as tag (tag.id)} + handleRemoveTag(tag)} /> + {/each} + + {#if canAddMore} + + {/if} +
+ + + {#if isOpen} +
+ +
+
+ + +
+
+ + +
+ {#if filteredTags.length > 0} + {#each filteredTags as tag (tag.id)} + + {/each} + {:else if searchQuery && !isCreating} +
Kein Tag gefunden
+ {/if} +
+ + + {#if onCreateTag} +
+ {#if isCreating} +
+ e.key === 'Enter' && handleCreateTag()} + /> + (newTagColor = c)} + size="sm" + /> +
+ + +
+
+ {:else} + + {/if} +
+ {/if} +
+ {/if} +
diff --git a/packages/shared-ui/src/molecules/tags/constants.ts b/packages/shared-ui/src/molecules/tags/constants.ts new file mode 100644 index 000000000..3bad3ddf1 --- /dev/null +++ b/packages/shared-ui/src/molecules/tags/constants.ts @@ -0,0 +1,56 @@ +/** + * Centralized tag constants and types for @manacore/shared-ui + */ + +export const TAG_COLORS = [ + { name: 'red', hex: '#ef4444' }, + { name: 'orange', hex: '#f97316' }, + { name: 'amber', hex: '#f59e0b' }, + { name: 'lime', hex: '#84cc16' }, + { name: 'green', hex: '#22c55e' }, + { name: 'teal', hex: '#14b8a6' }, + { name: 'cyan', hex: '#06b6d4' }, + { name: 'blue', hex: '#3b82f6' }, + { name: 'indigo', hex: '#6366f1' }, + { name: 'violet', hex: '#8b5cf6' }, + { name: 'pink', hex: '#ec4899' }, + { name: 'slate', hex: '#64748b' }, +] as const; + +export const DEFAULT_TAG_COLOR = '#3b82f6'; // blue + +export type TagColorName = (typeof TAG_COLORS)[number]['name']; +export type TagColorHex = (typeof TAG_COLORS)[number]['hex']; + +export interface Tag { + id: string; + name: string; + color?: string | null; + style?: { color?: string }; +} + +export interface TagData { + name?: string; + text?: string; + color?: string; + style?: { color?: string }; +} + +/** + * Get a random color from the palette + */ +export function getRandomTagColor(): string { + return TAG_COLORS[Math.floor(Math.random() * TAG_COLORS.length)].hex; +} + +/** + * Get color by name + */ +export function getTagColorByName(name: TagColorName): string { + for (const color of TAG_COLORS) { + if (color.name === name) { + return color.hex; + } + } + return DEFAULT_TAG_COLOR; +} diff --git a/packages/shared-ui/src/molecules/tags/index.ts b/packages/shared-ui/src/molecules/tags/index.ts index 4272c0fb8..1b607ddf3 100644 --- a/packages/shared-ui/src/molecules/tags/index.ts +++ b/packages/shared-ui/src/molecules/tags/index.ts @@ -1 +1,10 @@ +// Components export { default as TagBadge } from './TagBadge.svelte'; +export { default as TagColorPicker } from './TagColorPicker.svelte'; +export { default as TagEditModal } from './TagEditModal.svelte'; +export { default as TagSelector } from './TagSelector.svelte'; +export { default as TagList } from './TagList.svelte'; + +// Constants and Types +export { TAG_COLORS, DEFAULT_TAG_COLOR, getRandomTagColor, getTagColorByName } from './constants'; +export type { Tag, TagData, TagColorName, TagColorHex } from './constants'; diff --git a/packages/shared-ui/src/organisms/index.ts b/packages/shared-ui/src/organisms/index.ts index 893900c0e..f1fefa964 100644 --- a/packages/shared-ui/src/organisms/index.ts +++ b/packages/shared-ui/src/organisms/index.ts @@ -3,3 +3,25 @@ export { default as ConfirmationModal } from './ConfirmationModal.svelte'; export { default as FormModal } from './FormModal.svelte'; export { default as AppSlider } from './AppSlider.svelte'; export type { AppItem } from './AppSlider.types'; + +// Network Graph +export { + NetworkGraph, + NetworkControls, + stringToColor, + getInitials, + SIMULATION_CONFIG, + NODE_CONFIG, + LABEL_CONFIG, +} from './network'; +export type { + NetworkNode, + NetworkLink, + NetworkTag, + NetworkTransform, + NetworkGraphProps, + NetworkControlsProps, + NetworkGraphResponse, + SimulationNode, + SimulationLink, +} from './network'; diff --git a/packages/shared-ui/src/organisms/network/NetworkControls.svelte b/packages/shared-ui/src/organisms/network/NetworkControls.svelte new file mode 100644 index 000000000..3056e3167 --- /dev/null +++ b/packages/shared-ui/src/organisms/network/NetworkControls.svelte @@ -0,0 +1,604 @@ + + +
+ +
+ + + {#if searchInput} + + {/if} +
+ + + {#if tags.length > 0 || subtitles.length > 0} + + {/if} + + +
+ + + + +
+ + + + + +
+ + {nodeCount} + {nodeLabel} + + + + {linkCount} + {linkLabel} + +
+
+ + +{#if showKeyboardHelp} +
+
Tastaturkürzel
+
+ {#each keyboardShortcuts as shortcut} +
+ {shortcut.key} + {shortcut.description} +
+ {/each} +
+
+{/if} + + +{#if showFilters} +
+
+ + {#if tags.length > 0} +
+ + +
+ {/if} + + + {#if subtitles.length > 0} +
+ + +
+ {/if} + + +
+ + +
+ + + {#if hasActiveFilters} + + {/if} +
+
+{/if} + + diff --git a/packages/shared-ui/src/organisms/network/NetworkGraph.svelte b/packages/shared-ui/src/organisms/network/NetworkGraph.svelte new file mode 100644 index 000000000..c01518888 --- /dev/null +++ b/packages/shared-ui/src/organisms/network/NetworkGraph.svelte @@ -0,0 +1,671 @@ + + +
+ + + + + {#each links as link} + {@const coords = getLinkCoords(link)} + {@const sourceId = typeof link.source === 'string' ? link.source : link.source.id} + {@const targetId = typeof link.target === 'string' ? link.target : link.target.id} + {@const isHighlighted = + selectedNodeId && (sourceId === selectedNodeId || targetId === selectedNodeId)} + + handleLinkMouseEnter(e, link)} + onmousemove={handleLinkMouseMove} + onmouseleave={handleLinkMouseLeave} + /> + + + {/each} + + + + + {#each nodes as node (node.id)} + {@const isSelected = node.id === selectedNodeId} + {@const isConnected = isConnectedToSelected(node.id)} + {@const isDimmed = selectedNodeId && !isConnected} + {@const nodeRadius = isSelected ? NODE_CONFIG.selectedRadius : NODE_CONFIG.radius} + {@const avatarRadius = isSelected + ? NODE_CONFIG.selectedAvatarRadius + : NODE_CONFIG.avatarRadius} + {@const badgeOffset = isSelected + ? NODE_CONFIG.selectedBadgeOffset + : NODE_CONFIG.badgeOffset} + handleDragStart(e, node)} + onclick={() => handleNodeClick(node)} + ondblclick={() => handleNodeDoubleClick(node)} + role="button" + tabindex="0" + aria-label={node.name} + > + + + + + {#if node.photoUrl} + + + + + {:else} + + {getInitials(node.name)} + + {/if} + + + {#if node.isFavorite} + + + ⭐ + + {/if} + + + {#if node.connectionCount > 0} + + + {node.connectionCount} + + {/if} + + + + + {node.name} + + + + {#if node.subtitle} + {@const labelOffset = + (isSelected ? LABEL_CONFIG.selectedNameOffset : LABEL_CONFIG.nameOffset) * + transform.k} + + {node.subtitle} + + {/if} + + + {/each} + + + + + + {#if nodes.length === 0} +
+
🔗
+

Keine Verbindungen gefunden

+

Elemente werden verbunden, wenn sie gemeinsame Tags haben.

+
+ {/if} + + + {#if hoveredLink} + {@const names = getLinkNodeNames(hoveredLink)} + + {/if} +
+ + diff --git a/packages/shared-ui/src/organisms/network/constants.ts b/packages/shared-ui/src/organisms/network/constants.ts new file mode 100644 index 000000000..caa56223d --- /dev/null +++ b/packages/shared-ui/src/organisms/network/constants.ts @@ -0,0 +1,86 @@ +/** + * Generate a consistent HSL color from a string + * @param str - Input string (e.g., name) + * @returns HSL color string + */ +export function stringToColor(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + const hue = hash % 360; + return `hsl(${hue}, 70%, 50%)`; +} + +/** + * Get initials from a name + * @param name - Full name + * @returns 1-2 character initials + */ +export function getInitials(name: string): string { + const parts = name.trim().split(' ').filter(Boolean); + if (parts.length >= 2) { + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); + } + return name.substring(0, 2).toUpperCase(); +} + +/** + * D3 Force simulation default parameters + */ +export const SIMULATION_CONFIG = { + /** Distance between linked nodes */ + linkDistance: 100, + /** Strength of links (0-1) */ + linkStrength: 0.5, + /** Charge strength (negative = repulsion) */ + chargeStrength: -300, + /** Collision radius for nodes */ + collisionRadius: 50, + /** Initial alpha for simulation */ + initialAlpha: 1, + /** Alpha for reheating simulation */ + reheatAlpha: 0.3, + /** Zoom scale extent */ + zoomExtent: [0.1, 4] as [number, number], +} as const; + +/** + * Node size configuration + */ +export const NODE_CONFIG = { + /** Default node radius */ + radius: 36, + /** Selected node radius */ + selectedRadius: 40, + /** Avatar clip radius (slightly smaller than node) */ + avatarRadius: 34, + /** Selected avatar clip radius */ + selectedAvatarRadius: 38, + /** Badge offset from center */ + badgeOffset: 25, + /** Selected badge offset */ + selectedBadgeOffset: 28, +} as const; + +/** + * Label configuration + */ +export const LABEL_CONFIG = { + /** Font size for name label */ + nameFontSize: 18, + /** Selected name font size */ + selectedNameFontSize: 20, + /** Font size for subtitle label */ + subtitleFontSize: 14, + /** Y offset for name label */ + nameOffset: 58, + /** Selected name Y offset */ + selectedNameOffset: 62, + /** Gap between name and subtitle */ + subtitleGap: 22, + /** Font size for initials */ + initialsFontSize: 18, + /** Selected initials font size */ + selectedInitialsFontSize: 20, +} as const; diff --git a/packages/shared-ui/src/organisms/network/index.ts b/packages/shared-ui/src/organisms/network/index.ts new file mode 100644 index 000000000..e6eb71cec --- /dev/null +++ b/packages/shared-ui/src/organisms/network/index.ts @@ -0,0 +1,25 @@ +// Components +export { default as NetworkGraph } from './NetworkGraph.svelte'; +export { default as NetworkControls } from './NetworkControls.svelte'; + +// Types +export type { + NetworkNode, + NetworkLink, + NetworkTag, + NetworkTransform, + NetworkGraphProps, + NetworkControlsProps, + NetworkGraphResponse, + SimulationNode, + SimulationLink, +} from './network.types'; + +// Constants & Helpers +export { + stringToColor, + getInitials, + SIMULATION_CONFIG, + NODE_CONFIG, + LABEL_CONFIG, +} from './constants'; diff --git a/packages/shared-ui/src/organisms/network/network.types.ts b/packages/shared-ui/src/organisms/network/network.types.ts new file mode 100644 index 000000000..9c843b732 --- /dev/null +++ b/packages/shared-ui/src/organisms/network/network.types.ts @@ -0,0 +1,112 @@ +import type { SimulationNodeDatum } from 'd3-force'; + +/** + * Tag attached to a network node + */ +export interface NetworkTag { + id: string; + name: string; + color: string | null; +} + +/** + * Base network node interface (before D3 simulation) + */ +export interface NetworkNode { + id: string; + name: string; + photoUrl?: string | null; + subtitle?: string | null; // e.g., Company, Project, Category + isFavorite?: boolean; + tags: NetworkTag[]; + connectionCount: number; +} + +/** + * Network node with D3 simulation properties + */ +export interface SimulationNode extends NetworkNode, SimulationNodeDatum { + x?: number; + y?: number; + vx?: number; + vy?: number; + fx?: number | null; + fy?: number | null; +} + +/** + * Network link between nodes + */ +export interface NetworkLink { + source: string; + target: string; + type: 'tag'; + strength: number; // 0-100, based on shared tag count + sharedTags: string[]; +} + +/** + * Network link with D3 simulation properties + * Note: After D3 simulation runs, source/target become SimulationNode objects + */ +export interface SimulationLink { + source: string | SimulationNode; + target: string | SimulationNode; + type: 'tag'; + strength: number; + sharedTags: string[]; + index?: number; +} + +/** + * Zoom/pan transform state + */ +export interface NetworkTransform { + x: number; + y: number; + k: number; // scale factor +} + +/** + * Props for NetworkGraph component + */ +export interface NetworkGraphProps { + nodes: SimulationNode[]; + links: SimulationLink[]; + selectedNodeId?: string | null; + onNodeClick?: (node: SimulationNode) => void; + onNodeDoubleClick?: (node: SimulationNode) => void; + onBackgroundClick?: () => void; +} + +/** + * Props for NetworkControls component + */ +export interface NetworkControlsProps { + searchQuery?: string; + tags?: NetworkTag[]; + selectedTagId?: string | null; + subtitles?: string[]; // e.g., companies, projects + selectedSubtitle?: string | null; + subtitleLabel?: string; // e.g., "Firma", "Projekt" + nodeCount?: number; + linkCount?: number; + nodeLabel?: string; // e.g., "Kontakte", "Tasks" + linkLabel?: string; // e.g., "Verbindungen" + searchPlaceholder?: string; + onSearch?: (query: string) => void; + onTagFilter?: (tagId: string | null) => void; + onSubtitleFilter?: (subtitle: string | null) => void; + onZoomIn?: () => void; + onZoomOut?: () => void; + onResetZoom?: () => void; + onClearFilters?: () => void; +} + +/** + * API response structure for network graph + */ +export interface NetworkGraphResponse { + nodes: NetworkNode[]; + links: NetworkLink[]; +} diff --git a/packages/shared-ui/src/settings/GlobalSettingsSection.svelte b/packages/shared-ui/src/settings/GlobalSettingsSection.svelte index 21abe64f9..dd77908bd 100644 --- a/packages/shared-ui/src/settings/GlobalSettingsSection.svelte +++ b/packages/shared-ui/src/settings/GlobalSettingsSection.svelte @@ -8,14 +8,27 @@ import { getAvailableRoutes, getDefaultRoute } from '@manacore/shared-theme'; import SettingsSection from './SettingsSection.svelte'; import SettingsCard from './SettingsCard.svelte'; + import NavVisibilitySettings from './NavVisibilitySettings.svelte'; + + interface NavItem { + href: string; + label: string; + icon?: string; + } interface Props { /** User settings store instance */ userSettings: UserSettingsStore; /** App ID for start page selection */ appId?: string; + /** Navigation items for visibility settings */ + navItems?: NavItem[]; + /** Items that should always be visible (e.g., home route) */ + alwaysVisibleHrefs?: string[]; /** Whether to show navigation settings */ showNavigation?: boolean; + /** Whether to show nav visibility settings */ + showNavVisibility?: boolean; /** Whether to show theme settings */ showTheme?: boolean; /** Whether to show language settings */ @@ -33,7 +46,10 @@ let { userSettings, appId, + navItems = [], + alwaysVisibleHrefs = [], showNavigation = true, + showNavVisibility = true, showTheme = true, showLanguage = true, showGeneral = true, @@ -205,10 +221,21 @@
{/if} + {#if showNavVisibility && appId && navItems.length > 0} + +
+ +
+ {/if} + {#if showTheme}

@@ -303,7 +330,10 @@ {#if showGeneral}
@@ -329,7 +359,7 @@ > {#each availableRoutes as route} {/each} diff --git a/packages/shared-ui/src/settings/NavVisibilitySettings.svelte b/packages/shared-ui/src/settings/NavVisibilitySettings.svelte new file mode 100644 index 000000000..2223bfa51 --- /dev/null +++ b/packages/shared-ui/src/settings/NavVisibilitySettings.svelte @@ -0,0 +1,175 @@ + + +{#if hasRoutes} +
+
+

+ Navigation anpassen +

+

+ Versteckte Seiten bleiben über die URL erreichbar +

+
+ +
+ {#each hideableItems as item (item.href)} + {@const hidden = isRouteHidden(item.href)} + {@const iconPath = item.icon ? getIconPath(item.icon) : ''} + + {/each} +
+
+{/if} diff --git a/packages/shared-ui/src/settings/SettingsCard.svelte b/packages/shared-ui/src/settings/SettingsCard.svelte index 384ba3720..ff330dac8 100644 --- a/packages/shared-ui/src/settings/SettingsCard.svelte +++ b/packages/shared-ui/src/settings/SettingsCard.svelte @@ -33,18 +33,22 @@ 'bg-red-500/[0.08] border-red-500/30 ' + 'dark:bg-red-500/[0.12] dark:border-red-500/25 dark:shadow-lg'; - const headerClasses = - 'px-5 py-4 border-b border-black/[0.08] dark:border-white/10'; + const headerClasses = 'px-5 py-4 border-b border-black/[0.08] dark:border-white/10'; - const dangerHeaderClasses = - 'px-5 py-4 border-b border-red-500/20 bg-red-500/10'; + const dangerHeaderClasses = 'px-5 py-4 border-b border-red-500/20 bg-red-500/10';
{#if title || description} -
+
{#if title} -

{title}

+

+ {title} +

{/if} {#if description}

{description}

@@ -56,4 +60,3 @@ {@render children()}
- diff --git a/packages/shared-ui/src/settings/SettingsPage.svelte b/packages/shared-ui/src/settings/SettingsPage.svelte index 693ead145..fc9e66af5 100644 --- a/packages/shared-ui/src/settings/SettingsPage.svelte +++ b/packages/shared-ui/src/settings/SettingsPage.svelte @@ -1,5 +1,12 @@ -
-
-
-

{title}

- {#if subtitle} -

{subtitle}

- {/if} -
- -
- {@render children()} +
+ +
+ + + +
+
+
+

{title}

+ {#if subtitle} +

{subtitle}

+ {/if} +
+ +
+ {@render children()} +
+
+
+ + diff --git a/packages/shared-ui/src/settings/SettingsRow.svelte b/packages/shared-ui/src/settings/SettingsRow.svelte index 61d7ffa8a..70e13b71f 100644 --- a/packages/shared-ui/src/settings/SettingsRow.svelte +++ b/packages/shared-ui/src/settings/SettingsRow.svelte @@ -91,7 +91,12 @@
{:else if onclick} -