feat(calendar): improve header UI and add quick event overlay

- Compact header styling with smaller buttons and reduced padding
- Add configurable hour filter display in header
- Add QuickEventOverlay component for fast event creation
- Add allDayDisplayMode to event metadata types
- Extend settings store with filter hours configuration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-03 11:54:17 +01:00
parent 482509a574
commit e56485f21e
5 changed files with 606 additions and 31 deletions

View file

@ -60,18 +60,18 @@
<header class="calendar-header">
<div class="header-left">
<button class="btn btn-ghost" onclick={() => viewStore.goToToday()}>
<button class="today-btn" onclick={() => viewStore.goToToday()}>
Heute
</button>
<div class="nav-buttons">
<button class="btn btn-ghost btn-icon" onclick={() => viewStore.goToPrevious()} aria-label="Zurück">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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="btn btn-ghost btn-icon" onclick={() => viewStore.goToNext()} aria-label="Weiter">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
@ -93,14 +93,14 @@
Mo-Fr
</button>
<!-- Hide early hours toggle -->
<!-- Filter hours toggle -->
<button
class="filter-toggle"
class:active={settingsStore.hideEarlyHours}
onclick={() => settingsStore.set('hideEarlyHours', !settingsStore.hideEarlyHours)}
title="Frühe Stunden ausblenden (0-6 Uhr)"
class:active={settingsStore.filterHoursEnabled}
onclick={() => settingsStore.set('filterHoursEnabled', !settingsStore.filterHoursEnabled)}
title="Stunden filtern ({settingsStore.dayStartHour}-{settingsStore.dayEndHour} Uhr)"
>
7-24
{settingsStore.dayStartHour}-{settingsStore.dayEndHour}
</button>
</div>
@ -123,7 +123,7 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
padding: 0.5rem 1rem;
border-bottom: 1px solid hsl(var(--color-border));
background: hsl(var(--color-surface));
}
@ -131,16 +131,50 @@
.header-left {
display: flex;
align-items: center;
gap: 1rem;
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.25rem;
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 {
font-size: 1.25rem;
font-size: 1rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin: 0;
@ -149,22 +183,22 @@
.header-right {
display: flex;
align-items: center;
gap: 1rem;
gap: 0.75rem;
}
.view-selector {
display: flex;
background: hsl(var(--color-muted));
border-radius: var(--radius-md);
padding: 0.25rem;
padding: 0.125rem;
}
.view-btn {
padding: 0.5rem 1rem;
padding: 0.25rem 0.625rem;
border: none;
background: transparent;
border-radius: var(--radius-sm);
font-size: 0.875rem;
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
@ -183,15 +217,15 @@
.filter-toggles {
display: flex;
gap: 0.5rem;
gap: 0.25rem;
}
.filter-toggle {
padding: 0.5rem 0.75rem;
padding: 0.25rem 0.5rem;
border: 1px solid hsl(var(--color-border));
background: transparent;
border-radius: var(--radius-md);
font-size: 0.75rem;
border-radius: var(--radius-sm);
font-size: 0.6875rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
@ -209,14 +243,11 @@
border-color: hsl(var(--color-primary));
}
.btn-icon {
padding: 0.5rem;
}
@media (max-width: 640px) {
.calendar-header {
flex-direction: column;
gap: 1rem;
gap: 0.5rem;
padding: 0.5rem;
}
.header-left {
@ -225,7 +256,7 @@
}
.header-title {
font-size: 1rem;
font-size: 0.875rem;
}
}
</style>

View file

@ -20,6 +20,9 @@
let location = $state(event?.location || '');
let isAllDay = $state(event?.isAllDay || false);
let calendarId = $state(event?.calendarId || '');
let allDayDisplayMode = $state<'default' | 'header' | 'block'>(
event?.metadata?.allDayDisplayMode || 'default'
);
// Set default calendar when calendars are loaded
$effect(() => {
@ -73,6 +76,11 @@
const startDateTime = new Date(`${startDate}T${isAllDay ? '00:00' : startTime}`);
const endDateTime = new Date(`${endDate}T${isAllDay ? '23:59' : endTime}`);
// Build metadata with display mode if not default
const metadata = isAllDay && allDayDisplayMode !== 'default'
? { ...(event?.metadata || {}), allDayDisplayMode: allDayDisplayMode as 'header' | 'block' }
: event?.metadata;
const data: CreateEventInput | UpdateEventInput = {
title: title.trim(),
description: description.trim() || undefined,
@ -81,6 +89,7 @@
startTime: startDateTime.toISOString(),
endTime: endDateTime.toISOString(),
calendarId,
metadata,
};
submitting = true;
@ -117,6 +126,21 @@
</label>
</div>
{#if isAllDay}
<div class="flex flex-col gap-2">
<label for="displayMode" class="text-sm font-medium text-foreground">Anzeigeart</label>
<select
id="displayMode"
class="w-full px-3 py-2 border-2 border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary transition-colors"
bind:value={allDayDisplayMode}
>
<option value="default">Standard (aus Einstellungen)</option>
<option value="header">In Kopfzeile</option>
<option value="block">Als Tagesblock</option>
</select>
</div>
{/if}
<div class="flex gap-4">
<div class="flex-1 flex flex-col gap-2">
<label for="startDate" class="text-sm font-medium text-foreground">Beginn</label>

View file

@ -0,0 +1,499 @@
<script lang="ts">
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { goto } from '$app/navigation';
import { format, addMinutes } from 'date-fns';
interface Props {
startTime: Date;
position: { x: number; y: number };
onClose: () => void;
onCreated?: () => void;
}
let { startTime, position, onClose, onCreated }: Props = $props();
// Form state
let title = $state('');
let calendarId = $state('');
let isExpanded = $state(false);
let description = $state('');
let location = $state('');
let isAllDay = $state(false);
let submitting = $state(false);
// Time fields
let endTime = $derived(addMinutes(startTime, settingsStore.defaultEventDuration));
let startTimeStr = $derived(format(startTime, 'HH:mm'));
let endTimeStr = $state('');
let startDateStr = $derived(format(startTime, 'yyyy-MM-dd'));
let endDateStr = $state('');
// Initialize end time string
$effect(() => {
endTimeStr = format(endTime, 'HH:mm');
endDateStr = format(endTime, 'yyyy-MM-dd');
});
// Set default calendar
$effect(() => {
if (!calendarId && calendarsStore.defaultCalendar?.id) {
calendarId = calendarsStore.defaultCalendar.id;
}
});
// Calculate overlay position (ensure it stays within viewport)
let overlayStyle = $derived.by(() => {
const overlayWidth = isExpanded ? 360 : 300;
const overlayHeight = isExpanded ? 400 : 180;
let left = position.x;
let top = position.y;
// Keep within viewport bounds
if (typeof window !== 'undefined') {
if (left + overlayWidth > window.innerWidth - 20) {
left = window.innerWidth - overlayWidth - 20;
}
if (top + overlayHeight > window.innerHeight - 20) {
top = window.innerHeight - overlayHeight - 20;
}
if (left < 20) left = 20;
if (top < 20) top = 20;
}
return `left: ${left}px; top: ${top}px;`;
});
async function handleSubmit(e: Event) {
e.preventDefault();
if (!title.trim() || !calendarId) return;
submitting = true;
try {
const startDateTime = isAllDay
? new Date(`${startDateStr}T00:00:00`)
: new Date(`${startDateStr}T${startTimeStr}`);
const endDateTime = isAllDay
? new Date(`${endDateStr}T23:59:59`)
: new Date(`${endDateStr}T${endTimeStr}`);
await eventsStore.createEvent({
title: title.trim(),
calendarId,
startTime: startDateTime.toISOString(),
endTime: endDateTime.toISOString(),
isAllDay,
description: description.trim() || undefined,
location: location.trim() || undefined,
});
onCreated?.();
onClose();
} catch (error) {
console.error('Failed to create event:', error);
} finally {
submitting = false;
}
}
function handleMoreOptions() {
const params = new URLSearchParams({
start: startTime.toISOString(),
title: title.trim(),
calendar: calendarId,
});
if (description) params.set('description', description);
if (location) params.set('location', location);
goto(`/event/new?${params.toString()}`);
onClose();
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- Backdrop -->
<div class="overlay-backdrop" onclick={handleBackdropClick} role="presentation">
<!-- Overlay -->
<div
class="quick-event-overlay"
class:expanded={isExpanded}
style={overlayStyle}
role="dialog"
aria-modal="true"
aria-label="Termin erstellen"
>
<form onsubmit={handleSubmit}>
<!-- Header -->
<div class="overlay-header">
<span class="time-badge">
{format(startTime, 'EEE, d. MMM')} {startTimeStr}
</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>
</div>
<!-- Title input -->
<div class="form-group">
<input
type="text"
class="title-input"
bind:value={title}
placeholder="Termin hinzufügen"
autofocus
/>
</div>
<!-- Calendar select (compact) -->
<div class="form-group compact-row">
<div class="calendar-dot" style="background-color: {calendarsStore.getColor(calendarId)}"></div>
<select class="compact-select" bind:value={calendarId}>
{#each calendarsStore.calendars as cal}
<option value={cal.id}>{cal.name}</option>
{/each}
</select>
</div>
<!-- Expanded section -->
{#if isExpanded}
<div class="expanded-section">
<!-- All day toggle -->
<label class="toggle-row">
<input type="checkbox" bind:checked={isAllDay} />
<span>Ganztägig</span>
</label>
<!-- Time fields -->
{#if !isAllDay}
<div class="time-row">
<div class="time-field">
<label>Von</label>
<input type="time" bind:value={startTimeStr} />
</div>
<span class="time-sep"></span>
<div class="time-field">
<label>Bis</label>
<input type="time" bind:value={endTimeStr} />
</div>
</div>
{/if}
<!-- Location -->
<div class="form-group">
<input
type="text"
class="field-input"
bind:value={location}
placeholder="Ort hinzufügen"
/>
</div>
<!-- Description -->
<div class="form-group">
<textarea
class="field-input"
bind:value={description}
placeholder="Beschreibung"
rows="2"
></textarea>
</div>
</div>
{/if}
<!-- Actions -->
<div class="overlay-actions">
<button
type="button"
class="expand-btn"
onclick={() => isExpanded = !isExpanded}
>
{isExpanded ? 'Weniger' : 'Mehr Optionen'}
<svg class="w-3 h-3" class:rotated={isExpanded} 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>
<div class="action-buttons">
<button type="button" class="btn-ghost" onclick={handleMoreOptions}>
Alle Optionen
</button>
<button type="submit" class="btn-primary" disabled={submitting || !title.trim()}>
Speichern
</button>
</div>
</div>
</form>
</div>
</div>
<style>
.overlay-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
background: transparent;
}
.quick-event-overlay {
position: fixed;
width: 300px;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-lg);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15), 0 2px 10px rgba(0, 0, 0, 0.1);
z-index: 1001;
overflow: hidden;
animation: slideIn 150ms ease-out;
}
.quick-event-overlay.expanded {
width: 360px;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.overlay-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid hsl(var(--color-border));
background: hsl(var(--color-muted) / 0.3);
}
.time-badge {
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
}
.close-btn {
padding: 0.25rem;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 150ms;
}
.close-btn:hover {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.form-group {
padding: 0.5rem 1rem;
}
.title-input {
width: 100%;
padding: 0.5rem 0;
border: none;
background: transparent;
font-size: 1rem;
font-weight: 500;
color: hsl(var(--color-foreground));
outline: none;
}
.title-input::placeholder {
color: hsl(var(--color-muted-foreground));
}
.compact-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding-top: 0;
}
.calendar-dot {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.compact-select {
flex: 1;
padding: 0.25rem 0.5rem;
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-sm);
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.875rem;
cursor: pointer;
}
.expanded-section {
border-top: 1px solid hsl(var(--color-border));
padding-top: 0.5rem;
}
.toggle-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
cursor: pointer;
font-size: 0.875rem;
}
.toggle-row input {
accent-color: hsl(var(--color-primary));
}
.time-row {
display: flex;
align-items: flex-end;
gap: 0.5rem;
padding: 0.5rem 1rem;
}
.time-field {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.time-field label {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.time-field input {
padding: 0.375rem 0.5rem;
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-sm);
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.875rem;
}
.time-sep {
padding-bottom: 0.375rem;
color: hsl(var(--color-muted-foreground));
}
.field-input {
width: 100%;
padding: 0.5rem;
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-sm);
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.875rem;
resize: none;
}
.field-input:focus {
outline: none;
border-color: hsl(var(--color-primary));
}
.overlay-actions {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-top: 1px solid hsl(var(--color-border));
background: hsl(var(--color-muted) / 0.2);
}
.expand-btn {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.375rem 0.5rem;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.75rem;
cursor: pointer;
border-radius: var(--radius-sm);
transition: all 150ms;
}
.expand-btn:hover {
color: hsl(var(--color-foreground));
background: hsl(var(--color-muted));
}
.expand-btn svg {
transition: transform 150ms;
}
.expand-btn svg.rotated {
transform: rotate(180deg);
}
.action-buttons {
display: flex;
gap: 0.5rem;
}
.btn-ghost {
padding: 0.375rem 0.75rem;
border: 1px solid hsl(var(--color-border));
background: transparent;
color: hsl(var(--color-foreground));
font-size: 0.75rem;
font-weight: 500;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 150ms;
}
.btn-ghost:hover {
background: hsl(var(--color-muted));
}
.btn-primary {
padding: 0.375rem 0.75rem;
border: none;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font-size: 0.75rem;
font-weight: 500;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 150ms;
}
.btn-primary:hover {
background: hsl(var(--color-primary) / 0.9);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View file

@ -9,6 +9,7 @@ import type { CalendarViewType } from '@calendar/shared';
// Settings types
export type WeekStartDay = 0 | 1; // 0 = Sunday, 1 = Monday
export type TimeFormat = '24h' | '12h';
export type AllDayDisplayMode = 'header' | 'block'; // header = separate row, block = full day block in grid
export interface CalendarAppSettings {
// View settings
@ -20,6 +21,7 @@ export interface CalendarAppSettings {
filterHoursEnabled: boolean; // Filter visible hours
dayStartHour: number; // First visible hour (0-23)
dayEndHour: number; // Last visible hour (0-23)
allDayDisplayMode: AllDayDisplayMode; // How to display all-day events
// UI settings
sidebarCollapsed: boolean;
@ -35,7 +37,10 @@ const DEFAULT_SETTINGS: CalendarAppSettings = {
showOnlyWeekdays: false,
showWeekNumbers: false,
timeFormat: '24h',
hideEarlyHours: false,
filterHoursEnabled: false,
dayStartHour: 6,
dayEndHour: 20,
allDayDisplayMode: 'header',
sidebarCollapsed: false,
defaultEventDuration: 60,
defaultReminder: 15,
@ -94,8 +99,17 @@ export const settingsStore = {
get timeFormat() {
return settings.timeFormat;
},
get hideEarlyHours() {
return settings.hideEarlyHours;
get filterHoursEnabled() {
return settings.filterHoursEnabled;
},
get dayStartHour() {
return settings.dayStartHour;
},
get dayEndHour() {
return settings.dayEndHour;
},
get allDayDisplayMode() {
return settings.allDayDisplayMode;
},
get defaultEventDuration() {
return settings.defaultEventDuration;

View file

@ -7,6 +7,11 @@ export interface EventAttendee {
status?: 'accepted' | 'declined' | 'tentative' | 'pending';
}
/**
* How to display all-day events
*/
export type AllDayDisplayMode = 'header' | 'block';
/**
* Event metadata stored in JSONB
*/
@ -23,6 +28,8 @@ export interface EventMetadata {
priority?: 'low' | 'normal' | 'high';
/** Tags/labels for the event */
tags?: string[];
/** Override for all-day display mode (uses global setting if not set) */
allDayDisplayMode?: AllDayDisplayMode;
}
/**