70ab3f38c6
- Header wordmark Fabled -> Scribe; fable:calendar-changed event -> scribe:calendar-changed; SettingsView CSS comment. - Drop dead Project.auto_summary + summary_updated_at columns (migration 0063) -- the Ollama-era summarizer is gone; model + 2 frontend types + projects test updated. - Remove pivot vestiges: diagnostics _curator_busy()/curator_busy heartbeat field, tz BRIEFING_DAY_START_HOUR/user_briefing_date dead aliases, the ignored 'model' param on get_embedding (+ its test). ruff src/ clean; CI is the gate. Part of scribe plan #599. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
764 lines
22 KiB
Vue
764 lines
22 KiB
Vue
<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";
|
||
import { MapPin } from "lucide-vue-next";
|
||
|
||
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("scribe:calendar-changed", onCalendarChanged);
|
||
});
|
||
onUnmounted(() => {
|
||
document.removeEventListener("mousedown", onDocClick);
|
||
document.removeEventListener("scribe: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">
|
||
<MapPin :size="16" style="flex-shrink:0" />
|
||
{{ 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">
|
||
<MapPin :size="16" style="flex-shrink:0;margin-top:1px" />
|
||
{{ 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: 500;
|
||
margin: 0;
|
||
color: var(--color-text, #e8e9f0);
|
||
}
|
||
|
||
/* New event: Moss action-primary — workflow action, not a brand moment */
|
||
.btn-new-event {
|
||
background: var(--color-action-primary);
|
||
color: #fff;
|
||
border: none;
|
||
border-radius: 8px;
|
||
padding: 0.55rem 1.1rem;
|
||
font-size: 0.9rem;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: background 0.15s;
|
||
}
|
||
.btn-new-event:hover { background: var(--color-action-primary-hover); }
|
||
|
||
.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) {
|
||
/* FullCalendar renders this as <h2>, which would otherwise pick up the
|
||
theme.css h1/h2 → Fraunces rule. At 1.1rem it's below the doc's
|
||
"Fraunces only at ≥18px" threshold, so explicit Inter. */
|
||
font-family: 'Inter', system-ui, sans-serif;
|
||
font-size: 1.1rem;
|
||
font-weight: 500;
|
||
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: 500;
|
||
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(91, 74, 138,0.22);
|
||
color: var(--color-primary);
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* ── Upcoming strip ─────────────────────────────────────────────────────── */
|
||
.upcoming-section {
|
||
margin-top: 2rem;
|
||
}
|
||
|
||
.upcoming-title {
|
||
font-size: 1rem;
|
||
font-weight: 500;
|
||
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: 500;
|
||
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, #5B4A8A);
|
||
}
|
||
|
||
.upcoming-card-body {
|
||
padding: 0.6rem 0.85rem;
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.upcoming-card-title {
|
||
font-size: 0.9rem;
|
||
font-weight: 500;
|
||
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: 500;
|
||
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: 500;
|
||
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>
|