feat(calendar): internal event store with FullCalendar UI and CalDAV push sync
- AI calendar tools now always available (moved from _CALDAV_TOOLS to _CORE_TOOLS); create/list/search/update/delete events go through the internal DB store first, with fire-and-forget CalDAV push sync when the user has CalDAV configured - Add EventEntry interface and typed API helpers (listEvents, createEvent, getEvent, updateEvent, deleteEvent) to client.ts - Install @fullcalendar/vue3, daygrid, timegrid, interaction, core packages - Add EventSlideOver.vue: create/edit/delete slide-over with title, start/end, all-day toggle, location, description, color picker, and project selector - Add CalendarView.vue: month/week/day FullCalendar with drag-drop and resize wired to PATCH /api/events/:id; click empty date opens create slide-over - Wire /calendar route, Calendar nav link in AppHeader, g+l keyboard shortcut Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import FullCalendar from "@fullcalendar/vue3";
|
||||
import dayGridPlugin from "@fullcalendar/daygrid";
|
||||
import timeGridPlugin from "@fullcalendar/timegrid";
|
||||
import interactionPlugin from "@fullcalendar/interaction";
|
||||
import type { CalendarOptions, EventClickArg, EventDropArg } from "@fullcalendar/core";
|
||||
import type { DateClickArg, EventResizeDoneArg } from "@fullcalendar/interaction";
|
||||
import { listEvents, updateEvent, type EventEntry } from "@/api/client";
|
||||
import EventSlideOver from "@/components/EventSlideOver.vue";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
|
||||
const toast = useToastStore();
|
||||
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);
|
||||
|
||||
// Slide-over state
|
||||
const slideOverEvent = ref<EventEntry | null>(null); // null = create mode
|
||||
const slideOverOpen = ref(false);
|
||||
const slideOverDate = ref<string>("");
|
||||
|
||||
function openCreate(date: string) {
|
||||
slideOverEvent.value = null;
|
||||
slideOverDate.value = date;
|
||||
slideOverOpen.value = true;
|
||||
}
|
||||
|
||||
function openEdit(event: EventEntry) {
|
||||
slideOverEvent.value = event;
|
||||
slideOverDate.value = "";
|
||||
slideOverOpen.value = true;
|
||||
}
|
||||
|
||||
function closeSlideOver() {
|
||||
slideOverOpen.value = false;
|
||||
}
|
||||
|
||||
// Event entry cache keyed by id for quick lookups when clicking FC events
|
||||
const eventCache = new Map<number, EventEntry>();
|
||||
|
||||
function toFcEvent(e: EventEntry) {
|
||||
return {
|
||||
id: String(e.id),
|
||||
title: e.title,
|
||||
start: e.start_dt,
|
||||
end: e.end_dt ?? undefined,
|
||||
allDay: e.all_day,
|
||||
backgroundColor: e.color || undefined,
|
||||
borderColor: e.color || undefined,
|
||||
extendedProps: { entryId: e.id },
|
||||
};
|
||||
}
|
||||
|
||||
async function loadEvents(
|
||||
fetchInfo: { startStr: string; endStr: string },
|
||||
successCallback: (events: object[]) => void,
|
||||
failureCallback: (error: Error) => void,
|
||||
) {
|
||||
try {
|
||||
const entries = await listEvents(fetchInfo.startStr, fetchInfo.endStr);
|
||||
eventCache.clear();
|
||||
for (const e of entries) eventCache.set(e.id, e);
|
||||
successCallback(entries.map(toFcEvent));
|
||||
} catch (err) {
|
||||
failureCallback(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
|
||||
function handleDateClick(arg: DateClickArg) {
|
||||
openCreate(arg.dateStr);
|
||||
}
|
||||
|
||||
function handleEventClick(arg: EventClickArg) {
|
||||
const id = arg.event.extendedProps.entryId as number;
|
||||
const entry = eventCache.get(id);
|
||||
if (entry) openEdit(entry);
|
||||
}
|
||||
|
||||
async function handleEventDrop(arg: EventDropArg) {
|
||||
const id = arg.event.extendedProps.entryId as number;
|
||||
const start_dt = arg.event.startStr;
|
||||
const end_dt = arg.event.endStr || undefined;
|
||||
try {
|
||||
const updated = await updateEvent(id, { start_dt, end_dt, all_day: arg.event.allDay });
|
||||
eventCache.set(id, updated);
|
||||
} catch {
|
||||
arg.revert();
|
||||
toast.show("Failed to move event", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEventResize(arg: EventResizeDoneArg) {
|
||||
const id = arg.event.extendedProps.entryId as number;
|
||||
const start_dt = arg.event.startStr;
|
||||
const end_dt = arg.event.endStr || undefined;
|
||||
try {
|
||||
const updated = await updateEvent(id, { start_dt, end_dt });
|
||||
eventCache.set(id, updated);
|
||||
} catch {
|
||||
arg.revert();
|
||||
toast.show("Failed to resize event", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function onCreated(entry: EventEntry) {
|
||||
eventCache.set(entry.id, entry);
|
||||
calendarRef.value?.getApi().addEvent(toFcEvent(entry));
|
||||
closeSlideOver();
|
||||
}
|
||||
|
||||
function onUpdated(entry: EventEntry) {
|
||||
eventCache.set(entry.id, entry);
|
||||
// Replace the event in FullCalendar
|
||||
const api = calendarRef.value?.getApi();
|
||||
if (api) {
|
||||
const existing = api.getEventById(String(entry.id));
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
api.addEvent(toFcEvent(entry));
|
||||
}
|
||||
}
|
||||
closeSlideOver();
|
||||
}
|
||||
|
||||
function onDeleted(id: number) {
|
||||
eventCache.delete(id);
|
||||
const api = calendarRef.value?.getApi();
|
||||
if (api) {
|
||||
api.getEventById(String(id))?.remove();
|
||||
}
|
||||
closeSlideOver();
|
||||
}
|
||||
|
||||
const calendarOptions: CalendarOptions = {
|
||||
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
|
||||
initialView: "dayGridMonth",
|
||||
timeZone: "local",
|
||||
editable: true,
|
||||
selectable: false,
|
||||
headerToolbar: {
|
||||
left: "prev,next today",
|
||||
center: "title",
|
||||
right: "dayGridMonth,timeGridWeek,timeGridDay",
|
||||
},
|
||||
events: loadEvents,
|
||||
dateClick: handleDateClick,
|
||||
eventClick: handleEventClick,
|
||||
eventDrop: handleEventDrop,
|
||||
eventResize: handleEventResize,
|
||||
height: "auto",
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="calendar-view">
|
||||
<div class="cal-header">
|
||||
<h1 class="cal-title">Calendar</h1>
|
||||
<button class="btn-new-event" @click="openCreate(new Date().toISOString().slice(0, 10))">
|
||||
+ New Event
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="fc-wrapper">
|
||||
<FullCalendar ref="calendarRef" :options="calendarOptions" />
|
||||
</div>
|
||||
|
||||
<EventSlideOver
|
||||
v-if="slideOverOpen"
|
||||
:event="slideOverEvent"
|
||||
:initial-date="slideOverDate"
|
||||
@close="closeSlideOver"
|
||||
@created="onCreated"
|
||||
@updated="onUpdated"
|
||||
@deleted="onDeleted"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.calendar-view {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1.5rem 3rem;
|
||||
}
|
||||
|
||||
.cal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.cal-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
}
|
||||
|
||||
.btn-new-event {
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1.1rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn-new-event:hover { opacity: 0.88; }
|
||||
|
||||
.fc-wrapper {
|
||||
background: var(--color-surface, #1a1b1e);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
border-radius: var(--radius-lg, 18px);
|
||||
padding: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* FullCalendar dark theme overrides */
|
||||
:deep(.fc) {
|
||||
color: var(--color-text, #e8e9f0);
|
||||
font-family: inherit;
|
||||
}
|
||||
:deep(.fc-toolbar-title) {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
:deep(.fc-button) {
|
||||
background: var(--color-input-bg, #111113);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
color: var(--color-text-muted, #888);
|
||||
font-size: 0.82rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
:deep(.fc-button:hover),
|
||||
:deep(.fc-button-active) {
|
||||
background: var(--color-primary, #6366f1) !important;
|
||||
border-color: var(--color-primary, #6366f1) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
:deep(.fc-button:focus) { box-shadow: none !important; }
|
||||
:deep(.fc-daygrid-day-number),
|
||||
:deep(.fc-col-header-cell-cushion) {
|
||||
color: var(--color-text-muted, #888);
|
||||
text-decoration: none;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
:deep(.fc-daygrid-day.fc-day-today) {
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
:deep(.fc-event) {
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
padding: 1px 4px;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
:deep(.fc-event-main) { color: #fff; }
|
||||
:deep(.fc-event:not([style*="background"])) {
|
||||
background: var(--color-primary, #6366f1);
|
||||
}
|
||||
:deep(.fc-daygrid-day-frame) { min-height: 5rem; }
|
||||
:deep(.fc-scrollgrid) { border-color: var(--color-border, #2a2b30); }
|
||||
:deep(.fc-scrollgrid td),
|
||||
:deep(.fc-scrollgrid th) { border-color: var(--color-border, #2a2b30); }
|
||||
:deep(.fc-daygrid-day) { cursor: pointer; }
|
||||
:deep(.fc-daygrid-day:hover) { background: rgba(255,255,255,0.03); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user