refactor(calendar): extract EventCard component from views

- Create EventCard component encapsulating event block with resize handles
- Create TimeColumn component for hour labels (not yet integrated)
- Create OverflowIndicator component for overflow events (not yet integrated)
- Integrate EventCard into DayView (154 lines reduced)
- Integrate EventCard into WeekView (166 lines reduced)
- Remove duplicate event-card styles from both views

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-14 21:26:32 +01:00
parent 668957a30b
commit b966c91c32
5 changed files with 400 additions and 370 deletions

View file

@ -17,6 +17,7 @@
getVisibleOverflowEvents,
type OverflowEvents,
} from '$lib/utils/eventFiltering';
import EventCard from './EventCard.svelte';
import TaskBlock from './TaskBlock.svelte';
import EventContextMenu from '$lib/components/event/EventContextMenu.svelte';
import { goto } from '$app/navigation';
@ -625,9 +626,11 @@
const top = minutesToPercent(startMinutes);
const height = Math.max((duration / (totalVisibleHours * 60)) * 100, 1.5); // minimum ~20px at 60px/hour
const color = calendarsStore.getColor(event.calendarId);
return `top: ${top}%; height: ${height}%;`;
}
return `top: ${top}%; height: ${height}%; background-color: ${color};`;
function formatEventTime(event: CalendarEvent): string {
return `${format(toDate(event.startTime), 'HH:mm')} - ${format(toDate(event.endTime), 'HH:mm')}`;
}
/**
@ -782,63 +785,27 @@
{/each}
<!-- Timed events -->
{#each timedEvents as event}
{#each timedEvents as event (event.id)}
{@const isBeingDragged = isDragging && draggedEvent?.id === event.id}
{@const isBeingResized = isResizing && resizeEvent?.id === event.id}
{@const isDraft = eventsStore.isDraftEvent(event.id)}
{@const isSearchHighlighted = searchStore.isEventHighlighted(event.id)}
{@const isSearchDimmed = searchStore.isEventDimmed(event.id)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="event-card"
class:dragging={isBeingDragged}
class:resizing={isBeingResized}
class:draft={isDraft}
class:search-highlighted={isSearchHighlighted}
class:search-dimmed={isSearchDimmed}
data-event-id={event.id}
<EventCard
{event}
style={isBeingDragged
? `top: ${dragPreviewTop}%; height: ${dragPreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
? `top: ${dragPreviewTop}%; height: ${dragPreviewHeight}%;`
: isBeingResized
? `top: ${resizePreviewTop}%; height: ${resizePreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
? `top: ${resizePreviewTop}%; height: ${resizePreviewHeight}%;`
: getEventStyle(event)}
onpointerdown={(e) => startDrag(event, e)}
onclick={(e) => !isDraft && handleEventClick(event, e)}
oncontextmenu={(e) => handleEventContextMenu(event, e)}
role="button"
tabindex="0"
>
<!-- Top resize handle -->
<div
class="resize-handle top"
onpointerdown={(e) => startResize(event, 'top', e)}
role="separator"
aria-orientation="horizontal"
aria-label="Startzeit ändern"
aria-valuenow={0}
tabindex="-1"
></div>
<span class="event-time">
{format(toDate(event.startTime), 'HH:mm')} -
{format(toDate(event.endTime), 'HH:mm')}
</span>
<span class="event-title">{event.title || (isDraft ? '(Neuer Termin)' : '')}</span>
{#if event.location}
<span class="event-location">{event.location}</span>
{/if}
<!-- Bottom resize handle -->
<div
class="resize-handle bottom"
onpointerdown={(e) => startResize(event, 'bottom', e)}
role="separator"
aria-orientation="horizontal"
aria-label="Endzeit ändern"
aria-valuenow={0}
tabindex="-1"
></div>
</div>
color={calendarsStore.getColor(event.calendarId)}
isDragging={isBeingDragged}
isResizing={isBeingResized}
isSearchHighlighted={searchStore.isEventHighlighted(event.id)}
isSearchDimmed={searchStore.isEventDimmed(event.id)}
formattedTime={formatEventTime(event)}
onClick={handleEventClick}
onPointerDown={startDrag}
onContextMenu={handleEventContextMenu}
onResizeStart={startResize}
/>
{/each}
<!-- Scheduled Tasks (Time-Blocking) -->
@ -1046,127 +1013,6 @@
outline-offset: -2px;
}
.event-card {
position: absolute;
left: 8px;
width: calc(100% - 16px);
max-width: 400px;
color: white;
border: none;
text-align: left;
cursor: grab;
z-index: 1;
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px 10px;
border-radius: var(--radius-md);
overflow: hidden;
touch-action: none;
user-select: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition:
box-shadow 150ms ease,
opacity 150ms ease;
}
.event-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.event-card.dragging {
cursor: grabbing;
opacity: 0.9;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
z-index: 100;
}
.event-card.resizing {
cursor: ns-resize;
opacity: 0.85;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
z-index: 100;
outline: 2px dashed rgba(255, 255, 255, 0.6);
outline-offset: -2px;
}
.event-card.draft {
outline: 2px solid hsl(var(--color-primary));
outline-offset: -1px;
animation: pulse-outline 1.5s ease-in-out infinite;
}
/* Search highlighting */
.event-card.search-highlighted {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 1px;
box-shadow:
0 0 0 4px hsl(var(--color-primary) / 0.3),
0 4px 12px rgba(0, 0, 0, 0.25);
z-index: 10;
}
.event-card.search-dimmed {
opacity: 0.35;
filter: grayscale(0.3);
}
@keyframes pulse-outline {
0%,
100% {
outline-color: hsl(var(--color-primary));
}
50% {
outline-color: hsl(var(--color-primary) / 0.5);
}
}
/* Resize Handles */
.resize-handle {
position: absolute;
left: 0;
right: 0;
height: 8px;
cursor: ns-resize;
opacity: 0;
transition: opacity 150ms ease;
z-index: 10;
}
.resize-handle.top {
top: 0;
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
}
.resize-handle.bottom {
bottom: 0;
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
}
.event-card:hover .resize-handle {
opacity: 1;
background: rgba(255, 255, 255, 0.3);
}
.resize-handle:hover {
background: rgba(255, 255, 255, 0.5) !important;
}
.event-time {
font-size: 0.75rem;
opacity: 0.9;
}
.event-title {
font-size: 0.875rem;
font-weight: 500;
}
.event-location {
font-size: 0.75rem;
opacity: 0.8;
}
/* Time indicator */
.time-indicator {
position: absolute;

View file

@ -0,0 +1,251 @@
<script lang="ts">
import type { CalendarEvent } from '@calendar/shared';
import { eventsStore } from '$lib/stores/events.svelte';
import { _ } from 'svelte-i18n';
interface Props {
event: CalendarEvent;
style: string;
color: string;
isDragging?: boolean;
isResizing?: boolean;
isDraggingSource?: boolean;
isSearchHighlighted?: boolean;
isSearchDimmed?: boolean;
formattedTime: string;
onClick?: (event: CalendarEvent, e: MouseEvent) => void;
onPointerDown?: (event: CalendarEvent, e: PointerEvent) => void;
onContextMenu?: (event: CalendarEvent, e: MouseEvent) => void;
onResizeStart?: (event: CalendarEvent, edge: 'top' | 'bottom', e: PointerEvent) => void;
}
let {
event,
style,
color,
isDragging = false,
isResizing = false,
isDraggingSource = false,
isSearchHighlighted = false,
isSearchDimmed = false,
formattedTime,
onClick,
onPointerDown,
onContextMenu,
onResizeStart,
}: Props = $props();
let isDraft = $derived(eventsStore.isDraftEvent(event.id));
function handleClick(e: MouseEvent) {
if (isDragging || isResizing || isDraft) {
e.preventDefault();
e.stopPropagation();
return;
}
onClick?.(event, e);
}
function handlePointerDown(e: PointerEvent) {
onPointerDown?.(event, e);
}
function handleContextMenu(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (isDraft) return;
onContextMenu?.(event, e);
}
function handleResizeTop(e: PointerEvent) {
e.stopPropagation();
onResizeStart?.(event, 'top', e);
}
function handleResizeBottom(e: PointerEvent) {
e.stopPropagation();
onResizeStart?.(event, 'bottom', e);
}
function handleKeydown(e: KeyboardEvent) {
if ((e.key === 'Enter' || e.key === ' ') && !isDraft) {
e.preventDefault();
onClick?.(event, e as unknown as MouseEvent);
}
}
</script>
<div
class="event-card"
class:dragging={isDragging && !isDraggingSource}
class:dragging-source={isDraggingSource}
class:resizing={isResizing}
class:draft={isDraft}
class:search-highlighted={isSearchHighlighted}
class:search-dimmed={isSearchDimmed}
data-event-id={event.id}
{style}
style:background-color={color}
role="button"
tabindex="0"
aria-label={event.title || $_('calendar.draftEvent')}
onpointerdown={handlePointerDown}
onclick={handleClick}
onkeydown={handleKeydown}
oncontextmenu={handleContextMenu}
>
<!-- Top resize handle -->
{#if onResizeStart}
<div
class="resize-handle top"
onpointerdown={handleResizeTop}
role="slider"
aria-label={$_('event.changeStartTime')}
aria-valuenow={0}
tabindex="-1"
></div>
{/if}
<span class="event-time">{formattedTime}</span>
<span class="event-title">{event.title || (isDraft ? $_('calendar.draftEvent') : '')}</span>
{#if event.location}
<span class="event-location">{event.location}</span>
{/if}
<!-- Bottom resize handle -->
{#if onResizeStart}
<div
class="resize-handle bottom"
onpointerdown={handleResizeBottom}
role="slider"
aria-label={$_('event.changeEndTime')}
aria-valuenow={0}
tabindex="-1"
></div>
{/if}
</div>
<style>
.event-card {
position: absolute;
left: 2px;
right: 2px;
padding: 2px 4px;
border-radius: var(--radius-sm);
text-align: left;
cursor: grab;
z-index: 1;
overflow: hidden;
transition:
box-shadow 0.15s ease,
opacity 0.15s ease;
touch-action: none;
user-select: none;
color: white;
}
.event-card:hover {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
}
.event-card:focus-visible {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 1px;
}
.event-card.dragging {
cursor: grabbing;
opacity: 0.9;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
z-index: 100;
}
.event-card.resizing {
opacity: 0.85;
z-index: 100;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
outline: 2px dashed hsl(var(--color-primary) / 0.6);
outline-offset: -2px;
}
/* Ghost style for source position during cross-day drag */
.event-card.dragging-source {
opacity: 0.4;
background: transparent !important;
border: 2px dashed hsl(var(--color-border));
pointer-events: none;
}
.event-card.dragging-source .event-title,
.event-card.dragging-source .event-time,
.event-card.dragging-source .event-location {
opacity: 0.5;
}
.event-card.draft {
border: 2px dashed hsl(var(--color-primary) / 0.6);
background-color: hsl(var(--color-primary) / 0.3) !important;
}
.event-card.search-highlighted {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 1px;
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.3);
}
.event-card.search-dimmed {
opacity: 0.35;
filter: grayscale(0.3);
}
.event-time {
display: block;
font-size: 0.6rem;
opacity: 0.9;
}
.event-title {
display: block;
font-size: 0.7rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.event-location {
display: block;
font-size: 0.6rem;
opacity: 0.85;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Resize handles */
.resize-handle {
position: absolute;
left: 0;
right: 0;
height: 8px;
cursor: ns-resize;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 2;
}
.resize-handle.top {
top: 0;
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
}
.resize-handle.bottom {
bottom: 0;
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
}
.event-card:hover .resize-handle {
opacity: 1;
background: rgba(255, 255, 255, 0.3);
}
</style>

View file

@ -0,0 +1,57 @@
<script lang="ts">
/**
* OverflowIndicator Component
* Shows colored lines indicating events outside the visible time range
*/
import type { CalendarEvent } from '@calendar/shared';
interface OverflowEvent {
event: CalendarEvent;
color: string;
tooltip: string;
}
interface Props {
events: OverflowEvent[];
position: 'top' | 'bottom';
label?: string;
}
let { events, position, label }: Props = $props();
</script>
{#if events.length > 0}
<div class="overflow-indicator {position}" title={label}>
{#each events as { color, tooltip }}
<div class="overflow-line" style="background-color: {color}" title={tooltip}></div>
{/each}
</div>
{/if}
<style>
.overflow-indicator {
position: absolute;
left: 0;
right: 0;
display: flex;
gap: 2px;
padding: 2px 4px;
z-index: 5;
}
.overflow-indicator.top {
top: 0;
}
.overflow-indicator.bottom {
bottom: 0;
}
.overflow-line {
flex: 1;
height: 3px;
border-radius: 1px;
opacity: 0.8;
}
</style>

View file

@ -0,0 +1,42 @@
<script lang="ts">
/**
* TimeColumn Component
* Renders the time labels column for calendar views (WeekView, DayView, MultiDayView)
*/
interface Props {
hours: number[];
formatHour: (hour: number) => string;
}
let { hours, formatHour }: Props = $props();
</script>
<div class="time-column">
{#each hours as hour}
<div class="time-label">
{formatHour(hour)}
</div>
{/each}
</div>
<style>
.time-column {
width: 48px;
flex-shrink: 0;
border-right: 1px solid hsl(var(--color-border));
position: relative;
}
.time-label {
height: var(--hour-height, 60px);
display: flex;
align-items: flex-start;
justify-content: flex-end;
padding-right: 8px;
padding-top: 0;
font-size: 0.7rem;
color: hsl(var(--color-muted-foreground));
transform: translateY(-0.4em);
}
</style>

View file

@ -17,6 +17,7 @@
getVisibleOverflowEvents,
type OverflowEvents,
} from '$lib/utils/eventFiltering';
import EventCard from './EventCard.svelte';
import TaskBlock from './TaskBlock.svelte';
import EventContextMenu from '$lib/components/event/EventContextMenu.svelte';
import { goto } from '$app/navigation';
@ -201,9 +202,11 @@
const top = minutesToPercent(startMinutes);
const height = Math.max((duration / (totalVisibleHours * 60)) * 100, 2); // Min 2% height
const color = calendarsStore.getColor(event.calendarId);
return `top: ${top}%; height: ${height}%;`;
}
return `top: ${top}%; height: ${height}%; background-color: ${color};`;
function formatEventTimeRange(event: CalendarEvent): string {
return `${formatEventTime(event.startTime)} - ${formatEventTime(event.endTime)}`;
}
/**
@ -955,60 +958,27 @@
{#each getEventsForDay(day) as event (event.id)}
{@const isBeingDragged = isDragging && draggedEvent?.id === event.id}
{@const isBeingResized = isResizing && resizeEvent?.id === event.id}
{@const isDraft = eventsStore.isDraftEvent(event.id)}
{@const isCrossDayDrag =
isBeingDragged && dragTargetDay && !isSameDay(day, dragTargetDay)}
{@const isSearchHighlighted = searchStore.isEventHighlighted(event.id)}
{@const isSearchDimmed = searchStore.isEventDimmed(event.id)}
<div
class="event-card"
class:dragging={isBeingDragged && !isCrossDayDrag}
class:dragging-source={isCrossDayDrag}
class:resizing={isBeingResized}
class:draft={isDraft}
class:search-highlighted={isSearchHighlighted}
class:search-dimmed={isSearchDimmed}
data-event-id={event.id}
isBeingDragged && dragTargetDay !== null && !isSameDay(day, dragTargetDay)}
<EventCard
{event}
style={isBeingDragged && !isCrossDayDrag
? `top: ${dragPreviewTop}%; height: ${dragPreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
? `top: ${dragPreviewTop}%; height: ${dragPreviewHeight}%;`
: isBeingResized
? `top: ${resizePreviewTop}%; height: ${resizePreviewHeight}%; background-color: ${calendarsStore.getColor(event.calendarId)};`
? `top: ${resizePreviewTop}%; height: ${resizePreviewHeight}%;`
: getEventStyle(event)}
role="button"
tabindex="0"
aria-label={event.title || $_('calendar.draftEvent')}
onpointerdown={(e) => startDrag(event, e)}
onclick={(e) => !isDraft && handleEventClick(event, e)}
onkeydown={(e) => !isDraft && e.key === 'Enter' && goto(`/?event=${event.id}`)}
oncontextmenu={(e) => handleEventContextMenu(event, e)}
>
<!-- Top resize handle -->
<div
class="resize-handle top"
onpointerdown={(e) => startResize(event, 'top', e)}
role="slider"
aria-label={$_('event.changeStartTime')}
aria-valuenow={0}
tabindex="-1"
></div>
<span class="event-time">
{formatEventTime(event.startTime)} - {formatEventTime(event.endTime)}
</span>
<span class="event-title"
>{event.title || (isDraft ? $_('calendar.draftEvent') : '')}</span
>
<!-- Bottom resize handle -->
<div
class="resize-handle bottom"
onpointerdown={(e) => startResize(event, 'bottom', e)}
role="slider"
aria-label={$_('event.changeEndTime')}
aria-valuenow={0}
tabindex="-1"
></div>
</div>
color={calendarsStore.getColor(event.calendarId)}
isDragging={isBeingDragged && !isCrossDayDrag}
isDraggingSource={isCrossDayDrag}
isResizing={isBeingResized}
isSearchHighlighted={searchStore.isEventHighlighted(event.id)}
isSearchDimmed={searchStore.isEventDimmed(event.id)}
formattedTime={formatEventTimeRange(event)}
onClick={handleEventClick}
onPointerDown={startDrag}
onContextMenu={handleEventContextMenu}
onResizeStart={startResize}
/>
{/each}
<!-- Scheduled Tasks (Time-Blocking) -->
@ -1046,15 +1016,13 @@
<!-- Drag preview (solid) for cross-day dragging - shows where event will be -->
{#if isDragging && draggedEvent && dragTargetDay && isSameDay(day, dragTargetDay) && !getEventsForDay(day).some((e) => e.id === draggedEvent!.id)}
<div
class="event-card drag-preview"
style="top: {dragPreviewTop}%; height: {dragPreviewHeight}%; background-color: {calendarsStore.getColor(
draggedEvent.calendarId
)};"
>
<span class="event-time">{formatEventTime(draggedEvent.startTime)}</span>
<span class="event-title">{draggedEvent.title}</span>
</div>
<EventCard
event={draggedEvent}
style="top: {dragPreviewTop}%; height: {dragPreviewHeight}%;"
color={calendarsStore.getColor(draggedEvent.calendarId)}
isDragging={true}
formattedTime={formatEventTimeRange(draggedEvent)}
/>
{/if}
<!-- Overflow indicators for events outside visible time range -->
@ -1329,140 +1297,6 @@
background: hsl(var(--color-muted) / 0.3);
}
.event-card {
position: absolute;
left: 2px;
right: 2px;
padding: 2px 4px;
color: white;
border: none;
border-radius: var(--radius-sm);
text-align: left;
cursor: grab;
z-index: 1;
overflow: hidden;
transition:
box-shadow 0.15s ease,
opacity 0.15s ease;
touch-action: none;
user-select: none;
}
.event-card:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.event-card.dragging {
cursor: grabbing;
opacity: 0.9;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
z-index: 100;
}
.event-card.resizing {
opacity: 0.85;
z-index: 100;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
outline: 2px dashed rgba(255, 255, 255, 0.6);
outline-offset: -2px;
}
/* Ghost style for source position during cross-day drag */
.event-card.dragging-source {
opacity: 0.4;
background: transparent !important;
border: 2px dashed hsl(var(--color-border));
pointer-events: none;
}
.event-card.dragging-source .event-title,
.event-card.dragging-source .event-time {
opacity: 0.5;
}
/* Solid preview at target position during cross-day drag */
.event-card.drag-preview {
pointer-events: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
/* Search highlighting */
.event-card.search-highlighted {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 1px;
box-shadow:
0 0 0 4px hsl(var(--color-primary) / 0.3),
0 4px 12px rgba(0, 0, 0, 0.25);
z-index: 10;
}
.event-card.search-dimmed {
opacity: 0.35;
filter: grayscale(0.3);
}
.event-card.draft {
outline: 2px solid hsl(var(--color-primary));
outline-offset: -1px;
animation: pulse-outline 1.5s ease-in-out infinite;
}
@keyframes pulse-outline {
0%,
100% {
outline-color: hsl(var(--color-primary));
}
50% {
outline-color: hsl(var(--color-primary) / 0.5);
}
}
.event-time {
font-size: 0.65rem;
opacity: 0.9;
display: block;
}
.event-title {
display: block;
font-size: 0.75rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Resize handles */
.resize-handle {
position: absolute;
left: 0;
right: 0;
height: 8px;
cursor: ns-resize;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 2;
}
.resize-handle.top {
top: 0;
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
}
.resize-handle.bottom {
bottom: 0;
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
}
.event-card:hover .resize-handle {
opacity: 1;
background: rgba(255, 255, 255, 0.3);
}
.resize-handle:hover {
background: rgba(255, 255, 255, 0.5) !important;
}
.time-indicator {
position: absolute;
left: 0;