c3e201d26a
- 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>
271 lines
7.1 KiB
Vue
271 lines
7.1 KiB
Vue
<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>
|