Merge branch 'till-dev' into till-dev-backup

This commit is contained in:
Till-JS 2025-12-10 16:02:35 +01:00
commit f04300d5e9
269 changed files with 27419 additions and 2009 deletions

View file

@ -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)

View file

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

View file

@ -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
# ============================================

38
.github/CODEOWNERS vendored Normal file
View file

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

View file

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

View file

@ -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"

View file

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

View file

@ -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 {}

View file

@ -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'),

View file

@ -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;

View file

@ -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(),

View file

@ -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(),

View file

@ -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';

View file

@ -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(),

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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 };
}
}

View file

@ -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 {}

View file

@ -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<EventTag[]> {
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<EventTag[]> {
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<EventTag | null> {
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<EventTag> {
const [tag] = await this.db.insert(eventTags).values(data).returning();
return tag;
}
async update(id: string, userId: string, data: Partial<NewEventTag>): Promise<EventTag> {
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<void> {
await this.db.delete(eventTags).where(and(eq(eventTags.id, id), eq(eventTags.userId, userId)));
}
async getTagsForEvent(eventId: string): Promise<EventTag[]> {
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<string[]> {
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<void> {
// 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<void> {
await this.db.insert(eventToTags).values({ eventId, tagId }).onConflictDoNothing();
}
async removeTagFromEvent(eventId: string, tagId: string): Promise<void> {
await this.db
.delete(eventToTags)
.where(and(eq(eventToTags.eventId, eventId), eq(eventToTags.tagId, tagId)));
}
async getTagsByIds(ids: string[], userId: string): Promise<EventTag[]> {
if (ids.length === 0) return [];
return this.db
.select()
.from(eventTags)
.where(and(inArray(eventTags.id, ids), eq(eventTags.userId, userId)));
}
}

View file

@ -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[];
}

View file

@ -73,4 +73,9 @@ export class UpdateEventDto {
@IsOptional()
@IsObject()
metadata?: EventMetadata;
@IsOptional()
@IsArray()
@IsUUID('4', { each: true })
tagIds?: string[];
}

View file

@ -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],

View file

@ -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<Event[]> {
@ -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<NewEvent> = {
...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 };
}
}

View file

@ -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);
}
}

View file

@ -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 {}

View file

@ -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<string>('MANA_CORE_AUTH_URL') || 'http://localhost:3001';
}
/**
* Fetch tags from central Tags API
*/
private async fetchTagsByIds(tagIds: string[], accessToken: string): Promise<Map<string, Tag>> {
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<NetworkGraphResponse> {
// 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<string, string[]>();
const allTagIds = new Set<string>();
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<string, Tag>();
if (accessToken && allTagIds.size > 0) {
tagsMap = await this.fetchTagsByIds(Array.from(allTagIds), accessToken);
}
// 4. Build tags for each event
const eventTagsMap = new Map<string, Tag[]>();
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<string, number>();
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 };
}
}

View file

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

View file

@ -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"
},

View file

@ -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 {

View file

@ -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<typeof createTagsClient> | 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' },
};
}
}

View file

@ -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<NetworkGraphResponse> {
const result = await fetchApi<NetworkGraphResponse>('/network/graph');
if (result.error) {
throw result.error;
}
return result.data || { nodes: [], links: [] };
},
};

View file

@ -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<Subtask, 'id'>[];
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<T>(
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<string, string> = {
'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<TasksResponse>(`/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<TaskResponse>(`/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<TaskResponse>('/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<TaskResponse>(`/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<TaskResponse>(`/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<TaskResponse>(`/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<TasksResponse>('/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<TasksResponse>('/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<ProjectsResponse>('/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<LabelsResponse>('/labels');
return {
data: result.data?.labels || null,
error: result.error,
};
}
// ============================================
// Priority Colors Helper
// ============================================
export const PRIORITY_COLORS: Record<TaskPriority, string> = {
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<TaskPriority, string> = {
urgent: 'Dringend',
high: 'Wichtig',
medium: 'Normal',
low: 'Später',
};
export const PRIORITY_ORDER: Record<TaskPriority, number> = {
urgent: 0,
high: 1,
medium: 2,
low: 3,
};

View file

@ -0,0 +1,151 @@
<script lang="ts">
import { Calendar, CheckSquare, Filter } from 'lucide-svelte';
interface Props {
showEvents: boolean;
showTodos: boolean;
timeRange: '7' | '30' | 'all';
onToggleEvents?: () => void;
onToggleTodos?: () => void;
onRangeChange?: (range: '7' | '30' | 'all') => void;
}
let {
showEvents = true,
showTodos = true,
timeRange = '30',
onToggleEvents,
onToggleTodos,
onRangeChange,
}: Props = $props();
const rangeOptions = [
{ value: '7' as const, label: '7 Tage' },
{ value: '30' as const, label: '30 Tage' },
{ value: 'all' as const, label: 'Alle' },
];
</script>
<div class="agenda-filters">
<div class="filter-group type-toggles">
<button
type="button"
class="filter-toggle"
class:active={showEvents}
onclick={onToggleEvents}
aria-pressed={showEvents}
>
<Calendar size={14} />
<span>Events</span>
</button>
<button
type="button"
class="filter-toggle"
class:active={showTodos}
onclick={onToggleTodos}
aria-pressed={showTodos}
>
<CheckSquare size={14} />
<span>Aufgaben</span>
</button>
</div>
<div class="filter-group">
<div class="range-selector">
<Filter size={14} />
<select
value={timeRange}
onchange={(e) =>
onRangeChange?.((e.target as HTMLSelectElement).value as '7' | '30' | 'all')}
>
{#each rangeOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
</div>
</div>
<style>
.agenda-filters {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1rem;
background: hsl(var(--color-surface));
border-radius: var(--radius-lg);
border: 1px solid hsl(var(--color-border));
}
.filter-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.type-toggles {
display: flex;
gap: 0.375rem;
}
.filter-toggle {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: var(--radius-md);
border: 1px solid hsl(var(--color-border));
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 150ms ease;
}
.filter-toggle:hover {
border-color: hsl(var(--color-primary));
color: hsl(var(--color-primary));
}
.filter-toggle.active {
background: hsl(var(--color-primary) / 0.1);
border-color: hsl(var(--color-primary));
color: hsl(var(--color-primary));
}
.range-selector {
display: flex;
align-items: center;
gap: 0.5rem;
color: hsl(var(--color-muted-foreground));
}
.range-selector select {
padding: 0.375rem 0.75rem;
border-radius: var(--radius-md);
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
cursor: pointer;
}
.range-selector select:focus {
outline: none;
border-color: hsl(var(--color-primary));
}
@media (max-width: 480px) {
.agenda-filters {
flex-direction: column;
align-items: stretch;
gap: 0.75rem;
}
.filter-group {
justify-content: center;
}
}
</style>

View file

@ -0,0 +1,217 @@
<script lang="ts">
import type { CalendarEvent } from '@calendar/shared';
import type { Task } from '$lib/api/todos';
import { PRIORITY_COLORS, PRIORITY_LABELS } from '$lib/api/todos';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { todosStore } from '$lib/stores/todos.svelte';
import TodoCheckbox from '$lib/components/todo/TodoCheckbox.svelte';
import PriorityBadge from '$lib/components/todo/PriorityBadge.svelte';
import { Calendar, MapPin, Clock } from 'lucide-svelte';
import { format, parseISO } from 'date-fns';
import { de } from 'date-fns/locale';
type ItemType = 'event' | 'todo';
interface Props {
type: ItemType;
event?: CalendarEvent;
todo?: Task;
onclick?: () => void;
}
let { type, event, todo, onclick }: Props = $props();
let isToggling = $state(false);
// Event helpers
const eventColor = $derived(event ? calendarsStore.getColor(event.calendarId) : undefined);
const eventTimeLabel = $derived.by(() => {
if (!event) return '';
if (event.isAllDay) return 'Ganztägig';
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
return `${format(start, 'HH:mm')} - ${format(end, 'HH:mm')}`;
});
// Todo helpers
const todoTimeLabel = $derived.by(() => {
if (!todo) return '';
if (todo.dueTime) return `Fällig: ${todo.dueTime}`;
return 'Heute fällig';
});
async function handleToggleTodo() {
if (!todo) return;
isToggling = true;
await todosStore.toggleComplete(todo.id);
isToggling = false;
}
</script>
{#if type === 'event' && event}
<button type="button" class="agenda-item event" style="--item-color: {eventColor};" {onclick}>
<div class="item-indicator">
<Calendar size={14} />
</div>
<div class="item-content">
<div class="item-header">
<span class="item-time">{eventTimeLabel}</span>
</div>
<span class="item-title">{event.title}</span>
{#if event.location}
<div class="item-meta">
<MapPin size={12} />
<span>{event.location}</span>
</div>
{/if}
</div>
</button>
{:else if type === 'todo' && todo}
<div
class="agenda-item todo"
class:completed={todo.isCompleted}
style="--item-color: {PRIORITY_COLORS[todo.priority]};"
>
<div class="item-checkbox">
<TodoCheckbox
checked={todo.isCompleted}
loading={isToggling}
size="md"
onchange={handleToggleTodo}
/>
</div>
<button type="button" class="item-content" {onclick}>
<div class="item-header">
<PriorityBadge priority={todo.priority} variant="dot" size="sm" />
<span class="item-time">{todoTimeLabel}</span>
</div>
<span class="item-title">{todo.title}</span>
{#if todo.project}
<div class="item-meta">
<span class="project-tag" style="color: {todo.project.color};">
{todo.project.name}
</span>
</div>
{/if}
</button>
</div>
{/if}
<style>
.agenda-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: var(--radius-md);
background: hsl(var(--color-surface));
transition: all 150ms ease;
}
.agenda-item.event {
border: none;
cursor: pointer;
text-align: left;
width: 100%;
border-left: 4px solid var(--item-color);
}
.agenda-item.event:hover {
background: hsl(var(--color-muted) / 0.5);
transform: translateX(4px);
}
.agenda-item.todo {
border-left: 3px solid var(--item-color);
}
.agenda-item.todo.completed {
opacity: 0.6;
}
.agenda-item.todo.completed .item-title {
text-decoration: line-through;
color: hsl(var(--color-muted-foreground));
}
.item-indicator {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: var(--radius-sm);
background: var(--item-color);
color: white;
flex-shrink: 0;
}
.item-checkbox {
flex-shrink: 0;
padding-top: 2px;
}
.item-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.todo .item-content {
border: none;
background: transparent;
padding: 0;
cursor: pointer;
text-align: left;
}
.todo .item-content:hover .item-title {
color: hsl(var(--color-primary));
}
.item-header {
display: flex;
align-items: center;
gap: 0.5rem;
}
.item-time {
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
}
.item-title {
font-size: 0.9375rem;
font-weight: 500;
color: hsl(var(--color-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: color 150ms ease;
}
.item-meta {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.item-meta :global(svg) {
flex-shrink: 0;
}
.project-tag {
font-size: 0.6875rem;
font-weight: 500;
background: color-mix(in srgb, currentColor 15%, transparent);
padding: 1px 6px;
border-radius: 4px;
}
</style>

View file

@ -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 @@
</div>
{/if}
<!-- Todos section -->
{#if todosStore.serviceAvailable && todosStore.getTodosForDay(viewStore.currentDate).length > 0}
<div class="todos-section">
<div class="time-gutter"></div>
<div class="todos-content">
<TodoRow date={viewStore.currentDate} maxVisible={4} />
</div>
</div>
{/if}
<!-- Time grid -->
<div class="time-grid scrollbar-thin">
<div class="time-column">
@ -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;

View file

@ -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')}
</span>
<!-- Todos for this day -->
{#if todosStore.serviceAvailable}
<TodoDayCell date={day} maxVisible={2} />
{/if}
<div class="day-events">
{#each getEventsForDay(day) as event}
{@const isBeingDragged = isDragging && draggedEvent?.id === event.id}

View file

@ -0,0 +1,121 @@
<script lang="ts">
import { todosStore } from '$lib/stores/todos.svelte';
import type { Task } from '$lib/api/todos';
import { PRIORITY_COLORS } from '$lib/api/todos';
import TodoCheckbox from '$lib/components/todo/TodoCheckbox.svelte';
import TodoDetailModal from '$lib/components/todo/TodoDetailModal.svelte';
interface Props {
date: Date;
maxVisible?: number;
}
let { date, maxVisible = 2 }: Props = $props();
let selectedTask = $state<Task | null>(null);
let togglingIds = $state<Set<string>>(new Set());
const todosForDay = $derived(todosStore.getTodosForDay(date));
const visibleTodos = $derived(todosForDay.slice(0, maxVisible));
const overflowCount = $derived(Math.max(0, todosForDay.length - maxVisible));
async function handleToggle(task: Task, e: MouseEvent) {
e.stopPropagation();
togglingIds = new Set([...togglingIds, task.id]);
await todosStore.toggleComplete(task.id);
togglingIds = new Set([...togglingIds].filter((id) => id !== task.id));
}
function handleTaskClick(task: Task) {
selectedTask = task;
}
function handleModalClose() {
selectedTask = null;
}
</script>
{#if todosForDay.length > 0}
<div class="todo-day-cell">
{#each visibleTodos as task (task.id)}
<button
type="button"
class="todo-cell-item"
class:completed={task.isCompleted}
style="--priority-color: {PRIORITY_COLORS[task.priority]};"
onclick={() => handleTaskClick(task)}
>
<span class="priority-dot"></span>
<span class="todo-cell-title">{task.title}</span>
</button>
{/each}
{#if overflowCount > 0}
<span class="overflow-text">+{overflowCount} Aufgaben</span>
{/if}
</div>
{/if}
<!-- Detail Modal -->
{#if selectedTask}
<TodoDetailModal task={selectedTask} onClose={handleModalClose} />
{/if}
<style>
.todo-day-cell {
display: flex;
flex-direction: column;
gap: 1px;
margin-bottom: 2px;
}
.todo-cell-item {
display: flex;
align-items: center;
gap: 4px;
padding: 1px 4px;
border-radius: 3px;
border: none;
background: hsl(var(--color-muted) / 0.3);
cursor: pointer;
transition: background 150ms ease;
text-align: left;
width: 100%;
}
.todo-cell-item:hover {
background: hsl(var(--color-muted) / 0.5);
}
.todo-cell-item.completed {
opacity: 0.5;
}
.todo-cell-item.completed .todo-cell-title {
text-decoration: line-through;
}
.priority-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--priority-color);
flex-shrink: 0;
}
.todo-cell-title {
font-size: 0.625rem;
font-weight: 500;
color: hsl(var(--color-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.overflow-text {
font-size: 0.5625rem;
color: hsl(var(--color-muted-foreground));
padding: 0 4px;
}
</style>

View file

@ -0,0 +1,169 @@
<script lang="ts">
import { todosStore } from '$lib/stores/todos.svelte';
import type { Task } from '$lib/api/todos';
import { PRIORITY_COLORS } from '$lib/api/todos';
import TodoCheckbox from '$lib/components/todo/TodoCheckbox.svelte';
import TodoDetailModal from '$lib/components/todo/TodoDetailModal.svelte';
import { Check } from 'lucide-svelte';
interface Props {
date: Date;
maxVisible?: number;
}
let { date, maxVisible = 3 }: Props = $props();
let selectedTask = $state<Task | null>(null);
let togglingIds = $state<Set<string>>(new Set());
const todosForDay = $derived(todosStore.getTodosForDay(date));
const visibleTodos = $derived(todosForDay.slice(0, maxVisible));
const overflowCount = $derived(Math.max(0, todosForDay.length - maxVisible));
async function handleToggle(task: Task) {
togglingIds = new Set([...togglingIds, task.id]);
await todosStore.toggleComplete(task.id);
togglingIds = new Set([...togglingIds].filter((id) => id !== task.id));
}
function handleTaskClick(task: Task, e: MouseEvent) {
// Don't open modal if clicking checkbox
if ((e.target as HTMLElement).closest('.todo-checkbox')) return;
selectedTask = task;
}
function handleModalClose() {
selectedTask = null;
}
function handleShowAll() {
// Show first todo's modal, or navigate to tasks page
if (todosForDay.length > 0) {
selectedTask = todosForDay[0];
}
}
</script>
{#if todosForDay.length > 0}
<div class="todo-row">
<span class="todo-row-label">Aufgaben:</span>
<div class="todo-pills">
{#each visibleTodos as task (task.id)}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<button
type="button"
class="todo-pill"
class:completed={task.isCompleted}
style="--priority-color: {PRIORITY_COLORS[task.priority]};"
onclick={(e) => handleTaskClick(task, e)}
>
<TodoCheckbox
checked={task.isCompleted}
loading={togglingIds.has(task.id)}
size="sm"
onchange={() => handleToggle(task)}
/>
<span class="todo-pill-title">{task.title}</span>
</button>
{/each}
{#if overflowCount > 0}
<button type="button" class="overflow-badge" onclick={handleShowAll}>
+{overflowCount} mehr
</button>
{/if}
</div>
</div>
{/if}
<!-- Detail Modal -->
{#if selectedTask}
<TodoDetailModal task={selectedTask} onClose={handleModalClose} />
{/if}
<style>
.todo-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
background: hsl(var(--color-muted) / 0.2);
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
}
.todo-row-label {
font-size: 0.6875rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
flex-shrink: 0;
}
.todo-pills {
display: flex;
align-items: center;
gap: 0.375rem;
flex: 1;
min-width: 0;
overflow-x: auto;
scrollbar-width: none;
}
.todo-pills::-webkit-scrollbar {
display: none;
}
.todo-pill {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
border-radius: var(--radius-md);
border: none;
background: hsl(var(--color-surface));
border-left: 2px solid var(--priority-color);
cursor: pointer;
transition: all 150ms ease;
flex-shrink: 0;
max-width: 150px;
}
.todo-pill:hover {
background: hsl(var(--color-muted) / 0.5);
}
.todo-pill.completed {
opacity: 0.6;
}
.todo-pill.completed .todo-pill-title {
text-decoration: line-through;
}
.todo-pill-title {
font-size: 0.6875rem;
font-weight: 500;
color: hsl(var(--color-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.overflow-badge {
display: flex;
align-items: center;
padding: 0.25rem 0.5rem;
border-radius: var(--radius-md);
border: none;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
font-size: 0.6875rem;
font-weight: 500;
cursor: pointer;
flex-shrink: 0;
transition: background 150ms ease;
}
.overflow-badge:hover {
background: hsl(var(--color-primary) / 0.2);
}
</style>

View file

@ -0,0 +1,292 @@
<script lang="ts">
import { todosStore } from '$lib/stores/todos.svelte';
import type { Task } from '$lib/api/todos';
import TodoItem from '$lib/components/todo/TodoItem.svelte';
import TodoDetailModal from '$lib/components/todo/TodoDetailModal.svelte';
import QuickAddTodo from '$lib/components/todo/QuickAddTodo.svelte';
import { ChevronDown, ChevronRight, Plus, CheckSquare, AlertTriangle } from 'lucide-svelte';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
interface Props {
maxItems?: number;
}
let { maxItems = 5 }: Props = $props();
let isExpanded = $state(true);
let showQuickAdd = $state(false);
let selectedTask = $state<Task | null>(null);
// Derived: combined overdue + today todos
const displayTodos = $derived(todosStore.getSidebarTodos(maxItems));
const overdueCount = $derived(todosStore.overdueTodos.length);
const totalActiveCount = $derived(todosStore.activeTodosCount);
onMount(async () => {
// Fetch todos on mount
await todosStore.fetchTodayTodos();
await todosStore.fetchUpcomingTodos();
});
function toggleExpanded() {
isExpanded = !isExpanded;
}
function handleAddClick(e: MouseEvent) {
e.stopPropagation();
showQuickAdd = true;
}
function handleTaskClick(task: Task) {
selectedTask = task;
}
function handleModalClose() {
selectedTask = null;
}
function handleQuickAddSubmit() {
// Keep quick add open for successive adds
}
function handleQuickAddCancel() {
showQuickAdd = false;
}
function goToAllTasks() {
goto('/tasks');
}
</script>
<div class="todo-sidebar-section">
<!-- Header -->
<button type="button" class="section-header" onclick={toggleExpanded}>
<div class="header-left">
{#if isExpanded}
<ChevronDown size={16} />
{:else}
<ChevronRight size={16} />
{/if}
<CheckSquare size={16} class="section-icon" />
<span class="section-title">Aufgaben</span>
{#if totalActiveCount > 0}
<span class="count-badge">{totalActiveCount}</span>
{/if}
{#if overdueCount > 0}
<span class="overdue-badge" title="{overdueCount} überfällig">
<AlertTriangle size={12} />
</span>
{/if}
</div>
<button
type="button"
class="add-button"
onclick={handleAddClick}
aria-label="Aufgabe hinzufügen"
>
<Plus size={16} />
</button>
</button>
<!-- Content -->
{#if isExpanded}
<div class="section-content">
{#if !todosStore.serviceAvailable}
<div class="service-unavailable">
<AlertTriangle size={16} />
<span>Todo-Service nicht erreichbar</span>
</div>
{:else if todosStore.loading}
<div class="loading">
<div class="loading-spinner"></div>
<span>Laden...</span>
</div>
{:else if displayTodos.length === 0}
<div class="empty-state">
<CheckSquare size={20} />
<span>Keine offenen Aufgaben</span>
</div>
{:else}
<div class="todo-list">
{#each displayTodos as task (task.id)}
<TodoItem
{task}
variant="compact"
showProject={false}
onclick={() => handleTaskClick(task)}
/>
{/each}
</div>
{#if totalActiveCount > maxItems}
<button type="button" class="show-all-button" onclick={goToAllTasks}>
Alle {totalActiveCount} anzeigen
</button>
{/if}
{/if}
<!-- Quick Add -->
{#if showQuickAdd}
<div class="quick-add-wrapper">
<QuickAddTodo
placeholder="Neue Aufgabe..."
autofocus
showButton={false}
onsubmit={handleQuickAddSubmit}
oncancel={handleQuickAddCancel}
/>
</div>
{/if}
</div>
{/if}
</div>
<!-- Detail Modal -->
{#if selectedTask}
<TodoDetailModal task={selectedTask} onClose={handleModalClose} />
{/if}
<style>
.todo-sidebar-section {
background: hsl(var(--color-surface));
border-radius: var(--radius-lg);
border: 1px solid hsl(var(--color-border));
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.75rem 1rem;
border: none;
background: transparent;
cursor: pointer;
transition: background 150ms ease;
}
.section-header:hover {
background: hsl(var(--color-muted) / 0.3);
}
.header-left {
display: flex;
align-items: center;
gap: 0.5rem;
color: hsl(var(--color-foreground));
}
.header-left :global(svg) {
color: hsl(var(--color-muted-foreground));
}
.header-left :global(.section-icon) {
color: hsl(var(--color-primary));
}
.section-title {
font-size: 0.875rem;
font-weight: 600;
}
.count-badge {
font-size: 0.6875rem;
font-weight: 600;
background: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
padding: 1px 6px;
border-radius: 9999px;
}
.overdue-badge {
display: flex;
align-items: center;
justify-content: center;
color: hsl(var(--color-danger));
}
.add-button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius-md);
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
transition: all 150ms ease;
}
.add-button:hover {
background: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
}
.section-content {
padding: 0 0.5rem 0.5rem;
}
.todo-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.service-unavailable,
.loading,
.empty-state {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1.5rem 1rem;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
}
.service-unavailable {
color: hsl(var(--color-danger));
}
.loading-spinner {
width: 16px;
height: 16px;
border: 2px solid hsl(var(--color-muted));
border-top-color: hsl(var(--color-primary));
border-radius: 50%;
animation: spin 600ms linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.show-all-button {
width: 100%;
padding: 0.5rem;
margin-top: 0.5rem;
border: none;
background: transparent;
color: hsl(var(--color-primary));
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
border-radius: var(--radius-md);
transition: background 150ms ease;
}
.show-all-button:hover {
background: hsl(var(--color-primary) / 0.1);
}
.quick-add-wrapper {
margin-top: 0.5rem;
padding: 0 0.25rem;
}
</style>

View file

@ -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 @@
</div>
{/if}
<!-- Todos row (shown per day, below all-day events) -->
{#if todosStore.serviceAvailable}
<div class="todos-row">
<div class="time-gutter"></div>
{#each days as day}
<div class="todos-cell">
<TodoRow date={day} maxVisible={2} />
</div>
{/each}
</div>
{/if}
<!-- Day headers -->
<div class="day-headers">
<div class="time-gutter"></div>
@ -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;

View file

@ -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 @@
</div>
{/if}
<!-- Tags -->
{#if event.tags && event.tags.length > 0}
<div class="detail-row">
<span class="detail-icon">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
/>
</svg>
</span>
<div class="detail-content">
<span class="detail-label">Tags</span>
<div class="tags-display">
{#each event.tags as tag (tag.id)}
<TagBadge tag={{ name: tag.name, color: tag.color }} />
{/each}
</div>
</div>
</div>
{/if}
<!-- Teilnehmer -->
{#if event.metadata?.attendees && event.metadata.attendees.length > 0}
<div class="detail-row">
@ -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;
}
</style>

View file

@ -1,11 +1,15 @@
<script lang="ts">
import { onMount } from 'svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { eventTagsStore } from '$lib/stores/event-tags.svelte';
import { TagSelector, type Tag } from '@manacore/shared-ui';
import type {
CalendarEvent,
CreateEventInput,
UpdateEventInput,
LocationDetails,
EventTag,
} from '@calendar/shared';
import { format, addMinutes, parseISO } from 'date-fns';
@ -36,6 +40,32 @@
let locationCity = $state(event?.metadata?.locationDetails?.city || '');
let locationCountry = $state(event?.metadata?.locationDetails?.country || '');
// Tags state - store as Tag[] for compatibility with TagSelector
let selectedTags = $state<Tag[]>(
event?.tags?.map((t) => ({
id: t.id,
name: t.name,
color: t.color,
})) || []
);
// Convert EventTag to Tag type for shared-ui components
function eventTagToTag(tag: EventTag): Tag {
return {
id: tag.id,
name: tag.name,
color: tag.color,
};
}
// Handle tag selection changes
function handleTagsChange(newTags: Tag[]) {
selectedTags = newTags;
}
// Derived available tags for TagSelector
let availableTags = $derived(eventTagsStore.tags.map(eventTagToTag));
// Auto-expand location details if any field is filled
$effect(() => {
if (event?.metadata?.locationDetails) {
@ -90,6 +120,13 @@
let submitting = $state(false);
// Load tags on mount
onMount(() => {
if (eventTagsStore.tags.length === 0) {
eventTagsStore.fetchTags();
}
});
function handleSubmit(e: Event) {
e.preventDefault();
@ -142,6 +179,7 @@
endTime: endDateTime.toISOString(),
calendarId,
metadata: finalMetadata,
tagIds: selectedTags.length > 0 ? selectedTags.map((t) => t.id) : undefined,
};
submitting = true;
@ -337,6 +375,20 @@
></textarea>
</div>
<!-- Tags -->
{#if availableTags.length > 0 || eventTagsStore.loading}
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground">Tags</label>
<TagSelector
tags={availableTags}
{selectedTags}
onTagsChange={handleTagsChange}
placeholder="Tags auswählen..."
addTagLabel="Tag hinzufügen"
/>
</div>
{/if}
<div class="flex justify-end gap-3 pt-4 border-t border-border">
<button
type="button"

View file

@ -0,0 +1,124 @@
<script lang="ts">
import type { TaskPriority } from '$lib/api/todos';
import { PRIORITY_COLORS, PRIORITY_LABELS } from '$lib/api/todos';
interface Props {
priority: TaskPriority;
variant?: 'dot' | 'badge' | 'pill';
size?: 'sm' | 'md';
showLabel?: boolean;
}
let { priority, variant = 'dot', size = 'md', showLabel = false }: Props = $props();
const color = $derived(PRIORITY_COLORS[priority]);
const label = $derived(PRIORITY_LABELS[priority]);
</script>
{#if variant === 'dot'}
<span
class="priority-dot"
class:size-sm={size === 'sm'}
style="--priority-color: {color};"
title={label}
aria-label="Priorität: {label}"
></span>
{:else if variant === 'badge'}
<span
class="priority-badge"
class:size-sm={size === 'sm'}
style="--priority-color: {color};"
title={label}
>
{#if showLabel}
{label}
{:else}
{priority.charAt(0).toUpperCase()}
{/if}
</span>
{:else if variant === 'pill'}
<span class="priority-pill" class:size-sm={size === 'sm'} style="--priority-color: {color};">
<span class="pill-dot"></span>
{#if showLabel}
<span class="pill-label">{label}</span>
{/if}
</span>
{/if}
<style>
/* Dot variant */
.priority-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--priority-color);
flex-shrink: 0;
}
.priority-dot.size-sm {
width: 6px;
height: 6px;
}
/* Badge variant */
.priority-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 4px;
background: var(--priority-color);
color: white;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.priority-badge.size-sm {
min-width: 16px;
height: 16px;
padding: 0 4px;
font-size: 0.625rem;
}
/* Pill variant */
.priority-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
border-radius: 9999px;
background: color-mix(in srgb, var(--priority-color) 15%, transparent);
border: 1px solid color-mix(in srgb, var(--priority-color) 30%, transparent);
}
.priority-pill.size-sm {
gap: 4px;
padding: 1px 6px;
}
.pill-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--priority-color);
}
.priority-pill.size-sm .pill-dot {
width: 5px;
height: 5px;
}
.pill-label {
font-size: 0.75rem;
font-weight: 500;
color: var(--priority-color);
}
.priority-pill.size-sm .pill-label {
font-size: 0.6875rem;
}
</style>

View file

@ -0,0 +1,226 @@
<script lang="ts">
import { todosStore } from '$lib/stores/todos.svelte';
import { Plus, X } from 'lucide-svelte';
interface Props {
placeholder?: string;
onsubmit?: () => void;
oncancel?: () => void;
autofocus?: boolean;
showButton?: boolean;
}
let {
placeholder = 'Neue Aufgabe...',
onsubmit,
oncancel,
autofocus = false,
showButton = true,
}: Props = $props();
let title = $state('');
let isExpanded = $state(!showButton);
let isSubmitting = $state(false);
let inputRef: HTMLInputElement | undefined = $state();
function expand() {
isExpanded = true;
// Focus input after DOM update
setTimeout(() => inputRef?.focus(), 0);
}
function collapse() {
isExpanded = false;
title = '';
oncancel?.();
}
async function handleSubmit(e?: Event) {
e?.preventDefault();
const trimmedTitle = title.trim();
if (!trimmedTitle || isSubmitting) return;
isSubmitting = true;
const result = await todosStore.createTodo({
title: trimmedTitle,
priority: 'medium',
});
isSubmitting = false;
if (!result.error) {
title = '';
onsubmit?.();
// Keep input focused for quick successive adds
inputRef?.focus();
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
} else if (e.key === 'Escape') {
collapse();
}
}
function handleBlur() {
// Only collapse if empty and showButton is true
if (showButton && !title.trim()) {
collapse();
}
}
</script>
{#if showButton && !isExpanded}
<button type="button" class="add-button" onclick={expand}>
<Plus size={16} />
<span>Aufgabe hinzufügen</span>
</button>
{:else}
<form class="quick-add-form" onsubmit={handleSubmit}>
<input
bind:this={inputRef}
bind:value={title}
type="text"
class="quick-add-input"
{placeholder}
disabled={isSubmitting}
onkeydown={handleKeydown}
onblur={handleBlur}
autofocus={autofocus || isExpanded}
/>
{#if showButton}
<button type="button" class="cancel-button" onclick={collapse} disabled={isSubmitting}>
<X size={14} />
</button>
{/if}
<button
type="submit"
class="submit-button"
disabled={!title.trim() || isSubmitting}
aria-label="Aufgabe erstellen"
>
{#if isSubmitting}
<span class="spinner"></span>
{:else}
<Plus size={14} />
{/if}
</button>
</form>
{/if}
<style>
.add-button {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
border-radius: var(--radius-md);
border: 1px dashed hsl(var(--color-border));
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
cursor: pointer;
transition: all 150ms ease;
}
.add-button:hover {
border-color: hsl(var(--color-primary));
color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.05);
}
.quick-add-form {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem;
border-radius: var(--radius-md);
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-surface));
transition: border-color 150ms ease;
}
.quick-add-form:focus-within {
border-color: hsl(var(--color-primary));
}
.quick-add-input {
flex: 1;
min-width: 0;
padding: 0.375rem 0.5rem;
border: none;
background: transparent;
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
outline: none;
}
.quick-add-input::placeholder {
color: hsl(var(--color-muted-foreground));
}
.quick-add-input:disabled {
opacity: 0.5;
}
.cancel-button,
.submit-button {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: var(--radius-sm);
border: none;
cursor: pointer;
transition: all 150ms ease;
}
.cancel-button {
background: transparent;
color: hsl(var(--color-muted-foreground));
}
.cancel-button:hover:not(:disabled) {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.submit-button {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.submit-button:hover:not(:disabled) {
background: hsl(var(--color-primary) / 0.9);
}
.submit-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.spinner {
width: 12px;
height: 12px;
border: 2px solid hsl(var(--color-primary-foreground) / 0.3);
border-top-color: hsl(var(--color-primary-foreground));
border-radius: 50%;
animation: spin 600ms linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -0,0 +1,130 @@
<script lang="ts">
import { Check } from 'lucide-svelte';
interface Props {
checked: boolean;
loading?: boolean;
size?: 'sm' | 'md' | 'lg';
onchange?: (checked: boolean) => void;
}
let { checked, loading = false, size = 'md', onchange }: Props = $props();
const sizes = {
sm: { box: 14, icon: 10 },
md: { box: 18, icon: 12 },
lg: { box: 22, icon: 16 },
};
function handleClick(e: MouseEvent) {
e.stopPropagation();
if (!loading && onchange) {
onchange(!checked);
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
if (!loading && onchange) {
onchange(!checked);
}
}
}
</script>
<button
type="button"
class="todo-checkbox"
class:checked
class:loading
class:size-sm={size === 'sm'}
class:size-md={size === 'md'}
class:size-lg={size === 'lg'}
style="--box-size: {sizes[size].box}px; --icon-size: {sizes[size].icon}px;"
onclick={handleClick}
onkeydown={handleKeydown}
disabled={loading}
aria-checked={checked}
aria-label={checked ? 'Als unerledigt markieren' : 'Als erledigt markieren'}
role="checkbox"
>
{#if loading}
<span class="spinner"></span>
{:else if checked}
<Check size={sizes[size].icon} strokeWidth={3} />
{/if}
</button>
<style>
.todo-checkbox {
width: var(--box-size);
height: var(--box-size);
min-width: var(--box-size);
border-radius: 4px;
border: 2px solid hsl(var(--color-border));
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 150ms ease;
padding: 0;
}
.todo-checkbox:hover:not(:disabled) {
border-color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.1);
}
.todo-checkbox:focus-visible {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 2px;
}
.todo-checkbox.checked {
background: hsl(var(--color-primary));
border-color: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.todo-checkbox.checked:hover:not(:disabled) {
background: hsl(var(--color-primary) / 0.8);
border-color: hsl(var(--color-primary) / 0.8);
}
.todo-checkbox:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.todo-checkbox.loading {
cursor: wait;
}
.spinner {
width: calc(var(--icon-size) - 2px);
height: calc(var(--icon-size) - 2px);
border: 2px solid hsl(var(--color-muted-foreground) / 0.3);
border-top-color: hsl(var(--color-primary));
border-radius: 50%;
animation: spin 600ms linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Size variants */
.size-sm {
border-radius: 3px;
border-width: 1.5px;
}
.size-lg {
border-radius: 5px;
}
</style>

View file

@ -0,0 +1,625 @@
<script lang="ts">
import { todosStore } from '$lib/stores/todos.svelte';
import type { Task, UpdateTaskInput, TaskPriority } from '$lib/api/todos';
import { PRIORITY_LABELS, PRIORITY_COLORS } from '$lib/api/todos';
import { toast } from '$lib/stores/toast';
import TodoCheckbox from './TodoCheckbox.svelte';
import PriorityBadge from './PriorityBadge.svelte';
import { X, Calendar, Clock, Folder, Tag, Trash2, CheckSquare, AlertCircle } from 'lucide-svelte';
import { format, parseISO } from 'date-fns';
import { de } from 'date-fns/locale';
interface Props {
task: Task;
onClose: () => void;
}
let { task: initialTask, onClose }: Props = $props();
// Local editable state
let task = $state<Task>({ ...initialTask });
let isEditing = $state(false);
let isSaving = $state(false);
let isDeleting = $state(false);
let isToggling = $state(false);
// Form state
let title = $state(task.title);
let description = $state(task.description || '');
let dueDate = $state(task.dueDate ? formatDateForInput(task.dueDate) : '');
let dueTime = $state(task.dueTime || '');
let priority = $state<TaskPriority>(task.priority);
function formatDateForInput(date: string | Date | null | undefined): string {
if (!date) return '';
const d = typeof date === 'string' ? parseISO(date) : date;
return format(d, 'yyyy-MM-dd');
}
function formatDisplayDate(date: string | Date | null | undefined): string {
if (!date) return 'Kein Datum';
const d = typeof date === 'string' ? parseISO(date) : date;
return format(d, 'EEEE, d. MMMM yyyy', { locale: de });
}
async function handleToggleComplete() {
isToggling = true;
const result = await todosStore.toggleComplete(task.id);
if (result.data) {
task = result.data;
} else if (result.error) {
toast.error(`Fehler: ${result.error.message}`);
}
isToggling = false;
}
async function handleSave() {
if (!title.trim()) {
toast.error('Titel darf nicht leer sein');
return;
}
isSaving = true;
const updateData: UpdateTaskInput = {
title: title.trim(),
description: description.trim() || null,
dueDate: dueDate || null,
dueTime: dueTime || null,
priority,
};
const result = await todosStore.updateTodo(task.id, updateData);
if (result.error) {
toast.error(`Fehler beim Speichern: ${result.error.message}`);
} else if (result.data) {
task = result.data;
toast.success('Aufgabe aktualisiert');
isEditing = false;
}
isSaving = false;
}
async function handleDelete() {
if (!confirm('Möchten Sie diese Aufgabe wirklich löschen?')) {
return;
}
isDeleting = true;
const result = await todosStore.deleteTodo(task.id);
if (result.error) {
toast.error(`Fehler beim Löschen: ${result.error.message}`);
isDeleting = false;
} else {
toast.success('Aufgabe gelöscht');
onClose();
}
}
function startEditing() {
// Reset form state to current task values
title = task.title;
description = task.description || '';
dueDate = task.dueDate ? formatDateForInput(task.dueDate) : '';
dueTime = task.dueTime || '';
priority = task.priority;
isEditing = true;
}
function cancelEditing() {
isEditing = false;
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (isEditing) {
cancelEditing();
} else {
onClose();
}
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="modal-backdrop" onclick={handleBackdropClick}>
<div class="modal" role="dialog" aria-labelledby="modal-title" aria-modal="true">
<!-- Header -->
<div class="modal-header">
<div class="header-left">
<TodoCheckbox
checked={task.isCompleted}
loading={isToggling}
size="lg"
onchange={handleToggleComplete}
/>
{#if !isEditing}
<h2 id="modal-title" class="modal-title" class:completed={task.isCompleted}>
{task.title}
</h2>
{/if}
</div>
<button type="button" class="close-button" onclick={onClose} aria-label="Schließen">
<X size={20} />
</button>
</div>
<!-- Content -->
<div class="modal-content">
{#if isEditing}
<!-- Edit Mode -->
<form
class="edit-form"
onsubmit={(e) => {
e.preventDefault();
handleSave();
}}
>
<div class="form-group">
<label for="title">Titel</label>
<input
id="title"
type="text"
bind:value={title}
placeholder="Aufgabentitel"
required
autofocus
/>
</div>
<div class="form-group">
<label for="description">Beschreibung</label>
<textarea
id="description"
bind:value={description}
placeholder="Beschreibung hinzufügen..."
rows="3"
></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="dueDate">Fälligkeitsdatum</label>
<input id="dueDate" type="date" bind:value={dueDate} />
</div>
<div class="form-group">
<label for="dueTime">Uhrzeit</label>
<input id="dueTime" type="time" bind:value={dueTime} />
</div>
</div>
<div class="form-group">
<label>Priorität</label>
<div class="priority-options">
{#each Object.entries(PRIORITY_LABELS) as [key, label]}
<button
type="button"
class="priority-option"
class:selected={priority === key}
style="--priority-color: {PRIORITY_COLORS[key as TaskPriority]};"
onclick={() => (priority = key as TaskPriority)}
>
<span class="priority-dot"></span>
{label}
</button>
{/each}
</div>
</div>
</form>
{:else}
<!-- View Mode -->
<div class="detail-section">
{#if task.description}
<p class="description">{task.description}</p>
{/if}
<div class="detail-list">
<div class="detail-item">
<Calendar size={16} />
<span>{formatDisplayDate(task.dueDate)}</span>
</div>
{#if task.dueTime}
<div class="detail-item">
<Clock size={16} />
<span>{task.dueTime} Uhr</span>
</div>
{/if}
<div class="detail-item">
<AlertCircle size={16} />
<PriorityBadge {priority} variant="pill" showLabel />
</div>
{#if task.project}
<div class="detail-item">
<Folder size={16} />
<span class="project-name" style="color: {task.project.color};">
{task.project.name}
</span>
</div>
{/if}
{#if task.labels && task.labels.length > 0}
<div class="detail-item labels-row">
<Tag size={16} />
<div class="labels">
{#each task.labels as label}
<span class="label-tag" style="--label-color: {label.color};">
{label.name}
</span>
{/each}
</div>
</div>
{/if}
</div>
{#if task.subtasks && task.subtasks.length > 0}
<div class="subtasks-section">
<h3>
<CheckSquare size={16} />
Unteraufgaben ({task.subtasks.filter((s) => s.isCompleted).length}/{task.subtasks
.length})
</h3>
<ul class="subtask-list">
{#each task.subtasks as subtask}
<li class:completed={subtask.isCompleted}>
<span class="subtask-check">{subtask.isCompleted ? '☑' : '☐'}</span>
{subtask.title}
</li>
{/each}
</ul>
</div>
{/if}
</div>
{/if}
</div>
<!-- Footer -->
<div class="modal-footer">
{#if isEditing}
<button type="button" class="btn btn-secondary" onclick={cancelEditing} disabled={isSaving}>
Abbrechen
</button>
<button type="button" class="btn btn-primary" onclick={handleSave} disabled={isSaving}>
{#if isSaving}
Speichern...
{:else}
Speichern
{/if}
</button>
{:else}
<button type="button" class="btn btn-danger" onclick={handleDelete} disabled={isDeleting}>
<Trash2 size={16} />
{#if isDeleting}
Löschen...
{:else}
Löschen
{/if}
</button>
<button type="button" class="btn btn-primary" onclick={startEditing}> Bearbeiten </button>
{/if}
</div>
</div>
</div>
<style>
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 1rem;
}
.modal {
width: 100%;
max-width: 500px;
max-height: 90vh;
background: hsl(var(--color-background));
border-radius: var(--radius-lg);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid hsl(var(--color-border));
}
.header-left {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
}
.modal-title {
font-size: 1.125rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.modal-title.completed {
text-decoration: line-through;
color: hsl(var(--color-muted-foreground));
}
.close-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--radius-md);
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
transition: all 150ms ease;
}
.close-button:hover {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.modal-content {
flex: 1;
overflow-y: auto;
padding: 1.25rem;
}
/* View Mode */
.description {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.6;
margin: 0 0 1rem;
white-space: pre-wrap;
}
.detail-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.detail-item {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.875rem;
color: hsl(var(--color-foreground));
}
.detail-item :global(svg) {
color: hsl(var(--color-muted-foreground));
flex-shrink: 0;
}
.labels-row {
align-items: flex-start;
}
.labels {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.label-tag {
font-size: 0.75rem;
color: var(--label-color);
background: color-mix(in srgb, var(--label-color) 15%, transparent);
padding: 2px 8px;
border-radius: 9999px;
}
.subtasks-section {
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid hsl(var(--color-border));
}
.subtasks-section h3 {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin: 0 0 0.75rem;
}
.subtask-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.subtask-list li {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
}
.subtask-list li.completed {
color: hsl(var(--color-muted-foreground));
text-decoration: line-through;
}
.subtask-check {
font-size: 0.875rem;
}
/* Edit Mode */
.edit-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.form-group label {
font-size: 0.8125rem;
font-weight: 500;
color: hsl(var(--color-foreground));
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
input[type='text'],
input[type='date'],
input[type='time'],
textarea {
padding: 0.5rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-md);
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
font-size: 0.875rem;
transition: border-color 150ms ease;
}
input:focus,
textarea:focus {
outline: none;
border-color: hsl(var(--color-primary));
}
textarea {
resize: vertical;
min-height: 80px;
}
.priority-options {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.priority-option {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: var(--radius-md);
border: 1px solid hsl(var(--color-border));
background: transparent;
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
cursor: pointer;
transition: all 150ms ease;
}
.priority-option:hover {
border-color: var(--priority-color);
}
.priority-option.selected {
border-color: var(--priority-color);
background: color-mix(in srgb, var(--priority-color) 15%, transparent);
}
.priority-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--priority-color);
}
/* Footer */
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-top: 1px solid hsl(var(--color-border));
}
.btn {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
border-radius: var(--radius-md);
border: none;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 150ms ease;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.btn-primary:hover:not(:disabled) {
background: hsl(var(--color-primary) / 0.9);
}
.btn-secondary {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.btn-secondary:hover:not(:disabled) {
background: hsl(var(--color-muted) / 0.8);
}
.btn-danger {
background: hsl(var(--color-danger) / 0.1);
color: hsl(var(--color-danger));
}
.btn-danger:hover:not(:disabled) {
background: hsl(var(--color-danger));
color: white;
}
</style>

View file

@ -0,0 +1,287 @@
<script lang="ts">
import type { Task } from '$lib/api/todos';
import { PRIORITY_COLORS } from '$lib/api/todos';
import { todosStore } from '$lib/stores/todos.svelte';
import TodoCheckbox from './TodoCheckbox.svelte';
import PriorityBadge from './PriorityBadge.svelte';
import { format, parseISO, isToday, isTomorrow, isPast, startOfDay } from 'date-fns';
import { de } from 'date-fns/locale';
interface Props {
task: Task;
variant?: 'default' | 'compact' | 'minimal';
showProject?: boolean;
showDueDate?: boolean;
showPriority?: boolean;
onclick?: () => void;
}
let {
task,
variant = 'default',
showProject = true,
showDueDate = true,
showPriority = true,
onclick,
}: Props = $props();
let isToggling = $state(false);
const priorityColor = $derived(PRIORITY_COLORS[task.priority]);
const dueDateLabel = $derived.by(() => {
if (!task.dueDate) return null;
const date = typeof task.dueDate === 'string' ? parseISO(task.dueDate) : task.dueDate;
if (isToday(date)) {
return task.dueTime ? `Heute, ${task.dueTime}` : 'Heute';
}
if (isTomorrow(date)) {
return task.dueTime ? `Morgen, ${task.dueTime}` : 'Morgen';
}
if (isPast(startOfDay(date)) && !task.isCompleted) {
return format(date, 'd. MMM', { locale: de });
}
return format(date, 'd. MMM', { locale: de });
});
const isOverdue = $derived.by(() => {
if (!task.dueDate || task.isCompleted) return false;
const date = typeof task.dueDate === 'string' ? parseISO(task.dueDate) : task.dueDate;
return isPast(startOfDay(date)) && !isToday(date);
});
const subtaskProgress = $derived.by(() => {
if (!task.subtasks || task.subtasks.length === 0) return null;
const completed = task.subtasks.filter((s) => s.isCompleted).length;
return { completed, total: task.subtasks.length };
});
async function handleToggle(checked: boolean) {
isToggling = true;
await todosStore.toggleComplete(task.id);
isToggling = false;
}
function handleClick(e: MouseEvent) {
// Don't trigger onclick when clicking checkbox
if ((e.target as HTMLElement).closest('.todo-checkbox')) return;
onclick?.();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && onclick) {
onclick();
}
}
</script>
<div
class="todo-item"
class:completed={task.isCompleted}
class:overdue={isOverdue}
class:compact={variant === 'compact'}
class:minimal={variant === 'minimal'}
class:clickable={!!onclick}
style="--priority-color: {priorityColor};"
onclick={handleClick}
onkeydown={handleKeydown}
role={onclick ? 'button' : 'listitem'}
tabindex={onclick ? 0 : -1}
>
<TodoCheckbox
checked={task.isCompleted}
loading={isToggling}
size={variant === 'minimal' ? 'sm' : 'md'}
onchange={handleToggle}
/>
<div class="todo-content">
<div class="todo-main">
{#if showPriority && variant !== 'minimal'}
<PriorityBadge
priority={task.priority}
variant="dot"
size={variant === 'compact' ? 'sm' : 'md'}
/>
{/if}
<span class="todo-title">{task.title}</span>
{#if subtaskProgress && variant === 'default'}
<span class="subtask-count">
{subtaskProgress.completed}/{subtaskProgress.total}
</span>
{/if}
</div>
{#if variant !== 'minimal'}
<div class="todo-meta">
{#if showDueDate && dueDateLabel}
<span class="due-date" class:overdue={isOverdue}>
{dueDateLabel}
</span>
{/if}
{#if showProject && task.project}
<span class="project" style="--project-color: {task.project.color};">
{task.project.name}
</span>
{/if}
{#if task.labels && task.labels.length > 0 && variant === 'default'}
<div class="labels">
{#each task.labels.slice(0, 2) as label}
<span class="label" style="--label-color: {label.color};">
{label.name}
</span>
{/each}
{#if task.labels.length > 2}
<span class="label-more">+{task.labels.length - 2}</span>
{/if}
</div>
{/if}
</div>
{/if}
</div>
</div>
<style>
.todo-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.625rem 0.75rem;
border-radius: var(--radius-md);
background: hsl(var(--color-surface));
border-left: 3px solid var(--priority-color);
transition: all 150ms ease;
}
.todo-item.clickable {
cursor: pointer;
}
.todo-item.clickable:hover {
background: hsl(var(--color-muted) / 0.5);
transform: translateX(2px);
}
.todo-item.completed {
opacity: 0.6;
}
.todo-item.completed .todo-title {
text-decoration: line-through;
color: hsl(var(--color-muted-foreground));
}
.todo-item.overdue {
background: hsl(var(--color-danger) / 0.05);
}
/* Compact variant */
.todo-item.compact {
padding: 0.5rem 0.625rem;
gap: 0.5rem;
border-left-width: 2px;
}
/* Minimal variant */
.todo-item.minimal {
padding: 0.375rem 0.5rem;
gap: 0.375rem;
border-left-width: 2px;
background: transparent;
}
.todo-item.minimal:hover {
background: hsl(var(--color-muted) / 0.3);
}
.todo-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.todo-main {
display: flex;
align-items: center;
gap: 0.5rem;
}
.todo-title {
flex: 1;
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.compact .todo-title {
font-size: 0.8125rem;
}
.minimal .todo-title {
font-size: 0.75rem;
}
.subtask-count {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
background: hsl(var(--color-muted) / 0.5);
padding: 1px 6px;
border-radius: 9999px;
flex-shrink: 0;
}
.todo-meta {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.due-date {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.due-date.overdue {
color: hsl(var(--color-danger));
font-weight: 500;
}
.project {
font-size: 0.6875rem;
color: var(--project-color);
background: color-mix(in srgb, var(--project-color) 15%, transparent);
padding: 1px 6px;
border-radius: 4px;
}
.labels {
display: flex;
align-items: center;
gap: 0.25rem;
}
.label {
font-size: 0.625rem;
color: var(--label-color);
background: color-mix(in srgb, var(--label-color) 15%, transparent);
padding: 1px 4px;
border-radius: 3px;
}
.label-more {
font-size: 0.625rem;
color: hsl(var(--color-muted-foreground));
}
</style>

View file

@ -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<EventTag[]>([]);
let loading = $state(false);
let error = $state<string | null>(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;
},
};

View file

@ -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<SimulationNode[]>([]);
let links = $state<SimulationLink[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let selectedNodeId = $state<string | null>(null);
let simulation: Simulation<SimulationNode, SimulationLink> | null = null;
let searchQuery = $state('');
let filterTagId = $state<string | null>(null);
let filterLocation = $state<string | null>(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<string>();
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<string, { id: string; name: string; color: string | null }>();
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<SimulationNode, SimulationLink>(nodes)
.force(
'link',
forceLink<SimulationNode, SimulationLink>(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<string>();
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;
});
},
};

View file

@ -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<string, string> = {
confirmed: '#10B981', // green
tentative: '#F59E0B', // orange
cancelled: '#EF4444', // red
};
const STATUS_LABELS: Record<string, string> = {
confirmed: 'Bestätigt',
tentative: 'Vorläufig',
cancelled: 'Abgesagt',
};
// State
let events = $state<CalendarEvent[]>([]);
let calendars = $state<Calendar[]>([]);
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<string, number>();
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<string, number>();
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<string, number> = {
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<string, { total: number; thisWeek: number }>();
// 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;
},
};

View file

@ -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<Task[]>([]);
let projects = $state<Project[]>([]);
let labels = $state<Label[]>([]);
let loading = $state(false);
let error = $state<string | null>(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<boolean> {
const result = await api.getTasks({ limit: 1 });
serviceAvailable = !result.error;
return serviceAvailable;
},
};

View file

@ -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(' · ');
}

View file

@ -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<void> {
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<ThemeVariant[]>(
(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<ThemeVariant[]>([...DEFAULT_THEME_VARIANTS, ...pinnedThemes]);
// Theme variant dropdown items
let themeVariantItems = $derived<PillDropdownItem[]>([
...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="⌘↵"
/>
</div>

View file

@ -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 @@
<MiniCalendar selectedDate={viewStore.currentDate} onDateSelect={handleDateSelect} />
<CalendarSidebar />
<TodoSidebarSection maxItems={5} />
</aside>
<!-- FAB when sidebar is collapsed -->

View file

@ -0,0 +1,415 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { networkStore, type SimulationNode } from '$lib/stores/network.svelte';
import { NetworkGraph, NetworkControls } from '@manacore/shared-ui';
import '$lib/i18n';
let graphComponent: NetworkGraph;
let controlsComponent: NetworkControls;
let graphContainer: HTMLDivElement;
function handleNodeClick(node: SimulationNode) {
// Select node (highlight connections)
networkStore.selectNode(node.id);
}
function handleNodeDoubleClick(node: SimulationNode) {
// Navigate to event detail page
goto(`/event/${node.id}`);
}
function handleBackgroundClick() {
networkStore.selectNode(null);
}
function handleDragStart(node: SimulationNode) {
networkStore.fixNode(node.id, node.x ?? 0, node.y ?? 0);
networkStore.reheatSimulation();
}
function handleDrag(node: SimulationNode, x: number, y: number) {
networkStore.fixNode(node.id, x, y);
}
function handleDragEnd(node: SimulationNode) {
networkStore.releaseNode(node.id);
}
function handleZoomIn() {
graphComponent?.zoomIn();
}
function handleZoomOut() {
graphComponent?.zoomOut();
}
function handleResetZoom() {
graphComponent?.resetZoom();
}
function handleFocusSelected() {
graphComponent?.focusOnSelectedNode();
}
function handleFocusSearch() {
controlsComponent?.focusSearch();
}
function handleSearch(query: string) {
networkStore.setSearch(query);
}
function handleTagFilter(tagId: string | null) {
networkStore.setFilterTag(tagId);
}
function handleSubtitleFilter(location: string | null) {
networkStore.setFilterLocation(location);
}
function handleStrengthFilter(strength: number) {
networkStore.setMinStrength(strength);
}
function handleClearFilters() {
networkStore.clearFilters();
}
// Initialize simulation when data is loaded and container is ready
$effect(() => {
if (!networkStore.loading && networkStore.allNodes.length > 0 && graphContainer) {
const rect = graphContainer.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
networkStore.initSimulation(rect.width, rect.height);
}
}
});
onMount(() => {
networkStore.loadGraph();
});
onDestroy(() => {
networkStore.stopSimulation();
});
</script>
<svelte:head>
<title>Netzwerk - Kalender</title>
</svelte:head>
<div class="network-page">
<!-- Controls (floating) -->
<div class="controls-wrapper">
<NetworkControls
bind:this={controlsComponent}
searchQuery={networkStore.searchQuery}
tags={networkStore.uniqueTags}
selectedTagId={networkStore.filterTagId}
subtitles={networkStore.uniqueLocations}
selectedSubtitle={networkStore.filterLocation}
subtitleLabel="Ort"
nodeCount={networkStore.nodes.length}
linkCount={networkStore.links.length}
nodeLabel="Events"
linkLabel="Verbindungen"
searchPlaceholder="Event suchen..."
minStrength={networkStore.minStrength}
onSearch={handleSearch}
onTagFilter={handleTagFilter}
onSubtitleFilter={handleSubtitleFilter}
onStrengthFilter={handleStrengthFilter}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
onResetZoom={handleResetZoom}
onFocusSelected={handleFocusSelected}
onClearFilters={handleClearFilters}
/>
</div>
<!-- Error Banner -->
{#if networkStore.error}
<div class="error-banner" role="alert">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span>{networkStore.error}</span>
</div>
{/if}
<!-- Main Content -->
<div class="graph-container" bind:this={graphContainer}>
{#if networkStore.loading}
<div class="loading-container">
<div class="loading-spinner"></div>
<p>Lade Netzwerk-Graph...</p>
</div>
{:else}
<NetworkGraph
bind:this={graphComponent}
nodes={networkStore.nodes}
links={networkStore.links}
selectedNodeId={networkStore.selectedNodeId}
onNodeClick={handleNodeClick}
onNodeDoubleClick={handleNodeDoubleClick}
onBackgroundClick={handleBackgroundClick}
onDragStart={handleDragStart}
onDrag={handleDrag}
onDragEnd={handleDragEnd}
onFocusSearch={handleFocusSearch}
/>
{/if}
</div>
<!-- Selected Event Info Panel -->
{#if networkStore.selectedNode}
<div class="info-panel">
<div class="info-header">
<h3>{networkStore.selectedNode.name}</h3>
<button class="close-btn" onclick={() => networkStore.selectNode(null)}>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{#if networkStore.selectedNode.subtitle}
<p class="info-subtitle">{networkStore.selectedNode.subtitle}</p>
{/if}
{#if networkStore.selectedNode.tags.length > 0}
<div class="info-tags">
{#each networkStore.selectedNode.tags as tag}
<span
class="tag"
style="background-color: {tag.color || 'hsl(var(--muted))'}; color: white;"
>
{tag.name}
</span>
{/each}
</div>
{/if}
<div class="info-stats">
<span>{networkStore.selectedNode.connectionCount} Verbindungen</span>
</div>
<button class="view-btn" onclick={() => goto(`/event/${networkStore.selectedNode?.id}`)}>
Event anzeigen
</button>
</div>
{/if}
</div>
<style>
.network-page {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
}
/* Floating Controls */
.controls-wrapper {
position: absolute;
top: 5rem; /* Below the nav */
left: 1rem;
z-index: 10;
max-width: calc(100% - 2rem);
}
/* Error Banner */
.error-banner {
position: absolute;
top: 5rem;
left: 50%;
transform: translateX(-50%);
z-index: 10;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
background: hsl(var(--destructive) / 0.1);
border: 1px solid hsl(var(--destructive) / 0.3);
border-radius: 0.875rem;
color: hsl(var(--destructive));
backdrop-filter: blur(8px);
}
/* Graph Container - Full screen */
.graph-container {
flex: 1;
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
}
/* Loading */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 1rem;
color: hsl(var(--muted-foreground));
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid hsl(var(--muted));
border-top-color: hsl(var(--primary));
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Info Panel */
.info-panel {
position: fixed;
top: 5rem;
right: 1rem;
bottom: 1rem;
width: 320px;
max-width: calc(100vw - 2rem);
z-index: 50;
background: hsl(var(--card) / 0.9);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--border) / 0.5);
border-radius: 1rem;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
animation: slideInRight 0.2s ease-out;
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.info-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.info-header h3 {
font-size: 1.25rem;
font-weight: 600;
color: hsl(var(--foreground));
margin: 0;
}
.close-btn {
padding: 0.25rem;
border-radius: 0.5rem;
background: transparent;
border: none;
cursor: pointer;
color: hsl(var(--muted-foreground));
transition: all 0.15s;
}
.close-btn:hover {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}
.info-subtitle {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin: 0;
}
.info-tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tag {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.info-stats {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
}
.view-btn {
margin-top: auto;
padding: 0.75rem 1rem;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border: none;
border-radius: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.view-btn:hover {
opacity: 0.9;
}
/* Responsive */
@media (max-width: 1024px) {
.info-panel {
width: 100%;
max-width: 100%;
top: auto;
right: 0;
bottom: 0;
height: auto;
max-height: 50vh;
border-radius: 1rem 1rem 0 0;
animation: slideInUp 0.2s ease-out;
}
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
}
@media (max-width: 768px) {
.controls-wrapper {
top: 6rem;
width: calc(100% - 1rem);
max-width: none;
}
}
</style>

View file

@ -0,0 +1,287 @@
<script lang="ts">
import { onMount } from 'svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { calendarStatisticsStore } from '$lib/stores/statistics.svelte';
import {
StatsGrid,
ActivityHeatmap,
TrendLineChart,
DonutChart,
ProgressBars,
StatisticsSkeleton,
type StatItem,
} from '@manacore/shared-ui';
import {
BarChart3,
CalendarDays,
Calendar,
Clock,
CalendarCheck,
Hourglass,
} from 'lucide-svelte';
import { subDays, addDays } from 'date-fns';
let loading = $state(true);
// Update statistics when events change
$effect(() => {
calendarStatisticsStore.setEvents(eventsStore.events);
});
$effect(() => {
calendarStatisticsStore.setCalendars(calendarsStore.calendars);
});
// Build stats items for StatsGrid
let statsItems = $derived<StatItem[]>([
{
id: 'eventsToday',
label: 'Heute',
value: calendarStatisticsStore.eventsToday,
icon: CalendarDays,
variant: 'success',
},
{
id: 'eventsThisWeek',
label: 'Diese Woche',
value: calendarStatisticsStore.eventsThisWeek,
icon: Calendar,
variant: 'primary',
},
{
id: 'upcoming',
label: 'Anstehend (7 Tage)',
value: calendarStatisticsStore.upcomingEvents,
icon: CalendarCheck,
variant: 'info',
},
{
id: 'busyHours',
label: 'Stunden/Woche',
value: `${calendarStatisticsStore.busyHoursThisWeek}h`,
icon: Clock,
variant: 'neutral',
},
{
id: 'calendars',
label: 'Kalender',
value: calendarStatisticsStore.totalCalendars,
icon: Calendar,
variant: 'accent',
},
{
id: 'avgDuration',
label: 'Ø Dauer (Min)',
value: calendarStatisticsStore.averageEventDuration,
icon: Hourglass,
variant: 'info',
},
]);
onMount(async () => {
// Fetch events for the last 6 months + next month for statistics
const startDate = subDays(new Date(), 180);
const endDate = addDays(new Date(), 30);
await Promise.all([
eventsStore.fetchEvents(startDate, endDate),
calendarsStore.fetchCalendars(),
]);
loading = false;
});
</script>
<svelte:head>
<title>Statistiken - Kalender</title>
</svelte:head>
<div class="statistics-page">
<header class="page-header">
<div class="header-icon">
<BarChart3 size={28} />
</div>
<div class="header-content">
<h1>Statistiken</h1>
<p class="header-subtitle">Dein Kalender im Überblick</p>
</div>
</header>
{#if loading}
<StatisticsSkeleton statCards={6} legendItems={3} />
{:else}
<!-- Quick Stats -->
<section class="stats-section">
<StatsGrid items={statsItems} columns={6} />
</section>
<!-- Charts Grid -->
<div class="charts-grid">
<!-- Activity Heatmap -->
<section class="chart-section heatmap-section">
<ActivityHeatmap
data={calendarStatisticsStore.activityHeatmap}
itemName="Event"
itemNamePlural="Events"
/>
</section>
<!-- Weekly Trend + Status Donut -->
<div class="charts-row">
<section class="chart-section trend-section">
<TrendLineChart
data={calendarStatisticsStore.weeklyTrend}
itemName="Event"
itemNamePlural="Events"
/>
</section>
<section class="chart-section donut-section">
<DonutChart
data={calendarStatisticsStore.statusBreakdown}
title="Status"
centerLabel="Events"
centerValue={calendarStatisticsStore.totalEvents}
/>
</section>
</div>
<!-- Calendar Activity -->
<section class="chart-section calendars-section">
<ProgressBars
data={calendarStatisticsStore.calendarActivity}
title="Kalender-Aktivität"
emptyMessage="Keine Kalender mit Events"
/>
</section>
</div>
<!-- Additional Stats -->
<div class="additional-stats">
<div class="stat-card-small">
<span class="stat-label">Ganztägige Events</span>
<span class="stat-value">
{calendarStatisticsStore.allDayRatio.allDay}
<span class="stat-percentage"
>({calendarStatisticsStore.allDayRatio.allDayPercentage}%)</span
>
</span>
</div>
<div class="stat-card-small">
<span class="stat-label">Wiederkehrende Events</span>
<span class="stat-value">{calendarStatisticsStore.recurringEventsCount}</span>
</div>
<div class="stat-card-small">
<span class="stat-label">Events gesamt</span>
<span class="stat-value">{calendarStatisticsStore.totalEvents}</span>
</div>
</div>
{/if}
</div>
<style>
.statistics-page {
padding-bottom: 6rem;
}
.page-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
}
.header-icon {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
background: hsl(var(--primary) / 0.15);
color: hsl(var(--primary));
border-radius: 1rem;
}
.header-content h1 {
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--foreground));
margin: 0;
}
.header-subtitle {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin: 0.25rem 0 0 0;
}
.stats-section {
margin-bottom: 1.5rem;
}
.charts-grid {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.charts-row {
display: grid;
grid-template-columns: 1fr;
gap: 1.5rem;
}
@media (min-width: 768px) {
.charts-row {
grid-template-columns: 2fr 1fr;
}
}
.chart-section {
min-width: 0;
}
.additional-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-top: 1.5rem;
}
.stat-card-small {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 1rem;
}
:global(.dark) .stat-card-small {
background: rgba(30, 30, 30, 0.95);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.stat-card-small .stat-label {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
.stat-card-small .stat-value {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--foreground));
}
.stat-percentage {
font-size: 0.875rem;
font-weight: 400;
color: hsl(var(--muted-foreground));
}
</style>

View file

@ -0,0 +1,309 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { TagList, TagEditModal, type Tag } from '@manacore/shared-ui';
import { MagnifyingGlass, Plus, CaretLeft } from '@manacore/shared-icons';
import { eventTagsStore } from '$lib/stores/event-tags.svelte';
import type { EventTag } from '@calendar/shared';
let searchQuery = $state('');
let showModal = $state(false);
let editingTag = $state<EventTag | null>(null);
const filteredTags = $derived.by(() => {
if (!searchQuery.trim()) return eventTagsStore.tags;
const query = searchQuery.toLowerCase();
return eventTagsStore.tags.filter((t) => t.name.toLowerCase().includes(query));
});
// Convert EventTag to Tag type for shared-ui components
function eventTagToTag(tag: EventTag): Tag {
return {
id: tag.id,
name: tag.name,
color: tag.color,
};
}
function openCreateModal() {
editingTag = null;
showModal = true;
}
function openEditModal(tag: Tag) {
const eventTag = eventTagsStore.tags.find((t) => t.id === tag.id);
if (eventTag) {
editingTag = eventTag;
showModal = true;
}
}
function closeModal() {
showModal = false;
editingTag = null;
}
async function handleSave(name: string, color: string) {
try {
if (editingTag) {
await eventTagsStore.updateTag(editingTag.id, { name, color });
} else {
await eventTagsStore.createTag({ name, color });
}
closeModal();
} catch (e) {
console.error('Failed to save tag:', e);
}
}
async function handleDelete() {
if (!editingTag) return;
try {
await eventTagsStore.deleteTag(editingTag.id);
closeModal();
} catch (e) {
console.error('Failed to delete tag:', e);
}
}
async function handleDeleteFromList(tag: Tag) {
if (!confirm(`Tag "${tag.name}" wirklich löschen?`)) return;
try {
await eventTagsStore.deleteTag(tag.id);
} catch (e) {
console.error('Failed to delete tag:', e);
}
}
onMount(() => {
if (eventTagsStore.tags.length === 0) {
eventTagsStore.fetchTags();
}
});
</script>
<svelte:head>
<title>Tags - Kalender</title>
</svelte:head>
<div class="page-container">
<!-- Header -->
<header class="header">
<a href="/" class="back-button" aria-label="Zurück">
<CaretLeft size={20} weight="bold" />
</a>
<h1 class="title">Tags</h1>
<button onclick={openCreateModal} class="add-button" aria-label="Neues Tag">
<Plus size={20} weight="bold" />
</button>
</header>
<!-- Search -->
<div class="search-wrapper">
<MagnifyingGlass size={20} class="search-icon" />
<input
type="text"
placeholder="Tags durchsuchen..."
bind:value={searchQuery}
class="search-input"
/>
</div>
{#if eventTagsStore.error}
<div class="error-banner" role="alert">
<span>{eventTagsStore.error}</span>
</div>
{/if}
<!-- Tag List using shared component -->
<TagList
tags={filteredTags.map(eventTagToTag)}
loading={eventTagsStore.loading}
onEdit={openEditModal}
onDelete={handleDeleteFromList}
emptyMessage={searchQuery ? 'Keine Tags gefunden' : 'Keine Tags vorhanden'}
emptyDescription={searchQuery
? `Kein Tag für "${searchQuery}" gefunden`
: 'Erstelle dein erstes Tag'}
/>
{#if !eventTagsStore.loading && eventTagsStore.tags.length > 0}
<p class="tags-count">
{eventTagsStore.tags.length}
{eventTagsStore.tags.length === 1 ? 'Tag' : 'Tags'}
</p>
{/if}
{#if !eventTagsStore.loading && eventTagsStore.tags.length === 0 && !searchQuery}
<div class="empty-cta">
<button onclick={openCreateModal} class="btn btn-primary">
<Plus size={16} weight="bold" />
Neues Tag
</button>
</div>
{/if}
</div>
<!-- Create/Edit Modal using shared component -->
<TagEditModal
tag={editingTag ? eventTagToTag(editingTag) : null}
isOpen={showModal}
onClose={closeModal}
onSave={handleSave}
onDelete={editingTag ? handleDelete : undefined}
title={editingTag ? 'Tag bearbeiten' : 'Neues Tag'}
saveLabel={editingTag ? 'Speichern' : 'Erstellen'}
deleteLabel="Löschen"
cancelLabel="Abbrechen"
namePlaceholder="Tag Name"
colorLabel="Farbe"
previewLabel="Vorschau"
deleteConfirmMessage={`Tag "${editingTag?.name || ''}" wirklich löschen?`}
/>
<style>
.page-container {
max-width: 640px;
margin: 0 auto;
padding: 0 1rem 2rem;
}
/* Header */
.header {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 0;
margin-bottom: 0.5rem;
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: hsl(var(--muted));
color: hsl(var(--foreground));
transition: all 0.2s ease;
}
.back-button:hover {
background: hsl(var(--muted-foreground) / 0.2);
transform: translateX(-2px);
}
.title {
flex: 1;
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--foreground));
}
.add-button {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
.add-button:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px hsl(var(--primary) / 0.3);
}
/* Search */
.search-wrapper {
position: relative;
margin-bottom: 1.5rem;
}
.search-wrapper :global(.search-icon) {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
color: hsl(var(--muted-foreground));
pointer-events: none;
}
.search-input {
width: 100%;
padding: 0.875rem 1rem 0.875rem 3rem;
border: 1.5px solid hsl(var(--border));
border-radius: 0.75rem;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 0.9375rem;
transition: all 0.2s ease;
}
.search-input:focus {
outline: none;
border-color: hsl(var(--primary));
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1);
}
/* Error */
.error-banner {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: hsl(0 84% 60% / 0.1);
border: 1px solid hsl(0 84% 60% / 0.3);
border-radius: 0.75rem;
color: hsl(0 84% 60%);
margin-bottom: 1.5rem;
}
/* Count */
.tags-count {
text-align: center;
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin-top: 1.5rem;
}
/* Empty CTA */
.empty-cta {
display: flex;
justify-content: center;
margin-top: 1rem;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
border-radius: 0.625rem;
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s ease;
border: none;
text-decoration: none;
}
.btn-primary {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.btn-primary:hover {
box-shadow: 0 4px 12px hsl(var(--primary) / 0.3);
}
</style>

View file

@ -0,0 +1,486 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { todosStore } from '$lib/stores/todos.svelte';
import type { Task } from '$lib/api/todos';
import type { CalendarEvent } from '@calendar/shared';
import AgendaItem from '$lib/components/agenda/AgendaItem.svelte';
import AgendaFilters from '$lib/components/agenda/AgendaFilters.svelte';
import TodoDetailModal from '$lib/components/todo/TodoDetailModal.svelte';
import QuickAddTodo from '$lib/components/todo/QuickAddTodo.svelte';
import { AgendaSkeleton } from '$lib/components/skeletons';
import {
format,
parseISO,
isToday,
isTomorrow,
addDays,
startOfDay,
endOfDay,
isBefore,
} from 'date-fns';
import { de } from 'date-fns/locale';
import { CheckSquare, AlertTriangle, Plus } from 'lucide-svelte';
// State
let loading = $state(true);
let showEvents = $state(true);
let showTodos = $state(true);
let timeRange = $state<'7' | '30' | 'all'>('30');
let selectedTask = $state<Task | null>(null);
let showQuickAdd = $state(false);
// Combined and grouped items
type AgendaGroup = {
date: Date;
items: Array<{ type: 'event' | 'todo'; event?: CalendarEvent; todo?: Task }>;
};
let groupedItems = $derived.by(() => {
const groups = new Map<string, AgendaGroup['items']>();
const today = startOfDay(new Date());
// Add events
if (showEvents) {
const currentEvents = eventsStore.events ?? [];
if (Array.isArray(currentEvents)) {
for (const event of currentEvents) {
const start =
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const dateKey = format(start, 'yyyy-MM-dd');
if (!groups.has(dateKey)) {
groups.set(dateKey, []);
}
groups.get(dateKey)!.push({ type: 'event', event });
}
}
}
// Add todos
if (showTodos) {
const currentTodos = todosStore.todos ?? [];
if (Array.isArray(currentTodos)) {
for (const todo of currentTodos) {
if (todo.isCompleted) continue; // Skip completed todos
let dateKey: string;
if (todo.dueDate) {
const dueDate =
typeof todo.dueDate === 'string' ? parseISO(todo.dueDate) : todo.dueDate;
// Group overdue todos under today
if (isBefore(startOfDay(dueDate), today)) {
dateKey = format(today, 'yyyy-MM-dd');
} else {
dateKey = format(dueDate, 'yyyy-MM-dd');
}
} else {
// Todos without due date go under today
dateKey = format(today, 'yyyy-MM-dd');
}
if (!groups.has(dateKey)) {
groups.set(dateKey, []);
}
groups.get(dateKey)!.push({ type: 'todo', todo });
}
}
}
// Sort groups by date and items within each group
return Array.from(groups.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([dateKey, items]) => ({
date: parseISO(dateKey),
items: items.sort((a, b) => {
// Todos before events
if (a.type !== b.type) return a.type === 'todo' ? -1 : 1;
// Sort events by time
if (a.type === 'event' && b.type === 'event' && a.event && b.event) {
const aStart =
typeof a.event.startTime === 'string'
? parseISO(a.event.startTime)
: a.event.startTime;
const bStart =
typeof b.event.startTime === 'string'
? parseISO(b.event.startTime)
: b.event.startTime;
return aStart.getTime() - bStart.getTime();
}
// Sort todos by priority
if (a.type === 'todo' && b.type === 'todo' && a.todo && b.todo) {
const priorityOrder = { urgent: 0, high: 1, medium: 2, low: 3 };
return priorityOrder[a.todo.priority] - priorityOrder[b.todo.priority];
}
return 0;
}),
}));
});
// Stats
const overdueCount = $derived(todosStore.overdueTodos.length);
const todayCount = $derived(todosStore.todaysTodos.length);
const totalActiveCount = $derived(todosStore.activeTodosCount);
onMount(async () => {
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
// Fetch data based on time range
await fetchData();
loading = false;
});
async function fetchData() {
const start = startOfDay(new Date());
const days = timeRange === '7' ? 7 : timeRange === '30' ? 30 : 90;
const end = endOfDay(addDays(start, days));
await Promise.all([
eventsStore.fetchEvents(start, end),
todosStore.fetchTodos(start, end),
todosStore.fetchTodayTodos(),
]);
}
function formatDateHeader(date: Date) {
if (isToday(date)) {
return 'Heute';
}
if (isTomorrow(date)) {
return 'Morgen';
}
return format(date, 'EEEE, d. MMMM', { locale: de });
}
function handleEventClick(eventId: string) {
goto(`/?event=${eventId}`);
}
function handleTodoClick(task: Task) {
selectedTask = task;
}
function handleModalClose() {
selectedTask = null;
}
function toggleEvents() {
showEvents = !showEvents;
}
function toggleTodos() {
showTodos = !showTodos;
}
function handleRangeChange(range: '7' | '30' | 'all') {
timeRange = range;
loading = true;
fetchData().then(() => (loading = false));
}
</script>
<svelte:head>
<title>Aufgaben | Kalender</title>
</svelte:head>
<div class="tasks-page">
<header class="page-header">
<div class="header-content">
<div class="header-icon">
<CheckSquare size={24} />
</div>
<div>
<h1>Aufgaben</h1>
<p class="subtitle">Ihre Termine und Aufgaben auf einen Blick</p>
</div>
</div>
<!-- Stats -->
<div class="stats">
{#if overdueCount > 0}
<span class="stat overdue">
<AlertTriangle size={14} />
{overdueCount} überfällig
</span>
{/if}
<span class="stat">{todayCount} heute</span>
<span class="stat">{totalActiveCount} gesamt</span>
</div>
</header>
<!-- Filters -->
<AgendaFilters
{showEvents}
{showTodos}
{timeRange}
onToggleEvents={toggleEvents}
onToggleTodos={toggleTodos}
onRangeChange={handleRangeChange}
/>
<!-- Quick Add -->
<div class="quick-add-section">
{#if showQuickAdd}
<QuickAddTodo
placeholder="Neue Aufgabe hinzufügen..."
autofocus
showButton={false}
onsubmit={() => (showQuickAdd = false)}
oncancel={() => (showQuickAdd = false)}
/>
{:else}
<button type="button" class="quick-add-button" onclick={() => (showQuickAdd = true)}>
<Plus size={16} />
<span>Neue Aufgabe</span>
</button>
{/if}
</div>
<!-- Content -->
{#if loading}
<AgendaSkeleton />
{:else if !todosStore.serviceAvailable}
<div class="error-state card">
<AlertTriangle size={24} />
<p>Todo-Service ist nicht erreichbar</p>
<p class="hint">Bitte versuchen Sie es später erneut</p>
</div>
{:else if groupedItems.length === 0}
<div class="empty-state card">
<CheckSquare size={32} />
<p>Keine Einträge gefunden</p>
<p class="hint">
{#if !showEvents && !showTodos}
Aktivieren Sie mindestens einen Filter
{:else}
Erstellen Sie eine neue Aufgabe oder ändern Sie den Zeitraum
{/if}
</p>
</div>
{:else}
<div class="item-list">
{#each groupedItems as group}
<div class="date-group">
<h2 class="date-header" class:today={isToday(group.date)}>
{formatDateHeader(group.date)}
<span class="item-count">({group.items.length})</span>
</h2>
<div class="items">
{#each group.items as item}
{#if item.type === 'event' && item.event}
<AgendaItem
type="event"
event={item.event}
onclick={() => handleEventClick(item.event!.id)}
/>
{:else if item.type === 'todo' && item.todo}
<AgendaItem
type="todo"
todo={item.todo}
onclick={() => handleTodoClick(item.todo!)}
/>
{/if}
{/each}
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Detail Modal -->
{#if selectedTask}
<TodoDetailModal task={selectedTask} onClose={handleModalClose} />
{/if}
<style>
.tasks-page {
max-width: 700px;
margin: 0 auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.5rem;
}
.header-content {
display: flex;
align-items: center;
gap: 0.75rem;
}
.header-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: var(--radius-lg);
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
}
h1 {
font-size: 1.5rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin: 0;
}
.subtitle {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
margin: 0.25rem 0 0;
}
.stats {
display: flex;
align-items: center;
gap: 0.75rem;
}
.stat {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
padding: 0.25rem 0.5rem;
background: hsl(var(--color-muted) / 0.5);
border-radius: var(--radius-sm);
}
.stat.overdue {
color: hsl(var(--color-danger));
background: hsl(var(--color-danger) / 0.1);
}
.quick-add-section {
margin-bottom: 0.5rem;
}
.quick-add-button {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
padding: 0.75rem;
border-radius: var(--radius-lg);
border: 1px dashed hsl(var(--color-border));
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
cursor: pointer;
transition: all 150ms ease;
}
.quick-add-button:hover {
border-color: hsl(var(--color-primary));
color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.05);
}
.item-list {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.date-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.date-header {
font-size: 0.8125rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
letter-spacing: 0.05em;
display: flex;
align-items: center;
gap: 0.5rem;
}
.date-header.today {
color: hsl(var(--color-primary));
}
.item-count {
font-weight: 400;
font-size: 0.75rem;
}
.items {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.empty-state,
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 3rem 1.5rem;
text-align: center;
color: hsl(var(--color-muted-foreground));
}
.empty-state :global(svg),
.error-state :global(svg) {
opacity: 0.5;
}
.error-state {
color: hsl(var(--color-danger));
}
.hint {
font-size: 0.8125rem;
opacity: 0.7;
margin: 0;
}
.card {
background: hsl(var(--color-surface));
border-radius: var(--radius-lg);
border: 1px solid hsl(var(--color-border));
}
@media (max-width: 640px) {
.tasks-page {
padding: 1rem;
}
.page-header {
flex-direction: column;
align-items: stretch;
}
.stats {
justify-content: flex-start;
}
}
</style>

View file

@ -0,0 +1,19 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ThemePage } from '@manacore/shared-theme-ui';
import { theme } from '$lib/stores/theme';
</script>
<svelte:head>
<title>Themes | Calendar</title>
</svelte:head>
<ThemePage
currentVariant={theme.variant}
onSelectTheme={(v) => theme.setVariant(v)}
showModeSelector={true}
currentMode={theme.mode}
onModeChange={(m) => theme.setMode(m)}
showBackButton={true}
onBack={() => goto('/')}
/>

View file

@ -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[];
}
/**

View file

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

View file

@ -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";

View file

@ -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<ThemeVariant[]>(
(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<ThemeVariant[]>([...DEFAULT_THEME_VARIANTS, ...pinnedThemes]);
// Theme variant dropdown items
let themeVariantItems = $derived<PillDropdownItem[]>([
// 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;

View file

@ -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(),

View file

@ -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(),

View file

@ -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'),

View file

@ -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(),

View file

@ -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 {

View file

@ -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<ThemeVariant[]>(
(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<ThemeVariant[]>([...DEFAULT_THEME_VARIANTS, ...pinnedThemes]);
// Theme variant dropdown items (with SSR fallback)
let themeVariantItems = $derived<PillDropdownItem[]>([
...(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;

View file

@ -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<ContactTag[]> {
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<ContactTag[]> {
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<ContactTag | null> {

View file

@ -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:*",

View file

@ -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 {

View file

@ -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<typeof createTagsClient> | 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

View file

@ -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' },
];
</script>

View file

@ -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',
});
}
}
</script>
@ -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;

View file

@ -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(),
});

View file

@ -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<SimulationNode> {
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<SimulationNode[]>([]);
@ -42,6 +32,7 @@ let simulation: Simulation<SimulationNode, SimulationLink> | null = null;
let searchQuery = $state('');
let filterTagId = $state<string | null>(null);
let filterCompany = $state<string | null>(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<string>();
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;
},
/**

View file

@ -59,7 +59,7 @@ export interface ContactsAppSettings {
const DEFAULT_SETTINGS: ContactsAppSettings = {
// Display Settings
defaultView: 'list',
defaultView: 'alphabet',
sortBy: 'name',
sortOrder: 'asc',
showPhotos: true,

View file

@ -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<Contact[]>([]);
let tags = $state<ContactTag[]>([]);
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<string, number>();
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<string, number>();
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<string, number>();
// 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<string, number>();
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;
},
};

View file

@ -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<ViewMode>(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';
}
},
};

View file

@ -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(' · ');
}

View file

@ -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<ThemeVariant[]>(
(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<ThemeVariant[]>([...DEFAULT_THEME_VARIANTS, ...pinnedThemes]);
// Theme variant dropdown items
let themeVariantItems = $derived<PillDropdownItem[]>([
// 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<void> {
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 @@
<!-- Navigation Layout -->
<div class="layout-container">
<!-- Shadow gradient above navigation -->
<div class="nav-shadow-gradient"></div>
<!-- Floating/Sidebar Pill Navigation -->
<PillNavigation
items={navItems}
@ -267,7 +350,7 @@
class:sidebar-mode={isSidebarMode && !isCollapsed}
class:floating-mode={!isSidebarMode}
>
<div class="content-wrapper" class:settings-page={$page.url.pathname === '/settings'}>
<div class="content-wrapper">
{@render children()}
</div>
</main>
@ -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="⌘↵"
/>
</div>
@ -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;
}
}
</style>

View file

@ -1,23 +1,47 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { networkStore, type SimulationNode } from '$lib/stores/network.svelte';
import NetworkGraph from '$lib/components/network/NetworkGraph.svelte';
import NetworkControls from '$lib/components/network/NetworkControls.svelte';
import { NetworkGraph, NetworkControls } from '@manacore/shared-ui';
import ContactDetailModal from '$lib/components/ContactDetailModal.svelte';
import { NetworkGraphSkeleton } from '$lib/components/skeletons';
import '$lib/i18n';
let graphComponent: NetworkGraph;
let controlsComponent: NetworkControls;
let graphContainer: HTMLDivElement;
function handleNodeClick(node: SimulationNode) {
// Select node (highlight connections and show detail sidebar)
networkStore.selectNode(node.id);
}
function handleNodeDoubleClick(node: SimulationNode) {
// Navigate to contact detail page
goto(`/contacts/${node.id}`);
}
function handleBackgroundClick() {
networkStore.selectNode(null);
}
function handleCloseSidebar() {
networkStore.selectNode(null);
}
function handleDragStart(node: SimulationNode) {
networkStore.fixNode(node.id, node.x ?? 0, node.y ?? 0);
networkStore.reheatSimulation();
}
function handleDrag(node: SimulationNode, x: number, y: number) {
networkStore.fixNode(node.id, x, y);
}
function handleDragEnd(node: SimulationNode) {
networkStore.releaseNode(node.id);
}
function handleZoomIn() {
graphComponent?.zoomIn();
}
@ -30,9 +54,51 @@
graphComponent?.resetZoom();
}
function handleFocusSelected() {
graphComponent?.focusOnSelectedNode();
}
function handleFocusSearch() {
controlsComponent?.focusSearch();
}
function handleSearch(query: string) {
networkStore.setSearch(query);
}
function handleTagFilter(tagId: string | null) {
networkStore.setFilterTag(tagId);
}
function handleSubtitleFilter(company: string | null) {
networkStore.setFilterCompany(company);
}
function handleStrengthFilter(strength: number) {
networkStore.setMinStrength(strength);
}
function handleClearFilters() {
networkStore.clearFilters();
}
// Initialize simulation when data is loaded and container is ready
$effect(() => {
if (!networkStore.loading && networkStore.allNodes.length > 0 && graphContainer) {
const rect = graphContainer.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
networkStore.initSimulation(rect.width, rect.height);
}
}
});
onMount(() => {
networkStore.loadGraph();
});
onDestroy(() => {
networkStore.stopSimulation();
});
</script>
<svelte:head>
@ -43,9 +109,28 @@
<!-- Controls (floating) -->
<div class="controls-wrapper">
<NetworkControls
bind:this={controlsComponent}
searchQuery={networkStore.searchQuery}
tags={networkStore.uniqueTags}
selectedTagId={networkStore.filterTagId}
subtitles={networkStore.uniqueCompanies}
selectedSubtitle={networkStore.filterCompany}
subtitleLabel="Firma"
nodeCount={networkStore.nodes.length}
linkCount={networkStore.links.length}
nodeLabel="Kontakte"
linkLabel="Verbindungen"
searchPlaceholder="Kontakt suchen..."
minStrength={networkStore.minStrength}
onSearch={handleSearch}
onTagFilter={handleTagFilter}
onSubtitleFilter={handleSubtitleFilter}
onStrengthFilter={handleStrengthFilter}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
onResetZoom={handleResetZoom}
onFocusSelected={handleFocusSelected}
onClearFilters={handleClearFilters}
/>
</div>
@ -65,11 +150,23 @@
{/if}
<!-- Main Content -->
<div class="graph-container">
<div class="graph-container" bind:this={graphContainer}>
{#if networkStore.loading}
<NetworkGraphSkeleton />
{:else}
<NetworkGraph bind:this={graphComponent} onNodeClick={handleNodeClick} />
<NetworkGraph
bind:this={graphComponent}
nodes={networkStore.nodes}
links={networkStore.links}
selectedNodeId={networkStore.selectedNodeId}
onNodeClick={handleNodeClick}
onNodeDoubleClick={handleNodeDoubleClick}
onBackgroundClick={handleBackgroundClick}
onDragStart={handleDragStart}
onDrag={handleDrag}
onDragEnd={handleDragEnd}
onFocusSearch={handleFocusSearch}
/>
{/if}
</div>

View file

@ -0,0 +1,280 @@
<script lang="ts">
import { onMount } from 'svelte';
import { contactsStore } from '$lib/stores/contacts.svelte';
import { contactsStatisticsStore } from '$lib/stores/statistics.svelte';
import { tagsApi } from '$lib/api/tags';
import {
StatsGrid,
ActivityHeatmap,
TrendLineChart,
DonutChart,
ProgressBars,
StatisticsSkeleton,
type StatItem,
} from '@manacore/shared-ui';
import { BarChart3, Users, Star, UserPlus, Cake, Mail, CheckCircle } from 'lucide-svelte';
let loading = $state(true);
// Update statistics when contacts change
$effect(() => {
contactsStatisticsStore.setContacts(contactsStore.contacts);
});
// Build stats items for StatsGrid
let statsItems = $derived<StatItem[]>([
{
id: 'total',
label: 'Gesamt',
value: contactsStatisticsStore.totalContacts,
icon: Users,
variant: 'primary',
},
{
id: 'favorites',
label: 'Favoriten',
value: contactsStatisticsStore.favoriteContacts,
icon: Star,
variant: 'accent',
},
{
id: 'recentlyAdded',
label: 'Neu (7 Tage)',
value: contactsStatisticsStore.recentlyAdded,
icon: UserPlus,
variant: 'success',
},
{
id: 'birthdays',
label: 'Geburtstage',
value: contactsStatisticsStore.birthdaysThisMonth,
icon: Cake,
variant: 'info',
},
{
id: 'withEmail',
label: 'Mit E-Mail',
value: contactsStatisticsStore.contactsWithEmail,
icon: Mail,
variant: 'neutral',
},
{
id: 'completeness',
label: 'Vollständigkeit',
value: `${contactsStatisticsStore.completenessRate}%`,
icon: CheckCircle,
variant: contactsStatisticsStore.completenessRate >= 70 ? 'success' : 'danger',
},
]);
onMount(async () => {
// Fetch all contacts (without filters for statistics)
await contactsStore.loadContacts({ isArchived: false });
// Also load archived for complete statistics
const allContacts = [...contactsStore.contacts];
// Fetch tags
try {
const tagsResult = await tagsApi.list();
contactsStatisticsStore.setTags(tagsResult);
} catch (e) {
console.error('Failed to load tags:', e);
}
loading = false;
});
</script>
<svelte:head>
<title>Statistiken - Kontakte</title>
</svelte:head>
<div class="statistics-page">
<header class="page-header">
<div class="header-icon">
<BarChart3 size={28} />
</div>
<div class="header-content">
<h1>Statistiken</h1>
<p class="header-subtitle">Deine Kontakte im Überblick</p>
</div>
</header>
{#if loading}
<StatisticsSkeleton statCards={6} legendItems={4} />
{:else}
<!-- Quick Stats -->
<section class="stats-section">
<StatsGrid items={statsItems} columns={6} />
</section>
<!-- Charts Grid -->
<div class="charts-grid">
<!-- Activity Heatmap -->
<section class="chart-section heatmap-section">
<ActivityHeatmap
data={contactsStatisticsStore.activityHeatmap}
itemName="Kontakt"
itemNamePlural="Kontakte"
/>
</section>
<!-- Weekly Trend + Status Donut -->
<div class="charts-row">
<section class="chart-section trend-section">
<TrendLineChart
data={contactsStatisticsStore.weeklyTrend}
itemName="Kontakt"
itemNamePlural="Kontakte"
/>
</section>
<section class="chart-section donut-section">
<DonutChart
data={contactsStatisticsStore.statusBreakdown}
title="Status"
centerLabel="Kontakte"
centerValue={contactsStatisticsStore.totalContacts}
/>
</section>
</div>
<!-- Info Completeness -->
<div class="charts-row">
<section class="chart-section info-section">
<DonutChart
data={contactsStatisticsStore.infoBreakdown}
title="Informationen"
centerLabel="Kontakte"
centerValue={contactsStatisticsStore.totalContacts}
/>
</section>
<section class="chart-section country-section">
<ProgressBars
data={contactsStatisticsStore.countryBreakdown}
title="Nach Land"
emptyMessage="Keine Länder angegeben"
/>
</section>
</div>
</div>
<!-- Additional Stats -->
<div class="additional-stats">
<div class="stat-card-small">
<span class="stat-label">Aktive Kontakte</span>
<span class="stat-value">{contactsStatisticsStore.activeContacts}</span>
</div>
<div class="stat-card-small">
<span class="stat-label">Archivierte Kontakte</span>
<span class="stat-value">{contactsStatisticsStore.archivedContacts}</span>
</div>
<div class="stat-card-small">
<span class="stat-label">Tags</span>
<span class="stat-value">{contactsStatisticsStore.totalTags}</span>
</div>
</div>
{/if}
</div>
<style>
.statistics-page {
padding-bottom: 6rem;
}
.page-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
}
.header-icon {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
background: hsl(var(--primary) / 0.15);
color: hsl(var(--primary));
border-radius: 1rem;
}
.header-content h1 {
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--foreground));
margin: 0;
}
.header-subtitle {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin: 0.25rem 0 0 0;
}
.stats-section {
margin-bottom: 1.5rem;
}
.charts-grid {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.charts-row {
display: grid;
grid-template-columns: 1fr;
gap: 1.5rem;
}
@media (min-width: 768px) {
.charts-row {
grid-template-columns: 1fr 1fr;
}
}
.chart-section {
min-width: 0;
}
.additional-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-top: 1.5rem;
}
.stat-card-small {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 1rem;
}
:global(.dark) .stat-card-small {
background: rgba(30, 30, 30, 0.95);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.stat-card-small .stat-label {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
.stat-card-small .stat-value {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--foreground));
}
</style>

View file

@ -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<ContactTag[]>([]);
@ -13,9 +20,6 @@
// Modal state
let showModal = $state(false);
let editingTag = $state<ContactTag | null>(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 @@
<!-- Header -->
<header class="header">
<a href="/" class="back-button" aria-label={$_('common.back')}>
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<CaretLeft size={20} weight="bold" />
</a>
<h1 class="title">{$_('tags.title')}</h1>
<button onclick={openCreateModal} class="add-button" aria-label={$_('tags.new')}>
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
<Plus size={20} weight="bold" />
</button>
</header>
<!-- Search -->
<div class="search-wrapper">
<svg class="search-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<MagnifyingGlass size={20} class="search-icon" />
<input
type="text"
placeholder={$_('tags.search')}
@ -154,209 +126,55 @@
{#if error}
<div class="error-banner" role="alert">
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span>{error}</span>
</div>
{/if}
{#if loading}
<TagGridSkeleton count={6} />
{:else if tags.length === 0}
<div class="empty-state">
<div class="empty-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
/>
</svg>
</div>
<h2 class="empty-title">{$_('tags.noTags')}</h2>
<p class="empty-description">{$_('tags.createFirst')}</p>
<button onclick={openCreateModal} class="btn btn-primary">
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
{$_('tags.new')}
</button>
</div>
{:else if filteredTags.length === 0}
<div class="empty-state">
<div class="empty-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<h2 class="empty-title">{$_('tags.noResults')}</h2>
<p class="empty-description">{$_('tags.noResultsFor', { values: { query: searchQuery } })}</p>
</div>
{:else}
<div class="tags-grid">
{#each filteredTags as tag (tag.id)}
<div class="tag-card">
<div class="tag-color" style="background-color: {tag.color || '#6366f1'}">
<svg class="tag-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
/>
</svg>
</div>
<div class="tag-info">
<h3 class="tag-name">{tag.name}</h3>
</div>
<div class="tag-actions">
<button
onclick={() => openEditModal(tag)}
class="action-button"
aria-label={$_('actions.edit')}
>
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<button
onclick={() => handleDelete(tag)}
class="action-button delete"
aria-label={$_('actions.delete')}
>
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
{/each}
</div>
<!-- Tag List using shared component -->
<TagList
tags={filteredTags}
{loading}
onEdit={(tag) => 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}
<p class="tags-count">
{tags.length}
{tags.length === 1 ? $_('tags.tagSingular') : $_('tags.tagPlural')}
</p>
{/if}
{#if !loading && tags.length === 0 && !searchQuery}
<div class="empty-cta">
<button onclick={openCreateModal} class="btn btn-primary">
<Plus size={16} weight="bold" />
{$_('tags.new')}
</button>
</div>
{/if}
</div>
<!-- Create/Edit Modal -->
{#if showModal}
<div class="modal-backdrop" onclick={closeModal} role="presentation">
<div
class="modal"
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<header class="modal-header">
<h2 id="modal-title" class="modal-title">
{editingTag ? $_('tags.edit') : $_('tags.new')}
</h2>
<button onclick={closeModal} class="modal-close" aria-label={$_('common.cancel')}>
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</header>
<div class="modal-body">
<div class="form-group">
<label for="tag-name" class="form-label">{$_('tags.name')}</label>
<input
id="tag-name"
type="text"
bind:value={tagName}
placeholder={$_('tags.namePlaceholder')}
class="form-input"
/>
</div>
<div class="form-group">
<label class="form-label">{$_('tags.color')}</label>
<div class="color-picker">
{#each colorOptions as color}
<button
type="button"
class="color-option"
class:selected={tagColor === color}
style="background-color: {color}"
onclick={() => (tagColor = color)}
aria-label={color}
>
{#if tagColor === color}
<svg class="check-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="3"
d="M5 13l4 4L19 7"
/>
</svg>
{/if}
</button>
{/each}
</div>
</div>
<!-- Preview -->
<div class="form-group">
<label class="form-label">{$_('tags.preview')}</label>
<div class="tag-preview">
<span class="preview-tag" style="background-color: {tagColor}">
{tagName || $_('tags.namePlaceholder')}
</span>
</div>
</div>
</div>
<footer class="modal-footer">
<button onclick={closeModal} class="btn btn-secondary" disabled={saving}>
{$_('common.cancel')}
</button>
<button onclick={handleSave} class="btn btn-primary" disabled={saving || !tagName.trim()}>
{#if saving}
<span class="btn-spinner"></span>
{/if}
{editingTag ? $_('actions.save') : $_('actions.create')}
</button>
</footer>
</div>
</div>
{/if}
<!-- Create/Edit Modal using shared component -->
<TagEditModal
tag={editingTag}
isOpen={showModal}
onClose={closeModal}
onSave={handleSave}
onDelete={editingTag ? handleDelete : undefined}
title={editingTag ? $_('tags.edit') : $_('tags.new')}
saveLabel={editingTag ? $_('actions.save') : $_('actions.create')}
deleteLabel={$_('actions.delete')}
cancelLabel={$_('common.cancel')}
namePlaceholder={$_('tags.namePlaceholder')}
colorLabel={$_('tags.color')}
previewLabel={$_('tags.preview')}
deleteConfirmMessage={$_('tags.confirmDelete', { values: { name: editingTag?.name || '' } })}
/>
<style>
.page-container {
@ -433,13 +251,11 @@
margin-bottom: 1.5rem;
}
.search-icon {
.search-wrapper :global(.search-icon) {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
width: 1.25rem;
height: 1.25rem;
color: hsl(var(--muted-foreground));
pointer-events: none;
}
@ -474,156 +290,6 @@
margin-bottom: 1.5rem;
}
/* Loading */
.loading-container {
display: flex;
justify-content: center;
padding: 4rem 0;
}
.spinner {
width: 2.5rem;
height: 2.5rem;
border: 3px solid hsl(var(--muted));
border-top-color: hsl(var(--primary));
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 3rem 1rem;
text-align: center;
}
.empty-icon {
width: 5rem;
height: 5rem;
border-radius: 50%;
background: hsl(var(--muted));
color: hsl(var(--muted-foreground));
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.5rem;
}
.empty-icon svg {
width: 2.5rem;
height: 2.5rem;
}
.empty-title {
font-size: 1.25rem;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 0.5rem;
}
.empty-description {
color: hsl(var(--muted-foreground));
margin-bottom: 1.5rem;
max-width: 280px;
}
/* Tags Grid */
.tags-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
}
.tag-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
transition: all 0.2s ease;
}
.tag-card:hover {
border-color: hsl(var(--primary) / 0.3);
box-shadow: 0 4px 12px hsl(var(--foreground) / 0.05);
}
.tag-color {
width: 2.5rem;
height: 2.5rem;
border-radius: 0.625rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.tag-icon {
width: 1.25rem;
height: 1.25rem;
color: white;
}
.tag-info {
flex: 1;
min-width: 0;
}
.tag-name {
font-size: 0.9375rem;
font-weight: 600;
color: hsl(var(--foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tag-actions {
display: flex;
align-items: center;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.2s ease;
}
.tag-card:hover .tag-actions {
opacity: 1;
}
.action-button {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
background: transparent;
color: hsl(var(--muted-foreground));
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
.action-button:hover {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}
.action-button.delete:hover {
background: hsl(0 84% 60% / 0.1);
color: hsl(0 84% 60%);
}
/* Count */
.tags-count {
text-align: center;
@ -632,157 +298,11 @@
margin-top: 1.5rem;
}
/* Modal */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
/* Empty CTA */
.empty-cta {
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 1rem;
}
.modal {
background: hsl(var(--background));
border-radius: 1rem;
width: 100%;
max-width: 400px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid hsl(var(--border));
}
.modal-title {
font-size: 1.25rem;
font-weight: 600;
color: hsl(var(--foreground));
}
.modal-close {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
background: transparent;
color: hsl(var(--muted-foreground));
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
.modal-close:hover {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}
.modal-body {
padding: 1.5rem;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid hsl(var(--border));
}
/* Form */
.form-group {
margin-bottom: 1.25rem;
}
.form-group:last-child {
margin-bottom: 0;
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--foreground));
margin-bottom: 0.5rem;
}
.form-input {
width: 100%;
padding: 0.75rem 1rem;
border: 1.5px solid hsl(var(--border));
border-radius: 0.625rem;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 0.9375rem;
transition: all 0.2s ease;
}
.form-input:focus {
outline: none;
border-color: hsl(var(--primary));
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1);
}
/* Color Picker */
.color-picker {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.color-option {
width: 2.25rem;
height: 2.25rem;
border-radius: 0.5rem;
border: 2px solid transparent;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.color-option:hover {
transform: scale(1.1);
}
.color-option.selected {
border-color: hsl(var(--foreground));
box-shadow: 0 0 0 2px hsl(var(--background));
}
.check-icon {
width: 1rem;
height: 1rem;
color: white;
}
/* Tag Preview */
.tag-preview {
display: flex;
align-items: center;
gap: 0.5rem;
}
.preview-tag {
display: inline-flex;
align-items: center;
padding: 0.375rem 0.75rem;
border-radius: 9999px;
font-size: 0.8125rem;
font-weight: 500;
color: white;
margin-top: 1rem;
}
/* Buttons */
@ -801,46 +321,12 @@
text-decoration: none;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.btn-primary:hover:not(:disabled) {
.btn-primary:hover {
box-shadow: 0 4px 12px hsl(var(--primary) / 0.3);
}
.btn-secondary {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}
.btn-secondary:hover:not(:disabled) {
background: hsl(var(--muted-foreground) / 0.2);
}
.btn-spinner {
width: 1rem;
height: 1rem;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
/* Icons */
.icon {
width: 1.25rem;
height: 1.25rem;
}
.icon-sm {
width: 1rem;
height: 1rem;
}
</style>

View file

@ -0,0 +1,25 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ThemePage } from '@manacore/shared-theme-ui';
import { theme } from '$lib/stores/theme';
import { customThemesStore } from '$lib/stores/custom-themes.svelte';
</script>
<svelte:head>
<title>Themes | Contacts</title>
</svelte:head>
<ThemePage
currentVariant={theme.variant}
onSelectTheme={(v) => 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')}
/>

View file

@ -0,0 +1,29 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { CommunityThemesPage } from '@manacore/shared-theme-ui';
import { customThemesStore } from '$lib/stores/custom-themes.svelte';
import { theme } from '$lib/stores/theme';
// Get effective mode from theme store
let effectiveMode = $derived(
theme.mode === 'system'
? typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
: theme.mode
) as 'light' | 'dark';
</script>
<svelte:head>
<title>Community Themes | Contacts</title>
</svelte:head>
<CommunityThemesPage
store={customThemesStore}
{effectiveMode}
onBack={() => goto('/themes')}
onSelectTheme={(t) => {
// Could open a detail modal here
console.log('Selected theme:', t);
}}
/>

View file

@ -0,0 +1,75 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { ThemeEditorPage } from '@manacore/shared-theme-ui';
import { customThemesStore } from '$lib/stores/custom-themes.svelte';
import { theme } from '$lib/stores/theme';
import { onMount } from 'svelte';
import type { CustomTheme } from '@manacore/shared-theme';
// Get theme ID from URL if editing
let themeId = $derived($page.url.searchParams.get('id'));
let editingTheme = $state<CustomTheme | undefined>(undefined);
// Load theme data if editing
onMount(async () => {
if (themeId) {
await customThemesStore.loadCustomThemes();
editingTheme = customThemesStore.customThemes.find((t) => t.id === themeId);
}
});
// Get effective mode from theme store
let effectiveMode = $derived(
theme.mode === 'system'
? typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
: theme.mode
) as 'light' | 'dark';
async function handleSave(themeData: {
name: string;
description?: string;
emoji: string;
lightColors: any;
darkColors: any;
}) {
if (themeId && editingTheme) {
await customThemesStore.updateTheme(themeId, themeData);
} else {
await customThemesStore.createTheme(themeData);
}
goto('/themes');
}
async function handlePublish(themeData: {
name: string;
description?: string;
emoji: string;
lightColors: any;
darkColors: any;
tags?: string[];
}) {
let theme: CustomTheme;
if (themeId && editingTheme) {
theme = await customThemesStore.updateTheme(themeId, themeData);
} else {
theme = await customThemesStore.createTheme(themeData);
}
await customThemesStore.publishTheme(theme.id, { tags: themeData.tags });
goto('/themes');
}
</script>
<svelte:head>
<title>{themeId ? 'Theme bearbeiten' : 'Neues Theme'} | Contacts</title>
</svelte:head>
<ThemeEditorPage
{effectiveMode}
existingTheme={editingTheme}
onBack={() => goto('/themes')}
onSave={handleSave}
onPublish={handlePublish}
/>

View file

@ -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 {

View file

@ -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<ThemeVariant[]>(
(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<ThemeVariant[]>([...DEFAULT_THEME_VARIANTS, ...pinnedThemes]);
// Theme variant dropdown items
let themeVariantItems = $derived<PillDropdownItem[]>([
...theme.variants.map((variant) => ({
...visibleThemes.map((variant) => ({
id: variant,
label: THEME_DEFINITIONS[variant].label,
icon: THEME_DEFINITIONS[variant].icon,

View file

@ -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";

View file

@ -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<ThemeVariant[]>(
(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<ThemeVariant[]>([...DEFAULT_THEME_VARIANTS, ...pinnedThemes]);
// Theme variant dropdown items
let themeVariantItems = $derived<PillDropdownItem[]>([
...theme.variants.map((variant) => ({
...visibleThemes.map((variant) => ({
id: variant,
label: THEME_DEFINITIONS[variant].label,
icon: THEME_DEFINITIONS[variant].icon,

View file

@ -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(),

View file

@ -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'),

View file

@ -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'),

View file

@ -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) => ({

View file

@ -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'),

View file

@ -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';

View file

@ -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<ThemeVariant[]>(
(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<ThemeVariant[]>([...DEFAULT_THEME_VARIANTS, ...pinnedThemes]);
// Theme variant dropdown items
let themeVariantItems = $derived<PillDropdownItem[]>([
...theme.variants.map((variant) => ({
...visibleThemes.map((variant) => ({
id: variant,
label: THEME_DEFINITIONS[variant].label,
icon: THEME_DEFINITIONS[variant].icon,

View file

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

Some files were not shown because too many files have changed in this diff Show more