mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 02:01:10 +02:00
refactor(calendar): improve code quality with quick wins
- Add shared API client factory (base-client.ts) to eliminate duplication between calendar and todo API clients, includes timeout support - Add error toast notifications on API failures using new Svelte 5 runes-based toast store - Extract drag/drop and resize logic into reusable composables (useDragDrop.svelte.ts, useResize.svelte.ts) - Move hardcoded German strings to i18n locale files (de.json, en.json) - Add ARIA labels for accessibility on day cells, event cards, and resize handles - Fix nested button HTML issue in TodoSidebarSection 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
114c2e9c62
commit
4dc4a4e8df
18 changed files with 903 additions and 223 deletions
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<T extends object>(params: T): 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}` : '';
|
||||
}
|
||||
|
|
@ -2,66 +2,22 @@
|
|||
* API Client for Calendar Backend
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { env } from '$env/dynamic/public';
|
||||
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;
|
||||
|
||||
let authToken = token;
|
||||
if (!authToken && browser) {
|
||||
authToken = localStorage.getItem('@auth/appToken') || undefined;
|
||||
}
|
||||
|
||||
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 };
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// ============================================
|
||||
|
|
@ -150,78 +155,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
|
||||
|
|
|
|||
|
|
@ -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']) {
|
||||
|
|
|
|||
|
|
@ -17,11 +17,14 @@
|
|||
} 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;
|
||||
}
|
||||
|
||||
let { onQuickCreate }: Props = $props();
|
||||
let { onQuickCreate, onEventClick }: Props = $props();
|
||||
|
||||
// Constants
|
||||
const HOUR_HEIGHT = 60; // pixels per hour
|
||||
|
|
@ -358,7 +361,7 @@
|
|||
return `top: ${top}%; height: ${height}%; background-color: ${color};`;
|
||||
}
|
||||
|
||||
function handleEventClick(event: any, e: MouseEvent) {
|
||||
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 +371,11 @@
|
|||
}, 100);
|
||||
return;
|
||||
}
|
||||
goto(`/?event=${event.id}`);
|
||||
if (onEventClick) {
|
||||
onEventClick(event);
|
||||
} else {
|
||||
goto(`/?event=${event.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSlotClick(hour: number, e: MouseEvent) {
|
||||
|
|
|
|||
|
|
@ -28,12 +28,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(() => {
|
||||
|
|
@ -219,7 +223,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
function handleEventClick(event: any, e: MouseEvent) {
|
||||
function handleEventClick(event: CalendarEvent, e: MouseEvent) {
|
||||
// Don't navigate if dragging
|
||||
if (isDragging) {
|
||||
e.preventDefault();
|
||||
|
|
@ -227,7 +231,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 +259,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 +269,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')}
|
||||
|
|
@ -300,14 +310,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>
|
||||
|
|
|
|||
|
|
@ -23,12 +23,15 @@
|
|||
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;
|
||||
}
|
||||
let { dayCount, onQuickCreate }: Props = $props();
|
||||
let { dayCount, onQuickCreate, onEventClick }: Props = $props();
|
||||
|
||||
// Get date-fns locale based on current app locale
|
||||
const dateLocales = { de, en: enUS, fr, es, it };
|
||||
|
|
@ -161,7 +164,7 @@
|
|||
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 +174,11 @@
|
|||
}, 100);
|
||||
return;
|
||||
}
|
||||
goto(`/?event=${event.id}`);
|
||||
if (onEventClick) {
|
||||
onEventClick(event);
|
||||
} else {
|
||||
goto(`/?event=${event.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSlotClick(day: Date, hour: number, e: MouseEvent) {
|
||||
|
|
|
|||
|
|
@ -61,8 +61,8 @@
|
|||
|
||||
<div class="todo-sidebar-section">
|
||||
<!-- Header -->
|
||||
<button type="button" class="section-header" onclick={toggleExpanded}>
|
||||
<div class="header-left">
|
||||
<div class="section-header">
|
||||
<button type="button" class="header-toggle" onclick={toggleExpanded}>
|
||||
{#if isExpanded}
|
||||
<ChevronDown size={16} />
|
||||
{:else}
|
||||
|
|
@ -78,7 +78,7 @@
|
|||
<AlertTriangle size={12} />
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="add-button"
|
||||
|
|
@ -87,7 +87,7 @@
|
|||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
{#if isExpanded}
|
||||
|
|
@ -160,29 +160,31 @@
|
|||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
padding: 0 0.5rem 0 0;
|
||||
}
|
||||
|
||||
.header-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
padding: 0.75rem 0.5rem 0.75rem 1rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-foreground));
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease;
|
||||
}
|
||||
|
||||
.section-header:hover {
|
||||
.header-toggle:hover {
|
||||
background: hsl(var(--color-muted) / 0.3);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.header-left :global(svg) {
|
||||
.header-toggle :global(svg) {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.header-left :global(.section-icon) {
|
||||
.header-toggle :global(.section-icon) {
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,13 +21,16 @@
|
|||
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;
|
||||
}
|
||||
|
||||
let { onQuickCreate }: Props = $props();
|
||||
let { onQuickCreate, onEventClick }: Props = $props();
|
||||
|
||||
// Constants
|
||||
const HOUR_HEIGHT = 60; // px - should match CSS --hour-height
|
||||
|
|
@ -162,7 +165,7 @@
|
|||
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 +176,11 @@
|
|||
}, 100);
|
||||
return;
|
||||
}
|
||||
goto(`/?event=${event.id}`);
|
||||
if (onEventClick) {
|
||||
onEventClick(event);
|
||||
} else {
|
||||
goto(`/?event=${event.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSlotClick(day: Date, hour: number, e: MouseEvent) {
|
||||
|
|
@ -473,7 +480,8 @@
|
|||
<!-- Week number indicator (if enabled) -->
|
||||
{#if settingsStore.showWeekNumbers}
|
||||
<div class="week-number-indicator">
|
||||
KW {weekNumber}
|
||||
{$_('views.weekNumber')}
|
||||
{weekNumber}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
|
@ -482,7 +490,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}
|
||||
|
|
@ -576,6 +584,7 @@
|
|||
: 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}`)}
|
||||
|
|
@ -585,21 +594,23 @@
|
|||
class="resize-handle top"
|
||||
onpointerdown={(e) => startResize(event, 'top', e)}
|
||||
role="slider"
|
||||
aria-label="Startzeit ändern"
|
||||
aria-label={$_('event.changeStartTime')}
|
||||
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="slider"
|
||||
aria-label="Endzeit ändern"
|
||||
aria-label={$_('event.changeEndTime')}
|
||||
tabindex="-1"
|
||||
></div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
7
apps/calendar/apps/web/src/lib/composables/index.ts
Normal file
7
apps/calendar/apps/web/src/lib/composables/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* 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';
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,25 @@
|
|||
"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"
|
||||
},
|
||||
"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,25 @@
|
|||
"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"
|
||||
},
|
||||
"a11y": {
|
||||
"createEventOn": "Create event on {date}",
|
||||
"slotTime": "{day} {time}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
@ -145,8 +152,11 @@ export const eventsStore = {
|
|||
async deleteEvent(id: string) {
|
||||
const result = await api.deleteEvent(id);
|
||||
|
||||
if (!result.error) {
|
||||
if (result.error) {
|
||||
toastStore.error(`Termin konnte nicht gelöscht werden: ${result.error.message}`);
|
||||
} else {
|
||||
events = events.filter((e) => e.id !== id);
|
||||
toastStore.success('Termin gelöscht');
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
|
|||
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;
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
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';
|
||||
|
|
@ -18,24 +19,24 @@
|
|||
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 +51,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 +75,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,11 +94,6 @@
|
|||
initialized = true;
|
||||
});
|
||||
|
||||
function handleEventModalClose() {
|
||||
// Remove event param from URL
|
||||
goto('/', { replaceState: true });
|
||||
}
|
||||
|
||||
// Refetch events when view changes
|
||||
$effect(() => {
|
||||
if (initialized && authStore.isAuthenticated) {
|
||||
|
|
@ -96,7 +111,7 @@
|
|||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Kalender</title>
|
||||
<title>{$_('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="calendar-layout">
|
||||
|
|
@ -106,7 +121,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
|
||||
|
|
@ -125,7 +140,7 @@
|
|||
<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
|
||||
{$_('calendar.newEvent')}
|
||||
</button>
|
||||
|
||||
<MiniCalendar selectedDate={viewStore.currentDate} onDateSelect={handleDateSelect} />
|
||||
|
|
@ -141,7 +156,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,7 +167,7 @@
|
|||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="fab-new-event" onclick={handleNewEvent} title="Neuer Termin">
|
||||
<button class="fab-new-event" onclick={handleNewEvent} title={$_('calendar.newEvent')}>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
|
|
@ -173,37 +188,49 @@
|
|||
{#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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue