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>
445 lines
13 KiB
Vue
445 lines
13 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
|
import { createEvent, updateEvent, deleteEvent, type EventEntry, type EventCreatePayload, type EventUpdatePayload } from "@/api/client";
|
|
import ProjectSelector from "@/components/ProjectSelector.vue";
|
|
import { useToastStore } from "@/stores/toast";
|
|
|
|
const props = defineProps<{
|
|
// null = create mode; EventEntry = edit mode
|
|
event: EventEntry | null;
|
|
// pre-filled date string for create mode (YYYY-MM-DD or ISO)
|
|
initialDate?: string;
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
(e: "close"): void;
|
|
(e: "created", event: EventEntry): void;
|
|
(e: "updated", event: EventEntry): void;
|
|
(e: "deleted", id: number): void;
|
|
}>();
|
|
|
|
const toast = useToastStore();
|
|
|
|
const isEditMode = computed(() => !!props.event);
|
|
const saving = ref(false);
|
|
const deleting = ref(false);
|
|
const deleteConfirm = ref(false);
|
|
|
|
// Form fields
|
|
const title = ref("");
|
|
const startDate = ref("");
|
|
const startTime = ref("");
|
|
const endDate = ref("");
|
|
const endTime = ref("");
|
|
const allDay = ref(false);
|
|
const description = ref("");
|
|
const location = ref("");
|
|
const color = ref("");
|
|
const projectId = ref<number | null>(null);
|
|
|
|
function dateFromIso(iso: string): string {
|
|
return iso.slice(0, 10);
|
|
}
|
|
|
|
function timeFromIso(iso: string): string {
|
|
if (!iso.includes("T")) return "09:00";
|
|
return iso.slice(11, 16);
|
|
}
|
|
|
|
function toIso(date: string, time: string): string {
|
|
if (!time) return `${date}T00:00:00`;
|
|
return `${date}T${time}:00`;
|
|
}
|
|
|
|
function resetForm() {
|
|
if (props.event) {
|
|
title.value = props.event.title;
|
|
allDay.value = props.event.all_day;
|
|
startDate.value = dateFromIso(props.event.start_dt);
|
|
startTime.value = props.event.all_day ? "" : timeFromIso(props.event.start_dt);
|
|
endDate.value = props.event.end_dt ? dateFromIso(props.event.end_dt) : "";
|
|
endTime.value = props.event.end_dt && !props.event.all_day ? timeFromIso(props.event.end_dt) : "";
|
|
description.value = props.event.description || "";
|
|
location.value = props.event.location || "";
|
|
color.value = props.event.color || "";
|
|
projectId.value = props.event.project_id;
|
|
} else {
|
|
title.value = "";
|
|
allDay.value = false;
|
|
const base = props.initialDate ? dateFromIso(props.initialDate) : new Date().toISOString().slice(0, 10);
|
|
startDate.value = base;
|
|
startTime.value = "09:00";
|
|
endDate.value = base;
|
|
endTime.value = "10:00";
|
|
description.value = "";
|
|
location.value = "";
|
|
color.value = "";
|
|
projectId.value = null;
|
|
}
|
|
deleteConfirm.value = false;
|
|
}
|
|
|
|
watch(() => props.event, resetForm, { immediate: true });
|
|
watch(() => props.initialDate, resetForm);
|
|
|
|
function handleKeydown(e: KeyboardEvent) {
|
|
if (e.key === "Escape") emit("close");
|
|
}
|
|
|
|
onMounted(() => document.addEventListener("keydown", handleKeydown));
|
|
onUnmounted(() => document.removeEventListener("keydown", handleKeydown));
|
|
|
|
async function save() {
|
|
if (!title.value.trim()) {
|
|
toast.show("Title is required", "error");
|
|
return;
|
|
}
|
|
if (!startDate.value) {
|
|
toast.show("Start date is required", "error");
|
|
return;
|
|
}
|
|
|
|
const start_dt = allDay.value ? `${startDate.value}T00:00:00` : toIso(startDate.value, startTime.value);
|
|
const end_dt = endDate.value
|
|
? (allDay.value ? `${endDate.value}T00:00:00` : toIso(endDate.value, endTime.value))
|
|
: undefined;
|
|
|
|
saving.value = true;
|
|
try {
|
|
if (isEditMode.value && props.event) {
|
|
const payload: EventUpdatePayload = {
|
|
title: title.value.trim(),
|
|
start_dt,
|
|
end_dt,
|
|
all_day: allDay.value,
|
|
description: description.value,
|
|
location: location.value,
|
|
color: color.value,
|
|
project_id: projectId.value ?? undefined,
|
|
};
|
|
const updated = await updateEvent(props.event.id, payload);
|
|
toast.show("Event updated", "success");
|
|
emit("updated", updated);
|
|
} else {
|
|
const payload: EventCreatePayload = {
|
|
title: title.value.trim(),
|
|
start_dt,
|
|
end_dt,
|
|
all_day: allDay.value,
|
|
description: description.value,
|
|
location: location.value,
|
|
color: color.value,
|
|
project_id: projectId.value ?? undefined,
|
|
};
|
|
const created = await createEvent(payload);
|
|
toast.show("Event created", "success");
|
|
emit("created", created);
|
|
}
|
|
} catch {
|
|
toast.show("Failed to save event", "error");
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
async function doDelete() {
|
|
if (!props.event) return;
|
|
deleting.value = true;
|
|
try {
|
|
await deleteEvent(props.event.id);
|
|
toast.show("Event deleted", "success");
|
|
emit("deleted", props.event.id);
|
|
} catch {
|
|
toast.show("Failed to delete event", "error");
|
|
deleting.value = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Teleport to="body">
|
|
<div class="slide-over-backdrop" @click.self="emit('close')">
|
|
<div class="slide-over-panel" role="dialog" aria-modal="true">
|
|
<div class="so-header">
|
|
<h2 class="so-title">{{ isEditMode ? "Edit Event" : "New Event" }}</h2>
|
|
<button class="so-close" @click="emit('close')" aria-label="Close">✕</button>
|
|
</div>
|
|
|
|
<form class="so-form" @submit.prevent="save">
|
|
<!-- Title -->
|
|
<div class="so-field">
|
|
<label class="so-label">Title <span class="required">*</span></label>
|
|
<input v-model="title" class="so-input" placeholder="Event title" autofocus />
|
|
</div>
|
|
|
|
<!-- All-day toggle -->
|
|
<div class="so-field so-field-row">
|
|
<label class="so-label so-label-inline">All day</label>
|
|
<button
|
|
type="button"
|
|
:class="['toggle-btn', { active: allDay }]"
|
|
@click="allDay = !allDay"
|
|
>{{ allDay ? "Yes" : "No" }}</button>
|
|
</div>
|
|
|
|
<!-- Start -->
|
|
<div class="so-field">
|
|
<label class="so-label">Start</label>
|
|
<div class="dt-row">
|
|
<input v-model="startDate" type="date" class="so-input dt-date" />
|
|
<input v-if="!allDay" v-model="startTime" type="time" class="so-input dt-time" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- End -->
|
|
<div class="so-field">
|
|
<label class="so-label">End <span class="so-hint">(optional)</span></label>
|
|
<div class="dt-row">
|
|
<input v-model="endDate" type="date" class="so-input dt-date" />
|
|
<input v-if="!allDay" v-model="endTime" type="time" class="so-input dt-time" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Location -->
|
|
<div class="so-field">
|
|
<label class="so-label">Location <span class="so-hint">(optional)</span></label>
|
|
<input v-model="location" class="so-input" placeholder="Location" />
|
|
</div>
|
|
|
|
<!-- Description -->
|
|
<div class="so-field">
|
|
<label class="so-label">Description <span class="so-hint">(optional)</span></label>
|
|
<textarea v-model="description" class="so-input so-textarea" placeholder="Description" rows="3" />
|
|
</div>
|
|
|
|
<!-- Color -->
|
|
<div class="so-field so-field-row">
|
|
<label class="so-label so-label-inline">Color</label>
|
|
<div class="color-row">
|
|
<input v-model="color" type="color" class="color-picker" title="Pick event color" />
|
|
<input v-model="color" class="so-input color-hex" placeholder="#6366f1" />
|
|
<button v-if="color" type="button" class="btn-clear-color" @click="color = ''">✕</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Project -->
|
|
<div class="so-field">
|
|
<label class="so-label">Project <span class="so-hint">(optional)</span></label>
|
|
<ProjectSelector v-model="projectId" />
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="so-actions">
|
|
<button type="submit" class="btn-primary" :disabled="saving">
|
|
{{ saving ? "Saving…" : (isEditMode ? "Save" : "Create") }}
|
|
</button>
|
|
<button type="button" class="btn-secondary" @click="emit('close')">Cancel</button>
|
|
<template v-if="isEditMode">
|
|
<button
|
|
v-if="!deleteConfirm"
|
|
type="button"
|
|
class="btn-danger-ghost"
|
|
@click="deleteConfirm = true"
|
|
>Delete</button>
|
|
<template v-else>
|
|
<span class="delete-confirm-label">Delete this event?</span>
|
|
<button type="button" class="btn-danger" :disabled="deleting" @click="doDelete">
|
|
{{ deleting ? "Deleting…" : "Yes, delete" }}
|
|
</button>
|
|
<button type="button" class="btn-secondary" @click="deleteConfirm = false">No</button>
|
|
</template>
|
|
</template>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.slide-over-backdrop {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.45);
|
|
z-index: 200;
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.slide-over-panel {
|
|
background: var(--color-surface, #1a1b1e);
|
|
border-left: 1px solid var(--color-border, #2a2b30);
|
|
width: min(440px, 100vw);
|
|
height: 100%;
|
|
overflow-y: auto;
|
|
display: flex;
|
|
flex-direction: column;
|
|
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.4);
|
|
}
|
|
|
|
.so-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 1.25rem 1.5rem;
|
|
border-bottom: 1px solid var(--color-border, #2a2b30);
|
|
position: sticky;
|
|
top: 0;
|
|
background: var(--color-surface, #1a1b1e);
|
|
z-index: 1;
|
|
}
|
|
|
|
.so-title {
|
|
font-size: 1.05rem;
|
|
font-weight: 600;
|
|
margin: 0;
|
|
color: var(--color-text, #e8e9f0);
|
|
}
|
|
|
|
.so-close {
|
|
background: none;
|
|
border: none;
|
|
color: var(--color-text-muted, #888);
|
|
cursor: pointer;
|
|
font-size: 1.1rem;
|
|
padding: 0.25rem 0.4rem;
|
|
border-radius: 4px;
|
|
line-height: 1;
|
|
}
|
|
.so-close:hover { background: var(--color-hover, rgba(255,255,255,0.06)); }
|
|
|
|
.so-form {
|
|
padding: 1.25rem 1.5rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1.1rem;
|
|
flex: 1;
|
|
}
|
|
|
|
.so-field { display: flex; flex-direction: column; gap: 0.35rem; }
|
|
.so-field-row { flex-direction: row; align-items: center; gap: 0.75rem; }
|
|
|
|
.so-label {
|
|
font-size: 0.78rem;
|
|
font-weight: 600;
|
|
color: var(--color-text-muted, #888);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
.so-label-inline { flex-shrink: 0; margin: 0; }
|
|
.so-hint { font-weight: 400; text-transform: none; letter-spacing: 0; opacity: 0.7; }
|
|
|
|
.required { color: #f87171; }
|
|
|
|
.so-input {
|
|
background: var(--color-input-bg, #111113);
|
|
border: 1px solid var(--color-border, #2a2b30);
|
|
color: var(--color-text, #e8e9f0);
|
|
border-radius: 6px;
|
|
padding: 0.5rem 0.65rem;
|
|
font-size: 0.9rem;
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
transition: border-color 0.15s;
|
|
}
|
|
.so-input:focus { outline: none; border-color: var(--color-primary, #6366f1); }
|
|
|
|
.so-textarea { resize: vertical; min-height: 5rem; font-family: inherit; }
|
|
|
|
.dt-row { display: flex; gap: 0.5rem; }
|
|
.dt-date { flex: 1; }
|
|
.dt-time { width: 7.5rem; flex-shrink: 0; }
|
|
|
|
.toggle-btn {
|
|
background: var(--color-input-bg, #111113);
|
|
border: 1px solid var(--color-border, #2a2b30);
|
|
color: var(--color-text-muted, #888);
|
|
border-radius: 6px;
|
|
padding: 0.3rem 0.9rem;
|
|
font-size: 0.85rem;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
.toggle-btn.active {
|
|
background: var(--color-primary, #6366f1);
|
|
border-color: var(--color-primary, #6366f1);
|
|
color: #fff;
|
|
}
|
|
|
|
.color-row { display: flex; align-items: center; gap: 0.5rem; flex: 1; }
|
|
.color-picker { width: 2.4rem; height: 2.2rem; border: none; padding: 0; border-radius: 4px; cursor: pointer; flex-shrink: 0; }
|
|
.color-hex { flex: 1; }
|
|
.btn-clear-color {
|
|
background: none;
|
|
border: none;
|
|
color: var(--color-text-muted, #888);
|
|
cursor: pointer;
|
|
padding: 0.2rem 0.3rem;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.so-actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
padding-top: 0.5rem;
|
|
border-top: 1px solid var(--color-border, #2a2b30);
|
|
margin-top: auto;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 8px;
|
|
padding: 0.55rem 1.2rem;
|
|
font-size: 0.9rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: opacity 0.15s;
|
|
}
|
|
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
.btn-primary:hover:not(:disabled) { opacity: 0.88; }
|
|
|
|
.btn-secondary {
|
|
background: var(--color-input-bg, #111113);
|
|
border: 1px solid var(--color-border, #2a2b30);
|
|
color: var(--color-text-muted, #888);
|
|
border-radius: 8px;
|
|
padding: 0.55rem 1rem;
|
|
font-size: 0.9rem;
|
|
cursor: pointer;
|
|
}
|
|
.btn-secondary:hover { background: var(--color-hover, rgba(255,255,255,0.06)); }
|
|
|
|
.btn-danger-ghost {
|
|
margin-left: auto;
|
|
background: none;
|
|
border: 1px solid #ef4444;
|
|
color: #ef4444;
|
|
border-radius: 8px;
|
|
padding: 0.55rem 1rem;
|
|
font-size: 0.9rem;
|
|
cursor: pointer;
|
|
}
|
|
.btn-danger-ghost:hover { background: rgba(239, 68, 68, 0.1); }
|
|
|
|
.btn-danger {
|
|
background: #ef4444;
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 8px;
|
|
padding: 0.55rem 1rem;
|
|
font-size: 0.9rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
}
|
|
.btn-danger:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
|
|
.delete-confirm-label {
|
|
font-size: 0.85rem;
|
|
color: var(--color-text-muted, #888);
|
|
align-self: center;
|
|
}
|
|
</style>
|