mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
Feat: Tagmodal, fullscreenmode, tag groups, onepage design philosophy
This commit is contained in:
parent
893c6ef0fb
commit
bc0eecac95
36 changed files with 3542 additions and 343 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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),
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { IsArray, IsUUID } from 'class-validator';
|
||||
|
||||
export class ReorderEventTagGroupsDto {
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
groupIds!: string[];
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
4
apps/calendar/apps/backend/src/event-tag-group/index.ts
Normal file
4
apps/calendar/apps/backend/src/event-tag-group/index.ts
Normal 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';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
84
apps/calendar/apps/web/src/lib/api/event-tag-groups.ts
Normal file
84
apps/calendar/apps/web/src/lib/api/event-tag-groups.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
2
apps/calendar/apps/web/src/lib/components/tags/index.ts
Normal file
2
apps/calendar/apps/web/src/lib/components/tags/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as TagGroupEditModal } from './TagGroupEditModal.svelte';
|
||||
export { default as GroupedTagList } from './GroupedTagList.svelte';
|
||||
151
apps/calendar/apps/web/src/lib/stores/event-tag-groups.svelte.ts
Normal file
151
apps/calendar/apps/web/src/lib/stores/event-tag-groups.svelte.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
375
apps/calendar/apps/web/src/routes/(app)/tags/groups/+page.svelte
Normal file
375
apps/calendar/apps/web/src/routes/(app)/tags/groups/+page.svelte
Normal 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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) -->
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue