From dd40bb40e7a8a4a4f5add9429569c6e451334bf8 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Tue, 9 Dec 2025 17:45:29 +0100 Subject: [PATCH 01/68] feat(contacts): add duplicate detection, photo upload, and batch operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add duplicate detection service with merge functionality - Find duplicates by email, phone, or name - Merge contacts with field selection UI - Dismiss false positives - Add contact photo upload - Upload photos to MinIO/S3 storage - Display photos in all contact views - Delete photo functionality - Add batch operations for multiple contacts - Selection mode with checkboxes in all views - Batch delete, archive, and favorite actions - Fixed-height action bar to prevent layout shifts - Add multiple contact view modes - List view, Grid view, Alphabet view - View mode toggle component - Sort by first/last name - Increase avatar sizes across all views 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- COMMANDS.md | 2 +- apps/contacts/apps/backend/src/app.module.ts | 6 + .../backend/src/batch/batch.controller.ts | 89 +++ .../apps/backend/src/batch/batch.module.ts | 12 + .../apps/backend/src/batch/batch.service.ts | 239 +++++++ .../backend/src/db/schema/groups.schema.ts | 4 +- apps/contacts/apps/backend/src/db/seed.ts | 118 +++- .../src/duplicates/duplicates.controller.ts | 51 ++ .../src/duplicates/duplicates.module.ts | 12 + .../src/duplicates/duplicates.service.ts | 255 ++++++++ .../apps/backend/src/group/group.service.ts | 58 +- .../backend/src/photo/photo.controller.ts | 42 ++ .../apps/backend/src/photo/photo.module.ts | 19 + .../apps/backend/src/photo/photo.service.ts | 132 ++++ apps/contacts/apps/web/src/app.css | 13 +- apps/contacts/apps/web/src/lib/api/batch.ts | 78 +++ .../contacts/apps/web/src/lib/api/contacts.ts | 33 + .../apps/web/src/lib/api/duplicates.ts | 60 ++ .../lib/components/ContactDetailModal.svelte | 252 ++++++- .../web/src/lib/components/ContactList.svelte | 515 ++++++++++++--- .../web/src/lib/components/FilterBar.svelte | 397 ++++++++++++ .../web/src/lib/components/SortToggle.svelte | 66 ++ .../src/lib/components/ViewModeToggle.svelte | 85 +++ .../components/duplicates/MergeModal.svelte | 225 +++++++ .../views/ContactAlphabetView.svelte | 494 ++++++++++++++ .../components/views/ContactGridView.svelte | 338 ++++++++++ .../components/views/ContactListView.svelte | 157 +++++ .../apps/web/src/lib/i18n/locales/de.json | 39 +- .../apps/web/src/lib/i18n/locales/en.json | 39 +- .../web/src/lib/stores/contacts.svelte.ts | 7 + .../web/src/lib/stores/settings.svelte.ts | 228 +++++++ .../web/src/lib/stores/view-mode.svelte.ts | 67 ++ .../apps/web/src/routes/(app)/+layout.svelte | 32 +- .../src/routes/(app)/duplicates/+page.svelte | 255 ++++++++ .../web/src/routes/(app)/groups/+page.svelte | 197 ++++-- .../src/routes/(app)/settings/+page.svelte | 613 +++++++++++++++++- package.json | 2 +- .../src/settings/SettingsCard.svelte | 92 +-- .../shared-ui/src/settings/SettingsRow.svelte | 214 ++---- .../src/settings/SettingsSection.svelte | 67 +- .../src/settings/SettingsToggle.svelte | 189 +----- 41 files changed, 5174 insertions(+), 619 deletions(-) create mode 100644 apps/contacts/apps/backend/src/batch/batch.controller.ts create mode 100644 apps/contacts/apps/backend/src/batch/batch.module.ts create mode 100644 apps/contacts/apps/backend/src/batch/batch.service.ts create mode 100644 apps/contacts/apps/backend/src/duplicates/duplicates.controller.ts create mode 100644 apps/contacts/apps/backend/src/duplicates/duplicates.module.ts create mode 100644 apps/contacts/apps/backend/src/duplicates/duplicates.service.ts create mode 100644 apps/contacts/apps/backend/src/photo/photo.controller.ts create mode 100644 apps/contacts/apps/backend/src/photo/photo.module.ts create mode 100644 apps/contacts/apps/backend/src/photo/photo.service.ts create mode 100644 apps/contacts/apps/web/src/lib/api/batch.ts create mode 100644 apps/contacts/apps/web/src/lib/api/duplicates.ts create mode 100644 apps/contacts/apps/web/src/lib/components/FilterBar.svelte create mode 100644 apps/contacts/apps/web/src/lib/components/SortToggle.svelte create mode 100644 apps/contacts/apps/web/src/lib/components/ViewModeToggle.svelte create mode 100644 apps/contacts/apps/web/src/lib/components/duplicates/MergeModal.svelte create mode 100644 apps/contacts/apps/web/src/lib/components/views/ContactAlphabetView.svelte create mode 100644 apps/contacts/apps/web/src/lib/components/views/ContactGridView.svelte create mode 100644 apps/contacts/apps/web/src/lib/components/views/ContactListView.svelte create mode 100644 apps/contacts/apps/web/src/lib/stores/settings.svelte.ts create mode 100644 apps/contacts/apps/web/src/lib/stores/view-mode.svelte.ts create mode 100644 apps/contacts/apps/web/src/routes/(app)/duplicates/+page.svelte diff --git a/COMMANDS.md b/COMMANDS.md index d9e1e9a74..058e47e21 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -8,9 +8,9 @@ pnpm docker:down pnpm dev:calendar:app pnpm dev:todo:full +pnpm dev:contacts:full pnpm dev:chat:app pnpm dev:clock:app -pnpm dev:contacts:app pnpm dev:context:app pnpm dev:manacore:app # Nur ManaCore Web pnpm dev:manacore:backends # Alle 9 Backends für Dashboard-Widgets diff --git a/apps/contacts/apps/backend/src/app.module.ts b/apps/contacts/apps/backend/src/app.module.ts index 67ab6f46b..6d45e8468 100644 --- a/apps/contacts/apps/backend/src/app.module.ts +++ b/apps/contacts/apps/backend/src/app.module.ts @@ -10,6 +10,9 @@ import { HealthModule } from './health/health.module'; import { ImportModule } from './import/import.module'; import { ExportModule } from './export/export.module'; import { GoogleModule } from './google/google.module'; +import { DuplicatesModule } from './duplicates/duplicates.module'; +import { PhotoModule } from './photo/photo.module'; +import { BatchModule } from './batch/batch.module'; @Module({ imports: [ @@ -27,6 +30,9 @@ import { GoogleModule } from './google/google.module'; ImportModule, ExportModule, GoogleModule, + DuplicatesModule, + PhotoModule, + BatchModule, ], }) export class AppModule {} diff --git a/apps/contacts/apps/backend/src/batch/batch.controller.ts b/apps/contacts/apps/backend/src/batch/batch.controller.ts new file mode 100644 index 000000000..e038b6ffd --- /dev/null +++ b/apps/contacts/apps/backend/src/batch/batch.controller.ts @@ -0,0 +1,89 @@ +import { Controller, Post, Body, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { BatchService } from './batch.service'; +import { IsArray, IsString, IsBoolean, IsOptional, ArrayMinSize } from 'class-validator'; + +class BatchContactIdsDto { + @IsArray() + @IsString({ each: true }) + @ArrayMinSize(1) + contactIds: string[]; +} + +class BatchArchiveDto extends BatchContactIdsDto { + @IsBoolean() + @IsOptional() + archive?: boolean = true; +} + +class BatchFavoriteDto extends BatchContactIdsDto { + @IsBoolean() + @IsOptional() + favorite?: boolean = true; +} + +class BatchGroupDto extends BatchContactIdsDto { + @IsString() + groupId: string; +} + +class BatchTagsDto extends BatchContactIdsDto { + @IsArray() + @IsString({ each: true }) + @ArrayMinSize(1) + tagIds: string[]; +} + +@Controller('batch') +@UseGuards(JwtAuthGuard) +export class BatchController { + constructor(private readonly batchService: BatchService) {} + + @Post('delete') + async deleteMany(@CurrentUser() user: CurrentUserData, @Body() dto: BatchContactIdsDto) { + const result = await this.batchService.deleteMany(dto.contactIds, user.userId); + return result; + } + + @Post('archive') + async archiveMany(@CurrentUser() user: CurrentUserData, @Body() dto: BatchArchiveDto) { + const result = await this.batchService.archiveMany( + dto.contactIds, + user.userId, + dto.archive ?? true + ); + return result; + } + + @Post('favorite') + async favoriteMany(@CurrentUser() user: CurrentUserData, @Body() dto: BatchFavoriteDto) { + const result = await this.batchService.favoriteMany( + dto.contactIds, + user.userId, + dto.favorite ?? true + ); + return result; + } + + @Post('add-to-group') + async addToGroup(@CurrentUser() user: CurrentUserData, @Body() dto: BatchGroupDto) { + const result = await this.batchService.addToGroup(dto.contactIds, dto.groupId, user.userId); + return result; + } + + @Post('remove-from-group') + async removeFromGroup(@CurrentUser() user: CurrentUserData, @Body() dto: BatchGroupDto) { + const result = await this.batchService.removeFromGroup( + dto.contactIds, + dto.groupId, + user.userId + ); + return result; + } + + @Post('add-tags') + async addTags(@CurrentUser() user: CurrentUserData, @Body() dto: BatchTagsDto) { + const result = await this.batchService.addTags(dto.contactIds, dto.tagIds, user.userId); + return result; + } +} diff --git a/apps/contacts/apps/backend/src/batch/batch.module.ts b/apps/contacts/apps/backend/src/batch/batch.module.ts new file mode 100644 index 000000000..d7a9d5e05 --- /dev/null +++ b/apps/contacts/apps/backend/src/batch/batch.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { BatchController } from './batch.controller'; +import { BatchService } from './batch.service'; +import { DatabaseModule } from '../db/database.module'; + +@Module({ + imports: [DatabaseModule], + controllers: [BatchController], + providers: [BatchService], + exports: [BatchService], +}) +export class BatchModule {} diff --git a/apps/contacts/apps/backend/src/batch/batch.service.ts b/apps/contacts/apps/backend/src/batch/batch.service.ts new file mode 100644 index 000000000..156a0da3d --- /dev/null +++ b/apps/contacts/apps/backend/src/batch/batch.service.ts @@ -0,0 +1,239 @@ +import { Injectable, Inject, BadRequestException } from '@nestjs/common'; +import { eq, and, inArray } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { Database } from '../db/connection'; +import { contacts, contactToGroups, contactToTags } from '../db/schema'; +import type { Contact } from '../db/schema'; + +export interface BatchResult { + success: number; + failed: number; + errors: string[]; +} + +@Injectable() +export class BatchService { + constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} + + /** + * Delete multiple contacts + */ + async deleteMany(contactIds: string[], userId: string): Promise { + if (contactIds.length === 0) { + throw new BadRequestException('No contacts specified'); + } + + const result: BatchResult = { success: 0, failed: 0, errors: [] }; + + try { + // Delete in a single query + const deleted = await this.db + .delete(contacts) + .where(and(eq(contacts.userId, userId), inArray(contacts.id, contactIds))) + .returning(); + + result.success = deleted.length; + result.failed = contactIds.length - deleted.length; + + if (result.failed > 0) { + result.errors.push( + `${result.failed} contacts could not be deleted (not found or no permission)` + ); + } + } catch (e) { + result.failed = contactIds.length; + result.errors.push(e instanceof Error ? e.message : 'Delete failed'); + } + + return result; + } + + /** + * Archive multiple contacts + */ + async archiveMany(contactIds: string[], userId: string, archive = true): Promise { + if (contactIds.length === 0) { + throw new BadRequestException('No contacts specified'); + } + + const result: BatchResult = { success: 0, failed: 0, errors: [] }; + + try { + const updated = await this.db + .update(contacts) + .set({ isArchived: archive, updatedAt: new Date() }) + .where(and(eq(contacts.userId, userId), inArray(contacts.id, contactIds))) + .returning(); + + result.success = updated.length; + result.failed = contactIds.length - updated.length; + + if (result.failed > 0) { + result.errors.push( + `${result.failed} contacts could not be ${archive ? 'archived' : 'unarchived'}` + ); + } + } catch (e) { + result.failed = contactIds.length; + result.errors.push(e instanceof Error ? e.message : 'Archive operation failed'); + } + + return result; + } + + /** + * Set favorite status for multiple contacts + */ + async favoriteMany(contactIds: string[], userId: string, favorite = true): Promise { + if (contactIds.length === 0) { + throw new BadRequestException('No contacts specified'); + } + + const result: BatchResult = { success: 0, failed: 0, errors: [] }; + + try { + const updated = await this.db + .update(contacts) + .set({ isFavorite: favorite, updatedAt: new Date() }) + .where(and(eq(contacts.userId, userId), inArray(contacts.id, contactIds))) + .returning(); + + result.success = updated.length; + result.failed = contactIds.length - updated.length; + + if (result.failed > 0) { + result.errors.push(`${result.failed} contacts could not be updated`); + } + } catch (e) { + result.failed = contactIds.length; + result.errors.push(e instanceof Error ? e.message : 'Favorite operation failed'); + } + + return result; + } + + /** + * Add multiple contacts to a group + */ + async addToGroup(contactIds: string[], groupId: string, userId: string): Promise { + if (contactIds.length === 0) { + throw new BadRequestException('No contacts specified'); + } + + const result: BatchResult = { success: 0, failed: 0, errors: [] }; + + // Verify contacts belong to user + const validContacts = await this.db + .select({ id: contacts.id }) + .from(contacts) + .where(and(eq(contacts.userId, userId), inArray(contacts.id, contactIds))); + + const validIds = new Set(validContacts.map((c) => c.id)); + + for (const contactId of contactIds) { + if (!validIds.has(contactId)) { + result.failed++; + continue; + } + + try { + // Insert if not exists (ignore duplicates) + await this.db.insert(contactToGroups).values({ contactId, groupId }).onConflictDoNothing(); + result.success++; + } catch { + result.failed++; + } + } + + if (result.failed > 0) { + result.errors.push(`${result.failed} contacts could not be added to group`); + } + + return result; + } + + /** + * Remove multiple contacts from a group + */ + async removeFromGroup( + contactIds: string[], + groupId: string, + userId: string + ): Promise { + if (contactIds.length === 0) { + throw new BadRequestException('No contacts specified'); + } + + const result: BatchResult = { success: 0, failed: 0, errors: [] }; + + // Verify contacts belong to user first + const validContacts = await this.db + .select({ id: contacts.id }) + .from(contacts) + .where(and(eq(contacts.userId, userId), inArray(contacts.id, contactIds))); + + const validIds = validContacts.map((c) => c.id); + + if (validIds.length === 0) { + result.failed = contactIds.length; + result.errors.push('No valid contacts found'); + return result; + } + + try { + await this.db + .delete(contactToGroups) + .where( + and(eq(contactToGroups.groupId, groupId), inArray(contactToGroups.contactId, validIds)) + ); + result.success = validIds.length; + result.failed = contactIds.length - validIds.length; + } catch (e) { + result.failed = contactIds.length; + result.errors.push(e instanceof Error ? e.message : 'Remove from group failed'); + } + + return result; + } + + /** + * Add tags to multiple contacts + */ + async addTags(contactIds: string[], tagIds: string[], userId: string): Promise { + if (contactIds.length === 0) { + throw new BadRequestException('No contacts specified'); + } + + const result: BatchResult = { success: 0, failed: 0, errors: [] }; + + // Verify contacts belong to user + const validContacts = await this.db + .select({ id: contacts.id }) + .from(contacts) + .where(and(eq(contacts.userId, userId), inArray(contacts.id, contactIds))); + + const validIds = new Set(validContacts.map((c) => c.id)); + + for (const contactId of contactIds) { + if (!validIds.has(contactId)) { + result.failed++; + continue; + } + + for (const tagId of tagIds) { + try { + await this.db.insert(contactToTags).values({ contactId, tagId }).onConflictDoNothing(); + } catch { + // Ignore individual tag failures + } + } + result.success++; + } + + if (result.failed > 0) { + result.errors.push(`${result.failed} contacts could not be tagged`); + } + + return result; + } +} diff --git a/apps/contacts/apps/backend/src/db/schema/groups.schema.ts b/apps/contacts/apps/backend/src/db/schema/groups.schema.ts index 068c3c1d8..77a64d3ce 100644 --- a/apps/contacts/apps/backend/src/db/schema/groups.schema.ts +++ b/apps/contacts/apps/backend/src/db/schema/groups.schema.ts @@ -1,4 +1,4 @@ -import { pgTable, uuid, timestamp, varchar, text, primaryKey } from 'drizzle-orm/pg-core'; +import { pgTable, uuid, timestamp, varchar, text, primaryKey, boolean } from 'drizzle-orm/pg-core'; import { contacts } from './contacts.schema'; export const contactGroups = pgTable('contact_groups', { @@ -7,6 +7,8 @@ export const contactGroups = pgTable('contact_groups', { name: varchar('name', { length: 100 }).notNull(), description: text('description'), color: varchar('color', { length: 20 }), + icon: varchar('icon', { length: 50 }), + isPreset: boolean('is_preset').default(false).notNull(), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), }); diff --git a/apps/contacts/apps/backend/src/db/seed.ts b/apps/contacts/apps/backend/src/db/seed.ts index 82442dfcc..5b38dd41e 100644 --- a/apps/contacts/apps/backend/src/db/seed.ts +++ b/apps/contacts/apps/backend/src/db/seed.ts @@ -1,13 +1,75 @@ import 'dotenv/config'; import { drizzle } from 'drizzle-orm/postgres-js'; import postgres from 'postgres'; -import { contacts } from './schema'; +import { contacts, contactGroups } from './schema'; const DATABASE_URL = process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/contacts'; -// User ID - can be set via environment variable or defaults to test user -const USER_ID = process.env.SEED_USER_ID || 'seed-user-001'; +// User ID - can be set via environment variable or defaults to dev user +const USER_ID = process.env.SEED_USER_ID || process.env.DEV_USER_ID || '00000000-0000-0000-0000-000000000000'; + +// System user ID for preset groups (visible to all users) +const SYSTEM_USER_ID = 'system'; + +// Preset groups available to all users +interface PresetGroup { + name: string; + description: string; + color: string; + icon: string; +} + +const presetGroups: PresetGroup[] = [ + { + name: 'Familie', + description: 'Familienmitglieder und Verwandte', + color: '#ef4444', // Red + icon: 'home', + }, + { + name: 'Freunde', + description: 'Freunde und Bekannte', + color: '#f97316', // Orange + icon: 'users', + }, + { + name: 'Arbeit', + description: 'Kollegen und Geschäftskontakte', + color: '#3b82f6', // Blue + icon: 'briefcase', + }, + { + name: 'Kunden', + description: 'Kunden und Auftraggeber', + color: '#22c55e', // Green + icon: 'building', + }, + { + name: 'Partner', + description: 'Geschäftspartner und Lieferanten', + color: '#8b5cf6', // Purple + icon: 'handshake', + }, + { + name: 'VIP', + description: 'Wichtige Kontakte', + color: '#eab308', // Yellow/Gold + icon: 'star', + }, + { + name: 'Nachbarn', + description: 'Nachbarn und Anwohner', + color: '#14b8a6', // Teal + icon: 'map-pin', + }, + { + name: 'Vereine', + description: 'Vereinsmitglieder und Clubs', + color: '#ec4899', // Pink + icon: 'flag', + }, +]; interface SeedContact { firstName: string; @@ -472,6 +534,46 @@ const seedContacts: SeedContact[] = [ }, ]; +async function seedPresetGroups() { + console.log('🏷️ Seeding preset groups...'); + + const connection = postgres(DATABASE_URL); + const db = drizzle(connection); + + try { + const { sql, eq, and } = await import('drizzle-orm'); + + // Check if preset groups already exist + const existingPresets = await db + .select() + .from(contactGroups) + .where(and(eq(contactGroups.userId, SYSTEM_USER_ID), eq(contactGroups.isPreset, true))); + + if (existingPresets.length > 0) { + console.log(` ℹ️ ${existingPresets.length} preset groups already exist, skipping...`); + return; + } + + // Insert preset groups + const groupsToInsert = presetGroups.map((group) => ({ + userId: SYSTEM_USER_ID, + name: group.name, + description: group.description, + color: group.color, + icon: group.icon, + isPreset: true, + })); + + await db.insert(contactGroups).values(groupsToInsert); + console.log(` ✅ Inserted ${presetGroups.length} preset groups`); + } catch (error) { + console.error('❌ Preset groups seed failed:', error); + throw error; + } finally { + await connection.end(); + } +} + async function seed() { console.log('🌱 Starting seed...'); console.log(`📊 Preparing to insert ${seedContacts.length} contacts`); @@ -531,7 +633,15 @@ async function seed() { } } -seed() +async function main() { + // First seed preset groups (system-wide) + await seedPresetGroups(); + + // Then seed contacts for test user + await seed(); +} + +main() .then(() => { console.log('🎉 Seed completed!'); process.exit(0); diff --git a/apps/contacts/apps/backend/src/duplicates/duplicates.controller.ts b/apps/contacts/apps/backend/src/duplicates/duplicates.controller.ts new file mode 100644 index 000000000..ff04cd097 --- /dev/null +++ b/apps/contacts/apps/backend/src/duplicates/duplicates.controller.ts @@ -0,0 +1,51 @@ +import { + Controller, + Get, + Post, + Delete, + Body, + Param, + UseGuards, + ParseUUIDPipe, +} from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { DuplicatesService } from './duplicates.service'; +import { IsArray, IsString, ArrayMinSize } from 'class-validator'; + +class MergeContactsDto { + @IsString() + primaryId: string; + + @IsArray() + @IsString({ each: true }) + @ArrayMinSize(1) + mergeIds: string[]; +} + +@Controller('duplicates') +@UseGuards(JwtAuthGuard) +export class DuplicatesController { + constructor(private readonly duplicatesService: DuplicatesService) {} + + @Get() + async findDuplicates(@CurrentUser() user: CurrentUserData) { + const duplicates = await this.duplicatesService.findDuplicates(user.userId); + return { duplicates, total: duplicates.length }; + } + + @Post('merge') + async mergeContacts(@CurrentUser() user: CurrentUserData, @Body() dto: MergeContactsDto) { + const result = await this.duplicatesService.mergeContacts( + dto.primaryId, + dto.mergeIds, + user.userId + ); + return result; + } + + @Delete(':groupId/dismiss') + async dismissDuplicate(@CurrentUser() user: CurrentUserData, @Param('groupId') groupId: string) { + await this.duplicatesService.dismissDuplicate(groupId, user.userId); + return { success: true }; + } +} diff --git a/apps/contacts/apps/backend/src/duplicates/duplicates.module.ts b/apps/contacts/apps/backend/src/duplicates/duplicates.module.ts new file mode 100644 index 000000000..c227a7580 --- /dev/null +++ b/apps/contacts/apps/backend/src/duplicates/duplicates.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { DuplicatesController } from './duplicates.controller'; +import { DuplicatesService } from './duplicates.service'; +import { DatabaseModule } from '../db/database.module'; + +@Module({ + imports: [DatabaseModule], + controllers: [DuplicatesController], + providers: [DuplicatesService], + exports: [DuplicatesService], +}) +export class DuplicatesModule {} diff --git a/apps/contacts/apps/backend/src/duplicates/duplicates.service.ts b/apps/contacts/apps/backend/src/duplicates/duplicates.service.ts new file mode 100644 index 000000000..d2cc8f1d6 --- /dev/null +++ b/apps/contacts/apps/backend/src/duplicates/duplicates.service.ts @@ -0,0 +1,255 @@ +import { Injectable, Inject, NotFoundException } from '@nestjs/common'; +import { eq, and, or, ne, sql } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { Database } from '../db/connection'; +import { contacts } from '../db/schema'; +import type { Contact } from '../db/schema'; + +export interface DuplicateGroup { + id: string; + contacts: Contact[]; + matchType: 'email' | 'phone' | 'name'; + matchValue: string; +} + +export interface MergeResult { + mergedContact: Contact; + deletedIds: string[]; +} + +@Injectable() +export class DuplicatesService { + constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} + + /** + * Find all potential duplicate groups for a user + */ + async findDuplicates(userId: string): Promise { + const duplicateGroups: DuplicateGroup[] = []; + + // Get all contacts for this user + const allContacts = await this.db + .select() + .from(contacts) + .where(and(eq(contacts.userId, userId), eq(contacts.isArchived, false))); + + // Build lookup maps + const emailMap = new Map(); + const phoneMap = new Map(); + const nameMap = new Map(); + const processedIds = new Set(); + + for (const contact of allContacts) { + // Group by email + if (contact.email) { + const normalizedEmail = this.normalizeEmail(contact.email); + if (!emailMap.has(normalizedEmail)) { + emailMap.set(normalizedEmail, []); + } + emailMap.get(normalizedEmail)!.push(contact); + } + + // Group by phone (check both phone and mobile) + for (const phone of [contact.phone, contact.mobile].filter(Boolean) as string[]) { + const normalizedPhone = this.normalizePhone(phone); + if (normalizedPhone.length >= 6) { + if (!phoneMap.has(normalizedPhone)) { + phoneMap.set(normalizedPhone, []); + } + const existing = phoneMap.get(normalizedPhone)!; + if (!existing.some((c) => c.id === contact.id)) { + existing.push(contact); + } + } + } + + // Group by name (first + last) + if (contact.firstName && contact.lastName) { + const normalizedName = this.normalizeName(contact.firstName, contact.lastName); + if (!nameMap.has(normalizedName)) { + nameMap.set(normalizedName, []); + } + nameMap.get(normalizedName)!.push(contact); + } + } + + // Create duplicate groups from email matches + for (const [email, contactList] of emailMap) { + if (contactList.length > 1) { + const ids = contactList + .map((c) => c.id) + .sort() + .join('-'); + if (!processedIds.has(ids)) { + processedIds.add(ids); + duplicateGroups.push({ + id: `email-${ids}`, + contacts: contactList, + matchType: 'email', + matchValue: email, + }); + } + } + } + + // Create duplicate groups from phone matches + for (const [phone, contactList] of phoneMap) { + if (contactList.length > 1) { + const ids = contactList + .map((c) => c.id) + .sort() + .join('-'); + if (!processedIds.has(ids)) { + processedIds.add(ids); + duplicateGroups.push({ + id: `phone-${ids}`, + contacts: contactList, + matchType: 'phone', + matchValue: phone, + }); + } + } + } + + // Create duplicate groups from name matches (only if not already matched by email/phone) + for (const [name, contactList] of nameMap) { + if (contactList.length > 1) { + const ids = contactList + .map((c) => c.id) + .sort() + .join('-'); + if (!processedIds.has(ids)) { + processedIds.add(ids); + duplicateGroups.push({ + id: `name-${ids}`, + contacts: contactList, + matchType: 'name', + matchValue: name, + }); + } + } + } + + return duplicateGroups; + } + + /** + * Merge multiple contacts into one + * @param primaryId - The contact to keep (will be updated with merged data) + * @param mergeIds - The contacts to merge into primary (will be deleted) + * @param userId - User ID for authorization + */ + async mergeContacts(primaryId: string, mergeIds: string[], userId: string): Promise { + // Get the primary contact + const [primaryContact] = await this.db + .select() + .from(contacts) + .where(and(eq(contacts.id, primaryId), eq(contacts.userId, userId))); + + if (!primaryContact) { + throw new NotFoundException('Primary contact not found'); + } + + // Get contacts to merge + const contactsToMerge = await this.db + .select() + .from(contacts) + .where(and(eq(contacts.userId, userId), or(...mergeIds.map((id) => eq(contacts.id, id))))); + + if (contactsToMerge.length !== mergeIds.length) { + throw new NotFoundException('One or more contacts to merge not found'); + } + + // Merge data - fill empty fields from other contacts + const mergedData = this.mergeContactData(primaryContact, contactsToMerge); + + // Update primary contact with merged data + const [updatedContact] = await this.db + .update(contacts) + .set({ ...mergedData, updatedAt: new Date() }) + .where(eq(contacts.id, primaryId)) + .returning(); + + // Delete merged contacts + await this.db + .delete(contacts) + .where(and(eq(contacts.userId, userId), or(...mergeIds.map((id) => eq(contacts.id, id))))); + + return { + mergedContact: updatedContact, + deletedIds: mergeIds, + }; + } + + /** + * Dismiss a duplicate group (mark as not duplicates) + * This could be extended to store dismissals in a separate table + */ + async dismissDuplicate(groupId: string, userId: string): Promise { + // For now, this is a no-op + // In a full implementation, you'd store this in a `dismissed_duplicates` table + // to avoid showing the same group again + } + + private mergeContactData(primary: Contact, others: Contact[]): Partial { + const updates: Partial = {}; + const allContacts = [primary, ...others]; + + // Helper to get first non-empty value + const getFirst = (field: K): Contact[K] | undefined => { + for (const contact of allContacts) { + if (contact[field]) return contact[field]; + } + return undefined; + }; + + // Only update fields that are empty in primary + if (!primary.firstName) updates.firstName = getFirst('firstName'); + if (!primary.lastName) updates.lastName = getFirst('lastName'); + if (!primary.displayName) updates.displayName = getFirst('displayName'); + if (!primary.nickname) updates.nickname = getFirst('nickname'); + if (!primary.email) updates.email = getFirst('email'); + if (!primary.phone) updates.phone = getFirst('phone'); + if (!primary.mobile) updates.mobile = getFirst('mobile'); + if (!primary.street) updates.street = getFirst('street'); + if (!primary.city) updates.city = getFirst('city'); + if (!primary.postalCode) updates.postalCode = getFirst('postalCode'); + if (!primary.country) updates.country = getFirst('country'); + if (!primary.company) updates.company = getFirst('company'); + if (!primary.jobTitle) updates.jobTitle = getFirst('jobTitle'); + if (!primary.department) updates.department = getFirst('department'); + if (!primary.website) updates.website = getFirst('website'); + if (!primary.birthday) updates.birthday = getFirst('birthday'); + if (!primary.photoUrl) updates.photoUrl = getFirst('photoUrl'); + + // Merge notes (concatenate if both have notes) + const allNotes = allContacts + .map((c) => c.notes) + .filter(Boolean) + .join('\n\n---\n\n'); + if (allNotes && allNotes !== primary.notes) { + updates.notes = allNotes; + } + + // Keep favorite if any contact is favorite + if (others.some((c) => c.isFavorite)) { + updates.isFavorite = true; + } + + return updates; + } + + private normalizeEmail(email: string): string { + return email.toLowerCase().trim(); + } + + private normalizePhone(phone: string): string { + const hasPlus = phone.startsWith('+'); + const digits = phone.replace(/\D/g, ''); + return hasPlus ? '+' + digits : digits; + } + + private normalizeName(firstName: string, lastName: string): string { + return `${firstName.toLowerCase().trim()} ${lastName.toLowerCase().trim()}`; + } +} diff --git a/apps/contacts/apps/backend/src/group/group.service.ts b/apps/contacts/apps/backend/src/group/group.service.ts index 6dff826d2..04bd6ba55 100644 --- a/apps/contacts/apps/backend/src/group/group.service.ts +++ b/apps/contacts/apps/backend/src/group/group.service.ts @@ -1,5 +1,5 @@ -import { Injectable, Inject, NotFoundException } from '@nestjs/common'; -import { eq, and } from 'drizzle-orm'; +import { Injectable, Inject, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { eq, and, or } from 'drizzle-orm'; import { DATABASE_CONNECTION } from '../db/database.module'; import { Database } from '../db/connection'; import { @@ -9,19 +9,45 @@ import { type NewContactGroup, } from '../db/schema'; +// System user ID for preset groups (visible to all users) +const SYSTEM_USER_ID = 'system'; + @Injectable() export class GroupService { constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} + /** + * Get all groups for a user, including preset groups (system groups) + */ async findByUserId(userId: string): Promise { - return this.db.select().from(contactGroups).where(eq(contactGroups.userId, userId)); + // Get user's own groups + preset groups (system) + return this.db + .select() + .from(contactGroups) + .where( + or( + eq(contactGroups.userId, userId), + and(eq(contactGroups.userId, SYSTEM_USER_ID), eq(contactGroups.isPreset, true)) + ) + ); } + /** + * Find a group by ID (user's own or preset) + */ async findById(id: string, userId: string): Promise { const [group] = await this.db .select() .from(contactGroups) - .where(and(eq(contactGroups.id, id), eq(contactGroups.userId, userId))); + .where( + and( + eq(contactGroups.id, id), + or( + eq(contactGroups.userId, userId), + and(eq(contactGroups.userId, SYSTEM_USER_ID), eq(contactGroups.isPreset, true)) + ) + ) + ); return group || null; } @@ -30,7 +56,19 @@ export class GroupService { return group; } + /** + * Update a group - preset groups cannot be modified + */ async update(id: string, userId: string, data: Partial): Promise { + // First check if this is a preset group + const existingGroup = await this.findById(id, userId); + if (!existingGroup) { + throw new NotFoundException('Group not found'); + } + if (existingGroup.isPreset) { + throw new ForbiddenException('Preset groups cannot be modified'); + } + const [group] = await this.db .update(contactGroups) .set(data) @@ -44,7 +82,19 @@ export class GroupService { return group; } + /** + * Delete a group - preset groups cannot be deleted + */ async delete(id: string, userId: string): Promise { + // First check if this is a preset group + const existingGroup = await this.findById(id, userId); + if (!existingGroup) { + throw new NotFoundException('Group not found'); + } + if (existingGroup.isPreset) { + throw new ForbiddenException('Preset groups cannot be deleted'); + } + await this.db .delete(contactGroups) .where(and(eq(contactGroups.id, id), eq(contactGroups.userId, userId))); diff --git a/apps/contacts/apps/backend/src/photo/photo.controller.ts b/apps/contacts/apps/backend/src/photo/photo.controller.ts new file mode 100644 index 000000000..fc20fc9ba --- /dev/null +++ b/apps/contacts/apps/backend/src/photo/photo.controller.ts @@ -0,0 +1,42 @@ +import { + Controller, + Post, + Delete, + Param, + UseGuards, + UseInterceptors, + UploadedFile, + ParseUUIDPipe, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { PhotoService } from './photo.service'; + +@Controller('contacts') +@UseGuards(JwtAuthGuard) +export class PhotoController { + constructor(private readonly photoService: PhotoService) {} + + @Post(':id/photo') + @UseInterceptors( + FileInterceptor('photo', { + limits: { + fileSize: 5 * 1024 * 1024, // 5MB + }, + }) + ) + async uploadPhoto( + @CurrentUser() user: CurrentUserData, + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile() file: Express.Multer.File + ) { + const result = await this.photoService.uploadPhoto(id, user.userId, file); + return result; + } + + @Delete(':id/photo') + async deletePhoto(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { + await this.photoService.deletePhoto(id, user.userId); + return { success: true }; + } +} diff --git a/apps/contacts/apps/backend/src/photo/photo.module.ts b/apps/contacts/apps/backend/src/photo/photo.module.ts new file mode 100644 index 000000000..947817bfe --- /dev/null +++ b/apps/contacts/apps/backend/src/photo/photo.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { MulterModule } from '@nestjs/platform-express'; +import { memoryStorage } from 'multer'; +import { PhotoController } from './photo.controller'; +import { PhotoService } from './photo.service'; +import { DatabaseModule } from '../db/database.module'; + +@Module({ + imports: [ + DatabaseModule, + MulterModule.register({ + storage: memoryStorage(), + }), + ], + controllers: [PhotoController], + providers: [PhotoService], + exports: [PhotoService], +}) +export class PhotoModule {} diff --git a/apps/contacts/apps/backend/src/photo/photo.service.ts b/apps/contacts/apps/backend/src/photo/photo.service.ts new file mode 100644 index 000000000..0fad07162 --- /dev/null +++ b/apps/contacts/apps/backend/src/photo/photo.service.ts @@ -0,0 +1,132 @@ +import { Injectable, Inject, BadRequestException } from '@nestjs/common'; +import { eq, and } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { Database } from '../db/connection'; +import { contacts } from '../db/schema'; +import { + createContactsStorage, + generateUserFileKey, + getContentType, + validateFileSize, + validateFileExtension, + IMAGE_EXTENSIONS, +} from '@manacore/shared-storage'; + +const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB + +@Injectable() +export class PhotoService { + private storage = createContactsStorage(); + + constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} + + /** + * Upload a photo for a contact + */ + async uploadPhoto( + contactId: string, + userId: string, + file: Express.Multer.File + ): Promise<{ photoUrl: string }> { + // Validate file + if (!file) { + throw new BadRequestException('No file provided'); + } + + if (!validateFileSize(file.size, MAX_FILE_SIZE)) { + throw new BadRequestException(`File size exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit`); + } + + // validateFileExtension expects a filename, not just the extension + if (!validateFileExtension(file.originalname, IMAGE_EXTENSIONS)) { + throw new BadRequestException(`Invalid file type. Allowed: ${IMAGE_EXTENSIONS.join(', ')}`); + } + + const extension = file.originalname.split('.').pop()?.toLowerCase() || ''; + + // Verify contact belongs to user + const [contact] = await this.db + .select() + .from(contacts) + .where(and(eq(contacts.id, contactId), eq(contacts.userId, userId))); + + if (!contact) { + throw new BadRequestException('Contact not found'); + } + + // Delete old photo if exists + if (contact.photoUrl) { + try { + const oldKey = this.extractKeyFromUrl(contact.photoUrl); + if (oldKey) { + await this.storage.delete(oldKey); + } + } catch { + // Ignore deletion errors + } + } + + // Generate unique key for the new photo + const filename = `${contactId}.${extension}`; + const key = generateUserFileKey(userId, filename); + + // Upload to S3 + const contentType = getContentType(filename); + await this.storage.upload(key, file.buffer, { + contentType, + public: true, + }); + + // Generate the URL (for MinIO, construct it manually) + const photoUrl = `http://localhost:9000/contacts-storage/${key}`; + + // Update contact with photo URL + await this.db + .update(contacts) + .set({ photoUrl, updatedAt: new Date() }) + .where(eq(contacts.id, contactId)); + + return { photoUrl }; + } + + /** + * Delete photo for a contact + */ + async deletePhoto(contactId: string, userId: string): Promise { + // Get contact + const [contact] = await this.db + .select() + .from(contacts) + .where(and(eq(contacts.id, contactId), eq(contacts.userId, userId))); + + if (!contact) { + throw new BadRequestException('Contact not found'); + } + + if (!contact.photoUrl) { + return; // No photo to delete + } + + // Delete from S3 + try { + const key = this.extractKeyFromUrl(contact.photoUrl); + if (key) { + await this.storage.delete(key); + } + } catch { + // Ignore deletion errors + } + + // Update contact to remove photo URL + await this.db + .update(contacts) + .set({ photoUrl: null, updatedAt: new Date() }) + .where(eq(contacts.id, contactId)); + } + + private extractKeyFromUrl(url: string): string | null { + // Extract key from URLs like http://localhost:9000/contacts-storage/users/xxx/file.jpg + const match = url.match(/contacts-storage\/(.+)$/); + return match ? match[1] : null; + } +} diff --git a/apps/contacts/apps/web/src/app.css b/apps/contacts/apps/web/src/app.css index 0a4c926fc..af9e5ab22 100644 --- a/apps/contacts/apps/web/src/app.css +++ b/apps/contacts/apps/web/src/app.css @@ -61,8 +61,9 @@ /* Avatar styles */ .avatar { - width: 48px; - height: 48px; + width: 56px; + height: 56px; + min-width: 56px; border-radius: var(--radius-full); background-color: hsl(var(--primary)); color: hsl(var(--primary-foreground)); @@ -70,13 +71,13 @@ align-items: center; justify-content: center; font-weight: 600; - font-size: 1.125rem; + font-size: 1.25rem; } .avatar-lg { - width: 80px; - height: 80px; - font-size: 2rem; + width: 96px; + height: 96px; + font-size: 2.5rem; } /* Button styles */ diff --git a/apps/contacts/apps/web/src/lib/api/batch.ts b/apps/contacts/apps/web/src/lib/api/batch.ts new file mode 100644 index 000000000..4bcedb918 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/api/batch.ts @@ -0,0 +1,78 @@ +import { authStore } from '$lib/stores/auth.svelte'; + +const API_BASE = 'http://localhost:3015/api/v1'; + +async function fetchWithAuth(url: string, options: RequestInit = {}) { + const token = await authStore.getAccessToken(); + + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...(options.headers || {}), + }; + + if (token) { + (headers as Record)['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(`${API_BASE}${url}`, { + ...options, + headers, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Request failed' })); + throw new Error(error.message || 'Request failed'); + } + + return response.json(); +} + +export interface BatchResult { + success: number; + failed: number; + errors: string[]; +} + +export const batchApi = { + async deleteMany(contactIds: string[]): Promise { + return fetchWithAuth('/batch/delete', { + method: 'POST', + body: JSON.stringify({ contactIds }), + }); + }, + + async archiveMany(contactIds: string[], archive = true): Promise { + return fetchWithAuth('/batch/archive', { + method: 'POST', + body: JSON.stringify({ contactIds, archive }), + }); + }, + + async favoriteMany(contactIds: string[], favorite = true): Promise { + return fetchWithAuth('/batch/favorite', { + method: 'POST', + body: JSON.stringify({ contactIds, favorite }), + }); + }, + + async addToGroup(contactIds: string[], groupId: string): Promise { + return fetchWithAuth('/batch/add-to-group', { + method: 'POST', + body: JSON.stringify({ contactIds, groupId }), + }); + }, + + async removeFromGroup(contactIds: string[], groupId: string): Promise { + return fetchWithAuth('/batch/remove-from-group', { + method: 'POST', + body: JSON.stringify({ contactIds, groupId }), + }); + }, + + async addTags(contactIds: string[], tagIds: string[]): Promise { + return fetchWithAuth('/batch/add-tags', { + method: 'POST', + body: JSON.stringify({ contactIds, tagIds }), + }); + }, +}; diff --git a/apps/contacts/apps/web/src/lib/api/contacts.ts b/apps/contacts/apps/web/src/lib/api/contacts.ts index aa20ee37e..2288422f0 100644 --- a/apps/contacts/apps/web/src/lib/api/contacts.ts +++ b/apps/contacts/apps/web/src/lib/api/contacts.ts @@ -63,6 +63,8 @@ export interface ContactGroup { name: string; description?: string | null; color?: string | null; + icon?: string | null; + isPreset: boolean; createdAt: string; } @@ -287,3 +289,34 @@ export const activitiesApi = { }); }, }; + +// Photo API +export const photoApi = { + async upload(contactId: string, file: File): Promise<{ photoUrl: string }> { + const token = await authStore.getAccessToken(); + + const formData = new FormData(); + formData.append('photo', file); + + const response = await fetch(`${API_BASE}/contacts/${contactId}/photo`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + body: formData, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Upload failed' })); + throw new Error(error.message || 'Upload failed'); + } + + return response.json(); + }, + + async delete(contactId: string): Promise { + await fetchWithAuth(`/contacts/${contactId}/photo`, { + method: 'DELETE', + }); + }, +}; diff --git a/apps/contacts/apps/web/src/lib/api/duplicates.ts b/apps/contacts/apps/web/src/lib/api/duplicates.ts new file mode 100644 index 000000000..2c8a8c903 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/api/duplicates.ts @@ -0,0 +1,60 @@ +import { authStore } from '$lib/stores/auth.svelte'; +import type { Contact } from './contacts'; + +const API_BASE = 'http://localhost:3015/api/v1'; + +async function fetchWithAuth(url: string, options: RequestInit = {}) { + const token = await authStore.getAccessToken(); + + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...(options.headers || {}), + }; + + if (token) { + (headers as Record)['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(`${API_BASE}${url}`, { + ...options, + headers, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Request failed' })); + throw new Error(error.message || 'Request failed'); + } + + return response.json(); +} + +export interface DuplicateGroup { + id: string; + contacts: Contact[]; + matchType: 'email' | 'phone' | 'name'; + matchValue: string; +} + +export interface MergeResult { + mergedContact: Contact; + deletedIds: string[]; +} + +export const duplicatesApi = { + async findDuplicates(): Promise<{ duplicates: DuplicateGroup[]; total: number }> { + return fetchWithAuth('/duplicates'); + }, + + async mergeContacts(primaryId: string, mergeIds: string[]): Promise { + return fetchWithAuth('/duplicates/merge', { + method: 'POST', + body: JSON.stringify({ primaryId, mergeIds }), + }); + }, + + async dismissDuplicate(groupId: string): Promise { + await fetchWithAuth(`/duplicates/${groupId}/dismiss`, { + method: 'DELETE', + }); + }, +}; diff --git a/apps/contacts/apps/web/src/lib/components/ContactDetailModal.svelte b/apps/contacts/apps/web/src/lib/components/ContactDetailModal.svelte index d7136b8c5..2dbdeae71 100644 --- a/apps/contacts/apps/web/src/lib/components/ContactDetailModal.svelte +++ b/apps/contacts/apps/web/src/lib/components/ContactDetailModal.svelte @@ -1,7 +1,7 @@ + +
+ + + + + {#if activeFilterCount > 0 && !showFilters} +
+ {#if selectedGroupId} + {@const group = groups.find((g) => g.id === selectedGroupId)} + {#if group} + + {/if} + {/if} + {#if contactFilter !== 'all'} + + {/if} + {#if birthdayFilter !== 'all'} + + {/if} + {#if selectedCompany} + + {/if} + +
+ {/if} + + + {#if showFilters} +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + {#if companies.length > 0} +
+ + +
+ {/if} + + + {#if activeFilterCount > 0} + + {/if} +
+ {/if} +
+ + diff --git a/apps/contacts/apps/web/src/lib/components/SortToggle.svelte b/apps/contacts/apps/web/src/lib/components/SortToggle.svelte new file mode 100644 index 000000000..43cef1766 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/SortToggle.svelte @@ -0,0 +1,66 @@ + + +
+ + +
+ + diff --git a/apps/contacts/apps/web/src/lib/components/ViewModeToggle.svelte b/apps/contacts/apps/web/src/lib/components/ViewModeToggle.svelte new file mode 100644 index 000000000..45a37d88e --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/ViewModeToggle.svelte @@ -0,0 +1,85 @@ + + +
+ {#each modes as mode} + + {/each} +
+ + diff --git a/apps/contacts/apps/web/src/lib/components/duplicates/MergeModal.svelte b/apps/contacts/apps/web/src/lib/components/duplicates/MergeModal.svelte new file mode 100644 index 000000000..b5f076f1d --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/duplicates/MergeModal.svelte @@ -0,0 +1,225 @@ + + +{#if isOpen} +
+ + + + +
+ +
+

Duplikate zusammenführen

+

+ {contacts.length} Kontakte gefunden mit gleicher {getMatchTypeLabel(matchType)}: + {matchValue} +

+
+ + +
+

+ Wähle den Hauptkontakt aus. Die Daten der anderen Kontakte werden ergänzt (leere Felder + werden gefüllt). +

+ + +
+ + + + + {#each contacts as contact (contact.id)} + + {/each} + + + + {#each comparisonFields as field (field.key)} + + + {#each contacts as contact (contact.id)} + + {/each} + + {/each} + + + {#each contacts as contact (contact.id)} + + {/each} + + + + {#each contacts as contact (contact.id)} + + {/each} + + +
Feld + +
{field.label} + {getFieldValue(contact, field.key)} +
Erstellt am + {new Date(contact.createdAt).toLocaleDateString('de-DE')} +
Zuletzt geändert + {new Date(contact.updatedAt).toLocaleDateString('de-DE')} +
+
+ + +
+

+ Hinweis: Der ausgewählte Hauptkontakt wird beibehalten. Leere Felder werden + mit Daten aus den anderen Kontakten gefüllt. Die anderen Kontakte werden gelöscht. +

+
+
+ + +
+ +
+ + +
+
+
+
+{/if} + + diff --git a/apps/contacts/apps/web/src/lib/components/views/ContactAlphabetView.svelte b/apps/contacts/apps/web/src/lib/components/views/ContactAlphabetView.svelte new file mode 100644 index 000000000..062be56fa --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/views/ContactAlphabetView.svelte @@ -0,0 +1,494 @@ + + +
+ +
+ {#each availableLetters as letter} +
+ +
+ {letter} + {groupedContacts[letter].length} +
+ + +
+ {#each groupedContacts[letter] as contact (contact.id)} +
onContactClick(contact.id)} + onkeydown={(e) => e.key === 'Enter' && onContactClick(contact.id)} + class="alphabet-contact-card {selectionMode && selectedIds.has(contact.id) + ? 'selected' + : ''}" + > + + {#if selectionMode} + + {/if} + + +
+ {#if contact.photoUrl} + {getDisplayName(contact)} + {:else} + {getInitials(contact)} + {/if} +
+ + +
+
+ {getDisplayName(contact)} +
+
+ {#if contact.jobTitle && contact.company} + {contact.jobTitle} @ {contact.company} + {:else if contact.company} + {contact.company} + {:else if contact.email} + {contact.email} + {/if} +
+
+ + +
+ {#if contact.phone || contact.mobile} + e.stopPropagation()} + class="quick-action-btn" + title={$_('contacts.call')} + > + + + + + {/if} + {#if contact.email} + e.stopPropagation()} + class="quick-action-btn" + title={$_('contacts.email')} + > + + + + + {/if} + +
+
+ {/each} +
+
+ {/each} +
+ + +
+ {#each alphabet as letter} + + {/each} + {#if availableLetters.includes('#')} + + {/if} +
+
+ + diff --git a/apps/contacts/apps/web/src/lib/components/views/ContactGridView.svelte b/apps/contacts/apps/web/src/lib/components/views/ContactGridView.svelte new file mode 100644 index 000000000..8fb5e20fa --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/views/ContactGridView.svelte @@ -0,0 +1,338 @@ + + +
+ {#each contacts as contact (contact.id)} +
onContactClick(contact.id)} + onkeydown={(e) => e.key === 'Enter' && onContactClick(contact.id)} + class="grid-card {selectionMode && selectedIds.has(contact.id) ? 'selected' : ''}" + > + + {#if selectionMode} + + {/if} + + + + + +
+ {#if contact.photoUrl} + {getDisplayName(contact)} + {:else} + {getInitials(contact)} + {/if} +
+ + +
+

{getDisplayName(contact)}

+ {#if contact.jobTitle} +

{contact.jobTitle}

+ {/if} + {#if contact.company} +

{contact.company}

+ {/if} +
+ + + +
+ {/each} +
+ + diff --git a/apps/contacts/apps/web/src/lib/components/views/ContactListView.svelte b/apps/contacts/apps/web/src/lib/components/views/ContactListView.svelte new file mode 100644 index 000000000..279dde6c6 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/views/ContactListView.svelte @@ -0,0 +1,157 @@ + + +
+ {#each contacts as contact (contact.id)} +
onContactClick(contact.id)} + onkeydown={(e) => e.key === 'Enter' && onContactClick(contact.id)} + class="contact-card w-full text-left cursor-pointer {selectionMode && + selectedIds.has(contact.id) + ? 'selected' + : ''}" + > + + {#if selectionMode} + + {/if} + + +
+ {#if contact.photoUrl} + {getDisplayName(contact)} + {:else} + {getInitials(contact)} + {/if} +
+ + +
+
+ {getDisplayName(contact)} +
+ {#if contact.company || contact.jobTitle} +
+ {[contact.jobTitle, contact.company].filter(Boolean).join(' @ ')} +
+ {/if} + {#if contact.email} +
+ {contact.email} +
+ {/if} +
+ + + +
+ {/each} +
+ + diff --git a/apps/contacts/apps/web/src/lib/i18n/locales/de.json b/apps/contacts/apps/web/src/lib/i18n/locales/de.json index 6e3dd5128..46a012ab2 100644 --- a/apps/contacts/apps/web/src/lib/i18n/locales/de.json +++ b/apps/contacts/apps/web/src/lib/i18n/locales/de.json @@ -67,7 +67,22 @@ "noContacts": "Keine Kontakte gefunden", "addFirst": "Füge deinen ersten Kontakt hinzu", "favorites": "Favoriten", - "archive": "Archiv" + "archive": "Archiv", + "contact": "Kontakt", + "contactsPlural": "Kontakte", + "call": "Anrufen", + "email": "E-Mail senden", + "favorite": "Als Favorit markieren", + "unfavorite": "Favorit entfernen" + }, + "views": { + "list": "Listenansicht", + "grid": "Kachelansicht", + "alphabet": "Alphabetisch" + }, + "sort": { + "firstName": "Vorname", + "lastName": "Nachname" }, "contact": { "firstName": "Vorname", @@ -120,6 +135,28 @@ "contacts": "Kontakte", "loadMore": "Mehr laden" }, + "filters": { + "title": "Filter", + "clearAll": "Alle löschen", + "group": "Gruppe", + "allGroups": "Alle Gruppen", + "contactInfo": "Kontaktinfo", + "contact": { + "all": "Alle Kontakte", + "hasPhone": "Mit Telefon", + "hasEmail": "Mit E-Mail", + "incomplete": "Unvollständig" + }, + "birthdayLabel": "Geburtstag", + "birthday": { + "all": "Alle", + "today": "Heute", + "thisWeek": "Diese Woche", + "thisMonth": "Diesen Monat" + }, + "company": "Firma", + "allCompanies": "Alle Firmen" + }, "export": { "title": "Kontakte exportieren", "button": "Exportieren", diff --git a/apps/contacts/apps/web/src/lib/i18n/locales/en.json b/apps/contacts/apps/web/src/lib/i18n/locales/en.json index 99f517c22..29fe1c00c 100644 --- a/apps/contacts/apps/web/src/lib/i18n/locales/en.json +++ b/apps/contacts/apps/web/src/lib/i18n/locales/en.json @@ -67,7 +67,22 @@ "noContacts": "No contacts found", "addFirst": "Add your first contact", "favorites": "Favorites", - "archive": "Archive" + "archive": "Archive", + "contact": "Contact", + "contactsPlural": "Contacts", + "call": "Call", + "email": "Send email", + "favorite": "Mark as favorite", + "unfavorite": "Remove favorite" + }, + "views": { + "list": "List view", + "grid": "Grid view", + "alphabet": "Alphabetical" + }, + "sort": { + "firstName": "First Name", + "lastName": "Last Name" }, "contact": { "firstName": "First Name", @@ -120,6 +135,28 @@ "contacts": "Contacts", "loadMore": "Load more" }, + "filters": { + "title": "Filters", + "clearAll": "Clear all", + "group": "Group", + "allGroups": "All groups", + "contactInfo": "Contact info", + "contact": { + "all": "All contacts", + "hasPhone": "With phone", + "hasEmail": "With email", + "incomplete": "Incomplete" + }, + "birthdayLabel": "Birthday", + "birthday": { + "all": "All", + "today": "Today", + "thisWeek": "This week", + "thisMonth": "This month" + }, + "company": "Company", + "allCompanies": "All companies" + }, "export": { "title": "Export Contacts", "button": "Export", diff --git a/apps/contacts/apps/web/src/lib/stores/contacts.svelte.ts b/apps/contacts/apps/web/src/lib/stores/contacts.svelte.ts index 92d6452e3..7f9af9df8 100644 --- a/apps/contacts/apps/web/src/lib/stores/contacts.svelte.ts +++ b/apps/contacts/apps/web/src/lib/stores/contacts.svelte.ts @@ -199,6 +199,13 @@ export const contactsStore = { filters = { ...filters, search }; }, + /** + * Set group filter + */ + setGroupId(groupId: string | undefined) { + filters = { ...filters, groupId }; + }, + /** * Clear selected contact */ diff --git a/apps/contacts/apps/web/src/lib/stores/settings.svelte.ts b/apps/contacts/apps/web/src/lib/stores/settings.svelte.ts new file mode 100644 index 000000000..daf59e1af --- /dev/null +++ b/apps/contacts/apps/web/src/lib/stores/settings.svelte.ts @@ -0,0 +1,228 @@ +/** + * Settings Store - Manages user preferences for the Contacts app + * Uses Svelte 5 runes and localStorage for persistence + */ + +import { browser } from '$app/environment'; + +// Settings types +export type ContactSortBy = 'name' | 'company' | 'created' | 'updated'; +export type ContactSortOrder = 'asc' | 'desc'; +export type ContactView = 'list' | 'grid' | 'alphabet'; +export type DateFormat = 'dd.MM.yyyy' | 'MM/dd/yyyy' | 'yyyy-MM-dd'; + +export interface ContactsAppSettings { + // Display Settings + /** Default view mode for contacts list */ + defaultView: ContactView; + /** Default sort field */ + sortBy: ContactSortBy; + /** Default sort order */ + sortOrder: ContactSortOrder; + /** Show contact photos in list */ + showPhotos: boolean; + /** Show company name in list */ + showCompany: boolean; + /** Contacts per page in list view */ + contactsPerPage: number; + + // Contact Display + /** Display name format: 'first-last' or 'last-first' */ + nameFormat: 'first-last' | 'last-first'; + /** Date format for birthdays etc. */ + dateFormat: DateFormat; + /** Show birthday reminders */ + showBirthdayReminders: boolean; + /** Days before birthday to remind */ + birthdayReminderDays: number; + + // Import/Export + /** Default export format */ + defaultExportFormat: 'vcf' | 'csv' | 'json'; + /** Include notes in export */ + includeNotesInExport: boolean; + /** Include photos in export */ + includePhotosInExport: boolean; + + // Duplicates + /** Auto-detect duplicates on import */ + autoDetectDuplicates: boolean; + /** Duplicate detection sensitivity: 'strict' | 'normal' | 'loose' */ + duplicateSensitivity: 'strict' | 'normal' | 'loose'; + + // Privacy + /** Blur contact photos by default (privacy mode) */ + privacyMode: boolean; + /** Require confirmation before sharing contact */ + confirmBeforeSharing: boolean; +} + +const DEFAULT_SETTINGS: ContactsAppSettings = { + // Display Settings + defaultView: 'list', + sortBy: 'name', + sortOrder: 'asc', + showPhotos: true, + showCompany: true, + contactsPerPage: 50, + + // Contact Display + nameFormat: 'first-last', + dateFormat: 'dd.MM.yyyy', + showBirthdayReminders: true, + birthdayReminderDays: 7, + + // Import/Export + defaultExportFormat: 'vcf', + includeNotesInExport: true, + includePhotosInExport: true, + + // Duplicates + autoDetectDuplicates: true, + duplicateSensitivity: 'normal', + + // Privacy + privacyMode: false, + confirmBeforeSharing: true, +}; + +const STORAGE_KEY = 'contacts-settings'; + +// Load settings from localStorage +function loadSettings(): ContactsAppSettings { + if (!browser) return DEFAULT_SETTINGS; + + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored); + // Merge with defaults to handle new settings added in updates + return { ...DEFAULT_SETTINGS, ...parsed }; + } + } catch (e) { + console.error('Failed to load contacts settings:', e); + } + + return DEFAULT_SETTINGS; +} + +// Save settings to localStorage +function saveSettings(settings: ContactsAppSettings) { + if (!browser) return; + + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); + } catch (e) { + console.error('Failed to save contacts settings:', e); + } +} + +// State +let settings = $state(loadSettings()); + +export const contactsSettings = { + // Full settings object + get settings() { + return settings; + }, + + // Display Settings + get defaultView() { + return settings.defaultView; + }, + get sortBy() { + return settings.sortBy; + }, + get sortOrder() { + return settings.sortOrder; + }, + get showPhotos() { + return settings.showPhotos; + }, + get showCompany() { + return settings.showCompany; + }, + get contactsPerPage() { + return settings.contactsPerPage; + }, + + // Contact Display + get nameFormat() { + return settings.nameFormat; + }, + get dateFormat() { + return settings.dateFormat; + }, + get showBirthdayReminders() { + return settings.showBirthdayReminders; + }, + get birthdayReminderDays() { + return settings.birthdayReminderDays; + }, + + // Import/Export + get defaultExportFormat() { + return settings.defaultExportFormat; + }, + get includeNotesInExport() { + return settings.includeNotesInExport; + }, + get includePhotosInExport() { + return settings.includePhotosInExport; + }, + + // Duplicates + get autoDetectDuplicates() { + return settings.autoDetectDuplicates; + }, + get duplicateSensitivity() { + return settings.duplicateSensitivity; + }, + + // Privacy + get privacyMode() { + return settings.privacyMode; + }, + get confirmBeforeSharing() { + return settings.confirmBeforeSharing; + }, + + /** + * Initialize settings from localStorage + */ + initialize() { + if (!browser) return; + settings = loadSettings(); + }, + + /** + * Update a single setting + */ + set(key: K, value: ContactsAppSettings[K]) { + settings = { ...settings, [key]: value }; + saveSettings(settings); + }, + + /** + * Update multiple settings at once + */ + update(updates: Partial) { + settings = { ...settings, ...updates }; + saveSettings(settings); + }, + + /** + * Reset all settings to defaults + */ + reset() { + settings = { ...DEFAULT_SETTINGS }; + saveSettings(settings); + }, + + /** + * Get default settings (for reference) + */ + getDefaults() { + return DEFAULT_SETTINGS; + }, +}; diff --git a/apps/contacts/apps/web/src/lib/stores/view-mode.svelte.ts b/apps/contacts/apps/web/src/lib/stores/view-mode.svelte.ts new file mode 100644 index 000000000..3e7a4cff6 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/stores/view-mode.svelte.ts @@ -0,0 +1,67 @@ +/** + * View Mode Store - Manages contact list view mode + * Syncs with contactsSettings for the default view preference + */ + +import { browser } from '$app/environment'; +import { contactsSettings, type ContactView } from './settings.svelte'; + +export type ViewMode = ContactView; + +const STORAGE_KEY = 'contacts-view-mode'; + +// Get initial mode: current session preference > settings default > 'list' +function getInitialMode(): ViewMode { + if (!browser) return 'list'; + + // First check if there's a session-specific preference + const sessionMode = sessionStorage.getItem(STORAGE_KEY); + if (sessionMode === 'list' || sessionMode === 'grid' || sessionMode === 'alphabet') { + return sessionMode; + } + + // Otherwise use the default from settings + return contactsSettings.defaultView || 'list'; +} + +let mode = $state(getInitialMode()); + +export const viewModeStore = { + get mode() { + return mode; + }, + + setMode(newMode: ViewMode) { + mode = newMode; + // Save to sessionStorage for current session + if (browser) { + sessionStorage.setItem(STORAGE_KEY, newMode); + } + }, + + /** + * Reset to default view from settings + */ + resetToDefault() { + mode = contactsSettings.defaultView || 'list'; + if (browser) { + sessionStorage.removeItem(STORAGE_KEY); + } + }, + + /** + * Initialize mode from settings (call on app load) + */ + initialize() { + if (!browser) return; + + // Check if there's a session preference + const sessionMode = sessionStorage.getItem(STORAGE_KEY); + if (sessionMode === 'list' || sessionMode === 'grid' || sessionMode === 'alphabet') { + mode = sessionMode; + } else { + // Use default from settings + mode = contactsSettings.defaultView || 'list'; + } + }, +}; diff --git a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte index 074157096..c4b06dd68 100644 --- a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte @@ -18,6 +18,8 @@ import { setLocale, supportedLocales } from '$lib/i18n'; import ContactDetailModal from '$lib/components/ContactDetailModal.svelte'; import { contactsStore } from '$lib/stores/contacts.svelte'; + import { viewModeStore } from '$lib/stores/view-mode.svelte'; + import { contactsSettings } from '$lib/stores/settings.svelte'; // Check if we're on a contact detail route const contactDetailMatch = $derived($page.url.pathname.match(/^\/contacts\/([0-9a-f-]{36})$/i)); @@ -77,6 +79,7 @@ { href: '/groups', label: 'Gruppen', icon: 'folder' }, { href: '/favorites', label: 'Favoriten', icon: 'heart' }, { href: '/archive', label: 'Archiv', icon: 'archive' }, + { href: '/settings', label: 'Einstellungen', icon: 'settings' }, { href: '/feedback', label: 'Feedback', icon: 'chat' }, ]; @@ -154,6 +157,10 @@ // Load user settings await userSettings.load(); + // Initialize contacts settings and view mode + contactsSettings.initialize(); + viewModeStore.initialize(); + // Initialize sidebar mode from localStorage const savedSidebar = localStorage.getItem('contacts-nav-sidebar'); if (savedSidebar === 'true') { @@ -213,9 +220,9 @@
-
+
{@render children()}
@@ -240,7 +247,14 @@ /* Floating nav mode - add top padding for fixed nav */ .main-content.floating-mode { - padding-top: 100px; + padding-top: 80px; + } + + /* Extra padding on mobile for larger nav */ + @media (max-width: 768px) { + .main-content.floating-mode { + padding-top: 90px; + } } /* Sidebar mode - add left padding for sidebar nav */ @@ -255,11 +269,20 @@ padding: 2rem 1rem; } + /* Settings page has its own padding and max-width */ + .content-wrapper.settings-page { + max-width: none; + padding: 0; + } + @media (min-width: 640px) { .content-wrapper { padding-left: 1.5rem; padding-right: 1.5rem; } + .content-wrapper.settings-page { + padding: 0; + } } @media (min-width: 1024px) { @@ -267,5 +290,8 @@ padding-left: 2rem; padding-right: 2rem; } + .content-wrapper.settings-page { + padding: 0; + } } diff --git a/apps/contacts/apps/web/src/routes/(app)/duplicates/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/duplicates/+page.svelte new file mode 100644 index 000000000..b7d2eb249 --- /dev/null +++ b/apps/contacts/apps/web/src/routes/(app)/duplicates/+page.svelte @@ -0,0 +1,255 @@ + + + + Duplikate - Contacts + + +
+ +
+
+

Duplikate finden

+

Finde und führe doppelte Kontakte zusammen

+
+ +
+ + + {#if loading} +
+
+
+ {:else if error} + +
+
+

Fehler beim Laden

+

{error}

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

Keine Duplikate gefunden

+

+ Deine Kontakte sehen sauber aus! Es wurden keine potenziellen Duplikate erkannt. +

+
+ {:else} + +
+
+
{duplicates.length}
+
Duplikat-Gruppen
+
+
+
+ {duplicates.reduce((sum, d) => sum + d.contacts.length, 0)} +
+
Betroffene Kontakte
+
+
+
+ {duplicates.reduce((sum, d) => sum + d.contacts.length - 1, 0)} +
+
Mögliche Einsparung
+
+
+ + +
+ {#each duplicates as group (group.id)} +
+ +
+
+ {getMatchTypeIcon(group.matchType)} +
+
+ {group.contacts.length} Kontakte mit gleicher {getMatchTypeLabel(group.matchType)} +
+
+ {group.matchValue} +
+
+
+ +
+ + +
+
+ {#each group.contacts as contact (contact.id)} +
+
+ {#if contact.photoUrl} + {getDisplayName(contact)} + {:else} + {getInitials(contact)} + {/if} +
+
+
+ {getDisplayName(contact)} +
+ {#if contact.company} +
+ {contact.company} +
+ {/if} +
+
+ {/each} +
+
+
+ {/each} +
+ {/if} +
+ + +{#if selectedGroup} + +{/if} diff --git a/apps/contacts/apps/web/src/routes/(app)/groups/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/groups/+page.svelte index 8d3b5ffcd..d5592e7ab 100644 --- a/apps/contacts/apps/web/src/routes/(app)/groups/+page.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/groups/+page.svelte @@ -10,14 +10,38 @@ let error = $state(null); let searchQuery = $state(''); + // Sort groups: preset groups first, then user groups + const sortedGroups = $derived(() => { + return [...groups].sort((a, b) => { + // Preset groups first + if (a.isPreset && !b.isPreset) return -1; + if (!a.isPreset && b.isPreset) return 1; + // Then alphabetically + return a.name.localeCompare(b.name, 'de'); + }); + }); + const filteredGroups = $derived(() => { - if (!searchQuery.trim()) return groups; + const sorted = sortedGroups(); + if (!searchQuery.trim()) return sorted; const query = searchQuery.toLowerCase(); - return groups.filter( + return sorted.filter( (g) => g.name.toLowerCase().includes(query) || g.description?.toLowerCase().includes(query) ); }); + // Icon mapping for preset groups + const iconMap: Record = { + home: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6', + users: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z', + briefcase: 'M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z', + building: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4', + handshake: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z', + star: 'M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z', + 'map-pin': 'M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z M15 11a3 3 0 11-6 0 3 3 0 016 0z', + flag: 'M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9', + }; + async function loadGroups() { loading = true; error = null; @@ -34,18 +58,27 @@ goto(`/groups/${id}`); } - async function handleDeleteGroup(e: MouseEvent, id: string) { + async function handleDeleteGroup(e: MouseEvent, group: ContactGroup) { e.stopPropagation(); + if (group.isPreset) { + error = 'Voreingestellte Gruppen können nicht gelöscht werden'; + return; + } if (!confirm('Gruppe wirklich löschen?')) return; try { - await groupsApi.delete(id); - groups = groups.filter((g) => g.id !== id); + await groupsApi.delete(group.id); + groups = groups.filter((g) => g.id !== group.id); } catch (e) { error = e instanceof Error ? e.message : 'Fehler beim Löschen'; } } + function getIconPath(icon: string | null | undefined): string | null { + if (!icon) return null; + return iconMap[icon] || null; + } + function getGroupColor(color: string | null | undefined): string { return color || '#6366f1'; } @@ -151,49 +184,105 @@

Keine Gruppen gefunden für "{searchQuery}"

{:else} -
- {#each filteredGroups() as group (group.id)} -
handleGroupClick(group.id)} - onkeydown={(e) => e.key === 'Enter' && handleGroupClick(group.id)} - class="group-card" - > -
-
-

{group.name}

- {#if group.description} -

{group.description}

- {/if} -
-
- - - - +
-
- {/each} - + {/each} + + {/if} + + + {#if userGroups.length > 0} +
0}> + Meine Gruppen +
+
+ {#each userGroups as group (group.id)} +
handleGroupClick(group.id)} + onkeydown={(e) => e.key === 'Enter' && handleGroupClick(group.id)} + class="group-card" + > +
+
+

{group.name}

+ {#if group.description} +

{group.description}

+ {/if} +
+
+ + + + +
+
+ {/each} +
+ {/if}

{groups.length} Gruppe{groups.length !== 1 ? 'n' : ''}

{/if} @@ -397,11 +486,37 @@ box-shadow: 0 4px 12px hsl(var(--color-foreground) / 0.05); } + /* Section Headers */ + .section-header { + margin-bottom: 0.75rem; + } + + .section-header.has-margin { + margin-top: 1.5rem; + } + + .section-title { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: hsl(var(--color-muted-foreground)); + } + .group-color { width: 3rem; height: 3rem; border-radius: 0.75rem; flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + } + + .group-icon { + width: 1.5rem; + height: 1.5rem; + color: white; } .group-info { diff --git a/apps/contacts/apps/web/src/routes/(app)/settings/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/settings/+page.svelte index 6a9f4fc96..2db88da06 100644 --- a/apps/contacts/apps/web/src/routes/(app)/settings/+page.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/settings/+page.svelte @@ -1,17 +1,104 @@ @@ -19,8 +106,482 @@ + + + {#snippet icon()} + + + + {/snippet} + + + + {#snippet icon()} + + + + {/snippet} + + + + {#snippet icon()} + + + + {/snippet} + + Aktiv + + + + + - + + + + + {#snippet icon()} + + + + {/snippet} + + + + contactsSettings.set('defaultView', v as ContactView)} + > + {#snippet icon()} + + + + {/snippet} + + + + contactsSettings.set('sortBy', v as ContactSortBy)} + > + {#snippet icon()} + + + + {/snippet} + + + + contactsSettings.set('sortOrder', v as ContactSortOrder)} + > + {#snippet icon()} + + + + {/snippet} + + + contactsSettings.set('showPhotos', v)} + > + {#snippet icon()} + + + + {/snippet} + + + contactsSettings.set('showCompany', v)} + > + {#snippet icon()} + + + + {/snippet} + + + contactsSettings.set('contactsPerPage', v ?? 50)} + min={10} + max={200} + border={false} + > + {#snippet icon()} + + + + {/snippet} + + + + + + + {#snippet icon()} + + + + {/snippet} + + + + contactsSettings.set('nameFormat', v as 'first-last' | 'last-first')} + > + {#snippet icon()} + + + + {/snippet} + + + + contactsSettings.set('dateFormat', v as DateFormat)} + > + {#snippet icon()} + + + + {/snippet} + + + contactsSettings.set('showBirthdayReminders', v)} + > + {#snippet icon()} + + + + {/snippet} + + + contactsSettings.set('birthdayReminderDays', v ?? 7)} + min={1} + max={30} + border={false} + > + {#snippet icon()} + + + + {/snippet} + + + + + + + {#snippet icon()} + + + + {/snippet} + + + + contactsSettings.set('defaultExportFormat', v as 'vcf' | 'csv' | 'json')} + > + {#snippet icon()} + + + + {/snippet} + + + contactsSettings.set('includeNotesInExport', v)} + > + {#snippet icon()} + + + + {/snippet} + + + contactsSettings.set('includePhotosInExport', v)} + border={false} + > + {#snippet icon()} + + + + {/snippet} + + + + + + + {#snippet icon()} + + + + {/snippet} + + + contactsSettings.set('autoDetectDuplicates', v)} + > + {#snippet icon()} + + + + {/snippet} + + + + contactsSettings.set('duplicateSensitivity', v as 'strict' | 'normal' | 'loose')} + border={false} + > + {#snippet icon()} + + + + {/snippet} + + + + + + + {#snippet icon()} + + + + {/snippet} + + + contactsSettings.set('privacyMode', v)} + > + {#snippet icon()} + + + + {/snippet} + + + contactsSettings.set('confirmBeforeSharing', v)} + > + {#snippet icon()} + + + + {/snippet} + + + userSettings.updateGeneral({ confirmOnDelete: v })} + border={false} + > + {#snippet icon()} + + + + {/snippet} + + + @@ -37,8 +598,58 @@ + {#snippet icon()} + + + + {/snippet} 1.0.0 + + + + + {#snippet icon()} + + + + {/snippet} + + + + {#snippet icon()} + + + + {/snippet} + + diff --git a/package.json b/package.json index 232bddfbf..b357c9c86 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "dev:contacts:landing": "pnpm --filter @contacts/landing dev", "dev:contacts:backend": "pnpm --filter @contacts/backend dev", "dev:contacts:app": "turbo run dev --filter=@contacts/web --filter=@contacts/backend", - "dev:contacts:full": "./scripts/setup-databases.sh contacts && ./scripts/setup-databases.sh auth && concurrently -n auth,backend,web -c blue,green,cyan \"pnpm dev:auth\" \"pnpm dev:contacts:backend\" \"pnpm dev:contacts:web\"", + "dev:contacts:full": "./scripts/setup-databases.sh contacts && ./scripts/setup-databases.sh auth && pnpm contacts:db:seed && concurrently -n auth,backend,web -c blue,green,cyan \"pnpm dev:auth\" \"pnpm dev:contacts:backend\" \"pnpm dev:contacts:web\"", "contacts:db:push": "pnpm --filter @contacts/backend db:push", "contacts:db:studio": "pnpm --filter @contacts/backend db:studio", "contacts:db:seed": "pnpm --filter @contacts/backend db:seed", diff --git a/packages/shared-ui/src/settings/SettingsCard.svelte b/packages/shared-ui/src/settings/SettingsCard.svelte index 9ebf35ee1..384ba3720 100644 --- a/packages/shared-ui/src/settings/SettingsCard.svelte +++ b/packages/shared-ui/src/settings/SettingsCard.svelte @@ -21,87 +21,39 @@ class: className = '', children, }: Props = $props(); + + // Base card classes using Tailwind + const baseCardClasses = + 'rounded-2xl overflow-hidden shadow-md border backdrop-blur-xl ' + + 'bg-white/85 border-black/10 ' + + 'dark:bg-white/[0.06] dark:border-white/10 dark:shadow-lg'; + + const dangerCardClasses = + 'rounded-2xl overflow-hidden shadow-md border backdrop-blur-xl ' + + 'bg-red-500/[0.08] border-red-500/30 ' + + 'dark:bg-red-500/[0.12] dark:border-red-500/25 dark:shadow-lg'; + + const headerClasses = + 'px-5 py-4 border-b border-black/[0.08] dark:border-white/10'; + + const dangerHeaderClasses = + 'px-5 py-4 border-b border-red-500/20 bg-red-500/10'; -
+
{#if title || description} -
+
{#if title} -

{title}

+

{title}

{/if} {#if description} -

{description}

+

{description}

{/if}
{/if} -
+
{@render children()}
- diff --git a/packages/shared-ui/src/settings/SettingsRow.svelte b/packages/shared-ui/src/settings/SettingsRow.svelte index dd07cae6e..61d7ffa8a 100644 --- a/packages/shared-ui/src/settings/SettingsRow.svelte +++ b/packages/shared-ui/src/settings/SettingsRow.svelte @@ -35,91 +35,112 @@ }: Props = $props(); const isClickable = $derived(!!href || !!onclick); + + // Tailwind classes + const baseRowClasses = + 'flex items-center justify-between gap-4 px-5 py-4 bg-transparent w-full text-left no-underline transition-all duration-200'; + + const borderClasses = 'border-b border-black/[0.08] dark:border-white/10 last:border-b-0'; + + const clickableClasses = 'cursor-pointer hover:bg-black/[0.04] dark:hover:bg-white/[0.06]'; + + const disabledClasses = 'opacity-50 cursor-not-allowed pointer-events-none'; + + const iconClasses = + 'flex items-center justify-center flex-shrink-0 w-9 h-9 rounded-[0.625rem] bg-black/[0.04] dark:bg-white/[0.08] text-primary'; + + function getRowClasses(isBordered: boolean, isClick: boolean, isDisabled: boolean): string { + let classes = baseRowClasses; + if (isBordered) classes += ' ' + borderClasses; + if (isClick) classes += ' ' + clickableClasses; + if (isDisabled) classes += ' ' + disabledClasses; + return classes; + } {#if href} - -
+ +
{#if icon} - + {@render icon()} {/if} -
- {label} +
+ {label} {#if description} - {description} + {description} {/if}
-
+
{#if children} {@render children()} {:else} - + {/if}
{:else if onclick} - {:else} -
-
+
+
{#if icon} - + {@render icon()} {/if} -
- {label} +
+ {label} {#if description} - {description} + {description} {/if}
{#if children} -
+
{@render children()}
{/if} @@ -127,120 +148,9 @@ {/if} diff --git a/packages/shared-ui/src/settings/SettingsSection.svelte b/packages/shared-ui/src/settings/SettingsSection.svelte index c0b24f5e6..799afa1a2 100644 --- a/packages/shared-ui/src/settings/SettingsSection.svelte +++ b/packages/shared-ui/src/settings/SettingsSection.svelte @@ -15,72 +15,23 @@ let { title, icon, class: className = '', children }: Props = $props(); -
+
{#if title} -
+
{#if icon} - + {@render icon()} {/if} -

{title}

+

+ {title} +

{/if} -
+
{@render children()}
- - diff --git a/packages/shared-ui/src/settings/SettingsToggle.svelte b/packages/shared-ui/src/settings/SettingsToggle.svelte index c4c0dd6a7..75c19ba89 100644 --- a/packages/shared-ui/src/settings/SettingsToggle.svelte +++ b/packages/shared-ui/src/settings/SettingsToggle.svelte @@ -36,23 +36,31 @@ onToggle(!isOn); } } + + // Tailwind classes + const baseClasses = 'flex items-center justify-between gap-4 px-5 py-4'; + const borderClasses = 'border-b border-black/[0.08] dark:border-white/10 last:border-b-0'; + const disabledClasses = 'opacity-50 cursor-not-allowed'; + + const iconClasses = + 'flex items-center justify-center flex-shrink-0 w-9 h-9 rounded-[0.625rem] bg-black/[0.04] dark:bg-white/[0.08] text-primary [&>svg]:w-[1.125rem] [&>svg]:h-[1.125rem]';
-
+
{#if icon} - + {@render icon()} {/if} -
- {label} +
+ {label} {#if description} - {description} + {description} {/if}
@@ -60,165 +68,20 @@
- - From 09599c2a92b1b5ceaed6bf6c640e5ca4f1fe132e Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Tue, 9 Dec 2025 17:46:08 +0100 Subject: [PATCH 02/68] refactor(shared-ui): convert SettingsSelect from CSS to Tailwind classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../src/settings/SettingsSelect.svelte | 179 +++--------------- 1 file changed, 29 insertions(+), 150 deletions(-) diff --git a/packages/shared-ui/src/settings/SettingsSelect.svelte b/packages/shared-ui/src/settings/SettingsSelect.svelte index 0c8749a4b..1c5be0dc2 100644 --- a/packages/shared-ui/src/settings/SettingsSelect.svelte +++ b/packages/shared-ui/src/settings/SettingsSelect.svelte @@ -57,176 +57,55 @@ onchange(rawValue); } } + + // Tailwind classes + const baseClasses = 'flex items-center justify-between gap-4 px-5 py-4'; + const borderClasses = 'border-b border-black/[0.08] dark:border-white/10 last:border-b-0'; + const disabledClasses = 'opacity-50 cursor-not-allowed'; + + const iconClasses = + 'flex items-center justify-center flex-shrink-0 w-9 h-9 rounded-[0.625rem] bg-black/[0.04] dark:bg-white/[0.08] text-primary [&>svg]:w-[1.125rem] [&>svg]:h-[1.125rem]';
-
+
{#if icon} - + {@render icon()} {/if} -
- {label} +
+ {label} {#if description} - {description} + {description} {/if}
- - From 76f573fb0825dfdae87d1743d67b64868a9ea973 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Tue, 9 Dec 2025 17:49:07 +0100 Subject: [PATCH 03/68] refactor(shared-ui): convert settings components from scoped CSS to Tailwind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SettingsCard, SettingsSection, SettingsRow now use Tailwind classes - SettingsToggle, SettingsSelect, SettingsNumberInput updated - SettingsTimeInput, SettingsDangerZone, SettingsDangerButton updated - SettingsPage updated with Tailwind layout classes - Fixes dark mode styling issues when components used across packages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../web/src/lib/components/FilterBar.svelte | 31 ++- .../src/settings/SettingsDangerButton.svelte | 156 +++---------- .../src/settings/SettingsDangerZone.svelte | 55 +---- .../src/settings/SettingsNumberInput.svelte | 177 ++------------ .../src/settings/SettingsPage.svelte | 58 +---- .../src/settings/SettingsTimeInput.svelte | 215 +++--------------- 6 files changed, 119 insertions(+), 573 deletions(-) diff --git a/apps/contacts/apps/web/src/lib/components/FilterBar.svelte b/apps/contacts/apps/web/src/lib/components/FilterBar.svelte index b4fdfc5a8..df6adce69 100644 --- a/apps/contacts/apps/web/src/lib/components/FilterBar.svelte +++ b/apps/contacts/apps/web/src/lib/components/FilterBar.svelte @@ -3,7 +3,7 @@ import { onMount } from 'svelte'; import { groupsApi, type ContactGroup, type Contact } from '$lib/api/contacts'; - export type ContactFilter = 'all' | 'hasPhone' | 'hasEmail' | 'incomplete'; + export type ContactFilter = 'all' | 'favorites' | 'hasPhone' | 'hasEmail' | 'incomplete'; export type BirthdayFilter = 'all' | 'today' | 'thisWeek' | 'thisMonth'; interface Props { @@ -110,7 +110,12 @@ {group.name} - + {/if} @@ -119,7 +124,12 @@ {/if} @@ -127,7 +137,12 @@ {/if} @@ -135,7 +150,12 @@ {/if} @@ -172,6 +192,7 @@ onchange={(e) => onContactFilterChange(e.currentTarget.value as ContactFilter)} > + diff --git a/packages/shared-ui/src/settings/SettingsDangerButton.svelte b/packages/shared-ui/src/settings/SettingsDangerButton.svelte index ceeabba0f..23891d98d 100644 --- a/packages/shared-ui/src/settings/SettingsDangerButton.svelte +++ b/packages/shared-ui/src/settings/SettingsDangerButton.svelte @@ -30,148 +30,46 @@ disabled = false, class: className = '', }: Props = $props(); + + // Tailwind classes + const baseClasses = 'flex items-center justify-between gap-4 px-5 py-4'; + const borderClasses = 'border-b border-red-500/[0.12] dark:border-red-500/[0.18] last:border-b-0'; + const disabledClasses = 'opacity-50 cursor-not-allowed'; + + const iconClasses = + 'flex items-center justify-center flex-shrink-0 w-9 h-9 rounded-[0.625rem] bg-red-500/10 dark:bg-red-500/[0.15] text-red-500 dark:text-red-400 [&>svg]:w-[1.125rem] [&>svg]:h-[1.125rem]';
-
+
{#if icon} - + {@render icon()} {/if} -
- {label} +
+ {label} {#if description} - {description} + {description} {/if}
-
- - diff --git a/packages/shared-ui/src/settings/SettingsDangerZone.svelte b/packages/shared-ui/src/settings/SettingsDangerZone.svelte index 648126fb6..32e0350d9 100644 --- a/packages/shared-ui/src/settings/SettingsDangerZone.svelte +++ b/packages/shared-ui/src/settings/SettingsDangerZone.svelte @@ -13,55 +13,16 @@ let { title = 'Danger Zone', class: className = '', children }: Props = $props(); -
-
-

{title}

+
+
+

{title}

-
+
{@render children()}
- - diff --git a/packages/shared-ui/src/settings/SettingsNumberInput.svelte b/packages/shared-ui/src/settings/SettingsNumberInput.svelte index 2098a061b..9d82deb30 100644 --- a/packages/shared-ui/src/settings/SettingsNumberInput.svelte +++ b/packages/shared-ui/src/settings/SettingsNumberInput.svelte @@ -61,30 +61,45 @@ onchange(clampedValue); } } + + // Tailwind classes + const baseClasses = 'flex items-center justify-between gap-4 px-5 py-4'; + const borderClasses = 'border-b border-black/[0.08] dark:border-white/10 last:border-b-0'; + const disabledClasses = 'opacity-50 cursor-not-allowed'; + + const iconClasses = + 'flex items-center justify-center flex-shrink-0 w-9 h-9 rounded-[0.625rem] bg-black/[0.04] dark:bg-white/[0.08] text-primary [&>svg]:w-[1.125rem] [&>svg]:h-[1.125rem]';
-
+
{#if icon} - + {@render icon()} {/if} -
- {label} +
+ {label} {#if description} - {description} + {description} {/if}
- - diff --git a/packages/shared-ui/src/settings/SettingsPage.svelte b/packages/shared-ui/src/settings/SettingsPage.svelte index 5131b047e..693ead145 100644 --- a/packages/shared-ui/src/settings/SettingsPage.svelte +++ b/packages/shared-ui/src/settings/SettingsPage.svelte @@ -24,63 +24,17 @@ }; -
-
-
-

{title}

+
+
+
+

{title}

{#if subtitle} -

{subtitle}

+

{subtitle}

{/if}
-
+
{@render children()}
- - diff --git a/packages/shared-ui/src/settings/SettingsTimeInput.svelte b/packages/shared-ui/src/settings/SettingsTimeInput.svelte index 74c4ef370..7b1ad85b3 100644 --- a/packages/shared-ui/src/settings/SettingsTimeInput.svelte +++ b/packages/shared-ui/src/settings/SettingsTimeInput.svelte @@ -52,31 +52,46 @@ function handleClear() { onchange(null); } + + // Tailwind classes + const baseClasses = 'flex items-center justify-between gap-4 px-5 py-4'; + const borderClasses = 'border-b border-black/[0.08] dark:border-white/10 last:border-b-0'; + const disabledClasses = 'opacity-50 cursor-not-allowed'; + + const iconClasses = + 'flex items-center justify-center flex-shrink-0 w-9 h-9 rounded-[0.625rem] bg-black/[0.04] dark:bg-white/[0.08] text-primary [&>svg]:w-[1.125rem] [&>svg]:h-[1.125rem]';
-
+
{#if icon} - + {@render icon()} {/if} -
- {label} +
+ {label} {#if description} - {description} + {description} {/if}
-
+
- - From 4e5d12aa53bdbcaa2a0b19f6de95d09d1bb370ac Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Tue, 9 Dec 2025 18:00:55 +0100 Subject: [PATCH 04/68] feat(contacts): add enhanced favorites page with multiple view modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add quick favorites filter button on homepage with badge count - Create dedicated favorites page with hero header and stats cards - Implement three view modes for favorites: cards, list, alphabet - Cards: Large 120px avatars with gradient backgrounds, full contact details - List: 72px avatars with detail chips and hover actions - Alphabet: Grouped by letter with quick-jump navigation - Fix layout jump when favorites filter is active (exclude from FilterBar count) - Add tags management feature with CRUD operations - Reorganize import page to /data route - Add infinite scroll to contacts list - Add contact notes feature - Persist favorites view mode in localStorage 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/contacts/CLAUDE.md | 2 +- .../apps/backend/src/tag/tag.controller.ts | 39 + .../contacts/apps/web/src/lib/api/contacts.ts | 24 +- .../lib/components/ContactDetailModal.svelte | 4 + .../web/src/lib/components/ContactList.svelte | 197 +++- .../src/lib/components/ContactNotes.svelte | 598 +++++++++++++ .../web/src/lib/components/FilterBar.svelte | 11 +- .../favorites/FavoriteAlphabetView.svelte | 494 ++++++++++ .../favorites/FavoriteCardView.svelte | 363 ++++++++ .../favorites/FavoriteListView.svelte | 324 +++++++ .../lib/components/import/GoogleImport.svelte | 2 +- .../apps/web/src/lib/i18n/locales/de.json | 34 +- .../apps/web/src/lib/i18n/locales/en.json | 34 +- .../web/src/lib/stores/contacts.svelte.ts | 52 +- .../apps/web/src/routes/(app)/+layout.svelte | 1 + .../web/src/routes/(app)/data/+page.svelte | 635 +++++++++++++ .../src/routes/(app)/favorites/+page.svelte | 768 ++++++++++------ .../web/src/routes/(app)/import/+page.svelte | 283 ------ .../src/routes/(app)/settings/+page.svelte | 57 +- .../web/src/routes/(app)/tags/+page.svelte | 847 ++++++++++++++++++ 20 files changed, 4127 insertions(+), 642 deletions(-) create mode 100644 apps/contacts/apps/web/src/lib/components/ContactNotes.svelte create mode 100644 apps/contacts/apps/web/src/lib/components/favorites/FavoriteAlphabetView.svelte create mode 100644 apps/contacts/apps/web/src/lib/components/favorites/FavoriteCardView.svelte create mode 100644 apps/contacts/apps/web/src/lib/components/favorites/FavoriteListView.svelte create mode 100644 apps/contacts/apps/web/src/routes/(app)/data/+page.svelte delete mode 100644 apps/contacts/apps/web/src/routes/(app)/import/+page.svelte create mode 100644 apps/contacts/apps/web/src/routes/(app)/tags/+page.svelte diff --git a/apps/contacts/CLAUDE.md b/apps/contacts/CLAUDE.md index a95ef9c01..fde22e3f9 100644 --- a/apps/contacts/CLAUDE.md +++ b/apps/contacts/CLAUDE.md @@ -208,7 +208,7 @@ S3_BUCKET=contacts-photos # Get credentials from https://console.cloud.google.com/apis/credentials GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com GOOGLE_CLIENT_SECRET=your-client-secret -GOOGLE_REDIRECT_URI=http://localhost:5184/import?tab=google +GOOGLE_REDIRECT_URI=http://localhost:5184/data?tab=import&source=google ``` #### Mobile (.env) diff --git a/apps/contacts/apps/backend/src/tag/tag.controller.ts b/apps/contacts/apps/backend/src/tag/tag.controller.ts index 0371afd80..d77db0efa 100644 --- a/apps/contacts/apps/backend/src/tag/tag.controller.ts +++ b/apps/contacts/apps/backend/src/tag/tag.controller.ts @@ -71,4 +71,43 @@ export class TagController { await this.tagService.delete(id, user.userId); return { success: true }; } + + @Post(':id/contacts/:contactId') + async addToContact( + @CurrentUser() user: CurrentUserData, + @Param('id', ParseUUIDPipe) tagId: string, + @Param('contactId', ParseUUIDPipe) contactId: string + ) { + // Verify tag belongs to user + const tag = await this.tagService.findById(tagId, user.userId); + if (!tag) { + throw new Error('Tag not found'); + } + await this.tagService.addTagToContact(contactId, tagId); + return { success: true }; + } + + @Delete(':id/contacts/:contactId') + async removeFromContact( + @CurrentUser() user: CurrentUserData, + @Param('id', ParseUUIDPipe) tagId: string, + @Param('contactId', ParseUUIDPipe) contactId: string + ) { + // Verify tag belongs to user + const tag = await this.tagService.findById(tagId, user.userId); + if (!tag) { + throw new Error('Tag not found'); + } + await this.tagService.removeTagFromContact(contactId, tagId); + return { success: true }; + } + + @Get('contact/:contactId') + async getTagsForContact( + @CurrentUser() user: CurrentUserData, + @Param('contactId', ParseUUIDPipe) contactId: string + ) { + const tagIds = await this.tagService.getTagsForContact(contactId); + return { tagIds }; + } } diff --git a/apps/contacts/apps/web/src/lib/api/contacts.ts b/apps/contacts/apps/web/src/lib/api/contacts.ts index 2288422f0..b7b6be705 100644 --- a/apps/contacts/apps/web/src/lib/api/contacts.ts +++ b/apps/contacts/apps/web/src/lib/api/contacts.ts @@ -210,29 +210,45 @@ export const groupsApi = { // Tags API export const tagsApi = { - async list() { + async list(): Promise<{ tags: ContactTag[] }> { return fetchWithAuth('/tags'); }, - async create(data: { name: string; color?: string }) { + async create(data: { name: string; color?: string }): Promise<{ tag: ContactTag }> { return fetchWithAuth('/tags', { method: 'POST', body: JSON.stringify(data), }); }, - async update(id: string, data: { name?: string; color?: string }) { + async update(id: string, data: { name?: string; color?: string }): Promise<{ tag: ContactTag }> { return fetchWithAuth(`/tags/${id}`, { method: 'PATCH', body: JSON.stringify(data), }); }, - async delete(id: string) { + async delete(id: string): Promise<{ success: boolean }> { return fetchWithAuth(`/tags/${id}`, { method: 'DELETE', }); }, + + async addToContact(tagId: string, contactId: string): Promise<{ success: boolean }> { + return fetchWithAuth(`/tags/${tagId}/contacts/${contactId}`, { + method: 'POST', + }); + }, + + async removeFromContact(tagId: string, contactId: string): Promise<{ success: boolean }> { + return fetchWithAuth(`/tags/${tagId}/contacts/${contactId}`, { + method: 'DELETE', + }); + }, + + async getForContact(contactId: string): Promise<{ tagIds: string[] }> { + return fetchWithAuth(`/tags/contact/${contactId}`); + }, }; // Notes API diff --git a/apps/contacts/apps/web/src/lib/components/ContactDetailModal.svelte b/apps/contacts/apps/web/src/lib/components/ContactDetailModal.svelte index 2dbdeae71..f140824e4 100644 --- a/apps/contacts/apps/web/src/lib/components/ContactDetailModal.svelte +++ b/apps/contacts/apps/web/src/lib/components/ContactDetailModal.svelte @@ -2,6 +2,7 @@ import { goto } from '$app/navigation'; import { onMount } from 'svelte'; import { contactsApi, photoApi, type Contact } from '$lib/api/contacts'; + import ContactNotes from './ContactNotes.svelte'; interface Props { contactId: string; @@ -848,6 +849,9 @@
{/if} + + +
{/if} {/if} diff --git a/apps/contacts/apps/web/src/lib/components/ContactList.svelte b/apps/contacts/apps/web/src/lib/components/ContactList.svelte index 7783fb2fd..cebb714a7 100644 --- a/apps/contacts/apps/web/src/lib/components/ContactList.svelte +++ b/apps/contacts/apps/web/src/lib/components/ContactList.svelte @@ -1,10 +1,9 @@ @@ -248,22 +296,6 @@ - + {$_('contacts.new')} @@ -372,6 +404,32 @@ />
+ + {/if} + + {#if contactsStore.hasMore} +
+ {#if contactsStore.loadingMore} +
+
+ {$_('common.loadingMore')} +
+ {/if} +
+ {/if} +

- {contactsStore.total} + {contactsStore.contacts.length} / {contactsStore.total} {contactsStore.total === 1 ? $_('contacts.contact') : $_('contacts.contactsPlural')}

{/if}
- - (showExportModal = false)} /> - diff --git a/apps/contacts/apps/web/src/lib/components/ContactNotes.svelte b/apps/contacts/apps/web/src/lib/components/ContactNotes.svelte new file mode 100644 index 000000000..744a208f4 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/ContactNotes.svelte @@ -0,0 +1,598 @@ + + +
+
+
+ + + +
+

{$_('notes.title')}

+ +
+ + {#if error} +
{error}
+ {/if} + + + {#if showAddForm} +
+ +
+ + +
+
+ {/if} + + + {#if loading} +
+ +
+ {:else if notes.length === 0 && !showAddForm} +
+

{$_('notes.empty')}

+ +
+ {:else} +
+ {#each sortedNotes as note (note.id)} +
+ {#if editingNoteId === note.id} + + +
+ + +
+ {:else} + +
+ {#if note.isPinned} + + + + + + {/if} +

{note.content}

+ {formatDate(note.createdAt)} +
+
+ + + +
+ {/if} +
+ {/each} +
+ {/if} +
+ + diff --git a/apps/contacts/apps/web/src/lib/components/FilterBar.svelte b/apps/contacts/apps/web/src/lib/components/FilterBar.svelte index df6adce69..bbe3d546c 100644 --- a/apps/contacts/apps/web/src/lib/components/FilterBar.svelte +++ b/apps/contacts/apps/web/src/lib/components/FilterBar.svelte @@ -45,11 +45,11 @@ return Array.from(companySet).sort((a, b) => a.localeCompare(b, 'de')); }); - // Count active filters + // Count active filters (excluding favorites since it has its own quick button) let activeFilterCount = $derived.by(() => { let count = 0; if (selectedGroupId) count++; - if (contactFilter !== 'all') count++; + if (contactFilter !== 'all' && contactFilter !== 'favorites') count++; if (birthdayFilter !== 'all') count++; if (selectedCompany) count++; return count; @@ -68,7 +68,10 @@ function clearAllFilters() { onGroupChange(null); - onContactFilterChange('all'); + // Keep favorites filter if active (controlled by separate quick button) + if (contactFilter !== 'favorites') { + onContactFilterChange('all'); + } onBirthdayFilterChange('all'); onCompanyChange(null); } @@ -120,7 +123,7 @@ {/if} {/if} - {#if contactFilter !== 'all'} + {#if contactFilter !== 'all' && contactFilter !== 'favorites'}
+
+
+ {/each} +
+
+ {/each} +
+ + +
+ {#each alphabet as letter} + + {/each} + {#if availableLetters.includes('#')} + + {/if} +
+
+ + diff --git a/apps/contacts/apps/web/src/lib/components/favorites/FavoriteCardView.svelte b/apps/contacts/apps/web/src/lib/components/favorites/FavoriteCardView.svelte new file mode 100644 index 000000000..de131246c --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/favorites/FavoriteCardView.svelte @@ -0,0 +1,363 @@ + + +
+ {#each contacts as contact (contact.id)} +
onContactClick(contact.id)} + onkeydown={(e) => e.key === 'Enter' && onContactClick(contact.id)} + class="favorite-card" + > + +
+ + + + + +
+ {#if contact.photoUrl} + {getDisplayName(contact)} + {:else} + {getInitials(contact)} + {/if} +
+ + +
+

{getDisplayName(contact)}

+ {#if contact.jobTitle} +

{contact.jobTitle}

+ {/if} + {#if contact.company} +

{contact.company}

+ {/if} + + +
+ {#if contact.email} +
+ + + + {contact.email} +
+ {/if} + {#if contact.phone || contact.mobile} +
+ + + + {contact.mobile || contact.phone} +
+ {/if} + {#if contact.birthday} +
+ + + + {new Date(contact.birthday).toLocaleDateString('de-DE', { + day: 'numeric', + month: 'long', + })} +
+ {/if} +
+
+ + + +
+ {/each} +
+ + diff --git a/apps/contacts/apps/web/src/lib/components/favorites/FavoriteListView.svelte b/apps/contacts/apps/web/src/lib/components/favorites/FavoriteListView.svelte new file mode 100644 index 000000000..2377f9f93 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/favorites/FavoriteListView.svelte @@ -0,0 +1,324 @@ + + +
+ {#each contacts as contact (contact.id)} +
onContactClick(contact.id)} + onkeydown={(e) => e.key === 'Enter' && onContactClick(contact.id)} + class="favorite-row" + > + +
+ {#if contact.photoUrl} + {getDisplayName(contact)} + {:else} + {getInitials(contact)} + {/if} +
+ + +
+
+

{getDisplayName(contact)}

+ {#if contact.jobTitle || contact.company} +

+ {[contact.jobTitle, contact.company].filter(Boolean).join(' @ ')} +

+ {/if} +
+ + +
+ {#if contact.email} +
+ + + + {contact.email} +
+ {/if} + {#if contact.phone || contact.mobile} +
+ + + + {contact.mobile || contact.phone} +
+ {/if} + {#if contact.birthday} +
+ + + + {new Date(contact.birthday).toLocaleDateString('de-DE', { + day: 'numeric', + month: 'short', + })} +
+ {/if} +
+
+ + +
+ {#if contact.phone || contact.mobile} + e.stopPropagation()} + class="action-btn action-call" + title="Anrufen" + > + + + + + {/if} + {#if contact.email} + e.stopPropagation()} + class="action-btn action-email" + title="E-Mail senden" + > + + + + + {/if} + +
+
+ {/each} +
+ + diff --git a/apps/contacts/apps/web/src/lib/components/import/GoogleImport.svelte b/apps/contacts/apps/web/src/lib/components/import/GoogleImport.svelte index 47bd95bd7..258da86fc 100644 --- a/apps/contacts/apps/web/src/lib/components/import/GoogleImport.svelte +++ b/apps/contacts/apps/web/src/lib/components/import/GoogleImport.svelte @@ -31,7 +31,7 @@ try { await googleApi.handleCallback(code); // Remove code from URL - goto('/import?tab=google', { replaceState: true }); + goto('/data?tab=import&source=google', { replaceState: true }); } catch (e) { error = e instanceof Error ? e.message : 'Failed to connect'; } diff --git a/apps/contacts/apps/web/src/lib/i18n/locales/de.json b/apps/contacts/apps/web/src/lib/i18n/locales/de.json index 46a012ab2..bfeb20ee8 100644 --- a/apps/contacts/apps/web/src/lib/i18n/locales/de.json +++ b/apps/contacts/apps/web/src/lib/i18n/locales/de.json @@ -4,11 +4,13 @@ }, "common": { "back": "Zurück", - "cancel": "Abbrechen" + "cancel": "Abbrechen", + "loadingMore": "Lade weitere..." }, "nav": { "contacts": "Kontakte", "groups": "Gruppen", + "tags": "Tags", "favorites": "Favoriten", "archive": "Archiv", "search": "Suche", @@ -143,6 +145,7 @@ "contactInfo": "Kontaktinfo", "contact": { "all": "Alle Kontakte", + "favorites": "Favoriten", "hasPhone": "Mit Telefon", "hasEmail": "Mit E-Mail", "incomplete": "Unvollständig" @@ -166,5 +169,34 @@ "includeArchived": "Archivierte Kontakte einschließen", "exporting": "Exportiere...", "success": "Export erfolgreich" + }, + "notes": { + "title": "Notizen", + "add": "Notiz hinzufügen", + "addFirst": "Erste Notiz hinzufügen", + "empty": "Noch keine Notizen", + "placeholder": "Schreibe eine Notiz...", + "confirmDelete": "Diese Notiz löschen?", + "pin": "Notiz anheften", + "unpin": "Nicht mehr anheften", + "yesterday": "Gestern" + }, + "tags": { + "title": "Tags", + "new": "Neuer Tag", + "edit": "Tag bearbeiten", + "noTags": "Noch keine Tags", + "createFirst": "Erstelle deinen ersten Tag um Kontakte zu organisieren", + "search": "Tags durchsuchen...", + "name": "Name", + "namePlaceholder": "Tag-Name eingeben", + "color": "Farbe", + "preview": "Vorschau", + "contactCount": "{count} Kontakte", + "confirmDelete": "Möchtest du \"{name}\" wirklich löschen?", + "noResults": "Keine Tags gefunden", + "noResultsFor": "Keine Ergebnisse für \"{query}\"", + "tagSingular": "Tag", + "tagPlural": "Tags" } } diff --git a/apps/contacts/apps/web/src/lib/i18n/locales/en.json b/apps/contacts/apps/web/src/lib/i18n/locales/en.json index 29fe1c00c..cd1092c6e 100644 --- a/apps/contacts/apps/web/src/lib/i18n/locales/en.json +++ b/apps/contacts/apps/web/src/lib/i18n/locales/en.json @@ -4,11 +4,13 @@ }, "common": { "back": "Back", - "cancel": "Cancel" + "cancel": "Cancel", + "loadingMore": "Loading more..." }, "nav": { "contacts": "Contacts", "groups": "Groups", + "tags": "Tags", "favorites": "Favorites", "archive": "Archive", "search": "Search", @@ -143,6 +145,7 @@ "contactInfo": "Contact info", "contact": { "all": "All contacts", + "favorites": "Favorites", "hasPhone": "With phone", "hasEmail": "With email", "incomplete": "Incomplete" @@ -166,5 +169,34 @@ "includeArchived": "Include archived contacts", "exporting": "Exporting...", "success": "Export successful" + }, + "notes": { + "title": "Notes", + "add": "Add Note", + "addFirst": "Add your first note", + "empty": "No notes yet", + "placeholder": "Write a note...", + "confirmDelete": "Delete this note?", + "pin": "Pin note", + "unpin": "Unpin note", + "yesterday": "Yesterday" + }, + "tags": { + "title": "Tags", + "new": "New Tag", + "edit": "Edit Tag", + "noTags": "No tags yet", + "createFirst": "Create your first tag to organize contacts", + "search": "Search tags...", + "name": "Name", + "namePlaceholder": "Enter tag name", + "color": "Color", + "preview": "Preview", + "contactCount": "{count} contacts", + "confirmDelete": "Are you sure you want to delete \"{name}\"?", + "noResults": "No tags found", + "noResultsFor": "No results for \"{query}\"", + "tagSingular": "Tag", + "tagPlural": "Tags" } } diff --git a/apps/contacts/apps/web/src/lib/stores/contacts.svelte.ts b/apps/contacts/apps/web/src/lib/stores/contacts.svelte.ts index 7f9af9df8..758d1c0e8 100644 --- a/apps/contacts/apps/web/src/lib/stores/contacts.svelte.ts +++ b/apps/contacts/apps/web/src/lib/stores/contacts.svelte.ts @@ -5,13 +5,19 @@ import { contactsApi } from '$lib/api/contacts'; import type { Contact, ContactFilters } from '$lib/api/contacts'; +// Default page size for pagination +const DEFAULT_PAGE_SIZE = 50; + // State let contacts = $state([]); let selectedContact = $state(null); let loading = $state(false); +let loadingMore = $state(false); let error = $state(null); let total = $state(0); let filters = $state({}); +let hasMore = $state(true); +let currentOffset = $state(0); export const contactsStore = { // Getters @@ -24,6 +30,9 @@ export const contactsStore = { get loading() { return loading; }, + get loadingMore() { + return loadingMore; + }, get error() { return error; }, @@ -33,9 +42,12 @@ export const contactsStore = { get filters() { return filters; }, + get hasMore() { + return hasMore; + }, /** - * Load contacts with optional filters + * Load contacts with optional filters (resets to first page) */ async loadContacts(newFilters?: ContactFilters) { if (newFilters) { @@ -44,11 +56,18 @@ export const contactsStore = { loading = true; error = null; + currentOffset = 0; try { - const result = await contactsApi.list(filters); + const result = await contactsApi.list({ + ...filters, + limit: DEFAULT_PAGE_SIZE, + offset: 0, + }); contacts = result.contacts; total = result.total; + hasMore = contacts.length < total; + currentOffset = contacts.length; } catch (e) { error = e instanceof Error ? e.message : 'Failed to load contacts'; console.error('Failed to load contacts:', e); @@ -57,6 +76,35 @@ export const contactsStore = { } }, + /** + * Load more contacts (infinite scroll) + */ + async loadMore() { + if (loadingMore || !hasMore) return; + + loadingMore = true; + error = null; + + try { + const result = await contactsApi.list({ + ...filters, + limit: DEFAULT_PAGE_SIZE, + offset: currentOffset, + }); + + const newContacts = result.contacts; + contacts = [...contacts, ...newContacts]; + total = result.total; + currentOffset += newContacts.length; + hasMore = contacts.length < total; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to load more contacts'; + console.error('Failed to load more contacts:', e); + } finally { + loadingMore = false; + } + }, + /** * Load a single contact by ID */ diff --git a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte index c4b06dd68..9b4543436 100644 --- a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte @@ -77,6 +77,7 @@ const navItems: PillNavItem[] = [ { href: '/', label: 'Kontakte', icon: 'users' }, { href: '/groups', label: 'Gruppen', icon: 'folder' }, + { href: '/tags', label: 'Tags', icon: 'tag' }, { href: '/favorites', label: 'Favoriten', icon: 'heart' }, { href: '/archive', label: 'Archiv', icon: 'archive' }, { href: '/settings', label: 'Einstellungen', icon: 'settings' }, diff --git a/apps/contacts/apps/web/src/routes/(app)/data/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/data/+page.svelte new file mode 100644 index 000000000..319da69bb --- /dev/null +++ b/apps/contacts/apps/web/src/routes/(app)/data/+page.svelte @@ -0,0 +1,635 @@ + + + + Daten - Kontakte + + +
+ +
+
+

Daten verwalten

+

Kontakte importieren, exportieren und sichern

+
+ Zurück +
+ + +
+ + +
+ + + {#if activeTab === 'import'} + +
+ + +
+ + + {#if importError && importSource === 'file'} +
+ {importError} +
+ {/if} + + + {#if importSource === 'file'} + {#if importStep === 'upload'} +
+ {#if isLoading} +
+
+

Datei wird verarbeitet...

+
+ {:else} + + +
+

Unterstützte Formate

+
+
+
+ + + +
+
+
vCard (.vcf)
+
+ Standard-Format für Kontakte, kompatibel mit allen gängigen Apps +
+
+
+
+
+ + + +
+
+
CSV (.csv)
+
+ Tabellen-Format, ideal für Excel oder Google Sheets +
+
+
+
+ +
+ +
+
+ {/if} +
+ {/if} + + {#if importStep === 'preview' && preview} + + {/if} + + {#if importStep === 'result' && importResult} +
+
+ + + +
+ +
+

Import abgeschlossen

+

Deine Kontakte wurden erfolgreich importiert

+
+ +
+
+
{importResult.imported}
+
Importiert
+
+
+
{importResult.merged}
+
Zusammengeführt
+
+
+
{importResult.skipped}
+
Übersprungen
+
+
+ + {#if importResult.errors.length > 0} +
+

Fehler

+
    + {#each importResult.errors as err} +
  • {err.contactName}: {err.error}
  • + {/each} +
+
+ {/if} + +
+ + +
+
+ {/if} + {/if} + + + {#if importSource === 'google'} + + {/if} + {/if} + + + {#if activeTab === 'export'} +
+ + {#if exportSuccess} +
+ + + + Export erfolgreich! Die Datei wurde heruntergeladen. +
+ {/if} + + + {#if exportError} +
+ {exportError} +
+ {/if} + + +
+

Format wählen

+
+ + +
+
+ + +
+

Filter

+ +
+ +
+ + +
+ + + +
+
+ + +
+

Optionen

+ +
+ + + + + +
+
+ + +
+ +
+
+ {/if} +
diff --git a/apps/contacts/apps/web/src/routes/(app)/favorites/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/favorites/+page.svelte index 92ddfcfa7..3bf52e8d1 100644 --- a/apps/contacts/apps/web/src/routes/(app)/favorites/+page.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/favorites/+page.svelte @@ -1,16 +1,23 @@ Favoriten - Contacts -
- -
- - - - - -

Favoriten

-
- +
+ +
+
+
+ + + +
+
+

Favoriten

+

+ {#if contacts.length === 0} + Markiere Kontakte als Favoriten für schnellen Zugriff + {:else} + {contacts.length} Favorit{contacts.length !== 1 ? 'en' : ''} für schnellen Zugriff + {/if} +

+
+
+ + + {#if contacts.length > 0} +
+
+
+ + + +
+
+ {contacts.length} + Favoriten +
+
+
+
+ + + +
+
+ {contacts.filter((c) => c.email).length} + Mit E-Mail +
+
+
+
+ + + +
+
+ {contacts.filter((c) => c.phone || c.mobile).length} + Mit Telefon +
+
+
+ {/if} +
+ + +
+ +
+ -
-
- - -
- - - - + {#if searchQuery} + + {/if} +
+ + +
+ + + +
{#if error} {:else if contacts.length === 0}
@@ -134,17 +282,18 @@

Keine Favoriten

- Markiere Kontakte als Favoriten, um sie hier schnell zu finden. + Markiere Kontakte als Favoriten, um sie hier schnell wiederzufinden. Klicke einfach auf das + Herz-Symbol bei einem Kontakt.

- - + + - Zu Kontakten + Zu allen Kontakten
- {:else if filteredContacts().length === 0} + {:else if filteredContacts.length === 0}
-
+

Keine Ergebnisse

Keine Favoriten gefunden für "{searchQuery}"

+
{:else} -
- {#each filteredContacts() as contact (contact.id)} -
handleContactClick(contact.id)} - onkeydown={(e) => e.key === 'Enter' && handleContactClick(contact.id)} - class="contact-card" - > - -
- {#if contact.photoUrl} - {getDisplayName(contact)} - {:else} - {getInitials(contact)} - {/if} -
- - -
-

{getDisplayName(contact)}

- {#if contact.company || contact.jobTitle} -

- {[contact.jobTitle, contact.company].filter(Boolean).join(' @ ')} -

- {/if} - {#if contact.email} -

{contact.email}

- {/if} -
- - - -
- {/each} + +
+ {#if viewMode === 'cards'} + + {:else if viewMode === 'list'} + + {:else} + + {/if}
-

{contacts.length} Favorit{contacts.length !== 1 ? 'en' : ''}

+ + {/if}
diff --git a/apps/contacts/apps/web/src/routes/(app)/import/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/import/+page.svelte deleted file mode 100644 index 3fb8a9128..000000000 --- a/apps/contacts/apps/web/src/routes/(app)/import/+page.svelte +++ /dev/null @@ -1,283 +0,0 @@ - - - - {$_('import.title')} - Contacts - - -
- - - - -
- - -
- - - {#if error && activeTab === 'file'} -
- {error} -
- {/if} - - - {#if activeTab === 'file'} - - {#if step === 'upload'} -
- {#if isLoading} -
-
-

{$_('import.processing')}

-
- {:else} - - -
- -
- {/if} -
- {/if} - - - {#if step === 'preview' && preview} - - {/if} - - - {#if step === 'result' && result} -
-
- - - -
- -
-

{$_('import.result.title')}

-

{$_('import.result.subtitle')}

-
- -
-
-
{result.imported}
-
{$_('import.result.imported')}
-
-
-
{result.merged}
-
{$_('import.result.merged')}
-
-
-
{result.skipped}
-
{$_('import.result.skipped')}
-
-
- - {#if result.errors.length > 0} -
-

{$_('import.result.errors')}

-
    - {#each result.errors as err} -
  • {err.contactName}: {err.error}
  • - {/each} -
-
- {/if} - -
- - -
-
- {/if} - {/if} - - - {#if activeTab === 'google'} - - {/if} -
diff --git a/apps/contacts/apps/web/src/routes/(app)/settings/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/settings/+page.svelte index 2db88da06..ab38cd902 100644 --- a/apps/contacts/apps/web/src/routes/(app)/settings/+page.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/settings/+page.svelte @@ -53,12 +53,6 @@ { value: 'yyyy-MM-dd', label: 'JJJJ-MM-TT (ISO)' }, ]; - const exportFormatOptions = [ - { value: 'vcf', label: 'vCard (.vcf)' }, - { value: 'csv', label: 'CSV (.csv)' }, - { value: 'json', label: 'JSON (.json)' }, - ]; - const duplicateSensitivityOptions = [ { value: 'strict', label: 'Streng' }, { value: 'normal', label: 'Normal' }, @@ -195,8 +189,7 @@ description="Standard-Sortierung der Kontakte" options={sortByOptions} value={contactsSettings.sortBy} - onchange={(v: string | number | null) => - contactsSettings.set('sortBy', v as ContactSortBy)} + onchange={(v: string | number | null) => contactsSettings.set('sortBy', v as ContactSortBy)} > {#snippet icon()} @@ -385,7 +378,7 @@ - + {#snippet icon()} - - contactsSettings.set('defaultExportFormat', v as 'vcf' | 'csv' | 'json')} + {#snippet icon()} @@ -412,35 +402,16 @@ stroke-linecap="round" stroke-linejoin="round" stroke-width="2" - d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /> {/snippet} - + - contactsSettings.set('includeNotesInExport', v)} - > - {#snippet icon()} - - - - {/snippet} - - - contactsSettings.set('includePhotosInExport', v)} + {#snippet icon()} @@ -449,11 +420,11 @@ stroke-linecap="round" stroke-linejoin="round" stroke-width="2" - d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" + d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /> {/snippet} - + diff --git a/apps/contacts/apps/web/src/routes/(app)/tags/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/tags/+page.svelte new file mode 100644 index 000000000..e01ec9d85 --- /dev/null +++ b/apps/contacts/apps/web/src/routes/(app)/tags/+page.svelte @@ -0,0 +1,847 @@ + + + + {$_('tags.title')} - Contacts + + +
+ +
+ + + + + +

{$_('tags.title')}

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

{$_('tags.noTags')}

+

{$_('tags.createFirst')}

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

{$_('tags.noResults')}

+

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

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

{tag.name}

+
+
+ + +
+
+ {/each} +
+ +

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

+ {/if} +
+ + +{#if showModal} + +{/if} + + From 05dd9a00632d2be614b51f35de28d17365e724d9 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Tue, 9 Dec 2025 18:07:56 +0100 Subject: [PATCH 05/68] fix(contacts): remove groups store dependency from data page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove groupsStore import and usage from data/+page.svelte - Remove group filter from export options (groups feature not implemented) - Clean up export button removal from ContactList 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../web/src/lib/components/ContactList.svelte | 12 +++++----- .../web/src/routes/(app)/data/+page.svelte | 24 ------------------- 2 files changed, 6 insertions(+), 30 deletions(-) diff --git a/apps/contacts/apps/web/src/lib/components/ContactList.svelte b/apps/contacts/apps/web/src/lib/components/ContactList.svelte index cebb714a7..9997e8200 100644 --- a/apps/contacts/apps/web/src/lib/components/ContactList.svelte +++ b/apps/contacts/apps/web/src/lib/components/ContactList.svelte @@ -26,7 +26,7 @@ let loadMoreTrigger: HTMLDivElement; // Filter state - let selectedGroupId = $state(null); + let selectedTagId = $state(null); let contactFilter = $state('all'); let birthdayFilter = $state('all'); let selectedCompany = $state(null); @@ -432,13 +432,13 @@ { - selectedGroupId = id; + {selectedTagId} + onTagChange={(id) => { + selectedTagId = id; if (id) { - contactsStore.setGroupId(id); + contactsStore.setTagId(id); } else { - contactsStore.setGroupId(undefined); + contactsStore.setTagId(undefined); } contactsStore.loadContacts(); }} diff --git a/apps/contacts/apps/web/src/routes/(app)/data/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/data/+page.svelte index 319da69bb..59f412810 100644 --- a/apps/contacts/apps/web/src/routes/(app)/data/+page.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/data/+page.svelte @@ -8,7 +8,6 @@ import { importApi, type ImportPreviewResponse, type DuplicateAction } from '$lib/api/import'; import { exportApi, type ExportFormat } from '$lib/api/export'; import { contactsStore } from '$lib/stores/contacts.svelte'; - import { groupsStore } from '$lib/stores/groups.svelte'; import '$lib/i18n'; type Tab = 'import' | 'export'; @@ -40,19 +39,11 @@ let includeArchived = $state(false); let includeNotes = $state(true); let includePhotos = $state(true); - let selectedGroupId = $state(null); let onlyFavorites = $state(false); let isExporting = $state(false); let exportError = $state(null); let exportSuccess = $state(false); - // Load groups for export filter - $effect(() => { - if (activeTab === 'export' && groupsStore.groups.length === 0) { - groupsStore.loadGroups(); - } - }); - function setActiveTab(tab: Tab) { activeTab = tab; updateUrl(); @@ -156,7 +147,6 @@ try { await exportApi.exportContacts({ format: exportFormat, - groupId: selectedGroupId || undefined, includeFavorites: onlyFavorites || undefined, includeArchived, }); @@ -529,20 +519,6 @@

Filter

- -
- - -
-
- {/each} -
- {/if} - -

{groups.length} Gruppe{groups.length !== 1 ? 'n' : ''}

- {/if} -
- - diff --git a/apps/contacts/apps/web/src/routes/(app)/groups/[id]/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/groups/[id]/+page.svelte deleted file mode 100644 index ffc681080..000000000 --- a/apps/contacts/apps/web/src/routes/(app)/groups/[id]/+page.svelte +++ /dev/null @@ -1,1140 +0,0 @@ - - - - {group?.name || 'Gruppe'} - Contacts - - -
- -
- - - - - -

{isEditing ? 'Gruppe bearbeiten' : group?.name || 'Gruppe'}

- {#if !loading && group && !isEditing} - - {:else} -
- {/if} -
- - {#if loading} -
-
-
- {:else if error && !group} -
-
- - - -
-

Fehler

-

{error}

- Zurück zu Gruppen -
- {:else if group} - {#if error} - - {/if} - - {#if isEditing} - -
-
- - - -
-

{name || 'Gruppenname'}

-
- -
{ - e.preventDefault(); - handleSave(); - }} - class="form" - > -
-
-
- - - -
-

Details

-
-
- - -
-
- - -
-
- -
-
-
- - - -
-

Farbe

-
-
- {#each presetColors as presetColor} - - {/each} -
-
- -
- - -
-
- - - - {:else} - -
-
- - - -
-

{group.name}

- {#if group.description} -

{group.description}

- {/if} -
- - -
-
-
- - - -
-

Kontakte ({groupContacts().length})

- -
- - {#if groupContacts().length === 0} -

Keine Kontakte in dieser Gruppe

- {:else} -
- {#each groupContacts() as contact (contact.id)} -
-
- {#if contact.photoUrl} - {getDisplayName(contact)} - {:else} - {getInitials(contact)} - {/if} -
-
- {getDisplayName(contact)} - {#if contact.email} - {contact.email} - {/if} -
- -
- {/each} -
- {/if} -
- {/if} - {/if} -
- - -{#if showAddContacts} - -{/if} - - diff --git a/apps/contacts/apps/web/src/routes/(app)/groups/new/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/groups/new/+page.svelte deleted file mode 100644 index 87a2b8266..000000000 --- a/apps/contacts/apps/web/src/routes/(app)/groups/new/+page.svelte +++ /dev/null @@ -1,581 +0,0 @@ - - - - Neue Gruppe - Contacts - - -
- -
- - - - - -

Neue Gruppe

-
-
- - -
-
- - - -
-

{name || 'Neue Gruppe'}

- {#if description} -

{description}

- {/if} -
- - {#if error} - - {/if} - -
{ - e.preventDefault(); - handleSubmit(); - }} - class="form" - > - -
-
-
- - - -
-

Gruppenname

-
-
- - -
-
- - -
-
- - -
-
-
- - - -
-

Farbe

-
-
- {#each presetColors as presetColor} - - {/each} -
-
- -
- - -
-
-
- - -
- Abbrechen - -
-
-
- - diff --git a/apps/contacts/apps/web/src/routes/+layout.svelte b/apps/contacts/apps/web/src/routes/+layout.svelte index 5604dd4c8..397b1ff4a 100644 --- a/apps/contacts/apps/web/src/routes/+layout.svelte +++ b/apps/contacts/apps/web/src/routes/+layout.svelte @@ -1,15 +1,76 @@ + +
+ +
+ +
+ + + +
+ +
+ + +
+ +
+ + +
+ + + + + +
+ {#each Array(5) as _, i} +
+ +
+ + +
+
+ {/each} +
+
+
+ + diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/ContactCardSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/ContactCardSkeleton.svelte new file mode 100644 index 000000000..2d3430e1c --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/skeletons/ContactCardSkeleton.svelte @@ -0,0 +1,70 @@ + + +
+ +
+ +
+ + + + + +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ + +
+ + +
+
+ + diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/ContactDetailSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/ContactDetailSkeleton.svelte new file mode 100644 index 000000000..96318820b --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/skeletons/ContactDetailSkeleton.svelte @@ -0,0 +1,171 @@ + + +
+ +
+
+ +
+ + +
+ + +
+ {#each Array(3) as _} +
+ + +
+ {/each} +
+ + +
+ +
+
+ + +
+
+ {#each Array(3) as _, i} +
+
+ + +
+
+ {/each} +
+
+ + +
+
+ + +
+
+ {#each Array(2) as _, i} +
+
+ + +
+
+ {/each} +
+
+ + +
+
+ + +
+
+ + + +
+
+
+
+ + diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/ContactGridSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/ContactGridSkeleton.svelte new file mode 100644 index 000000000..362727151 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/skeletons/ContactGridSkeleton.svelte @@ -0,0 +1,57 @@ + + +
+ {#each Array(count) as _, i} + + {/each} +
+ + diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/ContactListSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/ContactListSkeleton.svelte new file mode 100644 index 000000000..3bb93cb5a --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/skeletons/ContactListSkeleton.svelte @@ -0,0 +1,31 @@ + + +
+ {#each Array(count) as _, i} + + {/each} +
diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/ContactNotesSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/ContactNotesSkeleton.svelte new file mode 100644 index 000000000..6ebee670b --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/skeletons/ContactNotesSkeleton.svelte @@ -0,0 +1,40 @@ + + +
+ {#each Array(3) as _, i} +
+
+ + +
+ +
+ {/each} +
+ + diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/ContactRowSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/ContactRowSkeleton.svelte new file mode 100644 index 000000000..58fb5d938 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/skeletons/ContactRowSkeleton.svelte @@ -0,0 +1,45 @@ + + +
+ + + + +
+ + + + + + +
+ + + +
+ + diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/DuplicateGroupSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/DuplicateGroupSkeleton.svelte new file mode 100644 index 000000000..bac423136 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/skeletons/DuplicateGroupSkeleton.svelte @@ -0,0 +1,89 @@ + + +
+ +
+
+ +
+ + +
+
+ +
+ + +
+
+ {#each Array(contactCount) as _, i} +
+ +
+ + +
+
+ {/each} +
+
+
+ + diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/DuplicateListSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/DuplicateListSkeleton.svelte new file mode 100644 index 000000000..7e618b617 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/skeletons/DuplicateListSkeleton.svelte @@ -0,0 +1,69 @@ + + +
+ +
+ {#each Array(3) as _} +
+ + +
+ {/each} +
+ + +
+ {#each Array(count) as _, i} + + {/each} +
+
+ + diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/FavoriteCardSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/FavoriteCardSkeleton.svelte new file mode 100644 index 000000000..5d5d3214b --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/skeletons/FavoriteCardSkeleton.svelte @@ -0,0 +1,62 @@ + + +
+ +
+ +
+ + +
+ +
+ +
+ + +
+ +
+ + +
+ + +
+
+
+ + diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/FavoriteGridSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/FavoriteGridSkeleton.svelte new file mode 100644 index 000000000..452e4c079 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/skeletons/FavoriteGridSkeleton.svelte @@ -0,0 +1,56 @@ + + +
+ {#each Array(count) as _, i} + + {/each} +
+ + diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/GoogleImportSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/GoogleImportSkeleton.svelte new file mode 100644 index 000000000..35305687c --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/skeletons/GoogleImportSkeleton.svelte @@ -0,0 +1,51 @@ + + +
+ +
+
+ +
+ + +
+
+ +
+ + +
+
+ +
+ + | + +
+
+ +
+ {#each Array(6) as _, i} +
+ + +
+ + +
+
+ {/each} +
+
+ + +
+ +
+
diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/ImportPreviewSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/ImportPreviewSkeleton.svelte new file mode 100644 index 000000000..d62e68f64 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/skeletons/ImportPreviewSkeleton.svelte @@ -0,0 +1,118 @@ + + +
+ +
+ {#each Array(3) as _} +
+ + +
+ {/each} +
+ + +
+
+ + +
+ +
+ {#each Array(5) as _, i} +
+ +
+ + +
+ +
+ {/each} +
+
+ + +
+ + +
+
+ + diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/NetworkGraphSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/NetworkGraphSkeleton.svelte new file mode 100644 index 000000000..c1e3ae27a --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/skeletons/NetworkGraphSkeleton.svelte @@ -0,0 +1,115 @@ + + +
+ +
+ {#each Array(8) as _, i} +
+ +
+ {/each} +
+ + + + + + + + + + + +
+
+ Netzwerk wird geladen... +
+
+ + diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/TagCardSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/TagCardSkeleton.svelte new file mode 100644 index 000000000..09ff2e540 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/skeletons/TagCardSkeleton.svelte @@ -0,0 +1,36 @@ + + +
+ +
+ + +
+ + +
+ +
+
+ + diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/TagGridSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/TagGridSkeleton.svelte new file mode 100644 index 000000000..f1c72956f --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/skeletons/TagGridSkeleton.svelte @@ -0,0 +1,50 @@ + + +
+ {#each Array(count) as _, i} + + {/each} +
+ + diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/index.ts b/apps/contacts/apps/web/src/lib/components/skeletons/index.ts new file mode 100644 index 000000000..82732122c --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/skeletons/index.ts @@ -0,0 +1,42 @@ +/** + * Contacts App Skeleton Components + * + * App-specific skeleton loaders that match the exact layout of contact components. + * Built on top of @manacore/shared-ui skeleton primitives. + */ + +// Contact List/Grid Skeletons +export { default as ContactRowSkeleton } from './ContactRowSkeleton.svelte'; +export { default as ContactListSkeleton } from './ContactListSkeleton.svelte'; +export { default as ContactCardSkeleton } from './ContactCardSkeleton.svelte'; +export { default as ContactGridSkeleton } from './ContactGridSkeleton.svelte'; + +// Tag Skeletons +export { default as TagCardSkeleton } from './TagCardSkeleton.svelte'; +export { default as TagGridSkeleton } from './TagGridSkeleton.svelte'; + +// Favorite Skeletons +export { default as FavoriteCardSkeleton } from './FavoriteCardSkeleton.svelte'; +export { default as FavoriteGridSkeleton } from './FavoriteGridSkeleton.svelte'; + +// Duplicate Skeletons +export { default as DuplicateGroupSkeleton } from './DuplicateGroupSkeleton.svelte'; +export { default as DuplicateListSkeleton } from './DuplicateListSkeleton.svelte'; + +// App Loading Skeleton +export { default as AppLoadingSkeleton } from './AppLoadingSkeleton.svelte'; + +// Import Preview Skeleton +export { default as ImportPreviewSkeleton } from './ImportPreviewSkeleton.svelte'; + +// Google Import Skeleton +export { default as GoogleImportSkeleton } from './GoogleImportSkeleton.svelte'; + +// Contact Detail Skeleton +export { default as ContactDetailSkeleton } from './ContactDetailSkeleton.svelte'; + +// Contact Notes Skeleton +export { default as ContactNotesSkeleton } from './ContactNotesSkeleton.svelte'; + +// Network Graph Skeleton +export { default as NetworkGraphSkeleton } from './NetworkGraphSkeleton.svelte'; diff --git a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte index c0bffdd0b..7f4da318b 100644 --- a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte @@ -17,10 +17,14 @@ import { getPillAppItems } from '@manacore/shared-branding'; import { setLocale, supportedLocales } from '$lib/i18n'; import ContactDetailModal from '$lib/components/ContactDetailModal.svelte'; + import SearchModal from '$lib/components/SearchModal.svelte'; import { contactsStore } from '$lib/stores/contacts.svelte'; import { viewModeStore } from '$lib/stores/view-mode.svelte'; import { contactsSettings } from '$lib/stores/settings.svelte'; + // Search modal state + let searchModalOpen = $state(false); + // 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); @@ -78,9 +82,10 @@ { href: '/', label: 'Kontakte', icon: 'users' }, { href: '/tags', label: 'Tags', icon: 'tag' }, { href: '/favorites', label: 'Favoriten', icon: 'heart' }, - { href: '/archive', label: 'Archiv', icon: 'archive' }, + { 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) @@ -92,7 +97,7 @@ // Cmd/Ctrl+K to open search (works even in inputs) if ((event.ctrlKey || event.metaKey) && event.key === 'k') { event.preventDefault(); - // TODO: Open search modal + searchModalOpen = true; return; } @@ -231,6 +236,9 @@ {#if showContactModal && modalContactId} {/if} + + + (searchModalOpen = false)} />
diff --git a/apps/todo/apps/web/src/lib/components/skeletons/KanbanBoardSkeleton.svelte b/apps/todo/apps/web/src/lib/components/skeletons/KanbanBoardSkeleton.svelte new file mode 100644 index 000000000..3f6ab58c9 --- /dev/null +++ b/apps/todo/apps/web/src/lib/components/skeletons/KanbanBoardSkeleton.svelte @@ -0,0 +1,70 @@ + + +
+
+ + + +
+
+
+
+
+ + diff --git a/apps/todo/apps/web/src/lib/components/skeletons/KanbanColumnSkeleton.svelte b/apps/todo/apps/web/src/lib/components/skeletons/KanbanColumnSkeleton.svelte new file mode 100644 index 000000000..43fddd36c --- /dev/null +++ b/apps/todo/apps/web/src/lib/components/skeletons/KanbanColumnSkeleton.svelte @@ -0,0 +1,99 @@ + + +
+ +
+
+ + + +
+ +
+ + +
+ {#each Array(taskCount) as _, i} +
+ +
+ + +
+
+ {/each} +
+ + +
+ +
+
+ + diff --git a/apps/todo/apps/web/src/lib/components/skeletons/StatisticsSkeleton.svelte b/apps/todo/apps/web/src/lib/components/skeletons/StatisticsSkeleton.svelte new file mode 100644 index 000000000..8cd238131 --- /dev/null +++ b/apps/todo/apps/web/src/lib/components/skeletons/StatisticsSkeleton.svelte @@ -0,0 +1,252 @@ + + +
+ +
+ {#each Array(6) as _, i} +
+ +
+ + +
+
+ {/each} +
+ + +
+ +
+
+ +
+
+ {#each Array(7) as _} +
+ {#each Array(12) as _} + + {/each} +
+ {/each} +
+
+ + +
+ +
+
+ +
+
+ {#each Array(7) as _, i} +
+ + +
+ {/each} +
+
+ + +
+
+ +
+
+ +
+
+ {#each Array(4) as _} +
+ + +
+ {/each} +
+
+
+ + +
+
+ +
+
+ {#each Array(4) as _, i} +
+
+ + +
+ +
+ {/each} +
+
+
+ + +
+ {#each Array(3) as _} +
+ + +
+ {/each} +
+
+ + diff --git a/apps/todo/apps/web/src/lib/components/skeletons/TaskItemSkeleton.svelte b/apps/todo/apps/web/src/lib/components/skeletons/TaskItemSkeleton.svelte new file mode 100644 index 000000000..9a2a08148 --- /dev/null +++ b/apps/todo/apps/web/src/lib/components/skeletons/TaskItemSkeleton.svelte @@ -0,0 +1,80 @@ + + +
+
+ +
+ +
+ + +
+
+ +
+ + {#if showSubtasks} +
+ {#each Array(2) as _} +
+ + +
+ {/each} +
+ {/if} +
+ + diff --git a/apps/todo/apps/web/src/lib/components/skeletons/TaskListSkeleton.svelte b/apps/todo/apps/web/src/lib/components/skeletons/TaskListSkeleton.svelte new file mode 100644 index 000000000..cd3f1cc18 --- /dev/null +++ b/apps/todo/apps/web/src/lib/components/skeletons/TaskListSkeleton.svelte @@ -0,0 +1,73 @@ + + +
+ {#each Array(sections) as _, sectionIndex} +
+ +
+
+ + + +
+ +
+ + +
+ {#each Array(tasksPerSection) as _, taskIndex} +
+ +
+ {/each} +
+
+ {/each} +
+ + diff --git a/apps/todo/apps/web/src/lib/components/skeletons/index.ts b/apps/todo/apps/web/src/lib/components/skeletons/index.ts new file mode 100644 index 000000000..6628b5579 --- /dev/null +++ b/apps/todo/apps/web/src/lib/components/skeletons/index.ts @@ -0,0 +1,20 @@ +/** + * Todo App Skeleton Components + * + * App-specific skeleton loaders that match the exact layout of todo components. + * Built on top of @manacore/shared-ui skeleton primitives. + */ + +// App Loading Skeleton +export { default as AppLoadingSkeleton } from './AppLoadingSkeleton.svelte'; + +// Task List Skeletons +export { default as TaskItemSkeleton } from './TaskItemSkeleton.svelte'; +export { default as TaskListSkeleton } from './TaskListSkeleton.svelte'; + +// Statistics Skeletons +export { default as StatisticsSkeleton } from './StatisticsSkeleton.svelte'; + +// Kanban Skeletons +export { default as KanbanColumnSkeleton } from './KanbanColumnSkeleton.svelte'; +export { default as KanbanBoardSkeleton } from './KanbanBoardSkeleton.svelte'; diff --git a/apps/todo/apps/web/src/routes/(app)/+page.svelte b/apps/todo/apps/web/src/routes/(app)/+page.svelte index a4818a266..00ef8a394 100644 --- a/apps/todo/apps/web/src/routes/(app)/+page.svelte +++ b/apps/todo/apps/web/src/routes/(app)/+page.svelte @@ -11,6 +11,7 @@ import QuickAddTask from '$lib/components/QuickAddTask.svelte'; import CollapsibleSection from '$lib/components/CollapsibleSection.svelte'; import TaskEditModal from '$lib/components/TaskEditModal.svelte'; + import { TaskListSkeleton } from '$lib/components/skeletons'; import type { Task } from '@todo/shared'; let isLoading = $state(true); @@ -130,11 +131,7 @@ {#if isLoading || tasksStore.loading} -
-
-
+ {:else if tasksStore.error}
{tasksStore.error} diff --git a/apps/todo/apps/web/src/routes/(app)/statistics/+page.svelte b/apps/todo/apps/web/src/routes/(app)/statistics/+page.svelte index 261aaded5..1f39e20e2 100644 --- a/apps/todo/apps/web/src/routes/(app)/statistics/+page.svelte +++ b/apps/todo/apps/web/src/routes/(app)/statistics/+page.svelte @@ -8,6 +8,7 @@ import WeeklyTrendChart from '$lib/components/statistics/WeeklyTrendChart.svelte'; import PriorityDonutChart from '$lib/components/statistics/PriorityDonutChart.svelte'; import ProjectProgressBars from '$lib/components/statistics/ProjectProgressBars.svelte'; + import { StatisticsSkeleton } from '$lib/components/skeletons'; import { BarChart3 } from 'lucide-svelte'; let loading = $state(true); @@ -44,10 +45,7 @@
{#if loading} -
-
-

Lade Statistiken...

-
+ {:else}
@@ -155,31 +153,6 @@ margin: 0.25rem 0 0 0; } - .loading-container { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 1rem; - padding: 4rem 2rem; - color: hsl(var(--muted-foreground)); - } - - .loading-spinner { - width: 32px; - height: 32px; - border: 3px solid hsl(var(--muted) / 0.3); - border-top-color: #8b5cf6; - border-radius: 50%; - animation: spin 0.8s linear infinite; - } - - @keyframes spin { - to { - transform: rotate(360deg); - } - } - .stats-section { margin-bottom: 1.5rem; } diff --git a/apps/todo/apps/web/src/routes/+layout.svelte b/apps/todo/apps/web/src/routes/+layout.svelte index 1d97fe43e..78045c14d 100644 --- a/apps/todo/apps/web/src/routes/+layout.svelte +++ b/apps/todo/apps/web/src/routes/+layout.svelte @@ -3,6 +3,7 @@ import { onMount } from 'svelte'; import { theme } from '$lib/stores/theme'; import { authStore } from '$lib/stores/auth.svelte'; + import { AppLoadingSkeleton } from '$lib/components/skeletons'; let { children } = $props(); @@ -20,14 +21,7 @@ {#if loading} -
-
-
-

Laden...

-
-
+ {:else}
{@render children()} diff --git a/packages/shared-ui/src/index.ts b/packages/shared-ui/src/index.ts index 445de250d..b735e8594 100644 --- a/packages/shared-ui/src/index.ts +++ b/packages/shared-ui/src/index.ts @@ -15,7 +15,15 @@ export { TagBadge } from './molecules'; export { AudioPlayer } from './molecules'; // Loading/Skeletons -export { SkeletonBox, SkeletonText } from './molecules'; +export { + SkeletonBox, + SkeletonText, + SkeletonAvatar, + SkeletonRow, + SkeletonList, + SkeletonCard, + SkeletonGrid, +} from './molecules'; // Feedback export { EmptyState } from './molecules'; diff --git a/packages/shared-ui/src/molecules/index.ts b/packages/shared-ui/src/molecules/index.ts index 6766ee887..97024aea5 100644 --- a/packages/shared-ui/src/molecules/index.ts +++ b/packages/shared-ui/src/molecules/index.ts @@ -15,7 +15,15 @@ export { TagBadge } from './tags'; export { AudioPlayer } from './media'; // Loading components -export { SkeletonBox, SkeletonText } from './loaders'; +export { + SkeletonBox, + SkeletonText, + SkeletonAvatar, + SkeletonRow, + SkeletonList, + SkeletonCard, + SkeletonGrid, +} from './loaders'; // Feedback components export { EmptyState } from './feedback'; diff --git a/packages/shared-ui/src/molecules/loaders/SkeletonAvatar.svelte b/packages/shared-ui/src/molecules/loaders/SkeletonAvatar.svelte new file mode 100644 index 000000000..097cbc1b3 --- /dev/null +++ b/packages/shared-ui/src/molecules/loaders/SkeletonAvatar.svelte @@ -0,0 +1,24 @@ + + + diff --git a/packages/shared-ui/src/molecules/loaders/SkeletonCard.svelte b/packages/shared-ui/src/molecules/loaders/SkeletonCard.svelte new file mode 100644 index 000000000..32df4cd27 --- /dev/null +++ b/packages/shared-ui/src/molecules/loaders/SkeletonCard.svelte @@ -0,0 +1,69 @@ + + +
+
+ {#if showAvatar} + + {/if} +
+ {#if titleLines > 0} + + {/if} + {#if bodyLines > 0} +
+ +
+ {/if} +
+
+ {#if showFooter} +
+ + +
+ {/if} +
diff --git a/packages/shared-ui/src/molecules/loaders/SkeletonGrid.svelte b/packages/shared-ui/src/molecules/loaders/SkeletonGrid.svelte new file mode 100644 index 000000000..7fbf8249e --- /dev/null +++ b/packages/shared-ui/src/molecules/loaders/SkeletonGrid.svelte @@ -0,0 +1,61 @@ + + +
+ {#each Array(count) as _, i} + + {/each} +
diff --git a/packages/shared-ui/src/molecules/loaders/SkeletonList.svelte b/packages/shared-ui/src/molecules/loaders/SkeletonList.svelte new file mode 100644 index 000000000..0cb3fd30d --- /dev/null +++ b/packages/shared-ui/src/molecules/loaders/SkeletonList.svelte @@ -0,0 +1,52 @@ + + +
+ {#each Array(count) as _, i} + + {/each} +
diff --git a/packages/shared-ui/src/molecules/loaders/SkeletonRow.svelte b/packages/shared-ui/src/molecules/loaders/SkeletonRow.svelte new file mode 100644 index 000000000..5198a993a --- /dev/null +++ b/packages/shared-ui/src/molecules/loaders/SkeletonRow.svelte @@ -0,0 +1,60 @@ + + +
+ {#if showAvatar} + + {/if} +
+ + {#if showSecondaryLine} +
+ +
+ {/if} +
+ {#if showRightContent} + + {/if} +
diff --git a/packages/shared-ui/src/molecules/loaders/index.ts b/packages/shared-ui/src/molecules/loaders/index.ts index 536a53011..4655a92d2 100644 --- a/packages/shared-ui/src/molecules/loaders/index.ts +++ b/packages/shared-ui/src/molecules/loaders/index.ts @@ -1,5 +1,25 @@ /** * Loading state components + * + * Primitives: + * - SkeletonBox: Base rectangular skeleton with shimmer + * - SkeletonText: Multi-line text skeleton + * - SkeletonAvatar: Circular avatar skeleton + * + * Composites: + * - SkeletonRow: Single list row with avatar + text + * - SkeletonList: Multiple rows with fade effect + * - SkeletonCard: Card with avatar, title, body, footer + * - SkeletonGrid: Grid of cards with fade effect */ + +// Primitives export { default as SkeletonBox } from './SkeletonBox.svelte'; export { default as SkeletonText } from './SkeletonText.svelte'; +export { default as SkeletonAvatar } from './SkeletonAvatar.svelte'; + +// Composites +export { default as SkeletonRow } from './SkeletonRow.svelte'; +export { default as SkeletonList } from './SkeletonList.svelte'; +export { default as SkeletonCard } from './SkeletonCard.svelte'; +export { default as SkeletonGrid } from './SkeletonGrid.svelte'; From 1dda43719283c8f81d094acce7ef67389a6bdff4 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Tue, 9 Dec 2025 20:24:22 +0100 Subject: [PATCH 08/68] feat(help): add centralized help system with shared packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add @manacore/shared-help-types with TypeScript types and Zod schemas - Add @manacore/shared-help-content with Markdown parser, content loader, and Fuse.js search - Add @manacore/shared-help-ui with Svelte 5 components (HelpPage, FAQSection, FeaturesOverview, etc.) - Add @manacore/shared-help-mobile with React Native components for Expo apps - Add help translations to shared-i18n (de, en, fr, it, es) - Implement self-contained help page in Contacts app with FAQ, Features, Shortcuts, and Contact sections - Support i18n with German and English content 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../web/src/routes/(app)/help/+page.svelte | 468 +++++++++ .../content/contact/de/support.md | 32 + .../content/contact/en/support.md | 32 + .../content/faq/de/account.md | 21 + .../content/faq/de/billing.md | 21 + .../content/faq/de/privacy.md | 23 + .../content/faq/en/account.md | 21 + .../content/faq/en/billing.md | 21 + .../content/faq/en/privacy.md | 23 + .../content/getting-started/de/welcome.md | 29 + .../content/getting-started/en/welcome.md | 29 + packages/shared-help-content/package.json | 44 + packages/shared-help-content/src/index.ts | 52 + packages/shared-help-content/src/loader.ts | 292 ++++++ packages/shared-help-content/src/merger.ts | 119 +++ packages/shared-help-content/src/parser.ts | 86 ++ packages/shared-help-content/src/search.ts | 209 ++++ packages/shared-help-content/tsconfig.json | 16 + packages/shared-help-mobile/package.json | 24 + .../src/components/CategoryTabs.tsx | 46 + .../src/components/ContactCard.tsx | 71 ++ .../src/components/FAQItem.tsx | 57 ++ .../src/components/FAQList.tsx | 39 + .../src/components/FeatureCard.tsx | 49 + .../src/components/FeaturesList.tsx | 26 + .../src/components/HelpSearchBar.tsx | 42 + .../src/hooks/useHelpContent.ts | 51 + packages/shared-help-mobile/src/index.ts | 32 + .../src/screens/HelpScreen.tsx | 206 ++++ packages/shared-help-mobile/src/types.ts | 95 ++ packages/shared-help-mobile/tsconfig.json | 17 + packages/shared-help-types/package.json | 35 + packages/shared-help-types/src/content.ts | 152 +++ packages/shared-help-types/src/index.ts | 13 + packages/shared-help-types/src/schemas.ts | 130 +++ packages/shared-help-types/src/search.ts | 71 ++ packages/shared-help-types/tsconfig.json | 16 + packages/shared-help-ui/package.json | 65 ++ .../src/components/ChangelogEntry.svelte | 152 +++ .../src/components/ChangelogSection.svelte | 45 + .../src/components/ContactSection.svelte | 123 +++ .../src/components/FAQItem.svelte | 46 + .../src/components/FAQSection.svelte | 117 +++ .../src/components/FeatureCard.svelte | 68 ++ .../src/components/FeaturesOverview.svelte | 50 + .../src/components/GettingStartedGuide.svelte | 111 +++ .../src/components/HelpSearch.svelte | 198 ++++ .../src/components/KeyboardShortcuts.svelte | 54 + packages/shared-help-ui/src/index.ts | 33 + .../shared-help-ui/src/pages/HelpPage.svelte | 169 ++++ packages/shared-help-ui/src/types.ts | 147 +++ packages/shared-help-ui/tsconfig.json | 16 + packages/shared-i18n/src/index.ts | 13 + .../shared-i18n/src/translations/help/de.json | 65 ++ .../shared-i18n/src/translations/help/en.json | 65 ++ .../shared-i18n/src/translations/help/es.json | 65 ++ .../shared-i18n/src/translations/help/fr.json | 65 ++ .../src/translations/help/index.ts | 108 ++ .../shared-i18n/src/translations/help/it.json | 65 ++ pnpm-lock.yaml | 932 ++++++++++++++++++ 60 files changed, 5482 insertions(+) create mode 100644 apps/contacts/apps/web/src/routes/(app)/help/+page.svelte create mode 100644 packages/shared-help-content/content/contact/de/support.md create mode 100644 packages/shared-help-content/content/contact/en/support.md create mode 100644 packages/shared-help-content/content/faq/de/account.md create mode 100644 packages/shared-help-content/content/faq/de/billing.md create mode 100644 packages/shared-help-content/content/faq/de/privacy.md create mode 100644 packages/shared-help-content/content/faq/en/account.md create mode 100644 packages/shared-help-content/content/faq/en/billing.md create mode 100644 packages/shared-help-content/content/faq/en/privacy.md create mode 100644 packages/shared-help-content/content/getting-started/de/welcome.md create mode 100644 packages/shared-help-content/content/getting-started/en/welcome.md create mode 100644 packages/shared-help-content/package.json create mode 100644 packages/shared-help-content/src/index.ts create mode 100644 packages/shared-help-content/src/loader.ts create mode 100644 packages/shared-help-content/src/merger.ts create mode 100644 packages/shared-help-content/src/parser.ts create mode 100644 packages/shared-help-content/src/search.ts create mode 100644 packages/shared-help-content/tsconfig.json create mode 100644 packages/shared-help-mobile/package.json create mode 100644 packages/shared-help-mobile/src/components/CategoryTabs.tsx create mode 100644 packages/shared-help-mobile/src/components/ContactCard.tsx create mode 100644 packages/shared-help-mobile/src/components/FAQItem.tsx create mode 100644 packages/shared-help-mobile/src/components/FAQList.tsx create mode 100644 packages/shared-help-mobile/src/components/FeatureCard.tsx create mode 100644 packages/shared-help-mobile/src/components/FeaturesList.tsx create mode 100644 packages/shared-help-mobile/src/components/HelpSearchBar.tsx create mode 100644 packages/shared-help-mobile/src/hooks/useHelpContent.ts create mode 100644 packages/shared-help-mobile/src/index.ts create mode 100644 packages/shared-help-mobile/src/screens/HelpScreen.tsx create mode 100644 packages/shared-help-mobile/src/types.ts create mode 100644 packages/shared-help-mobile/tsconfig.json create mode 100644 packages/shared-help-types/package.json create mode 100644 packages/shared-help-types/src/content.ts create mode 100644 packages/shared-help-types/src/index.ts create mode 100644 packages/shared-help-types/src/schemas.ts create mode 100644 packages/shared-help-types/src/search.ts create mode 100644 packages/shared-help-types/tsconfig.json create mode 100644 packages/shared-help-ui/package.json create mode 100644 packages/shared-help-ui/src/components/ChangelogEntry.svelte create mode 100644 packages/shared-help-ui/src/components/ChangelogSection.svelte create mode 100644 packages/shared-help-ui/src/components/ContactSection.svelte create mode 100644 packages/shared-help-ui/src/components/FAQItem.svelte create mode 100644 packages/shared-help-ui/src/components/FAQSection.svelte create mode 100644 packages/shared-help-ui/src/components/FeatureCard.svelte create mode 100644 packages/shared-help-ui/src/components/FeaturesOverview.svelte create mode 100644 packages/shared-help-ui/src/components/GettingStartedGuide.svelte create mode 100644 packages/shared-help-ui/src/components/HelpSearch.svelte create mode 100644 packages/shared-help-ui/src/components/KeyboardShortcuts.svelte create mode 100644 packages/shared-help-ui/src/index.ts create mode 100644 packages/shared-help-ui/src/pages/HelpPage.svelte create mode 100644 packages/shared-help-ui/src/types.ts create mode 100644 packages/shared-help-ui/tsconfig.json create mode 100644 packages/shared-i18n/src/translations/help/de.json create mode 100644 packages/shared-i18n/src/translations/help/en.json create mode 100644 packages/shared-i18n/src/translations/help/es.json create mode 100644 packages/shared-i18n/src/translations/help/fr.json create mode 100644 packages/shared-i18n/src/translations/help/index.ts create mode 100644 packages/shared-i18n/src/translations/help/it.json diff --git a/apps/contacts/apps/web/src/routes/(app)/help/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/help/+page.svelte new file mode 100644 index 000000000..2303da0f2 --- /dev/null +++ b/apps/contacts/apps/web/src/routes/(app)/help/+page.svelte @@ -0,0 +1,468 @@ + + + + {t.title} | Contacts + + +
+ +
+ + +

+ {t.title} +

+

+ {t.subtitle} +

+
+ + +
+ +
+ + + +
+
+ + +
+ +
+ + +
+ + {#if activeSection === 'faq'} +
+ {#each filteredFaqs as faq (faq.id)} +
+ + + {#if expandedFaqId === faq.id} +
+ {faq.answer} +
+ {/if} +
+ {/each} + + {#if filteredFaqs.length === 0} +

+ {$locale === 'de' ? 'Keine Ergebnisse gefunden' : 'No results found'} +

+ {/if} +
+ {/if} + + + {#if activeSection === 'features'} +
+ {#each features as feature} +
+
+ {feature.icon} +

+ {feature.title} +

+
+ +

+ {feature.description} +

+ +
    + {#each feature.highlights as highlight} +
  • + + + + {highlight} +
  • + {/each} +
+
+ {/each} +
+ {/if} + + + {#if activeSection === 'shortcuts'} +
+ + + + + + + + + {#each shortcuts as shortcut} + + + + + {/each} + +
Shortcut + {$locale === 'de' ? 'Aktion' : 'Action'} +
+ + {shortcut.shortcut} + + + {shortcut.action} +
+
+ {/if} + + + {#if activeSection === 'contact'} +
+
+

+ {t.contactTitle} +

+

+ {t.contactDescription} +

+
+ +
+ +
+ + + +
+
+

+ {t.email} +

+

support@manacore.app

+
+
+ +
+
+ + + +
+
+

+ {$locale === 'de' ? 'Antwortzeit' : 'Response Time'} +

+

+ {t.responseTime} +

+
+
+
+
+ {/if} +
+
diff --git a/packages/shared-help-content/content/contact/de/support.md b/packages/shared-help-content/content/contact/de/support.md new file mode 100644 index 000000000..51f233a5f --- /dev/null +++ b/packages/shared-help-content/content/contact/de/support.md @@ -0,0 +1,32 @@ +--- +id: contact-support +title: Support kontaktieren +language: de +order: 1 +supportEmail: support@manacore.app +responseTime: In der Regel innerhalb von 24 Stunden +--- + +## Brauchst du Hilfe? + +Unser Support-Team hilft dir bei allen Fragen oder Problemen. + +### Bevor du uns kontaktierst + +- Schau in den **FAQ**-Bereich für schnelle Antworten +- Durchstöbere unsere **Erste Schritte** Anleitungen +- Nutze die Suchfunktion im Hilfe-Center + +### Kontaktmöglichkeiten + +- **E-Mail**: Schreib uns an support@manacore.app +- **Antwortzeit**: Wir antworten in der Regel innerhalb von 24 Stunden an Werktagen + +### Was du angeben solltest + +Wenn du den Support kontaktierst, gib bitte an: + +1. Deine Konto-E-Mail-Adresse +2. Eine klare Beschreibung deines Problems +3. Schritte zur Reproduktion (falls zutreffend) +4. Screenshots, wenn sie bei der Erklärung helfen diff --git a/packages/shared-help-content/content/contact/en/support.md b/packages/shared-help-content/content/contact/en/support.md new file mode 100644 index 000000000..0ef4a01b8 --- /dev/null +++ b/packages/shared-help-content/content/contact/en/support.md @@ -0,0 +1,32 @@ +--- +id: contact-support +title: Contact Support +language: en +order: 1 +supportEmail: support@manacore.app +responseTime: Usually within 24 hours +--- + +## Need Help? + +Our support team is here to help you with any questions or issues. + +### Before Contacting Us + +- Check the **FAQ** section for quick answers +- Browse our **Getting Started** guides +- Search the help center using the search bar + +### Contact Options + +- **Email**: Send us a message at support@manacore.app +- **Response Time**: We typically respond within 24 hours on business days + +### What to Include + +When contacting support, please include: + +1. Your account email address +2. A clear description of your issue +3. Steps to reproduce the problem (if applicable) +4. Screenshots if they help explain the issue diff --git a/packages/shared-help-content/content/faq/de/account.md b/packages/shared-help-content/content/faq/de/account.md new file mode 100644 index 000000000..3926cab91 --- /dev/null +++ b/packages/shared-help-content/content/faq/de/account.md @@ -0,0 +1,21 @@ +--- +id: faq-account-001 +question: Wie erstelle ich ein Konto? +category: account +order: 1 +language: de +featured: true +tags: + - konto + - registrierung + - anmeldung +--- + +Die Kontoerstellung ist einfach: + +1. Klicke auf **Registrieren** auf der Anmeldeseite +2. Gib deine E-Mail-Adresse ein und wähle ein sicheres Passwort +3. Bestätige deine E-Mail-Adresse durch Klick auf den Link, den wir dir senden +4. Vervollständige dein Profil + +Du kannst dich auch mit deinem Google- oder Apple-Konto registrieren, um schneller loszulegen. diff --git a/packages/shared-help-content/content/faq/de/billing.md b/packages/shared-help-content/content/faq/de/billing.md new file mode 100644 index 000000000..62ba0aa1d --- /dev/null +++ b/packages/shared-help-content/content/faq/de/billing.md @@ -0,0 +1,21 @@ +--- +id: faq-billing-001 +question: Wie kann ich mein Abo kündigen? +category: billing +order: 1 +language: de +featured: true +tags: + - abo + - kündigung + - abrechnung +--- + +Du kannst dein Abo jederzeit kündigen: + +1. Gehe zu **Einstellungen** > **Abonnement** +2. Klicke auf **Abo verwalten** +3. Wähle **Abo kündigen** +4. Bestätige die Kündigung + +Dein Abo bleibt bis zum Ende des aktuellen Abrechnungszeitraums aktiv. Nach der Kündigung erfolgen keine weiteren Abbuchungen. diff --git a/packages/shared-help-content/content/faq/de/privacy.md b/packages/shared-help-content/content/faq/de/privacy.md new file mode 100644 index 000000000..cb85841af --- /dev/null +++ b/packages/shared-help-content/content/faq/de/privacy.md @@ -0,0 +1,23 @@ +--- +id: faq-privacy-001 +question: Wie werden meine Daten geschützt? +category: privacy +order: 1 +language: de +featured: true +tags: + - datenschutz + - daten + - sicherheit + - dsgvo +--- + +Wir nehmen deinen Datenschutz ernst: + +- **Verschlüsselung**: Alle Daten werden bei der Übertragung (TLS) und im Ruhezustand verschlüsselt +- **DSGVO-konform**: Wir halten uns an die EU-Datenschutzverordnung +- **Kein Datenverkauf**: Wir verkaufen niemals deine persönlichen Daten an Dritte +- **Datenexport**: Du kannst jederzeit alle deine Daten exportieren +- **Kontolöschung**: Du kannst dein Konto und alle zugehörigen Daten dauerhaft löschen + +Weitere Details findest du in unserer [Datenschutzerklärung](/privacy). diff --git a/packages/shared-help-content/content/faq/en/account.md b/packages/shared-help-content/content/faq/en/account.md new file mode 100644 index 000000000..b67f58c89 --- /dev/null +++ b/packages/shared-help-content/content/faq/en/account.md @@ -0,0 +1,21 @@ +--- +id: faq-account-001 +question: How do I create an account? +category: account +order: 1 +language: en +featured: true +tags: + - account + - registration + - signup +--- + +Creating an account is simple: + +1. Click the **Sign Up** button on the login page +2. Enter your email address and choose a secure password +3. Verify your email address by clicking the link we send you +4. Complete your profile setup + +You can also sign up using your Google or Apple account for faster registration. diff --git a/packages/shared-help-content/content/faq/en/billing.md b/packages/shared-help-content/content/faq/en/billing.md new file mode 100644 index 000000000..3ce9cc52b --- /dev/null +++ b/packages/shared-help-content/content/faq/en/billing.md @@ -0,0 +1,21 @@ +--- +id: faq-billing-001 +question: How do I cancel my subscription? +category: billing +order: 1 +language: en +featured: true +tags: + - subscription + - cancel + - billing +--- + +You can cancel your subscription at any time: + +1. Go to **Settings** > **Subscription** +2. Click **Manage Subscription** +3. Select **Cancel Subscription** +4. Confirm your cancellation + +Your subscription will remain active until the end of the current billing period. You won't be charged again after cancellation. diff --git a/packages/shared-help-content/content/faq/en/privacy.md b/packages/shared-help-content/content/faq/en/privacy.md new file mode 100644 index 000000000..7774b697b --- /dev/null +++ b/packages/shared-help-content/content/faq/en/privacy.md @@ -0,0 +1,23 @@ +--- +id: faq-privacy-001 +question: How is my data protected? +category: privacy +order: 1 +language: en +featured: true +tags: + - privacy + - data + - security + - gdpr +--- + +We take your privacy seriously: + +- **Encryption**: All data is encrypted in transit (TLS) and at rest +- **GDPR Compliant**: We follow EU data protection regulations +- **No Data Selling**: We never sell your personal data to third parties +- **Data Export**: You can export all your data at any time +- **Account Deletion**: You can permanently delete your account and all associated data + +For more details, please read our [Privacy Policy](/privacy). diff --git a/packages/shared-help-content/content/getting-started/de/welcome.md b/packages/shared-help-content/content/getting-started/de/welcome.md new file mode 100644 index 000000000..81f8847c1 --- /dev/null +++ b/packages/shared-help-content/content/getting-started/de/welcome.md @@ -0,0 +1,29 @@ +--- +id: guide-welcome +title: Erste Schritte +description: Lerne die Grundlagen und starte schnell durch +difficulty: beginner +estimatedTime: 5 Minuten +order: 1 +language: de +--- + +## Konto erstellen + +Beginne mit der Erstellung deines kostenlosen Kontos. Du kannst dich mit deiner E-Mail-Adresse registrieren oder Google/Apple für eine schnellere Anmeldung nutzen. + +## Dashboard erkunden + +Nach dem Einloggen siehst du dein Dashboard. Dies ist deine Zentrale, von der aus du auf alle Funktionen zugreifen und wichtige Informationen auf einen Blick sehen kannst. + +## Einstellungen anpassen + +Besuche die Einstellungen, um dein Erlebnis zu personalisieren. Du kannst anpassen: + +- **Design**: Wähle zwischen hell, dunkel oder Systemmodus +- **Sprache**: Wähle deine bevorzugte Sprache +- **Benachrichtigungen**: Konfiguriere, wie du benachrichtigt werden möchtest + +## Hilfe jederzeit verfügbar + +Wenn du Hilfe brauchst, klicke auf das Hilfe-Symbol oder besuche den Hilfe-Bereich. Du kannst auch unser Support-Team kontaktieren, wenn du Fragen hast. diff --git a/packages/shared-help-content/content/getting-started/en/welcome.md b/packages/shared-help-content/content/getting-started/en/welcome.md new file mode 100644 index 000000000..41c1877dc --- /dev/null +++ b/packages/shared-help-content/content/getting-started/en/welcome.md @@ -0,0 +1,29 @@ +--- +id: guide-welcome +title: Getting Started +description: Learn the basics and get up and running quickly +difficulty: beginner +estimatedTime: 5 minutes +order: 1 +language: en +--- + +## Create Your Account + +Start by creating your free account. You can sign up with your email address or use Google/Apple sign-in for a faster setup. + +## Explore the Dashboard + +After logging in, you'll see your dashboard. This is your home base where you can access all features and see important information at a glance. + +## Customize Your Settings + +Visit the Settings page to personalize your experience. You can adjust: + +- **Theme**: Choose between light, dark, or system mode +- **Language**: Select your preferred language +- **Notifications**: Configure how you want to be notified + +## Get Help Anytime + +If you need assistance, click the help icon or visit the Help section. You can also reach out to our support team if you have questions. diff --git a/packages/shared-help-content/package.json b/packages/shared-help-content/package.json new file mode 100644 index 000000000..fe90991f9 --- /dev/null +++ b/packages/shared-help-content/package.json @@ -0,0 +1,44 @@ +{ + "name": "@manacore/shared-help-content", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./loader": { + "types": "./src/loader.ts", + "default": "./src/loader.ts" + }, + "./parser": { + "types": "./src/parser.ts", + "default": "./src/parser.ts" + }, + "./search": { + "types": "./src/search.ts", + "default": "./src/search.ts" + }, + "./merger": { + "types": "./src/merger.ts", + "default": "./src/merger.ts" + } + }, + "scripts": { + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@manacore/shared-help-types": "workspace:*", + "fuse.js": "^7.0.0", + "gray-matter": "^4.0.3", + "marked": "^15.0.4" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "typescript": "^5.7.3", + "zod": "^3.24.1" + } +} diff --git a/packages/shared-help-content/src/index.ts b/packages/shared-help-content/src/index.ts new file mode 100644 index 000000000..3eedabeeb --- /dev/null +++ b/packages/shared-help-content/src/index.ts @@ -0,0 +1,52 @@ +/** + * @manacore/shared-help-content + * Central help content and utilities for loading, parsing, and searching + */ + +// Parser utilities +export { + parseMarkdown, + parseMarkdownFiles, + stripHtml, + generateExcerpt, + type ParsedContent, + type ParseOptions, +} from './parser.js'; + +// Content loader +export { + parseFAQContent, + parseFeatureContent, + parseShortcutsContent, + parseGettingStartedContent, + parseChangelogContent, + parseContactContent, + loadHelpContentFromFiles, + type LoaderOptions, +} from './loader.js'; + +// Content merger +export { mergeContent, createEmptyContent } from './merger.js'; + +// Search functionality +export { buildSearchIndex, search, createSearcher, flattenContentForSearch } from './search.js'; + +// Re-export types for convenience +export type { + HelpContent, + FAQItem, + FeatureItem, + ShortcutsItem, + GettingStartedItem, + ChangelogItem, + ContactInfo, + SupportedLanguage, + MergeContentOptions, +} from '@manacore/shared-help-types'; + +export type { + SearchResult, + SearchOptions, + SearchIndexConfig, + SearchableItem, +} from '@manacore/shared-help-types'; diff --git a/packages/shared-help-content/src/loader.ts b/packages/shared-help-content/src/loader.ts new file mode 100644 index 000000000..fce836ef4 --- /dev/null +++ b/packages/shared-help-content/src/loader.ts @@ -0,0 +1,292 @@ +/** + * Content Loader + * Utilities for loading help content from various sources + */ + +import type { + HelpContent, + FAQItem, + FeatureItem, + ShortcutsItem, + GettingStartedItem, + ChangelogItem, + ContactInfo, + SupportedLanguage, +} from '@manacore/shared-help-types'; +import { + faqFrontmatterSchema, + featureFrontmatterSchema, + shortcutsFrontmatterSchema, + gettingStartedFrontmatterSchema, + changelogFrontmatterSchema, + contactFrontmatterSchema, +} from '@manacore/shared-help-types'; +import { parseMarkdown } from './parser.js'; +import { createEmptyContent } from './merger.js'; + +export interface LoaderOptions { + /** Locale to load */ + locale: SupportedLanguage; + /** Fallback locale if content not found */ + fallbackLocale?: SupportedLanguage; +} + +/** + * Parse FAQ content from raw Markdown + */ +export function parseFAQContent(rawContent: string): FAQItem { + const parsed = parseMarkdown(rawContent, faqFrontmatterSchema); + const fm = parsed.frontmatter as Record; + return { + id: fm.id as string, + language: fm.language as SupportedLanguage, + order: fm.order as number | undefined, + appSpecific: fm.appSpecific as boolean | undefined, + apps: fm.apps as string[] | undefined, + lastUpdated: fm.lastUpdated as Date | undefined, + question: fm.question as string, + category: fm.category as FAQItem['category'], + featured: fm.featured as boolean | undefined, + tags: fm.tags as string[] | undefined, + relatedFaqs: fm.relatedFaqs as string[] | undefined, + answer: parsed.html, + }; +} + +/** + * Parse Feature content from raw Markdown + */ +export function parseFeatureContent(rawContent: string): FeatureItem { + const parsed = parseMarkdown(rawContent, featureFrontmatterSchema); + const fm = parsed.frontmatter as Record; + return { + id: fm.id as string, + language: fm.language as SupportedLanguage, + order: fm.order as number | undefined, + appSpecific: fm.appSpecific as boolean | undefined, + apps: fm.apps as string[] | undefined, + lastUpdated: fm.lastUpdated as Date | undefined, + title: fm.title as string, + description: fm.description as string, + icon: fm.icon as string | undefined, + category: fm.category as FeatureItem['category'], + available: fm.available as boolean | undefined, + comingSoon: fm.comingSoon as boolean | undefined, + highlights: fm.highlights as string[] | undefined, + learnMoreUrl: fm.learnMoreUrl as string | undefined, + content: parsed.html, + }; +} + +/** + * Parse Shortcuts content from raw Markdown + */ +export function parseShortcutsContent(rawContent: string): ShortcutsItem { + const parsed = parseMarkdown(rawContent, shortcutsFrontmatterSchema); + const fm = parsed.frontmatter as Record; + + // Parse markdown table to extract shortcuts + const shortcuts = parseShortcutsTable(parsed.content); + + return { + id: fm.id as string, + language: fm.language as SupportedLanguage, + order: fm.order as number | undefined, + appSpecific: fm.appSpecific as boolean | undefined, + apps: fm.apps as string[] | undefined, + lastUpdated: fm.lastUpdated as Date | undefined, + category: fm.category as ShortcutsItem['category'], + title: fm.title as string | undefined, + shortcuts, + }; +} + +/** + * Parse a markdown table into keyboard shortcuts + */ +function parseShortcutsTable( + content: string +): Array<{ shortcut: string; action: string; description?: string }> { + const shortcuts: Array<{ shortcut: string; action: string; description?: string }> = []; + const lines = content.split('\n'); + + let inTable = false; + for (const line of lines) { + const trimmed = line.trim(); + + // Skip header separator + if (trimmed.match(/^\|[-:\s|]+\|$/)) { + inTable = true; + continue; + } + + // Parse table row + if (inTable && trimmed.startsWith('|') && trimmed.endsWith('|')) { + const cells = trimmed + .slice(1, -1) + .split('|') + .map((cell) => cell.trim()); + + if (cells.length >= 2) { + shortcuts.push({ + shortcut: cells[0], + action: cells[1], + description: cells[2] || undefined, + }); + } + } else if (inTable && !trimmed.startsWith('|')) { + // End of table + break; + } + } + + return shortcuts; +} + +/** + * Parse Getting Started guide content from raw Markdown + */ +export function parseGettingStartedContent(rawContent: string): GettingStartedItem { + const parsed = parseMarkdown(rawContent, gettingStartedFrontmatterSchema); + const fm = parsed.frontmatter as Record; + + // Extract steps from content (h2 headers) + const steps = parseGuideSteps(parsed.content); + + return { + id: fm.id as string, + language: fm.language as SupportedLanguage, + order: fm.order as number | undefined, + appSpecific: fm.appSpecific as boolean | undefined, + apps: fm.apps as string[] | undefined, + lastUpdated: fm.lastUpdated as Date | undefined, + title: fm.title as string, + description: fm.description as string, + difficulty: fm.difficulty as GettingStartedItem['difficulty'], + estimatedTime: fm.estimatedTime as string | undefined, + prerequisites: fm.prerequisites as string[] | undefined, + content: parsed.html, + steps, + }; +} + +/** + * Parse guide steps from markdown content (h2 headers) + */ +function parseGuideSteps(content: string): Array<{ title: string; content: string }> { + const steps: Array<{ title: string; content: string }> = []; + const sections = content.split(/^## /m); + + for (let i = 1; i < sections.length; i++) { + const section = sections[i]; + const newlineIndex = section.indexOf('\n'); + const title = section.substring(0, newlineIndex).trim(); + const stepContent = section.substring(newlineIndex + 1).trim(); + + steps.push({ title, content: stepContent }); + } + + return steps; +} + +/** + * Parse Changelog content from raw Markdown + */ +export function parseChangelogContent(rawContent: string): ChangelogItem { + const parsed = parseMarkdown(rawContent, changelogFrontmatterSchema); + const fm = parsed.frontmatter as Record; + return { + id: fm.id as string, + language: fm.language as SupportedLanguage, + order: fm.order as number | undefined, + appSpecific: fm.appSpecific as boolean | undefined, + apps: fm.apps as string[] | undefined, + lastUpdated: fm.lastUpdated as Date | undefined, + version: fm.version as string, + title: fm.title as string, + releaseDate: fm.releaseDate as Date, + type: fm.type as ChangelogItem['type'], + summary: fm.summary as string | undefined, + highlighted: fm.highlighted as boolean | undefined, + changes: fm.changes as ChangelogItem['changes'], + platforms: fm.platforms as string[] | undefined, + content: parsed.html, + }; +} + +/** + * Parse Contact content from raw Markdown + */ +export function parseContactContent(rawContent: string): ContactInfo { + const parsed = parseMarkdown(rawContent, contactFrontmatterSchema); + const fm = parsed.frontmatter as Record; + return { + id: fm.id as string, + language: fm.language as SupportedLanguage, + order: fm.order as number | undefined, + appSpecific: fm.appSpecific as boolean | undefined, + apps: fm.apps as string[] | undefined, + lastUpdated: fm.lastUpdated as Date | undefined, + title: fm.title as string, + supportEmail: fm.supportEmail as string | undefined, + supportUrl: fm.supportUrl as string | undefined, + discordUrl: fm.discordUrl as string | undefined, + twitterUrl: fm.twitterUrl as string | undefined, + documentationUrl: fm.documentationUrl as string | undefined, + responseTime: fm.responseTime as string | undefined, + content: parsed.html, + }; +} + +/** + * Load help content from a map of file paths to content + * This is the main entry point for content loading + */ +export function loadHelpContentFromFiles( + files: Record, + options: LoaderOptions +): HelpContent { + const content = createEmptyContent(); + const { locale, fallbackLocale = 'en' } = options; + + for (const [path, rawContent] of Object.entries(files)) { + try { + // Determine content type from path + if (path.includes('/faq/')) { + const faq = parseFAQContent(rawContent); + if (faq.language === locale || faq.language === fallbackLocale) { + content.faq.push(faq); + } + } else if (path.includes('/features/')) { + const feature = parseFeatureContent(rawContent); + if (feature.language === locale || feature.language === fallbackLocale) { + content.features.push(feature); + } + } else if (path.includes('/shortcuts/')) { + const shortcuts = parseShortcutsContent(rawContent); + if (shortcuts.language === locale || shortcuts.language === fallbackLocale) { + content.shortcuts.push(shortcuts); + } + } else if (path.includes('/getting-started/')) { + const guide = parseGettingStartedContent(rawContent); + if (guide.language === locale || guide.language === fallbackLocale) { + content.gettingStarted.push(guide); + } + } else if (path.includes('/changelog/')) { + const changelog = parseChangelogContent(rawContent); + if (changelog.language === locale || changelog.language === fallbackLocale) { + content.changelog.push(changelog); + } + } else if (path.includes('/contact/')) { + const contact = parseContactContent(rawContent); + if (contact.language === locale || contact.language === fallbackLocale) { + content.contact = contact; + } + } + } catch { + // Skip files that fail to parse + } + } + + return content; +} diff --git a/packages/shared-help-content/src/merger.ts b/packages/shared-help-content/src/merger.ts new file mode 100644 index 000000000..6109955b8 --- /dev/null +++ b/packages/shared-help-content/src/merger.ts @@ -0,0 +1,119 @@ +/** + * Content Merger + * Merges central help content with app-specific content + */ + +import type { HelpContent, MergeContentOptions } from '@manacore/shared-help-types'; + +/** + * Filter content items by locale and app + */ +function filterItems( + items: T[], + locale: string, + appId: string +): T[] { + return items.filter((item) => { + // Filter by language + if (item.language !== locale) { + return false; + } + + // Include non-app-specific items + if (!item.appSpecific) { + return true; + } + + // Include app-specific items for this app + return item.apps?.includes(appId) ?? false; + }); +} + +/** + * Merge two arrays, optionally replacing items with same ID + */ +function mergeArrays( + central: T[], + appSpecific: T[], + overrideById: boolean +): T[] { + if (!overrideById) { + return [...central, ...appSpecific]; + } + + const appIds = new Set(appSpecific.map((item) => item.id)); + const filtered = central.filter((item) => !appIds.has(item.id)); + return [...filtered, ...appSpecific]; +} + +/** + * Sort items by order property + */ +function sortByOrder(items: T[]): T[] { + return [...items].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); +} + +/** + * Merge central help content with app-specific content + */ +export function mergeContent( + central: HelpContent, + appSpecific: Partial, + options: MergeContentOptions +): HelpContent { + const { appId, locale, overrideById = true } = options; + + // Filter central content by locale and app + const filteredCentral: HelpContent = { + faq: filterItems(central.faq, locale, appId), + features: filterItems(central.features, locale, appId), + shortcuts: filterItems(central.shortcuts, locale, appId), + gettingStarted: filterItems(central.gettingStarted, locale, appId), + changelog: filterItems(central.changelog, locale, appId), + contact: central.contact?.language === locale ? central.contact : null, + }; + + // Filter app-specific content + const filteredApp: Partial = { + faq: appSpecific.faq ? filterItems(appSpecific.faq, locale, appId) : [], + features: appSpecific.features ? filterItems(appSpecific.features, locale, appId) : [], + shortcuts: appSpecific.shortcuts ? filterItems(appSpecific.shortcuts, locale, appId) : [], + gettingStarted: appSpecific.gettingStarted + ? filterItems(appSpecific.gettingStarted, locale, appId) + : [], + changelog: appSpecific.changelog ? filterItems(appSpecific.changelog, locale, appId) : [], + contact: appSpecific.contact?.language === locale ? appSpecific.contact : null, + }; + + // Merge and sort + return { + faq: sortByOrder(mergeArrays(filteredCentral.faq, filteredApp.faq ?? [], overrideById)), + features: sortByOrder( + mergeArrays(filteredCentral.features, filteredApp.features ?? [], overrideById) + ), + shortcuts: sortByOrder( + mergeArrays(filteredCentral.shortcuts, filteredApp.shortcuts ?? [], overrideById) + ), + gettingStarted: sortByOrder( + mergeArrays(filteredCentral.gettingStarted, filteredApp.gettingStarted ?? [], overrideById) + ), + changelog: sortByOrder( + mergeArrays(filteredCentral.changelog, filteredApp.changelog ?? [], overrideById) + ), + contact: filteredApp.contact ?? filteredCentral.contact, + }; +} + +/** + * Create an empty HelpContent object + */ +export function createEmptyContent(): HelpContent { + return { + faq: [], + features: [], + shortcuts: [], + gettingStarted: [], + changelog: [], + contact: null, + }; +} diff --git a/packages/shared-help-content/src/parser.ts b/packages/shared-help-content/src/parser.ts new file mode 100644 index 000000000..ce0780405 --- /dev/null +++ b/packages/shared-help-content/src/parser.ts @@ -0,0 +1,86 @@ +/** + * Markdown + Frontmatter Parser + * Parses Markdown files with YAML frontmatter + */ + +import matter from 'gray-matter'; +import { marked } from 'marked'; +import type { ZodSchema } from 'zod'; + +export interface ParsedContent { + frontmatter: T; + content: string; + html: string; +} + +export interface ParseOptions { + /** Convert Markdown to HTML */ + renderHtml?: boolean; +} + +/** + * Parse a Markdown file with frontmatter + */ +export function parseMarkdown( + rawContent: string, + schema?: ZodSchema, + options: ParseOptions = { renderHtml: true } +): ParsedContent { + const { data, content } = matter(rawContent); + + // Validate frontmatter if schema provided + let frontmatter: T; + if (schema) { + const result = schema.safeParse(data); + if (!result.success) { + throw new Error(`Invalid frontmatter: ${result.error.message}`); + } + frontmatter = result.data; + } else { + frontmatter = data as T; + } + + // Render HTML if requested + const html = options.renderHtml ? (marked.parse(content) as string) : ''; + + return { + frontmatter, + content: content.trim(), + html, + }; +} + +/** + * Parse multiple Markdown files + */ +export function parseMarkdownFiles( + files: { filename: string; content: string }[], + schema?: ZodSchema, + options?: ParseOptions +): Array & { filename: string }> { + return files.map(({ filename, content }) => ({ + filename, + ...parseMarkdown(content, schema, options), + })); +} + +/** + * Extract text content from HTML (for search indexing) + */ +export function stripHtml(html: string): string { + return html + .replace(/<[^>]*>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * Generate excerpt from content + */ +export function generateExcerpt(content: string, maxLength = 150): string { + const text = stripHtml(content); + if (text.length <= maxLength) { + return text; + } + return text.substring(0, maxLength).trim() + '...'; +} diff --git a/packages/shared-help-content/src/search.ts b/packages/shared-help-content/src/search.ts new file mode 100644 index 000000000..4519aa5ae --- /dev/null +++ b/packages/shared-help-content/src/search.ts @@ -0,0 +1,209 @@ +/** + * Search Functionality using Fuse.js + * Provides full-text search across help content + */ + +import Fuse, { type IFuseOptions } from 'fuse.js'; +import type { + HelpContent, + FAQItem, + FeatureItem, + GettingStartedItem, + ChangelogItem, +} from '@manacore/shared-help-types'; +import type { + SearchableItem, + SearchResult, + SearchOptions, + SearchIndexConfig, +} from '@manacore/shared-help-types'; +import { generateExcerpt, stripHtml } from './parser.js'; + +const DEFAULT_CONFIG: SearchIndexConfig = { + titleWeight: 2, + contentWeight: 1, + tagsWeight: 1.5, + threshold: 0.3, + minMatchCharLength: 2, +}; + +/** + * Convert HelpContent to searchable items + */ +export function flattenContentForSearch(content: HelpContent): SearchableItem[] { + const items: SearchableItem[] = []; + + // FAQs + for (const faq of content.faq) { + items.push({ + id: faq.id, + type: 'faq', + title: faq.question, + question: faq.question, + content: stripHtml(faq.answer), + tags: faq.tags, + }); + } + + // Features + for (const feature of content.features) { + items.push({ + id: feature.id, + type: 'feature', + title: feature.title, + description: feature.description, + content: stripHtml(feature.content), + tags: feature.highlights, + }); + } + + // Getting Started Guides + for (const guide of content.gettingStarted) { + items.push({ + id: guide.id, + type: 'guide', + title: guide.title, + description: guide.description, + content: stripHtml(guide.content), + }); + } + + // Changelog + for (const log of content.changelog) { + items.push({ + id: log.id, + type: 'changelog', + title: `${log.version} - ${log.title}`, + content: stripHtml(log.content), + description: log.summary, + }); + } + + return items; +} + +/** + * Build a Fuse.js search index from help content + */ +export function buildSearchIndex( + content: HelpContent, + config: SearchIndexConfig = DEFAULT_CONFIG +): Fuse { + const items = flattenContentForSearch(content); + + const fuseOptions: IFuseOptions = { + keys: [ + { name: 'title', weight: config.titleWeight ?? 2 }, + { name: 'question', weight: config.titleWeight ?? 2 }, + { name: 'content', weight: config.contentWeight ?? 1 }, + { name: 'description', weight: config.contentWeight ?? 1 }, + { name: 'tags', weight: config.tagsWeight ?? 1.5 }, + ], + threshold: config.threshold ?? 0.3, + includeScore: true, + minMatchCharLength: config.minMatchCharLength ?? 2, + ignoreLocation: true, + }; + + return new Fuse(items, fuseOptions); +} + +/** + * Find the original item from content + */ +function findOriginalItem( + id: string, + type: string, + content: HelpContent +): FAQItem | FeatureItem | GettingStartedItem | ChangelogItem | null { + switch (type) { + case 'faq': + return content.faq.find((item) => item.id === id) ?? null; + case 'feature': + return content.features.find((item) => item.id === id) ?? null; + case 'guide': + return content.gettingStarted.find((item) => item.id === id) ?? null; + case 'changelog': + return content.changelog.find((item) => item.id === id) ?? null; + default: + return null; + } +} + +/** + * Highlight matching text in content + */ +function highlightMatch(text: string, query: string): string { + if (!query.trim()) return text; + const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'); + return text.replace(regex, '$1'); +} + +/** + * Search help content + */ +export function search( + index: Fuse, + query: string, + content: HelpContent, + options: SearchOptions = {} +): SearchResult[] { + const { limit = 10, threshold, types, appId } = options; + + if (!query.trim()) { + return []; + } + + let results = index.search(query, { limit: limit * 2 }); + + // Filter by type if specified + if (types && types.length > 0) { + results = results.filter((r) => types.includes(r.item.type)); + } + + // Filter by app if specified + if (appId) { + results = results.filter((r) => { + const originalItem = findOriginalItem(r.item.id, r.item.type, content); + if (!originalItem) return true; + if (!originalItem.appSpecific) return true; + return originalItem.apps?.includes(appId); + }); + } + + // Apply threshold filter if specified + if (threshold !== undefined) { + results = results.filter((r) => (r.score ?? 1) <= threshold); + } + + // Limit results + results = results.slice(0, limit); + + const mappedResults: SearchResult[] = []; + + for (const result of results) { + const originalItem = findOriginalItem(result.item.id, result.item.type, content); + if (!originalItem) continue; + + mappedResults.push({ + id: result.item.id, + type: result.item.type, + title: result.item.title, + excerpt: generateExcerpt(result.item.content, 150), + score: result.score ?? 1, + highlight: highlightMatch(result.item.title, query), + item: originalItem, + }); + } + + return mappedResults; +} + +/** + * Create a search function with pre-built index + */ +export function createSearcher(content: HelpContent, config?: SearchIndexConfig) { + const index = buildSearchIndex(content, config); + + return (query: string, options?: SearchOptions) => search(index, query, content, options); +} diff --git a/packages/shared-help-content/tsconfig.json b/packages/shared-help-content/tsconfig.json new file mode 100644 index 000000000..1b9470b61 --- /dev/null +++ b/packages/shared-help-content/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "verbatimModuleSyntax": true, + "noEmit": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/packages/shared-help-mobile/package.json b/packages/shared-help-mobile/package.json new file mode 100644 index 000000000..39a884fd3 --- /dev/null +++ b/packages/shared-help-mobile/package.json @@ -0,0 +1,24 @@ +{ + "name": "@manacore/shared-help-mobile", + "version": "1.0.0", + "private": true, + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "type-check": "echo 'Skipping type-check: @manacore/shared-help-mobile requires React Native environment'" + }, + "dependencies": { + "@manacore/shared-help-types": "workspace:*", + "@manacore/shared-help-content": "workspace:*" + }, + "devDependencies": { + "@types/react": "~18.3.12", + "typescript": "~5.8.3" + }, + "peerDependencies": { + "expo": ">=52.0.0", + "nativewind": "^4.0.0", + "react": "18.3.1", + "react-native": ">=0.76.0" + } +} diff --git a/packages/shared-help-mobile/src/components/CategoryTabs.tsx b/packages/shared-help-mobile/src/components/CategoryTabs.tsx new file mode 100644 index 000000000..b61a21875 --- /dev/null +++ b/packages/shared-help-mobile/src/components/CategoryTabs.tsx @@ -0,0 +1,46 @@ +/** + * Category Tabs component for mobile Help screen + */ + +import React from 'react'; +import { Text, TouchableOpacity, ScrollView } from 'react-native'; +import type { HelpSection } from '../types'; + +interface CategoryTabsProps { + sections: Array<{ id: HelpSection; label: string; show: boolean }>; + activeSection: HelpSection; + onSectionChange: (section: HelpSection) => void; +} + +export function CategoryTabs({ sections, activeSection, onSectionChange }: CategoryTabsProps) { + const visibleSections = sections.filter((s) => s.show); + + return ( + + {visibleSections.map((section) => ( + onSectionChange(section.id)} + className={`px-4 py-2 mr-2 rounded-full ${ + activeSection === section.id + ? 'bg-blue-500 dark:bg-blue-600' + : 'bg-gray-100 dark:bg-gray-800' + }`} + > + + {section.label} + + + ))} + + ); +} diff --git a/packages/shared-help-mobile/src/components/ContactCard.tsx b/packages/shared-help-mobile/src/components/ContactCard.tsx new file mode 100644 index 000000000..bd608dfd6 --- /dev/null +++ b/packages/shared-help-mobile/src/components/ContactCard.tsx @@ -0,0 +1,71 @@ +/** + * Contact Card component for mobile + */ + +import React from 'react'; +import { View, Text, TouchableOpacity, Linking } from 'react-native'; +import type { ContactInfo } from '@manacore/shared-help-types'; +import type { HelpTranslations } from '../types'; + +interface ContactCardProps { + contact: ContactInfo | null; + translations: Pick; +} + +export function ContactCard({ contact, translations }: ContactCardProps) { + if (!contact) { + return ( + + {translations.contact.noInfo} + + ); + } + + function handleEmailPress() { + if (contact.supportEmail) { + Linking.openURL(`mailto:${contact.supportEmail}`); + } + } + + // Strip HTML tags for mobile display + const plainContent = contact.content.replace(/<[^>]*>/g, '').trim(); + + return ( + + {plainContent && ( + + {plainContent} + + )} + + {contact.supportEmail && ( + + + ✉️ + + + + {translations.contact.email} + + {contact.supportEmail} + + + )} + + {contact.responseTime && ( + + + ⏱️ + + + Response Time + {contact.responseTime} + + + )} + + ); +} diff --git a/packages/shared-help-mobile/src/components/FAQItem.tsx b/packages/shared-help-mobile/src/components/FAQItem.tsx new file mode 100644 index 000000000..f7ff961ce --- /dev/null +++ b/packages/shared-help-mobile/src/components/FAQItem.tsx @@ -0,0 +1,57 @@ +/** + * Expandable FAQ Item component for mobile + */ + +import React from 'react'; +import { View, Text, TouchableOpacity, LayoutAnimation, Platform, UIManager } from 'react-native'; +import type { FAQItem as FAQItemType } from '@manacore/shared-help-types'; + +// Enable LayoutAnimation on Android +if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) { + UIManager.setLayoutAnimationEnabledExperimental(true); +} + +interface FAQItemProps { + item: FAQItemType; + expanded?: boolean; + onToggle?: () => void; +} + +export function FAQItem({ item, expanded = false, onToggle }: FAQItemProps) { + function handlePress() { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + onToggle?.(); + } + + // Strip HTML tags for mobile display + const plainAnswer = item.answer.replace(/<[^>]*>/g, '').trim(); + + return ( + + + + {item.question} + + + ▼ + + + + {expanded && ( + + + {plainAnswer} + + + )} + + ); +} diff --git a/packages/shared-help-mobile/src/components/FAQList.tsx b/packages/shared-help-mobile/src/components/FAQList.tsx new file mode 100644 index 000000000..cc8aecb2d --- /dev/null +++ b/packages/shared-help-mobile/src/components/FAQList.tsx @@ -0,0 +1,39 @@ +/** + * FAQ List component for mobile + */ + +import React, { useState } from 'react'; +import { View, Text } from 'react-native'; +import { FAQItem } from './FAQItem'; +import type { FAQListProps } from '../types'; + +export function FAQList({ items, translations }: FAQListProps) { + const [expandedId, setExpandedId] = useState( + items.length > 0 ? items[0].id : null + ); + + function toggleItem(id: string) { + setExpandedId(expandedId === id ? null : id); + } + + if (items.length === 0) { + return ( + + {translations.faq.noItems} + + ); + } + + return ( + + {items.map((item) => ( + toggleItem(item.id)} + /> + ))} + + ); +} diff --git a/packages/shared-help-mobile/src/components/FeatureCard.tsx b/packages/shared-help-mobile/src/components/FeatureCard.tsx new file mode 100644 index 000000000..b1614e57e --- /dev/null +++ b/packages/shared-help-mobile/src/components/FeatureCard.tsx @@ -0,0 +1,49 @@ +/** + * Feature Card component for mobile + */ + +import React from 'react'; +import { View, Text } from 'react-native'; +import type { FeatureItem } from '@manacore/shared-help-types'; + +interface FeatureCardProps { + item: FeatureItem; + comingSoonLabel?: string; +} + +export function FeatureCard({ item, comingSoonLabel = 'Coming soon' }: FeatureCardProps) { + return ( + + + {item.icon && {item.icon}} + + + + {item.title} + + {item.comingSoon && ( + + + {comingSoonLabel} + + + )} + + + + + {item.description} + + {item.highlights && item.highlights.length > 0 && ( + + {item.highlights.map((highlight, index) => ( + + + {highlight} + + ))} + + )} + + ); +} diff --git a/packages/shared-help-mobile/src/components/FeaturesList.tsx b/packages/shared-help-mobile/src/components/FeaturesList.tsx new file mode 100644 index 000000000..ba97d05e4 --- /dev/null +++ b/packages/shared-help-mobile/src/components/FeaturesList.tsx @@ -0,0 +1,26 @@ +/** + * Features List component for mobile + */ + +import React from 'react'; +import { View, Text } from 'react-native'; +import { FeatureCard } from './FeatureCard'; +import type { FeaturesListProps } from '../types'; + +export function FeaturesList({ items, translations }: FeaturesListProps) { + if (items.length === 0) { + return ( + + {translations.features.noItems} + + ); + } + + return ( + + {items.map((item) => ( + + ))} + + ); +} diff --git a/packages/shared-help-mobile/src/components/HelpSearchBar.tsx b/packages/shared-help-mobile/src/components/HelpSearchBar.tsx new file mode 100644 index 000000000..66b122c15 --- /dev/null +++ b/packages/shared-help-mobile/src/components/HelpSearchBar.tsx @@ -0,0 +1,42 @@ +/** + * Search Bar component for mobile Help screen + */ + +import React, { useState } from 'react'; +import { View, TextInput, TouchableOpacity, Text } from 'react-native'; +import type { HelpSearchBarProps } from '../types'; + +export function HelpSearchBar({ placeholder, onSearch, onClear }: HelpSearchBarProps) { + const [query, setQuery] = useState(''); + + function handleChangeText(text: string) { + setQuery(text); + onSearch(text); + } + + function handleClear() { + setQuery(''); + onClear(); + } + + return ( + + 🔍 + + {query.length > 0 && ( + + + + )} + + ); +} diff --git a/packages/shared-help-mobile/src/hooks/useHelpContent.ts b/packages/shared-help-mobile/src/hooks/useHelpContent.ts new file mode 100644 index 000000000..bf424e198 --- /dev/null +++ b/packages/shared-help-mobile/src/hooks/useHelpContent.ts @@ -0,0 +1,51 @@ +/** + * Hook for loading and managing help content in mobile apps + */ + +import { useState, useMemo } from 'react'; +import type { HelpContent } from '@manacore/shared-help-types'; +import { mergeContent, createEmptyContent, createSearcher } from '@manacore/shared-help-content'; +import type { UseHelpContentOptions, UseHelpContentResult } from '../types'; + +export function useHelpContent(options: UseHelpContentOptions): UseHelpContentResult { + const { appId, locale, centralContent, appContent } = options; + const [loading] = useState(false); + const [error, setError] = useState(null); + + // Merge central and app-specific content + const content = useMemo(() => { + try { + const base = centralContent ?? createEmptyContent(); + if (appContent) { + return mergeContent(base, appContent, { + appId, + locale, + }); + } + return base; + } catch (err) { + setError(err instanceof Error ? err : new Error('Failed to merge content')); + return createEmptyContent(); + } + }, [centralContent, appContent, appId, locale]); + + return { + content, + loading, + error, + }; +} + +/** + * Hook for searching help content + */ +export function useHelpSearch(content: HelpContent) { + const searcher = useMemo(() => createSearcher(content), [content]); + + return { + search: (query: string, limit?: number) => { + if (!query.trim()) return []; + return searcher(query, { limit: limit ?? 10 }); + }, + }; +} diff --git a/packages/shared-help-mobile/src/index.ts b/packages/shared-help-mobile/src/index.ts new file mode 100644 index 000000000..eb59a3b73 --- /dev/null +++ b/packages/shared-help-mobile/src/index.ts @@ -0,0 +1,32 @@ +/** + * @manacore/shared-help-mobile + * React Native components for the Help system + */ + +// Main screen +export { HelpScreen } from './screens/HelpScreen'; + +// Components +export { FAQList } from './components/FAQList'; +export { FAQItem } from './components/FAQItem'; +export { FeaturesList } from './components/FeaturesList'; +export { FeatureCard } from './components/FeatureCard'; +export { HelpSearchBar } from './components/HelpSearchBar'; +export { CategoryTabs } from './components/CategoryTabs'; +export { ContactCard } from './components/ContactCard'; + +// Hooks +export { useHelpContent, useHelpSearch } from './hooks/useHelpContent'; + +// Types +export type { + HelpScreenProps, + HelpTranslations, + HelpSection, + UseHelpContentOptions, + UseHelpContentResult, + FAQListProps, + FeaturesListProps, + HelpSearchBarProps, + HelpSearchResultsProps, +} from './types'; diff --git a/packages/shared-help-mobile/src/screens/HelpScreen.tsx b/packages/shared-help-mobile/src/screens/HelpScreen.tsx new file mode 100644 index 000000000..20592053b --- /dev/null +++ b/packages/shared-help-mobile/src/screens/HelpScreen.tsx @@ -0,0 +1,206 @@ +/** + * Main Help Screen component for mobile apps + */ + +import React, { useState, useMemo } from 'react'; +import { View, Text, ScrollView, SafeAreaView } from 'react-native'; +import type { HelpScreenProps, HelpSection } from '../types'; +import { HelpSearchBar } from '../components/HelpSearchBar'; +import { CategoryTabs } from '../components/CategoryTabs'; +import { FAQList } from '../components/FAQList'; +import { FeaturesList } from '../components/FeaturesList'; +import { ContactCard } from '../components/ContactCard'; +import { useHelpSearch } from '../hooks/useHelpContent'; +import type { SearchResult } from '@manacore/shared-help-types'; + +export function HelpScreen({ + content, + appName, + appId: _appId, + translations, + onBack: _onBack, + defaultSection = 'faq', +}: HelpScreenProps) { + const [activeSection, setActiveSection] = useState(defaultSection); + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState([]); + + const { search } = useHelpSearch(content); + + // Define available sections + const sections = useMemo( + () => [ + { id: 'faq' as HelpSection, label: translations.sections.faq, show: content.faq.length > 0 }, + { + id: 'features' as HelpSection, + label: translations.sections.features, + show: content.features.length > 0, + }, + { + id: 'shortcuts' as HelpSection, + label: translations.sections.shortcuts, + show: content.shortcuts.length > 0, + }, + { + id: 'getting-started' as HelpSection, + label: translations.sections.gettingStarted, + show: content.gettingStarted.length > 0, + }, + { + id: 'changelog' as HelpSection, + label: translations.sections.changelog, + show: content.changelog.length > 0, + }, + { + id: 'contact' as HelpSection, + label: translations.sections.contact, + show: !!content.contact, + }, + ], + [content, translations] + ); + + function handleSearch(query: string) { + setSearchQuery(query); + if (query.trim().length >= 2) { + const results = search(query, 10); + setSearchResults(results); + } else { + setSearchResults([]); + } + } + + function handleClearSearch() { + setSearchQuery(''); + setSearchResults([]); + } + + function handleResultPress(result: SearchResult) { + // Navigate to appropriate section + switch (result.type) { + case 'faq': + setActiveSection('faq'); + break; + case 'feature': + setActiveSection('features'); + break; + case 'guide': + setActiveSection('getting-started'); + break; + case 'changelog': + setActiveSection('changelog'); + break; + } + handleClearSearch(); + } + + // Use handleResultPress in search results (currently just viewing results) + void handleResultPress; + + function renderContent() { + // Show search results if searching + if (searchQuery.length >= 2) { + if (searchResults.length === 0) { + return ( + + + {translations.search.noResults.replace('{query}', searchQuery)} + + + ); + } + + return ( + + + {translations.search.resultsCount.replace('{count}', String(searchResults.length))} + + {searchResults.map((result) => ( + + {result.title} + + {result.excerpt} + + + ))} + + ); + } + + // Show section content + switch (activeSection) { + case 'faq': + return ; + case 'features': + return ; + case 'contact': + return ; + case 'shortcuts': + return ( + + + {translations.shortcuts.noItems} + + + ); + case 'getting-started': + return ( + + + {translations.gettingStarted.noItems} + + + ); + case 'changelog': + return ( + + + {translations.changelog.noItems} + + + ); + default: + return null; + } + } + + return ( + + + {/* Header */} + + + {translations.title} + + {translations.subtitle && ( + + {translations.subtitle} - {appName} + + )} + + + {/* Search */} + + + {/* Category Tabs */} + {searchQuery.length < 2 && ( + + )} + + {/* Content */} + {renderContent()} + + + ); +} diff --git a/packages/shared-help-mobile/src/types.ts b/packages/shared-help-mobile/src/types.ts new file mode 100644 index 000000000..3fcc8cb6f --- /dev/null +++ b/packages/shared-help-mobile/src/types.ts @@ -0,0 +1,95 @@ +/** + * Mobile-specific types for Help components + */ + +import type { HelpContent, SearchResult, SupportedLanguage } from '@manacore/shared-help-types'; + +export type HelpSection = + | 'faq' + | 'features' + | 'shortcuts' + | 'getting-started' + | 'changelog' + | 'contact'; + +export interface HelpScreenProps { + content: HelpContent; + appName: string; + appId: string; + translations: HelpTranslations; + onBack?: () => void; + defaultSection?: HelpSection; +} + +export interface HelpTranslations { + title: string; + subtitle?: string; + searchPlaceholder: string; + sections: { + faq: string; + features: string; + shortcuts: string; + gettingStarted: string; + changelog: string; + contact: string; + }; + search: { + noResults: string; + resultsCount: string; + }; + faq: { + noItems: string; + }; + features: { + noItems: string; + comingSoon: string; + }; + shortcuts: { + noItems: string; + }; + gettingStarted: { + noItems: string; + }; + changelog: { + noItems: string; + }; + contact: { + noInfo: string; + email: string; + }; +} + +export interface UseHelpContentOptions { + appId: string; + locale: SupportedLanguage; + centralContent?: HelpContent; + appContent?: Partial; +} + +export interface UseHelpContentResult { + content: HelpContent; + loading: boolean; + error: Error | null; +} + +export interface FAQListProps { + items: HelpContent['faq']; + translations: Pick; +} + +export interface FeaturesListProps { + items: HelpContent['features']; + translations: Pick; +} + +export interface HelpSearchBarProps { + placeholder?: string; + onSearch: (query: string) => void; + onClear: () => void; +} + +export interface HelpSearchResultsProps { + results: SearchResult[]; + onResultPress: (result: SearchResult) => void; + translations: Pick; +} diff --git a/packages/shared-help-mobile/tsconfig.json b/packages/shared-help-mobile/tsconfig.json new file mode 100644 index 000000000..6a5cbfdf1 --- /dev/null +++ b/packages/shared-help-mobile/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/packages/shared-help-types/package.json b/packages/shared-help-types/package.json new file mode 100644 index 000000000..435cc4c05 --- /dev/null +++ b/packages/shared-help-types/package.json @@ -0,0 +1,35 @@ +{ + "name": "@manacore/shared-help-types", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./content": { + "types": "./src/content.ts", + "default": "./src/content.ts" + }, + "./schemas": { + "types": "./src/schemas.ts", + "default": "./src/schemas.ts" + }, + "./search": { + "types": "./src/search.ts", + "default": "./src/search.ts" + } + }, + "scripts": { + "type-check": "tsc --noEmit" + }, + "dependencies": { + "zod": "^3.24.1" + }, + "devDependencies": { + "typescript": "^5.7.3" + } +} diff --git a/packages/shared-help-types/src/content.ts b/packages/shared-help-types/src/content.ts new file mode 100644 index 000000000..2bdba593f --- /dev/null +++ b/packages/shared-help-types/src/content.ts @@ -0,0 +1,152 @@ +/** + * Help Content Type Definitions + * Defines the structure for all help content types + */ + +// ============================================================================ +// Base Types +// ============================================================================ + +export type SupportedLanguage = 'en' | 'de' | 'fr' | 'it' | 'es'; + +export type FAQCategory = 'general' | 'account' | 'billing' | 'features' | 'technical' | 'privacy'; + +export type FeatureCategory = 'getting-started' | 'core' | 'advanced' | 'integration'; + +export type GuideDifficulty = 'beginner' | 'intermediate' | 'advanced'; + +export type ChangelogType = 'major' | 'minor' | 'patch' | 'beta'; + +export type ShortcutCategory = 'navigation' | 'editing' | 'general' | 'app-specific'; + +// ============================================================================ +// Content Item Types +// ============================================================================ + +export interface BaseContentItem { + id: string; + language: SupportedLanguage; + order?: number; + appSpecific?: boolean; + apps?: string[]; + lastUpdated?: Date; +} + +export interface FAQItem extends BaseContentItem { + question: string; + answer: string; + category: FAQCategory; + featured?: boolean; + tags?: string[]; + relatedFaqs?: string[]; +} + +export interface FeatureItem extends BaseContentItem { + title: string; + description: string; + content: string; + icon?: string; + category: FeatureCategory; + available?: boolean; + comingSoon?: boolean; + highlights?: string[]; + learnMoreUrl?: string; +} + +export interface KeyboardShortcut { + shortcut: string; + action: string; + description?: string; +} + +export interface ShortcutsItem extends BaseContentItem { + category: ShortcutCategory; + title?: string; + shortcuts: KeyboardShortcut[]; +} + +export interface GuideStep { + title: string; + content: string; + duration?: string; +} + +export interface GettingStartedItem extends BaseContentItem { + title: string; + description: string; + content: string; + difficulty: GuideDifficulty; + estimatedTime?: string; + prerequisites?: string[]; + steps?: GuideStep[]; +} + +export interface ChangelogChange { + title: string; + description?: string; + category?: string; +} + +export interface ChangelogItem extends BaseContentItem { + version: string; + title: string; + releaseDate: Date; + type: ChangelogType; + summary?: string; + content: string; + highlighted?: boolean; + changes?: { + features?: ChangelogChange[]; + improvements?: ChangelogChange[]; + bugfixes?: ChangelogChange[]; + }; + platforms?: string[]; +} + +export interface ContactInfo extends BaseContentItem { + title: string; + content: string; + supportEmail?: string; + supportUrl?: string; + discordUrl?: string; + twitterUrl?: string; + documentationUrl?: string; + responseTime?: string; +} + +// ============================================================================ +// Aggregated Content Types +// ============================================================================ + +export interface HelpContent { + faq: FAQItem[]; + features: FeatureItem[]; + shortcuts: ShortcutsItem[]; + gettingStarted: GettingStartedItem[]; + changelog: ChangelogItem[]; + contact: ContactInfo | null; +} + +export interface AppHelpContent { + appId: string; + appName: string; + content: HelpContent; +} + +// ============================================================================ +// Configuration Types +// ============================================================================ + +export interface HelpContentConfig { + appId: string; + locale: SupportedLanguage; + fallbackLocale?: SupportedLanguage; + includeAppSpecific?: boolean; +} + +export interface MergeContentOptions { + appId: string; + locale: SupportedLanguage; + /** If true, app-specific content replaces central content with same ID */ + overrideById?: boolean; +} diff --git a/packages/shared-help-types/src/index.ts b/packages/shared-help-types/src/index.ts new file mode 100644 index 000000000..b45ede049 --- /dev/null +++ b/packages/shared-help-types/src/index.ts @@ -0,0 +1,13 @@ +/** + * @manacore/shared-help-types + * Shared TypeScript types and Zod schemas for Help content + */ + +// Content types +export * from './content.js'; + +// Zod schemas for validation +export * from './schemas.js'; + +// Search types +export * from './search.js'; diff --git a/packages/shared-help-types/src/schemas.ts b/packages/shared-help-types/src/schemas.ts new file mode 100644 index 000000000..2849c7bd4 --- /dev/null +++ b/packages/shared-help-types/src/schemas.ts @@ -0,0 +1,130 @@ +/** + * Zod Schemas for Help Content Validation + * Used to validate Markdown frontmatter + */ + +import { z } from 'zod'; + +// ============================================================================ +// Base Schemas +// ============================================================================ + +export const supportedLanguageSchema = z.enum(['en', 'de', 'fr', 'it', 'es']); + +export const faqCategorySchema = z.enum([ + 'general', + 'account', + 'billing', + 'features', + 'technical', + 'privacy', +]); + +export const featureCategorySchema = z.enum(['getting-started', 'core', 'advanced', 'integration']); + +export const guideDifficultySchema = z.enum(['beginner', 'intermediate', 'advanced']); + +export const changelogTypeSchema = z.enum(['major', 'minor', 'patch', 'beta']); + +export const shortcutCategorySchema = z.enum(['navigation', 'editing', 'general', 'app-specific']); + +// ============================================================================ +// Content Item Schemas (for Frontmatter) +// ============================================================================ + +const baseContentSchema = z.object({ + id: z.string().min(1), + language: supportedLanguageSchema, + order: z.number().optional().default(0), + appSpecific: z.boolean().optional().default(false), + apps: z.array(z.string()).optional().default([]), + lastUpdated: z.coerce.date().optional(), +}); + +export const faqFrontmatterSchema = baseContentSchema.extend({ + question: z.string().min(1), + category: faqCategorySchema, + featured: z.boolean().optional().default(false), + tags: z.array(z.string()).optional().default([]), + relatedFaqs: z.array(z.string()).optional().default([]), +}); + +export const featureFrontmatterSchema = baseContentSchema.extend({ + title: z.string().min(1), + description: z.string().min(1), + icon: z.string().optional(), + category: featureCategorySchema, + available: z.boolean().optional().default(true), + comingSoon: z.boolean().optional().default(false), + highlights: z.array(z.string()).optional().default([]), + learnMoreUrl: z.string().url().optional(), +}); + +export const shortcutSchema = z.object({ + shortcut: z.string().min(1), + action: z.string().min(1), + description: z.string().optional(), +}); + +export const shortcutsFrontmatterSchema = baseContentSchema.extend({ + category: shortcutCategorySchema, + title: z.string().optional(), +}); + +export const guideStepSchema = z.object({ + title: z.string().min(1), + content: z.string().min(1), + duration: z.string().optional(), +}); + +export const gettingStartedFrontmatterSchema = baseContentSchema.extend({ + title: z.string().min(1), + description: z.string().min(1), + difficulty: guideDifficultySchema, + estimatedTime: z.string().optional(), + prerequisites: z.array(z.string()).optional().default([]), +}); + +export const changelogChangeSchema = z.object({ + title: z.string().min(1), + description: z.string().optional(), + category: z.string().optional(), +}); + +export const changelogFrontmatterSchema = baseContentSchema.extend({ + version: z.string().min(1), + title: z.string().min(1), + releaseDate: z.coerce.date(), + type: changelogTypeSchema, + summary: z.string().optional(), + highlighted: z.boolean().optional().default(false), + changes: z + .object({ + features: z.array(changelogChangeSchema).optional(), + improvements: z.array(changelogChangeSchema).optional(), + bugfixes: z.array(changelogChangeSchema).optional(), + }) + .optional(), + platforms: z.array(z.string()).optional().default(['all']), +}); + +export const contactFrontmatterSchema = baseContentSchema.extend({ + title: z.string().min(1), + supportEmail: z.string().email().optional(), + supportUrl: z.string().url().optional(), + discordUrl: z.string().url().optional(), + twitterUrl: z.string().url().optional(), + documentationUrl: z.string().url().optional(), + responseTime: z.string().optional(), +}); + +// ============================================================================ +// Type Exports from Schemas +// ============================================================================ + +export type FAQFrontmatter = z.infer; +export type FeatureFrontmatter = z.infer; +export type ShortcutsFrontmatter = z.infer; +export type GettingStartedFrontmatter = z.infer; +export type ChangelogFrontmatter = z.infer; +export type ContactFrontmatter = z.infer; diff --git a/packages/shared-help-types/src/search.ts b/packages/shared-help-types/src/search.ts new file mode 100644 index 000000000..900d11235 --- /dev/null +++ b/packages/shared-help-types/src/search.ts @@ -0,0 +1,71 @@ +/** + * Search-related Type Definitions + */ + +import type { FAQItem, FeatureItem, GettingStartedItem, ChangelogItem } from './content.js'; + +// ============================================================================ +// Searchable Item Types +// ============================================================================ + +export type SearchableContentType = 'faq' | 'feature' | 'guide' | 'changelog'; + +export interface SearchableItem { + id: string; + type: SearchableContentType; + title: string; + content: string; + tags?: string[]; + question?: string; + description?: string; +} + +// ============================================================================ +// Search Result Types +// ============================================================================ + +export interface SearchResult { + id: string; + type: SearchableContentType; + title: string; + excerpt: string; + score: number; + highlight?: string; + /** Original item reference */ + item: FAQItem | FeatureItem | GettingStartedItem | ChangelogItem; +} + +export interface SearchOptions { + /** Maximum number of results to return */ + limit?: number; + /** Minimum score threshold (0-1, lower is more strict) */ + threshold?: number; + /** Filter by content type */ + types?: SearchableContentType[]; + /** Filter by app ID (for app-specific content) */ + appId?: string; +} + +export interface SearchIndexConfig { + /** Weight for title/question field */ + titleWeight?: number; + /** Weight for content field */ + contentWeight?: number; + /** Weight for tags field */ + tagsWeight?: number; + /** Fuzzy match threshold (0-1, lower is more strict) */ + threshold?: number; + /** Minimum characters to start searching */ + minMatchCharLength?: number; +} + +// ============================================================================ +// Search State Types (for UI) +// ============================================================================ + +export interface SearchState { + query: string; + results: SearchResult[]; + isSearching: boolean; + hasSearched: boolean; +} diff --git a/packages/shared-help-types/tsconfig.json b/packages/shared-help-types/tsconfig.json new file mode 100644 index 000000000..1b9470b61 --- /dev/null +++ b/packages/shared-help-types/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "verbatimModuleSyntax": true, + "noEmit": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/packages/shared-help-ui/package.json b/packages/shared-help-ui/package.json new file mode 100644 index 000000000..9828cd4e8 --- /dev/null +++ b/packages/shared-help-ui/package.json @@ -0,0 +1,65 @@ +{ + "name": "@manacore/shared-help-ui", + "version": "1.0.0", + "private": true, + "type": "module", + "svelte": "./src/index.ts", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "svelte": "./src/index.ts", + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./HelpPage.svelte": { + "svelte": "./src/pages/HelpPage.svelte", + "default": "./src/pages/HelpPage.svelte" + }, + "./FAQSection.svelte": { + "svelte": "./src/components/FAQSection.svelte", + "default": "./src/components/FAQSection.svelte" + }, + "./FeaturesOverview.svelte": { + "svelte": "./src/components/FeaturesOverview.svelte", + "default": "./src/components/FeaturesOverview.svelte" + }, + "./KeyboardShortcuts.svelte": { + "svelte": "./src/components/KeyboardShortcuts.svelte", + "default": "./src/components/KeyboardShortcuts.svelte" + }, + "./GettingStartedGuide.svelte": { + "svelte": "./src/components/GettingStartedGuide.svelte", + "default": "./src/components/GettingStartedGuide.svelte" + }, + "./ChangelogSection.svelte": { + "svelte": "./src/components/ChangelogSection.svelte", + "default": "./src/components/ChangelogSection.svelte" + }, + "./ContactSection.svelte": { + "svelte": "./src/components/ContactSection.svelte", + "default": "./src/components/ContactSection.svelte" + }, + "./HelpSearch.svelte": { + "svelte": "./src/components/HelpSearch.svelte", + "default": "./src/components/HelpSearch.svelte" + } + }, + "scripts": { + "check": "svelte-check --tsconfig ./tsconfig.json", + "lint": "eslint ." + }, + "dependencies": { + "@manacore/shared-help-types": "workspace:*", + "@manacore/shared-help-content": "workspace:*", + "@manacore/shared-icons": "workspace:*" + }, + "devDependencies": { + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "^5.7.3" + }, + "peerDependencies": { + "svelte": "^5.0.0" + } +} diff --git a/packages/shared-help-ui/src/components/ChangelogEntry.svelte b/packages/shared-help-ui/src/components/ChangelogEntry.svelte new file mode 100644 index 000000000..f9cfde552 --- /dev/null +++ b/packages/shared-help-ui/src/components/ChangelogEntry.svelte @@ -0,0 +1,152 @@ + + +
+ + + {#if expanded} +
+ {#if item.summary} +

{item.summary}

+ {/if} + + {#if item.changes} + {#if item.changes.features && item.changes.features.length > 0} +
+
+ New Features +
+
    + {#each item.changes.features as change} +
  • + + + + {change.title} + {#if change.description} + - {change.description} + {/if} + +
  • + {/each} +
+
+ {/if} + + {#if item.changes.improvements && item.changes.improvements.length > 0} +
+
+ Improvements +
+
    + {#each item.changes.improvements as change} +
  • + + + {change.title} + {#if change.description} + - {change.description} + {/if} + +
  • + {/each} +
+
+ {/if} + + {#if item.changes.bugfixes && item.changes.bugfixes.length > 0} +
+
Bug Fixes
+
    + {#each item.changes.bugfixes as change} +
  • + × + + {change.title} + {#if change.description} + - {change.description} + {/if} + +
  • + {/each} +
+
+ {/if} + {/if} + + {#if item.content} +
+ {@html item.content} +
+ {/if} +
+ {/if} +
diff --git a/packages/shared-help-ui/src/components/ChangelogSection.svelte b/packages/shared-help-ui/src/components/ChangelogSection.svelte new file mode 100644 index 000000000..b30f45018 --- /dev/null +++ b/packages/shared-help-ui/src/components/ChangelogSection.svelte @@ -0,0 +1,45 @@ + + +{#if items.length === 0} +

+ {translations.changelog.noItems} +

+{:else} +
+ {#each displayedItems() as item (item.id)} + + {/each} + + {#if hasMore} +
+ +
+ {/if} +
+{/if} diff --git a/packages/shared-help-ui/src/components/ContactSection.svelte b/packages/shared-help-ui/src/components/ContactSection.svelte new file mode 100644 index 000000000..d22bbf656 --- /dev/null +++ b/packages/shared-help-ui/src/components/ContactSection.svelte @@ -0,0 +1,123 @@ + + +{#if !contact} +

+ {translations.contact.noInfo} +

+{:else} +
+
+ {@html contact.content} +
+ +
+ {#if contact.supportEmail} + +
+ + + +
+
+

+ {translations.contact.email} +

+

+ {contact.supportEmail} +

+
+
+ {/if} + + {#if contact.responseTime} +
+
+ + + +
+
+

+ {translations.contact.responseTime} +

+

+ {contact.responseTime} +

+
+
+ {/if} + + {#if contact.discordUrl} + +
+ + + +
+
+

Discord

+

Join our community

+
+
+ {/if} + + {#if contact.documentationUrl} + +
+ + + +
+
+

Documentation

+

Read the docs

+
+
+ {/if} +
+
+{/if} diff --git a/packages/shared-help-ui/src/components/FAQItem.svelte b/packages/shared-help-ui/src/components/FAQItem.svelte new file mode 100644 index 000000000..2f563282c --- /dev/null +++ b/packages/shared-help-ui/src/components/FAQItem.svelte @@ -0,0 +1,46 @@ + + +
+ + + {#if expanded} +
+ {@html item.answer} +
+ {/if} +
diff --git a/packages/shared-help-ui/src/components/FAQSection.svelte b/packages/shared-help-ui/src/components/FAQSection.svelte new file mode 100644 index 000000000..c29623a56 --- /dev/null +++ b/packages/shared-help-ui/src/components/FAQSection.svelte @@ -0,0 +1,117 @@ + + +
+ {#if showCategories && items.length > 0} +
+ + {#each categories as category} + {@const hasItems = items.some((item) => item.category === category)} + {#if hasItems} + + {/if} + {/each} +
+ {/if} + + {#if filteredItems().length === 0} +

+ {translations.faq.noItems} +

+ {:else} +
+ {#each filteredItems() as item (item.id)} + toggleItem(item.id)} + /> + {/each} +
+ {/if} + + {#if hasMore} +
+ +
+ {/if} +
diff --git a/packages/shared-help-ui/src/components/FeatureCard.svelte b/packages/shared-help-ui/src/components/FeatureCard.svelte new file mode 100644 index 000000000..54c702373 --- /dev/null +++ b/packages/shared-help-ui/src/components/FeatureCard.svelte @@ -0,0 +1,68 @@ + + +
+ {#if item.comingSoon} + + {comingSoonLabel} + + {/if} + +
+ {#if item.icon} + {item.icon} + {/if} +

+ {item.title} +

+
+ +

+ {item.description} +

+ + {#if item.highlights && item.highlights.length > 0} +
    + {#each item.highlights as highlight} +
  • + + + + {highlight} +
  • + {/each} +
+ {/if} + + {#if item.learnMoreUrl} + + {learnMoreLabel} → + + {/if} +
diff --git a/packages/shared-help-ui/src/components/FeaturesOverview.svelte b/packages/shared-help-ui/src/components/FeaturesOverview.svelte new file mode 100644 index 000000000..c45d4570a --- /dev/null +++ b/packages/shared-help-ui/src/components/FeaturesOverview.svelte @@ -0,0 +1,50 @@ + + +{#if !hasItems} +

+ {translations.features.noItems} +

+{:else} +
+ {#each Object.entries(groupedItems()) as [_category, categoryItems]} + {#if categoryItems.length > 0} +
+ {#each categoryItems as item (item.id)} + + {/each} +
+ {/if} + {/each} +
+{/if} diff --git a/packages/shared-help-ui/src/components/GettingStartedGuide.svelte b/packages/shared-help-ui/src/components/GettingStartedGuide.svelte new file mode 100644 index 000000000..0a1b26e02 --- /dev/null +++ b/packages/shared-help-ui/src/components/GettingStartedGuide.svelte @@ -0,0 +1,111 @@ + + +{#if items.length === 0} +

+ {translations.gettingStarted.noItems} +

+{:else} +
+ +
+ {#each items as item (item.id)} + + {/each} +
+ + +
+ {#if selectedGuide()} + {@const guide = selectedGuide()} +
+

+ {guide.title} +

+

+ {guide.description} +

+ + {#if guide.steps && guide.steps.length > 0} +
+ {#each guide.steps as step, index} +
+
+ {index + 1} +
+
+

+ {step.title} +

+
+ {step.content} +
+
+
+ {/each} +
+ {:else} +
+ {@html guide.content} +
+ {/if} +
+ {/if} +
+
+{/if} diff --git a/packages/shared-help-ui/src/components/HelpSearch.svelte b/packages/shared-help-ui/src/components/HelpSearch.svelte new file mode 100644 index 000000000..a4fb50798 --- /dev/null +++ b/packages/shared-help-ui/src/components/HelpSearch.svelte @@ -0,0 +1,198 @@ + + +
+
+ query.length >= 2 && (showResults = true)} + onblur={handleBlur} + placeholder={placeholder ?? translations.search.noResults} + class="w-full rounded-lg border border-gray-300 bg-white py-2.5 pl-10 pr-4 text-sm text-gray-900 placeholder-gray-500 transition-colors focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400" + /> +
+ {#if isSearching} + + + + + {:else} + + + + {/if} +
+
+ + {#if showResults} +
+ {#if results.length === 0} +
+ {translations.search.noResults.replace('{query}', query)} +
+ {:else} +
    + {#each results as result, index (result.id)} +
  • + +
  • + {/each} +
+
+ {translations.search.resultsCount.replace('{count}', String(results.length))} +
+ {/if} +
+ {/if} +
diff --git a/packages/shared-help-ui/src/components/KeyboardShortcuts.svelte b/packages/shared-help-ui/src/components/KeyboardShortcuts.svelte new file mode 100644 index 000000000..cd8673895 --- /dev/null +++ b/packages/shared-help-ui/src/components/KeyboardShortcuts.svelte @@ -0,0 +1,54 @@ + + +{#if !hasItems} +

+ {translations.shortcuts.noItems} +

+{:else} +
+ + + + + + + + + + {#each allShortcuts() as shortcut} + + + + + + {/each} + +
ShortcutActionDescription
+ + {shortcut.shortcut} + + + {shortcut.action} + + {shortcut.description || '-'} +
+
+{/if} diff --git a/packages/shared-help-ui/src/index.ts b/packages/shared-help-ui/src/index.ts new file mode 100644 index 000000000..6fda8226d --- /dev/null +++ b/packages/shared-help-ui/src/index.ts @@ -0,0 +1,33 @@ +/** + * @manacore/shared-help-ui + * Svelte 5 components for the Help page system + */ + +// Main page component +export { default as HelpPage } from './pages/HelpPage.svelte'; + +// Section components +export { default as FAQSection } from './components/FAQSection.svelte'; +export { default as FAQItem } from './components/FAQItem.svelte'; +export { default as FeaturesOverview } from './components/FeaturesOverview.svelte'; +export { default as FeatureCard } from './components/FeatureCard.svelte'; +export { default as KeyboardShortcuts } from './components/KeyboardShortcuts.svelte'; +export { default as GettingStartedGuide } from './components/GettingStartedGuide.svelte'; +export { default as ChangelogSection } from './components/ChangelogSection.svelte'; +export { default as ChangelogEntry } from './components/ChangelogEntry.svelte'; +export { default as ContactSection } from './components/ContactSection.svelte'; +export { default as HelpSearch } from './components/HelpSearch.svelte'; + +// Types +export type { + HelpPageProps, + HelpPageTranslations, + HelpSection, + FAQSectionProps, + FeaturesOverviewProps, + KeyboardShortcutsProps, + GettingStartedGuideProps, + ChangelogSectionProps, + ContactSectionProps, + HelpSearchProps, +} from './types.js'; diff --git a/packages/shared-help-ui/src/pages/HelpPage.svelte b/packages/shared-help-ui/src/pages/HelpPage.svelte new file mode 100644 index 000000000..4e57f682a --- /dev/null +++ b/packages/shared-help-ui/src/pages/HelpPage.svelte @@ -0,0 +1,169 @@ + + +
+ +
+ {#if showBackButton} + + {/if} + +

+ {translations.title} +

+ {#if translations.subtitle} +

+ {translations.subtitle} - {appName} +

+ {/if} +
+ + + {#if searchEnabled} +
+ +
+ {/if} + + + {#if visibleSections.length > 1} +
+ +
+ {/if} + + +
+ {#if activeSection === 'faq' && showFAQ} + + {:else if activeSection === 'features' && showFeatures} + + {:else if activeSection === 'shortcuts' && showShortcuts} + + {:else if activeSection === 'getting-started' && showGettingStarted} + + {:else if activeSection === 'changelog' && showChangelog} + + {:else if activeSection === 'contact' && showContact} + + {/if} +
+
diff --git a/packages/shared-help-ui/src/types.ts b/packages/shared-help-ui/src/types.ts new file mode 100644 index 000000000..00fdee74e --- /dev/null +++ b/packages/shared-help-ui/src/types.ts @@ -0,0 +1,147 @@ +/** + * Component Props and Translation Types + */ + +import type { HelpContent, SearchResult } from '@manacore/shared-help-types'; + +// ============================================================================ +// Translation Types +// ============================================================================ + +export interface HelpPageTranslations { + title: string; + subtitle?: string; + searchPlaceholder: string; + sections: { + faq: string; + features: string; + shortcuts: string; + gettingStarted: string; + changelog: string; + contact: string; + }; + search: { + noResults: string; + resultsCount: string; + searching: string; + }; + faq: { + noItems: string; + categories: { + general: string; + account: string; + billing: string; + features: string; + technical: string; + privacy: string; + }; + }; + features: { + noItems: string; + comingSoon: string; + learnMore: string; + }; + shortcuts: { + noItems: string; + }; + gettingStarted: { + noItems: string; + estimatedTime: string; + difficulty: { + beginner: string; + intermediate: string; + advanced: string; + }; + }; + changelog: { + noItems: string; + types: { + major: string; + minor: string; + patch: string; + beta: string; + }; + }; + contact: { + noInfo: string; + email: string; + responseTime: string; + }; + common: { + back: string; + showMore: string; + showLess: string; + }; +} + +// ============================================================================ +// Component Props +// ============================================================================ + +export type HelpSection = + | 'faq' + | 'features' + | 'shortcuts' + | 'getting-started' + | 'changelog' + | 'contact'; + +export interface HelpPageProps { + content: HelpContent; + appName: string; + appId: string; + translations: HelpPageTranslations; + searchEnabled?: boolean; + showFAQ?: boolean; + showFeatures?: boolean; + showShortcuts?: boolean; + showGettingStarted?: boolean; + showChangelog?: boolean; + showContact?: boolean; + defaultSection?: HelpSection; + showBackButton?: boolean; + onBack?: () => void; + onSectionChange?: (section: HelpSection) => void; + onSearch?: (query: string, results: SearchResult[]) => void; +} + +export interface FAQSectionProps { + items: HelpContent['faq']; + translations: Pick; + showCategories?: boolean; + maxItems?: number; + expandFirst?: boolean; +} + +export interface FeaturesOverviewProps { + items: HelpContent['features']; + translations: Pick; +} + +export interface KeyboardShortcutsProps { + items: HelpContent['shortcuts']; + translations: Pick; +} + +export interface GettingStartedGuideProps { + items: HelpContent['gettingStarted']; + translations: Pick; +} + +export interface ChangelogSectionProps { + items: HelpContent['changelog']; + translations: Pick; + maxItems?: number; +} + +export interface ContactSectionProps { + contact: HelpContent['contact']; + translations: Pick; +} + +export interface HelpSearchProps { + content: HelpContent; + translations: Pick; + placeholder?: string; + onResultSelect: (result: SearchResult) => void; +} diff --git a/packages/shared-help-ui/tsconfig.json b/packages/shared-help-ui/tsconfig.json new file mode 100644 index 000000000..1b9470b61 --- /dev/null +++ b/packages/shared-help-ui/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "verbatimModuleSyntax": true, + "noEmit": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/packages/shared-i18n/src/index.ts b/packages/shared-i18n/src/index.ts index 983fd7a43..21eeb2ad8 100644 --- a/packages/shared-i18n/src/index.ts +++ b/packages/shared-i18n/src/index.ts @@ -65,5 +65,18 @@ export { getForgotPasswordTranslations, } from './translations/auth'; +// Help translations +export { + en as helpTranslationsEn, + de as helpTranslationsDe, + it as helpTranslationsIt, + fr as helpTranslationsFr, + es as helpTranslationsEs, + type HelpTranslations, + type HelpLocale, + helpTranslations, + getHelpTranslations, +} from './translations/help'; + // Components export { LanguageSelector } from './components'; diff --git a/packages/shared-i18n/src/translations/help/de.json b/packages/shared-i18n/src/translations/help/de.json new file mode 100644 index 000000000..74efba394 --- /dev/null +++ b/packages/shared-i18n/src/translations/help/de.json @@ -0,0 +1,65 @@ +{ + "title": "Hilfe & Support", + "subtitle": "Finde Antworten und lerne die App kennen", + "searchPlaceholder": "Hilfe durchsuchen...", + "sections": { + "faq": "FAQ", + "features": "Features", + "shortcuts": "Tastenkürzel", + "gettingStarted": "Erste Schritte", + "changelog": "Neuigkeiten", + "contact": "Kontakt" + }, + "search": { + "noResults": "Keine Ergebnisse für \"{query}\"", + "resultsCount": "{count} Ergebnisse gefunden", + "searching": "Suche..." + }, + "faq": { + "noItems": "Keine FAQs verfügbar", + "categories": { + "general": "Allgemein", + "account": "Konto", + "billing": "Abrechnung", + "features": "Funktionen", + "technical": "Technik", + "privacy": "Datenschutz" + } + }, + "features": { + "noItems": "Noch keine Features dokumentiert", + "comingSoon": "Demnächst", + "learnMore": "Mehr erfahren" + }, + "shortcuts": { + "noItems": "Keine Tastenkürzel verfügbar" + }, + "gettingStarted": { + "noItems": "Noch keine Anleitungen verfügbar", + "estimatedTime": "Geschätzte Zeit", + "difficulty": { + "beginner": "Anfänger", + "intermediate": "Fortgeschritten", + "advanced": "Experte" + } + }, + "changelog": { + "noItems": "Noch keine Updates", + "types": { + "major": "Haupt-Update", + "minor": "Kleines Update", + "patch": "Fehlerbehebung", + "beta": "Beta" + } + }, + "contact": { + "noInfo": "Kontaktinformationen nicht verfügbar", + "email": "E-Mail senden", + "responseTime": "Antwortzeit" + }, + "common": { + "back": "Zurück", + "showMore": "Mehr anzeigen", + "showLess": "Weniger anzeigen" + } +} diff --git a/packages/shared-i18n/src/translations/help/en.json b/packages/shared-i18n/src/translations/help/en.json new file mode 100644 index 000000000..76bb0bd84 --- /dev/null +++ b/packages/shared-i18n/src/translations/help/en.json @@ -0,0 +1,65 @@ +{ + "title": "Help & Support", + "subtitle": "Find answers and learn how to use the app", + "searchPlaceholder": "Search help articles...", + "sections": { + "faq": "FAQ", + "features": "Features", + "shortcuts": "Keyboard Shortcuts", + "gettingStarted": "Getting Started", + "changelog": "What's New", + "contact": "Contact Us" + }, + "search": { + "noResults": "No results found for \"{query}\"", + "resultsCount": "{count} results found", + "searching": "Searching..." + }, + "faq": { + "noItems": "No FAQs available", + "categories": { + "general": "General", + "account": "Account", + "billing": "Billing", + "features": "Features", + "technical": "Technical", + "privacy": "Privacy" + } + }, + "features": { + "noItems": "No features documented yet", + "comingSoon": "Coming soon", + "learnMore": "Learn more" + }, + "shortcuts": { + "noItems": "No keyboard shortcuts available" + }, + "gettingStarted": { + "noItems": "No guides available yet", + "estimatedTime": "Estimated time", + "difficulty": { + "beginner": "Beginner", + "intermediate": "Intermediate", + "advanced": "Advanced" + } + }, + "changelog": { + "noItems": "No updates yet", + "types": { + "major": "Major", + "minor": "Minor", + "patch": "Patch", + "beta": "Beta" + } + }, + "contact": { + "noInfo": "Contact information not available", + "email": "Email us", + "responseTime": "Response time" + }, + "common": { + "back": "Back", + "showMore": "Show more", + "showLess": "Show less" + } +} diff --git a/packages/shared-i18n/src/translations/help/es.json b/packages/shared-i18n/src/translations/help/es.json new file mode 100644 index 000000000..9476514a7 --- /dev/null +++ b/packages/shared-i18n/src/translations/help/es.json @@ -0,0 +1,65 @@ +{ + "title": "Ayuda y Soporte", + "subtitle": "Encuentra respuestas y aprende a usar la aplicación", + "searchPlaceholder": "Buscar en la ayuda...", + "sections": { + "faq": "FAQ", + "features": "Características", + "shortcuts": "Atajos de teclado", + "gettingStarted": "Primeros pasos", + "changelog": "Novedades", + "contact": "Contacto" + }, + "search": { + "noResults": "Sin resultados para \"{query}\"", + "resultsCount": "{count} resultados encontrados", + "searching": "Buscando..." + }, + "faq": { + "noItems": "No hay preguntas frecuentes disponibles", + "categories": { + "general": "General", + "account": "Cuenta", + "billing": "Facturación", + "features": "Características", + "technical": "Técnico", + "privacy": "Privacidad" + } + }, + "features": { + "noItems": "No hay características documentadas", + "comingSoon": "Próximamente", + "learnMore": "Saber más" + }, + "shortcuts": { + "noItems": "No hay atajos de teclado disponibles" + }, + "gettingStarted": { + "noItems": "No hay guías disponibles", + "estimatedTime": "Tiempo estimado", + "difficulty": { + "beginner": "Principiante", + "intermediate": "Intermedio", + "advanced": "Avanzado" + } + }, + "changelog": { + "noItems": "Sin actualizaciones", + "types": { + "major": "Principal", + "minor": "Menor", + "patch": "Corrección", + "beta": "Beta" + } + }, + "contact": { + "noInfo": "Información de contacto no disponible", + "email": "Envíanos un correo", + "responseTime": "Tiempo de respuesta" + }, + "common": { + "back": "Volver", + "showMore": "Ver más", + "showLess": "Ver menos" + } +} diff --git a/packages/shared-i18n/src/translations/help/fr.json b/packages/shared-i18n/src/translations/help/fr.json new file mode 100644 index 000000000..079d16561 --- /dev/null +++ b/packages/shared-i18n/src/translations/help/fr.json @@ -0,0 +1,65 @@ +{ + "title": "Aide & Support", + "subtitle": "Trouvez des réponses et apprenez à utiliser l'application", + "searchPlaceholder": "Rechercher dans l'aide...", + "sections": { + "faq": "FAQ", + "features": "Fonctionnalités", + "shortcuts": "Raccourcis clavier", + "gettingStarted": "Premiers pas", + "changelog": "Nouveautés", + "contact": "Contact" + }, + "search": { + "noResults": "Aucun résultat pour \"{query}\"", + "resultsCount": "{count} résultats trouvés", + "searching": "Recherche..." + }, + "faq": { + "noItems": "Aucune FAQ disponible", + "categories": { + "general": "Général", + "account": "Compte", + "billing": "Facturation", + "features": "Fonctionnalités", + "technical": "Technique", + "privacy": "Confidentialité" + } + }, + "features": { + "noItems": "Aucune fonctionnalité documentée", + "comingSoon": "Bientôt disponible", + "learnMore": "En savoir plus" + }, + "shortcuts": { + "noItems": "Aucun raccourci clavier disponible" + }, + "gettingStarted": { + "noItems": "Aucun guide disponible", + "estimatedTime": "Temps estimé", + "difficulty": { + "beginner": "Débutant", + "intermediate": "Intermédiaire", + "advanced": "Avancé" + } + }, + "changelog": { + "noItems": "Aucune mise à jour", + "types": { + "major": "Majeure", + "minor": "Mineure", + "patch": "Correctif", + "beta": "Bêta" + } + }, + "contact": { + "noInfo": "Informations de contact non disponibles", + "email": "Nous contacter", + "responseTime": "Délai de réponse" + }, + "common": { + "back": "Retour", + "showMore": "Voir plus", + "showLess": "Voir moins" + } +} diff --git a/packages/shared-i18n/src/translations/help/index.ts b/packages/shared-i18n/src/translations/help/index.ts new file mode 100644 index 000000000..75c1c2a31 --- /dev/null +++ b/packages/shared-i18n/src/translations/help/index.ts @@ -0,0 +1,108 @@ +/** + * Help translations exports + */ + +import en from './en.json'; +import de from './de.json'; +import it from './it.json'; +import fr from './fr.json'; +import es from './es.json'; + +export { en, de, it, fr, es }; + +/** + * Help translations type structure + */ +export interface HelpTranslations { + title: string; + subtitle: string; + searchPlaceholder: string; + sections: { + faq: string; + features: string; + shortcuts: string; + gettingStarted: string; + changelog: string; + contact: string; + }; + search: { + noResults: string; + resultsCount: string; + searching: string; + }; + faq: { + noItems: string; + categories: { + general: string; + account: string; + billing: string; + features: string; + technical: string; + privacy: string; + }; + }; + features: { + noItems: string; + comingSoon: string; + learnMore: string; + }; + shortcuts: { + noItems: string; + }; + gettingStarted: { + noItems: string; + estimatedTime: string; + difficulty: { + beginner: string; + intermediate: string; + advanced: string; + }; + }; + changelog: { + noItems: string; + types: { + major: string; + minor: string; + patch: string; + beta: string; + }; + }; + contact: { + noInfo: string; + email: string; + responseTime: string; + }; + common: { + back: string; + showMore: string; + showLess: string; + }; +} + +/** + * Supported help locales + */ +export type HelpLocale = 'en' | 'de' | 'it' | 'fr' | 'es'; + +/** + * All help translations by locale + */ +export const helpTranslations: Record = { + en, + de, + it, + fr, + es, +}; + +/** + * Get help translations by locale + */ +export function getHelpTranslations(locale: string): HelpTranslations { + const supportedLocale = locale as HelpLocale; + if (supportedLocale in helpTranslations) { + return helpTranslations[supportedLocale]; + } + // Default to English + return helpTranslations.en; +} diff --git a/packages/shared-i18n/src/translations/help/it.json b/packages/shared-i18n/src/translations/help/it.json new file mode 100644 index 000000000..732c6d2c9 --- /dev/null +++ b/packages/shared-i18n/src/translations/help/it.json @@ -0,0 +1,65 @@ +{ + "title": "Aiuto & Supporto", + "subtitle": "Trova risposte e impara a usare l'app", + "searchPlaceholder": "Cerca nell'aiuto...", + "sections": { + "faq": "FAQ", + "features": "Funzionalità", + "shortcuts": "Scorciatoie", + "gettingStarted": "Primi passi", + "changelog": "Novità", + "contact": "Contatti" + }, + "search": { + "noResults": "Nessun risultato per \"{query}\"", + "resultsCount": "{count} risultati trovati", + "searching": "Ricerca..." + }, + "faq": { + "noItems": "Nessuna FAQ disponibile", + "categories": { + "general": "Generale", + "account": "Account", + "billing": "Fatturazione", + "features": "Funzionalità", + "technical": "Tecnico", + "privacy": "Privacy" + } + }, + "features": { + "noItems": "Nessuna funzionalità documentata", + "comingSoon": "Prossimamente", + "learnMore": "Scopri di più" + }, + "shortcuts": { + "noItems": "Nessuna scorciatoia disponibile" + }, + "gettingStarted": { + "noItems": "Nessuna guida disponibile", + "estimatedTime": "Tempo stimato", + "difficulty": { + "beginner": "Principiante", + "intermediate": "Intermedio", + "advanced": "Avanzato" + } + }, + "changelog": { + "noItems": "Nessun aggiornamento", + "types": { + "major": "Principale", + "minor": "Secondario", + "patch": "Correzione", + "beta": "Beta" + } + }, + "contact": { + "noInfo": "Informazioni di contatto non disponibili", + "email": "Inviaci un'email", + "responseTime": "Tempo di risposta" + }, + "common": { + "back": "Indietro", + "showMore": "Mostra di più", + "showLess": "Mostra meno" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e76e3b239..cffd9af6e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1040,6 +1040,15 @@ importers: '@manacore/shared-feedback-ui': specifier: workspace:* version: link:../../../../packages/shared-feedback-ui + '@manacore/shared-help-content': + specifier: workspace:* + version: link:../../../../packages/shared-help-content + '@manacore/shared-help-types': + specifier: workspace:* + version: link:../../../../packages/shared-help-types + '@manacore/shared-help-ui': + specifier: workspace:* + version: link:../../../../packages/shared-help-ui '@manacore/shared-i18n': specifier: workspace:* version: link:../../../../packages/shared-i18n @@ -1067,6 +1076,18 @@ importers: '@manacore/shared-utils': specifier: workspace:* version: link:../../../../packages/shared-utils + d3-force: + specifier: ^3.0.0 + version: 3.0.0 + d3-selection: + specifier: ^3.0.0 + version: 3.0.0 + d3-zoom: + specifier: ^3.0.0 + version: 3.0.0 + lucide-svelte: + specifier: ^0.556.0 + version: 0.556.0(svelte@5.44.0) svelte-i18n: specifier: ^4.0.1 version: 4.0.1(svelte@5.44.0) @@ -1083,6 +1104,15 @@ importers: '@tailwindcss/vite': specifier: ^4.1.7 version: 4.1.17(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)) + '@types/d3-force': + specifier: ^3.0.10 + version: 3.0.10 + '@types/d3-selection': + specifier: ^3.0.11 + version: 3.0.11 + '@types/d3-zoom': + specifier: ^3.0.8 + version: 3.0.8 '@types/node': specifier: ^20.0.0 version: 20.19.25 @@ -3933,6 +3963,91 @@ importers: specifier: ^5.7.3 version: 5.9.3 + packages/shared-help-content: + dependencies: + '@manacore/shared-help-types': + specifier: workspace:* + version: link:../shared-help-types + fuse.js: + specifier: ^7.0.0 + version: 7.1.0 + gray-matter: + specifier: ^4.0.3 + version: 4.0.3 + marked: + specifier: ^15.0.4 + version: 15.0.12 + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.19.1 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + zod: + specifier: ^3.24.1 + version: 3.25.76 + + packages/shared-help-mobile: + dependencies: + '@manacore/shared-help-content': + specifier: workspace:* + version: link:../shared-help-content + '@manacore/shared-help-types': + specifier: workspace:* + version: link:../shared-help-types + expo: + specifier: '>=52.0.0' + version: 54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + nativewind: + specifier: ^4.0.0 + version: 4.2.1(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1)(tailwindcss@4.1.17) + react: + specifier: 18.3.1 + version: 18.3.1 + react-native: + specifier: '>=0.76.0' + version: 0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1) + devDependencies: + '@types/react': + specifier: ~18.3.12 + version: 18.3.27 + typescript: + specifier: ~5.8.3 + version: 5.8.3 + + packages/shared-help-types: + dependencies: + zod: + specifier: ^3.24.1 + version: 3.25.76 + devDependencies: + typescript: + specifier: ^5.7.3 + version: 5.9.3 + + packages/shared-help-ui: + dependencies: + '@manacore/shared-help-content': + specifier: workspace:* + version: link:../shared-help-content + '@manacore/shared-help-types': + specifier: workspace:* + version: link:../shared-help-types + '@manacore/shared-icons': + specifier: workspace:* + version: link:../shared-icons + devDependencies: + svelte: + specifier: ^5.0.0 + version: 5.44.0 + svelte-check: + specifier: ^4.0.0 + version: 4.3.4(picomatch@4.0.3)(svelte@5.44.0)(typescript@5.9.3) + typescript: + specifier: ^5.7.3 + version: 5.9.3 + packages/shared-i18n: devDependencies: svelte: @@ -13502,6 +13617,10 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + fuse.js@7.1.0: + resolution: {integrity: sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==} + engines: {node: '>=10'} + gauge@3.0.2: resolution: {integrity: sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==} engines: {node: '>=10'} @@ -15113,6 +15232,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@15.0.12: + resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} + engines: {node: '>= 18'} + hasBin: true + marked@16.4.2: resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} engines: {node: '>= 20'} @@ -22226,6 +22350,83 @@ snapshots: - supports-color - utf-8-validate + '@expo/cli@54.0.16(expo-router@6.0.15)(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))': + dependencies: + '@0no-co/graphql.web': 1.2.0 + '@expo/code-signing-certificates': 0.0.5 + '@expo/config': 12.0.10 + '@expo/config-plugins': 54.0.2 + '@expo/devcert': 1.2.0 + '@expo/env': 2.0.7 + '@expo/image-utils': 0.8.7 + '@expo/json-file': 10.0.7 + '@expo/mcp-tunnel': 0.1.0 + '@expo/metro': 54.1.0 + '@expo/metro-config': 54.0.9(expo@54.0.25) + '@expo/osascript': 2.3.7 + '@expo/package-manager': 1.9.8 + '@expo/plist': 0.4.7 + '@expo/prebuild-config': 54.0.6(expo@54.0.25) + '@expo/schema-utils': 0.1.7 + '@expo/spawn-async': 1.7.2 + '@expo/ws-tunnel': 1.0.6 + '@expo/xcpretty': 4.3.2 + '@react-native/dev-middleware': 0.81.5 + '@urql/core': 5.2.0 + '@urql/exchange-retry': 1.3.2(@urql/core@5.2.0) + accepts: 1.3.8 + arg: 5.0.2 + better-opn: 3.0.2 + bplist-creator: 0.1.0 + bplist-parser: 0.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + compression: 1.8.1 + connect: 3.7.0 + debug: 4.4.3 + env-editor: 0.4.2 + expo: 54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + expo-server: 1.0.4 + freeport-async: 2.0.0 + getenv: 2.0.0 + glob: 10.5.0 + lan-network: 0.1.7 + minimatch: 9.0.5 + node-forge: 1.3.2 + npm-package-arg: 11.0.3 + ora: 3.4.0 + picomatch: 3.0.1 + pretty-bytes: 5.6.0 + pretty-format: 29.7.0 + progress: 2.0.3 + prompts: 2.4.2 + qrcode-terminal: 0.11.0 + require-from-string: 2.0.2 + requireg: 0.2.2 + resolve: 1.22.11 + resolve-from: 5.0.0 + resolve.exports: 2.0.3 + semver: 7.7.3 + send: 0.19.1 + slugify: 1.6.6 + source-map-support: 0.5.21 + stacktrace-parser: 0.1.11 + structured-headers: 0.4.1 + tar: 7.5.2 + terminal-link: 2.1.1 + undici: 6.22.0 + wrap-ansi: 7.0.0 + ws: 8.18.3 + optionalDependencies: + expo-router: 6.0.15(hwqworfppxvioilmgvd7t3oifm) + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1) + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - graphql + - supports-color + - utf-8-validate + '@expo/cli@54.0.16(expo-router@6.0.15)(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))': dependencies: '@0no-co/graphql.web': 1.2.0 @@ -22440,6 +22641,13 @@ snapshots: react: 19.1.0 react-native: 0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0) + '@expo/devtools@0.1.7(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1)': + dependencies: + chalk: 4.1.2 + optionalDependencies: + react: 18.3.1 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1) + '@expo/devtools@0.1.7(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)': dependencies: chalk: 4.1.2 @@ -22745,6 +22953,19 @@ snapshots: optionalDependencies: react-dom: 19.1.0(react@19.1.0) + '@expo/metro-runtime@6.1.2(expo@54.0.25)(react-dom@19.1.0(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1)': + dependencies: + anser: 1.4.10 + expo: 54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + pretty-format: 29.7.0 + react: 18.3.1 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1) + stacktrace-parser: 0.1.11 + whatwg-fetch: 3.6.20 + optionalDependencies: + react-dom: 19.1.0(react@18.3.1) + optional: true + '@expo/metro-runtime@6.1.2(expo@54.0.25)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)': dependencies: anser: 1.4.10 @@ -22988,6 +23209,12 @@ snapshots: react: 19.1.0 react-native: 0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0) + '@expo/vector-icons@15.0.3(expo-font@14.0.10(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1)': + dependencies: + expo-font: 14.0.10(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1) + '@expo/vector-icons@15.0.3(expo-font@14.0.10(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)': dependencies: expo-font: 14.0.10(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) @@ -24570,6 +24797,19 @@ snapshots: '@radix-ui/primitive@1.1.3': {} + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.1.0(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.1.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1) + react: 18.3.1 + react-dom: 19.1.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 19.2.3(@types/react@18.3.27) + optional: true + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.0) @@ -24587,18 +24827,55 @@ snapshots: '@babel/runtime': 7.28.4 react: 18.3.1 + '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.27)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 + optional: true + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.7)(react@19.1.0)': dependencies: react: 19.1.0 optionalDependencies: '@types/react': 19.2.7 + '@radix-ui/react-context@1.1.2(@types/react@18.3.27)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 + optional: true + '@radix-ui/react-context@1.1.2(@types/react@19.2.7)(react@19.1.0)': dependencies: react: 19.1.0 optionalDependencies: '@types/react': 19.2.7 + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.1.0(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.1.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.1.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.1.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.1.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.1.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 19.1.0(react@18.3.1) + react-remove-scroll: 2.7.1(@types/react@18.3.27)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 19.2.3(@types/react@18.3.27) + optional: true + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -24621,12 +24898,33 @@ snapshots: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) + '@radix-ui/react-direction@1.1.1(@types/react@18.3.27)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 + optional: true + '@radix-ui/react-direction@1.1.1(@types/react@19.2.7)(react@19.1.0)': dependencies: react: 19.1.0 optionalDependencies: '@types/react': 19.2.7 + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.1.0(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.1.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@18.3.27)(react@18.3.1) + react: 18.3.1 + react-dom: 19.1.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 19.2.3(@types/react@18.3.27) + optional: true + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -24640,12 +24938,31 @@ snapshots: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) + '@radix-ui/react-focus-guards@1.1.3(@types/react@18.3.27)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 + optional: true + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.7)(react@19.1.0)': dependencies: react: 19.1.0 optionalDependencies: '@types/react': 19.2.7 + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.1.0(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.1.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1) + react: 18.3.1 + react-dom: 19.1.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 19.2.3(@types/react@18.3.27) + optional: true + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.0) @@ -24657,6 +24974,14 @@ snapshots: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) + '@radix-ui/react-id@1.1.1(@types/react@18.3.27)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 + optional: true + '@radix-ui/react-id@1.1.1(@types/react@19.2.7)(react@19.1.0)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.0) @@ -24664,6 +24989,17 @@ snapshots: optionalDependencies: '@types/react': 19.2.7 + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.1.0(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.1.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) + react: 18.3.1 + react-dom: 19.1.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 19.2.3(@types/react@18.3.27) + optional: true + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -24674,6 +25010,17 @@ snapshots: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.1.0(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) + react: 18.3.1 + react-dom: 19.1.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 19.2.3(@types/react@18.3.27) + optional: true + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.0) @@ -24684,6 +25031,16 @@ snapshots: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.1.0(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1) + react: 18.3.1 + react-dom: 19.1.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 19.2.3(@types/react@18.3.27) + optional: true + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.1.0) @@ -24693,6 +25050,24 @@ snapshots: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.1.0(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.1.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.1.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1) + react: 18.3.1 + react-dom: 19.1.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 19.2.3(@types/react@18.3.27) + optional: true + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -24716,6 +25091,14 @@ snapshots: '@radix-ui/react-compose-refs': 1.0.0(react@18.3.1) react: 18.3.1 + '@radix-ui/react-slot@1.2.0(@types/react@18.3.27)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 + optional: true + '@radix-ui/react-slot@1.2.0(@types/react@19.2.7)(react@19.1.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.0) @@ -24723,6 +25106,14 @@ snapshots: optionalDependencies: '@types/react': 19.2.7 + '@radix-ui/react-slot@1.2.3(@types/react@18.3.27)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 + optional: true + '@radix-ui/react-slot@1.2.3(@types/react@19.2.7)(react@19.1.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.0) @@ -24730,6 +25121,23 @@ snapshots: optionalDependencies: '@types/react': 19.2.7 + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.1.0(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.1.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.1.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.1.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1) + react: 18.3.1 + react-dom: 19.1.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@types/react-dom': 19.2.3(@types/react@18.3.27) + optional: true + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -24746,12 +25154,28 @@ snapshots: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.27)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 + optional: true + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.7)(react@19.1.0)': dependencies: react: 19.1.0 optionalDependencies: '@types/react': 19.2.7 + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@18.3.27)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 + optional: true + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.7)(react@19.1.0)': dependencies: '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.7)(react@19.1.0) @@ -24760,6 +25184,14 @@ snapshots: optionalDependencies: '@types/react': 19.2.7 + '@radix-ui/react-use-effect-event@0.0.2(@types/react@18.3.27)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 + optional: true + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.7)(react@19.1.0)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.1.0) @@ -24767,6 +25199,14 @@ snapshots: optionalDependencies: '@types/react': 19.2.7 + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@18.3.27)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 + optional: true + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.7)(react@19.1.0)': dependencies: '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.1.0) @@ -24774,6 +25214,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.7 + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.3.27)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.27 + optional: true + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.7)(react@19.1.0)': dependencies: react: 19.1.0 @@ -25281,6 +25728,15 @@ snapshots: optionalDependencies: '@types/react': 19.2.7 + '@react-native/virtualized-lists@0.81.5(@types/react@18.3.27)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1)': + dependencies: + invariant: 2.2.4 + nullthrows: 1.1.1 + react: 18.3.1 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + '@react-native/virtualized-lists@0.81.5(@types/react@19.2.7)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@18.3.1))(react@18.3.1)': dependencies: invariant: 2.2.4 @@ -25338,6 +25794,20 @@ snapshots: transitivePeerDependencies: - '@react-native-masked-view/masked-view' + '@react-navigation/bottom-tabs@7.8.6(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1)': + dependencies: + '@react-navigation/elements': 2.8.3(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + '@react-navigation/native': 7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + color: 4.2.3 + react: 18.3.1 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1) + react-native-safe-area-context: 5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + react-native-screens: 4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + sf-symbols-typescript: 2.1.0 + transitivePeerDependencies: + - '@react-native-masked-view/masked-view' + optional: true + '@react-navigation/bottom-tabs@7.8.6(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)': dependencies: '@react-navigation/elements': 2.8.3(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) @@ -25443,6 +25913,23 @@ snapshots: - '@react-native-masked-view/masked-view' optional: true + '@react-navigation/drawer@7.7.4(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1)': + dependencies: + '@react-navigation/elements': 2.8.3(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + '@react-navigation/native': 7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + color: 4.2.3 + react: 18.3.1 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1) + react-native-drawer-layout: 4.2.0(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + react-native-gesture-handler: 2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + react-native-reanimated: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + react-native-safe-area-context: 5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + react-native-screens: 4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + use-latest-callback: 0.2.6(react@18.3.1) + transitivePeerDependencies: + - '@react-native-masked-view/masked-view' + optional: true + '@react-navigation/drawer@7.7.4(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)': dependencies: '@react-navigation/elements': 2.8.3(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) @@ -25505,6 +25992,17 @@ snapshots: use-latest-callback: 0.2.6(react@19.1.0) use-sync-external-store: 1.6.0(react@19.1.0) + '@react-navigation/elements@2.8.3(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1)': + dependencies: + '@react-navigation/native': 7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + color: 4.2.3 + react: 18.3.1 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1) + react-native-safe-area-context: 5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + use-latest-callback: 0.2.6(react@18.3.1) + use-sync-external-store: 1.6.0(react@18.3.1) + optional: true + '@react-navigation/elements@2.8.3(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)': dependencies: '@react-navigation/native': 7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) @@ -25557,6 +26055,21 @@ snapshots: transitivePeerDependencies: - '@react-native-masked-view/masked-view' + '@react-navigation/native-stack@7.8.0(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1)': + dependencies: + '@react-navigation/elements': 2.8.3(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + '@react-navigation/native': 7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + color: 4.2.3 + react: 18.3.1 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1) + react-native-safe-area-context: 5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + react-native-screens: 4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + sf-symbols-typescript: 2.1.0 + warn-once: 0.1.1 + transitivePeerDependencies: + - '@react-native-masked-view/masked-view' + optional: true + '@react-navigation/native-stack@7.8.0(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)': dependencies: '@react-navigation/elements': 2.8.3(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) @@ -25601,6 +26114,17 @@ snapshots: react-native: 0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0) use-latest-callback: 0.2.6(react@19.1.0) + '@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1)': + dependencies: + '@react-navigation/core': 7.13.2(react@18.3.1) + escape-string-regexp: 4.0.0 + fast-deep-equal: 3.1.3 + nanoid: 3.3.11 + react: 18.3.1 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1) + use-latest-callback: 0.2.6(react@18.3.1) + optional: true + '@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)': dependencies: '@react-navigation/core': 7.13.2(react@19.1.0) @@ -26685,6 +27209,19 @@ snapshots: jest: 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)) optional: true + '@testing-library/react-native@13.3.3(jest@30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react-test-renderer@19.1.0(react@18.3.1))(react@18.3.1)': + dependencies: + jest-matcher-utils: 30.2.0 + picocolors: 1.1.1 + pretty-format: 30.2.0 + react: 18.3.1 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1) + react-test-renderer: 19.1.0(react@18.3.1) + redent: 3.0.0 + optionalDependencies: + jest: 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)) + optional: true + '@testing-library/react-native@13.3.3(jest@30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: jest-matcher-utils: 30.2.0 @@ -27053,6 +27590,11 @@ snapshots: dependencies: '@types/react': 18.3.27 + '@types/react-dom@19.2.3(@types/react@18.3.27)': + dependencies: + '@types/react': 18.3.27 + optional: true + '@types/react-dom@19.2.3(@types/react@19.2.7)': dependencies: '@types/react': 19.2.7 @@ -31760,6 +32302,16 @@ snapshots: transitivePeerDependencies: - supports-color + expo-asset@12.0.10(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1): + dependencies: + '@expo/image-utils': 0.8.7 + expo: 54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + expo-constants: 18.0.10(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1)) + react: 18.3.1 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1) + transitivePeerDependencies: + - supports-color + expo-asset@12.0.10(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0): dependencies: '@expo/image-utils': 0.8.7 @@ -31857,6 +32409,15 @@ snapshots: transitivePeerDependencies: - supports-color + expo-constants@18.0.10(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1)): + dependencies: + '@expo/config': 12.0.10 + '@expo/env': 2.0.7 + expo: 54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1) + transitivePeerDependencies: + - supports-color + expo-constants@18.0.10(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)): dependencies: '@expo/config': 12.0.10 @@ -32028,6 +32589,11 @@ snapshots: expo: 54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) react-native: 0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0) + expo-file-system@19.0.19(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1)): + dependencies: + expo: 54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1) + expo-file-system@19.0.19(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)): dependencies: expo: 54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) @@ -32074,6 +32640,13 @@ snapshots: react: 19.1.0 react-native: 0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0) + expo-font@14.0.10(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1): + dependencies: + expo: 54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + fontfaceobserver: 2.3.0 + react: 18.3.1 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1) + expo-font@14.0.10(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0): dependencies: expo: 54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) @@ -32148,6 +32721,11 @@ snapshots: expo: 54.0.13(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) react: 19.1.0 + expo-keep-awake@15.0.7(expo@54.0.25)(react@18.3.1): + dependencies: + expo: 54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + react: 18.3.1 + expo-keep-awake@15.0.7(expo@54.0.25)(react@19.1.0): dependencies: expo: 54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) @@ -32215,6 +32793,17 @@ snapshots: - expo - supports-color + expo-linking@8.0.9(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1): + dependencies: + expo-constants: 18.0.10(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1)) + invariant: 2.2.4 + react: 18.3.1 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1) + transitivePeerDependencies: + - expo + - supports-color + optional: true + expo-linking@8.0.9(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0): dependencies: expo-constants: 18.0.10(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)) @@ -32345,6 +32934,12 @@ snapshots: react: 19.1.0 react-native: 0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0) + expo-modules-core@3.0.26(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1): + dependencies: + invariant: 2.2.4 + react: 18.3.1 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1) + expo-modules-core@3.0.26(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0): dependencies: invariant: 2.2.4 @@ -32503,6 +33098,53 @@ snapshots: - '@types/react-dom' - supports-color + expo-router@6.0.15(hwqworfppxvioilmgvd7t3oifm): + dependencies: + '@expo/metro-runtime': 6.1.2(expo@54.0.25)(react-dom@19.1.0(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + '@expo/schema-utils': 0.1.7 + '@radix-ui/react-slot': 1.2.0(@types/react@18.3.27)(react@18.3.1) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.1.0(react@18.3.1))(react@18.3.1) + '@react-navigation/bottom-tabs': 7.8.6(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + '@react-navigation/native': 7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + '@react-navigation/native-stack': 7.8.0(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + client-only: 0.0.1 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + expo: 54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + expo-constants: 18.0.10(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1)) + expo-linking: 8.0.9(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + expo-server: 1.0.4 + fast-deep-equal: 3.1.3 + invariant: 2.2.4 + nanoid: 3.3.11 + query-string: 7.1.3 + react: 18.3.1 + react-fast-compare: 3.2.2 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1) + react-native-is-edge-to-edge: 1.2.1(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + react-native-safe-area-context: 5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + react-native-screens: 4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + semver: 7.6.3 + server-only: 0.0.1 + sf-symbols-typescript: 2.1.0 + shallowequal: 1.1.0 + use-latest-callback: 0.2.6(react@18.3.1) + vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.1.0(react@18.3.1))(react@18.3.1) + optionalDependencies: + '@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + '@testing-library/react-native': 13.3.3(jest@30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react-test-renderer@19.1.0(react@18.3.1))(react@18.3.1) + react-dom: 19.1.0(react@18.3.1) + react-native-gesture-handler: 2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + react-native-reanimated: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + react-native-web: 0.21.2(react-dom@19.1.0(react@18.3.1))(react@18.3.1) + react-server-dom-webpack: 19.0.0(react-dom@19.1.0(react@18.3.1))(react@18.3.1)(webpack@5.100.2(esbuild@0.27.0)) + transitivePeerDependencies: + - '@react-native-masked-view/masked-view' + - '@types/react' + - '@types/react-dom' + - supports-color + optional: true + expo-router@6.0.15(jiucxy5ca3jdtbnulaxuc46jdq): dependencies: '@expo/metro-runtime': 6.1.2(expo@54.0.25)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) @@ -33017,6 +33659,43 @@ snapshots: - supports-color - utf-8-validate + expo@54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.28.4 + '@expo/cli': 54.0.16(expo-router@6.0.15)(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1)) + '@expo/config': 12.0.10 + '@expo/config-plugins': 54.0.2 + '@expo/devtools': 0.1.7(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + '@expo/fingerprint': 0.15.3 + '@expo/metro': 54.1.0 + '@expo/metro-config': 54.0.9(expo@54.0.25) + '@expo/vector-icons': 15.0.3(expo-font@14.0.10(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + '@ungap/structured-clone': 1.3.0 + babel-preset-expo: 54.0.7(@babel/core@7.28.5)(@babel/runtime@7.28.4)(expo@54.0.25)(react-refresh@0.14.2) + expo-asset: 12.0.10(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + expo-constants: 18.0.10(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1)) + expo-file-system: 19.0.19(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1)) + expo-font: 14.0.10(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + expo-keep-awake: 15.0.7(expo@54.0.25)(react@18.3.1) + expo-modules-autolinking: 3.0.22 + expo-modules-core: 3.0.26(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + pretty-format: 29.7.0 + react: 18.3.1 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1) + react-refresh: 0.14.2 + whatwg-url-without-unicode: 8.0.0-3 + optionalDependencies: + '@expo/metro-runtime': 6.1.2(expo@54.0.25)(react-dom@19.1.0(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + react-native-webview: 13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + transitivePeerDependencies: + - '@babel/core' + - '@modelcontextprotocol/sdk' + - bufferutil + - expo-router + - graphql + - supports-color + - utf-8-validate + expo@54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0): dependencies: '@babel/runtime': 7.28.4 @@ -33568,6 +34247,8 @@ snapshots: functions-have-names@1.2.3: {} + fuse.js@7.1.0: {} + gauge@3.0.2: dependencies: aproba: 2.1.0 @@ -36129,6 +36810,8 @@ snapshots: markdown-table@3.0.4: {} + marked@15.0.12: {} + marked@16.4.2: {} marked@17.0.1: {} @@ -37463,6 +38146,20 @@ snapshots: - react-native-svg - supports-color + nativewind@4.2.1(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1)(tailwindcss@4.1.17): + dependencies: + comment-json: 4.4.1 + debug: 4.4.3 + react-native-css-interop: 0.2.1(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1)(tailwindcss@4.1.17) + tailwindcss: 4.1.17 + transitivePeerDependencies: + - react + - react-native + - react-native-reanimated + - react-native-safe-area-context + - react-native-svg + - supports-color + nativewind@4.2.1(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1)): dependencies: comment-json: 4.4.1 @@ -38305,6 +39002,12 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-dom@19.1.0(react@18.3.1): + dependencies: + react: 18.3.1 + scheduler: 0.26.0 + optional: true + react-dom@19.1.0(react@19.1.0): dependencies: react: 19.1.0 @@ -38486,6 +39189,23 @@ snapshots: transitivePeerDependencies: - supports-color + react-native-css-interop@0.2.1(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1)(tailwindcss@4.1.17): + dependencies: + '@babel/helper-module-imports': 7.27.1 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + debug: 4.4.3 + lightningcss: 1.27.0 + react: 18.3.1 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1) + react-native-reanimated: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + semver: 7.7.3 + tailwindcss: 4.1.17 + optionalDependencies: + react-native-safe-area-context: 5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + transitivePeerDependencies: + - supports-color + react-native-css-interop@0.2.1(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1)): dependencies: '@babel/helper-module-imports': 7.27.1 @@ -38572,6 +39292,16 @@ snapshots: use-latest-callback: 0.2.6(react@19.1.0) optional: true + react-native-drawer-layout@4.2.0(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1): + dependencies: + color: 4.2.3 + react: 18.3.1 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1) + react-native-gesture-handler: 2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + react-native-reanimated: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + use-latest-callback: 0.2.6(react@18.3.1) + optional: true + react-native-drawer-layout@4.2.0(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0): dependencies: color: 4.2.3 @@ -38611,6 +39341,15 @@ snapshots: react: 19.1.0 react-native: 0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0) + react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1): + dependencies: + '@egjs/hammerjs': 2.0.17 + hoist-non-react-statics: 3.3.2 + invariant: 2.2.4 + react: 18.3.1 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1) + optional: true + react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0): dependencies: '@egjs/hammerjs': 2.0.17 @@ -38645,6 +39384,11 @@ snapshots: react: 19.1.0 react-native: 0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0) + react-native-is-edge-to-edge@1.2.1(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1) + react-native-is-edge-to-edge@1.2.1(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 @@ -38769,6 +39513,15 @@ snapshots: react-native-worklets: 0.6.1(@babel/core@7.28.5)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) semver: 7.7.2 + react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/core': 7.28.5 + react: 18.3.1 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1) + react-native-is-edge-to-edge: 1.2.1(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + react-native-worklets: 0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + semver: 7.7.2 + react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@18.3.1))(react@18.3.1))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@18.3.1))(react@18.3.1): dependencies: '@babel/core': 7.28.5 @@ -38807,6 +39560,12 @@ snapshots: react: 19.1.0 react-native: 0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0) + react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1) + optional: true + react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 @@ -38825,6 +39584,15 @@ snapshots: react-native-is-edge-to-edge: 1.2.1(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0) warn-once: 0.1.1 + react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-freeze: 1.0.4(react@18.3.1) + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1) + react-native-is-edge-to-edge: 1.2.1(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + warn-once: 0.1.1 + optional: true + react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0): dependencies: react: 19.1.0 @@ -38891,6 +39659,22 @@ snapshots: transitivePeerDependencies: - encoding + react-native-web@0.21.2(react-dom@19.1.0(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.28.4 + '@react-native/normalize-colors': 0.74.89 + fbjs: 3.0.5 + inline-style-prefixer: 7.0.1 + memoize-one: 6.0.0 + nullthrows: 1.1.1 + postcss-value-parser: 4.2.0 + react: 18.3.1 + react-dom: 19.1.0(react@18.3.1) + styleq: 0.1.3 + transitivePeerDependencies: + - encoding + optional: true + react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@babel/runtime': 7.28.4 @@ -38930,6 +39714,14 @@ snapshots: react-native: 0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0) optional: true + react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1): + dependencies: + escape-string-regexp: 4.0.0 + invariant: 2.2.4 + react: 18.3.1 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1) + optional: true + react-native-webview@13.12.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0): dependencies: escape-string-regexp: 4.0.0 @@ -38976,6 +39768,25 @@ snapshots: transitivePeerDependencies: - supports-color + react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/core': 7.28.5 + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-class-properties': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-classes': 7.28.4(@babel/core@7.28.5) + '@babel/plugin-transform-nullish-coalescing-operator': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-optional-chaining': 7.28.5(@babel/core@7.28.5) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.28.5) + '@babel/preset-typescript': 7.28.5(@babel/core@7.28.5) + convert-source-map: 2.0.0 + react: 18.3.1 + react-native: 0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1) + semver: 7.7.2 + transitivePeerDependencies: + - supports-color + react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@18.3.1))(react@18.3.1): dependencies: '@babel/core': 7.28.5 @@ -39172,6 +39983,53 @@ snapshots: - supports-color - utf-8-validate + react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1): + dependencies: + '@jest/create-cache-key-function': 29.7.0 + '@react-native/assets-registry': 0.81.5 + '@react-native/codegen': 0.81.5(@babel/core@7.28.5) + '@react-native/community-cli-plugin': 0.81.5 + '@react-native/gradle-plugin': 0.81.5 + '@react-native/js-polyfills': 0.81.5 + '@react-native/normalize-colors': 0.81.5 + '@react-native/virtualized-lists': 0.81.5(@types/react@18.3.27)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + abort-controller: 3.0.0 + anser: 1.4.10 + ansi-regex: 5.0.1 + babel-jest: 29.7.0(@babel/core@7.28.5) + babel-plugin-syntax-hermes-parser: 0.29.1 + base64-js: 1.5.1 + commander: 12.1.0 + flow-enums-runtime: 0.0.6 + glob: 7.2.3 + invariant: 2.2.4 + jest-environment-node: 29.7.0 + memoize-one: 5.2.1 + metro-runtime: 0.83.3 + metro-source-map: 0.83.3 + nullthrows: 1.1.1 + pretty-format: 29.7.0 + promise: 8.3.0 + react: 18.3.1 + react-devtools-core: 6.1.5 + react-refresh: 0.14.2 + regenerator-runtime: 0.13.11 + scheduler: 0.26.0 + semver: 7.7.3 + stacktrace-parser: 0.1.11 + whatwg-fetch: 3.6.20 + ws: 6.2.3 + yargs: 17.7.2 + optionalDependencies: + '@types/react': 18.3.27 + transitivePeerDependencies: + - '@babel/core' + - '@react-native-community/cli' + - '@react-native/metro-config' + - bufferutil + - supports-color + - utf-8-validate + react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@18.3.1): dependencies: '@jest/create-cache-key-function': 29.7.0 @@ -39270,6 +40128,15 @@ snapshots: react-refresh@0.17.0: {} + react-remove-scroll-bar@2.3.8(@types/react@18.3.27)(react@18.3.1): + dependencies: + react: 18.3.1 + react-style-singleton: 2.2.3(@types/react@18.3.27)(react@18.3.1) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.27 + optional: true + react-remove-scroll-bar@2.3.8(@types/react@19.2.7)(react@19.1.0): dependencies: react: 19.1.0 @@ -39278,6 +40145,18 @@ snapshots: optionalDependencies: '@types/react': 19.2.7 + react-remove-scroll@2.7.1(@types/react@18.3.27)(react@18.3.1): + dependencies: + react: 18.3.1 + react-remove-scroll-bar: 2.3.8(@types/react@18.3.27)(react@18.3.1) + react-style-singleton: 2.2.3(@types/react@18.3.27)(react@18.3.1) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@18.3.27)(react@18.3.1) + use-sidecar: 1.1.3(@types/react@18.3.27)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + optional: true + react-remove-scroll@2.7.1(@types/react@19.2.7)(react@19.1.0): dependencies: react: 19.1.0 @@ -39289,6 +40168,16 @@ snapshots: optionalDependencies: '@types/react': 19.2.7 + react-server-dom-webpack@19.0.0(react-dom@19.1.0(react@18.3.1))(react@18.3.1)(webpack@5.100.2(esbuild@0.27.0)): + dependencies: + acorn-loose: 8.5.2 + neo-async: 2.6.2 + react: 18.3.1 + react-dom: 19.1.0(react@18.3.1) + webpack: 5.100.2(esbuild@0.27.0) + webpack-sources: 3.3.3 + optional: true + react-server-dom-webpack@19.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(webpack@5.100.2(esbuild@0.27.0)): dependencies: acorn-loose: 8.5.2 @@ -39298,6 +40187,15 @@ snapshots: webpack: 5.100.2(esbuild@0.27.0) webpack-sources: 3.3.3 + react-style-singleton@2.2.3(@types/react@18.3.27)(react@18.3.1): + dependencies: + get-nonce: 1.0.1 + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.27 + optional: true + react-style-singleton@2.2.3(@types/react@19.2.7)(react@19.1.0): dependencies: get-nonce: 1.0.1 @@ -39306,6 +40204,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.7 + react-test-renderer@19.1.0(react@18.3.1): + dependencies: + react: 18.3.1 + react-is: 19.2.0 + scheduler: 0.26.0 + optional: true + react-test-renderer@19.1.0(react@19.1.0): dependencies: react: 19.1.0 @@ -41295,6 +42200,14 @@ snapshots: urlpattern-polyfill@10.1.0: optional: true + use-callback-ref@1.3.3(@types/react@18.3.27)(react@18.3.1): + dependencies: + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.27 + optional: true + use-callback-ref@1.3.3(@types/react@19.2.7)(react@19.1.0): dependencies: react: 19.1.0 @@ -41310,6 +42223,15 @@ snapshots: dependencies: react: 19.1.0 + use-sidecar@1.1.3(@types/react@18.3.27)(react@18.3.1): + dependencies: + detect-node-es: 1.1.0 + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.27 + optional: true + use-sidecar@1.1.3(@types/react@19.2.7)(react@19.1.0): dependencies: detect-node-es: 1.1.0 @@ -41355,6 +42277,16 @@ snapshots: vary@1.1.2: {} + vaul@1.1.2(@types/react-dom@19.2.3(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.1.0(react@18.3.1))(react@18.3.1): + dependencies: + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@19.1.0(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 19.1.0(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + optional: true + vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) From 2b3f92ff36df2c4ed482ef5270bc5a48fcdb20f8 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Tue, 9 Dec 2025 20:29:38 +0100 Subject: [PATCH 09/68] feat(contacts): add interactive network graph visualization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add NetworkModule backend with tag-based relationship detection - Create D3-force powered network graph component with zoom/pan/drag - Implement network store with Svelte 5 runes for state management - Add floating controls for search, filter by tag/company, and zoom - Full-screen graph layout with sidebar for selected contact details - Contacts are connected when they share common tags 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/contacts/apps/backend/src/app.module.ts | 2 + .../backend/src/network/network.controller.ts | 24 + .../backend/src/network/network.module.ts | 10 + .../backend/src/network/network.service.ts | 151 +++++ apps/contacts/apps/web/package.json | 10 + apps/contacts/apps/web/src/lib/api/network.ts | 74 +++ .../components/network/NetworkControls.svelte | 370 +++++++++++++ .../components/network/NetworkGraph.svelte | 492 +++++++++++++++++ .../apps/web/src/lib/stores/network.svelte.ts | 434 +++++++++++++++ .../apps/web/src/routes/(app)/+layout.svelte | 57 +- .../web/src/routes/(app)/network/+page.svelte | 522 ++++++++++++++++++ 11 files changed, 2142 insertions(+), 4 deletions(-) create mode 100644 apps/contacts/apps/backend/src/network/network.controller.ts create mode 100644 apps/contacts/apps/backend/src/network/network.module.ts create mode 100644 apps/contacts/apps/backend/src/network/network.service.ts create mode 100644 apps/contacts/apps/web/src/lib/api/network.ts create mode 100644 apps/contacts/apps/web/src/lib/components/network/NetworkControls.svelte create mode 100644 apps/contacts/apps/web/src/lib/components/network/NetworkGraph.svelte create mode 100644 apps/contacts/apps/web/src/lib/stores/network.svelte.ts create mode 100644 apps/contacts/apps/web/src/routes/(app)/network/+page.svelte diff --git a/apps/contacts/apps/backend/src/app.module.ts b/apps/contacts/apps/backend/src/app.module.ts index 9edecaeda..778b8ae18 100644 --- a/apps/contacts/apps/backend/src/app.module.ts +++ b/apps/contacts/apps/backend/src/app.module.ts @@ -12,6 +12,7 @@ import { GoogleModule } from './google/google.module'; import { DuplicatesModule } from './duplicates/duplicates.module'; import { PhotoModule } from './photo/photo.module'; import { BatchModule } from './batch/batch.module'; +import { NetworkModule } from './network/network.module'; @Module({ imports: [ @@ -31,6 +32,7 @@ import { BatchModule } from './batch/batch.module'; DuplicatesModule, PhotoModule, BatchModule, + NetworkModule, ], }) export class AppModule {} diff --git a/apps/contacts/apps/backend/src/network/network.controller.ts b/apps/contacts/apps/backend/src/network/network.controller.ts new file mode 100644 index 000000000..a9a596fc2 --- /dev/null +++ b/apps/contacts/apps/backend/src/network/network.controller.ts @@ -0,0 +1,24 @@ +import { Controller, Get, UseGuards, Query } from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { NetworkService } from './network.service'; +import { IsString, IsOptional, IsIn } from 'class-validator'; + +class NetworkQueryDto { + @IsString() + @IsOptional() + @IsIn(['tags']) + type?: 'tags'; +} + +@Controller('network') +@UseGuards(JwtAuthGuard) +export class NetworkController { + constructor(private readonly networkService: NetworkService) {} + + @Get('graph') + async getGraph(@CurrentUser() user: CurrentUserData, @Query() query: NetworkQueryDto) { + // Currently only tag-based graph is supported (MVP) + const graph = await this.networkService.getTagBasedGraph(user.userId); + return graph; + } +} diff --git a/apps/contacts/apps/backend/src/network/network.module.ts b/apps/contacts/apps/backend/src/network/network.module.ts new file mode 100644 index 000000000..719a19f0d --- /dev/null +++ b/apps/contacts/apps/backend/src/network/network.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { NetworkController } from './network.controller'; +import { NetworkService } from './network.service'; + +@Module({ + controllers: [NetworkController], + providers: [NetworkService], + exports: [NetworkService], +}) +export class NetworkModule {} diff --git a/apps/contacts/apps/backend/src/network/network.service.ts b/apps/contacts/apps/backend/src/network/network.service.ts new file mode 100644 index 000000000..ac4184977 --- /dev/null +++ b/apps/contacts/apps/backend/src/network/network.service.ts @@ -0,0 +1,151 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { eq, sql } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { Database } from '../db/connection'; +import { contacts, contactTags, contactToTags } from '../db/schema'; + +export interface NetworkNode { + id: string; + name: string; + photoUrl: string | null; + company: string | null; + isFavorite: boolean; + tags: { id: string; name: string; color: string | null }[]; + connectionCount: number; +} + +export interface NetworkLink { + source: string; + target: string; + type: 'tag'; + strength: number; + sharedTags: string[]; +} + +export interface NetworkGraphResponse { + nodes: NetworkNode[]; + links: NetworkLink[]; +} + +@Injectable() +export class NetworkService { + constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} + + async getTagBasedGraph(userId: string): Promise { + // 1. Get all contacts for the user (excluding archived) + const userContacts = await this.db + .select({ + id: contacts.id, + firstName: contacts.firstName, + lastName: contacts.lastName, + displayName: contacts.displayName, + photoUrl: contacts.photoUrl, + company: contacts.company, + isFavorite: contacts.isFavorite, + }) + .from(contacts) + .where(eq(contacts.userId, userId)); + + if (userContacts.length === 0) { + return { nodes: [], links: [] }; + } + + // 2. Get all tags for the user + const userTags = await this.db.select().from(contactTags).where(eq(contactTags.userId, userId)); + + const tagMap = new Map(userTags.map((t) => [t.id, t])); + + // 3. Get all contact-tag associations + const contactTagAssociations = await this.db + .select({ + contactId: contactToTags.contactId, + tagId: contactToTags.tagId, + }) + .from(contactToTags) + .innerJoin(contacts, eq(contactToTags.contactId, contacts.id)) + .where(eq(contacts.userId, userId)); + + // 4. Build contact -> tags mapping + const contactTagsMap = new Map(); + for (const assoc of contactTagAssociations) { + const existing = contactTagsMap.get(assoc.contactId) || []; + existing.push(assoc.tagId); + contactTagsMap.set(assoc.contactId, existing); + } + + // 5. Build nodes + const nodes: NetworkNode[] = userContacts.map((contact) => { + const tagIds = contactTagsMap.get(contact.id) || []; + const tags = tagIds + .map((tagId) => { + const tag = tagMap.get(tagId); + return tag ? { id: tag.id, name: tag.name, color: tag.color } : null; + }) + .filter((t): t is { id: string; name: string; color: string | null } => t !== null); + + return { + id: contact.id, + name: this.getDisplayName(contact), + photoUrl: contact.photoUrl, + company: contact.company, + isFavorite: contact.isFavorite ?? false, + tags, + connectionCount: 0, // Will be calculated after links + }; + }); + + // 6. Build links based on shared tags + const links: NetworkLink[] = []; + const connectionCounts = new Map(); + + for (let i = 0; i < nodes.length; i++) { + for (let j = i + 1; j < nodes.length; j++) { + const nodeA = nodes[i]; + const nodeB = nodes[j]; + + const tagsA = new Set(nodeA.tags.map((t) => t.id)); + const tagsB = new Set(nodeB.tags.map((t) => t.id)); + + const sharedTagIds = [...tagsA].filter((tagId) => tagsB.has(tagId)); + + if (sharedTagIds.length > 0) { + const sharedTagNames = sharedTagIds + .map((tagId) => tagMap.get(tagId)?.name) + .filter((name): name is string => !!name); + + // Strength based on number of shared tags (max 100) + const strength = Math.min(sharedTagIds.length * 25, 100); + + links.push({ + source: nodeA.id, + target: nodeB.id, + type: 'tag', + strength, + sharedTags: sharedTagNames, + }); + + // Count connections + connectionCounts.set(nodeA.id, (connectionCounts.get(nodeA.id) || 0) + 1); + connectionCounts.set(nodeB.id, (connectionCounts.get(nodeB.id) || 0) + 1); + } + } + } + + // 7. Update connection counts on nodes + for (const node of nodes) { + node.connectionCount = connectionCounts.get(node.id) || 0; + } + + return { nodes, links }; + } + + private getDisplayName(contact: { + firstName: string | null; + lastName: string | null; + displayName: string | null; + }): string { + if (contact.displayName) return contact.displayName; + const parts = [contact.firstName, contact.lastName].filter(Boolean); + return parts.length > 0 ? parts.join(' ') : 'Unbekannt'; + } +} diff --git a/apps/contacts/apps/web/package.json b/apps/contacts/apps/web/package.json index 1703446da..51d97079f 100644 --- a/apps/contacts/apps/web/package.json +++ b/apps/contacts/apps/web/package.json @@ -16,6 +16,9 @@ "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0", "@tailwindcss/vite": "^4.1.7", + "@types/d3-force": "^3.0.10", + "@types/d3-selection": "^3.0.11", + "@types/d3-zoom": "^3.0.8", "@types/node": "^20.0.0", "prettier": "^3.1.1", "prettier-plugin-svelte": "^3.1.2", @@ -32,6 +35,9 @@ "@manacore/shared-branding": "workspace:*", "@manacore/shared-feedback-service": "workspace:*", "@manacore/shared-feedback-ui": "workspace:*", + "@manacore/shared-help-content": "workspace:*", + "@manacore/shared-help-types": "workspace:*", + "@manacore/shared-help-ui": "workspace:*", "@manacore/shared-i18n": "workspace:*", "@manacore/shared-icons": "workspace:*", "@manacore/shared-profile-ui": "workspace:*", @@ -41,6 +47,10 @@ "@manacore/shared-theme-ui": "workspace:*", "@manacore/shared-ui": "workspace:*", "@manacore/shared-utils": "workspace:*", + "d3-force": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", + "lucide-svelte": "^0.556.0", "svelte-i18n": "^4.0.1" }, "type": "module" diff --git a/apps/contacts/apps/web/src/lib/api/network.ts b/apps/contacts/apps/web/src/lib/api/network.ts new file mode 100644 index 000000000..a7145170a --- /dev/null +++ b/apps/contacts/apps/web/src/lib/api/network.ts @@ -0,0 +1,74 @@ +import { authStore } from '$lib/stores/auth.svelte'; +import { API_BASE } from './config'; + +async function fetchWithAuth(url: string, options: RequestInit = {}) { + let token: string | null = null; + try { + token = await authStore.getAccessToken(); + console.log('[Network API] Got token:', token ? 'present' : 'missing'); + } catch (e) { + console.error('[Network API] Error getting token:', e); + } + + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...(options.headers || {}), + }; + + if (token) { + (headers as Record)['Authorization'] = `Bearer ${token}`; + } + + const fullUrl = `${API_BASE}${url}`; + console.log('[Network API] Fetching:', fullUrl); + + const response = await fetch(fullUrl, { + ...options, + headers, + }); + + console.log('[Network API] Response status:', response.status); + + if (!response.ok) { + const errorText = await response.text(); + console.error('[Network API] Error response:', errorText); + let error: { message?: string } = { message: 'Request failed' }; + try { + error = JSON.parse(errorText); + } catch { + error = { message: errorText || 'Request failed' }; + } + throw new Error(error.message || 'Request failed'); + } + + return response.json(); +} + +export interface NetworkNode { + id: string; + name: string; + photoUrl: string | null; + company: string | null; + isFavorite: boolean; + tags: { id: string; name: string; color: string | null }[]; + connectionCount: number; +} + +export interface NetworkLink { + source: string; + target: string; + type: 'tag'; + strength: number; + sharedTags: string[]; +} + +export interface NetworkGraphResponse { + nodes: NetworkNode[]; + links: NetworkLink[]; +} + +export const networkApi = { + async getGraph(): Promise { + return fetchWithAuth('/network/graph'); + }, +}; diff --git a/apps/contacts/apps/web/src/lib/components/network/NetworkControls.svelte b/apps/contacts/apps/web/src/lib/components/network/NetworkControls.svelte new file mode 100644 index 000000000..619059660 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/network/NetworkControls.svelte @@ -0,0 +1,370 @@ + + +
+ +
+ + + {#if searchInput} + + {/if} +
+ + + + + +
+ + + +
+ + +
+ + {networkStore.nodes.length} Kontakte + + + + {networkStore.links.length} Verbindungen + +
+
+ + +{#if showFilters} +
+
+ +
+ + +
+ + +
+ + +
+ + + {#if hasActiveFilters} + + {/if} +
+
+{/if} + + diff --git a/apps/contacts/apps/web/src/lib/components/network/NetworkGraph.svelte b/apps/contacts/apps/web/src/lib/components/network/NetworkGraph.svelte new file mode 100644 index 000000000..af1ff0d97 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/network/NetworkGraph.svelte @@ -0,0 +1,492 @@ + + +
+ + + + + {#each graphLinks as link} + {@const coords = getLinkCoords(link)} + {@const sourceId = typeof link.source === 'string' ? link.source : link.source.id} + {@const targetId = typeof link.target === 'string' ? link.target : link.target.id} + {@const isHighlighted = + networkStore.selectedNodeId && + (sourceId === networkStore.selectedNodeId || targetId === networkStore.selectedNodeId)} + + {link.sharedTags.join(', ')} + + {/each} + + + + + {#each graphNodes as node (node.id)} + {@const isSelected = node.id === networkStore.selectedNodeId} + {@const isConnected = isConnectedToSelected(node.id, graphLinks)} + {@const isDimmed = networkStore.selectedNodeId && !isConnected} + handleDragStart(e, node)} + onclick={() => handleNodeClick(node)} + ondblclick={() => handleNodeDoubleClick(node)} + role="button" + tabindex="0" + aria-label={node.name} + > + + + + + {#if node.photoUrl} + + + + + {:else} + + {getInitials(node.name)} + + {/if} + + + {#if node.isFavorite} + + + ⭐ + + {/if} + + + {#if node.connectionCount > 0} + + + {node.connectionCount} + + {/if} + + + + {node.name} + + + + {#if node.company} + + {node.company} + + {/if} + + {/each} + + + + + + {#if graphNodes.length === 0 && !networkStore.loading} +
+
🔗
+

Keine Verbindungen gefunden

+

+ Kontakte werden verbunden, wenn sie gemeinsame Tags haben. Füge Tags zu deinen Kontakten + hinzu, um das Netzwerk zu sehen. +

+
+ {/if} +
+ + diff --git a/apps/contacts/apps/web/src/lib/stores/network.svelte.ts b/apps/contacts/apps/web/src/lib/stores/network.svelte.ts new file mode 100644 index 000000000..58bd3df69 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/stores/network.svelte.ts @@ -0,0 +1,434 @@ +/** + * 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, + type SimulationNodeDatum, + type SimulationLinkDatum, +} from 'd3-force'; + +// Extended types for D3 simulation +export interface SimulationNode extends NetworkNode, SimulationNodeDatum { + x?: number; + y?: number; + vx?: number; + vy?: number; + fx?: number | null; + fy?: number | null; +} + +export interface SimulationLink extends SimulationLinkDatum { + type: 'tag'; + strength: number; + sharedTags: string[]; +} + +// State +let nodes = $state([]); +let links = $state([]); +let loading = $state(false); +let error = $state(null); +let selectedNodeId = $state(null); +let simulation: Simulation | null = null; +let searchQuery = $state(''); +let filterTagId = $state(null); +let filterCompany = $state(null); +let tickCounter = $state(0); // Used to trigger reactivity on simulation tick +let simulationInitialized = false; +let dataLoaded = false; // Prevent double loading +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.company?.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)); + } + + // Company filter + if (filterCompany) { + result = result.filter((node) => node.company === filterCompany); + } + + 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; + return filteredNodeIds.has(sourceId) && filteredNodeIds.has(targetId); + }); +}); + +// Get unique companies for filter dropdown +const uniqueCompanies = $derived.by(() => { + const companies = new Set(); + for (const node of nodes) { + if (node.company) { + companies.add(node.company); + } + } + return Array.from(companies).sort(); +}); + +// Get unique tags for filter dropdown +const uniqueTags = $derived.by(() => { + const tagsMap = new Map(); + for (const node of nodes) { + for (const tag of node.tags) { + if (!tagsMap.has(tag.id)) { + tagsMap.set(tag.id, tag); + } + } + } + return Array.from(tagsMap.values()).sort((a, b) => a.name.localeCompare(b.name)); +}); + +export const networkStore = { + // Getters + get nodes() { + // Access tickCounter to trigger reactivity on simulation updates + 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 filterCompany() { + return filterCompany; + }, + get uniqueCompanies() { + return uniqueCompanies; + }, + get uniqueTags() { + return uniqueTags; + }, + + /** + * Load network graph data from API + */ + async loadGraph(force = false) { + // Prevent double loading + if (dataLoaded && !force) { + console.log('[Network] Data already loaded, skipping'); + return; + } + + if (loading) { + console.log('[Network] Already loading, skipping'); + return; + } + + loading = true; + error = null; + + // Reset simulation state for fresh data + if (simulation) { + simulation.stop(); + simulation = null; + } + simulationInitialized = false; + + try { + const response = await networkApi.getGraph(); + + console.log( + '[Network] Loaded', + response.nodes.length, + 'nodes and', + response.links.length, + 'links' + ); + + // Convert to simulation nodes + nodes = response.nodes.map((node) => ({ + ...node, + 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; + + // Prevent re-initialization if already running + if (simulationInitialized && simulation) { + // Only update center if dimensions changed significantly + if ( + Math.abs(lastDimensions.width - width) > 50 || + Math.abs(lastDimensions.height - height) > 50 + ) { + console.log('[Network] Updating simulation center for new dimensions:', width, 'x', height); + lastDimensions = { width, height }; + this.updateSimulationCenter(width, height); + } + return; + } + + // Stop existing simulation + if (simulation) { + simulation.stop(); + } + + console.log( + '[Network] Initializing simulation with', + nodes.length, + 'nodes, dimensions:', + width, + 'x', + height + ); + lastDimensions = { width, height }; + + // Initialize node positions spread around the center + const centerX = width / 2; + const centerY = height / 2; + const radius = Math.min(width, height) / 3; + + nodes.forEach((node, i) => { + // Only set initial position if not already set + if (node.x === undefined || node.y === undefined) { + // Spread nodes in a circle initially + 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); + } + }); + + // Create new simulation + simulation = forceSimulation(nodes) + .force( + 'link', + forceLink(links) + .id((d) => d.id) + .distance(100) // Fixed distance for cleaner layout + .strength(0.5) + ) + .force('charge', forceManyBody().strength(-300)) + .force('center', forceCenter(centerX, centerY)) + .force('collision', forceCollide().radius(50)) + .on('tick', () => { + // Trigger Svelte reactivity by incrementing counter + tickCounter++; + }); + + simulationInitialized = true; + + // Run simulation with higher alpha for better initial spread + simulation.alpha(1).restart(); + }, + + /** + * Update simulation dimensions (e.g., on window resize) + */ + updateSimulationCenter(width: number, height: number) { + if (simulation) { + simulation.force('center', forceCenter(width / 2, height / 2)); + simulation.alpha(0.3).restart(); + } + }, + + /** + * Stop the simulation + */ + stopSimulation() { + if (simulation) { + simulation.stop(); + simulation = null; + } + simulationInitialized = false; + // Don't reset dataLoaded here - only reset when navigating away + }, + + /** + * Reset the store completely (call when leaving the page) + */ + reset() { + this.stopSimulation(); + nodes = []; + links = []; + dataLoaded = false; + lastDimensions = { width: 0, height: 0 }; + tickCounter = 0; + }, + + /** + * Reheat simulation (restart with some energy) + */ + reheatSimulation() { + if (simulation) { + simulation.alpha(0.3).restart(); + } + }, + + /** + * Fix node position (for dragging) + */ + fixNode(nodeId: string, x: number, y: number) { + const node = nodes.find((n) => n.id === nodeId); + if (node) { + node.fx = x; + node.fy = y; + } + }, + + /** + * Release node (after dragging) + */ + releaseNode(nodeId: string) { + const node = nodes.find((n) => n.id === nodeId); + if (node) { + node.fx = null; + node.fy = null; + } + }, + + /** + * Select a node + */ + selectNode(nodeId: string | null) { + selectedNodeId = nodeId; + }, + + /** + * Set search query + */ + setSearch(query: string) { + searchQuery = query; + }, + + /** + * Set tag filter + */ + setFilterTag(tagId: string | null) { + filterTagId = tagId; + }, + + /** + * Set company filter + */ + setFilterCompany(company: string | null) { + filterCompany = company; + }, + + /** + * Clear all filters + */ + clearFilters() { + searchQuery = ''; + filterTagId = null; + filterCompany = null; + }, + + /** + * Get connected nodes for a given node + */ + getConnectedNodes(nodeId: string): SimulationNode[] { + const connectedIds = new Set(); + + for (const link of links) { + const sourceId = typeof link.source === 'string' ? link.source : link.source.id; + const targetId = typeof link.target === 'string' ? link.target : link.target.id; + + if (sourceId === nodeId) { + connectedIds.add(targetId); + } else if (targetId === nodeId) { + connectedIds.add(sourceId); + } + } + + return nodes.filter((n) => connectedIds.has(n.id)); + }, + + /** + * Get links for a given node + */ + getNodeLinks(nodeId: string): SimulationLink[] { + return links.filter((link) => { + const sourceId = typeof link.source === 'string' ? link.source : link.source.id; + const targetId = typeof link.target === 'string' ? link.target : link.target.id; + return sourceId === nodeId || targetId === nodeId; + }); + }, +}; diff --git a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte index 7f4da318b..aa5eb3db1 100644 --- a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte @@ -3,8 +3,13 @@ import { page } from '$app/stores'; import { onMount } from 'svelte'; import { locale } from 'svelte-i18n'; - import { PillNavigation } from '@manacore/shared-ui'; - import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui'; + import { PillNavigation, CommandBar } from '@manacore/shared-ui'; + import type { + PillNavItem, + PillDropdownItem, + CommandBarItem, + QuickAction, + } 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'; @@ -17,8 +22,8 @@ import { getPillAppItems } from '@manacore/shared-branding'; import { setLocale, supportedLocales } from '$lib/i18n'; import ContactDetailModal from '$lib/components/ContactDetailModal.svelte'; - import SearchModal from '$lib/components/SearchModal.svelte'; import { contactsStore } from '$lib/stores/contacts.svelte'; + import { contactsApi } from '$lib/api/contacts'; import { viewModeStore } from '$lib/stores/view-mode.svelte'; import { contactsSettings } from '$lib/stores/settings.svelte'; @@ -152,6 +157,41 @@ goto('/', { replaceState: false }); } + // CommandBar search function + async function handleCommandBarSearch(query: string): Promise { + const response = await contactsApi.list({ search: query, limit: 10 }); + return (response.contacts || []).map((contact: any) => ({ + id: contact.id, + title: + contact.displayName || + [contact.firstName, contact.lastName].filter(Boolean).join(' ') || + contact.email || + 'Unbekannt', + subtitle: contact.company || contact.email, + imageUrl: contact.photoUrl, + isFavorite: contact.isFavorite, + })); + } + + // CommandBar item selection + function handleCommandBarSelect(item: CommandBarItem) { + goto(`/contacts/${item.id}`); + } + + // CommandBar quick actions + const commandBarQuickActions: QuickAction[] = [ + { + id: 'new', + label: 'Neuen Kontakt erstellen', + icon: 'plus', + href: '/contacts/new', + shortcut: 'N', + }, + { id: 'favorites', label: 'Favoriten anzeigen', icon: 'heart', href: '/favorites' }, + { id: 'tags', label: 'Tags verwalten', icon: 'tag', href: '/tags' }, + { id: 'import', label: 'Kontakte importieren', icon: 'upload', href: '/data?tab=import' }, + ]; + onMount(async () => { // Redirect to login if not authenticated if (!authStore.isAuthenticated) { @@ -238,7 +278,16 @@ {/if} - (searchModalOpen = false)} /> + (searchModalOpen = false)} + onSearch={handleCommandBarSearch} + onSelect={handleCommandBarSelect} + quickActions={commandBarQuickActions} + placeholder="Kontakt suchen..." + emptyText="Keine Kontakte gefunden" + searchingText="Suche..." + />
From a4846aea060d712f407dda19734a95fd8c52684e Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Tue, 9 Dec 2025 20:35:58 +0100 Subject: [PATCH 10/68] feat(shared-ui): add global CommandBar component with search across apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add reusable CommandBar component to shared-ui package with dark theme - Integrate CommandBar (Cmd/K) in contacts, calendar, todo, and clock apps - Implement search functionality for each app: - Contacts: search by name, company, email with relevance-based sorting - Calendar: search events by title/description within next year - Todo: search tasks by title/description - Clock: search alarms and timers by label - Add quick actions for common operations in each app 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/calendar/apps/web/src/lib/api/events.ts | 17 + .../apps/web/src/routes/(app)/+layout.svelte | 65 +- .../apps/web/src/routes/(app)/+layout.svelte | 105 ++- .../backend/src/contact/contact.service.ts | 58 +- .../apps/web/src/routes/(app)/+layout.svelte | 64 +- .../src/command-bar/CommandBar.svelte | 601 ++++++++++++++++++ packages/shared-ui/src/command-bar/index.ts | 2 + packages/shared-ui/src/index.ts | 4 + 8 files changed, 899 insertions(+), 17 deletions(-) create mode 100644 packages/shared-ui/src/command-bar/CommandBar.svelte create mode 100644 packages/shared-ui/src/command-bar/index.ts diff --git a/apps/calendar/apps/web/src/lib/api/events.ts b/apps/calendar/apps/web/src/lib/api/events.ts index 0a0b04d5a..ad35c3040 100644 --- a/apps/calendar/apps/web/src/lib/api/events.ts +++ b/apps/calendar/apps/web/src/lib/api/events.ts @@ -9,6 +9,7 @@ export interface QueryEventsParams { startDate: string; endDate: string; calendarIds?: string[]; + search?: string; } export async function getEvents(params: QueryEventsParams) { @@ -19,9 +20,25 @@ export async function getEvents(params: QueryEventsParams) { if (params.calendarIds?.length) { searchParams.set('calendarIds', params.calendarIds.join(',')); } + if (params.search) { + searchParams.set('search', params.search); + } return fetchApi(`/events?${searchParams.toString()}`); } +export async function searchEvents(query: string, limit: number = 10) { + // Search events within the next year + const now = new Date(); + const oneYearFromNow = new Date(); + oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1); + + return getEvents({ + startDate: now.toISOString(), + endDate: oneYearFromNow.toISOString(), + search: query, + }); +} + export async function getEvent(id: string) { const result = await fetchApi<{ event: CalendarEvent }>(`/events/${id}`); if (result.error || !result.data) { diff --git a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte index 942230a73..79b0f4509 100644 --- a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte @@ -3,8 +3,13 @@ import { page } from '$app/stores'; import { onMount } from 'svelte'; import { locale } from 'svelte-i18n'; - import { PillNavigation } from '@manacore/shared-ui'; - import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui'; + import { PillNavigation, CommandBar } from '@manacore/shared-ui'; + import type { + PillNavItem, + PillDropdownItem, + CommandBarItem, + QuickAction, + } 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'; @@ -19,12 +24,49 @@ import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n'; import { getPillAppItems } from '@manacore/shared-branding'; import { setLocale, supportedLocales } from '$lib/i18n'; + import { searchEvents } from '$lib/api/events'; + import { format } from 'date-fns'; + import { de } from 'date-fns/locale'; // App switcher items const appItems = getPillAppItems('calendar'); let { children } = $props(); + // CommandBar state + let commandBarOpen = $state(false); + + // CommandBar quick actions (no search for calendar yet) + const commandBarQuickActions: QuickAction[] = [ + { id: 'new', label: 'Neuen Termin erstellen', icon: 'plus', href: '/event/new', shortcut: 'N' }, + { + id: 'today', + label: 'Zu Heute springen', + icon: 'calendar', + onclick: () => viewStore.goToToday(), + }, + { id: 'agenda', label: 'Agenda anzeigen', icon: 'list', href: '/agenda' }, + { id: 'settings', label: 'Einstellungen', icon: 'settings', href: '/settings' }, + ]; + + // CommandBar search - search events + async function handleCommandBarSearch(query: string): Promise { + if (!query.trim()) return []; + + const result = await searchEvents(query); + if (result.error || !result.data) return []; + + return result.data.slice(0, 10).map((event) => ({ + id: event.id, + title: event.title, + subtitle: format(new Date(event.startTime), 'dd. MMM yyyy, HH:mm', { locale: de }), + })); + } + + function handleCommandBarSelect(item: CommandBarItem) { + goto(`/event/${item.id}`); + } + let isSidebarMode = $state(false); let isCollapsed = $state(false); @@ -79,6 +121,13 @@ function handleKeydown(event: KeyboardEvent) { const target = event.target as HTMLElement; + // Cmd/Ctrl+K to open command bar (works even in inputs) + if ((event.ctrlKey || event.metaKey) && event.key === 'k') { + event.preventDefault(); + commandBarOpen = true; + return; + } + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { return; } @@ -209,6 +258,18 @@ {@render children()}
+ + + (commandBarOpen = false)} + onSearch={handleCommandBarSearch} + onSelect={handleCommandBarSelect} + quickActions={commandBarQuickActions} + placeholder="Termin suchen..." + emptyText="Keine Termine gefunden" + searchingText="Suche..." + />
diff --git a/packages/shared-ui/src/command-bar/index.ts b/packages/shared-ui/src/command-bar/index.ts new file mode 100644 index 000000000..28928f717 --- /dev/null +++ b/packages/shared-ui/src/command-bar/index.ts @@ -0,0 +1,2 @@ +export { default as CommandBar } from './CommandBar.svelte'; +export type { CommandBarItem, QuickAction } from './CommandBar.svelte'; diff --git a/packages/shared-ui/src/index.ts b/packages/shared-ui/src/index.ts index b735e8594..c2e07e415 100644 --- a/packages/shared-ui/src/index.ts +++ b/packages/shared-ui/src/index.ts @@ -71,5 +71,9 @@ export { GlobalSettingsSection, } from './settings'; +// Command Bar +export { CommandBar } from './command-bar'; +export type { CommandBarItem, QuickAction } from './command-bar'; + // Pages export { default as AppsPage } from './pages/AppsPage.svelte'; From 7987fe009dc5f79c51ff33b26f5c56bd768768a2 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Tue, 9 Dec 2025 20:36:37 +0100 Subject: [PATCH 11/68] feat(ui): add skeleton loaders for calendar and clock apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calendar App: - Add AppLoadingSkeleton for root layout initialization - Add CalendarViewSkeleton for main week view - Add AgendaSkeleton for agenda page event list - Add EventDetailSkeleton for event modal - Add RedirectSkeleton for event redirect page - Fix TypeScript error in event/new page Clock App: - Add AppLoadingSkeleton for root layout initialization - Add WorldClockSkeleton for world clock grid - Add AlarmsSkeleton for alarms grid - Add TimersSkeleton for active timers grid All spinners replaced with contextual skeleton loaders using @manacore/shared-ui SkeletonBox component. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../components/event/EventDetailModal.svelte | 31 +--- .../skeletons/AgendaSkeleton.svelte | 43 +++++ .../skeletons/AppLoadingSkeleton.svelte | 55 +++++++ .../skeletons/CalendarViewSkeleton.svelte | 149 ++++++++++++++++++ .../skeletons/EventDetailSkeleton.svelte | 75 +++++++++ .../skeletons/RedirectSkeleton.svelte | 16 ++ .../web/src/lib/components/skeletons/index.ts | 21 +++ .../apps/web/src/routes/(app)/+page.svelte | 5 +- .../web/src/routes/(app)/agenda/+page.svelte | 9 +- .../src/routes/(app)/event/[id]/+page.svelte | 33 +--- .../src/routes/(app)/event/new/+page.svelte | 7 +- .../apps/web/src/routes/+layout.svelte | 10 +- .../skeletons/AlarmsSkeleton.svelte | 27 ++++ .../skeletons/AppLoadingSkeleton.svelte | 47 ++++++ .../skeletons/TimersSkeleton.svelte | 47 ++++++ .../skeletons/WorldClockSkeleton.svelte | 36 +++++ .../web/src/lib/components/skeletons/index.ts | 18 +++ .../web/src/routes/(app)/alarms/+page.svelte | 7 +- .../web/src/routes/(app)/timers/+page.svelte | 7 +- .../src/routes/(app)/world-clock/+page.svelte | 7 +- apps/clock/apps/web/src/routes/+layout.svelte | 10 +- 21 files changed, 558 insertions(+), 102 deletions(-) create mode 100644 apps/calendar/apps/web/src/lib/components/skeletons/AgendaSkeleton.svelte create mode 100644 apps/calendar/apps/web/src/lib/components/skeletons/AppLoadingSkeleton.svelte create mode 100644 apps/calendar/apps/web/src/lib/components/skeletons/CalendarViewSkeleton.svelte create mode 100644 apps/calendar/apps/web/src/lib/components/skeletons/EventDetailSkeleton.svelte create mode 100644 apps/calendar/apps/web/src/lib/components/skeletons/RedirectSkeleton.svelte create mode 100644 apps/calendar/apps/web/src/lib/components/skeletons/index.ts create mode 100644 apps/clock/apps/web/src/lib/components/skeletons/AlarmsSkeleton.svelte create mode 100644 apps/clock/apps/web/src/lib/components/skeletons/AppLoadingSkeleton.svelte create mode 100644 apps/clock/apps/web/src/lib/components/skeletons/TimersSkeleton.svelte create mode 100644 apps/clock/apps/web/src/lib/components/skeletons/WorldClockSkeleton.svelte create mode 100644 apps/clock/apps/web/src/lib/components/skeletons/index.ts diff --git a/apps/calendar/apps/web/src/lib/components/event/EventDetailModal.svelte b/apps/calendar/apps/web/src/lib/components/event/EventDetailModal.svelte index 2c5334b45..c2c0661a3 100644 --- a/apps/calendar/apps/web/src/lib/components/event/EventDetailModal.svelte +++ b/apps/calendar/apps/web/src/lib/components/event/EventDetailModal.svelte @@ -8,6 +8,7 @@ import * as api from '$lib/api/events'; import { format, parseISO } from 'date-fns'; import { de } from 'date-fns/locale'; + import { EventDetailSkeleton } from '$lib/components/skeletons'; interface Props { eventId: string; @@ -147,10 +148,7 @@