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/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/calendar/CalendarToolbar.svelte b/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbar.svelte index a583fd6c1..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,11 +1,12 @@ + +
+
+ + + + {#if eventTagsStore.loading} +
Lädt...
+ {:else if !hasTags} + + {:else} + {#each sortedTags as tag (tag.id)} + + {/each} + + + + {/if} +
+
+ + + + + diff --git a/apps/calendar/apps/web/src/lib/components/calendar/TagStripModal.svelte b/apps/calendar/apps/web/src/lib/components/calendar/TagStripModal.svelte new file mode 100644 index 000000000..6a4e6ea3a --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/calendar/TagStripModal.svelte @@ -0,0 +1,1463 @@ + + + + +{#if visible} + + + + + + + +{/if} + + diff --git a/apps/calendar/apps/web/src/lib/components/calendar/ViewCarousel.svelte b/apps/calendar/apps/web/src/lib/components/calendar/ViewCarousel.svelte index 816221391..8ea78cb75 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/ViewCarousel.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/ViewCarousel.svelte @@ -440,5 +440,8 @@ .carousel-page.current { /* Always interactive */ pointer-events: auto; + /* Enable vertical scrolling for keyboard navigation */ + overflow-y: auto; + overflow-x: hidden; } diff --git a/apps/calendar/apps/web/src/lib/components/calendar/ViewModePill.svelte b/apps/calendar/apps/web/src/lib/components/calendar/ViewModePill.svelte index 56b33226c..e5f272d25 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/ViewModePill.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/ViewModePill.svelte @@ -7,9 +7,10 @@ interface Props { isSidebarMode?: boolean; isToolbarExpanded?: boolean; + isMobile?: boolean; } - let { isSidebarMode = false, isToolbarExpanded = false }: Props = $props(); + let { isSidebarMode = false, isToolbarExpanded = false, isMobile = false }: Props = $props(); let contextMenu: ViewModePillContextMenu; @@ -55,6 +56,7 @@ class="view-mode-pill" class:sidebar-mode={isSidebarMode} class:toolbar-expanded={isToolbarExpanded} + class:mobile={isMobile} oncontextmenu={handleContextMenu} > {#each enabledViews as view} @@ -168,6 +170,39 @@ } } + /* Mobile: ViewModePill moves above InputBar as its own row */ + /* InputBar is at bottom: 70px (above PillNav), so controls go above that */ + .view-mode-pill.mobile { + /* Position centered above InputBar */ + right: auto; + left: 50%; + transform: translateX(-50%); + /* Above PillNav (70px) + InputBar (72px) + gap (8px) */ + bottom: calc(70px + 72px + 8px + env(safe-area-inset-bottom, 0px)); + } + + .view-mode-pill.mobile.toolbar-expanded { + /* Move up when toolbar is expanded (add toolbar height 70px) */ + bottom: calc(70px + 72px + 70px + 8px + env(safe-area-inset-bottom, 0px)); + } + + /* Fallback for CSS-only mobile detection */ + @media (max-width: 640px) { + .view-mode-pill:not(.mobile) { + /* Position centered above InputBar */ + right: auto; + left: 50%; + transform: translateX(-50%); + /* Above PillNav (70px) + InputBar (72px) + gap (8px) */ + bottom: calc(70px + 72px + 8px + env(safe-area-inset-bottom, 0px)); + } + + .view-mode-pill:not(.mobile).toolbar-expanded { + /* Move up when toolbar is expanded (add toolbar height 70px) */ + bottom: calc(70px + 72px + 70px + 8px + env(safe-area-inset-bottom, 0px)); + } + } + .view-btn { display: flex; align-items: center; diff --git a/apps/calendar/apps/web/src/lib/components/tags/GroupedTagList.svelte b/apps/calendar/apps/web/src/lib/components/tags/GroupedTagList.svelte new file mode 100644 index 000000000..08de1d1d4 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/tags/GroupedTagList.svelte @@ -0,0 +1,245 @@ + + +{#if loading} +
+
+
+{:else if totalTags === 0} +
+
{emptyMessage}
+
+{:else} +
+ + {#each groups as group (group.id)} + {@const groupTags = getTagsForGroup(group.id)} + {#if groupTags.length > 0} +
+ + + {/if} + + + + {#if isExpanded(group.id)} +
+ {#each groupTags as tag (tag.id)} + + {/each} +
+ {/if} +
+ {/if} + {/each} + + + {#if hasUngroupedTags} +
+ + + + + {#if isExpanded(null)} +
+ {#each ungroupedTags as tag (tag.id)} + + {/each} +
+ {/if} +
+ {/if} +
+{/if} + + diff --git a/apps/calendar/apps/web/src/lib/components/tags/TagGroupEditModal.svelte b/apps/calendar/apps/web/src/lib/components/tags/TagGroupEditModal.svelte new file mode 100644 index 000000000..1d55cfb27 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/tags/TagGroupEditModal.svelte @@ -0,0 +1,123 @@ + + + +
+ +
+ +
+ + +
+ Farbe + (color = c)} /> +
+ + +
+ Vorschau +
+ +
+
+ + + {#if isEditing && group?.tagCount !== undefined && group.tagCount > 0} +
+ {group.tagCount} + {group.tagCount === 1 ? 'Tag' : 'Tags'} in dieser Gruppe +
+ {/if} +
+ + {#snippet footer()} +
+
+ {#if onDelete && isEditing} + + {/if} +
+
+ + +
+
+ {/snippet} +
diff --git a/apps/calendar/apps/web/src/lib/components/tags/index.ts b/apps/calendar/apps/web/src/lib/components/tags/index.ts new file mode 100644 index 000000000..86c399062 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/tags/index.ts @@ -0,0 +1,2 @@ +export { default as TagGroupEditModal } from './TagGroupEditModal.svelte'; +export { default as GroupedTagList } from './GroupedTagList.svelte'; diff --git a/apps/calendar/apps/web/src/lib/stores/event-tag-groups.svelte.ts b/apps/calendar/apps/web/src/lib/stores/event-tag-groups.svelte.ts new file mode 100644 index 000000000..ce186ce3c --- /dev/null +++ b/apps/calendar/apps/web/src/lib/stores/event-tag-groups.svelte.ts @@ -0,0 +1,151 @@ +/** + * Event Tag Groups Store - Manages tag groups using Svelte 5 runes + */ + +import type { EventTagGroup } from '@calendar/shared'; +import * as api from '$lib/api/event-tag-groups'; + +// State +let groups = $state([]); +let ungroupedTagCount = $state(0); +let loading = $state(false); +let error = $state(null); + +// Helper to safely get groups array (Svelte 5 runes safety) +function getGroupsArray(): EventTagGroup[] { + const arr = groups ?? []; + return Array.isArray(arr) ? arr : []; +} + +export const eventTagGroupsStore = { + // Getters + get groups() { + return groups; + }, + get ungroupedTagCount() { + return ungroupedTagCount; + }, + get loading() { + return loading; + }, + get error() { + return error; + }, + + /** + * Fetch all groups + */ + async fetchGroups() { + loading = true; + error = null; + + const result = await api.getEventTagGroups(); + + if (result.error) { + error = result.error.message; + groups = []; + ungroupedTagCount = 0; + } else { + groups = result.data || []; + ungroupedTagCount = result.ungroupedTagCount; + } + + loading = false; + return result; + }, + + /** + * Create a new group + */ + async createGroup(data: api.CreateEventTagGroupInput) { + const result = await api.createEventTagGroup(data); + + if (result.data) { + groups = [...groups, result.data]; + } + + return result; + }, + + /** + * Update a group + */ + async updateGroup(id: string, data: api.UpdateEventTagGroupInput) { + const result = await api.updateEventTagGroup(id, data); + + if (result.data) { + groups = getGroupsArray().map((g) => (g.id === id ? result.data! : g)); + } + + return result; + }, + + /** + * Delete a group + */ + async deleteGroup(id: string) { + const result = await api.deleteEventTagGroup(id); + + if (!result.error) { + groups = getGroupsArray().filter((g) => g.id !== id); + } + + return result; + }, + + /** + * Get group by ID + */ + getById(id: string) { + return getGroupsArray().find((g) => g.id === id); + }, + + /** + * Clear store + */ + clear() { + groups = []; + ungroupedTagCount = 0; + error = null; + }, + + /** + * Update tag count for a group (after tag assignment changes) + */ + updateTagCount(groupId: string | null, delta: number) { + if (groupId === null) { + ungroupedTagCount = Math.max(0, ungroupedTagCount + delta); + } else { + groups = getGroupsArray().map((g) => { + if (g.id === groupId) { + return { ...g, tagCount: Math.max(0, (g.tagCount ?? 0) + delta) }; + } + return g; + }); + } + }, + + /** + * Reorder groups by providing new array order + */ + async reorderGroups(groupIds: string[]) { + // Optimistic update + const oldGroups = [...groups]; + groups = groupIds.map((id, i) => { + const g = getGroupsArray().find((g) => g.id === id)!; + return { ...g, sortOrder: i }; + }); + + const result = await api.reorderEventTagGroups(groupIds); + + if (result.error) { + // Rollback on error + groups = oldGroups; + } else if (result.data) { + groups = result.data; + ungroupedTagCount = result.ungroupedTagCount; + } + + return result; + }, +}; diff --git a/apps/calendar/apps/web/src/lib/stores/event-tags.svelte.ts b/apps/calendar/apps/web/src/lib/stores/event-tags.svelte.ts index 7bfe1960c..d7f2a4eec 100644 --- a/apps/calendar/apps/web/src/lib/stores/event-tags.svelte.ts +++ b/apps/calendar/apps/web/src/lib/stores/event-tags.svelte.ts @@ -1,11 +1,10 @@ /** * Event Tags Store - Manages event tags using Svelte 5 runes * - * Uses the central Tags API from mana-core-auth. Tags are now unified - * across all Manacore apps (Todo, Calendar, Contacts). + * Uses the Calendar Backend API which supports tag groups (groupId). */ -import type { EventTag } from '$lib/api/event-tags'; +import type { EventTag } from '@calendar/shared'; import * as api from '$lib/api/event-tags'; // State @@ -111,4 +110,27 @@ export const eventTagsStore = { tags = []; error = null; }, + + /** + * Get tags grouped by groupId + * Returns a Map where keys are groupId (or null for ungrouped) + */ + getGroupedTags(): Map { + const grouped = new Map(); + + for (const tag of getTagsArray()) { + const groupId = tag.groupId ?? null; + const existing = grouped.get(groupId) ?? []; + grouped.set(groupId, [...existing, tag]); + } + + return grouped; + }, + + /** + * Get tags by group ID (null for ungrouped) + */ + getTagsByGroup(groupId: string | null): EventTag[] { + return getTagsArray().filter((t) => (t.groupId ?? null) === groupId); + }, }; diff --git a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte index 7d22c42d0..47e13dd9f 100644 --- a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte @@ -656,8 +656,10 @@ onCollapsedChange={handleToolbarCollapsedChange} /> {/if} + {/if} - + +
- {/if} +
- - {#if settingsStore.sidebarCollapsed} - - {/if} -
@@ -179,6 +158,8 @@ display: flex; gap: 1.5rem; width: 100%; + flex: 1; + min-height: 0; position: relative; } @@ -238,59 +219,13 @@ color: hsl(var(--color-foreground)); } - /* FAB container */ - .sidebar-fab { - position: fixed; - left: 1rem; - bottom: 1rem; - flex-direction: column; - gap: 0.5rem; - z-index: 50; - animation: fab-slide-in 300ms cubic-bezier(0.4, 0, 0.2, 1); - transition: left 300ms cubic-bezier(0.4, 0, 0.2, 1); - } - - .sidebar-fab.pill-sidebar { - left: 195px; - } - - @keyframes fab-slide-in { - from { - opacity: 0; - transform: translateX(-20px) scale(0.8); - } - to { - opacity: 1; - transform: translateX(0) scale(1); - } - } - - .fab-expand { - width: 48px; - height: 48px; - border-radius: var(--radius-full); - border: none; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all 150ms ease; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - background: hsl(var(--color-surface)); - color: hsl(var(--color-foreground)); - border: 1px solid hsl(var(--color-border)); - } - - .fab-expand:hover { - background: hsl(var(--color-muted)); - transform: scale(1.05); - } - .calendar-main { flex: 1; display: flex; flex-direction: column; min-width: 0; + min-height: 0; + overflow: hidden; background: hsl(var(--color-surface)); border-radius: var(--radius-lg); border: 1px solid hsl(var(--color-border)); @@ -304,6 +239,8 @@ .calendar-content { flex: 1; + min-height: 0; + overflow: hidden; } /* Mobile: Bottom Todo Section */ 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/packages/shared/src/types/event.ts b/apps/calendar/packages/shared/src/types/event.ts index ef42258ed..482c82444 100644 --- a/apps/calendar/packages/shared/src/types/event.ts +++ b/apps/calendar/packages/shared/src/types/event.ts @@ -32,6 +32,20 @@ export interface ResponsiblePerson { 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 */ @@ -40,6 +54,8 @@ export interface EventTag { userId: string; name: string; color: string; + groupId?: string | null; + sortOrder?: number; createdAt: Date | string; updatedAt: Date | string; } diff --git a/packages/shared-ui/src/navigation/PillNavigation.svelte b/packages/shared-ui/src/navigation/PillNavigation.svelte index 9c615a62f..2eee4ccff 100644 --- a/packages/shared-ui/src/navigation/PillNavigation.svelte +++ b/packages/shared-ui/src/navigation/PillNavigation.svelte @@ -536,32 +536,61 @@ {#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} diff --git a/packages/shared-ui/src/navigation/types.ts b/packages/shared-ui/src/navigation/types.ts index bb057ebdc..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 {