mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:41:09 +02:00
Merge pull request #16 from Memo-2023/till-dev
feat: calendar improvements, split-screen, contacts integration, and theme refactoring
This commit is contained in:
commit
b44e515507
177 changed files with 17468 additions and 6896 deletions
|
|
@ -6,7 +6,7 @@ pnpm docker:up:all
|
|||
|
||||
pnpm docker:down
|
||||
|
||||
pnpm dev:calendar:app
|
||||
pnpm dev:calendar:full
|
||||
pnpm dev:todo:full
|
||||
pnpm dev:contacts:full
|
||||
pnpm dev:clock:full
|
||||
|
|
|
|||
|
|
@ -113,12 +113,56 @@ export class CalendarService {
|
|||
return updated;
|
||||
}
|
||||
|
||||
// Create a new default calendar
|
||||
return this.create(userId, {
|
||||
name: 'Mein Kalender',
|
||||
isDefault: true,
|
||||
color: '#3B82F6',
|
||||
});
|
||||
// Create default calendars for new user
|
||||
await this.createDefaultCalendars(userId);
|
||||
|
||||
// Return the default one
|
||||
const defaultCal = await this.db
|
||||
.select()
|
||||
.from(calendars)
|
||||
.where(and(eq(calendars.userId, userId), eq(calendars.isDefault, true)));
|
||||
|
||||
return defaultCal[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default calendars for a new user
|
||||
*/
|
||||
async createDefaultCalendars(userId: string): Promise<Calendar[]> {
|
||||
const defaultCalendars = [
|
||||
{
|
||||
name: 'Persönlich',
|
||||
color: '#3B82F6', // Blue
|
||||
isDefault: true,
|
||||
description: 'Private Termine',
|
||||
},
|
||||
{
|
||||
name: 'Beruf',
|
||||
color: '#10B981', // Green
|
||||
isDefault: false,
|
||||
description: 'Arbeit, Meetings, Projekte',
|
||||
},
|
||||
{
|
||||
name: 'Familie',
|
||||
color: '#F97316', // Orange
|
||||
isDefault: false,
|
||||
description: 'Familientermine, Geburtstage',
|
||||
},
|
||||
{
|
||||
name: 'Freizeit',
|
||||
color: '#8B5CF6', // Violet
|
||||
isDefault: false,
|
||||
description: 'Hobbies, Sport, Events',
|
||||
},
|
||||
];
|
||||
|
||||
const created: Calendar[] = [];
|
||||
for (const cal of defaultCalendars) {
|
||||
const calendar = await this.create(userId, cal);
|
||||
created.push(calendar);
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
private async clearDefaultCalendar(userId: string): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -12,8 +12,9 @@ import {
|
|||
import type { EventMetadata } from '../../db/schema/events.schema';
|
||||
|
||||
export class CreateEventDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
calendarId: string;
|
||||
calendarId?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
|
|
|
|||
|
|
@ -85,11 +85,20 @@ export class EventService {
|
|||
}
|
||||
|
||||
async create(userId: string, dto: CreateEventDto): Promise<Event> {
|
||||
// Verify user owns the calendar
|
||||
const calendar = await this.calendarService.findByIdOrThrow(dto.calendarId, userId);
|
||||
let calendarId = dto.calendarId;
|
||||
let calendar;
|
||||
|
||||
// If no calendarId provided, get or create default calendar
|
||||
if (!calendarId) {
|
||||
calendar = await this.calendarService.getOrCreateDefaultCalendar(userId);
|
||||
calendarId = calendar.id;
|
||||
} else {
|
||||
// Verify user owns the specified calendar
|
||||
calendar = await this.calendarService.findByIdOrThrow(calendarId, userId);
|
||||
}
|
||||
|
||||
const newEvent: NewEvent = {
|
||||
calendarId: dto.calendarId,
|
||||
calendarId,
|
||||
userId,
|
||||
title: dto.title,
|
||||
description: dto.description,
|
||||
|
|
@ -175,6 +184,16 @@ export class EventService {
|
|||
conditions.push(inArray(events.calendarId, query.calendarIds));
|
||||
}
|
||||
|
||||
// Search filter - search in title and description
|
||||
if (query.search) {
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(events.title, `%${query.search}%`),
|
||||
ilike(events.description, `%${query.search}%`)
|
||||
) as any
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.db
|
||||
.select({
|
||||
event: events,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ COPY packages/shared-profile-ui ./packages/shared-profile-ui
|
|||
COPY packages/shared-ui ./packages/shared-ui
|
||||
COPY packages/shared-utils ./packages/shared-utils
|
||||
COPY packages/shared-tags ./packages/shared-tags
|
||||
COPY packages/shared-splitscreen ./packages/shared-splitscreen
|
||||
|
||||
# Copy calendar packages and web
|
||||
COPY apps/calendar/packages ./apps/calendar/packages
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@types/d3-force": "^3.0.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/suncalc": "^1.9.2",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-svelte": "^3.1.2",
|
||||
"svelte": "^5.0.0",
|
||||
|
|
@ -31,6 +32,7 @@
|
|||
"dependencies": {
|
||||
"@calendar/shared": "workspace:*",
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-splitscreen": "workspace:*",
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
"@manacore/shared-feedback-service": "workspace:*",
|
||||
|
|
@ -43,12 +45,14 @@
|
|||
"@manacore/shared-tailwind": "workspace:*",
|
||||
"@manacore/shared-theme": "workspace:*",
|
||||
"@manacore/shared-theme-ui": "workspace:*",
|
||||
"@manacore/shared-types": "workspace:*",
|
||||
"@manacore/shared-ui": "workspace:*",
|
||||
"@manacore/shared-utils": "workspace:*",
|
||||
"@neodrag/svelte": "^2.3.3",
|
||||
"d3-force": "^3.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-svelte": "^0.559.0",
|
||||
"suncalc": "^1.9.0",
|
||||
"svelte-dnd-action": "^0.9.68",
|
||||
"svelte-i18n": "^4.0.1"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -42,18 +42,18 @@
|
|||
/* Hour slot in day/week view */
|
||||
.hour-slot {
|
||||
height: var(--hour-height);
|
||||
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 50%, transparent);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hour-slot:hover {
|
||||
background-color: hsl(var(--color-muted) / 0.3);
|
||||
background-color: color-mix(in srgb, var(--color-muted) 30%, transparent);
|
||||
}
|
||||
|
||||
/* Event card in calendar */
|
||||
.event-card {
|
||||
background-color: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-primary-foreground);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 6px;
|
||||
font-size: 0.75rem;
|
||||
|
|
@ -70,17 +70,17 @@
|
|||
/* Day cell in month view */
|
||||
.day-cell {
|
||||
min-height: 100px;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border: 1px solid var(--color-border);
|
||||
padding: var(--spacing-xs);
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.day-cell:hover {
|
||||
background-color: hsl(var(--color-muted) / 0.3);
|
||||
background-color: color-mix(in srgb, var(--color-muted) 30%, transparent);
|
||||
}
|
||||
|
||||
.day-cell.today {
|
||||
background-color: hsl(var(--color-primary) / 0.1);
|
||||
background-color: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
}
|
||||
|
||||
.day-cell.other-month {
|
||||
|
|
@ -93,7 +93,7 @@
|
|||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background-color: hsl(var(--color-error));
|
||||
background-color: var(--color-error);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
|
|
@ -105,7 +105,7 @@
|
|||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background-color: hsl(var(--color-error));
|
||||
background-color: var(--color-error);
|
||||
}
|
||||
|
||||
/* Mini calendar */
|
||||
|
|
@ -125,24 +125,24 @@
|
|||
}
|
||||
|
||||
.mini-calendar .day:hover {
|
||||
background-color: hsl(var(--color-muted));
|
||||
background-color: var(--color-muted);
|
||||
}
|
||||
|
||||
.mini-calendar .day.today {
|
||||
background-color: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-primary-foreground);
|
||||
}
|
||||
|
||||
.mini-calendar .day.selected {
|
||||
border: 2px solid hsl(var(--color-primary));
|
||||
border: 2px solid var(--color-primary);
|
||||
}
|
||||
|
||||
/* Card styles */
|
||||
.card {
|
||||
background-color: hsl(var(--color-surface));
|
||||
background-color: var(--color-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
|
|
@ -161,12 +161,12 @@
|
|||
}
|
||||
|
||||
.btn-primary {
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-foreground);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: hsl(var(--color-primary) / 0.9);
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
|
|
@ -175,21 +175,21 @@
|
|||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: hsl(var(--color-secondary));
|
||||
color: hsl(var(--color-secondary-foreground));
|
||||
background: var(--color-secondary);
|
||||
color: var(--color-secondary-foreground);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: hsl(var(--color-secondary) / 0.8);
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: hsl(var(--color-foreground));
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: hsl(var(--color-muted));
|
||||
background: var(--color-muted);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
|
|
@ -206,21 +206,21 @@
|
|||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 2px solid hsl(var(--color-border));
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: hsl(var(--color-background));
|
||||
color: hsl(var(--color-foreground));
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
font-size: 0.875rem;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--color-primary));
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
/* Select styling */
|
||||
|
|
@ -235,7 +235,7 @@ select.input {
|
|||
|
||||
/* Text colors */
|
||||
.text-destructive {
|
||||
color: hsl(var(--color-error));
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
|
|
|
|||
117
apps/calendar/apps/web/src/lib/api/base-client.ts
Normal file
117
apps/calendar/apps/web/src/lib/api/base-client.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/**
|
||||
* Base API Client Factory
|
||||
* Eliminates duplication between calendar and todo API clients
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
|
||||
export interface FetchOptions {
|
||||
method?: HttpMethod;
|
||||
body?: unknown;
|
||||
token?: string;
|
||||
isFormData?: boolean;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface ApiResult<T> {
|
||||
data: T | null;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export interface ApiClientConfig {
|
||||
baseUrl: string;
|
||||
apiPrefix?: string;
|
||||
getAuthToken?: () => string | null;
|
||||
defaultTimeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a configured API client for a specific backend
|
||||
*/
|
||||
export function createApiClient(config: ApiClientConfig) {
|
||||
const { baseUrl, apiPrefix = '/api/v1', defaultTimeout = 30000 } = config;
|
||||
|
||||
async function fetchApi<T>(endpoint: string, options: FetchOptions = {}): Promise<ApiResult<T>> {
|
||||
const { method = 'GET', body, token, isFormData = false, timeout = defaultTimeout } = options;
|
||||
|
||||
// Get auth token
|
||||
let authToken = token;
|
||||
if (!authToken && browser) {
|
||||
authToken = config.getAuthToken?.() ?? localStorage.getItem('@auth/appToken') ?? undefined;
|
||||
}
|
||||
|
||||
// Setup abort controller for timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
// Don't set Content-Type for FormData - browser sets it automatically with boundary
|
||||
if (!isFormData) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
if (authToken) {
|
||||
headers['Authorization'] = `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}${apiPrefix}${endpoint}`, {
|
||||
method,
|
||||
headers,
|
||||
body: isFormData ? (body as FormData) : body ? JSON.stringify(body) : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
return {
|
||||
data: null,
|
||||
error: new Error(errorData.message || `API error: ${response.status}`),
|
||||
};
|
||||
}
|
||||
|
||||
// Handle empty responses (204 No Content)
|
||||
if (response.status === 204) {
|
||||
return { data: null, error: null };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return { data, error: null };
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return {
|
||||
data: null,
|
||||
error: new Error('Request timed out'),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: null,
|
||||
error: error instanceof Error ? error : new Error('Unknown error'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { fetchApi };
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to build query strings from object
|
||||
*/
|
||||
export function buildQueryString(params: Record<string, unknown>): string {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
const queryString = searchParams.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
|
@ -6,63 +6,21 @@
|
|||
*/
|
||||
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { createApiClient, type FetchOptions, type ApiResult } from './base-client';
|
||||
|
||||
const API_BASE = env.PUBLIC_BACKEND_URL || 'http://localhost:3014';
|
||||
|
||||
type FetchOptions = {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
body?: unknown;
|
||||
token?: string;
|
||||
isFormData?: boolean;
|
||||
};
|
||||
const calendarClient = createApiClient({
|
||||
baseUrl: API_BASE,
|
||||
apiPrefix: '/api/v1',
|
||||
});
|
||||
|
||||
export async function fetchApi<T>(
|
||||
endpoint: string,
|
||||
options: FetchOptions = {}
|
||||
): Promise<{ data: T | null; error: Error | null }> {
|
||||
const { method = 'GET', body, token, isFormData = false } = options;
|
||||
|
||||
// Get a valid token (auto-refreshes if expired)
|
||||
const authToken = token || (await authStore.getValidToken());
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
// Don't set Content-Type for FormData - browser sets it automatically with boundary
|
||||
if (!isFormData) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
if (authToken) {
|
||||
headers['Authorization'] = `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/v1${endpoint}`, {
|
||||
method,
|
||||
headers,
|
||||
body: isFormData ? (body as FormData) : body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
return {
|
||||
data: null,
|
||||
error: new Error(errorData.message || `API error: ${response.status}`),
|
||||
};
|
||||
}
|
||||
|
||||
// Handle empty responses (204 No Content)
|
||||
if (response.status === 204) {
|
||||
return { data: null, error: null };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return { data, error: null };
|
||||
} catch (error) {
|
||||
return {
|
||||
data: null,
|
||||
error: error instanceof Error ? error : new Error('Unknown error'),
|
||||
};
|
||||
}
|
||||
): Promise<ApiResult<T>> {
|
||||
return calendarClient.fetchApi<T>(endpoint, options);
|
||||
}
|
||||
|
||||
// Re-export types for backwards compatibility
|
||||
export type { FetchOptions, ApiResult };
|
||||
|
|
|
|||
|
|
@ -23,17 +23,22 @@ export async function getEvents(params: QueryEventsParams) {
|
|||
if (params.search) {
|
||||
searchParams.set('search', params.search);
|
||||
}
|
||||
return fetchApi<CalendarEvent[]>(`/events?${searchParams.toString()}`);
|
||||
const result = await fetchApi<{ events: CalendarEvent[] }>(`/events?${searchParams.toString()}`);
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
return { data: result.data.events, error: null };
|
||||
}
|
||||
|
||||
export async function searchEvents(query: string, limit: number = 10) {
|
||||
// Search events within the next year
|
||||
const now = new Date();
|
||||
// Search events within a wide range (1 year past to 1 year future)
|
||||
const oneYearAgo = new Date();
|
||||
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
|
||||
const oneYearFromNow = new Date();
|
||||
oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1);
|
||||
|
||||
return getEvents({
|
||||
startDate: now.toISOString(),
|
||||
startDate: oneYearAgo.toISOString(),
|
||||
endDate: oneYearFromNow.toISOString(),
|
||||
search: query,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,11 +3,16 @@
|
|||
* Allows Calendar app to fetch/manage todos from the Todo service
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { createApiClient, buildQueryString } from './base-client';
|
||||
|
||||
const TODO_API_BASE = env.PUBLIC_TODO_BACKEND_URL || 'http://localhost:3018';
|
||||
|
||||
const todoClient = createApiClient({
|
||||
baseUrl: TODO_API_BASE,
|
||||
apiPrefix: '/api/v1',
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Types (mirrored from @todo/shared for cross-app use)
|
||||
// ============================================
|
||||
|
|
@ -68,6 +73,11 @@ export interface Task {
|
|||
dueDate?: string | null;
|
||||
dueTime?: string | null;
|
||||
startDate?: string | null;
|
||||
// Time-Blocking (for calendar integration)
|
||||
scheduledDate?: string | null;
|
||||
scheduledStartTime?: string | null; // HH:mm format
|
||||
scheduledEndTime?: string | null; // HH:mm format
|
||||
estimatedDuration?: number | null; // Duration in minutes
|
||||
priority: TaskPriority;
|
||||
status: TaskStatus;
|
||||
isCompleted: boolean;
|
||||
|
|
@ -92,6 +102,11 @@ export interface CreateTaskInput {
|
|||
projectId?: string | null;
|
||||
dueDate?: string | null;
|
||||
dueTime?: string | null;
|
||||
// Time-Blocking
|
||||
scheduledDate?: string | null;
|
||||
scheduledStartTime?: string | null;
|
||||
scheduledEndTime?: string | null;
|
||||
estimatedDuration?: number | null;
|
||||
priority?: TaskPriority;
|
||||
labelIds?: string[];
|
||||
subtasks?: Omit<Subtask, 'id'>[];
|
||||
|
|
@ -105,6 +120,11 @@ export interface UpdateTaskInput {
|
|||
projectId?: string | null;
|
||||
dueDate?: string | null;
|
||||
dueTime?: string | null;
|
||||
// Time-Blocking
|
||||
scheduledDate?: string | null;
|
||||
scheduledStartTime?: string | null;
|
||||
scheduledEndTime?: string | null;
|
||||
estimatedDuration?: number | null;
|
||||
priority?: TaskPriority;
|
||||
status?: TaskStatus;
|
||||
isCompleted?: boolean;
|
||||
|
|
@ -150,78 +170,10 @@ interface LabelsResponse {
|
|||
}
|
||||
|
||||
// ============================================
|
||||
// API Client
|
||||
// API Client (using shared base client)
|
||||
// ============================================
|
||||
|
||||
type FetchOptions = {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
body?: unknown;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
async function fetchTodoApi<T>(
|
||||
endpoint: string,
|
||||
options: FetchOptions = {}
|
||||
): Promise<{ data: T | null; error: Error | null }> {
|
||||
const { method = 'GET', body, token } = options;
|
||||
|
||||
let authToken = token;
|
||||
if (!authToken && browser) {
|
||||
authToken = localStorage.getItem('@auth/appToken') || undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (authToken) {
|
||||
headers['Authorization'] = `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${TODO_API_BASE}/api/v1${endpoint}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
return {
|
||||
data: null,
|
||||
error: new Error(errorData.message || `Todo API error: ${response.status}`),
|
||||
};
|
||||
}
|
||||
|
||||
// Handle empty responses (204 No Content)
|
||||
if (response.status === 204) {
|
||||
return { data: null, error: null };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return { data, error: null };
|
||||
} catch (error) {
|
||||
return {
|
||||
data: null,
|
||||
error: error instanceof Error ? error : new Error('Failed to connect to Todo service'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Helper Functions
|
||||
// ============================================
|
||||
|
||||
function buildQueryString(query: TaskQuery): string {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
params.append(key, String(value));
|
||||
}
|
||||
});
|
||||
const queryString = params.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
const fetchTodoApi = todoClient.fetchApi;
|
||||
|
||||
// ============================================
|
||||
// Task API Functions
|
||||
|
|
@ -230,7 +182,7 @@ function buildQueryString(query: TaskQuery): string {
|
|||
export async function getTasks(
|
||||
query: TaskQuery = {}
|
||||
): Promise<{ data: Task[] | null; error: Error | null }> {
|
||||
const queryString = buildQueryString(query);
|
||||
const queryString = buildQueryString(query as Record<string, unknown>);
|
||||
const result = await fetchTodoApi<TasksResponse>(`/tasks${queryString}`);
|
||||
return {
|
||||
data: result.data?.tasks || null,
|
||||
|
|
|
|||
|
|
@ -1,16 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { toast } from '$lib/stores/toast';
|
||||
import type { Toast } from '$lib/stores/toast';
|
||||
import { toastStore, type Toast } from '$lib/stores/toast.svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
let toasts = $state<Toast[]>([]);
|
||||
|
||||
toast.subscribe((value) => {
|
||||
toasts = value;
|
||||
});
|
||||
// Reactive getter from the runes-based store
|
||||
let toasts = $derived(toastStore.toasts);
|
||||
|
||||
function handleClose(id: string) {
|
||||
toast.remove(id);
|
||||
toastStore.remove(id);
|
||||
}
|
||||
|
||||
function getIcon(type: Toast['type']) {
|
||||
|
|
|
|||
|
|
@ -1,33 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { isNavCollapsed } from '$lib/stores/navigation';
|
||||
import { format } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import type { CalendarViewType } from '@calendar/shared';
|
||||
|
||||
// View type labels
|
||||
const viewLabels: Record<CalendarViewType, string> = {
|
||||
day: 'Tag',
|
||||
'5day': '5 Tage',
|
||||
week: 'Woche',
|
||||
'10day': '10 Tage',
|
||||
'14day': '14 Tage',
|
||||
month: 'Monat',
|
||||
year: 'Jahr',
|
||||
agenda: 'Agenda',
|
||||
};
|
||||
|
||||
// Views to show in selector
|
||||
const visibleViews: CalendarViewType[] = [
|
||||
'day',
|
||||
'5day',
|
||||
'week',
|
||||
'10day',
|
||||
'14day',
|
||||
'month',
|
||||
'year',
|
||||
];
|
||||
|
||||
// Format title based on view type
|
||||
let title = $derived.by(() => {
|
||||
|
|
@ -69,133 +43,16 @@
|
|||
return format(date, 'MMMM yyyy', { locale: de });
|
||||
}
|
||||
});
|
||||
|
||||
function handleViewChange(type: CalendarViewType) {
|
||||
viewStore.setViewType(type);
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="calendar-header" class:nav-collapsed={$isNavCollapsed}>
|
||||
<div class="header-left">
|
||||
<button class="today-btn" onclick={() => viewStore.goToToday()}> Heute </button>
|
||||
|
||||
<div class="nav-buttons">
|
||||
<button class="nav-btn" onclick={() => viewStore.goToPrevious()} aria-label="Zurück">
|
||||
<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="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="nav-btn" onclick={() => viewStore.goToNext()} aria-label="Weiter">
|
||||
<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="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h1 class="header-title">{title}</h1>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<!-- Filter toggles -->
|
||||
<div class="filter-toggles">
|
||||
<!-- Weekdays only toggle -->
|
||||
<button
|
||||
class="filter-toggle"
|
||||
class:active={settingsStore.showOnlyWeekdays}
|
||||
onclick={() => settingsStore.set('showOnlyWeekdays', !settingsStore.showOnlyWeekdays)}
|
||||
title="Nur Wochentage anzeigen (Mo-Fr)"
|
||||
>
|
||||
Mo-Fr
|
||||
</button>
|
||||
|
||||
<!-- Filter hours toggle -->
|
||||
<button
|
||||
class="filter-toggle"
|
||||
class:active={settingsStore.filterHoursEnabled}
|
||||
onclick={() => settingsStore.set('filterHoursEnabled', !settingsStore.filterHoursEnabled)}
|
||||
title="Stunden filtern ({settingsStore.dayStartHour}-{settingsStore.dayEndHour} Uhr)"
|
||||
>
|
||||
{settingsStore.dayStartHour}-{settingsStore.dayEndHour}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="view-selector">
|
||||
{#each visibleViews as type}
|
||||
<button
|
||||
class="view-btn"
|
||||
class:active={viewStore.viewType === type}
|
||||
onclick={() => handleViewChange(type)}
|
||||
>
|
||||
{viewLabels[type]}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<header class="calendar-header">
|
||||
<h1 class="header-title">{title}</h1>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
background: hsl(var(--color-background));
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
transition: padding-left 300ms ease;
|
||||
}
|
||||
|
||||
.calendar-header.nav-collapsed {
|
||||
padding-left: 4rem;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.today-btn {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-foreground));
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.today-btn:hover {
|
||||
background: hsl(var(--color-muted));
|
||||
}
|
||||
|
||||
.nav-buttons {
|
||||
display: flex;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
padding: 0.25rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
background: hsl(var(--color-muted));
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.header-title {
|
||||
|
|
@ -205,83 +62,9 @@
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.view-selector {
|
||||
display: flex;
|
||||
background: hsl(var(--color-muted));
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.125rem;
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.view-btn:hover {
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.view-btn.active {
|
||||
background: hsl(var(--color-background));
|
||||
color: hsl(var(--color-foreground));
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.filter-toggles {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.filter-toggle {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.filter-toggle:hover {
|
||||
background: hsl(var(--color-muted));
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.filter-toggle.active {
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
border-color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.calendar-header {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 0.875rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,189 @@
|
|||
<script lang="ts">
|
||||
import { PillToolbar, PillToolbarDivider } from '@manacore/shared-ui';
|
||||
import CalendarToolbarContent from './CalendarToolbarContent.svelte';
|
||||
|
||||
interface Props {
|
||||
isSidebarMode?: boolean;
|
||||
isCollapsed?: boolean;
|
||||
onModeChange?: (isSidebar: boolean) => void;
|
||||
onCollapsedChange?: (isCollapsed: boolean) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
isSidebarMode = false,
|
||||
isCollapsed = false,
|
||||
onModeChange,
|
||||
onCollapsedChange,
|
||||
}: Props = $props();
|
||||
|
||||
function toggleSidebarMode() {
|
||||
onModeChange?.(!isSidebarMode);
|
||||
}
|
||||
|
||||
function collapseToolbar() {
|
||||
onCollapsedChange?.(true);
|
||||
}
|
||||
|
||||
function expandToolbar() {
|
||||
onCollapsedChange?.(false);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !isCollapsed}
|
||||
<PillToolbar position="bottom" bottomOffset={isSidebarMode ? '0px' : '70px'}>
|
||||
<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}
|
||||
|
||||
<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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.375rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
border-radius: 9999px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.segment-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
:global(.dark) .segment-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 {
|
||||
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>
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
<script lang="ts">
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import type { CalendarViewType } from '@calendar/shared';
|
||||
import {
|
||||
PillToolbarButton,
|
||||
PillToolbarDivider,
|
||||
PillTimeRangeSelector,
|
||||
PillViewSwitcher,
|
||||
} from '@manacore/shared-ui';
|
||||
import PillCalendarSelector from './PillCalendarSelector.svelte';
|
||||
|
||||
interface Props {
|
||||
vertical?: boolean;
|
||||
}
|
||||
|
||||
let { vertical = false }: Props = $props();
|
||||
|
||||
// View type labels
|
||||
const viewLabels: Record<CalendarViewType, string> = {
|
||||
day: 'Tag',
|
||||
'5day': '5 Tage',
|
||||
week: 'Woche',
|
||||
'10day': '10 Tage',
|
||||
'14day': '14 Tage',
|
||||
month: 'Monat',
|
||||
year: 'Jahr',
|
||||
agenda: 'Agenda',
|
||||
};
|
||||
|
||||
// Views to show in selector
|
||||
const visibleViews: CalendarViewType[] = [
|
||||
'day',
|
||||
'5day',
|
||||
'week',
|
||||
'10day',
|
||||
'14day',
|
||||
'month',
|
||||
'year',
|
||||
];
|
||||
|
||||
// Convert to ViewOptions for PillViewSwitcher
|
||||
const viewOptions = visibleViews.map((type) => ({
|
||||
id: type,
|
||||
label: viewLabels[type],
|
||||
title: viewLabels[type],
|
||||
}));
|
||||
|
||||
// Hours change handlers
|
||||
function handleStartHourChange(hour: number) {
|
||||
settingsStore.set('dayStartHour', hour);
|
||||
}
|
||||
|
||||
function handleEndHourChange(hour: number) {
|
||||
settingsStore.set('dayEndHour', hour);
|
||||
}
|
||||
|
||||
function handleViewChange(type: string) {
|
||||
viewStore.setViewType(type as CalendarViewType);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="toolbar-content" class:vertical>
|
||||
<!-- Calendar selector -->
|
||||
<PillCalendarSelector direction={vertical ? 'down' : 'up'} embedded={true} />
|
||||
|
||||
{#if !vertical}
|
||||
<PillToolbarDivider />
|
||||
{/if}
|
||||
|
||||
<!-- Weekdays filter -->
|
||||
<PillToolbarButton
|
||||
onclick={() => settingsStore.set('showOnlyWeekdays', !settingsStore.showOnlyWeekdays)}
|
||||
active={settingsStore.showOnlyWeekdays}
|
||||
title="Nur Wochentage anzeigen (Mo-Fr)"
|
||||
>
|
||||
Mo-Fr
|
||||
</PillToolbarButton>
|
||||
|
||||
<!-- Hours filter with time range selector -->
|
||||
<PillTimeRangeSelector
|
||||
startHour={settingsStore.dayStartHour}
|
||||
endHour={settingsStore.dayEndHour}
|
||||
onStartHourChange={handleStartHourChange}
|
||||
onEndHourChange={handleEndHourChange}
|
||||
direction={vertical ? 'down' : 'up'}
|
||||
embedded={true}
|
||||
toggleMode={true}
|
||||
active={settingsStore.filterHoursEnabled}
|
||||
onToggle={() => settingsStore.set('filterHoursEnabled', !settingsStore.filterHoursEnabled)}
|
||||
labelFormat="range"
|
||||
/>
|
||||
|
||||
{#if !vertical}
|
||||
<PillToolbarDivider />
|
||||
{/if}
|
||||
|
||||
<!-- View selector -->
|
||||
<PillViewSwitcher
|
||||
options={viewOptions}
|
||||
value={viewStore.viewType}
|
||||
onChange={handleViewChange}
|
||||
primaryColor="#3b82f6"
|
||||
embedded={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toolbar-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.toolbar-content.vertical {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* All elements in vertical mode - full width, left aligned */
|
||||
.toolbar-content.vertical :global(.pill-toolbar-btn),
|
||||
.toolbar-content.vertical :global(.pill-dropdown .trigger-button),
|
||||
.toolbar-content.vertical :global(button) {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* PillViewSwitcher in vertical mode */
|
||||
.toolbar-content.vertical :global(.pill-view-switcher) {
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Hide the sliding indicator in vertical mode */
|
||||
.toolbar-content.vertical :global(.pill-view-switcher .sliding-indicator) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toolbar-content.vertical :global(.pill-view-switcher .switcher-btn) {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border-radius: 9999px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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%);
|
||||
}
|
||||
|
||||
/* PillTimeRangeSelector in vertical mode */
|
||||
.toolbar-content.vertical :global(.pill-time-range-selector),
|
||||
.toolbar-content.vertical :global(.pill-dropdown) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toolbar-content.vertical :global(.pill-time-range-selector .trigger-button),
|
||||
.toolbar-content.vertical :global(.pill-dropdown .trigger-button) {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* PillCalendarSelector in vertical mode */
|
||||
.toolbar-content.vertical :global(.calendar-selector) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toolbar-content.vertical :global(.calendar-selector .trigger-button) {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,500 @@
|
|||
<script lang="ts">
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
import {
|
||||
format,
|
||||
isToday,
|
||||
isSameDay,
|
||||
addDays,
|
||||
subDays,
|
||||
startOfDay,
|
||||
isWithinInterval,
|
||||
} from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import SunCalc from 'suncalc';
|
||||
|
||||
interface Props {
|
||||
isSidebarMode?: boolean;
|
||||
}
|
||||
|
||||
let { isSidebarMode = false }: Props = $props();
|
||||
|
||||
// Get event count for a day (max 5 dots displayed)
|
||||
function getEventCount(date: Date): number {
|
||||
const events = eventsStore.getEventsForDay(date, false);
|
||||
return Math.min(events.length, 5); // Cap at 5 dots
|
||||
}
|
||||
|
||||
// Moon phase emojis (8 phases)
|
||||
const MOON_EMOJIS = ['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘'];
|
||||
|
||||
// Get moon emoji for a date
|
||||
function getMoonEmoji(date: Date): string {
|
||||
const moonData = SunCalc.getMoonIllumination(date);
|
||||
// phase: 0 = new moon, 0.25 = first quarter, 0.5 = full moon, 0.75 = last quarter
|
||||
const phaseIndex = Math.floor(moonData.phase * 8) % 8;
|
||||
return MOON_EMOJIS[phaseIndex];
|
||||
}
|
||||
|
||||
// Check if this is a significant moon phase (new, first quarter, full, last quarter)
|
||||
function isSignificantMoonPhase(date: Date): { significant: boolean; emoji: string } {
|
||||
const moonData = SunCalc.getMoonIllumination(date);
|
||||
const phase = moonData.phase;
|
||||
// Lunar cycle is ~29.53 days, so 1 day = ~0.0339
|
||||
// Use half a day tolerance (~0.017) to ensure only 1 day is marked
|
||||
const tolerance = 0.017;
|
||||
|
||||
if (phase < tolerance || phase > 1 - tolerance) {
|
||||
return { significant: true, emoji: '🌑' }; // New moon
|
||||
}
|
||||
if (Math.abs(phase - 0.25) < tolerance) {
|
||||
return { significant: true, emoji: '🌓' }; // First quarter
|
||||
}
|
||||
if (Math.abs(phase - 0.5) < tolerance) {
|
||||
return { significant: true, emoji: '🌕' }; // Full moon
|
||||
}
|
||||
if (Math.abs(phase - 0.75) < tolerance) {
|
||||
return { significant: true, emoji: '🌗' }; // Last quarter
|
||||
}
|
||||
|
||||
return { significant: false, emoji: '' };
|
||||
}
|
||||
|
||||
// Reactive view range - needed to trigger re-renders
|
||||
let viewRange = $derived(viewStore.viewRange);
|
||||
let currentDate = $derived(viewStore.currentDate);
|
||||
|
||||
// How many days to load in each direction
|
||||
const DAYS_BUFFER = 60;
|
||||
const LOAD_THRESHOLD = 20;
|
||||
|
||||
// Generate initial days centered around current date
|
||||
let startDate = $state(subDays(startOfDay(new Date()), DAYS_BUFFER));
|
||||
let endDate = $state(addDays(startOfDay(new Date()), DAYS_BUFFER));
|
||||
|
||||
// Track if today is visible in the scroll view
|
||||
let isTodayVisible = $state(true);
|
||||
|
||||
// Generate array of days
|
||||
let days = $derived.by(() => {
|
||||
const result: Date[] = [];
|
||||
let current = startDate;
|
||||
while (current <= endDate) {
|
||||
result.push(current);
|
||||
current = addDays(current, 1);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
// Scroll container ref
|
||||
let scrollContainer: HTMLDivElement;
|
||||
let isLoadingMore = false;
|
||||
|
||||
// Scroll to selected date when it changes
|
||||
$effect(() => {
|
||||
if (scrollContainer && currentDate) {
|
||||
scrollToDate(currentDate);
|
||||
}
|
||||
});
|
||||
|
||||
async function scrollToDate(date: Date) {
|
||||
await tick();
|
||||
|
||||
const targetDate = startOfDay(date);
|
||||
if (targetDate < startDate) {
|
||||
startDate = subDays(targetDate, DAYS_BUFFER);
|
||||
await tick();
|
||||
} else if (targetDate > endDate) {
|
||||
endDate = addDays(targetDate, DAYS_BUFFER);
|
||||
await tick();
|
||||
}
|
||||
|
||||
const dayElement = scrollContainer?.querySelector(
|
||||
`[data-date="${format(date, 'yyyy-MM-dd')}"]`
|
||||
);
|
||||
if (dayElement) {
|
||||
dayElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
inline: 'center',
|
||||
block: 'nearest',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleDayClick(day: Date) {
|
||||
viewStore.setDate(day);
|
||||
}
|
||||
|
||||
function goToToday() {
|
||||
const today = new Date();
|
||||
viewStore.setDate(today);
|
||||
}
|
||||
|
||||
async function loadMoreDays(direction: 'past' | 'future') {
|
||||
if (isLoadingMore) return;
|
||||
isLoadingMore = true;
|
||||
|
||||
if (direction === 'past') {
|
||||
const scrollLeftBefore = scrollContainer?.scrollLeft || 0;
|
||||
startDate = subDays(startDate, DAYS_BUFFER);
|
||||
await tick();
|
||||
if (scrollContainer) {
|
||||
scrollContainer.scrollLeft = scrollLeftBefore + DAYS_BUFFER * 54;
|
||||
}
|
||||
} else {
|
||||
endDate = addDays(endDate, DAYS_BUFFER);
|
||||
}
|
||||
|
||||
isLoadingMore = false;
|
||||
}
|
||||
|
||||
function checkTodayVisibility() {
|
||||
if (!scrollContainer) return;
|
||||
|
||||
const todayElement = scrollContainer.querySelector('[data-is-today="true"]');
|
||||
if (!todayElement) {
|
||||
isTodayVisible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const containerRect = scrollContainer.getBoundingClientRect();
|
||||
const todayRect = todayElement.getBoundingClientRect();
|
||||
|
||||
isTodayVisible =
|
||||
todayRect.left >= containerRect.left - 20 && todayRect.right <= containerRect.right + 20;
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
if (!scrollContainer || isLoadingMore) return;
|
||||
|
||||
checkTodayVisibility();
|
||||
updateVisibleMonth();
|
||||
|
||||
const { scrollLeft, clientWidth } = scrollContainer;
|
||||
const dayWidth = 54;
|
||||
const visibleDayIndex = Math.floor(scrollLeft / dayWidth);
|
||||
const totalDays = days.length;
|
||||
|
||||
if (visibleDayIndex < LOAD_THRESHOLD) {
|
||||
loadMoreDays('past');
|
||||
}
|
||||
|
||||
if (totalDays - visibleDayIndex - Math.floor(clientWidth / dayWidth) < LOAD_THRESHOLD) {
|
||||
loadMoreDays('future');
|
||||
}
|
||||
}
|
||||
|
||||
// Get the month of the center visible day
|
||||
let visibleMonth = $state(format(new Date(), 'MMMM yyyy', { locale: de }));
|
||||
|
||||
function updateVisibleMonth() {
|
||||
if (!scrollContainer) return;
|
||||
|
||||
const containerRect = scrollContainer.getBoundingClientRect();
|
||||
const centerX = containerRect.left + containerRect.width / 2;
|
||||
|
||||
// Find the day element closest to center
|
||||
const dayElements = scrollContainer.querySelectorAll('.day-item');
|
||||
for (const el of dayElements) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.left <= centerX && rect.right >= centerX) {
|
||||
const dateStr = el.getAttribute('data-date');
|
||||
if (dateStr) {
|
||||
const date = new Date(dateStr);
|
||||
visibleMonth = format(date, 'MMMM yyyy', { locale: de });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
scrollToDate(viewStore.currentDate);
|
||||
});
|
||||
</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">
|
||||
<!-- Month label -->
|
||||
<div class="month-header">
|
||||
<span class="month-label">{visibleMonth}</span>
|
||||
</div>
|
||||
|
||||
<!-- Days row -->
|
||||
<div class="days-scroll" bind:this={scrollContainer} onscroll={handleScroll}>
|
||||
{#each days as day}
|
||||
{@const dayIsToday = isToday(day)}
|
||||
{@const dayIsSelected = isSameDay(day, currentDate)}
|
||||
{@const dayIsWeekend = day.getDay() === 0 || day.getDay() === 6}
|
||||
{@const dayInRange = isWithinInterval(day, { start: viewRange.start, end: viewRange.end })}
|
||||
{@const dayIsRangeStart = isSameDay(day, viewRange.start)}
|
||||
{@const dayIsRangeEnd = isSameDay(day, viewRange.end)}
|
||||
{@const isFirstOfMonth = day.getDate() === 1}
|
||||
{@const moonPhase = isSignificantMoonPhase(day)}
|
||||
{@const eventCount = getEventCount(day)}
|
||||
{#if isFirstOfMonth}
|
||||
<div class="month-divider"></div>
|
||||
{/if}
|
||||
<button
|
||||
class="day-item"
|
||||
class:weekend={dayIsWeekend}
|
||||
class:selected={dayIsSelected && !dayIsToday}
|
||||
class:in-range={dayInRange && !dayIsToday}
|
||||
class:range-start={dayIsRangeStart && !dayIsToday}
|
||||
class:range-end={dayIsRangeEnd && !dayIsToday}
|
||||
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);'
|
||||
: ''}
|
||||
>
|
||||
{#if moonPhase.significant}
|
||||
<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;' : ''}>
|
||||
{#each Array(eventCount) as _, i}
|
||||
<span class="event-dot" style={dayIsToday ? 'background: white;' : ''}></span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.date-strip-wrapper {
|
||||
position: fixed;
|
||||
bottom: calc(200px + env(safe-area-inset-bottom, 0px)); /* Above InputBar */
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 48;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
transition: bottom 0.3s ease;
|
||||
}
|
||||
|
||||
/* When PillNav is in sidebar mode, no PillNav/Toolbar at bottom - just InputBar */
|
||||
.date-strip-wrapper.sidebar-mode {
|
||||
bottom: calc(70px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.today-button {
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
color: #9ca3af;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.375rem;
|
||||
pointer-events: auto;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.date-strip-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-surface, #ffffff);
|
||||
border-radius: 16px;
|
||||
margin: 0 1rem;
|
||||
padding: 0.5rem;
|
||||
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);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.month-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.5rem 0.5rem;
|
||||
}
|
||||
|
||||
.month-label {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-foreground, #1f2937);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.month-divider {
|
||||
width: 1px;
|
||||
height: 40px;
|
||||
background: var(--color-border, #e5e7eb);
|
||||
margin: 0 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.days-scroll {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
scroll-behavior: auto;
|
||||
padding: 1.25rem 0.25rem 0.25rem;
|
||||
margin-top: -1rem;
|
||||
}
|
||||
|
||||
.days-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.day-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 52px;
|
||||
height: 58px;
|
||||
padding: 0.375rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
color: var(--color-foreground, #1f2937);
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.moon-indicator {
|
||||
position: absolute;
|
||||
top: -16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 1.125rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.event-dots {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
justify-content: center;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.event-dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: #3b82f6;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.day-item:hover {
|
||||
background: var(--color-muted, #f3f4f6);
|
||||
}
|
||||
|
||||
.day-item.weekend {
|
||||
color: var(--color-muted-foreground, #6b7280);
|
||||
}
|
||||
|
||||
.day-item.selected {
|
||||
background: var(--color-muted, #f3f4f6);
|
||||
color: #3b82f6;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* View range highlighting */
|
||||
.day-item.in-range {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.day-item.in-range.range-start {
|
||||
border-radius: 10px 0 0 10px;
|
||||
}
|
||||
|
||||
.day-item.in-range.range-end {
|
||||
border-radius: 0 10px 10px 0;
|
||||
}
|
||||
|
||||
.day-item.in-range.range-start.range-end {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.day-item.in-range:hover {
|
||||
background: rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
.day-weekday {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.day-number {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 640px) {
|
||||
.date-strip-container {
|
||||
margin: 0 0.5rem;
|
||||
padding: 0.375rem;
|
||||
}
|
||||
|
||||
.month-label {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.day-item {
|
||||
min-width: 44px;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.moon-indicator {
|
||||
font-size: 1rem;
|
||||
top: -14px;
|
||||
}
|
||||
|
||||
.day-number {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.day-weekday {
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.month-divider {
|
||||
height: 32px;
|
||||
margin: 0 0.375rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -3,8 +3,9 @@
|
|||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { todosStore } from '$lib/stores/todos.svelte';
|
||||
import TodoRow from './TodoRow.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 {
|
||||
format,
|
||||
|
|
@ -17,11 +18,15 @@
|
|||
} from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
|
||||
import type { CalendarEvent } from '@calendar/shared';
|
||||
|
||||
interface Props {
|
||||
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
|
||||
onEventClick?: (event: CalendarEvent) => void;
|
||||
onTaskClick?: (task: Task) => void;
|
||||
}
|
||||
|
||||
let { onQuickCreate }: Props = $props();
|
||||
let { onQuickCreate, onEventClick, onTaskClick }: Props = $props();
|
||||
|
||||
// Constants
|
||||
const HOUR_HEIGHT = 60; // pixels per hour
|
||||
|
|
@ -63,17 +68,74 @@
|
|||
return () => clearInterval(interval);
|
||||
});
|
||||
|
||||
let timedEvents = $derived(
|
||||
eventsStore.getEventsForDay(viewStore.currentDate).filter((e) => !e.isAllDay)
|
||||
);
|
||||
// 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;
|
||||
});
|
||||
|
||||
// Get events that are completely outside the visible time range
|
||||
let overflowEvents = $derived.by(() => {
|
||||
if (!settingsStore.filterHoursEnabled) {
|
||||
return { before: [] as CalendarEvent[], after: [] as CalendarEvent[] };
|
||||
}
|
||||
|
||||
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 };
|
||||
});
|
||||
|
||||
let allDayEvents = $derived(
|
||||
eventsStore.getEventsForDay(viewStore.currentDate).filter((e) => e.isAllDay)
|
||||
);
|
||||
|
||||
// Get display mode for an event (per-event override takes precedence over global setting)
|
||||
function getEventDisplayMode(event: any): 'header' | 'block' {
|
||||
return event.metadata?.allDayDisplayMode || settingsStore.allDayDisplayMode;
|
||||
function getEventDisplayMode(event: CalendarEvent): 'header' | 'block' {
|
||||
return (
|
||||
(event.metadata as { allDayDisplayMode?: 'header' | 'block' } | null)?.allDayDisplayMode ||
|
||||
settingsStore.allDayDisplayMode
|
||||
);
|
||||
}
|
||||
|
||||
// Split all-day events by display mode
|
||||
|
|
@ -87,7 +149,7 @@
|
|||
// Drag & Drop State
|
||||
// ============================================================================
|
||||
let isDragging = $state(false);
|
||||
let draggedEvent = $state<any>(null);
|
||||
let draggedEvent = $state<CalendarEvent | null>(null);
|
||||
let dragOffsetMinutes = $state(0);
|
||||
let dragPreviewTop = $state(0);
|
||||
let dragPreviewHeight = $state(0);
|
||||
|
|
@ -97,7 +159,7 @@
|
|||
// Resize State
|
||||
// ============================================================================
|
||||
let isResizing = $state(false);
|
||||
let resizeEvent = $state<any>(null);
|
||||
let resizeEvent = $state<CalendarEvent | null>(null);
|
||||
let resizeEdge = $state<'top' | 'bottom'>('bottom');
|
||||
let resizeOriginalStart = $state<Date | null>(null);
|
||||
let resizeOriginalEnd = $state<Date | null>(null);
|
||||
|
|
@ -107,6 +169,21 @@
|
|||
// Track if we actually moved during drag/resize (to prevent click on simple mousedown/up)
|
||||
let hasMoved = $state(false);
|
||||
|
||||
// ============================================================================
|
||||
// Task Drag & Drop State
|
||||
// ============================================================================
|
||||
let isTaskDragging = $state(false);
|
||||
let draggedTask = $state<Task | null>(null);
|
||||
let taskDragPreviewTop = $state(0);
|
||||
let taskDragPreviewHeight = $state(0);
|
||||
|
||||
// Task Resize State
|
||||
let isTaskResizing = $state(false);
|
||||
let resizeTask = $state<Task | null>(null);
|
||||
let taskResizeEdge = $state<'top' | 'bottom'>('bottom');
|
||||
let taskResizePreviewTop = $state(0);
|
||||
let taskResizePreviewHeight = $state(0);
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
|
@ -129,7 +206,7 @@
|
|||
// ============================================================================
|
||||
// Drag Handlers
|
||||
// ============================================================================
|
||||
function startDrag(event: any, e: PointerEvent) {
|
||||
function startDrag(event: CalendarEvent, e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
|
|
@ -215,7 +292,7 @@
|
|||
// ============================================================================
|
||||
// Resize Handlers
|
||||
// ============================================================================
|
||||
function startResize(event: any, edge: 'top' | 'bottom', e: PointerEvent) {
|
||||
function startResize(event: CalendarEvent, edge: 'top' | 'bottom', e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
|
|
@ -321,13 +398,238 @@
|
|||
document.removeEventListener('pointerup', handleDragEnd);
|
||||
document.removeEventListener('pointermove', handleResizeMove);
|
||||
document.removeEventListener('pointerup', handleResizeEnd);
|
||||
// Task cleanup
|
||||
isTaskDragging = false;
|
||||
draggedTask = null;
|
||||
isTaskResizing = false;
|
||||
resizeTask = null;
|
||||
document.removeEventListener('pointermove', handleTaskDragMove);
|
||||
document.removeEventListener('pointerup', handleTaskDragEnd);
|
||||
document.removeEventListener('pointermove', handleTaskResizeMove);
|
||||
document.removeEventListener('pointerup', handleTaskResizeEnd);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Task Drag & Drop
|
||||
// ============================================================================
|
||||
function handleTaskDragStart(task: Task, e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
isTaskDragging = true;
|
||||
draggedTask = task;
|
||||
hasMoved = false;
|
||||
|
||||
if (task.scheduledStartTime) {
|
||||
const [h, m] = task.scheduledStartTime.split(':').map(Number);
|
||||
const startMinutes = h * 60 + m - firstVisibleHour * 60;
|
||||
taskDragPreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100;
|
||||
}
|
||||
|
||||
const duration = task.estimatedDuration || 30;
|
||||
taskDragPreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
|
||||
|
||||
document.addEventListener('pointermove', handleTaskDragMove);
|
||||
document.addEventListener('pointerup', handleTaskDragEnd);
|
||||
}
|
||||
|
||||
function handleTaskDragMove(e: PointerEvent) {
|
||||
if (!isTaskDragging || !draggedTask || !dayColumnRef) return;
|
||||
hasMoved = true;
|
||||
|
||||
const rect = dayColumnRef.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 rawMinutes = percentY * minutesPerPercent;
|
||||
const snappedMinutes = Math.round(rawMinutes / 15) * 15;
|
||||
taskDragPreviewTop = (snappedMinutes / (totalVisibleHours * 60)) * 100;
|
||||
}
|
||||
|
||||
async function handleTaskDragEnd() {
|
||||
document.removeEventListener('pointermove', handleTaskDragMove);
|
||||
document.removeEventListener('pointerup', handleTaskDragEnd);
|
||||
|
||||
if (!isTaskDragging || !draggedTask || !hasMoved) {
|
||||
isTaskDragging = false;
|
||||
draggedTask = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const minutesFromStart = (taskDragPreviewTop / 100) * (totalVisibleHours * 60);
|
||||
const totalMinutes = firstVisibleHour * 60 + minutesFromStart;
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = Math.round(totalMinutes % 60);
|
||||
|
||||
const newStartTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
||||
const duration = draggedTask.estimatedDuration || 30;
|
||||
const endTotalMinutes = totalMinutes + duration;
|
||||
const endHours = Math.floor(endTotalMinutes / 60);
|
||||
const endMins = Math.round(endTotalMinutes % 60);
|
||||
const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
|
||||
|
||||
await todosStore.updateTodo(draggedTask.id, {
|
||||
scheduledStartTime: newStartTime,
|
||||
scheduledEndTime: newEndTime,
|
||||
});
|
||||
|
||||
isTaskDragging = false;
|
||||
draggedTask = null;
|
||||
hasMoved = false;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Task Resize
|
||||
// ============================================================================
|
||||
function handleTaskResizeStart(task: Task, edge: 'top' | 'bottom', e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isTaskResizing = true;
|
||||
resizeTask = task;
|
||||
taskResizeEdge = edge;
|
||||
hasMoved = false;
|
||||
|
||||
if (task.scheduledStartTime) {
|
||||
const [h, m] = task.scheduledStartTime.split(':').map(Number);
|
||||
const startMinutes = h * 60 + m - firstVisibleHour * 60;
|
||||
taskResizePreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100;
|
||||
}
|
||||
|
||||
const duration = task.estimatedDuration || 30;
|
||||
taskResizePreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
|
||||
|
||||
document.addEventListener('pointermove', handleTaskResizeMove);
|
||||
document.addEventListener('pointerup', handleTaskResizeEnd);
|
||||
}
|
||||
|
||||
function handleTaskResizeMove(e: PointerEvent) {
|
||||
if (!isTaskResizing || !resizeTask || !dayColumnRef) return;
|
||||
hasMoved = true;
|
||||
|
||||
const rect = dayColumnRef.getBoundingClientRect();
|
||||
const relativeY = e.clientY - rect.top;
|
||||
const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100));
|
||||
|
||||
const minutesPerPercent = (totalVisibleHours * 60) / 100;
|
||||
|
||||
if (taskResizeEdge === 'top') {
|
||||
const originalEndPercent = taskResizePreviewTop + taskResizePreviewHeight;
|
||||
const rawMinutes = percentY * minutesPerPercent;
|
||||
const snappedMinutes = Math.round(rawMinutes / 15) * 15;
|
||||
taskResizePreviewTop = (snappedMinutes / (totalVisibleHours * 60)) * 100;
|
||||
taskResizePreviewHeight = Math.max(2, originalEndPercent - taskResizePreviewTop);
|
||||
} else {
|
||||
const rawMinutes = percentY * minutesPerPercent;
|
||||
const snappedMinutes = Math.round(rawMinutes / 15) * 15;
|
||||
const newBottom = (snappedMinutes / (totalVisibleHours * 60)) * 100;
|
||||
taskResizePreviewHeight = Math.max(2, newBottom - taskResizePreviewTop);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTaskResizeEnd() {
|
||||
document.removeEventListener('pointermove', handleTaskResizeMove);
|
||||
document.removeEventListener('pointerup', handleTaskResizeEnd);
|
||||
|
||||
if (!isTaskResizing || !resizeTask || !hasMoved) {
|
||||
isTaskResizing = false;
|
||||
resizeTask = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const startMinutes =
|
||||
(taskResizePreviewTop / 100) * (totalVisibleHours * 60) + firstVisibleHour * 60;
|
||||
const endMinutes =
|
||||
((taskResizePreviewTop + taskResizePreviewHeight) / 100) * (totalVisibleHours * 60) +
|
||||
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 newDuration = Math.round(endMinutes - startMinutes);
|
||||
|
||||
await todosStore.updateTodo(resizeTask.id, {
|
||||
scheduledStartTime: newStartTime,
|
||||
scheduledEndTime: newEndTime,
|
||||
estimatedDuration: newDuration,
|
||||
});
|
||||
|
||||
isTaskResizing = false;
|
||||
resizeTask = null;
|
||||
hasMoved = false;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Sidebar Task Drop
|
||||
// ============================================================================
|
||||
let isSidebarDropTarget = $state(false);
|
||||
|
||||
function handleSidebarDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
if (!e.dataTransfer) return;
|
||||
const types = e.dataTransfer.types;
|
||||
if (!types.includes('application/json')) return;
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
isSidebarDropTarget = true;
|
||||
}
|
||||
|
||||
function handleSidebarDragLeave(e: DragEvent) {
|
||||
const relatedTarget = e.relatedTarget as HTMLElement;
|
||||
if (!relatedTarget?.closest('.day-column')) {
|
||||
isSidebarDropTarget = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSidebarDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
isSidebarDropTarget = false;
|
||||
|
||||
if (!e.dataTransfer || !dayColumnRef) return;
|
||||
|
||||
const jsonData = e.dataTransfer.getData('application/json');
|
||||
if (!jsonData) return;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(jsonData);
|
||||
if (data.type !== 'sidebar-task') return;
|
||||
|
||||
const rect = dayColumnRef.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 rawMinutes = percentY * minutesPerPercent;
|
||||
const snappedMinutes = Math.round(rawMinutes / 15) * 15;
|
||||
const totalMinutes = firstVisibleHour * 60 + snappedMinutes;
|
||||
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
const startTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
||||
|
||||
const duration = data.estimatedDuration || 30;
|
||||
const endMinutes = totalMinutes + duration;
|
||||
const endHours = Math.floor(endMinutes / 60);
|
||||
const endMins = endMinutes % 60;
|
||||
const endTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
|
||||
|
||||
await todosStore.updateTodo(data.taskId, {
|
||||
scheduledDate: format(viewStore.currentDate, 'yyyy-MM-dd'),
|
||||
scheduledStartTime: startTime,
|
||||
scheduledEndTime: endTime,
|
||||
estimatedDuration: duration,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to parse drop data:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Keyboard Handling
|
||||
// ============================================================================
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && (isDragging || isResizing)) {
|
||||
if (e.key === 'Escape' && (isDragging || isResizing || isTaskDragging || isTaskResizing)) {
|
||||
e.preventDefault();
|
||||
cleanup();
|
||||
}
|
||||
|
|
@ -342,7 +644,7 @@
|
|||
// ============================================================================
|
||||
// Event Styling
|
||||
// ============================================================================
|
||||
function getEventStyle(event: any) {
|
||||
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;
|
||||
|
||||
|
|
@ -358,7 +660,36 @@
|
|||
return `top: ${top}%; height: ${height}%; background-color: ${color};`;
|
||||
}
|
||||
|
||||
function handleEventClick(event: any, e: MouseEvent) {
|
||||
/**
|
||||
* Get style for a scheduled task (time-blocking)
|
||||
*/
|
||||
function getTaskStyle(task: Task): string {
|
||||
if (!task.scheduledStartTime) return '';
|
||||
|
||||
const [startHour, startMin] = task.scheduledStartTime.split(':').map(Number);
|
||||
const startMinutes = startHour * 60 + startMin;
|
||||
|
||||
let duration = task.estimatedDuration || 30;
|
||||
if (task.scheduledEndTime) {
|
||||
const [endHour, endMin] = task.scheduledEndTime.split(':').map(Number);
|
||||
const endMinutes = endHour * 60 + endMin;
|
||||
duration = endMinutes - startMinutes;
|
||||
}
|
||||
|
||||
const top = minutesToPercent(startMinutes);
|
||||
const height = Math.max((duration / (totalVisibleHours * 60)) * 100, 1.5);
|
||||
|
||||
return `top: ${top}%; height: ${height}%;`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scheduled tasks for current day
|
||||
*/
|
||||
function getScheduledTasks(): Task[] {
|
||||
return todosStore.getScheduledTasksForDay(viewStore.currentDate);
|
||||
}
|
||||
|
||||
function handleEventClick(event: CalendarEvent, e: MouseEvent) {
|
||||
// Don't navigate if dragging or resizing, or if we moved
|
||||
if (isDragging || isResizing || hasMoved) {
|
||||
e.preventDefault();
|
||||
|
|
@ -368,7 +699,11 @@
|
|||
}, 100);
|
||||
return;
|
||||
}
|
||||
goto(`/?event=${event.id}`);
|
||||
if (onEventClick) {
|
||||
onEventClick(event);
|
||||
} else {
|
||||
goto(`/?event=${event.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSlotClick(hour: number, e: MouseEvent) {
|
||||
|
|
@ -397,6 +732,8 @@
|
|||
{#each headerAllDayEvents 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)}
|
||||
>
|
||||
|
|
@ -407,16 +744,6 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Todos section -->
|
||||
{#if todosStore.serviceAvailable && todosStore.getTodosForDay(viewStore.currentDate).length > 0}
|
||||
<div class="todos-section">
|
||||
<div class="time-gutter"></div>
|
||||
<div class="todos-content">
|
||||
<TodoRow date={viewStore.currentDate} maxVisible={4} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Time grid -->
|
||||
<div class="time-grid scrollbar-thin">
|
||||
<div class="time-column">
|
||||
|
|
@ -427,7 +754,16 @@
|
|||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="day-column" class:today={isToday(viewStore.currentDate)} bind:this={dayColumnRef}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="day-column"
|
||||
class:today={isToday(viewStore.currentDate)}
|
||||
class:drop-target={isSidebarDropTarget}
|
||||
bind:this={dayColumnRef}
|
||||
ondragover={handleSidebarDragOver}
|
||||
ondragleave={handleSidebarDragLeave}
|
||||
ondrop={handleSidebarDrop}
|
||||
>
|
||||
{#each hours as hour}
|
||||
<button
|
||||
class="hour-slot"
|
||||
|
|
@ -440,6 +776,8 @@
|
|||
{#each blockAllDayEvents as event}
|
||||
<button
|
||||
class="all-day-block-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)}
|
||||
>
|
||||
|
|
@ -452,11 +790,16 @@
|
|||
{@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}
|
||||
style={isBeingDragged
|
||||
? `top: ${dragPreviewTop}%; height: ${dragPreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
|
||||
|
|
@ -475,6 +818,8 @@
|
|||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="Startzeit ändern"
|
||||
aria-valuenow={0}
|
||||
tabindex="-1"
|
||||
></div>
|
||||
|
||||
<span class="event-time">
|
||||
|
|
@ -499,10 +844,64 @@
|
|||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="Endzeit ändern"
|
||||
aria-valuenow={0}
|
||||
tabindex="-1"
|
||||
></div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Scheduled Tasks (Time-Blocking) -->
|
||||
{#each getScheduledTasks() as task (task.id)}
|
||||
{@const isTaskBeingDragged = isTaskDragging && draggedTask?.id === task.id}
|
||||
{@const isTaskBeingResized = isTaskResizing && resizeTask?.id === task.id}
|
||||
<TaskBlock
|
||||
{task}
|
||||
style={isTaskBeingDragged
|
||||
? `top: ${taskDragPreviewTop}%; height: ${taskDragPreviewHeight}%;`
|
||||
: isTaskBeingResized
|
||||
? `top: ${taskResizePreviewTop}%; height: ${taskResizePreviewHeight}%;`
|
||||
: getTaskStyle(task)}
|
||||
{onTaskClick}
|
||||
onDragStart={handleTaskDragStart}
|
||||
onResizeStart={handleTaskResizeStart}
|
||||
isDragging={isTaskBeingDragged}
|
||||
isResizing={isTaskBeingResized}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<!-- Overflow indicators for events outside visible time range -->
|
||||
{#if overflowEvents.before.length > 0}
|
||||
<div class="overflow-indicator top" title="{overflowEvents.before.length} Termin(e) früher">
|
||||
{#each overflowEvents.before as event}
|
||||
<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}"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if overflowEvents.after.length > 0}
|
||||
<div
|
||||
class="overflow-indicator bottom"
|
||||
title="{overflowEvents.after.length} Termin(e) später"
|
||||
>
|
||||
{#each overflowEvents.after as event}
|
||||
<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}"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Current time indicator -->
|
||||
{#if isToday(viewStore.currentDate)}
|
||||
<div class="time-indicator" style="top: {currentTimePosition}%"></div>
|
||||
|
|
@ -543,16 +942,18 @@
|
|||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
/* Todos section */
|
||||
.todos-section {
|
||||
display: flex;
|
||||
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
|
||||
.all-day-event.search-highlighted {
|
||||
outline: 2px solid hsl(var(--color-primary));
|
||||
outline-offset: 1px;
|
||||
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.3);
|
||||
}
|
||||
|
||||
.todos-content {
|
||||
flex: 1;
|
||||
.all-day-event.search-dimmed {
|
||||
opacity: 0.35;
|
||||
filter: grayscale(0.3);
|
||||
}
|
||||
|
||||
/* Block-style all-day events (displayed as full-day blocks in the grid) */
|
||||
|
|
@ -579,6 +980,17 @@
|
|||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.all-day-block-event.search-highlighted {
|
||||
opacity: 0.6;
|
||||
outline: 2px solid hsl(var(--color-primary));
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.all-day-block-event.search-dimmed {
|
||||
opacity: 0.15;
|
||||
filter: grayscale(0.5);
|
||||
}
|
||||
|
||||
.all-day-block-event .event-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
|
|
@ -624,6 +1036,12 @@
|
|||
background: hsl(var(--color-primary) / 0.05);
|
||||
}
|
||||
|
||||
.day-column.drop-target {
|
||||
background: hsl(var(--color-primary) / 0.15);
|
||||
outline: 2px dashed hsl(var(--color-primary));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.event-card {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
|
|
@ -672,6 +1090,21 @@
|
|||
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% {
|
||||
|
|
@ -762,4 +1195,39 @@
|
|||
.hour-slot:hover {
|
||||
background: hsl(var(--color-muted) / 0.2);
|
||||
}
|
||||
|
||||
/* Overflow indicators for events outside visible time range */
|
||||
.overflow-indicator {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
right: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
z-index: 5;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.overflow-indicator.top {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.overflow-indicator.bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.overflow-line {
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
height 0.15s ease;
|
||||
}
|
||||
|
||||
.overflow-line:hover {
|
||||
opacity: 1;
|
||||
height: 5px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { searchStore } from '$lib/stores/search.svelte';
|
||||
import { todosStore } from '$lib/stores/todos.svelte';
|
||||
import TodoDayCell from './TodoDayCell.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
|
@ -17,9 +18,6 @@
|
|||
isToday,
|
||||
isSameDay,
|
||||
isWeekend,
|
||||
setYear,
|
||||
setMonth,
|
||||
setDate,
|
||||
getHours,
|
||||
getMinutes,
|
||||
differenceInMinutes,
|
||||
|
|
@ -28,12 +26,16 @@
|
|||
setMinutes,
|
||||
} from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
import type { CalendarEvent } from '@calendar/shared';
|
||||
|
||||
interface Props {
|
||||
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
|
||||
onEventClick?: (event: CalendarEvent) => void;
|
||||
}
|
||||
|
||||
let { onQuickCreate }: Props = $props();
|
||||
let { onQuickCreate, onEventClick }: Props = $props();
|
||||
|
||||
// Get all days to display in the month grid (including days from prev/next months)
|
||||
let allCalendarDays = $derived.by(() => {
|
||||
|
|
@ -76,7 +78,7 @@
|
|||
// Drag & Drop State
|
||||
// ============================================================================
|
||||
let isDragging = $state(false);
|
||||
let draggedEvent = $state<any>(null);
|
||||
let draggedEvent = $state<CalendarEvent | null>(null);
|
||||
let dragTargetDay = $state<Date | null>(null);
|
||||
let monthViewRef = $state<HTMLElement | null>(null);
|
||||
|
||||
|
|
@ -219,7 +221,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
function handleEventClick(event: any, e: MouseEvent) {
|
||||
function handleEventClick(event: CalendarEvent, e: MouseEvent) {
|
||||
// Don't navigate if dragging
|
||||
if (isDragging) {
|
||||
e.preventDefault();
|
||||
|
|
@ -227,7 +229,11 @@
|
|||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
goto(`/?event=${event.id}`);
|
||||
if (onEventClick) {
|
||||
onEventClick(event);
|
||||
} else {
|
||||
goto(`/?event=${event.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMoreClick(day: Date, e: MouseEvent) {
|
||||
|
|
@ -251,7 +257,6 @@
|
|||
<div class="week-row">
|
||||
{#each week as day}
|
||||
{@const isDropTarget = isDragging && dragTargetDay && isSameDay(day, dragTargetDay)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="day-cell"
|
||||
class:other-month={!isSameMonth(day, viewStore.currentDate)}
|
||||
|
|
@ -262,6 +267,9 @@
|
|||
onkeydown={(e) => e.key === 'Enter' && handleDayClick(day, e as unknown as MouseEvent)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={$_('a11y.createEventOn', {
|
||||
values: { date: format(day, 'EEEE, d. MMMM', { locale: de }) },
|
||||
})}
|
||||
>
|
||||
<span class="day-number" class:today={isToday(day)}>
|
||||
{format(day, 'd')}
|
||||
|
|
@ -276,10 +284,15 @@
|
|||
{#each getEventsForDay(day) as event}
|
||||
{@const isBeingDragged = isDragging && draggedEvent?.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-pill"
|
||||
class:dragging={isBeingDragged}
|
||||
class:draft={isDraft}
|
||||
class:search-highlighted={isSearchHighlighted}
|
||||
class:search-dimmed={isSearchDimmed}
|
||||
data-event-id={event.id}
|
||||
style="background-color: {calendarsStore.getColor(event.calendarId)}"
|
||||
onpointerdown={(e) => startDrag(event, e)}
|
||||
|
|
@ -300,14 +313,17 @@
|
|||
)}</span
|
||||
>
|
||||
{/if}
|
||||
<span class="event-title">{event.title || (isDraft ? '(Neuer Termin)' : '')}</span
|
||||
<span class="event-title"
|
||||
>{event.title || (isDraft ? $_('calendar.draftEvent') : '')}</span
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if eventsStore.getEventsForDay(day).length > 3}
|
||||
<button class="more-events" onclick={(e) => handleMoreClick(day, e)}>
|
||||
+{eventsStore.getEventsForDay(day).length - 3} mehr
|
||||
{$_('views.moreEvents', {
|
||||
values: { count: eventsStore.getEventsForDay(day).length - 3 },
|
||||
})}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -444,6 +460,20 @@
|
|||
animation: pulse-outline 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Search highlighting */
|
||||
.event-pill.search-highlighted {
|
||||
outline: 2px solid hsl(var(--color-primary));
|
||||
outline-offset: 1px;
|
||||
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.3);
|
||||
z-index: 10;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.event-pill.search-dimmed {
|
||||
opacity: 0.35;
|
||||
filter: grayscale(0.3);
|
||||
}
|
||||
|
||||
@keyframes pulse-outline {
|
||||
0%,
|
||||
100% {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@
|
|||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
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 {
|
||||
format,
|
||||
|
|
@ -23,12 +26,16 @@
|
|||
const HOUR_HEIGHT = 60; // px - should match CSS --hour-height
|
||||
const MINUTES_PER_SLOT = 15; // Snap to 15-minute intervals
|
||||
|
||||
import type { CalendarEvent } from '@calendar/shared';
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
dayCount: 5 | 10 | 14;
|
||||
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
|
||||
onEventClick?: (event: CalendarEvent) => void;
|
||||
onTaskClick?: (task: Task) => void;
|
||||
}
|
||||
let { dayCount, onQuickCreate }: Props = $props();
|
||||
let { dayCount, onQuickCreate, onEventClick, onTaskClick }: Props = $props();
|
||||
|
||||
// Get date-fns locale based on current app locale
|
||||
const dateLocales = { de, en: enUS, fr, es, it };
|
||||
|
|
@ -93,7 +100,7 @@
|
|||
|
||||
// ========== Drag & Drop State ==========
|
||||
let isDragging = $state(false);
|
||||
let draggedEvent = $state<any>(null);
|
||||
let draggedEvent = $state<CalendarEvent | null>(null);
|
||||
let dragOffsetMinutes = $state(0);
|
||||
let dragTargetDay = $state<Date | null>(null);
|
||||
let dragPreviewTop = $state(0);
|
||||
|
|
@ -101,7 +108,7 @@
|
|||
|
||||
// ========== Resize State ==========
|
||||
let isResizing = $state(false);
|
||||
let resizeEvent = $state<any>(null);
|
||||
let resizeEvent = $state<CalendarEvent | null>(null);
|
||||
let resizeEdge = $state<'top' | 'bottom'>('bottom');
|
||||
let resizeOriginalStart = $state<Date | null>(null);
|
||||
let resizeOriginalEnd = $state<Date | null>(null);
|
||||
|
|
@ -111,11 +118,79 @@
|
|||
// Track if we actually moved during drag/resize (to prevent click on simple mousedown/up)
|
||||
let hasMoved = $state(false);
|
||||
|
||||
// Task Drag & Drop State
|
||||
let isTaskDragging = $state(false);
|
||||
let draggedTask = $state<Task | null>(null);
|
||||
let taskDragTargetDay = $state<Date | null>(null);
|
||||
let taskDragPreviewTop = $state(0);
|
||||
let taskDragPreviewHeight = $state(0);
|
||||
|
||||
// Task Resize State
|
||||
let isTaskResizing = $state(false);
|
||||
let resizeTask = $state<Task | null>(null);
|
||||
let taskResizeEdge = $state<'top' | 'bottom'>('bottom');
|
||||
let taskResizePreviewTop = $state(0);
|
||||
let taskResizePreviewHeight = $state(0);
|
||||
|
||||
// Reference to the days container for position calculations
|
||||
let daysContainerEl: HTMLDivElement;
|
||||
|
||||
function getEventsForDay(day: Date) {
|
||||
return eventsStore.getEventsForDay(day).filter((e) => !e.isAllDay);
|
||||
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;
|
||||
}
|
||||
|
||||
// Get events that are completely outside the visible time range
|
||||
function getOverflowEventsForDay(day: Date): { before: CalendarEvent[]; after: CalendarEvent[] } {
|
||||
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 };
|
||||
}
|
||||
|
||||
function getAllDayEventsForDay(day: Date) {
|
||||
|
|
@ -123,8 +198,11 @@
|
|||
}
|
||||
|
||||
// Get display mode for an event (per-event override takes precedence over global setting)
|
||||
function getEventDisplayMode(event: any): 'header' | 'block' {
|
||||
return event.metadata?.allDayDisplayMode || settingsStore.allDayDisplayMode;
|
||||
function getEventDisplayMode(event: CalendarEvent): 'header' | 'block' {
|
||||
return (
|
||||
(event.metadata as { allDayDisplayMode?: 'header' | 'block' } | null)?.allDayDisplayMode ||
|
||||
settingsStore.allDayDisplayMode
|
||||
);
|
||||
}
|
||||
|
||||
// Split all-day events by display mode
|
||||
|
|
@ -141,7 +219,7 @@
|
|||
days.some((day) => getHeaderAllDayEventsForDay(day).length > 0)
|
||||
);
|
||||
|
||||
function getEventStyle(event: any) {
|
||||
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;
|
||||
|
||||
|
|
@ -156,12 +234,41 @@
|
|||
return `top: ${top}%; height: ${height}%; background-color: ${color};`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get style for a scheduled task (time-blocking)
|
||||
*/
|
||||
function getTaskStyle(task: Task): string {
|
||||
if (!task.scheduledStartTime) return '';
|
||||
|
||||
const [startHour, startMin] = task.scheduledStartTime.split(':').map(Number);
|
||||
const startMinutes = startHour * 60 + startMin;
|
||||
|
||||
let duration = task.estimatedDuration || 30;
|
||||
if (task.scheduledEndTime) {
|
||||
const [endHour, endMin] = task.scheduledEndTime.split(':').map(Number);
|
||||
const endMinutes = endHour * 60 + endMin;
|
||||
duration = endMinutes - startMinutes;
|
||||
}
|
||||
|
||||
const top = minutesToPercent(startMinutes);
|
||||
const height = Math.max((duration / (totalVisibleHours * 60)) * 100, 2);
|
||||
|
||||
return `top: ${top}%; height: ${height}%;`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scheduled tasks for a specific day
|
||||
*/
|
||||
function getScheduledTasksForDay(day: Date): Task[] {
|
||||
return todosStore.getScheduledTasksForDay(day);
|
||||
}
|
||||
|
||||
function formatEventTime(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? parseISO(date) : date;
|
||||
return settingsStore.formatTime(d);
|
||||
}
|
||||
|
||||
function handleEventClick(event: any, e: MouseEvent) {
|
||||
function handleEventClick(event: CalendarEvent, e: MouseEvent) {
|
||||
// Don't navigate if we just finished dragging or resizing, or if we moved
|
||||
if (isDragging || isResizing || hasMoved) {
|
||||
e.preventDefault();
|
||||
|
|
@ -171,7 +278,11 @@
|
|||
}, 100);
|
||||
return;
|
||||
}
|
||||
goto(`/?event=${event.id}`);
|
||||
if (onEventClick) {
|
||||
onEventClick(event);
|
||||
} else {
|
||||
goto(`/?event=${event.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSlotClick(day: Date, hour: number, e: MouseEvent) {
|
||||
|
|
@ -218,7 +329,7 @@
|
|||
return Math.round(totalMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT;
|
||||
}
|
||||
|
||||
function startDrag(event: any, e: PointerEvent) {
|
||||
function startDrag(event: CalendarEvent, e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
|
|
@ -321,7 +432,7 @@
|
|||
|
||||
// ========== Resize Functions ==========
|
||||
|
||||
function startResize(event: any, edge: 'top' | 'bottom', e: PointerEvent) {
|
||||
function startResize(event: CalendarEvent, edge: 'top' | 'bottom', e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
|
|
@ -437,15 +548,258 @@
|
|||
hasMoved = false;
|
||||
}
|
||||
|
||||
// ========== Task Drag & Drop ==========
|
||||
|
||||
function handleTaskDragStart(task: Task, e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
isTaskDragging = true;
|
||||
draggedTask = task;
|
||||
hasMoved = false;
|
||||
|
||||
if (task.scheduledStartTime) {
|
||||
const [h, m] = task.scheduledStartTime.split(':').map(Number);
|
||||
const startMinutes = h * 60 + m - firstVisibleHour * 60;
|
||||
taskDragPreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100;
|
||||
}
|
||||
|
||||
const duration = task.estimatedDuration || 30;
|
||||
taskDragPreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
|
||||
|
||||
document.addEventListener('pointermove', handleTaskDragMove);
|
||||
document.addEventListener('pointerup', handleTaskDragEnd);
|
||||
}
|
||||
|
||||
function handleTaskDragMove(e: PointerEvent) {
|
||||
if (!isTaskDragging || !draggedTask) return;
|
||||
hasMoved = true;
|
||||
|
||||
const daysEl = daysContainerEl;
|
||||
if (!daysEl) return;
|
||||
|
||||
const dayColumns = daysEl.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 = days[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const targetColumn = daysEl.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 rawMinutes = percentY * minutesPerPercent;
|
||||
const snappedMinutes = Math.round(rawMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT;
|
||||
taskDragPreviewTop = (snappedMinutes / (totalVisibleHours * 60)) * 100;
|
||||
}
|
||||
|
||||
async function handleTaskDragEnd() {
|
||||
document.removeEventListener('pointermove', handleTaskDragMove);
|
||||
document.removeEventListener('pointerup', handleTaskDragEnd);
|
||||
|
||||
if (!isTaskDragging || !draggedTask || !hasMoved) {
|
||||
isTaskDragging = false;
|
||||
draggedTask = null;
|
||||
taskDragTargetDay = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const minutesFromStart = (taskDragPreviewTop / 100) * (totalVisibleHours * 60);
|
||||
const totalMinutes = firstVisibleHour * 60 + minutesFromStart;
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = Math.round(totalMinutes % 60);
|
||||
|
||||
const newStartTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
||||
const duration = draggedTask.estimatedDuration || 30;
|
||||
const endTotalMinutes = totalMinutes + duration;
|
||||
const endHours = Math.floor(endTotalMinutes / 60);
|
||||
const endMins = Math.round(endTotalMinutes % 60);
|
||||
const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
|
||||
|
||||
await todosStore.updateTodo(draggedTask.id, {
|
||||
scheduledDate: taskDragTargetDay ? format(taskDragTargetDay, 'yyyy-MM-dd') : undefined,
|
||||
scheduledStartTime: newStartTime,
|
||||
scheduledEndTime: newEndTime,
|
||||
});
|
||||
|
||||
isTaskDragging = false;
|
||||
draggedTask = null;
|
||||
taskDragTargetDay = null;
|
||||
hasMoved = false;
|
||||
}
|
||||
|
||||
// ========== Task Resize ==========
|
||||
|
||||
function handleTaskResizeStart(task: Task, edge: 'top' | 'bottom', e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isTaskResizing = true;
|
||||
resizeTask = task;
|
||||
taskResizeEdge = edge;
|
||||
hasMoved = false;
|
||||
|
||||
if (task.scheduledStartTime) {
|
||||
const [h, m] = task.scheduledStartTime.split(':').map(Number);
|
||||
const startMinutes = h * 60 + m - firstVisibleHour * 60;
|
||||
taskResizePreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100;
|
||||
}
|
||||
|
||||
const duration = task.estimatedDuration || 30;
|
||||
taskResizePreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
|
||||
|
||||
document.addEventListener('pointermove', handleTaskResizeMove);
|
||||
document.addEventListener('pointerup', handleTaskResizeEnd);
|
||||
}
|
||||
|
||||
function handleTaskResizeMove(e: PointerEvent) {
|
||||
if (!isTaskResizing || !resizeTask) return;
|
||||
hasMoved = true;
|
||||
|
||||
const daysEl = daysContainerEl;
|
||||
if (!daysEl) return;
|
||||
|
||||
const targetColumn = daysEl.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;
|
||||
|
||||
if (taskResizeEdge === 'top') {
|
||||
const originalEndPercent = taskResizePreviewTop + taskResizePreviewHeight;
|
||||
const rawMinutes = percentY * minutesPerPercent;
|
||||
const snappedMinutes = Math.round(rawMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT;
|
||||
taskResizePreviewTop = (snappedMinutes / (totalVisibleHours * 60)) * 100;
|
||||
taskResizePreviewHeight = Math.max(2, originalEndPercent - taskResizePreviewTop);
|
||||
} else {
|
||||
const rawMinutes = percentY * minutesPerPercent;
|
||||
const snappedMinutes = Math.round(rawMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT;
|
||||
const newBottom = (snappedMinutes / (totalVisibleHours * 60)) * 100;
|
||||
taskResizePreviewHeight = Math.max(2, newBottom - taskResizePreviewTop);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTaskResizeEnd() {
|
||||
document.removeEventListener('pointermove', handleTaskResizeMove);
|
||||
document.removeEventListener('pointerup', handleTaskResizeEnd);
|
||||
|
||||
if (!isTaskResizing || !resizeTask || !hasMoved) {
|
||||
isTaskResizing = false;
|
||||
resizeTask = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const startMinutes =
|
||||
(taskResizePreviewTop / 100) * (totalVisibleHours * 60) + firstVisibleHour * 60;
|
||||
const endMinutes =
|
||||
((taskResizePreviewTop + taskResizePreviewHeight) / 100) * (totalVisibleHours * 60) +
|
||||
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 newDuration = Math.round(endMinutes - startMinutes);
|
||||
|
||||
await todosStore.updateTodo(resizeTask.id, {
|
||||
scheduledStartTime: newStartTime,
|
||||
scheduledEndTime: newEndTime,
|
||||
estimatedDuration: newDuration,
|
||||
});
|
||||
|
||||
isTaskResizing = false;
|
||||
resizeTask = null;
|
||||
hasMoved = false;
|
||||
}
|
||||
|
||||
// ========== Sidebar Task Drop ==========
|
||||
let sidebarDropTarget = $state<{ day: Date; y: number } | null>(null);
|
||||
|
||||
function handleSidebarDragOver(e: DragEvent, day: Date) {
|
||||
e.preventDefault();
|
||||
if (!e.dataTransfer) return;
|
||||
const types = e.dataTransfer.types;
|
||||
if (!types.includes('application/json')) return;
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
sidebarDropTarget = { day, y: e.clientY };
|
||||
}
|
||||
|
||||
function handleSidebarDragLeave(e: DragEvent) {
|
||||
const relatedTarget = e.relatedTarget as HTMLElement;
|
||||
if (!relatedTarget?.closest('.day-column')) {
|
||||
sidebarDropTarget = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSidebarDrop(e: DragEvent, day: Date) {
|
||||
e.preventDefault();
|
||||
sidebarDropTarget = 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 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 = (totalVisibleHours * 60) / 100;
|
||||
const rawMinutes = percentY * minutesPerPercent;
|
||||
const snappedMinutes = Math.round(rawMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT;
|
||||
const totalMinutes = firstVisibleHour * 60 + snappedMinutes;
|
||||
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
const startTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
||||
|
||||
const duration = data.estimatedDuration || 30;
|
||||
const endMinutes = totalMinutes + duration;
|
||||
const endHours = Math.floor(endMinutes / 60);
|
||||
const endMins = endMinutes % 60;
|
||||
const endTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Keyboard Handling ==========
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && (isDragging || isResizing)) {
|
||||
if (e.key === 'Escape' && (isDragging || isResizing || isTaskDragging || isTaskResizing)) {
|
||||
e.preventDefault();
|
||||
document.removeEventListener('pointermove', handleDragMove);
|
||||
document.removeEventListener('pointerup', handleDragEnd);
|
||||
document.removeEventListener('pointermove', handleResizeMove);
|
||||
document.removeEventListener('pointerup', handleResizeEnd);
|
||||
document.removeEventListener('pointermove', handleTaskDragMove);
|
||||
document.removeEventListener('pointerup', handleTaskDragEnd);
|
||||
document.removeEventListener('pointermove', handleTaskResizeMove);
|
||||
document.removeEventListener('pointerup', handleTaskResizeEnd);
|
||||
isDragging = false;
|
||||
draggedEvent = null;
|
||||
dragTargetDay = null;
|
||||
|
|
@ -453,6 +807,11 @@
|
|||
resizeEvent = null;
|
||||
resizeOriginalStart = null;
|
||||
resizeOriginalEnd = null;
|
||||
isTaskDragging = false;
|
||||
draggedTask = null;
|
||||
taskDragTargetDay = null;
|
||||
isTaskResizing = false;
|
||||
resizeTask = null;
|
||||
hasMoved = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -477,6 +836,8 @@
|
|||
{#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}
|
||||
|
|
@ -516,7 +877,15 @@
|
|||
<!-- Day columns -->
|
||||
<div class="days-container" bind:this={daysContainerEl}>
|
||||
{#each days as day}
|
||||
<div class="day-column" class:today={isToday(day)}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="day-column"
|
||||
class:today={isToday(day)}
|
||||
class:drop-target={sidebarDropTarget && isSameDay(day, sidebarDropTarget.day)}
|
||||
ondragover={(e) => handleSidebarDragOver(e, day)}
|
||||
ondragleave={handleSidebarDragLeave}
|
||||
ondrop={(e) => handleSidebarDrop(e, day)}
|
||||
>
|
||||
{#each hours as hour}
|
||||
<button
|
||||
class="hour-slot"
|
||||
|
|
@ -529,6 +898,8 @@
|
|||
{#each getBlockAllDayEventsForDay(day) as event (event.id)}
|
||||
<button
|
||||
class="all-day-block-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}
|
||||
|
|
@ -542,13 +913,20 @@
|
|||
{@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}
|
||||
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}
|
||||
style={isBeingDragged
|
||||
style={isBeingDragged && !isCrossDayDrag
|
||||
? `top: ${dragPreviewTop}%; height: ${dragPreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
|
||||
: isBeingResized
|
||||
? `top: ${resizePreviewTop}%; height: ${resizePreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
|
||||
|
|
@ -567,6 +945,8 @@
|
|||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="Startzeit ändern"
|
||||
aria-valuenow={0}
|
||||
tabindex="-1"
|
||||
></div>
|
||||
|
||||
{#if columnClass !== 'very-compact'}
|
||||
|
|
@ -583,14 +963,49 @@
|
|||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="Endzeit ändern"
|
||||
aria-valuenow={0}
|
||||
tabindex="-1"
|
||||
></div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Drag preview ghost (for cross-day dragging) -->
|
||||
{#if isDragging && draggedEvent && dragTargetDay && isSameDay(day, dragTargetDay) && !getEventsForDay(day).some((e) => e.id === draggedEvent.id)}
|
||||
<!-- Scheduled Tasks (Time-Blocking) -->
|
||||
{#each getScheduledTasksForDay(day) as task (task.id)}
|
||||
{@const isTaskBeingDragged = isTaskDragging && draggedTask?.id === task.id}
|
||||
{@const isTaskBeingResized = isTaskResizing && resizeTask?.id === task.id}
|
||||
{@const isTaskCrossDayDrag =
|
||||
isTaskBeingDragged &&
|
||||
taskDragTargetDay !== null &&
|
||||
!isSameDay(day, taskDragTargetDay)}
|
||||
<TaskBlock
|
||||
{task}
|
||||
style={isTaskBeingDragged && !isTaskCrossDayDrag
|
||||
? `top: ${taskDragPreviewTop}%; height: ${taskDragPreviewHeight}%;`
|
||||
: isTaskBeingResized
|
||||
? `top: ${taskResizePreviewTop}%; height: ${taskResizePreviewHeight}%;`
|
||||
: getTaskStyle(task)}
|
||||
{onTaskClick}
|
||||
onDragStart={handleTaskDragStart}
|
||||
onResizeStart={handleTaskResizeStart}
|
||||
isDragging={isTaskBeingDragged && !isTaskCrossDayDrag}
|
||||
isResizing={isTaskBeingResized}
|
||||
isDraggingSource={isTaskCrossDayDrag}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<!-- Task Drag preview (solid) for cross-day dragging - shows where task will be -->
|
||||
{#if isTaskDragging && draggedTask && taskDragTargetDay && isSameDay(day, taskDragTargetDay) && !getScheduledTasksForDay(day).some((t) => t.id === draggedTask!.id)}
|
||||
<TaskBlock
|
||||
task={draggedTask}
|
||||
style="top: {taskDragPreviewTop}%; height: {taskDragPreviewHeight}%;"
|
||||
isDragging={true}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- 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-ghost"
|
||||
class="event-card drag-preview"
|
||||
style="top: {dragPreviewTop}%; height: {dragPreviewHeight}%; background-color: {calendarsStore.getColor(
|
||||
draggedEvent.calendarId
|
||||
)};"
|
||||
|
|
@ -602,6 +1017,36 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Overflow indicators for events outside visible time range -->
|
||||
{#if true}
|
||||
{@const overflow = getOverflowEventsForDay(day)}
|
||||
{#if overflow.before.length > 0}
|
||||
<div class="overflow-indicator top" title="{overflow.before.length} Termin(e) früher">
|
||||
{#each overflow.before as event}
|
||||
<div
|
||||
class="overflow-line"
|
||||
style="background-color: {calendarsStore.getColor(event.calendarId)}"
|
||||
title="{formatEventTime(event.startTime)} {event.title}"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if overflow.after.length > 0}
|
||||
<div
|
||||
class="overflow-indicator bottom"
|
||||
title="{overflow.after.length} Termin(e) später"
|
||||
>
|
||||
{#each overflow.after as event}
|
||||
<div
|
||||
class="overflow-line"
|
||||
style="background-color: {calendarsStore.getColor(event.calendarId)}"
|
||||
title="{formatEventTime(event.startTime)} {event.title}"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Current time indicator -->
|
||||
{#if isToday(day)}
|
||||
<div class="time-indicator" style="top: {currentTimePosition}%"></div>
|
||||
|
|
@ -645,6 +1090,18 @@
|
|||
border: none;
|
||||
cursor: pointer;
|
||||
max-width: 100%;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.all-day-event.search-highlighted {
|
||||
outline: 2px solid hsl(var(--color-primary));
|
||||
outline-offset: 1px;
|
||||
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.3);
|
||||
}
|
||||
|
||||
.all-day-event.search-dimmed {
|
||||
opacity: 0.35;
|
||||
filter: grayscale(0.3);
|
||||
}
|
||||
|
||||
.compact .all-day-event,
|
||||
|
|
@ -684,6 +1141,17 @@
|
|||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.all-day-block-event.search-highlighted {
|
||||
opacity: 0.6;
|
||||
outline: 2px solid hsl(var(--color-primary));
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.all-day-block-event.search-dimmed {
|
||||
opacity: 0.15;
|
||||
filter: grayscale(0.5);
|
||||
}
|
||||
|
||||
.all-day-block-event .event-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
|
|
@ -864,6 +1332,21 @@
|
|||
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% {
|
||||
|
|
@ -874,10 +1357,30 @@
|
|||
}
|
||||
}
|
||||
|
||||
.event-card.drag-ghost {
|
||||
opacity: 0.6;
|
||||
/* 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;
|
||||
border: 2px dashed white;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Sidebar task drop target */
|
||||
.day-column.drop-target {
|
||||
background: hsl(var(--color-primary) / 0.15);
|
||||
outline: 2px dashed hsl(var(--color-primary));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.compact .event-card,
|
||||
|
|
@ -970,4 +1473,49 @@
|
|||
border-radius: 50%;
|
||||
background: hsl(var(--color-error));
|
||||
}
|
||||
|
||||
/* Overflow indicators for events outside visible time range */
|
||||
.overflow-indicator {
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
right: 2px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
z-index: 5;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.overflow-indicator.top {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.overflow-indicator.bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.overflow-line {
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
height 0.15s ease;
|
||||
}
|
||||
|
||||
.overflow-line:hover {
|
||||
opacity: 1;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.compact .overflow-line,
|
||||
.very-compact .overflow-line {
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.compact .overflow-line:hover,
|
||||
.very-compact .overflow-line:hover {
|
||||
height: 4px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,425 @@
|
|||
<script lang="ts">
|
||||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
// Portal action - moves element to body to escape stacking contexts
|
||||
function portal(node: HTMLElement) {
|
||||
document.body.appendChild(node);
|
||||
return {
|
||||
destroy() {
|
||||
node.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface Props {
|
||||
direction?: 'up' | 'down';
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
let { direction = 'up', embedded = false }: Props = $props();
|
||||
|
||||
let isOpen = $state(false);
|
||||
let triggerButton: HTMLButtonElement;
|
||||
let dropdownPosition = $state({ top: 0, left: 0 });
|
||||
|
||||
function toggle() {
|
||||
if (triggerButton) {
|
||||
const rect = triggerButton.getBoundingClientRect();
|
||||
if (direction === 'down') {
|
||||
dropdownPosition = {
|
||||
top: rect.bottom + 8,
|
||||
left: rect.left,
|
||||
};
|
||||
} else {
|
||||
dropdownPosition = {
|
||||
top: rect.top - 8,
|
||||
left: rect.left,
|
||||
};
|
||||
}
|
||||
}
|
||||
isOpen = !isOpen;
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
function handleToggle(calendarId: string) {
|
||||
calendarsStore.toggleVisibility(calendarId);
|
||||
}
|
||||
|
||||
function handleAddCalendar() {
|
||||
close();
|
||||
goto('/settings');
|
||||
}
|
||||
|
||||
// Count visible calendars
|
||||
let visibleCount = $derived(calendarsStore.calendars.filter((c) => c.isVisible).length);
|
||||
let totalCount = $derived(calendarsStore.calendars.length);
|
||||
</script>
|
||||
|
||||
<div class="pill-calendar-selector">
|
||||
<!-- Trigger Button -->
|
||||
<button
|
||||
bind:this={triggerButton}
|
||||
onclick={toggle}
|
||||
class="trigger-button"
|
||||
class:pill={!embedded}
|
||||
class:glass-pill={!embedded}
|
||||
class:embedded-btn={embedded}
|
||||
>
|
||||
<svg class="pill-icon" 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>
|
||||
<span class="pill-label">{visibleCount}/{totalCount}</span>
|
||||
<svg
|
||||
class="chevron-icon"
|
||||
class:rotated={isOpen}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if isOpen}
|
||||
<!-- Backdrop - portal to body -->
|
||||
<button
|
||||
use:portal
|
||||
class="menu-backdrop"
|
||||
onclick={close}
|
||||
onkeydown={(e) => e.key === 'Escape' && close()}
|
||||
aria-label="Close dropdown"
|
||||
style="z-index: 99990;"
|
||||
></button>
|
||||
|
||||
<!-- Dropdown - portal to body -->
|
||||
<div
|
||||
use:portal
|
||||
class="dropdown-container"
|
||||
class:dropdown-up={direction === 'up'}
|
||||
class:dropdown-down={direction === 'down'}
|
||||
style="top: {dropdownPosition.top}px; left: {dropdownPosition.left}px; z-index: 99991;"
|
||||
>
|
||||
<div class="dropdown-header">
|
||||
<span class="header-title">Kalender</span>
|
||||
<button class="add-btn" onclick={handleAddCalendar} aria-label="Kalender hinzufügen">
|
||||
<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="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="calendar-list">
|
||||
{#each calendarsStore.calendars as calendar}
|
||||
<label class="calendar-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={calendar.isVisible}
|
||||
onchange={() => handleToggle(calendar.id)}
|
||||
style="accent-color: {calendar.color}"
|
||||
/>
|
||||
<span class="color-dot" style="background-color: {calendar.color}"></span>
|
||||
<span class="calendar-name">{calendar.name}</span>
|
||||
</label>
|
||||
{/each}
|
||||
|
||||
{#if calendarsStore.calendars.length === 0}
|
||||
<p class="empty-message">Keine Kalender</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.pill-calendar-selector {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.trigger-button {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Embedded mode - no background/border, for use inside a parent bar */
|
||||
.embedded-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
background: transparent;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
:global(.dark) .embedded-btn {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.embedded-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:global(.dark) .embedded-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.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 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
:global(.dark) .glass-pill {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.glass-pill:hover {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-color: rgba(0, 0, 0, 0.15);
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:global(.dark) .glass-pill:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.pill-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pill-label {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.chevron-icon {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
transition: transform 0.2s;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.chevron-icon.rotated {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.menu-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9998;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.dropdown-container {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
min-width: 200px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 1rem;
|
||||
box-shadow:
|
||||
0 10px 25px -5px rgba(0, 0, 0, 0.15),
|
||||
0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
||||
padding: 0.75rem;
|
||||
animation: dropdownIn 0.15s ease-out forwards;
|
||||
}
|
||||
|
||||
:global(.dark) .dropdown-container {
|
||||
background: rgba(30, 30, 30, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.dropdown-up {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
.dropdown-down {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@keyframes dropdownIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-100%) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(-100%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-down {
|
||||
animation-name: dropdownInDown;
|
||||
}
|
||||
|
||||
@keyframes dropdownInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(0) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:global(.dark) .dropdown-header {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
:global(.dark) .header-title {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
padding: 0.25rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
:global(.dark) .add-btn {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.add-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
:global(.dark) .add-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.calendar-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.calendar-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.calendar-item:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:global(.dark) .calendar-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.calendar-item input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.color-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 9999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.calendar-name {
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
:global(.dark) .calendar-name {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
:global(.dark) .empty-message {
|
||||
color: #9ca3af;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,297 @@
|
|||
<script lang="ts">
|
||||
import type { Task } from '$lib/api/todos';
|
||||
import { todosStore } from '$lib/stores/todos.svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { CheckSquare, Square } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
task: Task;
|
||||
style: string;
|
||||
onTaskClick?: (task: Task) => void;
|
||||
onDragStart?: (task: Task, e: PointerEvent) => void;
|
||||
onResizeStart?: (task: Task, edge: 'top' | 'bottom', e: PointerEvent) => void;
|
||||
isDragging?: boolean;
|
||||
isResizing?: boolean;
|
||||
isDraggingSource?: boolean; // True when this is the source of a cross-day drag (shows as ghost)
|
||||
}
|
||||
|
||||
let {
|
||||
task,
|
||||
style,
|
||||
onTaskClick,
|
||||
onDragStart,
|
||||
onResizeStart,
|
||||
isDragging = false,
|
||||
isResizing = false,
|
||||
isDraggingSource = false,
|
||||
}: Props = $props();
|
||||
|
||||
// Priority colors
|
||||
const PRIORITY_COLORS: Record<string, string> = {
|
||||
urgent: 'hsl(0, 72%, 51%)', // red
|
||||
high: 'hsl(25, 95%, 53%)', // orange
|
||||
medium: 'hsl(48, 96%, 53%)', // yellow
|
||||
low: 'hsl(142, 71%, 45%)', // green
|
||||
};
|
||||
|
||||
let priorityColor = $derived(PRIORITY_COLORS[task.priority] || PRIORITY_COLORS.medium);
|
||||
|
||||
async function toggleComplete(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
await todosStore.toggleComplete(task.id);
|
||||
}
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
// Don't trigger click if we just finished dragging
|
||||
if (isDragging || isResizing) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
onTaskClick?.(task);
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onTaskClick?.(task);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
// Don't allow dragging completed tasks
|
||||
if (task.isCompleted) return;
|
||||
// Don't start drag from checkbox
|
||||
if ((e.target as HTMLElement).closest('.task-checkbox')) return;
|
||||
// Don't start drag from resize handles
|
||||
if ((e.target as HTMLElement).closest('.resize-handle')) return;
|
||||
|
||||
onDragStart?.(task, e);
|
||||
}
|
||||
|
||||
function handleResizeTop(e: PointerEvent) {
|
||||
if (task.isCompleted) return;
|
||||
e.stopPropagation();
|
||||
onResizeStart?.(task, 'top', e);
|
||||
}
|
||||
|
||||
function handleResizeBottom(e: PointerEvent) {
|
||||
if (task.isCompleted) return;
|
||||
e.stopPropagation();
|
||||
onResizeStart?.(task, 'bottom', e);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="task-block"
|
||||
class:completed={task.isCompleted}
|
||||
class:dragging={isDragging}
|
||||
class:resizing={isResizing}
|
||||
class:dragging-source={isDraggingSource}
|
||||
{style}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="{$_('todo.task')}: {task.title}"
|
||||
onclick={handleClick}
|
||||
onkeydown={handleKeydown}
|
||||
onpointerdown={handlePointerDown}
|
||||
>
|
||||
<!-- Top resize handle (only for non-completed tasks) -->
|
||||
{#if onResizeStart && !task.isCompleted}
|
||||
<div
|
||||
class="resize-handle top"
|
||||
onpointerdown={handleResizeTop}
|
||||
role="slider"
|
||||
aria-label={$_('event.changeStartTime')}
|
||||
aria-valuenow={0}
|
||||
tabindex="-1"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<div class="task-priority-indicator" style="background-color: {priorityColor}"></div>
|
||||
|
||||
<button
|
||||
class="task-checkbox"
|
||||
onclick={toggleComplete}
|
||||
aria-label={task.isCompleted ? $_('todo.markIncomplete') : $_('todo.markComplete')}
|
||||
>
|
||||
{#if task.isCompleted}
|
||||
<CheckSquare size={14} />
|
||||
{:else}
|
||||
<Square size={14} />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<div class="task-content">
|
||||
<span class="task-time">
|
||||
{task.scheduledStartTime || ''}
|
||||
{#if task.scheduledEndTime}
|
||||
- {task.scheduledEndTime}
|
||||
{/if}
|
||||
</span>
|
||||
<span class="task-title">{task.title}</span>
|
||||
</div>
|
||||
|
||||
<!-- Bottom resize handle (only for non-completed tasks) -->
|
||||
{#if onResizeStart && !task.isCompleted}
|
||||
<div
|
||||
class="resize-handle bottom"
|
||||
onpointerdown={handleResizeBottom}
|
||||
role="slider"
|
||||
aria-label={$_('event.changeEndTime')}
|
||||
aria-valuenow={0}
|
||||
tabindex="-1"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.task-block {
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
right: 2px;
|
||||
padding: 2px 4px;
|
||||
background: hsl(var(--color-surface));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: var(--radius-sm);
|
||||
text-align: left;
|
||||
cursor: grab;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
transition:
|
||||
box-shadow 0.15s ease,
|
||||
opacity 0.15s ease;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.task-block:hover {
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
|
||||
.task-block.completed {
|
||||
background: hsl(var(--color-muted) / 0.3);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.task-block.completed .task-title {
|
||||
text-decoration: line-through;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.task-block.completed .task-checkbox {
|
||||
color: hsl(var(--color-success, 142 71% 45%));
|
||||
}
|
||||
|
||||
.task-block.completed .task-priority-indicator {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.task-block.dragging {
|
||||
cursor: grabbing;
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.task-block.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 */
|
||||
.task-block.dragging-source {
|
||||
opacity: 0.5;
|
||||
background: transparent;
|
||||
border: 2px dashed hsl(var(--color-border));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.task-block.dragging-source .task-title,
|
||||
.task-block.dragging-source .task-time,
|
||||
.task-block.dragging-source .task-checkbox {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.task-priority-indicator {
|
||||
width: 3px;
|
||||
min-height: 100%;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.task-checkbox {
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
margin-top: 1px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--color-foreground));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.task-checkbox:hover {
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.task-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.task-time {
|
||||
font-size: 0.6rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
display: block;
|
||||
}
|
||||
|
||||
.task-title {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
|
||||
.task-block:hover .resize-handle {
|
||||
opacity: 1;
|
||||
background: hsl(var(--color-primary) / 0.2);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -27,6 +27,8 @@
|
|||
// Fetch todos on mount
|
||||
await todosStore.fetchTodayTodos();
|
||||
await todosStore.fetchUpcomingTodos();
|
||||
// Also fetch scheduled todos (including completed) for calendar display
|
||||
await todosStore.fetchScheduledTodos();
|
||||
});
|
||||
|
||||
function toggleExpanded() {
|
||||
|
|
@ -116,6 +118,7 @@
|
|||
{task}
|
||||
variant="compact"
|
||||
showProject={false}
|
||||
draggable={!task.isCompleted}
|
||||
onclick={() => handleTaskClick(task)}
|
||||
/>
|
||||
{/each}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@
|
|||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { todosStore } from '$lib/stores/todos.svelte';
|
||||
import TodoRow from './TodoRow.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 {
|
||||
format,
|
||||
|
|
@ -21,13 +22,17 @@
|
|||
getWeek,
|
||||
} from 'date-fns';
|
||||
import { de, enUS, fr, es, it } from 'date-fns/locale';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { locale, _ } from 'svelte-i18n';
|
||||
|
||||
import type { CalendarEvent } from '@calendar/shared';
|
||||
|
||||
interface Props {
|
||||
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
|
||||
onEventClick?: (event: CalendarEvent) => void;
|
||||
onTaskClick?: (task: Task) => void;
|
||||
}
|
||||
|
||||
let { onQuickCreate }: Props = $props();
|
||||
let { onQuickCreate, onEventClick, onTaskClick }: Props = $props();
|
||||
|
||||
// Constants
|
||||
const HOUR_HEIGHT = 60; // px - should match CSS --hour-height
|
||||
|
|
@ -94,7 +99,7 @@
|
|||
|
||||
// Drag & Drop State
|
||||
let isDragging = $state(false);
|
||||
let draggedEvent = $state<any>(null);
|
||||
let draggedEvent = $state<CalendarEvent | null>(null);
|
||||
let dragOffsetMinutes = $state(0);
|
||||
let dragTargetDay = $state<Date | null>(null);
|
||||
let dragPreviewTop = $state(0);
|
||||
|
|
@ -102,7 +107,7 @@
|
|||
|
||||
// Resize State
|
||||
let isResizing = $state(false);
|
||||
let resizeEvent = $state<any>(null);
|
||||
let resizeEvent = $state<CalendarEvent | null>(null);
|
||||
let resizeEdge = $state<'top' | 'bottom'>('bottom');
|
||||
let resizeOriginalStart = $state<Date | null>(null);
|
||||
let resizeOriginalEnd = $state<Date | null>(null);
|
||||
|
|
@ -112,11 +117,79 @@
|
|||
// Track if we actually moved during drag/resize (to prevent click on simple mousedown/up)
|
||||
let hasMoved = $state(false);
|
||||
|
||||
// Task Drag & Drop State
|
||||
let isTaskDragging = $state(false);
|
||||
let draggedTask = $state<Task | null>(null);
|
||||
let taskDragTargetDay = $state<Date | null>(null);
|
||||
let taskDragPreviewTop = $state(0);
|
||||
let taskDragPreviewHeight = $state(0);
|
||||
|
||||
// Task Resize State
|
||||
let isTaskResizing = $state(false);
|
||||
let resizeTask = $state<Task | null>(null);
|
||||
let taskResizeEdge = $state<'top' | 'bottom'>('bottom');
|
||||
let taskResizePreviewTop = $state(0);
|
||||
let taskResizePreviewHeight = $state(0);
|
||||
|
||||
// Reference to the days container for position calculations
|
||||
let daysContainerEl: HTMLDivElement;
|
||||
|
||||
function getEventsForDay(day: Date) {
|
||||
return eventsStore.getEventsForDay(day).filter((e) => !e.isAllDay);
|
||||
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;
|
||||
}
|
||||
|
||||
// Get events that are completely outside the visible time range
|
||||
function getOverflowEventsForDay(day: Date): { before: CalendarEvent[]; after: CalendarEvent[] } {
|
||||
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 };
|
||||
}
|
||||
|
||||
function getAllDayEventsForDay(day: Date) {
|
||||
|
|
@ -124,8 +197,11 @@
|
|||
}
|
||||
|
||||
// Get display mode for an event (per-event override takes precedence over global setting)
|
||||
function getEventDisplayMode(event: any): 'header' | 'block' {
|
||||
return event.metadata?.allDayDisplayMode || settingsStore.allDayDisplayMode;
|
||||
function getEventDisplayMode(event: CalendarEvent): 'header' | 'block' {
|
||||
return (
|
||||
(event.metadata as { allDayDisplayMode?: 'header' | 'block' } | null)?.allDayDisplayMode ||
|
||||
settingsStore.allDayDisplayMode
|
||||
);
|
||||
}
|
||||
|
||||
// Split all-day events by display mode
|
||||
|
|
@ -142,7 +218,7 @@
|
|||
days.some((day) => getHeaderAllDayEventsForDay(day).length > 0)
|
||||
);
|
||||
|
||||
function getEventStyle(event: any) {
|
||||
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;
|
||||
|
||||
|
|
@ -157,12 +233,43 @@
|
|||
return `top: ${top}%; height: ${height}%; background-color: ${color};`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get style for a scheduled task (time-blocking)
|
||||
*/
|
||||
function getTaskStyle(task: Task): string {
|
||||
if (!task.scheduledStartTime) return '';
|
||||
|
||||
// Parse HH:mm time
|
||||
const [startHour, startMin] = task.scheduledStartTime.split(':').map(Number);
|
||||
const startMinutes = startHour * 60 + startMin;
|
||||
|
||||
// Calculate duration - use estimatedDuration or scheduledEndTime or default 30 min
|
||||
let duration = task.estimatedDuration || 30;
|
||||
if (task.scheduledEndTime) {
|
||||
const [endHour, endMin] = task.scheduledEndTime.split(':').map(Number);
|
||||
const endMinutes = endHour * 60 + endMin;
|
||||
duration = endMinutes - startMinutes;
|
||||
}
|
||||
|
||||
const top = minutesToPercent(startMinutes);
|
||||
const height = Math.max((duration / (totalVisibleHours * 60)) * 100, 2);
|
||||
|
||||
return `top: ${top}%; height: ${height}%;`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scheduled tasks for a specific day
|
||||
*/
|
||||
function getScheduledTasksForDay(day: Date): Task[] {
|
||||
return todosStore.getScheduledTasksForDay(day);
|
||||
}
|
||||
|
||||
function formatEventTime(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? parseISO(date) : date;
|
||||
return settingsStore.formatTime(d);
|
||||
}
|
||||
|
||||
function handleEventClick(event: any, e: MouseEvent) {
|
||||
function handleEventClick(event: CalendarEvent, e: MouseEvent) {
|
||||
// Don't navigate if we just finished dragging or resizing, or if we moved
|
||||
if (isDragging || isResizing || hasMoved) {
|
||||
e.preventDefault();
|
||||
|
|
@ -173,7 +280,11 @@
|
|||
}, 100);
|
||||
return;
|
||||
}
|
||||
goto(`/?event=${event.id}`);
|
||||
if (onEventClick) {
|
||||
onEventClick(event);
|
||||
} else {
|
||||
goto(`/?event=${event.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSlotClick(day: Date, hour: number, e: MouseEvent) {
|
||||
|
|
@ -220,7 +331,7 @@
|
|||
return Math.round(totalMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT;
|
||||
}
|
||||
|
||||
function startDrag(event: any, e: PointerEvent) {
|
||||
function startDrag(event: CalendarEvent, e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
|
|
@ -323,7 +434,7 @@
|
|||
|
||||
// ========== Resize Functions ==========
|
||||
|
||||
function startResize(event: any, edge: 'top' | 'bottom', e: PointerEvent) {
|
||||
function startResize(event: CalendarEvent, edge: 'top' | 'bottom', e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
|
|
@ -439,6 +550,263 @@
|
|||
hasMoved = false;
|
||||
}
|
||||
|
||||
// ========== Task Drag & Drop ==========
|
||||
|
||||
function handleTaskDragStart(task: Task, e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
isTaskDragging = true;
|
||||
draggedTask = task;
|
||||
hasMoved = false;
|
||||
|
||||
// Initialize preview position
|
||||
if (task.scheduledStartTime) {
|
||||
const [h, m] = task.scheduledStartTime.split(':').map(Number);
|
||||
const startMinutes = h * 60 + m - firstVisibleHour * 60;
|
||||
taskDragPreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100;
|
||||
}
|
||||
|
||||
const duration = task.estimatedDuration || 30;
|
||||
taskDragPreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
|
||||
|
||||
document.addEventListener('pointermove', handleTaskDragMove);
|
||||
document.addEventListener('pointerup', handleTaskDragEnd);
|
||||
}
|
||||
|
||||
function handleTaskDragMove(e: PointerEvent) {
|
||||
if (!isTaskDragging || !draggedTask) return;
|
||||
hasMoved = true;
|
||||
|
||||
// Find which day column we're over
|
||||
const daysEl = daysContainerEl;
|
||||
if (!daysEl) return;
|
||||
|
||||
const dayColumns = daysEl.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 = days[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate vertical position
|
||||
const targetColumn = daysEl.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));
|
||||
|
||||
// Snap to 15-minute intervals
|
||||
const minutesPerPercent = (totalVisibleHours * 60) / 100;
|
||||
const rawMinutes = percentY * minutesPerPercent;
|
||||
const snappedMinutes = Math.round(rawMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT;
|
||||
taskDragPreviewTop = (snappedMinutes / (totalVisibleHours * 60)) * 100;
|
||||
}
|
||||
|
||||
async function handleTaskDragEnd(e: PointerEvent) {
|
||||
document.removeEventListener('pointermove', handleTaskDragMove);
|
||||
document.removeEventListener('pointerup', handleTaskDragEnd);
|
||||
|
||||
if (!isTaskDragging || !draggedTask || !hasMoved) {
|
||||
isTaskDragging = false;
|
||||
draggedTask = null;
|
||||
taskDragTargetDay = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate new time from position
|
||||
const minutesFromStart = (taskDragPreviewTop / 100) * (totalVisibleHours * 60);
|
||||
const totalMinutes = firstVisibleHour * 60 + minutesFromStart;
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = Math.round(totalMinutes % 60);
|
||||
|
||||
const newStartTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
||||
|
||||
// Calculate end time based on duration
|
||||
const duration = draggedTask.estimatedDuration || 30;
|
||||
const endTotalMinutes = totalMinutes + duration;
|
||||
const endHours = Math.floor(endTotalMinutes / 60);
|
||||
const endMins = Math.round(endTotalMinutes % 60);
|
||||
const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
|
||||
|
||||
await todosStore.updateTodo(draggedTask.id, {
|
||||
scheduledDate: taskDragTargetDay ? format(taskDragTargetDay, 'yyyy-MM-dd') : undefined,
|
||||
scheduledStartTime: newStartTime,
|
||||
scheduledEndTime: newEndTime,
|
||||
});
|
||||
|
||||
isTaskDragging = false;
|
||||
draggedTask = null;
|
||||
taskDragTargetDay = null;
|
||||
hasMoved = false;
|
||||
}
|
||||
|
||||
// ========== Task Resize ==========
|
||||
|
||||
function handleTaskResizeStart(task: Task, edge: 'top' | 'bottom', e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isTaskResizing = true;
|
||||
resizeTask = task;
|
||||
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;
|
||||
taskResizePreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100;
|
||||
}
|
||||
|
||||
const duration = task.estimatedDuration || 30;
|
||||
taskResizePreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
|
||||
|
||||
document.addEventListener('pointermove', handleTaskResizeMove);
|
||||
document.addEventListener('pointerup', handleTaskResizeEnd);
|
||||
}
|
||||
|
||||
function handleTaskResizeMove(e: PointerEvent) {
|
||||
if (!isTaskResizing || !resizeTask) return;
|
||||
hasMoved = true;
|
||||
|
||||
const daysEl = daysContainerEl;
|
||||
if (!daysEl) return;
|
||||
|
||||
const targetColumn = daysEl.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;
|
||||
|
||||
if (taskResizeEdge === 'top') {
|
||||
// Adjust start time, keep end fixed
|
||||
const originalEndPercent = taskResizePreviewTop + taskResizePreviewHeight;
|
||||
const rawMinutes = percentY * minutesPerPercent;
|
||||
const snappedMinutes = Math.round(rawMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT;
|
||||
taskResizePreviewTop = (snappedMinutes / (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 / MINUTES_PER_SLOT) * MINUTES_PER_SLOT;
|
||||
const newBottom = (snappedMinutes / (totalVisibleHours * 60)) * 100;
|
||||
taskResizePreviewHeight = Math.max(2, newBottom - taskResizePreviewTop);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTaskResizeEnd(e: PointerEvent) {
|
||||
document.removeEventListener('pointermove', handleTaskResizeMove);
|
||||
document.removeEventListener('pointerup', handleTaskResizeEnd);
|
||||
|
||||
if (!isTaskResizing || !resizeTask || !hasMoved) {
|
||||
isTaskResizing = false;
|
||||
resizeTask = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate new times from position
|
||||
const startMinutes =
|
||||
(taskResizePreviewTop / 100) * (totalVisibleHours * 60) + firstVisibleHour * 60;
|
||||
const endMinutes =
|
||||
((taskResizePreviewTop + taskResizePreviewHeight) / 100) * (totalVisibleHours * 60) +
|
||||
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 newDuration = Math.round(endMinutes - startMinutes);
|
||||
|
||||
await todosStore.updateTodo(resizeTask.id, {
|
||||
scheduledStartTime: newStartTime,
|
||||
scheduledEndTime: newEndTime,
|
||||
estimatedDuration: newDuration,
|
||||
});
|
||||
|
||||
isTaskResizing = false;
|
||||
resizeTask = null;
|
||||
hasMoved = false;
|
||||
}
|
||||
|
||||
// ========== Sidebar Task Drop ==========
|
||||
let sidebarDropTarget = $state<{ day: Date; y: number } | null>(null);
|
||||
|
||||
function handleSidebarDragOver(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';
|
||||
sidebarDropTarget = { day, y: e.clientY };
|
||||
}
|
||||
|
||||
function handleSidebarDragLeave(e: DragEvent) {
|
||||
// Only clear if leaving the column entirely
|
||||
const relatedTarget = e.relatedTarget as HTMLElement;
|
||||
if (!relatedTarget?.closest('.day-column')) {
|
||||
sidebarDropTarget = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSidebarDrop(e: DragEvent, day: Date) {
|
||||
e.preventDefault();
|
||||
sidebarDropTarget = 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;
|
||||
|
||||
// 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 = (totalVisibleHours * 60) / 100;
|
||||
const rawMinutes = percentY * minutesPerPercent;
|
||||
const snappedMinutes = Math.round(rawMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT;
|
||||
const totalMinutes = firstVisibleHour * 60 + snappedMinutes;
|
||||
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
const startTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
||||
|
||||
// Calculate end time
|
||||
const duration = data.estimatedDuration || 30;
|
||||
const endMinutes = totalMinutes + duration;
|
||||
const endHours = Math.floor(endMinutes / 60);
|
||||
const endMins = endMinutes % 60;
|
||||
const endTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Keyboard Handling ==========
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
|
|
@ -459,6 +827,20 @@
|
|||
resizeOriginalEnd = null;
|
||||
hasMoved = false;
|
||||
}
|
||||
// Cancel task drag/resize
|
||||
if (isTaskDragging || isTaskResizing) {
|
||||
e.preventDefault();
|
||||
document.removeEventListener('pointermove', handleTaskDragMove);
|
||||
document.removeEventListener('pointerup', handleTaskDragEnd);
|
||||
document.removeEventListener('pointermove', handleTaskResizeMove);
|
||||
document.removeEventListener('pointerup', handleTaskResizeEnd);
|
||||
isTaskDragging = false;
|
||||
draggedTask = null;
|
||||
taskDragTargetDay = null;
|
||||
isTaskResizing = false;
|
||||
resizeTask = null;
|
||||
hasMoved = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -473,7 +855,8 @@
|
|||
<!-- Week number indicator (if enabled) -->
|
||||
{#if settingsStore.showWeekNumbers}
|
||||
<div class="week-number-indicator">
|
||||
KW {weekNumber}
|
||||
{$_('views.weekNumber')}
|
||||
{weekNumber}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
|
@ -482,7 +865,7 @@
|
|||
<div class="all-day-row">
|
||||
<div class="time-gutter">
|
||||
{#if settingsStore.showWeekNumbers}
|
||||
<span class="week-label">KW {weekNumber}</span>
|
||||
<span class="week-label">{$_('views.weekNumber')} {weekNumber}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#each days as day}
|
||||
|
|
@ -490,6 +873,8 @@
|
|||
{#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}`)}
|
||||
>
|
||||
|
|
@ -501,18 +886,6 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Todos row (shown per day, below all-day events) -->
|
||||
{#if todosStore.serviceAvailable}
|
||||
<div class="todos-row">
|
||||
<div class="time-gutter"></div>
|
||||
{#each days as day}
|
||||
<div class="todos-cell">
|
||||
<TodoRow date={day} maxVisible={2} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Day headers -->
|
||||
<div class="day-headers">
|
||||
<div class="time-gutter"></div>
|
||||
|
|
@ -538,7 +911,15 @@
|
|||
<!-- Day columns -->
|
||||
<div class="days-container" bind:this={daysContainerEl}>
|
||||
{#each days as day, dayIndex}
|
||||
<div class="day-column" class:today={isToday(day)}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="day-column"
|
||||
class:today={isToday(day)}
|
||||
class:drop-target={sidebarDropTarget && isSameDay(day, sidebarDropTarget.day)}
|
||||
ondragover={(e) => handleSidebarDragOver(e, day)}
|
||||
ondragleave={handleSidebarDragLeave}
|
||||
ondrop={(e) => handleSidebarDrop(e, day)}
|
||||
>
|
||||
{#each hours as hour}
|
||||
<button
|
||||
class="hour-slot"
|
||||
|
|
@ -551,6 +932,8 @@
|
|||
{#each getBlockAllDayEventsForDay(day) as event (event.id)}
|
||||
<button
|
||||
class="all-day-block-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}`)}
|
||||
>
|
||||
|
|
@ -563,19 +946,27 @@
|
|||
{@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}
|
||||
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}
|
||||
style={isBeingDragged
|
||||
style={isBeingDragged && !isCrossDayDrag
|
||||
? `top: ${dragPreviewTop}%; height: ${dragPreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
|
||||
: isBeingResized
|
||||
? `top: ${resizePreviewTop}%; height: ${resizePreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
|
||||
: 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}`)}
|
||||
|
|
@ -584,31 +975,68 @@
|
|||
<div
|
||||
class="resize-handle top"
|
||||
onpointerdown={(e) => startResize(event, 'top', e)}
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="Startzeit ändern"
|
||||
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 ? '(Neuer Termin)' : '')}</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="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="Endzeit ändern"
|
||||
role="slider"
|
||||
aria-label={$_('event.changeEndTime')}
|
||||
aria-valuenow={0}
|
||||
tabindex="-1"
|
||||
></div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Drag preview ghost (for cross-day dragging) -->
|
||||
{#if isDragging && draggedEvent && dragTargetDay && isSameDay(day, dragTargetDay) && !getEventsForDay(day).some((e) => e.id === draggedEvent.id)}
|
||||
<!-- Scheduled Tasks (Time-Blocking) -->
|
||||
{#each getScheduledTasksForDay(day) as task (task.id)}
|
||||
{@const isTaskBeingDragged = isTaskDragging && draggedTask?.id === task.id}
|
||||
{@const isTaskBeingResized = isTaskResizing && resizeTask?.id === task.id}
|
||||
{@const isTaskCrossDayDrag =
|
||||
isTaskBeingDragged &&
|
||||
taskDragTargetDay !== null &&
|
||||
!isSameDay(day, taskDragTargetDay)}
|
||||
<TaskBlock
|
||||
{task}
|
||||
style={isTaskBeingDragged && !isTaskCrossDayDrag
|
||||
? `top: ${taskDragPreviewTop}%; height: ${taskDragPreviewHeight}%;`
|
||||
: isTaskBeingResized
|
||||
? `top: ${taskResizePreviewTop}%; height: ${taskResizePreviewHeight}%;`
|
||||
: getTaskStyle(task)}
|
||||
{onTaskClick}
|
||||
onDragStart={handleTaskDragStart}
|
||||
onResizeStart={handleTaskResizeStart}
|
||||
isDragging={isTaskBeingDragged && !isTaskCrossDayDrag}
|
||||
isResizing={isTaskBeingResized}
|
||||
isDraggingSource={isTaskCrossDayDrag}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<!-- Task Drag preview (solid) for cross-day dragging - shows where task will be -->
|
||||
{#if isTaskDragging && draggedTask && taskDragTargetDay && isSameDay(day, taskDragTargetDay) && !getScheduledTasksForDay(day).some((t) => t.id === draggedTask!.id)}
|
||||
<TaskBlock
|
||||
task={draggedTask}
|
||||
style="top: {taskDragPreviewTop}%; height: {taskDragPreviewHeight}%;"
|
||||
isDragging={true}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- 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-ghost"
|
||||
class="event-card drag-preview"
|
||||
style="top: {dragPreviewTop}%; height: {dragPreviewHeight}%; background-color: {calendarsStore.getColor(
|
||||
draggedEvent.calendarId
|
||||
)};"
|
||||
|
|
@ -618,6 +1046,36 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Overflow indicators for events outside visible time range -->
|
||||
{#if true}
|
||||
{@const overflow = getOverflowEventsForDay(day)}
|
||||
{#if overflow.before.length > 0}
|
||||
<div class="overflow-indicator top" title="{overflow.before.length} Termin(e) früher">
|
||||
{#each overflow.before as event}
|
||||
<div
|
||||
class="overflow-line"
|
||||
style="background-color: {calendarsStore.getColor(event.calendarId)}"
|
||||
title="{formatEventTime(event.startTime)} {event.title}"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if overflow.after.length > 0}
|
||||
<div
|
||||
class="overflow-indicator bottom"
|
||||
title="{overflow.after.length} Termin(e) später"
|
||||
>
|
||||
{#each overflow.after as event}
|
||||
<div
|
||||
class="overflow-line"
|
||||
style="background-color: {calendarsStore.getColor(event.calendarId)}"
|
||||
title="{formatEventTime(event.startTime)} {event.title}"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Current time indicator -->
|
||||
{#if isToday(day)}
|
||||
<div class="time-indicator" style="top: {currentTimePosition}%"></div>
|
||||
|
|
@ -663,18 +1121,18 @@
|
|||
text-overflow: ellipsis;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
/* Todos row */
|
||||
.todos-row {
|
||||
display: flex;
|
||||
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
|
||||
.all-day-event.search-highlighted {
|
||||
outline: 2px solid hsl(var(--color-primary));
|
||||
outline-offset: 1px;
|
||||
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.3);
|
||||
}
|
||||
|
||||
.todos-cell {
|
||||
flex: 1;
|
||||
border-left: 1px solid hsl(var(--color-border));
|
||||
min-height: 0;
|
||||
.all-day-event.search-dimmed {
|
||||
opacity: 0.35;
|
||||
filter: grayscale(0.3);
|
||||
}
|
||||
|
||||
/* Block-style all-day events (displayed as full-day blocks in the grid) */
|
||||
|
|
@ -701,6 +1159,17 @@
|
|||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.all-day-block-event.search-highlighted {
|
||||
opacity: 0.6;
|
||||
outline: 2px solid hsl(var(--color-primary));
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.all-day-block-event.search-dimmed {
|
||||
opacity: 0.15;
|
||||
filter: grayscale(0.5);
|
||||
}
|
||||
|
||||
.all-day-block-event .event-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
|
|
@ -796,6 +1265,12 @@
|
|||
background: hsl(var(--color-primary) / 0.05);
|
||||
}
|
||||
|
||||
.day-column.drop-target {
|
||||
background: hsl(var(--color-primary) / 0.15);
|
||||
outline: 2px dashed hsl(var(--color-primary));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.hour-slot {
|
||||
height: var(--hour-height);
|
||||
width: 100%;
|
||||
|
|
@ -847,10 +1322,38 @@
|
|||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.event-card.drag-ghost {
|
||||
opacity: 0.6;
|
||||
/* 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;
|
||||
border: 2px dashed white;
|
||||
}
|
||||
|
||||
.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 {
|
||||
|
|
@ -934,4 +1437,39 @@
|
|||
background: hsl(var(--color-error));
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Overflow indicators for events outside visible time range */
|
||||
.overflow-indicator {
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
right: 2px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
z-index: 5;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.overflow-indicator.top {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.overflow-indicator.bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.overflow-line {
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
height 0.15s ease;
|
||||
}
|
||||
|
||||
.overflow-line:hover {
|
||||
opacity: 1;
|
||||
height: 5px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -16,13 +16,14 @@
|
|||
setMinutes,
|
||||
} from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import type { CalendarViewType } from '@calendar/shared';
|
||||
import type { CalendarViewType, CalendarEvent } from '@calendar/shared';
|
||||
|
||||
interface Props {
|
||||
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
|
||||
onEventClick?: (event: CalendarEvent) => void;
|
||||
}
|
||||
|
||||
let { onQuickCreate }: Props = $props();
|
||||
let { onQuickCreate, onEventClick }: Props = $props();
|
||||
|
||||
// Derived values
|
||||
let year = $derived(viewStore.currentDate.getFullYear());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,266 @@
|
|||
<script lang="ts">
|
||||
import type { EventAttendee, AttendeeStatus } from '@calendar/shared';
|
||||
import type { ContactSummary, ContactOrManual, ManualContactEntry } from '@manacore/shared-types';
|
||||
import { ContactSelector, ContactAvatar } from '@manacore/shared-ui';
|
||||
import { Check, X, HelpCircle, Clock, ChevronDown } from 'lucide-svelte';
|
||||
import { contactsStore } from '$lib/stores/contacts.svelte';
|
||||
|
||||
interface Props {
|
||||
attendees: EventAttendee[];
|
||||
onAttendeesChange: (attendees: EventAttendee[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { attendees, onAttendeesChange, disabled = false }: Props = $props();
|
||||
|
||||
let contactsAvailable = $state<boolean | null>(null);
|
||||
let showStatusDropdown = $state<string | null>(null);
|
||||
|
||||
// Check contacts availability on mount
|
||||
$effect(() => {
|
||||
contactsStore.checkAvailability().then((available) => {
|
||||
contactsAvailable = available;
|
||||
});
|
||||
});
|
||||
|
||||
// Convert attendees to ContactOrManual format for the selector
|
||||
const selectedContacts = $derived<ContactOrManual[]>(
|
||||
attendees.map((a) => {
|
||||
if (a.contactId) {
|
||||
return {
|
||||
contactId: a.contactId,
|
||||
displayName: a.name || a.email,
|
||||
email: a.email,
|
||||
photoUrl: a.photoUrl,
|
||||
company: a.company,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
// Manual entry
|
||||
return {
|
||||
email: a.email,
|
||||
name: a.name,
|
||||
isManual: true as const,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
function handleContactsChange(contacts: ContactOrManual[]) {
|
||||
const newAttendees: EventAttendee[] = contacts.map((contact) => {
|
||||
if ('isManual' in contact && contact.isManual) {
|
||||
// Manual entry
|
||||
const manual = contact as ManualContactEntry;
|
||||
// Preserve existing status if email matches
|
||||
const existing = attendees.find((a) => a.email === manual.email);
|
||||
return {
|
||||
email: manual.email,
|
||||
name: manual.name,
|
||||
status: existing?.status || ('pending' as AttendeeStatus),
|
||||
};
|
||||
} else {
|
||||
// Contact reference
|
||||
const contactRef = contact as {
|
||||
contactId: string;
|
||||
displayName: string;
|
||||
email?: string;
|
||||
photoUrl?: string;
|
||||
company?: string;
|
||||
};
|
||||
// Preserve existing status if contactId or email matches
|
||||
const existing = attendees.find(
|
||||
(a) => a.contactId === contactRef.contactId || a.email === contactRef.email
|
||||
);
|
||||
return {
|
||||
email: contactRef.email || '',
|
||||
name: contactRef.displayName,
|
||||
status: existing?.status || ('pending' as AttendeeStatus),
|
||||
contactId: contactRef.contactId,
|
||||
photoUrl: contactRef.photoUrl,
|
||||
company: contactRef.company,
|
||||
};
|
||||
}
|
||||
});
|
||||
onAttendeesChange(newAttendees);
|
||||
}
|
||||
|
||||
function handleSearch(query: string): Promise<ContactSummary[]> {
|
||||
return contactsStore.searchContacts(query);
|
||||
}
|
||||
|
||||
function handleStatusChange(email: string, status: AttendeeStatus) {
|
||||
const updated = attendees.map((a) => (a.email === email ? { ...a, status } : a));
|
||||
onAttendeesChange(updated);
|
||||
showStatusDropdown = null;
|
||||
}
|
||||
|
||||
function handleRemoveAttendee(email: string) {
|
||||
onAttendeesChange(attendees.filter((a) => a.email !== email));
|
||||
}
|
||||
|
||||
function getStatusColor(status?: AttendeeStatus): string {
|
||||
switch (status) {
|
||||
case 'accepted':
|
||||
return 'text-green-600 bg-green-100 dark:text-green-400 dark:bg-green-900/30';
|
||||
case 'declined':
|
||||
return 'text-red-600 bg-red-100 dark:text-red-400 dark:bg-red-900/30';
|
||||
case 'tentative':
|
||||
return 'text-yellow-600 bg-yellow-100 dark:text-yellow-400 dark:bg-yellow-900/30';
|
||||
default:
|
||||
return 'text-gray-500 bg-gray-100 dark:text-gray-400 dark:bg-gray-800';
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusLabel(status?: AttendeeStatus): string {
|
||||
switch (status) {
|
||||
case 'accepted':
|
||||
return 'Zugesagt';
|
||||
case 'declined':
|
||||
return 'Abgesagt';
|
||||
case 'tentative':
|
||||
return 'Vorbehaltlich';
|
||||
default:
|
||||
return 'Ausstehend';
|
||||
}
|
||||
}
|
||||
|
||||
const statusOptions: { value: AttendeeStatus; label: string }[] = [
|
||||
{ value: 'pending', label: 'Ausstehend' },
|
||||
{ value: 'accepted', label: 'Zugesagt' },
|
||||
{ value: 'tentative', label: 'Vorbehaltlich' },
|
||||
{ value: 'declined', label: 'Abgesagt' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="attendee-selector">
|
||||
<!-- Existing Attendees with Status -->
|
||||
{#if attendees.length > 0}
|
||||
<div class="space-y-2 mb-4">
|
||||
{#each attendees as attendee (attendee.email)}
|
||||
<div class="flex items-center gap-3 p-2 rounded-lg bg-gray-50 dark:bg-gray-800/50">
|
||||
<ContactAvatar
|
||||
photoUrl={attendee.photoUrl}
|
||||
name={attendee.name || attendee.email}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-foreground truncate">
|
||||
{attendee.name || attendee.email}
|
||||
</div>
|
||||
{#if attendee.name && attendee.email}
|
||||
<div class="text-xs text-muted-foreground truncate">
|
||||
{attendee.email}
|
||||
</div>
|
||||
{/if}
|
||||
{#if attendee.company}
|
||||
<div class="text-xs text-muted-foreground truncate">
|
||||
{attendee.company}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Status Dropdown -->
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() =>
|
||||
(showStatusDropdown =
|
||||
showStatusDropdown === attendee.email ? null : attendee.email)}
|
||||
class="
|
||||
flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium
|
||||
{getStatusColor(attendee.status)}
|
||||
hover:opacity-80 transition-opacity
|
||||
"
|
||||
{disabled}
|
||||
>
|
||||
{#if attendee.status === 'accepted'}
|
||||
<Check size={12} />
|
||||
{:else if attendee.status === 'declined'}
|
||||
<X size={12} />
|
||||
{:else if attendee.status === 'tentative'}
|
||||
<HelpCircle size={12} />
|
||||
{:else}
|
||||
<Clock size={12} />
|
||||
{/if}
|
||||
<span class="hidden sm:inline">{getStatusLabel(attendee.status)}</span>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
|
||||
{#if showStatusDropdown === attendee.email}
|
||||
<div
|
||||
class="
|
||||
absolute right-0 top-full mt-1 z-50
|
||||
bg-white dark:bg-gray-800
|
||||
border border-gray-200 dark:border-gray-700
|
||||
rounded-lg shadow-lg
|
||||
py-1 min-w-[140px]
|
||||
"
|
||||
>
|
||||
{#each statusOptions as option (option.value)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleStatusChange(attendee.email, option.value)}
|
||||
class="
|
||||
w-full flex items-center gap-2 px-3 py-1.5
|
||||
text-sm text-left
|
||||
hover:bg-gray-100 dark:hover:bg-gray-700
|
||||
{attendee.status === option.value ? 'bg-gray-50 dark:bg-gray-700/50' : ''}
|
||||
"
|
||||
>
|
||||
<span class="{getStatusColor(option.value)} p-0.5 rounded">
|
||||
{#if option.value === 'accepted'}
|
||||
<Check size={12} />
|
||||
{:else if option.value === 'declined'}
|
||||
<X size={12} />
|
||||
{:else if option.value === 'tentative'}
|
||||
<HelpCircle size={12} />
|
||||
{:else}
|
||||
<Clock size={12} />
|
||||
{/if}
|
||||
</span>
|
||||
{option.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Remove Button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleRemoveAttendee(attendee.email)}
|
||||
class="
|
||||
p-1 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>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Add New Attendees -->
|
||||
<ContactSelector
|
||||
{selectedContacts}
|
||||
onContactsChange={handleContactsChange}
|
||||
onSearch={handleSearch}
|
||||
allowManualEntry={true}
|
||||
placeholder="Teilnehmer hinzufügen..."
|
||||
addLabel="Teilnehmer"
|
||||
searchPlaceholder="Name oder E-Mail..."
|
||||
isAvailable={contactsAvailable ?? false}
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.attendee-selector {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -145,8 +145,8 @@
|
|||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div class="modal-backdrop" onclick={handleBackdropClick}>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_no_noninteractive_element_to_interactive_role -->
|
||||
<div class="modal-backdrop" onclick={handleBackdropClick} role="button" tabindex="-1">
|
||||
<div class="modal-container" role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||
{#if loading}
|
||||
<EventDetailSkeleton />
|
||||
|
|
|
|||
|
|
@ -4,12 +4,14 @@
|
|||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { eventTagsStore } from '$lib/stores/event-tags.svelte';
|
||||
import { TagSelector, type Tag } from '@manacore/shared-ui';
|
||||
import AttendeeSelector from './AttendeeSelector.svelte';
|
||||
import type {
|
||||
CalendarEvent,
|
||||
CreateEventInput,
|
||||
UpdateEventInput,
|
||||
LocationDetails,
|
||||
EventTag,
|
||||
EventAttendee,
|
||||
} from '@calendar/shared';
|
||||
import { format, addMinutes, parseISO } from 'date-fns';
|
||||
|
||||
|
|
@ -49,6 +51,9 @@
|
|||
})) || []
|
||||
);
|
||||
|
||||
// Attendees state
|
||||
let attendees = $state<EventAttendee[]>(event?.metadata?.attendees || []);
|
||||
|
||||
// Convert EventTag to Tag type for shared-ui components
|
||||
function eventTagToTag(tag: EventTag): Tag {
|
||||
return {
|
||||
|
|
@ -131,7 +136,7 @@
|
|||
e.preventDefault();
|
||||
|
||||
if (!title.trim()) return;
|
||||
if (!calendarId) return;
|
||||
// calendarId is now optional - backend will use/create default calendar if not provided
|
||||
|
||||
const startDateTime = new Date(`${startDate}T${isAllDay ? '00:00' : startTime}`);
|
||||
const endDateTime = new Date(`${endDate}T${isAllDay ? '23:59' : endTime}`);
|
||||
|
|
@ -167,6 +172,13 @@
|
|||
delete metadata.locationDetails;
|
||||
}
|
||||
|
||||
// Add attendees
|
||||
if (attendees.length > 0) {
|
||||
metadata.attendees = attendees;
|
||||
} else {
|
||||
delete metadata.attendees;
|
||||
}
|
||||
|
||||
// Only include metadata if it has properties
|
||||
const finalMetadata = Object.keys(metadata).length > 0 ? metadata : undefined;
|
||||
|
||||
|
|
@ -177,7 +189,8 @@
|
|||
isAllDay,
|
||||
startTime: startDateTime.toISOString(),
|
||||
endTime: endDateTime.toISOString(),
|
||||
calendarId,
|
||||
// Only include calendarId if set - backend will use default if not provided
|
||||
...(calendarId ? { calendarId } : {}),
|
||||
metadata: finalMetadata,
|
||||
tagIds: selectedTags.length > 0 ? selectedTags.map((t) => t.id) : undefined,
|
||||
};
|
||||
|
|
@ -202,15 +215,19 @@
|
|||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="calendar" class="text-sm font-medium text-foreground">Kalender</label>
|
||||
<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>
|
||||
{#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>
|
||||
{:else}
|
||||
<p class="text-sm text-muted-foreground italic">Standardkalender wird automatisch erstellt</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
|
|
@ -378,7 +395,7 @@
|
|||
<!-- Tags -->
|
||||
{#if availableTags.length > 0 || eventTagsStore.loading}
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-foreground">Tags</label>
|
||||
<span class="text-sm font-medium text-foreground">Tags</span>
|
||||
<TagSelector
|
||||
tags={availableTags}
|
||||
{selectedTags}
|
||||
|
|
@ -389,6 +406,15 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Teilnehmer -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-medium text-foreground">Teilnehmer</span>
|
||||
<AttendeeSelector
|
||||
{attendees}
|
||||
onAttendeesChange={(newAttendees) => (attendees = newAttendees)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -400,7 +426,7 @@
|
|||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 rounded-lg font-medium text-primary-foreground bg-primary hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
disabled={submitting || !title.trim() || !calendarId}
|
||||
disabled={submitting || !title.trim()}
|
||||
>
|
||||
{mode === 'create' ? 'Erstellen' : 'Speichern'}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -2,18 +2,35 @@
|
|||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import type { LocationDetails } from '@calendar/shared';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
import type { LocationDetails, CalendarEvent } from '@calendar/shared';
|
||||
import { format, addMinutes, parseISO } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import { tick, onMount, onDestroy } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
startTime: Date;
|
||||
onClose: () => void;
|
||||
onCreated?: () => void;
|
||||
// Portal action - moves element to body to escape stacking contexts
|
||||
function portal(node: HTMLElement) {
|
||||
document.body.appendChild(node);
|
||||
return {
|
||||
destroy() {
|
||||
node.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let { startTime, onClose, onCreated }: Props = $props();
|
||||
interface Props {
|
||||
startTime?: Date;
|
||||
event?: CalendarEvent;
|
||||
onClose: () => void;
|
||||
onCreated?: () => void;
|
||||
onUpdated?: () => void;
|
||||
onDeleted?: () => void;
|
||||
}
|
||||
|
||||
let { startTime, event, onClose, onCreated, onUpdated, onDeleted }: Props = $props();
|
||||
|
||||
// Mode: create or edit
|
||||
let isEditMode = $derived(!!event);
|
||||
|
||||
// Input ref for programmatic focus
|
||||
let titleInputRef = $state<HTMLInputElement | null>(null);
|
||||
|
|
@ -25,12 +42,17 @@
|
|||
// Track when draft event was last modified (to ignore clicks after drag/resize)
|
||||
let lastDraftUpdateTime = $state(0);
|
||||
|
||||
// Calculate position relative to draft event element
|
||||
// Calculate position relative to draft event element or existing event
|
||||
function updatePosition() {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const draftElement = document.querySelector('[data-event-id="__draft__"]');
|
||||
if (!draftElement) {
|
||||
// In edit mode, position relative to the existing event element
|
||||
const eventSelector = isEditMode
|
||||
? `[data-event-id="${event!.id}"]`
|
||||
: '[data-event-id="__draft__"]';
|
||||
const eventElement = document.querySelector(eventSelector);
|
||||
|
||||
if (!eventElement) {
|
||||
// Fallback: center in viewport
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
|
@ -42,7 +64,7 @@
|
|||
return;
|
||||
}
|
||||
|
||||
const rect = draftElement.getBoundingClientRect();
|
||||
const rect = eventElement.getBoundingClientRect();
|
||||
const overlayWidth = 380;
|
||||
const maxOverlayHeight = 450;
|
||||
const margin = 16;
|
||||
|
|
@ -79,7 +101,7 @@
|
|||
positionInitialized = true;
|
||||
}
|
||||
|
||||
// Handle clicks outside overlay (but allow clicks on draft event)
|
||||
// Handle clicks outside overlay (but allow clicks on event)
|
||||
function handleDocumentClick(e: MouseEvent) {
|
||||
// Ignore clicks within 250ms of draft event update (drag/resize just ended)
|
||||
if (Date.now() - lastDraftUpdateTime < 250) {
|
||||
|
|
@ -88,10 +110,13 @@
|
|||
|
||||
const target = e.target as HTMLElement;
|
||||
const overlay = document.querySelector('.quick-event-overlay');
|
||||
const draftEvent = document.querySelector('[data-event-id="__draft__"]');
|
||||
const eventSelector = isEditMode
|
||||
? `[data-event-id="${event!.id}"]`
|
||||
: '[data-event-id="__draft__"]';
|
||||
const eventElement = document.querySelector(eventSelector);
|
||||
|
||||
// Don't close if clicking on overlay or draft event
|
||||
if (overlay?.contains(target) || draftEvent?.contains(target)) {
|
||||
// Don't close if clicking on overlay or event element
|
||||
if (overlay?.contains(target) || eventElement?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -115,18 +140,19 @@
|
|||
document.removeEventListener('click', handleDocumentClick);
|
||||
});
|
||||
|
||||
// Update position when draft event changes (user dragged it)
|
||||
// Also track the update time to prevent closing overlay after drag/resize
|
||||
// Update position when draft event changes (user dragged it) - only in create mode
|
||||
$effect(() => {
|
||||
const draft = eventsStore.draftEvent;
|
||||
if (draft && positionInitialized) {
|
||||
// Track when draft was updated (for click ignore logic)
|
||||
lastDraftUpdateTime = Date.now();
|
||||
if (!isEditMode) {
|
||||
const draft = eventsStore.draftEvent;
|
||||
if (draft && positionInitialized) {
|
||||
// Track when draft was updated (for click ignore logic)
|
||||
lastDraftUpdateTime = Date.now();
|
||||
|
||||
// Use requestAnimationFrame to wait for DOM update
|
||||
requestAnimationFrame(() => {
|
||||
updatePosition();
|
||||
});
|
||||
// Use requestAnimationFrame to wait for DOM update
|
||||
requestAnimationFrame(() => {
|
||||
updatePosition();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -135,11 +161,15 @@
|
|||
if (titleInputRef) {
|
||||
tick().then(() => {
|
||||
titleInputRef?.focus();
|
||||
// Select all text in edit mode for easy replacement
|
||||
if (isEditMode) {
|
||||
titleInputRef?.select();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Form state - initialize from draft event
|
||||
// Form state - initialize from event (edit mode) or draft event (create mode)
|
||||
let title = $state('');
|
||||
let calendarId = $state('');
|
||||
let description = $state('');
|
||||
|
|
@ -155,82 +185,132 @@
|
|||
let locationCountry = $state('');
|
||||
let submitting = $state(false);
|
||||
|
||||
// Date/time fields - derive from draft event
|
||||
// Editable date/time strings (for form inputs)
|
||||
let startDateStr = $state('');
|
||||
let startTimeStr = $state('');
|
||||
let endDateStr = $state('');
|
||||
let endTimeStr = $state('');
|
||||
|
||||
// Initialize form state from event in edit mode
|
||||
$effect(() => {
|
||||
if (isEditMode && event) {
|
||||
title = event.title || '';
|
||||
calendarId = event.calendarId || '';
|
||||
description = event.description || '';
|
||||
location = event.location || '';
|
||||
isAllDay = event.isAllDay || false;
|
||||
allDayDisplayMode =
|
||||
(event.metadata?.allDayDisplayMode as 'default' | 'header' | 'block') || 'default';
|
||||
|
||||
// Initialize location details
|
||||
const loc = event.metadata?.locationDetails;
|
||||
if (loc) {
|
||||
showLocationDetails = true;
|
||||
locationStreet = loc.street || '';
|
||||
locationPostalCode = loc.postalCode || '';
|
||||
locationCity = loc.city || '';
|
||||
locationCountry = loc.country || '';
|
||||
}
|
||||
|
||||
// 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;
|
||||
startDateStr = format(eventStart, 'yyyy-MM-dd');
|
||||
startTimeStr = format(eventStart, 'HH:mm');
|
||||
endDateStr = format(eventEnd, 'yyyy-MM-dd');
|
||||
endTimeStr = format(eventEnd, 'HH:mm');
|
||||
}
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
const draft = eventsStore.draftEvent;
|
||||
if (draft) {
|
||||
return typeof draft.startTime === 'string' ? parseISO(draft.startTime) : draft.startTime;
|
||||
}
|
||||
return startTime;
|
||||
return startTime || new Date();
|
||||
});
|
||||
|
||||
let draftEnd = $derived(() => {
|
||||
if (isEditMode && event) {
|
||||
return typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
|
||||
}
|
||||
const draft = eventsStore.draftEvent;
|
||||
if (draft) {
|
||||
return typeof draft.endTime === 'string' ? parseISO(draft.endTime) : draft.endTime;
|
||||
}
|
||||
return addMinutes(startTime, settingsStore.defaultEventDuration);
|
||||
return addMinutes(startTime || new Date(), settingsStore.defaultEventDuration);
|
||||
});
|
||||
|
||||
// Display date/time - derived from draft event
|
||||
// Display date/time - derived from draft event or event
|
||||
let displayStartDate = $derived(format(draftStart(), 'yyyy-MM-dd'));
|
||||
let displayStartTime = $derived(format(draftStart(), 'HH:mm'));
|
||||
let displayEndDate = $derived(format(draftEnd(), 'yyyy-MM-dd'));
|
||||
let displayEndTime = $derived(format(draftEnd(), 'HH:mm'));
|
||||
|
||||
// Editable date/time strings (for form inputs)
|
||||
let startDateStr = $state(format(startTime, 'yyyy-MM-dd'));
|
||||
let startTimeStr = $state(format(startTime, 'HH:mm'));
|
||||
let endDateStr = $state('');
|
||||
let endTimeStr = $state('');
|
||||
|
||||
// Sync form fields from draft event when it changes (e.g., user drags it)
|
||||
// Sync form fields from draft event when it changes (e.g., user drags it) - only in create mode
|
||||
$effect(() => {
|
||||
startDateStr = displayStartDate;
|
||||
startTimeStr = displayStartTime;
|
||||
endDateStr = displayEndDate;
|
||||
endTimeStr = displayEndTime;
|
||||
if (!isEditMode) {
|
||||
startDateStr = displayStartDate;
|
||||
startTimeStr = displayStartTime;
|
||||
endDateStr = displayEndDate;
|
||||
endTimeStr = displayEndTime;
|
||||
}
|
||||
});
|
||||
|
||||
// Set default calendar
|
||||
// Set default calendar - only in create mode
|
||||
$effect(() => {
|
||||
if (!calendarId && calendarsStore.defaultCalendar?.id) {
|
||||
if (!isEditMode && !calendarId && calendarsStore.defaultCalendar?.id) {
|
||||
calendarId = calendarsStore.defaultCalendar.id;
|
||||
// Update draft event with calendar
|
||||
eventsStore.updateDraftEvent({ calendarId });
|
||||
}
|
||||
});
|
||||
|
||||
// Update draft event when title changes
|
||||
// Update draft event when title changes - only in create mode
|
||||
function handleTitleChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
title = target.value;
|
||||
eventsStore.updateDraftEvent({ title: target.value });
|
||||
if (!isEditMode) {
|
||||
eventsStore.updateDraftEvent({ title: target.value });
|
||||
}
|
||||
}
|
||||
|
||||
// Update draft event when time fields change
|
||||
function handleStartDateChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
startDateStr = target.value;
|
||||
updateDraftTimes();
|
||||
if (!isEditMode) {
|
||||
updateDraftTimes();
|
||||
}
|
||||
}
|
||||
|
||||
function handleStartTimeChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
startTimeStr = target.value;
|
||||
updateDraftTimes();
|
||||
if (!isEditMode) {
|
||||
updateDraftTimes();
|
||||
}
|
||||
}
|
||||
|
||||
function handleEndDateChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
endDateStr = target.value;
|
||||
updateDraftTimes();
|
||||
if (!isEditMode) {
|
||||
updateDraftTimes();
|
||||
}
|
||||
}
|
||||
|
||||
function handleEndTimeChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
endTimeStr = target.value;
|
||||
updateDraftTimes();
|
||||
if (!isEditMode) {
|
||||
updateDraftTimes();
|
||||
}
|
||||
}
|
||||
|
||||
function updateDraftTimes() {
|
||||
|
|
@ -252,13 +332,17 @@
|
|||
function handleCalendarChange(e: Event) {
|
||||
const target = e.target as HTMLSelectElement;
|
||||
calendarId = target.value;
|
||||
eventsStore.updateDraftEvent({ calendarId: target.value });
|
||||
if (!isEditMode) {
|
||||
eventsStore.updateDraftEvent({ calendarId: target.value });
|
||||
}
|
||||
}
|
||||
|
||||
// Update draft when all-day changes
|
||||
function handleAllDayToggle() {
|
||||
isAllDay = !isAllDay;
|
||||
updateDraftTimes();
|
||||
if (!isEditMode) {
|
||||
updateDraftTimes();
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay style
|
||||
|
|
@ -292,18 +376,32 @@
|
|||
}
|
||||
: undefined;
|
||||
|
||||
// Build metadata
|
||||
let metadata: Record<string, unknown> | undefined = undefined;
|
||||
// Build metadata - preserve existing metadata in edit mode
|
||||
let metadata: Record<string, unknown> | undefined = isEditMode
|
||||
? { ...(event?.metadata || {}) }
|
||||
: undefined;
|
||||
|
||||
if (isAllDay && allDayDisplayMode !== 'default') {
|
||||
metadata = { allDayDisplayMode: allDayDisplayMode as 'header' | 'block' };
|
||||
metadata = {
|
||||
...(metadata || {}),
|
||||
allDayDisplayMode: allDayDisplayMode as 'header' | 'block',
|
||||
};
|
||||
} else if (metadata) {
|
||||
delete metadata.allDayDisplayMode;
|
||||
}
|
||||
|
||||
if (locationDetails) {
|
||||
metadata = { ...(metadata || {}), locationDetails };
|
||||
} else if (metadata) {
|
||||
delete metadata.locationDetails;
|
||||
}
|
||||
|
||||
await eventsStore.createEvent({
|
||||
// Clean up empty metadata
|
||||
if (metadata && Object.keys(metadata).length === 0) {
|
||||
metadata = undefined;
|
||||
}
|
||||
|
||||
const eventData = {
|
||||
title: title.trim(),
|
||||
calendarId,
|
||||
startTime: startDateTime.toISOString(),
|
||||
|
|
@ -312,12 +410,56 @@
|
|||
description: description.trim() || undefined,
|
||||
location: location.trim() || undefined,
|
||||
metadata,
|
||||
});
|
||||
};
|
||||
|
||||
if (isEditMode && event) {
|
||||
// Update existing event
|
||||
const result = await eventsStore.updateEvent(event.id, eventData);
|
||||
if (result.error) {
|
||||
toast.error(`Fehler beim Speichern: ${result.error.message}`);
|
||||
return;
|
||||
}
|
||||
toast.success('Termin aktualisiert');
|
||||
onUpdated?.();
|
||||
} else {
|
||||
// Create new event
|
||||
await eventsStore.createEvent(eventData);
|
||||
// Refresh calendars if none existed (in case default was created)
|
||||
if (calendarsStore.calendars.length === 0) {
|
||||
await calendarsStore.fetchCalendars();
|
||||
}
|
||||
onCreated?.();
|
||||
}
|
||||
|
||||
onCreated?.();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to create event:', error);
|
||||
console.error('Failed to save event:', error);
|
||||
toast.error('Fehler beim Speichern');
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
if (result.error) {
|
||||
toast.error(`Fehler beim Löschen: ${result.error.message}`);
|
||||
return;
|
||||
}
|
||||
toast.success('Termin gelöscht');
|
||||
onDeleted?.();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete event:', error);
|
||||
toast.error('Fehler beim Löschen');
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
|
|
@ -333,27 +475,49 @@
|
|||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- Overlay (no blocking backdrop - allows interaction with calendar) -->
|
||||
<!-- Portal to body to escape stacking contexts -->
|
||||
<div
|
||||
use:portal
|
||||
class="quick-event-overlay"
|
||||
style={overlayStyle}
|
||||
style="{overlayStyle} z-index: 99999;"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Termin erstellen"
|
||||
aria-label={isEditMode ? 'Termin bearbeiten' : 'Termin erstellen'}
|
||||
>
|
||||
<form onsubmit={handleSubmit}>
|
||||
<!-- Header -->
|
||||
<div class="overlay-header">
|
||||
<span class="header-title">Neuer Termin</span>
|
||||
<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">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
{/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">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable content -->
|
||||
|
|
@ -399,17 +563,22 @@
|
|||
></div>
|
||||
</div>
|
||||
<div class="row-content">
|
||||
<label class="field-label">Kalender</label>
|
||||
<select class="field-select" value={calendarId} onchange={handleCalendarChange}>
|
||||
{#each calendarsStore.calendars as cal}
|
||||
<option value={cal.id}>{cal.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<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>
|
||||
{:else}
|
||||
<span class="field-placeholder">Standardkalender wird erstellt</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- All day toggle -->
|
||||
<div class="form-row clickable" onclick={handleAllDayToggle}>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_no_noninteractive_element_to_interactive_role -->
|
||||
<div class="form-row clickable" onclick={handleAllDayToggle} role="button" tabindex="0">
|
||||
<div class="row-icon">
|
||||
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
|
|
@ -437,7 +606,7 @@
|
|||
<div class="form-row sub-row">
|
||||
<div class="row-icon"></div>
|
||||
<div class="row-content">
|
||||
<label class="field-label">Anzeigeart</label>
|
||||
<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>
|
||||
|
|
@ -461,7 +630,7 @@
|
|||
</div>
|
||||
<div class="row-content datetime-row">
|
||||
<div class="datetime-field">
|
||||
<label class="field-label">Beginn</label>
|
||||
<span class="field-label">Beginn</span>
|
||||
<input
|
||||
type="date"
|
||||
class="field-input"
|
||||
|
|
@ -471,7 +640,7 @@
|
|||
</div>
|
||||
{#if !isAllDay}
|
||||
<div class="datetime-field time-field">
|
||||
<label class="field-label">Uhrzeit</label>
|
||||
<span class="field-label">Uhrzeit</span>
|
||||
<input
|
||||
type="time"
|
||||
class="field-input"
|
||||
|
|
@ -497,7 +666,7 @@
|
|||
</div>
|
||||
<div class="row-content datetime-row">
|
||||
<div class="datetime-field">
|
||||
<label class="field-label">Ende</label>
|
||||
<span class="field-label">Ende</span>
|
||||
<input
|
||||
type="date"
|
||||
class="field-input"
|
||||
|
|
@ -507,7 +676,7 @@
|
|||
</div>
|
||||
{#if !isAllDay}
|
||||
<div class="datetime-field time-field">
|
||||
<label class="field-label">Uhrzeit</label>
|
||||
<span class="field-label">Uhrzeit</span>
|
||||
<input
|
||||
type="time"
|
||||
class="field-input"
|
||||
|
|
@ -575,7 +744,7 @@
|
|||
<div class="row-icon"></div>
|
||||
<div class="row-content address-details-form">
|
||||
<div class="address-field">
|
||||
<label class="field-label">Straße</label>
|
||||
<span class="field-label">Straße</span>
|
||||
<input
|
||||
type="text"
|
||||
class="field-input"
|
||||
|
|
@ -585,7 +754,7 @@
|
|||
</div>
|
||||
<div class="address-row">
|
||||
<div class="address-field postal">
|
||||
<label class="field-label">PLZ</label>
|
||||
<span class="field-label">PLZ</span>
|
||||
<input
|
||||
type="text"
|
||||
class="field-input"
|
||||
|
|
@ -594,7 +763,7 @@
|
|||
/>
|
||||
</div>
|
||||
<div class="address-field city">
|
||||
<label class="field-label">Stadt</label>
|
||||
<span class="field-label">Stadt</span>
|
||||
<input
|
||||
type="text"
|
||||
class="field-input"
|
||||
|
|
@ -604,7 +773,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="address-field">
|
||||
<label class="field-label">Land</label>
|
||||
<span class="field-label">Land</span>
|
||||
<input
|
||||
type="text"
|
||||
class="field-input"
|
||||
|
|
@ -660,18 +829,18 @@
|
|||
box-shadow:
|
||||
0 20px 60px rgba(0, 0, 0, 0.2),
|
||||
0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1001;
|
||||
z-index: 99999 !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slideIn 150ms ease-out;
|
||||
overflow: hidden; /* Prevent any content from overflowing */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quick-event-overlay form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0; /* Allow form to shrink below content size */
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
|
@ -701,7 +870,14 @@
|
|||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.close-btn,
|
||||
.delete-btn {
|
||||
padding: 0.375rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
|
|
@ -716,11 +892,21 @@
|
|||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: hsl(var(--color-error) / 0.1);
|
||||
color: hsl(var(--color-error));
|
||||
}
|
||||
|
||||
.delete-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.overlay-content {
|
||||
flex: 1;
|
||||
min-height: 0; /* Important for flex scroll */
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain; /* Prevent scroll chaining to background */
|
||||
overscroll-behavior: contain;
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
|
|
@ -841,6 +1027,14 @@
|
|||
border-color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.field-placeholder {
|
||||
display: block;
|
||||
padding: 0.5rem 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.field-input.full {
|
||||
padding: 0.625rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@
|
|||
</button>
|
||||
{:else}
|
||||
<form class="quick-add-form" onsubmit={handleSubmit}>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
bind:this={inputRef}
|
||||
bind:value={title}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,18 @@
|
|||
import { toast } from '$lib/stores/toast';
|
||||
import TodoCheckbox from './TodoCheckbox.svelte';
|
||||
import PriorityBadge from './PriorityBadge.svelte';
|
||||
import { X, Calendar, Clock, Folder, Tag, Trash2, CheckSquare, AlertCircle } from 'lucide-svelte';
|
||||
import {
|
||||
X,
|
||||
Calendar,
|
||||
Clock,
|
||||
Folder,
|
||||
Tag,
|
||||
Trash2,
|
||||
CheckSquare,
|
||||
AlertCircle,
|
||||
CalendarClock,
|
||||
Timer,
|
||||
} from 'lucide-svelte';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
|
||||
|
|
@ -23,12 +34,34 @@
|
|||
let isDeleting = $state(false);
|
||||
let isToggling = $state(false);
|
||||
|
||||
// Form state
|
||||
let title = $state(task.title);
|
||||
let description = $state(task.description || '');
|
||||
let dueDate = $state(task.dueDate ? formatDateForInput(task.dueDate) : '');
|
||||
let dueTime = $state(task.dueTime || '');
|
||||
let priority = $state<TaskPriority>(task.priority);
|
||||
// Form state - initialized with derived values
|
||||
let title = $state(initialTask.title);
|
||||
let description = $state(initialTask.description || '');
|
||||
let dueDate = $state(initialTask.dueDate ? formatDateForInput(initialTask.dueDate) : '');
|
||||
let dueTime = $state(initialTask.dueTime || '');
|
||||
let priority = $state<TaskPriority>(initialTask.priority);
|
||||
|
||||
// Time-Blocking fields
|
||||
let scheduledDate = $state(
|
||||
initialTask.scheduledDate ? formatDateForInput(initialTask.scheduledDate) : ''
|
||||
);
|
||||
let scheduledStartTime = $state(initialTask.scheduledStartTime || '');
|
||||
let scheduledEndTime = $state(initialTask.scheduledEndTime || '');
|
||||
let estimatedDuration = $state(initialTask.estimatedDuration?.toString() || '');
|
||||
|
||||
// Sync form state when task changes
|
||||
$effect(() => {
|
||||
title = task.title;
|
||||
description = task.description || '';
|
||||
dueDate = task.dueDate ? formatDateForInput(task.dueDate) : '';
|
||||
dueTime = task.dueTime || '';
|
||||
priority = task.priority;
|
||||
// Time-Blocking
|
||||
scheduledDate = task.scheduledDate ? formatDateForInput(task.scheduledDate) : '';
|
||||
scheduledStartTime = task.scheduledStartTime || '';
|
||||
scheduledEndTime = task.scheduledEndTime || '';
|
||||
estimatedDuration = task.estimatedDuration?.toString() || '';
|
||||
});
|
||||
|
||||
function formatDateForInput(date: string | Date | null | undefined): string {
|
||||
if (!date) return '';
|
||||
|
|
@ -67,6 +100,11 @@
|
|||
dueDate: dueDate || null,
|
||||
dueTime: dueTime || null,
|
||||
priority,
|
||||
// Time-Blocking
|
||||
scheduledDate: scheduledDate || null,
|
||||
scheduledStartTime: scheduledStartTime || null,
|
||||
scheduledEndTime: scheduledEndTime || null,
|
||||
estimatedDuration: estimatedDuration ? parseInt(estimatedDuration, 10) : null,
|
||||
};
|
||||
|
||||
const result = await todosStore.updateTodo(task.id, updateData);
|
||||
|
|
@ -106,6 +144,11 @@
|
|||
dueDate = task.dueDate ? formatDateForInput(task.dueDate) : '';
|
||||
dueTime = task.dueTime || '';
|
||||
priority = task.priority;
|
||||
// Time-Blocking
|
||||
scheduledDate = task.scheduledDate ? formatDateForInput(task.scheduledDate) : '';
|
||||
scheduledStartTime = task.scheduledStartTime || '';
|
||||
scheduledEndTime = task.scheduledEndTime || '';
|
||||
estimatedDuration = task.estimatedDuration?.toString() || '';
|
||||
isEditing = true;
|
||||
}
|
||||
|
||||
|
|
@ -132,8 +175,8 @@
|
|||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div class="modal-backdrop" onclick={handleBackdropClick}>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="modal-backdrop" onclick={handleBackdropClick} role="presentation">
|
||||
<div class="modal" role="dialog" aria-labelledby="modal-title" aria-modal="true">
|
||||
<!-- Header -->
|
||||
<div class="modal-header">
|
||||
|
|
@ -168,14 +211,7 @@
|
|||
>
|
||||
<div class="form-group">
|
||||
<label for="title">Titel</label>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
bind:value={title}
|
||||
placeholder="Aufgabentitel"
|
||||
required
|
||||
autofocus
|
||||
/>
|
||||
<input id="title" type="text" bind:value={title} placeholder="Aufgabentitel" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
|
|
@ -200,8 +236,44 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Time-Blocking Section -->
|
||||
<div class="form-section">
|
||||
<span class="section-label">
|
||||
<CalendarClock size={16} />
|
||||
Zeitplanung (Time-Blocking)
|
||||
</span>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="scheduledDate">Geplantes Datum</label>
|
||||
<input id="scheduledDate" type="date" bind:value={scheduledDate} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="estimatedDuration">Dauer (Min.)</label>
|
||||
<input
|
||||
id="estimatedDuration"
|
||||
type="number"
|
||||
min="5"
|
||||
max="480"
|
||||
step="5"
|
||||
bind:value={estimatedDuration}
|
||||
placeholder="30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="scheduledStartTime">Startzeit</label>
|
||||
<input id="scheduledStartTime" type="time" bind:value={scheduledStartTime} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="scheduledEndTime">Endzeit</label>
|
||||
<input id="scheduledEndTime" type="time" bind:value={scheduledEndTime} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Priorität</label>
|
||||
<span class="label-text">Priorität</span>
|
||||
<div class="priority-options">
|
||||
{#each Object.entries(PRIORITY_LABELS) as [key, label]}
|
||||
<button
|
||||
|
|
@ -238,6 +310,29 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Time-Blocking Display -->
|
||||
{#if task.scheduledDate}
|
||||
<div class="detail-item scheduled">
|
||||
<CalendarClock size={16} />
|
||||
<span>
|
||||
Geplant: {formatDisplayDate(task.scheduledDate)}
|
||||
{#if task.scheduledStartTime}
|
||||
um {task.scheduledStartTime}
|
||||
{#if task.scheduledEndTime}
|
||||
- {task.scheduledEndTime}
|
||||
{/if}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if task.estimatedDuration}
|
||||
<div class="detail-item">
|
||||
<Timer size={16} />
|
||||
<span>Geschätzte Dauer: {task.estimatedDuration} Min.</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="detail-item">
|
||||
<AlertCircle size={16} />
|
||||
<PriorityBadge {priority} variant="pill" showLabel />
|
||||
|
|
@ -423,6 +518,17 @@
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-item.scheduled {
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
border-left: 3px solid hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.detail-item.scheduled :global(svg) {
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.labels-row {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
|
@ -508,9 +614,35 @@
|
|||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: hsl(var(--color-muted) / 0.3);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid hsl(var(--color-border) / 0.5);
|
||||
}
|
||||
|
||||
.section-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.section-label :global(svg) {
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
input[type='text'],
|
||||
input[type='date'],
|
||||
input[type='time'],
|
||||
input[type='number'],
|
||||
textarea {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
|
|
@ -532,6 +664,12 @@
|
|||
min-height: 80px;
|
||||
}
|
||||
|
||||
.label-text {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.priority-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
showProject?: boolean;
|
||||
showDueDate?: boolean;
|
||||
showPriority?: boolean;
|
||||
draggable?: boolean;
|
||||
onclick?: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -22,6 +23,7 @@
|
|||
showProject = true,
|
||||
showDueDate = true,
|
||||
showPriority = true,
|
||||
draggable = false,
|
||||
onclick,
|
||||
}: Props = $props();
|
||||
|
||||
|
|
@ -75,8 +77,25 @@
|
|||
onclick();
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragStart(e: DragEvent) {
|
||||
if (!draggable || !e.dataTransfer) return;
|
||||
// Store task data for drop target
|
||||
e.dataTransfer.setData(
|
||||
'application/json',
|
||||
JSON.stringify({
|
||||
type: 'sidebar-task',
|
||||
taskId: task.id,
|
||||
title: task.title,
|
||||
priority: task.priority,
|
||||
estimatedDuration: task.estimatedDuration || 30,
|
||||
})
|
||||
);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<div
|
||||
class="todo-item"
|
||||
class:completed={task.isCompleted}
|
||||
|
|
@ -84,9 +103,12 @@
|
|||
class:compact={variant === 'compact'}
|
||||
class:minimal={variant === 'minimal'}
|
||||
class:clickable={!!onclick}
|
||||
class:draggable-task={draggable}
|
||||
style="--priority-color: {priorityColor};"
|
||||
onclick={handleClick}
|
||||
onkeydown={handleKeydown}
|
||||
ondragstart={handleDragStart}
|
||||
draggable={draggable ? 'true' : 'false'}
|
||||
role={onclick ? 'button' : 'listitem'}
|
||||
tabindex={onclick ? 0 : -1}
|
||||
>
|
||||
|
|
@ -168,6 +190,15 @@
|
|||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.todo-item.draggable-task {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.todo-item.draggable-task:active {
|
||||
cursor: grabbing;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.todo-item.completed {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
|
|
|||
8
apps/calendar/apps/web/src/lib/composables/index.ts
Normal file
8
apps/calendar/apps/web/src/lib/composables/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* Calendar Composables
|
||||
* Reusable logic extracted from components
|
||||
*/
|
||||
|
||||
export { useDragDrop, type DragDropConfig, type DragState } from './useDragDrop.svelte';
|
||||
export { useResize, type ResizeConfig, type ResizeState } from './useResize.svelte';
|
||||
export { useTaskDragDrop } from './useTaskDragDrop.svelte';
|
||||
243
apps/calendar/apps/web/src/lib/composables/useDragDrop.svelte.ts
Normal file
243
apps/calendar/apps/web/src/lib/composables/useDragDrop.svelte.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
/**
|
||||
* Drag & Drop Composable for Calendar Events
|
||||
* Extracts drag logic from WeekView/DayView for reusability
|
||||
*/
|
||||
|
||||
import type { CalendarEvent } from '@calendar/shared';
|
||||
import { parseISO, differenceInMinutes, addMinutes, setHours, setMinutes } from 'date-fns';
|
||||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
|
||||
export interface DragDropConfig {
|
||||
/** Reference to the container element for position calculations */
|
||||
containerEl: HTMLElement | null;
|
||||
/** Array of visible days */
|
||||
days: Date[];
|
||||
/** First visible hour (for filtered hours mode) */
|
||||
firstVisibleHour: number;
|
||||
/** Last visible hour (for filtered hours mode) */
|
||||
lastVisibleHour: number;
|
||||
/** Height of one hour in pixels */
|
||||
hourHeight: number;
|
||||
/** Minutes per snap interval */
|
||||
snapMinutes?: number;
|
||||
}
|
||||
|
||||
export interface DragState {
|
||||
isDragging: boolean;
|
||||
draggedEvent: CalendarEvent | null;
|
||||
dragTargetDay: Date | null;
|
||||
dragPreviewTop: number;
|
||||
dragPreviewHeight: number;
|
||||
hasMoved: boolean;
|
||||
}
|
||||
|
||||
export function useDragDrop(getConfig: () => DragDropConfig) {
|
||||
// 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);
|
||||
let hasMoved = $state(false);
|
||||
|
||||
// Derived values
|
||||
const totalVisibleHours = $derived(() => {
|
||||
const config = getConfig();
|
||||
return config.lastVisibleHour - config.firstVisibleHour;
|
||||
});
|
||||
|
||||
/**
|
||||
* Convert minutes to percentage position (accounting for hidden hours)
|
||||
*/
|
||||
function minutesToPercent(minutes: number): number {
|
||||
const config = getConfig();
|
||||
const adjustedMinutes = minutes - config.firstVisibleHour * 60;
|
||||
return (adjustedMinutes / (totalVisibleHours() * 60)) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get day from X coordinate
|
||||
*/
|
||||
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 / (totalVisibleHours() * config.hourHeight)) * totalVisibleHours() * 60;
|
||||
const totalMinutes = visibleMinutes + config.firstVisibleHour * 60;
|
||||
|
||||
// Snap to interval
|
||||
const snapMinutes = config.snapMinutes ?? 15;
|
||||
return Math.round(totalMinutes / snapMinutes) * snapMinutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start dragging an event
|
||||
*/
|
||||
function startDrag(event: CalendarEvent, e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const config = getConfig();
|
||||
isDragging = true;
|
||||
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 duration = differenceInMinutes(end, start);
|
||||
|
||||
// Calculate initial preview position
|
||||
const startMinutes = start.getHours() * 60 + start.getMinutes();
|
||||
dragPreviewTop = minutesToPercent(startMinutes);
|
||||
dragPreviewHeight = (duration / (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 = minutesToPercent(clampedMinutes);
|
||||
if (newDay) {
|
||||
dragTargetDay = newDay;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDragEnd(e: PointerEvent) {
|
||||
document.removeEventListener('pointermove', handleDragMove);
|
||||
document.removeEventListener('pointerup', handleDragEnd);
|
||||
|
||||
if (!isDragging || !draggedEvent || !dragTargetDay || !hasMoved) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
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 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(),
|
||||
});
|
||||
}
|
||||
|
||||
cleanup();
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
isDragging = false;
|
||||
draggedEvent = null;
|
||||
dragTargetDay = null;
|
||||
hasMoved = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel drag operation (e.g., on Escape key)
|
||||
*/
|
||||
function cancelDrag() {
|
||||
if (isDragging) {
|
||||
document.removeEventListener('pointermove', handleDragMove);
|
||||
document.removeEventListener('pointerup', handleDragEnd);
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State (reactive getters)
|
||||
get isDragging() {
|
||||
return isDragging;
|
||||
},
|
||||
get draggedEvent() {
|
||||
return draggedEvent;
|
||||
},
|
||||
get dragTargetDay() {
|
||||
return dragTargetDay;
|
||||
},
|
||||
get dragPreviewTop() {
|
||||
return dragPreviewTop;
|
||||
},
|
||||
get dragPreviewHeight() {
|
||||
return dragPreviewHeight;
|
||||
},
|
||||
get hasMoved() {
|
||||
return hasMoved;
|
||||
},
|
||||
|
||||
// Methods
|
||||
startDrag,
|
||||
cancelDrag,
|
||||
minutesToPercent,
|
||||
};
|
||||
}
|
||||
235
apps/calendar/apps/web/src/lib/composables/useResize.svelte.ts
Normal file
235
apps/calendar/apps/web/src/lib/composables/useResize.svelte.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
/**
|
||||
* Resize Composable for Calendar Events
|
||||
* Extracts resize logic from WeekView/DayView for reusability
|
||||
*/
|
||||
|
||||
import type { CalendarEvent } from '@calendar/shared';
|
||||
import { parseISO, differenceInMinutes, setHours, setMinutes } from 'date-fns';
|
||||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
|
||||
export interface ResizeConfig {
|
||||
/** Reference to the container element for position calculations */
|
||||
containerEl: HTMLElement | null;
|
||||
/** First visible hour (for filtered hours mode) */
|
||||
firstVisibleHour: number;
|
||||
/** Last visible hour (for filtered hours mode) */
|
||||
lastVisibleHour: number;
|
||||
/** Height of one hour in pixels */
|
||||
hourHeight: number;
|
||||
/** Minutes per snap interval */
|
||||
snapMinutes?: number;
|
||||
}
|
||||
|
||||
export interface ResizeState {
|
||||
isResizing: boolean;
|
||||
resizeEvent: CalendarEvent | null;
|
||||
resizeEdge: 'top' | 'bottom';
|
||||
resizePreviewTop: number;
|
||||
resizePreviewHeight: number;
|
||||
hasMoved: boolean;
|
||||
}
|
||||
|
||||
export function useResize(getConfig: () => ResizeConfig) {
|
||||
// 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 hasMoved = $state(false);
|
||||
|
||||
// Derived values
|
||||
const totalVisibleHours = $derived(() => {
|
||||
const config = getConfig();
|
||||
return config.lastVisibleHour - config.firstVisibleHour;
|
||||
});
|
||||
|
||||
/**
|
||||
* Convert minutes to percentage position
|
||||
*/
|
||||
function minutesToPercent(minutes: number): number {
|
||||
const config = getConfig();
|
||||
const adjustedMinutes = minutes - config.firstVisibleHour * 60;
|
||||
return (adjustedMinutes / (totalVisibleHours() * 60)) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
const visibleMinutes =
|
||||
(relativeY / (totalVisibleHours() * config.hourHeight)) * totalVisibleHours() * 60;
|
||||
const totalMinutes = visibleMinutes + config.firstVisibleHour * 60;
|
||||
|
||||
const snapMinutes = config.snapMinutes ?? 15;
|
||||
return Math.round(totalMinutes / snapMinutes) * snapMinutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start resizing an event
|
||||
*/
|
||||
function startResize(event: CalendarEvent, edge: 'top' | 'bottom', e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
isResizing = true;
|
||||
resizeEvent = event;
|
||||
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;
|
||||
|
||||
resizeOriginalStart = start;
|
||||
resizeOriginalEnd = end;
|
||||
|
||||
// Set initial preview
|
||||
const startMinutes = start.getHours() * 60 + start.getMinutes();
|
||||
const duration = differenceInMinutes(end, start);
|
||||
resizePreviewTop = minutesToPercent(startMinutes);
|
||||
resizePreviewHeight = (duration / (totalVisibleHours() * 60)) * 100;
|
||||
|
||||
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);
|
||||
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, currentMinutes)
|
||||
);
|
||||
const newDuration = newEndMinutes - originalStartMinutes;
|
||||
resizePreviewHeight = (newDuration / (totalVisibleHours() * 60)) * 100;
|
||||
} else {
|
||||
// Resize from top - change start time
|
||||
const newStartMinutes = Math.max(
|
||||
config.firstVisibleHour * 60,
|
||||
Math.min(originalEndMinutes - 15, currentMinutes)
|
||||
);
|
||||
const newDuration = originalEndMinutes - newStartMinutes;
|
||||
resizePreviewTop = minutesToPercent(newStartMinutes);
|
||||
resizePreviewHeight = (newDuration / (totalVisibleHours() * 60)) * 100;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResizeEnd(e: PointerEvent) {
|
||||
document.removeEventListener('pointermove', handleResizeMove);
|
||||
document.removeEventListener('pointerup', handleResizeEnd);
|
||||
|
||||
if (!isResizing || !resizeEvent || !resizeOriginalStart || !resizeOriginalEnd || !hasMoved) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
const currentMinutes = getMinutesFromY(e.clientY);
|
||||
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, currentMinutes)
|
||||
);
|
||||
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, currentMinutes)
|
||||
);
|
||||
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(),
|
||||
});
|
||||
}
|
||||
|
||||
cleanup();
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
isResizing = false;
|
||||
resizeEvent = null;
|
||||
resizeOriginalStart = null;
|
||||
resizeOriginalEnd = null;
|
||||
hasMoved = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel resize operation
|
||||
*/
|
||||
function cancelResize() {
|
||||
if (isResizing) {
|
||||
document.removeEventListener('pointermove', handleResizeMove);
|
||||
document.removeEventListener('pointerup', handleResizeEnd);
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State (reactive getters)
|
||||
get isResizing() {
|
||||
return isResizing;
|
||||
},
|
||||
get resizeEvent() {
|
||||
return resizeEvent;
|
||||
},
|
||||
get resizeEdge() {
|
||||
return resizeEdge;
|
||||
},
|
||||
get resizePreviewTop() {
|
||||
return resizePreviewTop;
|
||||
},
|
||||
get resizePreviewHeight() {
|
||||
return resizePreviewHeight;
|
||||
},
|
||||
get hasMoved() {
|
||||
return hasMoved;
|
||||
},
|
||||
|
||||
// Methods
|
||||
startResize,
|
||||
cancelResize,
|
||||
minutesToPercent,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,306 @@
|
|||
/**
|
||||
* Composable for Task Drag & Drop in Calendar Views
|
||||
* Handles dragging tasks to reschedule and resizing to change duration
|
||||
*/
|
||||
|
||||
import type { Task, UpdateTaskInput } from '$lib/api/todos';
|
||||
import { todosStore } from '$lib/stores/todos.svelte';
|
||||
import { format, parseISO, addMinutes, differenceInMinutes, setHours, setMinutes } from 'date-fns';
|
||||
|
||||
const SNAP_MINUTES = 15;
|
||||
|
||||
interface UseTaskDragDropOptions {
|
||||
/** Minimum snap interval in minutes */
|
||||
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);
|
||||
let draggedTask = $state<Task | null>(null);
|
||||
let dragStartY = $state(0);
|
||||
let dragTargetDay = $state<Date | null>(null);
|
||||
let dragPreviewTop = $state(0);
|
||||
let dragPreviewHeight = $state(0);
|
||||
|
||||
// Resize state
|
||||
let isResizing = $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);
|
||||
|
||||
// Track if we actually moved
|
||||
let hasMoved = $state(false);
|
||||
|
||||
/**
|
||||
* Start dragging a task
|
||||
*/
|
||||
function startDrag(
|
||||
task: Task,
|
||||
e: PointerEvent,
|
||||
gridElement: HTMLElement,
|
||||
firstVisibleHour: number,
|
||||
totalVisibleHours: number
|
||||
) {
|
||||
e.preventDefault();
|
||||
isDragging = true;
|
||||
draggedTask = task;
|
||||
dragStartY = e.clientY;
|
||||
hasMoved = false;
|
||||
|
||||
// Calculate initial position
|
||||
if (task.scheduledStartTime) {
|
||||
const [h, m] = task.scheduledStartTime.split(':').map(Number);
|
||||
const startMinutes = h * 60 + m - firstVisibleHour * 60;
|
||||
dragPreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100;
|
||||
}
|
||||
|
||||
// Calculate height from duration
|
||||
const duration = task.estimatedDuration || 30;
|
||||
dragPreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
|
||||
|
||||
// Capture pointer
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle drag move
|
||||
*/
|
||||
function onDragMove(
|
||||
e: PointerEvent,
|
||||
gridElement: HTMLElement,
|
||||
day: Date,
|
||||
firstVisibleHour: number,
|
||||
totalVisibleHours: number
|
||||
) {
|
||||
if (!isDragging || !draggedTask) return;
|
||||
|
||||
hasMoved = true;
|
||||
dragTargetDay = day;
|
||||
|
||||
const rect = gridElement.getBoundingClientRect();
|
||||
const relativeY = e.clientY - rect.top;
|
||||
const percentY = (relativeY / rect.height) * 100;
|
||||
|
||||
// Snap to intervals
|
||||
const minutesPerPercent = (totalVisibleHours * 60) / 100;
|
||||
const rawMinutes = percentY * minutesPerPercent + firstVisibleHour * 60;
|
||||
const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes;
|
||||
|
||||
dragPreviewTop = ((snappedMinutes - firstVisibleHour * 60) / (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;
|
||||
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 newStartTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
||||
|
||||
// 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 updateData: UpdateTaskInput = {
|
||||
scheduledDate: dragTargetDay
|
||||
? format(dragTargetDay, 'yyyy-MM-dd')
|
||||
: draggedTask.scheduledDate,
|
||||
scheduledStartTime: newStartTime,
|
||||
scheduledEndTime: newEndTime,
|
||||
};
|
||||
|
||||
const result = await todosStore.updateTodo(draggedTask.id, updateData);
|
||||
if (result.data) {
|
||||
options.onTaskUpdate?.(result.data);
|
||||
}
|
||||
|
||||
isDragging = false;
|
||||
draggedTask = null;
|
||||
dragTargetDay = null;
|
||||
hasMoved = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start resizing a task
|
||||
*/
|
||||
function startResize(
|
||||
task: Task,
|
||||
edge: 'top' | 'bottom',
|
||||
e: PointerEvent,
|
||||
firstVisibleHour: number,
|
||||
totalVisibleHours: number
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isResizing = true;
|
||||
resizeTask = task;
|
||||
resizeEdge = edge;
|
||||
resizeStartY = e.clientY;
|
||||
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 duration = task.estimatedDuration || 30;
|
||||
resizePreviewHeight = (duration / (totalVisibleHours * 60)) * 100;
|
||||
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle resize move
|
||||
*/
|
||||
function onResizeMove(
|
||||
e: PointerEvent,
|
||||
gridElement: HTMLElement,
|
||||
firstVisibleHour: number,
|
||||
totalVisibleHours: number
|
||||
) {
|
||||
if (!isResizing || !resizeTask) return;
|
||||
|
||||
hasMoved = true;
|
||||
|
||||
const rect = gridElement.getBoundingClientRect();
|
||||
const relativeY = e.clientY - rect.top;
|
||||
const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100));
|
||||
|
||||
const minutesPerPercent = (totalVisibleHours * 60) / 100;
|
||||
|
||||
if (resizeEdge === 'top') {
|
||||
// Adjust start time, keep end fixed
|
||||
const originalEndPercent = resizePreviewTop + resizePreviewHeight;
|
||||
const rawMinutes = percentY * minutesPerPercent;
|
||||
const snappedMinutes = Math.round(rawMinutes / snapMinutes) * snapMinutes;
|
||||
resizePreviewTop = (snappedMinutes / (totalVisibleHours * 60)) * 100;
|
||||
resizePreviewHeight = Math.max(2, originalEndPercent - resizePreviewTop);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* End resize and update task
|
||||
*/
|
||||
async function endResize(firstVisibleHour: number, totalVisibleHours: number) {
|
||||
if (!isResizing || !resizeTask || !hasMoved) {
|
||||
isResizing = false;
|
||||
resizeTask = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate new times from position
|
||||
const startMinutes =
|
||||
(resizePreviewTop / 100) * (totalVisibleHours * 60) + firstVisibleHour * 60;
|
||||
const endMinutes =
|
||||
((resizePreviewTop + resizePreviewHeight) / 100) * (totalVisibleHours * 60) +
|
||||
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 newDuration = Math.round(endMinutes - startMinutes);
|
||||
|
||||
const updateData: UpdateTaskInput = {
|
||||
scheduledStartTime: newStartTime,
|
||||
scheduledEndTime: newEndTime,
|
||||
estimatedDuration: newDuration,
|
||||
};
|
||||
|
||||
const result = await todosStore.updateTodo(resizeTask.id, updateData);
|
||||
if (result.data) {
|
||||
options.onTaskUpdate?.(result.data);
|
||||
}
|
||||
|
||||
isResizing = false;
|
||||
resizeTask = null;
|
||||
hasMoved = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel any ongoing drag/resize
|
||||
*/
|
||||
function cancel() {
|
||||
isDragging = false;
|
||||
isResizing = false;
|
||||
draggedTask = null;
|
||||
resizeTask = null;
|
||||
dragTargetDay = null;
|
||||
hasMoved = false;
|
||||
}
|
||||
|
||||
return {
|
||||
// State getters
|
||||
get isDragging() {
|
||||
return isDragging;
|
||||
},
|
||||
get draggedTask() {
|
||||
return draggedTask;
|
||||
},
|
||||
get dragTargetDay() {
|
||||
return dragTargetDay;
|
||||
},
|
||||
get dragPreviewTop() {
|
||||
return dragPreviewTop;
|
||||
},
|
||||
get dragPreviewHeight() {
|
||||
return dragPreviewHeight;
|
||||
},
|
||||
get isResizing() {
|
||||
return isResizing;
|
||||
},
|
||||
get resizeTask() {
|
||||
return resizeTask;
|
||||
},
|
||||
get resizePreviewTop() {
|
||||
return resizePreviewTop;
|
||||
},
|
||||
get resizePreviewHeight() {
|
||||
return resizePreviewHeight;
|
||||
},
|
||||
get hasMoved() {
|
||||
return hasMoved;
|
||||
},
|
||||
|
||||
// Methods
|
||||
startDrag,
|
||||
onDragMove,
|
||||
endDrag,
|
||||
startResize,
|
||||
onResizeMove,
|
||||
endResize,
|
||||
cancel,
|
||||
};
|
||||
}
|
||||
|
|
@ -35,11 +35,17 @@ function getInitialLocale(): SupportedLocale {
|
|||
}
|
||||
|
||||
// Initialize i18n at module scope (required for SSR)
|
||||
// Always set initialLocale to ensure it's never undefined
|
||||
init({
|
||||
fallbackLocale: defaultLocale,
|
||||
initialLocale: getInitialLocale(),
|
||||
initialLocale: browser ? getInitialLocale() : defaultLocale,
|
||||
});
|
||||
|
||||
// On browser, also explicitly set locale to ensure it's loaded
|
||||
if (browser) {
|
||||
locale.set(getInitialLocale());
|
||||
}
|
||||
|
||||
// Set locale and persist to localStorage
|
||||
export function setLocale(newLocale: SupportedLocale) {
|
||||
locale.set(newLocale);
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@
|
|||
"month": "Monat",
|
||||
"year": "Jahr",
|
||||
"agenda": "Agenda",
|
||||
"weekdaysOnly": "Nur Wochentage"
|
||||
"weekdaysOnly": "Nur Wochentage",
|
||||
"weekNumber": "KW",
|
||||
"moreEvents": "+{count} mehr"
|
||||
},
|
||||
"calendar": {
|
||||
"today": "Heute",
|
||||
|
|
@ -27,7 +29,10 @@
|
|||
"noEvents": "Keine Termine",
|
||||
"allDay": "Ganztägig",
|
||||
"myCalendars": "Meine Kalender",
|
||||
"sharedCalendars": "Geteilte Kalender"
|
||||
"sharedCalendars": "Geteilte Kalender",
|
||||
"draftEvent": "(Neuer Termin)",
|
||||
"hideSidebar": "Sidebar ausblenden",
|
||||
"showSidebar": "Sidebar einblenden"
|
||||
},
|
||||
"event": {
|
||||
"title": "Titel",
|
||||
|
|
@ -41,7 +46,9 @@
|
|||
"calendar": "Kalender",
|
||||
"save": "Speichern",
|
||||
"delete": "Löschen",
|
||||
"cancel": "Abbrechen"
|
||||
"cancel": "Abbrechen",
|
||||
"changeStartTime": "Startzeit ändern",
|
||||
"changeEndTime": "Endzeit ändern"
|
||||
},
|
||||
"repeat": {
|
||||
"none": "Nicht wiederholen",
|
||||
|
|
@ -86,5 +93,30 @@
|
|||
"search": "Suchen",
|
||||
"error": "Fehler",
|
||||
"success": "Erfolgreich"
|
||||
},
|
||||
"errors": {
|
||||
"loadEvents": "Termine konnten nicht geladen werden",
|
||||
"createEvent": "Termin konnte nicht erstellt werden",
|
||||
"updateEvent": "Termin konnte nicht aktualisiert werden",
|
||||
"deleteEvent": "Termin konnte nicht gelöscht werden"
|
||||
},
|
||||
"success": {
|
||||
"eventCreated": "Termin erstellt",
|
||||
"eventDeleted": "Termin gelöscht"
|
||||
},
|
||||
"priority": {
|
||||
"urgent": "Dringend",
|
||||
"high": "Wichtig",
|
||||
"medium": "Normal",
|
||||
"low": "Später"
|
||||
},
|
||||
"todo": {
|
||||
"task": "Aufgabe",
|
||||
"markComplete": "Als erledigt markieren",
|
||||
"markIncomplete": "Als unerledigt markieren"
|
||||
},
|
||||
"a11y": {
|
||||
"createEventOn": "Termin erstellen am {date}",
|
||||
"slotTime": "{day} {time}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@
|
|||
"month": "Month",
|
||||
"year": "Year",
|
||||
"agenda": "Agenda",
|
||||
"weekdaysOnly": "Weekdays only"
|
||||
"weekdaysOnly": "Weekdays only",
|
||||
"weekNumber": "W",
|
||||
"moreEvents": "+{count} more"
|
||||
},
|
||||
"calendar": {
|
||||
"today": "Today",
|
||||
|
|
@ -27,7 +29,10 @@
|
|||
"noEvents": "No events",
|
||||
"allDay": "All day",
|
||||
"myCalendars": "My Calendars",
|
||||
"sharedCalendars": "Shared Calendars"
|
||||
"sharedCalendars": "Shared Calendars",
|
||||
"draftEvent": "(New Event)",
|
||||
"hideSidebar": "Hide sidebar",
|
||||
"showSidebar": "Show sidebar"
|
||||
},
|
||||
"event": {
|
||||
"title": "Title",
|
||||
|
|
@ -41,7 +46,9 @@
|
|||
"calendar": "Calendar",
|
||||
"save": "Save",
|
||||
"delete": "Delete",
|
||||
"cancel": "Cancel"
|
||||
"cancel": "Cancel",
|
||||
"changeStartTime": "Change start time",
|
||||
"changeEndTime": "Change end time"
|
||||
},
|
||||
"repeat": {
|
||||
"none": "Don't repeat",
|
||||
|
|
@ -86,5 +93,30 @@
|
|||
"search": "Search",
|
||||
"error": "Error",
|
||||
"success": "Success"
|
||||
},
|
||||
"errors": {
|
||||
"loadEvents": "Failed to load events",
|
||||
"createEvent": "Failed to create event",
|
||||
"updateEvent": "Failed to update event",
|
||||
"deleteEvent": "Failed to delete event"
|
||||
},
|
||||
"success": {
|
||||
"eventCreated": "Event created",
|
||||
"eventDeleted": "Event deleted"
|
||||
},
|
||||
"priority": {
|
||||
"urgent": "Urgent",
|
||||
"high": "High",
|
||||
"medium": "Normal",
|
||||
"low": "Low"
|
||||
},
|
||||
"todo": {
|
||||
"task": "Task",
|
||||
"markComplete": "Mark as complete",
|
||||
"markIncomplete": "Mark as incomplete"
|
||||
},
|
||||
"a11y": {
|
||||
"createEventOn": "Create event on {date}",
|
||||
"slotTime": "{day} {time}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
175
apps/calendar/apps/web/src/lib/stores/contacts.svelte.ts
Normal file
175
apps/calendar/apps/web/src/lib/stores/contacts.svelte.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
/**
|
||||
* Contacts Store for Calendar App
|
||||
*
|
||||
* Provides access to contacts from the Contacts app for event attendee management.
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { createContactsClient, type ContactsClient } from '@manacore/shared-auth';
|
||||
import type { ContactSummary } from '@manacore/shared-types';
|
||||
import { authStore } from './auth.svelte';
|
||||
|
||||
// State
|
||||
let client: ContactsClient | null = null;
|
||||
let isAvailable = $state<boolean | null>(null);
|
||||
let isChecking = $state(false);
|
||||
let lastCheck = $state<number>(0);
|
||||
|
||||
// Cache for recent search results
|
||||
let searchCache = $state<Map<string, { results: ContactSummary[]; timestamp: number }>>(new Map());
|
||||
const CACHE_TTL = 60000; // 1 minute
|
||||
|
||||
// Get contacts API URL dynamically
|
||||
function getContactsApiUrl(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
const injectedUrl = (window as unknown as { __PUBLIC_CONTACTS_API_URL__?: string })
|
||||
.__PUBLIC_CONTACTS_API_URL__;
|
||||
return injectedUrl || 'http://localhost:3015/api/v1';
|
||||
}
|
||||
return 'http://localhost:3015/api/v1';
|
||||
}
|
||||
|
||||
// Initialize client lazily
|
||||
function getClient(): ContactsClient {
|
||||
if (!client) {
|
||||
client = createContactsClient({
|
||||
apiUrl: getContactsApiUrl(),
|
||||
getAuthToken: async () => authStore.getAccessToken(),
|
||||
timeout: 5000,
|
||||
});
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
export const contactsStore = {
|
||||
// Getters
|
||||
get isAvailable() {
|
||||
return isAvailable;
|
||||
},
|
||||
get isChecking() {
|
||||
return isChecking;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if the Contacts API is available
|
||||
* Caches result for 30 seconds
|
||||
*/
|
||||
async checkAvailability(): Promise<boolean> {
|
||||
const now = Date.now();
|
||||
// Skip if checked recently
|
||||
if (lastCheck && now - lastCheck < 30000 && isAvailable !== null) {
|
||||
return isAvailable;
|
||||
}
|
||||
|
||||
isChecking = true;
|
||||
try {
|
||||
const available = await getClient().isAvailable();
|
||||
isAvailable = available;
|
||||
lastCheck = now;
|
||||
return available;
|
||||
} catch {
|
||||
isAvailable = false;
|
||||
lastCheck = now;
|
||||
return false;
|
||||
} finally {
|
||||
isChecking = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Search contacts by query string
|
||||
*/
|
||||
async searchContacts(query: string): Promise<ContactSummary[]> {
|
||||
// Check cache first
|
||||
const cacheKey = query.toLowerCase().trim();
|
||||
const cached = searchCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||
return cached.results;
|
||||
}
|
||||
|
||||
// Check availability
|
||||
if (isAvailable === null) {
|
||||
await this.checkAvailability();
|
||||
}
|
||||
|
||||
if (!isAvailable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await getClient().searchContacts({
|
||||
query,
|
||||
limit: 20,
|
||||
excludeArchived: true,
|
||||
});
|
||||
|
||||
// Cache results
|
||||
searchCache.set(cacheKey, {
|
||||
results,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error('[contactsStore] Search failed:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a single contact by ID
|
||||
*/
|
||||
async getContact(id: string): Promise<ContactSummary | null> {
|
||||
if (isAvailable === null) {
|
||||
await this.checkAvailability();
|
||||
}
|
||||
|
||||
if (!isAvailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await getClient().getContact(id);
|
||||
} catch (error) {
|
||||
console.error(`[contactsStore] Failed to get contact ${id}:`, error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get multiple contacts by IDs
|
||||
*/
|
||||
async getContacts(ids: string[]): Promise<ContactSummary[]> {
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
if (isAvailable === null) {
|
||||
await this.checkAvailability();
|
||||
}
|
||||
|
||||
if (!isAvailable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return await getClient().getContacts(ids);
|
||||
} catch (error) {
|
||||
console.error('[contactsStore] Failed to get contacts:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear the search cache
|
||||
*/
|
||||
clearCache() {
|
||||
searchCache.clear();
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset availability check (force recheck on next call)
|
||||
*/
|
||||
resetAvailability() {
|
||||
isAvailable = null;
|
||||
lastCheck = 0;
|
||||
},
|
||||
};
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
import type { CalendarEvent, CreateEventInput, UpdateEventInput } from '@calendar/shared';
|
||||
import * as api from '$lib/api/events';
|
||||
import { format, isWithinInterval, parseISO, isSameDay } from 'date-fns';
|
||||
import { toastStore } from './toast.svelte';
|
||||
|
||||
// State
|
||||
let events = $state<CalendarEvent[]>([]);
|
||||
|
|
@ -45,6 +46,7 @@ export const eventsStore = {
|
|||
|
||||
if (result.error) {
|
||||
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;
|
||||
|
|
@ -119,8 +121,11 @@ export const eventsStore = {
|
|||
async createEvent(data: CreateEventInput) {
|
||||
const result = await api.createEvent(data);
|
||||
|
||||
if (result.data) {
|
||||
if (result.error) {
|
||||
toastStore.error(`Termin konnte nicht erstellt werden: ${result.error.message}`);
|
||||
} else if (result.data) {
|
||||
events = [...events, result.data];
|
||||
toastStore.success('Termin erstellt');
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
@ -132,7 +137,9 @@ export const eventsStore = {
|
|||
async updateEvent(id: string, data: UpdateEventInput) {
|
||||
const result = await api.updateEvent(id, data);
|
||||
|
||||
if (result.data) {
|
||||
if (result.error) {
|
||||
toastStore.error(`Termin konnte nicht aktualisiert werden: ${result.error.message}`);
|
||||
} else if (result.data) {
|
||||
events = events.map((e) => (e.id === id ? result.data! : e));
|
||||
}
|
||||
|
||||
|
|
@ -140,13 +147,23 @@ export const eventsStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Delete an event
|
||||
* Delete an event (optimistic update)
|
||||
*/
|
||||
async deleteEvent(id: string) {
|
||||
// Optimistic: remove event immediately
|
||||
const eventToDelete = events.find((e) => e.id === id);
|
||||
events = events.filter((e) => e.id !== id);
|
||||
|
||||
const result = await api.deleteEvent(id);
|
||||
|
||||
if (!result.error) {
|
||||
events = events.filter((e) => e.id !== id);
|
||||
if (result.error) {
|
||||
// Rollback: restore the event on error
|
||||
if (eventToDelete) {
|
||||
events = [...events, eventToDelete];
|
||||
}
|
||||
toastStore.error(`Termin konnte nicht gelöscht werden: ${result.error.message}`);
|
||||
} else {
|
||||
toastStore.success('Termin gelöscht');
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@ import { writable } from 'svelte/store';
|
|||
|
||||
export const isSidebarMode = writable(false);
|
||||
export const isNavCollapsed = writable(false);
|
||||
export const isToolbarCollapsed = writable(false);
|
||||
|
|
|
|||
45
apps/calendar/apps/web/src/lib/stores/search.svelte.ts
Normal file
45
apps/calendar/apps/web/src/lib/stores/search.svelte.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* Search Store - manages search state for highlighting events in calendar views
|
||||
*/
|
||||
|
||||
interface SearchItem {
|
||||
id: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
class SearchStore {
|
||||
// Current search query
|
||||
query = $state('');
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
export const searchStore = new SearchStore();
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
/**
|
||||
* Settings Store - Manages user preferences for the calendar app
|
||||
* Uses Svelte 5 runes and localStorage for persistence
|
||||
* Uses Svelte 5 runes with:
|
||||
* - localStorage for immediate persistence
|
||||
* - userSettings store for cloud sync (device-specific)
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import type { CalendarViewType } from '@calendar/shared';
|
||||
import { userSettings } from './user-settings.svelte';
|
||||
|
||||
// Settings types
|
||||
export type WeekStartDay = 0 | 1; // 0 = Sunday, 1 = Monday
|
||||
|
|
@ -78,6 +81,34 @@ function saveSettings(settings: CalendarAppSettings) {
|
|||
|
||||
// State
|
||||
let settings = $state<CalendarAppSettings>(loadSettings());
|
||||
let cloudSyncEnabled = $state(false);
|
||||
let initialSyncDone = $state(false);
|
||||
|
||||
/**
|
||||
* Sync settings to cloud (device-specific)
|
||||
*/
|
||||
async function syncToCloud() {
|
||||
if (!cloudSyncEnabled || !browser) return;
|
||||
|
||||
try {
|
||||
await userSettings.updateDeviceAppSettings(settings as unknown as Record<string, unknown>);
|
||||
} catch (e) {
|
||||
console.error('Failed to sync calendar settings to cloud:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load settings from cloud (device-specific)
|
||||
*/
|
||||
function loadFromCloud(): Partial<CalendarAppSettings> | null {
|
||||
if (!userSettings.loaded) return null;
|
||||
|
||||
const cloudSettings = userSettings.currentDeviceAppSettings;
|
||||
if (cloudSettings && Object.keys(cloudSettings).length > 0) {
|
||||
return cloudSettings as unknown as Partial<CalendarAppSettings>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const settingsStore = {
|
||||
// Getters
|
||||
|
|
@ -120,6 +151,36 @@ export const settingsStore = {
|
|||
get sidebarCollapsed() {
|
||||
return settings.sidebarCollapsed;
|
||||
},
|
||||
get cloudSyncEnabled() {
|
||||
return cloudSyncEnabled;
|
||||
},
|
||||
|
||||
/**
|
||||
* Enable cloud sync and load settings from cloud
|
||||
*/
|
||||
enableCloudSync() {
|
||||
cloudSyncEnabled = true;
|
||||
|
||||
// On first sync, prefer cloud settings over local if they exist
|
||||
if (!initialSyncDone) {
|
||||
const cloudSettings = loadFromCloud();
|
||||
if (cloudSettings && Object.keys(cloudSettings).length > 0) {
|
||||
settings = { ...DEFAULT_SETTINGS, ...settings, ...cloudSettings };
|
||||
saveSettings(settings);
|
||||
} else {
|
||||
// No cloud settings yet, push local settings to cloud
|
||||
syncToCloud();
|
||||
}
|
||||
initialSyncDone = true;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Disable cloud sync
|
||||
*/
|
||||
disableCloudSync() {
|
||||
cloudSyncEnabled = false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle sidebar collapsed state
|
||||
|
|
@ -127,6 +188,7 @@ export const settingsStore = {
|
|||
toggleSidebar() {
|
||||
settings = { ...settings, sidebarCollapsed: !settings.sidebarCollapsed };
|
||||
saveSettings(settings);
|
||||
syncToCloud();
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
@ -143,6 +205,7 @@ export const settingsStore = {
|
|||
set<K extends keyof CalendarAppSettings>(key: K, value: CalendarAppSettings[K]) {
|
||||
settings = { ...settings, [key]: value };
|
||||
saveSettings(settings);
|
||||
syncToCloud();
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
@ -151,6 +214,7 @@ export const settingsStore = {
|
|||
update(updates: Partial<CalendarAppSettings>) {
|
||||
settings = { ...settings, ...updates };
|
||||
saveSettings(settings);
|
||||
syncToCloud();
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
@ -159,6 +223,7 @@ export const settingsStore = {
|
|||
reset() {
|
||||
settings = { ...DEFAULT_SETTINGS };
|
||||
saveSettings(settings);
|
||||
syncToCloud();
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
|||
57
apps/calendar/apps/web/src/lib/stores/toast.svelte.ts
Normal file
57
apps/calendar/apps/web/src/lib/stores/toast.svelte.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* Toast Store - Svelte 5 Runes version
|
||||
* Manages toast notifications
|
||||
*/
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'warning' | 'info';
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
// State
|
||||
let toasts = $state<Toast[]>([]);
|
||||
|
||||
function add(message: string, type: ToastType = 'info', duration: number = 4000): string {
|
||||
const id = crypto.randomUUID();
|
||||
const toast: Toast = { id, type, message, duration };
|
||||
|
||||
toasts = [...toasts, toast];
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
remove(id);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
function remove(id: string) {
|
||||
toasts = toasts.filter((t) => t.id !== id);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
toasts = [];
|
||||
}
|
||||
|
||||
export const toastStore = {
|
||||
get toasts() {
|
||||
return toasts;
|
||||
},
|
||||
|
||||
add,
|
||||
remove,
|
||||
clear,
|
||||
|
||||
success: (message: string, duration?: number) => add(message, 'success', duration),
|
||||
error: (message: string, duration?: number) => add(message, 'error', duration ?? 6000),
|
||||
warning: (message: string, duration?: number) => add(message, 'warning', duration),
|
||||
info: (message: string, duration?: number) => add(message, 'info', duration),
|
||||
};
|
||||
|
||||
// Keep old export for backwards compatibility
|
||||
export const toast = toastStore;
|
||||
|
|
@ -61,7 +61,7 @@ export const todosStore = {
|
|||
// ========== Derived Getters ==========
|
||||
|
||||
/**
|
||||
* Get todos for a specific day
|
||||
* Get todos for a specific day (by dueDate)
|
||||
*/
|
||||
getTodosForDay(date: Date): Task[] {
|
||||
const currentTodos = todos ?? [];
|
||||
|
|
@ -74,6 +74,50 @@ export const todosStore = {
|
|||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get scheduled tasks for a specific day (by scheduledDate - for time-blocking)
|
||||
* Note: Includes completed tasks so they remain visible in the calendar
|
||||
*/
|
||||
getScheduledTasksForDay(date: Date): Task[] {
|
||||
const currentTodos = todos ?? [];
|
||||
if (!Array.isArray(currentTodos)) return [];
|
||||
|
||||
return currentTodos.filter((task) => {
|
||||
if (!task.scheduledDate) return false;
|
||||
const scheduledDate =
|
||||
typeof task.scheduledDate === 'string' ? parseISO(task.scheduledDate) : task.scheduledDate;
|
||||
return isSameDay(scheduledDate, date);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get scheduled tasks within a date range (for time-blocking)
|
||||
* Note: Includes completed tasks so they remain visible in the calendar
|
||||
*/
|
||||
getScheduledTasksInRange(start: Date, end: Date): Task[] {
|
||||
const currentTodos = todos ?? [];
|
||||
if (!Array.isArray(currentTodos)) return [];
|
||||
|
||||
return currentTodos.filter((task) => {
|
||||
if (!task.scheduledDate) return false;
|
||||
const scheduledDate =
|
||||
typeof task.scheduledDate === 'string' ? parseISO(task.scheduledDate) : task.scheduledDate;
|
||||
return isWithinInterval(scheduledDate, { start, end });
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get unscheduled tasks (no scheduledDate - for sidebar drag source)
|
||||
*/
|
||||
get unscheduledForTimeBlocking(): Task[] {
|
||||
const currentTodos = todos ?? [];
|
||||
if (!Array.isArray(currentTodos)) return [];
|
||||
|
||||
return currentTodos
|
||||
.filter((task) => !task.isCompleted && !task.scheduledDate)
|
||||
.sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get todos within a date range
|
||||
*/
|
||||
|
|
@ -202,14 +246,13 @@ export const todosStore = {
|
|||
|
||||
/**
|
||||
* Fetch todos for a date range
|
||||
* Note: Fetches both completed and uncompleted tasks so scheduled tasks remain visible
|
||||
*/
|
||||
async fetchTodos(startDate?: Date, endDate?: Date) {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
const query: TaskQuery = {
|
||||
isCompleted: false,
|
||||
};
|
||||
const query: TaskQuery = {};
|
||||
|
||||
if (startDate) {
|
||||
query.dueDateFrom = format(startDate, 'yyyy-MM-dd');
|
||||
|
|
@ -236,7 +279,7 @@ export const todosStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Fetch today's todos (shortcut)
|
||||
* Fetch today's todos (shortcut) - only uncompleted tasks
|
||||
*/
|
||||
async fetchTodayTodos() {
|
||||
loading = true;
|
||||
|
|
@ -260,6 +303,40 @@ export const todosStore = {
|
|||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch all scheduled todos (including completed ones)
|
||||
* Used for calendar time-blocking to keep completed tasks visible
|
||||
*/
|
||||
async fetchScheduledTodos() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
// Fetch all tasks without isCompleted filter - API will return all
|
||||
const result = await api.getTasks({});
|
||||
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
serviceAvailable = false;
|
||||
} else {
|
||||
// Only keep tasks that have a scheduledDate (for time-blocking)
|
||||
// Merge with existing todos (avoid duplicates)
|
||||
const allTasks = result.data || [];
|
||||
const scheduledTasks = allTasks.filter((t) => t.scheduledDate);
|
||||
const existingIds = new Set(todos.map((t) => t.id));
|
||||
const uniqueNew = scheduledTasks.filter((t) => !existingIds.has(t.id));
|
||||
// Also update existing scheduled tasks (in case isCompleted changed)
|
||||
todos = todos.map((existing) => {
|
||||
const updated = scheduledTasks.find((t) => t.id === existing.id);
|
||||
return updated || existing;
|
||||
});
|
||||
todos = [...todos, ...uniqueNew];
|
||||
serviceAvailable = true;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch upcoming todos (shortcut)
|
||||
*/
|
||||
|
|
@ -271,7 +348,11 @@ export const todosStore = {
|
|||
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
serviceAvailable = false;
|
||||
// Only set serviceAvailable to false if we have no todos yet
|
||||
// (if fetchTodayTodos succeeded, we should still show the service as available)
|
||||
if (todos.length === 0) {
|
||||
serviceAvailable = false;
|
||||
}
|
||||
} else {
|
||||
// Merge with existing todos (avoid duplicates)
|
||||
const newTodos = result.data || [];
|
||||
|
|
@ -338,13 +419,20 @@ export const todosStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Delete a todo
|
||||
* Delete a todo (optimistic update)
|
||||
*/
|
||||
async deleteTodo(id: string) {
|
||||
// Optimistic: remove todo immediately
|
||||
const todoToDelete = todos.find((t) => t.id === id);
|
||||
todos = todos.filter((t) => t.id !== id);
|
||||
|
||||
const result = await api.deleteTask(id);
|
||||
|
||||
if (!result.error) {
|
||||
todos = todos.filter((t) => t.id !== id);
|
||||
if (result.error) {
|
||||
// Rollback: restore the todo on error
|
||||
if (todoToDelete) {
|
||||
todos = [...todos, todoToDelete];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
|
|||
41
apps/calendar/apps/web/src/lib/utils/eventDateHelpers.ts
Normal file
41
apps/calendar/apps/web/src/lib/utils/eventDateHelpers.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* Event Date Helpers
|
||||
* Utilities for consistent date handling across the calendar app
|
||||
*/
|
||||
|
||||
import { parseISO } from 'date-fns';
|
||||
|
||||
/**
|
||||
* Convert a date value that may be either a string or Date to a Date object
|
||||
* This handles the common pattern where API returns ISO strings but we need Date objects
|
||||
*/
|
||||
export function toDate(value: string | Date): Date {
|
||||
return typeof value === 'string' ? parseISO(value) : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the start time of an event as a Date object
|
||||
*/
|
||||
export function getEventStart(event: { startTime: string | Date }): Date {
|
||||
return toDate(event.startTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the end time of an event as a Date object
|
||||
*/
|
||||
export function getEventEnd(event: { endTime: string | Date }): Date {
|
||||
return toDate(event.endTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get both start and end times of an event as Date objects
|
||||
*/
|
||||
export function getEventTimes(event: { startTime: string | Date; endTime: string | Date }): {
|
||||
start: Date;
|
||||
end: Date;
|
||||
} {
|
||||
return {
|
||||
start: toDate(event.startTime),
|
||||
end: toDate(event.endTime),
|
||||
};
|
||||
}
|
||||
|
|
@ -3,12 +3,16 @@
|
|||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { PillNavigation, CommandBar } from '@manacore/shared-ui';
|
||||
import { PillNavigation, QuickInputBar } from '@manacore/shared-ui';
|
||||
import {
|
||||
SplitPaneContainer,
|
||||
setSplitPanelContext,
|
||||
DEFAULT_APPS,
|
||||
} from '@manacore/shared-splitscreen';
|
||||
import type {
|
||||
PillNavItem,
|
||||
PillDropdownItem,
|
||||
CommandBarItem,
|
||||
QuickAction,
|
||||
QuickInputItem,
|
||||
CreatePreview,
|
||||
} from '@manacore/shared-ui';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
|
|
@ -29,11 +33,13 @@
|
|||
import {
|
||||
isSidebarMode as sidebarModeStore,
|
||||
isNavCollapsed as collapsedStore,
|
||||
isToolbarCollapsed as toolbarCollapsedStore,
|
||||
} from '$lib/stores/navigation';
|
||||
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
|
||||
import { getPillAppItems } from '@manacore/shared-branding';
|
||||
import { setLocale, supportedLocales } from '$lib/i18n';
|
||||
import { searchEvents } from '$lib/api/events';
|
||||
import { searchStore } from '$lib/stores/search.svelte';
|
||||
import { format } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import {
|
||||
|
|
@ -41,31 +47,25 @@
|
|||
resolveEventIds,
|
||||
formatParsedEventPreview,
|
||||
} from '$lib/utils/event-parser';
|
||||
import CalendarToolbar from '$lib/components/calendar/CalendarToolbar.svelte';
|
||||
import CalendarToolbarContent from '$lib/components/calendar/CalendarToolbarContent.svelte';
|
||||
import DateStrip from '$lib/components/calendar/DateStrip.svelte';
|
||||
|
||||
// App switcher items
|
||||
const appItems = getPillAppItems('calendar');
|
||||
|
||||
// Split-Panel Store für Split-Screen Feature
|
||||
const splitPanel = setSplitPanelContext('calendar', DEFAULT_APPS);
|
||||
|
||||
// Handler für Split-Screen Panel-Öffnung
|
||||
function handleOpenInPanel(appId: string, url: string) {
|
||||
splitPanel.openPanel(appId);
|
||||
}
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// CommandBar state
|
||||
let commandBarOpen = $state(false);
|
||||
|
||||
// CommandBar quick actions (no search for calendar yet)
|
||||
const commandBarQuickActions: QuickAction[] = [
|
||||
{ id: 'new', label: 'Neuen Termin erstellen', icon: 'plus', href: '/event/new', shortcut: 'N' },
|
||||
{
|
||||
id: 'today',
|
||||
label: 'Zu Heute springen',
|
||||
icon: 'calendar',
|
||||
onclick: () => viewStore.goToToday(),
|
||||
},
|
||||
{ id: 'agenda', label: 'Agenda anzeigen', icon: 'list', href: '/agenda' },
|
||||
{ id: 'tasks', label: 'Aufgaben anzeigen', icon: 'check-square', href: '/tasks' },
|
||||
{ id: 'settings', label: 'Einstellungen', icon: 'settings', href: '/settings' },
|
||||
];
|
||||
|
||||
// CommandBar search - search events
|
||||
async function handleCommandBarSearch(query: string): Promise<CommandBarItem[]> {
|
||||
// InputBar search - search events
|
||||
async function handleSearch(query: string): Promise<QuickInputItem[]> {
|
||||
if (!query.trim()) return [];
|
||||
|
||||
const result = await searchEvents(query);
|
||||
|
|
@ -78,24 +78,34 @@
|
|||
}));
|
||||
}
|
||||
|
||||
function handleCommandBarSelect(item: CommandBarItem) {
|
||||
function handleSelect(item: QuickInputItem) {
|
||||
searchStore.clear();
|
||||
goto(`/event/${item.id}`);
|
||||
}
|
||||
|
||||
// CommandBar Quick-Create handlers
|
||||
function handleCommandBarParseCreate(query: string): CreatePreview | null {
|
||||
// Update search store when search changes (for calendar view highlighting)
|
||||
function handleSearchChange(query: string, results: QuickInputItem[]) {
|
||||
if (!query.trim()) {
|
||||
searchStore.clear();
|
||||
} else {
|
||||
searchStore.setSearch(query, results);
|
||||
}
|
||||
}
|
||||
|
||||
// QuickInputBar Quick-Create handlers
|
||||
function handleParseCreate(query: string): CreatePreview | null {
|
||||
if (!query.trim()) return null;
|
||||
|
||||
const parsed = parseEventInput(query);
|
||||
if (!parsed.title) return null;
|
||||
|
||||
return {
|
||||
title: parsed.title,
|
||||
title: `"${parsed.title}" erstellen`,
|
||||
subtitle: formatParsedEventPreview(parsed),
|
||||
};
|
||||
}
|
||||
|
||||
async function handleCommandBarCreate(query: string): Promise<void> {
|
||||
async function handleCreate(query: string): Promise<void> {
|
||||
const parsed = parseEventInput(query);
|
||||
if (!parsed.title) return;
|
||||
|
||||
|
|
@ -104,12 +114,6 @@
|
|||
const tags = eventTagsStore.tags.map((t) => ({ id: t.id, name: t.name }));
|
||||
const resolved = resolveEventIds(parsed, calendars, tags);
|
||||
|
||||
// Ensure we have a calendar
|
||||
if (!resolved.calendarId) {
|
||||
console.error('No calendar available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure we have start and end times
|
||||
if (!resolved.startTime) {
|
||||
// Default to now + 1 hour
|
||||
|
|
@ -119,8 +123,10 @@
|
|||
resolved.endTime = end.toISOString();
|
||||
}
|
||||
|
||||
// Create event - calendarId is now optional, backend will use/create default if not provided
|
||||
await eventsStore.createEvent({
|
||||
calendarId: resolved.calendarId,
|
||||
// Only include calendarId if resolved (from command or default calendar)
|
||||
...(resolved.calendarId ? { calendarId: resolved.calendarId } : {}),
|
||||
title: resolved.title,
|
||||
startTime: resolved.startTime,
|
||||
endTime: resolved.endTime || resolved.startTime,
|
||||
|
|
@ -128,14 +134,23 @@
|
|||
location: resolved.location,
|
||||
tagIds: resolved.tagIds,
|
||||
});
|
||||
|
||||
// Refresh calendars if none existed (in case default was created)
|
||||
if (calendarsStore.calendars.length === 0) {
|
||||
await calendarsStore.fetchCalendars();
|
||||
}
|
||||
}
|
||||
|
||||
let isSidebarMode = $state(false);
|
||||
let isCollapsed = $state(false);
|
||||
let isToolbarCollapsed = $state(false);
|
||||
|
||||
// Use theme store's isDark directly
|
||||
let isDark = $derived(theme.isDark);
|
||||
|
||||
// Show toolbar only on calendar main page
|
||||
let showCalendarToolbar = $derived($page.url.pathname === '/');
|
||||
|
||||
// Get pinned themes from user settings (extended themes only)
|
||||
let pinnedThemes = $derived<ThemeVariant[]>(
|
||||
(userSettings.theme?.pinnedThemes || []).filter((t): t is ThemeVariant =>
|
||||
|
|
@ -203,13 +218,6 @@
|
|||
function handleKeydown(event: KeyboardEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// Cmd/Ctrl+K to open command bar (works even in inputs)
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
|
||||
event.preventDefault();
|
||||
commandBarOpen = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -242,6 +250,19 @@
|
|||
}
|
||||
}
|
||||
|
||||
function handleToolbarModeChange(isSidebar: boolean) {
|
||||
// Sync toolbar mode with nav mode
|
||||
handleModeChange(isSidebar);
|
||||
}
|
||||
|
||||
function handleToolbarCollapsedChange(collapsed: boolean) {
|
||||
isToolbarCollapsed = collapsed;
|
||||
toolbarCollapsedStore.set(collapsed);
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('calendar-toolbar-collapsed', String(collapsed));
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggleTheme() {
|
||||
theme.toggleMode();
|
||||
}
|
||||
|
|
@ -262,6 +283,9 @@
|
|||
return;
|
||||
}
|
||||
|
||||
// Initialize split-panel from URL/localStorage
|
||||
splitPanel.initialize();
|
||||
|
||||
// Initialize view state
|
||||
viewStore.initialize();
|
||||
|
||||
|
|
@ -289,75 +313,114 @@
|
|||
isCollapsed = true;
|
||||
collapsedStore.set(true);
|
||||
}
|
||||
|
||||
// Initialize toolbar collapsed state from localStorage
|
||||
const savedToolbarCollapsed = localStorage.getItem('calendar-toolbar-collapsed');
|
||||
if (savedToolbarCollapsed === 'true') {
|
||||
isToolbarCollapsed = true;
|
||||
toolbarCollapsedStore.set(true);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class="layout-container">
|
||||
<PillNavigation
|
||||
items={navItems}
|
||||
currentPath={$page.url.pathname}
|
||||
appName="Kalender"
|
||||
homeRoute="/"
|
||||
onToggleTheme={handleToggleTheme}
|
||||
{isDark}
|
||||
{isSidebarMode}
|
||||
onModeChange={handleModeChange}
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
desktopPosition={userSettings.nav.desktopPosition}
|
||||
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"
|
||||
/>
|
||||
|
||||
<main
|
||||
class="main-content bg-background"
|
||||
class:sidebar-mode={isSidebarMode && !isCollapsed}
|
||||
class:floating-mode={!isSidebarMode && !isCollapsed}
|
||||
>
|
||||
<div
|
||||
class="content-wrapper"
|
||||
class:calendar-expanded={settingsStore.sidebarCollapsed && $page.url.pathname === '/'}
|
||||
<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}
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
{#snippet toolbarContent()}
|
||||
{#if showCalendarToolbar}
|
||||
<CalendarToolbarContent vertical={true} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</PillNavigation>
|
||||
|
||||
<!-- Global Command Bar (Cmd/K) -->
|
||||
<CommandBar
|
||||
bind:open={commandBarOpen}
|
||||
onClose={() => (commandBarOpen = false)}
|
||||
onSearch={handleCommandBarSearch}
|
||||
onSelect={handleCommandBarSelect}
|
||||
quickActions={commandBarQuickActions}
|
||||
placeholder="Termin suchen oder erstellen..."
|
||||
emptyText="Keine Termine gefunden"
|
||||
searchingText="Suche..."
|
||||
onCreate={handleCommandBarCreate}
|
||||
onParseCreate={handleCommandBarParseCreate}
|
||||
createText="Als Termin erstellen"
|
||||
createShortcut="⌘↵"
|
||||
/>
|
||||
</div>
|
||||
<!-- 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
|
||||
{isSidebarMode}
|
||||
isCollapsed={isToolbarCollapsed}
|
||||
onModeChange={handleToolbarModeChange}
|
||||
onCollapsedChange={handleToolbarCollapsedChange}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<main
|
||||
class="main-content bg-background"
|
||||
class:sidebar-mode={isSidebarMode && !isCollapsed}
|
||||
class:floating-mode={!isSidebarMode && !isCollapsed}
|
||||
class:has-toolbar={showCalendarToolbar}
|
||||
>
|
||||
<div
|
||||
class="content-wrapper"
|
||||
class:calendar-expanded={settingsStore.sidebarCollapsed && $page.url.pathname === '/'}
|
||||
>
|
||||
{@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>
|
||||
|
||||
<style>
|
||||
.layout-container {
|
||||
|
|
@ -369,13 +432,37 @@
|
|||
.main-content {
|
||||
transition: all 300ms ease;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
/* Space for QuickInputBar at bottom */
|
||||
padding-bottom: calc(80px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.main-content.floating-mode {
|
||||
padding-top: 70px;
|
||||
}
|
||||
|
||||
/* Extra padding when DateStrip + Toolbar are at bottom */
|
||||
.main-content.floating-mode.has-toolbar {
|
||||
padding-top: 0;
|
||||
padding-bottom: calc(
|
||||
280px + env(safe-area-inset-bottom)
|
||||
); /* DateStrip + Toolbar + PillNav + QuickInputBar */
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* On mobile, toolbars are at bottom, extra padding at bottom instead */
|
||||
.main-content {
|
||||
padding-bottom: calc(150px + env(safe-area-inset-bottom)); /* PillNav + QuickInputBar */
|
||||
}
|
||||
.main-content.has-toolbar {
|
||||
padding-bottom: calc(
|
||||
250px + env(safe-area-inset-bottom)
|
||||
); /* DateStrip + Toolbar + BottomNav + QuickInputBar */
|
||||
}
|
||||
.main-content.floating-mode.has-toolbar {
|
||||
padding-top: 70px;
|
||||
}
|
||||
}
|
||||
|
||||
.main-content.sidebar-mode {
|
||||
padding-left: 180px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,41 +1,38 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
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 CalendarHeader from '$lib/components/calendar/CalendarHeader.svelte';
|
||||
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 MiniCalendar from '$lib/components/calendar/MiniCalendar.svelte';
|
||||
import CalendarSidebar from '$lib/components/calendar/CalendarSidebar.svelte';
|
||||
import TodoSidebarSection from '$lib/components/calendar/TodoSidebarSection.svelte';
|
||||
import QuickEventOverlay from '$lib/components/event/QuickEventOverlay.svelte';
|
||||
import EventDetailModal from '$lib/components/event/EventDetailModal.svelte';
|
||||
import { CalendarViewSkeleton } from '$lib/components/skeletons';
|
||||
import { format, addMinutes } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import type { CalendarEvent } from '@calendar/shared';
|
||||
import { addMinutes } from 'date-fns';
|
||||
|
||||
let initialized = $state(false);
|
||||
|
||||
// Quick event overlay state
|
||||
let showQuickCreate = $state(false);
|
||||
// Quick event overlay state - for both create and edit
|
||||
let showQuickOverlay = $state(false);
|
||||
let quickCreateDate = $state<Date>(new Date());
|
||||
let editingEvent = $state<CalendarEvent | null>(null);
|
||||
|
||||
// Event modal state (local state for reactivity)
|
||||
let selectedEventId = $state<string | null>(null);
|
||||
|
||||
// Derive modal open state from URL
|
||||
let modalEventId = $derived($page.url.searchParams.get('event'));
|
||||
// Generate a unique key for the overlay to force remount
|
||||
let overlayKey = $state(0);
|
||||
|
||||
function handleQuickCreate(date: Date, position: { x: number; y: number }) {
|
||||
// Close any existing overlay first
|
||||
editingEvent = null;
|
||||
|
||||
quickCreateDate = date;
|
||||
|
||||
// Create draft event immediately so it appears in the grid
|
||||
|
|
@ -50,11 +47,22 @@
|
|||
isAllDay: false,
|
||||
});
|
||||
|
||||
showQuickCreate = true;
|
||||
overlayKey++;
|
||||
showQuickOverlay = true;
|
||||
}
|
||||
|
||||
function handleQuickCreateClose() {
|
||||
showQuickCreate = false;
|
||||
function handleEventClick(event: CalendarEvent) {
|
||||
// Close any existing overlay/draft first
|
||||
eventsStore.clearDraftEvent();
|
||||
|
||||
editingEvent = event;
|
||||
overlayKey++;
|
||||
showQuickOverlay = true;
|
||||
}
|
||||
|
||||
function handleQuickOverlayClose() {
|
||||
showQuickOverlay = false;
|
||||
editingEvent = null;
|
||||
eventsStore.clearDraftEvent();
|
||||
}
|
||||
|
||||
|
|
@ -63,6 +71,14 @@
|
|||
eventsStore.clearDraftEvent();
|
||||
}
|
||||
|
||||
function handleEventUpdated() {
|
||||
// Event is automatically updated in store
|
||||
}
|
||||
|
||||
function handleEventDeleted() {
|
||||
// Event is automatically removed from store
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (!authStore.isAuthenticated) {
|
||||
goto('/login');
|
||||
|
|
@ -74,29 +90,16 @@
|
|||
initialized = true;
|
||||
});
|
||||
|
||||
function handleEventModalClose() {
|
||||
// Remove event param from URL
|
||||
goto('/', { replaceState: true });
|
||||
}
|
||||
|
||||
// Refetch events when view changes
|
||||
$effect(() => {
|
||||
if (initialized && authStore.isAuthenticated) {
|
||||
eventsStore.fetchEvents(viewStore.viewRange.start, viewStore.viewRange.end);
|
||||
}
|
||||
});
|
||||
|
||||
function handleDateSelect(date: Date) {
|
||||
viewStore.setDate(date);
|
||||
}
|
||||
|
||||
function handleNewEvent() {
|
||||
goto('/event/new');
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Kalender</title>
|
||||
<title>{$_('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="calendar-layout">
|
||||
|
|
@ -106,7 +109,7 @@
|
|||
<button
|
||||
class="sidebar-collapse-btn"
|
||||
onclick={() => settingsStore.toggleSidebar()}
|
||||
title="Sidebar ausblenden"
|
||||
title={$_('calendar.hideSidebar')}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
|
|
@ -118,20 +121,6 @@
|
|||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="w-full mb-4 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium text-primary-foreground bg-primary hover:bg-primary/90 transition-colors"
|
||||
onclick={handleNewEvent}
|
||||
>
|
||||
<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="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Neuer Termin
|
||||
</button>
|
||||
|
||||
<MiniCalendar selectedDate={viewStore.currentDate} onDateSelect={handleDateSelect} />
|
||||
|
||||
<CalendarSidebar />
|
||||
|
||||
<TodoSidebarSection maxItems={5} />
|
||||
</aside>
|
||||
|
||||
|
|
@ -141,7 +130,7 @@
|
|||
<button
|
||||
class="fab-expand"
|
||||
onclick={() => settingsStore.toggleSidebar()}
|
||||
title="Sidebar einblenden"
|
||||
title={$_('calendar.showSidebar')}
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
|
|
@ -152,58 +141,58 @@
|
|||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="fab-new-event" onclick={handleNewEvent} title="Neuer Termin">
|
||||
<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="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Main Calendar Area -->
|
||||
<div class="calendar-main" class:expanded={settingsStore.sidebarCollapsed}>
|
||||
<CalendarHeader />
|
||||
|
||||
<div class="calendar-content">
|
||||
{#if !initialized}
|
||||
<CalendarViewSkeleton />
|
||||
{:else if viewStore.viewType === 'day'}
|
||||
<DayView onQuickCreate={handleQuickCreate} />
|
||||
<DayView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
|
||||
{:else if viewStore.viewType === '5day'}
|
||||
<MultiDayView dayCount={5} onQuickCreate={handleQuickCreate} />
|
||||
<MultiDayView
|
||||
dayCount={5}
|
||||
onQuickCreate={handleQuickCreate}
|
||||
onEventClick={handleEventClick}
|
||||
/>
|
||||
{:else if viewStore.viewType === 'week'}
|
||||
<WeekView onQuickCreate={handleQuickCreate} />
|
||||
<WeekView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
|
||||
{:else if viewStore.viewType === '10day'}
|
||||
<MultiDayView dayCount={10} onQuickCreate={handleQuickCreate} />
|
||||
<MultiDayView
|
||||
dayCount={10}
|
||||
onQuickCreate={handleQuickCreate}
|
||||
onEventClick={handleEventClick}
|
||||
/>
|
||||
{:else if viewStore.viewType === '14day'}
|
||||
<MultiDayView dayCount={14} onQuickCreate={handleQuickCreate} />
|
||||
<MultiDayView
|
||||
dayCount={14}
|
||||
onQuickCreate={handleQuickCreate}
|
||||
onEventClick={handleEventClick}
|
||||
/>
|
||||
{:else if viewStore.viewType === 'month'}
|
||||
<MonthView onQuickCreate={handleQuickCreate} />
|
||||
<MonthView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
|
||||
{:else if viewStore.viewType === 'year'}
|
||||
<YearView onQuickCreate={handleQuickCreate} />
|
||||
<YearView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
|
||||
{:else}
|
||||
<WeekView onQuickCreate={handleQuickCreate} />
|
||||
<WeekView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Event Overlay -->
|
||||
{#if showQuickCreate}
|
||||
<QuickEventOverlay
|
||||
startTime={quickCreateDate}
|
||||
onClose={handleQuickCreateClose}
|
||||
onCreated={handleEventCreated}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Event Detail Modal -->
|
||||
{#if modalEventId}
|
||||
<EventDetailModal eventId={modalEventId} onClose={handleEventModalClose} />
|
||||
<!-- Quick Event Overlay (for both create and edit) -->
|
||||
{#if showQuickOverlay}
|
||||
{#key overlayKey}
|
||||
<QuickEventOverlay
|
||||
startTime={editingEvent ? undefined : quickCreateDate}
|
||||
event={editingEvent ?? undefined}
|
||||
onClose={handleQuickOverlayClose}
|
||||
onCreated={handleEventCreated}
|
||||
onUpdated={handleEventUpdated}
|
||||
onDeleted={handleEventDeleted}
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
|
@ -290,8 +279,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
.fab-expand,
|
||||
.fab-new-event {
|
||||
.fab-expand {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-full);
|
||||
|
|
@ -302,9 +290,6 @@
|
|||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.fab-expand {
|
||||
background: hsl(var(--color-surface));
|
||||
color: hsl(var(--color-foreground));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
|
|
@ -315,16 +300,6 @@
|
|||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.fab-new-event {
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
}
|
||||
|
||||
.fab-new-event:hover {
|
||||
background: hsl(var(--color-primary) / 0.9);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.calendar-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
import EventForm from '$lib/components/event/EventForm.svelte';
|
||||
import type { CreateEventInput, UpdateEventInput } from '@calendar/shared';
|
||||
import { addHours, parseISO } from 'date-fns';
|
||||
|
||||
let initialStart = $state<Date | null>(null);
|
||||
|
||||
onMount(() => {
|
||||
if (!authStore.isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for start time in URL params
|
||||
const startParam = $page.url.searchParams.get('start');
|
||||
if (startParam) {
|
||||
initialStart = parseISO(startParam);
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSave(data: CreateEventInput | UpdateEventInput) {
|
||||
// In create mode, data is always CreateEventInput
|
||||
const result = await eventsStore.createEvent(data as CreateEventInput);
|
||||
|
||||
if (result.error) {
|
||||
toast.error(`Fehler beim Erstellen: ${result.error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Termin erstellt');
|
||||
goto('/');
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
goto('/');
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Neuer Termin | Kalender</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page-container">
|
||||
<div class="card">
|
||||
<h1 class="page-title">Neuer Termin</h1>
|
||||
<EventForm
|
||||
mode="create"
|
||||
initialStartTime={initialStart}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
import { NetworkGraph, NetworkControls } from '@manacore/shared-ui';
|
||||
import '$lib/i18n';
|
||||
|
||||
let graphComponent: NetworkGraph;
|
||||
let graphComponent = $state<NetworkGraph | null>(null);
|
||||
let controlsComponent: NetworkControls;
|
||||
let graphContainer: HTMLDivElement;
|
||||
|
||||
|
|
@ -172,7 +172,11 @@
|
|||
<div class="info-panel">
|
||||
<div class="info-header">
|
||||
<h3>{networkStore.selectedNode.name}</h3>
|
||||
<button class="close-btn" onclick={() => networkStore.selectNode(null)}>
|
||||
<button
|
||||
class="close-btn"
|
||||
onclick={() => networkStore.selectNode(null)}
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
|
|
|
|||
|
|
@ -516,105 +516,6 @@
|
|||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
/* Language options */
|
||||
.locale-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.locale-option {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 2px solid hsl(var(--color-border));
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.locale-option:hover {
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
|
||||
.locale-option.active {
|
||||
border-color: hsl(var(--color-primary));
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
}
|
||||
|
||||
/* Theme options */
|
||||
.theme-options {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.theme-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 2px solid hsl(var(--color-border));
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.theme-option:hover {
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
|
||||
.theme-option.active {
|
||||
border-color: hsl(var(--color-primary));
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
}
|
||||
|
||||
.theme-option .icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
/* Variant grid */
|
||||
.variant-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.variant-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem;
|
||||
border: 2px solid hsl(var(--color-border));
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.variant-option:hover {
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
|
||||
.variant-option.active {
|
||||
border-color: hsl(var(--color-primary));
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
}
|
||||
|
||||
.variant-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.variant-label {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
/* Select input */
|
||||
.select-input {
|
||||
width: 100%;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
<script lang="ts">
|
||||
import '../app.css';
|
||||
// Initialize i18n early - must be imported before any component that uses $_
|
||||
import { waitLocale } from '$lib/i18n';
|
||||
import { onMount } from 'svelte';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import ToastContainer from '$lib/components/ToastContainer.svelte';
|
||||
import { AppLoadingSkeleton } from '$lib/components/skeletons';
|
||||
|
||||
|
|
@ -11,6 +15,9 @@
|
|||
let loading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
// Wait for i18n locale to be loaded
|
||||
await waitLocale();
|
||||
|
||||
// Initialize theme
|
||||
theme.initialize();
|
||||
|
||||
|
|
@ -19,6 +26,18 @@
|
|||
|
||||
loading = false;
|
||||
});
|
||||
|
||||
// Load user settings when authenticated
|
||||
$effect(() => {
|
||||
if (authStore.isAuthenticated) {
|
||||
userSettings.load().then(() => {
|
||||
// Enable cloud sync for calendar settings after user settings are loaded
|
||||
settingsStore.enableCloudSync();
|
||||
});
|
||||
} else {
|
||||
settingsStore.disableCloudSync();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<ToastContainer />
|
||||
|
|
|
|||
|
|
@ -1,10 +1,21 @@
|
|||
/**
|
||||
* Event attendee RSVP status
|
||||
*/
|
||||
export type AttendeeStatus = 'accepted' | 'declined' | 'tentative' | 'pending';
|
||||
|
||||
/**
|
||||
* Event attendee information
|
||||
*/
|
||||
export interface EventAttendee {
|
||||
email: string;
|
||||
name?: string;
|
||||
status?: 'accepted' | 'declined' | 'tentative' | 'pending';
|
||||
status?: AttendeeStatus;
|
||||
/** Contact reference for linked contacts */
|
||||
contactId?: string;
|
||||
/** Cached photo URL from contact */
|
||||
photoUrl?: string;
|
||||
/** Cached company from contact */
|
||||
company?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -126,7 +137,8 @@ export interface CalendarEventWithCalendar extends CalendarEvent {
|
|||
* Data required to create a new event
|
||||
*/
|
||||
export interface CreateEventInput {
|
||||
calendarId: string;
|
||||
/** Calendar ID. If not provided, the default calendar will be used (or created if none exists) */
|
||||
calendarId?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-splitscreen": "workspace:*",
|
||||
"@manacore/shared-tags": "workspace:*",
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -37,10 +37,10 @@
|
|||
@layer components {
|
||||
/* Card styles */
|
||||
.card {
|
||||
background-color: hsl(var(--card));
|
||||
background-color: var(--color-card);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border: 1px solid var(--color-border);
|
||||
transition: transform var(--transition-base), box-shadow var(--transition-base);
|
||||
}
|
||||
|
||||
|
|
@ -56,14 +56,14 @@
|
|||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid hsl(var(--border));
|
||||
background-color: hsl(var(--card));
|
||||
border: 1px solid var(--color-border);
|
||||
background-color: var(--color-card);
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.contact-card:hover {
|
||||
border-color: hsl(var(--primary));
|
||||
background-color: hsl(var(--accent));
|
||||
border-color: var(--color-primary);
|
||||
background-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Avatar styles */
|
||||
|
|
@ -72,8 +72,8 @@
|
|||
height: 56px;
|
||||
min-width: 56px;
|
||||
border-radius: var(--radius-full);
|
||||
background-color: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-primary-foreground);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
@ -98,27 +98,27 @@
|
|||
}
|
||||
|
||||
.btn-primary {
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-foreground);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: hsl(var(--primary) / 0.9);
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
/* Input styles */
|
||||
.input {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 2px solid hsl(var(--border));
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--primary));
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Tag styles */
|
||||
|
|
|
|||
232
apps/contacts/apps/web/src/lib/api/todos.ts
Normal file
232
apps/contacts/apps/web/src/lib/api/todos.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
/**
|
||||
* Cross-App API Client for Todo Backend
|
||||
* Allows Contacts app to fetch tasks related to a contact
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
// Types mirrored from @todo/shared
|
||||
export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent';
|
||||
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled';
|
||||
|
||||
export interface Subtask {
|
||||
id: string;
|
||||
title: string;
|
||||
isCompleted: boolean;
|
||||
completedAt?: string | null;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface Label {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
color: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
color: string;
|
||||
icon?: string | null;
|
||||
order: number;
|
||||
isArchived: boolean;
|
||||
isDefault: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ContactReference {
|
||||
contactId: string;
|
||||
displayName: string;
|
||||
email?: string;
|
||||
photoUrl?: string;
|
||||
company?: string;
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
export interface TaskMetadata {
|
||||
notes?: string;
|
||||
attachments?: string[];
|
||||
linkedCalendarEventId?: string | null;
|
||||
storyPoints?: number | null;
|
||||
effectiveDuration?: {
|
||||
value: number;
|
||||
unit: 'minutes' | 'hours' | 'days';
|
||||
} | null;
|
||||
funRating?: number | null;
|
||||
assignee?: ContactReference | null;
|
||||
involvedContacts?: ContactReference[];
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
projectId?: string | null;
|
||||
userId: string;
|
||||
parentTaskId?: string | null;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
dueDate?: string | null;
|
||||
dueTime?: string | null;
|
||||
startDate?: string | null;
|
||||
scheduledDate?: string | null;
|
||||
scheduledStartTime?: string | null;
|
||||
scheduledEndTime?: string | null;
|
||||
estimatedDuration?: number | null;
|
||||
priority: TaskPriority;
|
||||
status: TaskStatus;
|
||||
isCompleted: boolean;
|
||||
completedAt?: string | null;
|
||||
order: number;
|
||||
columnId?: string | null;
|
||||
columnOrder?: number;
|
||||
recurrenceRule?: string | null;
|
||||
recurrenceEndDate?: string | null;
|
||||
lastOccurrence?: string | null;
|
||||
subtasks?: Subtask[] | null;
|
||||
metadata?: TaskMetadata | null;
|
||||
labels?: Label[];
|
||||
project?: Project | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// API Configuration
|
||||
function getTodoApiBase(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
const injectedUrl = (window as unknown as { __PUBLIC_TODO_BACKEND_URL__?: string })
|
||||
.__PUBLIC_TODO_BACKEND_URL__;
|
||||
return injectedUrl || 'http://localhost:3018';
|
||||
}
|
||||
return 'http://localhost:3018';
|
||||
}
|
||||
|
||||
interface ApiResult<T> {
|
||||
data: T | null;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
async function fetchTodoApi<T>(endpoint: string, options: RequestInit = {}): Promise<ApiResult<T>> {
|
||||
const token = await authStore.getAccessToken();
|
||||
const baseUrl = getTodoApiBase();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/v1${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
return {
|
||||
data: null,
|
||||
error: new Error(errorData.message || `API error: ${response.status}`),
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return { data, error: null };
|
||||
} catch (error) {
|
||||
return {
|
||||
data: null,
|
||||
error: error instanceof Error ? error : new Error('Unknown error'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// API Functions
|
||||
|
||||
/**
|
||||
* Get tasks related to a specific contact (assigned or involved)
|
||||
*/
|
||||
export async function getTasksByContact(
|
||||
contactId: string,
|
||||
includeCompleted: boolean = false
|
||||
): Promise<ApiResult<Task[]>> {
|
||||
const params = new URLSearchParams();
|
||||
if (includeCompleted) {
|
||||
params.set('includeCompleted', 'true');
|
||||
}
|
||||
const query = params.toString();
|
||||
|
||||
const result = await fetchTodoApi<{ tasks: Task[] }>(
|
||||
`/tasks/by-contact/${contactId}${query ? `?${query}` : ''}`
|
||||
);
|
||||
|
||||
return {
|
||||
data: result.data?.tasks || null,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a task
|
||||
*/
|
||||
export async function completeTask(taskId: string): Promise<ApiResult<Task>> {
|
||||
const result = await fetchTodoApi<{ task: Task }>(`/tasks/${taskId}/complete`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
return {
|
||||
data: result.data?.task || null,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Uncomplete a task
|
||||
*/
|
||||
export async function uncompleteTask(taskId: string): Promise<ApiResult<Task>> {
|
||||
const result = await fetchTodoApi<{ task: Task }>(`/tasks/${taskId}/uncomplete`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
return {
|
||||
data: result.data?.task || null,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the Todo service is available
|
||||
*/
|
||||
export async function checkTodoServiceAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const baseUrl = getTodoApiBase();
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/v1/health`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority styling helpers
|
||||
export const PRIORITY_COLORS: Record<TaskPriority, string> = {
|
||||
urgent: 'var(--color-danger, #ef4444)',
|
||||
high: 'var(--color-warning, #f59e0b)',
|
||||
medium: 'var(--color-accent, #3b82f6)',
|
||||
low: 'var(--color-success, #22c55e)',
|
||||
};
|
||||
|
||||
export const PRIORITY_LABELS: Record<TaskPriority, { de: string; en: string }> = {
|
||||
urgent: { de: 'Dringend', en: 'Urgent' },
|
||||
high: { de: 'Wichtig', en: 'High' },
|
||||
medium: { de: 'Normal', en: 'Medium' },
|
||||
low: { de: 'Niedrig', en: 'Low' },
|
||||
};
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
import { onMount } from 'svelte';
|
||||
import { contactsApi, photoApi, type Contact } from '$lib/api/contacts';
|
||||
import ContactNotes from './ContactNotes.svelte';
|
||||
import ContactTasks from './ContactTasks.svelte';
|
||||
import { ContactDetailSkeleton } from '$lib/components/skeletons';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -841,6 +842,9 @@
|
|||
|
||||
<!-- Contact Notes (separate from contact.notes field) -->
|
||||
<ContactNotes {contactId} />
|
||||
|
||||
<!-- Tasks related to this contact -->
|
||||
<ContactTasks {contactId} />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,8 @@
|
|||
import { contactsStore } from '$lib/stores/contacts.svelte';
|
||||
import { viewModeStore } from '$lib/stores/view-mode.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import ViewModeToggle from '$lib/components/ViewModeToggle.svelte';
|
||||
import SortToggle, { type SortField } from '$lib/components/SortToggle.svelte';
|
||||
import FilterBar, {
|
||||
type ContactFilter,
|
||||
type BirthdayFilter,
|
||||
} from '$lib/components/FilterBar.svelte';
|
||||
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';
|
||||
|
|
@ -32,9 +28,6 @@
|
|||
let birthdayFilter = $state<BirthdayFilter>('all');
|
||||
let selectedCompany = $state<string | null>(null);
|
||||
|
||||
// Count favorites for quick filter button
|
||||
let favoritesCount = $derived(contactsStore.contacts.filter((c) => c.isFavorite).length);
|
||||
|
||||
// Batch selection state
|
||||
let selectionMode = $state(false);
|
||||
let selectedIds = $state<Set<string>>(new Set());
|
||||
|
|
@ -277,32 +270,7 @@
|
|||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between flex-wrap gap-4">
|
||||
<h1 class="text-2xl font-bold text-foreground">{$_('contacts.title')}</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Selection Mode Toggle -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleSelectionMode}
|
||||
class="btn {selectionMode ? 'btn-primary' : 'btn-secondary'} flex items-center gap-2"
|
||||
title={selectionMode ? 'Auswahl beenden' : 'Mehrere auswählen'}
|
||||
>
|
||||
<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="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>
|
||||
<span class="hidden sm:inline">{selectionMode ? 'Fertig' : 'Auswählen'}</span>
|
||||
</button>
|
||||
<a href="/contacts/new" class="btn btn-primary flex items-center gap-2">
|
||||
<span>+</span>
|
||||
<span>{$_('contacts.new')}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-foreground">{$_('contacts.title')}</h1>
|
||||
|
||||
<!-- Batch Actions Bar (shown when in selection mode) -->
|
||||
{#if selectionMode}
|
||||
|
|
@ -381,79 +349,55 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Search, Filters and View Toggle -->
|
||||
<div class="flex items-center gap-4 flex-wrap">
|
||||
<div class="relative flex-1 min-w-[200px]">
|
||||
<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>
|
||||
<!-- Quick Favorites Filter -->
|
||||
<button
|
||||
type="button"
|
||||
class="favorites-quick-btn"
|
||||
class:active={contactFilter === 'favorites'}
|
||||
onclick={() => (contactFilter = contactFilter === 'favorites' ? 'all' : 'favorites')}
|
||||
title={contactFilter === 'favorites' ? 'Alle Kontakte anzeigen' : 'Nur Favoriten anzeigen'}
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
class:filled={contactFilter === 'favorites'}
|
||||
fill={contactFilter === 'favorites' ? '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="favorites-count">{favoritesCount}</span>
|
||||
{/if}
|
||||
</button>
|
||||
<FilterBar
|
||||
contacts={contactsStore.contacts}
|
||||
{selectedTagId}
|
||||
onTagChange={(id) => {
|
||||
selectedTagId = id;
|
||||
if (id) {
|
||||
contactsStore.setTagId(id);
|
||||
} else {
|
||||
contactsStore.setTagId(undefined);
|
||||
}
|
||||
contactsStore.loadContacts();
|
||||
}}
|
||||
{contactFilter}
|
||||
onContactFilterChange={(f) => (contactFilter = f)}
|
||||
{birthdayFilter}
|
||||
onBirthdayFilterChange={(f) => (birthdayFilter = f)}
|
||||
{selectedCompany}
|
||||
onCompanyChange={(c) => (selectedCompany = c)}
|
||||
<!-- Search Bar -->
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={$_('contacts.search')}
|
||||
bind:value={searchQuery}
|
||||
oninput={handleSearch}
|
||||
class="input w-full pl-10"
|
||||
/>
|
||||
<SortToggle value={sortField} onchange={(v) => (sortField = v)} />
|
||||
<ViewModeToggle />
|
||||
<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'}
|
||||
|
|
@ -565,57 +509,6 @@
|
|||
color: hsl(var(--color-error));
|
||||
}
|
||||
|
||||
/* Favorites Quick Filter Button */
|
||||
.favorites-quick-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: hsl(var(--background) / 0.75);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid hsl(var(--border) / 0.5);
|
||||
border-radius: 9999px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.favorites-quick-btn:hover {
|
||||
color: hsl(var(--foreground));
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
.favorites-quick-btn.active {
|
||||
color: #ef4444;
|
||||
border-color: #ef4444 / 0.5;
|
||||
background: hsl(0 84% 60% / 0.1);
|
||||
}
|
||||
|
||||
.favorites-quick-btn.active:hover {
|
||||
background: hsl(0 84% 60% / 0.15);
|
||||
}
|
||||
|
||||
.favorites-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
background: hsl(var(--muted));
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.favorites-quick-btn.active .favorites-count {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Infinite scroll */
|
||||
.load-more-trigger {
|
||||
height: 1px;
|
||||
|
|
|
|||
515
apps/contacts/apps/web/src/lib/components/ContactTasks.svelte
Normal file
515
apps/contacts/apps/web/src/lib/components/ContactTasks.svelte
Normal file
|
|
@ -0,0 +1,515 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { todosStore } from '$lib/stores/todos.svelte';
|
||||
import { PRIORITY_COLORS, type Task } from '$lib/api/todos';
|
||||
|
||||
interface Props {
|
||||
contactId: string;
|
||||
}
|
||||
|
||||
let { contactId }: Props = $props();
|
||||
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let showCompleted = $state(false);
|
||||
let displayLimit = $state(10);
|
||||
|
||||
// Categorized tasks
|
||||
let assignedTasks = $state<Task[]>([]);
|
||||
let involvedTasks = $state<Task[]>([]);
|
||||
|
||||
// Derived values
|
||||
const visibleAssigned = $derived.by(() => {
|
||||
const filtered = showCompleted ? assignedTasks : assignedTasks.filter((t) => !t.isCompleted);
|
||||
return filtered.slice(0, displayLimit);
|
||||
});
|
||||
|
||||
const visibleInvolved = $derived.by(() => {
|
||||
const filtered = showCompleted ? involvedTasks : involvedTasks.filter((t) => !t.isCompleted);
|
||||
return filtered.slice(0, displayLimit);
|
||||
});
|
||||
|
||||
const totalVisible = $derived(visibleAssigned.length + visibleInvolved.length);
|
||||
const totalTasks = $derived(assignedTasks.length + involvedTasks.length);
|
||||
const hasMore = $derived(totalVisible < totalTasks);
|
||||
|
||||
async function loadTasks() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
// Always load with completed to get full list, filter in UI
|
||||
await todosStore.loadTasksForContact(contactId, true);
|
||||
const categorized = todosStore.categorizeTasksForContact(contactId);
|
||||
assignedTasks = categorized.assigned;
|
||||
involvedTasks = categorized.involved;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : $_('contact.tasks.error');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleComplete(task: Task) {
|
||||
const success = await todosStore.toggleTaskCompletion(task.id, contactId);
|
||||
if (success) {
|
||||
// Refresh categorization
|
||||
const categorized = todosStore.categorizeTasksForContact(contactId);
|
||||
assignedTasks = categorized.assigned;
|
||||
involvedTasks = categorized.involved;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDueDate(dueDate: string | null | undefined): {
|
||||
text: string;
|
||||
status: 'overdue' | 'today' | 'upcoming' | 'none';
|
||||
} {
|
||||
if (!dueDate) return { text: '', status: 'none' };
|
||||
|
||||
const due = new Date(dueDate);
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const dueDay = new Date(due.getFullYear(), due.getMonth(), due.getDate());
|
||||
|
||||
const diffDays = Math.floor((dueDay.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays < 0) {
|
||||
return { text: $_('contact.tasks.overdue'), status: 'overdue' };
|
||||
} else if (diffDays === 0) {
|
||||
return { text: $_('contact.tasks.dueToday'), status: 'today' };
|
||||
} else if (diffDays === 1) {
|
||||
return { text: $_('contact.tasks.tomorrow'), status: 'upcoming' };
|
||||
} else if (diffDays < 7) {
|
||||
return { text: due.toLocaleDateString('de-DE', { weekday: 'short' }), status: 'upcoming' };
|
||||
} else {
|
||||
return {
|
||||
text: due.toLocaleDateString('de-DE', { day: '2-digit', month: 'short' }),
|
||||
status: 'upcoming',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function showMore() {
|
||||
displayLimit += 10;
|
||||
}
|
||||
|
||||
onMount(loadTasks);
|
||||
</script>
|
||||
|
||||
<section class="tasks-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="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>
|
||||
</div>
|
||||
<h3 class="section-title">{$_('contact.tasks.title')}</h3>
|
||||
<label class="show-completed-toggle">
|
||||
<input type="checkbox" bind:checked={showCompleted} />
|
||||
<span>{$_('contact.tasks.showCompleted')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="error-message">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">
|
||||
<span class="spinner"></span>
|
||||
</div>
|
||||
{:else if !todosStore.serviceAvailable}
|
||||
<div class="service-unavailable">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<p>{$_('contact.tasks.serviceUnavailable')}</p>
|
||||
</div>
|
||||
{:else if totalTasks === 0}
|
||||
<div class="empty-tasks">
|
||||
<p>{$_('contact.tasks.empty')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Assigned Tasks -->
|
||||
{#if assignedTasks.length > 0}
|
||||
<div class="task-group">
|
||||
<div class="group-header">
|
||||
<span class="group-title">{$_('contact.tasks.assigned')}</span>
|
||||
<span class="group-count">{visibleAssigned.length}</span>
|
||||
</div>
|
||||
<div class="tasks-list">
|
||||
{#each visibleAssigned as task (task.id)}
|
||||
{@const dueInfo = formatDueDate(task.dueDate)}
|
||||
<div class="task-item" class:completed={task.isCompleted}>
|
||||
<button
|
||||
class="task-checkbox"
|
||||
onclick={() => handleToggleComplete(task)}
|
||||
aria-label={task.isCompleted
|
||||
? $_('contact.tasks.markIncomplete')
|
||||
: $_('contact.tasks.markComplete')}
|
||||
style="--priority-color: {PRIORITY_COLORS[task.priority]}"
|
||||
>
|
||||
{#if task.isCompleted}
|
||||
<svg fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
<div class="task-content">
|
||||
<span class="task-title" class:completed={task.isCompleted}>{task.title}</span>
|
||||
{#if task.project}
|
||||
<span class="task-project" style="--project-color: {task.project.color}"
|
||||
>{task.project.name}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{#if dueInfo.status !== 'none' && !task.isCompleted}
|
||||
<span
|
||||
class="task-due"
|
||||
class:overdue={dueInfo.status === 'overdue'}
|
||||
class:today={dueInfo.status === 'today'}
|
||||
>
|
||||
{dueInfo.text}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Involved Tasks -->
|
||||
{#if involvedTasks.length > 0}
|
||||
<div class="task-group">
|
||||
<div class="group-header">
|
||||
<span class="group-title">{$_('contact.tasks.involved')}</span>
|
||||
<span class="group-count">{visibleInvolved.length}</span>
|
||||
</div>
|
||||
<div class="tasks-list">
|
||||
{#each visibleInvolved as task (task.id)}
|
||||
{@const dueInfo = formatDueDate(task.dueDate)}
|
||||
<div class="task-item" class:completed={task.isCompleted}>
|
||||
<button
|
||||
class="task-checkbox"
|
||||
onclick={() => handleToggleComplete(task)}
|
||||
aria-label={task.isCompleted
|
||||
? $_('contact.tasks.markIncomplete')
|
||||
: $_('contact.tasks.markComplete')}
|
||||
style="--priority-color: {PRIORITY_COLORS[task.priority]}"
|
||||
>
|
||||
{#if task.isCompleted}
|
||||
<svg fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
<div class="task-content">
|
||||
<span class="task-title" class:completed={task.isCompleted}>{task.title}</span>
|
||||
{#if task.project}
|
||||
<span class="task-project" style="--project-color: {task.project.color}"
|
||||
>{task.project.name}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{#if dueInfo.status !== 'none' && !task.isCompleted}
|
||||
<span
|
||||
class="task-due"
|
||||
class:overdue={dueInfo.status === 'overdue'}
|
||||
class:today={dueInfo.status === 'today'}
|
||||
>
|
||||
{dueInfo.text}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Show More Button -->
|
||||
{#if hasMore}
|
||||
<button class="show-more-btn" onclick={showMore}>
|
||||
{$_('contact.tasks.showMore', { values: { count: totalTasks - totalVisible } })}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.tasks-section {
|
||||
background: hsl(var(--color-surface));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.875rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding-bottom: 0.625rem;
|
||||
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-accent) / 0.1);
|
||||
color: hsl(var(--color-accent));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.section-icon svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.show-completed-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.show-completed-toggle input {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
accent-color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: hsl(var(--color-error) / 0.1);
|
||||
border-radius: 0.5rem;
|
||||
color: hsl(var(--color-error));
|
||||
font-size: 0.8125rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: 2px solid hsl(var(--color-muted));
|
||||
border-top-color: hsl(var(--color-accent));
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.service-unavailable {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1.5rem 1rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.service-unavailable svg {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
color: hsl(var(--color-warning));
|
||||
}
|
||||
|
||||
.service-unavailable p {
|
||||
font-size: 0.8125rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-tasks {
|
||||
text-align: center;
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.empty-tasks p {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
/* Task Groups */
|
||||
.task-group {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.task-group:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.group-count {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--color-muted));
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
/* Tasks List */
|
||||
.tasks-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
background: hsl(var(--color-muted) / 0.3);
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.task-item:hover {
|
||||
background: hsl(var(--color-muted) / 0.5);
|
||||
}
|
||||
|
||||
.task-item.completed {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.task-checkbox {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 2px solid var(--priority-color, hsl(var(--color-border)));
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-checkbox:hover {
|
||||
background: hsl(var(--color-muted));
|
||||
}
|
||||
|
||||
.task-item.completed .task-checkbox {
|
||||
background: var(--priority-color, hsl(var(--color-primary)));
|
||||
border-color: var(--priority-color, hsl(var(--color-primary)));
|
||||
}
|
||||
|
||||
.task-checkbox svg {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.task-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.task-title {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.task-title.completed {
|
||||
text-decoration: line-through;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.task-project {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--project-color, hsl(var(--color-muted-foreground)));
|
||||
}
|
||||
|
||||
.task-due {
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
background: hsl(var(--color-muted));
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-due.overdue {
|
||||
background: hsl(var(--color-error) / 0.1);
|
||||
color: hsl(var(--color-error));
|
||||
}
|
||||
|
||||
.task-due.today {
|
||||
background: hsl(var(--color-warning) / 0.1);
|
||||
color: hsl(var(--color-warning));
|
||||
}
|
||||
|
||||
/* Show More Button */
|
||||
.show-more-btn {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-muted) / 0.3);
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.show-more-btn:hover {
|
||||
background: hsl(var(--color-muted) / 0.5);
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
</style>
|
||||
274
apps/contacts/apps/web/src/lib/components/ContactsToolbar.svelte
Normal file
274
apps/contacts/apps/web/src/lib/components/ContactsToolbar.svelte
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
<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 type { Contact } from '$lib/api/contacts';
|
||||
|
||||
export type SortField = 'firstName' | 'lastName';
|
||||
|
||||
interface Props {
|
||||
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();
|
||||
|
||||
// Count favorites for quick filter button
|
||||
let favoritesCount = $derived(contactsStore.contacts.filter((c) => c.isFavorite).length);
|
||||
let showFavorites = $derived(contactFilter === 'favorites');
|
||||
|
||||
// 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);
|
||||
}
|
||||
</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>
|
||||
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
class="view-btn"
|
||||
class:active={viewModeStore.mode === 'grid'}
|
||||
onclick={() => viewModeStore.setMode('grid')}
|
||||
title={$_('views.grid')}
|
||||
>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="view-btn"
|
||||
class:active={viewModeStore.mode === 'alphabet'}
|
||||
onclick={() => viewModeStore.setMode('alphabet')}
|
||||
title={$_('views.alphabet')}
|
||||
>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</PillToolbar>
|
||||
|
||||
<style>
|
||||
.btn-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.btn-label {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.view-mode-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
color: #374151;
|
||||
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);
|
||||
}
|
||||
|
||||
.view-btn.active {
|
||||
background: color-mix(in srgb, #6366f1 15%, transparent 85%);
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.view-btn :global(svg) {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -16,6 +16,8 @@
|
|||
onBirthdayFilterChange: (filter: BirthdayFilter) => void;
|
||||
selectedCompany: string | null;
|
||||
onCompanyChange: (company: string | null) => void;
|
||||
/** When embedded in a toolbar, renders as just a button without background container */
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -28,6 +30,7 @@
|
|||
onBirthdayFilterChange,
|
||||
selectedCompany,
|
||||
onCompanyChange,
|
||||
embedded = false,
|
||||
}: Props = $props();
|
||||
|
||||
let tags = $state<ContactTag[]>([]);
|
||||
|
|
@ -81,37 +84,150 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
<div class="filter-bar">
|
||||
<!-- Filter Toggle Button -->
|
||||
<button
|
||||
type="button"
|
||||
class="filter-toggle"
|
||||
class:active={showFilters || activeFilterCount > 0}
|
||||
onclick={() => (showFilters = !showFilters)}
|
||||
>
|
||||
<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="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{$_('filters.title')}</span>
|
||||
{#if activeFilterCount > 0}
|
||||
<span class="filter-badge">{activeFilterCount}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if embedded}
|
||||
<!-- Embedded mode: just the button for use in a toolbar -->
|
||||
<div class="filter-bar-embedded">
|
||||
<button
|
||||
type="button"
|
||||
class="filter-toggle-embedded"
|
||||
class:active={showFilters || activeFilterCount > 0}
|
||||
onclick={() => (showFilters = !showFilters)}
|
||||
title={$_('filters.title')}
|
||||
>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
|
||||
/>
|
||||
</svg>
|
||||
{#if activeFilterCount > 0}
|
||||
<span class="filter-badge-embedded">{activeFilterCount}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Filter Pills (shown when filters are active) -->
|
||||
{#if activeFilterCount > 0 && !showFilters}
|
||||
<div class="active-filters">
|
||||
{#if selectedTagId}
|
||||
{@const tag = tags.find((t) => t.id === selectedTagId)}
|
||||
{#if tag}
|
||||
<button type="button" class="filter-pill" onclick={() => onTagChange(null)}>
|
||||
<span class="pill-color" style="background: {tag.color || '#6366f1'}"></span>
|
||||
{tag.name}
|
||||
<!-- Dropdown panel for embedded mode -->
|
||||
{#if showFilters}
|
||||
<div class="filter-dropdown">
|
||||
<!-- Tags Filter -->
|
||||
<div class="filter-section">
|
||||
<label class="filter-label">{$_('filters.tag')}</label>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={selectedTagId || ''}
|
||||
onchange={(e) => onTagChange(e.currentTarget.value || null)}
|
||||
>
|
||||
<option value="">{$_('filters.allTags')}</option>
|
||||
{#each tags as tag}
|
||||
<option value={tag.id}>{tag.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Contact Info Filter -->
|
||||
<div class="filter-section">
|
||||
<label class="filter-label">{$_('filters.contactInfo')}</label>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={contactFilter}
|
||||
onchange={(e) => onContactFilterChange(e.currentTarget.value as ContactFilter)}
|
||||
>
|
||||
<option value="all">{$_('filters.contact.all')}</option>
|
||||
<option value="favorites">{$_('filters.contact.favorites')}</option>
|
||||
<option value="hasPhone">{$_('filters.contact.hasPhone')}</option>
|
||||
<option value="hasEmail">{$_('filters.contact.hasEmail')}</option>
|
||||
<option value="incomplete">{$_('filters.contact.incomplete')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Birthday Filter -->
|
||||
<div class="filter-section">
|
||||
<label class="filter-label">{$_('filters.birthdayLabel')}</label>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={birthdayFilter}
|
||||
onchange={(e) => onBirthdayFilterChange(e.currentTarget.value as BirthdayFilter)}
|
||||
>
|
||||
<option value="all">{$_('filters.birthday.all')}</option>
|
||||
<option value="today">{$_('filters.birthday.today')}</option>
|
||||
<option value="thisWeek">{$_('filters.birthday.thisWeek')}</option>
|
||||
<option value="thisMonth">{$_('filters.birthday.thisMonth')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Company Filter -->
|
||||
{#if companies.length > 0}
|
||||
<div class="filter-section">
|
||||
<label class="filter-label">{$_('filters.company')}</label>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={selectedCompany || ''}
|
||||
onchange={(e) => onCompanyChange(e.currentTarget.value || null)}
|
||||
>
|
||||
<option value="">{$_('filters.allCompanies')}</option>
|
||||
{#each companies as company}
|
||||
<option value={company}>{company}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Clear Filters -->
|
||||
{#if activeFilterCount > 0}
|
||||
<button type="button" class="clear-filters-btn" onclick={clearAllFilters}>
|
||||
{$_('filters.clearAll')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="filter-bar">
|
||||
<!-- Filter Toggle Button -->
|
||||
<button
|
||||
type="button"
|
||||
class="filter-toggle"
|
||||
class:active={showFilters || activeFilterCount > 0}
|
||||
onclick={() => (showFilters = !showFilters)}
|
||||
>
|
||||
<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="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{$_('filters.title')}</span>
|
||||
{#if activeFilterCount > 0}
|
||||
<span class="filter-badge">{activeFilterCount}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Filter Pills (shown when filters are active) -->
|
||||
{#if activeFilterCount > 0 && !showFilters}
|
||||
<div class="active-filters">
|
||||
{#if selectedTagId}
|
||||
{@const tag = tags.find((t) => t.id === selectedTagId)}
|
||||
{#if tag}
|
||||
<button type="button" class="filter-pill" onclick={() => onTagChange(null)}>
|
||||
<span class="pill-color" style="background: {tag.color || '#6366f1'}"></span>
|
||||
{tag.name}
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if contactFilter !== 'all' && contactFilter !== 'favorites'}
|
||||
<button type="button" class="filter-pill" onclick={() => onContactFilterChange('all')}>
|
||||
{$_(`filters.contact.${contactFilter}`)}
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
|
|
@ -122,129 +238,201 @@
|
|||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if contactFilter !== 'all' && contactFilter !== 'favorites'}
|
||||
<button type="button" class="filter-pill" onclick={() => onContactFilterChange('all')}>
|
||||
{$_(`filters.contact.${contactFilter}`)}
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
{#if birthdayFilter !== 'all'}
|
||||
<button type="button" class="filter-pill" onclick={() => onBirthdayFilterChange('all')}>
|
||||
{$_(`filters.birthday.${birthdayFilter}`)}
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
{#if selectedCompany}
|
||||
<button type="button" class="filter-pill" onclick={() => onCompanyChange(null)}>
|
||||
{selectedCompany}
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<button type="button" class="clear-all-btn" onclick={clearAllFilters}>
|
||||
{$_('filters.clearAll')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if birthdayFilter !== 'all'}
|
||||
<button type="button" class="filter-pill" onclick={() => onBirthdayFilterChange('all')}>
|
||||
{$_(`filters.birthday.${birthdayFilter}`)}
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
{#if selectedCompany}
|
||||
<button type="button" class="filter-pill" onclick={() => onCompanyChange(null)}>
|
||||
{selectedCompany}
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<button type="button" class="clear-all-btn" onclick={clearAllFilters}>
|
||||
{$_('filters.clearAll')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Expanded Filter Panel -->
|
||||
{#if showFilters}
|
||||
<div class="filter-panel">
|
||||
<!-- Tags Filter -->
|
||||
<div class="filter-section">
|
||||
<label class="filter-label">{$_('filters.tag')}</label>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={selectedTagId || ''}
|
||||
onchange={(e) => onTagChange(e.currentTarget.value || null)}
|
||||
>
|
||||
<option value="">{$_('filters.allTags')}</option>
|
||||
{#each tags as tag}
|
||||
<option value={tag.id}>{tag.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Contact Info Filter -->
|
||||
<div class="filter-section">
|
||||
<label class="filter-label">{$_('filters.contactInfo')}</label>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={contactFilter}
|
||||
onchange={(e) => onContactFilterChange(e.currentTarget.value as ContactFilter)}
|
||||
>
|
||||
<option value="all">{$_('filters.contact.all')}</option>
|
||||
<option value="favorites">{$_('filters.contact.favorites')}</option>
|
||||
<option value="hasPhone">{$_('filters.contact.hasPhone')}</option>
|
||||
<option value="hasEmail">{$_('filters.contact.hasEmail')}</option>
|
||||
<option value="incomplete">{$_('filters.contact.incomplete')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Birthday Filter -->
|
||||
<div class="filter-section">
|
||||
<label class="filter-label">{$_('filters.birthdayLabel')}</label>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={birthdayFilter}
|
||||
onchange={(e) => onBirthdayFilterChange(e.currentTarget.value as BirthdayFilter)}
|
||||
>
|
||||
<option value="all">{$_('filters.birthday.all')}</option>
|
||||
<option value="today">{$_('filters.birthday.today')}</option>
|
||||
<option value="thisWeek">{$_('filters.birthday.thisWeek')}</option>
|
||||
<option value="thisMonth">{$_('filters.birthday.thisMonth')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Company Filter -->
|
||||
{#if companies.length > 0}
|
||||
<!-- Expanded Filter Panel -->
|
||||
{#if showFilters}
|
||||
<div class="filter-panel">
|
||||
<!-- Tags Filter -->
|
||||
<div class="filter-section">
|
||||
<label class="filter-label">{$_('filters.company')}</label>
|
||||
<label class="filter-label">{$_('filters.tag')}</label>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={selectedCompany || ''}
|
||||
onchange={(e) => onCompanyChange(e.currentTarget.value || null)}
|
||||
value={selectedTagId || ''}
|
||||
onchange={(e) => onTagChange(e.currentTarget.value || null)}
|
||||
>
|
||||
<option value="">{$_('filters.allCompanies')}</option>
|
||||
{#each companies as company}
|
||||
<option value={company}>{company}</option>
|
||||
<option value="">{$_('filters.allTags')}</option>
|
||||
{#each tags as tag}
|
||||
<option value={tag.id}>{tag.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Clear Filters -->
|
||||
{#if activeFilterCount > 0}
|
||||
<button type="button" class="clear-filters-btn" onclick={clearAllFilters}>
|
||||
{$_('filters.clearAll')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Contact Info Filter -->
|
||||
<div class="filter-section">
|
||||
<label class="filter-label">{$_('filters.contactInfo')}</label>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={contactFilter}
|
||||
onchange={(e) => onContactFilterChange(e.currentTarget.value as ContactFilter)}
|
||||
>
|
||||
<option value="all">{$_('filters.contact.all')}</option>
|
||||
<option value="favorites">{$_('filters.contact.favorites')}</option>
|
||||
<option value="hasPhone">{$_('filters.contact.hasPhone')}</option>
|
||||
<option value="hasEmail">{$_('filters.contact.hasEmail')}</option>
|
||||
<option value="incomplete">{$_('filters.contact.incomplete')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Birthday Filter -->
|
||||
<div class="filter-section">
|
||||
<label class="filter-label">{$_('filters.birthdayLabel')}</label>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={birthdayFilter}
|
||||
onchange={(e) => onBirthdayFilterChange(e.currentTarget.value as BirthdayFilter)}
|
||||
>
|
||||
<option value="all">{$_('filters.birthday.all')}</option>
|
||||
<option value="today">{$_('filters.birthday.today')}</option>
|
||||
<option value="thisWeek">{$_('filters.birthday.thisWeek')}</option>
|
||||
<option value="thisMonth">{$_('filters.birthday.thisMonth')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Company Filter -->
|
||||
{#if companies.length > 0}
|
||||
<div class="filter-section">
|
||||
<label class="filter-label">{$_('filters.company')}</label>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={selectedCompany || ''}
|
||||
onchange={(e) => onCompanyChange(e.currentTarget.value || null)}
|
||||
>
|
||||
<option value="">{$_('filters.allCompanies')}</option>
|
||||
{#each companies as company}
|
||||
<option value={company}>{company}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Clear Filters -->
|
||||
{#if activeFilterCount > 0}
|
||||
<button type="button" class="clear-filters-btn" onclick={clearAllFilters}>
|
||||
{$_('filters.clearAll')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Embedded mode styles */
|
||||
.filter-bar-embedded {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-toggle-embedded {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
color: #374151;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
:global(.dark) .filter-toggle-embedded {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.filter-toggle-embedded:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:global(.dark) .filter-toggle-embedded:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.filter-toggle-embedded.active {
|
||||
background: color-mix(in srgb, #3b82f6 15%, transparent 85%);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.filter-toggle-embedded :global(svg) {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filter-badge-embedded {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1rem;
|
||||
height: 1rem;
|
||||
padding: 0 0.25rem;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
background: #3b82f6;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.filter-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
min-width: 280px;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow:
|
||||
0 10px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
:global(.dark) .filter-dropdown {
|
||||
background: rgba(30, 30, 30, 0.95);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Standard mode styles */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,374 @@
|
|||
<script lang="ts">
|
||||
import { networkStore } from '$lib/stores/network.svelte';
|
||||
import { Search, ZoomIn, ZoomOut, RotateCcw, Filter, X } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onResetZoom: () => void;
|
||||
}
|
||||
|
||||
let { onZoomIn, onZoomOut, onResetZoom }: Props = $props();
|
||||
|
||||
let searchInput = $state(networkStore.searchQuery);
|
||||
let showFilters = $state(false);
|
||||
|
||||
function handleSearchInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
searchInput = target.value;
|
||||
networkStore.setSearch(target.value);
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchInput = '';
|
||||
networkStore.setSearch('');
|
||||
}
|
||||
|
||||
function handleTagChange(event: Event) {
|
||||
const target = event.target as HTMLSelectElement;
|
||||
networkStore.setFilterTag(target.value || null);
|
||||
}
|
||||
|
||||
function handleCompanyChange(event: Event) {
|
||||
const target = event.target as HTMLSelectElement;
|
||||
networkStore.setFilterCompany(target.value || null);
|
||||
}
|
||||
|
||||
function clearAllFilters() {
|
||||
searchInput = '';
|
||||
networkStore.clearFilters();
|
||||
}
|
||||
|
||||
const hasActiveFilters = $derived(
|
||||
networkStore.searchQuery || networkStore.filterTagId || networkStore.filterCompany
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="network-controls">
|
||||
<!-- Search bar -->
|
||||
<div class="search-container">
|
||||
<Search size={18} class="search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Kontakt suchen..."
|
||||
value={searchInput}
|
||||
oninput={handleSearchInput}
|
||||
class="search-input"
|
||||
/>
|
||||
{#if searchInput}
|
||||
<button onclick={clearSearch} class="clear-btn" aria-label="Suche löschen">
|
||||
<X size={16} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Filter toggle -->
|
||||
<button
|
||||
onclick={() => (showFilters = !showFilters)}
|
||||
class="control-btn"
|
||||
class:active={showFilters || hasActiveFilters}
|
||||
aria-label="Filter anzeigen"
|
||||
title="Filter"
|
||||
>
|
||||
<Filter size={18} />
|
||||
{#if hasActiveFilters}
|
||||
<span class="filter-badge"></span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Zoom controls -->
|
||||
<div class="zoom-controls">
|
||||
<button onclick={onZoomIn} class="control-btn" aria-label="Vergrößern" title="Vergrößern">
|
||||
<ZoomIn size={18} />
|
||||
</button>
|
||||
<button onclick={onZoomOut} class="control-btn" aria-label="Verkleinern" title="Verkleinern">
|
||||
<ZoomOut size={18} />
|
||||
</button>
|
||||
<button
|
||||
onclick={onResetZoom}
|
||||
class="control-btn"
|
||||
aria-label="Ansicht zurücksetzen"
|
||||
title="Zurücksetzen"
|
||||
>
|
||||
<RotateCcw size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="stats">
|
||||
<span class="stat">
|
||||
{networkStore.nodes.length} Kontakte
|
||||
</span>
|
||||
<span class="stat-divider">•</span>
|
||||
<span class="stat">
|
||||
{networkStore.links.length} Verbindungen
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter panel -->
|
||||
{#if showFilters}
|
||||
<div class="filter-panel">
|
||||
<div class="filter-row">
|
||||
<!-- Tag filter -->
|
||||
<div class="filter-group">
|
||||
<label for="tag-filter" class="filter-label">Tag</label>
|
||||
<select
|
||||
id="tag-filter"
|
||||
onchange={handleTagChange}
|
||||
value={networkStore.filterTagId || ''}
|
||||
class="filter-select"
|
||||
>
|
||||
<option value="">Alle Tags</option>
|
||||
{#each networkStore.uniqueTags as tag}
|
||||
<option value={tag.id}>
|
||||
{tag.name}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Company filter -->
|
||||
<div class="filter-group">
|
||||
<label for="company-filter" class="filter-label">Firma</label>
|
||||
<select
|
||||
id="company-filter"
|
||||
onchange={handleCompanyChange}
|
||||
value={networkStore.filterCompany || ''}
|
||||
class="filter-select"
|
||||
>
|
||||
<option value="">Alle Firmen</option>
|
||||
{#each networkStore.uniqueCompanies as company}
|
||||
<option value={company}>
|
||||
{company}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Clear filters button -->
|
||||
{#if hasActiveFilters}
|
||||
<button onclick={clearAllFilters} class="clear-filters-btn">
|
||||
<X size={14} />
|
||||
Filter löschen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.network-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: hsl(var(--card) / 0.8);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid hsl(var(--border) / 0.5);
|
||||
border-radius: 9999px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.search-container :global(.search-icon) {
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: hsl(var(--muted-foreground));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 2rem 0.5rem 2.5rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 0.875rem;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--primary));
|
||||
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
padding: 0.25rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: pointer;
|
||||
border-radius: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--muted));
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
position: relative;
|
||||
padding: 0.5rem;
|
||||
background: hsl(var(--background));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.5rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.control-btn.active {
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
border-color: hsl(var(--primary));
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.filter-badge {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -2px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: hsl(var(--primary));
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.zoom-controls {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding-left: 0.5rem;
|
||||
border-left: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Filter panel */
|
||||
.filter-panel {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: hsl(var(--card) / 0.8);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid hsl(var(--border) / 0.5);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.clear-filters-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: hsl(var(--destructive) / 0.1);
|
||||
border: 1px solid hsl(var(--destructive) / 0.2);
|
||||
border-radius: 0.5rem;
|
||||
color: hsl(var(--destructive));
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.clear-filters-btn:hover {
|
||||
background: hsl(var(--destructive) / 0.15);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.network-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.zoom-controls {
|
||||
padding-left: 0;
|
||||
border-left: none;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stats {
|
||||
justify-content: center;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,492 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import {
|
||||
networkStore,
|
||||
type SimulationNode,
|
||||
type SimulationLink,
|
||||
} from '$lib/stores/network.svelte';
|
||||
import { zoom, zoomIdentity, type ZoomBehavior } from 'd3-zoom';
|
||||
import { select } from 'd3-selection';
|
||||
|
||||
interface Props {
|
||||
width?: number;
|
||||
height?: number;
|
||||
onNodeClick?: (node: SimulationNode) => void;
|
||||
}
|
||||
|
||||
let { width = 800, height = 600, onNodeClick }: Props = $props();
|
||||
|
||||
let svgElement: SVGSVGElement;
|
||||
let containerElement: HTMLDivElement;
|
||||
let zoomBehavior: ZoomBehavior<SVGSVGElement, unknown> | null = null;
|
||||
let transform = $state({ x: 0, y: 0, k: 1 });
|
||||
let draggedNode: SimulationNode | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let containerWidth = $state(0);
|
||||
let containerHeight = $state(0);
|
||||
let hasInitialized = $state(false);
|
||||
let initTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Initialize simulation ONCE when nodes are loaded AND dimensions are stable
|
||||
function tryInitialize() {
|
||||
const nodeCount = networkStore.allNodes.length;
|
||||
if (!hasInitialized && nodeCount > 0 && containerWidth > 100 && containerHeight > 100) {
|
||||
console.log(
|
||||
'[NetworkGraph] Initializing with dimensions:',
|
||||
containerWidth,
|
||||
'x',
|
||||
containerHeight
|
||||
);
|
||||
hasInitialized = true;
|
||||
networkStore.initSimulation(containerWidth, containerHeight);
|
||||
}
|
||||
}
|
||||
|
||||
// Try to initialize when nodes become available
|
||||
$effect(() => {
|
||||
const nodeCount = networkStore.allNodes.length;
|
||||
if (nodeCount > 0 && containerWidth > 100 && containerHeight > 100) {
|
||||
tryInitialize();
|
||||
}
|
||||
});
|
||||
|
||||
// Get nodes and links (these will update on each tick)
|
||||
const graphNodes = $derived(networkStore.nodes);
|
||||
const graphLinks = $derived(networkStore.links);
|
||||
|
||||
// Setup zoom behavior
|
||||
$effect(() => {
|
||||
if (svgElement) {
|
||||
zoomBehavior = zoom<SVGSVGElement, unknown>()
|
||||
.scaleExtent([0.1, 4])
|
||||
.on('zoom', (event) => {
|
||||
transform = {
|
||||
x: event.transform.x,
|
||||
y: event.transform.y,
|
||||
k: event.transform.k,
|
||||
};
|
||||
});
|
||||
|
||||
select(svgElement).call(zoomBehavior);
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
// Setup resize observer - wait for stable dimensions before initializing
|
||||
if (containerElement) {
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const newWidth = entry.contentRect.width;
|
||||
const newHeight = entry.contentRect.height;
|
||||
|
||||
if (newWidth > 100 && newHeight > 100) {
|
||||
containerWidth = newWidth;
|
||||
containerHeight = newHeight;
|
||||
|
||||
// Debounce initialization to wait for layout to stabilize
|
||||
if (!hasInitialized) {
|
||||
if (initTimeoutId) clearTimeout(initTimeoutId);
|
||||
initTimeoutId = setTimeout(() => {
|
||||
console.log(
|
||||
'[NetworkGraph] Stable dimensions:',
|
||||
containerWidth,
|
||||
'x',
|
||||
containerHeight
|
||||
);
|
||||
tryInitialize();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(containerElement);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (initTimeoutId) clearTimeout(initTimeoutId);
|
||||
networkStore.reset();
|
||||
resizeObserver?.disconnect();
|
||||
});
|
||||
|
||||
function handleNodeClick(node: SimulationNode) {
|
||||
networkStore.selectNode(node.id);
|
||||
onNodeClick?.(node);
|
||||
}
|
||||
|
||||
function handleNodeDoubleClick(node: SimulationNode) {
|
||||
// Navigate to contact detail
|
||||
goto(`/contacts/${node.id}`);
|
||||
}
|
||||
|
||||
function handleDragStart(event: MouseEvent, node: SimulationNode) {
|
||||
event.stopPropagation();
|
||||
draggedNode = node;
|
||||
networkStore.fixNode(node.id, node.x ?? 0, node.y ?? 0);
|
||||
networkStore.reheatSimulation();
|
||||
}
|
||||
|
||||
function handleDrag(event: MouseEvent) {
|
||||
if (!draggedNode) return;
|
||||
|
||||
// Convert screen coordinates to graph coordinates
|
||||
const x = (event.clientX - svgElement.getBoundingClientRect().left - transform.x) / transform.k;
|
||||
const y = (event.clientY - svgElement.getBoundingClientRect().top - transform.y) / transform.k;
|
||||
|
||||
networkStore.fixNode(draggedNode.id, x, y);
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
if (draggedNode) {
|
||||
networkStore.releaseNode(draggedNode.id);
|
||||
draggedNode = null;
|
||||
}
|
||||
}
|
||||
|
||||
function resetZoom() {
|
||||
if (svgElement && zoomBehavior) {
|
||||
select(svgElement).transition().duration(300).call(zoomBehavior.transform, zoomIdentity);
|
||||
}
|
||||
}
|
||||
|
||||
function zoomIn() {
|
||||
if (svgElement && zoomBehavior) {
|
||||
select(svgElement).transition().duration(200).call(zoomBehavior.scaleBy, 1.3);
|
||||
}
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
if (svgElement && zoomBehavior) {
|
||||
select(svgElement).transition().duration(200).call(zoomBehavior.scaleBy, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to get node initials
|
||||
function getInitials(name: string): string {
|
||||
const parts = name.split(' ');
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
}
|
||||
return name.substring(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
// Helper to generate consistent color from string
|
||||
function stringToColor(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const hue = hash % 360;
|
||||
return `hsl(${hue}, 70%, 50%)`;
|
||||
}
|
||||
|
||||
// Get link coordinates
|
||||
function getLinkCoords(link: SimulationLink) {
|
||||
const source = link.source as SimulationNode;
|
||||
const target = link.target as SimulationNode;
|
||||
return {
|
||||
x1: source.x ?? 0,
|
||||
y1: source.y ?? 0,
|
||||
x2: target.x ?? 0,
|
||||
y2: target.y ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if a node is connected to selected node
|
||||
function isConnectedToSelected(nodeId: string, links: typeof graphLinks): boolean {
|
||||
if (!networkStore.selectedNodeId) return false;
|
||||
if (nodeId === networkStore.selectedNodeId) return true;
|
||||
|
||||
return links.some((link) => {
|
||||
const sourceId = typeof link.source === 'string' ? link.source : link.source.id;
|
||||
const targetId = typeof link.target === 'string' ? link.target : link.target.id;
|
||||
return (
|
||||
(sourceId === networkStore.selectedNodeId && targetId === nodeId) ||
|
||||
(targetId === networkStore.selectedNodeId && sourceId === nodeId)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Export zoom functions for parent component
|
||||
export { resetZoom, zoomIn, zoomOut };
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={containerElement}
|
||||
class="network-graph-container"
|
||||
onmousemove={handleDrag}
|
||||
onmouseup={handleDragEnd}
|
||||
onmouseleave={handleDragEnd}
|
||||
role="application"
|
||||
aria-label="Kontakt-Netzwerk Graph"
|
||||
>
|
||||
<svg bind:this={svgElement} class="network-graph-svg" style="width: 100%; height: 100%;">
|
||||
<g transform="translate({transform.x}, {transform.y}) scale({transform.k})">
|
||||
<!-- Links -->
|
||||
<g class="links">
|
||||
{#each graphLinks as link}
|
||||
{@const coords = getLinkCoords(link)}
|
||||
{@const sourceId = typeof link.source === 'string' ? link.source : link.source.id}
|
||||
{@const targetId = typeof link.target === 'string' ? link.target : link.target.id}
|
||||
{@const isHighlighted =
|
||||
networkStore.selectedNodeId &&
|
||||
(sourceId === networkStore.selectedNodeId || targetId === networkStore.selectedNodeId)}
|
||||
<line
|
||||
x1={coords.x1}
|
||||
y1={coords.y1}
|
||||
x2={coords.x2}
|
||||
y2={coords.y2}
|
||||
stroke-width={Math.max(1, link.strength / 25)}
|
||||
class="link"
|
||||
class:highlighted={isHighlighted}
|
||||
class:dimmed={networkStore.selectedNodeId && !isHighlighted}
|
||||
>
|
||||
<title>{link.sharedTags.join(', ')}</title>
|
||||
</line>
|
||||
{/each}
|
||||
</g>
|
||||
|
||||
<!-- Nodes -->
|
||||
<g class="nodes">
|
||||
{#each graphNodes as node (node.id)}
|
||||
{@const isSelected = node.id === networkStore.selectedNodeId}
|
||||
{@const isConnected = isConnectedToSelected(node.id, graphLinks)}
|
||||
{@const isDimmed = networkStore.selectedNodeId && !isConnected}
|
||||
<g
|
||||
transform="translate({node.x ?? 0}, {node.y ?? 0})"
|
||||
class="node"
|
||||
class:selected={isSelected}
|
||||
class:connected={isConnected && !isSelected}
|
||||
class:dimmed={isDimmed}
|
||||
onmousedown={(e) => handleDragStart(e, node)}
|
||||
onclick={() => handleNodeClick(node)}
|
||||
ondblclick={() => handleNodeDoubleClick(node)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={node.name}
|
||||
>
|
||||
<!-- Node circle -->
|
||||
<circle r={isSelected ? 28 : 24} fill={stringToColor(node.name)} class="node-circle" />
|
||||
|
||||
<!-- Avatar image or initials -->
|
||||
{#if node.photoUrl}
|
||||
<clipPath id="clip-{node.id}">
|
||||
<circle r={isSelected ? 26 : 22} />
|
||||
</clipPath>
|
||||
<image
|
||||
href={node.photoUrl}
|
||||
x={isSelected ? -26 : -22}
|
||||
y={isSelected ? -26 : -22}
|
||||
width={isSelected ? 52 : 44}
|
||||
height={isSelected ? 52 : 44}
|
||||
clip-path="url(#clip-{node.id})"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
/>
|
||||
{:else}
|
||||
<text
|
||||
class="node-initials"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="central"
|
||||
fill="white"
|
||||
font-size={isSelected ? 14 : 12}
|
||||
font-weight="600"
|
||||
>
|
||||
{getInitials(node.name)}
|
||||
</text>
|
||||
{/if}
|
||||
|
||||
<!-- Favorite indicator -->
|
||||
{#if node.isFavorite}
|
||||
<circle
|
||||
cx={isSelected ? 20 : 17}
|
||||
cy={isSelected ? -20 : -17}
|
||||
r="8"
|
||||
fill="hsl(var(--background))"
|
||||
/>
|
||||
<text
|
||||
x={isSelected ? 20 : 17}
|
||||
y={isSelected ? -20 : -17}
|
||||
text-anchor="middle"
|
||||
dominant-baseline="central"
|
||||
font-size="10"
|
||||
>
|
||||
⭐
|
||||
</text>
|
||||
{/if}
|
||||
|
||||
<!-- Connection count badge -->
|
||||
{#if node.connectionCount > 0}
|
||||
<circle
|
||||
cx={isSelected ? -20 : -17}
|
||||
cy={isSelected ? -20 : -17}
|
||||
r="10"
|
||||
fill="hsl(var(--primary))"
|
||||
/>
|
||||
<text
|
||||
x={isSelected ? -20 : -17}
|
||||
y={isSelected ? -20 : -17}
|
||||
text-anchor="middle"
|
||||
dominant-baseline="central"
|
||||
fill="white"
|
||||
font-size="9"
|
||||
font-weight="600"
|
||||
>
|
||||
{node.connectionCount}
|
||||
</text>
|
||||
{/if}
|
||||
|
||||
<!-- Node label -->
|
||||
<text
|
||||
y={isSelected ? 42 : 38}
|
||||
class="node-label"
|
||||
text-anchor="middle"
|
||||
font-size={isSelected ? 13 : 11}
|
||||
font-weight={isSelected ? '600' : '500'}
|
||||
>
|
||||
{node.name}
|
||||
</text>
|
||||
|
||||
<!-- Company label -->
|
||||
{#if node.company}
|
||||
<text
|
||||
y={isSelected ? 56 : 50}
|
||||
class="node-company"
|
||||
text-anchor="middle"
|
||||
font-size="9"
|
||||
>
|
||||
{node.company}
|
||||
</text>
|
||||
{/if}
|
||||
</g>
|
||||
{/each}
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- Empty state -->
|
||||
{#if graphNodes.length === 0 && !networkStore.loading}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">🔗</div>
|
||||
<p class="empty-title">Keine Verbindungen gefunden</p>
|
||||
<p class="empty-description">
|
||||
Kontakte werden verbunden, wenn sie gemeinsame Tags haben. Füge Tags zu deinen Kontakten
|
||||
hinzu, um das Netzwerk zu sehen.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.network-graph-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
.network-graph-svg {
|
||||
display: block;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.network-graph-svg:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.link {
|
||||
stroke: hsl(var(--muted-foreground) / 0.3);
|
||||
transition:
|
||||
stroke 0.2s,
|
||||
stroke-width 0.2s,
|
||||
opacity 0.2s;
|
||||
}
|
||||
|
||||
.link.highlighted {
|
||||
stroke: hsl(var(--primary));
|
||||
stroke-width: 3 !important;
|
||||
}
|
||||
|
||||
.link.dimmed {
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
/* Nodes */
|
||||
.node {
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.node:hover .node-circle {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.node.selected .node-circle {
|
||||
stroke: hsl(var(--primary));
|
||||
stroke-width: 4;
|
||||
}
|
||||
|
||||
.node.connected .node-circle {
|
||||
stroke: hsl(var(--primary) / 0.5);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.node.dimmed {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.node-circle {
|
||||
transition:
|
||||
r 0.2s,
|
||||
stroke 0.2s,
|
||||
stroke-width 0.2s,
|
||||
filter 0.2s;
|
||||
}
|
||||
|
||||
.node-initials {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.node-label {
|
||||
fill: hsl(var(--foreground));
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.node-company {
|
||||
fill: hsl(var(--muted-foreground));
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
color: hsl(var(--muted-foreground));
|
||||
max-width: 300px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -102,7 +102,22 @@
|
|||
"country": "Land",
|
||||
"website": "Website",
|
||||
"birthday": "Geburtstag",
|
||||
"notes": "Notizen"
|
||||
"notes": "Notizen",
|
||||
"tasks": {
|
||||
"title": "Aufgaben",
|
||||
"assigned": "Zugewiesen",
|
||||
"involved": "Beteiligt",
|
||||
"empty": "Keine Aufgaben für diesen Kontakt",
|
||||
"serviceUnavailable": "Todo-Service nicht erreichbar",
|
||||
"error": "Fehler beim Laden der Aufgaben",
|
||||
"overdue": "Überfällig",
|
||||
"dueToday": "Heute",
|
||||
"tomorrow": "Morgen",
|
||||
"showCompleted": "Erledigte",
|
||||
"showMore": "{count} weitere anzeigen",
|
||||
"markComplete": "Als erledigt markieren",
|
||||
"markIncomplete": "Als unerledigt markieren"
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"title": "Gruppen",
|
||||
|
|
|
|||
|
|
@ -102,7 +102,22 @@
|
|||
"country": "Country",
|
||||
"website": "Website",
|
||||
"birthday": "Birthday",
|
||||
"notes": "Notes"
|
||||
"notes": "Notes",
|
||||
"tasks": {
|
||||
"title": "Tasks",
|
||||
"assigned": "Assigned",
|
||||
"involved": "Involved",
|
||||
"empty": "No tasks for this contact",
|
||||
"serviceUnavailable": "Todo service unavailable",
|
||||
"error": "Failed to load tasks",
|
||||
"overdue": "Overdue",
|
||||
"dueToday": "Today",
|
||||
"tomorrow": "Tomorrow",
|
||||
"showCompleted": "Completed",
|
||||
"showMore": "Show {count} more",
|
||||
"markComplete": "Mark as complete",
|
||||
"markIncomplete": "Mark as incomplete"
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"title": "Groups",
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
/**
|
||||
* Custom Themes Store - Manages user's custom themes and community themes
|
||||
*/
|
||||
|
||||
import { createCustomThemesStore } from '@manacore/shared-theme';
|
||||
import { authStore } from './auth.svelte';
|
||||
|
||||
// Auth URL for theme API calls
|
||||
const MANA_AUTH_URL = 'http://localhost:3001';
|
||||
|
||||
// Create the custom themes store
|
||||
export const customThemesStore = createCustomThemesStore({
|
||||
authUrl: MANA_AUTH_URL,
|
||||
getAccessToken: () => authStore.getAccessToken(),
|
||||
});
|
||||
209
apps/contacts/apps/web/src/lib/stores/todos.svelte.ts
Normal file
209
apps/contacts/apps/web/src/lib/stores/todos.svelte.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
/**
|
||||
* Todo Store for Contacts App
|
||||
* Manages tasks related to contacts from the Todo service
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import {
|
||||
getTasksByContact,
|
||||
completeTask,
|
||||
uncompleteTask,
|
||||
checkTodoServiceAvailable,
|
||||
type Task,
|
||||
} from '$lib/api/todos';
|
||||
|
||||
// State
|
||||
let tasksByContact = $state<Map<string, Task[]>>(new Map());
|
||||
let loadingContacts = $state<Set<string>>(new Set());
|
||||
let serviceAvailable = $state<boolean | null>(null);
|
||||
let lastAvailabilityCheck = $state<number>(0);
|
||||
|
||||
// Cache TTL in milliseconds (5 minutes)
|
||||
const CACHE_TTL = 5 * 60 * 1000;
|
||||
// Availability check interval (30 seconds)
|
||||
const AVAILABILITY_CHECK_INTERVAL = 30 * 1000;
|
||||
|
||||
// Cache timestamps
|
||||
const cacheTimestamps = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* Check if cached data is still valid
|
||||
*/
|
||||
function isCacheValid(contactId: string): boolean {
|
||||
const timestamp = cacheTimestamps.get(contactId);
|
||||
if (!timestamp) return false;
|
||||
return Date.now() - timestamp < CACHE_TTL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the Todo service is available (with caching)
|
||||
*/
|
||||
async function checkAvailability(): Promise<boolean> {
|
||||
if (!browser) return false;
|
||||
|
||||
const now = Date.now();
|
||||
if (serviceAvailable !== null && now - lastAvailabilityCheck < AVAILABILITY_CHECK_INTERVAL) {
|
||||
return serviceAvailable;
|
||||
}
|
||||
|
||||
const available = await checkTodoServiceAvailable();
|
||||
serviceAvailable = available;
|
||||
lastAvailabilityCheck = now;
|
||||
return available;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load tasks for a specific contact
|
||||
*/
|
||||
async function loadTasksForContact(
|
||||
contactId: string,
|
||||
includeCompleted: boolean = false,
|
||||
forceRefresh: boolean = false
|
||||
): Promise<Task[]> {
|
||||
if (!browser) return [];
|
||||
|
||||
// Check cache first
|
||||
if (!forceRefresh && isCacheValid(contactId)) {
|
||||
return tasksByContact.get(contactId) || [];
|
||||
}
|
||||
|
||||
// Check service availability
|
||||
const available = await checkAvailability();
|
||||
if (!available) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Mark as loading
|
||||
loadingContacts = new Set([...loadingContacts, contactId]);
|
||||
|
||||
try {
|
||||
const { data, error } = await getTasksByContact(contactId, includeCompleted);
|
||||
|
||||
if (error) {
|
||||
console.error(`Failed to load tasks for contact ${contactId}:`, error);
|
||||
return [];
|
||||
}
|
||||
|
||||
const tasks = data || [];
|
||||
|
||||
// Update cache
|
||||
tasksByContact = new Map(tasksByContact).set(contactId, tasks);
|
||||
cacheTimestamps.set(contactId, Date.now());
|
||||
|
||||
return tasks;
|
||||
} finally {
|
||||
// Remove from loading set
|
||||
const newLoading = new Set(loadingContacts);
|
||||
newLoading.delete(contactId);
|
||||
loadingContacts = newLoading;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached tasks for a contact (does not fetch)
|
||||
*/
|
||||
function getTasksForContact(contactId: string): Task[] {
|
||||
return tasksByContact.get(contactId) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tasks are currently loading for a contact
|
||||
*/
|
||||
function isLoading(contactId: string): boolean {
|
||||
return loadingContacts.has(contactId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle task completion
|
||||
*/
|
||||
async function toggleTaskCompletion(taskId: string, contactId: string): Promise<boolean> {
|
||||
const tasks = tasksByContact.get(contactId) || [];
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
|
||||
if (!task) return false;
|
||||
|
||||
const { data, error } = task.isCompleted
|
||||
? await uncompleteTask(taskId)
|
||||
: await completeTask(taskId);
|
||||
|
||||
if (error || !data) {
|
||||
console.error('Failed to toggle task completion:', error);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update local state
|
||||
const updatedTasks = tasks.map((t) => (t.id === taskId ? data : t));
|
||||
tasksByContact = new Map(tasksByContact).set(contactId, updatedTasks);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache for a specific contact
|
||||
*/
|
||||
function clearCacheForContact(contactId: string): void {
|
||||
const newMap = new Map(tasksByContact);
|
||||
newMap.delete(contactId);
|
||||
tasksByContact = newMap;
|
||||
cacheTimestamps.delete(contactId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached data
|
||||
*/
|
||||
function clearCache(): void {
|
||||
tasksByContact = new Map();
|
||||
cacheTimestamps.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorize tasks by their relation to the contact
|
||||
*/
|
||||
function categorizeTasksForContact(contactId: string): { assigned: Task[]; involved: Task[] } {
|
||||
const tasks = tasksByContact.get(contactId) || [];
|
||||
|
||||
const assigned: Task[] = [];
|
||||
const involved: Task[] = [];
|
||||
|
||||
for (const task of tasks) {
|
||||
const isAssignee = task.metadata?.assignee?.contactId === contactId;
|
||||
const isInvolved = task.metadata?.involvedContacts?.some((c) => c.contactId === contactId);
|
||||
|
||||
if (isAssignee) {
|
||||
assigned.push(task);
|
||||
} else if (isInvolved) {
|
||||
involved.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by due date (overdue first, then by date)
|
||||
const sortByDueDate = (a: Task, b: Task): number => {
|
||||
if (!a.dueDate && !b.dueDate) return 0;
|
||||
if (!a.dueDate) return 1;
|
||||
if (!b.dueDate) return -1;
|
||||
return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime();
|
||||
};
|
||||
|
||||
assigned.sort(sortByDueDate);
|
||||
involved.sort(sortByDueDate);
|
||||
|
||||
return { assigned, involved };
|
||||
}
|
||||
|
||||
// Export store
|
||||
export const todosStore = {
|
||||
// Getters (reactive)
|
||||
get serviceAvailable() {
|
||||
return serviceAvailable;
|
||||
},
|
||||
|
||||
// Methods
|
||||
checkAvailability,
|
||||
loadTasksForContact,
|
||||
getTasksForContact,
|
||||
isLoading,
|
||||
toggleTaskCompletion,
|
||||
clearCacheForContact,
|
||||
clearCache,
|
||||
categorizeTasksForContact,
|
||||
};
|
||||
|
|
@ -3,11 +3,16 @@
|
|||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { PillNavigation, CommandBar } from '@manacore/shared-ui';
|
||||
import { PillNavigation, QuickInputBar } from '@manacore/shared-ui';
|
||||
import {
|
||||
SplitPaneContainer,
|
||||
setSplitPanelContext,
|
||||
DEFAULT_APPS,
|
||||
} from '@manacore/shared-splitscreen';
|
||||
import type {
|
||||
PillNavItem,
|
||||
PillDropdownItem,
|
||||
CommandBarItem,
|
||||
QuickInputItem,
|
||||
QuickAction,
|
||||
CreatePreview,
|
||||
} from '@manacore/shared-ui';
|
||||
|
|
@ -39,9 +44,6 @@
|
|||
formatParsedContactPreview,
|
||||
} from '$lib/utils/contact-parser';
|
||||
|
||||
// Search modal state
|
||||
let searchModalOpen = $state(false);
|
||||
|
||||
// Tags state for Quick-Create
|
||||
let availableTags = $state<{ id: string; name: string }[]>([]);
|
||||
|
||||
|
|
@ -53,6 +55,14 @@
|
|||
// App switcher items
|
||||
const appItems = getPillAppItems('contacts');
|
||||
|
||||
// Split-Panel Store für Split-Screen Feature
|
||||
const splitPanel = setSplitPanelContext('contacts', DEFAULT_APPS);
|
||||
|
||||
// Handler für Split-Screen Panel-Öffnung
|
||||
function handleOpenInPanel(appId: string, url: string) {
|
||||
splitPanel.openPanel(appId);
|
||||
}
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let isSidebarMode = $state(false);
|
||||
|
|
@ -130,13 +140,6 @@
|
|||
function handleKeydown(event: KeyboardEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// Cmd/Ctrl+K to open search (works even in inputs)
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
|
||||
event.preventDefault();
|
||||
searchModalOpen = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -188,8 +191,8 @@
|
|||
goto('/', { replaceState: false });
|
||||
}
|
||||
|
||||
// CommandBar search function
|
||||
async function handleCommandBarSearch(query: string): Promise<CommandBarItem[]> {
|
||||
// QuickInputBar search function
|
||||
async function handleSearch(query: string): Promise<QuickInputItem[]> {
|
||||
const response = await contactsApi.list({ search: query, limit: 10 });
|
||||
return (response.contacts || []).map((contact: any) => ({
|
||||
id: contact.id,
|
||||
|
|
@ -204,25 +207,25 @@
|
|||
}));
|
||||
}
|
||||
|
||||
// CommandBar item selection
|
||||
function handleCommandBarSelect(item: CommandBarItem) {
|
||||
// QuickInputBar item selection
|
||||
function handleSelect(item: QuickInputItem) {
|
||||
goto(`/contacts/${item.id}`);
|
||||
}
|
||||
|
||||
// CommandBar Quick-Create handlers
|
||||
function handleCommandBarParseCreate(query: string): CreatePreview | null {
|
||||
// QuickInputBar Quick-Create handlers
|
||||
function handleParseCreate(query: string): CreatePreview | null {
|
||||
if (!query.trim()) return null;
|
||||
|
||||
const parsed = parseContactInput(query);
|
||||
if (!parsed.displayName) return null;
|
||||
|
||||
return {
|
||||
title: parsed.displayName,
|
||||
title: `"${parsed.displayName}" erstellen`,
|
||||
subtitle: formatParsedContactPreview(parsed),
|
||||
};
|
||||
}
|
||||
|
||||
async function handleCommandBarCreate(query: string): Promise<void> {
|
||||
async function handleCreate(query: string): Promise<void> {
|
||||
const parsed = parseContactInput(query);
|
||||
if (!parsed.displayName) return;
|
||||
|
||||
|
|
@ -250,18 +253,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
// CommandBar quick actions
|
||||
const commandBarQuickActions: QuickAction[] = [
|
||||
{
|
||||
id: 'new',
|
||||
label: 'Neuen Kontakt erstellen',
|
||||
icon: 'plus',
|
||||
href: '/contacts/new',
|
||||
shortcut: 'N',
|
||||
},
|
||||
{ id: 'favorites', label: 'Favoriten anzeigen', icon: 'heart', href: '/favorites' },
|
||||
{ id: 'tags', label: 'Tags verwalten', icon: 'tag', href: '/tags' },
|
||||
{ id: 'import', label: 'Kontakte importieren', icon: 'upload', href: '/data?tab=import' },
|
||||
// QuickInputBar quick actions
|
||||
const quickActions: QuickAction[] = [
|
||||
{ id: 'favorites', label: 'Favoriten', icon: 'heart', href: '/favorites' },
|
||||
{ id: 'tags', label: 'Tags', icon: 'tag', href: '/tags' },
|
||||
{ id: 'settings', label: 'Einstellungen', icon: 'settings', href: '/settings' },
|
||||
];
|
||||
|
||||
onMount(async () => {
|
||||
|
|
@ -271,6 +267,9 @@
|
|||
return;
|
||||
}
|
||||
|
||||
// Initialize split-panel from URL/localStorage
|
||||
splitPanel.initialize();
|
||||
|
||||
// Load user settings and tags
|
||||
await userSettings.load();
|
||||
|
||||
|
|
@ -304,78 +303,81 @@
|
|||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- Navigation Layout -->
|
||||
<div class="layout-container">
|
||||
<!-- Shadow gradient above navigation -->
|
||||
<div class="nav-shadow-gradient"></div>
|
||||
<SplitPaneContainer>
|
||||
<!-- Navigation Layout -->
|
||||
<div class="layout-container">
|
||||
<!-- Shadow gradient above navigation -->
|
||||
<div class="nav-shadow-gradient"></div>
|
||||
|
||||
<!-- Floating/Sidebar Pill Navigation -->
|
||||
<PillNavigation
|
||||
items={navItems}
|
||||
currentPath={$page.url.pathname}
|
||||
appName="Contacts"
|
||||
homeRoute="/"
|
||||
onToggleTheme={handleToggleTheme}
|
||||
{isDark}
|
||||
{isSidebarMode}
|
||||
onModeChange={handleModeChange}
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
desktopPosition={userSettings.nav.desktopPosition}
|
||||
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"
|
||||
/>
|
||||
<!-- Floating/Sidebar Pill Navigation -->
|
||||
<PillNavigation
|
||||
items={navItems}
|
||||
currentPath={$page.url.pathname}
|
||||
appName="Contacts"
|
||||
homeRoute="/"
|
||||
onToggleTheme={handleToggleTheme}
|
||||
{isDark}
|
||||
{isSidebarMode}
|
||||
onModeChange={handleModeChange}
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
desktopPosition={userSettings.nav.desktopPosition}
|
||||
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}
|
||||
/>
|
||||
|
||||
<!-- Main Content with dynamic padding based on nav mode -->
|
||||
<main
|
||||
class="main-content bg-background"
|
||||
class:sidebar-mode={isSidebarMode && !isCollapsed}
|
||||
class:floating-mode={!isSidebarMode}
|
||||
>
|
||||
<div class="content-wrapper">
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
<!-- Main Content with dynamic padding based on nav mode -->
|
||||
<main
|
||||
class="main-content bg-background"
|
||||
class:sidebar-mode={isSidebarMode && !isCollapsed}
|
||||
class:floating-mode={!isSidebarMode}
|
||||
>
|
||||
<div class="content-wrapper">
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Contact Detail Modal -->
|
||||
{#if showContactModal && modalContactId}
|
||||
<ContactDetailModal contactId={modalContactId} onClose={handleCloseContactModal} />
|
||||
{/if}
|
||||
<!-- Contact Detail Modal -->
|
||||
{#if showContactModal && modalContactId}
|
||||
<ContactDetailModal contactId={modalContactId} onClose={handleCloseContactModal} />
|
||||
{/if}
|
||||
|
||||
<!-- Global Search Modal (Cmd/K) -->
|
||||
<CommandBar
|
||||
bind:open={searchModalOpen}
|
||||
onClose={() => (searchModalOpen = false)}
|
||||
onSearch={handleCommandBarSearch}
|
||||
onSelect={handleCommandBarSelect}
|
||||
quickActions={commandBarQuickActions}
|
||||
placeholder="Kontakt suchen oder erstellen..."
|
||||
emptyText="Keine Kontakte gefunden"
|
||||
searchingText="Suche..."
|
||||
onCreate={handleCommandBarCreate}
|
||||
onParseCreate={handleCommandBarParseCreate}
|
||||
createText="Als Kontakt erstellen"
|
||||
createShortcut="⌘↵"
|
||||
/>
|
||||
</div>
|
||||
<!-- Global Quick Input Bar -->
|
||||
<QuickInputBar
|
||||
onSearch={handleSearch}
|
||||
onSelect={handleSelect}
|
||||
{quickActions}
|
||||
placeholder="Neuer Kontakt oder suchen..."
|
||||
emptyText="Keine Kontakte gefunden"
|
||||
searchingText="Suche..."
|
||||
onCreate={handleCreate}
|
||||
onParseCreate={handleParseCreate}
|
||||
createText="Erstellen"
|
||||
appIcon="contacts"
|
||||
primaryColor="#3b82f6"
|
||||
autoFocus={false}
|
||||
/>
|
||||
</div>
|
||||
</SplitPaneContainer>
|
||||
|
||||
<style>
|
||||
.layout-container {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
import { goto } from '$app/navigation';
|
||||
import { ThemePage } from '@manacore/shared-theme-ui';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { customThemesStore } from '$lib/stores/custom-themes.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -17,9 +16,4 @@
|
|||
onModeChange={(m) => theme.setMode(m)}
|
||||
showBackButton={true}
|
||||
onBack={() => goto('/')}
|
||||
showCustomThemes={true}
|
||||
{customThemesStore}
|
||||
onCreateTheme={() => goto('/themes/editor')}
|
||||
onEditTheme={(t) => goto(`/themes/editor?id=${t.id}`)}
|
||||
onCommunityThemes={() => goto('/themes/community')}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { CommunityThemesPage } from '@manacore/shared-theme-ui';
|
||||
import { customThemesStore } from '$lib/stores/custom-themes.svelte';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
|
||||
// Get effective mode from theme store
|
||||
let effectiveMode = $derived(
|
||||
theme.mode === 'system'
|
||||
? typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light'
|
||||
: theme.mode
|
||||
) as 'light' | 'dark';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Community Themes | Contacts</title>
|
||||
</svelte:head>
|
||||
|
||||
<CommunityThemesPage
|
||||
store={customThemesStore}
|
||||
{effectiveMode}
|
||||
onBack={() => goto('/themes')}
|
||||
onSelectTheme={(t) => {
|
||||
// Could open a detail modal here
|
||||
console.log('Selected theme:', t);
|
||||
}}
|
||||
/>
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { ThemeEditorPage } from '@manacore/shared-theme-ui';
|
||||
import { customThemesStore } from '$lib/stores/custom-themes.svelte';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { onMount } from 'svelte';
|
||||
import type { CustomTheme } from '@manacore/shared-theme';
|
||||
|
||||
// Get theme ID from URL if editing
|
||||
let themeId = $derived($page.url.searchParams.get('id'));
|
||||
let editingTheme = $state<CustomTheme | undefined>(undefined);
|
||||
|
||||
// Load theme data if editing
|
||||
onMount(async () => {
|
||||
if (themeId) {
|
||||
await customThemesStore.loadCustomThemes();
|
||||
editingTheme = customThemesStore.customThemes.find((t) => t.id === themeId);
|
||||
}
|
||||
});
|
||||
|
||||
// Get effective mode from theme store
|
||||
let effectiveMode = $derived(
|
||||
theme.mode === 'system'
|
||||
? typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light'
|
||||
: theme.mode
|
||||
) as 'light' | 'dark';
|
||||
|
||||
async function handleSave(themeData: {
|
||||
name: string;
|
||||
description?: string;
|
||||
emoji: string;
|
||||
lightColors: any;
|
||||
darkColors: any;
|
||||
}) {
|
||||
if (themeId && editingTheme) {
|
||||
await customThemesStore.updateTheme(themeId, themeData);
|
||||
} else {
|
||||
await customThemesStore.createTheme(themeData);
|
||||
}
|
||||
goto('/themes');
|
||||
}
|
||||
|
||||
async function handlePublish(themeData: {
|
||||
name: string;
|
||||
description?: string;
|
||||
emoji: string;
|
||||
lightColors: any;
|
||||
darkColors: any;
|
||||
tags?: string[];
|
||||
}) {
|
||||
let theme: CustomTheme;
|
||||
if (themeId && editingTheme) {
|
||||
theme = await customThemesStore.updateTheme(themeId, themeData);
|
||||
} else {
|
||||
theme = await customThemesStore.createTheme(themeData);
|
||||
}
|
||||
await customThemesStore.publishTheme(theme.id, { tags: themeData.tags });
|
||||
goto('/themes');
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{themeId ? 'Theme bearbeiten' : 'Neues Theme'} | Contacts</title>
|
||||
</svelte:head>
|
||||
|
||||
<ThemeEditorPage
|
||||
{effectiveMode}
|
||||
existingTheme={editingTheme}
|
||||
onBack={() => goto('/themes')}
|
||||
onSave={handleSave}
|
||||
onPublish={handlePublish}
|
||||
/>
|
||||
|
|
@ -57,6 +57,12 @@ export const tasks = pgTable(
|
|||
dueTime: varchar('due_time', { length: 5 }),
|
||||
startDate: timestamp('start_date', { withTimezone: true }),
|
||||
|
||||
// Time-Blocking (for calendar integration)
|
||||
scheduledDate: timestamp('scheduled_date', { withTimezone: true }),
|
||||
scheduledStartTime: varchar('scheduled_start_time', { length: 5 }), // HH:mm
|
||||
scheduledEndTime: varchar('scheduled_end_time', { length: 5 }), // HH:mm
|
||||
estimatedDuration: integer('estimated_duration'), // in minutes
|
||||
|
||||
// Priority & Status
|
||||
priority: varchar('priority', { length: 10 }).default('medium').$type<TaskPriority>(),
|
||||
status: varchar('status', { length: 20 }).default('pending').$type<TaskStatus>(),
|
||||
|
|
@ -90,6 +96,7 @@ export const tasks = pgTable(
|
|||
projectIdx: index('tasks_project_idx').on(table.projectId),
|
||||
userIdx: index('tasks_user_idx').on(table.userId),
|
||||
dueDateIdx: index('tasks_due_date_idx').on(table.dueDate),
|
||||
scheduledDateIdx: index('tasks_scheduled_date_idx').on(table.scheduledDate),
|
||||
statusIdx: index('tasks_status_idx').on(table.isCompleted, table.status),
|
||||
parentIdx: index('tasks_parent_idx').on(table.parentTaskId),
|
||||
orderIdx: index('tasks_order_idx').on(table.projectId, table.order),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@ import {
|
|||
IsDateString,
|
||||
IsNotEmpty,
|
||||
ValidateNested,
|
||||
IsInt,
|
||||
Min,
|
||||
Max,
|
||||
Matches,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import type { TaskPriority } from '../../db/schema/tasks.schema';
|
||||
|
|
@ -47,6 +51,27 @@ export class CreateTaskDto {
|
|||
@IsDateString()
|
||||
startDate?: string | null;
|
||||
|
||||
// Time-Blocking fields
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
scheduledDate?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^([01]\d|2[0-3]):([0-5]\d)$/, { message: 'scheduledStartTime must be in HH:mm format' })
|
||||
scheduledStartTime?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^([01]\d|2[0-3]):([0-5]\d)$/, { message: 'scheduledEndTime must be in HH:mm format' })
|
||||
scheduledEndTime?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(1440) // Max 24 hours in minutes
|
||||
estimatedDuration?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['low', 'medium', 'high', 'urgent'])
|
||||
priority?: TaskPriority;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@ import {
|
|||
IsObject,
|
||||
MaxLength,
|
||||
IsDateString,
|
||||
IsInt,
|
||||
Min,
|
||||
Max,
|
||||
Matches,
|
||||
} from 'class-validator';
|
||||
import type { TaskPriority, TaskStatus, Subtask, TaskMetadata } from '../../db/schema/tasks.schema';
|
||||
|
||||
|
|
@ -43,6 +47,27 @@ export class UpdateTaskDto {
|
|||
@IsDateString()
|
||||
startDate?: string | null;
|
||||
|
||||
// Time-Blocking fields
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
scheduledDate?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^([01]\d|2[0-3]):([0-5]\d)$/, { message: 'scheduledStartTime must be in HH:mm format' })
|
||||
scheduledStartTime?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^([01]\d|2[0-3]):([0-5]\d)$/, { message: 'scheduledEndTime must be in HH:mm format' })
|
||||
scheduledEndTime?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(1440) // Max 24 hours in minutes
|
||||
estimatedDuration?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['low', 'medium', 'high', 'urgent'])
|
||||
priority?: TaskPriority;
|
||||
|
|
@ -74,4 +99,9 @@ export class UpdateTaskDto {
|
|||
@IsOptional()
|
||||
@IsObject()
|
||||
metadata?: TaskMetadata | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
labelIds?: string[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,20 @@ export class TaskController {
|
|||
return result;
|
||||
}
|
||||
|
||||
@Get('by-contact/:contactId')
|
||||
async getByContact(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('contactId') contactId: string,
|
||||
@Query('includeCompleted') includeCompleted?: string
|
||||
) {
|
||||
const tasks = await this.taskService.findByContact(
|
||||
user.userId,
|
||||
contactId,
|
||||
includeCompleted === 'true'
|
||||
);
|
||||
return { tasks };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
const task = await this.taskService.findByIdOrThrow(id, user.userId);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and, or, gte, lte, ilike, asc, desc, isNull, SQL, sql } from 'drizzle-orm';
|
||||
import { eq, and, or, gte, lte, ilike, asc, desc, isNull, SQL, sql, inArray } from 'drizzle-orm';
|
||||
import { RRule, RRuleSet, rrulestr } from 'rrule';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
|
|
@ -125,6 +125,11 @@ export class TaskService {
|
|||
dueDate: dto.dueDate ? new Date(dto.dueDate) : null,
|
||||
dueTime: dto.dueTime,
|
||||
startDate: dto.startDate ? new Date(dto.startDate) : null,
|
||||
// Time-Blocking fields
|
||||
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
|
||||
scheduledStartTime: dto.scheduledStartTime,
|
||||
scheduledEndTime: dto.scheduledEndTime,
|
||||
estimatedDuration: dto.estimatedDuration,
|
||||
priority: dto.priority ?? 'medium',
|
||||
recurrenceRule: dto.recurrenceRule,
|
||||
recurrenceEndDate: dto.recurrenceEndDate ? new Date(dto.recurrenceEndDate) : null,
|
||||
|
|
@ -151,14 +156,23 @@ export class TaskService {
|
|||
await this.projectService.findByIdOrThrow(dto.projectId, userId);
|
||||
}
|
||||
|
||||
// Extract labelIds before spreading dto (it's not a db column)
|
||||
const { labelIds, ...dtoWithoutLabels } = dto;
|
||||
|
||||
const updateData: Partial<NewTask> = {
|
||||
...dto,
|
||||
...dtoWithoutLabels,
|
||||
dueDate: dto.dueDate ? new Date(dto.dueDate) : dto.dueDate === null ? null : undefined,
|
||||
startDate: dto.startDate
|
||||
? new Date(dto.startDate)
|
||||
: dto.startDate === null
|
||||
? null
|
||||
: undefined,
|
||||
// Time-Blocking fields
|
||||
scheduledDate: dto.scheduledDate
|
||||
? new Date(dto.scheduledDate)
|
||||
: dto.scheduledDate === null
|
||||
? null
|
||||
: undefined,
|
||||
recurrenceEndDate: dto.recurrenceEndDate
|
||||
? new Date(dto.recurrenceEndDate)
|
||||
: dto.recurrenceEndDate === null
|
||||
|
|
@ -181,6 +195,11 @@ export class TaskService {
|
|||
.where(and(eq(tasks.id, id), eq(tasks.userId, userId)))
|
||||
.returning();
|
||||
|
||||
// Update labels if provided
|
||||
if (labelIds !== undefined) {
|
||||
await this.updateTaskLabels(id, userId, labelIds);
|
||||
}
|
||||
|
||||
return this.loadTaskLabels(updated);
|
||||
}
|
||||
|
||||
|
|
@ -411,6 +430,41 @@ export class TaskService {
|
|||
return this.findAll(userId, { isCompleted: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all tasks where the given contact is either the assignee or involved.
|
||||
* Searches in metadata->assignee->contactId and metadata->involvedContacts array.
|
||||
*/
|
||||
async findByContact(
|
||||
userId: string,
|
||||
contactId: string,
|
||||
includeCompleted: boolean = false
|
||||
): Promise<TaskWithLabels[]> {
|
||||
// Build conditions for the query
|
||||
const conditions: SQL[] = [eq(tasks.userId, userId)];
|
||||
|
||||
// Optionally exclude completed tasks
|
||||
if (!includeCompleted) {
|
||||
conditions.push(eq(tasks.isCompleted, false));
|
||||
}
|
||||
|
||||
// Search for contactId in metadata->assignee->contactId OR in metadata->involvedContacts array
|
||||
const contactCondition = or(
|
||||
// Check if assignee.contactId matches
|
||||
sql`${tasks.metadata}->>'assignee' IS NOT NULL AND ${tasks.metadata}->'assignee'->>'contactId' = ${contactId}`,
|
||||
// Check if contactId exists in involvedContacts array
|
||||
sql`${tasks.metadata}->'involvedContacts' @> ${JSON.stringify([{ contactId }])}::jsonb`
|
||||
);
|
||||
|
||||
conditions.push(contactCondition as SQL);
|
||||
|
||||
const result = await this.db.query.tasks.findMany({
|
||||
where: and(...conditions),
|
||||
orderBy: [asc(tasks.dueDate), asc(tasks.order)],
|
||||
});
|
||||
|
||||
return this.loadTaskLabelsBatch(result);
|
||||
}
|
||||
|
||||
async getTodayTasks(userId: string): Promise<TaskWithLabels[]> {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
|
@ -434,10 +488,13 @@ export class TaskService {
|
|||
}
|
||||
|
||||
async getUpcomingTasks(userId: string, days: number = 7): Promise<TaskWithLabels[]> {
|
||||
// Ensure days is a valid number
|
||||
const daysNum = typeof days === 'number' && !isNaN(days) ? days : 7;
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const endDate = new Date(today);
|
||||
endDate.setDate(endDate.getDate() + days);
|
||||
const endDate = new Date(today.getTime());
|
||||
endDate.setDate(endDate.getDate() + daysNum);
|
||||
|
||||
const result = await this.db.query.tasks.findMany({
|
||||
where: and(
|
||||
|
|
@ -525,10 +582,11 @@ export class TaskService {
|
|||
|
||||
const taskIds = taskList.map((t) => t.id);
|
||||
|
||||
// Single query to get all task-label relationships
|
||||
const allTaskLabels = await this.db.query.taskLabels.findMany({
|
||||
where: or(...taskIds.map((id) => eq(taskLabels.taskId, id))),
|
||||
});
|
||||
// Single query to get all task-label relationships using inArray
|
||||
const allTaskLabels = await this.db
|
||||
.select()
|
||||
.from(taskLabels)
|
||||
.where(inArray(taskLabels.taskId, taskIds));
|
||||
|
||||
if (allTaskLabels.length === 0) {
|
||||
// No labels for any task - return tasks with empty labels array
|
||||
|
|
@ -538,10 +596,8 @@ export class TaskService {
|
|||
// Get unique label IDs
|
||||
const uniqueLabelIds = [...new Set(allTaskLabels.map((tl) => tl.labelId))];
|
||||
|
||||
// Single query to get all labels
|
||||
const allLabels = await this.db.query.labels.findMany({
|
||||
where: or(...uniqueLabelIds.map((id) => eq(labels.id, id))),
|
||||
});
|
||||
// Single query to get all labels using inArray
|
||||
const allLabels = await this.db.select().from(labels).where(inArray(labels.id, uniqueLabelIds));
|
||||
|
||||
// Create a map of labelId -> label for fast lookup
|
||||
const labelMap = new Map(allLabels.map((l) => [l.id, l]));
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-splitscreen": "workspace:*",
|
||||
"@manacore/shared-types": "workspace:*",
|
||||
"@manacore/shared-utils": "workspace:*",
|
||||
"@manacore/shared-tags": "workspace:*",
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -14,13 +14,21 @@ interface CreateTaskDto {
|
|||
|
||||
interface UpdateTaskDto {
|
||||
title?: string;
|
||||
description?: string;
|
||||
description?: string | null;
|
||||
projectId?: string | null;
|
||||
parentTaskId?: string | null;
|
||||
dueDate?: string | null;
|
||||
dueTime?: string | null;
|
||||
startDate?: string | null;
|
||||
priority?: TaskPriority;
|
||||
status?: TaskStatus;
|
||||
subtasks?: Subtask[];
|
||||
isCompleted?: boolean;
|
||||
order?: number;
|
||||
subtasks?: Subtask[] | null;
|
||||
recurrenceRule?: string | null;
|
||||
recurrenceEndDate?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
labelIds?: string[];
|
||||
}
|
||||
|
||||
interface TaskQuery {
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@
|
|||
EffectiveDuration,
|
||||
UpdateTaskInput,
|
||||
} from '@todo/shared';
|
||||
import type { ContactReference, ContactOrManual } from '@manacore/shared-types';
|
||||
import { STATUS_OPTIONS, RECURRENCE_OPTIONS } from '@todo/shared';
|
||||
import { projectsStore } from '$lib/stores/projects.svelte';
|
||||
import { contactsStore } from '$lib/stores/contacts.svelte';
|
||||
import { format } from 'date-fns';
|
||||
import SubtaskList from './SubtaskList.svelte';
|
||||
import {
|
||||
|
|
@ -18,6 +20,7 @@
|
|||
FunRatingPicker,
|
||||
TagSelector,
|
||||
} from './form';
|
||||
import { ContactSelector } from '@manacore/shared-ui';
|
||||
|
||||
interface Props {
|
||||
task: Task;
|
||||
|
|
@ -45,6 +48,10 @@
|
|||
let storyPoints = $state<number | null>(null);
|
||||
let effectiveDuration = $state<EffectiveDuration | null>(null);
|
||||
let funRating = $state<number | null>(null);
|
||||
// Contact associations
|
||||
let assignee = $state<ContactOrManual[]>([]);
|
||||
let involvedContacts = $state<ContactOrManual[]>([]);
|
||||
let contactsAvailable = $state<boolean | null>(null);
|
||||
|
||||
// UI state
|
||||
let isLoading = $state(false);
|
||||
|
|
@ -69,7 +76,15 @@
|
|||
storyPoints = task.metadata?.storyPoints ?? null;
|
||||
effectiveDuration = task.metadata?.effectiveDuration ?? null;
|
||||
funRating = task.metadata?.funRating ?? null;
|
||||
// Contact associations
|
||||
assignee = task.metadata?.assignee ? [task.metadata.assignee] : [];
|
||||
involvedContacts = task.metadata?.involvedContacts || [];
|
||||
showDeleteConfirm = false;
|
||||
|
||||
// Check contacts availability
|
||||
contactsStore.checkAvailability().then((available) => {
|
||||
contactsAvailable = available;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -88,11 +103,26 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Extract ContactReference from ContactOrManual (filter out manual entries for now)
|
||||
function toContactReference(contact: ContactOrManual): ContactReference | null {
|
||||
if ('isManual' in contact && contact.isManual) {
|
||||
return null; // Manual entries not stored as contacts
|
||||
}
|
||||
return contact as ContactReference;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!title.trim()) return;
|
||||
|
||||
isLoading = true;
|
||||
try {
|
||||
// Convert assignee array to single ContactReference
|
||||
const assigneeRef = assignee.length > 0 ? toContactReference(assignee[0]) : null;
|
||||
// Convert involved contacts to array of ContactReferences
|
||||
const involvedRefs = involvedContacts
|
||||
.map(toContactReference)
|
||||
.filter((c): c is ContactReference => c !== null);
|
||||
|
||||
const data: UpdateTaskInput = {
|
||||
title: title.trim(),
|
||||
description: description.trim() || null,
|
||||
|
|
@ -110,6 +140,8 @@
|
|||
storyPoints: storyPoints ?? undefined,
|
||||
effectiveDuration: effectiveDuration ?? undefined,
|
||||
funRating: funRating ?? undefined,
|
||||
assignee: assigneeRef ?? undefined,
|
||||
involvedContacts: involvedRefs.length > 0 ? involvedRefs : undefined,
|
||||
},
|
||||
labelIds: selectedLabelIds,
|
||||
};
|
||||
|
|
@ -179,6 +211,37 @@
|
|||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Zuständige Person -->
|
||||
<div class="form-section">
|
||||
<label class="form-label">Zuständig</label>
|
||||
<ContactSelector
|
||||
selectedContacts={assignee}
|
||||
onContactsChange={(contacts) => (assignee = contacts)}
|
||||
onSearch={(q) => contactsStore.searchContacts(q)}
|
||||
singleSelect={true}
|
||||
allowManualEntry={false}
|
||||
placeholder="Person zuweisen..."
|
||||
addLabel="Zuweisen"
|
||||
searchPlaceholder="Name oder E-Mail..."
|
||||
isAvailable={contactsAvailable ?? false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Beteiligte Personen -->
|
||||
<div class="form-section">
|
||||
<label class="form-label">Beteiligte</label>
|
||||
<ContactSelector
|
||||
selectedContacts={involvedContacts}
|
||||
onContactsChange={(contacts) => (involvedContacts = contacts)}
|
||||
onSearch={(q) => contactsStore.searchContacts(q)}
|
||||
allowManualEntry={false}
|
||||
placeholder="Personen hinzufügen..."
|
||||
addLabel="Person hinzufügen"
|
||||
searchPlaceholder="Name oder E-Mail..."
|
||||
isAvailable={contactsAvailable ?? false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Zeitplanung -->
|
||||
<div class="form-section">
|
||||
<label class="form-label">Zeitplanung</label>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { format, isToday, isPast, isTomorrow } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import { projectsStore } from '$lib/stores/projects.svelte';
|
||||
import { ContactAvatar } from '@manacore/shared-ui';
|
||||
|
||||
interface Props {
|
||||
task: Task;
|
||||
|
|
@ -165,6 +166,33 @@
|
|||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Assignee and involved contacts -->
|
||||
{#if task.metadata?.assignee || (task.metadata?.involvedContacts && task.metadata.involvedContacts.length > 0)}
|
||||
<div class="contacts-display">
|
||||
{#if task.metadata?.assignee}
|
||||
<div class="assignee-avatar" title="Zuständig: {task.metadata.assignee.displayName}">
|
||||
<ContactAvatar
|
||||
name={task.metadata.assignee.displayName}
|
||||
photoUrl={task.metadata.assignee.photoUrl}
|
||||
size="xs"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if task.metadata?.involvedContacts && task.metadata.involvedContacts.length > 0}
|
||||
<div class="involved-avatars">
|
||||
{#each task.metadata.involvedContacts.slice(0, 2) as contact}
|
||||
<div class="involved-avatar" title="Beteiligt: {contact.displayName}">
|
||||
<ContactAvatar name={contact.displayName} photoUrl={contact.photoUrl} size="xs" />
|
||||
</div>
|
||||
{/each}
|
||||
{#if task.metadata.involvedContacts.length > 2}
|
||||
<span class="more-contacts">+{task.metadata.involvedContacts.length - 2}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Due date (always on the right) -->
|
||||
{#if dueDateText()}
|
||||
<span
|
||||
|
|
@ -424,6 +452,58 @@
|
|||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Contacts display */
|
||||
.contacts-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.assignee-avatar {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.assignee-avatar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
right: -1px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #8b5cf6;
|
||||
border-radius: 50%;
|
||||
border: 1px solid white;
|
||||
}
|
||||
|
||||
:global(.dark) .assignee-avatar::after {
|
||||
border-color: rgba(30, 30, 30, 1);
|
||||
}
|
||||
|
||||
.involved-avatars {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.involved-avatar {
|
||||
margin-left: -0.375rem;
|
||||
}
|
||||
|
||||
.involved-avatar:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.more-contacts {
|
||||
font-size: 0.625rem;
|
||||
color: #6b7280;
|
||||
margin-left: 0.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:global(.dark) .more-contacts {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Due date */
|
||||
.due-date {
|
||||
font-size: 0.75rem;
|
||||
|
|
|
|||
|
|
@ -28,18 +28,19 @@
|
|||
// Track which task is being animated for completion
|
||||
let animatingTaskId = $state<string | null>(null);
|
||||
|
||||
// Create a stable key from task IDs to detect real changes
|
||||
let lastTaskIds = '';
|
||||
// Create a stable key from task IDs and updatedAt to detect real changes
|
||||
let lastTaskKey = '';
|
||||
|
||||
// Sync items with tasks only when the set of task IDs changes
|
||||
// Sync items with tasks when IDs change OR when tasks are updated
|
||||
$effect(() => {
|
||||
const currentIds = tasks
|
||||
.map((t) => t.id)
|
||||
// Include updatedAt in the key to detect task updates
|
||||
const currentKey = tasks
|
||||
.map((t) => `${t.id}:${t.updatedAt || ''}`)
|
||||
.sort()
|
||||
.join(',');
|
||||
if (currentIds !== lastTaskIds) {
|
||||
if (currentKey !== lastTaskKey) {
|
||||
items = [...tasks];
|
||||
lastTaskIds = currentIds;
|
||||
lastTaskKey = currentKey;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -70,10 +71,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Update local state and sync lastTaskIds to prevent $effect from reverting
|
||||
// Update local state and sync lastTaskKey to prevent $effect from reverting
|
||||
items = newItems;
|
||||
lastTaskIds = newItems
|
||||
.map((t) => t.id)
|
||||
lastTaskKey = newItems
|
||||
.map((t) => `${t.id}:${t.updatedAt || ''}`)
|
||||
.sort()
|
||||
.join(',');
|
||||
}
|
||||
|
|
|
|||
848
apps/todo/apps/web/src/lib/components/TodoToolbar.svelte
Normal file
848
apps/todo/apps/web/src/lib/components/TodoToolbar.svelte
Normal file
|
|
@ -0,0 +1,848 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import type { TaskPriority } from '@todo/shared';
|
||||
import { PRIORITY_OPTIONS } from '@todo/shared';
|
||||
import { tasksStore } from '$lib/stores/tasks.svelte';
|
||||
import { projectsStore } from '$lib/stores/projects.svelte';
|
||||
import { labelsStore } from '$lib/stores/labels.svelte';
|
||||
import { viewStore, type SortBy } from '$lib/stores/view.svelte';
|
||||
import { format, addDays } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import {
|
||||
PillToolbar,
|
||||
PillToolbarButton,
|
||||
PillToolbarDivider,
|
||||
PillViewSwitcher,
|
||||
} from '@manacore/shared-ui';
|
||||
|
||||
interface Props {
|
||||
/** Current sort field */
|
||||
sortBy?: SortBy;
|
||||
/** Sort change callback */
|
||||
onSortChange?: (sortBy: SortBy) => void;
|
||||
/** Show completed tasks toggle */
|
||||
showCompleted?: boolean;
|
||||
/** Toggle show completed callback */
|
||||
onToggleShowCompleted?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
sortBy = viewStore.sortBy,
|
||||
onSortChange = (s: SortBy) => viewStore.setSort(s, viewStore.sortOrder),
|
||||
showCompleted = viewStore.showCompleted,
|
||||
onToggleShowCompleted = () => viewStore.toggleShowCompleted(),
|
||||
}: Props = $props();
|
||||
|
||||
// Quick add task state
|
||||
let inputValue = $state('');
|
||||
let isCreating = $state(false);
|
||||
let showQuickAddOptions = $state(false);
|
||||
let selectedDate = $state<Date>(new Date());
|
||||
let selectedPriority = $state<TaskPriority>('medium');
|
||||
let selectedProjectId = $state<string | undefined>(undefined);
|
||||
|
||||
// Dropdown states
|
||||
let showDatePicker = $state(false);
|
||||
let showPriorityPicker = $state(false);
|
||||
let showProjectPicker = $state(false);
|
||||
|
||||
// Filter dropdown states
|
||||
let showFilterDropdown = $state(false);
|
||||
let selectedPriorityFilters = $state<TaskPriority[]>([]);
|
||||
let selectedProjectFilter = $state<string | null>(null);
|
||||
let selectedLabelFilters = $state<string[]>([]);
|
||||
|
||||
// Quick date options
|
||||
const dateOptions = [
|
||||
{ label: 'Heute', date: new Date() },
|
||||
{ label: 'Morgen', date: addDays(new Date(), 1) },
|
||||
{ label: 'In 3 Tagen', date: addDays(new Date(), 3) },
|
||||
{ label: 'Nächste Woche', date: addDays(new Date(), 7) },
|
||||
];
|
||||
|
||||
const priorities: { value: TaskPriority; label: string; color: string }[] = [
|
||||
{ value: 'urgent', label: 'Dringend', color: '#ef4444' },
|
||||
{ value: 'high', label: 'Hoch', color: '#f97316' },
|
||||
{ value: 'medium', label: 'Normal', color: '#eab308' },
|
||||
{ value: 'low', label: 'Niedrig', color: '#3b82f6' },
|
||||
];
|
||||
|
||||
// Sort options
|
||||
const sortOptions = [
|
||||
{ id: 'dueDate', label: 'Datum', title: 'Nach Fälligkeitsdatum sortieren' },
|
||||
{ id: 'priority', label: 'Priorität', title: 'Nach Priorität sortieren' },
|
||||
{ id: 'title', label: 'Name', title: 'Alphabetisch sortieren' },
|
||||
];
|
||||
|
||||
// Derived values
|
||||
let currentPriority = $derived(PRIORITY_OPTIONS.find((p) => p.value === selectedPriority)!);
|
||||
let selectedProject = $derived(
|
||||
selectedProjectId ? projectsStore.getById(selectedProjectId) : undefined
|
||||
);
|
||||
let dateLabel = $derived(() => {
|
||||
const today = new Date();
|
||||
if (selectedDate.toDateString() === today.toDateString()) return 'Heute';
|
||||
if (selectedDate.toDateString() === addDays(today, 1).toDateString()) return 'Morgen';
|
||||
return format(selectedDate, 'dd. MMM', { locale: de });
|
||||
});
|
||||
|
||||
// Count active filters
|
||||
let activeFilterCount = $derived(
|
||||
selectedPriorityFilters.length + (selectedProjectFilter ? 1 : 0) + selectedLabelFilters.length
|
||||
);
|
||||
|
||||
function handleSortChange(value: string) {
|
||||
onSortChange(value as SortBy);
|
||||
}
|
||||
|
||||
function closeAllDropdowns() {
|
||||
showDatePicker = false;
|
||||
showPriorityPicker = false;
|
||||
showProjectPicker = false;
|
||||
showFilterDropdown = false;
|
||||
}
|
||||
|
||||
async function handleSubmit(event?: Event) {
|
||||
event?.preventDefault();
|
||||
|
||||
const title = inputValue.trim();
|
||||
if (!title || isCreating) return;
|
||||
|
||||
isCreating = true;
|
||||
|
||||
try {
|
||||
await tasksStore.createTask({
|
||||
title,
|
||||
projectId: selectedProjectId,
|
||||
dueDate: selectedDate.toISOString(),
|
||||
priority: selectedPriority,
|
||||
});
|
||||
|
||||
// Reset form
|
||||
inputValue = '';
|
||||
selectedDate = new Date();
|
||||
selectedPriority = 'medium';
|
||||
selectedProjectId = undefined;
|
||||
showQuickAddOptions = false;
|
||||
} catch (error) {
|
||||
console.error('Failed to create task:', error);
|
||||
} finally {
|
||||
isCreating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' && inputValue.trim()) {
|
||||
handleSubmit();
|
||||
} else if (event.key === 'Escape') {
|
||||
inputValue = '';
|
||||
showQuickAddOptions = false;
|
||||
closeAllDropdowns();
|
||||
}
|
||||
}
|
||||
|
||||
function handleInputFocus() {
|
||||
showQuickAddOptions = true;
|
||||
}
|
||||
|
||||
function selectDate(date: Date) {
|
||||
selectedDate = date;
|
||||
showDatePicker = false;
|
||||
}
|
||||
|
||||
function selectPriority(priority: TaskPriority) {
|
||||
selectedPriority = priority;
|
||||
showPriorityPicker = false;
|
||||
}
|
||||
|
||||
function selectProject(projectId: string | undefined) {
|
||||
selectedProjectId = projectId;
|
||||
showProjectPicker = false;
|
||||
}
|
||||
|
||||
function togglePriorityFilter(priority: TaskPriority) {
|
||||
if (selectedPriorityFilters.includes(priority)) {
|
||||
selectedPriorityFilters = selectedPriorityFilters.filter((p) => p !== priority);
|
||||
} else {
|
||||
selectedPriorityFilters = [...selectedPriorityFilters, priority];
|
||||
}
|
||||
}
|
||||
|
||||
function clearAllFilters() {
|
||||
selectedPriorityFilters = [];
|
||||
selectedProjectFilter = null;
|
||||
selectedLabelFilters = [];
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={closeAllDropdowns} />
|
||||
|
||||
<PillToolbar topOffset="70px">
|
||||
<!-- Quick Add Input -->
|
||||
<div class="quick-add-section" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="quick-add-input-wrapper">
|
||||
<svg class="input-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={inputValue}
|
||||
onkeydown={handleKeydown}
|
||||
onfocus={handleInputFocus}
|
||||
placeholder="Neue Aufgabe..."
|
||||
class="quick-add-input"
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Quick add options (visible when focused or has input) -->
|
||||
{#if showQuickAddOptions || inputValue.trim()}
|
||||
<div class="quick-add-options">
|
||||
<!-- Date picker -->
|
||||
<div class="option-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
class="option-btn"
|
||||
class:active={showDatePicker}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
showDatePicker = !showDatePicker;
|
||||
showPriorityPicker = false;
|
||||
showProjectPicker = false;
|
||||
}}
|
||||
title="Fälligkeitsdatum"
|
||||
>
|
||||
<svg class="option-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
<span class="option-label">{dateLabel()}</span>
|
||||
</button>
|
||||
|
||||
{#if showDatePicker}
|
||||
<div class="dropdown" onclick={(e) => e.stopPropagation()}>
|
||||
{#each dateOptions as option}
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-item"
|
||||
class:selected={selectedDate.toDateString() === option.date.toDateString()}
|
||||
onclick={() => selectDate(option.date)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Priority picker -->
|
||||
<div class="option-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
class="option-btn"
|
||||
class:active={showPriorityPicker}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
showPriorityPicker = !showPriorityPicker;
|
||||
showDatePicker = false;
|
||||
showProjectPicker = false;
|
||||
}}
|
||||
title="Priorität"
|
||||
>
|
||||
<svg class="option-icon" fill="none" viewBox="0 0 24 24" stroke={currentPriority.color}>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if showPriorityPicker}
|
||||
<div class="dropdown" onclick={(e) => e.stopPropagation()}>
|
||||
{#each PRIORITY_OPTIONS as priority}
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-item"
|
||||
class:selected={selectedPriority === priority.value}
|
||||
onclick={() => selectPriority(priority.value)}
|
||||
>
|
||||
<span class="priority-dot" style="background-color: {priority.color}"></span>
|
||||
{priority.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Project picker -->
|
||||
<div class="option-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
class="option-btn"
|
||||
class:active={showProjectPicker}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
showProjectPicker = !showProjectPicker;
|
||||
showDatePicker = false;
|
||||
showPriorityPicker = false;
|
||||
}}
|
||||
title="Projekt"
|
||||
>
|
||||
<svg
|
||||
class="option-icon"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke={selectedProject?.color || 'currentColor'}
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if showProjectPicker}
|
||||
<div class="dropdown" onclick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-item"
|
||||
class:selected={!selectedProjectId}
|
||||
onclick={() => selectProject(undefined)}
|
||||
>
|
||||
<span class="project-dot" style="background-color: #6b7280"></span>
|
||||
Kein Projekt
|
||||
</button>
|
||||
{#each projectsStore.activeProjects as project}
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-item"
|
||||
class:selected={selectedProjectId === project.id}
|
||||
onclick={() => selectProject(project.id)}
|
||||
>
|
||||
<span class="project-dot" style="background-color: {project.color}"></span>
|
||||
{project.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Submit button -->
|
||||
<button
|
||||
type="button"
|
||||
class="submit-btn"
|
||||
disabled={isCreating || !inputValue.trim()}
|
||||
onclick={() => handleSubmit()}
|
||||
>
|
||||
{#if isCreating}
|
||||
<div class="spinner"></div>
|
||||
{:else}
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 7l5 5m0 0l-5 5m5-5H6"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<PillToolbarDivider />
|
||||
|
||||
<!-- Kanban View Button -->
|
||||
<PillToolbarButton onclick={() => goto('/kanban')} title="Kanban-Ansicht">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2"
|
||||
/>
|
||||
</svg>
|
||||
</PillToolbarButton>
|
||||
|
||||
<PillToolbarDivider />
|
||||
|
||||
<!-- Filter Button -->
|
||||
<div class="filter-dropdown-container" onclick={(e) => e.stopPropagation()}>
|
||||
<PillToolbarButton
|
||||
onclick={() => {
|
||||
showFilterDropdown = !showFilterDropdown;
|
||||
closeAllDropdowns();
|
||||
}}
|
||||
active={activeFilterCount > 0}
|
||||
title="Filter"
|
||||
>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
|
||||
/>
|
||||
</svg>
|
||||
{#if activeFilterCount > 0}
|
||||
<span class="filter-count">{activeFilterCount}</span>
|
||||
{/if}
|
||||
</PillToolbarButton>
|
||||
|
||||
{#if showFilterDropdown}
|
||||
<div class="filter-dropdown" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="filter-section">
|
||||
<div class="filter-section-header">Priorität</div>
|
||||
<div class="filter-chips">
|
||||
{#each priorities as priority}
|
||||
<button
|
||||
type="button"
|
||||
class="filter-chip"
|
||||
class:selected={selectedPriorityFilters.includes(priority.value)}
|
||||
onclick={() => togglePriorityFilter(priority.value)}
|
||||
>
|
||||
<span class="chip-dot" style="background-color: {priority.color}"></span>
|
||||
{priority.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<div class="filter-section-header">Projekt</div>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={selectedProjectFilter || ''}
|
||||
onchange={(e) => (selectedProjectFilter = e.currentTarget.value || null)}
|
||||
>
|
||||
<option value="">Alle Projekte</option>
|
||||
{#each projectsStore.activeProjects as project}
|
||||
<option value={project.id}>{project.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if activeFilterCount > 0}
|
||||
<button type="button" class="clear-filters-btn" onclick={clearAllFilters}>
|
||||
Filter zurücksetzen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<PillToolbarDivider />
|
||||
|
||||
<!-- Sort Toggle -->
|
||||
<PillViewSwitcher
|
||||
options={sortOptions}
|
||||
value={sortBy}
|
||||
onChange={handleSortChange}
|
||||
primaryColor="#8b5cf6"
|
||||
embedded={true}
|
||||
/>
|
||||
|
||||
<PillToolbarDivider />
|
||||
|
||||
<!-- Show Completed Toggle -->
|
||||
<PillToolbarButton
|
||||
onclick={onToggleShowCompleted}
|
||||
active={showCompleted}
|
||||
title={showCompleted ? 'Erledigte ausblenden' : 'Erledigte anzeigen'}
|
||||
>
|
||||
<svg fill={showCompleted ? 'currentColor' : 'none'} stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</PillToolbarButton>
|
||||
</PillToolbar>
|
||||
|
||||
<style>
|
||||
/* Quick Add Section */
|
||||
.quick-add-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.quick-add-input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: #9ca3af;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.quick-add-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
:global(.dark) .quick-add-input {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.quick-add-input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.quick-add-input:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Quick add options */
|
||||
.quick-add-options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.option-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.option-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.375rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
border-radius: 9999px;
|
||||
transition: all 0.15s;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
:global(.dark) .option-btn {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.option-btn:hover,
|
||||
.option-btn.active {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
:global(.dark) .option-btn:hover,
|
||||
:global(.dark) .option-btn.active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.option-icon {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.option-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.option-label {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
/* Submit button */
|
||||
.submit-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border: none;
|
||||
background: #8b5cf6;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
border-radius: 9999px;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background: #7c3aed;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
background: #d1d5db;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
:global(.dark) .submit-btn:disabled {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
.submit-btn svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border: 2px solid white;
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Dropdowns */
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
min-width: 140px;
|
||||
padding: 0.375rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
:global(.dark) .dropdown {
|
||||
background: rgba(40, 40, 40, 0.95);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
text-align: left;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
:global(.dark) .dropdown-item {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:global(.dark) .dropdown-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.dropdown-item.selected {
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.priority-dot,
|
||||
.project-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 9999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Filter dropdown container */
|
||||
.filter-dropdown-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1rem;
|
||||
height: 1rem;
|
||||
padding: 0 0.25rem;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
background: #8b5cf6;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.filter-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
min-width: 260px;
|
||||
padding: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
:global(.dark) .filter-dropdown {
|
||||
background: rgba(30, 30, 30, 0.95);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.filter-section:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.filter-section-header {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
:global(.dark) .filter-section-header {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.filter-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
:global(.dark) .filter-chip {
|
||||
color: #f3f4f6;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.filter-chip:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:global(.dark) .filter-chip:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.filter-chip.selected {
|
||||
background: rgba(139, 92, 246, 0.15);
|
||||
border-color: rgba(139, 92, 246, 0.3);
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.chip-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #374151;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
:global(.dark) .filter-select {
|
||||
color: #f3f4f6;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.filter-select:hover {
|
||||
border-color: rgba(139, 92, 246, 0.5);
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
outline: none;
|
||||
border-color: #8b5cf6;
|
||||
}
|
||||
|
||||
.clear-filters-btn {
|
||||
width: 100%;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.clear-filters-btn:hover {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
:global(.dark) .clear-filters-btn:hover {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { dndzone, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action';
|
||||
import type { KanbanColumn, Task, UpdateTaskInput } from '@todo/shared';
|
||||
import { dndzone, SHADOW_PLACEHOLDER_ITEM_ID, type DndEvent } from 'svelte-dnd-action';
|
||||
import type { KanbanColumn, Task } from '@todo/shared';
|
||||
import KanbanTaskCard from './KanbanTaskCard.svelte';
|
||||
import KanbanColumnHeader from './KanbanColumnHeader.svelte';
|
||||
import QuickAddTaskInline from './QuickAddTaskInline.svelte';
|
||||
|
|
@ -36,13 +36,11 @@
|
|||
|
||||
const flipDurationMs = 200;
|
||||
|
||||
function handleDndConsider(e: CustomEvent<{ items: Task[] }>) {
|
||||
function handleDndConsider(e: CustomEvent<DndEvent<Task>>) {
|
||||
localTasks = e.detail.items;
|
||||
}
|
||||
|
||||
function handleDndFinalize(
|
||||
e: CustomEvent<{ items: Task[]; info: { id: string; source: { items: Task[] } } }>
|
||||
) {
|
||||
function handleDndFinalize(e: CustomEvent<DndEvent<Task>>) {
|
||||
const newItems = e.detail.items.filter((t) => t.id !== SHADOW_PLACEHOLDER_ITEM_ID);
|
||||
const movedTaskId = e.detail.info.id;
|
||||
|
||||
|
|
@ -71,20 +69,21 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function handleSaveTask(task: Task, data: UpdateTaskInput) {
|
||||
// Transform data to match updateTask API (convert null to undefined)
|
||||
const updateData: UpdateTaskInput = {};
|
||||
async function handleSaveTask(task: Task, data: Partial<Task>) {
|
||||
// Transform Partial<Task> to updateTask format
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (data.title !== undefined) updateData.title = data.title;
|
||||
if (data.description !== undefined) updateData.description = data.description ?? undefined;
|
||||
if (data.description !== undefined) updateData.description = data.description;
|
||||
if (data.projectId !== undefined) updateData.projectId = data.projectId;
|
||||
if (data.dueDate !== undefined) updateData.dueDate = data.dueDate ?? undefined;
|
||||
if (data.dueDate !== undefined) {
|
||||
updateData.dueDate = data.dueDate instanceof Date ? data.dueDate.toISOString() : data.dueDate;
|
||||
}
|
||||
if (data.priority !== undefined) updateData.priority = data.priority;
|
||||
if (data.status !== undefined) updateData.status = data.status;
|
||||
if (data.subtasks !== undefined) updateData.subtasks = data.subtasks ?? undefined;
|
||||
if (data.recurrenceRule !== undefined)
|
||||
updateData.recurrenceRule = data.recurrenceRule ?? undefined;
|
||||
if (data.subtasks !== undefined) updateData.subtasks = data.subtasks;
|
||||
if (data.recurrenceRule !== undefined) updateData.recurrenceRule = data.recurrenceRule;
|
||||
if (data.metadata !== undefined) updateData.metadata = data.metadata;
|
||||
if (data.labelIds !== undefined) updateData.labelIds = data.labelIds;
|
||||
if (data.labels !== undefined) updateData.labelIds = data.labels?.map((l) => l.id);
|
||||
|
||||
await tasksStore.updateTask(task.id, updateData);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import type { Task } from '@todo/shared';
|
||||
import { format, isToday, isPast, isTomorrow } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import { ConfirmationModal } from '@manacore/shared-ui';
|
||||
import { ConfirmationModal, ContactAvatar } from '@manacore/shared-ui';
|
||||
import TaskEditModal from '../TaskEditModal.svelte';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -249,6 +249,33 @@
|
|||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Contacts display -->
|
||||
{#if task.metadata?.assignee || (task.metadata?.involvedContacts && task.metadata.involvedContacts.length > 0)}
|
||||
<div class="contacts-display">
|
||||
{#if task.metadata?.assignee}
|
||||
<div class="assignee-avatar" title="Zuständig: {task.metadata.assignee.displayName}">
|
||||
<ContactAvatar
|
||||
name={task.metadata.assignee.displayName}
|
||||
photoUrl={task.metadata.assignee.photoUrl}
|
||||
size="xs"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if task.metadata?.involvedContacts && task.metadata.involvedContacts.length > 0}
|
||||
<div class="involved-avatars">
|
||||
{#each task.metadata.involvedContacts.slice(0, 2) as contact}
|
||||
<div class="involved-avatar" title="Beteiligt: {contact.displayName}">
|
||||
<ContactAvatar name={contact.displayName} photoUrl={contact.photoUrl} size="xs" />
|
||||
</div>
|
||||
{/each}
|
||||
{#if task.metadata.involvedContacts.length > 2}
|
||||
<span class="more-contacts">+{task.metadata.involvedContacts.length - 2}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Context Menu -->
|
||||
|
|
@ -500,6 +527,58 @@
|
|||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Contacts display */
|
||||
.contacts-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.assignee-avatar {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.assignee-avatar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
right: -1px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #8b5cf6;
|
||||
border-radius: 50%;
|
||||
border: 1px solid white;
|
||||
}
|
||||
|
||||
:global(.dark) .assignee-avatar::after {
|
||||
border-color: rgba(30, 30, 30, 1);
|
||||
}
|
||||
|
||||
.involved-avatars {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.involved-avatar {
|
||||
margin-left: -0.375rem;
|
||||
}
|
||||
|
||||
.involved-avatar:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.more-contacts {
|
||||
font-size: 0.625rem;
|
||||
color: #6b7280;
|
||||
margin-left: 0.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:global(.dark) .more-contacts {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Context Menu */
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
|
|
|
|||
23
apps/todo/apps/web/src/lib/services/feedback.ts
Normal file
23
apps/todo/apps/web/src/lib/services/feedback.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* Feedback Service Instance for Todo Web App
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { createFeedbackService } from '@manacore/shared-feedback-service';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
// 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 const feedbackService = createFeedbackService({
|
||||
apiUrl: getAuthUrl(),
|
||||
appId: 'todo',
|
||||
getAuthToken: async () => authStore.getAccessToken(),
|
||||
});
|
||||
175
apps/todo/apps/web/src/lib/stores/contacts.svelte.ts
Normal file
175
apps/todo/apps/web/src/lib/stores/contacts.svelte.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
/**
|
||||
* Contacts Store for Todo App
|
||||
*
|
||||
* Provides access to contacts from the Contacts app for task assignment.
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { createContactsClient, type ContactsClient } from '@manacore/shared-auth';
|
||||
import type { ContactSummary } from '@manacore/shared-types';
|
||||
import { authStore } from './auth.svelte';
|
||||
|
||||
// State
|
||||
let client: ContactsClient | null = null;
|
||||
let isAvailable = $state<boolean | null>(null);
|
||||
let isChecking = $state(false);
|
||||
let lastCheck = $state<number>(0);
|
||||
|
||||
// Cache for recent search results
|
||||
let searchCache = $state<Map<string, { results: ContactSummary[]; timestamp: number }>>(new Map());
|
||||
const CACHE_TTL = 60000; // 1 minute
|
||||
|
||||
// Get contacts API URL dynamically
|
||||
function getContactsApiUrl(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
const injectedUrl = (window as unknown as { __PUBLIC_CONTACTS_API_URL__?: string })
|
||||
.__PUBLIC_CONTACTS_API_URL__;
|
||||
return injectedUrl || 'http://localhost:3015/api/v1';
|
||||
}
|
||||
return 'http://localhost:3015/api/v1';
|
||||
}
|
||||
|
||||
// Initialize client lazily
|
||||
function getClient(): ContactsClient {
|
||||
if (!client) {
|
||||
client = createContactsClient({
|
||||
apiUrl: getContactsApiUrl(),
|
||||
getAuthToken: async () => authStore.getAccessToken(),
|
||||
timeout: 5000,
|
||||
});
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
export const contactsStore = {
|
||||
// Getters
|
||||
get isAvailable() {
|
||||
return isAvailable;
|
||||
},
|
||||
get isChecking() {
|
||||
return isChecking;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if the Contacts API is available
|
||||
* Caches result for 30 seconds
|
||||
*/
|
||||
async checkAvailability(): Promise<boolean> {
|
||||
const now = Date.now();
|
||||
// Skip if checked recently
|
||||
if (lastCheck && now - lastCheck < 30000 && isAvailable !== null) {
|
||||
return isAvailable;
|
||||
}
|
||||
|
||||
isChecking = true;
|
||||
try {
|
||||
const available = await getClient().isAvailable();
|
||||
isAvailable = available;
|
||||
lastCheck = now;
|
||||
return available;
|
||||
} catch {
|
||||
isAvailable = false;
|
||||
lastCheck = now;
|
||||
return false;
|
||||
} finally {
|
||||
isChecking = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Search contacts by query
|
||||
*/
|
||||
async searchContacts(query: string): Promise<ContactSummary[]> {
|
||||
// Check cache first
|
||||
const cacheKey = query.toLowerCase().trim();
|
||||
const cached = searchCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||
return cached.results;
|
||||
}
|
||||
|
||||
// Check availability
|
||||
if (isAvailable === null) {
|
||||
await this.checkAvailability();
|
||||
}
|
||||
|
||||
if (!isAvailable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await getClient().searchContacts({
|
||||
query,
|
||||
limit: 20,
|
||||
excludeArchived: true,
|
||||
});
|
||||
|
||||
// Cache results
|
||||
searchCache.set(cacheKey, {
|
||||
results,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error('[contactsStore] Search failed:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a single contact by ID
|
||||
*/
|
||||
async getContact(id: string): Promise<ContactSummary | null> {
|
||||
if (isAvailable === null) {
|
||||
await this.checkAvailability();
|
||||
}
|
||||
|
||||
if (!isAvailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await getClient().getContact(id);
|
||||
} catch (error) {
|
||||
console.error(`[contactsStore] Failed to get contact ${id}:`, error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get multiple contacts by IDs
|
||||
*/
|
||||
async getContacts(ids: string[]): Promise<ContactSummary[]> {
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
if (isAvailable === null) {
|
||||
await this.checkAvailability();
|
||||
}
|
||||
|
||||
if (!isAvailable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return await getClient().getContacts(ids);
|
||||
} catch (error) {
|
||||
console.error('[contactsStore] Failed to get contacts:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear the search cache
|
||||
*/
|
||||
clearCache() {
|
||||
searchCache.clear();
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset availability check (force recheck on next call)
|
||||
*/
|
||||
resetAvailability() {
|
||||
isAvailable = null;
|
||||
lastCheck = 0;
|
||||
},
|
||||
};
|
||||
|
|
@ -217,13 +217,21 @@ export const tasksStore = {
|
|||
id: string,
|
||||
data: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
description?: string | null;
|
||||
projectId?: string | null;
|
||||
parentTaskId?: string | null;
|
||||
dueDate?: string | null;
|
||||
dueTime?: string | null;
|
||||
startDate?: string | null;
|
||||
priority?: TaskPriority;
|
||||
status?: TaskStatus;
|
||||
subtasks?: Subtask[];
|
||||
isCompleted?: boolean;
|
||||
order?: number;
|
||||
subtasks?: Subtask[] | null;
|
||||
recurrenceRule?: string | null;
|
||||
recurrenceEndDate?: string | null;
|
||||
metadata?: { [key: string]: unknown } | null;
|
||||
labelIds?: string[];
|
||||
}
|
||||
) {
|
||||
error = null;
|
||||
|
|
|
|||
|
|
@ -3,11 +3,16 @@
|
|||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { PillNavigation, CommandBar } from '@manacore/shared-ui';
|
||||
import { PillNavigation, QuickInputBar } from '@manacore/shared-ui';
|
||||
import {
|
||||
SplitPaneContainer,
|
||||
setSplitPanelContext,
|
||||
DEFAULT_APPS,
|
||||
} from '@manacore/shared-splitscreen';
|
||||
import type {
|
||||
PillNavItem,
|
||||
PillDropdownItem,
|
||||
CommandBarItem,
|
||||
QuickInputItem,
|
||||
QuickAction,
|
||||
CreatePreview,
|
||||
} from '@manacore/shared-ui';
|
||||
|
|
@ -36,21 +41,25 @@
|
|||
// App switcher items
|
||||
const appItems = getPillAppItems('todo');
|
||||
|
||||
// Split-Panel Store für Split-Screen Feature
|
||||
const splitPanel = setSplitPanelContext('todo', DEFAULT_APPS);
|
||||
|
||||
// Handler für Split-Screen Panel-Öffnung
|
||||
function handleOpenInPanel(appId: string, url: string) {
|
||||
splitPanel.openPanel(appId);
|
||||
}
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// CommandBar state
|
||||
let commandBarOpen = $state(false);
|
||||
|
||||
// CommandBar quick actions
|
||||
const commandBarQuickActions: QuickAction[] = [
|
||||
{ id: 'new', label: 'Neue Aufgabe erstellen', icon: 'plus', href: '/task/new', shortcut: 'N' },
|
||||
{ id: 'kanban', label: 'Kanban-Board', icon: 'list', href: '/kanban' },
|
||||
{ id: 'stats', label: 'Statistiken', icon: 'chart', href: '/statistics' },
|
||||
// QuickInputBar quick actions
|
||||
const quickActions: QuickAction[] = [
|
||||
{ id: 'kanban', label: 'Kanban', icon: 'kanban', href: '/kanban' },
|
||||
{ id: 'stats', label: 'Statistik', icon: 'chart', href: '/statistics' },
|
||||
{ id: 'settings', label: 'Einstellungen', icon: 'settings', href: '/settings' },
|
||||
];
|
||||
|
||||
// CommandBar search - search tasks
|
||||
async function handleCommandBarSearch(query: string): Promise<CommandBarItem[]> {
|
||||
// QuickInputBar search - search tasks
|
||||
async function handleSearch(query: string): Promise<QuickInputItem[]> {
|
||||
if (!query.trim()) return [];
|
||||
|
||||
try {
|
||||
|
|
@ -69,25 +78,25 @@
|
|||
}
|
||||
}
|
||||
|
||||
function handleCommandBarSelect(item: CommandBarItem) {
|
||||
function handleSelect(item: QuickInputItem) {
|
||||
goto(`/task/${item.id}`);
|
||||
}
|
||||
|
||||
// CommandBar create - parse input and show preview
|
||||
function handleCommandBarParseCreate(query: string): CreatePreview | null {
|
||||
// QuickInputBar create - parse input and show preview
|
||||
function handleParseCreate(query: string): CreatePreview | null {
|
||||
if (!query.trim()) return null;
|
||||
|
||||
const parsed = parseTaskInput(query);
|
||||
const preview = formatParsedTaskPreview(parsed);
|
||||
|
||||
return {
|
||||
title: `"${parsed.title}" als Aufgabe erstellen`,
|
||||
title: `"${parsed.title}" erstellen`,
|
||||
subtitle: preview || 'Neue Aufgabe',
|
||||
};
|
||||
}
|
||||
|
||||
// CommandBar create - actually create the task
|
||||
async function handleCommandBarCreate(query: string): Promise<void> {
|
||||
// QuickInputBar create - actually create the task
|
||||
async function handleCreate(query: string): Promise<void> {
|
||||
if (!query.trim()) return;
|
||||
|
||||
const parsed = parseTaskInput(query);
|
||||
|
|
@ -192,13 +201,6 @@
|
|||
function handleKeydown(event: KeyboardEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// Cmd/Ctrl+K to open command bar (works even in inputs)
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
|
||||
event.preventDefault();
|
||||
commandBarOpen = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -257,6 +259,9 @@
|
|||
return;
|
||||
}
|
||||
|
||||
// Initialize split-panel from URL/localStorage
|
||||
splitPanel.initialize();
|
||||
|
||||
// Load data
|
||||
await Promise.all([
|
||||
projectsStore.fetchProjects(),
|
||||
|
|
@ -321,67 +326,70 @@
|
|||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class="layout-container">
|
||||
<PillNavigation
|
||||
items={navItems}
|
||||
currentPath={$page.url.pathname}
|
||||
appName="Todo"
|
||||
homeRoute="/"
|
||||
onToggleTheme={handleToggleTheme}
|
||||
{isDark}
|
||||
{isSidebarMode}
|
||||
onModeChange={handleModeChange}
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
desktopPosition={userSettings.nav.desktopPosition}
|
||||
showThemeToggle={true}
|
||||
showThemeVariants={true}
|
||||
{themeVariantItems}
|
||||
{currentThemeVariantLabel}
|
||||
themeMode={theme.mode}
|
||||
onThemeModeChange={handleThemeModeChange}
|
||||
showLanguageSwitcher={true}
|
||||
{languageItems}
|
||||
{currentLanguageLabel}
|
||||
showLogout={authStore.isAuthenticated}
|
||||
onLogout={handleLogout}
|
||||
loginHref="/login"
|
||||
primaryColor="#8b5cf6"
|
||||
showAppSwitcher={true}
|
||||
{appItems}
|
||||
{userEmail}
|
||||
settingsHref="/settings"
|
||||
manaHref="/mana"
|
||||
profileHref="/profile"
|
||||
allAppsHref="/apps"
|
||||
/>
|
||||
<SplitPaneContainer>
|
||||
<div class="layout-container">
|
||||
<PillNavigation
|
||||
items={navItems}
|
||||
currentPath={$page.url.pathname}
|
||||
appName="Todo"
|
||||
homeRoute="/"
|
||||
onToggleTheme={handleToggleTheme}
|
||||
{isDark}
|
||||
{isSidebarMode}
|
||||
onModeChange={handleModeChange}
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
desktopPosition={userSettings.nav.desktopPosition}
|
||||
showThemeToggle={true}
|
||||
showThemeVariants={true}
|
||||
{themeVariantItems}
|
||||
{currentThemeVariantLabel}
|
||||
themeMode={theme.mode}
|
||||
onThemeModeChange={handleThemeModeChange}
|
||||
showLanguageSwitcher={true}
|
||||
{languageItems}
|
||||
{currentLanguageLabel}
|
||||
showLogout={authStore.isAuthenticated}
|
||||
onLogout={handleLogout}
|
||||
loginHref="/login"
|
||||
primaryColor="#8b5cf6"
|
||||
showAppSwitcher={true}
|
||||
{appItems}
|
||||
{userEmail}
|
||||
settingsHref="/settings"
|
||||
manaHref="/mana"
|
||||
profileHref="/profile"
|
||||
allAppsHref="/apps"
|
||||
onOpenInPanel={handleOpenInPanel}
|
||||
/>
|
||||
|
||||
<main
|
||||
class="main-content bg-background"
|
||||
class:sidebar-mode={isSidebarMode && !isCollapsed}
|
||||
class:floating-mode={!isSidebarMode && !isCollapsed}
|
||||
>
|
||||
<div class="content-wrapper" class:full-width={$page.url.pathname === '/kanban'}>
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
<main
|
||||
class="main-content bg-background"
|
||||
class:sidebar-mode={isSidebarMode && !isCollapsed}
|
||||
class:floating-mode={!isSidebarMode && !isCollapsed}
|
||||
>
|
||||
<div class="content-wrapper" class:full-width={$page.url.pathname === '/kanban'}>
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Global Command Bar (Cmd/K) -->
|
||||
<CommandBar
|
||||
bind:open={commandBarOpen}
|
||||
onClose={() => (commandBarOpen = false)}
|
||||
onSearch={handleCommandBarSearch}
|
||||
onSelect={handleCommandBarSelect}
|
||||
quickActions={commandBarQuickActions}
|
||||
placeholder="Aufgabe suchen oder erstellen..."
|
||||
emptyText="Keine Aufgaben gefunden"
|
||||
searchingText="Suche..."
|
||||
onCreate={handleCommandBarCreate}
|
||||
onParseCreate={handleCommandBarParseCreate}
|
||||
createText="Als Aufgabe erstellen"
|
||||
createShortcut="⌘↵"
|
||||
/>
|
||||
</div>
|
||||
<!-- Global Quick Input Bar -->
|
||||
<QuickInputBar
|
||||
onSearch={handleSearch}
|
||||
onSelect={handleSelect}
|
||||
{quickActions}
|
||||
placeholder="Neue Aufgabe oder suchen..."
|
||||
emptyText="Keine Aufgaben gefunden"
|
||||
searchingText="Suche..."
|
||||
onCreate={handleCreate}
|
||||
onParseCreate={handleParseCreate}
|
||||
createText="Erstellen"
|
||||
appIcon="todo"
|
||||
primaryColor="#8b5cf6"
|
||||
autoFocus={true}
|
||||
/>
|
||||
</div>
|
||||
</SplitPaneContainer>
|
||||
|
||||
<style>
|
||||
.layout-container {
|
||||
|
|
@ -394,6 +402,8 @@
|
|||
transition: all 300ms ease;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
/* Space for QuickInputBar at bottom */
|
||||
padding-bottom: calc(80px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.main-content.floating-mode {
|
||||
|
|
@ -438,4 +448,11 @@
|
|||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile: More space for QuickInputBar + PillNav */
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
padding-bottom: calc(150px + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@
|
|||
import { tasksStore } from '$lib/stores/tasks.svelte';
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import TaskList from '$lib/components/TaskList.svelte';
|
||||
import QuickAddTask from '$lib/components/QuickAddTask.svelte';
|
||||
import CollapsibleSection from '$lib/components/CollapsibleSection.svelte';
|
||||
import TaskEditModal from '$lib/components/TaskEditModal.svelte';
|
||||
import TodoToolbar from '$lib/components/TodoToolbar.svelte';
|
||||
import { TaskListSkeleton } from '$lib/components/skeletons';
|
||||
import type { Task, UpdateTaskInput } from '@todo/shared';
|
||||
|
||||
|
|
@ -100,8 +100,12 @@
|
|||
if (!editingTask) return;
|
||||
|
||||
try {
|
||||
// Update task
|
||||
await tasksStore.updateTask(editingTask.id, data);
|
||||
// Update task - cast metadata to be compatible with store type
|
||||
const updateData = {
|
||||
...data,
|
||||
metadata: data.metadata as { [key: string]: unknown } | null | undefined,
|
||||
};
|
||||
await tasksStore.updateTask(editingTask.id, updateData);
|
||||
|
||||
// Update labels if provided
|
||||
if (data.labelIds !== undefined) {
|
||||
|
|
@ -160,7 +164,8 @@
|
|||
<p class="text-muted-foreground text-sm mt-1">Alle deine Aufgaben auf einen Blick</p>
|
||||
</header>
|
||||
|
||||
<QuickAddTask />
|
||||
<!-- Unified Toolbar with Quick Add -->
|
||||
<TodoToolbar />
|
||||
|
||||
{#if isLoading || tasksStore.loading}
|
||||
<TaskListSkeleton sections={3} tasksPerSection={3} />
|
||||
|
|
|
|||
|
|
@ -1,22 +1,11 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { FeedbackPage } from '@manacore/shared-feedback-ui';
|
||||
|
||||
onMount(() => {
|
||||
if (!authStore.isAuthenticated) {
|
||||
goto('/login');
|
||||
}
|
||||
});
|
||||
import { feedbackService } from '$lib/services/feedback';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Feedback | Todo</title>
|
||||
</svelte:head>
|
||||
|
||||
<FeedbackPage
|
||||
appName="Todo"
|
||||
userEmail={authStore.user?.email || undefined}
|
||||
primaryColor="#8b5cf6"
|
||||
/>
|
||||
<FeedbackPage {feedbackService} appName="Todo" currentUserId={authStore.user?.id} />
|
||||
|
|
|
|||
|
|
@ -11,20 +11,20 @@
|
|||
// Get translations based on current locale
|
||||
const translations = $derived(getForgotPasswordTranslations($locale || 'de'));
|
||||
|
||||
async function handleResetPassword(email: string) {
|
||||
async function handleForgotPassword(email: string) {
|
||||
return authStore.resetPassword(email);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{translations.title} | Todo</title>
|
||||
<title>{translations.titleForm} | Todo</title>
|
||||
</svelte:head>
|
||||
|
||||
<ForgotPasswordPage
|
||||
appName="Todo"
|
||||
logo={TodoLogo}
|
||||
primaryColor="#8b5cf6"
|
||||
onResetPassword={handleResetPassword}
|
||||
onForgotPassword={handleForgotPassword}
|
||||
{goto}
|
||||
loginPath="/login"
|
||||
lightBackground="#f3e8ff"
|
||||
|
|
|
|||
|
|
@ -26,8 +26,6 @@
|
|||
primaryColor="#8b5cf6"
|
||||
onSignUp={handleSignUp}
|
||||
{goto}
|
||||
enableGoogle={false}
|
||||
enableApple={false}
|
||||
successRedirect="/"
|
||||
loginPath="/login"
|
||||
lightBackground="#f3e8ff"
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@
|
|||
"scripts": {
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-types": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { Label } from './label';
|
||||
import type { ContactReference } from '@manacore/shared-types';
|
||||
|
||||
export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent';
|
||||
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled';
|
||||
|
|
@ -26,6 +27,9 @@ export interface TaskMetadata {
|
|||
storyPoints?: number | null; // Fibonacci: 1, 2, 3, 5, 8, 13, 21
|
||||
effectiveDuration?: EffectiveDuration | null; // Actual time spent
|
||||
funRating?: number | null; // 1-10 scale
|
||||
// Contact associations
|
||||
assignee?: ContactReference | null; // Person responsible for the task
|
||||
involvedContacts?: ContactReference[]; // Other people involved
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
|
|
@ -43,6 +47,12 @@ export interface Task {
|
|||
dueTime?: string | null; // HH:mm format
|
||||
startDate?: Date | string | null;
|
||||
|
||||
// Time-Blocking (for calendar integration)
|
||||
scheduledDate?: Date | string | null; // Date when task is scheduled
|
||||
scheduledStartTime?: string | null; // HH:mm format - when to start
|
||||
scheduledEndTime?: string | null; // HH:mm format - when to end
|
||||
estimatedDuration?: number | null; // Duration in minutes
|
||||
|
||||
// Priority & Status
|
||||
priority: TaskPriority;
|
||||
status: TaskStatus;
|
||||
|
|
@ -84,6 +94,11 @@ export interface CreateTaskInput {
|
|||
dueDate?: string | null;
|
||||
dueTime?: string | null;
|
||||
startDate?: string | null;
|
||||
// Time-Blocking
|
||||
scheduledDate?: string | null;
|
||||
scheduledStartTime?: string | null;
|
||||
scheduledEndTime?: string | null;
|
||||
estimatedDuration?: number | null;
|
||||
priority?: TaskPriority;
|
||||
recurrenceRule?: string | null;
|
||||
recurrenceEndDate?: string | null;
|
||||
|
|
@ -100,6 +115,11 @@ export interface UpdateTaskInput {
|
|||
dueDate?: string | null;
|
||||
dueTime?: string | null;
|
||||
startDate?: string | null;
|
||||
// Time-Blocking
|
||||
scheduledDate?: string | null;
|
||||
scheduledStartTime?: string | null;
|
||||
scheduledEndTime?: string | null;
|
||||
estimatedDuration?: number | null;
|
||||
priority?: TaskPriority;
|
||||
status?: TaskStatus;
|
||||
isCompleted?: boolean;
|
||||
|
|
|
|||
507
docs/architecture/WORKSPACE_ORCHESTRATOR.md
Normal file
507
docs/architecture/WORKSPACE_ORCHESTRATOR.md
Normal file
|
|
@ -0,0 +1,507 @@
|
|||
# ManaCore Workspace Orchestrator
|
||||
|
||||
> Architektur-Entscheidung für modulares Multi-App-System mit Split-Screen und Drag & Drop
|
||||
|
||||
**Status:** Proposal
|
||||
**Datum:** 2024-12-12
|
||||
**Autor:** Till Schneider / Claude
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Dieses Dokument beschreibt die Architektur des **ManaCore Workspace Orchestrators** - ein modulares System, das es ermöglicht:
|
||||
|
||||
1. Mehrere Apps nebeneinander im Split-Screen anzuzeigen
|
||||
2. Drag & Drop zwischen Apps zu unterstützen
|
||||
3. Flexible Deployments mit unterschiedlichen App-Kombinationen zu erstellen
|
||||
4. Die Anzahl der Apps beliebig zu skalieren
|
||||
|
||||
---
|
||||
|
||||
## Problemstellung
|
||||
|
||||
### Aktuelle Situation
|
||||
|
||||
Das ManaCore-Ökosystem besteht aus mehreren unabhängigen SvelteKit-Anwendungen:
|
||||
|
||||
| App | Port (Dev) | Domain (Prod) |
|
||||
|-----|------------|---------------|
|
||||
| Calendar | 5179 | calendar.manacore.app |
|
||||
| Contacts | 5184 | contacts.manacore.app |
|
||||
| Todo | 5188 | todo.manacore.app |
|
||||
| Chat | 5174 | chat.manacore.app |
|
||||
| Clock | 5187 | clock.manacore.app |
|
||||
| Picture | 5185 | picture.manacore.app |
|
||||
|
||||
Jede App ist eine **eigenständige SvelteKit-Instanz** mit:
|
||||
- Eigenem Dev-Server und Production-Build
|
||||
- Eigener Domain/Subdomain
|
||||
- Eigenem Backend (NestJS)
|
||||
- Geteilter Auth über Mana Core Auth (JWT)
|
||||
|
||||
### Anforderungen
|
||||
|
||||
1. **Split-Screen:** Zwei Apps nebeneinander anzeigen
|
||||
2. **Drag & Drop:** Elemente zwischen Apps verschieben (z.B. Kontakt auf Kalender droppen)
|
||||
3. **Modulare Deployments:** Kunde A bekommt nur Calendar+Todo, Kunde B bekommt alles
|
||||
4. **Skalierbarkeit:** System muss mit 20+ Apps funktionieren
|
||||
5. **Wartbarkeit:** Neue Apps einfach hinzufügen
|
||||
|
||||
### Warum die aktuelle Architektur nicht ausreicht
|
||||
|
||||
- **Separate Browser-Tabs:** Kein Drag & Drop zwischen Tabs möglich
|
||||
- **iFrames:** Drag & Drop über iFrame-Grenzen ist technisch problematisch (CORS, Event-Blocking)
|
||||
- **Keine geteilte State:** Jede App hat eigenen Svelte-Kontext
|
||||
|
||||
---
|
||||
|
||||
## Evaluierte Ansätze
|
||||
|
||||
### 1. Build-Time Feature Flags
|
||||
|
||||
**Konzept:** Zur Build-Zeit konfigurieren welche Apps inkludiert werden.
|
||||
|
||||
```bash
|
||||
ENABLED_APPS=calendar,todo pnpm build
|
||||
```
|
||||
|
||||
**Bewertung:**
|
||||
- ✅ Minimale Bundle-Size
|
||||
- ✅ Einfaches Konzept
|
||||
- ❌ Neuer Build pro Konfiguration nötig
|
||||
- ❌ Keine dynamische Aktivierung
|
||||
- ❌ Viele Build-Artefakte zu managen
|
||||
|
||||
**Fazit:** Zu unflexibel für unterschiedliche Deployment-Szenarien.
|
||||
|
||||
---
|
||||
|
||||
### 2. Plugin-Architektur (Runtime Loading)
|
||||
|
||||
**Konzept:** Apps als Plugins zur Laufzeit laden.
|
||||
|
||||
```typescript
|
||||
// manifest.json
|
||||
{
|
||||
"apps": [
|
||||
{ "id": "calendar", "enabled": true, "url": "/plugins/calendar.js" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Bewertung:**
|
||||
- ✅ Ein Build, viele Konfigurationen
|
||||
- ✅ Dynamische Aktivierung möglich
|
||||
- ❌ Komplexe Plugin-API
|
||||
- ❌ Versionierung zwischen Plugins
|
||||
- ❌ Initiale Ladezeit durch viele Chunks
|
||||
|
||||
**Fazit:** Guter Ansatz, aber zu komplex für unsere Anforderungen.
|
||||
|
||||
---
|
||||
|
||||
### 3. Monorepo mit Conditional Exports
|
||||
|
||||
**Konzept:** Alle Apps als separate Packages, verschiedene Entry-Points pro Deployment.
|
||||
|
||||
```
|
||||
packages/
|
||||
├── app-calendar/
|
||||
├── app-todo/
|
||||
└── ...
|
||||
|
||||
deployments/
|
||||
├── full/ # Alle Apps
|
||||
├── productivity/ # Calendar + Todo
|
||||
└── crm/ # Contacts + Calendar
|
||||
```
|
||||
|
||||
**Bewertung:**
|
||||
- ✅ Klare Package-Grenzen
|
||||
- ✅ Gutes Dependency-Management
|
||||
- ❌ Viele Packages zu maintainen
|
||||
- ❌ Versionskoordination aufwändig
|
||||
|
||||
**Fazit:** Solide, aber zu viel Overhead.
|
||||
|
||||
---
|
||||
|
||||
### 4. Monolith (Alle Apps in einer SvelteKit-Instanz)
|
||||
|
||||
**Konzept:** Alle Apps in eine einzige SvelteKit-App zusammenführen.
|
||||
|
||||
```
|
||||
src/routes/
|
||||
├── calendar/[...rest]
|
||||
├── todo/[...rest]
|
||||
└── contacts/[...rest]
|
||||
```
|
||||
|
||||
**Bewertung:**
|
||||
- ✅ Triviales Drag & Drop (alles im selben DOM)
|
||||
- ✅ Gemeinsamer Svelte-Store
|
||||
- ✅ Einfache Implementierung
|
||||
- ❌ **Keine flexiblen Deployments möglich**
|
||||
- ❌ Bundle enthält immer alle Apps
|
||||
- ❌ Skaliert schlecht bei 20+ Apps
|
||||
- ❌ Einzelne Apps nicht unabhängig deploybar
|
||||
|
||||
**Fazit:** Löst Drag & Drop, aber widerspricht den Modularitäts-Anforderungen.
|
||||
|
||||
---
|
||||
|
||||
### 5. Micro-Frontend Orchestrator (Gewählt)
|
||||
|
||||
**Konzept:** Kombination aus Shell-Anwendung, App-Registry und Build-Optimierung.
|
||||
|
||||
**Bewertung:**
|
||||
- ✅ Flexibel: Build-Time ODER Runtime-Konfiguration
|
||||
- ✅ Skaliert auf viele Apps
|
||||
- ✅ Klare Contracts zwischen Apps
|
||||
- ✅ Drag & Drop ist First-Class-Citizen
|
||||
- ✅ Code-Splitting out of the box
|
||||
- ✅ Apps können einzeln oder zusammen deployed werden
|
||||
- ⚠️ Initialer Setup-Aufwand
|
||||
|
||||
**Fazit:** Bester Trade-off zwischen Flexibilität und Komplexität.
|
||||
|
||||
---
|
||||
|
||||
## Gewählte Architektur: Micro-Frontend Orchestrator
|
||||
|
||||
### Architektur-Übersicht
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Workspace Shell │
|
||||
│ ┌───────────────┬─────────────────┬───────────────────┐ │
|
||||
│ │ App Registry │ Drag Context │ Split Router │ │
|
||||
│ └───────────────┴─────────────────┴───────────────────┘ │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ App Manifest │
|
||||
│ { "calendar": {...}, "todo": {...}, "contacts": {...} } │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
|
||||
│ │ Calendar │ │ Todo │ │ Contacts │ ... │
|
||||
│ │ Module │ │ Module │ │ Module │ │
|
||||
│ └────────────┘ └────────────┘ └────────────┘ │
|
||||
│ ↑ ↑ ↑ │
|
||||
│ └───────────────┴───────────────┘ │
|
||||
│ Shared Services Layer │
|
||||
│ (Auth, Theme, i18n, API Client, Drag/Drop) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Kernkomponenten
|
||||
|
||||
#### 1. Workspace Shell
|
||||
|
||||
Die äußere Hülle, die immer geladen wird:
|
||||
|
||||
- **Split-Pane Layout:** Rendert 1-2 App-Panels nebeneinander
|
||||
- **App Registry:** Kennt alle verfügbaren Apps und ihre Capabilities
|
||||
- **Drag Context:** Globaler Drag-Layer über allen Panels
|
||||
- **Navigation:** PillNavigation mit App-Switcher für Split-Screen
|
||||
|
||||
#### 2. App Module
|
||||
|
||||
Jede App ist ein eigenständiges Modul:
|
||||
|
||||
```typescript
|
||||
interface ManaAppModule {
|
||||
// Identifikation
|
||||
id: string; // 'calendar'
|
||||
name: string; // 'Kalender'
|
||||
icon: string; // App-Icon
|
||||
color: string; // Primärfarbe
|
||||
|
||||
// Capabilities
|
||||
draggable: DragType[]; // Was kann aus dieser App gedraggt werden?
|
||||
droppable: DropHandler[]; // Was kann diese App empfangen?
|
||||
|
||||
// UI
|
||||
component: SvelteComponent; // Haupt-Komponente
|
||||
routes: RouteDefinition[]; // Interne Routes
|
||||
|
||||
// Optional
|
||||
toolbar?: SvelteComponent; // App-spezifische Toolbar
|
||||
quickActions?: QuickAction[]; // Für QuickInputBar
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Drag & Drop Registry
|
||||
|
||||
Zentrale Koordination für Cross-App Drag & Drop:
|
||||
|
||||
```typescript
|
||||
// Drag-Types die Apps exportieren können
|
||||
type DragType =
|
||||
| 'contact' // Kontakt-Karte
|
||||
| 'event' // Kalender-Event
|
||||
| 'task' // Todo-Task
|
||||
| 'file' // Datei
|
||||
| 'note'; // Notiz
|
||||
|
||||
// Drop-Handler die Apps registrieren
|
||||
interface DropHandler {
|
||||
accepts: DragType[];
|
||||
zone: 'panel' | 'specific'; // Ganzes Panel oder spezifische Bereiche
|
||||
onDrop: (item: DragItem, target: DropTarget) => void;
|
||||
preview?: (item: DragItem) => SvelteComponent;
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Deployment-Konfiguration
|
||||
|
||||
```typescript
|
||||
// manacore.config.ts
|
||||
export default defineWorkspace({
|
||||
// Welche Apps in diesem Build?
|
||||
apps: ['calendar', 'todo', 'contacts'],
|
||||
|
||||
// Wie werden Apps geladen?
|
||||
loadStrategy: 'lazy', // 'eager' | 'lazy' | 'on-demand'
|
||||
|
||||
// Feature-Flags pro App
|
||||
features: {
|
||||
calendar: {
|
||||
views: ['week', 'month', 'agenda'],
|
||||
sharing: true,
|
||||
recurring: true,
|
||||
},
|
||||
todo: {
|
||||
projects: true,
|
||||
labels: true,
|
||||
recurring: false,
|
||||
},
|
||||
contacts: {
|
||||
import: true,
|
||||
export: true,
|
||||
googleSync: false,
|
||||
}
|
||||
},
|
||||
|
||||
// Erlaubte Drag & Drop Verbindungen
|
||||
connections: [
|
||||
{ from: 'contacts.contact', to: 'calendar.attendee' },
|
||||
{ from: 'contacts.contact', to: 'todo.assignee' },
|
||||
{ from: 'calendar.event', to: 'todo.task' },
|
||||
{ from: 'todo.task', to: 'calendar.event' },
|
||||
],
|
||||
|
||||
// Default Split-Screen Konfiguration
|
||||
defaultLayout: {
|
||||
primary: 'calendar',
|
||||
secondary: null, // Kein Split-Screen als Default
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Build-Output
|
||||
|
||||
```
|
||||
dist/
|
||||
├── shell.js # Workspace Shell (~50KB)
|
||||
├── shared.js # Shared Services (~100KB)
|
||||
├── manifest.json # App-Konfiguration
|
||||
└── apps/
|
||||
├── calendar.js # Calendar Module (lazy)
|
||||
├── calendar.css
|
||||
├── todo.js # Todo Module (lazy)
|
||||
├── todo.css
|
||||
├── contacts.js # Contacts Module (lazy)
|
||||
└── contacts.css
|
||||
```
|
||||
|
||||
### Split-Screen URL-Schema
|
||||
|
||||
```
|
||||
/workspace # Single App (Default)
|
||||
/workspace?app=calendar # Calendar alleine
|
||||
/workspace?left=calendar&right=todo # Split-Screen
|
||||
/workspace?left=calendar&right=todo&split=60 # 60/40 Split
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Drag & Drop Implementierung
|
||||
|
||||
### Globaler Drag-Layer
|
||||
|
||||
```svelte
|
||||
<!-- WorkspaceShell.svelte -->
|
||||
<div class="workspace">
|
||||
<!-- App Panels -->
|
||||
<div class="panels">
|
||||
<AppPanel app={leftApp} />
|
||||
{#if rightApp}
|
||||
<Divider />
|
||||
<AppPanel app={rightApp} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Globaler Drag Overlay (über allen Panels) -->
|
||||
<DragOverlay />
|
||||
</div>
|
||||
```
|
||||
|
||||
### Drag-Flow
|
||||
|
||||
1. **Drag Start:** App signalisiert Drag mit Type und Payload
|
||||
2. **Drag Move:** Element wird im globalen Overlay gerendert
|
||||
3. **Drag Over:** Drop-Zonen in allen Apps highlighten
|
||||
4. **Drop:** Ziel-App erhält Payload und verarbeitet ihn
|
||||
|
||||
### Beispiel: Kontakt auf Kalender droppen
|
||||
|
||||
```typescript
|
||||
// Contacts App registriert Draggable
|
||||
ContactsModule.draggable = [{
|
||||
type: 'contact',
|
||||
getData: (contact) => ({
|
||||
id: contact.id,
|
||||
name: contact.displayName,
|
||||
email: contact.email,
|
||||
}),
|
||||
preview: ContactDragPreview,
|
||||
}];
|
||||
|
||||
// Calendar App registriert Drop-Handler
|
||||
CalendarModule.droppable = [{
|
||||
accepts: ['contact'],
|
||||
zone: 'event-form', // Nur in Event-Formularen
|
||||
onDrop: (item) => {
|
||||
// Kontakt als Teilnehmer hinzufügen
|
||||
addAttendee({
|
||||
name: item.data.name,
|
||||
email: item.data.email,
|
||||
});
|
||||
},
|
||||
}];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment-Szenarien
|
||||
|
||||
### Szenario 1: Vollversion (SaaS)
|
||||
|
||||
```typescript
|
||||
// manacore.config.ts
|
||||
apps: ['calendar', 'todo', 'contacts', 'chat', 'files', 'notes', ...]
|
||||
```
|
||||
|
||||
Alle Apps verfügbar, User kann Split-Screen frei konfigurieren.
|
||||
|
||||
### Szenario 2: Produktivitäts-Suite
|
||||
|
||||
```typescript
|
||||
// productivity.config.ts
|
||||
apps: ['calendar', 'todo', 'notes']
|
||||
features: {
|
||||
calendar: { sharing: false }, // Keine Team-Features
|
||||
}
|
||||
```
|
||||
|
||||
Fokussiertes Bundle für Einzelnutzer.
|
||||
|
||||
### Szenario 3: CRM-Paket
|
||||
|
||||
```typescript
|
||||
// crm.config.ts
|
||||
apps: ['contacts', 'calendar', 'tasks']
|
||||
connections: [
|
||||
{ from: 'contacts.contact', to: 'calendar.attendee' },
|
||||
{ from: 'contacts.contact', to: 'tasks.assignee' },
|
||||
]
|
||||
```
|
||||
|
||||
Kontakt-zentriertes Bundle mit relevanten Verknüpfungen.
|
||||
|
||||
### Szenario 4: Single-App (Legacy-Kompatibilität)
|
||||
|
||||
```typescript
|
||||
// calendar-only.config.ts
|
||||
apps: ['calendar']
|
||||
features: {
|
||||
splitScreen: false, // Kein Split-Screen UI
|
||||
}
|
||||
```
|
||||
|
||||
Einzelne App wie bisher, für Migration bestehender User.
|
||||
|
||||
---
|
||||
|
||||
## Warum nicht Monolith?
|
||||
|
||||
Der Monolith-Ansatz (alle Apps in einer SvelteKit-Instanz) wurde bewusst **nicht gewählt**, obwohl er Drag & Drop trivial machen würde:
|
||||
|
||||
| Kriterium | Monolith | Orchestrator |
|
||||
|-----------|----------|--------------|
|
||||
| Drag & Drop | Trivial | Erfordert Koordination |
|
||||
| Bundle-Size | Immer alles | Nur aktivierte Apps |
|
||||
| Flexible Deployments | Nicht möglich | Beliebig konfigurierbar |
|
||||
| App-Unabhängigkeit | Keine | Vollständig |
|
||||
| Skalierung (20+ Apps) | Problematisch | Kein Problem |
|
||||
| Build-Zeit | Wächst mit jeder App | Konstant pro App |
|
||||
| Team-Arbeit | Merge-Konflikte | Unabhängige Entwicklung |
|
||||
|
||||
**Kernargument:** Bei wachsender App-Anzahl wird der Monolith unwartbar. Der Orchestrator skaliert linear, der Monolith exponentiell (in Komplexität).
|
||||
|
||||
---
|
||||
|
||||
## Migrations-Strategie
|
||||
|
||||
### Phase 1: Workspace Shell erstellen
|
||||
|
||||
- Neue App: `apps/workspace`
|
||||
- Implementiert Shell, Registry, Drag-Layer
|
||||
- Kann bestehende Apps als "Legacy iFrames" einbetten (Fallback)
|
||||
|
||||
### Phase 2: App-Module extrahieren
|
||||
|
||||
- Calendar, Todo, Contacts als Module refactoren
|
||||
- Gemeinsame Komponenten in Shared Services
|
||||
- Drag & Drop Contracts definieren
|
||||
|
||||
### Phase 3: Schrittweise Migration
|
||||
|
||||
- Eine App nach der anderen in Orchestrator integrieren
|
||||
- Parallelbetrieb: Standalone + Workspace
|
||||
- Alte Standalone-Deployments weiter unterstützen
|
||||
|
||||
### Phase 4: Neue Apps als Module
|
||||
|
||||
- Alle neuen Apps direkt als Module entwickeln
|
||||
- Einheitliche App-Template verwenden
|
||||
|
||||
---
|
||||
|
||||
## Offene Fragen
|
||||
|
||||
1. **Routing:** Wie verhalten sich Deep-Links im Split-Screen?
|
||||
2. **State-Sync:** Sollen Apps Änderungen in Echtzeit sehen?
|
||||
3. **Mobile:** Split-Screen nur Desktop oder auch Tablet?
|
||||
4. **Keyboard-Navigation:** Wie wechselt Fokus zwischen Panels?
|
||||
5. **Undo/Redo:** Global oder pro Panel?
|
||||
|
||||
---
|
||||
|
||||
## Nächste Schritte
|
||||
|
||||
1. [ ] Workspace Shell Proof-of-Concept
|
||||
2. [ ] Drag & Drop Registry implementieren
|
||||
3. [ ] Calendar als erstes Modul migrieren
|
||||
4. [ ] Split-Screen Layout mit Resize
|
||||
5. [ ] Deployment-Pipeline anpassen
|
||||
|
||||
---
|
||||
|
||||
## Referenzen
|
||||
|
||||
- [Module Federation](https://webpack.js.org/concepts/module-federation/)
|
||||
- [Micro Frontends](https://micro-frontends.org/)
|
||||
- [Svelte Context API](https://svelte.dev/docs#run-time-svelte-setcontext)
|
||||
- [HTML Drag and Drop API](https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API)
|
||||
|
|
@ -10,6 +10,7 @@ Dieses Verzeichnis dokumentiert zentrale Services, die von allen Manacore-Apps g
|
|||
| **Theming** | Theme-Varianten, Dark Mode, Accessibility, Custom Themes | [THEMING.md](./THEMING.md) |
|
||||
| **Help** | Zentrale Hilfeseite mit FAQ, Features, Shortcuts, Changelog | [HELP.md](./HELP.md) |
|
||||
| **Command Bar** | Globale Schnellsuche und Navigation (Cmd/Ctrl+K) | [COMMAND-BAR.md](./COMMAND-BAR.md) |
|
||||
| **Split-Screen** | Zwei Apps nebeneinander im Browser (iFrame-basiert) | [SPLIT-SCREEN.md](./SPLIT-SCREEN.md) |
|
||||
|
||||
## Architektur-Prinzipien
|
||||
|
||||
|
|
@ -80,6 +81,7 @@ pnpm db:push
|
|||
| `@manacore/shared-help-content` | Content-Loader, Parser, Merger, Suche |
|
||||
| `@manacore/shared-help-ui` | Svelte UI-Komponenten für Hilfeseite |
|
||||
| `@manacore/shared-help-mobile` | React Native Komponenten für Hilfe |
|
||||
| `@manacore/shared-splitscreen` | Split-Screen Container, Store, Komponenten |
|
||||
|
||||
## Hinzufügen neuer zentraler Services
|
||||
|
||||
|
|
|
|||
375
docs/central-services/SPLIT-SCREEN.md
Normal file
375
docs/central-services/SPLIT-SCREEN.md
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
# Split-Screen Feature
|
||||
|
||||
Das Split-Screen Feature ermöglicht es, zwei ManaCore-Apps nebeneinander in einem Browser-Tab anzuzeigen. Die rechte App wird dabei in einem iFrame eingebettet.
|
||||
|
||||
## Übersicht
|
||||
|
||||
| Aspekt | Details |
|
||||
|--------|---------|
|
||||
| **Package** | `@manacore/shared-splitscreen` |
|
||||
| **Integrierte Apps** | Calendar, Todo, Contacts |
|
||||
| **Aktivierung** | Split-Button in App-Dropdown oder Ctrl/Cmd+Klick |
|
||||
| **Persistenz** | URL-Parameter + localStorage |
|
||||
| **Mobile** | Automatisch deaktiviert (<1024px) |
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ SplitPaneContainer │
|
||||
├─────────────────────────┬───────┬───────────────────────────────┤
|
||||
│ │ │ │
|
||||
│ Main Panel │ ║ ║ ║ │ Side Panel │
|
||||
│ (aktuelle App) │ ║ ║ ║ │ (iFrame mit App) │
|
||||
│ │ ║ ║ ║ │ │
|
||||
│ ┌─────────────────┐ │ Resize│ ┌─────────────────────────┐ │
|
||||
│ │ PillNavigation │ │ Handle│ │ AppPanel │ │
|
||||
│ └─────────────────┘ │ │ │ ┌─────────────────────┐│ │
|
||||
│ │ │ │ │ PanelControls ││ │
|
||||
│ ┌─────────────────┐ │ │ │ │ [Swap] [Close] ││ │
|
||||
│ │ Content │ │ │ │ └─────────────────────┘│ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ │ │ │ │ ┌─────────────────────┐│ │
|
||||
│ │ │ │ │ │ │ ││ │
|
||||
│ │ │ │ │ │ │ <iframe> ││ │
|
||||
│ │ │ │ │ │ │ ││ │
|
||||
│ │ │ │ │ │ └─────────────────────┘│ │
|
||||
│ └─────────────────┘ │ │ └─────────────────────────┘ │
|
||||
│ │ │ │
|
||||
└─────────────────────────┴───────┴───────────────────────────────┘
|
||||
```
|
||||
|
||||
## Package-Struktur
|
||||
|
||||
```
|
||||
packages/shared-splitscreen/
|
||||
├── src/
|
||||
│ ├── index.ts # Barrel exports
|
||||
│ ├── types.ts # TypeScript types
|
||||
│ ├── stores/
|
||||
│ │ └── split-panel.svelte.ts # Svelte 5 runes store mit Context API
|
||||
│ ├── components/
|
||||
│ │ ├── SplitPaneContainer.svelte # Haupt-Layout (CSS Grid)
|
||||
│ │ ├── AppPanel.svelte # iFrame-Container
|
||||
│ │ ├── PanelControls.svelte # Swap/Close Buttons
|
||||
│ │ └── ResizeHandle.svelte # Draggable Divider
|
||||
│ └── utils/
|
||||
│ ├── url-state.ts # URL-Persistenz (?panel=todo&split=60)
|
||||
│ ├── local-storage.ts # localStorage-Persistenz
|
||||
│ └── index.ts # Utils barrel
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
## Komponenten
|
||||
|
||||
### SplitPaneContainer
|
||||
|
||||
Haupt-Container mit CSS Grid Layout.
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { SplitPaneContainer, setSplitPanelContext, DEFAULT_APPS } from '@manacore/shared-splitscreen';
|
||||
|
||||
// Context initialisieren
|
||||
const splitPanel = setSplitPanelContext('calendar', DEFAULT_APPS);
|
||||
</script>
|
||||
|
||||
<SplitPaneContainer>
|
||||
<!-- Dein App-Content -->
|
||||
<slot />
|
||||
</SplitPaneContainer>
|
||||
```
|
||||
|
||||
**CSS Grid:**
|
||||
- Ohne Split: `grid-template-columns: 1fr`
|
||||
- Mit Split: `grid-template-columns: {dividerPos}% 6px 1fr`
|
||||
|
||||
### AppPanel
|
||||
|
||||
iFrame-Container mit Loading/Error States.
|
||||
|
||||
**iFrame Sandbox Permissions:**
|
||||
```typescript
|
||||
const sandboxPermissions = [
|
||||
'allow-same-origin', // Für localStorage/Cookie-Zugriff
|
||||
'allow-scripts', // JavaScript ausführen
|
||||
'allow-forms', // Formulare absenden
|
||||
'allow-popups', // Popups öffnen
|
||||
'allow-popups-to-escape-sandbox',
|
||||
'allow-storage-access-by-user-activation',
|
||||
];
|
||||
```
|
||||
|
||||
### ResizeHandle
|
||||
|
||||
Draggable Divider für Panel-Größenanpassung.
|
||||
|
||||
**Features:**
|
||||
- Maus- und Touch-Support
|
||||
- Tastatur-Navigation (Pfeiltasten)
|
||||
- Doppelklick = Reset auf 50%
|
||||
- Constraints: 20% - 80%
|
||||
|
||||
**Accessibility:**
|
||||
```svelte
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-valuenow={position}
|
||||
aria-valuemin={20}
|
||||
aria-valuemax={80}
|
||||
tabindex="0"
|
||||
/>
|
||||
```
|
||||
|
||||
### PanelControls
|
||||
|
||||
Overlay mit Swap- und Close-Buttons.
|
||||
|
||||
- **Swap:** Navigiert zur App im rechten Panel (window.location.href)
|
||||
- **Close:** Schließt das Split-Panel
|
||||
|
||||
## Store API
|
||||
|
||||
### Initialisierung
|
||||
|
||||
```typescript
|
||||
import { setSplitPanelContext, DEFAULT_APPS } from '@manacore/shared-splitscreen';
|
||||
|
||||
// Im Layout-Component (z.B. +layout.svelte)
|
||||
const splitPanel = setSplitPanelContext('calendar', DEFAULT_APPS);
|
||||
|
||||
// In onMount initialisieren (lädt URL/localStorage State)
|
||||
onMount(() => {
|
||||
splitPanel.initialize();
|
||||
});
|
||||
```
|
||||
|
||||
### Store Interface
|
||||
|
||||
```typescript
|
||||
interface SplitPanelStore {
|
||||
// State (readonly)
|
||||
readonly isActive: boolean; // Split-Modus aktiv?
|
||||
readonly rightPanel: PanelConfig | null; // Rechtes Panel
|
||||
readonly dividerPosition: number; // Position in % (20-80)
|
||||
readonly isMobile: boolean; // Mobile Breakpoint erreicht?
|
||||
readonly availableApps: AppDefinition[]; // Verfügbare Apps (ohne aktuelle)
|
||||
|
||||
// Actions
|
||||
openPanel: (appId: string, path?: string) => void;
|
||||
closePanel: () => void;
|
||||
swapPanels: () => void;
|
||||
setDividerPosition: (position: number) => void;
|
||||
resetDividerPosition: () => void;
|
||||
initialize: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
### Context-Zugriff
|
||||
|
||||
```typescript
|
||||
import { getSplitPanelContext } from '@manacore/shared-splitscreen';
|
||||
|
||||
// In Child-Components
|
||||
const splitPanel = getSplitPanelContext();
|
||||
```
|
||||
|
||||
## Verfügbare Apps
|
||||
|
||||
Standard-Konfiguration in `DEFAULT_APPS`:
|
||||
|
||||
| App | ID | Port | Farbe |
|
||||
|-----|----|------|-------|
|
||||
| Calendar | `calendar` | 5179 | #3b82f6 |
|
||||
| Todo | `todo` | 5188 | #10b981 |
|
||||
| Contacts | `contacts` | 5184 | #8b5cf6 |
|
||||
| Clock | `clock` | 5187 | #f59e0b |
|
||||
|
||||
## Persistenz
|
||||
|
||||
### URL-State
|
||||
|
||||
```
|
||||
https://calendar.app/?panel=todo&split=60
|
||||
```
|
||||
|
||||
- `panel`: App-ID des rechten Panels
|
||||
- `split`: Divider-Position in % (nur wenn ≠ 50)
|
||||
|
||||
### localStorage
|
||||
|
||||
```typescript
|
||||
// Key: manacore-splitscreen-{appId}
|
||||
{
|
||||
"version": 1,
|
||||
"state": {
|
||||
"dividerPosition": 60,
|
||||
"rightPanel": {
|
||||
"appId": "todo",
|
||||
"url": "http://localhost:5188/",
|
||||
"name": "Todo"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Priorität:** URL > localStorage
|
||||
|
||||
## Integration in Apps
|
||||
|
||||
### 1. Dependency hinzufügen
|
||||
|
||||
```json
|
||||
// package.json
|
||||
{
|
||||
"dependencies": {
|
||||
"@manacore/shared-splitscreen": "workspace:*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Layout anpassen
|
||||
|
||||
```svelte
|
||||
<!-- +layout.svelte -->
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { PillNavigation } from '@manacore/shared-ui';
|
||||
import {
|
||||
SplitPaneContainer,
|
||||
setSplitPanelContext,
|
||||
DEFAULT_APPS,
|
||||
} from '@manacore/shared-splitscreen';
|
||||
|
||||
// Split-Panel Store initialisieren
|
||||
const splitPanel = setSplitPanelContext('calendar', DEFAULT_APPS);
|
||||
|
||||
// Handler für Split-Panel Öffnung
|
||||
function handleOpenInPanel(appId: string, url: string) {
|
||||
splitPanel.openPanel(appId);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
splitPanel.initialize();
|
||||
});
|
||||
</script>
|
||||
|
||||
<SplitPaneContainer>
|
||||
<div class="layout-container">
|
||||
<PillNavigation
|
||||
{...props}
|
||||
onOpenInPanel={handleOpenInPanel}
|
||||
/>
|
||||
|
||||
<main>
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
</SplitPaneContainer>
|
||||
```
|
||||
|
||||
### 3. PillNavigation Props
|
||||
|
||||
Die `PillNavigation`-Komponente unterstützt automatisch:
|
||||
|
||||
- **Split-Button:** Erscheint bei jedem App-Item im Dropdown
|
||||
- **Modifier-Key Detection:** Ctrl/Cmd+Klick öffnet im Split-Panel
|
||||
|
||||
```typescript
|
||||
// Neues Prop für PillNavigation
|
||||
onOpenInPanel?: (appId: string, url: string) => void;
|
||||
```
|
||||
|
||||
## Mobile Verhalten
|
||||
|
||||
Split-Screen ist auf mobilen Geräten deaktiviert:
|
||||
|
||||
```typescript
|
||||
const MOBILE_BREAKPOINT = 1024; // px
|
||||
|
||||
// Automatische Prüfung bei Resize
|
||||
window.addEventListener('resize', () => {
|
||||
if (window.innerWidth < MOBILE_BREAKPOINT && isActive) {
|
||||
closePanel();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**CSS Fallback:**
|
||||
```css
|
||||
@media (max-width: 1023px) {
|
||||
.split-pane-container {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
.side-panel,
|
||||
.resize-handle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Constraints
|
||||
|
||||
| Constraint | Wert |
|
||||
|------------|------|
|
||||
| Min. Divider Position | 20% |
|
||||
| Max. Divider Position | 80% |
|
||||
| Default Position | 50% |
|
||||
| Mobile Breakpoint | 1024px |
|
||||
| Resize Handle Breite | 6px |
|
||||
|
||||
## Bekannte Einschränkungen
|
||||
|
||||
1. **Keine Cross-App Kommunikation:** Apps im iFrame können nicht direkt kommunizieren
|
||||
2. **Separate Auth Sessions:** Jede App hat ihre eigene Session (funktioniert wegen shared localStorage)
|
||||
3. **Kein Drag & Drop zwischen Apps:** Feature wurde bewusst nicht implementiert
|
||||
4. **Performance:** iFrame lädt komplette App, kann bei langsamen Verbindungen spürbar sein
|
||||
|
||||
## Zukünftige Erweiterungen
|
||||
|
||||
- [ ] postMessage API für Cross-App Events
|
||||
- [ ] Mehr als 2 Panels
|
||||
- [ ] Panel Templates / Presets
|
||||
- [ ] Keyboard Shortcuts (Cmd+\ zum Togglen)
|
||||
- [ ] Panel-Position speichern per App-Kombination
|
||||
|
||||
## Debugging
|
||||
|
||||
### DevTools
|
||||
|
||||
```javascript
|
||||
// State prüfen
|
||||
const store = getSplitPanelContext();
|
||||
console.log({
|
||||
isActive: store.isActive,
|
||||
rightPanel: store.rightPanel,
|
||||
dividerPosition: store.dividerPosition,
|
||||
isMobile: store.isMobile,
|
||||
});
|
||||
|
||||
// Manuell öffnen
|
||||
store.openPanel('todo');
|
||||
|
||||
// Manuell schließen
|
||||
store.closePanel();
|
||||
```
|
||||
|
||||
### URL testen
|
||||
|
||||
```
|
||||
# Todo im Split öffnen
|
||||
http://localhost:5179/?panel=todo
|
||||
|
||||
# Mit angepasster Position
|
||||
http://localhost:5179/?panel=contacts&split=70
|
||||
```
|
||||
|
||||
### localStorage löschen
|
||||
|
||||
```javascript
|
||||
localStorage.removeItem('manacore-splitscreen-calendar');
|
||||
localStorage.removeItem('manacore-splitscreen-todo');
|
||||
localStorage.removeItem('manacore-splitscreen-contacts');
|
||||
```
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Central Theming System
|
||||
|
||||
Das zentrale Theming-System ermöglicht einheitliches Aussehen und Benutzereinstellungen über alle Manacore-Apps hinweg. Es besteht aus mehreren Schichten: Theme-Varianten, Light/Dark-Modus, Accessibility-Einstellungen und Custom Themes.
|
||||
Das zentrale Theming-System ermöglicht einheitliches Aussehen und Benutzereinstellungen über alle Manacore-Apps hinweg. Es besteht aus mehreren Schichten: Theme-Varianten, Light/Dark-Modus und Accessibility-Einstellungen.
|
||||
|
||||
## Architektur
|
||||
|
||||
|
|
@ -14,11 +14,6 @@ Das zentrale Theming-System ermöglicht einheitliches Aussehen und Benutzereinst
|
|||
│ │ - locale: "de" │ │
|
||||
│ │ - general: { startPages, sounds, etc. } │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ custom_themes Tabelle (Community Themes) │ │
|
||||
│ │ - lightColors, darkColors, author, downloads, etc. │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌────────────────────┼────────────────────┐
|
||||
|
|
@ -45,18 +40,18 @@ Es gibt 8 vordefinierte Theme-Varianten:
|
|||
### Standard-Varianten (PillNav)
|
||||
| Name | Farbe | Icon | Hue |
|
||||
|------|-------|------|-----|
|
||||
| `lume` | Gold ✨ | sparkle | 47 |
|
||||
| `nature` | Grün 🌿 | leaf | 122 |
|
||||
| `stone` | Blau-Grau 🪨 | hexagon | 200 |
|
||||
| `ocean` | Blau 🌊 | waves | 199 |
|
||||
| `lume` | Gold | sparkle | 47 |
|
||||
| `nature` | Grün | leaf | 122 |
|
||||
| `stone` | Blau-Grau | hexagon | 200 |
|
||||
| `ocean` | Blau | waves | 199 |
|
||||
|
||||
### Erweiterte Varianten (Themes-Seite)
|
||||
| Name | Farbe | Icon | Hue |
|
||||
|------|-------|------|-----|
|
||||
| `sunset` | Coral/Orange 🌅 | sun | 15 |
|
||||
| `midnight` | Violett 🌙 | moon | 260 |
|
||||
| `rose` | Pink 🌹 | flower | 340 |
|
||||
| `lavender` | Lavendel 💜 | sparkle | 270 |
|
||||
| `sunset` | Coral/Orange | sun | 15 |
|
||||
| `midnight` | Violett | moon | 260 |
|
||||
| `rose` | Pink | flower | 340 |
|
||||
| `lavender` | Lavendel | sparkle | 270 |
|
||||
|
||||
## Theme-Modus
|
||||
|
||||
|
|
@ -223,55 +218,6 @@ a11y.resetAll();
|
|||
- Respektiert `prefers-reduced-motion`
|
||||
- Kann manuell überschrieben werden
|
||||
|
||||
## Custom Themes
|
||||
|
||||
### Custom Themes Store
|
||||
|
||||
```typescript
|
||||
import { createCustomThemesStore } from '@manacore/shared-theme';
|
||||
|
||||
export const customThemes = createCustomThemesStore({
|
||||
authUrl: 'http://localhost:3001',
|
||||
getAccessToken: () => authStore.getAccessToken(),
|
||||
});
|
||||
|
||||
// Eigene Themes laden
|
||||
await customThemes.loadCustomThemes();
|
||||
|
||||
// Theme erstellen
|
||||
const newTheme = await customThemes.createTheme({
|
||||
name: 'Mein Theme',
|
||||
emoji: '🎨',
|
||||
lightColors: { primary: '200 80% 50%', ... },
|
||||
darkColors: { primary: '200 70% 60%', ... },
|
||||
});
|
||||
|
||||
// Community Themes durchsuchen
|
||||
await customThemes.browseCommunity({
|
||||
sort: 'popular',
|
||||
search: 'dark',
|
||||
});
|
||||
|
||||
// Theme herunterladen
|
||||
await customThemes.downloadTheme(themeId);
|
||||
|
||||
// Theme anwenden
|
||||
customThemes.applyCustomTheme(theme);
|
||||
```
|
||||
|
||||
### Theme Editor
|
||||
|
||||
Der Theme Editor erlaubt das visuelle Erstellen von Themes:
|
||||
|
||||
**Hauptfarben (immer sichtbar):**
|
||||
- Primary, Background, Surface, Foreground
|
||||
- Error, Success, Warning
|
||||
|
||||
**Erweiterte Farben (zugeklappt):**
|
||||
- PrimaryForeground, Secondary, SecondaryForeground
|
||||
- SurfaceHover, SurfaceElevated, Muted, MutedForeground
|
||||
- Border, BorderStrong, Input, Ring
|
||||
|
||||
## UI-Komponenten
|
||||
|
||||
### ThemePage
|
||||
|
|
@ -285,10 +231,13 @@ Vollständige Themes-Seite mit allen Optionen:
|
|||
</script>
|
||||
|
||||
<ThemePage
|
||||
themeStore={theme}
|
||||
currentVariant={theme.variant}
|
||||
onSelectTheme={(v) => theme.setVariant(v)}
|
||||
showModeSelector={true}
|
||||
currentMode={theme.mode}
|
||||
onModeChange={(m) => theme.setMode(m)}
|
||||
a11yStore={a11y}
|
||||
showAccessibility={true}
|
||||
showPinnedThemes={true}
|
||||
showA11ySettings={true}
|
||||
/>
|
||||
```
|
||||
|
||||
|
|
@ -406,7 +355,6 @@ theme: {
|
|||
| `src/a11y-constants.ts` | A11y Konstanten |
|
||||
| `src/a11y-utils.ts` | A11y Helper |
|
||||
| `src/user-settings-store.svelte.ts` | Server-Sync Store |
|
||||
| `src/custom-themes-store.svelte.ts` | Custom Themes Store |
|
||||
| `src/utils.ts` | Theme Utilities |
|
||||
| `src/app-routes.ts` | Start-Page Konfiguration |
|
||||
|
||||
|
|
@ -420,11 +368,7 @@ theme: {
|
|||
| `src/components/ThemeCard.svelte` | Theme-Vorschau Karte |
|
||||
| `src/components/ThemeGrid.svelte` | Grid von Theme-Karten |
|
||||
| `src/components/A11ySettings.svelte` | A11y Einstellungen |
|
||||
| `src/components/editor/` | Theme Editor Komponenten |
|
||||
| `src/components/community/` | Community Themes Komponenten |
|
||||
| `src/pages/ThemePage.svelte` | Vollständige Themes-Seite |
|
||||
| `src/pages/ThemeEditorPage.svelte` | Theme Editor Seite |
|
||||
| `src/pages/CommunityThemesPage.svelte` | Community Themes |
|
||||
|
||||
## Integration in eine App
|
||||
|
||||
|
|
@ -485,7 +429,15 @@ export const a11y = createA11yStore({
|
|||
import { theme, a11y } from '$lib/stores/theme';
|
||||
</script>
|
||||
|
||||
<ThemePage themeStore={theme} a11yStore={a11y} />
|
||||
<ThemePage
|
||||
currentVariant={theme.variant}
|
||||
onSelectTheme={(v) => theme.setVariant(v)}
|
||||
showModeSelector={true}
|
||||
currentMode={theme.mode}
|
||||
onModeChange={(m) => theme.setMode(m)}
|
||||
a11yStore={a11y}
|
||||
showA11ySettings={true}
|
||||
/>
|
||||
```
|
||||
|
||||
## Vorteile
|
||||
|
|
@ -493,5 +445,4 @@ export const a11y = createA11yStore({
|
|||
- **Konsistenz:** Alle Apps sehen einheitlich aus
|
||||
- **User Experience:** Theme-Einstellungen werden gespeichert
|
||||
- **Accessibility:** Barrierefreiheit ist eingebaut
|
||||
- **Anpassbarkeit:** Nutzer können eigene Themes erstellen
|
||||
- **Community:** Themes können geteilt werden
|
||||
- **Einfachheit:** 8 vordefinierte Themes zur Auswahl
|
||||
|
|
|
|||
418
docs/optimizable/foundation-layer-improvements.md
Normal file
418
docs/optimizable/foundation-layer-improvements.md
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
# Foundation Layer - Verbesserungsvorschläge
|
||||
|
||||
> **Stand:** Dezember 2024
|
||||
> **Betrifft:** Contacts, Todo, Calendar (Foundation Services)
|
||||
|
||||
## Aktuelle Architektur (Gut!)
|
||||
|
||||
Die drei Foundation Services sind korrekt als **separate Services mit eigenen Datenbanken** aufgesetzt:
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Contacts │ │ Todo │ │ Calendar │
|
||||
│ :3010 │ │ :3011 │ │ :3012 │
|
||||
│ │ │ │ │ │
|
||||
│ contacts DB │ │ todo DB │ │ calendar DB │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
**Warum das richtig ist:**
|
||||
- Unabhängige Deployments
|
||||
- Failure Isolation
|
||||
- Unabhängige Skalierung
|
||||
- Keine Schema-Konflikte zwischen Teams
|
||||
|
||||
---
|
||||
|
||||
## Verbesserungsvorschläge
|
||||
|
||||
### 1. Foundation Clients Package
|
||||
|
||||
**Aufwand:** Mittel | **Priorität:** Hoch
|
||||
|
||||
Einheitlicher API-Client für alle Consumer Apps (Chat, Picture, Clock, etc.).
|
||||
|
||||
**Neues Package:** `packages/foundation-clients/`
|
||||
|
||||
```typescript
|
||||
// packages/foundation-clients/src/index.ts
|
||||
export class FoundationClients {
|
||||
contacts: ContactsClient;
|
||||
todo: TodoClient;
|
||||
calendar: CalendarClient;
|
||||
|
||||
constructor(config: FoundationConfig) {
|
||||
this.contacts = new ContactsClient(config);
|
||||
this.todo = new TodoClient(config);
|
||||
this.calendar = new CalendarClient(config);
|
||||
}
|
||||
}
|
||||
|
||||
// packages/foundation-clients/src/contacts.client.ts
|
||||
export class ContactsClient {
|
||||
private baseUrl: string;
|
||||
private cache: Map<string, CachedContact> = new Map();
|
||||
|
||||
async get(id: string): Promise<Contact | null> {
|
||||
// Mit Caching
|
||||
const cached = this.cache.get(id);
|
||||
if (cached && !this.isStale(cached)) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/contacts/${id}`, {
|
||||
headers: { Authorization: `Bearer ${this.token}` }
|
||||
});
|
||||
|
||||
if (!response.ok) return null;
|
||||
const contact = await response.json();
|
||||
this.cache.set(id, { data: contact, fetchedAt: Date.now() });
|
||||
return contact;
|
||||
}
|
||||
|
||||
async search(query: string): Promise<ContactSummary[]> {
|
||||
// Für Autocomplete in anderen Apps
|
||||
}
|
||||
|
||||
async getBulk(ids: string[]): Promise<Contact[]> {
|
||||
// Effizient für Listen
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Nutzung in Consumer Apps:**
|
||||
|
||||
```typescript
|
||||
// apps/chat/apps/backend/src/chat.service.ts
|
||||
import { FoundationClients } from '@manacore/foundation-clients';
|
||||
|
||||
@Injectable()
|
||||
export class ChatService {
|
||||
private foundation: FoundationClients;
|
||||
|
||||
constructor(configService: ConfigService) {
|
||||
this.foundation = new FoundationClients({
|
||||
contactsUrl: configService.get('CONTACTS_API_URL'),
|
||||
todoUrl: configService.get('TODO_API_URL'),
|
||||
calendarUrl: configService.get('CALENDAR_API_URL'),
|
||||
});
|
||||
}
|
||||
|
||||
async getMessageWithContact(messageId: string) {
|
||||
const message = await this.getMessage(messageId);
|
||||
const sender = await this.foundation.contacts.get(message.senderId);
|
||||
return { ...message, sender };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Event Bus (Redis Pub/Sub)
|
||||
|
||||
**Aufwand:** Mittel | **Priorität:** Mittel
|
||||
|
||||
Ermöglicht reaktive Updates zwischen Services ohne Polling.
|
||||
|
||||
**Events definieren:**
|
||||
|
||||
```typescript
|
||||
// packages/foundation-events/src/index.ts
|
||||
export const FoundationEvents = {
|
||||
// Contacts
|
||||
CONTACT_CREATED: 'contact.created',
|
||||
CONTACT_UPDATED: 'contact.updated',
|
||||
CONTACT_DELETED: 'contact.deleted',
|
||||
|
||||
// Todo
|
||||
TASK_CREATED: 'task.created',
|
||||
TASK_COMPLETED: 'task.completed',
|
||||
TASK_DELETED: 'task.deleted',
|
||||
|
||||
// Calendar
|
||||
EVENT_CREATED: 'event.created',
|
||||
EVENT_UPDATED: 'event.updated',
|
||||
EVENT_DELETED: 'event.deleted',
|
||||
} as const;
|
||||
|
||||
export interface TaskCompletedEvent {
|
||||
taskId: string;
|
||||
userId: string;
|
||||
completedAt: string;
|
||||
linkedCalendarEventId?: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Publisher (Todo Service):**
|
||||
|
||||
```typescript
|
||||
// apps/todo/apps/backend/src/task/task.service.ts
|
||||
import { RedisService } from '@manacore/shared-redis';
|
||||
import { FoundationEvents } from '@manacore/foundation-events';
|
||||
|
||||
@Injectable()
|
||||
export class TaskService {
|
||||
constructor(private redis: RedisService) {}
|
||||
|
||||
async completeTask(taskId: string, userId: string) {
|
||||
const task = await this.markCompleted(taskId);
|
||||
|
||||
// Event publizieren
|
||||
await this.redis.publish(FoundationEvents.TASK_COMPLETED, {
|
||||
taskId: task.id,
|
||||
userId,
|
||||
completedAt: new Date().toISOString(),
|
||||
linkedCalendarEventId: task.metadata?.linkedCalendarEventId,
|
||||
});
|
||||
|
||||
return task;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Subscriber (Calendar Service):**
|
||||
|
||||
```typescript
|
||||
// apps/calendar/apps/backend/src/calendar.module.ts
|
||||
import { FoundationEvents } from '@manacore/foundation-events';
|
||||
|
||||
@Injectable()
|
||||
export class CalendarEventSubscriber implements OnModuleInit {
|
||||
constructor(
|
||||
private redis: RedisService,
|
||||
private eventService: EventService
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.redis.subscribe(FoundationEvents.TASK_COMPLETED, async (data) => {
|
||||
if (data.linkedCalendarEventId) {
|
||||
await this.eventService.markLinkedTaskCompleted(
|
||||
data.linkedCalendarEventId
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Use Cases:**
|
||||
|
||||
| Event | Reaktion |
|
||||
|-------|----------|
|
||||
| `task.completed` | Calendar markiert verknüpftes Event |
|
||||
| `contact.updated` | Chat aktualisiert Sender-Anzeige |
|
||||
| `event.deleted` | Todo entfernt `linkedCalendarEventId` |
|
||||
| `contact.deleted` | Alle Apps entfernen Kontakt-Referenzen |
|
||||
|
||||
---
|
||||
|
||||
### 3. Bulk-Endpoints
|
||||
|
||||
**Aufwand:** Klein | **Priorität:** Hoch
|
||||
|
||||
Reduziert N+1 API-Calls bei Listen-Ansichten.
|
||||
|
||||
**Contacts Service:**
|
||||
|
||||
```typescript
|
||||
// apps/contacts/apps/backend/src/contact/contact.controller.ts
|
||||
@Controller('contacts')
|
||||
export class ContactController {
|
||||
@Post('bulk')
|
||||
async getBulk(@Body() body: { ids: string[] }): Promise<Contact[]> {
|
||||
return this.contactService.findByIds(body.ids);
|
||||
}
|
||||
|
||||
@Get('search')
|
||||
async search(
|
||||
@Query('q') query: string,
|
||||
@Query('limit') limit = 10
|
||||
): Promise<ContactSummary[]> {
|
||||
return this.contactService.search(query, limit);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Todo Service:**
|
||||
|
||||
```typescript
|
||||
// apps/todo/apps/backend/src/task/task.controller.ts
|
||||
@Controller('tasks')
|
||||
export class TaskController {
|
||||
@Post('bulk')
|
||||
async getBulk(@Body() body: { ids: string[] }): Promise<Task[]> {
|
||||
return this.taskService.findByIds(body.ids);
|
||||
}
|
||||
|
||||
@Get('by-contact/:contactId')
|
||||
async getByContact(@Param('contactId') contactId: string): Promise<Task[]> {
|
||||
// Tasks die mit einem Kontakt verknüpft sind
|
||||
return this.taskService.findByLinkedContact(contactId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Calendar Service:**
|
||||
|
||||
```typescript
|
||||
// apps/calendar/apps/backend/src/event/event.controller.ts
|
||||
@Controller('events')
|
||||
export class EventController {
|
||||
@Post('bulk')
|
||||
async getBulk(@Body() body: { ids: string[] }): Promise<Event[]> {
|
||||
return this.eventService.findByIds(body.ids);
|
||||
}
|
||||
|
||||
@Get('by-attendee')
|
||||
async getByAttendee(@Query('email') email: string): Promise<Event[]> {
|
||||
return this.eventService.findByAttendeeEmail(email);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Caching-Layer
|
||||
|
||||
**Aufwand:** Klein | **Priorität:** Mittel
|
||||
|
||||
Kontakte ändern sich selten - perfekt für Caching.
|
||||
|
||||
**In Foundation Clients (Client-Side Cache):**
|
||||
|
||||
```typescript
|
||||
// packages/foundation-clients/src/cache.ts
|
||||
export class SimpleCache<T> {
|
||||
private cache = new Map<string, { data: T; expiresAt: number }>();
|
||||
private ttlMs: number;
|
||||
|
||||
constructor(ttlSeconds = 300) {
|
||||
this.ttlMs = ttlSeconds * 1000;
|
||||
}
|
||||
|
||||
get(key: string): T | null {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) return null;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
set(key: string, data: T): void {
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
expiresAt: Date.now() + this.ttlMs,
|
||||
});
|
||||
}
|
||||
|
||||
invalidate(key: string): void {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Redis Cache (Server-Side):**
|
||||
|
||||
```typescript
|
||||
// apps/contacts/apps/backend/src/contact/contact.service.ts
|
||||
@Injectable()
|
||||
export class ContactService {
|
||||
private readonly CACHE_TTL = 300; // 5 Minuten
|
||||
|
||||
async findById(id: string): Promise<Contact | null> {
|
||||
// 1. Redis Cache prüfen
|
||||
const cached = await this.redis.get(`contact:${id}`);
|
||||
if (cached) return JSON.parse(cached);
|
||||
|
||||
// 2. DB Query
|
||||
const contact = await this.db
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(eq(contacts.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (contact[0]) {
|
||||
// 3. In Cache speichern
|
||||
await this.redis.setex(
|
||||
`contact:${id}`,
|
||||
this.CACHE_TTL,
|
||||
JSON.stringify(contact[0])
|
||||
);
|
||||
}
|
||||
|
||||
return contact[0] || null;
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateContactDto): Promise<Contact> {
|
||||
const updated = await this.db
|
||||
.update(contacts)
|
||||
.set(data)
|
||||
.where(eq(contacts.id, id))
|
||||
.returning();
|
||||
|
||||
// Cache invalidieren
|
||||
await this.redis.del(`contact:${id}`);
|
||||
|
||||
return updated[0];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementierungs-Reihenfolge
|
||||
|
||||
| Phase | Task | Abhängigkeiten |
|
||||
|-------|------|----------------|
|
||||
| **1** | Bulk-Endpoints hinzufügen | Keine |
|
||||
| **2** | Foundation Clients Package erstellen | Bulk-Endpoints |
|
||||
| **3** | Client-Side Caching in Foundation Clients | Foundation Clients |
|
||||
| **4** | Redis Cache in Services | Redis Setup |
|
||||
| **5** | Event Bus Setup | Redis Setup |
|
||||
| **6** | Event Publisher/Subscriber | Event Bus |
|
||||
|
||||
---
|
||||
|
||||
## Neue Package-Struktur
|
||||
|
||||
```
|
||||
packages/
|
||||
├── foundation-clients/ # NEU: API Clients
|
||||
│ ├── src/
|
||||
│ │ ├── contacts.client.ts
|
||||
│ │ ├── todo.client.ts
|
||||
│ │ ├── calendar.client.ts
|
||||
│ │ ├── cache.ts
|
||||
│ │ └── index.ts
|
||||
│ └── package.json
|
||||
│
|
||||
├── foundation-events/ # NEU: Event Definitions
|
||||
│ ├── src/
|
||||
│ │ ├── contact.events.ts
|
||||
│ │ ├── task.events.ts
|
||||
│ │ ├── calendar.events.ts
|
||||
│ │ └── index.ts
|
||||
│ └── package.json
|
||||
│
|
||||
├── shared-types/ # Existiert bereits
|
||||
│ └── src/
|
||||
│ ├── contact.ts # ContactReference, ContactSummary
|
||||
│ └── ...
|
||||
│
|
||||
└── shared-redis/ # NEU oder erweitern
|
||||
└── src/
|
||||
├── redis.service.ts
|
||||
├── pub-sub.ts
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Offene Fragen
|
||||
|
||||
- [ ] Welche Consumer Apps werden als erste integriert?
|
||||
- [ ] Redis bereits im Stack oder neu einführen?
|
||||
- [ ] Cache TTL pro Entity-Typ oder einheitlich?
|
||||
- [ ] Event Bus: Redis Pub/Sub vs. dediziertes System (Bull, etc.)?
|
||||
|
|
@ -15,6 +15,7 @@
|
|||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-types": "workspace:*",
|
||||
"base64-js": "^1.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
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