feat(calendar): add hierarchical zoom drill-down transitions
This commit is contained in:
604
frontend/app.vue
604
frontend/app.vue
@@ -2074,7 +2074,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
});
|
||||
|
||||
const calendarView = ref<CalendarView>("month");
|
||||
const calendarView = ref<CalendarView>("year");
|
||||
const calendarCursor = ref(new Date(new Date().getFullYear(), new Date().getMonth(), 1));
|
||||
const selectedDateKey = ref(dayKey(new Date()));
|
||||
|
||||
@@ -2115,118 +2115,112 @@ const calendarViewOptions: { value: CalendarView; label: string }[] = [
|
||||
{ value: "agenda", label: "Agenda" },
|
||||
];
|
||||
|
||||
const calendarZoomLevel = computed<number>({
|
||||
get() {
|
||||
if (calendarView.value === "year") return 1;
|
||||
if (calendarView.value === "month" || calendarView.value === "agenda") return 2;
|
||||
if (calendarView.value === "week") return 3;
|
||||
return 4;
|
||||
},
|
||||
set(next) {
|
||||
const level = Math.max(1, Math.min(4, Number(next) || 2));
|
||||
if (level === 1) {
|
||||
calendarView.value = "year";
|
||||
return;
|
||||
}
|
||||
if (level === 2) {
|
||||
calendarView.value = "month";
|
||||
return;
|
||||
}
|
||||
if (level === 3) {
|
||||
calendarView.value = "week";
|
||||
return;
|
||||
}
|
||||
calendarView.value = "day";
|
||||
},
|
||||
type CalendarTransitionDirection = "in" | "out" | "side";
|
||||
|
||||
const calendarTransitionDirection = ref<CalendarTransitionDirection>("side");
|
||||
const calendarHoveredMonthIndex = ref<number | null>(null);
|
||||
const calendarHoveredWeekStartKey = ref("");
|
||||
const calendarHoveredDayKey = ref("");
|
||||
let calendarWheelLockUntil = 0;
|
||||
|
||||
const calendarSceneKey = computed(() => `${calendarView.value}-${calendarRouteToken(calendarView.value)}`);
|
||||
const calendarTransitionName = computed(() => {
|
||||
if (calendarTransitionDirection.value === "in") return "calendar-zoom-in";
|
||||
if (calendarTransitionDirection.value === "out") return "calendar-zoom-out";
|
||||
return "calendar-zoom-side";
|
||||
});
|
||||
|
||||
const calendarZoomLabel = computed(() => {
|
||||
const canCalendarZoomIn = computed(() => calendarView.value !== "day");
|
||||
const canCalendarZoomOut = computed(() => calendarView.value !== "year");
|
||||
const calendarZoomDepthLabel = computed(() => {
|
||||
if (calendarView.value === "day") return "Day";
|
||||
if (calendarView.value === "week") return "Week";
|
||||
if (calendarView.value === "month" || calendarView.value === "agenda") return "Month";
|
||||
return "Year";
|
||||
return "Months";
|
||||
});
|
||||
|
||||
const calendarPanMode = ref(false);
|
||||
const calendarCanvasRef = ref<HTMLElement | null>(null);
|
||||
const calendarCanvasScale = ref(1);
|
||||
const calendarCanvasOffsetX = ref(0);
|
||||
const calendarCanvasOffsetY = ref(0);
|
||||
const calendarCanvasDragging = ref(false);
|
||||
let calendarCanvasPointerId: number | null = null;
|
||||
let calendarCanvasDragStartX = 0;
|
||||
let calendarCanvasDragStartY = 0;
|
||||
let calendarCanvasStartOffsetX = 0;
|
||||
let calendarCanvasStartOffsetY = 0;
|
||||
|
||||
const calendarCanvasStyle = computed(() => ({
|
||||
transform: `translate(${calendarCanvasOffsetX.value}px, ${calendarCanvasOffsetY.value}px) scale(${calendarCanvasScale.value})`,
|
||||
}));
|
||||
|
||||
function clampCalendarCanvasScale(next: number) {
|
||||
return Math.max(0.72, Math.min(2.35, Number(next) || 1));
|
||||
function setCalendarTransition(direction: CalendarTransitionDirection) {
|
||||
calendarTransitionDirection.value = direction;
|
||||
}
|
||||
|
||||
function resetCalendarCanvas() {
|
||||
calendarCanvasScale.value = 1;
|
||||
calendarCanvasOffsetX.value = 0;
|
||||
calendarCanvasOffsetY.value = 0;
|
||||
calendarCanvasDragging.value = false;
|
||||
calendarCanvasPointerId = null;
|
||||
function resolveMonthAnchor(event?: WheelEvent) {
|
||||
const target = event?.target as HTMLElement | null;
|
||||
const monthAttr = target?.closest<HTMLElement>("[data-calendar-month-index]")?.dataset.calendarMonthIndex;
|
||||
if (monthAttr) {
|
||||
const parsed = Number(monthAttr);
|
||||
if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 11) return parsed;
|
||||
}
|
||||
if (calendarHoveredMonthIndex.value !== null) return calendarHoveredMonthIndex.value;
|
||||
return calendarCursor.value.getMonth();
|
||||
}
|
||||
|
||||
function toggleCalendarPanMode() {
|
||||
calendarPanMode.value = !calendarPanMode.value;
|
||||
if (!calendarPanMode.value) {
|
||||
calendarCanvasDragging.value = false;
|
||||
calendarCanvasPointerId = null;
|
||||
function resolveWeekAnchor(event?: WheelEvent) {
|
||||
const target = event?.target as HTMLElement | null;
|
||||
const weekKey = target?.closest<HTMLElement>("[data-calendar-week-start-key]")?.dataset.calendarWeekStartKey;
|
||||
if (weekKey) return weekKey;
|
||||
if (calendarHoveredWeekStartKey.value) return calendarHoveredWeekStartKey.value;
|
||||
if (calendarHoveredDayKey.value) return calendarHoveredDayKey.value;
|
||||
return selectedDateKey.value;
|
||||
}
|
||||
|
||||
function resolveDayAnchor(event?: WheelEvent) {
|
||||
const target = event?.target as HTMLElement | null;
|
||||
const dayKeyAttr = target?.closest<HTMLElement>("[data-calendar-day-key]")?.dataset.calendarDayKey;
|
||||
if (dayKeyAttr) return dayKeyAttr;
|
||||
if (calendarHoveredDayKey.value) return calendarHoveredDayKey.value;
|
||||
return selectedDateKey.value;
|
||||
}
|
||||
|
||||
function zoomInCalendar(event?: Event) {
|
||||
const wheelEvent = event instanceof WheelEvent ? event : undefined;
|
||||
if (calendarView.value === "year") {
|
||||
openYearMonth(resolveMonthAnchor(wheelEvent), "in");
|
||||
return;
|
||||
}
|
||||
|
||||
if (calendarView.value === "month" || calendarView.value === "agenda") {
|
||||
openWeekView(resolveWeekAnchor(wheelEvent), "in");
|
||||
return;
|
||||
}
|
||||
|
||||
if (calendarView.value === "week") {
|
||||
openDayView(resolveDayAnchor(wheelEvent), "in");
|
||||
}
|
||||
}
|
||||
|
||||
function onCalendarCanvasWheel(event: WheelEvent) {
|
||||
const host = calendarCanvasRef.value;
|
||||
if (!host) return;
|
||||
function zoomOutCalendar() {
|
||||
focusedCalendarEventId.value = "";
|
||||
|
||||
const rect = host.getBoundingClientRect();
|
||||
const pivotX = event.clientX - rect.left;
|
||||
const pivotY = event.clientY - rect.top;
|
||||
const prevScale = calendarCanvasScale.value;
|
||||
const ratio = event.deltaY < 0 ? 1.08 : 0.92;
|
||||
const nextScale = clampCalendarCanvasScale(prevScale * ratio);
|
||||
if (Math.abs(nextScale - prevScale) < 0.0001) return;
|
||||
if (calendarView.value === "day") {
|
||||
setCalendarTransition("out");
|
||||
calendarView.value = "week";
|
||||
return;
|
||||
}
|
||||
|
||||
const contentX = (pivotX - calendarCanvasOffsetX.value) / prevScale;
|
||||
const contentY = (pivotY - calendarCanvasOffsetY.value) / prevScale;
|
||||
calendarCanvasScale.value = nextScale;
|
||||
calendarCanvasOffsetX.value = pivotX - contentX * nextScale;
|
||||
calendarCanvasOffsetY.value = pivotY - contentY * nextScale;
|
||||
if (calendarView.value === "week") {
|
||||
setCalendarTransition("out");
|
||||
calendarView.value = "month";
|
||||
return;
|
||||
}
|
||||
|
||||
if (calendarView.value === "month" || calendarView.value === "agenda") {
|
||||
setCalendarTransition("out");
|
||||
calendarView.value = "year";
|
||||
}
|
||||
}
|
||||
|
||||
function onCalendarCanvasPointerDown(event: PointerEvent) {
|
||||
if (!calendarPanMode.value || event.button !== 0) return;
|
||||
const target = event.currentTarget as HTMLElement | null;
|
||||
if (!target) return;
|
||||
function onCalendarHierarchyWheel(event: WheelEvent) {
|
||||
const now = Date.now();
|
||||
if (now < calendarWheelLockUntil) return;
|
||||
if (Math.abs(event.deltaY) < 5) return;
|
||||
calendarWheelLockUntil = now + 240;
|
||||
|
||||
calendarCanvasDragging.value = true;
|
||||
calendarCanvasPointerId = event.pointerId;
|
||||
calendarCanvasDragStartX = event.clientX;
|
||||
calendarCanvasDragStartY = event.clientY;
|
||||
calendarCanvasStartOffsetX = calendarCanvasOffsetX.value;
|
||||
calendarCanvasStartOffsetY = calendarCanvasOffsetY.value;
|
||||
target.setPointerCapture(event.pointerId);
|
||||
}
|
||||
if (event.deltaY < 0) {
|
||||
zoomInCalendar(event);
|
||||
return;
|
||||
}
|
||||
|
||||
function onCalendarCanvasPointerMove(event: PointerEvent) {
|
||||
if (!calendarPanMode.value || !calendarCanvasDragging.value || calendarCanvasPointerId !== event.pointerId) return;
|
||||
calendarCanvasOffsetX.value = calendarCanvasStartOffsetX + (event.clientX - calendarCanvasDragStartX);
|
||||
calendarCanvasOffsetY.value = calendarCanvasStartOffsetY + (event.clientY - calendarCanvasDragStartY);
|
||||
}
|
||||
|
||||
function stopCalendarCanvasDrag(event?: PointerEvent) {
|
||||
if (!calendarCanvasDragging.value) return;
|
||||
if (event && calendarCanvasPointerId !== event.pointerId) return;
|
||||
calendarCanvasDragging.value = false;
|
||||
calendarCanvasPointerId = null;
|
||||
zoomOutCalendar();
|
||||
}
|
||||
|
||||
const monthCells = computed(() => {
|
||||
@@ -2344,6 +2338,7 @@ const selectedDayEvents = computed(() => getEventsByDate(selectedDateKey.value))
|
||||
|
||||
function shiftCalendar(step: number) {
|
||||
focusedCalendarEventId.value = "";
|
||||
setCalendarTransition("side");
|
||||
if (calendarView.value === "year") {
|
||||
const next = new Date(calendarCursor.value);
|
||||
next.setFullYear(next.getFullYear() + step);
|
||||
@@ -2371,6 +2366,7 @@ function shiftCalendar(step: number) {
|
||||
|
||||
function setToday() {
|
||||
focusedCalendarEventId.value = "";
|
||||
setCalendarTransition("side");
|
||||
const now = new Date();
|
||||
selectedDateKey.value = dayKey(now);
|
||||
calendarCursor.value = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
@@ -2383,21 +2379,24 @@ function pickDate(key: string) {
|
||||
calendarCursor.value = new Date(d.getFullYear(), d.getMonth(), 1);
|
||||
}
|
||||
|
||||
function openDayView(key: string) {
|
||||
function openDayView(key: string, direction: CalendarTransitionDirection = "in") {
|
||||
pickDate(key);
|
||||
setCalendarTransition(direction);
|
||||
calendarView.value = "day";
|
||||
}
|
||||
|
||||
function openWeekView(key: string) {
|
||||
function openWeekView(key: string, direction: CalendarTransitionDirection = "in") {
|
||||
pickDate(key);
|
||||
setCalendarTransition(direction);
|
||||
calendarView.value = "week";
|
||||
}
|
||||
|
||||
function openYearMonth(monthIndex: number) {
|
||||
function openYearMonth(monthIndex: number, direction: CalendarTransitionDirection = "in") {
|
||||
focusedCalendarEventId.value = "";
|
||||
const year = calendarCursor.value.getFullYear();
|
||||
calendarCursor.value = new Date(year, monthIndex, 1);
|
||||
selectedDateKey.value = dayKey(new Date(year, monthIndex, 1));
|
||||
setCalendarTransition(direction);
|
||||
calendarView.value = "month";
|
||||
}
|
||||
|
||||
@@ -4083,30 +4082,12 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
</div>
|
||||
|
||||
<div class="justify-self-end flex items-center gap-2">
|
||||
<button
|
||||
class="btn btn-xs"
|
||||
:class="calendarPanMode ? 'btn-primary' : 'btn-ghost'"
|
||||
:title="calendarPanMode ? 'Disable pan mode' : 'Enable pan mode'"
|
||||
@click.stop="toggleCalendarPanMode"
|
||||
>
|
||||
Pan
|
||||
</button>
|
||||
<button class="btn btn-xs btn-ghost" title="Reset canvas" @click.stop="resetCalendarCanvas">Reset</button>
|
||||
<span class="text-[10px] uppercase tracking-wide text-base-content/60">Zoom</span>
|
||||
<div class="flex items-center gap-2 rounded-lg border border-base-300 bg-base-100 px-2 py-1">
|
||||
<input
|
||||
v-model.number="calendarZoomLevel"
|
||||
type="range"
|
||||
min="1"
|
||||
max="4"
|
||||
step="1"
|
||||
class="range range-xs w-24 calendar-zoom-range"
|
||||
aria-label="Calendar zoom level"
|
||||
>
|
||||
<span class="min-w-[3.2rem] text-right text-[10px] font-semibold uppercase tracking-wide text-base-content/70">
|
||||
{{ calendarZoomLabel }}
|
||||
</span>
|
||||
<div class="hidden items-center gap-2 rounded-lg border border-base-300 bg-base-100 px-2 py-1 sm:flex">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-wide text-base-content/60">Wheel to zoom</span>
|
||||
<span class="badge badge-ghost badge-xs min-w-[3.2rem] justify-center">{{ calendarZoomDepthLabel }}</span>
|
||||
</div>
|
||||
<button class="btn btn-xs btn-ghost" :disabled="!canCalendarZoomOut" title="Zoom out" @click.stop="zoomOutCalendar">−</button>
|
||||
<button class="btn btn-xs btn-ghost" :disabled="!canCalendarZoomIn" title="Zoom in" @click.stop="zoomInCalendar">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4141,186 +4122,190 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
</button>
|
||||
<div
|
||||
class="calendar-content-scroll min-h-0 h-full overflow-y-auto pr-1"
|
||||
:class="calendarPanMode ? (calendarCanvasDragging ? 'calendar-pan-active' : 'calendar-pan-ready') : ''"
|
||||
@wheel.prevent="onCalendarHierarchyWheel"
|
||||
>
|
||||
<div
|
||||
ref="calendarCanvasRef"
|
||||
class="calendar-canvas"
|
||||
:style="calendarCanvasStyle"
|
||||
@wheel.prevent="onCalendarCanvasWheel"
|
||||
@pointerdown="onCalendarCanvasPointerDown"
|
||||
@pointermove="onCalendarCanvasPointerMove"
|
||||
@pointerup="stopCalendarCanvasDrag"
|
||||
@pointercancel="stopCalendarCanvasDrag"
|
||||
@pointerleave="stopCalendarCanvasDrag"
|
||||
>
|
||||
<div v-if="calendarView === 'month'" class="space-y-1">
|
||||
<div class="grid grid-cols-7 gap-1 text-center text-xs font-semibold text-base-content/60">
|
||||
<span>Sun</span>
|
||||
<span>Mon</span>
|
||||
<span>Tue</span>
|
||||
<span>Wed</span>
|
||||
<span>Thu</span>
|
||||
<span>Fri</span>
|
||||
<span>Sat</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<Transition :name="calendarTransitionName" mode="out-in">
|
||||
<div
|
||||
v-for="row in monthRows"
|
||||
:key="row.key"
|
||||
class="group relative"
|
||||
:key="calendarSceneKey"
|
||||
class="calendar-scene"
|
||||
@mouseleave="calendarHoveredMonthIndex = null; calendarHoveredWeekStartKey = ''; calendarHoveredDayKey = ''"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="calendar-hover-jump calendar-hover-jump-row"
|
||||
title="Expand week vertically"
|
||||
aria-label="Expand week vertically"
|
||||
@click.stop="openWeekView(row.startKey)"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" class="h-3.5 w-3.5 fill-none stroke-current" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M10 2.5v15M6.2 6.4 10 2.5l3.8 3.9M6.2 13.6 10 17.5l3.8-3.9" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="grid grid-cols-7 gap-1">
|
||||
<button
|
||||
v-for="cell in row.cells"
|
||||
:key="cell.key"
|
||||
class="group relative min-h-24 rounded-lg border p-1 text-left"
|
||||
:class="[
|
||||
cell.inMonth ? 'border-base-300 bg-base-100' : 'border-base-200 bg-base-200/40 text-base-content/40',
|
||||
selectedDateKey === cell.key ? 'border-primary bg-primary/5' : '',
|
||||
monthCellHasFocusedEvent(cell.events) ? 'border-success/60 bg-success/10' : '',
|
||||
]"
|
||||
@click="pickDate(cell.key)"
|
||||
>
|
||||
<p class="mb-1 text-xs font-semibold">{{ cell.day }}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="calendar-hover-jump"
|
||||
title="Expand to day"
|
||||
aria-label="Expand to day"
|
||||
@click.stop="openDayView(cell.key)"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" class="h-3.5 w-3.5 fill-none stroke-current" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M7 13L3 17M13 7l4-4M3 13V17h4M17 7V3h-4" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-for="event in monthCellEvents(cell.events)"
|
||||
:key="event.id"
|
||||
class="block w-full truncate rounded px-1 text-left text-[10px] text-base-content/70 transition hover:underline"
|
||||
:class="isReviewHighlightedEvent(event.id) ? 'bg-success/20 text-success-content ring-1 ring-success/40' : ''"
|
||||
@click.stop="openThreadFromCalendarItem(event)"
|
||||
<div v-if="calendarView === 'month'" class="space-y-1">
|
||||
<div class="grid grid-cols-7 gap-1 text-center text-xs font-semibold text-base-content/60">
|
||||
<span>Sun</span>
|
||||
<span>Mon</span>
|
||||
<span>Tue</span>
|
||||
<span>Wed</span>
|
||||
<span>Thu</span>
|
||||
<span>Fri</span>
|
||||
<span>Sat</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="row in monthRows"
|
||||
:key="row.key"
|
||||
class="group relative"
|
||||
:data-calendar-week-start-key="row.startKey"
|
||||
@mouseenter="calendarHoveredWeekStartKey = row.startKey"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="calendar-hover-jump calendar-hover-jump-row"
|
||||
title="Expand week vertically"
|
||||
aria-label="Expand week vertically"
|
||||
@click.stop="openWeekView(row.startKey)"
|
||||
>
|
||||
{{ formatTime(event.start) }} {{ event.title }}
|
||||
<svg viewBox="0 0 20 20" class="h-3.5 w-3.5 fill-none stroke-current" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M10 2.5v15M6.2 6.4 10 2.5l3.8 3.9M6.2 13.6 10 17.5l3.8-3.9" />
|
||||
</svg>
|
||||
</button>
|
||||
</button>
|
||||
<div class="grid grid-cols-7 gap-1">
|
||||
<button
|
||||
v-for="cell in row.cells"
|
||||
:key="cell.key"
|
||||
class="group relative min-h-24 rounded-lg border p-1 text-left"
|
||||
:class="[
|
||||
cell.inMonth ? 'border-base-300 bg-base-100' : 'border-base-200 bg-base-200/40 text-base-content/40',
|
||||
selectedDateKey === cell.key ? 'border-primary bg-primary/5' : '',
|
||||
monthCellHasFocusedEvent(cell.events) ? 'border-success/60 bg-success/10' : '',
|
||||
]"
|
||||
:data-calendar-day-key="cell.key"
|
||||
@mouseenter="calendarHoveredDayKey = cell.key"
|
||||
@click="pickDate(cell.key)"
|
||||
>
|
||||
<p class="mb-1 text-xs font-semibold">{{ cell.day }}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="calendar-hover-jump"
|
||||
title="Expand to day"
|
||||
aria-label="Expand to day"
|
||||
@click.stop="openDayView(cell.key)"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" class="h-3.5 w-3.5 fill-none stroke-current" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M7 13L3 17M13 7l4-4M3 13V17h4M17 7V3h-4" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-for="event in monthCellEvents(cell.events)"
|
||||
:key="event.id"
|
||||
class="block w-full truncate rounded px-1 text-left text-[10px] text-base-content/70 transition hover:underline"
|
||||
:class="isReviewHighlightedEvent(event.id) ? 'bg-success/20 text-success-content ring-1 ring-success/40' : ''"
|
||||
@click.stop="openThreadFromCalendarItem(event)"
|
||||
>
|
||||
{{ formatTime(event.start) }} {{ event.title }}
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="calendarView === 'week'" class="calendar-week-scroll overflow-x-auto pb-1">
|
||||
<div class="calendar-week-grid">
|
||||
<article
|
||||
v-for="day in weekDays"
|
||||
:key="day.key"
|
||||
class="group relative flex min-h-[18rem] flex-col rounded-xl border border-base-300 bg-base-100 p-2.5"
|
||||
:class="selectedDateKey === day.key ? 'border-primary bg-primary/5' : ''"
|
||||
@click="pickDate(day.key)"
|
||||
>
|
||||
<div class="mb-2 flex items-start justify-between gap-2">
|
||||
<p class="text-sm font-semibold leading-tight">{{ day.label }} {{ day.day }}</p>
|
||||
<div v-else-if="calendarView === 'week'" class="calendar-week-scroll overflow-x-auto pb-1">
|
||||
<div class="calendar-week-grid">
|
||||
<article
|
||||
v-for="day in weekDays"
|
||||
:key="day.key"
|
||||
class="group relative flex min-h-[18rem] flex-col rounded-xl border border-base-300 bg-base-100 p-2.5 cursor-zoom-in"
|
||||
:class="selectedDateKey === day.key ? 'border-primary bg-primary/5' : ''"
|
||||
:data-calendar-day-key="day.key"
|
||||
@mouseenter="calendarHoveredDayKey = day.key"
|
||||
@click="pickDate(day.key)"
|
||||
>
|
||||
<div class="mb-2 flex items-start justify-between gap-2">
|
||||
<p class="text-sm font-semibold leading-tight">{{ day.label }} {{ day.day }}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="calendar-hover-jump calendar-hover-jump-week"
|
||||
title="Expand day line"
|
||||
aria-label="Expand day line"
|
||||
@click.stop="openDayView(day.key)"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" class="h-3.5 w-3.5 fill-none stroke-current" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M10 3v14M6 7l4-4 4 4M6 13l4 4 4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<button
|
||||
v-for="event in day.events"
|
||||
:key="event.id"
|
||||
class="block w-full rounded-md px-2 py-1.5 text-left text-xs"
|
||||
:class="isReviewHighlightedEvent(event.id) ? 'bg-success/20 ring-1 ring-success/45' : 'bg-base-200 hover:bg-base-300/80'"
|
||||
@click.stop="openThreadFromCalendarItem(event)"
|
||||
>
|
||||
{{ formatTime(event.start) }} - {{ event.title }} ({{ event.contact }})
|
||||
</button>
|
||||
<p v-if="day.events.length === 0" class="pt-1 text-xs text-base-content/50">No events</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="calendarView === 'day'" class="space-y-2">
|
||||
<button
|
||||
v-for="event in selectedDayEvents"
|
||||
:key="event.id"
|
||||
class="block w-full rounded-xl border border-base-300 p-3 text-left transition hover:bg-base-200/60"
|
||||
:class="isReviewHighlightedEvent(event.id) ? 'border-success/60 bg-success/10' : ''"
|
||||
@click="openThreadFromCalendarItem(event)"
|
||||
>
|
||||
<p class="font-medium">{{ event.title }}</p>
|
||||
<p class="text-xs text-base-content/60">{{ event.contact }}</p>
|
||||
<p class="text-xs text-base-content/60">{{ formatTime(event.start) }} - {{ formatTime(event.end) }}</p>
|
||||
<p class="mt-1 text-sm text-base-content/80">{{ event.note }}</p>
|
||||
</button>
|
||||
<p v-if="selectedDayEvents.length === 0" class="text-sm text-base-content/60">No events on this day.</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="calendarView === 'year'" class="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<button
|
||||
v-for="item in yearMonths"
|
||||
:key="`year-month-${item.monthIndex}`"
|
||||
class="group relative rounded-xl border border-base-300 p-3 text-left transition hover:border-primary/50 hover:bg-primary/5 cursor-zoom-in"
|
||||
:data-calendar-month-index="item.monthIndex"
|
||||
@mouseenter="calendarHoveredMonthIndex = item.monthIndex"
|
||||
@click="openYearMonth(item.monthIndex)"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="calendar-hover-jump calendar-hover-jump-week"
|
||||
title="Expand day line"
|
||||
aria-label="Expand day line"
|
||||
@click.stop="openDayView(day.key)"
|
||||
class="calendar-hover-jump calendar-hover-jump-month"
|
||||
title="Expand month diagonally"
|
||||
aria-label="Expand month diagonally"
|
||||
@click.stop="openYearMonth(item.monthIndex)"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" class="h-3.5 w-3.5 fill-none stroke-current" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M10 3v14M6 7l4-4 4 4M6 13l4 4 4-4" />
|
||||
<path d="M7 13L3 17M13 7l4-4M3 13V17h4M17 7V3h-4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<p class="font-medium">{{ item.label }}</p>
|
||||
<p class="text-xs text-base-content/60">{{ item.count }} events</p>
|
||||
<button
|
||||
v-for="event in day.events"
|
||||
:key="event.id"
|
||||
class="block w-full rounded-md px-2 py-1.5 text-left text-xs"
|
||||
:class="isReviewHighlightedEvent(event.id) ? 'bg-success/20 ring-1 ring-success/45' : 'bg-base-200 hover:bg-base-300/80'"
|
||||
@click.stop="openThreadFromCalendarItem(event)"
|
||||
v-if="item.first"
|
||||
class="mt-1 block w-full text-left text-xs text-base-content/70 hover:underline"
|
||||
@click.stop="openThreadFromCalendarItem(item.first)"
|
||||
>
|
||||
{{ formatTime(event.start) }} - {{ event.title }} ({{ event.contact }})
|
||||
{{ formatDay(item.first.start) }} · {{ item.first.title }}
|
||||
</button>
|
||||
<p v-if="day.events.length === 0" class="pt-1 text-xs text-base-content/50">No events</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="calendarView === 'day'" class="space-y-2">
|
||||
<button
|
||||
v-for="event in selectedDayEvents"
|
||||
:key="event.id"
|
||||
class="block w-full rounded-xl border border-base-300 p-3 text-left transition hover:bg-base-200/60"
|
||||
:class="isReviewHighlightedEvent(event.id) ? 'border-success/60 bg-success/10' : ''"
|
||||
@click="openThreadFromCalendarItem(event)"
|
||||
>
|
||||
<p class="font-medium">{{ event.title }}</p>
|
||||
<p class="text-xs text-base-content/60">{{ event.contact }}</p>
|
||||
<p class="text-xs text-base-content/60">{{ formatTime(event.start) }} - {{ formatTime(event.end) }}</p>
|
||||
<p class="mt-1 text-sm text-base-content/80">{{ event.note }}</p>
|
||||
</button>
|
||||
<p v-if="selectedDayEvents.length === 0" class="text-sm text-base-content/60">No events on this day.</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="calendarView === 'year'" class="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<button
|
||||
v-for="item in yearMonths"
|
||||
:key="`year-month-${item.monthIndex}`"
|
||||
class="group relative rounded-xl border border-base-300 p-3 text-left transition hover:border-primary/50 hover:bg-primary/5"
|
||||
@click="openYearMonth(item.monthIndex)"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="calendar-hover-jump calendar-hover-jump-month"
|
||||
title="Expand month diagonally"
|
||||
aria-label="Expand month diagonally"
|
||||
@click.stop="openYearMonth(item.monthIndex)"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" class="h-3.5 w-3.5 fill-none stroke-current" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M7 13L3 17M13 7l4-4M3 13V17h4M17 7V3h-4" />
|
||||
</svg>
|
||||
</button>
|
||||
<p class="font-medium">{{ item.label }}</p>
|
||||
<p class="text-xs text-base-content/60">{{ item.count }} events</p>
|
||||
<button
|
||||
v-if="item.first"
|
||||
class="mt-1 block w-full text-left text-xs text-base-content/70 hover:underline"
|
||||
@click.stop="openThreadFromCalendarItem(item.first)"
|
||||
>
|
||||
{{ formatDay(item.first.start) }} · {{ item.first.title }}
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<button
|
||||
v-for="event in sortedEvents"
|
||||
:key="event.id"
|
||||
class="block w-full rounded-xl border border-base-300 p-3 text-left transition hover:bg-base-200/60"
|
||||
:class="isReviewHighlightedEvent(event.id) ? 'border-success/60 bg-success/10' : ''"
|
||||
@click="openThreadFromCalendarItem(event)"
|
||||
>
|
||||
<p class="font-medium">{{ event.title }}</p>
|
||||
<p class="text-xs text-base-content/60">{{ event.contact }}</p>
|
||||
<p class="text-xs text-base-content/60">{{ formatDay(event.start) }} · {{ formatTime(event.start) }} - {{ formatTime(event.end) }}</p>
|
||||
<p class="mt-1 text-sm text-base-content/80">{{ event.note }}</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<button
|
||||
v-for="event in sortedEvents"
|
||||
:key="event.id"
|
||||
class="block w-full rounded-xl border border-base-300 p-3 text-left transition hover:bg-base-200/60"
|
||||
:class="isReviewHighlightedEvent(event.id) ? 'border-success/60 bg-success/10' : ''"
|
||||
@click="openThreadFromCalendarItem(event)"
|
||||
>
|
||||
<p class="font-medium">{{ event.title }}</p>
|
||||
<p class="text-xs text-base-content/60">{{ event.contact }}</p>
|
||||
<p class="text-xs text-base-content/60">{{ formatDay(event.start) }} · {{ formatTime(event.start) }} - {{ formatTime(event.end) }}</p>
|
||||
<p class="mt-1 text-sm text-base-content/80">{{ event.note }}</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -5394,21 +5379,14 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
|
||||
.calendar-content-scroll {
|
||||
height: 100%;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.calendar-canvas {
|
||||
.calendar-scene {
|
||||
min-height: 100%;
|
||||
min-width: 100%;
|
||||
transform-origin: 0 0;
|
||||
transition: transform 120ms ease-out;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.calendar-pan-ready {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.calendar-pan-active {
|
||||
cursor: grabbing;
|
||||
transform-origin: center center;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.calendar-week-grid {
|
||||
@@ -5514,9 +5492,43 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
transform: translate(0, -50%);
|
||||
}
|
||||
|
||||
.calendar-zoom-range {
|
||||
--range-shdw: color-mix(in oklab, var(--color-primary) 72%, white 8%);
|
||||
--range-bg: color-mix(in oklab, var(--color-base-300) 85%, white 10%);
|
||||
.calendar-zoom-in-enter-active,
|
||||
.calendar-zoom-in-leave-active,
|
||||
.calendar-zoom-out-enter-active,
|
||||
.calendar-zoom-out-leave-active,
|
||||
.calendar-zoom-side-enter-active,
|
||||
.calendar-zoom-side-leave-active {
|
||||
transition: transform 230ms cubic-bezier(0.2, 0.86, 0.2, 1), opacity 230ms ease;
|
||||
}
|
||||
|
||||
.calendar-zoom-in-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.92) translateY(8px);
|
||||
}
|
||||
|
||||
.calendar-zoom-in-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(1.07) translateY(-8px);
|
||||
}
|
||||
|
||||
.calendar-zoom-out-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(1.07) translateY(-8px);
|
||||
}
|
||||
|
||||
.calendar-zoom-out-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.92) translateY(8px);
|
||||
}
|
||||
|
||||
.calendar-zoom-side-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
.calendar-zoom-side-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
|
||||
Reference in New Issue
Block a user