mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-17 08:39:40 +02:00
Merge pull request #18 from Memo-2023/till-dev
feat: calendar improvements, contacts refactoring & auth token refresh
This commit is contained in:
commit
9238ff72a3
186 changed files with 20251 additions and 7376 deletions
|
|
@ -799,18 +799,20 @@ Docker containers can reach each other by service name (`mana-core-auth`), but b
|
|||
|
||||
### Apps Using This Pattern Correctly
|
||||
|
||||
- ✅ `chat/apps/web` - Has `hooks.server.ts` with runtime injection
|
||||
- ✅ `todo/apps/web` - Fixed
|
||||
- ✅ `calendar/apps/web` - Fixed
|
||||
- ✅ `clock/apps/web` - Fixed
|
||||
All web apps with backends now use the runtime injection pattern:
|
||||
|
||||
### Apps That Still Need Fixing
|
||||
- ✅ `chat/apps/web`
|
||||
- ✅ `picture/apps/web`
|
||||
- ✅ `zitare/apps/web`
|
||||
- ✅ `contacts/apps/web`
|
||||
- ✅ `calendar/apps/web`
|
||||
- ✅ `clock/apps/web`
|
||||
- ✅ `todo/apps/web`
|
||||
|
||||
- ❌ `contacts/apps/web`
|
||||
- ❌ `manadeck/apps/web`
|
||||
- ❌ `manacore/apps/web`
|
||||
- ❌ `zitare/apps/web`
|
||||
- ❌ `picture/apps/web`
|
||||
### Apps That May Need Fixing
|
||||
|
||||
- ❓ `manadeck/apps/web` - Check if using dynamic URLs
|
||||
- ❓ `manacore/apps/web` - Check if using dynamic URLs
|
||||
|
||||
### Quick Checklist for New SvelteKit Apps
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,7 +58,9 @@ export function createApiClient(config: ApiClientConfig) {
|
|||
headers['Authorization'] = `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}${apiPrefix}${endpoint}`, {
|
||||
const url = `${baseUrl}${apiPrefix}${endpoint}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: isFormData ? (body as FormData) : body ? JSON.stringify(body) : undefined,
|
||||
|
|
|
|||
100
apps/calendar/apps/web/src/lib/api/birthdays.ts
Normal file
100
apps/calendar/apps/web/src/lib/api/birthdays.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* Cross-App API Client for Contacts Backend - Birthday Data
|
||||
* Allows Calendar app to fetch contact birthdays for display
|
||||
*/
|
||||
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { createApiClient } from './base-client';
|
||||
|
||||
const CONTACTS_API_BASE = env.PUBLIC_CONTACTS_API_URL || 'http://localhost:3015';
|
||||
|
||||
const contactsClient = createApiClient({
|
||||
baseUrl: CONTACTS_API_BASE,
|
||||
apiPrefix: '/api/v1',
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Types for Birthday Integration
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Lightweight contact data for birthday display
|
||||
* Only essential fields from Contacts API
|
||||
*/
|
||||
export interface ContactBirthdaySummary {
|
||||
id: string;
|
||||
displayName: string | null;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
birthday: string; // YYYY-MM-DD format
|
||||
photoUrl: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Birthday event for calendar display
|
||||
* Generated from ContactBirthdaySummary with display date
|
||||
*/
|
||||
export interface BirthdayEvent {
|
||||
id: string; // Format: birthday-{contactId}-{date}
|
||||
contactId: string;
|
||||
title: string; // "{Name}'s Geburtstag"
|
||||
displayName: string;
|
||||
photoUrl: string | null;
|
||||
birthday: string; // Original birthday date
|
||||
age: number; // Age on this birthday (0 if birth year unknown)
|
||||
startTime: string; // ISO date of the birthday occurrence
|
||||
endTime: string; // Same as startTime (all-day event)
|
||||
isAllDay: true;
|
||||
isBirthday: true; // Type discriminator
|
||||
calendarId: string; // Virtual calendar ID
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// API Response Types
|
||||
// ============================================
|
||||
|
||||
interface BirthdaysResponse {
|
||||
contacts: ContactBirthdaySummary[];
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// API Functions
|
||||
// ============================================
|
||||
|
||||
const fetchContactsApi = contactsClient.fetchApi;
|
||||
|
||||
/**
|
||||
* Fetch all contacts with birthdays from Contacts service
|
||||
*/
|
||||
export async function getBirthdays(): Promise<{
|
||||
data: ContactBirthdaySummary[] | null;
|
||||
error: Error | null;
|
||||
}> {
|
||||
const result = await fetchContactsApi<BirthdaysResponse>('/contacts/birthdays');
|
||||
return {
|
||||
data: result.data?.contacts || null,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Helper Functions
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Get display name from contact, with fallback
|
||||
*/
|
||||
export function getContactDisplayName(contact: ContactBirthdaySummary): string {
|
||||
if (contact.displayName) return contact.displayName;
|
||||
const fullName = [contact.firstName, contact.lastName].filter(Boolean).join(' ');
|
||||
return fullName || 'Unbekannt';
|
||||
}
|
||||
|
||||
/**
|
||||
* Birthday calendar constants
|
||||
*/
|
||||
export const BIRTHDAY_CALENDAR = {
|
||||
id: '__birthdays__',
|
||||
name: 'Geburtstage',
|
||||
color: '#EC4899', // Pink
|
||||
} as const;
|
||||
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,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { Calendar, CheckSquare, Filter } from 'lucide-svelte';
|
||||
import { FilterDropdown, type FilterDropdownOption } from '@manacore/shared-ui';
|
||||
|
||||
interface Props {
|
||||
showEvents: boolean;
|
||||
|
|
@ -19,10 +20,10 @@
|
|||
onRangeChange,
|
||||
}: Props = $props();
|
||||
|
||||
const rangeOptions = [
|
||||
{ value: '7' as const, label: '7 Tage' },
|
||||
{ value: '30' as const, label: '30 Tage' },
|
||||
{ value: 'all' as const, label: 'Alle' },
|
||||
const rangeOptions: FilterDropdownOption[] = [
|
||||
{ value: '7', label: '7 Tage' },
|
||||
{ value: '30', label: '30 Tage' },
|
||||
{ value: 'all', label: 'Alle' },
|
||||
];
|
||||
</script>
|
||||
|
||||
|
|
@ -53,15 +54,13 @@
|
|||
<div class="filter-group">
|
||||
<div class="range-selector">
|
||||
<Filter size={14} />
|
||||
<select
|
||||
<FilterDropdown
|
||||
options={rangeOptions}
|
||||
value={timeRange}
|
||||
onchange={(e) =>
|
||||
onRangeChange?.((e.target as HTMLSelectElement).value as '7' | '30' | 'all')}
|
||||
>
|
||||
{#each rangeOptions as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
onChange={(v) => onRangeChange?.(v as '7' | '30' | 'all')}
|
||||
placeholder="Zeitraum"
|
||||
embedded={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -122,21 +121,6 @@
|
|||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.range-selector select {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: hsl(var(--color-surface));
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.range-selector select:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.agenda-filters {
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -7,8 +7,9 @@
|
|||
import TodoCheckbox from '$lib/components/todo/TodoCheckbox.svelte';
|
||||
import PriorityBadge from '$lib/components/todo/PriorityBadge.svelte';
|
||||
import { Calendar, MapPin, Clock } from 'lucide-svelte';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { format } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import { toDate } from '$lib/utils/eventDateHelpers';
|
||||
|
||||
type ItemType = 'event' | 'todo';
|
||||
|
||||
|
|
@ -29,8 +30,8 @@
|
|||
if (!event) return '';
|
||||
if (event.isAllDay) return 'Ganztägig';
|
||||
|
||||
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
|
||||
const start = toDate(event.startTime);
|
||||
const end = toDate(event.endTime);
|
||||
|
||||
return `${format(start, 'HH:mm')} - ${format(end, 'HH:mm')}`;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,269 @@
|
|||
<script lang="ts">
|
||||
import { env } from '$env/dynamic/public';
|
||||
import type { BirthdayEvent } from '$lib/api/birthdays';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { X, User, ExternalLink, Cake } from 'lucide-svelte';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
|
||||
interface Props {
|
||||
birthday: BirthdayEvent;
|
||||
position: { x: number; y: number };
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { birthday, position, onClose }: Props = $props();
|
||||
|
||||
const CONTACTS_WEB_URL = env.PUBLIC_CONTACTS_WEB_URL || 'http://localhost:5184';
|
||||
const contactUrl = `${CONTACTS_WEB_URL}/contacts/${birthday.contactId}`;
|
||||
|
||||
// Format the original birthday date
|
||||
let birthdayDateFormatted = $derived(() => {
|
||||
try {
|
||||
const date = parseISO(birthday.birthday);
|
||||
return format(date, 'd. MMMM', { locale: de });
|
||||
} catch {
|
||||
return birthday.birthday;
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate popover position to stay within viewport
|
||||
let adjustedPosition = $derived(() => {
|
||||
const popoverWidth = 280;
|
||||
const popoverHeight = 200;
|
||||
const padding = 16;
|
||||
|
||||
let x = position.x;
|
||||
let y = position.y;
|
||||
|
||||
// Check right boundary
|
||||
if (x + popoverWidth + padding > window.innerWidth) {
|
||||
x = window.innerWidth - popoverWidth - padding;
|
||||
}
|
||||
|
||||
// Check bottom boundary
|
||||
if (y + popoverHeight + padding > window.innerHeight) {
|
||||
y = position.y - popoverHeight - 8; // Show above
|
||||
}
|
||||
|
||||
// Check left boundary
|
||||
if (x < padding) {
|
||||
x = padding;
|
||||
}
|
||||
|
||||
// Check top boundary
|
||||
if (y < padding) {
|
||||
y = padding;
|
||||
}
|
||||
|
||||
return { x, y };
|
||||
});
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-50"
|
||||
onclick={handleBackdropClick}
|
||||
onkeydown={(e) => e.key === 'Escape' && onClose()}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
>
|
||||
<!-- Popover -->
|
||||
<div
|
||||
class="birthday-popover"
|
||||
style="left: {adjustedPosition().x}px; top: {adjustedPosition().y}px;"
|
||||
role="dialog"
|
||||
aria-label="Geburtstag Details"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="popover-header">
|
||||
<div class="header-content">
|
||||
{#if birthday.photoUrl}
|
||||
<img src={birthday.photoUrl} alt={birthday.displayName} class="contact-avatar" />
|
||||
{:else}
|
||||
<div class="contact-avatar-placeholder">
|
||||
<Cake size={24} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="header-info">
|
||||
<h3 class="contact-name">{birthday.displayName}</h3>
|
||||
{#if settingsStore.showBirthdayAge && birthday.age > 0}
|
||||
<p class="contact-age">wird {birthday.age} Jahre alt</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="close-btn" onclick={onClose} aria-label="Schließen">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="popover-content">
|
||||
<div class="info-row">
|
||||
<Cake size={16} class="info-icon" />
|
||||
<span>Geburtstag: {birthdayDateFormatted()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="popover-actions">
|
||||
<a href={contactUrl} target="_blank" rel="noopener noreferrer" class="action-btn primary">
|
||||
<User size={16} />
|
||||
<span>Kontakt öffnen</span>
|
||||
<ExternalLink size={14} class="external-icon" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.birthday-popover {
|
||||
position: fixed;
|
||||
width: 280px;
|
||||
background: hsl(var(--color-surface));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px hsl(var(--color-foreground) / 0.15);
|
||||
overflow: hidden;
|
||||
z-index: 51;
|
||||
}
|
||||
|
||||
.popover-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 1rem;
|
||||
background: linear-gradient(135deg, #ec4899 0%, #f472b6 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.contact-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid white;
|
||||
}
|
||||
|
||||
.contact-avatar-placeholder {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--color-surface) / 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid white;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.contact-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.contact-age {
|
||||
font-size: 0.875rem;
|
||||
margin: 0.125rem 0 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: hsl(var(--color-surface) / 0.2);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: hsl(var(--color-surface) / 0.3);
|
||||
}
|
||||
|
||||
.popover-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.info-row :global(.info-icon) {
|
||||
color: #ec4899;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.popover-actions {
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
border-top: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.625rem 1rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
background: #ec4899;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn.primary:hover {
|
||||
background: #db2777;
|
||||
}
|
||||
|
||||
.action-btn :global(.external-icon) {
|
||||
opacity: 0.7;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,329 @@
|
|||
<script lang="ts">
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte';
|
||||
import EventContextMenu from '$lib/components/event/EventContextMenu.svelte';
|
||||
import { format, parseISO, isToday, isTomorrow, startOfDay } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import { toDate } from '$lib/utils/eventDateHelpers';
|
||||
import type { CalendarEvent } from '@calendar/shared';
|
||||
|
||||
interface Props {
|
||||
/** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */
|
||||
date?: Date;
|
||||
onEventClick?: (event: CalendarEvent) => void;
|
||||
}
|
||||
|
||||
let { date, onEventClick }: Props = $props();
|
||||
|
||||
// Use provided date or fall back to viewStore
|
||||
let effectiveDate = $derived(date ?? viewStore.currentDate);
|
||||
|
||||
// Group events by date
|
||||
let groupedEvents = $derived.by(() => {
|
||||
const currentEvents = eventsStore.events ?? [];
|
||||
if (!Array.isArray(currentEvents)) return [];
|
||||
|
||||
// Filter by visible calendars
|
||||
const visibleCalendarIds = new Set(calendarsStore.visibleCalendars.map((c) => c.id));
|
||||
|
||||
// Filter events that start from current date onwards
|
||||
const startDate = startOfDay(effectiveDate);
|
||||
|
||||
const groups: Map<string, CalendarEvent[]> = new Map();
|
||||
|
||||
for (const event of currentEvents) {
|
||||
// Skip events from hidden calendars
|
||||
if (!visibleCalendarIds.has(event.calendarId)) continue;
|
||||
|
||||
const start = toDate(event.startTime);
|
||||
|
||||
// Skip events before the start date
|
||||
if (start < startDate) continue;
|
||||
|
||||
const dateKey = format(start, 'yyyy-MM-dd');
|
||||
|
||||
if (!groups.has(dateKey)) {
|
||||
groups.set(dateKey, []);
|
||||
}
|
||||
groups.get(dateKey)!.push(event);
|
||||
}
|
||||
|
||||
// Sort groups by date
|
||||
return Array.from(groups.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([dateKey, events]) => ({
|
||||
date: parseISO(dateKey),
|
||||
events: events.sort((a, b) => {
|
||||
const aStart = toDate(a.startTime);
|
||||
const bStart = toDate(b.startTime);
|
||||
return aStart.getTime() - bStart.getTime();
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
function formatDateHeader(date: Date) {
|
||||
if (isToday(date)) {
|
||||
return 'Heute';
|
||||
}
|
||||
if (isTomorrow(date)) {
|
||||
return 'Morgen';
|
||||
}
|
||||
return format(date, 'EEEE, d. MMMM', { locale: de });
|
||||
}
|
||||
|
||||
function handleEventClick(event: CalendarEvent) {
|
||||
if (onEventClick) {
|
||||
onEventClick(event);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEventContextMenu(event: CalendarEvent, e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
eventContextMenuStore.show(event, e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
function handleContextMenuEdit(event: CalendarEvent) {
|
||||
if (onEventClick) {
|
||||
onEventClick(event);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="agenda-view">
|
||||
{#if groupedEvents.length === 0}
|
||||
<div class="empty-state">
|
||||
<svg class="empty-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
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>
|
||||
<p>Keine Termine in diesem Zeitraum</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="event-list">
|
||||
{#each groupedEvents as group}
|
||||
<div class="date-group">
|
||||
<h2 class="date-header" class:today={isToday(group.date)}>
|
||||
{formatDateHeader(group.date)}
|
||||
</h2>
|
||||
|
||||
<div class="events-for-date">
|
||||
{#each group.events as event}
|
||||
<button
|
||||
class="event-item"
|
||||
onclick={() => handleEventClick(event)}
|
||||
oncontextmenu={(e) => handleEventContextMenu(event, e)}
|
||||
>
|
||||
<div
|
||||
class="color-bar"
|
||||
style="background-color: {calendarsStore.getColor(event.calendarId)}"
|
||||
></div>
|
||||
<div class="event-content">
|
||||
<div class="event-time">
|
||||
{#if event.isAllDay}
|
||||
Ganztägig
|
||||
{:else}
|
||||
{format(toDate(event.startTime), 'HH:mm')} - {format(
|
||||
toDate(event.endTime),
|
||||
'HH:mm'
|
||||
)}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="event-title">{event.title}</div>
|
||||
{#if event.location}
|
||||
<div class="event-location">
|
||||
<svg
|
||||
class="location-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
{event.location}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<svg class="chevron-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<EventContextMenu onEdit={handleContextMenuEdit} />
|
||||
|
||||
<style>
|
||||
.agenda-view {
|
||||
padding: 1rem;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.event-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.date-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.date-header {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0;
|
||||
padding-left: 0.5rem;
|
||||
padding-bottom: 0.25rem;
|
||||
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
|
||||
}
|
||||
|
||||
.date-header.today {
|
||||
color: hsl(var(--color-primary));
|
||||
border-color: hsl(var(--color-primary) / 0.3);
|
||||
}
|
||||
|
||||
.events-for-date {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: var(--radius-md);
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
background: hsl(var(--color-surface));
|
||||
transition:
|
||||
transform 150ms ease,
|
||||
box-shadow 150ms ease,
|
||||
border-color 150ms ease;
|
||||
}
|
||||
|
||||
.event-item:hover {
|
||||
transform: translateX(4px);
|
||||
border-color: hsl(var(--color-border-hover, var(--color-border)));
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.color-bar {
|
||||
width: 4px;
|
||||
align-self: stretch;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
|
||||
.event-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.event-time {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.event-title {
|
||||
font-weight: 500;
|
||||
font-size: 0.9375rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.event-location {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.location-icon {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chevron-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
opacity: 0.5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.event-item:hover .chevron-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,9 +1,50 @@
|
|||
<script lang="ts">
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { format } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import CalendarHeaderContextMenu from './CalendarHeaderContextMenu.svelte';
|
||||
|
||||
// Format title based on view type
|
||||
let contextMenu: CalendarHeaderContextMenu;
|
||||
|
||||
// Get weekday format string based on setting
|
||||
function getWeekdayFormat(): string {
|
||||
switch (settingsStore.headerWeekdayFormat) {
|
||||
case 'full':
|
||||
return 'EEEE';
|
||||
case 'short':
|
||||
return 'EEE';
|
||||
case 'hidden':
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Get date format string based on settings
|
||||
function getDateFormat(includeYear: boolean = true): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
// Weekday
|
||||
const weekdayFormat = getWeekdayFormat();
|
||||
if (weekdayFormat) {
|
||||
parts.push(weekdayFormat);
|
||||
}
|
||||
|
||||
// Date with optional month
|
||||
if (settingsStore.headerShowDate) {
|
||||
if (settingsStore.headerAlwaysShowMonth) {
|
||||
parts.push(includeYear ? 'd.M. MMMM yyyy' : 'd.M.');
|
||||
} else {
|
||||
parts.push(includeYear ? 'd. MMMM yyyy' : 'd.');
|
||||
}
|
||||
} else if (includeYear) {
|
||||
// Only month and year if date is hidden
|
||||
parts.push('MMMM yyyy');
|
||||
}
|
||||
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
// Format title based on view type and settings
|
||||
let title = $derived.by(() => {
|
||||
const date = viewStore.currentDate;
|
||||
const rangeStart = viewStore.viewRange.start;
|
||||
|
|
@ -11,23 +52,26 @@
|
|||
|
||||
// Helper to format date range
|
||||
const formatRange = () => {
|
||||
const showMonth = settingsStore.headerAlwaysShowMonth;
|
||||
const startFormat = showMonth ? 'd.M.' : 'd.';
|
||||
|
||||
if (rangeStart.getMonth() === rangeEnd.getMonth()) {
|
||||
return (
|
||||
format(rangeStart, 'd.', { locale: de }) +
|
||||
format(rangeStart, startFormat, { locale: de }) +
|
||||
' - ' +
|
||||
format(rangeEnd, 'd. MMMM yyyy', { locale: de })
|
||||
format(rangeEnd, showMonth ? 'd.M. MMMM yyyy' : 'd. MMMM yyyy', { locale: de })
|
||||
);
|
||||
}
|
||||
return (
|
||||
format(rangeStart, 'd. MMM', { locale: de }) +
|
||||
format(rangeStart, showMonth ? 'd.M. MMM' : 'd. MMM', { locale: de }) +
|
||||
' - ' +
|
||||
format(rangeEnd, 'd. MMM yyyy', { locale: de })
|
||||
format(rangeEnd, showMonth ? 'd.M. MMM yyyy' : 'd. MMM yyyy', { locale: de })
|
||||
);
|
||||
};
|
||||
|
||||
switch (viewStore.viewType) {
|
||||
case 'day':
|
||||
return format(date, 'EEEE, d. MMMM yyyy', { locale: de });
|
||||
return format(date, getDateFormat(true), { locale: de });
|
||||
case '5day':
|
||||
case 'week':
|
||||
case '10day':
|
||||
|
|
@ -43,16 +87,29 @@
|
|||
return format(date, 'MMMM yyyy', { locale: de });
|
||||
}
|
||||
});
|
||||
|
||||
function handleContextMenu(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
contextMenu.show(e.clientX, e.clientY);
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="calendar-header">
|
||||
<header
|
||||
class="calendar-header"
|
||||
class:compact={settingsStore.headerCompact}
|
||||
oncontextmenu={handleContextMenu}
|
||||
role="banner"
|
||||
>
|
||||
<h1 class="header-title">{title}</h1>
|
||||
</header>
|
||||
|
||||
<CalendarHeaderContextMenu bind:this={contextMenu} />
|
||||
|
||||
<style>
|
||||
.calendar-header {
|
||||
padding: 0.75rem 1rem;
|
||||
background: transparent;
|
||||
cursor: context-menu;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
|
|
@ -67,4 +124,19 @@
|
|||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Compact variant */
|
||||
.calendar-header.compact {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.calendar-header.compact .header-title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.calendar-header.compact .header-title {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
<script lang="ts">
|
||||
import { ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
|
||||
import { ArrowsIn, TextAa, Calendar, CalendarBlank } from '@manacore/shared-icons';
|
||||
import { settingsStore, type WeekdayFormat } from '$lib/stores/settings.svelte';
|
||||
|
||||
// Context menu state
|
||||
let visible = $state(false);
|
||||
let x = $state(0);
|
||||
let y = $state(0);
|
||||
|
||||
// Build menu items based on current settings
|
||||
let menuItems = $derived.by((): ContextMenuItem[] => {
|
||||
return [
|
||||
{
|
||||
id: 'compact',
|
||||
label: 'Kompakte Ansicht',
|
||||
icon: ArrowsIn,
|
||||
toggle: true,
|
||||
checked: settingsStore.headerCompact,
|
||||
action: () => toggleSetting('headerCompact'),
|
||||
},
|
||||
{
|
||||
id: 'divider-1',
|
||||
label: '',
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
id: 'weekday-full',
|
||||
label: 'Wochentag ausgeschrieben',
|
||||
icon: TextAa,
|
||||
toggle: true,
|
||||
checked: settingsStore.headerWeekdayFormat === 'full',
|
||||
action: () => setWeekdayFormat('full'),
|
||||
},
|
||||
{
|
||||
id: 'weekday-short',
|
||||
label: 'Wochentag gekürzt',
|
||||
icon: TextAa,
|
||||
toggle: true,
|
||||
checked: settingsStore.headerWeekdayFormat === 'short',
|
||||
action: () => setWeekdayFormat('short'),
|
||||
},
|
||||
{
|
||||
id: 'weekday-hidden',
|
||||
label: 'Wochentag ausblenden',
|
||||
icon: TextAa,
|
||||
toggle: true,
|
||||
checked: settingsStore.headerWeekdayFormat === 'hidden',
|
||||
action: () => setWeekdayFormat('hidden'),
|
||||
},
|
||||
{
|
||||
id: 'divider-2',
|
||||
label: '',
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
id: 'show-date',
|
||||
label: 'Datum anzeigen',
|
||||
icon: Calendar,
|
||||
toggle: true,
|
||||
checked: settingsStore.headerShowDate,
|
||||
action: () => toggleSetting('headerShowDate'),
|
||||
},
|
||||
{
|
||||
id: 'always-show-month',
|
||||
label: 'Monat immer anzeigen',
|
||||
icon: CalendarBlank,
|
||||
toggle: true,
|
||||
checked: settingsStore.headerAlwaysShowMonth,
|
||||
action: () => toggleSetting('headerAlwaysShowMonth'),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
function toggleSetting(key: keyof typeof settingsStore.settings) {
|
||||
const currentValue = settingsStore.settings[key];
|
||||
if (typeof currentValue === 'boolean') {
|
||||
settingsStore.set(key, !currentValue);
|
||||
}
|
||||
}
|
||||
|
||||
function setWeekdayFormat(format: WeekdayFormat) {
|
||||
settingsStore.set('headerWeekdayFormat', format);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
visible = false;
|
||||
}
|
||||
|
||||
// Export show function to be called from parent
|
||||
export function show(clientX: number, clientY: number) {
|
||||
x = clientX;
|
||||
y = clientY;
|
||||
visible = true;
|
||||
}
|
||||
|
||||
export function hide() {
|
||||
visible = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<ContextMenu {visible} {x} {y} items={menuItems} onClose={handleClose} />
|
||||
|
|
@ -1,17 +1,21 @@
|
|||
<script lang="ts">
|
||||
import { PillToolbar, PillToolbarDivider } from '@manacore/shared-ui';
|
||||
import { ExpandableToolbar } from '@manacore/shared-ui';
|
||||
import CalendarToolbarContent from './CalendarToolbarContent.svelte';
|
||||
|
||||
interface Props {
|
||||
isSidebarMode?: boolean;
|
||||
isCollapsed?: boolean;
|
||||
isMobile?: boolean;
|
||||
bottomOffset?: string;
|
||||
onModeChange?: (isSidebar: boolean) => void;
|
||||
onCollapsedChange?: (isCollapsed: boolean) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
isSidebarMode = false,
|
||||
isCollapsed = false,
|
||||
isCollapsed = true,
|
||||
isMobile = false,
|
||||
bottomOffset = '70px',
|
||||
onModeChange,
|
||||
onCollapsedChange,
|
||||
}: Props = $props();
|
||||
|
|
@ -19,105 +23,54 @@
|
|||
function toggleSidebarMode() {
|
||||
onModeChange?.(!isSidebarMode);
|
||||
}
|
||||
|
||||
function collapseToolbar() {
|
||||
onCollapsedChange?.(true);
|
||||
}
|
||||
|
||||
function expandToolbar() {
|
||||
onCollapsedChange?.(false);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !isCollapsed}
|
||||
<PillToolbar position="bottom" bottomOffset={isSidebarMode ? '0px' : '70px'}>
|
||||
<CalendarToolbarContent />
|
||||
<ExpandableToolbar
|
||||
{isCollapsed}
|
||||
{onCollapsedChange}
|
||||
{isSidebarMode}
|
||||
{bottomOffset}
|
||||
collapsedTitle="Kalender-Optionen"
|
||||
expandedTitle="Schließen"
|
||||
>
|
||||
<CalendarToolbarContent />
|
||||
|
||||
<PillToolbarDivider />
|
||||
|
||||
<!-- Layout Control -->
|
||||
<div class="segmented-control glass-pill">
|
||||
<button onclick={collapseToolbar} class="segment-btn" title="Toolbar minimieren">
|
||||
<svg class="segment-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="segment-divider"></div>
|
||||
<button
|
||||
onclick={toggleSidebarMode}
|
||||
class="segment-btn"
|
||||
title={isSidebarMode ? 'Zur Bottom-Navigation' : 'Zur Sidebar-Navigation'}
|
||||
>
|
||||
<svg class="segment-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{#if isSidebarMode}
|
||||
<!-- Bottom bar layout icon -->
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 3h18v9H3V3zm0 12h18v6H3v-6z"
|
||||
/>
|
||||
{:else}
|
||||
<!-- Sidebar layout icon -->
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 3h7v18H3V3zm9 0h9v18h-9V3z"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</PillToolbar>
|
||||
{/if}
|
||||
|
||||
<!-- FAB for collapsed state -->
|
||||
{#if isCollapsed}
|
||||
<button
|
||||
onclick={expandToolbar}
|
||||
class="toolbar-fab glass-pill"
|
||||
class:sidebar-mode={isSidebarMode}
|
||||
title="Toolbar anzeigen"
|
||||
>
|
||||
<svg class="fab-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
{#snippet rightActions()}
|
||||
<button
|
||||
onclick={toggleSidebarMode}
|
||||
class="layout-btn"
|
||||
title={isSidebarMode ? 'Zur Bottom-Navigation' : 'Zur Sidebar-Navigation'}
|
||||
>
|
||||
<svg class="layout-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{#if isSidebarMode}
|
||||
<!-- Bottom bar layout icon -->
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 3h18v9H3V3zm0 12h18v6H3v-6z"
|
||||
/>
|
||||
{:else}
|
||||
<!-- Sidebar layout icon -->
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 3h7v18H3V3zm9 0h9v18h-9V3z"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
{/snippet}
|
||||
</ExpandableToolbar>
|
||||
|
||||
<style>
|
||||
.segmented-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.125rem;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.glass-pill {
|
||||
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 2px 4px rgba(0, 0, 0, 0.05);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
:global(.dark) .glass-pill {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.segment-btn {
|
||||
/* Layout toggle button - app-specific style */
|
||||
.layout-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.375rem;
|
||||
padding: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
|
@ -126,64 +79,17 @@
|
|||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.segment-btn:hover {
|
||||
.layout-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
:global(.dark) .segment-btn:hover {
|
||||
:global(.dark) .layout-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.segment-divider {
|
||||
width: 1px;
|
||||
height: 1rem;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
margin: 0 0.125rem;
|
||||
}
|
||||
|
||||
:global(.dark) .segment-divider {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.segment-icon {
|
||||
.layout-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
/* FAB for collapsed state - positioned right, above PillNav FAB */
|
||||
.toolbar-fab {
|
||||
position: fixed;
|
||||
bottom: calc(56px + env(safe-area-inset-bottom, 0px)); /* Above PillNav FAB */
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 9999px 0 0 9999px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.toolbar-fab.sidebar-mode {
|
||||
bottom: calc(56px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.toolbar-fab:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.fab-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.toolbar-fab:hover .fab-icon {
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
'14day',
|
||||
'month',
|
||||
'year',
|
||||
'agenda',
|
||||
];
|
||||
|
||||
// Convert to ViewOptions for PillViewSwitcher
|
||||
|
|
@ -100,7 +101,6 @@
|
|||
options={viewOptions}
|
||||
value={viewStore.viewType}
|
||||
onChange={handleViewChange}
|
||||
primaryColor="#3b82f6"
|
||||
embedded={true}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -154,21 +154,12 @@
|
|||
}
|
||||
|
||||
.toolbar-content.vertical :global(.pill-view-switcher .switcher-btn:hover) {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:global(.dark) .toolbar-content.vertical :global(.pill-view-switcher .switcher-btn:hover) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background: hsl(var(--color-foreground) / 0.05);
|
||||
}
|
||||
|
||||
.toolbar-content.vertical :global(.pill-view-switcher .switcher-btn.active) {
|
||||
background: color-mix(in srgb, #3b82f6 15%, transparent 85%);
|
||||
border-color: color-mix(in srgb, #3b82f6 25%, transparent 75%);
|
||||
}
|
||||
|
||||
:global(.dark) .toolbar-content.vertical :global(.pill-view-switcher .switcher-btn.active) {
|
||||
background: color-mix(in srgb, #3b82f6 25%, transparent 75%);
|
||||
border-color: color-mix(in srgb, #3b82f6 35%, transparent 65%);
|
||||
background: hsl(var(--color-primary) / 0.15);
|
||||
border-color: hsl(var(--color-primary) / 0.25);
|
||||
}
|
||||
|
||||
/* PillTimeRangeSelector in vertical mode */
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import DateStripContextMenu from './DateStripContextMenu.svelte';
|
||||
import {
|
||||
format,
|
||||
isToday,
|
||||
|
|
@ -9,16 +11,28 @@
|
|||
subDays,
|
||||
startOfDay,
|
||||
isWithinInterval,
|
||||
getWeek,
|
||||
startOfWeek,
|
||||
} from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import SunCalc from 'suncalc';
|
||||
|
||||
interface Props {
|
||||
isSidebarMode?: boolean;
|
||||
// Context menu reference
|
||||
let contextMenu: DateStripContextMenu;
|
||||
|
||||
function handleContextMenu(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
contextMenu?.show(e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
let { isSidebarMode = false }: Props = $props();
|
||||
interface Props {
|
||||
isSidebarMode?: boolean;
|
||||
isToolbarExpanded?: boolean;
|
||||
hasTagStrip?: boolean; // Whether TagStrip is visible below
|
||||
}
|
||||
|
||||
let { isSidebarMode = false, isToolbarExpanded = false, hasTagStrip = false }: Props = $props();
|
||||
|
||||
// Get event count for a day (max 5 dots displayed)
|
||||
function getEventCount(date: Date): number {
|
||||
|
|
@ -61,6 +75,17 @@
|
|||
return { significant: false, emoji: '' };
|
||||
}
|
||||
|
||||
// Check if a date is the first day of the week (respects weekStartsOn setting)
|
||||
function isFirstDayOfWeek(date: Date): boolean {
|
||||
const weekStart = startOfWeek(date, { weekStartsOn: settingsStore.weekStartsOn });
|
||||
return isSameDay(date, weekStart);
|
||||
}
|
||||
|
||||
// Get week number for a date
|
||||
function getWeekNumber(date: Date): number {
|
||||
return getWeek(date, { weekStartsOn: settingsStore.weekStartsOn });
|
||||
}
|
||||
|
||||
// Reactive view range - needed to trigger re-renders
|
||||
let viewRange = $derived(viewStore.viewRange);
|
||||
let currentDate = $derived(viewStore.currentDate);
|
||||
|
|
@ -98,7 +123,7 @@
|
|||
}
|
||||
});
|
||||
|
||||
async function scrollToDate(date: Date) {
|
||||
async function scrollToDate(date: Date, instant = false) {
|
||||
await tick();
|
||||
|
||||
const targetDate = startOfDay(date);
|
||||
|
|
@ -115,7 +140,7 @@
|
|||
);
|
||||
if (dayElement) {
|
||||
dayElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
behavior: instant ? 'instant' : 'smooth',
|
||||
inline: 'center',
|
||||
block: 'nearest',
|
||||
});
|
||||
|
|
@ -185,7 +210,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Get the month of the center visible day
|
||||
// Get the month of the center visible day (initial: today)
|
||||
let visibleMonth = $state(format(new Date(), 'MMMM yyyy', { locale: de }));
|
||||
|
||||
function updateVisibleMonth() {
|
||||
|
|
@ -209,20 +234,35 @@
|
|||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
scrollToDate(viewStore.currentDate);
|
||||
onMount(async () => {
|
||||
// Always scroll to today on mount, then update the visible month
|
||||
const today = new Date();
|
||||
await scrollToDate(today, true);
|
||||
updateVisibleMonth();
|
||||
checkTodayVisibility();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="date-strip-wrapper" class:sidebar-mode={isSidebarMode}>
|
||||
{#if !isTodayVisible}
|
||||
<button onclick={goToToday} title="Zum heutigen Tag" class="today-button"> Heute </button>
|
||||
{/if}
|
||||
|
||||
<div class="date-strip-container">
|
||||
<div
|
||||
class="date-strip-wrapper"
|
||||
class:sidebar-mode={isSidebarMode}
|
||||
class:toolbar-expanded={isToolbarExpanded}
|
||||
class:compact={settingsStore.dateStripCompact}
|
||||
class:has-tag-strip={hasTagStrip}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="date-strip-container" oncontextmenu={handleContextMenu}>
|
||||
<!-- Month label -->
|
||||
<div class="month-header">
|
||||
<span class="month-label">{visibleMonth}</span>
|
||||
<span class="month-label">
|
||||
{#if !isTodayVisible}
|
||||
<button onclick={goToToday} title="Zum heutigen Tag" class="today-button">
|
||||
<span class="today-label">Heute</span>
|
||||
<span class="today-date">{format(new Date(), 'd. MMM', { locale: de })}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{visibleMonth}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Days row -->
|
||||
|
|
@ -237,12 +277,16 @@
|
|||
{@const isFirstOfMonth = day.getDate() === 1}
|
||||
{@const moonPhase = isSignificantMoonPhase(day)}
|
||||
{@const eventCount = getEventCount(day)}
|
||||
{@const showWeekNumber = settingsStore.dateStripShowWeekNumbers && isFirstDayOfWeek(day)}
|
||||
{#if isFirstOfMonth}
|
||||
<div class="month-divider"></div>
|
||||
<div
|
||||
class="month-divider"
|
||||
class:show-line={settingsStore.dateStripShowMonthDividers}
|
||||
></div>
|
||||
{/if}
|
||||
<button
|
||||
class="day-item"
|
||||
class:weekend={dayIsWeekend}
|
||||
class:weekend={dayIsWeekend && settingsStore.dateStripHighlightWeekends}
|
||||
class:selected={dayIsSelected && !dayIsToday}
|
||||
class:in-range={dayInRange && !dayIsToday}
|
||||
class:range-start={dayIsRangeStart && !dayIsToday}
|
||||
|
|
@ -250,23 +294,22 @@
|
|||
data-date={format(day, 'yyyy-MM-dd')}
|
||||
data-is-today={dayIsToday}
|
||||
onclick={() => handleDayClick(day)}
|
||||
style={dayIsToday
|
||||
? 'background: #3b82f6; color: white; border-radius: 10px; font-weight: 700; box-shadow: 0 2px 8px rgba(59, 130, 246, 0.4);'
|
||||
: ''}
|
||||
class:is-today={dayIsToday}
|
||||
>
|
||||
{#if moonPhase.significant}
|
||||
{#if showWeekNumber}
|
||||
<span class="week-number-label">KW {getWeekNumber(day)}</span>
|
||||
{/if}
|
||||
{#if moonPhase.significant && settingsStore.dateStripShowMoonPhases}
|
||||
<span class="moon-indicator">{moonPhase.emoji}</span>
|
||||
{/if}
|
||||
<span class="day-weekday" style={dayIsToday ? 'opacity: 1; color: white;' : ''}
|
||||
>{format(day, 'EE', { locale: de })}</span
|
||||
>
|
||||
<span class="day-number" style={dayIsToday ? 'color: white;' : ''}
|
||||
>{format(day, 'd')}</span
|
||||
>
|
||||
{#if eventCount > 0}
|
||||
<div class="event-dots" style={dayIsToday ? 'opacity: 0.9;' : ''}>
|
||||
{#if settingsStore.dateStripShowWeekday}
|
||||
<span class="day-weekday">{format(day, 'EE', { locale: de })}</span>
|
||||
{/if}
|
||||
<span class="day-number">{format(day, 'd')}</span>
|
||||
{#if eventCount > 0 && settingsStore.dateStripShowEventIndicators}
|
||||
<div class="event-dots">
|
||||
{#each Array(eventCount) as _, i}
|
||||
<span class="event-dot" style={dayIsToday ? 'background: white;' : ''}></span>
|
||||
<span class="event-dot"></span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -276,45 +319,93 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<DateStripContextMenu bind:this={contextMenu} />
|
||||
|
||||
<style>
|
||||
.date-strip-wrapper {
|
||||
position: fixed;
|
||||
bottom: calc(200px + env(safe-area-inset-bottom, 0px)); /* Above InputBar */
|
||||
bottom: calc(140px + env(safe-area-inset-bottom, 0px)); /* Above InputBar + PillNav */
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 48;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
align-items: stretch;
|
||||
pointer-events: none;
|
||||
transition: bottom 0.3s ease;
|
||||
transition: bottom 0.2s ease;
|
||||
}
|
||||
|
||||
/* When PillNav is in sidebar mode, no PillNav/Toolbar at bottom - just InputBar */
|
||||
/* When toolbar is expanded, push DateStrip up */
|
||||
.date-strip-wrapper.toolbar-expanded {
|
||||
bottom: calc(210px + env(safe-area-inset-bottom, 0px)); /* Extra space for toolbar */
|
||||
}
|
||||
|
||||
/* When PillNav is in sidebar mode, no PillNav at bottom - just InputBar */
|
||||
.date-strip-wrapper.sidebar-mode {
|
||||
bottom: calc(70px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.date-strip-wrapper.sidebar-mode.toolbar-expanded {
|
||||
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 {
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px solid #d1d5db;
|
||||
position: absolute;
|
||||
right: 100%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
margin-right: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.375rem 0.875rem;
|
||||
background: hsl(var(--color-surface) / 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
color: #9ca3af;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.375rem;
|
||||
color: hsl(var(--color-primary));
|
||||
pointer-events: auto;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 8px hsl(var(--color-foreground) / 0.08);
|
||||
}
|
||||
|
||||
.today-button:hover {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-color: #3b82f6;
|
||||
color: #3b82f6;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.2);
|
||||
background: hsl(var(--color-surface) / 0.95);
|
||||
border-color: hsl(var(--color-primary) / 0.3);
|
||||
transform: translateY(-50%) scale(1.02);
|
||||
box-shadow: 0 4px 12px hsl(var(--color-foreground) / 0.12);
|
||||
}
|
||||
|
||||
.today-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.today-date {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.date-strip-container {
|
||||
|
|
@ -322,12 +413,12 @@
|
|||
flex-direction: column;
|
||||
background: var(--color-surface, #ffffff);
|
||||
border-radius: 16px;
|
||||
margin: 0 1rem;
|
||||
padding: 0.5rem;
|
||||
margin: 0;
|
||||
padding: 0.5rem 0;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
pointer-events: auto;
|
||||
max-width: calc(100vw - 2rem);
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
|
@ -340,20 +431,27 @@
|
|||
}
|
||||
|
||||
.month-label {
|
||||
position: relative;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-foreground, #1f2937);
|
||||
white-space: nowrap;
|
||||
min-width: 150px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.month-divider {
|
||||
width: 1px;
|
||||
height: 40px;
|
||||
background: var(--color-border, #e5e7eb);
|
||||
background: transparent;
|
||||
margin: 0 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.month-divider.show-line {
|
||||
background: hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.days-scroll {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -363,8 +461,16 @@
|
|||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
scroll-behavior: auto;
|
||||
padding: 1.25rem 0.25rem 0.25rem;
|
||||
padding: 1.25rem 1rem 0.25rem;
|
||||
margin-top: -1rem;
|
||||
mask-image: linear-gradient(to right, transparent 0%, black 8%, black 92%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent 0%,
|
||||
black 8%,
|
||||
black 92%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.days-scroll::-webkit-scrollbar {
|
||||
|
|
@ -398,6 +504,20 @@
|
|||
line-height: 1;
|
||||
}
|
||||
|
||||
.week-number-label {
|
||||
position: absolute;
|
||||
top: -14px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.event-dots {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
|
|
@ -409,7 +529,7 @@
|
|||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: #3b82f6;
|
||||
background: hsl(var(--color-primary));
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
|
|
@ -418,18 +538,34 @@
|
|||
}
|
||||
|
||||
.day-item.weekend {
|
||||
color: var(--color-muted-foreground, #6b7280);
|
||||
background: transparent;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.day-item.weekend:hover {
|
||||
background: hsl(var(--color-muted) / 0.3);
|
||||
border-color: hsl(var(--color-muted-foreground) / 0.5);
|
||||
}
|
||||
|
||||
/* Weekend + in-range combination */
|
||||
.day-item.weekend.in-range {
|
||||
background: hsl(var(--color-primary) / 0.15);
|
||||
border: 1px solid hsl(var(--color-primary) / 0.4);
|
||||
}
|
||||
|
||||
.day-item.weekend.in-range:hover {
|
||||
background: hsl(var(--color-primary) / 0.25);
|
||||
}
|
||||
|
||||
.day-item.selected {
|
||||
background: var(--color-muted, #f3f4f6);
|
||||
color: #3b82f6;
|
||||
color: hsl(var(--color-primary));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* View range highlighting */
|
||||
.day-item.in-range {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
background: hsl(var(--color-primary) / 0.15);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
|
|
@ -446,7 +582,33 @@
|
|||
}
|
||||
|
||||
.day-item.in-range:hover {
|
||||
background: rgba(59, 130, 246, 0.25);
|
||||
background: hsl(var(--color-primary) / 0.25);
|
||||
}
|
||||
|
||||
/* Today styling */
|
||||
.day-item.is-today {
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground, 0 0% 100%));
|
||||
border-radius: 10px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2px 8px hsl(var(--color-primary) / 0.4);
|
||||
}
|
||||
|
||||
.day-item.is-today .day-weekday {
|
||||
opacity: 1;
|
||||
color: hsl(var(--color-primary-foreground, 0 0% 100%));
|
||||
}
|
||||
|
||||
.day-item.is-today .day-number {
|
||||
color: hsl(var(--color-primary-foreground, 0 0% 100%));
|
||||
}
|
||||
|
||||
.day-item.is-today .event-dots {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.day-item.is-today .event-dot {
|
||||
background: hsl(var(--color-primary-foreground, 0 0% 100%));
|
||||
}
|
||||
|
||||
.day-weekday {
|
||||
|
|
@ -465,9 +627,15 @@
|
|||
|
||||
/* Responsive */
|
||||
@media (max-width: 640px) {
|
||||
.date-strip-wrapper {
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.date-strip-container {
|
||||
margin: 0 0.5rem;
|
||||
padding: 0.375rem;
|
||||
margin: 0;
|
||||
padding: 0.375rem 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.month-label {
|
||||
|
|
@ -484,6 +652,11 @@
|
|||
top: -14px;
|
||||
}
|
||||
|
||||
.week-number-label {
|
||||
top: -12px;
|
||||
font-size: 0.5rem;
|
||||
}
|
||||
|
||||
.day-number {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
|
@ -497,4 +670,67 @@
|
|||
margin: 0 0.375rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Compact mode */
|
||||
.date-strip-wrapper.compact .date-strip-container {
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.date-strip-wrapper.compact .month-header {
|
||||
padding: 0.125rem 0.5rem 0.25rem;
|
||||
}
|
||||
|
||||
.date-strip-wrapper.compact .month-label {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.date-strip-wrapper.compact .day-item {
|
||||
min-width: 40px;
|
||||
height: 44px;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.date-strip-wrapper.compact .day-weekday {
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.date-strip-wrapper.compact .day-number {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.date-strip-wrapper.compact .moon-indicator {
|
||||
font-size: 0.875rem;
|
||||
top: -12px;
|
||||
}
|
||||
|
||||
.date-strip-wrapper.compact .week-number-label {
|
||||
top: -10px;
|
||||
font-size: 0.5rem;
|
||||
}
|
||||
|
||||
.date-strip-wrapper.compact .month-divider {
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.date-strip-wrapper.compact .event-dot {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.date-strip-wrapper.compact .month-header {
|
||||
padding-top: 0.375rem;
|
||||
}
|
||||
|
||||
.date-strip-wrapper.compact .today-button {
|
||||
padding: 0.25rem 0.625rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.date-strip-wrapper.compact .today-label {
|
||||
font-size: 0.5625rem;
|
||||
}
|
||||
|
||||
.date-strip-wrapper.compact .today-date {
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
<script lang="ts">
|
||||
import { ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
|
||||
import { Moon, Calendar, Eye, Columns, ArrowsIn, ArrowsOut } from '@manacore/shared-icons';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
|
||||
// Context menu state
|
||||
let visible = $state(false);
|
||||
let x = $state(0);
|
||||
let y = $state(0);
|
||||
|
||||
// Build menu items based on current settings
|
||||
let menuItems = $derived.by((): ContextMenuItem[] => {
|
||||
return [
|
||||
{
|
||||
id: 'moon-phases',
|
||||
label: 'Mondphasen',
|
||||
icon: Moon,
|
||||
toggle: true,
|
||||
checked: settingsStore.dateStripShowMoonPhases,
|
||||
action: () => toggleSetting('dateStripShowMoonPhases'),
|
||||
},
|
||||
{
|
||||
id: 'event-indicators',
|
||||
label: 'Termin-Punkte',
|
||||
icon: Eye,
|
||||
toggle: true,
|
||||
checked: settingsStore.dateStripShowEventIndicators,
|
||||
action: () => toggleSetting('dateStripShowEventIndicators'),
|
||||
},
|
||||
{
|
||||
id: 'weekday',
|
||||
label: 'Wochentag',
|
||||
icon: Calendar,
|
||||
toggle: true,
|
||||
checked: settingsStore.dateStripShowWeekday,
|
||||
action: () => toggleSetting('dateStripShowWeekday'),
|
||||
},
|
||||
{
|
||||
id: 'week-numbers',
|
||||
label: 'Kalenderwochen',
|
||||
icon: Calendar,
|
||||
toggle: true,
|
||||
checked: settingsStore.dateStripShowWeekNumbers,
|
||||
action: () => toggleSetting('dateStripShowWeekNumbers'),
|
||||
},
|
||||
{
|
||||
id: 'divider-1',
|
||||
label: '',
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
id: 'highlight-weekends',
|
||||
label: 'Wochenenden hervorheben',
|
||||
icon: Calendar,
|
||||
toggle: true,
|
||||
checked: settingsStore.dateStripHighlightWeekends,
|
||||
action: () => toggleSetting('dateStripHighlightWeekends'),
|
||||
},
|
||||
{
|
||||
id: 'month-dividers',
|
||||
label: 'Monatstrennlinien',
|
||||
icon: Columns,
|
||||
toggle: true,
|
||||
checked: settingsStore.dateStripShowMonthDividers,
|
||||
action: () => toggleSetting('dateStripShowMonthDividers'),
|
||||
},
|
||||
{
|
||||
id: 'divider-2',
|
||||
label: '',
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
id: 'compact',
|
||||
label: 'Kompakte Ansicht',
|
||||
icon: ArrowsIn,
|
||||
toggle: true,
|
||||
checked: settingsStore.dateStripCompact,
|
||||
action: () => toggleSetting('dateStripCompact'),
|
||||
},
|
||||
{
|
||||
id: 'divider-3',
|
||||
label: '',
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
id: 'minimize',
|
||||
label: settingsStore.dateStripCollapsed ? 'Erweitern' : 'Minimieren',
|
||||
icon: settingsStore.dateStripCollapsed ? ArrowsOut : ArrowsIn,
|
||||
action: () => toggleSetting('dateStripCollapsed'),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
function toggleSetting(key: keyof typeof settingsStore.settings) {
|
||||
const currentValue = settingsStore.settings[key];
|
||||
if (typeof currentValue === 'boolean') {
|
||||
settingsStore.set(key, !currentValue);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
visible = false;
|
||||
}
|
||||
|
||||
// Export show function to be called from parent
|
||||
export function show(clientX: number, clientY: number) {
|
||||
x = clientX;
|
||||
y = clientY;
|
||||
visible = true;
|
||||
}
|
||||
|
||||
export function hide() {
|
||||
visible = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<ContextMenu {visible} {x} {y} items={menuItems} onClose={handleClose} />
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
<script lang="ts">
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { format } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import DateStripContextMenu from './DateStripContextMenu.svelte';
|
||||
|
||||
interface Props {
|
||||
isSidebarMode?: boolean;
|
||||
isToolbarExpanded?: boolean;
|
||||
isMobile?: boolean;
|
||||
hasTagStrip?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
isSidebarMode = false,
|
||||
isToolbarExpanded = false,
|
||||
isMobile = false,
|
||||
hasTagStrip = false,
|
||||
}: Props = $props();
|
||||
|
||||
let contextMenu: DateStripContextMenu;
|
||||
|
||||
function handleClick() {
|
||||
settingsStore.set('dateStripCollapsed', false);
|
||||
}
|
||||
|
||||
function handleContextMenu(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
contextMenu?.show(e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
// Format current date for FAB display: "Dez 14"
|
||||
let fabLabel = $derived(format(new Date(), 'MMM d', { locale: de }));
|
||||
</script>
|
||||
|
||||
<div
|
||||
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
|
||||
onclick={handleClick}
|
||||
oncontextmenu={handleContextMenu}
|
||||
class="datestrip-fab"
|
||||
title="Datumsleiste erweitern (Rechtsklick für Optionen)"
|
||||
>
|
||||
<span class="fab-label">{fabLabel}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DateStripContextMenu bind:this={contextMenu} />
|
||||
|
||||
<style>
|
||||
.datestrip-fab-container {
|
||||
position: fixed;
|
||||
bottom: calc(70px + 9px + env(safe-area-inset-bottom, 0px));
|
||||
/* Position left of InputBar: center (50%) minus half of InputBar (225px) minus gap (8px) minus fab width (54px) */
|
||||
left: calc(50% - 225px - 8px - 54px);
|
||||
z-index: 49;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
bottom 0.2s ease,
|
||||
left 0.2s ease;
|
||||
}
|
||||
|
||||
.datestrip-fab-container.sidebar-mode {
|
||||
bottom: calc(9px + env(safe-area-inset-bottom, 0px));
|
||||
/* In sidebar mode, InputBar is 700px wide, so position accordingly */
|
||||
left: calc(50% - 350px - 8px - 54px);
|
||||
}
|
||||
|
||||
.datestrip-fab-container.toolbar-expanded {
|
||||
bottom: calc(140px + 9px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.datestrip-fab-container.sidebar-mode.toolbar-expanded {
|
||||
bottom: calc(70px + 9px + env(safe-area-inset-bottom, 0px));
|
||||
/* In sidebar mode, InputBar is 700px wide */
|
||||
left: calc(50% - 350px - 8px - 54px);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.datestrip-fab-container {
|
||||
left: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 54px;
|
||||
height: 54px;
|
||||
padding: 0 0.75rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s ease;
|
||||
pointer-events: auto;
|
||||
background: hsl(var(--color-surface) / 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
box-shadow: 0 2px 8px hsl(var(--color-foreground) / 0.08);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.datestrip-fab:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px hsl(var(--color-foreground) / 0.15);
|
||||
}
|
||||
|
||||
.fab-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -5,129 +5,89 @@
|
|||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { searchStore } from '$lib/stores/search.svelte';
|
||||
import { todosStore, type Task } from '$lib/stores/todos.svelte';
|
||||
import TaskBlock from './TaskBlock.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte';
|
||||
import { birthdaysStore } from '$lib/stores/birthdays.svelte';
|
||||
import BirthdayPopover from '$lib/components/birthday/BirthdayPopover.svelte';
|
||||
import { useVisibleHours, useCurrentTimeIndicator, useBirthdayPopover } from '$lib/composables';
|
||||
import { toDate } from '$lib/utils/eventDateHelpers';
|
||||
import { HOUR_HEIGHT_PX, SNAP_INTERVAL_MINUTES } from '$lib/utils/calendarConstants';
|
||||
import {
|
||||
format,
|
||||
isToday,
|
||||
parseISO,
|
||||
differenceInMinutes,
|
||||
addMinutes,
|
||||
setHours,
|
||||
setMinutes,
|
||||
} from 'date-fns';
|
||||
getVisibleTimedEvents,
|
||||
getVisibleAllDayEvents,
|
||||
getVisibleOverflowEvents,
|
||||
type OverflowEvents,
|
||||
} from '$lib/utils/eventFiltering';
|
||||
import EventCard from './EventCard.svelte';
|
||||
import TaskBlock from './TaskBlock.svelte';
|
||||
import EventContextMenu from '$lib/components/event/EventContextMenu.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { format, isToday, differenceInMinutes, addMinutes, setHours, setMinutes } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
|
||||
import type { CalendarEvent } from '@calendar/shared';
|
||||
|
||||
interface Props {
|
||||
/** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */
|
||||
date?: Date;
|
||||
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
|
||||
onEventClick?: (event: CalendarEvent) => void;
|
||||
onTaskClick?: (task: Task) => void;
|
||||
}
|
||||
|
||||
let { onQuickCreate, onEventClick, onTaskClick }: Props = $props();
|
||||
let { date, onQuickCreate, onEventClick, onTaskClick }: Props = $props();
|
||||
|
||||
// Constants
|
||||
const HOUR_HEIGHT = 60; // pixels per hour
|
||||
const SNAP_MINUTES = 15; // snap to 15-minute intervals
|
||||
// Use provided date or fall back to viewStore
|
||||
let effectiveDate = $derived(date ?? viewStore.currentDate);
|
||||
|
||||
// Generate hours (filtered based on settings)
|
||||
let allHours = Array.from({ length: 24 }, (_, i) => i);
|
||||
let hours = $derived(
|
||||
settingsStore.filterHoursEnabled
|
||||
? allHours.filter((h) => h >= settingsStore.dayStartHour && h < settingsStore.dayEndHour)
|
||||
: allHours
|
||||
);
|
||||
// Use shared constants
|
||||
const HOUR_HEIGHT = HOUR_HEIGHT_PX;
|
||||
const SNAP_MINUTES = SNAP_INTERVAL_MINUTES;
|
||||
|
||||
// Calculate visible hours range for positioning
|
||||
let firstVisibleHour = $derived(
|
||||
settingsStore.filterHoursEnabled ? settingsStore.dayStartHour : 0
|
||||
);
|
||||
let lastVisibleHour = $derived(settingsStore.filterHoursEnabled ? settingsStore.dayEndHour : 24);
|
||||
let totalVisibleHours = $derived(lastVisibleHour - firstVisibleHour);
|
||||
// Use composables for hour filtering and time indicator
|
||||
const visibleHours = useVisibleHours();
|
||||
const timeIndicator = useCurrentTimeIndicator();
|
||||
|
||||
// Helper to convert minutes to percentage position (accounting for hidden hours)
|
||||
function minutesToPercent(minutes: number): number {
|
||||
const adjustedMinutes = minutes - firstVisibleHour * 60;
|
||||
return (adjustedMinutes / (totalVisibleHours * 60)) * 100;
|
||||
}
|
||||
// Destructure for convenience (these are reactive getters)
|
||||
let hours = $derived(visibleHours.hours);
|
||||
let firstVisibleHour = $derived(visibleHours.firstVisibleHour);
|
||||
let lastVisibleHour = $derived(visibleHours.lastVisibleHour);
|
||||
let totalVisibleHours = $derived(visibleHours.totalVisibleHours);
|
||||
const minutesToPercent = visibleHours.minutesToPercent;
|
||||
|
||||
// Current time indicator position
|
||||
let now = $state(new Date());
|
||||
let currentTimePosition = $derived.by(() => {
|
||||
const minutes = now.getHours() * 60 + now.getMinutes();
|
||||
return minutesToPercent(minutes);
|
||||
});
|
||||
|
||||
// Update current time every minute
|
||||
$effect(() => {
|
||||
const interval = setInterval(() => {
|
||||
now = new Date();
|
||||
}, 60000);
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
let currentTimePosition = $derived(minutesToPercent(timeIndicator.currentMinutes));
|
||||
|
||||
// Get timed events, filtering out those outside visible range when hour filter is enabled
|
||||
let timedEvents = $derived.by(() => {
|
||||
const allEvents = eventsStore.getEventsForDay(viewStore.currentDate).filter((e) => !e.isAllDay);
|
||||
|
||||
if (settingsStore.filterHoursEnabled) {
|
||||
const visibleStartMinutes = settingsStore.dayStartHour * 60;
|
||||
const visibleEndMinutes = settingsStore.dayEndHour * 60;
|
||||
|
||||
return allEvents.filter((event) => {
|
||||
const start =
|
||||
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
|
||||
|
||||
const eventStartMinutes = start.getHours() * 60 + start.getMinutes();
|
||||
const eventEndMinutes = end.getHours() * 60 + end.getMinutes();
|
||||
|
||||
// Event overlaps with visible range
|
||||
return eventStartMinutes < visibleEndMinutes && eventEndMinutes > visibleStartMinutes;
|
||||
});
|
||||
}
|
||||
|
||||
return allEvents;
|
||||
});
|
||||
let timedEvents = $derived(
|
||||
getVisibleTimedEvents(
|
||||
eventsStore.getEventsForDay(effectiveDate),
|
||||
calendarsStore.visibleCalendars,
|
||||
{
|
||||
filterHoursEnabled: settingsStore.filterHoursEnabled,
|
||||
dayStartHour: settingsStore.dayStartHour,
|
||||
dayEndHour: settingsStore.dayEndHour,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Get events that are completely outside the visible time range
|
||||
let overflowEvents = $derived.by(() => {
|
||||
let overflowEvents = $derived.by((): OverflowEvents => {
|
||||
if (!settingsStore.filterHoursEnabled) {
|
||||
return { before: [] as CalendarEvent[], after: [] as CalendarEvent[] };
|
||||
return { before: [], after: [] };
|
||||
}
|
||||
|
||||
const allEvents = eventsStore.getEventsForDay(viewStore.currentDate).filter((e) => !e.isAllDay);
|
||||
const before: CalendarEvent[] = [];
|
||||
const after: CalendarEvent[] = [];
|
||||
|
||||
const visibleStartMinutes = settingsStore.dayStartHour * 60;
|
||||
const visibleEndMinutes = settingsStore.dayEndHour * 60;
|
||||
|
||||
for (const event of allEvents) {
|
||||
const start =
|
||||
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
|
||||
|
||||
const eventStartMinutes = start.getHours() * 60 + start.getMinutes();
|
||||
const eventEndMinutes = end.getHours() * 60 + end.getMinutes();
|
||||
|
||||
// Event ends before visible range starts
|
||||
if (eventEndMinutes <= visibleStartMinutes) {
|
||||
before.push(event);
|
||||
}
|
||||
// Event starts after visible range ends
|
||||
else if (eventStartMinutes >= visibleEndMinutes) {
|
||||
after.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
return { before, after };
|
||||
return getVisibleOverflowEvents(
|
||||
eventsStore.getEventsForDay(effectiveDate),
|
||||
calendarsStore.visibleCalendars,
|
||||
settingsStore.dayStartHour,
|
||||
settingsStore.dayEndHour
|
||||
);
|
||||
});
|
||||
|
||||
let allDayEvents = $derived(
|
||||
eventsStore.getEventsForDay(viewStore.currentDate).filter((e) => e.isAllDay)
|
||||
getVisibleAllDayEvents(
|
||||
eventsStore.getEventsForDay(effectiveDate),
|
||||
calendarsStore.visibleCalendars
|
||||
)
|
||||
);
|
||||
|
||||
// Get display mode for an event (per-event override takes precedence over global setting)
|
||||
|
|
@ -145,6 +105,15 @@
|
|||
|
||||
let blockAllDayEvents = $derived(allDayEvents.filter((e) => getEventDisplayMode(e) === 'block'));
|
||||
|
||||
// Birthday Popover (using composable)
|
||||
const birthdayPopover = useBirthdayPopover();
|
||||
|
||||
// Get birthdays for current day (if enabled in settings)
|
||||
let birthdays = $derived.by(() => {
|
||||
if (!settingsStore.showBirthdays) return [];
|
||||
return birthdaysStore.getBirthdaysForDay(effectiveDate);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Drag & Drop State
|
||||
// ============================================================================
|
||||
|
|
@ -165,6 +134,7 @@
|
|||
let resizeOriginalEnd = $state<Date | null>(null);
|
||||
let resizePreviewTop = $state(0);
|
||||
let resizePreviewHeight = $state(0);
|
||||
let resizeOffsetMinutes = $state(0);
|
||||
|
||||
// Track if we actually moved during drag/resize (to prevent click on simple mousedown/up)
|
||||
let hasMoved = $state(false);
|
||||
|
|
@ -210,8 +180,8 @@
|
|||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
|
||||
const start = toDate(event.startTime);
|
||||
const end = toDate(event.endTime);
|
||||
const startMinutes = start.getHours() * 60 + start.getMinutes();
|
||||
const duration = differenceInMinutes(end, start);
|
||||
|
||||
|
|
@ -255,18 +225,12 @@
|
|||
Math.min(newStartMinutes, lastVisibleHour * 60 - 30)
|
||||
);
|
||||
|
||||
const start =
|
||||
typeof draggedEvent.startTime === 'string'
|
||||
? parseISO(draggedEvent.startTime)
|
||||
: draggedEvent.startTime;
|
||||
const end =
|
||||
typeof draggedEvent.endTime === 'string'
|
||||
? parseISO(draggedEvent.endTime)
|
||||
: draggedEvent.endTime;
|
||||
const start = toDate(draggedEvent.startTime);
|
||||
const end = toDate(draggedEvent.endTime);
|
||||
const duration = differenceInMinutes(end, start);
|
||||
|
||||
// Create new start time on same day
|
||||
let newStart = new Date(viewStore.currentDate);
|
||||
let newStart = new Date(effectiveDate);
|
||||
newStart = setHours(newStart, Math.floor(clampedMinutes / 60));
|
||||
newStart = setMinutes(newStart, clampedMinutes % 60);
|
||||
newStart.setSeconds(0, 0);
|
||||
|
|
@ -301,16 +265,25 @@
|
|||
resizeEdge = edge;
|
||||
hasMoved = false;
|
||||
|
||||
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
|
||||
const start = toDate(event.startTime);
|
||||
const end = toDate(event.endTime);
|
||||
resizeOriginalStart = start;
|
||||
resizeOriginalEnd = end;
|
||||
|
||||
const startMinutes = start.getHours() * 60 + start.getMinutes();
|
||||
const endMinutes = end.getHours() * 60 + end.getMinutes();
|
||||
const duration = differenceInMinutes(end, start);
|
||||
resizePreviewTop = minutesToPercent(startMinutes);
|
||||
resizePreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
|
||||
|
||||
// Calculate offset between snapped click position and actual event boundary
|
||||
const clickMinutes = getMinutesFromY(e.clientY);
|
||||
if (edge === 'top') {
|
||||
resizeOffsetMinutes = clickMinutes - startMinutes;
|
||||
} else {
|
||||
resizeOffsetMinutes = clickMinutes - endMinutes;
|
||||
}
|
||||
|
||||
document.addEventListener('pointermove', handleResizeMove);
|
||||
document.addEventListener('pointerup', handleResizeEnd);
|
||||
}
|
||||
|
|
@ -320,18 +293,19 @@
|
|||
|
||||
hasMoved = true;
|
||||
const mouseMinutes = getMinutesFromY(e.clientY);
|
||||
const snappedMinutes = snapToGrid(mouseMinutes);
|
||||
// Apply offset to prevent jumping when drag starts
|
||||
const adjustedMinutes = snapToGrid(mouseMinutes - resizeOffsetMinutes);
|
||||
|
||||
const origStartMinutes = resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes();
|
||||
const origEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes();
|
||||
|
||||
if (resizeEdge === 'top') {
|
||||
const newStartMinutes = Math.min(snappedMinutes, origEndMinutes - SNAP_MINUTES);
|
||||
const newStartMinutes = Math.min(adjustedMinutes, origEndMinutes - SNAP_MINUTES);
|
||||
const clampedStart = Math.max(firstVisibleHour * 60, newStartMinutes);
|
||||
resizePreviewTop = minutesToPercent(clampedStart);
|
||||
resizePreviewHeight = ((origEndMinutes - clampedStart) / (totalVisibleHours * 60)) * 100;
|
||||
} else {
|
||||
const newEndMinutes = Math.max(snappedMinutes, origStartMinutes + SNAP_MINUTES);
|
||||
const newEndMinutes = Math.max(adjustedMinutes, origStartMinutes + SNAP_MINUTES);
|
||||
const clampedEnd = Math.min(lastVisibleHour * 60, newEndMinutes);
|
||||
resizePreviewHeight = ((clampedEnd - origStartMinutes) / (totalVisibleHours * 60)) * 100;
|
||||
}
|
||||
|
|
@ -344,7 +318,8 @@
|
|||
}
|
||||
|
||||
const mouseMinutes = getMinutesFromY(e.clientY);
|
||||
const snappedMinutes = snapToGrid(mouseMinutes);
|
||||
// Apply offset to prevent jumping
|
||||
const adjustedMinutes = snapToGrid(mouseMinutes - resizeOffsetMinutes);
|
||||
|
||||
const origStartMinutes = resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes();
|
||||
const origEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes();
|
||||
|
|
@ -355,17 +330,17 @@
|
|||
if (resizeEdge === 'top') {
|
||||
const newStartMinutes = Math.max(
|
||||
firstVisibleHour * 60,
|
||||
Math.min(snappedMinutes, origEndMinutes - SNAP_MINUTES)
|
||||
Math.min(adjustedMinutes, origEndMinutes - SNAP_MINUTES)
|
||||
);
|
||||
newStart = setHours(new Date(viewStore.currentDate), Math.floor(newStartMinutes / 60));
|
||||
newStart = setHours(new Date(effectiveDate), Math.floor(newStartMinutes / 60));
|
||||
newStart = setMinutes(newStart, newStartMinutes % 60);
|
||||
newStart.setSeconds(0, 0);
|
||||
} else {
|
||||
const newEndMinutes = Math.min(
|
||||
lastVisibleHour * 60,
|
||||
Math.max(snappedMinutes, origStartMinutes + SNAP_MINUTES)
|
||||
Math.max(adjustedMinutes, origStartMinutes + SNAP_MINUTES)
|
||||
);
|
||||
newEnd = setHours(new Date(viewStore.currentDate), Math.floor(newEndMinutes / 60));
|
||||
newEnd = setHours(new Date(effectiveDate), Math.floor(newEndMinutes / 60));
|
||||
newEnd = setMinutes(newEnd, newEndMinutes % 60);
|
||||
newEnd.setSeconds(0, 0);
|
||||
}
|
||||
|
|
@ -393,6 +368,7 @@
|
|||
resizeEvent = null;
|
||||
resizeOriginalStart = null;
|
||||
resizeOriginalEnd = null;
|
||||
resizeOffsetMinutes = 0;
|
||||
hasMoved = false;
|
||||
document.removeEventListener('pointermove', handleDragMove);
|
||||
document.removeEventListener('pointerup', handleDragEnd);
|
||||
|
|
@ -615,7 +591,7 @@
|
|||
const endTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
|
||||
|
||||
await todosStore.updateTodo(data.taskId, {
|
||||
scheduledDate: format(viewStore.currentDate, 'yyyy-MM-dd'),
|
||||
scheduledDate: format(effectiveDate, 'yyyy-MM-dd'),
|
||||
scheduledStartTime: startTime,
|
||||
scheduledEndTime: endTime,
|
||||
estimatedDuration: duration,
|
||||
|
|
@ -645,8 +621,8 @@
|
|||
// Event Styling
|
||||
// ============================================================================
|
||||
function getEventStyle(event: CalendarEvent) {
|
||||
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
|
||||
const start = toDate(event.startTime);
|
||||
const end = toDate(event.endTime);
|
||||
|
||||
const startMinutes = start.getHours() * 60 + start.getMinutes();
|
||||
const duration = differenceInMinutes(end, start);
|
||||
|
|
@ -655,9 +631,11 @@
|
|||
const top = minutesToPercent(startMinutes);
|
||||
const height = Math.max((duration / (totalVisibleHours * 60)) * 100, 1.5); // minimum ~20px at 60px/hour
|
||||
|
||||
const color = calendarsStore.getColor(event.calendarId);
|
||||
return `top: ${top}%; height: ${height}%;`;
|
||||
}
|
||||
|
||||
return `top: ${top}%; height: ${height}%; background-color: ${color};`;
|
||||
function formatEventTime(event: CalendarEvent): string {
|
||||
return `${format(toDate(event.startTime), 'HH:mm')} - ${format(toDate(event.endTime), 'HH:mm')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -686,7 +664,7 @@
|
|||
* Get scheduled tasks for current day
|
||||
*/
|
||||
function getScheduledTasks(): Task[] {
|
||||
return todosStore.getScheduledTasksForDay(viewStore.currentDate);
|
||||
return todosStore.getScheduledTasksForDay(effectiveDate);
|
||||
}
|
||||
|
||||
function handleEventClick(event: CalendarEvent, e: MouseEvent) {
|
||||
|
|
@ -710,7 +688,7 @@
|
|||
// Don't create event if dragging or resizing
|
||||
if (isDragging || isResizing) return;
|
||||
|
||||
const startTime = new Date(viewStore.currentDate);
|
||||
const startTime = new Date(effectiveDate);
|
||||
startTime.setHours(hour, 0, 0, 0);
|
||||
|
||||
if (onQuickCreate) {
|
||||
|
|
@ -719,11 +697,25 @@
|
|||
goto(`/event/new?start=${startTime.toISOString()}`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEventContextMenu(event: CalendarEvent, e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Don't show context menu for draft events
|
||||
if (eventsStore.isDraftEvent(event.id)) return;
|
||||
eventContextMenuStore.show(event, e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
function handleContextMenuEdit(event: CalendarEvent) {
|
||||
if (onEventClick) {
|
||||
onEventClick(event);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="day-view">
|
||||
<!-- Header-style all-day events -->
|
||||
{#if headerAllDayEvents.length > 0}
|
||||
<!-- Header-style all-day events and birthdays -->
|
||||
{#if headerAllDayEvents.length > 0 || birthdays.length > 0}
|
||||
<div class="all-day-section">
|
||||
<div class="time-gutter">
|
||||
<span class="all-day-label">Ganztägig</span>
|
||||
|
|
@ -740,6 +732,18 @@
|
|||
{event.title}
|
||||
</button>
|
||||
{/each}
|
||||
<!-- Birthdays -->
|
||||
{#each birthdays as birthday}
|
||||
<button
|
||||
class="all-day-event birthday-event"
|
||||
onclick={(e) => birthdayPopover.handleBirthdayClick(birthday, e)}
|
||||
>
|
||||
🎂 {birthday.displayName}
|
||||
{#if settingsStore.showBirthdayAge && birthday.age > 0}
|
||||
<span class="birthday-age">({birthday.age})</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -757,7 +761,7 @@
|
|||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="day-column"
|
||||
class:today={isToday(viewStore.currentDate)}
|
||||
class:today={isToday(effectiveDate)}
|
||||
class:drop-target={isSidebarDropTarget}
|
||||
bind:this={dayColumnRef}
|
||||
ondragover={handleSidebarDragOver}
|
||||
|
|
@ -786,68 +790,27 @@
|
|||
{/each}
|
||||
|
||||
<!-- Timed events -->
|
||||
{#each timedEvents as event}
|
||||
{#each timedEvents as event (event.id)}
|
||||
{@const isBeingDragged = isDragging && draggedEvent?.id === event.id}
|
||||
{@const isBeingResized = isResizing && resizeEvent?.id === event.id}
|
||||
{@const isDraft = eventsStore.isDraftEvent(event.id)}
|
||||
{@const isSearchHighlighted = searchStore.isEventHighlighted(event.id)}
|
||||
{@const isSearchDimmed = searchStore.isEventDimmed(event.id)}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="event-card"
|
||||
class:dragging={isBeingDragged}
|
||||
class:resizing={isBeingResized}
|
||||
class:draft={isDraft}
|
||||
class:search-highlighted={isSearchHighlighted}
|
||||
class:search-dimmed={isSearchDimmed}
|
||||
data-event-id={event.id}
|
||||
<EventCard
|
||||
{event}
|
||||
style={isBeingDragged
|
||||
? `top: ${dragPreviewTop}%; height: ${dragPreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
|
||||
? `top: ${dragPreviewTop}%; height: ${dragPreviewHeight}%;`
|
||||
: isBeingResized
|
||||
? `top: ${resizePreviewTop}%; height: ${resizePreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
|
||||
? `top: ${resizePreviewTop}%; height: ${resizePreviewHeight}%;`
|
||||
: getEventStyle(event)}
|
||||
onpointerdown={(e) => startDrag(event, e)}
|
||||
onclick={(e) => !isDraft && handleEventClick(event, e)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<!-- Top resize handle -->
|
||||
<div
|
||||
class="resize-handle top"
|
||||
onpointerdown={(e) => startResize(event, 'top', e)}
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="Startzeit ändern"
|
||||
aria-valuenow={0}
|
||||
tabindex="-1"
|
||||
></div>
|
||||
|
||||
<span class="event-time">
|
||||
{format(
|
||||
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime,
|
||||
'HH:mm'
|
||||
)} -
|
||||
{format(
|
||||
typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime,
|
||||
'HH:mm'
|
||||
)}
|
||||
</span>
|
||||
<span class="event-title">{event.title || (isDraft ? '(Neuer Termin)' : '')}</span>
|
||||
{#if event.location}
|
||||
<span class="event-location">{event.location}</span>
|
||||
{/if}
|
||||
|
||||
<!-- Bottom resize handle -->
|
||||
<div
|
||||
class="resize-handle bottom"
|
||||
onpointerdown={(e) => startResize(event, 'bottom', e)}
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="Endzeit ändern"
|
||||
aria-valuenow={0}
|
||||
tabindex="-1"
|
||||
></div>
|
||||
</div>
|
||||
color={calendarsStore.getColor(event.calendarId)}
|
||||
isDragging={isBeingDragged}
|
||||
isResizing={isBeingResized}
|
||||
isSearchHighlighted={searchStore.isEventHighlighted(event.id)}
|
||||
isSearchDimmed={searchStore.isEventDimmed(event.id)}
|
||||
formattedTime={formatEventTime(event)}
|
||||
onClick={handleEventClick}
|
||||
onPointerDown={startDrag}
|
||||
onContextMenu={handleEventContextMenu}
|
||||
onResizeStart={startResize}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<!-- Scheduled Tasks (Time-Blocking) -->
|
||||
|
|
@ -876,10 +839,7 @@
|
|||
<div
|
||||
class="overflow-line"
|
||||
style="background-color: {calendarsStore.getColor(event.calendarId)}"
|
||||
title="{format(
|
||||
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime,
|
||||
'HH:mm'
|
||||
)} {event.title}"
|
||||
title="{format(toDate(event.startTime), 'HH:mm')} {event.title}"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -893,33 +853,45 @@
|
|||
<div
|
||||
class="overflow-line"
|
||||
style="background-color: {calendarsStore.getColor(event.calendarId)}"
|
||||
title="{format(
|
||||
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime,
|
||||
'HH:mm'
|
||||
)} {event.title}"
|
||||
title="{format(toDate(event.startTime), 'HH:mm')} {event.title}"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Current time indicator -->
|
||||
{#if isToday(viewStore.currentDate)}
|
||||
{#if isToday(effectiveDate)}
|
||||
<div class="time-indicator" style="top: {currentTimePosition}%"></div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Context Menu -->
|
||||
<EventContextMenu onEdit={handleContextMenuEdit} />
|
||||
|
||||
<!-- Birthday Popover -->
|
||||
{#if birthdayPopover.selectedBirthday}
|
||||
<BirthdayPopover
|
||||
birthday={birthdayPopover.selectedBirthday}
|
||||
position={birthdayPopover.popoverPosition}
|
||||
onClose={birthdayPopover.closePopover}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.day-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.all-day-section {
|
||||
display: flex;
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
padding: 0.5rem 0;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.all-day-label {
|
||||
|
|
@ -960,13 +932,14 @@
|
|||
.all-day-block-event {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 4px;
|
||||
right: 4px;
|
||||
left: 8px;
|
||||
width: calc(100% - 16px);
|
||||
max-width: 400px;
|
||||
bottom: 0;
|
||||
padding: 8px 12px;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--radius-md);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
z-index: 0;
|
||||
|
|
@ -1002,27 +975,29 @@
|
|||
.time-grid {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.time-column {
|
||||
width: var(--time-column-width);
|
||||
width: 50px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.time-label {
|
||||
height: var(--hour-height);
|
||||
padding-right: 0.5rem;
|
||||
padding-right: 0.75rem;
|
||||
text-align: right;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
position: relative;
|
||||
top: -0.5em;
|
||||
top: 0.25em;
|
||||
}
|
||||
|
||||
.time-gutter {
|
||||
width: var(--time-column-width);
|
||||
width: 50px;
|
||||
flex-shrink: 0;
|
||||
padding-right: 0.5rem;
|
||||
padding-right: 0.75rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
|
|
@ -1030,6 +1005,7 @@
|
|||
flex: 1;
|
||||
position: relative;
|
||||
border-left: 1px solid hsl(var(--color-border));
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.day-column.today {
|
||||
|
|
@ -1042,125 +1018,6 @@
|
|||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.event-card {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
right: 4px;
|
||||
color: white;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: grab;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
transition:
|
||||
box-shadow 150ms ease,
|
||||
opacity 150ms ease;
|
||||
}
|
||||
|
||||
.event-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.event-card.dragging {
|
||||
cursor: grabbing;
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.event-card.resizing {
|
||||
cursor: ns-resize;
|
||||
opacity: 0.85;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
||||
z-index: 100;
|
||||
outline: 2px dashed rgba(255, 255, 255, 0.6);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.event-card.draft {
|
||||
outline: 2px solid hsl(var(--color-primary));
|
||||
outline-offset: -1px;
|
||||
animation: pulse-outline 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Search highlighting */
|
||||
.event-card.search-highlighted {
|
||||
outline: 2px solid hsl(var(--color-primary));
|
||||
outline-offset: 1px;
|
||||
box-shadow:
|
||||
0 0 0 4px hsl(var(--color-primary) / 0.3),
|
||||
0 4px 12px rgba(0, 0, 0, 0.25);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.event-card.search-dimmed {
|
||||
opacity: 0.35;
|
||||
filter: grayscale(0.3);
|
||||
}
|
||||
|
||||
@keyframes pulse-outline {
|
||||
0%,
|
||||
100% {
|
||||
outline-color: hsl(var(--color-primary));
|
||||
}
|
||||
50% {
|
||||
outline-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
/* Resize Handles */
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 8px;
|
||||
cursor: ns-resize;
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.resize-handle.top {
|
||||
top: 0;
|
||||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||
}
|
||||
|
||||
.resize-handle.bottom {
|
||||
bottom: 0;
|
||||
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
|
||||
}
|
||||
|
||||
.event-card:hover .resize-handle {
|
||||
opacity: 1;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.resize-handle:hover {
|
||||
background: rgba(255, 255, 255, 0.5) !important;
|
||||
}
|
||||
|
||||
.event-time {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.event-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.event-location {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Time indicator */
|
||||
.time-indicator {
|
||||
position: absolute;
|
||||
|
|
@ -1230,4 +1087,18 @@
|
|||
opacity: 1;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
/* Birthday events in all-day row */
|
||||
.all-day-event.birthday-event {
|
||||
background: linear-gradient(135deg, #ec4899 0%, #f472b6 100%);
|
||||
}
|
||||
|
||||
.all-day-event.birthday-event:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.birthday-age {
|
||||
opacity: 0.85;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,251 @@
|
|||
<script lang="ts">
|
||||
import type { CalendarEvent } from '@calendar/shared';
|
||||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
event: CalendarEvent;
|
||||
style: string;
|
||||
color: string;
|
||||
isDragging?: boolean;
|
||||
isResizing?: boolean;
|
||||
isDraggingSource?: boolean;
|
||||
isSearchHighlighted?: boolean;
|
||||
isSearchDimmed?: boolean;
|
||||
formattedTime: string;
|
||||
onClick?: (event: CalendarEvent, e: MouseEvent) => void;
|
||||
onPointerDown?: (event: CalendarEvent, e: PointerEvent) => void;
|
||||
onContextMenu?: (event: CalendarEvent, e: MouseEvent) => void;
|
||||
onResizeStart?: (event: CalendarEvent, edge: 'top' | 'bottom', e: PointerEvent) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
event,
|
||||
style,
|
||||
color,
|
||||
isDragging = false,
|
||||
isResizing = false,
|
||||
isDraggingSource = false,
|
||||
isSearchHighlighted = false,
|
||||
isSearchDimmed = false,
|
||||
formattedTime,
|
||||
onClick,
|
||||
onPointerDown,
|
||||
onContextMenu,
|
||||
onResizeStart,
|
||||
}: Props = $props();
|
||||
|
||||
let isDraft = $derived(eventsStore.isDraftEvent(event.id));
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (isDragging || isResizing || isDraft) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
onClick?.(event, e);
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
onPointerDown?.(event, e);
|
||||
}
|
||||
|
||||
function handleContextMenu(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isDraft) return;
|
||||
onContextMenu?.(event, e);
|
||||
}
|
||||
|
||||
function handleResizeTop(e: PointerEvent) {
|
||||
e.stopPropagation();
|
||||
onResizeStart?.(event, 'top', e);
|
||||
}
|
||||
|
||||
function handleResizeBottom(e: PointerEvent) {
|
||||
e.stopPropagation();
|
||||
onResizeStart?.(event, 'bottom', e);
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && !isDraft) {
|
||||
e.preventDefault();
|
||||
onClick?.(event, e as unknown as MouseEvent);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="event-card"
|
||||
class:dragging={isDragging && !isDraggingSource}
|
||||
class:dragging-source={isDraggingSource}
|
||||
class:resizing={isResizing}
|
||||
class:draft={isDraft}
|
||||
class:search-highlighted={isSearchHighlighted}
|
||||
class:search-dimmed={isSearchDimmed}
|
||||
data-event-id={event.id}
|
||||
{style}
|
||||
style:background-color={color}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={event.title || $_('calendar.draftEvent')}
|
||||
onpointerdown={handlePointerDown}
|
||||
onclick={handleClick}
|
||||
onkeydown={handleKeydown}
|
||||
oncontextmenu={handleContextMenu}
|
||||
>
|
||||
<!-- Top resize handle -->
|
||||
{#if onResizeStart}
|
||||
<div
|
||||
class="resize-handle top"
|
||||
onpointerdown={handleResizeTop}
|
||||
role="slider"
|
||||
aria-label={$_('event.changeStartTime')}
|
||||
aria-valuenow={0}
|
||||
tabindex="-1"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<span class="event-time">{formattedTime}</span>
|
||||
<span class="event-title">{event.title || (isDraft ? $_('calendar.draftEvent') : '')}</span>
|
||||
{#if event.location}
|
||||
<span class="event-location">{event.location}</span>
|
||||
{/if}
|
||||
|
||||
<!-- Bottom resize handle -->
|
||||
{#if onResizeStart}
|
||||
<div
|
||||
class="resize-handle bottom"
|
||||
onpointerdown={handleResizeBottom}
|
||||
role="slider"
|
||||
aria-label={$_('event.changeEndTime')}
|
||||
aria-valuenow={0}
|
||||
tabindex="-1"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.event-card {
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
right: 2px;
|
||||
padding: 2px 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
text-align: left;
|
||||
cursor: grab;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
box-shadow 0.15s ease,
|
||||
opacity 0.15s ease;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.event-card:hover {
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.event-card:focus-visible {
|
||||
outline: 2px solid hsl(var(--color-primary));
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.event-card.dragging {
|
||||
cursor: grabbing;
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.event-card.resizing {
|
||||
opacity: 0.85;
|
||||
z-index: 100;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||
outline: 2px dashed hsl(var(--color-primary) / 0.6);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Ghost style for source position during cross-day drag */
|
||||
.event-card.dragging-source {
|
||||
opacity: 0.4;
|
||||
background: transparent !important;
|
||||
border: 2px dashed hsl(var(--color-border));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.event-card.dragging-source .event-title,
|
||||
.event-card.dragging-source .event-time,
|
||||
.event-card.dragging-source .event-location {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.event-card.draft {
|
||||
border: 2px dashed hsl(var(--color-primary) / 0.6);
|
||||
background-color: hsl(var(--color-primary) / 0.3) !important;
|
||||
}
|
||||
|
||||
.event-card.search-highlighted {
|
||||
outline: 2px solid hsl(var(--color-primary));
|
||||
outline-offset: 1px;
|
||||
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.3);
|
||||
}
|
||||
|
||||
.event-card.search-dimmed {
|
||||
opacity: 0.35;
|
||||
filter: grayscale(0.3);
|
||||
}
|
||||
|
||||
.event-time {
|
||||
display: block;
|
||||
font-size: 0.6rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.event-title {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.event-location {
|
||||
display: block;
|
||||
font-size: 0.6rem;
|
||||
opacity: 0.85;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Resize handles */
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 8px;
|
||||
cursor: ns-resize;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.resize-handle.top {
|
||||
top: 0;
|
||||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||
}
|
||||
|
||||
.resize-handle.bottom {
|
||||
bottom: 0;
|
||||
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
|
||||
}
|
||||
|
||||
.event-card:hover .resize-handle {
|
||||
opacity: 1;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -5,7 +5,11 @@
|
|||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { searchStore } from '$lib/stores/search.svelte';
|
||||
import { todosStore } from '$lib/stores/todos.svelte';
|
||||
import { birthdaysStore, type BirthdayEvent } from '$lib/stores/birthdays.svelte';
|
||||
import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte';
|
||||
import TodoDayCell from './TodoDayCell.svelte';
|
||||
import BirthdayPopover from '$lib/components/birthday/BirthdayPopover.svelte';
|
||||
import { useBirthdayPopover } from '$lib/composables';
|
||||
import { goto } from '$app/navigation';
|
||||
import {
|
||||
format,
|
||||
|
|
@ -27,20 +31,26 @@
|
|||
} from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { filterByVisibleCalendars } from '$lib/utils/eventFiltering';
|
||||
|
||||
import type { CalendarEvent } from '@calendar/shared';
|
||||
|
||||
interface Props {
|
||||
/** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */
|
||||
date?: Date;
|
||||
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
|
||||
onEventClick?: (event: CalendarEvent) => void;
|
||||
}
|
||||
|
||||
let { onQuickCreate, onEventClick }: Props = $props();
|
||||
let { date, onQuickCreate, onEventClick }: Props = $props();
|
||||
|
||||
// Use provided date or fall back to viewStore
|
||||
let effectiveDate = $derived(date ?? viewStore.currentDate);
|
||||
|
||||
// Get all days to display in the month grid (including days from prev/next months)
|
||||
let allCalendarDays = $derived.by(() => {
|
||||
const monthStart = startOfMonth(viewStore.currentDate);
|
||||
const monthEnd = endOfMonth(viewStore.currentDate);
|
||||
const monthStart = startOfMonth(effectiveDate);
|
||||
const monthEnd = endOfMonth(effectiveDate);
|
||||
const calendarStart = startOfWeek(monthStart, { weekStartsOn: settingsStore.weekStartsOn });
|
||||
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: settingsStore.weekStartsOn });
|
||||
|
||||
|
|
@ -80,7 +90,6 @@
|
|||
let isDragging = $state(false);
|
||||
let draggedEvent = $state<CalendarEvent | null>(null);
|
||||
let dragTargetDay = $state<Date | null>(null);
|
||||
let monthViewRef = $state<HTMLElement | null>(null);
|
||||
|
||||
// Store for day cell refs
|
||||
let dayCellRefs = $state<Map<string, HTMLElement>>(new Map());
|
||||
|
|
@ -191,8 +200,18 @@
|
|||
// ============================================================================
|
||||
// Event Handlers
|
||||
// ============================================================================
|
||||
function getEventsForDay(day: Date) {
|
||||
return eventsStore.getEventsForDay(day).slice(0, 3); // Max 3 events shown
|
||||
function getEventsForDay(day: Date): CalendarEvent[] {
|
||||
return filterByVisibleCalendars(
|
||||
eventsStore.getEventsForDay(day),
|
||||
calendarsStore.visibleCalendars
|
||||
).slice(0, 3); // Max 3 events shown
|
||||
}
|
||||
|
||||
function getAllEventsForDay(day: Date): CalendarEvent[] {
|
||||
return filterByVisibleCalendars(
|
||||
eventsStore.getEventsForDay(day),
|
||||
calendarsStore.visibleCalendars
|
||||
);
|
||||
}
|
||||
|
||||
function handleDayClick(day: Date, e: MouseEvent) {
|
||||
|
|
@ -241,9 +260,27 @@
|
|||
viewStore.setDate(day);
|
||||
viewStore.setViewType('day');
|
||||
}
|
||||
|
||||
function handleEventContextMenu(event: CalendarEvent, e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Don't show context menu for draft events
|
||||
if (eventsStore.isDraftEvent(event.id)) return;
|
||||
eventContextMenuStore.show(event, e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Birthday Functions
|
||||
// ============================================================================
|
||||
const birthdayPopover = useBirthdayPopover();
|
||||
|
||||
function getBirthdaysForDay(day: Date): BirthdayEvent[] {
|
||||
if (!settingsStore.showBirthdays) return [];
|
||||
return birthdaysStore.getBirthdaysForDay(day);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="month-view" style="--column-count: {columnCount}" bind:this={monthViewRef}>
|
||||
<div class="month-view" style="--column-count: {columnCount}">
|
||||
<!-- Week day headers -->
|
||||
<div class="weekday-headers">
|
||||
{#each weekDays as day}
|
||||
|
|
@ -259,7 +296,7 @@
|
|||
{@const isDropTarget = isDragging && dragTargetDay && isSameDay(day, dragTargetDay)}
|
||||
<div
|
||||
class="day-cell"
|
||||
class:other-month={!isSameMonth(day, viewStore.currentDate)}
|
||||
class:other-month={!isSameMonth(day, effectiveDate)}
|
||||
class:today={isToday(day)}
|
||||
class:drop-target={isDropTarget}
|
||||
use:bindDayCellRef={day}
|
||||
|
|
@ -297,6 +334,7 @@
|
|||
style="background-color: {calendarsStore.getColor(event.calendarId)}"
|
||||
onpointerdown={(e) => startDrag(event, e)}
|
||||
onclick={(e) => !isDraft && handleEventClick(event, e)}
|
||||
oncontextmenu={(e) => handleEventContextMenu(event, e)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
|
|
@ -319,10 +357,27 @@
|
|||
</div>
|
||||
{/each}
|
||||
|
||||
{#if eventsStore.getEventsForDay(day).length > 3}
|
||||
<!-- Birthdays -->
|
||||
{#each getBirthdaysForDay(day) as birthday}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="event-pill birthday-pill"
|
||||
onclick={(e) => birthdayPopover.handleBirthdayClick(birthday, e)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
🎂
|
||||
<span class="event-title">{birthday.displayName}</span>
|
||||
{#if settingsStore.showBirthdayAge && birthday.age > 0}
|
||||
<span class="birthday-age">({birthday.age})</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if getAllEventsForDay(day).length > 3}
|
||||
<button class="more-events" onclick={(e) => handleMoreClick(day, e)}>
|
||||
{$_('views.moreEvents', {
|
||||
values: { count: eventsStore.getEventsForDay(day).length - 3 },
|
||||
values: { count: getAllEventsForDay(day).length - 3 },
|
||||
})}
|
||||
</button>
|
||||
{/if}
|
||||
|
|
@ -334,6 +389,15 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Birthday Popover -->
|
||||
{#if birthdayPopover.selectedBirthday}
|
||||
<BirthdayPopover
|
||||
birthday={birthdayPopover.selectedBirthday}
|
||||
position={birthdayPopover.popoverPosition}
|
||||
onClose={birthdayPopover.closePopover}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.month-view {
|
||||
display: flex;
|
||||
|
|
@ -344,6 +408,10 @@
|
|||
display: grid;
|
||||
grid-template-columns: repeat(var(--column-count, 7), 1fr);
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
background: hsl(var(--color-background));
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.weekday-header {
|
||||
|
|
@ -514,4 +582,19 @@
|
|||
outline: 2px dashed hsl(var(--color-primary));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Birthday pills */
|
||||
.event-pill.birthday-pill {
|
||||
background: linear-gradient(135deg, #ec4899 0%, #f472b6 100%);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.event-pill.birthday-pill:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.birthday-age {
|
||||
opacity: 0.85;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,19 @@
|
|||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { searchStore } from '$lib/stores/search.svelte';
|
||||
import { todosStore, type Task } from '$lib/stores/todos.svelte';
|
||||
import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte';
|
||||
import {
|
||||
useVisibleHours,
|
||||
useCurrentTimeIndicator,
|
||||
} from '$lib/composables/useVisibleHours.svelte';
|
||||
import { toDate } from '$lib/utils/eventDateHelpers';
|
||||
import { HOUR_HEIGHT_PX, SNAP_INTERVAL_MINUTES } from '$lib/utils/calendarConstants';
|
||||
import {
|
||||
getVisibleTimedEvents,
|
||||
getVisibleAllDayEvents,
|
||||
getVisibleOverflowEvents,
|
||||
type OverflowEvents,
|
||||
} from '$lib/utils/eventFiltering';
|
||||
import TaskBlock from './TaskBlock.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import {
|
||||
|
|
@ -12,7 +25,6 @@
|
|||
eachDayOfInterval,
|
||||
isToday,
|
||||
isSameDay,
|
||||
parseISO,
|
||||
differenceInMinutes,
|
||||
isWeekend,
|
||||
addMinutes,
|
||||
|
|
@ -22,20 +34,40 @@
|
|||
import { de, enUS, fr, es, it } from 'date-fns/locale';
|
||||
import { locale } from 'svelte-i18n';
|
||||
|
||||
// Constants
|
||||
const HOUR_HEIGHT = 60; // px - should match CSS --hour-height
|
||||
const MINUTES_PER_SLOT = 15; // Snap to 15-minute intervals
|
||||
// Use shared constants
|
||||
const HOUR_HEIGHT = HOUR_HEIGHT_PX;
|
||||
const MINUTES_PER_SLOT = SNAP_INTERVAL_MINUTES;
|
||||
|
||||
import type { CalendarEvent } from '@calendar/shared';
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
dayCount: 5 | 10 | 14;
|
||||
dayCount: number;
|
||||
/** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */
|
||||
date?: Date;
|
||||
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
|
||||
onEventClick?: (event: CalendarEvent) => void;
|
||||
onTaskClick?: (task: Task) => void;
|
||||
}
|
||||
let { dayCount, onQuickCreate, onEventClick, onTaskClick }: Props = $props();
|
||||
let { dayCount, date, onQuickCreate, onEventClick, onTaskClick }: Props = $props();
|
||||
|
||||
// Use provided date or fall back to viewStore
|
||||
let effectiveDate = $derived(date ?? viewStore.currentDate);
|
||||
|
||||
// Calculate view range based on effective date
|
||||
let effectiveViewRange = $derived.by(() => {
|
||||
if (date) {
|
||||
// Calculate range for the provided date based on day count
|
||||
const end = new Date(date);
|
||||
end.setDate(end.getDate() + dayCount - 1);
|
||||
return {
|
||||
start: date,
|
||||
end: end,
|
||||
};
|
||||
}
|
||||
// Use viewStore range when no date override
|
||||
return viewStore.viewRange;
|
||||
});
|
||||
|
||||
// Get date-fns locale based on current app locale
|
||||
const dateLocales = { de, en: enUS, fr, es, it };
|
||||
|
|
@ -46,8 +78,8 @@
|
|||
// Generate days based on view range, optionally filtering weekends
|
||||
let allDays = $derived(
|
||||
eachDayOfInterval({
|
||||
start: viewStore.viewRange.start,
|
||||
end: viewStore.viewRange.end,
|
||||
start: effectiveViewRange.start,
|
||||
end: effectiveViewRange.end,
|
||||
})
|
||||
);
|
||||
|
||||
|
|
@ -55,47 +87,26 @@
|
|||
settingsStore.showOnlyWeekdays ? allDays.filter((day) => !isWeekend(day)) : allDays
|
||||
);
|
||||
|
||||
// Generate hours (filtered based on settings)
|
||||
let allHours = Array.from({ length: 24 }, (_, i) => i);
|
||||
let hours = $derived(
|
||||
settingsStore.filterHoursEnabled
|
||||
? allHours.filter((h) => h >= settingsStore.dayStartHour && h < settingsStore.dayEndHour)
|
||||
: allHours
|
||||
);
|
||||
// Use composables for hour filtering and time indicator
|
||||
const visibleHours = useVisibleHours();
|
||||
const timeIndicator = useCurrentTimeIndicator();
|
||||
|
||||
// Calculate visible hours range for positioning
|
||||
let firstVisibleHour = $derived(
|
||||
settingsStore.filterHoursEnabled ? settingsStore.dayStartHour : 0
|
||||
);
|
||||
let lastVisibleHour = $derived(settingsStore.filterHoursEnabled ? settingsStore.dayEndHour : 24);
|
||||
let totalVisibleHours = $derived(lastVisibleHour - firstVisibleHour);
|
||||
|
||||
// Helper to convert minutes to percentage position (accounting for hidden hours)
|
||||
function minutesToPercent(minutes: number): number {
|
||||
const adjustedMinutes = minutes - firstVisibleHour * 60;
|
||||
return (adjustedMinutes / (totalVisibleHours * 60)) * 100;
|
||||
}
|
||||
// Destructure for convenience (these are reactive getters)
|
||||
let hours = $derived(visibleHours.hours);
|
||||
let firstVisibleHour = $derived(visibleHours.firstVisibleHour);
|
||||
let lastVisibleHour = $derived(visibleHours.lastVisibleHour);
|
||||
let totalVisibleHours = $derived(visibleHours.totalVisibleHours);
|
||||
const minutesToPercent = visibleHours.minutesToPercent;
|
||||
|
||||
// Current time indicator position
|
||||
let now = $state(new Date());
|
||||
let currentTimePosition = $derived.by(() => {
|
||||
const minutes = now.getHours() * 60 + now.getMinutes();
|
||||
return minutesToPercent(minutes);
|
||||
});
|
||||
|
||||
// Update current time every minute
|
||||
$effect(() => {
|
||||
const interval = setInterval(() => {
|
||||
now = new Date();
|
||||
}, 60000);
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
let currentTimePosition = $derived(minutesToPercent(timeIndicator.currentMinutes));
|
||||
|
||||
// Determine column width based on day count
|
||||
let columnClass = $derived.by(() => {
|
||||
if (days.length <= 5) return 'normal';
|
||||
if (days.length <= 10) return 'compact';
|
||||
return 'very-compact';
|
||||
if (days.length <= 14) return 'very-compact';
|
||||
return 'ultra-compact';
|
||||
});
|
||||
|
||||
// ========== Drag & Drop State ==========
|
||||
|
|
@ -114,6 +125,7 @@
|
|||
let resizeOriginalEnd = $state<Date | null>(null);
|
||||
let resizePreviewTop = $state(0);
|
||||
let resizePreviewHeight = $state(0);
|
||||
let resizeOffsetMinutes = $state(0);
|
||||
|
||||
// Track if we actually moved during drag/resize (to prevent click on simple mousedown/up)
|
||||
let hasMoved = $state(false);
|
||||
|
|
@ -135,66 +147,35 @@
|
|||
// Reference to the days container for position calculations
|
||||
let daysContainerEl: HTMLDivElement;
|
||||
|
||||
function getEventsForDay(day: Date) {
|
||||
const allEvents = eventsStore.getEventsForDay(day).filter((e) => !e.isAllDay);
|
||||
|
||||
// If hour filtering is enabled, only show events that overlap with visible range
|
||||
if (settingsStore.filterHoursEnabled) {
|
||||
const visibleStartMinutes = settingsStore.dayStartHour * 60;
|
||||
const visibleEndMinutes = settingsStore.dayEndHour * 60;
|
||||
|
||||
return allEvents.filter((event) => {
|
||||
const start =
|
||||
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
|
||||
|
||||
const eventStartMinutes = start.getHours() * 60 + start.getMinutes();
|
||||
const eventEndMinutes = end.getHours() * 60 + end.getMinutes();
|
||||
|
||||
// Event overlaps with visible range
|
||||
return eventStartMinutes < visibleEndMinutes && eventEndMinutes > visibleStartMinutes;
|
||||
});
|
||||
}
|
||||
|
||||
return allEvents;
|
||||
function getEventsForDay(day: Date): CalendarEvent[] {
|
||||
return getVisibleTimedEvents(
|
||||
eventsStore.getEventsForDay(day),
|
||||
calendarsStore.visibleCalendars,
|
||||
{
|
||||
filterHoursEnabled: settingsStore.filterHoursEnabled,
|
||||
dayStartHour: settingsStore.dayStartHour,
|
||||
dayEndHour: settingsStore.dayEndHour,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Get events that are completely outside the visible time range
|
||||
function getOverflowEventsForDay(day: Date): { before: CalendarEvent[]; after: CalendarEvent[] } {
|
||||
function getOverflowEventsForDay(day: Date): OverflowEvents {
|
||||
if (!settingsStore.filterHoursEnabled) {
|
||||
return { before: [], after: [] };
|
||||
}
|
||||
|
||||
const allEvents = eventsStore.getEventsForDay(day).filter((e) => !e.isAllDay);
|
||||
const before: CalendarEvent[] = [];
|
||||
const after: CalendarEvent[] = [];
|
||||
|
||||
const visibleStartMinutes = settingsStore.dayStartHour * 60;
|
||||
const visibleEndMinutes = settingsStore.dayEndHour * 60;
|
||||
|
||||
for (const event of allEvents) {
|
||||
const start =
|
||||
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
|
||||
|
||||
const eventStartMinutes = start.getHours() * 60 + start.getMinutes();
|
||||
const eventEndMinutes = end.getHours() * 60 + end.getMinutes();
|
||||
|
||||
// Event ends before visible range starts
|
||||
if (eventEndMinutes <= visibleStartMinutes) {
|
||||
before.push(event);
|
||||
}
|
||||
// Event starts after visible range ends
|
||||
else if (eventStartMinutes >= visibleEndMinutes) {
|
||||
after.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
return { before, after };
|
||||
return getVisibleOverflowEvents(
|
||||
eventsStore.getEventsForDay(day),
|
||||
calendarsStore.visibleCalendars,
|
||||
settingsStore.dayStartHour,
|
||||
settingsStore.dayEndHour
|
||||
);
|
||||
}
|
||||
|
||||
function getAllDayEventsForDay(day: Date) {
|
||||
return eventsStore.getEventsForDay(day).filter((e) => e.isAllDay);
|
||||
function getAllDayEventsForDay(day: Date): CalendarEvent[] {
|
||||
return getVisibleAllDayEvents(
|
||||
eventsStore.getEventsForDay(day),
|
||||
calendarsStore.visibleCalendars
|
||||
);
|
||||
}
|
||||
|
||||
// Get display mode for an event (per-event override takes precedence over global setting)
|
||||
|
|
@ -220,8 +201,8 @@
|
|||
);
|
||||
|
||||
function getEventStyle(event: CalendarEvent) {
|
||||
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
|
||||
const start = toDate(event.startTime);
|
||||
const end = toDate(event.endTime);
|
||||
|
||||
const startMinutes = start.getHours() * 60 + start.getMinutes();
|
||||
const duration = differenceInMinutes(end, start);
|
||||
|
|
@ -264,8 +245,7 @@
|
|||
}
|
||||
|
||||
function formatEventTime(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? parseISO(date) : date;
|
||||
return settingsStore.formatTime(d);
|
||||
return settingsStore.formatTime(toDate(date));
|
||||
}
|
||||
|
||||
function handleEventClick(event: CalendarEvent, e: MouseEvent) {
|
||||
|
|
@ -299,6 +279,14 @@
|
|||
}
|
||||
}
|
||||
|
||||
function handleEventContextMenu(event: CalendarEvent, e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Don't show context menu for draft events
|
||||
if (eventsStore.isDraftEvent(event.id)) return;
|
||||
eventContextMenuStore.show(event, e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
// ========== Drag & Drop Functions ==========
|
||||
|
||||
function getDayFromX(clientX: number): Date | null {
|
||||
|
|
@ -337,8 +325,8 @@
|
|||
draggedEvent = event;
|
||||
hasMoved = false;
|
||||
|
||||
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
|
||||
const start = toDate(event.startTime);
|
||||
const end = toDate(event.endTime);
|
||||
const duration = differenceInMinutes(end, start);
|
||||
|
||||
// Calculate initial preview position
|
||||
|
|
@ -388,14 +376,8 @@
|
|||
return;
|
||||
}
|
||||
|
||||
const start =
|
||||
typeof draggedEvent.startTime === 'string'
|
||||
? parseISO(draggedEvent.startTime)
|
||||
: draggedEvent.startTime;
|
||||
const end =
|
||||
typeof draggedEvent.endTime === 'string'
|
||||
? parseISO(draggedEvent.endTime)
|
||||
: draggedEvent.endTime;
|
||||
const start = toDate(draggedEvent.startTime);
|
||||
const end = toDate(draggedEvent.endTime);
|
||||
const duration = differenceInMinutes(end, start);
|
||||
|
||||
// Calculate new start time
|
||||
|
|
@ -441,18 +423,27 @@
|
|||
resizeEdge = edge;
|
||||
hasMoved = false;
|
||||
|
||||
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
|
||||
const start = toDate(event.startTime);
|
||||
const end = toDate(event.endTime);
|
||||
|
||||
resizeOriginalStart = start;
|
||||
resizeOriginalEnd = end;
|
||||
|
||||
// Set initial preview
|
||||
const startMinutes = start.getHours() * 60 + start.getMinutes();
|
||||
const endMinutes = end.getHours() * 60 + end.getMinutes();
|
||||
const duration = differenceInMinutes(end, start);
|
||||
resizePreviewTop = minutesToPercent(startMinutes);
|
||||
resizePreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
|
||||
|
||||
// Calculate offset between snapped click position and actual event boundary
|
||||
const clickMinutes = getMinutesFromY(e.clientY);
|
||||
if (edge === 'top') {
|
||||
resizeOffsetMinutes = clickMinutes - startMinutes;
|
||||
} else {
|
||||
resizeOffsetMinutes = clickMinutes - endMinutes;
|
||||
}
|
||||
|
||||
document.addEventListener('pointermove', handleResizeMove);
|
||||
document.addEventListener('pointerup', handleResizeEnd);
|
||||
}
|
||||
|
|
@ -462,6 +453,8 @@
|
|||
|
||||
hasMoved = true;
|
||||
const currentMinutes = getMinutesFromY(e.clientY);
|
||||
// Apply offset to prevent jumping when drag starts
|
||||
const adjustedMinutes = currentMinutes - resizeOffsetMinutes;
|
||||
const originalStartMinutes =
|
||||
resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes();
|
||||
const originalEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes();
|
||||
|
|
@ -470,7 +463,7 @@
|
|||
// Resize from bottom - change end time
|
||||
const newEndMinutes = Math.max(
|
||||
originalStartMinutes + 15,
|
||||
Math.min(lastVisibleHour * 60, currentMinutes)
|
||||
Math.min(lastVisibleHour * 60, adjustedMinutes)
|
||||
);
|
||||
const newDuration = newEndMinutes - originalStartMinutes;
|
||||
resizePreviewHeight = (newDuration / (totalVisibleHours * 60)) * 100;
|
||||
|
|
@ -478,7 +471,7 @@
|
|||
// Resize from top - change start time
|
||||
const newStartMinutes = Math.max(
|
||||
firstVisibleHour * 60,
|
||||
Math.min(originalEndMinutes - 15, currentMinutes)
|
||||
Math.min(originalEndMinutes - 15, adjustedMinutes)
|
||||
);
|
||||
const newDuration = originalEndMinutes - newStartMinutes;
|
||||
resizePreviewTop = minutesToPercent(newStartMinutes);
|
||||
|
|
@ -495,11 +488,14 @@
|
|||
resizeEvent = null;
|
||||
resizeOriginalStart = null;
|
||||
resizeOriginalEnd = null;
|
||||
resizeOffsetMinutes = 0;
|
||||
hasMoved = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentMinutes = getMinutesFromY(e.clientY);
|
||||
// Apply offset to prevent jumping
|
||||
const adjustedMinutes = currentMinutes - resizeOffsetMinutes;
|
||||
const originalStartMinutes =
|
||||
resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes();
|
||||
const originalEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes();
|
||||
|
|
@ -510,7 +506,7 @@
|
|||
if (resizeEdge === 'bottom') {
|
||||
const newEndMinutes = Math.max(
|
||||
originalStartMinutes + 15,
|
||||
Math.min(lastVisibleHour * 60, currentMinutes)
|
||||
Math.min(lastVisibleHour * 60, adjustedMinutes)
|
||||
);
|
||||
const newHours = Math.floor(newEndMinutes / 60);
|
||||
const newMins = newEndMinutes % 60;
|
||||
|
|
@ -519,7 +515,7 @@
|
|||
} else {
|
||||
const newStartMinutes = Math.max(
|
||||
firstVisibleHour * 60,
|
||||
Math.min(originalEndMinutes - 15, currentMinutes)
|
||||
Math.min(originalEndMinutes - 15, adjustedMinutes)
|
||||
);
|
||||
const newHours = Math.floor(newStartMinutes / 60);
|
||||
const newMins = newStartMinutes % 60;
|
||||
|
|
@ -545,6 +541,7 @@
|
|||
resizeEvent = null;
|
||||
resizeOriginalStart = null;
|
||||
resizeOriginalEnd = null;
|
||||
resizeOffsetMinutes = 0;
|
||||
hasMoved = false;
|
||||
}
|
||||
|
||||
|
|
@ -807,6 +804,7 @@
|
|||
resizeEvent = null;
|
||||
resizeOriginalStart = null;
|
||||
resizeOriginalEnd = null;
|
||||
resizeOffsetMinutes = 0;
|
||||
isTaskDragging = false;
|
||||
draggedTask = null;
|
||||
taskDragTargetDay = null;
|
||||
|
|
@ -826,41 +824,45 @@
|
|||
class="multi-day-view"
|
||||
class:compact={columnClass === 'compact'}
|
||||
class:very-compact={columnClass === 'very-compact'}
|
||||
class:ultra-compact={columnClass === 'ultra-compact'}
|
||||
>
|
||||
<!-- All-day events row (only shown when there are header-mode all-day events) -->
|
||||
{#if hasAnyHeaderAllDayEvents}
|
||||
<div class="all-day-row">
|
||||
<!-- Sticky header container -->
|
||||
<div class="sticky-header">
|
||||
<!-- Day headers -->
|
||||
<div class="day-headers">
|
||||
<div class="time-gutter"></div>
|
||||
{#each days as day}
|
||||
<div class="all-day-cell">
|
||||
{#each getHeaderAllDayEventsForDay(day) as event}
|
||||
<button
|
||||
class="all-day-event"
|
||||
class:search-highlighted={searchStore.isEventHighlighted(event.id)}
|
||||
class:search-dimmed={searchStore.isEventDimmed(event.id)}
|
||||
style="background-color: {calendarsStore.getColor(event.calendarId)}"
|
||||
onclick={(e) => handleEventClick(event, e)}
|
||||
title={event.title}
|
||||
>
|
||||
{event.title}
|
||||
</button>
|
||||
{/each}
|
||||
<div class="day-header" class:today={isToday(day)}>
|
||||
<span class="day-name"
|
||||
>{format(day, columnClass === 'very-compact' ? 'EEEEE' : 'EEE', { locale: de })}</span
|
||||
>
|
||||
<span class="day-number" class:today={isToday(day)}>{format(day, 'd')}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Day headers -->
|
||||
<div class="day-headers">
|
||||
<div class="time-gutter"></div>
|
||||
{#each days as day}
|
||||
<div class="day-header" class:today={isToday(day)}>
|
||||
<span class="day-name"
|
||||
>{format(day, columnClass === 'very-compact' ? 'EEEEE' : 'EEE', { locale: de })}</span
|
||||
>
|
||||
<span class="day-number" class:today={isToday(day)}>{format(day, 'd')}</span>
|
||||
<!-- All-day events row (only shown when there are header-mode all-day events) -->
|
||||
{#if hasAnyHeaderAllDayEvents}
|
||||
<div class="all-day-row">
|
||||
<div class="time-gutter"></div>
|
||||
{#each days as day}
|
||||
<div class="all-day-cell">
|
||||
{#each getHeaderAllDayEventsForDay(day) as event}
|
||||
<button
|
||||
class="all-day-event"
|
||||
class:search-highlighted={searchStore.isEventHighlighted(event.id)}
|
||||
class:search-dimmed={searchStore.isEventDimmed(event.id)}
|
||||
style="background-color: {calendarsStore.getColor(event.calendarId)}"
|
||||
onclick={(e) => handleEventClick(event, e)}
|
||||
title={event.title}
|
||||
>
|
||||
{event.title}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Time grid -->
|
||||
|
|
@ -935,6 +937,7 @@
|
|||
tabindex="0"
|
||||
onpointerdown={(e) => startDrag(event, e)}
|
||||
onclick={(e) => !isDraft && handleEventClick(event, e)}
|
||||
oncontextmenu={(e) => handleEventContextMenu(event, e)}
|
||||
onkeydown={(e) => !isDraft && e.key === 'Enter' && goto(`/?event=${event.id}`)}
|
||||
title={`${formatEventTime(event.startTime)} - ${formatEventTime(event.endTime)}: ${event.title || (isDraft ? '(Neuer Termin)' : '')}`}
|
||||
>
|
||||
|
|
@ -1063,6 +1066,13 @@
|
|||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sticky-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: hsl(var(--color-background));
|
||||
}
|
||||
|
||||
.all-day-row {
|
||||
display: flex;
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
|
|
@ -1080,6 +1090,7 @@
|
|||
}
|
||||
|
||||
.all-day-event {
|
||||
width: 100%;
|
||||
padding: 2px 6px;
|
||||
font-size: 0.75rem;
|
||||
color: white;
|
||||
|
|
@ -1089,8 +1100,8 @@
|
|||
text-overflow: ellipsis;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
max-width: 100%;
|
||||
transition: opacity 0.15s ease;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.all-day-event.search-highlighted {
|
||||
|
|
@ -1249,7 +1260,7 @@
|
|||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
position: relative;
|
||||
top: -0.5em;
|
||||
top: 0.25em;
|
||||
}
|
||||
|
||||
.compact .time-label,
|
||||
|
|
@ -1518,4 +1529,61 @@
|
|||
.very-compact .overflow-line:hover {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
/* Ultra-compact mode for 14+ days */
|
||||
.ultra-compact .day-header {
|
||||
padding: 0.0625rem;
|
||||
}
|
||||
|
||||
.ultra-compact .day-name {
|
||||
font-size: 0.55rem;
|
||||
}
|
||||
|
||||
.ultra-compact .day-number {
|
||||
font-size: 0.75rem;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.ultra-compact .time-label {
|
||||
font-size: 0.55rem;
|
||||
padding-right: 0.125rem;
|
||||
}
|
||||
|
||||
.ultra-compact .event-card {
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
.ultra-compact .event-title {
|
||||
font-size: 0.5rem;
|
||||
}
|
||||
|
||||
.ultra-compact .all-day-event {
|
||||
padding: 1px 2px;
|
||||
font-size: 0.55rem;
|
||||
}
|
||||
|
||||
.ultra-compact .all-day-block-event {
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 1px 2px;
|
||||
}
|
||||
|
||||
.ultra-compact .all-day-block-event .event-title {
|
||||
font-size: 0.5rem;
|
||||
}
|
||||
|
||||
.ultra-compact .resize-handle {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.ultra-compact .overflow-line {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.ultra-compact .overflow-line:hover {
|
||||
height: 3px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* OverflowIndicator Component
|
||||
* Shows colored lines indicating events outside the visible time range
|
||||
*/
|
||||
|
||||
import type { CalendarEvent } from '@calendar/shared';
|
||||
|
||||
interface OverflowEvent {
|
||||
event: CalendarEvent;
|
||||
color: string;
|
||||
tooltip: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
events: OverflowEvent[];
|
||||
position: 'top' | 'bottom';
|
||||
label?: string;
|
||||
}
|
||||
|
||||
let { events, position, label }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if events.length > 0}
|
||||
<div class="overflow-indicator {position}" title={label}>
|
||||
{#each events as { color, tooltip }}
|
||||
<div class="overflow-line" style="background-color: {color}" title={tooltip}></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.overflow-indicator {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 2px 4px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.overflow-indicator.top {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.overflow-indicator.bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.overflow-line {
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
border-radius: 1px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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
|
|
@ -0,0 +1,42 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* TimeColumn Component
|
||||
* Renders the time labels column for calendar views (WeekView, DayView, MultiDayView)
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
hours: number[];
|
||||
formatHour: (hour: number) => string;
|
||||
}
|
||||
|
||||
let { hours, formatHour }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="time-column">
|
||||
{#each hours as hour}
|
||||
<div class="time-label">
|
||||
{formatHour(hour)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.time-column {
|
||||
width: 48px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid hsl(var(--color-border));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.time-label {
|
||||
height: var(--hour-height, 60px);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
padding-right: 8px;
|
||||
padding-top: 0;
|
||||
font-size: 0.7rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
transform: translateY(-0.4em);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -158,6 +158,18 @@
|
|||
border-radius: var(--radius-lg);
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Mobile: Full-bleed ohne Rundungen */
|
||||
@media (max-width: 768px) {
|
||||
.todo-sidebar-section {
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
border-top: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
}
|
||||
|
||||
.section-header {
|
||||
|
|
@ -240,12 +252,18 @@
|
|||
|
||||
.section-content {
|
||||
padding: 0 0.5rem 0.5rem;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.todo-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.service-unavailable,
|
||||
|
|
@ -258,6 +276,7 @@
|
|||
padding: 1.5rem 1rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.8125rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.service-unavailable {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,447 @@
|
|||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { getOffsetDate } from '$lib/utils/dateNavigation';
|
||||
import WeekView from './WeekView.svelte';
|
||||
import DayView from './DayView.svelte';
|
||||
import MonthView from './MonthView.svelte';
|
||||
import MultiDayView from './MultiDayView.svelte';
|
||||
import YearView from './YearView.svelte';
|
||||
import AgendaView from './AgendaView.svelte';
|
||||
import type { CalendarEvent } from '@calendar/shared';
|
||||
|
||||
interface Props {
|
||||
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
|
||||
onEventClick?: (event: CalendarEvent) => void;
|
||||
disableSwipe?: boolean;
|
||||
}
|
||||
|
||||
let { onQuickCreate, onEventClick, disableSwipe = false }: Props = $props();
|
||||
|
||||
// Swipe tracking state
|
||||
let offsetX = $state(0);
|
||||
let startX = $state(0);
|
||||
let isSwiping = $state(false);
|
||||
let isAnimating = $state(false);
|
||||
let animatingDirection: 'prev' | 'next' | null = null;
|
||||
|
||||
// Velocity tracking for momentum
|
||||
let lastX = 0;
|
||||
let lastTime = 0;
|
||||
let velocity = 0;
|
||||
|
||||
// Animation frame tracking
|
||||
let animationFrameId: number | null = null;
|
||||
let pendingCallback: (() => void) | null = null;
|
||||
|
||||
// Container refs
|
||||
let viewportEl: HTMLDivElement;
|
||||
let viewportWidth = $state(0);
|
||||
|
||||
// Threshold: 15% of viewport width or high velocity triggers navigation
|
||||
const SNAP_THRESHOLD = 0.15;
|
||||
const VELOCITY_THRESHOLD = 0.5; // px/ms - increased for faster swipes
|
||||
// Animation speed (px/ms) - constant speed for linear feel
|
||||
const ANIMATION_SPEED = 3.0; // increased for snappier feel
|
||||
// Debounce for wheel events
|
||||
const WHEEL_DEBOUNCE_MS = 50; // reduced for faster response
|
||||
let wheelDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Calculate dates for previous/current/next views
|
||||
let prevDate = $derived(getOffsetDate(viewStore.currentDate, viewStore.viewType, -1));
|
||||
let currentDate = $derived(viewStore.currentDate);
|
||||
let nextDate = $derived(getOffsetDate(viewStore.currentDate, viewStore.viewType, 1));
|
||||
|
||||
// Update viewport width on mount and resize
|
||||
$effect(() => {
|
||||
if (!browser || !viewportEl) return;
|
||||
|
||||
const updateWidth = () => {
|
||||
viewportWidth = viewportEl.offsetWidth;
|
||||
};
|
||||
|
||||
updateWidth();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateWidth);
|
||||
resizeObserver.observe(viewportEl);
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
});
|
||||
|
||||
// Wheel handler (trackpad horizontal scroll)
|
||||
function handleWheel(e: WheelEvent) {
|
||||
if (disableSwipe) return;
|
||||
|
||||
// Only handle horizontal scrolling (deltaX dominant)
|
||||
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
|
||||
|
||||
// Don't interfere with event dragging
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-event-id]') || target.closest('[data-dragging]')) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
// If animating, check if we should chain navigation
|
||||
if (isAnimating) {
|
||||
const scrollDirection = e.deltaX < 0 ? 'next' : 'prev';
|
||||
if (scrollDirection === animatingDirection && Math.abs(e.deltaX) > 10) {
|
||||
// Chain navigation - immediately go to next page in same direction
|
||||
chainNavigation(scrollDirection);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Simple direct offset update
|
||||
offsetX += e.deltaX * -1;
|
||||
offsetX = Math.max(-viewportWidth, Math.min(viewportWidth, offsetX));
|
||||
|
||||
// Debounced snap
|
||||
if (wheelDebounceTimer) clearTimeout(wheelDebounceTimer);
|
||||
wheelDebounceTimer = setTimeout(snapToPage, WHEEL_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
// Touch handlers
|
||||
function handleTouchStart(e: TouchEvent) {
|
||||
if (disableSwipe || isAnimating) return;
|
||||
|
||||
// Don't interfere with event dragging
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-event-id]') || target.closest('[data-dragging]')) return;
|
||||
|
||||
startX = e.touches[0].clientX;
|
||||
lastX = startX;
|
||||
lastTime = performance.now();
|
||||
velocity = 0;
|
||||
isSwiping = true;
|
||||
|
||||
if (wheelDebounceTimer) {
|
||||
clearTimeout(wheelDebounceTimer);
|
||||
wheelDebounceTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchMove(e: TouchEvent) {
|
||||
if (!isSwiping || disableSwipe) return;
|
||||
|
||||
const currentX = e.touches[0].clientX;
|
||||
const currentTime = performance.now();
|
||||
|
||||
// Calculate velocity (px/ms)
|
||||
const dt = currentTime - lastTime;
|
||||
if (dt > 0) {
|
||||
velocity = (currentX - lastX) / dt;
|
||||
}
|
||||
|
||||
lastX = currentX;
|
||||
lastTime = currentTime;
|
||||
|
||||
offsetX = currentX - startX;
|
||||
offsetX = Math.max(-viewportWidth, Math.min(viewportWidth, offsetX));
|
||||
}
|
||||
|
||||
function handleTouchEnd() {
|
||||
if (!isSwiping) return;
|
||||
isSwiping = false;
|
||||
snapToPage();
|
||||
}
|
||||
|
||||
function handleTouchCancel() {
|
||||
if (!isSwiping) return;
|
||||
isSwiping = false;
|
||||
isAnimating = true;
|
||||
animateToOffset(0, () => {
|
||||
isAnimating = false;
|
||||
});
|
||||
}
|
||||
|
||||
// Chain navigation - immediately complete current and start next
|
||||
function chainNavigation(direction: 'prev' | 'next') {
|
||||
// Cancel current animation
|
||||
if (animationFrameId !== null) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = null;
|
||||
}
|
||||
|
||||
// Complete current navigation immediately (without resetting state flags)
|
||||
if (animatingDirection === 'prev') {
|
||||
viewStore.goToPrevious();
|
||||
} else if (animatingDirection === 'next') {
|
||||
viewStore.goToNext();
|
||||
}
|
||||
|
||||
// Reset and start new animation for another page in same direction
|
||||
offsetX = direction === 'prev' ? viewportWidth * 0.4 : -viewportWidth * 0.4;
|
||||
animatingDirection = direction;
|
||||
|
||||
const targetOffset = direction === 'prev' ? viewportWidth : -viewportWidth;
|
||||
pendingCallback = () => {
|
||||
if (direction === 'prev') {
|
||||
viewStore.goToPrevious();
|
||||
} else {
|
||||
viewStore.goToNext();
|
||||
}
|
||||
offsetX = 0;
|
||||
isAnimating = false;
|
||||
animatingDirection = null;
|
||||
pendingCallback = null;
|
||||
};
|
||||
|
||||
animateToOffset(targetOffset, pendingCallback);
|
||||
}
|
||||
|
||||
// Snap to page based on current offset and velocity
|
||||
function snapToPage() {
|
||||
if (isAnimating || viewportWidth === 0) return;
|
||||
|
||||
const threshold = viewportWidth * SNAP_THRESHOLD;
|
||||
const hasHighVelocity = Math.abs(velocity) > VELOCITY_THRESHOLD;
|
||||
|
||||
// Determine direction based on position and velocity
|
||||
let targetPage: 'prev' | 'next' | 'current' = 'current';
|
||||
|
||||
if (offsetX > threshold || (hasHighVelocity && velocity > 0 && offsetX > 0)) {
|
||||
targetPage = 'prev';
|
||||
} else if (offsetX < -threshold || (hasHighVelocity && velocity < 0 && offsetX < 0)) {
|
||||
targetPage = 'next';
|
||||
}
|
||||
|
||||
isAnimating = true;
|
||||
animatingDirection = targetPage === 'current' ? null : targetPage;
|
||||
|
||||
if (targetPage === 'prev') {
|
||||
pendingCallback = () => {
|
||||
viewStore.goToPrevious();
|
||||
offsetX = 0;
|
||||
isAnimating = false;
|
||||
animatingDirection = null;
|
||||
pendingCallback = null;
|
||||
};
|
||||
animateToOffset(viewportWidth, pendingCallback);
|
||||
} else if (targetPage === 'next') {
|
||||
pendingCallback = () => {
|
||||
viewStore.goToNext();
|
||||
offsetX = 0;
|
||||
isAnimating = false;
|
||||
animatingDirection = null;
|
||||
pendingCallback = null;
|
||||
};
|
||||
animateToOffset(-viewportWidth, pendingCallback);
|
||||
} else {
|
||||
pendingCallback = () => {
|
||||
isAnimating = false;
|
||||
animatingDirection = null;
|
||||
pendingCallback = null;
|
||||
};
|
||||
animateToOffset(0, pendingCallback);
|
||||
}
|
||||
}
|
||||
|
||||
function animateToOffset(targetX: number, onComplete: () => void) {
|
||||
// Cancel any existing animation
|
||||
if (animationFrameId !== null) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
|
||||
const startX = offsetX;
|
||||
const distance = targetX - startX;
|
||||
const direction = Math.sign(distance);
|
||||
const absDistance = Math.abs(distance);
|
||||
|
||||
// If already at target, complete immediately
|
||||
if (absDistance < 1) {
|
||||
offsetX = targetX;
|
||||
onComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
let lastFrameTime = performance.now();
|
||||
|
||||
function tick() {
|
||||
const now = performance.now();
|
||||
const dt = now - lastFrameTime;
|
||||
lastFrameTime = now;
|
||||
|
||||
// Move at constant speed
|
||||
const step = ANIMATION_SPEED * dt * direction;
|
||||
offsetX += step;
|
||||
|
||||
// Check if we've reached or passed the target
|
||||
const reachedTarget =
|
||||
(direction > 0 && offsetX >= targetX) || (direction < 0 && offsetX <= targetX);
|
||||
|
||||
if (reachedTarget) {
|
||||
offsetX = targetX;
|
||||
animationFrameId = null;
|
||||
onComplete();
|
||||
} else {
|
||||
animationFrameId = requestAnimationFrame(tick);
|
||||
}
|
||||
}
|
||||
|
||||
animationFrameId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
// Computed styles
|
||||
let trackStyle = $derived(`transform: translateX(calc(-33.333% + ${offsetX}px))`);
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="carousel-viewport"
|
||||
bind:this={viewportEl}
|
||||
onwheel={handleWheel}
|
||||
ontouchstart={handleTouchStart}
|
||||
ontouchmove={handleTouchMove}
|
||||
ontouchend={handleTouchEnd}
|
||||
ontouchcancel={handleTouchCancel}
|
||||
>
|
||||
<div class="carousel-track" style={trackStyle}>
|
||||
<!-- Previous View -->
|
||||
<div class="carousel-page" class:inactive={!isSwiping && offsetX <= 0}>
|
||||
{#if viewStore.viewType === 'day'}
|
||||
<DayView date={prevDate} />
|
||||
{:else if viewStore.viewType === '3day'}
|
||||
<MultiDayView dayCount={3} date={prevDate} />
|
||||
{:else if viewStore.viewType === '5day'}
|
||||
<MultiDayView dayCount={5} date={prevDate} />
|
||||
{:else if viewStore.viewType === 'week'}
|
||||
<WeekView date={prevDate} />
|
||||
{:else if viewStore.viewType === '10day'}
|
||||
<MultiDayView dayCount={10} date={prevDate} />
|
||||
{:else if viewStore.viewType === '14day'}
|
||||
<MultiDayView dayCount={14} date={prevDate} />
|
||||
{:else if viewStore.viewType === '30day'}
|
||||
<MultiDayView dayCount={30} date={prevDate} />
|
||||
{:else if viewStore.viewType === '60day'}
|
||||
<MultiDayView dayCount={60} date={prevDate} />
|
||||
{:else if viewStore.viewType === '90day'}
|
||||
<MultiDayView dayCount={90} date={prevDate} />
|
||||
{:else if viewStore.viewType === '365day'}
|
||||
<MultiDayView dayCount={365} date={prevDate} />
|
||||
{:else if viewStore.viewType === 'custom'}
|
||||
<MultiDayView dayCount={settingsStore.customDayCount} date={prevDate} />
|
||||
{:else if viewStore.viewType === 'month'}
|
||||
<MonthView date={prevDate} />
|
||||
{:else if viewStore.viewType === 'year'}
|
||||
<YearView date={prevDate} />
|
||||
{:else if viewStore.viewType === 'agenda'}
|
||||
<AgendaView date={prevDate} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Current View (main interactive view) -->
|
||||
<div class="carousel-page current">
|
||||
{#if viewStore.viewType === 'day'}
|
||||
<DayView {onQuickCreate} {onEventClick} />
|
||||
{:else if viewStore.viewType === '3day'}
|
||||
<MultiDayView dayCount={3} {onQuickCreate} {onEventClick} />
|
||||
{:else if viewStore.viewType === '5day'}
|
||||
<MultiDayView dayCount={5} {onQuickCreate} {onEventClick} />
|
||||
{:else if viewStore.viewType === 'week'}
|
||||
<WeekView {onQuickCreate} {onEventClick} />
|
||||
{:else if viewStore.viewType === '10day'}
|
||||
<MultiDayView dayCount={10} {onQuickCreate} {onEventClick} />
|
||||
{:else if viewStore.viewType === '14day'}
|
||||
<MultiDayView dayCount={14} {onQuickCreate} {onEventClick} />
|
||||
{:else if viewStore.viewType === '30day'}
|
||||
<MultiDayView dayCount={30} {onQuickCreate} {onEventClick} />
|
||||
{:else if viewStore.viewType === '60day'}
|
||||
<MultiDayView dayCount={60} {onQuickCreate} {onEventClick} />
|
||||
{:else if viewStore.viewType === '90day'}
|
||||
<MultiDayView dayCount={90} {onQuickCreate} {onEventClick} />
|
||||
{:else if viewStore.viewType === '365day'}
|
||||
<MultiDayView dayCount={365} {onQuickCreate} {onEventClick} />
|
||||
{:else if viewStore.viewType === 'custom'}
|
||||
<MultiDayView dayCount={settingsStore.customDayCount} {onQuickCreate} {onEventClick} />
|
||||
{:else if viewStore.viewType === 'month'}
|
||||
<MonthView {onQuickCreate} {onEventClick} />
|
||||
{:else if viewStore.viewType === 'year'}
|
||||
<YearView {onQuickCreate} {onEventClick} />
|
||||
{:else if viewStore.viewType === 'agenda'}
|
||||
<AgendaView {onEventClick} />
|
||||
{:else}
|
||||
<WeekView {onQuickCreate} {onEventClick} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Next View -->
|
||||
<div class="carousel-page" class:inactive={!isSwiping && offsetX >= 0}>
|
||||
{#if viewStore.viewType === 'day'}
|
||||
<DayView date={nextDate} />
|
||||
{:else if viewStore.viewType === '3day'}
|
||||
<MultiDayView dayCount={3} date={nextDate} />
|
||||
{:else if viewStore.viewType === '5day'}
|
||||
<MultiDayView dayCount={5} date={nextDate} />
|
||||
{:else if viewStore.viewType === 'week'}
|
||||
<WeekView date={nextDate} />
|
||||
{:else if viewStore.viewType === '10day'}
|
||||
<MultiDayView dayCount={10} date={nextDate} />
|
||||
{:else if viewStore.viewType === '14day'}
|
||||
<MultiDayView dayCount={14} date={nextDate} />
|
||||
{:else if viewStore.viewType === '30day'}
|
||||
<MultiDayView dayCount={30} date={nextDate} />
|
||||
{:else if viewStore.viewType === '60day'}
|
||||
<MultiDayView dayCount={60} date={nextDate} />
|
||||
{:else if viewStore.viewType === '90day'}
|
||||
<MultiDayView dayCount={90} date={nextDate} />
|
||||
{:else if viewStore.viewType === '365day'}
|
||||
<MultiDayView dayCount={365} date={nextDate} />
|
||||
{:else if viewStore.viewType === 'custom'}
|
||||
<MultiDayView dayCount={settingsStore.customDayCount} date={nextDate} />
|
||||
{:else if viewStore.viewType === 'month'}
|
||||
<MonthView date={nextDate} />
|
||||
{:else if viewStore.viewType === 'year'}
|
||||
<YearView date={nextDate} />
|
||||
{:else if viewStore.viewType === 'agenda'}
|
||||
<AgendaView date={nextDate} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.carousel-viewport {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.carousel-track {
|
||||
display: flex;
|
||||
width: 300%;
|
||||
height: 100%;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.carousel-page {
|
||||
width: 33.333%;
|
||||
height: 100%;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Mobile: Allow vertical scrolling within calendar page */
|
||||
@media (max-width: 768px) {
|
||||
.carousel-page {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
|
||||
/* Inactive pages have reduced interactivity for performance */
|
||||
.carousel-page.inactive {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.carousel-page.current {
|
||||
/* Always interactive */
|
||||
pointer-events: auto;
|
||||
/* Enable vertical scrolling for keyboard navigation */
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,240 @@
|
|||
<script lang="ts">
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import type { CalendarViewType } from '@calendar/shared';
|
||||
import ViewModePillContextMenu from './ViewModePillContextMenu.svelte';
|
||||
|
||||
interface Props {
|
||||
isSidebarMode?: boolean;
|
||||
isToolbarExpanded?: boolean;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
let { isSidebarMode = false, isToolbarExpanded = false, isMobile = false }: Props = $props();
|
||||
|
||||
let contextMenu: ViewModePillContextMenu;
|
||||
|
||||
function handleContextMenu(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
contextMenu?.show(e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
function handleViewClick(view: CalendarViewType) {
|
||||
viewStore.setViewType(view);
|
||||
}
|
||||
|
||||
// View labels (short versions for pill)
|
||||
const viewLabels: Record<CalendarViewType, string> = {
|
||||
day: '1',
|
||||
'5day': '5',
|
||||
week: '7',
|
||||
'10day': '10',
|
||||
'14day': '14',
|
||||
month: 'M',
|
||||
year: 'Y',
|
||||
agenda: 'A',
|
||||
};
|
||||
|
||||
// View titles for tooltip
|
||||
const viewTitles: Record<CalendarViewType, string> = {
|
||||
day: 'Tagesansicht',
|
||||
'5day': '5-Tage-Ansicht',
|
||||
week: 'Wochenansicht',
|
||||
'10day': '10-Tage-Ansicht',
|
||||
'14day': '14-Tage-Ansicht',
|
||||
month: 'Monatsansicht',
|
||||
year: 'Jahresansicht',
|
||||
agenda: 'Agenda',
|
||||
};
|
||||
|
||||
// Get enabled views from settings
|
||||
let enabledViews = $derived(settingsStore.quickViewPillViews);
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="view-mode-pill"
|
||||
class:sidebar-mode={isSidebarMode}
|
||||
class:toolbar-expanded={isToolbarExpanded}
|
||||
class:mobile={isMobile}
|
||||
oncontextmenu={handleContextMenu}
|
||||
>
|
||||
{#each enabledViews as view}
|
||||
<button
|
||||
type="button"
|
||||
class="view-btn"
|
||||
class:active={viewStore.viewType === view}
|
||||
onclick={() => handleViewClick(view)}
|
||||
title={viewTitles[view]}
|
||||
>
|
||||
{#if view === 'day'}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<rect x="6" y="4" width="12" height="16" rx="2" stroke-width="2" />
|
||||
<path stroke-linecap="round" stroke-width="2" d="M6 8h12" />
|
||||
</svg>
|
||||
{:else if view === '5day' || view === 'week'}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{#if view === '5day'}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-width="2"
|
||||
d="M5 4v16M9 4v16M13 4v16M17 4v16M21 4v16"
|
||||
/>
|
||||
{:else}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-width="2"
|
||||
d="M3 4v16M6.5 4v16M10 4v16M13.5 4v16M17 4v16M20.5 4v16M24 4v16"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
{:else if view === '10day' || view === '14day'}
|
||||
<span class="view-text">{viewLabels[view]}</span>
|
||||
{:else if view === 'month'}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" stroke-width="2" />
|
||||
<path stroke-linecap="round" stroke-width="2" d="M3 9h18M8 4v4M16 4v4" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-width="1.5"
|
||||
d="M7 13h2M11 13h2M15 13h2M7 17h2M11 17h2"
|
||||
/>
|
||||
</svg>
|
||||
{:else if view === 'year'}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<rect x="2" y="2" width="4" height="4" rx="0.5" stroke-width="1.5" />
|
||||
<rect x="7.5" y="2" width="4" height="4" rx="0.5" stroke-width="1.5" />
|
||||
<rect x="13" y="2" width="4" height="4" rx="0.5" stroke-width="1.5" />
|
||||
<rect x="18.5" y="2" width="4" height="4" rx="0.5" stroke-width="1.5" />
|
||||
<rect x="2" y="8" width="4" height="4" rx="0.5" stroke-width="1.5" />
|
||||
<rect x="7.5" y="8" width="4" height="4" rx="0.5" stroke-width="1.5" />
|
||||
<rect x="13" y="8" width="4" height="4" rx="0.5" stroke-width="1.5" />
|
||||
<rect x="18.5" y="8" width="4" height="4" rx="0.5" stroke-width="1.5" />
|
||||
<rect x="2" y="14" width="4" height="4" rx="0.5" stroke-width="1.5" />
|
||||
<rect x="7.5" y="14" width="4" height="4" rx="0.5" stroke-width="1.5" />
|
||||
<rect x="13" y="14" width="4" height="4" rx="0.5" stroke-width="1.5" />
|
||||
<rect x="18.5" y="14" width="4" height="4" rx="0.5" stroke-width="1.5" />
|
||||
</svg>
|
||||
{:else if view === 'agenda'}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h12" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<ViewModePillContextMenu bind:this={contextMenu} />
|
||||
|
||||
<style>
|
||||
.view-mode-pill {
|
||||
position: fixed;
|
||||
/* Same vertical alignment as FAB */
|
||||
bottom: calc(70px + 9px + env(safe-area-inset-bottom, 0px));
|
||||
/* Position left of the Toolbar FAB (which is at right: calc(50% - 350px - 70px)) */
|
||||
/* FAB position + FAB width (54px) + gap (8px) = left of FAB */
|
||||
right: calc(50% - 350px - 70px + 54px + 8px);
|
||||
z-index: 91;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
padding: 0.375rem;
|
||||
background: hsl(var(--color-surface) / 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 9999px;
|
||||
box-shadow: 0 2px 8px hsl(var(--color-foreground) / 0.08);
|
||||
transition: bottom 0.2s ease;
|
||||
}
|
||||
|
||||
/* When toolbar is expanded, move pill up */
|
||||
.view-mode-pill.toolbar-expanded {
|
||||
bottom: calc(140px + 9px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
/* Sidebar mode positioning */
|
||||
.view-mode-pill.sidebar-mode {
|
||||
bottom: calc(9px + env(safe-area-inset-bottom, 0px));
|
||||
right: calc(50% - 350px - 70px + 54px + 8px);
|
||||
}
|
||||
|
||||
.view-mode-pill.sidebar-mode.toolbar-expanded {
|
||||
bottom: calc(70px + 9px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
/* Responsive - on smaller screens, FAB is at right: 1rem */
|
||||
@media (max-width: 900px) {
|
||||
.view-mode-pill {
|
||||
right: calc(1rem + 54px + 8px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0.375rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.view-btn:hover {
|
||||
background: hsl(var(--color-muted) / 0.5);
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.view-btn.active {
|
||||
background: color-mix(in srgb, #3b82f6 15%, transparent 85%);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.view-btn :global(svg) {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
}
|
||||
|
||||
.view-text {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,410 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import type { CalendarViewType } from '@calendar/shared';
|
||||
|
||||
// Context menu state
|
||||
let visible = $state(false);
|
||||
let x = $state(0);
|
||||
let y = $state(0);
|
||||
let menuElement = $state<HTMLElement | null>(null);
|
||||
let adjustedX = $state(0);
|
||||
let adjustedY = $state(0);
|
||||
|
||||
// Custom day count input state
|
||||
let customDayInput = $state(String(settingsStore.customDayCount));
|
||||
|
||||
// View labels
|
||||
const viewLabels: Record<CalendarViewType, string> = {
|
||||
day: 'Tag (1)',
|
||||
'3day': '3 Tage',
|
||||
'5day': '5 Tage',
|
||||
week: 'Woche (7)',
|
||||
'10day': '10 Tage',
|
||||
'14day': '14 Tage',
|
||||
'30day': '30 Tage',
|
||||
'60day': '60 Tage',
|
||||
'90day': '90 Tage',
|
||||
'365day': '365 Tage',
|
||||
month: 'Monat',
|
||||
year: 'Jahr',
|
||||
agenda: 'Agenda',
|
||||
custom: 'Benutzerdefiniert',
|
||||
};
|
||||
|
||||
// All available views (ordered)
|
||||
const allViews: CalendarViewType[] = [
|
||||
'day',
|
||||
'3day',
|
||||
'5day',
|
||||
'week',
|
||||
'10day',
|
||||
'14day',
|
||||
'30day',
|
||||
'60day',
|
||||
'90day',
|
||||
'365day',
|
||||
'month',
|
||||
'year',
|
||||
'agenda',
|
||||
'custom',
|
||||
];
|
||||
|
||||
// Adjust position to keep menu within viewport
|
||||
$effect(() => {
|
||||
if (visible && menuElement) {
|
||||
const rect = menuElement.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// Adjust X if menu would overflow right
|
||||
if (x + rect.width > viewportWidth - 10) {
|
||||
adjustedX = x - rect.width;
|
||||
} else {
|
||||
adjustedX = x;
|
||||
}
|
||||
|
||||
// Adjust Y if menu would overflow bottom
|
||||
if (y + rect.height > viewportHeight - 10) {
|
||||
adjustedY = y - rect.height;
|
||||
} else {
|
||||
adjustedY = y;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Sync custom day input when settings change
|
||||
$effect(() => {
|
||||
customDayInput = String(settingsStore.customDayCount);
|
||||
});
|
||||
|
||||
function isViewEnabled(view: CalendarViewType): boolean {
|
||||
return settingsStore.quickViewPillViews.includes(view);
|
||||
}
|
||||
|
||||
function toggleView(view: CalendarViewType) {
|
||||
const current = settingsStore.quickViewPillViews;
|
||||
if (current.includes(view)) {
|
||||
// Remove view (but keep at least one)
|
||||
if (current.length > 1) {
|
||||
settingsStore.set(
|
||||
'quickViewPillViews',
|
||||
current.filter((v) => v !== view)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Add view
|
||||
settingsStore.set('quickViewPillViews', [...current, view]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCustomDayInputChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
customDayInput = target.value;
|
||||
}
|
||||
|
||||
function applyCustomDays() {
|
||||
const value = parseInt(customDayInput, 10);
|
||||
if (isNaN(value) || value < 1 || value > 365) {
|
||||
// Reset to current value if invalid
|
||||
customDayInput = String(settingsStore.customDayCount);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set custom day count
|
||||
settingsStore.set('customDayCount', value);
|
||||
customDayInput = String(value);
|
||||
|
||||
// Auto-enable 'custom' view if not already
|
||||
const current = settingsStore.quickViewPillViews;
|
||||
if (!current.includes('custom')) {
|
||||
settingsStore.set('quickViewPillViews', [...current, 'custom']);
|
||||
}
|
||||
|
||||
// Switch to custom view
|
||||
viewStore.setViewType('custom');
|
||||
|
||||
// Close the menu
|
||||
visible = false;
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
visible = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleInputKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
applyCustomDays();
|
||||
}
|
||||
// Stop propagation to prevent menu from closing
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Close on click outside
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuElement && !menuElement.contains(e.target as Node)) {
|
||||
visible = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Close on scroll
|
||||
const handleScroll = () => {
|
||||
visible = false;
|
||||
};
|
||||
|
||||
window.addEventListener('click', handleClickOutside);
|
||||
window.addEventListener('scroll', handleScroll, true);
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('click', handleClickOutside);
|
||||
window.removeEventListener('scroll', handleScroll, true);
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
});
|
||||
|
||||
// Export show function to be called from parent
|
||||
export function show(clientX: number, clientY: number) {
|
||||
x = clientX;
|
||||
y = clientY;
|
||||
visible = true;
|
||||
}
|
||||
|
||||
export function hide() {
|
||||
visible = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<!-- Backdrop to block clicks on elements behind -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="context-menu-backdrop"
|
||||
onpointerdown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
visible = false;
|
||||
}}
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
visible = false;
|
||||
}}
|
||||
oncontextmenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
visible = false;
|
||||
}}
|
||||
></div>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
bind:this={menuElement}
|
||||
class="context-menu"
|
||||
style="left: {adjustedX}px; top: {adjustedY}px;"
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
transition:fly={{ duration: 150, y: -8 }}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
oncontextmenu={(e) => e.preventDefault()}
|
||||
onkeydown={handleKeyDown}
|
||||
>
|
||||
<!-- Standard view toggles -->
|
||||
{#each allViews as view}
|
||||
<button class="menu-item has-toggle" onclick={() => toggleView(view)} role="menuitem">
|
||||
<span class="item-toggle" class:checked={isViewEnabled(view)}>
|
||||
<span class="toggle-track">
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="item-label">{viewLabels[view]}</span>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Custom day count section -->
|
||||
<div class="custom-section">
|
||||
<span class="custom-label">Benutzerdefiniert (1-365)</span>
|
||||
<div class="custom-input-row">
|
||||
<input
|
||||
type="number"
|
||||
class="custom-input"
|
||||
min="1"
|
||||
max="365"
|
||||
value={customDayInput}
|
||||
oninput={handleCustomDayInputChange}
|
||||
onkeydown={handleInputKeyDown}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span class="custom-unit">Tage</span>
|
||||
<button class="custom-apply-btn" onclick={applyCustomDays}> Setzen </button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.context-menu-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9998;
|
||||
background: transparent;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
min-width: 200px;
|
||||
max-width: 280px;
|
||||
padding: 0.375rem;
|
||||
background: var(--color-surface-elevated-3);
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: var(--radius-lg);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
text-align: left;
|
||||
transition: background-color 100ms ease;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: hsl(var(--color-muted));
|
||||
}
|
||||
|
||||
.item-label {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
margin: 0.375rem 0.5rem;
|
||||
background: hsl(var(--color-border));
|
||||
}
|
||||
|
||||
/* Toggle switch styles */
|
||||
.item-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-track {
|
||||
position: relative;
|
||||
width: 28px;
|
||||
height: 16px;
|
||||
background: hsl(var(--color-muted));
|
||||
border-radius: 8px;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.toggle-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: hsl(var(--color-background));
|
||||
border-radius: 50%;
|
||||
transition: transform 150ms ease;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.item-toggle.checked .toggle-track {
|
||||
background: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.item-toggle.checked .toggle-thumb {
|
||||
transform: translateX(12px);
|
||||
}
|
||||
|
||||
/* Custom section styles */
|
||||
.custom-section {
|
||||
padding: 0.5rem 0.625rem;
|
||||
}
|
||||
|
||||
.custom-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.custom-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.custom-input {
|
||||
width: 60px;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: var(--radius-md);
|
||||
background: hsl(var(--color-background));
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.8125rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.custom-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
/* Hide number input spinners */
|
||||
.custom-input::-webkit-outer-spin-button,
|
||||
.custom-input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
.custom-input[type='number'] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.custom-unit {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.custom-apply-btn {
|
||||
margin-left: auto;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.custom-apply-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -5,21 +5,36 @@
|
|||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { searchStore } from '$lib/stores/search.svelte';
|
||||
import { todosStore, type Task } from '$lib/stores/todos.svelte';
|
||||
import { birthdaysStore, type BirthdayEvent } from '$lib/stores/birthdays.svelte';
|
||||
import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte';
|
||||
import BirthdayPopover from '$lib/components/birthday/BirthdayPopover.svelte';
|
||||
import { useVisibleHours, useCurrentTimeIndicator, useBirthdayPopover } from '$lib/composables';
|
||||
import { toDate } from '$lib/utils/eventDateHelpers';
|
||||
import { HOUR_HEIGHT_PX, SNAP_INTERVAL_MINUTES } from '$lib/utils/calendarConstants';
|
||||
import {
|
||||
getVisibleTimedEvents,
|
||||
getVisibleAllDayEvents,
|
||||
getVisibleOverflowEvents,
|
||||
type OverflowEvents,
|
||||
} from '$lib/utils/eventFiltering';
|
||||
import EventCard from './EventCard.svelte';
|
||||
import TaskBlock from './TaskBlock.svelte';
|
||||
import EventContextMenu from '$lib/components/event/EventContextMenu.svelte';
|
||||
import CalendarHeaderContextMenu from './CalendarHeaderContextMenu.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import {
|
||||
format,
|
||||
eachDayOfInterval,
|
||||
startOfDay,
|
||||
isToday,
|
||||
isWeekend,
|
||||
isSameDay,
|
||||
parseISO,
|
||||
differenceInMinutes,
|
||||
addMinutes,
|
||||
setHours,
|
||||
setMinutes,
|
||||
getWeek,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
} from 'date-fns';
|
||||
import { de, enUS, fr, es, it } from 'date-fns/locale';
|
||||
import { locale, _ } from 'svelte-i18n';
|
||||
|
|
@ -27,16 +42,35 @@
|
|||
import type { CalendarEvent } from '@calendar/shared';
|
||||
|
||||
interface Props {
|
||||
/** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */
|
||||
date?: Date;
|
||||
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
|
||||
onEventClick?: (event: CalendarEvent) => void;
|
||||
onTaskClick?: (task: Task) => void;
|
||||
}
|
||||
|
||||
let { onQuickCreate, onEventClick, onTaskClick }: Props = $props();
|
||||
let { date, onQuickCreate, onEventClick, onTaskClick }: Props = $props();
|
||||
|
||||
// Constants
|
||||
const HOUR_HEIGHT = 60; // px - should match CSS --hour-height
|
||||
const MINUTES_PER_SLOT = 15; // Snap to 15-minute intervals
|
||||
// Use provided date or fall back to viewStore
|
||||
let effectiveDate = $derived(date ?? viewStore.currentDate);
|
||||
|
||||
// Calculate view range based on effective date
|
||||
let effectiveViewRange = $derived.by(() => {
|
||||
if (date) {
|
||||
// Calculate range for the provided date
|
||||
const weekStartsOn = settingsStore.weekStartsOn;
|
||||
return {
|
||||
start: startOfWeek(date, { weekStartsOn }),
|
||||
end: endOfWeek(date, { weekStartsOn }),
|
||||
};
|
||||
}
|
||||
// Use viewStore range when no date override
|
||||
return viewStore.viewRange;
|
||||
});
|
||||
|
||||
// Use shared constants
|
||||
const HOUR_HEIGHT = HOUR_HEIGHT_PX;
|
||||
const MINUTES_PER_SLOT = SNAP_INTERVAL_MINUTES;
|
||||
|
||||
// Get date-fns locale based on current app locale
|
||||
const dateLocales = { de, en: enUS, fr, es, it };
|
||||
|
|
@ -47,8 +81,8 @@
|
|||
// Generate days of the week, optionally filtering weekends
|
||||
let allDays = $derived(
|
||||
eachDayOfInterval({
|
||||
start: viewStore.viewRange.start,
|
||||
end: viewStore.viewRange.end,
|
||||
start: effectiveViewRange.start,
|
||||
end: effectiveViewRange.end,
|
||||
})
|
||||
);
|
||||
|
||||
|
|
@ -58,44 +92,22 @@
|
|||
|
||||
// Get week number for display
|
||||
let weekNumber = $derived(
|
||||
getWeek(viewStore.viewRange.start, { weekStartsOn: settingsStore.weekStartsOn })
|
||||
getWeek(effectiveViewRange.start, { weekStartsOn: settingsStore.weekStartsOn })
|
||||
);
|
||||
|
||||
// Generate hours (filtered based on settings)
|
||||
let allHours = Array.from({ length: 24 }, (_, i) => i);
|
||||
let hours = $derived(
|
||||
settingsStore.filterHoursEnabled
|
||||
? allHours.filter((h) => h >= settingsStore.dayStartHour && h < settingsStore.dayEndHour)
|
||||
: allHours
|
||||
);
|
||||
// Use composables for hour filtering and time indicator
|
||||
const visibleHours = useVisibleHours();
|
||||
const timeIndicator = useCurrentTimeIndicator();
|
||||
|
||||
// Calculate visible hours range for positioning
|
||||
let firstVisibleHour = $derived(
|
||||
settingsStore.filterHoursEnabled ? settingsStore.dayStartHour : 0
|
||||
);
|
||||
let lastVisibleHour = $derived(settingsStore.filterHoursEnabled ? settingsStore.dayEndHour : 24);
|
||||
let totalVisibleHours = $derived(lastVisibleHour - firstVisibleHour);
|
||||
|
||||
// Helper to convert minutes to percentage position (accounting for hidden hours)
|
||||
function minutesToPercent(minutes: number): number {
|
||||
const adjustedMinutes = minutes - firstVisibleHour * 60;
|
||||
return (adjustedMinutes / (totalVisibleHours * 60)) * 100;
|
||||
}
|
||||
// Destructure for convenience (these are reactive getters)
|
||||
let hours = $derived(visibleHours.hours);
|
||||
let firstVisibleHour = $derived(visibleHours.firstVisibleHour);
|
||||
let lastVisibleHour = $derived(visibleHours.lastVisibleHour);
|
||||
let totalVisibleHours = $derived(visibleHours.totalVisibleHours);
|
||||
const minutesToPercent = visibleHours.minutesToPercent;
|
||||
|
||||
// Current time indicator position
|
||||
let now = $state(new Date());
|
||||
let currentTimePosition = $derived.by(() => {
|
||||
const minutes = now.getHours() * 60 + now.getMinutes();
|
||||
return minutesToPercent(minutes);
|
||||
});
|
||||
|
||||
// Update current time every minute
|
||||
$effect(() => {
|
||||
const interval = setInterval(() => {
|
||||
now = new Date();
|
||||
}, 60000);
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
let currentTimePosition = $derived(minutesToPercent(timeIndicator.currentMinutes));
|
||||
|
||||
// Drag & Drop State
|
||||
let isDragging = $state(false);
|
||||
|
|
@ -113,6 +125,7 @@
|
|||
let resizeOriginalEnd = $state<Date | null>(null);
|
||||
let resizePreviewTop = $state(0);
|
||||
let resizePreviewHeight = $state(0);
|
||||
let resizeOffsetMinutes = $state(0);
|
||||
|
||||
// Track if we actually moved during drag/resize (to prevent click on simple mousedown/up)
|
||||
let hasMoved = $state(false);
|
||||
|
|
@ -134,66 +147,49 @@
|
|||
// Reference to the days container for position calculations
|
||||
let daysContainerEl: HTMLDivElement;
|
||||
|
||||
function getEventsForDay(day: Date) {
|
||||
const allEvents = eventsStore.getEventsForDay(day).filter((e) => !e.isAllDay);
|
||||
// Birthday Popover (using composable)
|
||||
const birthdayPopover = useBirthdayPopover();
|
||||
|
||||
// If hour filtering is enabled, only show events that overlap with visible range
|
||||
if (settingsStore.filterHoursEnabled) {
|
||||
const visibleStartMinutes = settingsStore.dayStartHour * 60;
|
||||
const visibleEndMinutes = settingsStore.dayEndHour * 60;
|
||||
|
||||
return allEvents.filter((event) => {
|
||||
const start =
|
||||
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
|
||||
|
||||
const eventStartMinutes = start.getHours() * 60 + start.getMinutes();
|
||||
const eventEndMinutes = end.getHours() * 60 + end.getMinutes();
|
||||
|
||||
// Event overlaps with visible range
|
||||
return eventStartMinutes < visibleEndMinutes && eventEndMinutes > visibleStartMinutes;
|
||||
});
|
||||
}
|
||||
|
||||
return allEvents;
|
||||
// Get birthdays for a day (if enabled in settings)
|
||||
function getBirthdaysForDay(day: Date): BirthdayEvent[] {
|
||||
if (!settingsStore.showBirthdays) return [];
|
||||
return birthdaysStore.getBirthdaysForDay(day);
|
||||
}
|
||||
|
||||
// Get events that are completely outside the visible time range
|
||||
function getOverflowEventsForDay(day: Date): { before: CalendarEvent[]; after: CalendarEvent[] } {
|
||||
// Check if there are any birthdays to show in the all-day row
|
||||
let hasAnyBirthdays = $derived(
|
||||
settingsStore.showBirthdays && days.some((day) => getBirthdaysForDay(day).length > 0)
|
||||
);
|
||||
|
||||
function getEventsForDay(day: Date): CalendarEvent[] {
|
||||
return getVisibleTimedEvents(
|
||||
eventsStore.getEventsForDay(day),
|
||||
calendarsStore.visibleCalendars,
|
||||
{
|
||||
filterHoursEnabled: settingsStore.filterHoursEnabled,
|
||||
dayStartHour: settingsStore.dayStartHour,
|
||||
dayEndHour: settingsStore.dayEndHour,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function getOverflowEventsForDay(day: Date): OverflowEvents {
|
||||
if (!settingsStore.filterHoursEnabled) {
|
||||
return { before: [], after: [] };
|
||||
}
|
||||
|
||||
const allEvents = eventsStore.getEventsForDay(day).filter((e) => !e.isAllDay);
|
||||
const before: CalendarEvent[] = [];
|
||||
const after: CalendarEvent[] = [];
|
||||
|
||||
const visibleStartMinutes = settingsStore.dayStartHour * 60;
|
||||
const visibleEndMinutes = settingsStore.dayEndHour * 60;
|
||||
|
||||
for (const event of allEvents) {
|
||||
const start =
|
||||
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
|
||||
|
||||
const eventStartMinutes = start.getHours() * 60 + start.getMinutes();
|
||||
const eventEndMinutes = end.getHours() * 60 + end.getMinutes();
|
||||
|
||||
// Event ends before visible range starts
|
||||
if (eventEndMinutes <= visibleStartMinutes) {
|
||||
before.push(event);
|
||||
}
|
||||
// Event starts after visible range ends
|
||||
else if (eventStartMinutes >= visibleEndMinutes) {
|
||||
after.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
return { before, after };
|
||||
return getVisibleOverflowEvents(
|
||||
eventsStore.getEventsForDay(day),
|
||||
calendarsStore.visibleCalendars,
|
||||
settingsStore.dayStartHour,
|
||||
settingsStore.dayEndHour
|
||||
);
|
||||
}
|
||||
|
||||
function getAllDayEventsForDay(day: Date) {
|
||||
return eventsStore.getEventsForDay(day).filter((e) => e.isAllDay);
|
||||
function getAllDayEventsForDay(day: Date): CalendarEvent[] {
|
||||
return getVisibleAllDayEvents(
|
||||
eventsStore.getEventsForDay(day),
|
||||
calendarsStore.visibleCalendars
|
||||
);
|
||||
}
|
||||
|
||||
// Get display mode for an event (per-event override takes precedence over global setting)
|
||||
|
|
@ -219,8 +215,8 @@
|
|||
);
|
||||
|
||||
function getEventStyle(event: CalendarEvent) {
|
||||
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
|
||||
const start = toDate(event.startTime);
|
||||
const end = toDate(event.endTime);
|
||||
|
||||
const startMinutes = start.getHours() * 60 + start.getMinutes();
|
||||
const duration = differenceInMinutes(end, start);
|
||||
|
|
@ -228,9 +224,11 @@
|
|||
const top = minutesToPercent(startMinutes);
|
||||
const height = Math.max((duration / (totalVisibleHours * 60)) * 100, 2); // Min 2% height
|
||||
|
||||
const color = calendarsStore.getColor(event.calendarId);
|
||||
return `top: ${top}%; height: ${height}%;`;
|
||||
}
|
||||
|
||||
return `top: ${top}%; height: ${height}%; background-color: ${color};`;
|
||||
function formatEventTimeRange(event: CalendarEvent): string {
|
||||
return `${formatEventTime(event.startTime)} - ${formatEventTime(event.endTime)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -265,8 +263,7 @@
|
|||
}
|
||||
|
||||
function formatEventTime(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? parseISO(date) : date;
|
||||
return settingsStore.formatTime(d);
|
||||
return settingsStore.formatTime(toDate(date));
|
||||
}
|
||||
|
||||
function handleEventClick(event: CalendarEvent, e: MouseEvent) {
|
||||
|
|
@ -301,6 +298,19 @@
|
|||
}
|
||||
}
|
||||
|
||||
function handleEventContextMenu(event: CalendarEvent, e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (eventsStore.isDraftEvent(event.id)) return;
|
||||
eventContextMenuStore.show(event, e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
function handleContextMenuEdit(event: CalendarEvent) {
|
||||
if (onEventClick) {
|
||||
onEventClick(event);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Drag & Drop Functions ==========
|
||||
|
||||
function getDayFromX(clientX: number): Date | null {
|
||||
|
|
@ -339,8 +349,8 @@
|
|||
draggedEvent = event;
|
||||
hasMoved = false;
|
||||
|
||||
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
|
||||
const start = toDate(event.startTime);
|
||||
const end = toDate(event.endTime);
|
||||
const duration = differenceInMinutes(end, start);
|
||||
|
||||
// Calculate initial preview position
|
||||
|
|
@ -390,14 +400,8 @@
|
|||
return;
|
||||
}
|
||||
|
||||
const start =
|
||||
typeof draggedEvent.startTime === 'string'
|
||||
? parseISO(draggedEvent.startTime)
|
||||
: draggedEvent.startTime;
|
||||
const end =
|
||||
typeof draggedEvent.endTime === 'string'
|
||||
? parseISO(draggedEvent.endTime)
|
||||
: draggedEvent.endTime;
|
||||
const start = toDate(draggedEvent.startTime);
|
||||
const end = toDate(draggedEvent.endTime);
|
||||
const duration = differenceInMinutes(end, start);
|
||||
|
||||
// Calculate new start time
|
||||
|
|
@ -443,18 +447,27 @@
|
|||
resizeEdge = edge;
|
||||
hasMoved = false;
|
||||
|
||||
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
|
||||
const start = toDate(event.startTime);
|
||||
const end = toDate(event.endTime);
|
||||
|
||||
resizeOriginalStart = start;
|
||||
resizeOriginalEnd = end;
|
||||
|
||||
// Set initial preview
|
||||
const startMinutes = start.getHours() * 60 + start.getMinutes();
|
||||
const endMinutes = end.getHours() * 60 + end.getMinutes();
|
||||
const duration = differenceInMinutes(end, start);
|
||||
resizePreviewTop = minutesToPercent(startMinutes);
|
||||
resizePreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
|
||||
|
||||
// Calculate offset between snapped click position and actual event boundary
|
||||
const clickMinutes = getMinutesFromY(e.clientY);
|
||||
if (edge === 'top') {
|
||||
resizeOffsetMinutes = clickMinutes - startMinutes;
|
||||
} else {
|
||||
resizeOffsetMinutes = clickMinutes - endMinutes;
|
||||
}
|
||||
|
||||
document.addEventListener('pointermove', handleResizeMove);
|
||||
document.addEventListener('pointerup', handleResizeEnd);
|
||||
}
|
||||
|
|
@ -464,6 +477,8 @@
|
|||
|
||||
hasMoved = true;
|
||||
const currentMinutes = getMinutesFromY(e.clientY);
|
||||
// Apply offset to prevent jumping when drag starts
|
||||
const adjustedMinutes = currentMinutes - resizeOffsetMinutes;
|
||||
const originalStartMinutes =
|
||||
resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes();
|
||||
const originalEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes();
|
||||
|
|
@ -472,7 +487,7 @@
|
|||
// Resize from bottom - change end time
|
||||
const newEndMinutes = Math.max(
|
||||
originalStartMinutes + 15,
|
||||
Math.min(lastVisibleHour * 60, currentMinutes)
|
||||
Math.min(lastVisibleHour * 60, adjustedMinutes)
|
||||
);
|
||||
const newDuration = newEndMinutes - originalStartMinutes;
|
||||
resizePreviewHeight = (newDuration / (totalVisibleHours * 60)) * 100;
|
||||
|
|
@ -480,7 +495,7 @@
|
|||
// Resize from top - change start time
|
||||
const newStartMinutes = Math.max(
|
||||
firstVisibleHour * 60,
|
||||
Math.min(originalEndMinutes - 15, currentMinutes)
|
||||
Math.min(originalEndMinutes - 15, adjustedMinutes)
|
||||
);
|
||||
const newDuration = originalEndMinutes - newStartMinutes;
|
||||
resizePreviewTop = minutesToPercent(newStartMinutes);
|
||||
|
|
@ -497,11 +512,14 @@
|
|||
resizeEvent = null;
|
||||
resizeOriginalStart = null;
|
||||
resizeOriginalEnd = null;
|
||||
resizeOffsetMinutes = 0;
|
||||
hasMoved = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentMinutes = getMinutesFromY(e.clientY);
|
||||
// Apply offset to prevent jumping
|
||||
const adjustedMinutes = currentMinutes - resizeOffsetMinutes;
|
||||
const originalStartMinutes =
|
||||
resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes();
|
||||
const originalEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes();
|
||||
|
|
@ -512,7 +530,7 @@
|
|||
if (resizeEdge === 'bottom') {
|
||||
const newEndMinutes = Math.max(
|
||||
originalStartMinutes + 15,
|
||||
Math.min(lastVisibleHour * 60, currentMinutes)
|
||||
Math.min(lastVisibleHour * 60, adjustedMinutes)
|
||||
);
|
||||
const newHours = Math.floor(newEndMinutes / 60);
|
||||
const newMins = newEndMinutes % 60;
|
||||
|
|
@ -521,7 +539,7 @@
|
|||
} else {
|
||||
const newStartMinutes = Math.max(
|
||||
firstVisibleHour * 60,
|
||||
Math.min(originalEndMinutes - 15, currentMinutes)
|
||||
Math.min(originalEndMinutes - 15, adjustedMinutes)
|
||||
);
|
||||
const newHours = Math.floor(newStartMinutes / 60);
|
||||
const newMins = newStartMinutes % 60;
|
||||
|
|
@ -547,6 +565,7 @@
|
|||
resizeEvent = null;
|
||||
resizeOriginalStart = null;
|
||||
resizeOriginalEnd = null;
|
||||
resizeOffsetMinutes = 0;
|
||||
hasMoved = false;
|
||||
}
|
||||
|
||||
|
|
@ -825,6 +844,7 @@
|
|||
resizeEvent = null;
|
||||
resizeOriginalStart = null;
|
||||
resizeOriginalEnd = null;
|
||||
resizeOffsetMinutes = 0;
|
||||
hasMoved = false;
|
||||
}
|
||||
// Cancel task drag/resize
|
||||
|
|
@ -860,41 +880,56 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- All-day events row (only shown when there are header-mode all-day events) -->
|
||||
{#if hasAnyHeaderAllDayEvents}
|
||||
<div class="all-day-row">
|
||||
<div class="time-gutter">
|
||||
{#if settingsStore.showWeekNumbers}
|
||||
<span class="week-label">{$_('views.weekNumber')} {weekNumber}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Sticky header container -->
|
||||
<div class="sticky-header">
|
||||
<!-- Day headers -->
|
||||
<div class="day-headers">
|
||||
<div class="time-gutter"></div>
|
||||
{#each days as day}
|
||||
<div class="all-day-cell">
|
||||
{#each getHeaderAllDayEventsForDay(day) as event}
|
||||
<button
|
||||
class="all-day-event"
|
||||
class:search-highlighted={searchStore.isEventHighlighted(event.id)}
|
||||
class:search-dimmed={searchStore.isEventDimmed(event.id)}
|
||||
style="background-color: {calendarsStore.getColor(event.calendarId)}"
|
||||
onclick={() => goto(`/?event=${event.id}`)}
|
||||
>
|
||||
{event.title}
|
||||
</button>
|
||||
{/each}
|
||||
<div class="day-header" class:today={isToday(day)}>
|
||||
<span class="day-name">{format(day, 'EEE', { locale: currentDateLocale })}</span>
|
||||
<span class="day-number" class:today={isToday(day)}>{format(day, 'd')}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Day headers -->
|
||||
<div class="day-headers">
|
||||
<div class="time-gutter"></div>
|
||||
{#each days as day}
|
||||
<div class="day-header" class:today={isToday(day)}>
|
||||
<span class="day-name">{format(day, 'EEE', { locale: currentDateLocale })}</span>
|
||||
<span class="day-number" class:today={isToday(day)}>{format(day, 'd')}</span>
|
||||
<!-- All-day events row (shown when there are header-mode all-day events or birthdays) -->
|
||||
{#if hasAnyHeaderAllDayEvents || hasAnyBirthdays}
|
||||
<div class="all-day-row">
|
||||
<div class="time-gutter">
|
||||
{#if settingsStore.showWeekNumbers}
|
||||
<span class="week-label">{$_('views.weekNumber')} {weekNumber}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#each days as day}
|
||||
<div class="all-day-cell">
|
||||
{#each getHeaderAllDayEventsForDay(day) as event}
|
||||
<button
|
||||
class="all-day-event"
|
||||
class:search-highlighted={searchStore.isEventHighlighted(event.id)}
|
||||
class:search-dimmed={searchStore.isEventDimmed(event.id)}
|
||||
style="background-color: {calendarsStore.getColor(event.calendarId)}"
|
||||
onclick={() => goto(`/?event=${event.id}`)}
|
||||
>
|
||||
{event.title}
|
||||
</button>
|
||||
{/each}
|
||||
<!-- Birthdays -->
|
||||
{#each getBirthdaysForDay(day) as birthday}
|
||||
<button
|
||||
class="all-day-event birthday-event"
|
||||
onclick={(e) => birthdayPopover.handleBirthdayClick(birthday, e)}
|
||||
>
|
||||
🎂 {birthday.displayName}
|
||||
{#if settingsStore.showBirthdayAge && birthday.age > 0}
|
||||
<span class="birthday-age">({birthday.age})</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Time grid -->
|
||||
|
|
@ -945,59 +980,27 @@
|
|||
{#each getEventsForDay(day) as event (event.id)}
|
||||
{@const isBeingDragged = isDragging && draggedEvent?.id === event.id}
|
||||
{@const isBeingResized = isResizing && resizeEvent?.id === event.id}
|
||||
{@const isDraft = eventsStore.isDraftEvent(event.id)}
|
||||
{@const isCrossDayDrag =
|
||||
isBeingDragged && dragTargetDay && !isSameDay(day, dragTargetDay)}
|
||||
{@const isSearchHighlighted = searchStore.isEventHighlighted(event.id)}
|
||||
{@const isSearchDimmed = searchStore.isEventDimmed(event.id)}
|
||||
<div
|
||||
class="event-card"
|
||||
class:dragging={isBeingDragged && !isCrossDayDrag}
|
||||
class:dragging-source={isCrossDayDrag}
|
||||
class:resizing={isBeingResized}
|
||||
class:draft={isDraft}
|
||||
class:search-highlighted={isSearchHighlighted}
|
||||
class:search-dimmed={isSearchDimmed}
|
||||
data-event-id={event.id}
|
||||
isBeingDragged && dragTargetDay !== null && !isSameDay(day, dragTargetDay)}
|
||||
<EventCard
|
||||
{event}
|
||||
style={isBeingDragged && !isCrossDayDrag
|
||||
? `top: ${dragPreviewTop}%; height: ${dragPreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
|
||||
? `top: ${dragPreviewTop}%; height: ${dragPreviewHeight}%;`
|
||||
: isBeingResized
|
||||
? `top: ${resizePreviewTop}%; height: ${resizePreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
|
||||
? `top: ${resizePreviewTop}%; height: ${resizePreviewHeight}%;`
|
||||
: getEventStyle(event)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={event.title || $_('calendar.draftEvent')}
|
||||
onpointerdown={(e) => startDrag(event, e)}
|
||||
onclick={(e) => !isDraft && handleEventClick(event, e)}
|
||||
onkeydown={(e) => !isDraft && e.key === 'Enter' && goto(`/?event=${event.id}`)}
|
||||
>
|
||||
<!-- Top resize handle -->
|
||||
<div
|
||||
class="resize-handle top"
|
||||
onpointerdown={(e) => startResize(event, 'top', e)}
|
||||
role="slider"
|
||||
aria-label={$_('event.changeStartTime')}
|
||||
aria-valuenow={0}
|
||||
tabindex="-1"
|
||||
></div>
|
||||
|
||||
<span class="event-time">
|
||||
{formatEventTime(event.startTime)} - {formatEventTime(event.endTime)}
|
||||
</span>
|
||||
<span class="event-title"
|
||||
>{event.title || (isDraft ? $_('calendar.draftEvent') : '')}</span
|
||||
>
|
||||
|
||||
<!-- Bottom resize handle -->
|
||||
<div
|
||||
class="resize-handle bottom"
|
||||
onpointerdown={(e) => startResize(event, 'bottom', e)}
|
||||
role="slider"
|
||||
aria-label={$_('event.changeEndTime')}
|
||||
aria-valuenow={0}
|
||||
tabindex="-1"
|
||||
></div>
|
||||
</div>
|
||||
color={calendarsStore.getColor(event.calendarId)}
|
||||
isDragging={isBeingDragged && !isCrossDayDrag}
|
||||
isDraggingSource={isCrossDayDrag}
|
||||
isResizing={isBeingResized}
|
||||
isSearchHighlighted={searchStore.isEventHighlighted(event.id)}
|
||||
isSearchDimmed={searchStore.isEventDimmed(event.id)}
|
||||
formattedTime={formatEventTimeRange(event)}
|
||||
onClick={handleEventClick}
|
||||
onPointerDown={startDrag}
|
||||
onContextMenu={handleEventContextMenu}
|
||||
onResizeStart={startResize}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<!-- Scheduled Tasks (Time-Blocking) -->
|
||||
|
|
@ -1035,15 +1038,13 @@
|
|||
|
||||
<!-- Drag preview (solid) for cross-day dragging - shows where event will be -->
|
||||
{#if isDragging && draggedEvent && dragTargetDay && isSameDay(day, dragTargetDay) && !getEventsForDay(day).some((e) => e.id === draggedEvent!.id)}
|
||||
<div
|
||||
class="event-card drag-preview"
|
||||
style="top: {dragPreviewTop}%; height: {dragPreviewHeight}%; background-color: {calendarsStore.getColor(
|
||||
draggedEvent.calendarId
|
||||
)};"
|
||||
>
|
||||
<span class="event-time">{formatEventTime(draggedEvent.startTime)}</span>
|
||||
<span class="event-title">{draggedEvent.title}</span>
|
||||
</div>
|
||||
<EventCard
|
||||
event={draggedEvent}
|
||||
style="top: {dragPreviewTop}%; height: {dragPreviewHeight}%;"
|
||||
color={calendarsStore.getColor(draggedEvent.calendarId)}
|
||||
isDragging={true}
|
||||
formattedTime={formatEventTimeRange(draggedEvent)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Overflow indicators for events outside visible time range -->
|
||||
|
|
@ -1086,6 +1087,17 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<EventContextMenu onEdit={handleContextMenuEdit} />
|
||||
|
||||
<!-- Birthday Popover -->
|
||||
{#if birthdayPopover.selectedBirthday}
|
||||
<BirthdayPopover
|
||||
birthday={birthdayPopover.selectedBirthday}
|
||||
position={birthdayPopover.popoverPosition}
|
||||
onClose={birthdayPopover.closePopover}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.week-view {
|
||||
display: flex;
|
||||
|
|
@ -1096,6 +1108,13 @@
|
|||
display: none; /* Hidden by default, shown in gutter instead */
|
||||
}
|
||||
|
||||
.sticky-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: hsl(var(--color-background));
|
||||
}
|
||||
|
||||
.all-day-row {
|
||||
display: flex;
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
|
|
@ -1112,6 +1131,7 @@
|
|||
}
|
||||
|
||||
.all-day-event {
|
||||
width: 100%;
|
||||
padding: 2px 6px;
|
||||
font-size: 0.75rem;
|
||||
color: white;
|
||||
|
|
@ -1122,6 +1142,7 @@
|
|||
border: none;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.all-day-event.search-highlighted {
|
||||
|
|
@ -1135,6 +1156,20 @@
|
|||
filter: grayscale(0.3);
|
||||
}
|
||||
|
||||
/* Birthday events in all-day row */
|
||||
.all-day-event.birthday-event {
|
||||
background: linear-gradient(135deg, #ec4899 0%, #f472b6 100%);
|
||||
}
|
||||
|
||||
.all-day-event.birthday-event:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.birthday-age {
|
||||
opacity: 0.85;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* Block-style all-day events (displayed as full-day blocks in the grid) */
|
||||
.all-day-block-event {
|
||||
position: absolute;
|
||||
|
|
@ -1246,7 +1281,7 @@
|
|||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
position: relative;
|
||||
top: -0.5em;
|
||||
top: 0.25em;
|
||||
}
|
||||
|
||||
.days-container {
|
||||
|
|
@ -1284,140 +1319,6 @@
|
|||
background: hsl(var(--color-muted) / 0.3);
|
||||
}
|
||||
|
||||
.event-card {
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
right: 2px;
|
||||
padding: 2px 4px;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
text-align: left;
|
||||
cursor: grab;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
box-shadow 0.15s ease,
|
||||
opacity 0.15s ease;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.event-card:hover {
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.event-card.dragging {
|
||||
cursor: grabbing;
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.event-card.resizing {
|
||||
opacity: 0.85;
|
||||
z-index: 100;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||
outline: 2px dashed rgba(255, 255, 255, 0.6);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Ghost style for source position during cross-day drag */
|
||||
.event-card.dragging-source {
|
||||
opacity: 0.4;
|
||||
background: transparent !important;
|
||||
border: 2px dashed hsl(var(--color-border));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.event-card.dragging-source .event-title,
|
||||
.event-card.dragging-source .event-time {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Solid preview at target position during cross-day drag */
|
||||
.event-card.drag-preview {
|
||||
pointer-events: none;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Search highlighting */
|
||||
.event-card.search-highlighted {
|
||||
outline: 2px solid hsl(var(--color-primary));
|
||||
outline-offset: 1px;
|
||||
box-shadow:
|
||||
0 0 0 4px hsl(var(--color-primary) / 0.3),
|
||||
0 4px 12px rgba(0, 0, 0, 0.25);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.event-card.search-dimmed {
|
||||
opacity: 0.35;
|
||||
filter: grayscale(0.3);
|
||||
}
|
||||
|
||||
.event-card.draft {
|
||||
outline: 2px solid hsl(var(--color-primary));
|
||||
outline-offset: -1px;
|
||||
animation: pulse-outline 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-outline {
|
||||
0%,
|
||||
100% {
|
||||
outline-color: hsl(var(--color-primary));
|
||||
}
|
||||
50% {
|
||||
outline-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.event-time {
|
||||
font-size: 0.65rem;
|
||||
opacity: 0.9;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.event-title {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Resize handles */
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 8px;
|
||||
cursor: ns-resize;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.resize-handle.top {
|
||||
top: 0;
|
||||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||
}
|
||||
|
||||
.resize-handle.bottom {
|
||||
bottom: 0;
|
||||
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
|
||||
}
|
||||
|
||||
.event-card:hover .resize-handle {
|
||||
opacity: 1;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.resize-handle:hover {
|
||||
background: rgba(255, 255, 255, 0.5) !important;
|
||||
}
|
||||
|
||||
.time-indicator {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
|
|
|
|||
|
|
@ -11,22 +11,27 @@
|
|||
eachDayOfInterval,
|
||||
isSameMonth,
|
||||
isToday,
|
||||
parseISO,
|
||||
setHours,
|
||||
setMinutes,
|
||||
} from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import { toDate } from '$lib/utils/eventDateHelpers';
|
||||
import type { CalendarViewType, CalendarEvent } from '@calendar/shared';
|
||||
|
||||
interface Props {
|
||||
/** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */
|
||||
date?: Date;
|
||||
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
|
||||
onEventClick?: (event: CalendarEvent) => void;
|
||||
}
|
||||
|
||||
let { onQuickCreate, onEventClick }: Props = $props();
|
||||
let { date, onQuickCreate, onEventClick }: Props = $props();
|
||||
|
||||
// Use provided date or fall back to viewStore
|
||||
let effectiveDate = $derived(date ?? viewStore.currentDate);
|
||||
|
||||
// Derived values
|
||||
let year = $derived(viewStore.currentDate.getFullYear());
|
||||
let year = $derived(effectiveDate.getFullYear());
|
||||
|
||||
let months = $derived(Array.from({ length: 12 }, (_, i) => new Date(year, i, 1)));
|
||||
|
||||
|
|
@ -58,8 +63,7 @@
|
|||
const events = eventsStore.events ?? [];
|
||||
|
||||
for (const event of events) {
|
||||
const start =
|
||||
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const start = toDate(event.startTime);
|
||||
const key = format(start, 'yyyy-MM-dd');
|
||||
counts.set(key, (counts.get(key) || 0) + 1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,198 @@
|
|||
<script lang="ts">
|
||||
import { ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
|
||||
import { Pencil, Copy, Trash, Palette, CalendarBlank, Export } from '@manacore/shared-icons';
|
||||
import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte';
|
||||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
import { toastStore } from '$lib/stores/toast.svelte';
|
||||
import type { CalendarEvent } from '@calendar/shared';
|
||||
|
||||
interface Props {
|
||||
onEdit?: (event: CalendarEvent) => void;
|
||||
}
|
||||
|
||||
let { onEdit }: Props = $props();
|
||||
|
||||
// Build menu items based on target event
|
||||
let menuItems = $derived.by((): ContextMenuItem[] => {
|
||||
const event = eventContextMenuStore.targetEvent;
|
||||
if (!event) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'edit',
|
||||
label: 'Bearbeiten',
|
||||
icon: Pencil,
|
||||
shortcut: 'E',
|
||||
action: () => handleEdit(),
|
||||
},
|
||||
{
|
||||
id: 'duplicate',
|
||||
label: 'Duplizieren',
|
||||
icon: Copy,
|
||||
shortcut: 'D',
|
||||
action: () => handleDuplicate(),
|
||||
},
|
||||
{
|
||||
id: 'divider-1',
|
||||
label: '',
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
id: 'change-calendar',
|
||||
label: 'Kalender wechseln',
|
||||
icon: CalendarBlank,
|
||||
action: () => handleChangeCalendar(),
|
||||
disabled: calendarsStore.calendars.length <= 1,
|
||||
},
|
||||
{
|
||||
id: 'change-color',
|
||||
label: 'Farbe ändern',
|
||||
icon: Palette,
|
||||
action: () => handleChangeColor(),
|
||||
},
|
||||
{
|
||||
id: 'export',
|
||||
label: 'Exportieren (.ics)',
|
||||
icon: Export,
|
||||
action: () => handleExport(),
|
||||
},
|
||||
{
|
||||
id: 'divider-2',
|
||||
label: '',
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
label: 'Löschen',
|
||||
icon: Trash,
|
||||
variant: 'danger',
|
||||
action: () => handleDelete(),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
function handleEdit() {
|
||||
const event = eventContextMenuStore.targetEvent;
|
||||
if (event && onEdit) {
|
||||
onEdit(event);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDuplicate() {
|
||||
const event = eventContextMenuStore.targetEvent;
|
||||
if (!event) return;
|
||||
|
||||
try {
|
||||
await eventsStore.createEvent({
|
||||
calendarId: event.calendarId,
|
||||
title: `${event.title} (Kopie)`,
|
||||
description: event.description ?? undefined,
|
||||
location: event.location ?? undefined,
|
||||
startTime: event.startTime,
|
||||
endTime: event.endTime,
|
||||
isAllDay: event.isAllDay,
|
||||
color: event.color ?? undefined,
|
||||
});
|
||||
toastStore.success('Termin dupliziert');
|
||||
} catch (error) {
|
||||
console.error('Error duplicating event:', error);
|
||||
toastStore.error('Fehler beim Duplizieren');
|
||||
}
|
||||
}
|
||||
|
||||
function handleChangeCalendar() {
|
||||
// TODO: Implement calendar picker modal
|
||||
const event = eventContextMenuStore.targetEvent;
|
||||
if (!event) return;
|
||||
|
||||
// For now, cycle through calendars
|
||||
const calendars = calendarsStore.calendars;
|
||||
const currentIndex = calendars.findIndex((c) => c.id === event.calendarId);
|
||||
const nextIndex = (currentIndex + 1) % calendars.length;
|
||||
const nextCalendar = calendars[nextIndex];
|
||||
|
||||
if (nextCalendar) {
|
||||
eventsStore.updateEvent(event.id, { calendarId: nextCalendar.id });
|
||||
toastStore.success(`Verschoben nach "${nextCalendar.name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleChangeColor() {
|
||||
// TODO: Implement color picker modal
|
||||
const event = eventContextMenuStore.targetEvent;
|
||||
if (!event) return;
|
||||
|
||||
// For now, cycle through some predefined colors
|
||||
const colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899'];
|
||||
const currentIndex = colors.indexOf(event.color || '');
|
||||
const nextIndex = (currentIndex + 1) % colors.length;
|
||||
|
||||
eventsStore.updateEvent(event.id, { color: colors[nextIndex] });
|
||||
toastStore.success('Farbe geändert');
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
const event = eventContextMenuStore.targetEvent;
|
||||
if (!event) return;
|
||||
|
||||
// Generate simple ICS content
|
||||
const startDate =
|
||||
typeof event.startTime === 'string' ? new Date(event.startTime) : event.startTime;
|
||||
const endDate = typeof event.endTime === 'string' ? new Date(event.endTime) : event.endTime;
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return date.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
||||
};
|
||||
|
||||
const icsContent = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Manacore//Calendar//DE
|
||||
BEGIN:VEVENT
|
||||
UID:${event.id}
|
||||
DTSTART:${formatDate(startDate)}
|
||||
DTEND:${formatDate(endDate)}
|
||||
SUMMARY:${event.title}
|
||||
${event.description ? `DESCRIPTION:${event.description}` : ''}
|
||||
${event.location ? `LOCATION:${event.location}` : ''}
|
||||
END:VEVENT
|
||||
END:VCALENDAR`;
|
||||
|
||||
const blob = new Blob([icsContent], { type: 'text/calendar' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${event.title.replace(/[^a-zA-Z0-9]/g, '_')}.ics`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toastStore.success('Termin exportiert');
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
const event = eventContextMenuStore.targetEvent;
|
||||
if (!event) return;
|
||||
|
||||
if (confirm(`Möchten Sie "${event.title}" wirklich löschen?`)) {
|
||||
try {
|
||||
await eventsStore.deleteEvent(event.id);
|
||||
toastStore.success('Termin gelöscht');
|
||||
} catch (error) {
|
||||
console.error('Error deleting event:', error);
|
||||
toastStore.error('Fehler beim Löschen');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
eventContextMenuStore.hide();
|
||||
}
|
||||
</script>
|
||||
|
||||
<ContextMenu
|
||||
visible={eventContextMenuStore.visible}
|
||||
x={eventContextMenuStore.x}
|
||||
y={eventContextMenuStore.y}
|
||||
items={menuItems}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
|
|
@ -2,13 +2,14 @@
|
|||
import { goto } from '$app/navigation';
|
||||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
import { toast } from '$lib/stores/toast.svelte';
|
||||
import EventForm from './EventForm.svelte';
|
||||
import { TagBadge } from '@manacore/shared-ui';
|
||||
import type { CalendarEvent, UpdateEventInput } from '@calendar/shared';
|
||||
import * as api from '$lib/api/events';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { format } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import { toDate } from '$lib/utils/eventDateHelpers';
|
||||
import { EventDetailSkeleton } from '$lib/components/skeletons';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -99,8 +100,8 @@
|
|||
if (event.isAllDay) {
|
||||
return 'Ganztägig';
|
||||
}
|
||||
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
|
||||
const start = toDate(event.startTime);
|
||||
const end = toDate(event.endTime);
|
||||
return `${format(start, 'PPPp', { locale: de })} - ${format(end, 'p', { locale: de })}`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,14 @@
|
|||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { eventTagsStore } from '$lib/stores/event-tags.svelte';
|
||||
import { TagSelector, type Tag } from '@manacore/shared-ui';
|
||||
import {
|
||||
TagSelector,
|
||||
FilterDropdown,
|
||||
type Tag,
|
||||
type FilterDropdownOption,
|
||||
} from '@manacore/shared-ui';
|
||||
import AttendeeSelector from './AttendeeSelector.svelte';
|
||||
import ResponsiblePersonSelector from './ResponsiblePersonSelector.svelte';
|
||||
import type {
|
||||
CalendarEvent,
|
||||
CreateEventInput,
|
||||
|
|
@ -12,8 +18,10 @@
|
|||
LocationDetails,
|
||||
EventTag,
|
||||
EventAttendee,
|
||||
ResponsiblePerson,
|
||||
} from '@calendar/shared';
|
||||
import { format, addMinutes, parseISO } from 'date-fns';
|
||||
import { format, addMinutes } from 'date-fns';
|
||||
import { toDate } from '$lib/utils/eventDateHelpers';
|
||||
|
||||
interface Props {
|
||||
mode: 'create' | 'edit';
|
||||
|
|
@ -51,6 +59,11 @@
|
|||
})) || []
|
||||
);
|
||||
|
||||
// Responsible person state
|
||||
let responsiblePerson = $state<ResponsiblePerson | null>(
|
||||
event?.metadata?.responsiblePerson || null
|
||||
);
|
||||
|
||||
// Attendees state
|
||||
let attendees = $state<EventAttendee[]>(event?.metadata?.attendees || []);
|
||||
|
||||
|
|
@ -71,6 +84,18 @@
|
|||
// Derived available tags for TagSelector
|
||||
let availableTags = $derived(eventTagsStore.tags.map(eventTagToTag));
|
||||
|
||||
// Calendar options for FilterDropdown
|
||||
let calendarOptions = $derived<FilterDropdownOption[]>(
|
||||
calendarsStore.calendars.map((cal) => ({ value: cal.id, label: cal.name }))
|
||||
);
|
||||
|
||||
// All-day display mode options
|
||||
const displayModeOptions: FilterDropdownOption[] = [
|
||||
{ value: 'default', label: 'Standard (aus Einstellungen)' },
|
||||
{ value: 'header', label: 'In Kopfzeile' },
|
||||
{ value: 'block', label: 'Als Tagesblock' },
|
||||
];
|
||||
|
||||
// Auto-expand location details if any field is filled
|
||||
$effect(() => {
|
||||
if (event?.metadata?.locationDetails) {
|
||||
|
|
@ -97,9 +122,8 @@
|
|||
// Initialize date/time fields using settings for default duration
|
||||
$effect(() => {
|
||||
if (event) {
|
||||
const start =
|
||||
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
|
||||
const start = toDate(event.startTime);
|
||||
const end = toDate(event.endTime);
|
||||
startDate = format(start, 'yyyy-MM-dd');
|
||||
startTime = format(start, 'HH:mm');
|
||||
endDate = format(end, 'yyyy-MM-dd');
|
||||
|
|
@ -172,6 +196,13 @@
|
|||
delete metadata.locationDetails;
|
||||
}
|
||||
|
||||
// Add responsible person
|
||||
if (responsiblePerson) {
|
||||
metadata.responsiblePerson = responsiblePerson;
|
||||
} else {
|
||||
delete metadata.responsiblePerson;
|
||||
}
|
||||
|
||||
// Add attendees
|
||||
if (attendees.length > 0) {
|
||||
metadata.attendees = attendees;
|
||||
|
|
@ -214,17 +245,14 @@
|
|||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="calendar" class="text-sm font-medium text-foreground">Kalender</label>
|
||||
<span class="text-sm font-medium text-foreground">Kalender</span>
|
||||
{#if calendarsStore.calendars.length > 0}
|
||||
<select
|
||||
id="calendar"
|
||||
class="w-full px-3 py-2 border-2 border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary transition-colors"
|
||||
bind:value={calendarId}
|
||||
>
|
||||
{#each calendarsStore.calendars as cal}
|
||||
<option value={cal.id}>{cal.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<FilterDropdown
|
||||
options={calendarOptions}
|
||||
value={calendarId}
|
||||
onChange={(v) => (calendarId = typeof v === 'string' ? v : '')}
|
||||
placeholder="Kalender wählen"
|
||||
/>
|
||||
{:else}
|
||||
<p class="text-sm text-muted-foreground italic">Standardkalender wird automatisch erstellt</p>
|
||||
{/if}
|
||||
|
|
@ -239,16 +267,13 @@
|
|||
|
||||
{#if isAllDay}
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="displayMode" class="text-sm font-medium text-foreground">Anzeigeart</label>
|
||||
<select
|
||||
id="displayMode"
|
||||
class="w-full px-3 py-2 border-2 border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary transition-colors"
|
||||
bind:value={allDayDisplayMode}
|
||||
>
|
||||
<option value="default">Standard (aus Einstellungen)</option>
|
||||
<option value="header">In Kopfzeile</option>
|
||||
<option value="block">Als Tagesblock</option>
|
||||
</select>
|
||||
<span class="text-sm font-medium text-foreground">Anzeigeart</span>
|
||||
<FilterDropdown
|
||||
options={displayModeOptions}
|
||||
value={allDayDisplayMode}
|
||||
onChange={(v) => (allDayDisplayMode = (v as 'default' | 'header' | 'block') || 'default')}
|
||||
placeholder="Anzeigeart wählen"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
|
@ -406,6 +431,15 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Verantwortliche Person -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-medium text-foreground">Verantwortliche Person</span>
|
||||
<ResponsiblePersonSelector
|
||||
{responsiblePerson}
|
||||
onResponsiblePersonChange={(person) => (responsiblePerson = person)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Teilnehmer -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-medium text-foreground">Teilnehmer</span>
|
||||
|
|
|
|||
|
|
@ -2,10 +2,26 @@
|
|||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
import type { LocationDetails, CalendarEvent } from '@calendar/shared';
|
||||
import { format, addMinutes, parseISO } from 'date-fns';
|
||||
import { contactsStore } from '$lib/stores/contacts.svelte';
|
||||
import { toast } from '$lib/stores/toast.svelte';
|
||||
import type {
|
||||
LocationDetails,
|
||||
CalendarEvent,
|
||||
ResponsiblePerson,
|
||||
EventAttendee,
|
||||
} from '@calendar/shared';
|
||||
import type { ContactSummary, ContactOrManual, ManualContactEntry } from '@manacore/shared-types';
|
||||
import {
|
||||
ContactSelector,
|
||||
ContactAvatar,
|
||||
ConfirmationPopover,
|
||||
FilterDropdown,
|
||||
type FilterDropdownOption,
|
||||
} from '@manacore/shared-ui';
|
||||
import { Users } from 'lucide-svelte';
|
||||
import { format, addMinutes } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import { toDate } from '$lib/utils/eventDateHelpers';
|
||||
import { tick, onMount, onDestroy } from 'svelte';
|
||||
|
||||
// Portal action - moves element to body to escape stacking contexts
|
||||
|
|
@ -109,6 +125,13 @@
|
|||
}
|
||||
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
// If target was removed from DOM by state change (e.g., button that toggles its own visibility),
|
||||
// ignore the click to prevent false "outside" detection
|
||||
if (!target.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const overlay = document.querySelector('.quick-event-overlay');
|
||||
const eventSelector = isEditMode
|
||||
? `[data-event-id="${event!.id}"]`
|
||||
|
|
@ -185,6 +208,26 @@
|
|||
let locationCountry = $state('');
|
||||
let submitting = $state(false);
|
||||
|
||||
// People state
|
||||
let responsiblePerson = $state<ResponsiblePerson | null>(null);
|
||||
let attendees = $state<EventAttendee[]>([]);
|
||||
let showPeopleSelector = $state(false);
|
||||
let contactsAvailable = $state<boolean | null>(null);
|
||||
|
||||
// All-day display mode options
|
||||
const displayModeOptions: FilterDropdownOption[] = [
|
||||
{ value: 'default', label: 'Standard (aus Einstellungen)' },
|
||||
{ value: 'header', label: 'In Kopfzeile' },
|
||||
{ value: 'block', label: 'Als Tagesblock' },
|
||||
];
|
||||
|
||||
// Check contacts availability
|
||||
$effect(() => {
|
||||
contactsStore.checkAvailability().then((available) => {
|
||||
contactsAvailable = available;
|
||||
});
|
||||
});
|
||||
|
||||
// Editable date/time strings (for form inputs)
|
||||
let startDateStr = $state('');
|
||||
let startTimeStr = $state('');
|
||||
|
|
@ -212,10 +255,13 @@
|
|||
locationCountry = loc.country || '';
|
||||
}
|
||||
|
||||
// Initialize people
|
||||
responsiblePerson = event.metadata?.responsiblePerson || null;
|
||||
attendees = event.metadata?.attendees || [];
|
||||
|
||||
// Initialize time fields
|
||||
const eventStart =
|
||||
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const eventEnd = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
|
||||
const eventStart = toDate(event.startTime);
|
||||
const eventEnd = toDate(event.endTime);
|
||||
startDateStr = format(eventStart, 'yyyy-MM-dd');
|
||||
startTimeStr = format(eventStart, 'HH:mm');
|
||||
endDateStr = format(eventEnd, 'yyyy-MM-dd');
|
||||
|
|
@ -226,22 +272,22 @@
|
|||
// Date/time fields - derive from draft event (create mode) or event (edit mode)
|
||||
let draftStart = $derived(() => {
|
||||
if (isEditMode && event) {
|
||||
return typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
return toDate(event.startTime);
|
||||
}
|
||||
const draft = eventsStore.draftEvent;
|
||||
if (draft) {
|
||||
return typeof draft.startTime === 'string' ? parseISO(draft.startTime) : draft.startTime;
|
||||
return toDate(draft.startTime);
|
||||
}
|
||||
return startTime || new Date();
|
||||
});
|
||||
|
||||
let draftEnd = $derived(() => {
|
||||
if (isEditMode && event) {
|
||||
return typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
|
||||
return toDate(event.endTime);
|
||||
}
|
||||
const draft = eventsStore.draftEvent;
|
||||
if (draft) {
|
||||
return typeof draft.endTime === 'string' ? parseISO(draft.endTime) : draft.endTime;
|
||||
return toDate(draft.endTime);
|
||||
}
|
||||
return addMinutes(startTime || new Date(), settingsStore.defaultEventDuration);
|
||||
});
|
||||
|
|
@ -328,15 +374,6 @@
|
|||
});
|
||||
}
|
||||
|
||||
// Update draft when calendar changes
|
||||
function handleCalendarChange(e: Event) {
|
||||
const target = e.target as HTMLSelectElement;
|
||||
calendarId = target.value;
|
||||
if (!isEditMode) {
|
||||
eventsStore.updateDraftEvent({ calendarId: target.value });
|
||||
}
|
||||
}
|
||||
|
||||
// Update draft when all-day changes
|
||||
function handleAllDayToggle() {
|
||||
isAllDay = !isAllDay;
|
||||
|
|
@ -348,6 +385,112 @@
|
|||
// Overlay style
|
||||
let overlayStyle = $derived(`left: ${overlayPosition.left}px; top: ${overlayPosition.top}px;`);
|
||||
|
||||
// People helpers
|
||||
function handleContactSearch(query: string): Promise<ContactSummary[]> {
|
||||
return contactsStore.searchContacts(query);
|
||||
}
|
||||
|
||||
function handleResponsiblePersonChange(contacts: ContactOrManual[]) {
|
||||
if (contacts.length === 0) {
|
||||
responsiblePerson = null;
|
||||
return;
|
||||
}
|
||||
const contact = contacts[0];
|
||||
if ('isManual' in contact && contact.isManual) {
|
||||
const manual = contact as ManualContactEntry;
|
||||
responsiblePerson = { email: manual.email, name: manual.name };
|
||||
} else {
|
||||
const ref = contact as {
|
||||
contactId: string;
|
||||
displayName: string;
|
||||
email?: string;
|
||||
photoUrl?: string;
|
||||
company?: string;
|
||||
};
|
||||
responsiblePerson = {
|
||||
email: ref.email || '',
|
||||
name: ref.displayName,
|
||||
contactId: ref.contactId,
|
||||
photoUrl: ref.photoUrl,
|
||||
company: ref.company,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function handleAttendeesChange(contacts: ContactOrManual[]) {
|
||||
attendees = contacts.map((contact) => {
|
||||
if ('isManual' in contact && contact.isManual) {
|
||||
const manual = contact as ManualContactEntry;
|
||||
const existing = attendees.find((a) => a.email === manual.email);
|
||||
return {
|
||||
email: manual.email,
|
||||
name: manual.name,
|
||||
status: existing?.status || ('pending' as const),
|
||||
};
|
||||
}
|
||||
const ref = contact as {
|
||||
contactId: string;
|
||||
displayName: string;
|
||||
email?: string;
|
||||
photoUrl?: string;
|
||||
company?: string;
|
||||
};
|
||||
const existing = attendees.find(
|
||||
(a) => a.contactId === ref.contactId || a.email === ref.email
|
||||
);
|
||||
return {
|
||||
email: ref.email || '',
|
||||
name: ref.displayName,
|
||||
status: existing?.status || ('pending' as const),
|
||||
contactId: ref.contactId,
|
||||
photoUrl: ref.photoUrl,
|
||||
company: ref.company,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Convert to ContactOrManual for selectors
|
||||
const responsibleAsContact = $derived<ContactOrManual[]>(
|
||||
responsiblePerson
|
||||
? responsiblePerson.contactId
|
||||
? [
|
||||
{
|
||||
contactId: responsiblePerson.contactId,
|
||||
displayName: responsiblePerson.name || responsiblePerson.email,
|
||||
email: responsiblePerson.email,
|
||||
photoUrl: responsiblePerson.photoUrl,
|
||||
company: responsiblePerson.company,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
email: responsiblePerson.email,
|
||||
name: responsiblePerson.name,
|
||||
isManual: true as const,
|
||||
},
|
||||
]
|
||||
: []
|
||||
);
|
||||
|
||||
const attendeesAsContacts = $derived<ContactOrManual[]>(
|
||||
attendees.map((a) =>
|
||||
a.contactId
|
||||
? {
|
||||
contactId: a.contactId,
|
||||
displayName: a.name || a.email,
|
||||
email: a.email,
|
||||
photoUrl: a.photoUrl,
|
||||
company: a.company,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
}
|
||||
: { email: a.email, name: a.name, isManual: true as const }
|
||||
)
|
||||
);
|
||||
|
||||
// Count of people assigned
|
||||
const peopleCount = $derived((responsiblePerson ? 1 : 0) + attendees.length);
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!title.trim() || !calendarId) return;
|
||||
|
|
@ -396,6 +539,20 @@
|
|||
delete metadata.locationDetails;
|
||||
}
|
||||
|
||||
// Add responsible person
|
||||
if (responsiblePerson) {
|
||||
metadata = { ...(metadata || {}), responsiblePerson };
|
||||
} else if (metadata) {
|
||||
delete metadata.responsiblePerson;
|
||||
}
|
||||
|
||||
// Add attendees
|
||||
if (attendees.length > 0) {
|
||||
metadata = { ...(metadata || {}), attendees };
|
||||
} else if (metadata) {
|
||||
delete metadata.attendees;
|
||||
}
|
||||
|
||||
// Clean up empty metadata
|
||||
if (metadata && Object.keys(metadata).length === 0) {
|
||||
metadata = undefined;
|
||||
|
|
@ -423,11 +580,16 @@
|
|||
onUpdated?.();
|
||||
} else {
|
||||
// Create new event
|
||||
await eventsStore.createEvent(eventData);
|
||||
const result = await eventsStore.createEvent(eventData);
|
||||
if (result.error) {
|
||||
toast.error(`Fehler beim Erstellen: ${result.error.message}`);
|
||||
return;
|
||||
}
|
||||
// Refresh calendars if none existed (in case default was created)
|
||||
if (calendarsStore.calendars.length === 0) {
|
||||
await calendarsStore.fetchCalendars();
|
||||
}
|
||||
toast.success('Termin erstellt');
|
||||
onCreated?.();
|
||||
}
|
||||
|
||||
|
|
@ -443,10 +605,6 @@
|
|||
async function handleDelete() {
|
||||
if (!event) return;
|
||||
|
||||
if (!confirm('Möchten Sie diesen Termin wirklich löschen?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
submitting = true;
|
||||
try {
|
||||
const result = await eventsStore.deleteEvent(event.id);
|
||||
|
|
@ -490,22 +648,25 @@
|
|||
<span class="header-title">{isEditMode ? 'Termin bearbeiten' : 'Neuer Termin'}</span>
|
||||
<div class="header-actions">
|
||||
{#if isEditMode}
|
||||
<button
|
||||
type="button"
|
||||
class="delete-btn"
|
||||
onclick={handleDelete}
|
||||
disabled={submitting}
|
||||
aria-label="Löschen"
|
||||
<ConfirmationPopover
|
||||
onConfirm={handleDelete}
|
||||
variant="danger"
|
||||
title="Termin löschen?"
|
||||
confirmLabel="Löschen"
|
||||
loading={submitting}
|
||||
placement="bottom"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="delete-btn" disabled={submitting} aria-label="Löschen">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</ConfirmationPopover>
|
||||
{/if}
|
||||
<button type="button" class="close-btn" onclick={onClose} aria-label="Schließen">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
|
@ -554,24 +715,118 @@
|
|||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Calendar select -->
|
||||
<!-- Calendar pills -->
|
||||
<div class="calendar-pills-container">
|
||||
{#if calendarsStore.calendars.length > 0}
|
||||
<div class="calendar-pills-scroll">
|
||||
{#each calendarsStore.calendars as cal}
|
||||
<button
|
||||
type="button"
|
||||
class="calendar-pill"
|
||||
class:active={calendarId === cal.id}
|
||||
onclick={() => {
|
||||
calendarId = cal.id;
|
||||
if (!isEditMode) {
|
||||
eventsStore.updateDraftEvent({ calendarId: cal.id });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span class="calendar-pill-dot" style="background-color: {cal.color || '#3b82f6'}"
|
||||
></span>
|
||||
<span class="calendar-pill-name">{cal.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<span class="field-placeholder">Standardkalender wird erstellt</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- People (compact) -->
|
||||
<div class="form-row">
|
||||
<div class="row-icon">
|
||||
<div
|
||||
class="calendar-dot"
|
||||
style="background-color: {calendarsStore.getColor(calendarId)}"
|
||||
></div>
|
||||
<Users class="icon" size={18} />
|
||||
</div>
|
||||
<div class="row-content">
|
||||
<span class="field-label">Kalender</span>
|
||||
{#if calendarsStore.calendars.length > 0}
|
||||
<select class="field-select" value={calendarId} onchange={handleCalendarChange}>
|
||||
{#each calendarsStore.calendars as cal}
|
||||
<option value={cal.id}>{cal.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<!-- Responsible person - always show directly -->
|
||||
<div class="people-subsection">
|
||||
<span class="field-label">Verantwortlich</span>
|
||||
{#if responsiblePerson}
|
||||
<div class="person-chip">
|
||||
<ContactAvatar
|
||||
photoUrl={responsiblePerson.photoUrl}
|
||||
name={responsiblePerson.name || responsiblePerson.email}
|
||||
size="xs"
|
||||
/>
|
||||
<span class="person-name">{responsiblePerson.name || responsiblePerson.email}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="remove-person"
|
||||
onclick={() => (responsiblePerson = null)}>×</button
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<ContactSelector
|
||||
selectedContacts={[]}
|
||||
onContactsChange={handleResponsiblePersonChange}
|
||||
onSearch={handleContactSearch}
|
||||
allowManualEntry={true}
|
||||
placeholder="Person auswählen..."
|
||||
addLabel="Auswählen"
|
||||
searchPlaceholder="Name oder E-Mail..."
|
||||
isAvailable={contactsAvailable ?? false}
|
||||
singleSelect={true}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Attendees - show when expanded or when there are attendees -->
|
||||
{#if showPeopleSelector || attendees.length > 0}
|
||||
<div class="people-subsection">
|
||||
<span class="field-label">Teilnehmer</span>
|
||||
{#if attendees.length > 0}
|
||||
<div class="people-chips">
|
||||
{#each attendees as attendee (attendee.email)}
|
||||
<div class="person-chip">
|
||||
<ContactAvatar
|
||||
photoUrl={attendee.photoUrl}
|
||||
name={attendee.name || attendee.email}
|
||||
size="xs"
|
||||
/>
|
||||
<span class="person-name">{attendee.name || attendee.email}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="remove-person"
|
||||
onclick={() =>
|
||||
(attendees = attendees.filter((a) => a.email !== attendee.email))}
|
||||
>×</button
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<ContactSelector
|
||||
selectedContacts={attendeesAsContacts}
|
||||
onContactsChange={handleAttendeesChange}
|
||||
onSearch={handleContactSearch}
|
||||
allowManualEntry={true}
|
||||
placeholder={attendees.length > 0
|
||||
? 'Weitere hinzufügen...'
|
||||
: 'Teilnehmer hinzufügen...'}
|
||||
addLabel="Hinzufügen"
|
||||
searchPlaceholder="Name oder E-Mail..."
|
||||
isAvailable={contactsAvailable ?? false}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="field-placeholder">Standardkalender wird erstellt</span>
|
||||
<!-- Show expand button for attendees -->
|
||||
<button
|
||||
type="button"
|
||||
class="add-attendees-btn"
|
||||
onclick={() => (showPeopleSelector = true)}
|
||||
>
|
||||
+ Teilnehmer hinzufügen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -607,11 +862,13 @@
|
|||
<div class="row-icon"></div>
|
||||
<div class="row-content">
|
||||
<span class="field-label">Anzeigeart</span>
|
||||
<select class="field-select" bind:value={allDayDisplayMode}>
|
||||
<option value="default">Standard (aus Einstellungen)</option>
|
||||
<option value="header">In Kopfzeile</option>
|
||||
<option value="block">Als Tagesblock</option>
|
||||
</select>
|
||||
<FilterDropdown
|
||||
options={displayModeOptions}
|
||||
value={allDayDisplayMode}
|
||||
onChange={(v) =>
|
||||
(allDayDisplayMode = (v as 'default' | 'header' | 'block') || 'default')}
|
||||
placeholder="Anzeigeart wählen"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -823,12 +1080,9 @@
|
|||
position: fixed;
|
||||
width: 380px;
|
||||
max-height: 450px;
|
||||
background: hsl(var(--color-surface));
|
||||
background: var(--color-surface-elevated-2);
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0, 0, 0, 0.2),
|
||||
0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
z-index: 99999 !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -982,6 +1236,63 @@
|
|||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Calendar pills */
|
||||
.calendar-pills-container {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.calendar-pills-scroll {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
padding: 0 1.25rem 2px;
|
||||
}
|
||||
|
||||
.calendar-pills-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.calendar-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 9999px;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: all 150ms;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.calendar-pill:hover {
|
||||
background: hsl(var(--color-muted) / 0.3);
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.calendar-pill.active {
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
border-color: hsl(var(--color-primary) / 0.3);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.calendar-pill-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.calendar-pill-name {
|
||||
}
|
||||
|
||||
.row-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
|
@ -1010,7 +1321,6 @@
|
|||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.field-select,
|
||||
.field-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.625rem;
|
||||
|
|
@ -1021,7 +1331,6 @@
|
|||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.field-select:focus,
|
||||
.field-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--color-primary));
|
||||
|
|
@ -1073,14 +1382,17 @@
|
|||
.overlay-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-top: 1px solid hsl(var(--color-border));
|
||||
background: hsl(var(--color-surface));
|
||||
background: var(--color-surface-elevated-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.overlay-actions .btn-primary {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
|
|
@ -1198,4 +1510,78 @@
|
|||
.address-field.city {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* People section */
|
||||
.add-attendees-btn {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.25rem 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
transition: color 150ms;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.add-attendees-btn:hover {
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.people-subsection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.people-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.person-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0.5rem 0.25rem 0.25rem;
|
||||
background: hsl(var(--color-muted) / 0.5);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.person-name {
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.remove-person {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-full);
|
||||
transition: all 150ms;
|
||||
}
|
||||
|
||||
.remove-person:hover {
|
||||
background: hsl(var(--color-error) / 0.1);
|
||||
color: hsl(var(--color-error));
|
||||
}
|
||||
|
||||
.people-subsection + .people-subsection {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,216 @@
|
|||
<script lang="ts">
|
||||
import type { ResponsiblePerson } from '@calendar/shared';
|
||||
import type { ContactSummary, ContactOrManual, ManualContactEntry } from '@manacore/shared-types';
|
||||
import { ContactSelector, ContactAvatar } from '@manacore/shared-ui';
|
||||
import { X, ExternalLink } from 'lucide-svelte';
|
||||
import { contactsStore } from '$lib/stores/contacts.svelte';
|
||||
|
||||
interface Props {
|
||||
responsiblePerson: ResponsiblePerson | null;
|
||||
onResponsiblePersonChange: (person: ResponsiblePerson | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { responsiblePerson, onResponsiblePersonChange, disabled = false }: Props = $props();
|
||||
|
||||
let contactsAvailable = $state<boolean | null>(null);
|
||||
let showSelector = $state(false);
|
||||
|
||||
// Check contacts availability on mount
|
||||
$effect(() => {
|
||||
contactsStore.checkAvailability().then((available) => {
|
||||
contactsAvailable = available;
|
||||
});
|
||||
});
|
||||
|
||||
// Convert responsible person to ContactOrManual format for the selector
|
||||
const selectedContacts = $derived<ContactOrManual[]>(
|
||||
responsiblePerson
|
||||
? responsiblePerson.contactId
|
||||
? [
|
||||
{
|
||||
contactId: responsiblePerson.contactId,
|
||||
displayName: responsiblePerson.name || responsiblePerson.email,
|
||||
email: responsiblePerson.email,
|
||||
photoUrl: responsiblePerson.photoUrl,
|
||||
company: responsiblePerson.company,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
email: responsiblePerson.email,
|
||||
name: responsiblePerson.name,
|
||||
isManual: true as const,
|
||||
},
|
||||
]
|
||||
: []
|
||||
);
|
||||
|
||||
function handleContactsChange(contacts: ContactOrManual[]) {
|
||||
if (contacts.length === 0) {
|
||||
onResponsiblePersonChange(null);
|
||||
showSelector = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const contact = contacts[0];
|
||||
|
||||
if ('isManual' in contact && contact.isManual) {
|
||||
// Manual entry
|
||||
const manual = contact as ManualContactEntry;
|
||||
onResponsiblePersonChange({
|
||||
email: manual.email,
|
||||
name: manual.name,
|
||||
});
|
||||
} else {
|
||||
// Contact reference
|
||||
const contactRef = contact as {
|
||||
contactId: string;
|
||||
displayName: string;
|
||||
email?: string;
|
||||
photoUrl?: string;
|
||||
company?: string;
|
||||
};
|
||||
onResponsiblePersonChange({
|
||||
email: contactRef.email || '',
|
||||
name: contactRef.displayName,
|
||||
contactId: contactRef.contactId,
|
||||
photoUrl: contactRef.photoUrl,
|
||||
company: contactRef.company,
|
||||
});
|
||||
}
|
||||
|
||||
showSelector = false;
|
||||
}
|
||||
|
||||
function handleSearch(query: string): Promise<ContactSummary[]> {
|
||||
return contactsStore.searchContacts(query);
|
||||
}
|
||||
|
||||
function handleRemove() {
|
||||
onResponsiblePersonChange(null);
|
||||
}
|
||||
|
||||
function handleOpenContact() {
|
||||
if (responsiblePerson?.contactId) {
|
||||
// Open contacts app with this contact
|
||||
window.open(`/contacts/${responsiblePerson.contactId}`, '_blank');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="responsible-person-selector">
|
||||
{#if responsiblePerson}
|
||||
<!-- Selected Person Display -->
|
||||
<div class="flex items-center gap-3 p-2 rounded-lg bg-gray-50 dark:bg-gray-800/50">
|
||||
<ContactAvatar
|
||||
photoUrl={responsiblePerson.photoUrl}
|
||||
name={responsiblePerson.name || responsiblePerson.email}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-foreground truncate">
|
||||
{responsiblePerson.name || responsiblePerson.email}
|
||||
</div>
|
||||
{#if responsiblePerson.name && responsiblePerson.email}
|
||||
<div class="text-xs text-muted-foreground truncate">
|
||||
{responsiblePerson.email}
|
||||
</div>
|
||||
{/if}
|
||||
{#if responsiblePerson.company}
|
||||
<div class="text-xs text-muted-foreground truncate">
|
||||
{responsiblePerson.company}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Open Contact Button (only if linked to contact) -->
|
||||
{#if responsiblePerson.contactId}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleOpenContact}
|
||||
class="
|
||||
p-1.5 rounded-md
|
||||
text-gray-400 hover:text-blue-500
|
||||
hover:bg-blue-50 dark:hover:bg-blue-900/20
|
||||
transition-colors
|
||||
"
|
||||
title="Kontakt öffnen"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Remove Button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleRemove}
|
||||
class="
|
||||
p-1.5 rounded-md
|
||||
text-gray-400 hover:text-red-500
|
||||
hover:bg-red-50 dark:hover:bg-red-900/20
|
||||
transition-colors
|
||||
"
|
||||
title="Entfernen"
|
||||
{disabled}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{:else if showSelector}
|
||||
<!-- Contact Selector -->
|
||||
<ContactSelector
|
||||
selectedContacts={[]}
|
||||
onContactsChange={handleContactsChange}
|
||||
onSearch={handleSearch}
|
||||
allowManualEntry={true}
|
||||
placeholder="Person suchen oder E-Mail eingeben..."
|
||||
addLabel="Verantwortlich"
|
||||
searchPlaceholder="Name oder E-Mail..."
|
||||
isAvailable={contactsAvailable ?? false}
|
||||
{disabled}
|
||||
singleSelect={true}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showSelector = false)}
|
||||
class="mt-2 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
{:else}
|
||||
<!-- Add Button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showSelector = true)}
|
||||
class="
|
||||
w-full flex items-center justify-center gap-2
|
||||
px-4 py-2.5 rounded-lg
|
||||
border-2 border-dashed border-gray-200 dark:border-gray-700
|
||||
text-sm text-muted-foreground
|
||||
hover:border-gray-300 dark:hover:border-gray-600
|
||||
hover:text-foreground
|
||||
transition-colors
|
||||
"
|
||||
{disabled}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
Verantwortliche Person hinzufügen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.responsible-person-selector {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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';
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
import { todosStore } from '$lib/stores/todos.svelte';
|
||||
import type { Task, UpdateTaskInput, TaskPriority } from '$lib/api/todos';
|
||||
import { PRIORITY_LABELS, PRIORITY_COLORS } from '$lib/api/todos';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
import { toast } from '$lib/stores/toast.svelte';
|
||||
import TodoCheckbox from './TodoCheckbox.svelte';
|
||||
import PriorityBadge from './PriorityBadge.svelte';
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,32 @@
|
|||
* Reusable logic extracted from components
|
||||
*/
|
||||
|
||||
// Visible hours and time indicator
|
||||
export { useVisibleHours, useCurrentTimeIndicator } from './useVisibleHours.svelte';
|
||||
|
||||
// Event drag/drop and resize (comprehensive composable)
|
||||
export {
|
||||
useEventDragDrop,
|
||||
type EventDragDropConfig,
|
||||
type EventDragState,
|
||||
type EventResizeState,
|
||||
} from './useEventDragDrop.svelte';
|
||||
|
||||
// Task drag/drop and resize
|
||||
export { useTaskDragDrop, type TaskDragDropConfig } from './useTaskDragDrop.svelte';
|
||||
|
||||
// Sidebar task drop handling
|
||||
export { useSidebarDrop, type SidebarDropConfig } from './useSidebarDrop.svelte';
|
||||
|
||||
// Keyboard handling
|
||||
export { useCalendarKeyboard, type CancellableOperation } from './useCalendarKeyboard.svelte';
|
||||
|
||||
// Birthday popover management
|
||||
export { useBirthdayPopover } from './useBirthdayPopover.svelte';
|
||||
|
||||
// Swipe/scroll navigation for view switching
|
||||
export { useSwipeNavigation, type SwipeNavigationOptions } from './useSwipeNavigation.svelte';
|
||||
|
||||
// Legacy exports (kept for backwards compatibility, may be removed later)
|
||||
export { useDragDrop, type DragDropConfig, type DragState } from './useDragDrop.svelte';
|
||||
export { useResize, type ResizeConfig, type ResizeState } from './useResize.svelte';
|
||||
export { useTaskDragDrop } from './useTaskDragDrop.svelte';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* Birthday Popover Composable
|
||||
* Manages birthday popover state and handlers for calendar views
|
||||
*/
|
||||
|
||||
import type { BirthdayEvent } from '$lib/api/birthdays';
|
||||
|
||||
export function useBirthdayPopover() {
|
||||
let selectedBirthday = $state<BirthdayEvent | null>(null);
|
||||
let popoverPosition = $state<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
|
||||
/**
|
||||
* Handle click on a birthday indicator to show the popover
|
||||
*/
|
||||
function handleBirthdayClick(birthday: BirthdayEvent, e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
selectedBirthday = birthday;
|
||||
popoverPosition = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the birthday popover
|
||||
*/
|
||||
function closePopover() {
|
||||
selectedBirthday = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if popover is currently open
|
||||
*/
|
||||
function isOpen(): boolean {
|
||||
return selectedBirthday !== null;
|
||||
}
|
||||
|
||||
return {
|
||||
// State (reactive getters)
|
||||
get selectedBirthday() {
|
||||
return selectedBirthday;
|
||||
},
|
||||
get popoverPosition() {
|
||||
return popoverPosition;
|
||||
},
|
||||
|
||||
// Methods
|
||||
handleBirthdayClick,
|
||||
closePopover,
|
||||
isOpen,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* Calendar Keyboard Handling Composable
|
||||
* Handles keyboard shortcuts for calendar views (e.g., Escape to cancel drag/resize)
|
||||
*/
|
||||
|
||||
export interface CancellableOperation {
|
||||
/** Check if operation is active */
|
||||
isActive: () => boolean;
|
||||
/** Cancel the operation */
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a keyboard handler that cancels operations on Escape key
|
||||
* Automatically sets up and cleans up the event listener via $effect
|
||||
*
|
||||
* @param operations - Array of operations that can be cancelled (e.g., drag/drop, resize)
|
||||
*/
|
||||
export function useCalendarKeyboard(operations: CancellableOperation[]) {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
// Check if any operation is active
|
||||
const activeOperation = operations.find((op) => op.isActive());
|
||||
if (activeOperation) {
|
||||
e.preventDefault();
|
||||
activeOperation.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Setup listener - call this in $effect
|
||||
function setup() {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
|
||||
return {
|
||||
setup,
|
||||
handleKeyDown,
|
||||
};
|
||||
}
|
||||
|
|
@ -4,7 +4,8 @@
|
|||
*/
|
||||
|
||||
import type { CalendarEvent } from '@calendar/shared';
|
||||
import { parseISO, differenceInMinutes, addMinutes, setHours, setMinutes } from 'date-fns';
|
||||
import { differenceInMinutes, addMinutes, setHours, setMinutes } from 'date-fns';
|
||||
import { toDate } from '$lib/utils/eventDateHelpers';
|
||||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
|
||||
export interface DragDropConfig {
|
||||
|
|
@ -107,8 +108,8 @@ export function useDragDrop(getConfig: () => DragDropConfig) {
|
|||
draggedEvent = event;
|
||||
hasMoved = false;
|
||||
|
||||
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
|
||||
const start = toDate(event.startTime);
|
||||
const end = toDate(event.endTime);
|
||||
const duration = differenceInMinutes(end, start);
|
||||
|
||||
// Calculate initial preview position
|
||||
|
|
@ -158,14 +159,8 @@ export function useDragDrop(getConfig: () => DragDropConfig) {
|
|||
}
|
||||
|
||||
const config = getConfig();
|
||||
const start =
|
||||
typeof draggedEvent.startTime === 'string'
|
||||
? parseISO(draggedEvent.startTime)
|
||||
: draggedEvent.startTime;
|
||||
const end =
|
||||
typeof draggedEvent.endTime === 'string'
|
||||
? parseISO(draggedEvent.endTime)
|
||||
: draggedEvent.endTime;
|
||||
const start = toDate(draggedEvent.startTime);
|
||||
const end = toDate(draggedEvent.endTime);
|
||||
const duration = differenceInMinutes(end, start);
|
||||
|
||||
// Calculate new start time
|
||||
|
|
|
|||
|
|
@ -0,0 +1,427 @@
|
|||
/**
|
||||
* Event Drag & Drop + Resize Composable
|
||||
* Extracts duplicated drag/resize logic from WeekView, DayView, MultiDayView
|
||||
*/
|
||||
|
||||
import type { CalendarEvent } from '@calendar/shared';
|
||||
import { differenceInMinutes, addMinutes, setHours, setMinutes } from 'date-fns';
|
||||
import { toDate } from '$lib/utils/eventDateHelpers';
|
||||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
import { SNAP_INTERVAL_MINUTES } from '$lib/utils/calendarConstants';
|
||||
|
||||
export interface EventDragDropConfig {
|
||||
/** Reference to the container element for position calculations */
|
||||
containerEl: HTMLElement | null;
|
||||
/** Array of visible days (for multi-day views) or single day (for day view) */
|
||||
days: Date[];
|
||||
/** First visible hour (for filtered hours mode) */
|
||||
firstVisibleHour: number;
|
||||
/** Last visible hour (for filtered hours mode) */
|
||||
lastVisibleHour: number;
|
||||
/** Total visible hours */
|
||||
totalVisibleHours: number;
|
||||
/** Height of one hour in pixels */
|
||||
hourHeight: number;
|
||||
/** Minutes per snap interval (default: 15) */
|
||||
snapMinutes?: number;
|
||||
/** Function to convert minutes to percentage position */
|
||||
minutesToPercent: (minutes: number) => number;
|
||||
}
|
||||
|
||||
export interface EventDragState {
|
||||
isDragging: boolean;
|
||||
draggedEvent: CalendarEvent | null;
|
||||
dragTargetDay: Date | null;
|
||||
dragPreviewTop: number;
|
||||
dragPreviewHeight: number;
|
||||
hasMoved: boolean;
|
||||
}
|
||||
|
||||
export interface EventResizeState {
|
||||
isResizing: boolean;
|
||||
resizeEvent: CalendarEvent | null;
|
||||
resizeEdge: 'top' | 'bottom';
|
||||
resizePreviewTop: number;
|
||||
resizePreviewHeight: number;
|
||||
}
|
||||
|
||||
export function useEventDragDrop(getConfig: () => EventDragDropConfig) {
|
||||
// ========== Drag State ==========
|
||||
let isDragging = $state(false);
|
||||
let draggedEvent = $state<CalendarEvent | null>(null);
|
||||
let dragOffsetMinutes = $state(0);
|
||||
let dragTargetDay = $state<Date | null>(null);
|
||||
let dragPreviewTop = $state(0);
|
||||
let dragPreviewHeight = $state(0);
|
||||
|
||||
// ========== Resize State ==========
|
||||
let isResizing = $state(false);
|
||||
let resizeEvent = $state<CalendarEvent | null>(null);
|
||||
let resizeEdge = $state<'top' | 'bottom'>('bottom');
|
||||
let resizeOriginalStart = $state<Date | null>(null);
|
||||
let resizeOriginalEnd = $state<Date | null>(null);
|
||||
let resizePreviewTop = $state(0);
|
||||
let resizePreviewHeight = $state(0);
|
||||
let resizeOffsetMinutes = $state(0);
|
||||
|
||||
// Track if we actually moved during drag/resize (to prevent click on simple mousedown/up)
|
||||
let hasMoved = $state(false);
|
||||
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
function getSnapMinutes(): number {
|
||||
return getConfig().snapMinutes ?? SNAP_INTERVAL_MINUTES;
|
||||
}
|
||||
|
||||
function snapToGrid(minutes: number): number {
|
||||
const snap = getSnapMinutes();
|
||||
return Math.round(minutes / snap) * snap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get day from X coordinate (for multi-day views)
|
||||
*/
|
||||
function getDayFromX(clientX: number): Date | null {
|
||||
const config = getConfig();
|
||||
if (!config.containerEl) return null;
|
||||
|
||||
const rect = config.containerEl.getBoundingClientRect();
|
||||
const relativeX = clientX - rect.left;
|
||||
const dayWidth = rect.width / config.days.length;
|
||||
const dayIndex = Math.floor(relativeX / dayWidth);
|
||||
|
||||
if (dayIndex >= 0 && dayIndex < config.days.length) {
|
||||
return config.days[dayIndex];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get minutes from Y coordinate
|
||||
*/
|
||||
function getMinutesFromY(clientY: number): number {
|
||||
const config = getConfig();
|
||||
if (!config.containerEl) return 0;
|
||||
|
||||
const rect = config.containerEl.getBoundingClientRect();
|
||||
const scrollTop = config.containerEl.parentElement?.scrollTop || 0;
|
||||
const relativeY = clientY - rect.top + scrollTop;
|
||||
|
||||
// Account for hidden early hours
|
||||
const visibleMinutes =
|
||||
(relativeY / (config.totalVisibleHours * config.hourHeight)) * config.totalVisibleHours * 60;
|
||||
const totalMinutes = visibleMinutes + config.firstVisibleHour * 60;
|
||||
|
||||
// Snap to interval
|
||||
return snapToGrid(totalMinutes);
|
||||
}
|
||||
|
||||
// ========== Drag Functions ==========
|
||||
|
||||
function startDrag(event: CalendarEvent, e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
isDragging = true;
|
||||
draggedEvent = event;
|
||||
hasMoved = false;
|
||||
|
||||
const start = toDate(event.startTime);
|
||||
const end = toDate(event.endTime);
|
||||
const duration = differenceInMinutes(end, start);
|
||||
|
||||
// Calculate initial preview position
|
||||
const startMinutes = start.getHours() * 60 + start.getMinutes();
|
||||
dragPreviewTop = config.minutesToPercent(startMinutes);
|
||||
dragPreviewHeight = (duration / (config.totalVisibleHours * 60)) * 100;
|
||||
dragTargetDay = start;
|
||||
|
||||
// Calculate offset from event start to click position
|
||||
const clickMinutes = getMinutesFromY(e.clientY);
|
||||
dragOffsetMinutes = clickMinutes - startMinutes;
|
||||
|
||||
document.addEventListener('pointermove', handleDragMove);
|
||||
document.addEventListener('pointerup', handleDragEnd);
|
||||
}
|
||||
|
||||
function handleDragMove(e: PointerEvent) {
|
||||
if (!isDragging || !draggedEvent) return;
|
||||
|
||||
const config = getConfig();
|
||||
hasMoved = true;
|
||||
|
||||
// Calculate new position
|
||||
const newDay = getDayFromX(e.clientX);
|
||||
const newMinutes = getMinutesFromY(e.clientY) - dragOffsetMinutes;
|
||||
|
||||
// Clamp to valid range
|
||||
const clampedMinutes = Math.max(
|
||||
config.firstVisibleHour * 60,
|
||||
Math.min(config.lastVisibleHour * 60 - 15, newMinutes)
|
||||
);
|
||||
|
||||
// Update preview
|
||||
dragPreviewTop = config.minutesToPercent(clampedMinutes);
|
||||
if (newDay) {
|
||||
dragTargetDay = newDay;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDragEnd(e: PointerEvent) {
|
||||
document.removeEventListener('pointermove', handleDragMove);
|
||||
document.removeEventListener('pointerup', handleDragEnd);
|
||||
|
||||
if (!isDragging || !draggedEvent || !dragTargetDay || !hasMoved) {
|
||||
cleanupDrag();
|
||||
return;
|
||||
}
|
||||
|
||||
const start = toDate(draggedEvent.startTime);
|
||||
const end = toDate(draggedEvent.endTime);
|
||||
const duration = differenceInMinutes(end, start);
|
||||
|
||||
// Calculate new start time
|
||||
const newMinutes = getMinutesFromY(e.clientY) - dragOffsetMinutes;
|
||||
const clampedMinutes = Math.max(0, Math.min(24 * 60 - 15, newMinutes));
|
||||
const newHours = Math.floor(clampedMinutes / 60);
|
||||
const newMins = clampedMinutes % 60;
|
||||
|
||||
let newStart = new Date(dragTargetDay);
|
||||
newStart = setHours(newStart, newHours);
|
||||
newStart = setMinutes(newStart, newMins);
|
||||
|
||||
const newEnd = addMinutes(newStart, duration);
|
||||
|
||||
// Update event via store
|
||||
if (eventsStore.isDraftEvent(draggedEvent.id)) {
|
||||
eventsStore.updateDraftEvent({
|
||||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
});
|
||||
} else {
|
||||
await eventsStore.updateEvent(draggedEvent.id, {
|
||||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
cleanupDrag();
|
||||
}
|
||||
|
||||
function cleanupDrag() {
|
||||
isDragging = false;
|
||||
draggedEvent = null;
|
||||
dragTargetDay = null;
|
||||
hasMoved = false;
|
||||
}
|
||||
|
||||
// ========== Resize Functions ==========
|
||||
|
||||
function startResize(event: CalendarEvent, edge: 'top' | 'bottom', e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
isResizing = true;
|
||||
resizeEvent = event;
|
||||
resizeEdge = edge;
|
||||
hasMoved = false;
|
||||
|
||||
const start = toDate(event.startTime);
|
||||
const end = toDate(event.endTime);
|
||||
|
||||
resizeOriginalStart = start;
|
||||
resizeOriginalEnd = end;
|
||||
|
||||
// Set initial preview
|
||||
const startMinutes = start.getHours() * 60 + start.getMinutes();
|
||||
const endMinutes = end.getHours() * 60 + end.getMinutes();
|
||||
const duration = differenceInMinutes(end, start);
|
||||
resizePreviewTop = config.minutesToPercent(startMinutes);
|
||||
resizePreviewHeight = (duration / (config.totalVisibleHours * 60)) * 100;
|
||||
|
||||
// Calculate offset between snapped click position and actual event boundary
|
||||
const clickMinutes = getMinutesFromY(e.clientY);
|
||||
if (edge === 'top') {
|
||||
resizeOffsetMinutes = clickMinutes - startMinutes;
|
||||
} else {
|
||||
resizeOffsetMinutes = clickMinutes - endMinutes;
|
||||
}
|
||||
|
||||
document.addEventListener('pointermove', handleResizeMove);
|
||||
document.addEventListener('pointerup', handleResizeEnd);
|
||||
}
|
||||
|
||||
function handleResizeMove(e: PointerEvent) {
|
||||
if (!isResizing || !resizeEvent || !resizeOriginalStart || !resizeOriginalEnd) return;
|
||||
|
||||
const config = getConfig();
|
||||
hasMoved = true;
|
||||
|
||||
const currentMinutes = getMinutesFromY(e.clientY);
|
||||
// Apply offset to prevent jumping when drag starts
|
||||
const adjustedMinutes = currentMinutes - resizeOffsetMinutes;
|
||||
const originalStartMinutes =
|
||||
resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes();
|
||||
const originalEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes();
|
||||
|
||||
if (resizeEdge === 'bottom') {
|
||||
// Resize from bottom - change end time
|
||||
const newEndMinutes = Math.max(
|
||||
originalStartMinutes + 15,
|
||||
Math.min(config.lastVisibleHour * 60, adjustedMinutes)
|
||||
);
|
||||
const newDuration = newEndMinutes - originalStartMinutes;
|
||||
resizePreviewHeight = (newDuration / (config.totalVisibleHours * 60)) * 100;
|
||||
} else {
|
||||
// Resize from top - change start time
|
||||
const newStartMinutes = Math.max(
|
||||
config.firstVisibleHour * 60,
|
||||
Math.min(originalEndMinutes - 15, adjustedMinutes)
|
||||
);
|
||||
const newDuration = originalEndMinutes - newStartMinutes;
|
||||
resizePreviewTop = config.minutesToPercent(newStartMinutes);
|
||||
resizePreviewHeight = (newDuration / (config.totalVisibleHours * 60)) * 100;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResizeEnd(e: PointerEvent) {
|
||||
document.removeEventListener('pointermove', handleResizeMove);
|
||||
document.removeEventListener('pointerup', handleResizeEnd);
|
||||
|
||||
if (!isResizing || !resizeEvent || !resizeOriginalStart || !resizeOriginalEnd || !hasMoved) {
|
||||
cleanupResize();
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
const currentMinutes = getMinutesFromY(e.clientY);
|
||||
// Apply offset to prevent jumping
|
||||
const adjustedMinutes = currentMinutes - resizeOffsetMinutes;
|
||||
const originalStartMinutes =
|
||||
resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes();
|
||||
const originalEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes();
|
||||
|
||||
let newStart = resizeOriginalStart;
|
||||
let newEnd = resizeOriginalEnd;
|
||||
|
||||
if (resizeEdge === 'bottom') {
|
||||
const newEndMinutes = Math.max(
|
||||
originalStartMinutes + 15,
|
||||
Math.min(config.lastVisibleHour * 60, adjustedMinutes)
|
||||
);
|
||||
const newHours = Math.floor(newEndMinutes / 60);
|
||||
const newMins = newEndMinutes % 60;
|
||||
newEnd = setHours(new Date(resizeOriginalEnd), newHours);
|
||||
newEnd = setMinutes(newEnd, newMins);
|
||||
} else {
|
||||
const newStartMinutes = Math.max(
|
||||
config.firstVisibleHour * 60,
|
||||
Math.min(originalEndMinutes - 15, adjustedMinutes)
|
||||
);
|
||||
const newHours = Math.floor(newStartMinutes / 60);
|
||||
const newMins = newStartMinutes % 60;
|
||||
newStart = setHours(new Date(resizeOriginalStart), newHours);
|
||||
newStart = setMinutes(newStart, newMins);
|
||||
}
|
||||
|
||||
// Update event via store
|
||||
if (eventsStore.isDraftEvent(resizeEvent.id)) {
|
||||
eventsStore.updateDraftEvent({
|
||||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
});
|
||||
} else {
|
||||
await eventsStore.updateEvent(resizeEvent.id, {
|
||||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
cleanupResize();
|
||||
}
|
||||
|
||||
function cleanupResize() {
|
||||
isResizing = false;
|
||||
resizeEvent = null;
|
||||
resizeOriginalStart = null;
|
||||
resizeOriginalEnd = null;
|
||||
resizeOffsetMinutes = 0;
|
||||
hasMoved = false;
|
||||
}
|
||||
|
||||
// ========== Combined Cleanup ==========
|
||||
|
||||
function cleanup() {
|
||||
document.removeEventListener('pointermove', handleDragMove);
|
||||
document.removeEventListener('pointerup', handleDragEnd);
|
||||
document.removeEventListener('pointermove', handleResizeMove);
|
||||
document.removeEventListener('pointerup', handleResizeEnd);
|
||||
cleanupDrag();
|
||||
cleanupResize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel any active drag/resize operation (e.g., on Escape key)
|
||||
*/
|
||||
function cancel() {
|
||||
if (isDragging || isResizing) {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// Drag state (reactive getters)
|
||||
get isDragging() {
|
||||
return isDragging;
|
||||
},
|
||||
get draggedEvent() {
|
||||
return draggedEvent;
|
||||
},
|
||||
get dragTargetDay() {
|
||||
return dragTargetDay;
|
||||
},
|
||||
get dragPreviewTop() {
|
||||
return dragPreviewTop;
|
||||
},
|
||||
get dragPreviewHeight() {
|
||||
return dragPreviewHeight;
|
||||
},
|
||||
|
||||
// Resize state (reactive getters)
|
||||
get isResizing() {
|
||||
return isResizing;
|
||||
},
|
||||
get resizeEvent() {
|
||||
return resizeEvent;
|
||||
},
|
||||
get resizeEdge() {
|
||||
return resizeEdge;
|
||||
},
|
||||
get resizePreviewTop() {
|
||||
return resizePreviewTop;
|
||||
},
|
||||
get resizePreviewHeight() {
|
||||
return resizePreviewHeight;
|
||||
},
|
||||
|
||||
// Shared state
|
||||
get hasMoved() {
|
||||
return hasMoved;
|
||||
},
|
||||
|
||||
// Reset hasMoved after click handling
|
||||
resetHasMoved() {
|
||||
hasMoved = false;
|
||||
},
|
||||
|
||||
// Methods
|
||||
startDrag,
|
||||
startResize,
|
||||
cancel,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
|
|
@ -4,7 +4,8 @@
|
|||
*/
|
||||
|
||||
import type { CalendarEvent } from '@calendar/shared';
|
||||
import { parseISO, differenceInMinutes, setHours, setMinutes } from 'date-fns';
|
||||
import { differenceInMinutes, setHours, setMinutes } from 'date-fns';
|
||||
import { toDate } from '$lib/utils/eventDateHelpers';
|
||||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
|
||||
export interface ResizeConfig {
|
||||
|
|
@ -86,8 +87,8 @@ export function useResize(getConfig: () => ResizeConfig) {
|
|||
resizeEdge = edge;
|
||||
hasMoved = false;
|
||||
|
||||
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
|
||||
const start = toDate(event.startTime);
|
||||
const end = toDate(event.endTime);
|
||||
|
||||
resizeOriginalStart = start;
|
||||
resizeOriginalEnd = end;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
/**
|
||||
* Sidebar Task Drop Composable
|
||||
* Handles dropping tasks from sidebar into calendar day columns
|
||||
*/
|
||||
|
||||
import { todosStore } from '$lib/stores/todos.svelte';
|
||||
import { format } from 'date-fns';
|
||||
import { SNAP_INTERVAL_MINUTES } from '$lib/utils/calendarConstants';
|
||||
|
||||
export interface SidebarDropConfig {
|
||||
/** First visible hour (for filtered hours mode) */
|
||||
firstVisibleHour: number;
|
||||
/** Total visible hours */
|
||||
totalVisibleHours: number;
|
||||
/** Minutes per snap interval (default: 15) */
|
||||
snapMinutes?: number;
|
||||
}
|
||||
|
||||
export function useSidebarDrop(getConfig: () => SidebarDropConfig) {
|
||||
// Track active drop target (for visual feedback)
|
||||
let dropTarget = $state<{ day: Date; y: number } | null>(null);
|
||||
|
||||
function getSnapMinutes(): number {
|
||||
return getConfig().snapMinutes ?? SNAP_INTERVAL_MINUTES;
|
||||
}
|
||||
|
||||
function formatTime(hours: number, minutes: number): string {
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle dragover event on a day column
|
||||
*/
|
||||
function handleDragOver(e: DragEvent, day: Date) {
|
||||
e.preventDefault();
|
||||
if (!e.dataTransfer) return;
|
||||
|
||||
// Check if this is a sidebar task drag
|
||||
const types = e.dataTransfer.types;
|
||||
if (!types.includes('application/json')) return;
|
||||
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
dropTarget = { day, y: e.clientY };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle dragleave event
|
||||
*/
|
||||
function handleDragLeave(e: DragEvent) {
|
||||
// Only clear if leaving the column entirely
|
||||
const relatedTarget = e.relatedTarget as HTMLElement;
|
||||
if (!relatedTarget?.closest('.day-column')) {
|
||||
dropTarget = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle drop event on a day column
|
||||
*/
|
||||
async function handleDrop(e: DragEvent, day: Date) {
|
||||
e.preventDefault();
|
||||
dropTarget = null;
|
||||
|
||||
if (!e.dataTransfer) return;
|
||||
|
||||
const jsonData = e.dataTransfer.getData('application/json');
|
||||
if (!jsonData) return;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(jsonData);
|
||||
if (data.type !== 'sidebar-task') return;
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
// Calculate drop time from Y position
|
||||
const dayColumn = (e.target as HTMLElement).closest('.day-column');
|
||||
if (!dayColumn) return;
|
||||
|
||||
const rect = dayColumn.getBoundingClientRect();
|
||||
const relativeY = e.clientY - rect.top;
|
||||
const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100));
|
||||
|
||||
const minutesPerPercent = (config.totalVisibleHours * 60) / 100;
|
||||
const rawMinutes = percentY * minutesPerPercent;
|
||||
const snapMinutes = getSnapMinutes();
|
||||
const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes;
|
||||
const totalMinutes = config.firstVisibleHour * 60 + snappedMinutes;
|
||||
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
const startTime = formatTime(hours, minutes);
|
||||
|
||||
// Calculate end time
|
||||
const duration = data.estimatedDuration || 30;
|
||||
const endMinutes = totalMinutes + duration;
|
||||
const endHours = Math.floor(endMinutes / 60);
|
||||
const endMins = endMinutes % 60;
|
||||
const endTime = formatTime(endHours, endMins);
|
||||
|
||||
// Update the task with scheduled time
|
||||
await todosStore.updateTodo(data.taskId, {
|
||||
scheduledDate: format(day, 'yyyy-MM-dd'),
|
||||
scheduledStartTime: startTime,
|
||||
scheduledEndTime: endTime,
|
||||
estimatedDuration: duration,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to parse drop data:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear drop target (use when component unmounts or for manual cleanup)
|
||||
*/
|
||||
function clearDropTarget() {
|
||||
dropTarget = null;
|
||||
}
|
||||
|
||||
return {
|
||||
// State (reactive getter)
|
||||
get dropTarget() {
|
||||
return dropTarget;
|
||||
},
|
||||
|
||||
// Methods
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
clearDropTarget,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
/**
|
||||
* Swipe Navigation Composable
|
||||
* Enables horizontal swipe/scroll navigation for calendar views
|
||||
*
|
||||
* Supports:
|
||||
* - Trackpad horizontal scroll (Mac/Windows)
|
||||
* - Touch swipe (Mobile/Tablet)
|
||||
* - Mouse horizontal scroll wheel
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export interface SwipeNavigationOptions {
|
||||
/** Minimum pixels to trigger navigation (default: 80) */
|
||||
threshold?: number;
|
||||
/** Debounce time in ms for wheel events (default: 150) */
|
||||
debounceMs?: number;
|
||||
/** Disable swipe navigation temporarily */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_THRESHOLD = 80;
|
||||
const DEFAULT_DEBOUNCE_MS = 150;
|
||||
|
||||
/**
|
||||
* Creates swipe/scroll navigation for a container element
|
||||
*
|
||||
* @param getElement - Function returning the target element
|
||||
* @param onNext - Callback when swiping left (go to next period)
|
||||
* @param onPrevious - Callback when swiping right (go to previous period)
|
||||
* @param options - Configuration options
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <script>
|
||||
* import { useSwipeNavigation } from '$lib/composables';
|
||||
* import { viewStore } from '$lib/stores/view.svelte';
|
||||
*
|
||||
* let containerRef: HTMLElement;
|
||||
*
|
||||
* useSwipeNavigation(
|
||||
* () => containerRef,
|
||||
* () => viewStore.goToNext(),
|
||||
* () => viewStore.goToPrevious()
|
||||
* );
|
||||
* </script>
|
||||
*
|
||||
* <div bind:this={containerRef}>...</div>
|
||||
* ```
|
||||
*/
|
||||
export function useSwipeNavigation(
|
||||
getElement: () => HTMLElement | null,
|
||||
onNext: () => void,
|
||||
onPrevious: () => void,
|
||||
options: SwipeNavigationOptions = {}
|
||||
) {
|
||||
if (!browser) return;
|
||||
|
||||
const threshold = options.threshold ?? DEFAULT_THRESHOLD;
|
||||
const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
||||
|
||||
// Track accumulated wheel delta for trackpad detection
|
||||
let accumulatedDelta = 0;
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Touch tracking
|
||||
let touchStartX = 0;
|
||||
let touchStartY = 0;
|
||||
let isTouching = false;
|
||||
|
||||
/**
|
||||
* Handle wheel events (trackpad horizontal scroll)
|
||||
*/
|
||||
function handleWheel(e: WheelEvent) {
|
||||
// Skip if disabled
|
||||
if (options.disabled) return;
|
||||
|
||||
// Only handle horizontal scrolling (deltaX dominant)
|
||||
// This distinguishes trackpad gestures from vertical scrolling
|
||||
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
|
||||
|
||||
// Don't interfere with event dragging
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-event-id]') || target.closest('[data-dragging]')) return;
|
||||
|
||||
// Prevent default scroll behavior for horizontal gestures
|
||||
e.preventDefault();
|
||||
|
||||
// Accumulate horizontal delta
|
||||
accumulatedDelta += e.deltaX;
|
||||
|
||||
// Reset accumulator after debounce period
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
accumulatedDelta = 0;
|
||||
}, debounceMs);
|
||||
|
||||
// Check if threshold reached
|
||||
if (accumulatedDelta > threshold) {
|
||||
onNext();
|
||||
accumulatedDelta = 0;
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
} else if (accumulatedDelta < -threshold) {
|
||||
onPrevious();
|
||||
accumulatedDelta = 0;
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle touch start
|
||||
*/
|
||||
function handleTouchStart(e: TouchEvent) {
|
||||
// Skip if disabled
|
||||
if (options.disabled) return;
|
||||
|
||||
// Don't interfere with event dragging
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-event-id]') || target.closest('[data-dragging]')) return;
|
||||
|
||||
touchStartX = e.touches[0].clientX;
|
||||
touchStartY = e.touches[0].clientY;
|
||||
isTouching = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle touch end
|
||||
*/
|
||||
function handleTouchEnd(e: TouchEvent) {
|
||||
// Skip if disabled or wasn't tracking
|
||||
if (options.disabled || !isTouching) return;
|
||||
isTouching = false;
|
||||
|
||||
const touchEndX = e.changedTouches[0].clientX;
|
||||
const touchEndY = e.changedTouches[0].clientY;
|
||||
|
||||
const deltaX = touchEndX - touchStartX;
|
||||
const deltaY = touchEndY - touchStartY;
|
||||
|
||||
// Only trigger if horizontal movement is dominant and exceeds threshold
|
||||
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > threshold) {
|
||||
if (deltaX > 0) {
|
||||
// Swiped right → go to previous
|
||||
onPrevious();
|
||||
} else {
|
||||
// Swiped left → go to next
|
||||
onNext();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle touch cancel
|
||||
*/
|
||||
function handleTouchCancel() {
|
||||
isTouching = false;
|
||||
}
|
||||
|
||||
// Setup and cleanup with $effect
|
||||
$effect(() => {
|
||||
const el = getElement();
|
||||
if (!el) return;
|
||||
|
||||
// Add event listeners
|
||||
el.addEventListener('wheel', handleWheel, { passive: false });
|
||||
el.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||
el.addEventListener('touchend', handleTouchEnd, { passive: true });
|
||||
el.addEventListener('touchcancel', handleTouchCancel, { passive: true });
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
el.removeEventListener('wheel', handleWheel);
|
||||
el.removeEventListener('touchstart', handleTouchStart);
|
||||
el.removeEventListener('touchend', handleTouchEnd);
|
||||
el.removeEventListener('touchcancel', handleTouchCancel);
|
||||
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -1,306 +1,321 @@
|
|||
/**
|
||||
* Composable for Task Drag & Drop in Calendar Views
|
||||
* Handles dragging tasks to reschedule and resizing to change duration
|
||||
* Task Drag & Drop + Resize Composable
|
||||
* Extracts duplicated task drag/resize logic from WeekView, DayView, MultiDayView
|
||||
*
|
||||
* Uses document-level event listeners for smooth drag operations across the entire screen.
|
||||
*/
|
||||
|
||||
import type { Task, UpdateTaskInput } from '$lib/api/todos';
|
||||
import type { Task } from '$lib/stores/todos.svelte';
|
||||
import { todosStore } from '$lib/stores/todos.svelte';
|
||||
import { format, parseISO, addMinutes, differenceInMinutes, setHours, setMinutes } from 'date-fns';
|
||||
import { format } from 'date-fns';
|
||||
import { SNAP_INTERVAL_MINUTES } from '$lib/utils/calendarConstants';
|
||||
|
||||
const SNAP_MINUTES = 15;
|
||||
|
||||
interface UseTaskDragDropOptions {
|
||||
/** Minimum snap interval in minutes */
|
||||
export interface TaskDragDropConfig {
|
||||
/** Reference to the container element for position calculations */
|
||||
containerEl: HTMLElement | null;
|
||||
/** Array of visible days (for multi-day views) or single day (for day view) */
|
||||
days: Date[];
|
||||
/** First visible hour (for filtered hours mode) */
|
||||
firstVisibleHour: number;
|
||||
/** Total visible hours */
|
||||
totalVisibleHours: number;
|
||||
/** Minutes per snap interval (default: 15) */
|
||||
snapMinutes?: number;
|
||||
/** Callback when task is updated */
|
||||
onTaskUpdate?: (task: Task) => void;
|
||||
}
|
||||
|
||||
export function useTaskDragDrop(options: UseTaskDragDropOptions = {}) {
|
||||
const snapMinutes = options.snapMinutes ?? SNAP_MINUTES;
|
||||
|
||||
// Drag state
|
||||
let isDragging = $state(false);
|
||||
export function useTaskDragDrop(getConfig: () => TaskDragDropConfig) {
|
||||
// ========== Drag State ==========
|
||||
let isTaskDragging = $state(false);
|
||||
let draggedTask = $state<Task | null>(null);
|
||||
let dragStartY = $state(0);
|
||||
let dragTargetDay = $state<Date | null>(null);
|
||||
let dragPreviewTop = $state(0);
|
||||
let dragPreviewHeight = $state(0);
|
||||
let taskDragTargetDay = $state<Date | null>(null);
|
||||
let taskDragPreviewTop = $state(0);
|
||||
let taskDragPreviewHeight = $state(0);
|
||||
|
||||
// Resize state
|
||||
let isResizing = $state(false);
|
||||
// ========== Resize State ==========
|
||||
let isTaskResizing = $state(false);
|
||||
let resizeTask = $state<Task | null>(null);
|
||||
let resizeEdge = $state<'top' | 'bottom'>('bottom');
|
||||
let resizeStartY = $state(0);
|
||||
let resizePreviewTop = $state(0);
|
||||
let resizePreviewHeight = $state(0);
|
||||
let taskResizeEdge = $state<'top' | 'bottom'>('bottom');
|
||||
let taskResizePreviewTop = $state(0);
|
||||
let taskResizePreviewHeight = $state(0);
|
||||
|
||||
// Track if we actually moved
|
||||
// Track if we actually moved during drag/resize
|
||||
let hasMoved = $state(false);
|
||||
|
||||
/**
|
||||
* Start dragging a task
|
||||
*/
|
||||
function startDrag(
|
||||
task: Task,
|
||||
e: PointerEvent,
|
||||
gridElement: HTMLElement,
|
||||
firstVisibleHour: number,
|
||||
totalVisibleHours: number
|
||||
) {
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
function getSnapMinutes(): number {
|
||||
return getConfig().snapMinutes ?? SNAP_INTERVAL_MINUTES;
|
||||
}
|
||||
|
||||
function formatTime(hours: number, minutes: number): string {
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// ========== Drag Functions ==========
|
||||
|
||||
function startDrag(task: Task, e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
isDragging = true;
|
||||
|
||||
const config = getConfig();
|
||||
isTaskDragging = true;
|
||||
draggedTask = task;
|
||||
dragStartY = e.clientY;
|
||||
hasMoved = false;
|
||||
|
||||
// Calculate initial position
|
||||
// Initialize preview position from task's current time
|
||||
if (task.scheduledStartTime) {
|
||||
const [h, m] = task.scheduledStartTime.split(':').map(Number);
|
||||
const startMinutes = h * 60 + m - firstVisibleHour * 60;
|
||||
dragPreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100;
|
||||
const startMinutes = h * 60 + m - config.firstVisibleHour * 60;
|
||||
taskDragPreviewTop = (startMinutes / (config.totalVisibleHours * 60)) * 100;
|
||||
}
|
||||
|
||||
// Calculate height from duration
|
||||
const duration = task.estimatedDuration || 30;
|
||||
dragPreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
|
||||
taskDragPreviewHeight = (duration / (config.totalVisibleHours * 60)) * 100;
|
||||
|
||||
// Capture pointer
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
document.addEventListener('pointermove', handleDragMove);
|
||||
document.addEventListener('pointerup', handleDragEnd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle drag move
|
||||
*/
|
||||
function onDragMove(
|
||||
e: PointerEvent,
|
||||
gridElement: HTMLElement,
|
||||
day: Date,
|
||||
firstVisibleHour: number,
|
||||
totalVisibleHours: number
|
||||
) {
|
||||
if (!isDragging || !draggedTask) return;
|
||||
function handleDragMove(e: PointerEvent) {
|
||||
if (!isTaskDragging || !draggedTask) return;
|
||||
|
||||
const config = getConfig();
|
||||
hasMoved = true;
|
||||
dragTargetDay = day;
|
||||
|
||||
const rect = gridElement.getBoundingClientRect();
|
||||
// Find which day column we're over
|
||||
if (config.containerEl) {
|
||||
const dayColumns = config.containerEl.querySelectorAll('.day-column');
|
||||
for (let i = 0; i < dayColumns.length; i++) {
|
||||
const col = dayColumns[i];
|
||||
const rect = col.getBoundingClientRect();
|
||||
if (e.clientX >= rect.left && e.clientX <= rect.right) {
|
||||
taskDragTargetDay = config.days[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate vertical position
|
||||
const targetColumn = config.containerEl?.querySelector('.day-column');
|
||||
if (!targetColumn) return;
|
||||
|
||||
const rect = targetColumn.getBoundingClientRect();
|
||||
const relativeY = e.clientY - rect.top;
|
||||
const percentY = (relativeY / rect.height) * 100;
|
||||
const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100));
|
||||
|
||||
// Snap to intervals
|
||||
const minutesPerPercent = (totalVisibleHours * 60) / 100;
|
||||
const rawMinutes = percentY * minutesPerPercent + firstVisibleHour * 60;
|
||||
const minutesPerPercent = (config.totalVisibleHours * 60) / 100;
|
||||
const rawMinutes = percentY * minutesPerPercent;
|
||||
const snapMinutes = getSnapMinutes();
|
||||
const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes;
|
||||
|
||||
dragPreviewTop = ((snappedMinutes - firstVisibleHour * 60) / (totalVisibleHours * 60)) * 100;
|
||||
taskDragPreviewTop = (snappedMinutes / (config.totalVisibleHours * 60)) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* End drag and update task
|
||||
*/
|
||||
async function endDrag(firstVisibleHour: number, totalVisibleHours: number) {
|
||||
if (!isDragging || !draggedTask || !hasMoved) {
|
||||
isDragging = false;
|
||||
draggedTask = null;
|
||||
dragTargetDay = null;
|
||||
async function handleDragEnd() {
|
||||
document.removeEventListener('pointermove', handleDragMove);
|
||||
document.removeEventListener('pointerup', handleDragEnd);
|
||||
|
||||
if (!isTaskDragging || !draggedTask || !hasMoved) {
|
||||
cleanupDrag();
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate new time from position
|
||||
const minutesFromMidnight =
|
||||
(dragPreviewTop / 100) * (totalVisibleHours * 60) + firstVisibleHour * 60;
|
||||
const hours = Math.floor(minutesFromMidnight / 60);
|
||||
const minutes = Math.round(minutesFromMidnight % 60);
|
||||
const config = getConfig();
|
||||
|
||||
const newStartTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
||||
// Calculate new time from position
|
||||
const minutesFromStart = (taskDragPreviewTop / 100) * (config.totalVisibleHours * 60);
|
||||
const totalMinutes = config.firstVisibleHour * 60 + minutesFromStart;
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = Math.round(totalMinutes % 60);
|
||||
|
||||
const newStartTime = formatTime(hours, minutes);
|
||||
|
||||
// Calculate end time based on duration
|
||||
const duration = draggedTask.estimatedDuration || 30;
|
||||
const endMinutes = minutesFromMidnight + duration;
|
||||
const endHours = Math.floor(endMinutes / 60);
|
||||
const endMins = Math.round(endMinutes % 60);
|
||||
const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
|
||||
const endTotalMinutes = totalMinutes + duration;
|
||||
const endHours = Math.floor(endTotalMinutes / 60);
|
||||
const endMins = Math.round(endTotalMinutes % 60);
|
||||
const newEndTime = formatTime(endHours, endMins);
|
||||
|
||||
const updateData: UpdateTaskInput = {
|
||||
scheduledDate: dragTargetDay
|
||||
? format(dragTargetDay, 'yyyy-MM-dd')
|
||||
: draggedTask.scheduledDate,
|
||||
await todosStore.updateTodo(draggedTask.id, {
|
||||
scheduledDate: taskDragTargetDay ? format(taskDragTargetDay, 'yyyy-MM-dd') : undefined,
|
||||
scheduledStartTime: newStartTime,
|
||||
scheduledEndTime: newEndTime,
|
||||
};
|
||||
});
|
||||
|
||||
const result = await todosStore.updateTodo(draggedTask.id, updateData);
|
||||
if (result.data) {
|
||||
options.onTaskUpdate?.(result.data);
|
||||
}
|
||||
cleanupDrag();
|
||||
}
|
||||
|
||||
isDragging = false;
|
||||
function cleanupDrag() {
|
||||
isTaskDragging = false;
|
||||
draggedTask = null;
|
||||
dragTargetDay = null;
|
||||
taskDragTargetDay = null;
|
||||
hasMoved = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start resizing a task
|
||||
*/
|
||||
function startResize(
|
||||
task: Task,
|
||||
edge: 'top' | 'bottom',
|
||||
e: PointerEvent,
|
||||
firstVisibleHour: number,
|
||||
totalVisibleHours: number
|
||||
) {
|
||||
// ========== Resize Functions ==========
|
||||
|
||||
function startResize(task: Task, edge: 'top' | 'bottom', e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isResizing = true;
|
||||
|
||||
const config = getConfig();
|
||||
isTaskResizing = true;
|
||||
resizeTask = task;
|
||||
resizeEdge = edge;
|
||||
resizeStartY = e.clientY;
|
||||
taskResizeEdge = edge;
|
||||
hasMoved = false;
|
||||
|
||||
// Initialize preview position
|
||||
if (task.scheduledStartTime) {
|
||||
const [h, m] = task.scheduledStartTime.split(':').map(Number);
|
||||
const startMinutes = h * 60 + m - firstVisibleHour * 60;
|
||||
resizePreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100;
|
||||
const startMinutes = h * 60 + m - config.firstVisibleHour * 60;
|
||||
taskResizePreviewTop = (startMinutes / (config.totalVisibleHours * 60)) * 100;
|
||||
}
|
||||
|
||||
const duration = task.estimatedDuration || 30;
|
||||
resizePreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
|
||||
taskResizePreviewHeight = (duration / (config.totalVisibleHours * 60)) * 100;
|
||||
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
document.addEventListener('pointermove', handleResizeMove);
|
||||
document.addEventListener('pointerup', handleResizeEnd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle resize move
|
||||
*/
|
||||
function onResizeMove(
|
||||
e: PointerEvent,
|
||||
gridElement: HTMLElement,
|
||||
firstVisibleHour: number,
|
||||
totalVisibleHours: number
|
||||
) {
|
||||
if (!isResizing || !resizeTask) return;
|
||||
function handleResizeMove(e: PointerEvent) {
|
||||
if (!isTaskResizing || !resizeTask) return;
|
||||
|
||||
const config = getConfig();
|
||||
hasMoved = true;
|
||||
|
||||
const rect = gridElement.getBoundingClientRect();
|
||||
const targetColumn = config.containerEl?.querySelector('.day-column');
|
||||
if (!targetColumn) return;
|
||||
|
||||
const rect = targetColumn.getBoundingClientRect();
|
||||
const relativeY = e.clientY - rect.top;
|
||||
const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100));
|
||||
|
||||
const minutesPerPercent = (totalVisibleHours * 60) / 100;
|
||||
const minutesPerPercent = (config.totalVisibleHours * 60) / 100;
|
||||
const snapMinutes = getSnapMinutes();
|
||||
|
||||
if (resizeEdge === 'top') {
|
||||
if (taskResizeEdge === 'top') {
|
||||
// Adjust start time, keep end fixed
|
||||
const originalEndPercent = resizePreviewTop + resizePreviewHeight;
|
||||
const originalEndPercent = taskResizePreviewTop + taskResizePreviewHeight;
|
||||
const rawMinutes = percentY * minutesPerPercent;
|
||||
const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes;
|
||||
resizePreviewTop = (snappedMinutes / (totalVisibleHours * 60)) * 100;
|
||||
resizePreviewHeight = Math.max(2, originalEndPercent - resizePreviewTop);
|
||||
taskResizePreviewTop = (snappedMinutes / (config.totalVisibleHours * 60)) * 100;
|
||||
taskResizePreviewHeight = Math.max(2, originalEndPercent - taskResizePreviewTop);
|
||||
} else {
|
||||
// Adjust end time, keep start fixed
|
||||
const rawMinutes = percentY * minutesPerPercent;
|
||||
const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes;
|
||||
const newBottom = (snappedMinutes / (totalVisibleHours * 60)) * 100;
|
||||
resizePreviewHeight = Math.max(2, newBottom - resizePreviewTop);
|
||||
const newBottom = (snappedMinutes / (config.totalVisibleHours * 60)) * 100;
|
||||
taskResizePreviewHeight = Math.max(2, newBottom - taskResizePreviewTop);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* End resize and update task
|
||||
*/
|
||||
async function endResize(firstVisibleHour: number, totalVisibleHours: number) {
|
||||
if (!isResizing || !resizeTask || !hasMoved) {
|
||||
isResizing = false;
|
||||
resizeTask = null;
|
||||
async function handleResizeEnd() {
|
||||
document.removeEventListener('pointermove', handleResizeMove);
|
||||
document.removeEventListener('pointerup', handleResizeEnd);
|
||||
|
||||
if (!isTaskResizing || !resizeTask || !hasMoved) {
|
||||
cleanupResize();
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
// Calculate new times from position
|
||||
const startMinutes =
|
||||
(resizePreviewTop / 100) * (totalVisibleHours * 60) + firstVisibleHour * 60;
|
||||
(taskResizePreviewTop / 100) * (config.totalVisibleHours * 60) + config.firstVisibleHour * 60;
|
||||
const endMinutes =
|
||||
((resizePreviewTop + resizePreviewHeight) / 100) * (totalVisibleHours * 60) +
|
||||
firstVisibleHour * 60;
|
||||
((taskResizePreviewTop + taskResizePreviewHeight) / 100) * (config.totalVisibleHours * 60) +
|
||||
config.firstVisibleHour * 60;
|
||||
|
||||
const startHours = Math.floor(startMinutes / 60);
|
||||
const startMins = Math.round(startMinutes % 60);
|
||||
const endHours = Math.floor(endMinutes / 60);
|
||||
const endMins = Math.round(endMinutes % 60);
|
||||
|
||||
const newStartTime = `${startHours.toString().padStart(2, '0')}:${startMins.toString().padStart(2, '0')}`;
|
||||
const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
|
||||
const newStartTime = formatTime(startHours, startMins);
|
||||
const newEndTime = formatTime(endHours, endMins);
|
||||
const newDuration = Math.round(endMinutes - startMinutes);
|
||||
|
||||
const updateData: UpdateTaskInput = {
|
||||
await todosStore.updateTodo(resizeTask.id, {
|
||||
scheduledStartTime: newStartTime,
|
||||
scheduledEndTime: newEndTime,
|
||||
estimatedDuration: newDuration,
|
||||
};
|
||||
});
|
||||
|
||||
const result = await todosStore.updateTodo(resizeTask.id, updateData);
|
||||
if (result.data) {
|
||||
options.onTaskUpdate?.(result.data);
|
||||
}
|
||||
cleanupResize();
|
||||
}
|
||||
|
||||
isResizing = false;
|
||||
function cleanupResize() {
|
||||
isTaskResizing = false;
|
||||
resizeTask = null;
|
||||
hasMoved = false;
|
||||
}
|
||||
|
||||
// ========== Combined Cleanup ==========
|
||||
|
||||
function cleanup() {
|
||||
document.removeEventListener('pointermove', handleDragMove);
|
||||
document.removeEventListener('pointerup', handleDragEnd);
|
||||
document.removeEventListener('pointermove', handleResizeMove);
|
||||
document.removeEventListener('pointerup', handleResizeEnd);
|
||||
cleanupDrag();
|
||||
cleanupResize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel any ongoing drag/resize
|
||||
* Cancel any active drag/resize operation
|
||||
*/
|
||||
function cancel() {
|
||||
isDragging = false;
|
||||
isResizing = false;
|
||||
draggedTask = null;
|
||||
resizeTask = null;
|
||||
dragTargetDay = null;
|
||||
hasMoved = false;
|
||||
if (isTaskDragging || isTaskResizing) {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State getters
|
||||
get isDragging() {
|
||||
return isDragging;
|
||||
// Drag state (reactive getters)
|
||||
get isTaskDragging() {
|
||||
return isTaskDragging;
|
||||
},
|
||||
get draggedTask() {
|
||||
return draggedTask;
|
||||
},
|
||||
get dragTargetDay() {
|
||||
return dragTargetDay;
|
||||
get taskDragTargetDay() {
|
||||
return taskDragTargetDay;
|
||||
},
|
||||
get dragPreviewTop() {
|
||||
return dragPreviewTop;
|
||||
get taskDragPreviewTop() {
|
||||
return taskDragPreviewTop;
|
||||
},
|
||||
get dragPreviewHeight() {
|
||||
return dragPreviewHeight;
|
||||
get taskDragPreviewHeight() {
|
||||
return taskDragPreviewHeight;
|
||||
},
|
||||
get isResizing() {
|
||||
return isResizing;
|
||||
|
||||
// Resize state (reactive getters)
|
||||
get isTaskResizing() {
|
||||
return isTaskResizing;
|
||||
},
|
||||
get resizeTask() {
|
||||
return resizeTask;
|
||||
},
|
||||
get resizePreviewTop() {
|
||||
return resizePreviewTop;
|
||||
get taskResizeEdge() {
|
||||
return taskResizeEdge;
|
||||
},
|
||||
get resizePreviewHeight() {
|
||||
return resizePreviewHeight;
|
||||
get taskResizePreviewTop() {
|
||||
return taskResizePreviewTop;
|
||||
},
|
||||
get taskResizePreviewHeight() {
|
||||
return taskResizePreviewHeight;
|
||||
},
|
||||
|
||||
// Shared state
|
||||
get hasMoved() {
|
||||
return hasMoved;
|
||||
},
|
||||
|
||||
// Methods
|
||||
startDrag,
|
||||
onDragMove,
|
||||
endDrag,
|
||||
startResize,
|
||||
onResizeMove,
|
||||
endResize,
|
||||
cancel,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
/**
|
||||
* useVisibleHours Composable
|
||||
*
|
||||
* Provides hour filtering and time-to-position calculations for calendar views.
|
||||
* Extracts common logic from WeekView, MultiDayView, and DayView.
|
||||
*/
|
||||
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
|
||||
const ALL_HOURS = Array.from({ length: 24 }, (_, i) => i);
|
||||
|
||||
/**
|
||||
* Creates reactive hour visibility state and helper functions
|
||||
*/
|
||||
export function useVisibleHours() {
|
||||
// Filtered hours based on settings
|
||||
let hours = $derived(
|
||||
settingsStore.filterHoursEnabled
|
||||
? ALL_HOURS.filter((h) => h >= settingsStore.dayStartHour && h < settingsStore.dayEndHour)
|
||||
: ALL_HOURS
|
||||
);
|
||||
|
||||
// Calculate visible hours range for positioning
|
||||
let firstVisibleHour = $derived(
|
||||
settingsStore.filterHoursEnabled ? settingsStore.dayStartHour : 0
|
||||
);
|
||||
|
||||
let lastVisibleHour = $derived(settingsStore.filterHoursEnabled ? settingsStore.dayEndHour : 24);
|
||||
|
||||
let totalVisibleHours = $derived(lastVisibleHour - firstVisibleHour);
|
||||
|
||||
/**
|
||||
* Convert minutes (from midnight) to percentage position
|
||||
* accounting for hidden hours when filtering is enabled
|
||||
*/
|
||||
function minutesToPercent(minutes: number): number {
|
||||
const adjustedMinutes = minutes - firstVisibleHour * 60;
|
||||
return (adjustedMinutes / (totalVisibleHours * 60)) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert percentage position back to minutes (from midnight)
|
||||
*/
|
||||
function percentToMinutes(percent: number): number {
|
||||
return (percent / 100) * (totalVisibleHours * 60) + firstVisibleHour * 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a time range overlaps with the visible hours range
|
||||
*/
|
||||
function isTimeRangeVisible(startMinutes: number, endMinutes: number): boolean {
|
||||
const visibleStartMinutes = firstVisibleHour * 60;
|
||||
const visibleEndMinutes = lastVisibleHour * 60;
|
||||
return startMinutes < visibleEndMinutes && endMinutes > visibleStartMinutes;
|
||||
}
|
||||
|
||||
return {
|
||||
get hours() {
|
||||
return hours;
|
||||
},
|
||||
get firstVisibleHour() {
|
||||
return firstVisibleHour;
|
||||
},
|
||||
get lastVisibleHour() {
|
||||
return lastVisibleHour;
|
||||
},
|
||||
get totalVisibleHours() {
|
||||
return totalVisibleHours;
|
||||
},
|
||||
minutesToPercent,
|
||||
percentToMinutes,
|
||||
isTimeRangeVisible,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a reactive current time indicator
|
||||
* Updates every minute and provides position calculation
|
||||
*/
|
||||
export function useCurrentTimeIndicator() {
|
||||
let now = $state(new Date());
|
||||
|
||||
// Update current time every minute
|
||||
$effect(() => {
|
||||
const interval = setInterval(() => {
|
||||
now = new Date();
|
||||
}, 60000);
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
|
||||
return {
|
||||
get now() {
|
||||
return now;
|
||||
},
|
||||
/**
|
||||
* Get current time as minutes from midnight
|
||||
*/
|
||||
get currentMinutes() {
|
||||
return now.getHours() * 60 + now.getMinutes();
|
||||
},
|
||||
};
|
||||
}
|
||||
108
apps/calendar/apps/web/src/lib/config/helpConfig.ts
Normal file
108
apps/calendar/apps/web/src/lib/config/helpConfig.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { NavigationArrow, CalendarBlank, ListChecks } from '@manacore/shared-icons';
|
||||
import {
|
||||
COMMON_SHORTCUTS,
|
||||
COMMON_SYNTAX,
|
||||
DEFAULT_LIVE_EXAMPLE,
|
||||
type HelpModalConfig,
|
||||
type ShortcutCategory,
|
||||
type SyntaxGroup,
|
||||
} from '@manacore/shared-ui';
|
||||
|
||||
/**
|
||||
* Calendar-specific keyboard shortcuts
|
||||
*/
|
||||
const CALENDAR_SHORTCUTS: ShortcutCategory[] = [
|
||||
{
|
||||
id: 'navigation',
|
||||
title: 'Navigation',
|
||||
icon: NavigationArrow,
|
||||
shortcuts: [
|
||||
{
|
||||
keys: ['Cmd', '1'],
|
||||
altKeys: ['Ctrl', '1'],
|
||||
description: 'Kalender öffnen',
|
||||
category: 'navigation',
|
||||
},
|
||||
{
|
||||
keys: ['Cmd', '2'],
|
||||
altKeys: ['Ctrl', '2'],
|
||||
description: 'Aufgaben öffnen',
|
||||
category: 'navigation',
|
||||
},
|
||||
{
|
||||
keys: ['Cmd', '3'],
|
||||
altKeys: ['Ctrl', '3'],
|
||||
description: 'Statistiken öffnen',
|
||||
category: 'navigation',
|
||||
},
|
||||
{
|
||||
keys: ['Cmd', '4'],
|
||||
altKeys: ['Ctrl', '4'],
|
||||
description: 'Einstellungen öffnen',
|
||||
category: 'navigation',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'calendar',
|
||||
title: 'Kalender',
|
||||
icon: CalendarBlank,
|
||||
shortcuts: [
|
||||
{
|
||||
keys: ['Enter'],
|
||||
description: 'Event/Task öffnen',
|
||||
category: 'calendar',
|
||||
},
|
||||
{
|
||||
keys: ['Space'],
|
||||
description: 'Event/Task öffnen',
|
||||
category: 'calendar',
|
||||
},
|
||||
{
|
||||
keys: ['Esc'],
|
||||
description: 'Drag/Resize abbrechen',
|
||||
category: 'calendar',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'tasks',
|
||||
title: 'Aufgaben',
|
||||
icon: ListChecks,
|
||||
shortcuts: [
|
||||
{
|
||||
keys: ['Enter'],
|
||||
description: 'Aufgabe öffnen',
|
||||
category: 'tasks',
|
||||
},
|
||||
{
|
||||
keys: ['Space'],
|
||||
description: 'Aufgabe abhaken',
|
||||
category: 'tasks',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Calendar-specific syntax patterns (extends common syntax)
|
||||
*/
|
||||
const CALENDAR_SYNTAX: SyntaxGroup[] = [
|
||||
// Calendar uses all common syntax patterns
|
||||
];
|
||||
|
||||
/**
|
||||
* Complete help configuration for the Calendar app
|
||||
* Combines common shortcuts/syntax with Calendar-specific ones
|
||||
*/
|
||||
export const CALENDAR_HELP_CONFIG: HelpModalConfig = {
|
||||
shortcuts: [...COMMON_SHORTCUTS, ...CALENDAR_SHORTCUTS],
|
||||
syntax: [...COMMON_SYNTAX, ...CALENDAR_SYNTAX],
|
||||
defaultTab: 'shortcuts',
|
||||
liveExample: DEFAULT_LIVE_EXAMPLE,
|
||||
};
|
||||
|
||||
/**
|
||||
* Export individual parts for customization
|
||||
*/
|
||||
export { CALENDAR_SHORTCUTS, CALENDAR_SYNTAX };
|
||||
219
apps/calendar/apps/web/src/lib/stores/birthdays.svelte.ts
Normal file
219
apps/calendar/apps/web/src/lib/stores/birthdays.svelte.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
/**
|
||||
* Birthdays Store - Manages contact birthdays for calendar display
|
||||
* Cross-app integration with Contacts Backend (similar to todosStore)
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import * as api from '$lib/api/birthdays';
|
||||
import type { ContactBirthdaySummary, BirthdayEvent } from '$lib/api/birthdays';
|
||||
import { getContactDisplayName, BIRTHDAY_CALENDAR } from '$lib/api/birthdays';
|
||||
import { differenceInYears, isSameDay, isWithinInterval, parseISO, format } from 'date-fns';
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { ContactBirthdaySummary, BirthdayEvent };
|
||||
|
||||
// ============================================
|
||||
// State
|
||||
// ============================================
|
||||
|
||||
let birthdays = $state<ContactBirthdaySummary[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let serviceAvailable = $state(true);
|
||||
let lastFetchTime = $state<number>(0);
|
||||
|
||||
// Cache settings
|
||||
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
// ============================================
|
||||
// Store Export
|
||||
// ============================================
|
||||
|
||||
export const birthdaysStore = {
|
||||
// ========== Getters ==========
|
||||
get birthdays() {
|
||||
return birthdays ?? [];
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
get serviceAvailable() {
|
||||
return serviceAvailable;
|
||||
},
|
||||
get calendarId() {
|
||||
return BIRTHDAY_CALENDAR.id;
|
||||
},
|
||||
get calendarColor() {
|
||||
return BIRTHDAY_CALENDAR.color;
|
||||
},
|
||||
get calendarName() {
|
||||
return BIRTHDAY_CALENDAR.name;
|
||||
},
|
||||
|
||||
// ========== Birthday Getters ==========
|
||||
|
||||
/**
|
||||
* Get birthday events for a specific day
|
||||
* Matches by month and day (ignores year)
|
||||
*/
|
||||
getBirthdaysForDay(date: Date): BirthdayEvent[] {
|
||||
const currentBirthdays = birthdays ?? [];
|
||||
if (!Array.isArray(currentBirthdays) || currentBirthdays.length === 0) return [];
|
||||
|
||||
return currentBirthdays
|
||||
.filter((contact) => {
|
||||
if (!contact.birthday) return false;
|
||||
const birthdayDate = parseISO(contact.birthday);
|
||||
// Compare month and day only
|
||||
return (
|
||||
birthdayDate.getMonth() === date.getMonth() && birthdayDate.getDate() === date.getDate()
|
||||
);
|
||||
})
|
||||
.map((contact) => this.toBirthdayEvent(contact, date));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get birthday events within a date range
|
||||
*/
|
||||
getBirthdaysInRange(start: Date, end: Date): BirthdayEvent[] {
|
||||
const currentBirthdays = birthdays ?? [];
|
||||
if (!Array.isArray(currentBirthdays) || currentBirthdays.length === 0) return [];
|
||||
|
||||
const events: BirthdayEvent[] = [];
|
||||
const current = new Date(start);
|
||||
|
||||
// Iterate through each day in range
|
||||
while (current <= end) {
|
||||
const dayBirthdays = this.getBirthdaysForDay(current);
|
||||
events.push(...dayBirthdays);
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
return events;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a specific day has any birthdays
|
||||
*/
|
||||
hasBirthdaysOnDay(date: Date): boolean {
|
||||
const currentBirthdays = birthdays ?? [];
|
||||
if (!Array.isArray(currentBirthdays)) return false;
|
||||
|
||||
return currentBirthdays.some((contact) => {
|
||||
if (!contact.birthday) return false;
|
||||
const birthdayDate = parseISO(contact.birthday);
|
||||
return (
|
||||
birthdayDate.getMonth() === date.getMonth() && birthdayDate.getDate() === date.getDate()
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get upcoming birthdays (next N days)
|
||||
*/
|
||||
getUpcomingBirthdays(days: number = 30): BirthdayEvent[] {
|
||||
const start = new Date();
|
||||
const end = new Date();
|
||||
end.setDate(end.getDate() + days);
|
||||
return this.getBirthdaysInRange(start, end);
|
||||
},
|
||||
|
||||
/**
|
||||
* Convert contact to birthday event
|
||||
*/
|
||||
toBirthdayEvent(contact: ContactBirthdaySummary, displayDate: Date): BirthdayEvent {
|
||||
const displayName = getContactDisplayName(contact);
|
||||
const birthdayDate = parseISO(contact.birthday);
|
||||
const birthYear = birthdayDate.getFullYear();
|
||||
|
||||
// Calculate age (0 if year seems invalid, e.g., 1900 default)
|
||||
let age = differenceInYears(displayDate, birthdayDate);
|
||||
if (birthYear < 1900 || birthYear > new Date().getFullYear()) {
|
||||
age = 0; // Unknown birth year
|
||||
}
|
||||
|
||||
const dateStr = format(displayDate, 'yyyy-MM-dd');
|
||||
|
||||
return {
|
||||
id: `birthday-${contact.id}-${dateStr}`,
|
||||
contactId: contact.id,
|
||||
title: `${displayName}`,
|
||||
displayName,
|
||||
photoUrl: contact.photoUrl,
|
||||
birthday: contact.birthday,
|
||||
age,
|
||||
startTime: displayDate.toISOString(),
|
||||
endTime: displayDate.toISOString(),
|
||||
isAllDay: true,
|
||||
isBirthday: true,
|
||||
calendarId: BIRTHDAY_CALENDAR.id,
|
||||
};
|
||||
},
|
||||
|
||||
// ========== API Methods ==========
|
||||
|
||||
/**
|
||||
* Fetch birthdays from Contacts service
|
||||
* Uses cache to avoid frequent refetches
|
||||
*/
|
||||
async fetchBirthdays(force = false) {
|
||||
if (!browser) return;
|
||||
|
||||
// Use cache if still valid
|
||||
if (!force && Date.now() - lastFetchTime < CACHE_TTL && birthdays.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
const result = await api.getBirthdays();
|
||||
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
serviceAvailable = false;
|
||||
} else {
|
||||
birthdays = result.data || [];
|
||||
serviceAvailable = true;
|
||||
lastFetchTime = Date.now();
|
||||
}
|
||||
|
||||
loading = false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if Contacts service is available
|
||||
*/
|
||||
async checkServiceHealth(): Promise<boolean> {
|
||||
const result = await api.getBirthdays();
|
||||
serviceAvailable = !result.error;
|
||||
return serviceAvailable;
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear birthdays cache
|
||||
*/
|
||||
clear() {
|
||||
birthdays = [];
|
||||
lastFetchTime = 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get contact by ID from cached birthdays
|
||||
*/
|
||||
getContactById(id: string): ContactBirthdaySummary | undefined {
|
||||
const currentBirthdays = birthdays ?? [];
|
||||
if (!Array.isArray(currentBirthdays)) return undefined;
|
||||
return currentBirthdays.find((c) => c.id === id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Count of contacts with birthdays
|
||||
*/
|
||||
get count(): number {
|
||||
return birthdays?.length ?? 0;
|
||||
},
|
||||
};
|
||||
|
|
@ -4,18 +4,42 @@
|
|||
|
||||
import type { Calendar, CreateCalendarInput, UpdateCalendarInput } from '@calendar/shared';
|
||||
import * as api from '$lib/api/calendars';
|
||||
import { BIRTHDAY_CALENDAR } from '$lib/api/birthdays';
|
||||
import { settingsStore } from './settings.svelte';
|
||||
|
||||
// State
|
||||
let calendars = $state<Calendar[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Virtual birthday calendar (created dynamically based on settings)
|
||||
const birthdayCalendar: Calendar = {
|
||||
id: BIRTHDAY_CALENDAR.id,
|
||||
userId: '',
|
||||
name: BIRTHDAY_CALENDAR.name,
|
||||
color: BIRTHDAY_CALENDAR.color,
|
||||
isDefault: false,
|
||||
isVisible: true, // Visibility controlled by settingsStore.showBirthdays
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Helper to safely get calendars array (Svelte 5 runes safety)
|
||||
function getCalendarsArray(): Calendar[] {
|
||||
const arr = calendars ?? [];
|
||||
return Array.isArray(arr) ? arr : [];
|
||||
}
|
||||
|
||||
// Derived: all calendars including virtual birthday calendar
|
||||
const allCalendars = $derived.by(() => {
|
||||
const userCalendars = getCalendarsArray();
|
||||
// Add virtual birthday calendar if birthdays are enabled in settings
|
||||
if (settingsStore.showBirthdays) {
|
||||
return [...userCalendars, { ...birthdayCalendar, isVisible: true }];
|
||||
}
|
||||
return userCalendars;
|
||||
});
|
||||
|
||||
// Derived: visible calendars
|
||||
const visibleCalendars = $derived(getCalendarsArray().filter((c) => c.isVisible));
|
||||
|
||||
|
|
@ -30,6 +54,9 @@ export const calendarsStore = {
|
|||
get calendars() {
|
||||
return calendars;
|
||||
},
|
||||
get allCalendars() {
|
||||
return allCalendars;
|
||||
},
|
||||
get visibleCalendars() {
|
||||
return visibleCalendars;
|
||||
},
|
||||
|
|
@ -42,6 +69,9 @@ export const calendarsStore = {
|
|||
get error() {
|
||||
return error;
|
||||
},
|
||||
get birthdayCalendarId() {
|
||||
return BIRTHDAY_CALENDAR.id;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch all calendars
|
||||
|
|
@ -115,6 +145,23 @@ export const calendarsStore = {
|
|||
return this.updateCalendar(id, { isVisible: !calendar.isVisible });
|
||||
},
|
||||
|
||||
/**
|
||||
* Set a calendar as the default
|
||||
*/
|
||||
async setAsDefault(id: string) {
|
||||
const result = await api.updateCalendar(id, { isDefault: true });
|
||||
|
||||
if (result.data) {
|
||||
// Update local state: set this one as default, remove default from others
|
||||
calendars = getCalendarsArray().map((c) => ({
|
||||
...c,
|
||||
isDefault: c.id === id,
|
||||
}));
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get calendar by ID
|
||||
*/
|
||||
|
|
@ -126,7 +173,26 @@ export const calendarsStore = {
|
|||
* Get calendar color by ID (with fallback)
|
||||
*/
|
||||
getColor(id: string) {
|
||||
// Handle virtual birthday calendar
|
||||
if (id === BIRTHDAY_CALENDAR.id) {
|
||||
return BIRTHDAY_CALENDAR.color;
|
||||
}
|
||||
const calendar = getCalendarsArray().find((c) => c.id === id);
|
||||
return calendar?.color || '#3b82f6';
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle birthday calendar visibility
|
||||
* (This updates the settings store, not the calendar itself)
|
||||
*/
|
||||
toggleBirthdaysVisibility() {
|
||||
settingsStore.set('showBirthdays', !settingsStore.showBirthdays);
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a calendar ID is the virtual birthday calendar
|
||||
*/
|
||||
isBirthdayCalendar(id: string) {
|
||||
return id === BIRTHDAY_CALENDAR.id;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
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);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* Event Context Menu Store - Manages context menu state for calendar events
|
||||
*/
|
||||
|
||||
import type { CalendarEvent } from '@calendar/shared';
|
||||
|
||||
// State
|
||||
let visible = $state(false);
|
||||
let x = $state(0);
|
||||
let y = $state(0);
|
||||
let targetEvent = $state<CalendarEvent | null>(null);
|
||||
|
||||
export const eventContextMenuStore = {
|
||||
// Getters
|
||||
get visible() {
|
||||
return visible;
|
||||
},
|
||||
get x() {
|
||||
return x;
|
||||
},
|
||||
get y() {
|
||||
return y;
|
||||
},
|
||||
get targetEvent() {
|
||||
return targetEvent;
|
||||
},
|
||||
|
||||
/**
|
||||
* Show the context menu for an event
|
||||
*/
|
||||
show(event: CalendarEvent, clientX: number, clientY: number) {
|
||||
targetEvent = event;
|
||||
x = clientX;
|
||||
y = clientY;
|
||||
visible = true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Hide the context menu
|
||||
*/
|
||||
hide() {
|
||||
visible = false;
|
||||
targetEvent = null;
|
||||
},
|
||||
};
|
||||
|
|
@ -4,7 +4,8 @@
|
|||
|
||||
import type { CalendarEvent, CreateEventInput, UpdateEventInput } from '@calendar/shared';
|
||||
import * as api from '$lib/api/events';
|
||||
import { format, isWithinInterval, parseISO, isSameDay } from 'date-fns';
|
||||
import { format, isWithinInterval, isSameDay } from 'date-fns';
|
||||
import { toDate } from '$lib/utils/eventDateHelpers';
|
||||
import { toastStore } from './toast.svelte';
|
||||
|
||||
// State
|
||||
|
|
@ -48,9 +49,10 @@ export const eventsStore = {
|
|||
error = result.error.message;
|
||||
toastStore.error(`Termine konnten nicht geladen werden: ${result.error.message}`);
|
||||
} else {
|
||||
// API returns { events: [...] }
|
||||
const data = result.data as { events: CalendarEvent[] } | null;
|
||||
events = data?.events || [];
|
||||
// API returns events array directly (already extracted in api/events.ts)
|
||||
const eventsData = result.data as CalendarEvent[] | null;
|
||||
console.log('[Events Store] Loaded events:', eventsData?.length, eventsData);
|
||||
events = eventsData || [];
|
||||
loadedRange = { start: startDate, end: endDate };
|
||||
}
|
||||
|
||||
|
|
@ -67,9 +69,8 @@ export const eventsStore = {
|
|||
if (!Array.isArray(currentEvents)) return [];
|
||||
|
||||
const result = currentEvents.filter((event) => {
|
||||
const eventStart =
|
||||
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const eventEnd = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
|
||||
const eventStart = toDate(event.startTime);
|
||||
const eventEnd = toDate(event.endTime);
|
||||
|
||||
// For all-day events, check if day falls within event range
|
||||
if (event.isAllDay) {
|
||||
|
|
@ -85,10 +86,7 @@ export const eventsStore = {
|
|||
|
||||
// Include draft event if it exists and is on this day
|
||||
if (includeDraft && draftEvent) {
|
||||
const draftStart =
|
||||
typeof draftEvent.startTime === 'string'
|
||||
? parseISO(draftEvent.startTime)
|
||||
: draftEvent.startTime;
|
||||
const draftStart = toDate(draftEvent.startTime);
|
||||
if (isSameDay(date, draftStart)) {
|
||||
result.push(draftEvent);
|
||||
}
|
||||
|
|
@ -106,9 +104,8 @@ export const eventsStore = {
|
|||
if (!Array.isArray(currentEvents)) return [];
|
||||
|
||||
return currentEvents.filter((event) => {
|
||||
const eventStart =
|
||||
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const eventEnd = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
|
||||
const eventStart = toDate(event.startTime);
|
||||
const eventEnd = toDate(event.endTime);
|
||||
|
||||
// Check if event overlaps with the range
|
||||
return eventStart <= end && eventEnd >= start;
|
||||
|
|
@ -121,11 +118,8 @@ export const eventsStore = {
|
|||
async createEvent(data: CreateEventInput) {
|
||||
const result = await api.createEvent(data);
|
||||
|
||||
if (result.error) {
|
||||
toastStore.error(`Termin konnte nicht erstellt werden: ${result.error.message}`);
|
||||
} else if (result.data) {
|
||||
if (result.data) {
|
||||
events = [...events, result.data];
|
||||
toastStore.success('Termin erstellt');
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -7,39 +7,55 @@ interface SearchItem {
|
|||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
class SearchStore {
|
||||
// Current search query
|
||||
query = $state('');
|
||||
// State
|
||||
let query = $state('');
|
||||
let matchingEventIds = $state<Set<string>>(new Set());
|
||||
let isSearching = $state(false);
|
||||
|
||||
// Event IDs that match the search
|
||||
matchingEventIds = $state<Set<string>>(new Set());
|
||||
|
||||
// Whether search is active (user is typing in InputBar)
|
||||
isSearching = $state(false);
|
||||
|
||||
// Set search query and matching items (events or any items with an id)
|
||||
setSearch(query: string, matchingItems: SearchItem[]) {
|
||||
this.query = query;
|
||||
this.matchingEventIds = new Set(matchingItems.map((item) => item.id));
|
||||
this.isSearching = query.trim().length > 0;
|
||||
}
|
||||
|
||||
// Clear search
|
||||
clear() {
|
||||
this.query = '';
|
||||
this.matchingEventIds = new Set();
|
||||
this.isSearching = false;
|
||||
}
|
||||
|
||||
// Check if an event matches the search
|
||||
isEventHighlighted(eventId: string): boolean {
|
||||
return this.isSearching && this.matchingEventIds.has(eventId);
|
||||
}
|
||||
|
||||
// Check if an event should be dimmed (search active but event doesn't match)
|
||||
isEventDimmed(eventId: string): boolean {
|
||||
return this.isSearching && !this.matchingEventIds.has(eventId);
|
||||
}
|
||||
/**
|
||||
* Set search query and matching items (events or any items with an id)
|
||||
*/
|
||||
function setSearch(newQuery: string, matchingItems: SearchItem[]) {
|
||||
query = newQuery;
|
||||
matchingEventIds = new Set(matchingItems.map((item) => item.id));
|
||||
isSearching = newQuery.trim().length > 0;
|
||||
}
|
||||
|
||||
export const searchStore = new SearchStore();
|
||||
/**
|
||||
* Clear search
|
||||
*/
|
||||
function clear() {
|
||||
query = '';
|
||||
matchingEventIds = new Set();
|
||||
isSearching = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event matches the search
|
||||
*/
|
||||
function isEventHighlighted(eventId: string): boolean {
|
||||
return isSearching && matchingEventIds.has(eventId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event should be dimmed (search active but event doesn't match)
|
||||
*/
|
||||
function isEventDimmed(eventId: string): boolean {
|
||||
return isSearching && !matchingEventIds.has(eventId);
|
||||
}
|
||||
|
||||
export const searchStore = {
|
||||
get query() {
|
||||
return query;
|
||||
},
|
||||
get matchingEventIds() {
|
||||
return matchingEventIds;
|
||||
},
|
||||
get isSearching() {
|
||||
return isSearching;
|
||||
},
|
||||
setSearch,
|
||||
clear,
|
||||
isEventHighlighted,
|
||||
isEventDimmed,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { userSettings } from './user-settings.svelte';
|
|||
export type WeekStartDay = 0 | 1; // 0 = Sunday, 1 = Monday
|
||||
export type TimeFormat = '24h' | '12h';
|
||||
export type AllDayDisplayMode = 'header' | 'block'; // header = separate row, block = full day block in grid
|
||||
export type WeekdayFormat = 'full' | 'short' | 'hidden';
|
||||
|
||||
export interface CalendarAppSettings {
|
||||
// View settings
|
||||
|
|
@ -26,9 +27,39 @@ export interface CalendarAppSettings {
|
|||
dayEndHour: number; // Last visible hour (0-23)
|
||||
allDayDisplayMode: AllDayDisplayMode; // How to display all-day events
|
||||
|
||||
// Header settings
|
||||
headerCompact: boolean; // Compact header display
|
||||
headerWeekdayFormat: WeekdayFormat; // Weekday display format
|
||||
headerShowDate: boolean; // Show date in header
|
||||
headerAlwaysShowMonth: boolean; // Always show month (e.g., "13.12.")
|
||||
|
||||
// DateStrip settings
|
||||
dateStripShowMoonPhases: boolean; // Show moon phase indicators
|
||||
dateStripShowEventIndicators: boolean; // Show event dot indicators
|
||||
dateStripShowWeekday: boolean; // Show weekday names (Mo, Di, Mi...)
|
||||
dateStripHighlightWeekends: boolean; // Visually highlight weekend days
|
||||
dateStripShowMonthDividers: boolean; // Show vertical dividers between months
|
||||
dateStripCompact: boolean; // Use compact/smaller DateStrip
|
||||
dateStripShowWeekNumbers: boolean; // Show week numbers at start of week
|
||||
dateStripCollapsed: boolean; // Whether DateStrip is minimized to FAB
|
||||
|
||||
// TagStrip settings
|
||||
tagStripCollapsed: boolean; // Whether TagStrip is hidden
|
||||
|
||||
// Immersive Mode settings
|
||||
immersiveModeEnabled: boolean; // Fullscreen mode - hides all UI elements
|
||||
|
||||
// Birthday settings (cross-app integration with Contacts)
|
||||
showBirthdays: boolean; // Show contact birthdays in calendar
|
||||
showBirthdayAge: boolean; // Show age in birthday events
|
||||
|
||||
// UI settings
|
||||
sidebarCollapsed: boolean;
|
||||
|
||||
// Quick View Pill settings
|
||||
quickViewPillViews: CalendarViewType[]; // Views shown in quick switcher
|
||||
customDayCount: number; // Custom day count for 'custom' view type (1-365)
|
||||
|
||||
// Event defaults
|
||||
defaultEventDuration: number; // in minutes
|
||||
defaultReminder: number; // in minutes before event
|
||||
|
|
@ -44,7 +75,33 @@ const DEFAULT_SETTINGS: CalendarAppSettings = {
|
|||
dayStartHour: 6,
|
||||
dayEndHour: 20,
|
||||
allDayDisplayMode: 'header',
|
||||
// Header defaults
|
||||
headerCompact: false,
|
||||
headerWeekdayFormat: 'full',
|
||||
headerShowDate: true,
|
||||
headerAlwaysShowMonth: false,
|
||||
// DateStrip defaults
|
||||
dateStripShowMoonPhases: true,
|
||||
dateStripShowEventIndicators: true,
|
||||
dateStripShowWeekday: true,
|
||||
dateStripHighlightWeekends: true,
|
||||
dateStripShowMonthDividers: true,
|
||||
dateStripCompact: false,
|
||||
dateStripShowWeekNumbers: false,
|
||||
dateStripCollapsed: false,
|
||||
// TagStrip defaults
|
||||
tagStripCollapsed: true, // Hidden by default
|
||||
// Immersive Mode defaults
|
||||
immersiveModeEnabled: false,
|
||||
// Birthday defaults
|
||||
showBirthdays: true,
|
||||
showBirthdayAge: true,
|
||||
// UI defaults
|
||||
sidebarCollapsed: false,
|
||||
// Quick View Pill defaults
|
||||
quickViewPillViews: ['week', 'month', 'agenda'],
|
||||
customDayCount: 30, // Default: 30 days (1 month)
|
||||
// Event defaults
|
||||
defaultEventDuration: 60,
|
||||
defaultReminder: 15,
|
||||
};
|
||||
|
|
@ -142,6 +199,59 @@ export const settingsStore = {
|
|||
get allDayDisplayMode() {
|
||||
return settings.allDayDisplayMode;
|
||||
},
|
||||
// Header settings
|
||||
get headerCompact() {
|
||||
return settings.headerCompact;
|
||||
},
|
||||
get headerWeekdayFormat() {
|
||||
return settings.headerWeekdayFormat;
|
||||
},
|
||||
get headerShowDate() {
|
||||
return settings.headerShowDate;
|
||||
},
|
||||
get headerAlwaysShowMonth() {
|
||||
return settings.headerAlwaysShowMonth;
|
||||
},
|
||||
// DateStrip settings
|
||||
get dateStripShowMoonPhases() {
|
||||
return settings.dateStripShowMoonPhases;
|
||||
},
|
||||
get dateStripShowEventIndicators() {
|
||||
return settings.dateStripShowEventIndicators;
|
||||
},
|
||||
get dateStripShowWeekday() {
|
||||
return settings.dateStripShowWeekday;
|
||||
},
|
||||
get dateStripHighlightWeekends() {
|
||||
return settings.dateStripHighlightWeekends;
|
||||
},
|
||||
get dateStripShowMonthDividers() {
|
||||
return settings.dateStripShowMonthDividers;
|
||||
},
|
||||
get dateStripCompact() {
|
||||
return settings.dateStripCompact;
|
||||
},
|
||||
get dateStripShowWeekNumbers() {
|
||||
return settings.dateStripShowWeekNumbers;
|
||||
},
|
||||
get dateStripCollapsed() {
|
||||
return settings.dateStripCollapsed;
|
||||
},
|
||||
// TagStrip settings
|
||||
get tagStripCollapsed() {
|
||||
return settings.tagStripCollapsed;
|
||||
},
|
||||
// Immersive Mode settings
|
||||
get immersiveModeEnabled() {
|
||||
return settings.immersiveModeEnabled;
|
||||
},
|
||||
// Birthday settings
|
||||
get showBirthdays() {
|
||||
return settings.showBirthdays;
|
||||
},
|
||||
get showBirthdayAge() {
|
||||
return settings.showBirthdayAge;
|
||||
},
|
||||
get defaultEventDuration() {
|
||||
return settings.defaultEventDuration;
|
||||
},
|
||||
|
|
@ -151,6 +261,12 @@ export const settingsStore = {
|
|||
get sidebarCollapsed() {
|
||||
return settings.sidebarCollapsed;
|
||||
},
|
||||
get quickViewPillViews() {
|
||||
return settings.quickViewPillViews;
|
||||
},
|
||||
get customDayCount() {
|
||||
return settings.customDayCount;
|
||||
},
|
||||
get cloudSyncEnabled() {
|
||||
return cloudSyncEnabled;
|
||||
},
|
||||
|
|
@ -191,6 +307,24 @@ export const settingsStore = {
|
|||
syncToCloud();
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle TagStrip visibility
|
||||
*/
|
||||
toggleTagStrip() {
|
||||
settings = { ...settings, tagStripCollapsed: !settings.tagStripCollapsed };
|
||||
saveSettings(settings);
|
||||
syncToCloud();
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle Immersive Mode (fullscreen, hide all UI)
|
||||
*/
|
||||
toggleImmersiveMode() {
|
||||
settings = { ...settings, immersiveModeEnabled: !settings.immersiveModeEnabled };
|
||||
saveSettings(settings);
|
||||
syncToCloud();
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize settings from localStorage
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
import { writable } from 'svelte/store';
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'warning' | 'info';
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
function createToastStore() {
|
||||
const { subscribe, update } = writable<Toast[]>([]);
|
||||
|
||||
function add(message: string, type: ToastType = 'info', duration: number = 4000) {
|
||||
const id = crypto.randomUUID();
|
||||
const toast: Toast = { id, type, message, duration };
|
||||
|
||||
update((toasts) => [...toasts, toast]);
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
remove(id);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
function remove(id: string) {
|
||||
update((toasts) => toasts.filter((t) => t.id !== id));
|
||||
}
|
||||
|
||||
function clear() {
|
||||
update(() => []);
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
add,
|
||||
remove,
|
||||
clear,
|
||||
success: (message: string, duration?: number) => add(message, 'success', duration),
|
||||
error: (message: string, duration?: number) => add(message, 'error', duration),
|
||||
warning: (message: string, duration?: number) => add(message, 'warning', duration),
|
||||
info: (message: string, duration?: number) => add(message, 'info', duration),
|
||||
};
|
||||
}
|
||||
|
||||
export const toast = createToastStore();
|
||||
|
|
@ -38,6 +38,11 @@ const viewRange = $derived.by(() => {
|
|||
start: startOfDay(currentDate),
|
||||
end: endOfDay(currentDate),
|
||||
};
|
||||
case '3day':
|
||||
return {
|
||||
start: startOfDay(currentDate),
|
||||
end: endOfDay(addDays(currentDate, 2)),
|
||||
};
|
||||
case '5day':
|
||||
return {
|
||||
start: startOfDay(currentDate),
|
||||
|
|
@ -58,6 +63,33 @@ const viewRange = $derived.by(() => {
|
|||
start: startOfDay(currentDate),
|
||||
end: endOfDay(addDays(currentDate, 13)),
|
||||
};
|
||||
case '30day':
|
||||
return {
|
||||
start: startOfDay(currentDate),
|
||||
end: endOfDay(addDays(currentDate, 29)),
|
||||
};
|
||||
case '60day':
|
||||
return {
|
||||
start: startOfDay(currentDate),
|
||||
end: endOfDay(addDays(currentDate, 59)),
|
||||
};
|
||||
case '90day':
|
||||
return {
|
||||
start: startOfDay(currentDate),
|
||||
end: endOfDay(addDays(currentDate, 89)),
|
||||
};
|
||||
case '365day':
|
||||
return {
|
||||
start: startOfDay(currentDate),
|
||||
end: endOfDay(addDays(currentDate, 364)),
|
||||
};
|
||||
case 'custom': {
|
||||
const customDays = settingsStore.customDayCount;
|
||||
return {
|
||||
start: startOfDay(currentDate),
|
||||
end: endOfDay(addDays(currentDate, customDays - 1)),
|
||||
};
|
||||
}
|
||||
case 'month':
|
||||
return {
|
||||
start: startOfMonth(currentDate),
|
||||
|
|
@ -108,7 +140,22 @@ export const viewStore = {
|
|||
const savedView = localStorage.getItem('calendar-view-type');
|
||||
if (
|
||||
savedView &&
|
||||
['day', '5day', 'week', '10day', '14day', 'month', 'year', 'agenda'].includes(savedView)
|
||||
[
|
||||
'day',
|
||||
'3day',
|
||||
'5day',
|
||||
'week',
|
||||
'10day',
|
||||
'14day',
|
||||
'30day',
|
||||
'60day',
|
||||
'90day',
|
||||
'365day',
|
||||
'month',
|
||||
'year',
|
||||
'agenda',
|
||||
'custom',
|
||||
].includes(savedView)
|
||||
) {
|
||||
viewType = savedView as CalendarViewType;
|
||||
} else {
|
||||
|
|
@ -149,6 +196,9 @@ export const viewStore = {
|
|||
case 'day':
|
||||
currentDate = subDays(currentDate, 1);
|
||||
break;
|
||||
case '3day':
|
||||
currentDate = subDays(currentDate, 3);
|
||||
break;
|
||||
case '5day':
|
||||
currentDate = subDays(currentDate, 5);
|
||||
break;
|
||||
|
|
@ -161,6 +211,21 @@ export const viewStore = {
|
|||
case '14day':
|
||||
currentDate = subDays(currentDate, 14);
|
||||
break;
|
||||
case '30day':
|
||||
currentDate = subDays(currentDate, 30);
|
||||
break;
|
||||
case '60day':
|
||||
currentDate = subDays(currentDate, 60);
|
||||
break;
|
||||
case '90day':
|
||||
currentDate = subDays(currentDate, 90);
|
||||
break;
|
||||
case '365day':
|
||||
currentDate = subDays(currentDate, 365);
|
||||
break;
|
||||
case 'custom':
|
||||
currentDate = subDays(currentDate, settingsStore.customDayCount);
|
||||
break;
|
||||
case 'month':
|
||||
currentDate = subMonths(currentDate, 1);
|
||||
break;
|
||||
|
|
@ -181,6 +246,9 @@ export const viewStore = {
|
|||
case 'day':
|
||||
currentDate = addDays(currentDate, 1);
|
||||
break;
|
||||
case '3day':
|
||||
currentDate = addDays(currentDate, 3);
|
||||
break;
|
||||
case '5day':
|
||||
currentDate = addDays(currentDate, 5);
|
||||
break;
|
||||
|
|
@ -193,6 +261,21 @@ export const viewStore = {
|
|||
case '14day':
|
||||
currentDate = addDays(currentDate, 14);
|
||||
break;
|
||||
case '30day':
|
||||
currentDate = addDays(currentDate, 30);
|
||||
break;
|
||||
case '60day':
|
||||
currentDate = addDays(currentDate, 60);
|
||||
break;
|
||||
case '90day':
|
||||
currentDate = addDays(currentDate, 90);
|
||||
break;
|
||||
case '365day':
|
||||
currentDate = addDays(currentDate, 365);
|
||||
break;
|
||||
case 'custom':
|
||||
currentDate = addDays(currentDate, settingsStore.customDayCount);
|
||||
break;
|
||||
case 'month':
|
||||
currentDate = addMonths(currentDate, 1);
|
||||
break;
|
||||
|
|
|
|||
60
apps/calendar/apps/web/src/lib/utils/calendarConstants.ts
Normal file
60
apps/calendar/apps/web/src/lib/utils/calendarConstants.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* Shared calendar constants
|
||||
* Single source of truth for magic numbers used across calendar views
|
||||
*/
|
||||
|
||||
/**
|
||||
* Height of one hour in pixels (should match CSS --hour-height variable)
|
||||
*/
|
||||
export const HOUR_HEIGHT_PX = 60;
|
||||
|
||||
/**
|
||||
* Snap interval for drag/drop and resize operations in minutes
|
||||
*/
|
||||
export const SNAP_INTERVAL_MINUTES = 15;
|
||||
|
||||
/**
|
||||
* Default event duration in minutes when creating quick events
|
||||
*/
|
||||
export const DEFAULT_EVENT_DURATION_MINUTES = 60;
|
||||
|
||||
/**
|
||||
* Minimum event height as percentage of visible hours
|
||||
*/
|
||||
export const MIN_EVENT_HEIGHT_PERCENT = 1.5;
|
||||
|
||||
/**
|
||||
* Maximum number of event dots to show in month view cells
|
||||
*/
|
||||
export const MAX_EVENT_DOTS = 5;
|
||||
|
||||
/**
|
||||
* Days buffer for infinite scroll in date strip
|
||||
*/
|
||||
export const DATE_STRIP_BUFFER_DAYS = 60;
|
||||
|
||||
/**
|
||||
* Default visible hours range
|
||||
*/
|
||||
export const DEFAULT_DAY_START_HOUR = 0;
|
||||
export const DEFAULT_DAY_END_HOUR = 24;
|
||||
|
||||
/**
|
||||
* Week starts on (0 = Sunday, 1 = Monday)
|
||||
*/
|
||||
export const DEFAULT_WEEK_STARTS_ON = 1;
|
||||
|
||||
/**
|
||||
* All constants as a single object for convenient destructuring
|
||||
*/
|
||||
export const CALENDAR_CONSTANTS = {
|
||||
HOUR_HEIGHT_PX,
|
||||
SNAP_INTERVAL_MINUTES,
|
||||
DEFAULT_EVENT_DURATION_MINUTES,
|
||||
MIN_EVENT_HEIGHT_PERCENT,
|
||||
MAX_EVENT_DOTS,
|
||||
DATE_STRIP_BUFFER_DAYS,
|
||||
DEFAULT_DAY_START_HOUR,
|
||||
DEFAULT_DAY_END_HOUR,
|
||||
DEFAULT_WEEK_STARTS_ON,
|
||||
} as const;
|
||||
84
apps/calendar/apps/web/src/lib/utils/dateNavigation.ts
Normal file
84
apps/calendar/apps/web/src/lib/utils/dateNavigation.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* Date Navigation Utilities
|
||||
* Helper functions for calculating date offsets based on view type
|
||||
*/
|
||||
|
||||
import type { CalendarViewType } from '@calendar/shared';
|
||||
import {
|
||||
addDays,
|
||||
addWeeks,
|
||||
addMonths,
|
||||
addYears,
|
||||
subDays,
|
||||
subWeeks,
|
||||
subMonths,
|
||||
subYears,
|
||||
} from 'date-fns';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
|
||||
/**
|
||||
* Calculate a date offset based on the current view type
|
||||
*
|
||||
* @param date - The base date
|
||||
* @param viewType - The current calendar view type
|
||||
* @param offset - Number of periods to offset (-1 = previous, 1 = next)
|
||||
* @returns The calculated date
|
||||
*
|
||||
* @example
|
||||
* // Get previous week's date
|
||||
* getOffsetDate(new Date(), 'week', -1)
|
||||
*
|
||||
* // Get next month's date
|
||||
* getOffsetDate(new Date(), 'month', 1)
|
||||
*/
|
||||
export function getOffsetDate(date: Date, viewType: CalendarViewType, offset: number): Date {
|
||||
switch (viewType) {
|
||||
case 'day':
|
||||
return offset > 0 ? addDays(date, offset) : subDays(date, Math.abs(offset));
|
||||
|
||||
case '3day':
|
||||
return offset > 0 ? addDays(date, offset * 3) : subDays(date, Math.abs(offset) * 3);
|
||||
|
||||
case '5day':
|
||||
return offset > 0 ? addDays(date, offset * 5) : subDays(date, Math.abs(offset) * 5);
|
||||
|
||||
case 'week':
|
||||
return offset > 0 ? addWeeks(date, offset) : subWeeks(date, Math.abs(offset));
|
||||
|
||||
case '10day':
|
||||
return offset > 0 ? addDays(date, offset * 10) : subDays(date, Math.abs(offset) * 10);
|
||||
|
||||
case '14day':
|
||||
return offset > 0 ? addDays(date, offset * 14) : subDays(date, Math.abs(offset) * 14);
|
||||
|
||||
case '30day':
|
||||
return offset > 0 ? addDays(date, offset * 30) : subDays(date, Math.abs(offset) * 30);
|
||||
|
||||
case '60day':
|
||||
return offset > 0 ? addDays(date, offset * 60) : subDays(date, Math.abs(offset) * 60);
|
||||
|
||||
case '90day':
|
||||
return offset > 0 ? addDays(date, offset * 90) : subDays(date, Math.abs(offset) * 90);
|
||||
|
||||
case '365day':
|
||||
return offset > 0 ? addDays(date, offset * 365) : subDays(date, Math.abs(offset) * 365);
|
||||
|
||||
case 'custom': {
|
||||
const days = settingsStore.customDayCount;
|
||||
return offset > 0 ? addDays(date, offset * days) : subDays(date, Math.abs(offset) * days);
|
||||
}
|
||||
|
||||
case 'month':
|
||||
return offset > 0 ? addMonths(date, offset) : subMonths(date, Math.abs(offset));
|
||||
|
||||
case 'year':
|
||||
return offset > 0 ? addYears(date, offset) : subYears(date, Math.abs(offset));
|
||||
|
||||
case 'agenda':
|
||||
// Agenda moves by 7 days
|
||||
return offset > 0 ? addDays(date, offset * 7) : subDays(date, Math.abs(offset) * 7);
|
||||
|
||||
default:
|
||||
return offset > 0 ? addWeeks(date, offset) : subWeeks(date, Math.abs(offset));
|
||||
}
|
||||
}
|
||||
165
apps/calendar/apps/web/src/lib/utils/eventFiltering.ts
Normal file
165
apps/calendar/apps/web/src/lib/utils/eventFiltering.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
/**
|
||||
* Event Filtering Utilities
|
||||
* Reusable functions for filtering calendar events by visibility, time range, etc.
|
||||
*/
|
||||
|
||||
import type { CalendarEvent } from '@calendar/shared';
|
||||
import type { Calendar } from '@calendar/shared';
|
||||
import { toDate } from './eventDateHelpers';
|
||||
|
||||
/**
|
||||
* Create a Set of visible calendar IDs for efficient lookup
|
||||
*/
|
||||
export function getVisibleCalendarIds(visibleCalendars: Calendar[]): Set<string> {
|
||||
return new Set(visibleCalendars.map((c) => c.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter events to only include those from visible calendars
|
||||
*/
|
||||
export function filterByVisibleCalendars(
|
||||
events: CalendarEvent[],
|
||||
visibleCalendars: Calendar[]
|
||||
): CalendarEvent[] {
|
||||
const visibleIds = getVisibleCalendarIds(visibleCalendars);
|
||||
return events.filter((e) => visibleIds.has(e.calendarId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter events to only include timed (non-all-day) events
|
||||
*/
|
||||
export function filterTimedEvents(events: CalendarEvent[]): CalendarEvent[] {
|
||||
return events.filter((e) => !e.isAllDay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter events to only include all-day events
|
||||
*/
|
||||
export function filterAllDayEvents(events: CalendarEvent[]): CalendarEvent[] {
|
||||
return events.filter((e) => e.isAllDay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get event time in minutes from midnight
|
||||
*/
|
||||
export function getEventMinutes(event: CalendarEvent): { start: number; end: number } {
|
||||
const start = toDate(event.startTime);
|
||||
const end = toDate(event.endTime);
|
||||
return {
|
||||
start: start.getHours() * 60 + start.getMinutes(),
|
||||
end: end.getHours() * 60 + end.getMinutes(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event overlaps with a given time range (in minutes from midnight)
|
||||
*/
|
||||
export function eventOverlapsTimeRange(
|
||||
event: CalendarEvent,
|
||||
startMinutes: number,
|
||||
endMinutes: number
|
||||
): boolean {
|
||||
const { start: eventStart, end: eventEnd } = getEventMinutes(event);
|
||||
return eventStart < endMinutes && eventEnd > startMinutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter timed events that overlap with a visible hour range
|
||||
*/
|
||||
export function filterByHourRange(
|
||||
events: CalendarEvent[],
|
||||
dayStartHour: number,
|
||||
dayEndHour: number
|
||||
): CalendarEvent[] {
|
||||
const startMinutes = dayStartHour * 60;
|
||||
const endMinutes = dayEndHour * 60;
|
||||
return events.filter((event) => eventOverlapsTimeRange(event, startMinutes, endMinutes));
|
||||
}
|
||||
|
||||
/**
|
||||
* Result type for overflow events
|
||||
*/
|
||||
export interface OverflowEvents {
|
||||
before: CalendarEvent[];
|
||||
after: CalendarEvent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events that are outside the visible hour range
|
||||
* Returns events that end before the visible range starts (before)
|
||||
* and events that start after the visible range ends (after)
|
||||
*/
|
||||
export function getOverflowEvents(
|
||||
events: CalendarEvent[],
|
||||
dayStartHour: number,
|
||||
dayEndHour: number
|
||||
): OverflowEvents {
|
||||
const startMinutes = dayStartHour * 60;
|
||||
const endMinutes = dayEndHour * 60;
|
||||
|
||||
const before: CalendarEvent[] = [];
|
||||
const after: CalendarEvent[] = [];
|
||||
|
||||
for (const event of events) {
|
||||
const { start: eventStart, end: eventEnd } = getEventMinutes(event);
|
||||
|
||||
if (eventEnd <= startMinutes) {
|
||||
before.push(event);
|
||||
} else if (eventStart >= endMinutes) {
|
||||
after.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
return { before, after };
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined filter: Get visible timed events for a day with optional hour filtering
|
||||
*/
|
||||
export function getVisibleTimedEvents(
|
||||
events: CalendarEvent[],
|
||||
visibleCalendars: Calendar[],
|
||||
options?: {
|
||||
filterHoursEnabled?: boolean;
|
||||
dayStartHour?: number;
|
||||
dayEndHour?: number;
|
||||
}
|
||||
): CalendarEvent[] {
|
||||
let filtered = filterByVisibleCalendars(events, visibleCalendars);
|
||||
filtered = filterTimedEvents(filtered);
|
||||
|
||||
if (
|
||||
options?.filterHoursEnabled &&
|
||||
options.dayStartHour !== undefined &&
|
||||
options.dayEndHour !== undefined
|
||||
) {
|
||||
filtered = filterByHourRange(filtered, options.dayStartHour, options.dayEndHour);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined filter: Get visible all-day events for a day
|
||||
*/
|
||||
export function getVisibleAllDayEvents(
|
||||
events: CalendarEvent[],
|
||||
visibleCalendars: Calendar[]
|
||||
): CalendarEvent[] {
|
||||
let filtered = filterByVisibleCalendars(events, visibleCalendars);
|
||||
return filterAllDayEvents(filtered);
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined filter: Get overflow events for visible calendars
|
||||
*/
|
||||
export function getVisibleOverflowEvents(
|
||||
events: CalendarEvent[],
|
||||
visibleCalendars: Calendar[],
|
||||
dayStartHour: number,
|
||||
dayEndHour: number
|
||||
): OverflowEvents {
|
||||
let filtered = filterByVisibleCalendars(events, visibleCalendars);
|
||||
filtered = filterTimedEvents(filtered);
|
||||
return getOverflowEvents(filtered, dayStartHour, dayEndHour);
|
||||
}
|
||||
|
|
@ -3,7 +3,12 @@
|
|||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { PillNavigation, QuickInputBar } from '@manacore/shared-ui';
|
||||
import {
|
||||
PillNavigation,
|
||||
QuickInputBar,
|
||||
InputBarHelpModal,
|
||||
ImmersiveModeToggle,
|
||||
} from '@manacore/shared-ui';
|
||||
import {
|
||||
SplitPaneContainer,
|
||||
setSplitPanelContext,
|
||||
|
|
@ -14,6 +19,8 @@
|
|||
PillDropdownItem,
|
||||
QuickInputItem,
|
||||
CreatePreview,
|
||||
PillTabGroupConfig,
|
||||
PillNavElement,
|
||||
} from '@manacore/shared-ui';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
|
@ -23,6 +30,8 @@
|
|||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
import { eventTagsStore } from '$lib/stores/event-tags.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { birthdaysStore } from '$lib/stores/birthdays.svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import {
|
||||
THEME_DEFINITIONS,
|
||||
DEFAULT_THEME_VARIANTS,
|
||||
|
|
@ -50,6 +59,12 @@
|
|||
import CalendarToolbar from '$lib/components/calendar/CalendarToolbar.svelte';
|
||||
import CalendarToolbarContent from '$lib/components/calendar/CalendarToolbarContent.svelte';
|
||||
import DateStrip from '$lib/components/calendar/DateStrip.svelte';
|
||||
import DateStripFab from '$lib/components/calendar/DateStripFab.svelte';
|
||||
import TagStrip from '$lib/components/calendar/TagStrip.svelte';
|
||||
import EventContextMenu from '$lib/components/event/EventContextMenu.svelte';
|
||||
import ViewModePillContextMenu from '$lib/components/calendar/ViewModePillContextMenu.svelte';
|
||||
import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte';
|
||||
import type { CalendarViewType } from '@calendar/shared';
|
||||
|
||||
// App switcher items
|
||||
const appItems = getPillAppItems('calendar');
|
||||
|
|
@ -143,7 +158,52 @@
|
|||
|
||||
let isSidebarMode = $state(false);
|
||||
let isCollapsed = $state(false);
|
||||
let isToolbarCollapsed = $state(false);
|
||||
let isToolbarCollapsed = $state(true); // Default to collapsed - FAB next to InputBar
|
||||
|
||||
// Mobile detection for responsive layout
|
||||
let isMobile = $state(false);
|
||||
|
||||
function updateMobileState() {
|
||||
if (browser) {
|
||||
isMobile = window.innerWidth <= 640;
|
||||
}
|
||||
}
|
||||
|
||||
// InputBar help modal state
|
||||
let helpModalOpen = $state(false);
|
||||
let helpModalMode = $state<'shortcuts' | 'syntax'>('shortcuts');
|
||||
|
||||
function handleShowShortcuts() {
|
||||
helpModalMode = 'shortcuts';
|
||||
helpModalOpen = true;
|
||||
}
|
||||
|
||||
function handleShowSyntaxHelp() {
|
||||
helpModalMode = 'syntax';
|
||||
helpModalOpen = true;
|
||||
}
|
||||
|
||||
function handleCloseHelpModal() {
|
||||
helpModalOpen = false;
|
||||
}
|
||||
|
||||
// Default calendar for InputBar quick create
|
||||
let selectedDefaultCalendarId = $derived(
|
||||
calendarsStore.calendars.find((c) => c.isDefault)?.id || calendarsStore.calendars[0]?.id
|
||||
);
|
||||
|
||||
function handleDefaultCalendarChange(id: string) {
|
||||
// Update the default calendar via API
|
||||
calendarsStore.setAsDefault(id);
|
||||
}
|
||||
|
||||
// Calendar options for InputBar context menu
|
||||
let calendarOptions = $derived(
|
||||
calendarsStore.calendars.map((c) => ({
|
||||
id: c.id,
|
||||
label: c.name,
|
||||
}))
|
||||
);
|
||||
|
||||
// Use theme store's isDark directly
|
||||
let isDark = $derived(theme.isDark);
|
||||
|
|
@ -195,26 +255,143 @@
|
|||
// User email for user dropdown
|
||||
let userEmail = $derived(authStore.user?.email || 'Menü');
|
||||
|
||||
// Base navigation items for Calendar
|
||||
const baseNavItems: PillNavItem[] = [
|
||||
{ href: '/', label: 'Kalender', icon: 'calendar' },
|
||||
{ href: '/agenda', label: 'Agenda', icon: 'list' },
|
||||
{ href: '/tasks', label: 'Aufgaben', icon: 'check-square' },
|
||||
{ href: '/tags', label: 'Tags', icon: 'tag' },
|
||||
// Toggle TagStrip visibility
|
||||
function handleTagsToggle() {
|
||||
settingsStore.toggleTagStrip();
|
||||
}
|
||||
|
||||
// Tags button active state (show as active when TagStrip is visible)
|
||||
let isTagStripVisible = $derived(!settingsStore.tagStripCollapsed);
|
||||
|
||||
// Offset for elements above TagStrip (70px when visible)
|
||||
let tagStripOffset = $derived(showCalendarToolbar && !settingsStore.tagStripCollapsed ? 70 : 0);
|
||||
|
||||
// Base navigation items for Calendar (without Kalender/Aufgaben - handled by tab group)
|
||||
// Note: Tags uses onClick to toggle TagStrip visibility instead of navigating
|
||||
let baseNavItems = $derived<PillNavItem[]>([
|
||||
{
|
||||
href: '/tags',
|
||||
label: 'Tags',
|
||||
icon: 'tag',
|
||||
onClick: handleTagsToggle,
|
||||
active: isTagStripVisible,
|
||||
},
|
||||
{ href: '/statistics', label: 'Statistiken', icon: 'bar-chart-3' },
|
||||
{ href: '/network', label: 'Netzwerk', icon: 'share-2' },
|
||||
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
|
||||
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
|
||||
];
|
||||
]);
|
||||
|
||||
// Navigation items filtered by visibility settings
|
||||
const navItems = $derived(
|
||||
filterHiddenNavItems('calendar', baseNavItems, userSettings.nav.hiddenNavItems)
|
||||
);
|
||||
|
||||
// Navigation shortcuts (Ctrl+1-4) - use base items for consistent shortcuts
|
||||
const navRoutes = baseNavItems.map((item) => item.href);
|
||||
// Active tab based on sidebar state: 'tasks' when sidebar is open, 'calendar' when closed
|
||||
let activeTab = $derived(settingsStore.sidebarCollapsed ? 'calendar' : 'tasks');
|
||||
|
||||
// Tab group for Kalender/Aufgaben
|
||||
let calendarTasksTabGroup = $derived<PillTabGroupConfig>({
|
||||
type: 'tabs',
|
||||
options: [
|
||||
{ id: 'calendar', icon: 'calendar', label: 'Kalender', title: 'Kalender anzeigen' },
|
||||
{ id: 'tasks', icon: 'check-square', label: 'Aufgaben', title: 'Aufgaben-Sidebar öffnen' },
|
||||
],
|
||||
value: activeTab,
|
||||
onChange: handleTabChange,
|
||||
});
|
||||
|
||||
// View switcher context menu
|
||||
let viewContextMenu: ViewModePillContextMenu;
|
||||
|
||||
function handleViewContextMenu(x: number, y: number) {
|
||||
viewContextMenu?.show(x, y);
|
||||
}
|
||||
|
||||
// View labels for tabs (numbers for day views, letters for others)
|
||||
const viewLabels: Record<CalendarViewType, string> = {
|
||||
day: '1',
|
||||
'3day': '3',
|
||||
'5day': '5',
|
||||
week: '7',
|
||||
'10day': '10',
|
||||
'14day': '14',
|
||||
'30day': '30',
|
||||
'60day': '60',
|
||||
'90day': '90',
|
||||
'365day': '365',
|
||||
month: 'M',
|
||||
year: 'Y',
|
||||
agenda: 'L',
|
||||
custom: '', // Will be set dynamically
|
||||
};
|
||||
|
||||
// View titles for tooltips
|
||||
const viewTitles: Record<CalendarViewType, string> = {
|
||||
day: 'Tagesansicht',
|
||||
'3day': '3-Tage-Ansicht',
|
||||
'5day': '5-Tage-Ansicht',
|
||||
week: 'Wochenansicht',
|
||||
'10day': '10-Tage-Ansicht',
|
||||
'14day': '14-Tage-Ansicht',
|
||||
'30day': '30-Tage-Ansicht',
|
||||
'60day': '60-Tage-Ansicht',
|
||||
'90day': '90-Tage-Ansicht',
|
||||
'365day': '365-Tage-Ansicht',
|
||||
month: 'Monatsansicht',
|
||||
year: 'Jahresansicht',
|
||||
agenda: 'Agenda',
|
||||
custom: 'Benutzerdefiniert',
|
||||
};
|
||||
|
||||
// Get enabled views from settings
|
||||
let enabledViews = $derived(settingsStore.quickViewPillViews);
|
||||
|
||||
// Get label for a view (dynamic for custom)
|
||||
function getViewLabel(view: CalendarViewType): string {
|
||||
if (view === 'custom') {
|
||||
return String(settingsStore.customDayCount);
|
||||
}
|
||||
return viewLabels[view];
|
||||
}
|
||||
|
||||
// View switcher tab group (only shown on calendar main page)
|
||||
let viewSwitcherTabGroup = $derived<PillTabGroupConfig>({
|
||||
type: 'tabs',
|
||||
options: enabledViews.map((view) => ({
|
||||
id: view,
|
||||
label: getViewLabel(view),
|
||||
title: view === 'custom' ? `${settingsStore.customDayCount}-Tage-Ansicht` : viewTitles[view],
|
||||
})),
|
||||
value: viewStore.viewType,
|
||||
onChange: (id) => viewStore.setViewType(id as CalendarViewType),
|
||||
onContextMenu: handleViewContextMenu,
|
||||
});
|
||||
|
||||
// Prepended elements (tab groups at the start of navigation)
|
||||
let prependElements = $derived<PillNavElement[]>(
|
||||
showCalendarToolbar ? [calendarTasksTabGroup, viewSwitcherTabGroup] : [calendarTasksTabGroup]
|
||||
);
|
||||
|
||||
// Handle tab change: toggle sidebar for tasks, close for calendar
|
||||
function handleTabChange(tabId: string) {
|
||||
// Always navigate to main calendar page if not there
|
||||
if ($page.url.pathname !== '/') {
|
||||
goto('/');
|
||||
}
|
||||
|
||||
if (tabId === 'tasks') {
|
||||
// Toggle behavior: if sidebar is already open, close it
|
||||
settingsStore.toggleSidebar();
|
||||
} else if (tabId === 'calendar') {
|
||||
// Kalender-Tab: close sidebar if open
|
||||
if (!settingsStore.sidebarCollapsed) {
|
||||
settingsStore.toggleSidebar();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation shortcuts (Ctrl+1 = Kalender, Ctrl+2 = Aufgaben toggle, Ctrl+3+ = other nav items)
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
|
|
@ -224,14 +401,71 @@
|
|||
|
||||
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey) {
|
||||
const num = parseInt(event.key);
|
||||
if (num >= 1 && num <= navRoutes.length) {
|
||||
if (num === 1) {
|
||||
// Ctrl+1: Kalender (close sidebar)
|
||||
event.preventDefault();
|
||||
const route = navRoutes[num - 1];
|
||||
handleTabChange('calendar');
|
||||
} else if (num === 2) {
|
||||
// Ctrl+2: Aufgaben (toggle sidebar)
|
||||
event.preventDefault();
|
||||
handleTabChange('tasks');
|
||||
} else if (num >= 3 && num <= baseNavItems.length + 2) {
|
||||
// Ctrl+3+: other nav items (offset by 2 for the tab group)
|
||||
event.preventDefault();
|
||||
const route = baseNavItems[num - 3]?.href;
|
||||
if (route) {
|
||||
goto(route);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// F = Toggle Immersive Mode (no modifier keys)
|
||||
if (
|
||||
(event.key === 'f' || event.key === 'F') &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.shiftKey &&
|
||||
!event.altKey
|
||||
) {
|
||||
event.preventDefault();
|
||||
settingsStore.toggleImmersiveMode();
|
||||
}
|
||||
|
||||
// Arrow keys for calendar navigation (only on main calendar page, no modifiers)
|
||||
if (
|
||||
showCalendarToolbar &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.shiftKey &&
|
||||
!event.altKey
|
||||
) {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
viewStore.goToPrevious();
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
viewStore.goToNext();
|
||||
} else if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||
// Scroll calendar view up/down - scroll to top/bottom
|
||||
const scrollContainer = document.querySelector('.carousel-page.current');
|
||||
if (scrollContainer) {
|
||||
event.preventDefault();
|
||||
if (event.key === 'ArrowDown') {
|
||||
// Scroll to bottom
|
||||
scrollContainer.scrollTo({
|
||||
top: scrollContainer.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
} else {
|
||||
// Scroll to top
|
||||
scrollContainer.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleModeChange(isSidebar: boolean) {
|
||||
|
|
@ -276,6 +510,18 @@
|
|||
goto('/login');
|
||||
}
|
||||
|
||||
// Context menu edit handler - navigate to event
|
||||
function handleContextMenuEdit(event: { id: string }) {
|
||||
goto(`/?event=${event.id}`);
|
||||
}
|
||||
|
||||
// Reactive effect: load birthdays when setting is enabled
|
||||
$effect(() => {
|
||||
if (browser && settingsStore.showBirthdays && authStore.isAuthenticated) {
|
||||
birthdaysStore.fetchBirthdays();
|
||||
}
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
// Redirect to login if not authenticated
|
||||
if (!authStore.isAuthenticated) {
|
||||
|
|
@ -294,6 +540,8 @@
|
|||
await eventTagsStore.fetchTags();
|
||||
await userSettings.load();
|
||||
|
||||
// Note: Birthdays are loaded via reactive $effect when showBirthdays is enabled
|
||||
|
||||
// Redirect to start page if on root and a custom start page is set
|
||||
const currentPath = window.location.pathname;
|
||||
if (currentPath === '/' && userSettings.startPage && userSettings.startPage !== '/') {
|
||||
|
|
@ -314,119 +562,185 @@
|
|||
collapsedStore.set(true);
|
||||
}
|
||||
|
||||
// Initialize toolbar collapsed state from localStorage
|
||||
// Initialize toolbar collapsed state from localStorage (default is now collapsed)
|
||||
const savedToolbarCollapsed = localStorage.getItem('calendar-toolbar-collapsed');
|
||||
if (savedToolbarCollapsed === 'true') {
|
||||
isToolbarCollapsed = true;
|
||||
toolbarCollapsedStore.set(true);
|
||||
if (savedToolbarCollapsed === 'false') {
|
||||
isToolbarCollapsed = false;
|
||||
toolbarCollapsedStore.set(false);
|
||||
}
|
||||
|
||||
// Initialize mobile state
|
||||
updateMobileState();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
<svelte:window onkeydown={handleKeydown} onresize={updateMobileState} />
|
||||
|
||||
<SplitPaneContainer>
|
||||
<div class="layout-container">
|
||||
<PillNavigation
|
||||
items={navItems}
|
||||
currentPath={$page.url.pathname}
|
||||
appName="Kalender"
|
||||
homeRoute="/"
|
||||
onToggleTheme={handleToggleTheme}
|
||||
{isDark}
|
||||
{isSidebarMode}
|
||||
onModeChange={handleModeChange}
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
desktopPosition="bottom"
|
||||
showThemeToggle={true}
|
||||
showThemeVariants={true}
|
||||
{themeVariantItems}
|
||||
{currentThemeVariantLabel}
|
||||
themeMode={theme.mode}
|
||||
onThemeModeChange={handleThemeModeChange}
|
||||
showLanguageSwitcher={true}
|
||||
{languageItems}
|
||||
{currentLanguageLabel}
|
||||
showLogout={authStore.isAuthenticated}
|
||||
onLogout={handleLogout}
|
||||
loginHref="/login"
|
||||
primaryColor="#3b82f6"
|
||||
showAppSwitcher={true}
|
||||
{appItems}
|
||||
{userEmail}
|
||||
settingsHref="/settings"
|
||||
manaHref="/mana"
|
||||
profileHref="/profile"
|
||||
allAppsHref="/apps"
|
||||
onOpenInPanel={handleOpenInPanel}
|
||||
>
|
||||
{#snippet toolbarContent()}
|
||||
{#if showCalendarToolbar}
|
||||
<CalendarToolbarContent vertical={true} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</PillNavigation>
|
||||
|
||||
<!-- Date strip (only on main calendar page) -->
|
||||
{#if showCalendarToolbar}
|
||||
<DateStrip {isSidebarMode} />
|
||||
{/if}
|
||||
|
||||
<!-- Calendar toolbar (only on main calendar page, not in sidebar mode) -->
|
||||
{#if showCalendarToolbar && !isSidebarMode}
|
||||
<CalendarToolbar
|
||||
<!-- UI Elements (hidden in immersive mode) -->
|
||||
{#if !settingsStore.immersiveModeEnabled}
|
||||
<PillNavigation
|
||||
items={navItems}
|
||||
{prependElements}
|
||||
currentPath={$page.url.pathname}
|
||||
appName="Kalender"
|
||||
homeRoute="/"
|
||||
onToggleTheme={handleToggleTheme}
|
||||
{isDark}
|
||||
{isSidebarMode}
|
||||
isCollapsed={isToolbarCollapsed}
|
||||
onModeChange={handleToolbarModeChange}
|
||||
onCollapsedChange={handleToolbarCollapsedChange}
|
||||
/>
|
||||
onModeChange={handleModeChange}
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
desktopPosition="bottom"
|
||||
showThemeToggle={true}
|
||||
showThemeVariants={true}
|
||||
{themeVariantItems}
|
||||
{currentThemeVariantLabel}
|
||||
themeMode={theme.mode}
|
||||
onThemeModeChange={handleThemeModeChange}
|
||||
showLanguageSwitcher={true}
|
||||
{languageItems}
|
||||
{currentLanguageLabel}
|
||||
showLogout={authStore.isAuthenticated}
|
||||
onLogout={handleLogout}
|
||||
loginHref="/login"
|
||||
primaryColor="#3b82f6"
|
||||
showAppSwitcher={true}
|
||||
{appItems}
|
||||
{userEmail}
|
||||
settingsHref="/settings"
|
||||
manaHref="/mana"
|
||||
profileHref="/profile"
|
||||
allAppsHref="/apps"
|
||||
onOpenInPanel={handleOpenInPanel}
|
||||
>
|
||||
{#snippet toolbarContent()}
|
||||
{#if showCalendarToolbar}
|
||||
<CalendarToolbarContent vertical={true} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</PillNavigation>
|
||||
|
||||
<!-- Date strip (only on main calendar page) -->
|
||||
{#if showCalendarToolbar}
|
||||
{#if settingsStore.dateStripCollapsed}
|
||||
<DateStripFab
|
||||
{isSidebarMode}
|
||||
isToolbarExpanded={!isToolbarCollapsed}
|
||||
{isMobile}
|
||||
hasTagStrip={!settingsStore.tagStripCollapsed}
|
||||
/>
|
||||
{:else}
|
||||
<DateStrip
|
||||
{isSidebarMode}
|
||||
isToolbarExpanded={!isToolbarCollapsed}
|
||||
hasTagStrip={!settingsStore.tagStripCollapsed}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Tag strip (only on main calendar page, when not collapsed) - directly above PillNav -->
|
||||
{#if showCalendarToolbar && !settingsStore.tagStripCollapsed}
|
||||
<TagStrip {isSidebarMode} />
|
||||
{/if}
|
||||
|
||||
<!-- Calendar toolbar (only on main calendar page, not in sidebar mode) -->
|
||||
{#if showCalendarToolbar && !isSidebarMode}
|
||||
<CalendarToolbar
|
||||
{isSidebarMode}
|
||||
isCollapsed={isToolbarCollapsed}
|
||||
{isMobile}
|
||||
bottomOffset={settingsStore.tagStripCollapsed ? '70px' : '140px'}
|
||||
onModeChange={handleToolbarModeChange}
|
||||
onCollapsedChange={handleToolbarCollapsedChange}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- 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}
|
||||
onSearchChange={handleSearchChange}
|
||||
placeholder="Neuer Termin oder suchen..."
|
||||
emptyText="Keine Termine gefunden"
|
||||
searchingText="Suche..."
|
||||
onCreate={handleCreate}
|
||||
onParseCreate={handleParseCreate}
|
||||
createText="Erstellen"
|
||||
appIcon="calendar"
|
||||
bottomOffset={isMobile
|
||||
? `${70 + tagStripOffset}px`
|
||||
: isSidebarMode
|
||||
? `${tagStripOffset}px`
|
||||
: showCalendarToolbar && !isToolbarCollapsed
|
||||
? `${140 + tagStripOffset}px`
|
||||
: `${70 + tagStripOffset}px`}
|
||||
hasFabRight={showCalendarToolbar && !isSidebarMode}
|
||||
hasFabLeft={!isMobile &&
|
||||
showCalendarToolbar &&
|
||||
!isSidebarMode &&
|
||||
settingsStore.dateStripCollapsed}
|
||||
defaultOptions={calendarOptions}
|
||||
selectedDefaultId={selectedDefaultCalendarId}
|
||||
defaultOptionLabel="Standard-Kalender"
|
||||
onDefaultChange={handleDefaultCalendarChange}
|
||||
onShowShortcuts={handleShowShortcuts}
|
||||
onShowSyntaxHelp={handleShowSyntaxHelp}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Immersive Mode Toggle (always visible on main calendar page) -->
|
||||
<ImmersiveModeToggle
|
||||
isImmersive={settingsStore.immersiveModeEnabled}
|
||||
onToggle={() => settingsStore.toggleImmersiveMode()}
|
||||
visible={showCalendarToolbar}
|
||||
/>
|
||||
|
||||
<main
|
||||
class="main-content bg-background"
|
||||
class:sidebar-mode={isSidebarMode && !isCollapsed}
|
||||
class:floating-mode={!isSidebarMode && !isCollapsed}
|
||||
class:has-toolbar={showCalendarToolbar}
|
||||
class:immersive={settingsStore.immersiveModeEnabled}
|
||||
>
|
||||
<div
|
||||
class="content-wrapper"
|
||||
class:calendar-expanded={settingsStore.sidebarCollapsed && $page.url.pathname === '/'}
|
||||
class:immersive={settingsStore.immersiveModeEnabled}
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Global Input Bar -->
|
||||
<QuickInputBar
|
||||
onSearch={handleSearch}
|
||||
onSelect={handleSelect}
|
||||
onSearchChange={handleSearchChange}
|
||||
placeholder="Neuer Termin oder suchen..."
|
||||
emptyText="Keine Termine gefunden"
|
||||
searchingText="Suche..."
|
||||
onCreate={handleCreate}
|
||||
onParseCreate={handleParseCreate}
|
||||
createText="Erstellen"
|
||||
appIcon="calendar"
|
||||
primaryColor="#3b82f6"
|
||||
autoFocus={true}
|
||||
bottomOffset={showCalendarToolbar
|
||||
? isSidebarMode
|
||||
? '0px'
|
||||
: '130px'
|
||||
: isSidebarMode
|
||||
? '0px'
|
||||
: '70px'}
|
||||
/>
|
||||
</div>
|
||||
</SplitPaneContainer>
|
||||
|
||||
<!-- Global Event Context Menu - rendered at top level for proper z-index -->
|
||||
<EventContextMenu onEdit={handleContextMenuEdit} />
|
||||
|
||||
<!-- View Mode Context Menu -->
|
||||
<ViewModePillContextMenu bind:this={viewContextMenu} />
|
||||
|
||||
<!-- InputBar Help Modal -->
|
||||
<InputBarHelpModal open={helpModalOpen} onClose={handleCloseHelpModal} mode={helpModalMode} />
|
||||
|
||||
<style>
|
||||
.layout-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Mobile: Fixed viewport, no scroll */
|
||||
@media (max-width: 768px) {
|
||||
.layout-container {
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.main-content {
|
||||
|
|
@ -434,32 +748,53 @@
|
|||
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 {
|
||||
padding-top: 70px;
|
||||
}
|
||||
|
||||
/* Extra padding when DateStrip + Toolbar are at bottom */
|
||||
/* Extra padding when DateStrip is at bottom (toolbar is now a FAB) */
|
||||
.main-content.floating-mode.has-toolbar {
|
||||
padding-top: 0;
|
||||
padding-bottom: calc(
|
||||
280px + env(safe-area-inset-bottom)
|
||||
); /* DateStrip + Toolbar + PillNav + QuickInputBar */
|
||||
220px + env(safe-area-inset-bottom)
|
||||
); /* DateStrip + PillNav + QuickInputBar */
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* On mobile, toolbars are at bottom, extra padding at bottom instead */
|
||||
/* On mobile, fixed height layout - no page scroll */
|
||||
.main-content {
|
||||
padding-bottom: calc(150px + env(safe-area-inset-bottom)); /* PillNav + QuickInputBar */
|
||||
height: calc(100vh - 70px); /* Full height minus bottom nav */
|
||||
overflow: hidden;
|
||||
padding-bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.main-content.has-toolbar {
|
||||
padding-bottom: calc(
|
||||
250px + env(safe-area-inset-bottom)
|
||||
); /* DateStrip + Toolbar + BottomNav + QuickInputBar */
|
||||
height: calc(100vh - 70px);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.main-content.floating-mode.has-toolbar {
|
||||
padding-top: 70px;
|
||||
.main-content.floating-mode {
|
||||
padding-top: 0; /* No top padding on mobile - everything is at bottom */
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile: Fixed height, internal scrolling only */
|
||||
@media (max-width: 640px) {
|
||||
.main-content {
|
||||
height: calc(100vh - 70px);
|
||||
overflow: hidden;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.main-content.has-toolbar {
|
||||
height: calc(100vh - 70px);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -474,6 +809,22 @@
|
|||
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 */
|
||||
@media (max-width: 768px) {
|
||||
.content-wrapper {
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
|
|
@ -496,5 +847,66 @@
|
|||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Immersive Mode - fullscreen, no UI elements visible */
|
||||
.main-content.immersive {
|
||||
padding: 0 !important;
|
||||
height: 100vh !important;
|
||||
width: 100vw;
|
||||
transition: padding 300ms ease;
|
||||
}
|
||||
|
||||
.content-wrapper.immersive {
|
||||
padding: 0 !important;
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
/* Adjust InputBar when FABs are visible (toolbar FAB on right, DateStripFab on left) */
|
||||
/* For a centered InputBar with max-width 450px, left edge is at 50% - 225px */
|
||||
/* DateStripFab is positioned at: 50% - 225px - 8px gap - 54px fab width */
|
||||
/* Note: In sidebar mode, InputBar uses default 700px max-width */
|
||||
:global(.quick-input-bar.has-fab-right .input-container),
|
||||
:global(.quick-input-bar.has-fab-left .input-container) {
|
||||
max-width: 450px;
|
||||
}
|
||||
|
||||
/* On smaller screens (<900px), FABs move to fixed positions (left: 1rem, right: 1rem) */
|
||||
@media (max-width: 900px) {
|
||||
:global(.quick-input-bar.has-fab-right .input-container) {
|
||||
max-width: calc(100% - 140px); /* 54px FAB + padding */
|
||||
margin-left: auto;
|
||||
margin-right: 0;
|
||||
}
|
||||
:global(.quick-input-bar.has-fab-left .input-container) {
|
||||
max-width: calc(100% - 140px); /* 54px FAB + padding */
|
||||
margin-left: 0;
|
||||
margin-right: auto;
|
||||
}
|
||||
:global(.quick-input-bar.has-fab-right.has-fab-left .input-container) {
|
||||
max-width: calc(100% - 200px); /* Both FABs */
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile: InputBar in its own row (above PillNav), Settings FAB stays next to InputBar */
|
||||
@media (max-width: 640px) {
|
||||
/* InputBar takes all available space up to the FAB */
|
||||
:global(.quick-input-bar.has-fab-right .input-container) {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:global(.quick-input-bar.has-fab-right) {
|
||||
padding-left: 1rem;
|
||||
padding-right: calc(54px + 1rem + 8px); /* FAB width + margin + gap */
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -7,12 +7,7 @@
|
|||
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 WeekView from '$lib/components/calendar/WeekView.svelte';
|
||||
import DayView from '$lib/components/calendar/DayView.svelte';
|
||||
import MonthView from '$lib/components/calendar/MonthView.svelte';
|
||||
import MultiDayView from '$lib/components/calendar/MultiDayView.svelte';
|
||||
import YearView from '$lib/components/calendar/YearView.svelte';
|
||||
import ViewCarousel from '$lib/components/calendar/ViewCarousel.svelte';
|
||||
import TodoSidebarSection from '$lib/components/calendar/TodoSidebarSection.svelte';
|
||||
import QuickEventOverlay from '$lib/components/event/QuickEventOverlay.svelte';
|
||||
import { CalendarViewSkeleton } from '$lib/components/skeletons';
|
||||
|
|
@ -103,8 +98,8 @@
|
|||
</svelte:head>
|
||||
|
||||
<div class="calendar-layout">
|
||||
<!-- Left Sidebar -->
|
||||
<aside class="calendar-sidebar" class:collapsed={settingsStore.sidebarCollapsed}>
|
||||
<!-- Desktop: Left Sidebar -->
|
||||
<aside class="calendar-sidebar desktop-only" class:collapsed={settingsStore.sidebarCollapsed}>
|
||||
<!-- Collapse button at top -->
|
||||
<button
|
||||
class="sidebar-collapse-btn"
|
||||
|
|
@ -124,63 +119,25 @@
|
|||
<TodoSidebarSection maxItems={5} />
|
||||
</aside>
|
||||
|
||||
<!-- FAB when sidebar is collapsed -->
|
||||
{#if settingsStore.sidebarCollapsed}
|
||||
<div class="sidebar-fab" 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">
|
||||
{#if !initialized}
|
||||
<CalendarViewSkeleton />
|
||||
{:else if viewStore.viewType === 'day'}
|
||||
<DayView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
|
||||
{:else if viewStore.viewType === '5day'}
|
||||
<MultiDayView
|
||||
dayCount={5}
|
||||
onQuickCreate={handleQuickCreate}
|
||||
onEventClick={handleEventClick}
|
||||
/>
|
||||
{:else if viewStore.viewType === 'week'}
|
||||
<WeekView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
|
||||
{:else if viewStore.viewType === '10day'}
|
||||
<MultiDayView
|
||||
dayCount={10}
|
||||
onQuickCreate={handleQuickCreate}
|
||||
onEventClick={handleEventClick}
|
||||
/>
|
||||
{:else if viewStore.viewType === '14day'}
|
||||
<MultiDayView
|
||||
dayCount={14}
|
||||
onQuickCreate={handleQuickCreate}
|
||||
onEventClick={handleEventClick}
|
||||
/>
|
||||
{:else if viewStore.viewType === 'month'}
|
||||
<MonthView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
|
||||
{:else if viewStore.viewType === 'year'}
|
||||
<YearView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
|
||||
{:else}
|
||||
<WeekView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
|
||||
<ViewCarousel onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: Bottom Todo Section -->
|
||||
<aside
|
||||
class="calendar-sidebar-mobile mobile-only"
|
||||
class:collapsed={settingsStore.sidebarCollapsed}
|
||||
>
|
||||
<TodoSidebarSection maxItems={3} />
|
||||
</aside>
|
||||
|
||||
<!-- Quick Event Overlay (for both create and edit) -->
|
||||
{#if showQuickOverlay}
|
||||
{#key overlayKey}
|
||||
|
|
@ -201,13 +158,24 @@
|
|||
display: flex;
|
||||
gap: 1.5rem;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Desktop only elements */
|
||||
.desktop-only {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Mobile only elements - hidden by default */
|
||||
.mobile-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.calendar-sidebar {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
position: relative;
|
||||
|
|
@ -251,60 +219,13 @@
|
|||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
/* FAB container */
|
||||
.sidebar-fab {
|
||||
position: fixed;
|
||||
left: 1rem;
|
||||
bottom: 1rem;
|
||||
display: flex;
|
||||
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));
|
||||
|
|
@ -318,15 +239,109 @@
|
|||
|
||||
.calendar-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.calendar-sidebar {
|
||||
display: none;
|
||||
/* Mobile: Bottom Todo Section */
|
||||
.calendar-sidebar-mobile {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
background: hsl(var(--color-surface));
|
||||
border-top: 1px solid hsl(var(--color-border));
|
||||
padding: 0.75rem;
|
||||
overflow-y: auto;
|
||||
transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.calendar-sidebar-mobile.collapsed {
|
||||
height: 0;
|
||||
flex: 0;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Mobile Layout - 50/50 Splitscreen */
|
||||
@media (max-width: 768px) {
|
||||
.calendar-layout {
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
flex: 1;
|
||||
height: 100%; /* Fill parent container */
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-fab {
|
||||
/* Hide desktop elements on mobile */
|
||||
.desktop-only {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Show mobile elements */
|
||||
.mobile-only {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Calendar container */
|
||||
.calendar-main {
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* When todos are visible: 50/50 split */
|
||||
.calendar-layout:has(.calendar-sidebar-mobile:not(.collapsed)) .calendar-main {
|
||||
flex: 0 0 50%;
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
/* When todos are collapsed: calendar takes full space */
|
||||
.calendar-layout:has(.calendar-sidebar-mobile.collapsed) .calendar-main {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Calendar content must scroll internally */
|
||||
.calendar-content {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Todos section takes other half */
|
||||
.calendar-sidebar-mobile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 0 0 50%;
|
||||
height: 50%;
|
||||
max-height: none;
|
||||
border-radius: 0;
|
||||
margin-bottom: 0;
|
||||
padding: 0;
|
||||
border-top: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Make TodoSidebarSection fill the container */
|
||||
.calendar-sidebar-mobile > :global(*) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.calendar-sidebar-mobile.collapsed {
|
||||
flex: 0;
|
||||
height: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet: Keep desktop layout but smaller sidebar */
|
||||
@media (min-width: 769px) and (max-width: 1024px) {
|
||||
.calendar-sidebar {
|
||||
width: 220px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,238 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
import { format, parseISO, isToday, isTomorrow, addDays, startOfDay, endOfDay } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import { AgendaSkeleton } from '$lib/components/skeletons';
|
||||
|
||||
let loading = $state(true);
|
||||
|
||||
// Group events by date
|
||||
let groupedEvents = $derived.by(() => {
|
||||
// Safety check: ensure events is an array
|
||||
const currentEvents = eventsStore.events ?? [];
|
||||
if (!Array.isArray(currentEvents)) return [];
|
||||
|
||||
const groups: Map<string, typeof currentEvents> = new Map();
|
||||
|
||||
for (const event of currentEvents) {
|
||||
const start =
|
||||
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const dateKey = format(start, 'yyyy-MM-dd');
|
||||
|
||||
if (!groups.has(dateKey)) {
|
||||
groups.set(dateKey, []);
|
||||
}
|
||||
groups.get(dateKey)!.push(event);
|
||||
}
|
||||
|
||||
// Sort groups by date
|
||||
return Array.from(groups.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([dateKey, events]) => ({
|
||||
date: parseISO(dateKey),
|
||||
events: events.sort((a, b) => {
|
||||
const aStart = typeof a.startTime === 'string' ? parseISO(a.startTime) : a.startTime;
|
||||
const bStart = typeof b.startTime === 'string' ? parseISO(b.startTime) : b.startTime;
|
||||
return aStart.getTime() - bStart.getTime();
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
if (!authStore.isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch events for next 30 days
|
||||
const start = startOfDay(new Date());
|
||||
const end = endOfDay(addDays(start, 30));
|
||||
await eventsStore.fetchEvents(start, end);
|
||||
loading = false;
|
||||
});
|
||||
|
||||
function formatDateHeader(date: Date) {
|
||||
if (isToday(date)) {
|
||||
return 'Heute';
|
||||
}
|
||||
if (isTomorrow(date)) {
|
||||
return 'Morgen';
|
||||
}
|
||||
return format(date, 'EEEE, d. MMMM', { locale: de });
|
||||
}
|
||||
|
||||
function handleEventClick(eventId: string) {
|
||||
// Navigate to calendar with event modal
|
||||
goto(`/?event=${eventId}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Agenda | Kalender</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="agenda-page">
|
||||
<header class="page-header">
|
||||
<h1>Agenda</h1>
|
||||
<p class="subtitle">Ihre kommenden Termine</p>
|
||||
</header>
|
||||
|
||||
{#if loading}
|
||||
<AgendaSkeleton />
|
||||
{:else if groupedEvents.length === 0}
|
||||
<div class="empty-state card">
|
||||
<p>Keine Termine in den nächsten 30 Tagen</p>
|
||||
<button class="btn btn-primary" onclick={() => goto('/event/new')}> Termin erstellen </button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="event-list">
|
||||
{#each groupedEvents as group}
|
||||
<div class="date-group">
|
||||
<h2 class="date-header" class:today={isToday(group.date)}>
|
||||
{formatDateHeader(group.date)}
|
||||
</h2>
|
||||
|
||||
{#each group.events as event}
|
||||
<button class="event-item card" onclick={() => handleEventClick(event.id)}>
|
||||
<div
|
||||
class="color-bar"
|
||||
style="background-color: {calendarsStore.getColor(event.calendarId)}"
|
||||
></div>
|
||||
<div class="event-content">
|
||||
<div class="event-time">
|
||||
{#if event.isAllDay}
|
||||
Ganztägig
|
||||
{:else}
|
||||
{format(
|
||||
typeof event.startTime === 'string'
|
||||
? parseISO(event.startTime)
|
||||
: event.startTime,
|
||||
'HH:mm'
|
||||
)} -
|
||||
{format(
|
||||
typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime,
|
||||
'HH:mm'
|
||||
)}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="event-title">{event.title}</div>
|
||||
{#if event.location}
|
||||
<div class="event-location">{event.location}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.agenda-page {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--color-foreground));
|
||||
margin: 0 0 0.25rem 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.event-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.date-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.date-header {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.date-header.today {
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.event-item {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.event-item:hover {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.color-bar {
|
||||
width: 4px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.event-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.event-time {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.event-title {
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-foreground));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.event-location {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { SubscriptionPage } from '@manacore/shared-subscription-ui';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
import { toast } from '$lib/stores/toast.svelte';
|
||||
|
||||
function handleSubscribe(planId: string) {
|
||||
console.log('Subscribe to plan:', planId);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
|
|
@ -1,486 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
import { todosStore } from '$lib/stores/todos.svelte';
|
||||
import type { Task } from '$lib/api/todos';
|
||||
import type { CalendarEvent } from '@calendar/shared';
|
||||
import AgendaItem from '$lib/components/agenda/AgendaItem.svelte';
|
||||
import AgendaFilters from '$lib/components/agenda/AgendaFilters.svelte';
|
||||
import TodoDetailModal from '$lib/components/todo/TodoDetailModal.svelte';
|
||||
import QuickAddTodo from '$lib/components/todo/QuickAddTodo.svelte';
|
||||
import { AgendaSkeleton } from '$lib/components/skeletons';
|
||||
import {
|
||||
format,
|
||||
parseISO,
|
||||
isToday,
|
||||
isTomorrow,
|
||||
addDays,
|
||||
startOfDay,
|
||||
endOfDay,
|
||||
isBefore,
|
||||
} from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import { CheckSquare, AlertTriangle, Plus } from 'lucide-svelte';
|
||||
|
||||
// State
|
||||
let loading = $state(true);
|
||||
let showEvents = $state(true);
|
||||
let showTodos = $state(true);
|
||||
let timeRange = $state<'7' | '30' | 'all'>('30');
|
||||
let selectedTask = $state<Task | null>(null);
|
||||
let showQuickAdd = $state(false);
|
||||
|
||||
// Combined and grouped items
|
||||
type AgendaGroup = {
|
||||
date: Date;
|
||||
items: Array<{ type: 'event' | 'todo'; event?: CalendarEvent; todo?: Task }>;
|
||||
};
|
||||
|
||||
let groupedItems = $derived.by(() => {
|
||||
const groups = new Map<string, AgendaGroup['items']>();
|
||||
const today = startOfDay(new Date());
|
||||
|
||||
// Add events
|
||||
if (showEvents) {
|
||||
const currentEvents = eventsStore.events ?? [];
|
||||
if (Array.isArray(currentEvents)) {
|
||||
for (const event of currentEvents) {
|
||||
const start =
|
||||
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
|
||||
const dateKey = format(start, 'yyyy-MM-dd');
|
||||
|
||||
if (!groups.has(dateKey)) {
|
||||
groups.set(dateKey, []);
|
||||
}
|
||||
groups.get(dateKey)!.push({ type: 'event', event });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add todos
|
||||
if (showTodos) {
|
||||
const currentTodos = todosStore.todos ?? [];
|
||||
if (Array.isArray(currentTodos)) {
|
||||
for (const todo of currentTodos) {
|
||||
if (todo.isCompleted) continue; // Skip completed todos
|
||||
|
||||
let dateKey: string;
|
||||
if (todo.dueDate) {
|
||||
const dueDate =
|
||||
typeof todo.dueDate === 'string' ? parseISO(todo.dueDate) : todo.dueDate;
|
||||
// Group overdue todos under today
|
||||
if (isBefore(startOfDay(dueDate), today)) {
|
||||
dateKey = format(today, 'yyyy-MM-dd');
|
||||
} else {
|
||||
dateKey = format(dueDate, 'yyyy-MM-dd');
|
||||
}
|
||||
} else {
|
||||
// Todos without due date go under today
|
||||
dateKey = format(today, 'yyyy-MM-dd');
|
||||
}
|
||||
|
||||
if (!groups.has(dateKey)) {
|
||||
groups.set(dateKey, []);
|
||||
}
|
||||
groups.get(dateKey)!.push({ type: 'todo', todo });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort groups by date and items within each group
|
||||
return Array.from(groups.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([dateKey, items]) => ({
|
||||
date: parseISO(dateKey),
|
||||
items: items.sort((a, b) => {
|
||||
// Todos before events
|
||||
if (a.type !== b.type) return a.type === 'todo' ? -1 : 1;
|
||||
|
||||
// Sort events by time
|
||||
if (a.type === 'event' && b.type === 'event' && a.event && b.event) {
|
||||
const aStart =
|
||||
typeof a.event.startTime === 'string'
|
||||
? parseISO(a.event.startTime)
|
||||
: a.event.startTime;
|
||||
const bStart =
|
||||
typeof b.event.startTime === 'string'
|
||||
? parseISO(b.event.startTime)
|
||||
: b.event.startTime;
|
||||
return aStart.getTime() - bStart.getTime();
|
||||
}
|
||||
|
||||
// Sort todos by priority
|
||||
if (a.type === 'todo' && b.type === 'todo' && a.todo && b.todo) {
|
||||
const priorityOrder = { urgent: 0, high: 1, medium: 2, low: 3 };
|
||||
return priorityOrder[a.todo.priority] - priorityOrder[b.todo.priority];
|
||||
}
|
||||
|
||||
return 0;
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
// Stats
|
||||
const overdueCount = $derived(todosStore.overdueTodos.length);
|
||||
const todayCount = $derived(todosStore.todaysTodos.length);
|
||||
const totalActiveCount = $derived(todosStore.activeTodosCount);
|
||||
|
||||
onMount(async () => {
|
||||
if (!authStore.isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch data based on time range
|
||||
await fetchData();
|
||||
loading = false;
|
||||
});
|
||||
|
||||
async function fetchData() {
|
||||
const start = startOfDay(new Date());
|
||||
const days = timeRange === '7' ? 7 : timeRange === '30' ? 30 : 90;
|
||||
const end = endOfDay(addDays(start, days));
|
||||
|
||||
await Promise.all([
|
||||
eventsStore.fetchEvents(start, end),
|
||||
todosStore.fetchTodos(start, end),
|
||||
todosStore.fetchTodayTodos(),
|
||||
]);
|
||||
}
|
||||
|
||||
function formatDateHeader(date: Date) {
|
||||
if (isToday(date)) {
|
||||
return 'Heute';
|
||||
}
|
||||
if (isTomorrow(date)) {
|
||||
return 'Morgen';
|
||||
}
|
||||
return format(date, 'EEEE, d. MMMM', { locale: de });
|
||||
}
|
||||
|
||||
function handleEventClick(eventId: string) {
|
||||
goto(`/?event=${eventId}`);
|
||||
}
|
||||
|
||||
function handleTodoClick(task: Task) {
|
||||
selectedTask = task;
|
||||
}
|
||||
|
||||
function handleModalClose() {
|
||||
selectedTask = null;
|
||||
}
|
||||
|
||||
function toggleEvents() {
|
||||
showEvents = !showEvents;
|
||||
}
|
||||
|
||||
function toggleTodos() {
|
||||
showTodos = !showTodos;
|
||||
}
|
||||
|
||||
function handleRangeChange(range: '7' | '30' | 'all') {
|
||||
timeRange = range;
|
||||
loading = true;
|
||||
fetchData().then(() => (loading = false));
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Aufgaben | Kalender</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="tasks-page">
|
||||
<header class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="header-icon">
|
||||
<CheckSquare size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Aufgaben</h1>
|
||||
<p class="subtitle">Ihre Termine und Aufgaben auf einen Blick</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="stats">
|
||||
{#if overdueCount > 0}
|
||||
<span class="stat overdue">
|
||||
<AlertTriangle size={14} />
|
||||
{overdueCount} überfällig
|
||||
</span>
|
||||
{/if}
|
||||
<span class="stat">{todayCount} heute</span>
|
||||
<span class="stat">{totalActiveCount} gesamt</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Filters -->
|
||||
<AgendaFilters
|
||||
{showEvents}
|
||||
{showTodos}
|
||||
{timeRange}
|
||||
onToggleEvents={toggleEvents}
|
||||
onToggleTodos={toggleTodos}
|
||||
onRangeChange={handleRangeChange}
|
||||
/>
|
||||
|
||||
<!-- Quick Add -->
|
||||
<div class="quick-add-section">
|
||||
{#if showQuickAdd}
|
||||
<QuickAddTodo
|
||||
placeholder="Neue Aufgabe hinzufügen..."
|
||||
autofocus
|
||||
showButton={false}
|
||||
onsubmit={() => (showQuickAdd = false)}
|
||||
oncancel={() => (showQuickAdd = false)}
|
||||
/>
|
||||
{:else}
|
||||
<button type="button" class="quick-add-button" onclick={() => (showQuickAdd = true)}>
|
||||
<Plus size={16} />
|
||||
<span>Neue Aufgabe</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
{#if loading}
|
||||
<AgendaSkeleton />
|
||||
{:else if !todosStore.serviceAvailable}
|
||||
<div class="error-state card">
|
||||
<AlertTriangle size={24} />
|
||||
<p>Todo-Service ist nicht erreichbar</p>
|
||||
<p class="hint">Bitte versuchen Sie es später erneut</p>
|
||||
</div>
|
||||
{:else if groupedItems.length === 0}
|
||||
<div class="empty-state card">
|
||||
<CheckSquare size={32} />
|
||||
<p>Keine Einträge gefunden</p>
|
||||
<p class="hint">
|
||||
{#if !showEvents && !showTodos}
|
||||
Aktivieren Sie mindestens einen Filter
|
||||
{:else}
|
||||
Erstellen Sie eine neue Aufgabe oder ändern Sie den Zeitraum
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="item-list">
|
||||
{#each groupedItems as group}
|
||||
<div class="date-group">
|
||||
<h2 class="date-header" class:today={isToday(group.date)}>
|
||||
{formatDateHeader(group.date)}
|
||||
<span class="item-count">({group.items.length})</span>
|
||||
</h2>
|
||||
|
||||
<div class="items">
|
||||
{#each group.items as item}
|
||||
{#if item.type === 'event' && item.event}
|
||||
<AgendaItem
|
||||
type="event"
|
||||
event={item.event}
|
||||
onclick={() => handleEventClick(item.event!.id)}
|
||||
/>
|
||||
{:else if item.type === 'todo' && item.todo}
|
||||
<AgendaItem
|
||||
type="todo"
|
||||
todo={item.todo}
|
||||
onclick={() => handleTodoClick(item.todo!)}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Detail Modal -->
|
||||
{#if selectedTask}
|
||||
<TodoDetailModal task={selectedTask} onClose={handleModalClose} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.tasks-page {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: hsl(var(--color-muted) / 0.5);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.stat.overdue {
|
||||
color: hsl(var(--color-danger));
|
||||
background: hsl(var(--color-danger) / 0.1);
|
||||
}
|
||||
|
||||
.quick-add-section {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.quick-add-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px dashed hsl(var(--color-border));
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.quick-add-button:hover {
|
||||
border-color: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary));
|
||||
background: hsl(var(--color-primary) / 0.05);
|
||||
}
|
||||
|
||||
.item-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.date-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.date-header {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.date-header.today {
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.item-count {
|
||||
font-weight: 400;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-state,
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 3rem 1.5rem;
|
||||
text-align: center;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.empty-state :global(svg),
|
||||
.error-state :global(svg) {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.error-state {
|
||||
color: hsl(var(--color-danger));
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.8125rem;
|
||||
opacity: 0.7;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: hsl(var(--color-surface));
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.tasks-page {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.stats {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,9 +1,28 @@
|
|||
/**
|
||||
* Calendar view types
|
||||
*/
|
||||
export type CalendarViewType =
|
||||
| 'day'
|
||||
| '3day'
|
||||
| '5day'
|
||||
| 'week'
|
||||
| '10day'
|
||||
| '14day'
|
||||
| '30day'
|
||||
| '60day'
|
||||
| '90day'
|
||||
| '365day'
|
||||
| 'month'
|
||||
| 'year'
|
||||
| 'agenda'
|
||||
| 'custom';
|
||||
|
||||
/**
|
||||
* Calendar settings stored in JSONB
|
||||
*/
|
||||
export interface CalendarSettings {
|
||||
/** Default view when opening the calendar */
|
||||
defaultView?: 'day' | '5day' | 'week' | '10day' | '14day' | 'month' | 'year' | 'agenda';
|
||||
defaultView?: CalendarViewType;
|
||||
/** 0 = Sunday, 1 = Monday */
|
||||
weekStartsOn?: 0 | 1;
|
||||
/** Show week numbers in calendar views */
|
||||
|
|
@ -57,19 +76,6 @@ export interface UpdateCalendarInput {
|
|||
settings?: CalendarSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendar view types
|
||||
*/
|
||||
export type CalendarViewType =
|
||||
| 'day'
|
||||
| '5day'
|
||||
| 'week'
|
||||
| '10day'
|
||||
| '14day'
|
||||
| 'month'
|
||||
| 'year'
|
||||
| 'agenda';
|
||||
|
||||
/**
|
||||
* Default calendar colors
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -18,6 +18,34 @@ export interface EventAttendee {
|
|||
company?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsible person for an event (single person accountable for the event)
|
||||
*/
|
||||
export interface ResponsiblePerson {
|
||||
email: string;
|
||||
name?: string;
|
||||
/** Contact reference for linked contacts */
|
||||
contactId?: string;
|
||||
/** Cached photo URL from contact */
|
||||
photoUrl?: string;
|
||||
/** Cached company from contact */
|
||||
company?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event tag group for organizing tags
|
||||
*/
|
||||
export interface EventTagGroup {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
color: string;
|
||||
sortOrder: number;
|
||||
tagCount?: number;
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event tag with color
|
||||
*/
|
||||
|
|
@ -26,6 +54,8 @@ export interface EventTag {
|
|||
userId: string;
|
||||
name: string;
|
||||
color: string;
|
||||
groupId?: string | null;
|
||||
sortOrder?: number;
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
}
|
||||
|
|
@ -57,7 +87,9 @@ export interface EventMetadata {
|
|||
url?: string;
|
||||
/** Video conference URL (Zoom, Meet, etc.) */
|
||||
conferenceUrl?: string;
|
||||
/** Event attendees */
|
||||
/** Responsible person for this event */
|
||||
responsiblePerson?: ResponsiblePerson;
|
||||
/** Event attendees/participants */
|
||||
attendees?: EventAttendee[];
|
||||
/** Event organizer email */
|
||||
organizer?: string;
|
||||
|
|
|
|||
|
|
@ -20,8 +20,22 @@ import {
|
|||
IsDateString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
IsArray,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
|
||||
class CustomDateDto {
|
||||
@IsUUID()
|
||||
id: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
label: string;
|
||||
|
||||
@IsDateString()
|
||||
date: string;
|
||||
}
|
||||
|
||||
class CreateContactDto {
|
||||
@IsString()
|
||||
|
|
@ -107,6 +121,78 @@ class CreateContactDto {
|
|||
@IsOptional()
|
||||
notes?: string;
|
||||
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CustomDateDto)
|
||||
customDates?: CustomDateDto[];
|
||||
|
||||
// Social Media
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(255)
|
||||
linkedin?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(100)
|
||||
twitter?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(255)
|
||||
facebook?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(100)
|
||||
instagram?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(255)
|
||||
xing?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(100)
|
||||
github?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(255)
|
||||
youtube?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(100)
|
||||
tiktok?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(100)
|
||||
telegram?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(50)
|
||||
whatsapp?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(50)
|
||||
signal?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(100)
|
||||
discord?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(100)
|
||||
bluesky?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
organizationId?: string;
|
||||
|
|
@ -168,6 +254,16 @@ export class ContactController {
|
|||
return { contacts, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all contacts with birthdays (for calendar integration)
|
||||
* Returns lightweight data: id, displayName, firstName, lastName, birthday, photoUrl
|
||||
*/
|
||||
@Get('birthdays')
|
||||
async getBirthdays(@CurrentUser() user: CurrentUserData) {
|
||||
const contacts = await this.contactService.findWithBirthdays(user.userId);
|
||||
return { contacts };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
const contact = await this.contactService.findById(id, user.userId);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,19 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and, or, ilike, desc, sql } from 'drizzle-orm';
|
||||
import { eq, and, or, ilike, desc, sql, isNotNull } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { Database } from '../db/connection';
|
||||
import { contacts } from '../db/schema';
|
||||
import type { Contact, NewContact } from '../db/schema';
|
||||
|
||||
export interface ContactBirthdaySummary {
|
||||
id: string;
|
||||
displayName: string | null;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
birthday: string;
|
||||
photoUrl: string | null;
|
||||
}
|
||||
|
||||
export interface ContactFilters {
|
||||
search?: string;
|
||||
isFavorite?: boolean;
|
||||
|
|
@ -148,4 +157,34 @@ export class ContactService {
|
|||
|
||||
return Number(result[0]?.count || 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all contacts with birthdays (for calendar integration)
|
||||
* Returns only essential fields for lightweight transfer
|
||||
*/
|
||||
async findWithBirthdays(userId: string): Promise<ContactBirthdaySummary[]> {
|
||||
const result = await this.db
|
||||
.select({
|
||||
id: contacts.id,
|
||||
displayName: contacts.displayName,
|
||||
firstName: contacts.firstName,
|
||||
lastName: contacts.lastName,
|
||||
birthday: contacts.birthday,
|
||||
photoUrl: contacts.photoUrl,
|
||||
})
|
||||
.from(contacts)
|
||||
.where(
|
||||
and(
|
||||
eq(contacts.userId, userId),
|
||||
eq(contacts.isArchived, false),
|
||||
isNotNull(contacts.birthday)
|
||||
)
|
||||
)
|
||||
.orderBy(contacts.lastName, contacts.firstName);
|
||||
|
||||
return result.map((c) => ({
|
||||
...c,
|
||||
birthday: c.birthday || '',
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,22 @@ export const contacts = pgTable('contacts', {
|
|||
birthday: date('birthday'),
|
||||
notes: text('notes'),
|
||||
photoUrl: varchar('photo_url', { length: 500 }),
|
||||
customDates: jsonb('custom_dates').$type<CustomDate[]>().default([]),
|
||||
|
||||
// Social Media
|
||||
linkedin: varchar('linkedin', { length: 255 }),
|
||||
twitter: varchar('twitter', { length: 100 }),
|
||||
facebook: varchar('facebook', { length: 255 }),
|
||||
instagram: varchar('instagram', { length: 100 }),
|
||||
xing: varchar('xing', { length: 255 }),
|
||||
github: varchar('github', { length: 100 }),
|
||||
youtube: varchar('youtube', { length: 255 }),
|
||||
tiktok: varchar('tiktok', { length: 100 }),
|
||||
telegram: varchar('telegram', { length: 100 }),
|
||||
whatsapp: varchar('whatsapp', { length: 50 }),
|
||||
signal: varchar('signal', { length: 50 }),
|
||||
discord: varchar('discord', { length: 100 }),
|
||||
bluesky: varchar('bluesky', { length: 100 }),
|
||||
|
||||
// Flags
|
||||
isFavorite: boolean('is_favorite').default(false),
|
||||
|
|
@ -50,3 +66,9 @@ export const contacts = pgTable('contacts', {
|
|||
|
||||
export type Contact = typeof contacts.$inferSelect;
|
||||
export type NewContact = typeof contacts.$inferInsert;
|
||||
|
||||
export interface CustomDate {
|
||||
id: string;
|
||||
label: string;
|
||||
date: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,8 +31,6 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-splitscreen": "workspace:*",
|
||||
"@manacore/shared-tags": "workspace:*",
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
"@manacore/shared-feedback-service": "workspace:*",
|
||||
|
|
@ -43,7 +41,9 @@
|
|||
"@manacore/shared-i18n": "workspace:*",
|
||||
"@manacore/shared-icons": "workspace:*",
|
||||
"@manacore/shared-profile-ui": "workspace:*",
|
||||
"@manacore/shared-splitscreen": "workspace:*",
|
||||
"@manacore/shared-subscription-ui": "workspace:*",
|
||||
"@manacore/shared-tags": "workspace:*",
|
||||
"@manacore/shared-tailwind": "workspace:*",
|
||||
"@manacore/shared-theme": "workspace:*",
|
||||
"@manacore/shared-theme-ui": "workspace:*",
|
||||
|
|
@ -52,6 +52,7 @@
|
|||
"d3-force": "^3.0.0",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-svelte": "^0.556.0",
|
||||
"svelte-i18n": "^4.0.1"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,30 +1,4 @@
|
|||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { API_BASE } from './config';
|
||||
|
||||
async function fetchWithAuth(url: string, options: RequestInit = {}) {
|
||||
const token = await authStore.getAccessToken();
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
};
|
||||
|
||||
if (token) {
|
||||
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${url}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: 'Request failed' }));
|
||||
throw new Error(error.message || 'Request failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
import { fetchWithAuth } from './client';
|
||||
|
||||
export interface BatchResult {
|
||||
success: number;
|
||||
|
|
|
|||
71
apps/contacts/apps/web/src/lib/api/client.ts
Normal file
71
apps/contacts/apps/web/src/lib/api/client.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* Centralized API client with authentication
|
||||
*/
|
||||
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { API_BASE } from './config';
|
||||
|
||||
/**
|
||||
* Make an authenticated API request
|
||||
* @param url API endpoint (will be prefixed with API_BASE)
|
||||
* @param options Fetch options
|
||||
* @returns Parsed JSON response
|
||||
*/
|
||||
export async function fetchWithAuth<T = unknown>(
|
||||
url: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const token = await authStore.getAccessToken();
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
};
|
||||
|
||||
if (token) {
|
||||
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${url}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: 'Request failed' }));
|
||||
throw new Error(error.message || 'Request failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated API request without JSON content type
|
||||
* Used for file uploads (FormData)
|
||||
*/
|
||||
export async function fetchWithAuthFormData<T = unknown>(
|
||||
url: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const token = await authStore.getAccessToken();
|
||||
|
||||
const headers: HeadersInit = {
|
||||
...(options.headers || {}),
|
||||
};
|
||||
|
||||
if (token) {
|
||||
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${url}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: 'Request failed' }));
|
||||
throw new Error(error.message || 'Request failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
|
@ -1,7 +1,13 @@
|
|||
import { PUBLIC_BACKEND_URL } from '$env/static/public';
|
||||
import { PUBLIC_BACKEND_URL, PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public';
|
||||
|
||||
/**
|
||||
* API Configuration
|
||||
* Uses environment variable PUBLIC_BACKEND_URL with fallback for development
|
||||
* Uses environment variables with fallbacks for development
|
||||
*/
|
||||
export const API_BASE = `${PUBLIC_BACKEND_URL || 'http://localhost:3015'}/api/v1`;
|
||||
|
||||
/**
|
||||
* Mana Core Auth URL
|
||||
* Central authentication service URL
|
||||
*/
|
||||
export const MANA_AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
|
||||
|
|
|
|||
|
|
@ -1,33 +1,9 @@
|
|||
import { browser } from '$app/environment';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { API_BASE } from './config';
|
||||
import { MANA_AUTH_URL } from './config';
|
||||
import { fetchWithAuth, fetchWithAuthFormData } from './client';
|
||||
import { createTagsClient, type Tag } from '@manacore/shared-tags';
|
||||
|
||||
async function fetchWithAuth(url: string, options: RequestInit = {}) {
|
||||
const token = await authStore.getAccessToken();
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
};
|
||||
|
||||
if (token) {
|
||||
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${url}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: 'Request failed' }));
|
||||
throw new Error(error.message || 'Request failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export interface Contact {
|
||||
id: string;
|
||||
userId: string;
|
||||
|
|
@ -49,6 +25,27 @@ export interface Contact {
|
|||
birthday?: string | null;
|
||||
notes?: string | null;
|
||||
photoUrl?: string | null;
|
||||
customDates?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
date: string;
|
||||
}> | null;
|
||||
// Social Media
|
||||
linkedin?: string | null;
|
||||
twitter?: string | null;
|
||||
facebook?: string | null;
|
||||
instagram?: string | null;
|
||||
xing?: string | null;
|
||||
github?: string | null;
|
||||
youtube?: string | null;
|
||||
tiktok?: string | null;
|
||||
telegram?: string | null;
|
||||
whatsapp?: string | null;
|
||||
signal?: string | null;
|
||||
discord?: string | null;
|
||||
bluesky?: string | null;
|
||||
// Tags (populated by API)
|
||||
tags?: Array<{ id: string; name: string; color: string | null }>;
|
||||
isFavorite: boolean;
|
||||
isArchived: boolean;
|
||||
organizationId?: string | null;
|
||||
|
|
@ -90,9 +87,19 @@ export interface ContactFilters {
|
|||
offset?: number;
|
||||
}
|
||||
|
||||
// API Response types
|
||||
interface ContactResponse {
|
||||
contact: Contact;
|
||||
}
|
||||
|
||||
interface ContactListResponse {
|
||||
contacts: Contact[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// Contacts API
|
||||
export const contactsApi = {
|
||||
async list(filters: ContactFilters = {}) {
|
||||
async list(filters: ContactFilters = {}): Promise<ContactListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.search) params.set('search', filters.search);
|
||||
if (filters.isFavorite !== undefined) params.set('isFavorite', String(filters.isFavorite));
|
||||
|
|
@ -102,16 +109,16 @@ export const contactsApi = {
|
|||
if (filters.offset) params.set('offset', String(filters.offset));
|
||||
|
||||
const query = params.toString();
|
||||
return fetchWithAuth(`/contacts${query ? `?${query}` : ''}`);
|
||||
return fetchWithAuth<ContactListResponse>(`/contacts${query ? `?${query}` : ''}`);
|
||||
},
|
||||
|
||||
async get(id: string): Promise<Contact> {
|
||||
const response = await fetchWithAuth(`/contacts/${id}`);
|
||||
const response = await fetchWithAuth<ContactResponse>(`/contacts/${id}`);
|
||||
return response.contact;
|
||||
},
|
||||
|
||||
async create(data: Partial<Contact>): Promise<Contact> {
|
||||
const response = await fetchWithAuth('/contacts', {
|
||||
const response = await fetchWithAuth<ContactResponse>('/contacts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
|
@ -119,7 +126,7 @@ export const contactsApi = {
|
|||
},
|
||||
|
||||
async update(id: string, data: Partial<Contact>): Promise<Contact> {
|
||||
const response = await fetchWithAuth(`/contacts/${id}`, {
|
||||
const response = await fetchWithAuth<ContactResponse>(`/contacts/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
|
@ -133,14 +140,14 @@ export const contactsApi = {
|
|||
},
|
||||
|
||||
async toggleFavorite(id: string): Promise<Contact> {
|
||||
const response = await fetchWithAuth(`/contacts/${id}/favorite`, {
|
||||
const response = await fetchWithAuth<ContactResponse>(`/contacts/${id}/favorite`, {
|
||||
method: 'POST',
|
||||
});
|
||||
return response.contact;
|
||||
},
|
||||
|
||||
async toggleArchive(id: string): Promise<Contact> {
|
||||
const response = await fetchWithAuth(`/contacts/${id}/archive`, {
|
||||
const response = await fetchWithAuth<ContactResponse>(`/contacts/${id}/archive`, {
|
||||
method: 'POST',
|
||||
});
|
||||
return response.contact;
|
||||
|
|
@ -150,16 +157,6 @@ export const contactsApi = {
|
|||
// Tags API - Uses central Tags API from mana-core-auth
|
||||
// Contact-tag associations still use the Contacts backend
|
||||
|
||||
// Get auth URL dynamically at runtime
|
||||
function getAuthUrl(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
|
||||
.__PUBLIC_MANA_CORE_AUTH_URL__;
|
||||
return injectedUrl || 'http://localhost:3001';
|
||||
}
|
||||
return 'http://localhost:3001';
|
||||
}
|
||||
|
||||
// Lazy-initialized tags client
|
||||
let _tagsClient: ReturnType<typeof createTagsClient> | null = null;
|
||||
|
||||
|
|
@ -167,7 +164,7 @@ function getTagsClient() {
|
|||
if (!browser) return null;
|
||||
if (!_tagsClient) {
|
||||
_tagsClient = createTagsClient({
|
||||
authUrl: getAuthUrl(),
|
||||
authUrl: MANA_AUTH_URL,
|
||||
getToken: async () => {
|
||||
const token = await authStore.getAccessToken();
|
||||
return token || '';
|
||||
|
|
@ -212,19 +209,19 @@ export const tagsApi = {
|
|||
|
||||
// Contact-tag associations still use Contacts backend
|
||||
async addToContact(tagId: string, contactId: string): Promise<{ success: boolean }> {
|
||||
return fetchWithAuth(`/tags/${tagId}/contacts/${contactId}`, {
|
||||
return fetchWithAuth<{ success: boolean }>(`/tags/${tagId}/contacts/${contactId}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
},
|
||||
|
||||
async removeFromContact(tagId: string, contactId: string): Promise<{ success: boolean }> {
|
||||
return fetchWithAuth(`/tags/${tagId}/contacts/${contactId}`, {
|
||||
return fetchWithAuth<{ success: boolean }>(`/tags/${tagId}/contacts/${contactId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
|
||||
async getForContact(contactId: string): Promise<{ tagIds: string[] }> {
|
||||
return fetchWithAuth(`/tags/contact/${contactId}`);
|
||||
return fetchWithAuth<{ tagIds: string[] }>(`/tags/contact/${contactId}`);
|
||||
},
|
||||
|
||||
// Create default tags via central Tags API
|
||||
|
|
@ -236,44 +233,68 @@ export const tagsApi = {
|
|||
},
|
||||
};
|
||||
|
||||
// Notes API Response types
|
||||
interface NotesListResponse {
|
||||
notes: ContactNote[];
|
||||
}
|
||||
|
||||
interface NoteResponse {
|
||||
note: ContactNote;
|
||||
}
|
||||
|
||||
// Notes API
|
||||
export const notesApi = {
|
||||
async list(contactId: string) {
|
||||
return fetchWithAuth(`/contacts/${contactId}/notes`);
|
||||
async list(contactId: string): Promise<NotesListResponse> {
|
||||
return fetchWithAuth<NotesListResponse>(`/contacts/${contactId}/notes`);
|
||||
},
|
||||
|
||||
async create(contactId: string, data: { content: string; isPinned?: boolean }) {
|
||||
return fetchWithAuth(`/contacts/${contactId}/notes`, {
|
||||
async create(
|
||||
contactId: string,
|
||||
data: { content: string; isPinned?: boolean }
|
||||
): Promise<NoteResponse> {
|
||||
return fetchWithAuth<NoteResponse>(`/contacts/${contactId}/notes`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
async update(noteId: string, data: { content?: string; isPinned?: boolean }) {
|
||||
return fetchWithAuth(`/notes/${noteId}`, {
|
||||
async update(
|
||||
noteId: string,
|
||||
data: { content?: string; isPinned?: boolean }
|
||||
): Promise<NoteResponse> {
|
||||
return fetchWithAuth<NoteResponse>(`/notes/${noteId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
async delete(noteId: string) {
|
||||
return fetchWithAuth(`/notes/${noteId}`, {
|
||||
async delete(noteId: string): Promise<void> {
|
||||
await fetchWithAuth(`/notes/${noteId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
|
||||
async togglePin(noteId: string) {
|
||||
return fetchWithAuth(`/notes/${noteId}/pin`, {
|
||||
async togglePin(noteId: string): Promise<NoteResponse> {
|
||||
return fetchWithAuth<NoteResponse>(`/notes/${noteId}/pin`, {
|
||||
method: 'POST',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// Activities API Response types
|
||||
interface ActivitiesListResponse {
|
||||
activities: ContactActivity[];
|
||||
}
|
||||
|
||||
interface ActivityResponse {
|
||||
activity: ContactActivity;
|
||||
}
|
||||
|
||||
// Activities API
|
||||
export const activitiesApi = {
|
||||
async list(contactId: string, limit?: number) {
|
||||
async list(contactId: string, limit?: number): Promise<ActivitiesListResponse> {
|
||||
const params = limit ? `?limit=${limit}` : '';
|
||||
return fetchWithAuth(`/contacts/${contactId}/activities${params}`);
|
||||
return fetchWithAuth<ActivitiesListResponse>(`/contacts/${contactId}/activities${params}`);
|
||||
},
|
||||
|
||||
async create(
|
||||
|
|
@ -283,8 +304,8 @@ export const activitiesApi = {
|
|||
description?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
) {
|
||||
return fetchWithAuth(`/contacts/${contactId}/activities`, {
|
||||
): Promise<ActivityResponse> {
|
||||
return fetchWithAuth<ActivityResponse>(`/contacts/${contactId}/activities`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
|
@ -294,25 +315,13 @@ export const activitiesApi = {
|
|||
// Photo API
|
||||
export const photoApi = {
|
||||
async upload(contactId: string, file: File): Promise<{ photoUrl: string }> {
|
||||
const token = await authStore.getAccessToken();
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('photo', file);
|
||||
|
||||
const response = await fetch(`${API_BASE}/contacts/${contactId}/photo`, {
|
||||
return fetchWithAuthFormData<{ photoUrl: string }>(`/contacts/${contactId}/photo`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: 'Upload failed' }));
|
||||
throw new Error(error.message || 'Upload failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async delete(contactId: string): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
<script lang="ts">
|
||||
import { ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
|
||||
import { EyeSlash, ArrowsDownUp, Hash, ArrowsIn, ArrowsOut } from '@manacore/shared-icons';
|
||||
import { contactsSettings } from '$lib/stores/settings.svelte';
|
||||
import { contactsFilterStore } from '$lib/stores/filter.svelte';
|
||||
|
||||
// Context menu state
|
||||
let visible = $state(false);
|
||||
let x = $state(0);
|
||||
let y = $state(0);
|
||||
|
||||
// Build menu items based on current settings
|
||||
let menuItems = $derived.by((): ContextMenuItem[] => {
|
||||
return [
|
||||
{
|
||||
id: 'hide-inactive',
|
||||
label: 'Inaktive Buchstaben ausblenden',
|
||||
icon: EyeSlash,
|
||||
toggle: true,
|
||||
checked: contactsSettings.alphabetNavHideInactive,
|
||||
action: () =>
|
||||
contactsSettings.set(
|
||||
'alphabetNavHideInactive',
|
||||
!contactsSettings.alphabetNavHideInactive
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'compact',
|
||||
label: 'Kompakte Ansicht',
|
||||
icon: ArrowsIn,
|
||||
toggle: true,
|
||||
checked: contactsSettings.alphabetNavCompact,
|
||||
action: () =>
|
||||
contactsSettings.set('alphabetNavCompact', !contactsSettings.alphabetNavCompact),
|
||||
},
|
||||
{
|
||||
id: 'reverse-order',
|
||||
label: 'Umgekehrte Reihenfolge (Z-A)',
|
||||
icon: ArrowsDownUp,
|
||||
toggle: true,
|
||||
checked: contactsSettings.alphabetNavReverseOrder,
|
||||
action: () =>
|
||||
contactsSettings.set(
|
||||
'alphabetNavReverseOrder',
|
||||
!contactsSettings.alphabetNavReverseOrder
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'show-hash',
|
||||
label: '# Symbol anzeigen',
|
||||
icon: Hash,
|
||||
toggle: true,
|
||||
checked: contactsSettings.alphabetNavShowHash,
|
||||
action: () =>
|
||||
contactsSettings.set('alphabetNavShowHash', !contactsSettings.alphabetNavShowHash),
|
||||
},
|
||||
{
|
||||
id: 'divider-1',
|
||||
label: '',
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
id: 'minimize',
|
||||
label: contactsFilterStore.isAlphabetNavCollapsed ? 'Erweitern' : 'Minimieren',
|
||||
icon: contactsFilterStore.isAlphabetNavCollapsed ? ArrowsOut : ArrowsIn,
|
||||
action: () => contactsFilterStore.toggleAlphabetNav(),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
function handleClose() {
|
||||
visible = false;
|
||||
}
|
||||
|
||||
export function show(clientX: number, clientY: number) {
|
||||
x = clientX;
|
||||
y = clientY;
|
||||
visible = true;
|
||||
}
|
||||
|
||||
export function hide() {
|
||||
visible = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<ContextMenu {visible} {x} {y} items={menuItems} onClose={handleClose} />
|
||||
|
|
@ -5,6 +5,9 @@
|
|||
import ContactNotes from './ContactNotes.svelte';
|
||||
import ContactTasks from './ContactTasks.svelte';
|
||||
import { ContactDetailSkeleton } from '$lib/components/skeletons';
|
||||
import SocialMediaFields from './forms/SocialMediaFields.svelte';
|
||||
import DateFields from './forms/DateFields.svelte';
|
||||
import SocialMediaLinks from './SocialMediaLinks.svelte';
|
||||
|
||||
interface Props {
|
||||
contactId: string;
|
||||
|
|
@ -36,6 +39,25 @@
|
|||
let country = $state('');
|
||||
let notes = $state('');
|
||||
|
||||
// Dates
|
||||
let birthday = $state('');
|
||||
let customDates = $state<Array<{ id: string; label: string; date: string }>>([]);
|
||||
|
||||
// Social Media
|
||||
let linkedin = $state('');
|
||||
let twitter = $state('');
|
||||
let facebook = $state('');
|
||||
let instagram = $state('');
|
||||
let xing = $state('');
|
||||
let github = $state('');
|
||||
let youtube = $state('');
|
||||
let tiktok = $state('');
|
||||
let telegram = $state('');
|
||||
let whatsapp = $state('');
|
||||
let signal = $state('');
|
||||
let discord = $state('');
|
||||
let bluesky = $state('');
|
||||
|
||||
const initials = $derived(() => {
|
||||
if (!contact) return '?';
|
||||
const f = contact.firstName?.[0] || '';
|
||||
|
|
@ -70,6 +92,23 @@
|
|||
postalCode = contact.postalCode || '';
|
||||
country = contact.country || '';
|
||||
notes = contact.notes || '';
|
||||
// Dates
|
||||
birthday = contact.birthday || '';
|
||||
customDates = contact.customDates ? [...contact.customDates] : [];
|
||||
// Social Media
|
||||
linkedin = contact.linkedin || '';
|
||||
twitter = contact.twitter || '';
|
||||
facebook = contact.facebook || '';
|
||||
instagram = contact.instagram || '';
|
||||
xing = contact.xing || '';
|
||||
github = contact.github || '';
|
||||
youtube = contact.youtube || '';
|
||||
tiktok = contact.tiktok || '';
|
||||
telegram = contact.telegram || '';
|
||||
whatsapp = contact.whatsapp || '';
|
||||
signal = contact.signal || '';
|
||||
discord = contact.discord || '';
|
||||
bluesky = contact.bluesky || '';
|
||||
}
|
||||
|
||||
function getDisplayName() {
|
||||
|
|
@ -111,6 +150,23 @@
|
|||
postalCode: postalCode || null,
|
||||
country: country || null,
|
||||
notes: notes || null,
|
||||
// Dates
|
||||
birthday: birthday || null,
|
||||
customDates: customDates.filter((d) => d.label && d.date),
|
||||
// Social Media
|
||||
linkedin: linkedin || null,
|
||||
twitter: twitter || null,
|
||||
facebook: facebook || null,
|
||||
instagram: instagram || null,
|
||||
xing: xing || null,
|
||||
github: github || null,
|
||||
youtube: youtube || null,
|
||||
tiktok: tiktok || null,
|
||||
telegram: telegram || null,
|
||||
whatsapp: whatsapp || null,
|
||||
signal: signal || null,
|
||||
discord: discord || null,
|
||||
bluesky: bluesky || null,
|
||||
});
|
||||
editing = false;
|
||||
} catch (e) {
|
||||
|
|
@ -478,6 +534,26 @@
|
|||
<textarea bind:value={notes} rows="4" class="input textarea"></textarea>
|
||||
</section>
|
||||
|
||||
<!-- Dates Section -->
|
||||
<DateFields bind:birthday bind:customDates />
|
||||
|
||||
<!-- Social Media Section -->
|
||||
<SocialMediaFields
|
||||
bind:linkedin
|
||||
bind:twitter
|
||||
bind:facebook
|
||||
bind:instagram
|
||||
bind:xing
|
||||
bind:github
|
||||
bind:youtube
|
||||
bind:tiktok
|
||||
bind:telegram
|
||||
bind:whatsapp
|
||||
bind:signal
|
||||
bind:discord
|
||||
bind:bluesky
|
||||
/>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="actions">
|
||||
<button
|
||||
|
|
@ -819,6 +895,56 @@
|
|||
</section>
|
||||
{/if}
|
||||
|
||||
{#if contact.birthday || (contact.customDates && contact.customDates.length > 0)}
|
||||
<section class="detail-section">
|
||||
<div class="section-header">
|
||||
<div class="section-icon">
|
||||
<svg 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>
|
||||
</div>
|
||||
<h3 class="section-title">Daten</h3>
|
||||
</div>
|
||||
<div class="detail-list">
|
||||
{#if contact.birthday}
|
||||
<div class="detail-item">
|
||||
<div class="detail-content">
|
||||
<span class="detail-label">Geburtstag</span>
|
||||
<span class="detail-value"
|
||||
>{new Date(contact.birthday).toLocaleDateString('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if contact.customDates}
|
||||
{#each contact.customDates as customDate}
|
||||
<div class="detail-item">
|
||||
<div class="detail-content">
|
||||
<span class="detail-label">{customDate.label}</span>
|
||||
<span class="detail-value"
|
||||
>{new Date(customDate.date).toLocaleDateString('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if contact.notes}
|
||||
<section class="detail-section">
|
||||
<div class="section-header">
|
||||
|
|
@ -840,6 +966,8 @@
|
|||
</section>
|
||||
{/if}
|
||||
|
||||
<SocialMediaLinks {contact} />
|
||||
|
||||
<!-- Contact Notes (separate from contact.notes field) -->
|
||||
<ContactNotes {contactId} />
|
||||
|
||||
|
|
|
|||
|
|
@ -3,31 +3,24 @@
|
|||
import { _ } from 'svelte-i18n';
|
||||
import { contactsStore } from '$lib/stores/contacts.svelte';
|
||||
import { viewModeStore } from '$lib/stores/view-mode.svelte';
|
||||
import { contactsFilterStore } from '$lib/stores/filter.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { ContactFilter, BirthdayFilter } from '$lib/components/FilterBar.svelte';
|
||||
import ContactsToolbar, { type SortField } from '$lib/components/ContactsToolbar.svelte';
|
||||
import ContactListView from '$lib/components/views/ContactListView.svelte';
|
||||
import ContactGridView from '$lib/components/views/ContactGridView.svelte';
|
||||
import ContactAlphabetView from '$lib/components/views/ContactAlphabetView.svelte';
|
||||
import { ContactListSkeleton, ContactGridSkeleton } from '$lib/components/skeletons';
|
||||
import ContactNetworkView from '$lib/components/views/ContactNetworkView.svelte';
|
||||
import {
|
||||
ContactListSkeleton,
|
||||
ContactGridSkeleton,
|
||||
NetworkGraphSkeleton,
|
||||
} from '$lib/components/skeletons';
|
||||
import { batchApi } from '$lib/api/batch';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
|
||||
let searchQuery = $state('');
|
||||
let sortField = $state<SortField>('lastName');
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte';
|
||||
|
||||
// Infinite scroll
|
||||
let scrollContainer: HTMLDivElement;
|
||||
let intersectionObserver: IntersectionObserver | null = null;
|
||||
let loadMoreTrigger: HTMLDivElement;
|
||||
|
||||
// Filter state
|
||||
let selectedTagId = $state<string | null>(null);
|
||||
let contactFilter = $state<ContactFilter>('all');
|
||||
let birthdayFilter = $state<BirthdayFilter>('all');
|
||||
let selectedCompany = $state<string | null>(null);
|
||||
|
||||
// Batch selection state
|
||||
let selectionMode = $state(false);
|
||||
let selectedIds = $state<Set<string>>(new Set());
|
||||
|
|
@ -73,11 +66,31 @@
|
|||
return !contact.phone && !contact.mobile && !contact.email;
|
||||
}
|
||||
|
||||
// Filtered and sorted contacts
|
||||
// Filtered and sorted contacts (using filter store)
|
||||
let filteredContacts = $derived.by(() => {
|
||||
let result = [...contactsStore.contacts];
|
||||
|
||||
// Apply contact filter
|
||||
// Apply search filter from InputBar
|
||||
const searchQuery = contactsFilterStore.searchQuery?.toLowerCase().trim();
|
||||
if (searchQuery) {
|
||||
result = result.filter((c) => {
|
||||
const searchFields = [
|
||||
c.firstName,
|
||||
c.lastName,
|
||||
c.displayName,
|
||||
c.nickname,
|
||||
c.company,
|
||||
c.email,
|
||||
c.phone,
|
||||
c.mobile,
|
||||
c.city,
|
||||
];
|
||||
return searchFields.some((field) => field?.toLowerCase().includes(searchQuery));
|
||||
});
|
||||
}
|
||||
|
||||
// Apply contact filter from store
|
||||
const contactFilter = contactsFilterStore.contactFilter;
|
||||
if (contactFilter === 'favorites') {
|
||||
result = result.filter((c) => c.isFavorite);
|
||||
} else if (contactFilter === 'hasPhone') {
|
||||
|
|
@ -88,7 +101,8 @@
|
|||
result = result.filter((c) => isContactIncomplete(c));
|
||||
}
|
||||
|
||||
// Apply birthday filter
|
||||
// Apply birthday filter from store
|
||||
const birthdayFilter = contactsFilterStore.birthdayFilter;
|
||||
if (birthdayFilter === 'today') {
|
||||
result = result.filter((c) => isBirthdayToday(c.birthday));
|
||||
} else if (birthdayFilter === 'thisWeek') {
|
||||
|
|
@ -97,7 +111,8 @@
|
|||
result = result.filter((c) => isBirthdayThisMonth(c.birthday));
|
||||
}
|
||||
|
||||
// Apply company filter
|
||||
// Apply company filter from store
|
||||
const selectedCompany = contactsFilterStore.selectedCompany;
|
||||
if (selectedCompany) {
|
||||
result = result.filter((c) => c.company === selectedCompany);
|
||||
}
|
||||
|
|
@ -105,8 +120,9 @@
|
|||
return result;
|
||||
});
|
||||
|
||||
// Sorted contacts based on selected sort field
|
||||
// Sorted contacts based on selected sort field from store
|
||||
let sortedContacts = $derived.by(() => {
|
||||
const sortField = contactsFilterStore.sortField;
|
||||
return [...filteredContacts].sort((a, b) => {
|
||||
const aValue =
|
||||
(sortField === 'firstName'
|
||||
|
|
@ -122,14 +138,6 @@
|
|||
});
|
||||
});
|
||||
|
||||
function handleSearch() {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
contactsStore.setSearch(searchQuery);
|
||||
contactsStore.loadContacts();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
async function handleToggleFavorite(e: MouseEvent, id: string) {
|
||||
e.stopPropagation();
|
||||
await contactsStore.toggleFavorite(id);
|
||||
|
|
@ -266,11 +274,26 @@
|
|||
intersectionObserver.observe(loadMoreTrigger);
|
||||
}
|
||||
});
|
||||
|
||||
// Reload contacts when tag filter changes (tag filtering is server-side)
|
||||
let lastTagId: string | null = null;
|
||||
$effect(() => {
|
||||
const currentTagId = contactsFilterStore.selectedTagId;
|
||||
if (currentTagId !== lastTagId) {
|
||||
lastTagId = currentTagId;
|
||||
if (currentTagId) {
|
||||
contactsStore.setTagId(currentTagId);
|
||||
} else {
|
||||
contactsStore.setTagId(undefined);
|
||||
}
|
||||
contactsStore.loadContacts();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<h1 class="text-2xl font-bold text-foreground">{$_('contacts.title')}</h1>
|
||||
<h1 class="text-2xl font-bold text-foreground text-center">{$_('contacts.title')}</h1>
|
||||
|
||||
<!-- Batch Actions Bar (shown when in selection mode) -->
|
||||
{#if selectionMode}
|
||||
|
|
@ -349,58 +372,11 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={$_('contacts.search')}
|
||||
bind:value={searchQuery}
|
||||
oninput={handleSearch}
|
||||
class="input w-full pl-10"
|
||||
/>
|
||||
<svg
|
||||
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Unified Toolbar -->
|
||||
<ContactsToolbar
|
||||
contacts={contactsStore.contacts}
|
||||
{sortField}
|
||||
onSortFieldChange={(v) => (sortField = v)}
|
||||
{contactFilter}
|
||||
onContactFilterChange={(f) => (contactFilter = f)}
|
||||
{birthdayFilter}
|
||||
onBirthdayFilterChange={(f) => (birthdayFilter = f)}
|
||||
{selectedTagId}
|
||||
onTagChange={(id) => {
|
||||
selectedTagId = id;
|
||||
if (id) {
|
||||
contactsStore.setTagId(id);
|
||||
} else {
|
||||
contactsStore.setTagId(undefined);
|
||||
}
|
||||
contactsStore.loadContacts();
|
||||
}}
|
||||
{selectedCompany}
|
||||
onCompanyChange={(c) => (selectedCompany = c)}
|
||||
{selectionMode}
|
||||
onToggleSelectionMode={toggleSelectionMode}
|
||||
/>
|
||||
|
||||
<!-- Loading state with skeleton -->
|
||||
{#if contactsStore.loading}
|
||||
{#if viewModeStore.mode === 'grid'}
|
||||
{#if viewModeStore.mode === 'network'}
|
||||
<NetworkGraphSkeleton />
|
||||
{:else if viewModeStore.mode === 'grid'}
|
||||
<ContactGridSkeleton count={8} />
|
||||
{:else}
|
||||
<ContactListSkeleton count={10} />
|
||||
|
|
@ -411,13 +387,15 @@
|
|||
<div class="text-6xl mb-4">👤</div>
|
||||
<h2 class="text-xl font-semibold text-foreground mb-2">{$_('contacts.noContacts')}</h2>
|
||||
<p class="text-muted-foreground mb-4">{$_('contacts.addFirst')}</p>
|
||||
<a href="/contacts/new" class="btn btn-primary">
|
||||
<button type="button" onclick={() => newContactModalStore.open()} class="btn btn-primary">
|
||||
{$_('contacts.new')}
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Contacts View -->
|
||||
{#if viewModeStore.mode === 'grid'}
|
||||
{#if viewModeStore.mode === 'network'}
|
||||
<ContactNetworkView />
|
||||
{:else if viewModeStore.mode === 'grid'}
|
||||
<ContactGridView
|
||||
contacts={sortedContacts}
|
||||
onContactClick={handleContactClick}
|
||||
|
|
@ -426,7 +404,7 @@
|
|||
{selectedIds}
|
||||
onToggleSelection={toggleSelection}
|
||||
/>
|
||||
{:else if viewModeStore.mode === 'alphabet'}
|
||||
{:else}
|
||||
<ContactAlphabetView
|
||||
contacts={sortedContacts}
|
||||
onContactClick={handleContactClick}
|
||||
|
|
@ -434,21 +412,12 @@
|
|||
{selectionMode}
|
||||
{selectedIds}
|
||||
onToggleSelection={toggleSelection}
|
||||
{sortField}
|
||||
/>
|
||||
{:else}
|
||||
<ContactListView
|
||||
contacts={sortedContacts}
|
||||
onContactClick={handleContactClick}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
{selectionMode}
|
||||
{selectedIds}
|
||||
onToggleSelection={toggleSelection}
|
||||
sortField={contactsFilterStore.sortField}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Infinite scroll trigger & loading more indicator -->
|
||||
{#if contactsStore.hasMore}
|
||||
<!-- Infinite scroll trigger & loading more indicator (not for network view) -->
|
||||
{#if viewModeStore.mode !== 'network' && contactsStore.hasMore}
|
||||
<div bind:this={loadMoreTrigger} class="load-more-trigger">
|
||||
{#if contactsStore.loadingMore}
|
||||
<div class="loading-more">
|
||||
|
|
@ -459,11 +428,13 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Total count -->
|
||||
<p class="text-sm text-muted-foreground text-center">
|
||||
{contactsStore.contacts.length} / {contactsStore.total}
|
||||
{contactsStore.total === 1 ? $_('contacts.contact') : $_('contacts.contactsPlural')}
|
||||
</p>
|
||||
<!-- Total count (not for network view) -->
|
||||
{#if viewModeStore.mode !== 'network'}
|
||||
<p class="text-sm text-muted-foreground text-center">
|
||||
{contactsStore.contacts.length} / {contactsStore.total}
|
||||
{contactsStore.total === 1 ? $_('contacts.contact') : $_('contacts.contactsPlural')}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,185 +1,40 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { goto } from '$app/navigation';
|
||||
import { contactsStore } from '$lib/stores/contacts.svelte';
|
||||
import { viewModeStore, type ViewMode } from '$lib/stores/view-mode.svelte';
|
||||
import {
|
||||
PillToolbar,
|
||||
PillToolbarButton,
|
||||
PillToolbarDivider,
|
||||
PillViewSwitcher,
|
||||
} from '@manacore/shared-ui';
|
||||
import FilterBar, {
|
||||
type ContactFilter,
|
||||
type BirthdayFilter,
|
||||
} from '$lib/components/FilterBar.svelte';
|
||||
import { ExpandableToolbar } from '@manacore/shared-ui';
|
||||
import ContactsToolbarContent from './ContactsToolbarContent.svelte';
|
||||
import { contactsFilterStore } from '$lib/stores/filter.svelte';
|
||||
import { viewModeStore } from '$lib/stores/view-mode.svelte';
|
||||
import type { Contact } from '$lib/api/contacts';
|
||||
|
||||
export type SortField = 'firstName' | 'lastName';
|
||||
|
||||
interface Props {
|
||||
isSidebarMode?: boolean;
|
||||
contacts: Contact[];
|
||||
sortField: SortField;
|
||||
onSortFieldChange: (field: SortField) => void;
|
||||
contactFilter: ContactFilter;
|
||||
onContactFilterChange: (filter: ContactFilter) => void;
|
||||
birthdayFilter: BirthdayFilter;
|
||||
onBirthdayFilterChange: (filter: BirthdayFilter) => void;
|
||||
selectedTagId: string | null;
|
||||
onTagChange: (tagId: string | null) => void;
|
||||
selectedCompany: string | null;
|
||||
onCompanyChange: (company: string | null) => void;
|
||||
/** Selection mode state */
|
||||
selectionMode: boolean;
|
||||
/** Toggle selection mode callback */
|
||||
onToggleSelectionMode: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
contacts,
|
||||
sortField,
|
||||
onSortFieldChange,
|
||||
contactFilter,
|
||||
onContactFilterChange,
|
||||
birthdayFilter,
|
||||
onBirthdayFilterChange,
|
||||
selectedTagId,
|
||||
onTagChange,
|
||||
selectedCompany,
|
||||
onCompanyChange,
|
||||
selectionMode,
|
||||
onToggleSelectionMode,
|
||||
}: Props = $props();
|
||||
let { isSidebarMode = false, contacts }: Props = $props();
|
||||
|
||||
// Count favorites for quick filter button
|
||||
let favoritesCount = $derived(contactsStore.contacts.filter((c) => c.isFavorite).length);
|
||||
let showFavorites = $derived(contactFilter === 'favorites');
|
||||
// Use store for collapsed state
|
||||
let isCollapsed = $derived(contactsFilterStore.isToolbarCollapsed);
|
||||
|
||||
// Sort options
|
||||
const sortOptions = [
|
||||
{ id: 'firstName', label: $_('sort.firstName'), title: $_('sort.firstName') },
|
||||
{ id: 'lastName', label: $_('sort.lastName'), title: $_('sort.lastName') },
|
||||
];
|
||||
|
||||
// View mode options
|
||||
const viewOptions = [
|
||||
{ id: 'list', label: '', title: $_('views.list'), icon: 'list' },
|
||||
{ id: 'grid', label: '', title: $_('views.grid'), icon: 'grid' },
|
||||
{ id: 'alphabet', label: '', title: $_('views.alphabet'), icon: 'alphabet' },
|
||||
];
|
||||
|
||||
function toggleFavorites() {
|
||||
if (contactFilter === 'favorites') {
|
||||
onContactFilterChange('all');
|
||||
} else {
|
||||
onContactFilterChange('favorites');
|
||||
}
|
||||
}
|
||||
|
||||
function handleSortChange(value: string) {
|
||||
onSortFieldChange(value as SortField);
|
||||
}
|
||||
|
||||
function handleViewModeChange(value: string) {
|
||||
viewModeStore.setMode(value as ViewMode);
|
||||
function handleCollapsedChange(collapsed: boolean) {
|
||||
contactsFilterStore.setToolbarCollapsed(collapsed);
|
||||
}
|
||||
</script>
|
||||
|
||||
<PillToolbar topOffset="70px">
|
||||
<!-- New Contact Button -->
|
||||
<PillToolbarButton onclick={() => goto('/contacts/new')} title={$_('contacts.new')}>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<span class="btn-label">{$_('contacts.new')}</span>
|
||||
</PillToolbarButton>
|
||||
<!-- Main Expandable Toolbar (uses its own fixed positioning) -->
|
||||
<ExpandableToolbar
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
{isSidebarMode}
|
||||
collapsedTitle="Filter & Optionen"
|
||||
expandedTitle="Schließen"
|
||||
>
|
||||
<ContactsToolbarContent {contacts} />
|
||||
</ExpandableToolbar>
|
||||
|
||||
<PillToolbarDivider />
|
||||
|
||||
<!-- Selection Mode Toggle -->
|
||||
<PillToolbarButton
|
||||
onclick={onToggleSelectionMode}
|
||||
active={selectionMode}
|
||||
title={selectionMode ? 'Auswahl beenden' : 'Mehrere auswählen'}
|
||||
>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
||||
/>
|
||||
</svg>
|
||||
</PillToolbarButton>
|
||||
|
||||
<PillToolbarDivider />
|
||||
|
||||
<!-- Favorites Toggle -->
|
||||
<PillToolbarButton
|
||||
onclick={toggleFavorites}
|
||||
active={showFavorites}
|
||||
title={$_('filters.contact.favorites')}
|
||||
>
|
||||
<svg fill={showFavorites ? 'currentColor' : 'none'} stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
|
||||
/>
|
||||
</svg>
|
||||
{#if favoritesCount > 0}
|
||||
<span class="count">{favoritesCount}</span>
|
||||
{/if}
|
||||
</PillToolbarButton>
|
||||
|
||||
<PillToolbarDivider />
|
||||
|
||||
<!-- Filter Dropdown -->
|
||||
<FilterBar
|
||||
{contacts}
|
||||
{selectedTagId}
|
||||
{onTagChange}
|
||||
{contactFilter}
|
||||
{onContactFilterChange}
|
||||
{birthdayFilter}
|
||||
{onBirthdayFilterChange}
|
||||
{selectedCompany}
|
||||
{onCompanyChange}
|
||||
embedded={true}
|
||||
/>
|
||||
|
||||
<PillToolbarDivider />
|
||||
|
||||
<!-- Sort Toggle -->
|
||||
<PillViewSwitcher
|
||||
options={sortOptions}
|
||||
value={sortField}
|
||||
onChange={handleSortChange}
|
||||
primaryColor="#6366f1"
|
||||
embedded={true}
|
||||
/>
|
||||
|
||||
<PillToolbarDivider />
|
||||
|
||||
<!-- View Mode -->
|
||||
<div class="view-mode-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="view-btn"
|
||||
class:active={viewModeStore.mode === 'list'}
|
||||
onclick={() => viewModeStore.setMode('list')}
|
||||
title={$_('views.list')}
|
||||
>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- View Mode Pill - positioned to the LEFT of the FAB -->
|
||||
{#if !isSidebarMode}
|
||||
<div class="view-mode-pill" class:expanded={!isCollapsed}>
|
||||
<button
|
||||
type="button"
|
||||
class="view-btn"
|
||||
|
|
@ -212,29 +67,57 @@
|
|||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="view-btn"
|
||||
class:active={viewModeStore.mode === 'network'}
|
||||
onclick={() => viewModeStore.setMode('network')}
|
||||
title={$_('views.network')}
|
||||
>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</PillToolbar>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.btn-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.btn-label {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.view-mode-buttons {
|
||||
/* View Mode Pill - positioned to the LEFT of the FAB (which is at right: calc(50% - 350px - 70px)) */
|
||||
.view-mode-pill {
|
||||
position: fixed;
|
||||
/* Same vertical alignment as FAB: bottom offset + 9px + safe-area */
|
||||
bottom: calc(70px + 9px + env(safe-area-inset-bottom, 0px));
|
||||
/* Position to the left of the FAB: FAB is at right: calc(50% - 350px - 70px), FAB width is 54px, gap is 8px */
|
||||
right: calc(50% - 350px - 70px + 54px + 8px);
|
||||
z-index: 91;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
padding: 0.375rem;
|
||||
background: hsl(var(--color-surface) / 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 9999px;
|
||||
box-shadow: 0 2px 8px hsl(var(--color-foreground) / 0.08);
|
||||
transition: bottom 0.2s ease;
|
||||
}
|
||||
|
||||
/* When toolbar is expanded, move pill up with FAB */
|
||||
.view-mode-pill.expanded {
|
||||
bottom: calc(70px + 70px + 9px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
/* Responsive - on smaller screens, position relative to viewport edge */
|
||||
@media (max-width: 900px) {
|
||||
.view-mode-pill {
|
||||
right: calc(1rem + 54px + 8px); /* FAB at right: 1rem, FAB width 54px, gap 8px */
|
||||
}
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
|
|
@ -246,29 +129,22 @@
|
|||
border: none;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
color: #374151;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
:global(.dark) .view-btn {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.view-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:global(.dark) .view-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background: hsl(var(--color-muted) / 0.5);
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.view-btn.active {
|
||||
background: color-mix(in srgb, #6366f1 15%, transparent 85%);
|
||||
color: #6366f1;
|
||||
background: color-mix(in srgb, #3b82f6 15%, transparent 85%);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.view-btn :global(svg) {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue