Files
FabledScribe/frontend/src/views/CalendarView.vue
T

762 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } 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";
import { fmtTime, fmtDateTime, fmtDayLabel } from "@/utils/dateFormat";
const toast = useToastStore();
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);
// Slide-over state
const slideOverEvent = ref<EventEntry | null>(null);
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
const eventCache = new Map<number, EventEntry>();
function toFcEvent(e: EventEntry) {
// For all-day events pass date-only strings so FullCalendar never shifts
// the date through timezone conversion (UTC midnight → previous day in UTC-X).
return {
id: String(e.id),
title: e.title,
start: e.all_day ? e.start_dt.slice(0, 10) : e.start_dt,
end: e.all_day ? (e.end_dt?.slice(0, 10) ?? undefined) : (e.end_dt ?? undefined),
allDay: e.all_day,
backgroundColor: e.color || undefined,
borderColor: e.color || undefined,
extendedProps: { entryId: e.id },
};
}
// ── Upcoming events list ───────────────────────────────────────────────────
const upcomingEvents = ref<EventEntry[]>([]);
async function loadUpcoming() {
const now = new Date();
const end = new Date(now.getTime() + 28 * 86_400_000); // 4 weeks
try {
const entries = await listEvents(now.toISOString(), end.toISOString());
upcomingEvents.value = entries.sort(
(a, b) => new Date(a.start_dt).getTime() - new Date(b.start_dt).getTime()
);
} catch { /* silent */ }
}
onMounted(loadUpcoming);
// ── Month/year picker ──────────────────────────────────────────────────────
const currentViewYear = ref(new Date().getFullYear());
const currentViewMonth = ref(new Date().getMonth());
const pickerOpen = ref(false);
const pickerYear = ref(new Date().getFullYear());
const pickerStyle = ref<Record<string, string>>({});
const pickerEl = ref<HTMLElement | null>(null);
const MONTH_NAMES = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] as const;
function handleDatesSet(arg: { view: { currentStart: Date } }) {
const d = arg.view.currentStart;
currentViewYear.value = d.getFullYear();
currentViewMonth.value = d.getMonth();
}
function jumpTo(year: number, month: number) {
calendarRef.value?.getApi().gotoDate(new Date(year, month, 1));
pickerOpen.value = false;
}
// ── Event popover ──────────────────────────────────────────────────────────
const popover = ref<EventEntry | null>(null);
const popoverStyle = ref<Record<string, string>>({});
const popoverEl = ref<HTMLElement | null>(null);
function showPopover(entry: EventEntry, clickEvent: MouseEvent) {
popover.value = entry;
nextTickPositionPopover(clickEvent);
}
function nextTickPositionPopover(clickEvent: MouseEvent) {
// Position after DOM update
requestAnimationFrame(() => {
const vw = window.innerWidth;
const vh = window.innerHeight;
const pw = 280;
const ph = 220; // approximate
let left = clickEvent.clientX + 8;
let top = clickEvent.clientY + 8;
if (left + pw > vw - 16) left = clickEvent.clientX - pw - 8;
if (top + ph > vh - 16) top = clickEvent.clientY - ph - 8;
popoverStyle.value = {
position: "fixed",
left: `${Math.max(8, left)}px`,
top: `${Math.max(8, top)}px`,
zIndex: "9999",
};
});
}
function closePopover() {
popover.value = null;
}
function onPopoverEdit() {
if (popover.value) {
openEdit(popover.value);
closePopover();
}
}
// Close popover / open picker on outside or title click
function onDocClick(e: MouseEvent) {
const target = e.target as HTMLElement;
// Title click → toggle month/year picker
const titleEl = target.closest(".fc-toolbar-title");
if (titleEl) {
if (!pickerOpen.value) {
pickerYear.value = currentViewYear.value;
const rect = titleEl.getBoundingClientRect();
const left = Math.max(8, Math.min(rect.left + rect.width / 2 - 140, window.innerWidth - 296));
pickerStyle.value = {
position: "fixed",
top: `${rect.bottom + 6}px`,
left: `${left}px`,
zIndex: "9999",
};
}
pickerOpen.value = !pickerOpen.value;
return;
}
// Close picker on outside click
if (pickerOpen.value && pickerEl.value && !pickerEl.value.contains(target)) {
pickerOpen.value = false;
}
// Close event popover on outside click
if (popover.value && popoverEl.value && !popoverEl.value.contains(target)) {
closePopover();
}
}
function onCalendarChanged() {
calendarRef.value?.getApi().refetchEvents();
}
onMounted(() => {
document.addEventListener("mousedown", onDocClick);
document.addEventListener("fable:calendar-changed", onCalendarChanged);
});
onUnmounted(() => {
document.removeEventListener("mousedown", onDocClick);
document.removeEventListener("fable:calendar-changed", onCalendarChanged);
});
// ── Calendar callbacks ─────────────────────────────────────────────────────
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));
loadUpcoming();
} catch (err) {
failureCallback(err instanceof Error ? err : new Error(String(err)));
}
}
function handleDateClick(arg: DateClickArg) {
closePopover();
openCreate(arg.dateStr);
}
function handleEventClick(arg: EventClickArg) {
const id = arg.event.extendedProps.entryId as number;
const entry = eventCache.get(id);
if (entry) showPopover(entry, arg.jsEvent as MouseEvent);
}
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);
loadUpcoming();
} 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);
loadUpcoming();
} 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();
loadUpcoming();
}
function onUpdated(entry: EventEntry) {
eventCache.set(entry.id, entry);
const api = calendarRef.value?.getApi();
if (api) {
const existing = api.getEventById(String(entry.id));
if (existing) {
existing.remove();
api.addEvent(toFcEvent(entry));
}
}
closeSlideOver();
loadUpcoming();
}
function onDeleted(id: number) {
eventCache.delete(id);
const api = calendarRef.value?.getApi();
if (api) api.getEventById(String(id))?.remove();
closeSlideOver();
upcomingEvents.value = upcomingEvents.value.filter((e) => e.id !== id);
}
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,
datesSet: handleDatesSet,
dateClick: handleDateClick,
eventClick: handleEventClick,
eventDrop: handleEventDrop,
eventResize: handleEventResize,
height: "auto",
};
// Group upcoming events by day label
const upcomingGrouped = computed(() => {
const groups: { label: string; date: string; events: EventEntry[] }[] = [];
for (const e of upcomingEvents.value) {
const label = fmtDayLabel(e.start_dt);
const existing = groups.find((g) => g.label === label);
if (existing) {
existing.events.push(e);
} else {
groups.push({ label, date: e.start_dt, events: [e] });
}
}
return groups;
});
</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>
<!-- ── Upcoming events strip ───────────────────────────────────────── -->
<div v-if="upcomingEvents.length" class="upcoming-section">
<h2 class="upcoming-title">Upcoming</h2>
<div class="upcoming-groups">
<div v-for="group in upcomingGrouped" :key="group.label" class="upcoming-group">
<div class="upcoming-day-label">{{ group.label }}</div>
<div class="upcoming-cards">
<div
v-for="ev in group.events"
:key="ev.id"
class="upcoming-card"
:style="ev.color ? { '--ev-color': ev.color } : {}"
@click="openEdit(ev)"
>
<div class="upcoming-card-accent"></div>
<div class="upcoming-card-body">
<div class="upcoming-card-title">{{ ev.title }}</div>
<div class="upcoming-card-time">
<template v-if="ev.all_day">All day</template>
<template v-else>
{{ fmtTime(ev.start_dt) }}
<span v-if="ev.end_dt"> {{ fmtTime(ev.end_dt) }}</span>
</template>
</div>
<div v-if="ev.location" class="upcoming-card-meta">
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="flex-shrink:0">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
</svg>
{{ ev.location }}
</div>
<div v-if="ev.description" class="upcoming-card-desc">{{ ev.description }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ── Event popover ───────────────────────────────────────────────── -->
<Teleport to="body">
<div
v-if="popover"
ref="popoverEl"
class="event-popover"
:style="popoverStyle"
>
<div class="popover-accent" :style="popover.color ? { background: popover.color } : {}"></div>
<div class="popover-content">
<div class="popover-title">{{ popover.title }}</div>
<div class="popover-time">
<template v-if="popover.all_day">
{{ fmtDateTime(popover.start_dt, true) }}
</template>
<template v-else>
{{ fmtDateTime(popover.start_dt, false) }}
<span v-if="popover.end_dt"> {{ fmtTime(popover.end_dt) }}</span>
</template>
</div>
<div v-if="popover.location" class="popover-meta">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="flex-shrink:0;margin-top:1px">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
</svg>
{{ popover.location }}
</div>
<div v-if="popover.description" class="popover-desc">{{ popover.description }}</div>
<div class="popover-actions">
<button class="popover-btn popover-btn--edit" @click="onPopoverEdit">Edit</button>
<button class="popover-btn popover-btn--close" @click="closePopover">Close</button>
</div>
</div>
</div>
</Teleport>
<!-- ── Month/year picker ──────────────────────────────────────────── -->
<Teleport to="body">
<div v-if="pickerOpen" ref="pickerEl" class="month-picker" :style="pickerStyle">
<div class="picker-year-row">
<button class="picker-year-btn" @click="pickerYear--" aria-label="Previous year"></button>
<span class="picker-year-label">{{ pickerYear }}</span>
<button class="picker-year-btn" @click="pickerYear++" aria-label="Next year"></button>
</div>
<div class="picker-months">
<button
v-for="(name, i) in MONTH_NAMES"
:key="i"
class="picker-month"
:class="{ active: pickerYear === currentViewYear && i === currentViewMonth }"
@click="jumpTo(pickerYear, i)"
>{{ name }}</button>
</div>
</div>
</Teleport>
<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: var(--page-max-width);
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: var(--gradient-cta);
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);
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;
cursor: pointer;
border-radius: 6px;
padding: 2px 8px;
transition: background 0.15s;
user-select: none;
}
:deep(.fc-toolbar-title:hover) {
background: rgba(255,255,255,0.07);
}
:deep(.fc-button) {
background: var(--color-input-bg, var(--color-bg));
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) !important;
border-color: var(--color-primary) !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: var(--color-primary-tint);
}
: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);
}
: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); }
/* ── Month/year picker ──────────────────────────────────────────────────── */
.month-picker {
background: var(--color-bg-card);
border: 1px solid var(--color-border, #2a2b30);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
width: 280px;
padding: 12px 14px;
}
.picker-year-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.picker-year-label {
font-size: 0.95rem;
font-weight: 700;
color: var(--color-text, #e8e9f0);
}
.picker-year-btn {
background: none;
border: 1px solid var(--color-border, #2a2b30);
border-radius: 6px;
color: var(--color-text-muted, #888);
cursor: pointer;
padding: 2px 10px;
font-size: 1rem;
line-height: 1.4;
transition: color 0.15s, border-color 0.15s;
}
.picker-year-btn:hover {
color: var(--color-text, #e8e9f0);
border-color: rgba(255,255,255,0.25);
}
.picker-months {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
}
.picker-month {
padding: 7px 4px;
border: none;
border-radius: 7px;
background: transparent;
color: var(--color-text, #e8e9f0);
cursor: pointer;
font-size: 0.84rem;
text-align: center;
transition: background 0.12s, color 0.12s;
}
.picker-month:hover {
background: rgba(255,255,255,0.08);
}
.picker-month.active {
background: rgba(124,58,237,0.22);
color: var(--color-primary);
font-weight: 700;
}
/* ── Upcoming strip ─────────────────────────────────────────────────────── */
.upcoming-section {
margin-top: 2rem;
}
.upcoming-title {
font-size: 1rem;
font-weight: 600;
color: var(--color-text-muted, #888);
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 0.78rem;
margin: 0 0 1rem;
}
.upcoming-groups {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.upcoming-day-label {
font-size: 0.8rem;
font-weight: 700;
color: var(--color-text-muted, #888);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.upcoming-cards {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.upcoming-card {
display: flex;
align-items: stretch;
gap: 0;
background: var(--color-surface);
border: 1px solid var(--color-border, #2a2b30);
border-radius: 10px;
overflow: hidden;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.upcoming-card:hover {
border-color: color-mix(in srgb, var(--color-primary) 40%, transparent);
background: var(--color-bg-secondary);
}
.upcoming-card-accent {
width: 4px;
flex-shrink: 0;
background: var(--ev-color, #7c3aed);
}
.upcoming-card-body {
padding: 0.6rem 0.85rem;
flex: 1;
min-width: 0;
}
.upcoming-card-title {
font-size: 0.9rem;
font-weight: 600;
color: var(--color-text, #e8e9f0);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.upcoming-card-time {
font-size: 0.78rem;
color: var(--color-text-muted, #888);
margin-top: 0.15rem;
}
.upcoming-card-meta {
display: flex;
align-items: flex-start;
gap: 0.3rem;
font-size: 0.78rem;
color: var(--color-text-muted, #888);
margin-top: 0.25rem;
}
.upcoming-card-desc {
font-size: 0.8rem;
color: var(--color-text-secondary, #aaa);
margin-top: 0.3rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* ── Event popover ──────────────────────────────────────────────────────── */
.event-popover {
background: var(--color-bg-card);
border: 1px solid var(--color-border, #2a2b30);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.45);
width: 280px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.popover-accent {
height: 4px;
background: var(--color-primary);
}
.popover-content {
padding: 0.85rem 1rem 0.75rem;
}
.popover-title {
font-size: 0.95rem;
font-weight: 700;
color: var(--color-text, #e8e9f0);
margin-bottom: 0.35rem;
}
.popover-time {
font-size: 0.8rem;
color: var(--color-text-muted, #888);
margin-bottom: 0.4rem;
}
.popover-meta {
display: flex;
align-items: flex-start;
gap: 0.3rem;
font-size: 0.8rem;
color: var(--color-text-muted, #888);
margin-bottom: 0.4rem;
}
.popover-desc {
font-size: 0.82rem;
color: var(--color-text-secondary, #aaa);
line-height: 1.45;
margin-bottom: 0.6rem;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
.popover-actions {
display: flex;
gap: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--color-border, #2a2b30);
}
.popover-btn {
flex: 1;
padding: 0.35rem 0;
border: none;
border-radius: 6px;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.popover-btn:hover { opacity: 0.85; }
.popover-btn--edit {
background: var(--color-primary);
color: #fff;
}
.popover-btn--close {
background: var(--color-input-bg, var(--color-bg));
color: var(--color-text-muted, #888);
border: 1px solid var(--color-border, #2a2b30);
}
</style>