Feat: Tagmodal, fullscreenmode, tag groups, onepage design philosophy

This commit is contained in:
Till-JS 2025-12-15 03:37:01 +01:00
parent 893c6ef0fb
commit bc0eecac95
36 changed files with 3542 additions and 343 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,7 @@
import { IsArray, IsUUID } from 'class-validator';
export class ReorderEventTagGroupsDto {
@IsArray()
@IsUUID('4', { each: true })
groupIds!: string[];
}

View file

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

View file

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

View file

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

View file

@ -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<EventTagGroup[]> {
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<EventTagGroup[]> {
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<EventTagGroup | null> {
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<EventTagGroup> {
// 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<Omit<NewEventTagGroup, 'userId'>>
): Promise<EventTagGroup> {
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<void> {
// 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<number> {
const tags = await this.db.select().from(eventTags).where(eq(eventTags.groupId, groupId));
return tags.length;
}
async getTagCountsForUser(userId: string): Promise<Map<string | null, number>> {
const tags = await this.db.select().from(eventTags).where(eq(eventTags.userId, userId));
const counts = new Map<string | null, number>();
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<EventTagGroup[]> {
// 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);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<typeof createTagsClient> | null = null;
function getTagsClient() {
if (!browser) return null;
if (!_tagsClient) {
_tagsClient = createTagsClient({
authUrl: getAuthUrl(),
getToken: async () => {
const token = await authStore.getAccessToken();
return token || '';
},
});
}
return _tagsClient;
export 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;
}

View file

@ -1,11 +1,12 @@
<script lang="ts">
import { ExpandableToolbar } from '@manacore/shared-ui';
import CalendarToolbarContent from './CalendarToolbarContent.svelte';
import ViewModePill from './ViewModePill.svelte';
interface Props {
isSidebarMode?: boolean;
isCollapsed?: boolean;
isMobile?: boolean;
bottomOffset?: string;
onModeChange?: (isSidebar: boolean) => void;
onCollapsedChange?: (isCollapsed: boolean) => void;
}
@ -13,6 +14,8 @@
let {
isSidebarMode = false,
isCollapsed = true,
isMobile = false,
bottomOffset = '70px',
onModeChange,
onCollapsedChange,
}: Props = $props();
@ -26,6 +29,7 @@
{isCollapsed}
{onCollapsedChange}
{isSidebarMode}
{bottomOffset}
collapsedTitle="Kalender-Optionen"
expandedTitle="Schließen"
>
@ -60,11 +64,6 @@
{/snippet}
</ExpandableToolbar>
<!-- View Mode Pill - positioned to the left of the FAB -->
{#if !isSidebarMode}
<ViewModePill {isSidebarMode} isToolbarExpanded={!isCollapsed} />
{/if}
<style>
/* Layout toggle button - app-specific style */
.layout-btn {

View file

@ -26,16 +26,13 @@
contextMenu?.show(e.clientX, e.clientY);
}
function handleMinimize() {
settingsStore.set('dateStripCollapsed', true);
}
interface Props {
isSidebarMode?: boolean;
isToolbarExpanded?: boolean;
hasTagStrip?: boolean; // Whether TagStrip is visible below
}
let { isSidebarMode = false, isToolbarExpanded = false }: Props = $props();
let { isSidebarMode = false, isToolbarExpanded = false, hasTagStrip = false }: Props = $props();
// Get event count for a day (max 5 dots displayed)
function getEventCount(date: Date): number {
@ -251,14 +248,8 @@
class:sidebar-mode={isSidebarMode}
class:toolbar-expanded={isToolbarExpanded}
class:compact={settingsStore.dateStripCompact}
class:has-tag-strip={hasTagStrip}
>
<!-- Minimize button at left edge -->
<button class="minimize-btn" onclick={handleMinimize} title="Datumsleiste minimieren">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="20" height="20">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="date-strip-container" oncontextmenu={handleContextMenu}>
<!-- Month label -->
@ -358,6 +349,23 @@
bottom: calc(140px + env(safe-area-inset-bottom, 0px));
}
/* When TagStrip is visible below, add extra offset */
.date-strip-wrapper.has-tag-strip {
bottom: calc(210px + env(safe-area-inset-bottom, 0px)); /* +70px for TagStrip */
}
.date-strip-wrapper.has-tag-strip.toolbar-expanded {
bottom: calc(280px + env(safe-area-inset-bottom, 0px));
}
.date-strip-wrapper.has-tag-strip.sidebar-mode {
bottom: calc(140px + env(safe-area-inset-bottom, 0px));
}
.date-strip-wrapper.has-tag-strip.sidebar-mode.toolbar-expanded {
bottom: calc(210px + env(safe-area-inset-bottom, 0px));
}
.today-button {
position: absolute;
right: 100%;
@ -725,47 +733,4 @@
.date-strip-wrapper.compact .today-date {
font-size: 0.625rem;
}
/* Minimize button */
.minimize-btn {
position: absolute;
left: 0.5rem;
bottom: 34%;
transform: translateY(50%);
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: transparent;
border: none;
border-radius: 8px;
cursor: pointer;
color: hsl(var(--color-muted-foreground));
pointer-events: auto;
transition: all 0.15s ease;
z-index: 10;
}
.minimize-btn:hover {
background: hsl(var(--color-muted) / 0.8);
color: hsl(var(--color-foreground));
}
.minimize-btn:active {
transform: translateY(50%) scale(0.95);
}
@media (max-width: 640px) {
.minimize-btn {
left: 0.25rem;
width: 32px;
height: 32px;
}
.minimize-btn svg {
width: 18px;
height: 18px;
}
}
</style>

View file

@ -7,9 +7,16 @@
interface Props {
isSidebarMode?: boolean;
isToolbarExpanded?: boolean;
isMobile?: boolean;
hasTagStrip?: boolean;
}
let { isSidebarMode = false, isToolbarExpanded = false }: Props = $props();
let {
isSidebarMode = false,
isToolbarExpanded = false,
isMobile = false,
hasTagStrip = false,
}: Props = $props();
let contextMenu: DateStripContextMenu;
@ -30,6 +37,8 @@
class="datestrip-fab-container"
class:sidebar-mode={isSidebarMode}
class:toolbar-expanded={isToolbarExpanded}
class:mobile={isMobile}
class:has-tag-strip={hasTagStrip}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<button
@ -79,6 +88,52 @@
}
}
/* Mobile: Position in row above InputBar, left of ViewModePill */
/* InputBar is at bottom: 70px (above PillNav), so controls go above that */
.datestrip-fab-container.mobile {
/* Above PillNav (70px) + InputBar (72px) + gap (8px), to the left of center */
bottom: calc(70px + 72px + 8px + env(safe-area-inset-bottom, 0px));
left: calc(50% - 100px - 54px - 8px);
}
.datestrip-fab-container.mobile.toolbar-expanded {
bottom: calc(70px + 72px + 70px + 8px + env(safe-area-inset-bottom, 0px));
}
/* When TagStrip is visible, add 70px offset */
.datestrip-fab-container.has-tag-strip {
bottom: calc(140px + 9px + env(safe-area-inset-bottom, 0px));
}
.datestrip-fab-container.has-tag-strip.toolbar-expanded {
bottom: calc(210px + 9px + env(safe-area-inset-bottom, 0px));
}
.datestrip-fab-container.has-tag-strip.sidebar-mode {
bottom: calc(70px + 9px + env(safe-area-inset-bottom, 0px));
}
.datestrip-fab-container.has-tag-strip.mobile {
bottom: calc(140px + 72px + 8px + env(safe-area-inset-bottom, 0px));
}
.datestrip-fab-container.has-tag-strip.mobile.toolbar-expanded {
bottom: calc(140px + 72px + 70px + 8px + env(safe-area-inset-bottom, 0px));
}
/* Fallback for CSS-only mobile detection */
@media (max-width: 640px) {
.datestrip-fab-container:not(.mobile) {
/* Above PillNav (70px) + InputBar (72px) + gap (8px), to the left of center */
bottom: calc(70px + 72px + 8px + env(safe-area-inset-bottom, 0px));
left: calc(50% - 100px - 54px - 8px);
}
.datestrip-fab-container:not(.mobile).toolbar-expanded {
bottom: calc(70px + 72px + 70px + 8px + env(safe-area-inset-bottom, 0px));
}
}
.datestrip-fab {
display: flex;
flex-direction: column;

View file

@ -0,0 +1,273 @@
<script lang="ts">
import { settingsStore } from '$lib/stores/settings.svelte';
import { eventTagsStore } from '$lib/stores/event-tags.svelte';
import { eventTagGroupsStore } from '$lib/stores/event-tag-groups.svelte';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { DotsThree, Plus } from '@manacore/shared-icons';
import TagStripModal from './TagStripModal.svelte';
interface Props {
isSidebarMode?: boolean;
}
let { isSidebarMode = false }: Props = $props();
let showModal = $state(false);
function handleTagClick(tagId: string) {
// Navigate to tags page with the tag selected for editing
goto(`/tags?edit=${tagId}`);
}
function handleOpenModal() {
showModal = true;
}
function handleCloseModal() {
showModal = false;
}
// Sort tags by group, then by name
const sortedTags = $derived.by(() => {
const tags = [...eventTagsStore.tags];
const groupOrder = new Map(eventTagGroupsStore.groups.map((g, i) => [g.id, i]));
return tags.sort((a, b) => {
// Ungrouped tags go last
const aOrder = a.groupId ? (groupOrder.get(a.groupId) ?? 999) : 1000;
const bOrder = b.groupId ? (groupOrder.get(b.groupId) ?? 999) : 1000;
if (aOrder !== bOrder) return aOrder - bOrder;
return a.name.localeCompare(b.name, 'de');
});
});
const hasTags = $derived(eventTagsStore.tags.length > 0);
onMount(async () => {
// Fetch tags and groups if not already loaded
if (eventTagsStore.tags.length === 0) {
await eventTagsStore.fetchTags();
}
if (eventTagGroupsStore.groups.length === 0) {
await eventTagGroupsStore.fetchGroups();
}
});
</script>
<div class="tag-strip-wrapper" class:sidebar-mode={isSidebarMode}>
<div class="tag-strip-container">
<!-- More Pill (opens modal) -->
<button class="more-pill glass-tag" onclick={handleOpenModal} title="Alle Tags anzeigen">
<DotsThree size={18} weight="bold" />
<span class="tag-name">Mehr</span>
</button>
{#if eventTagsStore.loading}
<div class="loading-state">Lädt...</div>
{:else if !hasTags}
<button class="empty-state glass-tag" onclick={() => goto('/tags')}>
<span>Keine Tags vorhanden</span>
<span class="add-hint">+ Erstellen</span>
</button>
{:else}
{#each sortedTags as tag (tag.id)}
<button
class="tag-pill glass-tag"
onclick={() => handleTagClick(tag.id)}
title={tag.name}
style="--tag-color: {tag.color || '#3b82f6'}"
>
<span class="tag-dot"></span>
<span class="tag-name">{tag.name}</span>
</button>
{/each}
<!-- Create Tag Button -->
<button
class="create-pill glass-tag"
onclick={() => goto('/tags?new=true')}
title="Neuer Tag"
>
<Plus size={16} weight="bold" />
</button>
{/if}
</div>
</div>
<!-- Tags Modal -->
<TagStripModal visible={showModal} onClose={handleCloseModal} {isSidebarMode} />
<style>
.tag-strip-wrapper {
position: fixed;
bottom: calc(70px + env(safe-area-inset-bottom, 0px)); /* Directly above PillNav */
left: 0;
right: 0;
z-index: 49; /* Above other strips */
display: flex;
flex-direction: column;
align-items: stretch;
pointer-events: none;
transition: bottom 0.2s ease;
}
/* When PillNav is in sidebar mode, TagStrip at very bottom */
.tag-strip-wrapper.sidebar-mode {
bottom: calc(0px + env(safe-area-inset-bottom, 0px));
}
.tag-strip-container {
display: flex;
align-items: center;
gap: 0.75rem;
background: transparent;
pointer-events: auto;
/* Center when content fits, left-align and scroll when it overflows */
width: fit-content;
max-width: 100%;
margin-left: auto;
margin-right: auto;
padding: 0.5rem 2rem;
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.tag-strip-container::-webkit-scrollbar {
display: none;
}
.tag-pill,
.more-pill,
.create-pill {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s ease;
}
/* More pill with muted style */
.more-pill {
color: #6b7280;
}
.more-pill .tag-name {
color: #6b7280;
font-weight: 600;
}
:global(.dark) .more-pill {
color: #9ca3af;
}
:global(.dark) .more-pill .tag-name {
color: #9ca3af;
}
/* Create pill with primary accent */
.create-pill {
color: #3b82f6;
padding: 0.5rem !important;
}
:global(.dark) .create-pill {
color: #60a5fa;
}
/* Glass tag styling - same as PillNavigation pills */
.glass-tag {
padding: 0.5rem 1rem;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.1);
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-tag {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.glass-tag:hover {
transform: scale(1.05);
background: rgba(255, 255, 255, 0.95);
border-color: rgba(0, 0, 0, 0.15);
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-tag:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.25);
}
.glass-tag:active {
transform: scale(0.98);
}
.tag-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: var(--tag-color);
flex-shrink: 0;
}
.tag-name {
font-size: 0.9375rem;
font-weight: 500;
color: #374151;
white-space: nowrap;
}
:global(.dark) .tag-name {
color: #f3f4f6;
}
.loading-state {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
padding: 0.5rem;
}
.empty-state {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
color: #6b7280;
font-size: 0.875rem;
flex-shrink: 0;
}
:global(.dark) .empty-state {
color: #9ca3af;
}
.add-hint {
font-size: 0.875rem;
color: #3b82f6;
font-weight: 500;
}
/* Responsive */
@media (max-width: 640px) {
.tag-strip-wrapper {
left: 0;
right: 0;
}
.tag-strip-container {
padding: 0.5rem 1rem;
}
}
</style>

File diff suppressed because it is too large Load diff

View file

@ -440,5 +440,8 @@
.carousel-page.current {
/* Always interactive */
pointer-events: auto;
/* Enable vertical scrolling for keyboard navigation */
overflow-y: auto;
overflow-x: hidden;
}
</style>

View file

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

View file

@ -0,0 +1,245 @@
<script lang="ts">
import { TagBadge, type Tag } from '@manacore/shared-ui';
import { CaretDown, CaretRight, Pencil, FolderSimple } from '@manacore/shared-icons';
import type { EventTag, EventTagGroup } from '@calendar/shared';
interface Props {
groups: EventTagGroup[];
tags: EventTag[];
ungroupedLabel?: string;
onEditTag: (tag: EventTag) => void;
onEditGroup?: (group: EventTagGroup) => void;
loading?: boolean;
emptyMessage?: string;
}
let {
groups,
tags,
ungroupedLabel = 'Ohne Gruppe',
onEditTag,
onEditGroup,
loading = false,
emptyMessage = 'Keine Tags vorhanden',
}: Props = $props();
// Track collapsed state (inverted - we track what's collapsed, not expanded)
// This way new groups are automatically expanded
let collapsedGroups = $state<Set<string | null>>(new Set());
function toggleGroup(groupId: string | null) {
const newSet = new Set(collapsedGroups);
if (newSet.has(groupId)) {
newSet.delete(groupId);
} else {
newSet.add(groupId);
}
collapsedGroups = newSet;
}
function isExpanded(groupId: string | null): boolean {
return !collapsedGroups.has(groupId);
}
// Get tags for a specific group
function getTagsForGroup(groupId: string | null): EventTag[] {
return tags.filter((t) => (t.groupId ?? null) === groupId);
}
// Convert EventTag to Tag for TagBadge
function toTag(eventTag: EventTag): Tag {
return {
id: eventTag.id,
name: eventTag.name,
color: eventTag.color,
};
}
// Get ungrouped tags
const ungroupedTags = $derived(getTagsForGroup(null));
const hasUngroupedTags = $derived(ungroupedTags.length > 0);
const totalTags = $derived(tags.length);
</script>
{#if loading}
<div class="flex justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
{:else if totalTags === 0}
<div class="text-center py-12">
<div class="text-muted-foreground mb-2">{emptyMessage}</div>
</div>
{:else}
<div class="space-y-2">
<!-- Groups with their tags -->
{#each groups as group (group.id)}
{@const groupTags = getTagsForGroup(group.id)}
{#if groupTags.length > 0}
<div class="group-section">
<!-- Group Header -->
<button type="button" onclick={() => toggleGroup(group.id)} class="group-header">
<div class="flex items-center gap-2">
{#if isExpanded(group.id)}
<CaretDown size={16} weight="bold" class="text-muted-foreground" />
{:else}
<CaretRight size={16} weight="bold" class="text-muted-foreground" />
{/if}
<div
class="w-3 h-3 rounded-full flex-shrink-0"
style="background-color: {group.color}"
></div>
<span class="font-medium">{group.name}</span>
<span class="text-xs text-muted-foreground">({groupTags.length})</span>
</div>
{#if onEditGroup}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
onEditGroup(group);
}}
class="edit-group-btn"
aria-label="Gruppe bearbeiten"
>
<Pencil size={14} />
</button>
{/if}
</button>
<!-- Tags in this group -->
{#if isExpanded(group.id)}
<div class="tags-container">
{#each groupTags as tag (tag.id)}
<button type="button" class="tag-pill" onclick={() => onEditTag(tag)}>
<TagBadge tag={toTag(tag)} />
<span class="edit-icon">
<Pencil size={12} />
</span>
</button>
{/each}
</div>
{/if}
</div>
{/if}
{/each}
<!-- Ungrouped tags -->
{#if hasUngroupedTags}
<div class="group-section">
<!-- Ungrouped Header -->
<button type="button" onclick={() => toggleGroup(null)} class="group-header">
<div class="flex items-center gap-2">
{#if isExpanded(null)}
<CaretDown size={16} weight="bold" class="text-muted-foreground" />
{:else}
<CaretRight size={16} weight="bold" class="text-muted-foreground" />
{/if}
<FolderSimple size={14} class="text-muted-foreground" />
<span class="font-medium text-muted-foreground">{ungroupedLabel}</span>
<span class="text-xs text-muted-foreground">({ungroupedTags.length})</span>
</div>
</button>
<!-- Ungrouped Tags -->
{#if isExpanded(null)}
<div class="tags-container">
{#each ungroupedTags as tag (tag.id)}
<button type="button" class="tag-pill" onclick={() => onEditTag(tag)}>
<TagBadge tag={toTag(tag)} />
<span class="edit-icon">
<Pencil size={12} />
</span>
</button>
{/each}
</div>
{/if}
</div>
{/if}
</div>
{/if}
<style>
.group-section {
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
overflow: hidden;
background: hsl(var(--card));
}
.group-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.75rem 1rem;
background: hsl(var(--muted) / 0.3);
border: none;
cursor: pointer;
transition: background 0.2s ease;
text-align: left;
}
.group-header:hover {
background: hsl(var(--muted) / 0.5);
}
.edit-group-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.375rem;
border-radius: 0.375rem;
color: hsl(var(--muted-foreground));
background: transparent;
border: none;
cursor: pointer;
opacity: 0;
transition: all 0.2s ease;
}
.group-header:hover .edit-group-btn {
opacity: 1;
}
.edit-group-btn:hover {
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.1);
}
.tags-container {
padding: 0.75rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tag-pill {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0;
padding-right: 0.5rem;
border: none;
background: transparent;
cursor: pointer;
border-radius: 9999px;
transition: all 0.15s ease;
}
.tag-pill:hover {
background: hsl(var(--muted) / 0.5);
}
.edit-icon {
display: flex;
align-items: center;
justify-content: center;
color: hsl(var(--muted-foreground));
opacity: 0;
transition: opacity 0.15s ease;
}
.tag-pill:hover .edit-icon {
opacity: 1;
}
</style>

View file

@ -0,0 +1,123 @@
<script lang="ts">
import { Modal, Input, TagColorPicker, TagBadge } from '@manacore/shared-ui';
import type { EventTagGroup } from '@calendar/shared';
interface Props {
group?: EventTagGroup | null;
isOpen: boolean;
onClose: () => void;
onSave: (name: string, color: string) => void;
onDelete?: () => void;
}
let { group = null, isOpen, onClose, onSave, onDelete }: Props = $props();
const DEFAULT_COLOR = '#3B82F6';
let name = $state(group?.name ?? '');
let color = $state(group?.color ?? DEFAULT_COLOR);
// Reset form when group changes
$effect(() => {
if (isOpen) {
name = group?.name ?? '';
color = group?.color ?? DEFAULT_COLOR;
}
});
function handleSave() {
if (name.trim()) {
onSave(name.trim(), color);
}
}
function handleDelete() {
if (
onDelete &&
confirm(
`Gruppe "${group?.name}" wirklich löschen? Tags in dieser Gruppe werden nicht gelöscht.`
)
) {
onDelete();
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' && name.trim()) {
e.preventDefault();
handleSave();
}
}
const previewTag = $derived({ name: name || 'Gruppenname', color });
const isEditing = $derived(!!group);
</script>
<Modal
visible={isOpen}
{onClose}
title={isEditing ? 'Gruppe bearbeiten' : 'Neue Gruppe'}
maxWidth="sm"
>
<div class="space-y-6">
<!-- Name Input -->
<div>
<Input bind:value={name} placeholder="Gruppenname" onkeydown={handleKeyDown} />
</div>
<!-- Color Picker -->
<div>
<span class="block text-sm font-medium text-muted-foreground mb-3"> Farbe </span>
<TagColorPicker selectedColor={color} onColorChange={(c) => (color = c)} />
</div>
<!-- Preview -->
<div>
<span class="block text-sm font-medium text-muted-foreground mb-3"> Vorschau </span>
<div class="flex items-center gap-2">
<TagBadge tag={previewTag} />
</div>
</div>
<!-- Tag Count Info (only when editing) -->
{#if isEditing && group?.tagCount !== undefined && group.tagCount > 0}
<div class="text-sm text-muted-foreground">
{group.tagCount}
{group.tagCount === 1 ? 'Tag' : 'Tags'} in dieser Gruppe
</div>
{/if}
</div>
{#snippet footer()}
<div class="flex items-center justify-between">
<div>
{#if onDelete && isEditing}
<button
type="button"
onclick={handleDelete}
class="px-4 py-2 text-sm font-medium text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
>
Löschen
</button>
{/if}
</div>
<div class="flex items-center gap-3">
<button
type="button"
onclick={onClose}
class="px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/10 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
type="button"
onclick={handleSave}
disabled={!name.trim()}
class="px-4 py-2 text-sm font-medium bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
>
{isEditing ? 'Speichern' : 'Erstellen'}
</button>
</div>
</div>
{/snippet}
</Modal>

View file

@ -0,0 +1,2 @@
export { default as TagGroupEditModal } from './TagGroupEditModal.svelte';
export { default as GroupedTagList } from './GroupedTagList.svelte';

View file

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

View file

@ -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<string | null, EventTag[]> {
const grouped = new Map<string | null, EventTag[]>();
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);
},
};

View file

@ -656,8 +656,10 @@
onCollapsedChange={handleToolbarCollapsedChange}
/>
{/if}
{/if}
<!-- Global Input Bar -->
<!-- Global Input Bar (hidden via CSS in immersive mode to prevent re-mount focus) -->
<div class="input-bar-wrapper" class:hidden={settingsStore.immersiveModeEnabled}>
<QuickInputBar
onSearch={handleSearch}
onSelect={handleSelect}
@ -688,7 +690,7 @@
onShowShortcuts={handleShowShortcuts}
onShowSyntaxHelp={handleShowSyntaxHelp}
/>
{/if}
</div>
<!-- Immersive Mode Toggle (always visible on main calendar page) -->
<ImmersiveModeToggle
@ -728,7 +730,8 @@
.layout-container {
display: flex;
flex-direction: column;
min-height: 100vh;
height: 100vh;
overflow: hidden;
}
/* Mobile: Fixed viewport, no scroll */
@ -745,6 +748,11 @@
position: relative;
/* Space for QuickInputBar at bottom */
padding-bottom: calc(80px + env(safe-area-inset-bottom));
/* Flex container for children */
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.main-content.floating-mode {
@ -801,6 +809,11 @@
padding: 1rem;
position: relative;
z-index: 0;
/* Flex for calendar layout */
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* Mobile: no padding, full height */

View file

@ -7,7 +7,6 @@
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { isSidebarMode as sidebarModeStore } from '$lib/stores/navigation';
import ViewCarousel from '$lib/components/calendar/ViewCarousel.svelte';
import TodoSidebarSection from '$lib/components/calendar/TodoSidebarSection.svelte';
import QuickEventOverlay from '$lib/components/event/QuickEventOverlay.svelte';
@ -120,26 +119,6 @@
<TodoSidebarSection maxItems={5} />
</aside>
<!-- Desktop: FAB when sidebar is collapsed -->
{#if settingsStore.sidebarCollapsed}
<div class="sidebar-fab desktop-only" class:pill-sidebar={$sidebarModeStore}>
<button
class="fab-expand"
onclick={() => settingsStore.toggleSidebar()}
title={$_('calendar.showSidebar')}
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</button>
</div>
{/if}
<!-- Main Calendar Area -->
<div class="calendar-main" class:expanded={settingsStore.sidebarCollapsed}>
<div class="calendar-content">
@ -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 */

View file

@ -1,86 +1,121 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { TagList, TagEditModal, type Tag } from '@manacore/shared-ui';
import { MagnifyingGlass, Plus, CaretLeft } from '@manacore/shared-icons';
import { Modal, Input, TagColorPicker, TagBadge } from '@manacore/shared-ui';
import { MagnifyingGlass, Plus, CaretLeft, FolderSimple } from '@manacore/shared-icons';
import { eventTagsStore } from '$lib/stores/event-tags.svelte';
import { eventTagGroupsStore } from '$lib/stores/event-tag-groups.svelte';
import { GroupedTagList } from '$lib/components/tags';
import type { EventTag } from '@calendar/shared';
let searchQuery = $state('');
let showModal = $state(false);
let showTagModal = $state(false);
let editingTag = $state<EventTag | null>(null);
let selectedGroupId = $state<string | null>(null);
// Filtered tags based on search
const filteredTags = $derived.by(() => {
if (!searchQuery.trim()) return eventTagsStore.tags;
const query = searchQuery.toLowerCase();
return eventTagsStore.tags.filter((t) => t.name.toLowerCase().includes(query));
});
// Convert EventTag to Tag type for shared-ui components
function eventTagToTag(tag: EventTag): Tag {
return {
id: tag.id,
name: tag.name,
color: tag.color,
};
}
// Filter groups that have matching tags (for search)
const filteredGroups = $derived.by(() => {
if (!searchQuery.trim()) return eventTagGroupsStore.groups;
// Only show groups that have at least one matching tag
const tagGroupIds = new Set(filteredTags.map((t) => t.groupId).filter(Boolean));
return eventTagGroupsStore.groups.filter((g) => tagGroupIds.has(g.id));
});
function openCreateModal() {
editingTag = null;
showModal = true;
}
// Form state for custom tag edit modal with group selection
let tagName = $state('');
let tagColor = $state('#3B82F6');
function openEditModal(tag: Tag) {
const eventTag = eventTagsStore.tags.find((t) => t.id === tag.id);
if (eventTag) {
editingTag = eventTag;
showModal = true;
const DEFAULT_COLOR = '#3B82F6';
// Reset form when editing tag changes
$effect(() => {
if (showTagModal) {
tagName = editingTag?.name ?? '';
tagColor = editingTag?.color ?? DEFAULT_COLOR;
selectedGroupId = editingTag?.groupId ?? null;
}
});
function openCreateTagModal() {
editingTag = null;
selectedGroupId = null;
showTagModal = true;
}
function closeModal() {
showModal = false;
function openEditTagModal(tag: EventTag) {
editingTag = tag;
showTagModal = true;
}
function closeTagModal() {
showTagModal = false;
editingTag = null;
}
async function handleSave(name: string, color: string) {
async function handleSaveTag() {
if (!tagName.trim()) return;
try {
if (editingTag) {
await eventTagsStore.updateTag(editingTag.id, { name, color });
await eventTagsStore.updateTag(editingTag.id, {
name: tagName.trim(),
color: tagColor,
groupId: selectedGroupId,
});
} else {
await eventTagsStore.createTag({ name, color });
await eventTagsStore.createTag({
name: tagName.trim(),
color: tagColor,
groupId: selectedGroupId ?? undefined,
});
}
closeModal();
closeTagModal();
// Refresh groups to update tag counts
eventTagGroupsStore.fetchGroups();
} catch (e) {
console.error('Failed to save tag:', e);
}
}
async function handleDelete() {
async function handleDeleteTag() {
if (!editingTag) return;
if (!confirm(`Tag "${editingTag.name}" wirklich löschen?`)) return;
try {
await eventTagsStore.deleteTag(editingTag.id);
closeModal();
closeTagModal();
// Refresh groups to update tag counts
eventTagGroupsStore.fetchGroups();
} catch (e) {
console.error('Failed to delete tag:', e);
}
}
async function handleDeleteFromList(tag: Tag) {
if (!confirm(`Tag "${tag.name}" wirklich löschen?`)) return;
try {
await eventTagsStore.deleteTag(tag.id);
} catch (e) {
console.error('Failed to delete tag:', e);
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' && tagName.trim()) {
e.preventDefault();
handleSaveTag();
}
}
onMount(() => {
if (eventTagsStore.tags.length === 0) {
eventTagsStore.fetchTags();
}
const previewTag = $derived({ name: tagName || 'Tag Name', color: tagColor });
const isLoading = $derived(eventTagsStore.loading || eventTagGroupsStore.loading);
onMount(async () => {
// Fetch both tags and groups in parallel
await Promise.all([
eventTagsStore.tags.length === 0 ? eventTagsStore.fetchTags() : Promise.resolve(),
eventTagGroupsStore.groups.length === 0
? eventTagGroupsStore.fetchGroups()
: Promise.resolve(),
]);
});
</script>
@ -95,7 +130,10 @@
<CaretLeft size={20} weight="bold" />
</a>
<h1 class="title">Tags</h1>
<button onclick={openCreateModal} class="add-button" aria-label="Neues Tag">
<a href="/tags/groups" class="groups-button" aria-label="Gruppen verwalten">
<FolderSimple size={20} weight="bold" />
</a>
<button onclick={openCreateTagModal} class="add-button" aria-label="Neues Tag">
<Plus size={20} weight="bold" />
</button>
</header>
@ -111,34 +149,31 @@
/>
</div>
{#if eventTagsStore.error}
{#if eventTagsStore.error || eventTagGroupsStore.error}
<div class="error-banner" role="alert">
<span>{eventTagsStore.error}</span>
<span>{eventTagsStore.error || eventTagGroupsStore.error}</span>
</div>
{/if}
<!-- Tag List using shared component -->
<TagList
tags={filteredTags.map(eventTagToTag)}
loading={eventTagsStore.loading}
onEdit={openEditModal}
onDelete={handleDeleteFromList}
<!-- Grouped Tag List -->
<GroupedTagList
groups={filteredGroups}
tags={filteredTags}
loading={isLoading}
onEditTag={openEditTagModal}
emptyMessage={searchQuery ? 'Keine Tags gefunden' : 'Keine Tags vorhanden'}
emptyDescription={searchQuery
? `Kein Tag für "${searchQuery}" gefunden`
: 'Erstelle dein erstes Tag'}
/>
{#if !eventTagsStore.loading && eventTagsStore.tags.length > 0}
{#if !isLoading && eventTagsStore.tags.length > 0}
<p class="tags-count">
{eventTagsStore.tags.length}
{eventTagsStore.tags.length === 1 ? 'Tag' : 'Tags'}
</p>
{/if}
{#if !eventTagsStore.loading && eventTagsStore.tags.length === 0 && !searchQuery}
{#if !isLoading && eventTagsStore.tags.length === 0 && !searchQuery}
<div class="empty-cta">
<button onclick={openCreateModal} class="btn btn-primary">
<button onclick={openCreateTagModal} class="btn btn-primary">
<Plus size={16} weight="bold" />
Neues Tag
</button>
@ -146,22 +181,80 @@
{/if}
</div>
<!-- Create/Edit Modal using shared component -->
<TagEditModal
tag={editingTag ? eventTagToTag(editingTag) : null}
isOpen={showModal}
onClose={closeModal}
onSave={handleSave}
onDelete={editingTag ? handleDelete : undefined}
<!-- Custom Tag Edit Modal with Group Selection -->
<Modal
visible={showTagModal}
onClose={closeTagModal}
title={editingTag ? 'Tag bearbeiten' : 'Neues Tag'}
saveLabel={editingTag ? 'Speichern' : 'Erstellen'}
deleteLabel="Löschen"
cancelLabel="Abbrechen"
namePlaceholder="Tag Name"
colorLabel="Farbe"
previewLabel="Vorschau"
deleteConfirmMessage={`Tag "${editingTag?.name || ''}" wirklich löschen?`}
/>
maxWidth="sm"
>
<div class="space-y-6">
<!-- Name Input -->
<div>
<Input bind:value={tagName} placeholder="Tag Name" onkeydown={handleKeyDown} />
</div>
<!-- Group Selection -->
<div>
<span class="block text-sm font-medium text-muted-foreground mb-3"> Gruppe </span>
<select bind:value={selectedGroupId} class="group-select">
<option value={null}>Keine Gruppe</option>
{#each eventTagGroupsStore.groups as group (group.id)}
<option value={group.id}>
{group.name}
</option>
{/each}
</select>
</div>
<!-- Color Picker -->
<div>
<span class="block text-sm font-medium text-muted-foreground mb-3"> Farbe </span>
<TagColorPicker selectedColor={tagColor} onColorChange={(c) => (tagColor = c)} />
</div>
<!-- Preview -->
<div>
<span class="block text-sm font-medium text-muted-foreground mb-3"> Vorschau </span>
<div class="flex items-center gap-2">
<TagBadge tag={previewTag} />
</div>
</div>
</div>
{#snippet footer()}
<div class="flex items-center justify-between">
<div>
{#if editingTag}
<button
type="button"
onclick={handleDeleteTag}
class="px-4 py-2 text-sm font-medium text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
>
Löschen
</button>
{/if}
</div>
<div class="flex items-center gap-3">
<button
type="button"
onclick={closeTagModal}
class="px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/10 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
type="button"
onclick={handleSaveTag}
disabled={!tagName.trim()}
class="px-4 py-2 text-sm font-medium bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
>
{editingTag ? 'Speichern' : 'Erstellen'}
</button>
</div>
</div>
{/snippet}
</Modal>
<style>
.page-container {
@ -174,7 +267,7 @@
.header {
display: flex;
align-items: center;
gap: 1rem;
gap: 0.75rem;
padding: 1rem 0;
margin-bottom: 0.5rem;
}
@ -203,6 +296,22 @@
color: hsl(var(--foreground));
}
.groups-button {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: hsl(var(--muted));
color: hsl(var(--foreground));
transition: all 0.2s ease;
}
.groups-button:hover {
background: hsl(var(--muted-foreground) / 0.2);
}
.add-button {
display: flex;
align-items: center;
@ -254,6 +363,25 @@
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1);
}
/* Group Select */
.group-select {
width: 100%;
padding: 0.75rem 1rem;
border: 1.5px solid hsl(var(--border));
border-radius: 0.75rem;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 0.9375rem;
cursor: pointer;
transition: all 0.2s ease;
}
.group-select:focus {
outline: none;
border-color: hsl(var(--primary));
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1);
}
/* Error */
.error-banner {
display: flex;

View file

@ -0,0 +1,375 @@
<script lang="ts">
import { onMount } from 'svelte';
import { CaretLeft, Plus, Pencil, Trash } from '@manacore/shared-icons';
import { eventTagGroupsStore } from '$lib/stores/event-tag-groups.svelte';
import { TagGroupEditModal } from '$lib/components/tags';
import type { EventTagGroup } from '@calendar/shared';
let showModal = $state(false);
let editingGroup = $state<EventTagGroup | null>(null);
function openCreateModal() {
editingGroup = null;
showModal = true;
}
function openEditModal(group: EventTagGroup) {
editingGroup = group;
showModal = true;
}
function closeModal() {
showModal = false;
editingGroup = null;
}
async function handleSave(name: string, color: string) {
try {
if (editingGroup) {
await eventTagGroupsStore.updateGroup(editingGroup.id, { name, color });
} else {
await eventTagGroupsStore.createGroup({ name, color });
}
closeModal();
} catch (e) {
console.error('Failed to save group:', e);
}
}
async function handleDelete() {
if (!editingGroup) return;
try {
await eventTagGroupsStore.deleteGroup(editingGroup.id);
closeModal();
} catch (e) {
console.error('Failed to delete group:', e);
}
}
async function handleDeleteFromList(group: EventTagGroup) {
if (
!confirm(
`Gruppe "${group.name}" wirklich löschen? Tags in dieser Gruppe werden nicht gelöscht.`
)
)
return;
try {
await eventTagGroupsStore.deleteGroup(group.id);
} catch (e) {
console.error('Failed to delete group:', e);
}
}
onMount(() => {
if (eventTagGroupsStore.groups.length === 0) {
eventTagGroupsStore.fetchGroups();
}
});
</script>
<svelte:head>
<title>Tag-Gruppen - Kalender</title>
</svelte:head>
<div class="page-container">
<!-- Header -->
<header class="header">
<a href="/tags" class="back-button" aria-label="Zurück zu Tags">
<CaretLeft size={20} weight="bold" />
</a>
<h1 class="title">Tag-Gruppen</h1>
<button onclick={openCreateModal} class="add-button" aria-label="Neue Gruppe">
<Plus size={20} weight="bold" />
</button>
</header>
{#if eventTagGroupsStore.error}
<div class="error-banner" role="alert">
<span>{eventTagGroupsStore.error}</span>
</div>
{/if}
{#if eventTagGroupsStore.loading}
<div class="flex justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
{:else if eventTagGroupsStore.groups.length === 0}
<div class="empty-state">
<p class="text-muted-foreground mb-4">Noch keine Gruppen vorhanden</p>
<button onclick={openCreateModal} class="btn btn-primary">
<Plus size={16} weight="bold" />
Neue Gruppe erstellen
</button>
</div>
{:else}
<div class="groups-list">
{#each eventTagGroupsStore.groups as group (group.id)}
<div class="group-item">
<div class="group-info">
<div class="group-color" style="background-color: {group.color}"></div>
<div class="group-details">
<span class="group-name">{group.name}</span>
<span class="group-tag-count">
{group.tagCount ?? 0}
{(group.tagCount ?? 0) === 1 ? 'Tag' : 'Tags'}
</span>
</div>
</div>
<div class="group-actions">
<button
type="button"
onclick={() => openEditModal(group)}
class="action-btn"
aria-label="Gruppe bearbeiten"
>
<Pencil size={16} />
</button>
<button
type="button"
onclick={() => handleDeleteFromList(group)}
class="action-btn action-btn-delete"
aria-label="Gruppe löschen"
>
<Trash size={16} />
</button>
</div>
</div>
{/each}
</div>
<p class="groups-count">
{eventTagGroupsStore.groups.length}
{eventTagGroupsStore.groups.length === 1 ? 'Gruppe' : 'Gruppen'}
</p>
{/if}
<!-- Info about ungrouped tags -->
{#if eventTagGroupsStore.ungroupedTagCount > 0}
<div class="ungrouped-info">
<span>
{eventTagGroupsStore.ungroupedTagCount}
{eventTagGroupsStore.ungroupedTagCount === 1 ? 'Tag' : 'Tags'} ohne Gruppe
</span>
</div>
{/if}
</div>
<!-- Group Edit Modal -->
<TagGroupEditModal
group={editingGroup}
isOpen={showModal}
onClose={closeModal}
onSave={handleSave}
onDelete={editingGroup ? handleDelete : undefined}
/>
<style>
.page-container {
max-width: 640px;
margin: 0 auto;
padding: 0 1rem 2rem;
}
/* Header */
.header {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 0;
margin-bottom: 1rem;
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: hsl(var(--muted));
color: hsl(var(--foreground));
transition: all 0.2s ease;
}
.back-button:hover {
background: hsl(var(--muted-foreground) / 0.2);
transform: translateX(-2px);
}
.title {
flex: 1;
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--foreground));
}
.add-button {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
.add-button:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px hsl(var(--primary) / 0.3);
}
/* Error */
.error-banner {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: hsl(0 84% 60% / 0.1);
border: 1px solid hsl(0 84% 60% / 0.3);
border-radius: 0.75rem;
color: hsl(0 84% 60%);
margin-bottom: 1.5rem;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 3rem 1rem;
}
/* Groups List */
.groups-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.group-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.875rem 1rem;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
transition: all 0.2s ease;
}
.group-item:hover {
background: hsl(var(--muted) / 0.3);
}
.group-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.group-color {
width: 1rem;
height: 1rem;
border-radius: 50%;
flex-shrink: 0;
}
.group-details {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.group-name {
font-weight: 500;
color: hsl(var(--foreground));
}
.group-tag-count {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
.group-actions {
display: flex;
align-items: center;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.2s ease;
}
.group-item:hover .group-actions {
opacity: 1;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
border-radius: 0.5rem;
color: hsl(var(--muted-foreground));
background: transparent;
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
.action-btn:hover {
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.1);
}
.action-btn-delete:hover {
color: hsl(0 84% 60%);
background: hsl(0 84% 60% / 0.1);
}
/* Count */
.groups-count {
text-align: center;
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin-top: 1.5rem;
}
/* Ungrouped Info */
.ungrouped-info {
text-align: center;
font-size: 0.8125rem;
color: hsl(var(--muted-foreground));
margin-top: 0.75rem;
padding: 0.5rem;
background: hsl(var(--muted) / 0.3);
border-radius: 0.5rem;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
border-radius: 0.625rem;
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s ease;
border: none;
text-decoration: none;
}
.btn-primary {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.btn-primary:hover {
box-shadow: 0 4px 12px hsl(var(--primary) / 0.3);
}
</style>

View file

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

View file

@ -536,32 +536,61 @@
<!-- Navigation Items -->
{#each items as item}
<a href={item.href} class="pill glass-pill" class:active={isActive(item.href)}>
{#if item.icon}
{#if item.icon === 'mana'}
<svg class="pill-icon" viewBox="0 0 24 24" fill="currentColor">
<path
d="M12.3047 1C12.3392 1.04573 19.608 10.6706 19.6084 14.6953C19.6084 18.7293 16.3386 21.9998 12.3047 22C8.27061 22 5 18.7294 5 14.6953C5.00041 10.661 12.3047 1 12.3047 1ZM12.3047 7.3916C12.2811 7.42276 8.65234 12.2288 8.65234 14.2393C8.65241 16.2562 10.2877 17.8916 12.3047 17.8916C14.3217 17.8916 15.957 16.2562 15.957 14.2393C15.957 12.2301 12.3331 7.42917 12.3047 7.3916Z"
/>
</svg>
{:else if item.iconSvg}
{@html item.iconSvg}
{:else if phosphorIcons[item.icon]}
{@const IconComponent = phosphorIcons[item.icon]}
<IconComponent size={18} class="pill-icon" />
{:else}
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={getIconPath(item.icon)}
/>
</svg>
{#if item.onClick}
<button onclick={item.onClick} class="pill glass-pill" class:active={item.active}>
{#if item.icon}
{#if item.icon === 'mana'}
<svg class="pill-icon" viewBox="0 0 24 24" fill="currentColor">
<path
d="M12.3047 1C12.3392 1.04573 19.608 10.6706 19.6084 14.6953C19.6084 18.7293 16.3386 21.9998 12.3047 22C8.27061 22 5 18.7294 5 14.6953C5.00041 10.661 12.3047 1 12.3047 1ZM12.3047 7.3916C12.2811 7.42276 8.65234 12.2288 8.65234 14.2393C8.65241 16.2562 10.2877 17.8916 12.3047 17.8916C14.3217 17.8916 15.957 16.2562 15.957 14.2393C15.957 12.2301 12.3331 7.42917 12.3047 7.3916Z"
/>
</svg>
{:else if item.iconSvg}
{@html item.iconSvg}
{:else if phosphorIcons[item.icon]}
{@const IconComponent = phosphorIcons[item.icon]}
<IconComponent size={18} class="pill-icon" />
{:else}
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={getIconPath(item.icon)}
/>
</svg>
{/if}
{/if}
{/if}
<span class="pill-label">{item.label}</span>
</a>
<span class="pill-label">{item.label}</span>
</button>
{:else}
<a href={item.href} class="pill glass-pill" class:active={isActive(item.href)}>
{#if item.icon}
{#if item.icon === 'mana'}
<svg class="pill-icon" viewBox="0 0 24 24" fill="currentColor">
<path
d="M12.3047 1C12.3392 1.04573 19.608 10.6706 19.6084 14.6953C19.6084 18.7293 16.3386 21.9998 12.3047 22C8.27061 22 5 18.7294 5 14.6953C5.00041 10.661 12.3047 1 12.3047 1ZM12.3047 7.3916C12.2811 7.42276 8.65234 12.2288 8.65234 14.2393C8.65241 16.2562 10.2877 17.8916 12.3047 17.8916C14.3217 17.8916 15.957 16.2562 15.957 14.2393C15.957 12.2301 12.3331 7.42917 12.3047 7.3916Z"
/>
</svg>
{:else if item.iconSvg}
{@html item.iconSvg}
{:else if phosphorIcons[item.icon]}
{@const IconComponent = phosphorIcons[item.icon]}
<IconComponent size={18} class="pill-icon" />
{:else}
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={getIconPath(item.icon)}
/>
</svg>
{/if}
{/if}
<span class="pill-label">{item.label}</span>
</a>
{/if}
{/each}
<!-- Additional Elements (Tab Groups, Dividers) -->

View file

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