mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
Merge branch 'till-dev' into till-dev-backup
This commit is contained in:
commit
f04300d5e9
269 changed files with 27419 additions and 2009 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
38
.github/CODEOWNERS
vendored
Normal 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
|
||||
130
.github/workflows/cd-staging-tagged.yml
vendored
130
.github/workflows/cd-staging-tagged.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
103
.github/workflows/staging-config-check.yml
vendored
Normal file
103
.github/workflows/staging-config-check.yml
vendored
Normal 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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
10
apps/calendar/apps/backend/src/event-tag/event-tag.module.ts
Normal file
10
apps/calendar/apps/backend/src/event-tag/event-tag.module.ts
Normal 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 {}
|
||||
119
apps/calendar/apps/backend/src/event-tag/event-tag.service.ts
Normal file
119
apps/calendar/apps/backend/src/event-tag/event-tag.service.ts
Normal 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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,4 +73,9 @@ export class UpdateEventDto {
|
|||
@IsOptional()
|
||||
@IsObject()
|
||||
metadata?: EventMetadata;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
tagIds?: string[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
18
apps/calendar/apps/backend/src/network/network.controller.ts
Normal file
18
apps/calendar/apps/backend/src/network/network.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/calendar/apps/backend/src/network/network.module.ts
Normal file
10
apps/calendar/apps/backend/src/network/network.module.ts
Normal 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 {}
|
||||
178
apps/calendar/apps/backend/src/network/network.service.ts
Normal file
178
apps/calendar/apps/backend/src/network/network.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
132
apps/calendar/apps/web/src/lib/api/event-tags.ts
Normal file
132
apps/calendar/apps/web/src/lib/api/event-tags.ts
Normal 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' },
|
||||
};
|
||||
}
|
||||
}
|
||||
47
apps/calendar/apps/web/src/lib/api/network.ts
Normal file
47
apps/calendar/apps/web/src/lib/api/network.ts
Normal 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: [] };
|
||||
},
|
||||
};
|
||||
370
apps/calendar/apps/web/src/lib/api/todos.ts
Normal file
370
apps/calendar/apps/web/src/lib/api/todos.ts
Normal 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,
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
287
apps/calendar/apps/web/src/lib/components/todo/TodoItem.svelte
Normal file
287
apps/calendar/apps/web/src/lib/components/todo/TodoItem.svelte
Normal 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>
|
||||
114
apps/calendar/apps/web/src/lib/stores/event-tags.svelte.ts
Normal file
114
apps/calendar/apps/web/src/lib/stores/event-tags.svelte.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
370
apps/calendar/apps/web/src/lib/stores/network.svelte.ts
Normal file
370
apps/calendar/apps/web/src/lib/stores/network.svelte.ts
Normal 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;
|
||||
});
|
||||
},
|
||||
};
|
||||
270
apps/calendar/apps/web/src/lib/stores/statistics.svelte.ts
Normal file
270
apps/calendar/apps/web/src/lib/stores/statistics.svelte.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
405
apps/calendar/apps/web/src/lib/stores/todos.svelte.ts
Normal file
405
apps/calendar/apps/web/src/lib/stores/todos.svelte.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
261
apps/calendar/apps/web/src/lib/utils/event-parser.ts
Normal file
261
apps/calendar/apps/web/src/lib/utils/event-parser.ts
Normal 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(' · ');
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
415
apps/calendar/apps/web/src/routes/(app)/network/+page.svelte
Normal file
415
apps/calendar/apps/web/src/routes/(app)/network/+page.svelte
Normal 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>
|
||||
287
apps/calendar/apps/web/src/routes/(app)/statistics/+page.svelte
Normal file
287
apps/calendar/apps/web/src/routes/(app)/statistics/+page.svelte
Normal 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>
|
||||
309
apps/calendar/apps/web/src/routes/(app)/tags/+page.svelte
Normal file
309
apps/calendar/apps/web/src/routes/(app)/tags/+page.svelte
Normal 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>
|
||||
486
apps/calendar/apps/web/src/routes/(app)/tasks/+page.svelte
Normal file
486
apps/calendar/apps/web/src/routes/(app)/tasks/+page.svelte
Normal 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>
|
||||
19
apps/calendar/apps/web/src/routes/(app)/themes/+page.svelte
Normal file
19
apps/calendar/apps/web/src/routes/(app)/themes/+page.svelte
Normal 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('/')}
|
||||
/>
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
@ -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;
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ export interface ContactsAppSettings {
|
|||
|
||||
const DEFAULT_SETTINGS: ContactsAppSettings = {
|
||||
// Display Settings
|
||||
defaultView: 'list',
|
||||
defaultView: 'alphabet',
|
||||
sortBy: 'name',
|
||||
sortOrder: 'asc',
|
||||
showPhotos: true,
|
||||
|
|
|
|||
275
apps/contacts/apps/web/src/lib/stores/statistics.svelte.ts
Normal file
275
apps/contacts/apps/web/src/lib/stores/statistics.svelte.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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';
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
227
apps/contacts/apps/web/src/lib/utils/contact-parser.ts
Normal file
227
apps/contacts/apps/web/src/lib/utils/contact-parser.ts
Normal 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(' · ');
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
280
apps/contacts/apps/web/src/routes/(app)/statistics/+page.svelte
Normal file
280
apps/contacts/apps/web/src/routes/(app)/statistics/+page.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
25
apps/contacts/apps/web/src/routes/(app)/themes/+page.svelte
Normal file
25
apps/contacts/apps/web/src/routes/(app)/themes/+page.svelte
Normal 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')}
|
||||
/>
|
||||
|
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue