diff --git a/.claude/guidelines/sveltekit-web.md b/.claude/guidelines/sveltekit-web.md index 4a69dd273..c149c4dfc 100644 --- a/.claude/guidelines/sveltekit-web.md +++ b/.claude/guidelines/sveltekit-web.md @@ -799,18 +799,20 @@ Docker containers can reach each other by service name (`mana-core-auth`), but b ### Apps Using This Pattern Correctly -- ✅ `chat/apps/web` - Has `hooks.server.ts` with runtime injection -- ✅ `todo/apps/web` - Fixed -- ✅ `calendar/apps/web` - Fixed -- ✅ `clock/apps/web` - Fixed +All web apps with backends now use the runtime injection pattern: -### Apps That Still Need Fixing +- ✅ `chat/apps/web` +- ✅ `picture/apps/web` +- ✅ `zitare/apps/web` +- ✅ `contacts/apps/web` +- ✅ `calendar/apps/web` +- ✅ `clock/apps/web` +- ✅ `todo/apps/web` -- ❌ `contacts/apps/web` -- ❌ `manadeck/apps/web` -- ❌ `manacore/apps/web` -- ❌ `zitare/apps/web` -- ❌ `picture/apps/web` +### Apps That May Need Fixing + +- ❓ `manadeck/apps/web` - Check if using dynamic URLs +- ❓ `manacore/apps/web` - Check if using dynamic URLs ### Quick Checklist for New SvelteKit Apps diff --git a/apps/calendar/apps/backend/src/app.module.ts b/apps/calendar/apps/backend/src/app.module.ts index 38844c46d..3ccbf19e8 100644 --- a/apps/calendar/apps/backend/src/app.module.ts +++ b/apps/calendar/apps/backend/src/app.module.ts @@ -6,6 +6,7 @@ import { HealthModule } from './health/health.module'; import { CalendarModule } from './calendar/calendar.module'; import { EventModule } from './event/event.module'; import { EventTagModule } from './event-tag/event-tag.module'; +import { EventTagGroupModule } from './event-tag-group/event-tag-group.module'; import { ReminderModule } from './reminder/reminder.module'; import { ShareModule } from './share/share.module'; import { NetworkModule } from './network/network.module'; @@ -22,6 +23,7 @@ import { NetworkModule } from './network/network.module'; CalendarModule, EventModule, EventTagModule, + EventTagGroupModule, ReminderModule, ShareModule, NetworkModule, diff --git a/apps/calendar/apps/backend/src/db/schema/event-tag-groups.schema.ts b/apps/calendar/apps/backend/src/db/schema/event-tag-groups.schema.ts new file mode 100644 index 000000000..3eb442921 --- /dev/null +++ b/apps/calendar/apps/backend/src/db/schema/event-tag-groups.schema.ts @@ -0,0 +1,23 @@ +import { pgTable, uuid, text, timestamp, varchar, integer, index } from 'drizzle-orm/pg-core'; + +/** + * Event tag groups table - stores user-defined tag groups (e.g., Persons, Locations) + */ +export const eventTagGroups = pgTable( + 'event_tag_groups', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: text('user_id').notNull(), + name: varchar('name', { length: 100 }).notNull(), + color: varchar('color', { length: 7 }).default('#3B82F6'), + sortOrder: integer('sort_order').default(0), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + userIdx: index('event_tag_groups_user_idx').on(table.userId), + }) +); + +export type EventTagGroup = typeof eventTagGroups.$inferSelect; +export type NewEventTagGroup = typeof eventTagGroups.$inferInsert; diff --git a/apps/calendar/apps/backend/src/db/schema/event-tags.schema.ts b/apps/calendar/apps/backend/src/db/schema/event-tags.schema.ts index 43e22848d..8964ca468 100644 --- a/apps/calendar/apps/backend/src/db/schema/event-tags.schema.ts +++ b/apps/calendar/apps/backend/src/db/schema/event-tags.schema.ts @@ -1,5 +1,15 @@ -import { pgTable, uuid, text, timestamp, varchar, primaryKey, index } from 'drizzle-orm/pg-core'; +import { + pgTable, + uuid, + text, + timestamp, + varchar, + primaryKey, + index, + integer, +} from 'drizzle-orm/pg-core'; import { events } from './events.schema'; +import { eventTagGroups } from './event-tag-groups.schema'; /** * Event tags table - stores user-defined tags with colors @@ -11,11 +21,14 @@ export const eventTags = pgTable( userId: text('user_id').notNull(), name: varchar('name', { length: 100 }).notNull(), color: varchar('color', { length: 7 }).default('#3B82F6'), + groupId: uuid('group_id').references(() => eventTagGroups.id, { onDelete: 'set null' }), + sortOrder: integer('sort_order').default(0), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), }, (table) => ({ userIdx: index('event_tags_user_idx').on(table.userId), + groupIdx: index('event_tags_group_idx').on(table.groupId), }) ); diff --git a/apps/calendar/apps/backend/src/db/schema/index.ts b/apps/calendar/apps/backend/src/db/schema/index.ts index 1cf8619a4..430959615 100644 --- a/apps/calendar/apps/backend/src/db/schema/index.ts +++ b/apps/calendar/apps/backend/src/db/schema/index.ts @@ -2,6 +2,7 @@ export * from './calendars.schema'; export * from './events.schema'; export * from './event-tags.schema'; +export * from './event-tag-groups.schema'; export * from './calendar-shares.schema'; export * from './reminders.schema'; export * from './external-calendars.schema'; diff --git a/apps/calendar/apps/backend/src/event-tag-group/dto/create-event-tag-group.dto.ts b/apps/calendar/apps/backend/src/event-tag-group/dto/create-event-tag-group.dto.ts new file mode 100644 index 000000000..36c902392 --- /dev/null +++ b/apps/calendar/apps/backend/src/event-tag-group/dto/create-event-tag-group.dto.ts @@ -0,0 +1,12 @@ +import { IsString, IsOptional, MaxLength } from 'class-validator'; + +export class CreateEventTagGroupDto { + @IsString() + @MaxLength(100) + name!: string; + + @IsString() + @IsOptional() + @MaxLength(7) + color?: string; +} diff --git a/apps/calendar/apps/backend/src/event-tag-group/dto/index.ts b/apps/calendar/apps/backend/src/event-tag-group/dto/index.ts new file mode 100644 index 000000000..8d61d64fd --- /dev/null +++ b/apps/calendar/apps/backend/src/event-tag-group/dto/index.ts @@ -0,0 +1,3 @@ +export * from './create-event-tag-group.dto'; +export * from './update-event-tag-group.dto'; +export * from './reorder-event-tag-groups.dto'; diff --git a/apps/calendar/apps/backend/src/event-tag-group/dto/reorder-event-tag-groups.dto.ts b/apps/calendar/apps/backend/src/event-tag-group/dto/reorder-event-tag-groups.dto.ts new file mode 100644 index 000000000..5877d7515 --- /dev/null +++ b/apps/calendar/apps/backend/src/event-tag-group/dto/reorder-event-tag-groups.dto.ts @@ -0,0 +1,7 @@ +import { IsArray, IsUUID } from 'class-validator'; + +export class ReorderEventTagGroupsDto { + @IsArray() + @IsUUID('4', { each: true }) + groupIds!: string[]; +} diff --git a/apps/calendar/apps/backend/src/event-tag-group/dto/update-event-tag-group.dto.ts b/apps/calendar/apps/backend/src/event-tag-group/dto/update-event-tag-group.dto.ts new file mode 100644 index 000000000..ba5697c38 --- /dev/null +++ b/apps/calendar/apps/backend/src/event-tag-group/dto/update-event-tag-group.dto.ts @@ -0,0 +1,13 @@ +import { IsString, IsOptional, MaxLength } from 'class-validator'; + +export class UpdateEventTagGroupDto { + @IsString() + @IsOptional() + @MaxLength(100) + name?: string; + + @IsString() + @IsOptional() + @MaxLength(7) + color?: string; +} diff --git a/apps/calendar/apps/backend/src/event-tag-group/event-tag-group.controller.ts b/apps/calendar/apps/backend/src/event-tag-group/event-tag-group.controller.ts new file mode 100644 index 000000000..6ee3b2b98 --- /dev/null +++ b/apps/calendar/apps/backend/src/event-tag-group/event-tag-group.controller.ts @@ -0,0 +1,91 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + UseGuards, + ParseUUIDPipe, + NotFoundException, +} from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { EventTagGroupService } from './event-tag-group.service'; +import { CreateEventTagGroupDto, UpdateEventTagGroupDto, ReorderEventTagGroupsDto } from './dto'; + +@Controller('event-tag-groups') +@UseGuards(JwtAuthGuard) +export class EventTagGroupController { + constructor(private readonly eventTagGroupService: EventTagGroupService) {} + + @Get() + async findAll(@CurrentUser() user: CurrentUserData) { + const groups = await this.eventTagGroupService.findByUserId(user.userId); + const tagCounts = await this.eventTagGroupService.getTagCountsForUser(user.userId); + + // Add tag count to each group + const groupsWithCounts = groups.map((group) => ({ + ...group, + tagCount: tagCounts.get(group.id) ?? 0, + })); + + return { + groups: groupsWithCounts, + ungroupedTagCount: tagCounts.get(null) ?? 0, + }; + } + + @Put('reorder') + async reorder(@CurrentUser() user: CurrentUserData, @Body() dto: ReorderEventTagGroupsDto) { + const groups = await this.eventTagGroupService.reorder(user.userId, dto.groupIds); + const tagCounts = await this.eventTagGroupService.getTagCountsForUser(user.userId); + + const groupsWithCounts = groups.map((group) => ({ + ...group, + tagCount: tagCounts.get(group.id) ?? 0, + })); + + return { + groups: groupsWithCounts, + ungroupedTagCount: tagCounts.get(null) ?? 0, + }; + } + + @Get(':id') + async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { + const group = await this.eventTagGroupService.findById(id, user.userId); + if (!group) { + throw new NotFoundException('Tag group not found'); + } + + const tagCount = await this.eventTagGroupService.getTagCountByGroup(id); + return { group: { ...group, tagCount } }; + } + + @Post() + async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateEventTagGroupDto) { + const group = await this.eventTagGroupService.create({ + ...dto, + userId: user.userId, + }); + return { group: { ...group, tagCount: 0 } }; + } + + @Put(':id') + async update( + @CurrentUser() user: CurrentUserData, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateEventTagGroupDto + ) { + const group = await this.eventTagGroupService.update(id, user.userId, dto); + const tagCount = await this.eventTagGroupService.getTagCountByGroup(id); + return { group: { ...group, tagCount } }; + } + + @Delete(':id') + async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { + await this.eventTagGroupService.delete(id, user.userId); + return { success: true }; + } +} diff --git a/apps/calendar/apps/backend/src/event-tag-group/event-tag-group.module.ts b/apps/calendar/apps/backend/src/event-tag-group/event-tag-group.module.ts new file mode 100644 index 000000000..1bcb448c1 --- /dev/null +++ b/apps/calendar/apps/backend/src/event-tag-group/event-tag-group.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { EventTagGroupController } from './event-tag-group.controller'; +import { EventTagGroupService } from './event-tag-group.service'; + +@Module({ + controllers: [EventTagGroupController], + providers: [EventTagGroupService], + exports: [EventTagGroupService], +}) +export class EventTagGroupModule {} diff --git a/apps/calendar/apps/backend/src/event-tag-group/event-tag-group.service.ts b/apps/calendar/apps/backend/src/event-tag-group/event-tag-group.service.ts new file mode 100644 index 000000000..12e68be07 --- /dev/null +++ b/apps/calendar/apps/backend/src/event-tag-group/event-tag-group.service.ts @@ -0,0 +1,125 @@ +import { Injectable, Inject, NotFoundException } from '@nestjs/common'; +import { eq, and, asc } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { Database } from '../db/connection'; +import { eventTagGroups, eventTags } from '../db/schema'; +import type { EventTagGroup, NewEventTagGroup } from '../db/schema'; + +const DEFAULT_TAG_GROUPS = [ + { name: 'Personen', color: '#ec4899' }, // pink + { name: 'Orte', color: '#14b8a6' }, // teal + { name: 'Allgemein', color: '#3b82f6' }, // blue +] as const; + +@Injectable() +export class EventTagGroupService { + constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} + + async findByUserId(userId: string): Promise { + const groups = await this.db + .select() + .from(eventTagGroups) + .where(eq(eventTagGroups.userId, userId)) + .orderBy(asc(eventTagGroups.sortOrder), asc(eventTagGroups.name)); + + // Create default groups on first access (when user has no groups yet) + if (groups.length === 0) { + return this.createDefaultGroups(userId); + } + + return groups; + } + + async createDefaultGroups(userId: string): Promise { + const groupsToCreate = DEFAULT_TAG_GROUPS.map((group, index) => ({ + userId, + name: group.name, + color: group.color, + sortOrder: index, + })); + + return this.db.insert(eventTagGroups).values(groupsToCreate).returning(); + } + + async findById(id: string, userId: string): Promise { + const [group] = await this.db + .select() + .from(eventTagGroups) + .where(and(eq(eventTagGroups.id, id), eq(eventTagGroups.userId, userId))); + return group || null; + } + + async create(data: NewEventTagGroup): Promise { + // Get highest sortOrder for user + const existing = await this.db + .select() + .from(eventTagGroups) + .where(eq(eventTagGroups.userId, data.userId)); + + const maxSortOrder = existing.reduce((max, g) => Math.max(max, g.sortOrder ?? 0), -1); + + const [group] = await this.db + .insert(eventTagGroups) + .values({ ...data, sortOrder: maxSortOrder + 1 }) + .returning(); + return group; + } + + async update( + id: string, + userId: string, + data: Partial> + ): Promise { + const [group] = await this.db + .update(eventTagGroups) + .set({ ...data, updatedAt: new Date() }) + .where(and(eq(eventTagGroups.id, id), eq(eventTagGroups.userId, userId))) + .returning(); + + if (!group) { + throw new NotFoundException('Tag group not found'); + } + + return group; + } + + async delete(id: string, userId: string): Promise { + // First, unassign all tags from this group (set groupId to null) + await this.db.update(eventTags).set({ groupId: null }).where(eq(eventTags.groupId, id)); + + // Then delete the group + await this.db + .delete(eventTagGroups) + .where(and(eq(eventTagGroups.id, id), eq(eventTagGroups.userId, userId))); + } + + async getTagCountByGroup(groupId: string): Promise { + const tags = await this.db.select().from(eventTags).where(eq(eventTags.groupId, groupId)); + return tags.length; + } + + async getTagCountsForUser(userId: string): Promise> { + const tags = await this.db.select().from(eventTags).where(eq(eventTags.userId, userId)); + + const counts = new Map(); + for (const tag of tags) { + const groupId = tag.groupId; + counts.set(groupId, (counts.get(groupId) ?? 0) + 1); + } + return counts; + } + + async reorder(userId: string, groupIds: string[]): Promise { + // Update sortOrder for each group based on array position + await Promise.all( + groupIds.map((id, index) => + this.db + .update(eventTagGroups) + .set({ sortOrder: index, updatedAt: new Date() }) + .where(and(eq(eventTagGroups.id, id), eq(eventTagGroups.userId, userId))) + ) + ); + + return this.findByUserId(userId); + } +} diff --git a/apps/calendar/apps/backend/src/event-tag-group/index.ts b/apps/calendar/apps/backend/src/event-tag-group/index.ts new file mode 100644 index 000000000..a610b0f60 --- /dev/null +++ b/apps/calendar/apps/backend/src/event-tag-group/index.ts @@ -0,0 +1,4 @@ +export * from './event-tag-group.module'; +export * from './event-tag-group.service'; +export * from './event-tag-group.controller'; +export * from './dto'; diff --git a/apps/calendar/apps/backend/src/event-tag/dto/create-event-tag.dto.ts b/apps/calendar/apps/backend/src/event-tag/dto/create-event-tag.dto.ts index a36de228e..aa9a398be 100644 --- a/apps/calendar/apps/backend/src/event-tag/dto/create-event-tag.dto.ts +++ b/apps/calendar/apps/backend/src/event-tag/dto/create-event-tag.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsOptional, MaxLength } from 'class-validator'; +import { IsString, IsOptional, MaxLength, IsUUID } from 'class-validator'; export class CreateEventTagDto { @IsString() @@ -9,4 +9,8 @@ export class CreateEventTagDto { @IsOptional() @MaxLength(7) color?: string; + + @IsUUID() + @IsOptional() + groupId?: string; } diff --git a/apps/calendar/apps/backend/src/event-tag/dto/update-event-tag.dto.ts b/apps/calendar/apps/backend/src/event-tag/dto/update-event-tag.dto.ts index 22718254e..f3355afd3 100644 --- a/apps/calendar/apps/backend/src/event-tag/dto/update-event-tag.dto.ts +++ b/apps/calendar/apps/backend/src/event-tag/dto/update-event-tag.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsOptional, MaxLength } from 'class-validator'; +import { IsString, IsOptional, MaxLength, IsUUID } from 'class-validator'; export class UpdateEventTagDto { @IsString() @@ -10,4 +10,8 @@ export class UpdateEventTagDto { @IsOptional() @MaxLength(7) color?: string; + + @IsUUID() + @IsOptional() + groupId?: string | null; } diff --git a/apps/calendar/apps/backend/src/event-tag/event-tag.service.ts b/apps/calendar/apps/backend/src/event-tag/event-tag.service.ts index a9092ddc6..98a2f7786 100644 --- a/apps/calendar/apps/backend/src/event-tag/event-tag.service.ts +++ b/apps/calendar/apps/backend/src/event-tag/event-tag.service.ts @@ -1,5 +1,5 @@ import { Injectable, Inject, NotFoundException } from '@nestjs/common'; -import { eq, and, inArray } from 'drizzle-orm'; +import { eq, and, inArray, isNull, asc } from 'drizzle-orm'; import { DATABASE_CONNECTION } from '../db/database.module'; import { Database } from '../db/connection'; import { eventTags, eventToTags } from '../db/schema'; @@ -116,4 +116,31 @@ export class EventTagService { .from(eventTags) .where(and(inArray(eventTags.id, ids), eq(eventTags.userId, userId))); } + + async findByGroupId(groupId: string | null, userId: string): Promise { + const condition = + groupId === null + ? and(isNull(eventTags.groupId), eq(eventTags.userId, userId)) + : and(eq(eventTags.groupId, groupId), eq(eventTags.userId, userId)); + + return this.db + .select() + .from(eventTags) + .where(condition) + .orderBy(asc(eventTags.sortOrder), asc(eventTags.name)); + } + + async updateTagGroup(tagId: string, userId: string, groupId: string | null): Promise { + const [tag] = await this.db + .update(eventTags) + .set({ groupId, updatedAt: new Date() }) + .where(and(eq(eventTags.id, tagId), eq(eventTags.userId, userId))) + .returning(); + + if (!tag) { + throw new NotFoundException('Tag not found'); + } + + return tag; + } } diff --git a/apps/calendar/apps/web/src/lib/api/base-client.ts b/apps/calendar/apps/web/src/lib/api/base-client.ts index 06f9ba7c7..0b53439e3 100644 --- a/apps/calendar/apps/web/src/lib/api/base-client.ts +++ b/apps/calendar/apps/web/src/lib/api/base-client.ts @@ -58,7 +58,9 @@ export function createApiClient(config: ApiClientConfig) { headers['Authorization'] = `Bearer ${authToken}`; } - const response = await fetch(`${baseUrl}${apiPrefix}${endpoint}`, { + const url = `${baseUrl}${apiPrefix}${endpoint}`; + + const response = await fetch(url, { method, headers, body: isFormData ? (body as FormData) : body ? JSON.stringify(body) : undefined, diff --git a/apps/calendar/apps/web/src/lib/api/birthdays.ts b/apps/calendar/apps/web/src/lib/api/birthdays.ts new file mode 100644 index 000000000..10e4ed9b4 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/api/birthdays.ts @@ -0,0 +1,100 @@ +/** + * Cross-App API Client for Contacts Backend - Birthday Data + * Allows Calendar app to fetch contact birthdays for display + */ + +import { env } from '$env/dynamic/public'; +import { createApiClient } from './base-client'; + +const CONTACTS_API_BASE = env.PUBLIC_CONTACTS_API_URL || 'http://localhost:3015'; + +const contactsClient = createApiClient({ + baseUrl: CONTACTS_API_BASE, + apiPrefix: '/api/v1', +}); + +// ============================================ +// Types for Birthday Integration +// ============================================ + +/** + * Lightweight contact data for birthday display + * Only essential fields from Contacts API + */ +export interface ContactBirthdaySummary { + id: string; + displayName: string | null; + firstName: string | null; + lastName: string | null; + birthday: string; // YYYY-MM-DD format + photoUrl: string | null; +} + +/** + * Birthday event for calendar display + * Generated from ContactBirthdaySummary with display date + */ +export interface BirthdayEvent { + id: string; // Format: birthday-{contactId}-{date} + contactId: string; + title: string; // "{Name}'s Geburtstag" + displayName: string; + photoUrl: string | null; + birthday: string; // Original birthday date + age: number; // Age on this birthday (0 if birth year unknown) + startTime: string; // ISO date of the birthday occurrence + endTime: string; // Same as startTime (all-day event) + isAllDay: true; + isBirthday: true; // Type discriminator + calendarId: string; // Virtual calendar ID +} + +// ============================================ +// API Response Types +// ============================================ + +interface BirthdaysResponse { + contacts: ContactBirthdaySummary[]; +} + +// ============================================ +// API Functions +// ============================================ + +const fetchContactsApi = contactsClient.fetchApi; + +/** + * Fetch all contacts with birthdays from Contacts service + */ +export async function getBirthdays(): Promise<{ + data: ContactBirthdaySummary[] | null; + error: Error | null; +}> { + const result = await fetchContactsApi('/contacts/birthdays'); + return { + data: result.data?.contacts || null, + error: result.error, + }; +} + +// ============================================ +// Helper Functions +// ============================================ + +/** + * Get display name from contact, with fallback + */ +export function getContactDisplayName(contact: ContactBirthdaySummary): string { + if (contact.displayName) return contact.displayName; + const fullName = [contact.firstName, contact.lastName].filter(Boolean).join(' '); + return fullName || 'Unbekannt'; +} + +/** + * Birthday calendar constants + */ +export const BIRTHDAY_CALENDAR = { + id: '__birthdays__', + name: 'Geburtstage', + color: '#EC4899', // Pink +} as const; diff --git a/apps/calendar/apps/web/src/lib/api/event-tag-groups.ts b/apps/calendar/apps/web/src/lib/api/event-tag-groups.ts new file mode 100644 index 000000000..32e00baf5 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/api/event-tag-groups.ts @@ -0,0 +1,84 @@ +/** + * Event Tag Groups API Client + */ + +import { fetchApi } from './client'; +import type { EventTagGroup } from '@calendar/shared'; + +export interface CreateEventTagGroupInput { + name: string; + color?: string; +} + +export interface UpdateEventTagGroupInput { + name?: string; + color?: string; +} + +interface GetEventTagGroupsResponse { + groups: EventTagGroup[]; + ungroupedTagCount: number; +} + +export async function getEventTagGroups() { + const result = await fetchApi('/event-tag-groups'); + if (result.error || !result.data) { + return { data: null, ungroupedTagCount: 0, error: result.error }; + } + return { + data: result.data.groups, + ungroupedTagCount: result.data.ungroupedTagCount, + error: null, + }; +} + +export async function getEventTagGroup(id: string) { + const result = await fetchApi<{ group: EventTagGroup }>(`/event-tag-groups/${id}`); + if (result.error || !result.data) { + return { data: null, error: result.error }; + } + return { data: result.data.group, error: null }; +} + +export async function createEventTagGroup(data: CreateEventTagGroupInput) { + const result = await fetchApi<{ group: EventTagGroup }>('/event-tag-groups', { + method: 'POST', + body: data, + }); + if (result.error || !result.data) { + return { data: null, error: result.error }; + } + return { data: result.data.group, error: null }; +} + +export async function updateEventTagGroup(id: string, data: UpdateEventTagGroupInput) { + const result = await fetchApi<{ group: EventTagGroup }>(`/event-tag-groups/${id}`, { + method: 'PUT', + body: data, + }); + if (result.error || !result.data) { + return { data: null, error: result.error }; + } + return { data: result.data.group, error: null }; +} + +export async function deleteEventTagGroup(id: string) { + return fetchApi<{ success: boolean }>(`/event-tag-groups/${id}`, { + method: 'DELETE', + }); +} + +export async function reorderEventTagGroups(groupIds: string[]) { + const result = await fetchApi('/event-tag-groups/reorder', { + method: 'PUT', + body: { groupIds }, + }); + if (result.error || !result.data) { + return { data: null, ungroupedTagCount: 0, error: result.error }; + } + return { + data: result.data.groups, + ungroupedTagCount: result.data.ungroupedTagCount, + error: null, + }; +} diff --git a/apps/calendar/apps/web/src/lib/api/event-tags.ts b/apps/calendar/apps/web/src/lib/api/event-tags.ts index 7ad216b7b..a3fb56a8b 100644 --- a/apps/calendar/apps/web/src/lib/api/event-tags.ts +++ b/apps/calendar/apps/web/src/lib/api/event-tags.ts @@ -1,132 +1,70 @@ /** - * Event Tags API Client - Uses central Tags API from mana-core-auth + * Event Tags API Client - Uses Calendar Backend API * - * This module wraps the central Tags API to provide backward-compatible - * "event tags" interface for the Calendar app. Tags are now unified - * across all Manacore apps (Todo, Calendar, Contacts). + * This module provides the event tags interface for the Calendar app, + * using the calendar backend's /event-tags endpoint which supports + * tag groups (groupId). */ -import { browser } from '$app/environment'; -import { - createTagsClient, - type Tag, - type CreateTagInput, - type UpdateTagInput, -} from '@manacore/shared-tags'; -import { authStore } from '$lib/stores/auth.svelte'; +import { fetchApi } from './client'; +import type { EventTag } from '@calendar/shared'; -// Re-export Tag as EventTag for backward compatibility -export type EventTag = Tag; -export type CreateEventTagInput = CreateTagInput; -export type UpdateEventTagInput = UpdateTagInput; +// Re-export EventTag from shared +export type { EventTag }; -// Get auth URL dynamically at runtime -function getAuthUrl(): string { - if (browser && typeof window !== 'undefined') { - const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string }) - .__PUBLIC_MANA_CORE_AUTH_URL__; - return injectedUrl || 'http://localhost:3001'; - } - return 'http://localhost:3001'; +export interface CreateEventTagInput { + name: string; + color?: string; + groupId?: string | null; } -// Lazy-initialized client -let _tagsClient: ReturnType | null = null; - -function getTagsClient() { - if (!browser) return null; - if (!_tagsClient) { - _tagsClient = createTagsClient({ - authUrl: getAuthUrl(), - getToken: async () => { - const token = await authStore.getAccessToken(); - return token || ''; - }, - }); - } - return _tagsClient; +export interface UpdateEventTagInput { + name?: string; + color?: string; + groupId?: string | null; } export async function getEventTags() { - const client = getTagsClient(); - if (!client) return { data: null, error: null }; - try { - const tags = await client.getAll(); - return { data: tags, error: null }; - } catch (e) { - return { - data: null, - error: { message: e instanceof Error ? e.message : 'Failed to fetch tags' }, - }; + const result = await fetchApi<{ tags: EventTag[] }>('/event-tags'); + if (result.error || !result.data) { + return { data: null, error: result.error }; } + return { data: result.data.tags, error: null }; } export async function getEventTag(id: string) { - const client = getTagsClient(); - if (!client) return { data: null, error: null }; - try { - const tag = await client.getById(id); - return { data: tag, error: null }; - } catch (e) { - return { - data: null, - error: { message: e instanceof Error ? e.message : 'Failed to fetch tag' }, - }; + const result = await fetchApi<{ tag: EventTag }>(`/event-tags/${id}`); + if (result.error || !result.data) { + return { data: null, error: result.error }; } + return { data: result.data.tag, error: null }; } export async function createEventTag(data: CreateEventTagInput) { - const client = getTagsClient(); - if (!client) return { data: null, error: { message: 'Tags client not available' } }; - try { - const tag = await client.create(data); - return { data: tag, error: null }; - } catch (e) { - return { - data: null, - error: { message: e instanceof Error ? e.message : 'Failed to create tag' }, - }; + const result = await fetchApi<{ tag: EventTag }>('/event-tags', { + method: 'POST', + body: data, + }); + if (result.error || !result.data) { + return { data: null, error: result.error }; } + return { data: result.data.tag, error: null }; } export async function updateEventTag(id: string, data: UpdateEventTagInput) { - const client = getTagsClient(); - if (!client) return { data: null, error: { message: 'Tags client not available' } }; - try { - const tag = await client.update(id, data); - return { data: tag, error: null }; - } catch (e) { - return { - data: null, - error: { message: e instanceof Error ? e.message : 'Failed to update tag' }, - }; + const result = await fetchApi<{ tag: EventTag }>(`/event-tags/${id}`, { + method: 'PUT', + body: data, + }); + if (result.error || !result.data) { + return { data: null, error: result.error }; } + return { data: result.data.tag, error: null }; } export async function deleteEventTag(id: string) { - const client = getTagsClient(); - if (!client) return { data: null, error: { message: 'Tags client not available' } }; - try { - await client.delete(id); - return { data: { success: true }, error: null }; - } catch (e) { - return { - data: null, - error: { message: e instanceof Error ? e.message : 'Failed to delete tag' }, - }; - } -} - -export async function createDefaultEventTags() { - const client = getTagsClient(); - if (!client) return { data: null, error: null }; - try { - const tags = await client.createDefaults(); - return { data: tags, error: null }; - } catch (e) { - return { - data: null, - error: { message: e instanceof Error ? e.message : 'Failed to create default tags' }, - }; - } + const result = await fetchApi<{ success: boolean }>(`/event-tags/${id}`, { + method: 'DELETE', + }); + return result; } diff --git a/apps/calendar/apps/web/src/lib/components/agenda/AgendaFilters.svelte b/apps/calendar/apps/web/src/lib/components/agenda/AgendaFilters.svelte index 15500781d..b1efe167e 100644 --- a/apps/calendar/apps/web/src/lib/components/agenda/AgendaFilters.svelte +++ b/apps/calendar/apps/web/src/lib/components/agenda/AgendaFilters.svelte @@ -1,5 +1,6 @@ @@ -53,15 +54,13 @@
- + onChange={(v) => onRangeChange?.(v as '7' | '30' | 'all')} + placeholder="Zeitraum" + embedded={true} + />
@@ -122,21 +121,6 @@ color: hsl(var(--color-muted-foreground)); } - .range-selector select { - padding: 0.375rem 0.75rem; - border-radius: var(--radius-md); - border: 1px solid hsl(var(--color-border)); - background: hsl(var(--color-surface)); - color: hsl(var(--color-foreground)); - font-size: 0.8125rem; - cursor: pointer; - } - - .range-selector select:focus { - outline: none; - border-color: hsl(var(--color-primary)); - } - @media (max-width: 480px) { .agenda-filters { flex-direction: column; diff --git a/apps/calendar/apps/web/src/lib/components/agenda/AgendaItem.svelte b/apps/calendar/apps/web/src/lib/components/agenda/AgendaItem.svelte index ce0620e0f..54549afa9 100644 --- a/apps/calendar/apps/web/src/lib/components/agenda/AgendaItem.svelte +++ b/apps/calendar/apps/web/src/lib/components/agenda/AgendaItem.svelte @@ -7,8 +7,9 @@ import TodoCheckbox from '$lib/components/todo/TodoCheckbox.svelte'; import PriorityBadge from '$lib/components/todo/PriorityBadge.svelte'; import { Calendar, MapPin, Clock } from 'lucide-svelte'; - import { format, parseISO } from 'date-fns'; + import { format } from 'date-fns'; import { de } from 'date-fns/locale'; + import { toDate } from '$lib/utils/eventDateHelpers'; type ItemType = 'event' | 'todo'; @@ -29,8 +30,8 @@ if (!event) return ''; if (event.isAllDay) return 'Ganztägig'; - const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); return `${format(start, 'HH:mm')} - ${format(end, 'HH:mm')}`; }); diff --git a/apps/calendar/apps/web/src/lib/components/birthday/BirthdayPopover.svelte b/apps/calendar/apps/web/src/lib/components/birthday/BirthdayPopover.svelte new file mode 100644 index 000000000..5dde69d6a --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/birthday/BirthdayPopover.svelte @@ -0,0 +1,269 @@ + + + + + +
e.key === 'Escape' && onClose()} + role="button" + tabindex="-1" +> + + +
+ + diff --git a/apps/calendar/apps/web/src/lib/components/calendar/AgendaView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/AgendaView.svelte new file mode 100644 index 000000000..43a94001c --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/calendar/AgendaView.svelte @@ -0,0 +1,329 @@ + + +
+ {#if groupedEvents.length === 0} +
+ + + +

Keine Termine in diesem Zeitraum

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

+ {formatDateHeader(group.date)} +

+ +
+ {#each group.events as event} + + {/each} +
+
+ {/each} +
+ {/if} +
+ + + + diff --git a/apps/calendar/apps/web/src/lib/components/calendar/CalendarHeader.svelte b/apps/calendar/apps/web/src/lib/components/calendar/CalendarHeader.svelte index b16235177..9ee69b7d8 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/CalendarHeader.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/CalendarHeader.svelte @@ -1,9 +1,50 @@ -
+ + + diff --git a/apps/calendar/apps/web/src/lib/components/calendar/CalendarHeaderContextMenu.svelte b/apps/calendar/apps/web/src/lib/components/calendar/CalendarHeaderContextMenu.svelte new file mode 100644 index 000000000..704a5bbca --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/calendar/CalendarHeaderContextMenu.svelte @@ -0,0 +1,102 @@ + + + diff --git a/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbar.svelte b/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbar.svelte index 607f9d473..60306ea41 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbar.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbar.svelte @@ -1,17 +1,21 @@ -{#if !isCollapsed} - - + + - - - -
- -
- -
-
-{/if} - - -{#if isCollapsed} - -{/if} + {#snippet rightActions()} + + {/snippet} + diff --git a/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbarContent.svelte b/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbarContent.svelte index dd5d4461d..af591ef61 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbarContent.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbarContent.svelte @@ -37,6 +37,7 @@ '14day', 'month', 'year', + 'agenda', ]; // Convert to ViewOptions for PillViewSwitcher @@ -100,7 +101,6 @@ options={viewOptions} value={viewStore.viewType} onChange={handleViewChange} - primaryColor="#3b82f6" embedded={true} /> @@ -154,21 +154,12 @@ } .toolbar-content.vertical :global(.pill-view-switcher .switcher-btn:hover) { - background: rgba(0, 0, 0, 0.05); - } - - :global(.dark) .toolbar-content.vertical :global(.pill-view-switcher .switcher-btn:hover) { - background: rgba(255, 255, 255, 0.1); + background: hsl(var(--color-foreground) / 0.05); } .toolbar-content.vertical :global(.pill-view-switcher .switcher-btn.active) { - background: color-mix(in srgb, #3b82f6 15%, transparent 85%); - border-color: color-mix(in srgb, #3b82f6 25%, transparent 75%); - } - - :global(.dark) .toolbar-content.vertical :global(.pill-view-switcher .switcher-btn.active) { - background: color-mix(in srgb, #3b82f6 25%, transparent 75%); - border-color: color-mix(in srgb, #3b82f6 35%, transparent 65%); + background: hsl(var(--color-primary) / 0.15); + border-color: hsl(var(--color-primary) / 0.25); } /* PillTimeRangeSelector in vertical mode */ diff --git a/apps/calendar/apps/web/src/lib/components/calendar/DateStrip.svelte b/apps/calendar/apps/web/src/lib/components/calendar/DateStrip.svelte index 86e875875..7cee9e61e 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/DateStrip.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/DateStrip.svelte @@ -1,6 +1,8 @@ -
- {#if !isTodayVisible} - - {/if} - -
+
+ +
- {visibleMonth} + + {#if !isTodayVisible} + + {/if} + {visibleMonth} +
@@ -237,12 +277,16 @@ {@const isFirstOfMonth = day.getDate() === 1} {@const moonPhase = isSignificantMoonPhase(day)} {@const eventCount = getEventCount(day)} + {@const showWeekNumber = settingsStore.dateStripShowWeekNumbers && isFirstDayOfWeek(day)} {#if isFirstOfMonth} -
+
{/if}
+ + diff --git a/apps/calendar/apps/web/src/lib/components/calendar/DateStripContextMenu.svelte b/apps/calendar/apps/web/src/lib/components/calendar/DateStripContextMenu.svelte new file mode 100644 index 000000000..83b25db47 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/calendar/DateStripContextMenu.svelte @@ -0,0 +1,117 @@ + + + diff --git a/apps/calendar/apps/web/src/lib/components/calendar/DateStripFab.svelte b/apps/calendar/apps/web/src/lib/components/calendar/DateStripFab.svelte new file mode 100644 index 000000000..7e35b47f5 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/calendar/DateStripFab.svelte @@ -0,0 +1,168 @@ + + +
+ + +
+ + + + diff --git a/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte index f1c692594..34f4a64b0 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte @@ -5,129 +5,89 @@ import { settingsStore } from '$lib/stores/settings.svelte'; import { searchStore } from '$lib/stores/search.svelte'; import { todosStore, type Task } from '$lib/stores/todos.svelte'; - import TaskBlock from './TaskBlock.svelte'; - import { goto } from '$app/navigation'; + import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte'; + import { birthdaysStore } from '$lib/stores/birthdays.svelte'; + import BirthdayPopover from '$lib/components/birthday/BirthdayPopover.svelte'; + import { useVisibleHours, useCurrentTimeIndicator, useBirthdayPopover } from '$lib/composables'; + import { toDate } from '$lib/utils/eventDateHelpers'; + import { HOUR_HEIGHT_PX, SNAP_INTERVAL_MINUTES } from '$lib/utils/calendarConstants'; import { - format, - isToday, - parseISO, - differenceInMinutes, - addMinutes, - setHours, - setMinutes, - } from 'date-fns'; + getVisibleTimedEvents, + getVisibleAllDayEvents, + getVisibleOverflowEvents, + type OverflowEvents, + } from '$lib/utils/eventFiltering'; + import EventCard from './EventCard.svelte'; + import TaskBlock from './TaskBlock.svelte'; + import EventContextMenu from '$lib/components/event/EventContextMenu.svelte'; + import { goto } from '$app/navigation'; + import { format, isToday, differenceInMinutes, addMinutes, setHours, setMinutes } from 'date-fns'; import { de } from 'date-fns/locale'; import type { CalendarEvent } from '@calendar/shared'; interface Props { + /** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */ + date?: Date; onQuickCreate?: (date: Date, position: { x: number; y: number }) => void; onEventClick?: (event: CalendarEvent) => void; onTaskClick?: (task: Task) => void; } - let { onQuickCreate, onEventClick, onTaskClick }: Props = $props(); + let { date, onQuickCreate, onEventClick, onTaskClick }: Props = $props(); - // Constants - const HOUR_HEIGHT = 60; // pixels per hour - const SNAP_MINUTES = 15; // snap to 15-minute intervals + // Use provided date or fall back to viewStore + let effectiveDate = $derived(date ?? viewStore.currentDate); - // Generate hours (filtered based on settings) - let allHours = Array.from({ length: 24 }, (_, i) => i); - let hours = $derived( - settingsStore.filterHoursEnabled - ? allHours.filter((h) => h >= settingsStore.dayStartHour && h < settingsStore.dayEndHour) - : allHours - ); + // Use shared constants + const HOUR_HEIGHT = HOUR_HEIGHT_PX; + const SNAP_MINUTES = SNAP_INTERVAL_MINUTES; - // Calculate visible hours range for positioning - let firstVisibleHour = $derived( - settingsStore.filterHoursEnabled ? settingsStore.dayStartHour : 0 - ); - let lastVisibleHour = $derived(settingsStore.filterHoursEnabled ? settingsStore.dayEndHour : 24); - let totalVisibleHours = $derived(lastVisibleHour - firstVisibleHour); + // Use composables for hour filtering and time indicator + const visibleHours = useVisibleHours(); + const timeIndicator = useCurrentTimeIndicator(); - // Helper to convert minutes to percentage position (accounting for hidden hours) - function minutesToPercent(minutes: number): number { - const adjustedMinutes = minutes - firstVisibleHour * 60; - return (adjustedMinutes / (totalVisibleHours * 60)) * 100; - } + // Destructure for convenience (these are reactive getters) + let hours = $derived(visibleHours.hours); + let firstVisibleHour = $derived(visibleHours.firstVisibleHour); + let lastVisibleHour = $derived(visibleHours.lastVisibleHour); + let totalVisibleHours = $derived(visibleHours.totalVisibleHours); + const minutesToPercent = visibleHours.minutesToPercent; // Current time indicator position - let now = $state(new Date()); - let currentTimePosition = $derived.by(() => { - const minutes = now.getHours() * 60 + now.getMinutes(); - return minutesToPercent(minutes); - }); - - // Update current time every minute - $effect(() => { - const interval = setInterval(() => { - now = new Date(); - }, 60000); - return () => clearInterval(interval); - }); + let currentTimePosition = $derived(minutesToPercent(timeIndicator.currentMinutes)); // Get timed events, filtering out those outside visible range when hour filter is enabled - let timedEvents = $derived.by(() => { - const allEvents = eventsStore.getEventsForDay(viewStore.currentDate).filter((e) => !e.isAllDay); - - if (settingsStore.filterHoursEnabled) { - const visibleStartMinutes = settingsStore.dayStartHour * 60; - const visibleEndMinutes = settingsStore.dayEndHour * 60; - - return allEvents.filter((event) => { - const start = - typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; - - const eventStartMinutes = start.getHours() * 60 + start.getMinutes(); - const eventEndMinutes = end.getHours() * 60 + end.getMinutes(); - - // Event overlaps with visible range - return eventStartMinutes < visibleEndMinutes && eventEndMinutes > visibleStartMinutes; - }); - } - - return allEvents; - }); + let timedEvents = $derived( + getVisibleTimedEvents( + eventsStore.getEventsForDay(effectiveDate), + calendarsStore.visibleCalendars, + { + filterHoursEnabled: settingsStore.filterHoursEnabled, + dayStartHour: settingsStore.dayStartHour, + dayEndHour: settingsStore.dayEndHour, + } + ) + ); // Get events that are completely outside the visible time range - let overflowEvents = $derived.by(() => { + let overflowEvents = $derived.by((): OverflowEvents => { if (!settingsStore.filterHoursEnabled) { - return { before: [] as CalendarEvent[], after: [] as CalendarEvent[] }; + return { before: [], after: [] }; } - - const allEvents = eventsStore.getEventsForDay(viewStore.currentDate).filter((e) => !e.isAllDay); - const before: CalendarEvent[] = []; - const after: CalendarEvent[] = []; - - const visibleStartMinutes = settingsStore.dayStartHour * 60; - const visibleEndMinutes = settingsStore.dayEndHour * 60; - - for (const event of allEvents) { - const start = - typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; - - const eventStartMinutes = start.getHours() * 60 + start.getMinutes(); - const eventEndMinutes = end.getHours() * 60 + end.getMinutes(); - - // Event ends before visible range starts - if (eventEndMinutes <= visibleStartMinutes) { - before.push(event); - } - // Event starts after visible range ends - else if (eventStartMinutes >= visibleEndMinutes) { - after.push(event); - } - } - - return { before, after }; + return getVisibleOverflowEvents( + eventsStore.getEventsForDay(effectiveDate), + calendarsStore.visibleCalendars, + settingsStore.dayStartHour, + settingsStore.dayEndHour + ); }); let allDayEvents = $derived( - eventsStore.getEventsForDay(viewStore.currentDate).filter((e) => e.isAllDay) + getVisibleAllDayEvents( + eventsStore.getEventsForDay(effectiveDate), + calendarsStore.visibleCalendars + ) ); // Get display mode for an event (per-event override takes precedence over global setting) @@ -145,6 +105,15 @@ let blockAllDayEvents = $derived(allDayEvents.filter((e) => getEventDisplayMode(e) === 'block')); + // Birthday Popover (using composable) + const birthdayPopover = useBirthdayPopover(); + + // Get birthdays for current day (if enabled in settings) + let birthdays = $derived.by(() => { + if (!settingsStore.showBirthdays) return []; + return birthdaysStore.getBirthdaysForDay(effectiveDate); + }); + // ============================================================================ // Drag & Drop State // ============================================================================ @@ -165,6 +134,7 @@ let resizeOriginalEnd = $state(null); let resizePreviewTop = $state(0); let resizePreviewHeight = $state(0); + let resizeOffsetMinutes = $state(0); // Track if we actually moved during drag/resize (to prevent click on simple mousedown/up) let hasMoved = $state(false); @@ -210,8 +180,8 @@ e.preventDefault(); e.stopPropagation(); - const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); const startMinutes = start.getHours() * 60 + start.getMinutes(); const duration = differenceInMinutes(end, start); @@ -255,18 +225,12 @@ Math.min(newStartMinutes, lastVisibleHour * 60 - 30) ); - const start = - typeof draggedEvent.startTime === 'string' - ? parseISO(draggedEvent.startTime) - : draggedEvent.startTime; - const end = - typeof draggedEvent.endTime === 'string' - ? parseISO(draggedEvent.endTime) - : draggedEvent.endTime; + const start = toDate(draggedEvent.startTime); + const end = toDate(draggedEvent.endTime); const duration = differenceInMinutes(end, start); // Create new start time on same day - let newStart = new Date(viewStore.currentDate); + let newStart = new Date(effectiveDate); newStart = setHours(newStart, Math.floor(clampedMinutes / 60)); newStart = setMinutes(newStart, clampedMinutes % 60); newStart.setSeconds(0, 0); @@ -301,16 +265,25 @@ resizeEdge = edge; hasMoved = false; - const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); resizeOriginalStart = start; resizeOriginalEnd = end; const startMinutes = start.getHours() * 60 + start.getMinutes(); + const endMinutes = end.getHours() * 60 + end.getMinutes(); const duration = differenceInMinutes(end, start); resizePreviewTop = minutesToPercent(startMinutes); resizePreviewHeight = (duration / (totalVisibleHours * 60)) * 100; + // Calculate offset between snapped click position and actual event boundary + const clickMinutes = getMinutesFromY(e.clientY); + if (edge === 'top') { + resizeOffsetMinutes = clickMinutes - startMinutes; + } else { + resizeOffsetMinutes = clickMinutes - endMinutes; + } + document.addEventListener('pointermove', handleResizeMove); document.addEventListener('pointerup', handleResizeEnd); } @@ -320,18 +293,19 @@ hasMoved = true; const mouseMinutes = getMinutesFromY(e.clientY); - const snappedMinutes = snapToGrid(mouseMinutes); + // Apply offset to prevent jumping when drag starts + const adjustedMinutes = snapToGrid(mouseMinutes - resizeOffsetMinutes); const origStartMinutes = resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes(); const origEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes(); if (resizeEdge === 'top') { - const newStartMinutes = Math.min(snappedMinutes, origEndMinutes - SNAP_MINUTES); + const newStartMinutes = Math.min(adjustedMinutes, origEndMinutes - SNAP_MINUTES); const clampedStart = Math.max(firstVisibleHour * 60, newStartMinutes); resizePreviewTop = minutesToPercent(clampedStart); resizePreviewHeight = ((origEndMinutes - clampedStart) / (totalVisibleHours * 60)) * 100; } else { - const newEndMinutes = Math.max(snappedMinutes, origStartMinutes + SNAP_MINUTES); + const newEndMinutes = Math.max(adjustedMinutes, origStartMinutes + SNAP_MINUTES); const clampedEnd = Math.min(lastVisibleHour * 60, newEndMinutes); resizePreviewHeight = ((clampedEnd - origStartMinutes) / (totalVisibleHours * 60)) * 100; } @@ -344,7 +318,8 @@ } const mouseMinutes = getMinutesFromY(e.clientY); - const snappedMinutes = snapToGrid(mouseMinutes); + // Apply offset to prevent jumping + const adjustedMinutes = snapToGrid(mouseMinutes - resizeOffsetMinutes); const origStartMinutes = resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes(); const origEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes(); @@ -355,17 +330,17 @@ if (resizeEdge === 'top') { const newStartMinutes = Math.max( firstVisibleHour * 60, - Math.min(snappedMinutes, origEndMinutes - SNAP_MINUTES) + Math.min(adjustedMinutes, origEndMinutes - SNAP_MINUTES) ); - newStart = setHours(new Date(viewStore.currentDate), Math.floor(newStartMinutes / 60)); + newStart = setHours(new Date(effectiveDate), Math.floor(newStartMinutes / 60)); newStart = setMinutes(newStart, newStartMinutes % 60); newStart.setSeconds(0, 0); } else { const newEndMinutes = Math.min( lastVisibleHour * 60, - Math.max(snappedMinutes, origStartMinutes + SNAP_MINUTES) + Math.max(adjustedMinutes, origStartMinutes + SNAP_MINUTES) ); - newEnd = setHours(new Date(viewStore.currentDate), Math.floor(newEndMinutes / 60)); + newEnd = setHours(new Date(effectiveDate), Math.floor(newEndMinutes / 60)); newEnd = setMinutes(newEnd, newEndMinutes % 60); newEnd.setSeconds(0, 0); } @@ -393,6 +368,7 @@ resizeEvent = null; resizeOriginalStart = null; resizeOriginalEnd = null; + resizeOffsetMinutes = 0; hasMoved = false; document.removeEventListener('pointermove', handleDragMove); document.removeEventListener('pointerup', handleDragEnd); @@ -615,7 +591,7 @@ const endTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`; await todosStore.updateTodo(data.taskId, { - scheduledDate: format(viewStore.currentDate, 'yyyy-MM-dd'), + scheduledDate: format(effectiveDate, 'yyyy-MM-dd'), scheduledStartTime: startTime, scheduledEndTime: endTime, estimatedDuration: duration, @@ -645,8 +621,8 @@ // Event Styling // ============================================================================ function getEventStyle(event: CalendarEvent) { - const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); const startMinutes = start.getHours() * 60 + start.getMinutes(); const duration = differenceInMinutes(end, start); @@ -655,9 +631,11 @@ const top = minutesToPercent(startMinutes); const height = Math.max((duration / (totalVisibleHours * 60)) * 100, 1.5); // minimum ~20px at 60px/hour - const color = calendarsStore.getColor(event.calendarId); + return `top: ${top}%; height: ${height}%;`; + } - return `top: ${top}%; height: ${height}%; background-color: ${color};`; + function formatEventTime(event: CalendarEvent): string { + return `${format(toDate(event.startTime), 'HH:mm')} - ${format(toDate(event.endTime), 'HH:mm')}`; } /** @@ -686,7 +664,7 @@ * Get scheduled tasks for current day */ function getScheduledTasks(): Task[] { - return todosStore.getScheduledTasksForDay(viewStore.currentDate); + return todosStore.getScheduledTasksForDay(effectiveDate); } function handleEventClick(event: CalendarEvent, e: MouseEvent) { @@ -710,7 +688,7 @@ // Don't create event if dragging or resizing if (isDragging || isResizing) return; - const startTime = new Date(viewStore.currentDate); + const startTime = new Date(effectiveDate); startTime.setHours(hour, 0, 0, 0); if (onQuickCreate) { @@ -719,11 +697,25 @@ goto(`/event/new?start=${startTime.toISOString()}`); } } + + function handleEventContextMenu(event: CalendarEvent, e: MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + // Don't show context menu for draft events + if (eventsStore.isDraftEvent(event.id)) return; + eventContextMenuStore.show(event, e.clientX, e.clientY); + } + + function handleContextMenuEdit(event: CalendarEvent) { + if (onEventClick) { + onEventClick(event); + } + }
- - {#if headerAllDayEvents.length > 0} + + {#if headerAllDayEvents.length > 0 || birthdays.length > 0}
Ganztägig @@ -740,6 +732,18 @@ {event.title} {/each} + + {#each birthdays as birthday} + + {/each}
{/if} @@ -757,7 +761,7 @@
- {#each timedEvents as event} + {#each timedEvents as event (event.id)} {@const isBeingDragged = isDragging && draggedEvent?.id === event.id} {@const isBeingResized = isResizing && resizeEvent?.id === event.id} - {@const isDraft = eventsStore.isDraftEvent(event.id)} - {@const isSearchHighlighted = searchStore.isEventHighlighted(event.id)} - {@const isSearchDimmed = searchStore.isEventDimmed(event.id)} - -
startDrag(event, e)} - onclick={(e) => !isDraft && handleEventClick(event, e)} - role="button" - tabindex="0" - > - -
startResize(event, 'top', e)} - role="separator" - aria-orientation="horizontal" - aria-label="Startzeit ändern" - aria-valuenow={0} - tabindex="-1" - >
- - - {format( - typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime, - 'HH:mm' - )} - - {format( - typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime, - 'HH:mm' - )} - - {event.title || (isDraft ? '(Neuer Termin)' : '')} - {#if event.location} - {event.location} - {/if} - - -
startResize(event, 'bottom', e)} - role="separator" - aria-orientation="horizontal" - aria-label="Endzeit ändern" - aria-valuenow={0} - tabindex="-1" - >
-
+ color={calendarsStore.getColor(event.calendarId)} + isDragging={isBeingDragged} + isResizing={isBeingResized} + isSearchHighlighted={searchStore.isEventHighlighted(event.id)} + isSearchDimmed={searchStore.isEventDimmed(event.id)} + formattedTime={formatEventTime(event)} + onClick={handleEventClick} + onPointerDown={startDrag} + onContextMenu={handleEventContextMenu} + onResizeStart={startResize} + /> {/each} @@ -876,10 +839,7 @@
{/each}
@@ -893,33 +853,45 @@
{/each}
{/if} - {#if isToday(viewStore.currentDate)} + {#if isToday(effectiveDate)}
{/if}
+ + + + +{#if birthdayPopover.selectedBirthday} + +{/if} + diff --git a/apps/calendar/apps/web/src/lib/components/calendar/EventCard.svelte b/apps/calendar/apps/web/src/lib/components/calendar/EventCard.svelte new file mode 100644 index 000000000..a106e4cea --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/calendar/EventCard.svelte @@ -0,0 +1,251 @@ + + +
+ + {#if onResizeStart} +
+ {/if} + + {formattedTime} + {event.title || (isDraft ? $_('calendar.draftEvent') : '')} + {#if event.location} + {event.location} + {/if} + + + {#if onResizeStart} +
+ {/if} +
+ + diff --git a/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte index 936c5b755..ebbc02494 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte @@ -5,7 +5,11 @@ import { settingsStore } from '$lib/stores/settings.svelte'; import { searchStore } from '$lib/stores/search.svelte'; import { todosStore } from '$lib/stores/todos.svelte'; + import { birthdaysStore, type BirthdayEvent } from '$lib/stores/birthdays.svelte'; + import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte'; import TodoDayCell from './TodoDayCell.svelte'; + import BirthdayPopover from '$lib/components/birthday/BirthdayPopover.svelte'; + import { useBirthdayPopover } from '$lib/composables'; import { goto } from '$app/navigation'; import { format, @@ -27,20 +31,26 @@ } from 'date-fns'; import { de } from 'date-fns/locale'; import { _ } from 'svelte-i18n'; + import { filterByVisibleCalendars } from '$lib/utils/eventFiltering'; import type { CalendarEvent } from '@calendar/shared'; interface Props { + /** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */ + date?: Date; onQuickCreate?: (date: Date, position: { x: number; y: number }) => void; onEventClick?: (event: CalendarEvent) => void; } - let { onQuickCreate, onEventClick }: Props = $props(); + let { date, onQuickCreate, onEventClick }: Props = $props(); + + // Use provided date or fall back to viewStore + let effectiveDate = $derived(date ?? viewStore.currentDate); // Get all days to display in the month grid (including days from prev/next months) let allCalendarDays = $derived.by(() => { - const monthStart = startOfMonth(viewStore.currentDate); - const monthEnd = endOfMonth(viewStore.currentDate); + const monthStart = startOfMonth(effectiveDate); + const monthEnd = endOfMonth(effectiveDate); const calendarStart = startOfWeek(monthStart, { weekStartsOn: settingsStore.weekStartsOn }); const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: settingsStore.weekStartsOn }); @@ -80,7 +90,6 @@ let isDragging = $state(false); let draggedEvent = $state(null); let dragTargetDay = $state(null); - let monthViewRef = $state(null); // Store for day cell refs let dayCellRefs = $state>(new Map()); @@ -191,8 +200,18 @@ // ============================================================================ // Event Handlers // ============================================================================ - function getEventsForDay(day: Date) { - return eventsStore.getEventsForDay(day).slice(0, 3); // Max 3 events shown + function getEventsForDay(day: Date): CalendarEvent[] { + return filterByVisibleCalendars( + eventsStore.getEventsForDay(day), + calendarsStore.visibleCalendars + ).slice(0, 3); // Max 3 events shown + } + + function getAllEventsForDay(day: Date): CalendarEvent[] { + return filterByVisibleCalendars( + eventsStore.getEventsForDay(day), + calendarsStore.visibleCalendars + ); } function handleDayClick(day: Date, e: MouseEvent) { @@ -241,9 +260,27 @@ viewStore.setDate(day); viewStore.setViewType('day'); } + + function handleEventContextMenu(event: CalendarEvent, e: MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + // Don't show context menu for draft events + if (eventsStore.isDraftEvent(event.id)) return; + eventContextMenuStore.show(event, e.clientX, e.clientY); + } + + // ============================================================================ + // Birthday Functions + // ============================================================================ + const birthdayPopover = useBirthdayPopover(); + + function getBirthdaysForDay(day: Date): BirthdayEvent[] { + if (!settingsStore.showBirthdays) return []; + return birthdaysStore.getBirthdaysForDay(day); + } -
+
{#each weekDays as day} @@ -259,7 +296,7 @@ {@const isDropTarget = isDragging && dragTargetDay && isSameDay(day, dragTargetDay)}
startDrag(event, e)} onclick={(e) => !isDraft && handleEventClick(event, e)} + oncontextmenu={(e) => handleEventContextMenu(event, e)} role="button" tabindex="0" > @@ -319,10 +357,27 @@
{/each} - {#if eventsStore.getEventsForDay(day).length > 3} + + {#each getBirthdaysForDay(day) as birthday} + +
birthdayPopover.handleBirthdayClick(birthday, e)} + role="button" + tabindex="0" + > + 🎂 + {birthday.displayName} + {#if settingsStore.showBirthdayAge && birthday.age > 0} + ({birthday.age}) + {/if} +
+ {/each} + + {#if getAllEventsForDay(day).length > 3} {/if} @@ -334,6 +389,15 @@
+ +{#if birthdayPopover.selectedBirthday} + +{/if} + diff --git a/apps/calendar/apps/web/src/lib/components/calendar/MultiDayView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/MultiDayView.svelte index 2348ea8ae..cc5787666 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/MultiDayView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/MultiDayView.svelte @@ -5,6 +5,19 @@ import { settingsStore } from '$lib/stores/settings.svelte'; import { searchStore } from '$lib/stores/search.svelte'; import { todosStore, type Task } from '$lib/stores/todos.svelte'; + import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte'; + import { + useVisibleHours, + useCurrentTimeIndicator, + } from '$lib/composables/useVisibleHours.svelte'; + import { toDate } from '$lib/utils/eventDateHelpers'; + import { HOUR_HEIGHT_PX, SNAP_INTERVAL_MINUTES } from '$lib/utils/calendarConstants'; + import { + getVisibleTimedEvents, + getVisibleAllDayEvents, + getVisibleOverflowEvents, + type OverflowEvents, + } from '$lib/utils/eventFiltering'; import TaskBlock from './TaskBlock.svelte'; import { goto } from '$app/navigation'; import { @@ -12,7 +25,6 @@ eachDayOfInterval, isToday, isSameDay, - parseISO, differenceInMinutes, isWeekend, addMinutes, @@ -22,20 +34,40 @@ import { de, enUS, fr, es, it } from 'date-fns/locale'; import { locale } from 'svelte-i18n'; - // Constants - const HOUR_HEIGHT = 60; // px - should match CSS --hour-height - const MINUTES_PER_SLOT = 15; // Snap to 15-minute intervals + // Use shared constants + const HOUR_HEIGHT = HOUR_HEIGHT_PX; + const MINUTES_PER_SLOT = SNAP_INTERVAL_MINUTES; import type { CalendarEvent } from '@calendar/shared'; // Props interface Props { - dayCount: 5 | 10 | 14; + dayCount: number; + /** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */ + date?: Date; onQuickCreate?: (date: Date, position: { x: number; y: number }) => void; onEventClick?: (event: CalendarEvent) => void; onTaskClick?: (task: Task) => void; } - let { dayCount, onQuickCreate, onEventClick, onTaskClick }: Props = $props(); + let { dayCount, date, onQuickCreate, onEventClick, onTaskClick }: Props = $props(); + + // Use provided date or fall back to viewStore + let effectiveDate = $derived(date ?? viewStore.currentDate); + + // Calculate view range based on effective date + let effectiveViewRange = $derived.by(() => { + if (date) { + // Calculate range for the provided date based on day count + const end = new Date(date); + end.setDate(end.getDate() + dayCount - 1); + return { + start: date, + end: end, + }; + } + // Use viewStore range when no date override + return viewStore.viewRange; + }); // Get date-fns locale based on current app locale const dateLocales = { de, en: enUS, fr, es, it }; @@ -46,8 +78,8 @@ // Generate days based on view range, optionally filtering weekends let allDays = $derived( eachDayOfInterval({ - start: viewStore.viewRange.start, - end: viewStore.viewRange.end, + start: effectiveViewRange.start, + end: effectiveViewRange.end, }) ); @@ -55,47 +87,26 @@ settingsStore.showOnlyWeekdays ? allDays.filter((day) => !isWeekend(day)) : allDays ); - // Generate hours (filtered based on settings) - let allHours = Array.from({ length: 24 }, (_, i) => i); - let hours = $derived( - settingsStore.filterHoursEnabled - ? allHours.filter((h) => h >= settingsStore.dayStartHour && h < settingsStore.dayEndHour) - : allHours - ); + // Use composables for hour filtering and time indicator + const visibleHours = useVisibleHours(); + const timeIndicator = useCurrentTimeIndicator(); - // Calculate visible hours range for positioning - let firstVisibleHour = $derived( - settingsStore.filterHoursEnabled ? settingsStore.dayStartHour : 0 - ); - let lastVisibleHour = $derived(settingsStore.filterHoursEnabled ? settingsStore.dayEndHour : 24); - let totalVisibleHours = $derived(lastVisibleHour - firstVisibleHour); - - // Helper to convert minutes to percentage position (accounting for hidden hours) - function minutesToPercent(minutes: number): number { - const adjustedMinutes = minutes - firstVisibleHour * 60; - return (adjustedMinutes / (totalVisibleHours * 60)) * 100; - } + // Destructure for convenience (these are reactive getters) + let hours = $derived(visibleHours.hours); + let firstVisibleHour = $derived(visibleHours.firstVisibleHour); + let lastVisibleHour = $derived(visibleHours.lastVisibleHour); + let totalVisibleHours = $derived(visibleHours.totalVisibleHours); + const minutesToPercent = visibleHours.minutesToPercent; // Current time indicator position - let now = $state(new Date()); - let currentTimePosition = $derived.by(() => { - const minutes = now.getHours() * 60 + now.getMinutes(); - return minutesToPercent(minutes); - }); - - // Update current time every minute - $effect(() => { - const interval = setInterval(() => { - now = new Date(); - }, 60000); - return () => clearInterval(interval); - }); + let currentTimePosition = $derived(minutesToPercent(timeIndicator.currentMinutes)); // Determine column width based on day count let columnClass = $derived.by(() => { if (days.length <= 5) return 'normal'; if (days.length <= 10) return 'compact'; - return 'very-compact'; + if (days.length <= 14) return 'very-compact'; + return 'ultra-compact'; }); // ========== Drag & Drop State ========== @@ -114,6 +125,7 @@ let resizeOriginalEnd = $state(null); let resizePreviewTop = $state(0); let resizePreviewHeight = $state(0); + let resizeOffsetMinutes = $state(0); // Track if we actually moved during drag/resize (to prevent click on simple mousedown/up) let hasMoved = $state(false); @@ -135,66 +147,35 @@ // Reference to the days container for position calculations let daysContainerEl: HTMLDivElement; - function getEventsForDay(day: Date) { - const allEvents = eventsStore.getEventsForDay(day).filter((e) => !e.isAllDay); - - // If hour filtering is enabled, only show events that overlap with visible range - if (settingsStore.filterHoursEnabled) { - const visibleStartMinutes = settingsStore.dayStartHour * 60; - const visibleEndMinutes = settingsStore.dayEndHour * 60; - - return allEvents.filter((event) => { - const start = - typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; - - const eventStartMinutes = start.getHours() * 60 + start.getMinutes(); - const eventEndMinutes = end.getHours() * 60 + end.getMinutes(); - - // Event overlaps with visible range - return eventStartMinutes < visibleEndMinutes && eventEndMinutes > visibleStartMinutes; - }); - } - - return allEvents; + function getEventsForDay(day: Date): CalendarEvent[] { + return getVisibleTimedEvents( + eventsStore.getEventsForDay(day), + calendarsStore.visibleCalendars, + { + filterHoursEnabled: settingsStore.filterHoursEnabled, + dayStartHour: settingsStore.dayStartHour, + dayEndHour: settingsStore.dayEndHour, + } + ); } - // Get events that are completely outside the visible time range - function getOverflowEventsForDay(day: Date): { before: CalendarEvent[]; after: CalendarEvent[] } { + function getOverflowEventsForDay(day: Date): OverflowEvents { if (!settingsStore.filterHoursEnabled) { return { before: [], after: [] }; } - - const allEvents = eventsStore.getEventsForDay(day).filter((e) => !e.isAllDay); - const before: CalendarEvent[] = []; - const after: CalendarEvent[] = []; - - const visibleStartMinutes = settingsStore.dayStartHour * 60; - const visibleEndMinutes = settingsStore.dayEndHour * 60; - - for (const event of allEvents) { - const start = - typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; - - const eventStartMinutes = start.getHours() * 60 + start.getMinutes(); - const eventEndMinutes = end.getHours() * 60 + end.getMinutes(); - - // Event ends before visible range starts - if (eventEndMinutes <= visibleStartMinutes) { - before.push(event); - } - // Event starts after visible range ends - else if (eventStartMinutes >= visibleEndMinutes) { - after.push(event); - } - } - - return { before, after }; + return getVisibleOverflowEvents( + eventsStore.getEventsForDay(day), + calendarsStore.visibleCalendars, + settingsStore.dayStartHour, + settingsStore.dayEndHour + ); } - function getAllDayEventsForDay(day: Date) { - return eventsStore.getEventsForDay(day).filter((e) => e.isAllDay); + function getAllDayEventsForDay(day: Date): CalendarEvent[] { + return getVisibleAllDayEvents( + eventsStore.getEventsForDay(day), + calendarsStore.visibleCalendars + ); } // Get display mode for an event (per-event override takes precedence over global setting) @@ -220,8 +201,8 @@ ); function getEventStyle(event: CalendarEvent) { - const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); const startMinutes = start.getHours() * 60 + start.getMinutes(); const duration = differenceInMinutes(end, start); @@ -264,8 +245,7 @@ } function formatEventTime(date: Date | string): string { - const d = typeof date === 'string' ? parseISO(date) : date; - return settingsStore.formatTime(d); + return settingsStore.formatTime(toDate(date)); } function handleEventClick(event: CalendarEvent, e: MouseEvent) { @@ -299,6 +279,14 @@ } } + function handleEventContextMenu(event: CalendarEvent, e: MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + // Don't show context menu for draft events + if (eventsStore.isDraftEvent(event.id)) return; + eventContextMenuStore.show(event, e.clientX, e.clientY); + } + // ========== Drag & Drop Functions ========== function getDayFromX(clientX: number): Date | null { @@ -337,8 +325,8 @@ draggedEvent = event; hasMoved = false; - const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); const duration = differenceInMinutes(end, start); // Calculate initial preview position @@ -388,14 +376,8 @@ return; } - const start = - typeof draggedEvent.startTime === 'string' - ? parseISO(draggedEvent.startTime) - : draggedEvent.startTime; - const end = - typeof draggedEvent.endTime === 'string' - ? parseISO(draggedEvent.endTime) - : draggedEvent.endTime; + const start = toDate(draggedEvent.startTime); + const end = toDate(draggedEvent.endTime); const duration = differenceInMinutes(end, start); // Calculate new start time @@ -441,18 +423,27 @@ resizeEdge = edge; hasMoved = false; - const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); resizeOriginalStart = start; resizeOriginalEnd = end; // Set initial preview const startMinutes = start.getHours() * 60 + start.getMinutes(); + const endMinutes = end.getHours() * 60 + end.getMinutes(); const duration = differenceInMinutes(end, start); resizePreviewTop = minutesToPercent(startMinutes); resizePreviewHeight = (duration / (totalVisibleHours * 60)) * 100; + // Calculate offset between snapped click position and actual event boundary + const clickMinutes = getMinutesFromY(e.clientY); + if (edge === 'top') { + resizeOffsetMinutes = clickMinutes - startMinutes; + } else { + resizeOffsetMinutes = clickMinutes - endMinutes; + } + document.addEventListener('pointermove', handleResizeMove); document.addEventListener('pointerup', handleResizeEnd); } @@ -462,6 +453,8 @@ hasMoved = true; const currentMinutes = getMinutesFromY(e.clientY); + // Apply offset to prevent jumping when drag starts + const adjustedMinutes = currentMinutes - resizeOffsetMinutes; const originalStartMinutes = resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes(); const originalEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes(); @@ -470,7 +463,7 @@ // Resize from bottom - change end time const newEndMinutes = Math.max( originalStartMinutes + 15, - Math.min(lastVisibleHour * 60, currentMinutes) + Math.min(lastVisibleHour * 60, adjustedMinutes) ); const newDuration = newEndMinutes - originalStartMinutes; resizePreviewHeight = (newDuration / (totalVisibleHours * 60)) * 100; @@ -478,7 +471,7 @@ // Resize from top - change start time const newStartMinutes = Math.max( firstVisibleHour * 60, - Math.min(originalEndMinutes - 15, currentMinutes) + Math.min(originalEndMinutes - 15, adjustedMinutes) ); const newDuration = originalEndMinutes - newStartMinutes; resizePreviewTop = minutesToPercent(newStartMinutes); @@ -495,11 +488,14 @@ resizeEvent = null; resizeOriginalStart = null; resizeOriginalEnd = null; + resizeOffsetMinutes = 0; hasMoved = false; return; } const currentMinutes = getMinutesFromY(e.clientY); + // Apply offset to prevent jumping + const adjustedMinutes = currentMinutes - resizeOffsetMinutes; const originalStartMinutes = resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes(); const originalEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes(); @@ -510,7 +506,7 @@ if (resizeEdge === 'bottom') { const newEndMinutes = Math.max( originalStartMinutes + 15, - Math.min(lastVisibleHour * 60, currentMinutes) + Math.min(lastVisibleHour * 60, adjustedMinutes) ); const newHours = Math.floor(newEndMinutes / 60); const newMins = newEndMinutes % 60; @@ -519,7 +515,7 @@ } else { const newStartMinutes = Math.max( firstVisibleHour * 60, - Math.min(originalEndMinutes - 15, currentMinutes) + Math.min(originalEndMinutes - 15, adjustedMinutes) ); const newHours = Math.floor(newStartMinutes / 60); const newMins = newStartMinutes % 60; @@ -545,6 +541,7 @@ resizeEvent = null; resizeOriginalStart = null; resizeOriginalEnd = null; + resizeOffsetMinutes = 0; hasMoved = false; } @@ -807,6 +804,7 @@ resizeEvent = null; resizeOriginalStart = null; resizeOriginalEnd = null; + resizeOffsetMinutes = 0; isTaskDragging = false; draggedTask = null; taskDragTargetDay = null; @@ -826,41 +824,45 @@ class="multi-day-view" class:compact={columnClass === 'compact'} class:very-compact={columnClass === 'very-compact'} + class:ultra-compact={columnClass === 'ultra-compact'} > - - {#if hasAnyHeaderAllDayEvents} -
+ +
-
-
-

Meine Kalender

- -
+ + {#snippet icon()} + + + + {/snippet} + +
+
+ +
- {#if showNewCalendarForm} -
-
{ - e.preventDefault(); - handleCreateCalendar(); - }} - > -
- - -
-
- - -
-
-
- {/if} - -
- {#each calendarsStore.calendars as calendar} -
- {#if editingCalendar?.id === calendar.id} + {#if showNewCalendarForm} +
{ e.preventDefault(); - const form = e.target as HTMLFormElement; - const name = (form.elements.namedItem('name') as HTMLInputElement).value; - const color = (form.elements.namedItem('color') as HTMLInputElement).value; - handleUpdateCalendar(calendar, name, color); + handleCreateCalendar(); }} >
- - + +
- +
- {:else} -
- - {calendar.name} - {#if calendar.isDefault} - Standard - {/if} -
-
- - {#if !calendar.isDefault} -
+ {/if} + +
+ {#each calendarsStore.calendars as calendar} + {#if editingCalendar?.id === calendar.id} +
+
{ + e.preventDefault(); + handleUpdateCalendar(); + }} > - Löschen - - {/if} +
+
+ + +
+ +
+ +
+ + {editColor} +
+
+
+ + + +
+ + +
+
+
+ {:else} +
+
+ + {calendar.name} + {#if calendar.isDefault} + Standard + {/if} +
+
+ + {#if !calendar.isDefault} + + {/if} +
+
+ {/if} + {/each} + + {#if calendarsStore.calendars.length === 0} +
+

Keine Kalender vorhanden

{/if}
- {/each} - - {#if calendarsStore.calendars.length === 0} -
-

Keine Kalender vorhanden

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

Kalender-Ansicht

- -
-
- Standard-Ansicht - Ansicht beim Öffnen des Kalenders -
- -
- -
-
- Zeitformat - Anzeige der Uhrzeiten -
-
- - -
-
- -
- -
- -
- -
- -
- -
- - {#if settingsStore.filterHoursEnabled} -
-
- Sichtbare Stunden - Zeitbereich der in der Kalenderansicht angezeigt wird -
-
-
- - + + {/snippet} + +
+
+
+ Standard-Ansicht + Ansicht beim Öffnen des Kalenders
- -
- - + 24h (14:00) + + +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ + {#if settingsStore.filterHoursEnabled} +
+
+ Sichtbare Stunden + Zeitbereich der in der Kalenderansicht angezeigt wird +
+
+
+ Von + settingsStore.set('dayStartHour', Number(v))} + placeholder="Start" + /> +
+ +
+ Bis + settingsStore.set('dayEndHour', Number(v))} + placeholder="Ende" + /> +
+
+
+ {/if} + +
+
+ Ganztägige Termine + Wie sollen ganztägige Termine angezeigt werden? +
+
+ +
- {/if} - -
-
- Ganztägige Termine - Wie sollen ganztägige Termine angezeigt werden? -
-
- - -
-
-
+ + -
-

Termine

+ + {#snippet icon()} + + + + {/snippet} + +
+
+
+ Standard-Dauer + Voreingestellte Dauer für neue Termine +
+ handleEventDurationChange(Number(v))} + placeholder="Dauer wählen" + /> +
-
-
- Standard-Dauer - Voreingestellte Dauer für neue Termine +
+
+ Standard-Erinnerung + Voreingestellte Erinnerung für neue Termine +
+ handleReminderChange(Number(v))} + placeholder="Erinnerung wählen" + /> +
- -
+ + -
-
- Standard-Erinnerung - Voreingestellte Erinnerung für neue Termine + + + {#snippet icon()} + + + + {/snippet} + +
+
+ +
+ + {#if settingsStore.showBirthdays} +
+ +
+ {/if}
- -
-
+ + -
-

Konto

+ + {#snippet icon()} + + + + {/snippet} + +
+
+
+ E-Mail + {authStore.user?.email || '-'} +
+
-
-
- E-Mail - {authStore.user?.email || '-'} +
+ +
-
- -
- -
-
+ + diff --git a/apps/calendar/apps/web/src/routes/(app)/tags/+page.svelte b/apps/calendar/apps/web/src/routes/(app)/tags/+page.svelte index d05568248..740d573a9 100644 --- a/apps/calendar/apps/web/src/routes/(app)/tags/+page.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/tags/+page.svelte @@ -1,86 +1,121 @@ @@ -95,7 +130,10 @@

Tags

- @@ -111,34 +149,31 @@ /> - {#if eventTagsStore.error} + {#if eventTagsStore.error || eventTagGroupsStore.error} {/if} - - + - {#if !eventTagsStore.loading && eventTagsStore.tags.length > 0} + {#if !isLoading && eventTagsStore.tags.length > 0}

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

{/if} - {#if !eventTagsStore.loading && eventTagsStore.tags.length === 0 && !searchQuery} + {#if !isLoading && eventTagsStore.tags.length === 0 && !searchQuery}
- @@ -146,22 +181,80 @@ {/if}
- - + + maxWidth="sm" +> +
+ +
+ +
+ + +
+ Gruppe + +
+ + +
+ Farbe + (tagColor = c)} /> +
+ + +
+ Vorschau +
+ +
+
+
+ + {#snippet footer()} +
+
+ {#if editingTag} + + {/if} +
+
+ + +
+
+ {/snippet} +
diff --git a/apps/calendar/apps/web/src/routes/(app)/tasks/+page.svelte b/apps/calendar/apps/web/src/routes/(app)/tasks/+page.svelte deleted file mode 100644 index 403dbd875..000000000 --- a/apps/calendar/apps/web/src/routes/(app)/tasks/+page.svelte +++ /dev/null @@ -1,486 +0,0 @@ - - - - Aufgaben | Kalender - - -
- - - - - - -
- {#if showQuickAdd} - (showQuickAdd = false)} - oncancel={() => (showQuickAdd = false)} - /> - {:else} - - {/if} -
- - - {#if loading} - - {:else if !todosStore.serviceAvailable} -
- -

Todo-Service ist nicht erreichbar

-

Bitte versuchen Sie es später erneut

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

Keine Einträge gefunden

-

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

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

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

- -
- {#each group.items as item} - {#if item.type === 'event' && item.event} - handleEventClick(item.event!.id)} - /> - {:else if item.type === 'todo' && item.todo} - handleTodoClick(item.todo!)} - /> - {/if} - {/each} -
-
- {/each} -
- {/if} -
- - -{#if selectedTask} - -{/if} - - diff --git a/apps/calendar/packages/shared/src/types/calendar.ts b/apps/calendar/packages/shared/src/types/calendar.ts index 9e110cced..d84b6bea1 100644 --- a/apps/calendar/packages/shared/src/types/calendar.ts +++ b/apps/calendar/packages/shared/src/types/calendar.ts @@ -1,9 +1,28 @@ +/** + * Calendar view types + */ +export type CalendarViewType = + | 'day' + | '3day' + | '5day' + | 'week' + | '10day' + | '14day' + | '30day' + | '60day' + | '90day' + | '365day' + | 'month' + | 'year' + | 'agenda' + | 'custom'; + /** * Calendar settings stored in JSONB */ export interface CalendarSettings { /** Default view when opening the calendar */ - defaultView?: 'day' | '5day' | 'week' | '10day' | '14day' | 'month' | 'year' | 'agenda'; + defaultView?: CalendarViewType; /** 0 = Sunday, 1 = Monday */ weekStartsOn?: 0 | 1; /** Show week numbers in calendar views */ @@ -57,19 +76,6 @@ export interface UpdateCalendarInput { settings?: CalendarSettings; } -/** - * Calendar view types - */ -export type CalendarViewType = - | 'day' - | '5day' - | 'week' - | '10day' - | '14day' - | 'month' - | 'year' - | 'agenda'; - /** * Default calendar colors */ diff --git a/apps/calendar/packages/shared/src/types/event.ts b/apps/calendar/packages/shared/src/types/event.ts index bc0c7f345..482c82444 100644 --- a/apps/calendar/packages/shared/src/types/event.ts +++ b/apps/calendar/packages/shared/src/types/event.ts @@ -18,6 +18,34 @@ export interface EventAttendee { company?: string; } +/** + * Responsible person for an event (single person accountable for the event) + */ +export interface ResponsiblePerson { + email: string; + name?: string; + /** Contact reference for linked contacts */ + contactId?: string; + /** Cached photo URL from contact */ + photoUrl?: string; + /** Cached company from contact */ + company?: string; +} + +/** + * Event tag group for organizing tags + */ +export interface EventTagGroup { + id: string; + userId: string; + name: string; + color: string; + sortOrder: number; + tagCount?: number; + createdAt: Date | string; + updatedAt: Date | string; +} + /** * Event tag with color */ @@ -26,6 +54,8 @@ export interface EventTag { userId: string; name: string; color: string; + groupId?: string | null; + sortOrder?: number; createdAt: Date | string; updatedAt: Date | string; } @@ -57,7 +87,9 @@ export interface EventMetadata { url?: string; /** Video conference URL (Zoom, Meet, etc.) */ conferenceUrl?: string; - /** Event attendees */ + /** Responsible person for this event */ + responsiblePerson?: ResponsiblePerson; + /** Event attendees/participants */ attendees?: EventAttendee[]; /** Event organizer email */ organizer?: string; diff --git a/apps/contacts/apps/backend/src/contact/contact.controller.ts b/apps/contacts/apps/backend/src/contact/contact.controller.ts index 9add5756d..61efe2cb4 100644 --- a/apps/contacts/apps/backend/src/contact/contact.controller.ts +++ b/apps/contacts/apps/backend/src/contact/contact.controller.ts @@ -20,8 +20,22 @@ import { IsDateString, IsUUID, MaxLength, + IsArray, + ValidateNested, } from 'class-validator'; -import { Transform } from 'class-transformer'; +import { Transform, Type } from 'class-transformer'; + +class CustomDateDto { + @IsUUID() + id: string; + + @IsString() + @MaxLength(100) + label: string; + + @IsDateString() + date: string; +} class CreateContactDto { @IsString() @@ -107,6 +121,78 @@ class CreateContactDto { @IsOptional() notes?: string; + @IsArray() + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => CustomDateDto) + customDates?: CustomDateDto[]; + + // Social Media + @IsString() + @IsOptional() + @MaxLength(255) + linkedin?: string; + + @IsString() + @IsOptional() + @MaxLength(100) + twitter?: string; + + @IsString() + @IsOptional() + @MaxLength(255) + facebook?: string; + + @IsString() + @IsOptional() + @MaxLength(100) + instagram?: string; + + @IsString() + @IsOptional() + @MaxLength(255) + xing?: string; + + @IsString() + @IsOptional() + @MaxLength(100) + github?: string; + + @IsString() + @IsOptional() + @MaxLength(255) + youtube?: string; + + @IsString() + @IsOptional() + @MaxLength(100) + tiktok?: string; + + @IsString() + @IsOptional() + @MaxLength(100) + telegram?: string; + + @IsString() + @IsOptional() + @MaxLength(50) + whatsapp?: string; + + @IsString() + @IsOptional() + @MaxLength(50) + signal?: string; + + @IsString() + @IsOptional() + @MaxLength(100) + discord?: string; + + @IsString() + @IsOptional() + @MaxLength(100) + bluesky?: string; + @IsUUID() @IsOptional() organizationId?: string; @@ -168,6 +254,16 @@ export class ContactController { return { contacts, total }; } + /** + * Get all contacts with birthdays (for calendar integration) + * Returns lightweight data: id, displayName, firstName, lastName, birthday, photoUrl + */ + @Get('birthdays') + async getBirthdays(@CurrentUser() user: CurrentUserData) { + const contacts = await this.contactService.findWithBirthdays(user.userId); + return { contacts }; + } + @Get(':id') async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { const contact = await this.contactService.findById(id, user.userId); diff --git a/apps/contacts/apps/backend/src/contact/contact.service.ts b/apps/contacts/apps/backend/src/contact/contact.service.ts index f6cbd9092..3f528efef 100644 --- a/apps/contacts/apps/backend/src/contact/contact.service.ts +++ b/apps/contacts/apps/backend/src/contact/contact.service.ts @@ -1,10 +1,19 @@ import { Injectable, Inject, NotFoundException } from '@nestjs/common'; -import { eq, and, or, ilike, desc, sql } from 'drizzle-orm'; +import { eq, and, or, ilike, desc, sql, isNotNull } from 'drizzle-orm'; import { DATABASE_CONNECTION } from '../db/database.module'; import { Database } from '../db/connection'; import { contacts } from '../db/schema'; import type { Contact, NewContact } from '../db/schema'; +export interface ContactBirthdaySummary { + id: string; + displayName: string | null; + firstName: string | null; + lastName: string | null; + birthday: string; + photoUrl: string | null; +} + export interface ContactFilters { search?: string; isFavorite?: boolean; @@ -148,4 +157,34 @@ export class ContactService { return Number(result[0]?.count || 0); } + + /** + * Find all contacts with birthdays (for calendar integration) + * Returns only essential fields for lightweight transfer + */ + async findWithBirthdays(userId: string): Promise { + const result = await this.db + .select({ + id: contacts.id, + displayName: contacts.displayName, + firstName: contacts.firstName, + lastName: contacts.lastName, + birthday: contacts.birthday, + photoUrl: contacts.photoUrl, + }) + .from(contacts) + .where( + and( + eq(contacts.userId, userId), + eq(contacts.isArchived, false), + isNotNull(contacts.birthday) + ) + ) + .orderBy(contacts.lastName, contacts.firstName); + + return result.map((c) => ({ + ...c, + birthday: c.birthday || '', + })); + } } diff --git a/apps/contacts/apps/backend/src/db/schema/contacts.schema.ts b/apps/contacts/apps/backend/src/db/schema/contacts.schema.ts index f535bad03..60870ae26 100644 --- a/apps/contacts/apps/backend/src/db/schema/contacts.schema.ts +++ b/apps/contacts/apps/backend/src/db/schema/contacts.schema.ts @@ -31,6 +31,22 @@ export const contacts = pgTable('contacts', { birthday: date('birthday'), notes: text('notes'), photoUrl: varchar('photo_url', { length: 500 }), + customDates: jsonb('custom_dates').$type().default([]), + + // Social Media + linkedin: varchar('linkedin', { length: 255 }), + twitter: varchar('twitter', { length: 100 }), + facebook: varchar('facebook', { length: 255 }), + instagram: varchar('instagram', { length: 100 }), + xing: varchar('xing', { length: 255 }), + github: varchar('github', { length: 100 }), + youtube: varchar('youtube', { length: 255 }), + tiktok: varchar('tiktok', { length: 100 }), + telegram: varchar('telegram', { length: 100 }), + whatsapp: varchar('whatsapp', { length: 50 }), + signal: varchar('signal', { length: 50 }), + discord: varchar('discord', { length: 100 }), + bluesky: varchar('bluesky', { length: 100 }), // Flags isFavorite: boolean('is_favorite').default(false), @@ -50,3 +66,9 @@ export const contacts = pgTable('contacts', { export type Contact = typeof contacts.$inferSelect; export type NewContact = typeof contacts.$inferInsert; + +export interface CustomDate { + id: string; + label: string; + date: string; +} diff --git a/apps/contacts/apps/web/package.json b/apps/contacts/apps/web/package.json index f274af7a9..735a53d38 100644 --- a/apps/contacts/apps/web/package.json +++ b/apps/contacts/apps/web/package.json @@ -31,8 +31,6 @@ }, "dependencies": { "@manacore/shared-auth": "workspace:*", - "@manacore/shared-splitscreen": "workspace:*", - "@manacore/shared-tags": "workspace:*", "@manacore/shared-auth-ui": "workspace:*", "@manacore/shared-branding": "workspace:*", "@manacore/shared-feedback-service": "workspace:*", @@ -43,7 +41,9 @@ "@manacore/shared-i18n": "workspace:*", "@manacore/shared-icons": "workspace:*", "@manacore/shared-profile-ui": "workspace:*", + "@manacore/shared-splitscreen": "workspace:*", "@manacore/shared-subscription-ui": "workspace:*", + "@manacore/shared-tags": "workspace:*", "@manacore/shared-tailwind": "workspace:*", "@manacore/shared-theme": "workspace:*", "@manacore/shared-theme-ui": "workspace:*", @@ -52,6 +52,7 @@ "d3-force": "^3.0.0", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0", + "date-fns": "^4.1.0", "lucide-svelte": "^0.556.0", "svelte-i18n": "^4.0.1" }, diff --git a/apps/contacts/apps/web/src/lib/api/batch.ts b/apps/contacts/apps/web/src/lib/api/batch.ts index 5307425ee..2fcf3b956 100644 --- a/apps/contacts/apps/web/src/lib/api/batch.ts +++ b/apps/contacts/apps/web/src/lib/api/batch.ts @@ -1,30 +1,4 @@ -import { authStore } from '$lib/stores/auth.svelte'; -import { API_BASE } from './config'; - -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(); -} +import { fetchWithAuth } from './client'; export interface BatchResult { success: number; diff --git a/apps/contacts/apps/web/src/lib/api/client.ts b/apps/contacts/apps/web/src/lib/api/client.ts new file mode 100644 index 000000000..750ed1668 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/api/client.ts @@ -0,0 +1,71 @@ +/** + * Centralized API client with authentication + */ + +import { authStore } from '$lib/stores/auth.svelte'; +import { API_BASE } from './config'; + +/** + * Make an authenticated API request + * @param url API endpoint (will be prefixed with API_BASE) + * @param options Fetch options + * @returns Parsed JSON response + */ +export async function fetchWithAuth( + url: string, + options: RequestInit = {} +): Promise { + 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(); +} + +/** + * Make an authenticated API request without JSON content type + * Used for file uploads (FormData) + */ +export async function fetchWithAuthFormData( + url: string, + options: RequestInit = {} +): Promise { + const token = await authStore.getAccessToken(); + + const headers: HeadersInit = { + ...(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(); +} diff --git a/apps/contacts/apps/web/src/lib/api/config.ts b/apps/contacts/apps/web/src/lib/api/config.ts index 21c4a43ec..6316da671 100644 --- a/apps/contacts/apps/web/src/lib/api/config.ts +++ b/apps/contacts/apps/web/src/lib/api/config.ts @@ -1,7 +1,13 @@ -import { PUBLIC_BACKEND_URL } from '$env/static/public'; +import { PUBLIC_BACKEND_URL, PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public'; /** * API Configuration - * Uses environment variable PUBLIC_BACKEND_URL with fallback for development + * Uses environment variables with fallbacks for development */ export const API_BASE = `${PUBLIC_BACKEND_URL || 'http://localhost:3015'}/api/v1`; + +/** + * Mana Core Auth URL + * Central authentication service URL + */ +export const MANA_AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001'; diff --git a/apps/contacts/apps/web/src/lib/api/contacts.ts b/apps/contacts/apps/web/src/lib/api/contacts.ts index f62c27559..f99090ec1 100644 --- a/apps/contacts/apps/web/src/lib/api/contacts.ts +++ b/apps/contacts/apps/web/src/lib/api/contacts.ts @@ -1,33 +1,9 @@ import { browser } from '$app/environment'; import { authStore } from '$lib/stores/auth.svelte'; -import { API_BASE } from './config'; +import { MANA_AUTH_URL } from './config'; +import { fetchWithAuth, fetchWithAuthFormData } from './client'; import { createTagsClient, type Tag } from '@manacore/shared-tags'; -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 Contact { id: string; userId: string; @@ -49,6 +25,27 @@ export interface Contact { birthday?: string | null; notes?: string | null; photoUrl?: string | null; + customDates?: Array<{ + id: string; + label: string; + date: string; + }> | null; + // Social Media + linkedin?: string | null; + twitter?: string | null; + facebook?: string | null; + instagram?: string | null; + xing?: string | null; + github?: string | null; + youtube?: string | null; + tiktok?: string | null; + telegram?: string | null; + whatsapp?: string | null; + signal?: string | null; + discord?: string | null; + bluesky?: string | null; + // Tags (populated by API) + tags?: Array<{ id: string; name: string; color: string | null }>; isFavorite: boolean; isArchived: boolean; organizationId?: string | null; @@ -90,9 +87,19 @@ export interface ContactFilters { offset?: number; } +// API Response types +interface ContactResponse { + contact: Contact; +} + +interface ContactListResponse { + contacts: Contact[]; + total: number; +} + // Contacts API export const contactsApi = { - async list(filters: ContactFilters = {}) { + async list(filters: ContactFilters = {}): Promise { const params = new URLSearchParams(); if (filters.search) params.set('search', filters.search); if (filters.isFavorite !== undefined) params.set('isFavorite', String(filters.isFavorite)); @@ -102,16 +109,16 @@ export const contactsApi = { if (filters.offset) params.set('offset', String(filters.offset)); const query = params.toString(); - return fetchWithAuth(`/contacts${query ? `?${query}` : ''}`); + return fetchWithAuth(`/contacts${query ? `?${query}` : ''}`); }, async get(id: string): Promise { - const response = await fetchWithAuth(`/contacts/${id}`); + const response = await fetchWithAuth(`/contacts/${id}`); return response.contact; }, async create(data: Partial): Promise { - const response = await fetchWithAuth('/contacts', { + const response = await fetchWithAuth('/contacts', { method: 'POST', body: JSON.stringify(data), }); @@ -119,7 +126,7 @@ export const contactsApi = { }, async update(id: string, data: Partial): Promise { - const response = await fetchWithAuth(`/contacts/${id}`, { + const response = await fetchWithAuth(`/contacts/${id}`, { method: 'PATCH', body: JSON.stringify(data), }); @@ -133,14 +140,14 @@ export const contactsApi = { }, async toggleFavorite(id: string): Promise { - const response = await fetchWithAuth(`/contacts/${id}/favorite`, { + const response = await fetchWithAuth(`/contacts/${id}/favorite`, { method: 'POST', }); return response.contact; }, async toggleArchive(id: string): Promise { - const response = await fetchWithAuth(`/contacts/${id}/archive`, { + const response = await fetchWithAuth(`/contacts/${id}/archive`, { method: 'POST', }); return response.contact; @@ -150,16 +157,6 @@ export const contactsApi = { // Tags API - Uses central Tags API from mana-core-auth // Contact-tag associations still use the Contacts backend -// Get auth URL dynamically at runtime -function getAuthUrl(): string { - if (browser && typeof window !== 'undefined') { - const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string }) - .__PUBLIC_MANA_CORE_AUTH_URL__; - return injectedUrl || 'http://localhost:3001'; - } - return 'http://localhost:3001'; -} - // Lazy-initialized tags client let _tagsClient: ReturnType | null = null; @@ -167,7 +164,7 @@ function getTagsClient() { if (!browser) return null; if (!_tagsClient) { _tagsClient = createTagsClient({ - authUrl: getAuthUrl(), + authUrl: MANA_AUTH_URL, getToken: async () => { const token = await authStore.getAccessToken(); return token || ''; @@ -212,19 +209,19 @@ export const tagsApi = { // Contact-tag associations still use Contacts backend async addToContact(tagId: string, contactId: string): Promise<{ success: boolean }> { - return fetchWithAuth(`/tags/${tagId}/contacts/${contactId}`, { + return fetchWithAuth<{ success: boolean }>(`/tags/${tagId}/contacts/${contactId}`, { method: 'POST', }); }, async removeFromContact(tagId: string, contactId: string): Promise<{ success: boolean }> { - return fetchWithAuth(`/tags/${tagId}/contacts/${contactId}`, { + return fetchWithAuth<{ success: boolean }>(`/tags/${tagId}/contacts/${contactId}`, { method: 'DELETE', }); }, async getForContact(contactId: string): Promise<{ tagIds: string[] }> { - return fetchWithAuth(`/tags/contact/${contactId}`); + return fetchWithAuth<{ tagIds: string[] }>(`/tags/contact/${contactId}`); }, // Create default tags via central Tags API @@ -236,44 +233,68 @@ export const tagsApi = { }, }; +// Notes API Response types +interface NotesListResponse { + notes: ContactNote[]; +} + +interface NoteResponse { + note: ContactNote; +} + // Notes API export const notesApi = { - async list(contactId: string) { - return fetchWithAuth(`/contacts/${contactId}/notes`); + async list(contactId: string): Promise { + return fetchWithAuth(`/contacts/${contactId}/notes`); }, - async create(contactId: string, data: { content: string; isPinned?: boolean }) { - return fetchWithAuth(`/contacts/${contactId}/notes`, { + async create( + contactId: string, + data: { content: string; isPinned?: boolean } + ): Promise { + return fetchWithAuth(`/contacts/${contactId}/notes`, { method: 'POST', body: JSON.stringify(data), }); }, - async update(noteId: string, data: { content?: string; isPinned?: boolean }) { - return fetchWithAuth(`/notes/${noteId}`, { + async update( + noteId: string, + data: { content?: string; isPinned?: boolean } + ): Promise { + return fetchWithAuth(`/notes/${noteId}`, { method: 'PATCH', body: JSON.stringify(data), }); }, - async delete(noteId: string) { - return fetchWithAuth(`/notes/${noteId}`, { + async delete(noteId: string): Promise { + await fetchWithAuth(`/notes/${noteId}`, { method: 'DELETE', }); }, - async togglePin(noteId: string) { - return fetchWithAuth(`/notes/${noteId}/pin`, { + async togglePin(noteId: string): Promise { + return fetchWithAuth(`/notes/${noteId}/pin`, { method: 'POST', }); }, }; +// Activities API Response types +interface ActivitiesListResponse { + activities: ContactActivity[]; +} + +interface ActivityResponse { + activity: ContactActivity; +} + // Activities API export const activitiesApi = { - async list(contactId: string, limit?: number) { + async list(contactId: string, limit?: number): Promise { const params = limit ? `?limit=${limit}` : ''; - return fetchWithAuth(`/contacts/${contactId}/activities${params}`); + return fetchWithAuth(`/contacts/${contactId}/activities${params}`); }, async create( @@ -283,8 +304,8 @@ export const activitiesApi = { description?: string; metadata?: Record; } - ) { - return fetchWithAuth(`/contacts/${contactId}/activities`, { + ): Promise { + return fetchWithAuth(`/contacts/${contactId}/activities`, { method: 'POST', body: JSON.stringify(data), }); @@ -294,25 +315,13 @@ 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`, { + return fetchWithAuthFormData<{ photoUrl: string }>(`/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 { diff --git a/apps/contacts/apps/web/src/lib/components/AlphabetNavContextMenu.svelte b/apps/contacts/apps/web/src/lib/components/AlphabetNavContextMenu.svelte new file mode 100644 index 000000000..3c3e9f4d9 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/AlphabetNavContextMenu.svelte @@ -0,0 +1,86 @@ + + + diff --git a/apps/contacts/apps/web/src/lib/components/ContactDetailModal.svelte b/apps/contacts/apps/web/src/lib/components/ContactDetailModal.svelte index 213178a9c..13b4bf4f7 100644 --- a/apps/contacts/apps/web/src/lib/components/ContactDetailModal.svelte +++ b/apps/contacts/apps/web/src/lib/components/ContactDetailModal.svelte @@ -5,6 +5,9 @@ import ContactNotes from './ContactNotes.svelte'; import ContactTasks from './ContactTasks.svelte'; import { ContactDetailSkeleton } from '$lib/components/skeletons'; + import SocialMediaFields from './forms/SocialMediaFields.svelte'; + import DateFields from './forms/DateFields.svelte'; + import SocialMediaLinks from './SocialMediaLinks.svelte'; interface Props { contactId: string; @@ -36,6 +39,25 @@ let country = $state(''); let notes = $state(''); + // Dates + let birthday = $state(''); + let customDates = $state>([]); + + // Social Media + let linkedin = $state(''); + let twitter = $state(''); + let facebook = $state(''); + let instagram = $state(''); + let xing = $state(''); + let github = $state(''); + let youtube = $state(''); + let tiktok = $state(''); + let telegram = $state(''); + let whatsapp = $state(''); + let signal = $state(''); + let discord = $state(''); + let bluesky = $state(''); + const initials = $derived(() => { if (!contact) return '?'; const f = contact.firstName?.[0] || ''; @@ -70,6 +92,23 @@ postalCode = contact.postalCode || ''; country = contact.country || ''; notes = contact.notes || ''; + // Dates + birthday = contact.birthday || ''; + customDates = contact.customDates ? [...contact.customDates] : []; + // Social Media + linkedin = contact.linkedin || ''; + twitter = contact.twitter || ''; + facebook = contact.facebook || ''; + instagram = contact.instagram || ''; + xing = contact.xing || ''; + github = contact.github || ''; + youtube = contact.youtube || ''; + tiktok = contact.tiktok || ''; + telegram = contact.telegram || ''; + whatsapp = contact.whatsapp || ''; + signal = contact.signal || ''; + discord = contact.discord || ''; + bluesky = contact.bluesky || ''; } function getDisplayName() { @@ -111,6 +150,23 @@ postalCode: postalCode || null, country: country || null, notes: notes || null, + // Dates + birthday: birthday || null, + customDates: customDates.filter((d) => d.label && d.date), + // Social Media + linkedin: linkedin || null, + twitter: twitter || null, + facebook: facebook || null, + instagram: instagram || null, + xing: xing || null, + github: github || null, + youtube: youtube || null, + tiktok: tiktok || null, + telegram: telegram || null, + whatsapp: whatsapp || null, + signal: signal || null, + discord: discord || null, + bluesky: bluesky || null, }); editing = false; } catch (e) { @@ -478,6 +534,26 @@ + + + + + +
{:else} - {#if viewModeStore.mode === 'grid'} + {#if viewModeStore.mode === 'network'} + + {:else if viewModeStore.mode === 'grid'} - {:else if viewModeStore.mode === 'alphabet'} + {:else} - {:else} - {/if} - - {#if contactsStore.hasMore} + + {#if viewModeStore.mode !== 'network' && contactsStore.hasMore}
{#if contactsStore.loadingMore}
@@ -459,11 +428,13 @@
{/if} - -

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

+ + {#if viewModeStore.mode !== 'network'} +

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

+ {/if} {/if}
diff --git a/apps/contacts/apps/web/src/lib/components/ContactsToolbar.svelte b/apps/contacts/apps/web/src/lib/components/ContactsToolbar.svelte index 2e30f283e..c77dfca75 100644 --- a/apps/contacts/apps/web/src/lib/components/ContactsToolbar.svelte +++ b/apps/contacts/apps/web/src/lib/components/ContactsToolbar.svelte @@ -1,185 +1,40 @@ - - - goto('/contacts/new')} title={$_('contacts.new')}> - - - - {$_('contacts.new')} - + + + + - - - - - - - - - - - - - - - - - {#if favoritesCount > 0} - {favoritesCount} - {/if} - - - - - - - - - - - - - - - -
- + +{#if !isSidebarMode} +
+
- +{/if} diff --git a/apps/contacts/apps/web/src/lib/components/ContactsToolbarContent.svelte b/apps/contacts/apps/web/src/lib/components/ContactsToolbarContent.svelte new file mode 100644 index 000000000..1cbcb4eaa --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/ContactsToolbarContent.svelte @@ -0,0 +1,412 @@ + + +
+ {#if isNetworkMode} + + + +
+ + +
+ +
+ + +
+ + + + +
+ + + {#if hasActiveNetworkFilters} +
+ + {/if} + + +
+ {networkStore.nodes.length} {$_('contacts.contactsPlural')} + + {networkStore.links.length} {$_('network.connections')} +
+ {:else} + + + +
+ + contactsFilterStore.setSelectedTagId(typeof v === 'string' ? v : null)} + placeholder={$_('filters.allTags')} + embedded={true} + direction="up" + /> + + + + contactsFilterStore.setContactFilter( + (typeof v === 'string' ? v : 'all') as ContactFilter + )} + placeholder={$_('filters.contact.all')} + embedded={true} + direction="up" + /> + + + + contactsFilterStore.setBirthdayFilter( + (typeof v === 'string' ? v : 'all') as BirthdayFilter + )} + placeholder={$_('filters.birthday.all')} + embedded={true} + direction="up" + /> + + + {#if companyOptions.length > 0} + contactsFilterStore.setSelectedCompany(typeof v === 'string' ? v : null)} + placeholder={$_('filters.allCompanies')} + embedded={true} + direction="up" + /> + {/if} + + + {#if activeFilterCount > 0} + + {/if} +
+ +
+ + + + {/if} +
+ + diff --git a/apps/contacts/apps/web/src/lib/components/FilterBar.svelte b/apps/contacts/apps/web/src/lib/components/FilterBar.svelte index 43f0f5c2b..ccae142c5 100644 --- a/apps/contacts/apps/web/src/lib/components/FilterBar.svelte +++ b/apps/contacts/apps/web/src/lib/components/FilterBar.svelte @@ -1,6 +1,7 @@ + + + + + + + + + diff --git a/apps/contacts/apps/web/src/lib/components/SearchModal.svelte b/apps/contacts/apps/web/src/lib/components/SearchModal.svelte index 38eddb294..cea269e12 100644 --- a/apps/contacts/apps/web/src/lib/components/SearchModal.svelte +++ b/apps/contacts/apps/web/src/lib/components/SearchModal.svelte @@ -1,6 +1,7 @@ + +{#if hasAny} +
+
+
+ + + +
+

Social Media

+
+ +
+{/if} + + diff --git a/apps/contacts/apps/web/src/lib/components/ViewModeToggle.svelte b/apps/contacts/apps/web/src/lib/components/ViewModeToggle.svelte index 96a9df394..80ab572e3 100644 --- a/apps/contacts/apps/web/src/lib/components/ViewModeToggle.svelte +++ b/apps/contacts/apps/web/src/lib/components/ViewModeToggle.svelte @@ -4,7 +4,6 @@ const modes: { id: ViewMode; icon: string; label: string }[] = [ { id: 'alphabet', icon: 'alphabet', label: 'views.alphabet' }, - { id: 'list', icon: 'list', label: 'views.list' }, { id: 'grid', icon: 'grid', label: 'views.grid' }, ]; @@ -18,16 +17,7 @@ onclick={() => viewModeStore.setMode(mode.id)} title={$_(mode.label)} > - {#if mode.icon === 'list'} - - - - {:else if mode.icon === 'grid'} + {#if mode.icon === 'grid'} - import { _ } from 'svelte-i18n'; - import type { Contact } from '$lib/api/contacts'; - - interface Props { - contacts: Contact[]; - onContactClick: (id: string) => void; - onToggleFavorite: (e: MouseEvent, id: string) => void; - } - - let { contacts, onContactClick, onToggleFavorite }: Props = $props(); - - const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); - - function getInitials(contact: Contact) { - const first = contact.firstName?.[0] || ''; - const last = contact.lastName?.[0] || ''; - return (first + last).toUpperCase() || contact.email?.[0]?.toUpperCase() || '?'; - } - - function getDisplayName(contact: Contact) { - if (contact.displayName) return contact.displayName; - if (contact.firstName || contact.lastName) { - return [contact.firstName, contact.lastName].filter(Boolean).join(' '); - } - return contact.email || 'Unbekannt'; - } - - function getFirstLetter(contact: Contact): string { - const name = - contact.lastName || contact.firstName || contact.displayName || contact.email || ''; - const letter = name[0]?.toUpperCase() || '#'; - return /[A-Z]/.test(letter) ? letter : '#'; - } - - function formatPhone(phone: string | null | undefined) { - if (!phone) return null; - return phone.replace(/\s/g, ''); - } - - // Group contacts by first letter - let groupedContacts = $derived.by(() => { - const groups: Record = {}; - - // Sort contacts by last name first - const sorted = [...contacts].sort((a, b) => { - const aName = (a.lastName || a.firstName || a.displayName || a.email || '').toLowerCase(); - const bName = (b.lastName || b.firstName || b.displayName || b.email || '').toLowerCase(); - return aName.localeCompare(bName, 'de'); - }); - - for (const contact of sorted) { - const letter = getFirstLetter(contact); - if (!groups[letter]) { - groups[letter] = []; - } - groups[letter].push(contact); - } - - return groups; - }); - - let availableLetters = $derived(Object.keys(groupedContacts).sort()); - - function scrollToLetter(letter: string) { - const element = document.getElementById(`fav-section-${letter}`); - if (element) { - element.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - } - - -
- -
- {#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-card" - > - -
- {#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 || contact.email} -
- {#if contact.phone || contact.mobile} - - - - - {contact.mobile || contact.phone} - - {/if} - {#if contact.email} - - - - - {contact.email} - - {/if} -
- {/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} -
-
- {/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 deleted file mode 100644 index de131246c..000000000 --- a/apps/contacts/apps/web/src/lib/components/favorites/FavoriteCardView.svelte +++ /dev/null @@ -1,363 +0,0 @@ - - -
- {#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 deleted file mode 100644 index 2377f9f93..000000000 --- a/apps/contacts/apps/web/src/lib/components/favorites/FavoriteListView.svelte +++ /dev/null @@ -1,324 +0,0 @@ - - -
- {#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/forms/DateFields.svelte b/apps/contacts/apps/web/src/lib/components/forms/DateFields.svelte new file mode 100644 index 000000000..02629c15f --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/forms/DateFields.svelte @@ -0,0 +1,354 @@ + + +
+ + {#if isOpen} +
+ +
+ + +
+ + + {#each customDates as customDate (customDate.id)} +
+
+ + updateCustomDate(customDate.id, 'label', e.currentTarget.value)} + class="input" + placeholder="z.B. Hochzeitstag, Kennenlerndatum" + /> +
+
+ + updateCustomDate(customDate.id, 'date', e.currentTarget.value)} + class="input" + /> +
+ +
+ {/each} + + + +
+ {/if} +
+ + diff --git a/apps/contacts/apps/web/src/lib/components/forms/SocialMediaFields.svelte b/apps/contacts/apps/web/src/lib/components/forms/SocialMediaFields.svelte new file mode 100644 index 000000000..0cda4d897 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/forms/SocialMediaFields.svelte @@ -0,0 +1,369 @@ + + +
+ + {#if isOpen} + + {/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 index af1ff0d97..2fd293032 100644 --- a/apps/contacts/apps/web/src/lib/components/network/NetworkGraph.svelte +++ b/apps/contacts/apps/web/src/lib/components/network/NetworkGraph.svelte @@ -347,15 +347,15 @@ {node.name} - - {#if node.company} + + {#if node.subtitle} - {node.company} + {node.subtitle} {/if} diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/ContactGridSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/ContactGridSkeleton.svelte index 362727151..004482fe3 100644 --- a/apps/contacts/apps/web/src/lib/components/skeletons/ContactGridSkeleton.svelte +++ b/apps/contacts/apps/web/src/lib/components/skeletons/ContactGridSkeleton.svelte @@ -5,6 +5,7 @@ */ import ContactCardSkeleton from './ContactCardSkeleton.svelte'; + import { calculateFadeOpacity } from './utils'; interface Props { /** Number of skeleton cards to show */ @@ -16,17 +17,11 @@ } let { count = 8, fadeEffect = true, minOpacity = 0.4 }: Props = $props(); - - function calculateOpacity(index: number): number { - if (!fadeEffect) return 1; - const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1); - return Math.max(minOpacity, 1 - index * fadeStep); - }
{#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 index 3bb93cb5a..3e81b4146 100644 --- a/apps/contacts/apps/web/src/lib/components/skeletons/ContactListSkeleton.svelte +++ b/apps/contacts/apps/web/src/lib/components/skeletons/ContactListSkeleton.svelte @@ -5,6 +5,7 @@ */ import ContactRowSkeleton from './ContactRowSkeleton.svelte'; + import { calculateFadeOpacity } from './utils'; interface Props { /** Number of skeleton rows to show */ @@ -16,16 +17,10 @@ } let { count = 8, fadeEffect = true, minOpacity = 0.3 }: Props = $props(); - - function calculateOpacity(index: number): number { - if (!fadeEffect) return 1; - const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1); - return Math.max(minOpacity, 1 - index * fadeStep); - }
{#each Array(count) 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 index 7e618b617..0295a9191 100644 --- a/apps/contacts/apps/web/src/lib/components/skeletons/DuplicateListSkeleton.svelte +++ b/apps/contacts/apps/web/src/lib/components/skeletons/DuplicateListSkeleton.svelte @@ -6,6 +6,7 @@ import { SkeletonBox } from '@manacore/shared-ui'; import DuplicateGroupSkeleton from './DuplicateGroupSkeleton.svelte'; + import { calculateFadeOpacity } from './utils'; interface Props { /** Number of duplicate groups to show */ @@ -17,12 +18,6 @@ } let { count = 3, fadeEffect = true, minOpacity = 0.4 }: Props = $props(); - - function calculateOpacity(index: number): number { - if (!fadeEffect) return 1; - const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1); - return Math.max(minOpacity, 1 - index * fadeStep); - }
@@ -39,7 +34,10 @@
{#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 deleted file mode 100644 index 5d5d3214b..000000000 --- a/apps/contacts/apps/web/src/lib/components/skeletons/FavoriteCardSkeleton.svelte +++ /dev/null @@ -1,62 +0,0 @@ - - -
- -
- -
- - -
- -
- -
- - -
- -
- - -
- - -
-
-
- - diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/FavoriteGridSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/FavoriteGridSkeleton.svelte deleted file mode 100644 index 452e4c079..000000000 --- a/apps/contacts/apps/web/src/lib/components/skeletons/FavoriteGridSkeleton.svelte +++ /dev/null @@ -1,56 +0,0 @@ - - -
- {#each Array(count) as _, i} - - {/each} -
- - diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/TagGridSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/TagGridSkeleton.svelte index f1c72956f..171fc0c14 100644 --- a/apps/contacts/apps/web/src/lib/components/skeletons/TagGridSkeleton.svelte +++ b/apps/contacts/apps/web/src/lib/components/skeletons/TagGridSkeleton.svelte @@ -4,6 +4,7 @@ */ import TagCardSkeleton from './TagCardSkeleton.svelte'; + import { calculateFadeOpacity } from './utils'; interface Props { /** Number of skeleton cards to show */ @@ -15,17 +16,11 @@ } let { count = 6, fadeEffect = true, minOpacity = 0.4 }: Props = $props(); - - function calculateOpacity(index: number): number { - if (!fadeEffect) return 1; - const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1); - return Math.max(minOpacity, 1 - index * fadeStep); - }
{#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 index 82732122c..c67ab3f59 100644 --- a/apps/contacts/apps/web/src/lib/components/skeletons/index.ts +++ b/apps/contacts/apps/web/src/lib/components/skeletons/index.ts @@ -5,6 +5,9 @@ * Built on top of @manacore/shared-ui skeleton primitives. */ +// Utilities +export { calculateFadeOpacity } from './utils'; + // Contact List/Grid Skeletons export { default as ContactRowSkeleton } from './ContactRowSkeleton.svelte'; export { default as ContactListSkeleton } from './ContactListSkeleton.svelte'; @@ -15,10 +18,6 @@ export { default as ContactGridSkeleton } from './ContactGridSkeleton.svelte'; 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'; diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/utils.ts b/apps/contacts/apps/web/src/lib/components/skeletons/utils.ts new file mode 100644 index 000000000..ccd94edbb --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/skeletons/utils.ts @@ -0,0 +1,19 @@ +/** + * Skeleton utility functions + */ + +/** + * Calculate opacity for cascading fade effect in skeleton lists + * @param index Current item index + * @param count Total number of items + * @param minOpacity Minimum opacity (default: 0.3) + * @returns Opacity value between minOpacity and 1 + */ +export function calculateFadeOpacity( + index: number, + count: number, + minOpacity: number = 0.3 +): number { + const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1); + return Math.max(minOpacity, 1 - index * fadeStep); +} diff --git a/apps/contacts/apps/web/src/lib/components/views/ContactAlphabetView.svelte b/apps/contacts/apps/web/src/lib/components/views/ContactAlphabetView.svelte index 6fdc07007..8ac723aa5 100644 --- a/apps/contacts/apps/web/src/lib/components/views/ContactAlphabetView.svelte +++ b/apps/contacts/apps/web/src/lib/components/views/ContactAlphabetView.svelte @@ -2,6 +2,11 @@ import { _ } from 'svelte-i18n'; import type { Contact } from '$lib/api/contacts'; import type { SortField } from '$lib/components/SortToggle.svelte'; + import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte'; + import { isSidebarMode } from '$lib/stores/navigation'; + import { contactsFilterStore } from '$lib/stores/filter.svelte'; + import { contactsSettings } from '$lib/stores/settings.svelte'; + import AlphabetNavContextMenu from '$lib/components/AlphabetNavContextMenu.svelte'; interface Props { contacts: Contact[]; @@ -11,6 +16,7 @@ selectedIds?: Set; onToggleSelection?: (id: string) => void; sortField?: SortField; + showNewContactCard?: boolean; } let { @@ -21,14 +27,38 @@ selectedIds = new Set(), onToggleSelection, sortField = 'lastName', + showNewContactCard = true, }: Props = $props(); + // Derived state for toolbar positioning + let isToolbarExpanded = $derived(!contactsFilterStore.isToolbarCollapsed); + let isAlphabetNavCollapsed = $derived(contactsFilterStore.isAlphabetNavCollapsed); + + function toggleAlphabetNav() { + contactsFilterStore.toggleAlphabetNav(); + } + + // Context menu for alphabet nav + let alphabetContextMenu: AlphabetNavContextMenu; + + function handleAlphabetContextMenu(e: MouseEvent) { + e.preventDefault(); + alphabetContextMenu?.show(e.clientX, e.clientY); + } + function handleCheckboxClick(e: MouseEvent, id: string) { e.stopPropagation(); onToggleSelection?.(id); } - const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); + // Alphabet with optional reverse order + let alphabet = $derived.by(() => { + let letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); + if (contactsSettings.alphabetNavReverseOrder) { + letters = letters.reverse(); + } + return letters; + }); function getInitials(contact: Contact) { const first = contact.firstName?.[0] || ''; @@ -87,6 +117,38 @@
+ + {#if showNewContactCard && !selectionMode} +
+
newContactModalStore.open()} + onkeydown={(e) => e.key === 'Enter' && newContactModalStore.open()} + class="alphabet-contact-card new-contact-card" + > + +
+ + + +
+ + +
+
+ {$_('contacts.new')} +
+
+
+
+ {/if} +
{#each availableLetters as letter} @@ -142,30 +204,46 @@
-
- {getDisplayName(contact)} -
-
- {#if contact.jobTitle && contact.company} - {contact.jobTitle} @ {contact.company} - {:else if contact.company} - {contact.company} - {:else if contact.email} - {contact.email} +
+ {getDisplayName(contact)} + {#if contact.isFavorite} + + + + {/if} + {#if contact.company} + @ {contact.company} {/if}
+ {#if contact.tags && contact.tags.length > 0} +
+ {#each contact.tags.slice(0, 3) as tag} + + {tag.name} + + {/each} + {#if contact.tags.length > 3} + +{contact.tags.length - 3} + {/if} +
+ {/if}
- -
+ +
{#if contact.phone || contact.mobile} e.stopPropagation()} - class="quick-action-btn" - title={$_('contacts.call')} + class="action-chip" + title={contact.mobile || contact.phone} > - + e.stopPropagation()} - class="quick-action-btn" - title={$_('contacts.email')} + class="action-chip" + title={contact.email} > - + {/if} -
{/each} @@ -222,37 +278,98 @@ {/each}
- -
- {#each alphabet as letter} - - {/each} - {#if availableLetters.includes('#')} - - {/if} + +
+ +
+ + + {#if !isAlphabetNavCollapsed} +
+ +
+ {#each alphabet as letter} + {@const isActive = availableLetters.includes(letter)} + {@const shouldHide = contactsSettings.alphabetNavHideInactive && !isActive} + {#if !shouldHide} + + {/if} + {/each} + {#if contactsSettings.alphabetNavShowHash && availableLetters.includes('#')} + + {/if} +
+
+ {/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 index 8fb5e20fa..6a1f559fc 100644 --- a/apps/contacts/apps/web/src/lib/components/views/ContactGridView.svelte +++ b/apps/contacts/apps/web/src/lib/components/views/ContactGridView.svelte @@ -1,6 +1,7 @@
+ + {#if showNewContactCard && !selectionMode} +
newContactModalStore.open()} + onkeydown={(e) => e.key === 'Enter' && newContactModalStore.open()} + class="grid-card new-contact-card" + > + +
+ + + +
+ + +
+

{$_('contacts.new')}

+

{$_('contacts.addFirst')}

+
+
+ {/if} + {#each contacts as contact (contact.id)}
diff --git a/apps/contacts/apps/web/src/lib/components/views/ContactListView.svelte b/apps/contacts/apps/web/src/lib/components/views/ContactListView.svelte deleted file mode 100644 index 279dde6c6..000000000 --- a/apps/contacts/apps/web/src/lib/components/views/ContactListView.svelte +++ /dev/null @@ -1,157 +0,0 @@ - - -
- {#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/routes/(app)/network/+page.svelte b/apps/contacts/apps/web/src/lib/components/views/ContactNetworkView.svelte similarity index 66% rename from apps/contacts/apps/web/src/routes/(app)/network/+page.svelte rename to apps/contacts/apps/web/src/lib/components/views/ContactNetworkView.svelte index 0508d3e8f..768a05779 100644 --- a/apps/contacts/apps/web/src/routes/(app)/network/+page.svelte +++ b/apps/contacts/apps/web/src/lib/components/views/ContactNetworkView.svelte @@ -2,13 +2,42 @@ import { onMount, onDestroy } from 'svelte'; import { goto } from '$app/navigation'; import { networkStore, type SimulationNode } from '$lib/stores/network.svelte'; - import { NetworkGraph, NetworkControls } from '@manacore/shared-ui'; + import { contactsFilterStore } from '$lib/stores/filter.svelte'; + import { NetworkGraph } from '@manacore/shared-ui'; import ContactDetailModal from '$lib/components/ContactDetailModal.svelte'; import { NetworkGraphSkeleton } from '$lib/components/skeletons'; - import '$lib/i18n'; + + // Sync global search to network store + $effect(() => { + networkStore.setSearch(contactsFilterStore.searchQuery); + }); + + // Sync tag filter to network store + $effect(() => { + networkStore.setFilterTag(contactsFilterStore.selectedTagId); + }); + + // Sync company filter to network store + $effect(() => { + networkStore.setFilterCompany(contactsFilterStore.selectedCompany); + }); + + // Refocus view when search results change + let previousNodeCount = $state(0); + $effect(() => { + const currentNodeCount = networkStore.nodes.length; + const hasSearch = contactsFilterStore.searchQuery.length > 0; + + // If search is active and node count changed, reset zoom to show all results + if (hasSearch && currentNodeCount !== previousNodeCount && currentNodeCount > 0) { + setTimeout(() => { + graphComponent?.resetZoom(); + }, 100); + } + previousNodeCount = currentNodeCount; + }); let graphComponent: NetworkGraph; - let controlsComponent: NetworkControls; let graphContainer: HTMLDivElement; function handleNodeClick(node: SimulationNode) { @@ -42,45 +71,10 @@ networkStore.releaseNode(node.id); } - function handleZoomIn() { - graphComponent?.zoomIn(); - } - - function handleZoomOut() { - graphComponent?.zoomOut(); - } - - function handleResetZoom() { - graphComponent?.resetZoom(); - } - - function handleFocusSelected() { - graphComponent?.focusOnSelectedNode(); - } - - function handleFocusSearch() { - controlsComponent?.focusSearch(); - } - - function handleSearch(query: string) { - networkStore.setSearch(query); - } - - function handleTagFilter(tagId: string | null) { - networkStore.setFilterTag(tagId); - } - - function handleSubtitleFilter(company: string | null) { - networkStore.setFilterCompany(company); - } - - function handleStrengthFilter(strength: number) { - networkStore.setMinStrength(strength); - } - - function handleClearFilters() { - networkStore.clearFilters(); - } + // Register graph component with store when it changes + $effect(() => { + networkStore.setGraphComponent(graphComponent); + }); // Initialize simulation when data is loaded and container is ready $effect(() => { @@ -97,47 +91,16 @@ }); onDestroy(() => { + networkStore.setGraphComponent(null); networkStore.stopSimulation(); }); - - Netzwerk - Contacts - - -
- -
- -
- +
{#if networkStore.error} diff --git a/apps/contacts/apps/web/src/lib/config/social-media.ts b/apps/contacts/apps/web/src/lib/config/social-media.ts new file mode 100644 index 000000000..784139d35 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/config/social-media.ts @@ -0,0 +1,168 @@ +/** + * Social Media Platform Configuration + * Centralized config for all social media platforms used in the contacts app + */ + +export interface SocialPlatform { + /** Unique identifier matching Contact field name */ + id: string; + /** Display name */ + name: string; + /** Short badge label */ + badge: string; + /** Input type for form fields */ + inputType: 'url' | 'text' | 'tel'; + /** Placeholder text for form input */ + placeholder: string; + /** Whether this platform has a clickable link */ + hasLink: boolean; + /** Function to build the full URL from a value */ + buildUrl?: (value: string) => string; +} + +/** + * All supported social media platforms + */ +export const SOCIAL_PLATFORMS: SocialPlatform[] = [ + { + id: 'linkedin', + name: 'LinkedIn', + badge: 'in', + inputType: 'url', + placeholder: 'https://linkedin.com/in/...', + hasLink: true, + buildUrl: (v) => (v.startsWith('http') ? v : `https://linkedin.com/in/${v}`), + }, + { + id: 'twitter', + name: 'Twitter / X', + badge: 'X', + inputType: 'text', + placeholder: '@username', + hasLink: true, + buildUrl: (v) => (v.startsWith('http') ? v : `https://x.com/${v.replace('@', '')}`), + }, + { + id: 'facebook', + name: 'Facebook', + badge: 'f', + inputType: 'url', + placeholder: 'https://facebook.com/...', + hasLink: true, + buildUrl: (v) => (v.startsWith('http') ? v : `https://facebook.com/${v}`), + }, + { + id: 'instagram', + name: 'Instagram', + badge: 'ig', + inputType: 'text', + placeholder: '@username', + hasLink: true, + buildUrl: (v) => (v.startsWith('http') ? v : `https://instagram.com/${v.replace('@', '')}`), + }, + { + id: 'xing', + name: 'Xing', + badge: 'xi', + inputType: 'url', + placeholder: 'https://xing.com/profile/...', + hasLink: true, + buildUrl: (v) => (v.startsWith('http') ? v : `https://xing.com/profile/${v}`), + }, + { + id: 'github', + name: 'GitHub', + badge: 'gh', + inputType: 'text', + placeholder: 'username', + hasLink: true, + buildUrl: (v) => (v.startsWith('http') ? v : `https://github.com/${v}`), + }, + { + id: 'youtube', + name: 'YouTube', + badge: 'yt', + inputType: 'url', + placeholder: 'https://youtube.com/@...', + hasLink: true, + buildUrl: (v) => (v.startsWith('http') ? v : `https://youtube.com/@${v}`), + }, + { + id: 'tiktok', + name: 'TikTok', + badge: 'tt', + inputType: 'text', + placeholder: '@username', + hasLink: true, + buildUrl: (v) => (v.startsWith('http') ? v : `https://tiktok.com/@${v.replace('@', '')}`), + }, + { + id: 'telegram', + name: 'Telegram', + badge: 'tg', + inputType: 'text', + placeholder: '@username', + hasLink: true, + buildUrl: (v) => `https://t.me/${v.replace('@', '')}`, + }, + { + id: 'whatsapp', + name: 'WhatsApp', + badge: 'wa', + inputType: 'tel', + placeholder: '+49...', + hasLink: true, + buildUrl: (v) => `https://wa.me/${v.replace(/[^0-9]/g, '')}`, + }, + { + id: 'signal', + name: 'Signal', + badge: 'sg', + inputType: 'tel', + placeholder: '+49...', + hasLink: false, + }, + { + id: 'discord', + name: 'Discord', + badge: 'dc', + inputType: 'text', + placeholder: 'username#1234', + hasLink: false, + }, + { + id: 'bluesky', + name: 'Bluesky', + badge: 'bs', + inputType: 'text', + placeholder: '@handle.bsky.social', + hasLink: true, + buildUrl: (v) => (v.startsWith('http') ? v : `https://bsky.app/profile/${v.replace('@', '')}`), + }, +]; + +/** + * Get platform config by ID + */ +export function getPlatform(id: string): SocialPlatform | undefined { + return SOCIAL_PLATFORMS.find((p) => p.id === id); +} + +/** + * Check if a contact has any social media data + */ +export function hasSocialMedia(contact: Record): boolean { + return SOCIAL_PLATFORMS.some((p) => !!contact[p.id]); +} + +/** + * Get all social media entries for a contact + */ +export function getSocialMediaEntries( + contact: Record +): Array<{ platform: SocialPlatform; value: string }> { + return SOCIAL_PLATFORMS.filter((p) => !!contact[p.id]).map((platform) => ({ + platform, + value: contact[platform.id] as string, + })); +} diff --git a/apps/contacts/apps/web/src/lib/services/feedback.ts b/apps/contacts/apps/web/src/lib/services/feedback.ts index 95dc1656d..fd578e9d9 100644 --- a/apps/contacts/apps/web/src/lib/services/feedback.ts +++ b/apps/contacts/apps/web/src/lib/services/feedback.ts @@ -4,8 +4,7 @@ import { createFeedbackService } from '@manacore/shared-feedback-service'; import { authStore } from '$lib/stores/auth.svelte'; - -const MANA_AUTH_URL = 'http://localhost:3001'; +import { MANA_AUTH_URL } from '$lib/api/config'; export const feedbackService = createFeedbackService({ apiUrl: MANA_AUTH_URL, diff --git a/apps/contacts/apps/web/src/lib/stores/auth.svelte.ts b/apps/contacts/apps/web/src/lib/stores/auth.svelte.ts index 3f7320628..4582f599b 100644 --- a/apps/contacts/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/contacts/apps/web/src/lib/stores/auth.svelte.ts @@ -7,9 +7,28 @@ import { browser } from '$app/environment'; import { initializeWebAuth } from '@manacore/shared-auth'; import type { UserData } from '@manacore/shared-auth'; -// Initialize Mana Core Auth only on the client side -const MANA_AUTH_URL = 'http://localhost:3001'; -const BACKEND_URL = 'http://localhost:3015'; +// Get auth URL dynamically at runtime - fallback for SSR and client +function getAuthUrl(): string { + if (browser && typeof window !== 'undefined') { + // Client-side: use injected window variable (set by hooks.server.ts) + // Falls back to localhost for local development + const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string }) + .__PUBLIC_MANA_CORE_AUTH_URL__; + return injectedUrl || 'http://localhost:3001'; + } + // Server-side (SSR): use Docker internal URL for container-to-container communication + return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001'; +} + +// Get backend URL dynamically at runtime +function getBackendUrl(): string { + if (browser && typeof window !== 'undefined') { + const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string }) + .__PUBLIC_BACKEND_URL__; + return injectedUrl || 'http://localhost:3015'; + } + return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3015'; +} // Lazy initialization to avoid SSR issues with localStorage let _authService: ReturnType['authService'] | null = null; @@ -19,8 +38,8 @@ function getAuthService() { if (!browser) return null; if (!_authService) { const auth = initializeWebAuth({ - baseUrl: MANA_AUTH_URL, - backendUrl: BACKEND_URL, // Enables automatic token refresh on 401 responses + baseUrl: getAuthUrl(), + backendUrl: getBackendUrl(), // Enables automatic token refresh on 401 responses }); _authService = auth.authService; _tokenManager = auth.tokenManager; diff --git a/apps/contacts/apps/web/src/lib/stores/filter.svelte.ts b/apps/contacts/apps/web/src/lib/stores/filter.svelte.ts new file mode 100644 index 000000000..426dec25b --- /dev/null +++ b/apps/contacts/apps/web/src/lib/stores/filter.svelte.ts @@ -0,0 +1,136 @@ +/** + * Filter Store - Manages filter state for the Contacts app toolbar + * Uses Svelte 5 runes for reactivity + */ + +import { browser } from '$app/environment'; + +export type SortField = 'firstName' | 'lastName'; +export type ContactFilter = 'all' | 'favorites' | 'hasPhone' | 'hasEmail' | 'incomplete'; +export type BirthdayFilter = 'all' | 'today' | 'thisWeek' | 'thisMonth'; + +export interface ContactsFilterState { + sortField: SortField; + contactFilter: ContactFilter; + birthdayFilter: BirthdayFilter; + selectedTagId: string | null; + selectedCompany: string | null; + isToolbarCollapsed: boolean; + isAlphabetNavCollapsed: boolean; + searchQuery: string; +} + +const DEFAULT_STATE: ContactsFilterState = { + sortField: 'lastName', + contactFilter: 'all', + birthdayFilter: 'all', + selectedTagId: null, + selectedCompany: null, + isToolbarCollapsed: true, + isAlphabetNavCollapsed: false, + searchQuery: '', +}; + +const STORAGE_KEY = 'contacts-filter-state'; + +function loadState(): ContactsFilterState { + if (!browser) return DEFAULT_STATE; + + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored); + return { ...DEFAULT_STATE, ...parsed }; + } + } catch (e) { + console.error('Failed to load contacts filter state:', e); + } + + return DEFAULT_STATE; +} + +function saveState(state: ContactsFilterState) { + if (!browser) return; + + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch (e) { + console.error('Failed to save contacts filter state:', e); + } +} + +// Reactive state +let state = $state(DEFAULT_STATE); + +// Generic update helper +function update( + key: K, + value: ContactsFilterState[K], + persist = true +) { + state = { ...state, [key]: value }; + if (persist) saveState(state); +} + +export const contactsFilterStore = { + // Getters - Required for Svelte 5 reactivity + get sortField() { + return state.sortField; + }, + get contactFilter() { + return state.contactFilter; + }, + get birthdayFilter() { + return state.birthdayFilter; + }, + get selectedTagId() { + return state.selectedTagId; + }, + get selectedCompany() { + return state.selectedCompany; + }, + get isToolbarCollapsed() { + return state.isToolbarCollapsed; + }, + get isAlphabetNavCollapsed() { + return state.isAlphabetNavCollapsed; + }, + get searchQuery() { + return state.searchQuery; + }, + + // Setters + setSortField: (value: SortField) => update('sortField', value), + setContactFilter: (value: ContactFilter) => update('contactFilter', value), + setBirthdayFilter: (value: BirthdayFilter) => update('birthdayFilter', value), + setSelectedTagId: (value: string | null) => update('selectedTagId', value), + setSelectedCompany: (value: string | null) => update('selectedCompany', value), + setToolbarCollapsed: (value: boolean) => update('isToolbarCollapsed', value), + setAlphabetNavCollapsed: (value: boolean) => update('isAlphabetNavCollapsed', value), + setSearchQuery: (value: string) => update('searchQuery', value, false), + + toggleToolbar() { + update('isToolbarCollapsed', !state.isToolbarCollapsed); + }, + + toggleAlphabetNav() { + update('isAlphabetNavCollapsed', !state.isAlphabetNavCollapsed); + }, + + resetFilters() { + state = { + ...state, + contactFilter: 'all', + birthdayFilter: 'all', + selectedTagId: null, + selectedCompany: null, + searchQuery: '', + }; + saveState(state); + }, + + initialize() { + if (!browser) return; + state = loadState(); + }, +}; diff --git a/apps/contacts/apps/web/src/lib/stores/network.svelte.ts b/apps/contacts/apps/web/src/lib/stores/network.svelte.ts index 777b6141d..f0d2f8ea2 100644 --- a/apps/contacts/apps/web/src/lib/stores/network.svelte.ts +++ b/apps/contacts/apps/web/src/lib/stores/network.svelte.ts @@ -22,6 +22,45 @@ import type { export type SimulationNode = SharedSimulationNode; export type SimulationLink = SharedSimulationLink; +// Interface for NetworkGraph component zoom methods +interface NetworkGraphZoomMethods { + zoomIn(): void; + zoomOut(): void; + resetZoom(): void; + focusOnSelectedNode(): void; +} + +// Graph component reference for zoom controls +let graphComponentRef: NetworkGraphZoomMethods | null = null; + +// localStorage key for toolbar state +const TOOLBAR_STORAGE_KEY = 'network-toolbar-state'; + +// Load toolbar state from localStorage +function loadToolbarState(): boolean { + if (!browser) return true; + try { + const stored = localStorage.getItem(TOOLBAR_STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored); + return parsed.isCollapsed ?? true; + } + } catch { + // Ignore parse errors + } + return true; +} + +// Save toolbar state to localStorage +function saveToolbarState(isCollapsed: boolean) { + if (!browser) return; + try { + localStorage.setItem(TOOLBAR_STORAGE_KEY, JSON.stringify({ isCollapsed })); + } catch { + // Ignore storage errors + } +} + // State let nodes = $state([]); let links = $state([]); @@ -37,6 +76,7 @@ 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 }; +let isToolbarCollapsed = $state(loadToolbarState()); // Derived state for filtering const filteredNodes = $derived.by(() => { @@ -159,6 +199,60 @@ export const networkStore = { get uniqueTags() { return uniqueTags; }, + get isToolbarCollapsed() { + return isToolbarCollapsed; + }, + + /** + * Set toolbar collapsed state + */ + setToolbarCollapsed(value: boolean) { + isToolbarCollapsed = value; + saveToolbarState(value); + }, + + /** + * Toggle toolbar collapsed state + */ + toggleToolbar() { + isToolbarCollapsed = !isToolbarCollapsed; + saveToolbarState(isToolbarCollapsed); + }, + + /** + * Register graph component reference for zoom controls + */ + setGraphComponent(component: NetworkGraphZoomMethods | null) { + graphComponentRef = component; + }, + + /** + * Zoom in on the graph + */ + zoomIn() { + graphComponentRef?.zoomIn(); + }, + + /** + * Zoom out on the graph + */ + zoomOut() { + graphComponentRef?.zoomOut(); + }, + + /** + * Reset zoom to fit all nodes + */ + resetZoom() { + graphComponentRef?.resetZoom(); + }, + + /** + * Focus on the currently selected node + */ + focusOnSelected() { + graphComponentRef?.focusOnSelectedNode(); + }, /** * Load network graph data from API diff --git a/apps/contacts/apps/web/src/lib/stores/new-contact-modal.svelte.ts b/apps/contacts/apps/web/src/lib/stores/new-contact-modal.svelte.ts new file mode 100644 index 000000000..b565ef051 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/stores/new-contact-modal.svelte.ts @@ -0,0 +1,41 @@ +/** + * Store for controlling the New Contact Modal + */ + +interface NewContactData { + firstName?: string; + lastName?: string; + displayName?: string; + email?: string; + phone?: string; + company?: string; +} + +let isOpen = $state(false); +let prefillData = $state(null); + +export const newContactModalStore = { + get isOpen() { + return isOpen; + }, + + get prefillData() { + return prefillData; + }, + + /** + * Open the modal, optionally with pre-filled data + */ + open(data?: NewContactData) { + prefillData = data || null; + isOpen = true; + }, + + /** + * Close the modal and reset data + */ + close() { + isOpen = false; + prefillData = null; + }, +}; diff --git a/apps/contacts/apps/web/src/lib/stores/settings.svelte.ts b/apps/contacts/apps/web/src/lib/stores/settings.svelte.ts index f5d679f13..21a036ab8 100644 --- a/apps/contacts/apps/web/src/lib/stores/settings.svelte.ts +++ b/apps/contacts/apps/web/src/lib/stores/settings.svelte.ts @@ -8,7 +8,7 @@ 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 ContactView = 'grid' | 'alphabet' | 'network'; export type DateFormat = 'dd.MM.yyyy' | 'MM/dd/yyyy' | 'yyyy-MM-dd'; export interface ContactsAppSettings { @@ -55,6 +55,20 @@ export interface ContactsAppSettings { privacyMode: boolean; /** Require confirmation before sharing contact */ confirmBeforeSharing: boolean; + + // Alphabet Navigation Settings + /** Hide letters that have no contacts */ + alphabetNavHideInactive: boolean; + /** Use compact/smaller alphabet buttons */ + alphabetNavCompact: boolean; + /** Reverse letter order (Z-A instead of A-Z) */ + alphabetNavReverseOrder: boolean; + /** Show # symbol for non-letter names */ + alphabetNavShowHash: boolean; + + // Immersive Mode + /** Fullscreen mode - hides all UI elements */ + immersiveModeEnabled: boolean; } const DEFAULT_SETTINGS: ContactsAppSettings = { @@ -84,6 +98,15 @@ const DEFAULT_SETTINGS: ContactsAppSettings = { // Privacy privacyMode: false, confirmBeforeSharing: true, + + // Alphabet Navigation + alphabetNavHideInactive: false, + alphabetNavCompact: false, + alphabetNavReverseOrder: false, + alphabetNavShowHash: true, + + // Immersive Mode + immersiveModeEnabled: false, }; const STORAGE_KEY = 'contacts-settings'; @@ -187,6 +210,33 @@ export const contactsSettings = { return settings.confirmBeforeSharing; }, + // Alphabet Navigation + get alphabetNavHideInactive() { + return settings.alphabetNavHideInactive; + }, + get alphabetNavCompact() { + return settings.alphabetNavCompact; + }, + get alphabetNavReverseOrder() { + return settings.alphabetNavReverseOrder; + }, + get alphabetNavShowHash() { + return settings.alphabetNavShowHash; + }, + + // Immersive Mode + get immersiveModeEnabled() { + return settings.immersiveModeEnabled; + }, + + /** + * Toggle Immersive Mode (fullscreen, hide all UI) + */ + toggleImmersiveMode() { + settings = { ...settings, immersiveModeEnabled: !settings.immersiveModeEnabled }; + saveSettings(settings); + }, + /** * Initialize settings from localStorage */ diff --git a/apps/contacts/apps/web/src/lib/stores/user-settings.svelte.ts b/apps/contacts/apps/web/src/lib/stores/user-settings.svelte.ts index 70c7b99ae..e70961ed6 100644 --- a/apps/contacts/apps/web/src/lib/stores/user-settings.svelte.ts +++ b/apps/contacts/apps/web/src/lib/stores/user-settings.svelte.ts @@ -9,8 +9,7 @@ import { createUserSettingsStore } from '@manacore/shared-theme'; import { authStore } from './auth.svelte'; - -const MANA_AUTH_URL = 'http://localhost:3001'; +import { MANA_AUTH_URL } from '$lib/api/config'; export const userSettings = createUserSettingsStore({ appId: 'contacts', diff --git a/apps/contacts/apps/web/src/lib/stores/view-mode.svelte.ts b/apps/contacts/apps/web/src/lib/stores/view-mode.svelte.ts index 1a61b9bbb..258e1d63c 100644 --- a/apps/contacts/apps/web/src/lib/stores/view-mode.svelte.ts +++ b/apps/contacts/apps/web/src/lib/stores/view-mode.svelte.ts @@ -10,13 +10,20 @@ export type ViewMode = ContactView; const STORAGE_KEY = 'contacts-view-mode'; +// Valid view modes +const VALID_MODES: ViewMode[] = ['grid', 'alphabet', 'network']; + +function isValidMode(mode: string | null): mode is ViewMode { + return mode !== null && VALID_MODES.includes(mode as ViewMode); +} + // Get initial mode: current session preference > settings default > 'alphabet' function getInitialMode(): ViewMode { if (!browser) return 'alphabet'; // First check if there's a session-specific preference const sessionMode = sessionStorage.getItem(STORAGE_KEY); - if (sessionMode === 'list' || sessionMode === 'grid' || sessionMode === 'alphabet') { + if (isValidMode(sessionMode)) { return sessionMode; } @@ -57,7 +64,7 @@ export const viewModeStore = { // Check if there's a session preference const sessionMode = sessionStorage.getItem(STORAGE_KEY); - if (sessionMode === 'list' || sessionMode === 'grid' || sessionMode === 'alphabet') { + if (isValidMode(sessionMode)) { mode = sessionMode; } else { // Use default from settings diff --git a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte index e06d03bd3..d21dd6fc7 100644 --- a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte @@ -3,7 +3,7 @@ import { page } from '$app/stores'; import { onMount } from 'svelte'; import { locale } from 'svelte-i18n'; - import { PillNavigation, QuickInputBar } from '@manacore/shared-ui'; + import { PillNavigation, QuickInputBar, ImmersiveModeToggle } from '@manacore/shared-ui'; import { SplitPaneContainer, setSplitPanelContext, @@ -13,7 +13,6 @@ PillNavItem, PillDropdownItem, QuickInputItem, - QuickAction, CreatePreview, } from '@manacore/shared-ui'; import { theme } from '$lib/stores/theme'; @@ -34,15 +33,19 @@ import { getPillAppItems } from '@manacore/shared-branding'; import { setLocale, supportedLocales } from '$lib/i18n'; import ContactDetailModal from '$lib/components/ContactDetailModal.svelte'; + import NewContactModal from '$lib/components/NewContactModal.svelte'; import { contactsStore } from '$lib/stores/contacts.svelte'; + import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte'; import { contactsApi, tagsApi } from '$lib/api/contacts'; import { viewModeStore } from '$lib/stores/view-mode.svelte'; import { contactsSettings } from '$lib/stores/settings.svelte'; + import { contactsFilterStore } from '$lib/stores/filter.svelte'; import { parseContactInput, resolveContactIds, formatParsedContactPreview, } from '$lib/utils/contact-parser'; + import ContactsToolbar from '$lib/components/ContactsToolbar.svelte'; // Tags state for Quick-Create let availableTags = $state<{ id: string; name: string }[]>([]); @@ -68,6 +71,19 @@ let isSidebarMode = $state(false); let isCollapsed = $state(false); + // Show toolbar only on main contacts page + const showContactsToolbar = $derived($page.url.pathname === '/' && !isSidebarMode); + + // Check if toolbar is expanded + const isToolbarExpanded = $derived( + showContactsToolbar && !contactsFilterStore.isToolbarCollapsed + ); + + // Dynamic bottom offset based on toolbar state + const inputBarBottomOffset = $derived( + isSidebarMode ? '0px' : isToolbarExpanded ? '140px' : '70px' + ); + // Use theme store's isDark directly let isDark = $derived(theme.isDark); @@ -121,9 +137,7 @@ const baseNavItems: PillNavItem[] = [ { href: '/', label: 'Kontakte', icon: 'users' }, { href: '/tags', label: 'Tags', icon: 'tag' }, - { href: '/favorites', label: 'Favoriten', icon: 'heart' }, { href: '/statistics', label: 'Statistiken', icon: 'bar-chart-3' }, - { href: '/network', label: 'Netzwerk', icon: 'share-2' }, { href: '/settings', label: 'Einstellungen', icon: 'settings' }, { href: '/feedback', label: 'Feedback', icon: 'chat' }, { href: '/help', label: 'Hilfe', icon: 'help-circle' }, @@ -154,6 +168,18 @@ } } } + + // F = Toggle Immersive Mode (no modifier keys) + if ( + (event.key === 'f' || event.key === 'F') && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey && + !event.altKey + ) { + event.preventDefault(); + contactsSettings.toggleImmersiveMode(); + } } function handleModeChange(isSidebar: boolean) { @@ -227,38 +253,22 @@ async function handleCreate(query: string): Promise { const parsed = parseContactInput(query); - if (!parsed.displayName) return; - - // Resolve tag names to IDs - const resolved = resolveContactIds(parsed, availableTags); - - try { - const contact = await contactsStore.createContact({ - displayName: resolved.displayName, - firstName: resolved.firstName, - lastName: resolved.lastName, - company: resolved.company, - email: resolved.email, - phone: resolved.phone, - }); - - // Add tags to the created contact - if (resolved.tagIds.length > 0 && contact) { - for (const tagId of resolved.tagIds) { - await tagsApi.addToContact(tagId, contact.id); - } - } - } catch (e) { - console.error('Failed to create contact:', e); + if (!parsed.displayName) { + // If no query, just open empty modal + newContactModalStore.open(); + return; } - } - // QuickInputBar quick actions - const quickActions: QuickAction[] = [ - { id: 'favorites', label: 'Favoriten', icon: 'heart', href: '/favorites' }, - { id: 'tags', label: 'Tags', icon: 'tag', href: '/tags' }, - { id: 'settings', label: 'Einstellungen', icon: 'settings', href: '/settings' }, - ]; + // Open modal with prefilled data + newContactModalStore.open({ + displayName: parsed.displayName, + firstName: parsed.firstName || undefined, + lastName: parsed.lastName || undefined, + email: parsed.email || undefined, + phone: parsed.phone || undefined, + company: parsed.company || undefined, + }); + } onMount(async () => { // Redirect to login if not authenticated @@ -281,9 +291,10 @@ console.error('Failed to load tags:', e); } - // Initialize contacts settings and view mode + // Initialize contacts settings, view mode, and filter store contactsSettings.initialize(); viewModeStore.initialize(); + contactsFilterStore.initialize(); // Initialize sidebar mode from localStorage const savedSidebar = localStorage.getItem('contacts-nav-sidebar'); @@ -306,43 +317,70 @@
- - + + {#if !contactsSettings.immersiveModeEnabled} + + - - + contactsFilterStore.setSearchQuery(query)} + placeholder="Neuer Kontakt oder suchen..." + emptyText="Keine Kontakte gefunden" + searchingText="Suche..." + onCreate={handleCreate} + onParseCreate={handleParseCreate} + createText="Erstellen" + appIcon="contacts" + bottomOffset={inputBarBottomOffset} + hasFabRight={showContactsToolbar} + /> + + + {#if showContactsToolbar} + + {/if} + {/if} + + + contactsSettings.toggleImmersiveMode()} /> @@ -350,8 +388,9 @@ class="main-content bg-background" class:sidebar-mode={isSidebarMode && !isCollapsed} class:floating-mode={!isSidebarMode} + class:immersive={contactsSettings.immersiveModeEnabled} > -
+
{@render children()}
@@ -361,21 +400,10 @@ {/if} - - + + {#if newContactModalStore.isOpen} + newContactModalStore.close()} /> + {/if}
@@ -389,17 +417,19 @@ .main-content { flex: 1; transition: all 300ms ease; + /* Space for QuickInputBar + PillNav at bottom */ + padding-bottom: calc(150px + env(safe-area-inset-bottom)); } - /* Floating nav mode - add top padding for fixed nav */ + /* Floating nav mode - nav is at bottom, no top padding needed */ .main-content.floating-mode { - padding-top: 80px; + padding-top: 0; } - /* Extra padding on mobile for larger nav */ + /* Extra bottom padding on mobile */ @media (max-width: 768px) { - .main-content.floating-mode { - padding-top: 90px; + .main-content { + padding-bottom: calc(160px + env(safe-area-inset-bottom)); } } @@ -408,10 +438,20 @@ padding-left: 180px; } + /* Immersive mode - fullscreen, no padding */ + .main-content.immersive { + padding: 0 !important; + height: 100vh; + width: 100vw; + } + + .content-wrapper.immersive { + padding: 0; + height: 100%; + } + .content-wrapper { - max-width: 900px; - margin-left: auto; - margin-right: auto; + /* No max-width - let individual views control their own width */ padding: 1rem; } @@ -427,26 +467,22 @@ } } - /* Shadow gradient above pill navigation */ - .nav-shadow-gradient { - position: fixed; - top: 0; - left: 0; - right: 0; - height: 80px; - background: linear-gradient( - to bottom, - hsl(var(--background)) 0%, - hsl(var(--background)) 50%, - hsl(var(--background) / 0) 100% - ); - pointer-events: none; - z-index: 40; + /* Adjust InputBar when toolbar elements (view-mode-pill + FAB) are visible */ + /* Pill left edge is at: 50% - 238px from right edge of viewport */ + /* This means from center, there's 238px to the pill's left edge */ + /* For a centered InputBar with max-width W, right edge is at: center + W/2 */ + /* We need: center + W/2 < center + 238 - 12px gap, so W/2 < 226, W < 452px */ + :global(.quick-input-bar.has-fab-right .input-container) { + max-width: 450px; } - @media (max-width: 768px) { - .nav-shadow-gradient { - height: 90px; + /* On smaller screens (<900px), the FAB + pill move to right: 1rem position */ + /* So we need fixed padding instead */ + @media (max-width: 900px) { + :global(.quick-input-bar.has-fab-right .input-container) { + max-width: calc(100% - 200px); /* ~120px pill + 8px + 54px FAB + 18px gap */ + margin-left: 0; + margin-right: auto; } } diff --git a/apps/contacts/apps/web/src/routes/(app)/contacts/new/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/contacts/new/+page.svelte deleted file mode 100644 index 4f5900295..000000000 --- a/apps/contacts/apps/web/src/routes/(app)/contacts/new/+page.svelte +++ /dev/null @@ -1,747 +0,0 @@ - - - - Neuer Kontakt - Contacts - - -
- -
- - - - - -

Neuer Kontakt

-
-
- - -
-
-
- {initials()} -
- -
-

{displayName()}

- {#if company || jobTitle} -

{[jobTitle, company].filter(Boolean).join(' bei ')}

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

Name

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

Kontakt

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

Arbeit

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

Adresse

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

Notizen

-
- -
- - -
- Abbrechen - -
-
-
- - diff --git a/apps/contacts/apps/web/src/routes/(app)/favorites/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/favorites/+page.svelte deleted file mode 100644 index 528c0bed3..000000000 --- a/apps/contacts/apps/web/src/routes/(app)/favorites/+page.svelte +++ /dev/null @@ -1,767 +0,0 @@ - - - - Favoriten - Contacts - - -
- -
-
-
- - - -
-
-

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} - - {/if} - - {#if loading} - - {#if viewMode === 'cards'} - - {:else} - - {/if} - {:else if contacts.length === 0} -
-
- - - -
-

Keine Favoriten

-

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

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

Keine Ergebnisse

-

Keine Favoriten gefunden für "{searchQuery}"

- -
- {:else} - -
- {#if viewMode === 'cards'} - - {:else if viewMode === 'list'} - - {:else} - - {/if} -
- - - - {/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 33795c905..b5702ea51 100644 --- a/apps/contacts/apps/web/src/routes/(app)/settings/+page.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/settings/+page.svelte @@ -25,9 +25,9 @@ // Options for selects const viewOptions = [ - { value: 'list', label: 'Liste' }, { value: 'grid', label: 'Kacheln' }, { value: 'alphabet', label: 'Alphabetisch' }, + { value: 'network', label: 'Netzwerk' }, ]; const sortByOptions = [ @@ -63,7 +63,6 @@ const startPageLabels: Record = { 'nav.contacts': 'Kontakte', 'nav.groups': 'Gruppen', - 'nav.favorites': 'Favoriten', }; function translateLabel(key: string): string { diff --git a/apps/contacts/apps/web/src/routes/(app)/statistics/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/statistics/+page.svelte index f288dbd02..ae5532654 100644 --- a/apps/contacts/apps/web/src/routes/(app)/statistics/+page.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/statistics/+page.svelte @@ -1,8 +1,8 @@ - {translations.title} | Contacts + {translations.titleForm} | Contacts
- + {#if !todoSettings.immersiveModeEnabled} + + + + + {/if} + + + todoSettings.toggleImmersiveMode()} />
-
+
{@render children()}
- - -
@@ -414,6 +444,19 @@ padding-left: 180px; } + /* Immersive mode - fullscreen, no padding */ + .main-content.immersive { + padding: 0 !important; + height: 100vh; + width: 100vw; + } + + .content-wrapper.immersive { + padding: 0; + max-width: none; + height: 100%; + } + .content-wrapper { max-width: 900px; margin-left: auto; diff --git a/apps/zitare/apps/web/src/lib/stores/auth.svelte.ts b/apps/zitare/apps/web/src/lib/stores/auth.svelte.ts index 688e7e17f..03771a80c 100644 --- a/apps/zitare/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/zitare/apps/web/src/lib/stores/auth.svelte.ts @@ -7,9 +7,28 @@ import { browser } from '$app/environment'; import { initializeWebAuth } from '@manacore/shared-auth'; import type { UserData } from '@manacore/shared-auth'; -// Initialize Mana Core Auth only on the client side -const MANA_AUTH_URL = 'http://localhost:3001'; -const BACKEND_URL = 'http://localhost:3007'; +// Get auth URL dynamically at runtime - fallback for SSR and client +function getAuthUrl(): string { + if (browser && typeof window !== 'undefined') { + // Client-side: use injected window variable (set by hooks.server.ts) + // Falls back to localhost for local development + const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string }) + .__PUBLIC_MANA_CORE_AUTH_URL__; + return injectedUrl || 'http://localhost:3001'; + } + // Server-side (SSR): use Docker internal URL for container-to-container communication + return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001'; +} + +// Get backend URL dynamically at runtime +function getBackendUrl(): string { + if (browser && typeof window !== 'undefined') { + const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string }) + .__PUBLIC_BACKEND_URL__; + return injectedUrl || 'http://localhost:3007'; + } + return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3007'; +} // Lazy initialization to avoid SSR issues with localStorage let _authService: ReturnType['authService'] | null = null; @@ -19,8 +38,8 @@ function getAuthService() { if (!browser) return null; if (!_authService) { const auth = initializeWebAuth({ - baseUrl: MANA_AUTH_URL, - backendUrl: BACKEND_URL, // Enables automatic token refresh on 401 responses + baseUrl: getAuthUrl(), + backendUrl: getBackendUrl(), // Enables automatic token refresh on 401 responses }); _authService = auth.authService; _tokenManager = auth.tokenManager; diff --git a/packages/shared-splitscreen/src/components/SplitPaneContainer.svelte b/packages/shared-splitscreen/src/components/SplitPaneContainer.svelte index f0ddaf89d..9020a2d61 100644 --- a/packages/shared-splitscreen/src/components/SplitPaneContainer.svelte +++ b/packages/shared-splitscreen/src/components/SplitPaneContainer.svelte @@ -81,7 +81,7 @@ width: 100%; height: 100%; min-height: 0; - overflow: hidden; + overflow: clip; position: relative; } @@ -99,7 +99,7 @@ height: 100%; min-width: 0; min-height: 0; - overflow: hidden; + overflow: clip; } .side-panel { diff --git a/packages/shared-tailwind/src/themes.css b/packages/shared-tailwind/src/themes.css index 7f5c0e691..78d9ce6e3 100644 --- a/packages/shared-tailwind/src/themes.css +++ b/packages/shared-tailwind/src/themes.css @@ -33,6 +33,11 @@ --color-surface: var(--theme-surface); --color-surface-hover: var(--theme-surface-hover); --color-surface-elevated: var(--theme-surface-elevated); + + /* Elevation system - progressively lighter surfaces for overlays */ + --color-surface-elevated-1: var(--theme-surface-elevated-1); + --color-surface-elevated-2: var(--theme-surface-elevated-2); + --color-surface-elevated-3: var(--theme-surface-elevated-3); --color-muted: var(--theme-muted); --color-muted-foreground: var(--theme-muted-foreground); --color-border: var(--theme-border); @@ -129,6 +134,10 @@ --theme-surface: hsl(0 0% 100%); --theme-surface-hover: hsl(0 0% 96%); --theme-surface-elevated: hsl(0 0% 100%); + /* Elevation system - progressively lighter surfaces for overlays */ + --theme-surface-elevated-1: hsl(0 0% 100%); + --theme-surface-elevated-2: hsl(0 0% 100%); + --theme-surface-elevated-3: hsl(0 0% 100%); --theme-muted: hsl(0 0% 90%); --theme-muted-foreground: hsl(0 0% 40%); --theme-border: hsl(0 0% 90%); @@ -192,6 +201,10 @@ --theme-surface: hsl(0 0% 12%); --theme-surface-hover: hsl(0 0% 16%); --theme-surface-elevated: hsl(0 0% 14%); + /* Elevation system - progressively lighter surfaces for overlays */ + --theme-surface-elevated-1: hsl(0 0% 16%); + --theme-surface-elevated-2: hsl(0 0% 20%); + --theme-surface-elevated-3: hsl(0 0% 24%); --theme-muted: hsl(0 0% 20%); --theme-muted-foreground: hsl(0 0% 60%); --theme-border: hsl(0 0% 26%); @@ -244,6 +257,9 @@ --theme-surface: hsl(0 0% 100%); --theme-surface-hover: hsl(0 0% 96%); --theme-surface-elevated: hsl(0 0% 100%); + --theme-surface-elevated-1: hsl(0 0% 100%); + --theme-surface-elevated-2: hsl(0 0% 100%); + --theme-surface-elevated-3: hsl(0 0% 100%); --theme-muted: hsl(0 0% 90%); --theme-muted-foreground: hsl(0 0% 40%); --theme-border: hsl(0 0% 90%); @@ -275,6 +291,9 @@ --theme-surface: hsl(0 0% 12%); --theme-surface-hover: hsl(0 0% 16%); --theme-surface-elevated: hsl(0 0% 14%); + --theme-surface-elevated-1: hsl(0 0% 16%); + --theme-surface-elevated-2: hsl(0 0% 20%); + --theme-surface-elevated-3: hsl(0 0% 24%); --theme-muted: hsl(0 0% 20%); --theme-muted-foreground: hsl(0 0% 60%); --theme-border: hsl(0 0% 26%); @@ -306,6 +325,9 @@ --theme-surface: hsl(0 0% 100%); --theme-surface-hover: hsl(120 25% 95%); --theme-surface-elevated: hsl(0 0% 100%); + --theme-surface-elevated-1: hsl(0 0% 100%); + --theme-surface-elevated-2: hsl(0 0% 100%); + --theme-surface-elevated-3: hsl(0 0% 100%); --theme-muted: hsl(120 25% 95%); --theme-muted-foreground: hsl(122 20% 40%); --theme-border: hsl(120 25% 91%); @@ -337,6 +359,9 @@ --theme-surface: hsl(120 10% 12%); --theme-surface-hover: hsl(120 10% 16%); --theme-surface-elevated: hsl(120 10% 14%); + --theme-surface-elevated-1: hsl(120 10% 16%); + --theme-surface-elevated-2: hsl(120 10% 20%); + --theme-surface-elevated-3: hsl(120 10% 24%); --theme-muted: hsl(120 10% 20%); --theme-muted-foreground: hsl(120 10% 60%); --theme-border: hsl(120 10% 25%); @@ -368,6 +393,9 @@ --theme-surface: hsl(0 0% 100%); --theme-surface-hover: hsl(200 10% 94%); --theme-surface-elevated: hsl(0 0% 100%); + --theme-surface-elevated-1: hsl(0 0% 100%); + --theme-surface-elevated-2: hsl(0 0% 100%); + --theme-surface-elevated-3: hsl(0 0% 100%); --theme-muted: hsl(200 10% 94%); --theme-muted-foreground: hsl(200 10% 45%); --theme-border: hsl(200 10% 88%); @@ -399,6 +427,9 @@ --theme-surface: hsl(200 10% 12%); --theme-surface-hover: hsl(200 10% 16%); --theme-surface-elevated: hsl(200 10% 14%); + --theme-surface-elevated-1: hsl(200 10% 16%); + --theme-surface-elevated-2: hsl(200 10% 20%); + --theme-surface-elevated-3: hsl(200 10% 24%); --theme-muted: hsl(200 10% 20%); --theme-muted-foreground: hsl(200 10% 60%); --theme-border: hsl(200 10% 25%); @@ -430,6 +461,9 @@ --theme-surface: hsl(0 0% 100%); --theme-surface-hover: hsl(199 100% 94%); --theme-surface-elevated: hsl(0 0% 100%); + --theme-surface-elevated-1: hsl(0 0% 100%); + --theme-surface-elevated-2: hsl(0 0% 100%); + --theme-surface-elevated-3: hsl(0 0% 100%); --theme-muted: hsl(199 100% 94%); --theme-muted-foreground: hsl(199 50% 40%); --theme-border: hsl(199 71% 87%); @@ -461,6 +495,9 @@ --theme-surface: hsl(199 30% 12%); --theme-surface-hover: hsl(199 30% 16%); --theme-surface-elevated: hsl(199 30% 14%); + --theme-surface-elevated-1: hsl(199 30% 16%); + --theme-surface-elevated-2: hsl(199 30% 20%); + --theme-surface-elevated-3: hsl(199 30% 24%); --theme-muted: hsl(199 20% 20%); --theme-muted-foreground: hsl(199 20% 60%); --theme-border: hsl(199 20% 25%); @@ -493,6 +530,9 @@ --theme-surface: hsl(0 0% 12%); --theme-surface-hover: hsl(0 0% 16%); --theme-surface-elevated: hsl(0 0% 14%); + --theme-surface-elevated-1: hsl(0 0% 16%); + --theme-surface-elevated-2: hsl(0 0% 20%); + --theme-surface-elevated-3: hsl(0 0% 24%); --theme-muted: hsl(0 0% 20%); --theme-muted-foreground: hsl(0 0% 60%); --theme-border: hsl(0 0% 26%); diff --git a/packages/shared-theme-ui/src/pages/ThemePage.svelte b/packages/shared-theme-ui/src/pages/ThemePage.svelte index b9993f060..1c30b5413 100644 --- a/packages/shared-theme-ui/src/pages/ThemePage.svelte +++ b/packages/shared-theme-ui/src/pages/ThemePage.svelte @@ -1,5 +1,10 @@ + +{#if visible} + +{/if} + + diff --git a/packages/shared-ui/src/context-menu/ContextMenu.svelte b/packages/shared-ui/src/context-menu/ContextMenu.svelte new file mode 100644 index 000000000..997a6a1bd --- /dev/null +++ b/packages/shared-ui/src/context-menu/ContextMenu.svelte @@ -0,0 +1,285 @@ + + +{#if visible} + + +
{ + e.preventDefault(); + e.stopPropagation(); + onClose(); + }} + onclick={(e) => { + e.preventDefault(); + e.stopPropagation(); + onClose(); + }} + oncontextmenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + onClose(); + }} + >
+ + + +{/if} + + diff --git a/packages/shared-ui/src/context-menu/index.ts b/packages/shared-ui/src/context-menu/index.ts new file mode 100644 index 000000000..59acefbf5 --- /dev/null +++ b/packages/shared-ui/src/context-menu/index.ts @@ -0,0 +1,3 @@ +export { default as ContextMenu } from './ContextMenu.svelte'; +export type { ContextMenuItem, ContextMenuState } from './types'; +export { createContextMenuState } from './types'; diff --git a/packages/shared-ui/src/context-menu/types.ts b/packages/shared-ui/src/context-menu/types.ts new file mode 100644 index 000000000..c1bb498ba --- /dev/null +++ b/packages/shared-ui/src/context-menu/types.ts @@ -0,0 +1,46 @@ +import type { Component } from 'svelte'; + +export interface ContextMenuItem { + /** Unique identifier for the item */ + id: string; + /** Display label */ + label: string; + /** Icon component to render (Phosphor icon or any Svelte component) */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + icon?: Component; + /** Keyboard shortcut hint */ + shortcut?: string; + /** Whether the item is disabled */ + disabled?: boolean; + /** Visual variant */ + variant?: 'default' | 'danger'; + /** Item type - use 'divider' for separator */ + type?: 'item' | 'divider'; + /** Action to perform when clicked */ + action?: () => void; + /** Additional data attached to the item */ + data?: unknown; + /** Show a toggle switch (for boolean settings) */ + toggle?: boolean; + /** Current toggle state (only used when toggle is true) */ + checked?: boolean; +} + +export interface ContextMenuState { + visible: boolean; + x: number; + y: number; + target: T | null; +} + +/** + * Creates a context menu state object + */ +export function createContextMenuState(): ContextMenuState { + return { + visible: false, + x: 0, + y: 0, + target: null, + }; +} diff --git a/packages/shared-ui/src/help/HelpModal.svelte b/packages/shared-ui/src/help/HelpModal.svelte new file mode 100644 index 000000000..94b206bfb --- /dev/null +++ b/packages/shared-ui/src/help/HelpModal.svelte @@ -0,0 +1,183 @@ + + + +
+ + + + + +
+
+ + diff --git a/packages/shared-ui/src/help/KeyboardShortcutsPanel.svelte b/packages/shared-ui/src/help/KeyboardShortcutsPanel.svelte new file mode 100644 index 000000000..d28c04116 --- /dev/null +++ b/packages/shared-ui/src/help/KeyboardShortcutsPanel.svelte @@ -0,0 +1,251 @@ + + +
+ {#each categories as category} + {@const CategoryIcon = getCategoryIcon(category)} +
+
+ + {category.title} +
+ +
+ {#each category.shortcuts as shortcut} + {@const ShortcutIcon = getShortcutIcon(shortcut.keys)} +
+
+ +
+
+ {#each shortcut.keys as key, i} + {#if i > 0}+{/if} + {key} + {/each} + {#if shortcut.altKeys && !compact} + + oder + {#each shortcut.altKeys as key, i} + {#if i > 0}+{/if} + {key} + {/each} + + {/if} +
+ {shortcut.description} +
+ {/each} +
+
+ {/each} +
+ + diff --git a/packages/shared-ui/src/help/SyntaxHelpPanel.svelte b/packages/shared-ui/src/help/SyntaxHelpPanel.svelte new file mode 100644 index 000000000..f39896d5f --- /dev/null +++ b/packages/shared-ui/src/help/SyntaxHelpPanel.svelte @@ -0,0 +1,415 @@ + + +
+ {#if introText && !compact} +

{introText}

+ {/if} + + {#each groups as group} +
+

{group.title}

+
+ {#each group.items as item} + {@const Icon = item.icon ?? getPatternIcon(item.pattern)} +
+
+ +
+
+
+ {item.pattern} + {item.description} +
+
+ {#each item.examples as ex} + {#if typeof ex === 'string'} + {ex} + {:else} + + {ex.text} + {#if ex.label} + {ex.label} + {/if} + + {/if} + {/each} +
+
+
+ {/each} +
+
+ {/each} + + {#if showLiveExample && !compact} +
+
Beispiel-Eingabe
+
+ {#each example.highlights as hl} + {hl.content} + {/each} +
+
+ {/if} +
+ + diff --git a/packages/shared-ui/src/help/constants.ts b/packages/shared-ui/src/help/constants.ts new file mode 100644 index 000000000..e57e158a7 --- /dev/null +++ b/packages/shared-ui/src/help/constants.ts @@ -0,0 +1,122 @@ +import type { ShortcutCategory, SyntaxGroup } from './types'; + +/** + * Common keyboard shortcuts shared across all apps with InputBar + */ +export const COMMON_SHORTCUTS: ShortcutCategory[] = [ + { + id: 'inputbar', + title: 'Eingabefeld', + shortcuts: [ + { + keys: ['Enter'], + description: 'Auswahl bestätigen / Erstellen', + category: 'inputbar', + }, + { + keys: ['Cmd', 'Enter'], + altKeys: ['Ctrl', 'Enter'], + description: 'Direkt erstellen', + category: 'inputbar', + }, + { + keys: ['Esc'], + description: 'Schließen & Eingabe löschen', + category: 'inputbar', + }, + { + keys: ['↑', '↓'], + description: 'Durch Ergebnisse navigieren', + category: 'inputbar', + }, + { + keys: ['Rechtsklick'], + description: 'Einstellungen öffnen', + category: 'inputbar', + }, + ], + }, + { + id: 'dialogs', + title: 'Dialoge', + shortcuts: [ + { + keys: ['Esc'], + description: 'Dialog schließen', + category: 'dialogs', + }, + ], + }, +]; + +/** + * Common syntax patterns shared across all apps with InputBar + */ +export const COMMON_SYNTAX: SyntaxGroup[] = [ + { + title: 'Kategorien & Tags', + items: [ + { + pattern: '#tag', + description: 'Tag hinzufügen', + examples: ['#arbeit', '#privat', '#wichtig'], + color: 'primary', + }, + { + pattern: '@name', + description: 'Kalender oder Projekt zuweisen', + examples: ['@team', '@privat', '@projekt'], + color: 'success', + }, + ], + }, + { + title: 'Zeit & Datum', + items: [ + { + pattern: 'Datum', + description: 'Natürliche Datumsangaben', + examples: ['heute', 'morgen', 'montag', 'in 3 tagen', 'nächste woche'], + color: 'accent', + }, + { + pattern: 'Uhrzeit', + description: 'Zeitangaben', + examples: ['14:00', '9 uhr', 'um 15:30'], + color: 'accent', + }, + ], + }, + { + title: 'Priorität', + items: [ + { + pattern: 'Priorität', + description: 'Dringlichkeit festlegen', + examples: [ + { text: '!!!', label: 'dringend', color: 'error' }, + { text: '!!', label: 'hoch', color: 'warning' }, + { text: '!', label: 'normal', color: 'warning-soft' }, + ], + color: 'error', + }, + ], + }, +]; + +/** + * Default live example for syntax highlighting demo + */ +export const DEFAULT_LIVE_EXAMPLE = { + text: 'Meeting mit Team morgen 14:00 @arbeit #wichtig', + highlights: [ + { type: 'text' as const, content: 'Meeting mit Team ' }, + { type: 'date' as const, content: 'morgen' }, + { type: 'text' as const, content: ' ' }, + { type: 'time' as const, content: '14:00' }, + { type: 'text' as const, content: ' ' }, + { type: 'reference' as const, content: '@arbeit' }, + { type: 'text' as const, content: ' ' }, + { type: 'tag' as const, content: '#wichtig' }, + ], +}; diff --git a/packages/shared-ui/src/help/index.ts b/packages/shared-ui/src/help/index.ts new file mode 100644 index 000000000..cf2bbb318 --- /dev/null +++ b/packages/shared-ui/src/help/index.ts @@ -0,0 +1,18 @@ +// Help Components +export { default as HelpModal } from './HelpModal.svelte'; +export { default as KeyboardShortcutsPanel } from './KeyboardShortcutsPanel.svelte'; +export { default as SyntaxHelpPanel } from './SyntaxHelpPanel.svelte'; + +// Types +export type { + KeyboardShortcut, + ShortcutCategory, + SyntaxColor, + SyntaxExample, + SyntaxPattern, + SyntaxGroup, + HelpModalConfig, +} from './types'; + +// Constants +export { COMMON_SHORTCUTS, COMMON_SYNTAX, DEFAULT_LIVE_EXAMPLE } from './constants'; diff --git a/packages/shared-ui/src/help/types.ts b/packages/shared-ui/src/help/types.ts new file mode 100644 index 000000000..6fca61d09 --- /dev/null +++ b/packages/shared-ui/src/help/types.ts @@ -0,0 +1,93 @@ +import type { Component } from 'svelte'; + +/** + * Represents a single keyboard shortcut + */ +export interface KeyboardShortcut { + /** Keys to press, e.g. ['Cmd', 'Enter'] or ['↑', '↓'] */ + keys: string[]; + /** Description of what the shortcut does */ + description: string; + /** Category ID for grouping */ + category: string; + /** Alternative keys (e.g. Ctrl instead of Cmd) */ + altKeys?: string[]; +} + +/** + * A category/group of related shortcuts + */ +export interface ShortcutCategory { + /** Unique identifier */ + id: string; + /** Display title */ + title: string; + /** Optional icon component */ + icon?: Component; + /** Shortcuts in this category */ + shortcuts: KeyboardShortcut[]; +} + +/** + * Color variants for syntax highlighting + */ +export type SyntaxColor = 'primary' | 'success' | 'accent' | 'error' | 'warning' | 'warning-soft'; + +/** + * A syntax example - can be a simple string or an object with label + */ +export type SyntaxExample = + | string + | { + text: string; + label?: string; + color?: SyntaxColor; + }; + +/** + * A syntax pattern that can be used in the InputBar + */ +export interface SyntaxPattern { + /** The pattern syntax, e.g. '#tag', '@name', 'Datum' */ + pattern: string; + /** Description of what the pattern does */ + description: string; + /** Example usages */ + examples: SyntaxExample[]; + /** Color for highlighting */ + color: SyntaxColor; + /** Optional icon component */ + icon?: Component; +} + +/** + * A group of related syntax patterns + */ +export interface SyntaxGroup { + /** Group title */ + title: string; + /** Patterns in this group */ + items: SyntaxPattern[]; +} + +/** + * Configuration for the HelpModal + */ +export interface HelpModalConfig { + /** Shortcut categories to display */ + shortcuts?: ShortcutCategory[]; + /** Syntax groups to display */ + syntax?: SyntaxGroup[]; + /** Whether to show tabs (auto-detected if both shortcuts and syntax are provided) */ + showTabs?: boolean; + /** Default tab to show */ + defaultTab?: 'shortcuts' | 'syntax'; + /** Live example text for syntax highlighting demo */ + liveExample?: { + text: string; + highlights: Array<{ + type: 'text' | 'tag' | 'reference' | 'date' | 'time' | 'priority'; + content: string; + }>; + }; +} diff --git a/packages/shared-ui/src/index.ts b/packages/shared-ui/src/index.ts index 1915e966c..8548ed304 100644 --- a/packages/shared-ui/src/index.ts +++ b/packages/shared-ui/src/index.ts @@ -2,8 +2,8 @@ export { Text, Button, Badge, Card } from './atoms'; // Molecules -export { Toggle, Input, Select, Textarea, Checkbox } from './molecules'; -export type { SelectOption } from './molecules'; +export { Toggle, Input, Select, Textarea, Checkbox, FilterDropdown } from './molecules'; +export type { SelectOption, FilterDropdownOption } from './molecules'; // Stats export { GlassCard, StatRow } from './molecules'; @@ -45,6 +45,9 @@ export { ContactAvatar, ContactBadge, ContactSelector } from './molecules'; // Layout export { ModalFooter, DataCard, PageHeader, KeyboardShortcutsPanel } from './molecules'; +// Confirmation (inline popover) +export { ConfirmationPopover } from './molecules'; + // Organisms export { Modal, ConfirmationModal, FormModal, AppSlider } from './organisms'; export type { AppItem } from './organisms'; @@ -85,6 +88,7 @@ export { PillToolbar, PillToolbarButton, PillToolbarDivider, + ExpandableToolbar, } from './navigation'; export type { NavItem, @@ -97,6 +101,8 @@ export type { PillNavElement, PillNavigationProps, PillTabOption, + PillTabGroupConfig, + ExpandableToolbarProps, } from './navigation'; // Settings @@ -119,8 +125,28 @@ export { CommandBar } from './command-bar'; export type { CommandBarItem } from './command-bar'; // Input Bar -export { InputBar, QuickInputBar } from './quick-input'; -export type { QuickInputItem, QuickAction, CreatePreview } from './quick-input'; +export { + InputBar, + QuickInputBar, + InputBarContextMenu, + InputBarHelpModal, + // Recent history + getRecentTags, + getRecentReferences, + addRecentTag, + addRecentReference, + extractAndSaveFromInput, + clearRecentHistory, + createRecentInputHistoryStore, + // Settings + loadInputBarSettings, + saveInputBarSettings, + updateInputBarSetting, + resetInputBarSettings, + createInputBarSettingsStore, + getInputBarSettingsStore, +} from './quick-input'; +export type { QuickInputItem, QuickAction, CreatePreview, InputBarSettings } from './quick-input'; // Pages export { default as AppsPage } from './pages/AppsPage.svelte'; @@ -143,3 +169,29 @@ export type { DonutSegment, ProgressItem, } from './charts'; + +// Context Menu +export { ContextMenu, createContextMenuState } from './context-menu'; +export type { ContextMenuItem, ContextMenuState } from './context-menu'; + +// Help Components +export { + HelpModal, + KeyboardShortcutsPanel as HelpKeyboardShortcutsPanel, + SyntaxHelpPanel, + COMMON_SHORTCUTS, + COMMON_SYNTAX, + DEFAULT_LIVE_EXAMPLE, +} from './help'; +export type { + KeyboardShortcut as HelpKeyboardShortcut, + ShortcutCategory, + SyntaxColor, + SyntaxExample, + SyntaxPattern, + SyntaxGroup, + HelpModalConfig, +} from './help'; + +// Immersive Mode +export { default as ImmersiveModeToggle } from './components/ImmersiveModeToggle.svelte'; diff --git a/packages/shared-ui/src/molecules/ConfirmationPopover.svelte b/packages/shared-ui/src/molecules/ConfirmationPopover.svelte new file mode 100644 index 000000000..1acabb8e0 --- /dev/null +++ b/packages/shared-ui/src/molecules/ConfirmationPopover.svelte @@ -0,0 +1,407 @@ + + + + + + +
+ {@render children()} +
+ + +{#if visible} + +{/if} + + diff --git a/packages/shared-ui/src/molecules/FilterDropdown.svelte b/packages/shared-ui/src/molecules/FilterDropdown.svelte new file mode 100644 index 000000000..5c6063e0a --- /dev/null +++ b/packages/shared-ui/src/molecules/FilterDropdown.svelte @@ -0,0 +1,716 @@ + + +
+ + + + {#if isOpen} + + + + +
+ + {#if showSearch} +
+ + + + + {#if searchQuery} + + {/if} +
+ {/if} + + +
+ {#if filteredOptions.length === 0} +
Keine Ergebnisse
+ {:else} + {#each [...groupedOptions] as [groupName, groupOptions], groupIndex} + {#if groupName} +
{groupName}
+ {/if} + {#each groupOptions as option, optionIndex} + {@const flatIndex = selectableOptions.indexOf(option)} + + {/each} + {/each} + {/if} +
+ + + {#if multiSelect && Array.isArray(value) && value.length > 0} + + {/if} +
+ {/if} +
+ + diff --git a/packages/shared-ui/src/molecules/FilterDropdown.types.ts b/packages/shared-ui/src/molecules/FilterDropdown.types.ts new file mode 100644 index 000000000..39e45266c --- /dev/null +++ b/packages/shared-ui/src/molecules/FilterDropdown.types.ts @@ -0,0 +1,8 @@ +export interface FilterDropdownOption { + value: string; + label: string; + icon?: string; + disabled?: boolean; + divider?: boolean; + group?: string; +} diff --git a/packages/shared-ui/src/molecules/index.ts b/packages/shared-ui/src/molecules/index.ts index 1a7f23173..25d193845 100644 --- a/packages/shared-ui/src/molecules/index.ts +++ b/packages/shared-ui/src/molecules/index.ts @@ -3,7 +3,9 @@ export { default as Input } from './Input.svelte'; export { default as Select } from './Select.svelte'; export { default as Textarea } from './Textarea.svelte'; export { default as Checkbox } from './Checkbox.svelte'; +export { default as FilterDropdown } from './FilterDropdown.svelte'; export type { SelectOption } from './Select.types'; +export type { FilterDropdownOption } from './FilterDropdown.types'; // Stats components export { GlassCard, StatRow } from './stats'; @@ -47,3 +49,6 @@ export { default as ModalFooter } from './ModalFooter.svelte'; export { default as DataCard } from './DataCard.svelte'; export { default as PageHeader } from './PageHeader.svelte'; export { default as KeyboardShortcutsPanel } from './KeyboardShortcutsPanel.svelte'; + +// Confirmation +export { default as ConfirmationPopover } from './ConfirmationPopover.svelte'; diff --git a/packages/shared-ui/src/navigation/PillNavigation.svelte b/packages/shared-ui/src/navigation/PillNavigation.svelte index 787436eb6..2eee4ccff 100644 --- a/packages/shared-ui/src/navigation/PillNavigation.svelte +++ b/packages/shared-ui/src/navigation/PillNavigation.svelte @@ -200,6 +200,8 @@ showThemeToggle?: boolean; /** Primary color for active state (CSS custom property or hex) */ primaryColor?: string; + /** Elements to prepend before nav items (tab groups, dividers, nav items) */ + prependElements?: PillNavElement[]; /** Additional elements (tab groups, dividers) to show after nav items */ elements?: PillNavElement[]; /** Show logout button */ @@ -269,6 +271,7 @@ showLanguageSwitcher = false, showThemeToggle = true, primaryColor, + prependElements = [], elements = [], showLogout = true, themeVariantItems = [], @@ -495,34 +498,99 @@ {/if} + + {#each prependElements as element} + {#if isTabGroup(element)} + + {:else if isDivider(element)} +
+ {:else if isNavItem(element)} + + {#if element.icon} + {#if phosphorIcons[element.icon]} + {@const IconComponent = phosphorIcons[element.icon]} + + {:else} + + + + {/if} + {/if} + {element.label} + + {/if} + {/each} + {#each items as item} - - {#if item.icon} - {#if item.icon === 'mana'} - - - - {:else if item.iconSvg} - {@html item.iconSvg} - {:else if phosphorIcons[item.icon]} - {@const IconComponent = phosphorIcons[item.icon]} - - {:else} - - - + {#if item.onClick} + + {:else} + + {#if item.icon} + {#if item.icon === 'mana'} + + + + {:else if item.iconSvg} + {@html item.iconSvg} + {:else if phosphorIcons[item.icon]} + {@const IconComponent = phosphorIcons[item.icon]} + + {:else} + + + + {/if} + {/if} + {item.label} + + {/if} {/each} @@ -533,6 +601,7 @@ value={element.value} onChange={element.onChange} sectionLabel={element.sectionLabel} + onContextMenu={element.onContextMenu} {isSidebarMode} {primaryColor} /> diff --git a/packages/shared-ui/src/navigation/PillTabGroup.svelte b/packages/shared-ui/src/navigation/PillTabGroup.svelte index 503acb3d7..d857f4db2 100644 --- a/packages/shared-ui/src/navigation/PillTabGroup.svelte +++ b/packages/shared-ui/src/navigation/PillTabGroup.svelte @@ -14,6 +14,8 @@ isSidebarMode?: boolean; /** Primary color for active state */ primaryColor?: string; + /** Called on right-click (context menu) - receives click coordinates */ + onContextMenu?: (x: number, y: number) => void; } let { @@ -23,8 +25,16 @@ sectionLabel, isSidebarMode = false, primaryColor, + onContextMenu, }: Props = $props(); + function handleContextMenu(event: MouseEvent) { + if (onContextMenu) { + event.preventDefault(); + onContextMenu(event.clientX, event.clientY); + } + } + // Icon SVG paths (same as PillNavigation) const icons: Record = { list: 'M4 6h16M4 10h16M4 14h16M4 18h16', @@ -38,6 +48,10 @@ fire: 'M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z', trending: 'M13 7h8m0 0v8m0-8l-8 8-4-4-6 6', single: 'M4 6h16M4 12h16M4 18h16', + calendar: + 'M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z', + 'check-square': + 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4', }; function getIconPath(name: string): string { @@ -51,7 +65,8 @@ } -
+ +
{#if sectionLabel && isSidebarMode} {/if} @@ -86,7 +101,7 @@ {/if} {/if} - {#if option.label && isSidebarMode} + {#if option.label} {option.label} {/if} diff --git a/packages/shared-ui/src/navigation/PillTimeRangeSelector.svelte b/packages/shared-ui/src/navigation/PillTimeRangeSelector.svelte index 2ea43fbb6..99c7ff773 100644 --- a/packages/shared-ui/src/navigation/PillTimeRangeSelector.svelte +++ b/packages/shared-ui/src/navigation/PillTimeRangeSelector.svelte @@ -268,34 +268,22 @@ } .glass-pill { - background: rgba(255, 255, 255, 0.85); + background: hsl(var(--color-surface) / 0.85); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); - border: 1px solid rgba(0, 0, 0, 0.1); + border: 1px solid hsl(var(--color-border)); box-shadow: - 0 4px 6px -1px rgba(0, 0, 0, 0.1), - 0 2px 4px -1px rgba(0, 0, 0, 0.06); - color: #374151; - } - - :global(.dark) .glass-pill { - background: rgba(255, 255, 255, 0.12); - border: 1px solid rgba(255, 255, 255, 0.15); - color: #f3f4f6; + 0 4px 6px -1px hsl(var(--color-foreground) / 0.1), + 0 2px 4px -1px hsl(var(--color-foreground) / 0.06); + color: hsl(var(--color-foreground)); } .glass-pill:hover { - background: rgba(255, 255, 255, 0.95); - border-color: rgba(0, 0, 0, 0.15); + background: hsl(var(--color-surface) / 0.95); transform: translateY(-1px); box-shadow: - 0 10px 15px -3px rgba(0, 0, 0, 0.1), - 0 4px 6px -2px rgba(0, 0, 0, 0.05); - } - - :global(.dark) .glass-pill:hover { - background: rgba(255, 255, 255, 0.2); - border-color: rgba(255, 255, 255, 0.25); + 0 10px 15px -3px hsl(var(--color-foreground) / 0.1), + 0 4px 6px -2px hsl(var(--color-foreground) / 0.05); } .pill-icon { @@ -322,42 +310,23 @@ cursor: pointer; transition: all 0.15s ease; background: transparent; - color: #374151; - } - - :global(.dark) .embedded-btn { - color: #f3f4f6; + color: hsl(var(--color-foreground)); } .embedded-btn:hover { - background: rgba(0, 0, 0, 0.05); - } - - :global(.dark) .embedded-btn:hover { - background: rgba(255, 255, 255, 0.1); + background: hsl(var(--color-foreground) / 0.05); } /* Active state for toggle mode */ .embedded-btn.active { - background: color-mix(in srgb, var(--color-primary-500, #3b82f6) 15%, transparent 85%); - color: var(--color-primary-500, #3b82f6); - } - - :global(.dark) .embedded-btn.active { - background: color-mix(in srgb, var(--color-primary-500, #3b82f6) 25%, transparent 75%); - color: var(--color-primary-400, #60a5fa); + background: hsl(var(--color-primary) / 0.15); + color: hsl(var(--color-primary)); } .glass-pill.active { - background: color-mix(in srgb, var(--color-primary-500, #3b82f6) 15%, white 85%); - border-color: var(--color-primary-500, #3b82f6); - color: var(--color-primary-500, #3b82f6); - } - - :global(.dark) .glass-pill.active { - background: color-mix(in srgb, var(--color-primary-500, #3b82f6) 30%, transparent 70%); - border-color: var(--color-primary-400, #60a5fa); - color: var(--color-primary-400, #60a5fa); + background: hsl(var(--color-primary) / 0.15); + border-color: hsl(var(--color-primary)); + color: hsl(var(--color-primary)); } .chevron-icon { @@ -420,18 +389,10 @@ } .glass-dropdown { - background: rgba(255, 255, 255, 0.95); + background: color-mix(in srgb, var(--color-surface-elevated-1) 95%, transparent); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); - border: 1px solid rgba(0, 0, 0, 0.1); - box-shadow: - 0 20px 25px -5px rgba(0, 0, 0, 0.1), - 0 10px 10px -5px rgba(0, 0, 0, 0.04); - } - - :global(.dark) .glass-dropdown { - background: rgba(30, 30, 30, 0.95); - border: 1px solid rgba(255, 255, 255, 0.15); + border: 1px solid hsl(var(--color-border)); } .dropdown-header { @@ -440,13 +401,8 @@ font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; - color: #6b7280; - border-bottom: 1px solid rgba(0, 0, 0, 0.1); - } - - :global(.dark) .dropdown-header { - color: #9ca3af; - border-bottom-color: rgba(255, 255, 255, 0.1); + color: hsl(var(--color-muted-foreground)); + border-bottom: 1px solid hsl(var(--color-border)); } .time-selectors { @@ -467,14 +423,10 @@ font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; - color: #6b7280; + color: hsl(var(--color-muted-foreground)); padding: 0 0.5rem; } - :global(.dark) .column-label { - color: #9ca3af; - } - .hour-list { display: flex; flex-direction: column; @@ -509,32 +461,19 @@ border-radius: 0.5rem; font-size: 0.8125rem; font-weight: 500; - color: #374151; + color: hsl(var(--color-foreground)); cursor: pointer; transition: all 0.15s; text-align: left; } - :global(.dark) .hour-option { - color: #e5e7eb; - } - .hour-option:hover:not(.disabled) { - background: rgba(0, 0, 0, 0.05); - } - - :global(.dark) .hour-option:hover:not(.disabled) { - background: rgba(255, 255, 255, 0.1); + background: hsl(var(--color-foreground) / 0.05); } .hour-option.active { - background: color-mix(in srgb, var(--color-primary-500, #3b82f6) 20%, white 80%); - color: var(--color-primary-500, #3b82f6); - } - - :global(.dark) .hour-option.active { - background: color-mix(in srgb, var(--color-primary-500, #3b82f6) 30%, transparent 70%); - color: var(--color-primary-500, #3b82f6); + background: hsl(var(--color-primary) / 0.2); + color: hsl(var(--color-primary)); } .hour-option.disabled { @@ -544,27 +483,19 @@ .time-divider { width: 1px; - background: rgba(0, 0, 0, 0.1); + background: hsl(var(--color-border)); margin: 0.5rem 0; } - :global(.dark) .time-divider { - background: rgba(255, 255, 255, 0.1); - } - .dropdown-footer { padding: 0.5rem 1rem; - border-top: 1px solid rgba(0, 0, 0, 0.1); + border-top: 1px solid hsl(var(--color-border)); text-align: center; } - :global(.dark) .dropdown-footer { - border-top-color: rgba(255, 255, 255, 0.1); - } - .current-range { font-size: 0.8125rem; font-weight: 600; - color: var(--color-primary-500, #3b82f6); + color: hsl(var(--color-primary)); } diff --git a/packages/shared-ui/src/navigation/PillToolbarButton.svelte b/packages/shared-ui/src/navigation/PillToolbarButton.svelte index 6af067a8f..1676a457b 100644 --- a/packages/shared-ui/src/navigation/PillToolbarButton.svelte +++ b/packages/shared-ui/src/navigation/PillToolbarButton.svelte @@ -49,28 +49,20 @@ border: none; border-radius: 9999px; cursor: pointer; - color: #374151; + color: hsl(var(--color-foreground)); font-size: 0.875rem; font-weight: 500; white-space: nowrap; transition: all 0.15s ease; } - :global(.dark) .toolbar-btn { - color: #f3f4f6; - } - .toolbar-btn:hover:not(:disabled) { - background: rgba(0, 0, 0, 0.05); - } - - :global(.dark) .toolbar-btn:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.1); + background: hsl(var(--color-foreground) / 0.05); } .toolbar-btn.active { - background: color-mix(in srgb, #3b82f6 15%, transparent 85%); - color: #3b82f6; + background: hsl(var(--color-primary) / 0.15); + color: hsl(var(--color-primary)); } .toolbar-btn.icon-only { diff --git a/packages/shared-ui/src/navigation/PillViewSwitcher.svelte b/packages/shared-ui/src/navigation/PillViewSwitcher.svelte index d462852b2..321e0ffd6 100644 --- a/packages/shared-ui/src/navigation/PillViewSwitcher.svelte +++ b/packages/shared-ui/src/navigation/PillViewSwitcher.svelte @@ -21,13 +21,11 @@ value: string; /** Called when view changes */ onChange: (id: string) => void; - /** Primary color for active state */ - primaryColor?: string; /** Embedded mode - no background/border, for use inside a parent bar */ embedded?: boolean; } - let { options, value, onChange, primaryColor = '#3b82f6', embedded = false }: Props = $props(); + let { options, value, onChange, embedded = false }: Props = $props(); let containerRef = $state(null); let indicatorStyle = $state(''); @@ -84,7 +82,6 @@ class="pill-view-switcher" class:glass-pill={!embedded} class:embedded-switcher={embedded} - style="--switcher-primary-color: {primaryColor}" bind:this={containerRef} > @@ -127,18 +124,13 @@ } .glass-pill { - background: rgba(255, 255, 255, 0.85); + background: hsl(var(--color-surface) / 0.85); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); - border: 1px solid rgba(0, 0, 0, 0.1); + border: 1px solid hsl(var(--color-border)); box-shadow: - 0 4px 6px -1px rgba(0, 0, 0, 0.1), - 0 2px 4px -1px rgba(0, 0, 0, 0.06); - } - - :global(.dark) .glass-pill { - background: rgba(255, 255, 255, 0.12); - border: 1px solid rgba(255, 255, 255, 0.15); + 0 4px 6px -1px hsl(var(--color-foreground) / 0.1), + 0 2px 4px -1px hsl(var(--color-foreground) / 0.06); } /* Embedded mode - no background/border */ @@ -156,17 +148,12 @@ top: 0; bottom: 0; border-radius: 9999px; - background: color-mix(in srgb, var(--switcher-primary-color) 15%, white 85%); - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + background: hsl(var(--color-primary) / 0.15); + box-shadow: 0 1px 3px hsl(var(--color-foreground) / 0.1); transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); z-index: 0; } - :global(.dark) .sliding-indicator { - background: color-mix(in srgb, var(--switcher-primary-color) 25%, transparent 75%); - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); - } - .switcher-btn { position: relative; z-index: 1; @@ -179,31 +166,19 @@ border: none; border-radius: 9999px; cursor: pointer; - color: #6b7280; + color: hsl(var(--color-muted-foreground)); font-size: 0.875rem; font-weight: 500; white-space: nowrap; transition: color 0.15s ease; } - :global(.dark) .switcher-btn { - color: #9ca3af; - } - .switcher-btn:hover:not(.disabled) { - color: #374151; - } - - :global(.dark) .switcher-btn:hover:not(.disabled) { - color: #e5e7eb; + color: hsl(var(--color-foreground)); } .switcher-btn.active { - color: var(--switcher-primary-color); - } - - :global(.dark) .switcher-btn.active { - color: var(--switcher-primary-color); + color: hsl(var(--color-primary)); } .switcher-btn.disabled { diff --git a/packages/shared-ui/src/navigation/expandable-toolbar/ExpandableToolbar.svelte b/packages/shared-ui/src/navigation/expandable-toolbar/ExpandableToolbar.svelte new file mode 100644 index 000000000..96a55227b --- /dev/null +++ b/packages/shared-ui/src/navigation/expandable-toolbar/ExpandableToolbar.svelte @@ -0,0 +1,223 @@ + + + +
+ +
+ + +{#if !isCollapsed} +
+
+ {@render children()} + + {#if rightActions} +
+ {@render rightActions()} + {/if} +
+
+{/if} + + diff --git a/packages/shared-ui/src/navigation/expandable-toolbar/index.ts b/packages/shared-ui/src/navigation/expandable-toolbar/index.ts new file mode 100644 index 000000000..db710aed6 --- /dev/null +++ b/packages/shared-ui/src/navigation/expandable-toolbar/index.ts @@ -0,0 +1,2 @@ +export { default as ExpandableToolbar } from './ExpandableToolbar.svelte'; +export type { ExpandableToolbarProps } from './types'; diff --git a/packages/shared-ui/src/navigation/expandable-toolbar/types.ts b/packages/shared-ui/src/navigation/expandable-toolbar/types.ts new file mode 100644 index 000000000..fec554943 --- /dev/null +++ b/packages/shared-ui/src/navigation/expandable-toolbar/types.ts @@ -0,0 +1,28 @@ +import type { Snippet } from 'svelte'; + +export interface ExpandableToolbarProps { + /** Whether the toolbar is collapsed */ + isCollapsed?: boolean; + /** Called when collapsed state changes */ + onCollapsedChange?: (isCollapsed: boolean) => void; + /** Whether in sidebar mode (affects positioning) */ + isSidebarMode?: boolean; + /** Bottom offset from viewport bottom (default: '70px') */ + bottomOffset?: string; + /** Sidebar mode bottom offset (default: '0px') */ + sidebarBottomOffset?: string; + /** Panel height when expanded (default: '70px') */ + panelHeight?: string; + /** FAB tooltip when collapsed */ + collapsedTitle?: string; + /** FAB tooltip when expanded */ + expandedTitle?: string; + /** Custom collapsed icon snippet */ + collapsedIcon?: Snippet; + /** Custom expanded icon snippet */ + expandedIcon?: Snippet; + /** Panel content (required) */ + children: Snippet; + /** Optional right-side content (e.g., layout toggle) */ + rightActions?: Snippet; +} diff --git a/packages/shared-ui/src/navigation/index.ts b/packages/shared-ui/src/navigation/index.ts index 500d8fe2f..e5a8947b8 100644 --- a/packages/shared-ui/src/navigation/index.ts +++ b/packages/shared-ui/src/navigation/index.ts @@ -10,6 +10,8 @@ export { default as PillViewSwitcher } from './PillViewSwitcher.svelte'; export { default as PillToolbar } from './PillToolbar.svelte'; export { default as PillToolbarButton } from './PillToolbarButton.svelte'; export { default as PillToolbarDivider } from './PillToolbarDivider.svelte'; +export { ExpandableToolbar } from './expandable-toolbar'; +export type { ExpandableToolbarProps } from './expandable-toolbar'; export type { NavItem, NavbarProps, diff --git a/packages/shared-ui/src/navigation/types.ts b/packages/shared-ui/src/navigation/types.ts index f10228bce..7128c02b1 100644 --- a/packages/shared-ui/src/navigation/types.ts +++ b/packages/shared-ui/src/navigation/types.ts @@ -14,12 +14,16 @@ export interface KeyboardShortcut { export interface PillNavItem { /** Display label for the navigation item */ label: string; - /** URL to navigate to */ + /** URL to navigate to (ignored if onClick is provided) */ href: string; /** Icon name (predefined) or 'mana' for special mana icon */ icon?: string; /** Custom SVG icon HTML (for custom icons) */ iconSvg?: string; + /** Click handler - if provided, prevents navigation and calls this instead */ + onClick?: () => void; + /** Whether this item is currently active/selected (for toggle buttons) */ + active?: boolean; } export interface PillDropdownItem { @@ -92,6 +96,8 @@ export interface PillTabGroupConfig { onChange: (id: string) => void; /** Optional section label (shown above in sidebar mode) */ sectionLabel?: string; + /** Called on right-click (context menu) - receives click coordinates */ + onContextMenu?: (x: number, y: number) => void; } export interface PillDivider { @@ -137,6 +143,10 @@ export interface PillNavigationProps { showThemeToggle?: boolean; /** Primary color for active state */ primaryColor?: string; + /** Elements to prepend before nav items (tab groups, dividers, nav items) */ + prependElements?: PillNavElement[]; + /** Additional elements to show after nav items (tab groups, dividers) */ + elements?: PillNavElement[]; } export interface NavItem { diff --git a/packages/shared-ui/src/organisms/ConfirmationModal.svelte b/packages/shared-ui/src/organisms/ConfirmationModal.svelte index 6e6988160..33254687f 100644 --- a/packages/shared-ui/src/organisms/ConfirmationModal.svelte +++ b/packages/shared-ui/src/organisms/ConfirmationModal.svelte @@ -162,8 +162,8 @@ onclick={onClose} disabled={loading} class="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-xl font-semibold text-sm - bg-black/5 dark:bg-white/10 text-foreground - hover:bg-black/10 dark:hover:bg-white/20 hover:shadow-md + bg-foreground/5 text-foreground + hover:bg-foreground/10 hover:shadow-md transition-all duration-200 hover:-translate-y-0.5 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0" > diff --git a/packages/shared-ui/src/organisms/FormModal.svelte b/packages/shared-ui/src/organisms/FormModal.svelte index bd359a999..3bf472fbf 100644 --- a/packages/shared-ui/src/organisms/FormModal.svelte +++ b/packages/shared-ui/src/organisms/FormModal.svelte @@ -88,10 +88,8 @@
{#if error} -
- +
+ {error}
diff --git a/packages/shared-ui/src/organisms/Modal.svelte b/packages/shared-ui/src/organisms/Modal.svelte index 58dfa6f91..efeeec703 100644 --- a/packages/shared-ui/src/organisms/Modal.svelte +++ b/packages/shared-ui/src/organisms/Modal.svelte @@ -53,7 +53,8 @@
e.key === 'Enter' && handleBackdropClick(e as unknown as MouseEvent)} role="dialog" @@ -65,15 +66,13 @@
e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} > {#if showHeader} -
+
{#if icon} {@render icon()} @@ -86,7 +85,7 @@
- {/if} -
+ {#if showSearch} +
+ + + {#if searchInput} + + {/if} +
+ {/if} {#if tags.length > 0 || subtitles.length > 0} diff --git a/packages/shared-ui/src/quick-input/InputBar.svelte b/packages/shared-ui/src/quick-input/InputBar.svelte index 36a1da80d..707a9acad 100644 --- a/packages/shared-ui/src/quick-input/InputBar.svelte +++ b/packages/shared-ui/src/quick-input/InputBar.svelte @@ -2,6 +2,11 @@ import { onMount } from 'svelte'; import { slide } from 'svelte/transition'; import type { QuickInputItem, CreatePreview } from './types'; + import InputBarContextMenu from './InputBarContextMenu.svelte'; + import { getInputBarSettingsStore } from './inputBarSettings.svelte'; + + // Settings store + const settingsStore = getInputBarSettingsStore(); // Syntax highlighting patterns for command keywords interface HighlightPattern { @@ -44,6 +49,11 @@ return result; } + interface DefaultOption { + id: string; + label: string; + } + interface Props { onSearch: (query: string) => Promise; onSelect: (item: QuickInputItem) => void; @@ -55,10 +65,26 @@ searchingText?: string; createText?: string; appIcon?: string; - primaryColor?: string; - autoFocus?: boolean; /** Bottom offset from viewport bottom (default: '70px') */ bottomOffset?: string; + /** Whether to leave space for a FAB button on the right side on mobile (default: false) */ + hasFabRight?: boolean; + /** Whether to leave space for a FAB button on the left side on mobile (default: false) */ + hasFabLeft?: boolean; + /** Enable context menu on right-click (default: true) */ + enableContextMenu?: boolean; + /** App-specific default options for context menu (e.g., calendars) */ + defaultOptions?: DefaultOption[]; + /** Currently selected default option ID */ + selectedDefaultId?: string; + /** Label for the default option selector (e.g., "Standard-Kalender") */ + defaultOptionLabel?: string; + /** Callback when default option changes */ + onDefaultChange?: (id: string) => void; + /** Callback to show keyboard shortcuts help */ + onShowShortcuts?: () => void; + /** Callback to show syntax help */ + onShowSyntaxHelp?: () => void; } let { @@ -72,11 +98,21 @@ searchingText = 'Suche...', createText = 'Erstellen', appIcon = 'search', - primaryColor = '#8b5cf6', - autoFocus = true, bottomOffset = '70px', + hasFabRight = false, + hasFabLeft = false, + enableContextMenu = true, + defaultOptions = [], + selectedDefaultId, + defaultOptionLabel = 'Standard-Kalender', + onDefaultChange, + onShowShortcuts, + onShowSyntaxHelp, }: Props = $props(); + // Use settings for autoFocus + let effectiveAutoFocus = $derived(settingsStore.autoFocus); + let searchQuery = $state(''); let results = $state([]); let loading = $state(false); @@ -87,13 +123,20 @@ let searchTimeout: ReturnType; let inputElement = $state(null); + // Context menu state + let contextMenuVisible = $state(false); + let contextMenuX = $state(0); + let contextMenuY = $state(0); + // Computed create preview let createPreview = $derived( searchQuery.trim() && onParseCreate ? onParseCreate(searchQuery) : null ); - // Highlighted text for overlay - let highlightedQuery = $derived(highlightText(searchQuery)); + // Highlighted text for overlay (respects syntax highlighting setting) + let highlightedQuery = $derived( + settingsStore.syntaxHighlighting ? highlightText(searchQuery) : searchQuery + ); // Check if create option is selected (it's always first when available) let isCreateSelected = $derived(selectedIndex === 0 && createPreview !== null); @@ -103,13 +146,19 @@ showPanel = isFocused && searchQuery.trim().length > 0; }); - // Auto-focus on mount + // Auto-focus on mount (respects autoFocus setting) onMount(() => { - if (autoFocus) { + if (effectiveAutoFocus) { setTimeout(() => inputElement?.focus(), 100); } }); + // Handler for settings changes (to trigger re-render) + function handleSettingsChange() { + // Force reactivity update by accessing the store + settingsStore.refresh(); + } + async function handleSearch() { clearTimeout(searchTimeout); @@ -240,11 +289,29 @@ isFocused = false; }, 150); } + + // Context menu handlers + function handleContextMenu(event: MouseEvent) { + if (!enableContextMenu) return; + + event.preventDefault(); + event.stopPropagation(); + + contextMenuX = event.clientX; + contextMenuY = event.clientY; + contextMenuVisible = true; + } + + function handleContextMenuClose() { + contextMenuVisible = false; + }
{#if showPanel} @@ -334,7 +401,8 @@ {/if} -
+ +
{#if appIcon === 'check-square' || appIcon === 'todo'} @@ -412,6 +480,21 @@ {/if}
+ + +