Files
FabledScribe/frontend/src/components/EventSlideOver.vue
T

549 lines
17 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 {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso.slice(0, 10);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function timeFromIso(iso: string): string {
if (!iso.includes("T")) return "09:00";
const d = new Date(iso);
if (isNaN(d.getTime())) return iso.slice(11, 16);
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}
function toIso(date: string, time: string): string {
if (!time) return `${date}T00:00:00`;
// Include local timezone offset so the server stores the correct UTC time
const local = new Date(`${date}T${time}:00`);
const off = -local.getTimezoneOffset();
const sign = off >= 0 ? "+" : "-";
const h = String(Math.floor(Math.abs(off) / 60)).padStart(2, "0");
const min = String(Math.abs(off) % 60).padStart(2, "0");
return `${date}T${time}:00${sign}${h}:${min}`;
}
// ── Time helpers ──────────────────────────────────────────────────────────────
/** Round up to next 30-minute boundary */
function nextRoundedTime(): string {
const now = new Date();
let h = now.getHours();
let m = now.getMinutes();
if (m <= 30) { m = 30; }
else { m = 0; h = (h + 1) % 24; }
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
/** Add hours to a time string (HH:MM), returns HH:MM */
function addHours(time: string, hours: number): string {
const [h, m] = time.split(":").map(Number);
const totalMin = h * 60 + m + hours * 60;
const nh = Math.floor(totalMin / 60) % 24;
const nm = totalMin % 60;
return `${String(nh).padStart(2, "0")}:${String(nm).padStart(2, "0")}`;
}
/** Duration in minutes between two time strings */
function durationMin(start: string, end: string): number {
const [sh, sm] = start.split(":").map(Number);
const [eh, em] = end.split(":").map(Number);
return (eh * 60 + em) - (sh * 60 + sm);
}
/** True if a date+time is in the past */
const isPastEvent = computed(() => {
if (allDay.value || !startDate.value || !startTime.value) return false;
const dt = new Date(`${startDate.value}T${startTime.value}:00`);
return dt.getTime() < Date.now();
});
// Track duration so end moves with start
let _lastDurationMin = 60;
function resetForm() {
if (props.event) {
title.value = props.event.title;
allDay.value = props.event.all_day;
startDate.value = props.event.all_day ? props.event.start_dt.slice(0, 10) : dateFromIso(props.event.start_dt);
startTime.value = props.event.all_day ? "" : timeFromIso(props.event.start_dt);
endDate.value = props.event.end_dt ? (props.event.all_day ? props.event.end_dt.slice(0, 10) : dateFromIso(props.event.end_dt)) : startDate.value;
endTime.value = props.event.end_dt && !props.event.all_day ? timeFromIso(props.event.end_dt) : addHours(startTime.value || "09:00", 1);
description.value = props.event.description || "";
location.value = props.event.location || "";
color.value = props.event.color || "";
projectId.value = props.event.project_id;
_lastDurationMin = !allDay.value && startTime.value && endTime.value ? durationMin(startTime.value, endTime.value) : 60;
if (_lastDurationMin <= 0) _lastDurationMin = 60;
} else {
title.value = "";
allDay.value = false;
const base = props.initialDate ? dateFromIso(props.initialDate) : new Date().toISOString().slice(0, 10);
const roundedStart = nextRoundedTime();
startDate.value = base;
startTime.value = roundedStart;
endDate.value = base;
endTime.value = addHours(roundedStart, 1);
description.value = "";
location.value = "";
color.value = "";
projectId.value = null;
_lastDurationMin = 60;
}
deleteConfirm.value = false;
}
// When start time changes, move end time to preserve duration
watch(startTime, (newStart) => {
if (allDay.value || !newStart) return;
endTime.value = addHours(newStart, _lastDurationMin / 60);
});
// When start date changes, move end date to match (preserve same-day or multi-day gap)
watch(startDate, (newDate) => {
if (!newDate) return;
endDate.value = newDate;
});
// When end time changes manually, update the tracked duration (but guard against end < start)
watch(endTime, (newEnd) => {
if (allDay.value || !newEnd || !startTime.value) return;
const dur = durationMin(startTime.value, newEnd);
if (dur <= 0) {
// Snap back to start + 1 hour
endTime.value = addHours(startTime.value, 1);
_lastDurationMin = 60;
} else {
_lastDurationMin = dur;
}
});
// All-day toggle: clear/restore times
watch(allDay, (isAllDay) => {
if (isAllDay) {
startTime.value = "";
endTime.value = "";
} else {
const rounded = nextRoundedTime();
startTime.value = rounded;
endTime.value = addHours(rounded, 1);
_lastDurationMin = 60;
}
});
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;
}
if (!allDay.value && !startTime.value) {
toast.show("Start time 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 <span class="required">*</span></label>
<div class="dt-row">
<input v-model="startDate" type="date" class="so-input dt-date" required />
<input v-if="!allDay" v-model="startTime" type="time" class="so-input dt-time" required />
</div>
<p v-if="isPastEvent" class="so-past-hint">This event is in the past</p>
</div>
<!-- End -->
<div class="so-field">
<label class="so-label">End</label>
<div class="dt-row">
<input v-model="endDate" type="date" class="so-input dt-date" :min="startDate" />
<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="#7c3aed" />
<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); }
.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; }
.so-past-hint {
margin: 4px 0 0;
font-size: 0.75rem;
color: var(--color-accent-warm, #d4a017);
font-style: italic;
}
.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);
border-color: var(--color-primary);
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: var(--gradient-cta);
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>