calendar: replace fade transitions with zoom slider flow
This commit is contained in:
564
frontend/app.vue
564
frontend/app.vue
@@ -2072,6 +2072,7 @@ onBeforeUnmount(() => {
|
||||
clearInterval(lifecycleClock);
|
||||
lifecycleClock = null;
|
||||
}
|
||||
clearCalendarZoomOverlay();
|
||||
});
|
||||
|
||||
const calendarView = ref<CalendarView>("year");
|
||||
@@ -2115,32 +2116,154 @@ const calendarViewOptions: { value: CalendarView; label: string }[] = [
|
||||
{ value: "agenda", label: "Agenda" },
|
||||
];
|
||||
|
||||
type CalendarTransitionDirection = "in" | "out" | "side";
|
||||
type CalendarHierarchyView = "year" | "month" | "week" | "day";
|
||||
type CalendarRect = { left: number; top: number; width: number; height: number };
|
||||
|
||||
const calendarTransitionDirection = ref<CalendarTransitionDirection>("side");
|
||||
const calendarContentWrapRef = ref<HTMLElement | null>(null);
|
||||
const calendarHoveredMonthIndex = ref<number | null>(null);
|
||||
const calendarHoveredWeekStartKey = ref("");
|
||||
const calendarHoveredDayKey = ref("");
|
||||
const calendarZoomOverlay = ref<{ active: boolean } & CalendarRect>({
|
||||
active: false,
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
let calendarWheelLockUntil = 0;
|
||||
let calendarZoomOverlayTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const CALENDAR_ZOOM_DURATION_MS = 180;
|
||||
const calendarZoomStops: Array<{ view: CalendarHierarchyView; label: string }> = [
|
||||
{ view: "year", label: "Year" },
|
||||
{ view: "month", label: "Month" },
|
||||
{ view: "week", label: "Week" },
|
||||
{ view: "day", label: "Day" },
|
||||
];
|
||||
const calendarZoomOrder: CalendarHierarchyView[] = ["year", "month", "week", "day"];
|
||||
|
||||
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 normalizedCalendarView = computed<CalendarHierarchyView>(() =>
|
||||
calendarView.value === "agenda" ? "month" : calendarView.value,
|
||||
);
|
||||
const calendarZoomLevelIndex = computed(() =>
|
||||
Math.max(0, calendarZoomStops.findIndex((stop) => stop.view === normalizedCalendarView.value)),
|
||||
);
|
||||
const calendarZoomOverlayStyle = computed(() => ({
|
||||
left: `${calendarZoomOverlay.value.left}px`,
|
||||
top: `${calendarZoomOverlay.value.top}px`,
|
||||
width: `${calendarZoomOverlay.value.width}px`,
|
||||
height: `${calendarZoomOverlay.value.height}px`,
|
||||
}));
|
||||
|
||||
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 "Months";
|
||||
});
|
||||
function clearCalendarZoomOverlay() {
|
||||
if (calendarZoomOverlayTimer) {
|
||||
clearTimeout(calendarZoomOverlayTimer);
|
||||
calendarZoomOverlayTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function setCalendarTransition(direction: CalendarTransitionDirection) {
|
||||
calendarTransitionDirection.value = direction;
|
||||
function queryCalendarElement(selector: string) {
|
||||
return calendarContentWrapRef.value?.querySelector<HTMLElement>(selector) ?? null;
|
||||
}
|
||||
|
||||
function getCalendarViewportRect(): CalendarRect | null {
|
||||
const wrapRect = calendarContentWrapRef.value?.getBoundingClientRect();
|
||||
if (!wrapRect) return null;
|
||||
return {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: Math.max(24, wrapRect.width),
|
||||
height: Math.max(24, wrapRect.height),
|
||||
};
|
||||
}
|
||||
|
||||
function getElementRectInCalendar(element: HTMLElement | null): CalendarRect | null {
|
||||
if (!element) return null;
|
||||
const wrapRect = calendarContentWrapRef.value?.getBoundingClientRect();
|
||||
if (!wrapRect) return null;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const left = Math.max(0, rect.left - wrapRect.left);
|
||||
const top = Math.max(0, rect.top - wrapRect.top);
|
||||
const width = Math.max(24, Math.min(rect.width, wrapRect.width - left));
|
||||
const height = Math.max(24, Math.min(rect.height, wrapRect.height - top));
|
||||
return { left, top, width, height };
|
||||
}
|
||||
|
||||
function weekRowStartForDate(key: string) {
|
||||
const date = new Date(`${key}T00:00:00`);
|
||||
date.setDate(date.getDate() - date.getDay());
|
||||
return dayKey(date);
|
||||
}
|
||||
|
||||
function primeCalendarRect(rect: CalendarRect) {
|
||||
clearCalendarZoomOverlay();
|
||||
calendarZoomOverlay.value = {
|
||||
active: true,
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
};
|
||||
}
|
||||
|
||||
function morphCalendarRect(toRect: CalendarRect) {
|
||||
requestAnimationFrame(() => {
|
||||
calendarZoomOverlay.value = {
|
||||
active: true,
|
||||
left: toRect.left,
|
||||
top: toRect.top,
|
||||
width: toRect.width,
|
||||
height: toRect.height,
|
||||
};
|
||||
});
|
||||
calendarZoomOverlayTimer = setTimeout(() => {
|
||||
calendarZoomOverlay.value = {
|
||||
...calendarZoomOverlay.value,
|
||||
active: false,
|
||||
};
|
||||
}, CALENDAR_ZOOM_DURATION_MS + 40);
|
||||
}
|
||||
|
||||
function waitCalendarZoom() {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(() => resolve(), CALENDAR_ZOOM_DURATION_MS);
|
||||
});
|
||||
}
|
||||
|
||||
async function animateCalendarZoomIn(sourceElement: HTMLElement | null, apply: () => void) {
|
||||
const fromRect = getElementRectInCalendar(sourceElement);
|
||||
const viewportRect = getCalendarViewportRect();
|
||||
if (!fromRect || !viewportRect) {
|
||||
apply();
|
||||
return;
|
||||
}
|
||||
|
||||
primeCalendarRect(fromRect);
|
||||
apply();
|
||||
await nextTick();
|
||||
morphCalendarRect(viewportRect);
|
||||
await waitCalendarZoom();
|
||||
}
|
||||
|
||||
async function animateCalendarZoomOut(apply: () => void, resolveTarget: () => HTMLElement | null) {
|
||||
const viewportRect = getCalendarViewportRect();
|
||||
if (!viewportRect) {
|
||||
apply();
|
||||
return;
|
||||
}
|
||||
|
||||
primeCalendarRect(viewportRect);
|
||||
apply();
|
||||
await nextTick();
|
||||
const targetRect = getElementRectInCalendar(resolveTarget());
|
||||
if (!targetRect) {
|
||||
calendarZoomOverlay.value = {
|
||||
...calendarZoomOverlay.value,
|
||||
active: false,
|
||||
};
|
||||
return;
|
||||
}
|
||||
morphCalendarRect(targetRect);
|
||||
await waitCalendarZoom();
|
||||
}
|
||||
|
||||
function resolveMonthAnchor(event?: WheelEvent) {
|
||||
@@ -2171,41 +2294,78 @@ function resolveDayAnchor(event?: WheelEvent) {
|
||||
return selectedDateKey.value;
|
||||
}
|
||||
|
||||
function zoomInCalendar(event?: Event) {
|
||||
async function zoomInCalendar(event?: Event) {
|
||||
const wheelEvent = event instanceof WheelEvent ? event : undefined;
|
||||
if (calendarView.value === "year") {
|
||||
openYearMonth(resolveMonthAnchor(wheelEvent), "in");
|
||||
const monthIndex = resolveMonthAnchor(wheelEvent);
|
||||
await animateCalendarZoomIn(queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`), () => {
|
||||
openYearMonth(monthIndex);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (calendarView.value === "month" || calendarView.value === "agenda") {
|
||||
openWeekView(resolveWeekAnchor(wheelEvent), "in");
|
||||
const anchorDayKey = resolveWeekAnchor(wheelEvent);
|
||||
const rowStartKey = weekRowStartForDate(anchorDayKey);
|
||||
await animateCalendarZoomIn(
|
||||
queryCalendarElement(`[data-calendar-week-start-key="${rowStartKey}"]`) ??
|
||||
queryCalendarElement(`[data-calendar-day-key="${anchorDayKey}"]`),
|
||||
() => {
|
||||
openWeekView(anchorDayKey);
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (calendarView.value === "week") {
|
||||
openDayView(resolveDayAnchor(wheelEvent), "in");
|
||||
const dayAnchor = resolveDayAnchor(wheelEvent);
|
||||
await animateCalendarZoomIn(queryCalendarElement(`[data-calendar-day-key="${dayAnchor}"]`), () => {
|
||||
openDayView(dayAnchor);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function zoomOutCalendar() {
|
||||
async function zoomToMonth(monthIndex: number) {
|
||||
await animateCalendarZoomIn(queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`), () => {
|
||||
openYearMonth(monthIndex);
|
||||
});
|
||||
}
|
||||
|
||||
async function zoomOutCalendar() {
|
||||
focusedCalendarEventId.value = "";
|
||||
|
||||
if (calendarView.value === "day") {
|
||||
setCalendarTransition("out");
|
||||
calendarView.value = "week";
|
||||
const targetDayKey = selectedDateKey.value;
|
||||
await animateCalendarZoomOut(
|
||||
() => {
|
||||
calendarView.value = "week";
|
||||
},
|
||||
() => queryCalendarElement(`[data-calendar-day-key="${targetDayKey}"]`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (calendarView.value === "week") {
|
||||
setCalendarTransition("out");
|
||||
calendarView.value = "month";
|
||||
const targetRowKey = weekRowStartForDate(selectedDateKey.value);
|
||||
await animateCalendarZoomOut(
|
||||
() => {
|
||||
calendarView.value = "month";
|
||||
},
|
||||
() =>
|
||||
queryCalendarElement(`[data-calendar-week-start-key="${targetRowKey}"]`) ??
|
||||
queryCalendarElement(`[data-calendar-day-key="${selectedDateKey.value}"]`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (calendarView.value === "month" || calendarView.value === "agenda") {
|
||||
setCalendarTransition("out");
|
||||
calendarView.value = "year";
|
||||
const targetMonthIndex = calendarCursor.value.getMonth();
|
||||
await animateCalendarZoomOut(
|
||||
() => {
|
||||
calendarView.value = "year";
|
||||
},
|
||||
() => queryCalendarElement(`[data-calendar-month-index="${targetMonthIndex}"]`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2216,11 +2376,36 @@ function onCalendarHierarchyWheel(event: WheelEvent) {
|
||||
calendarWheelLockUntil = now + 240;
|
||||
|
||||
if (event.deltaY < 0) {
|
||||
zoomInCalendar(event);
|
||||
void zoomInCalendar(event);
|
||||
return;
|
||||
}
|
||||
|
||||
zoomOutCalendar();
|
||||
void zoomOutCalendar();
|
||||
}
|
||||
|
||||
async function setCalendarZoomLevel(targetView: CalendarHierarchyView) {
|
||||
let currentIndex = calendarZoomOrder.indexOf(normalizedCalendarView.value);
|
||||
const targetIndex = calendarZoomOrder.indexOf(targetView);
|
||||
if (currentIndex < 0 || targetIndex < 0 || currentIndex === targetIndex) return;
|
||||
|
||||
while (currentIndex !== targetIndex) {
|
||||
if (targetIndex > currentIndex) {
|
||||
await zoomInCalendar();
|
||||
} else {
|
||||
await zoomOutCalendar();
|
||||
}
|
||||
currentIndex = calendarZoomOrder.indexOf(normalizedCalendarView.value);
|
||||
}
|
||||
}
|
||||
|
||||
function onCalendarZoomSliderInput(event: Event) {
|
||||
const value = Number((event.target as HTMLInputElement | null)?.value ?? NaN);
|
||||
if (!Number.isFinite(value)) return;
|
||||
const sliderStep = Math.max(0, Math.min(3, Math.round(value)));
|
||||
const targetIndex = 3 - sliderStep;
|
||||
const targetView = calendarZoomOrder[targetIndex];
|
||||
if (!targetView) return;
|
||||
void setCalendarZoomLevel(targetView);
|
||||
}
|
||||
|
||||
const monthCells = computed(() => {
|
||||
@@ -2338,7 +2523,6 @@ 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);
|
||||
@@ -2366,7 +2550,6 @@ 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);
|
||||
@@ -2379,24 +2562,21 @@ function pickDate(key: string) {
|
||||
calendarCursor.value = new Date(d.getFullYear(), d.getMonth(), 1);
|
||||
}
|
||||
|
||||
function openDayView(key: string, direction: CalendarTransitionDirection = "in") {
|
||||
function openDayView(key: string) {
|
||||
pickDate(key);
|
||||
setCalendarTransition(direction);
|
||||
calendarView.value = "day";
|
||||
}
|
||||
|
||||
function openWeekView(key: string, direction: CalendarTransitionDirection = "in") {
|
||||
function openWeekView(key: string) {
|
||||
pickDate(key);
|
||||
setCalendarTransition(direction);
|
||||
calendarView.value = "week";
|
||||
}
|
||||
|
||||
function openYearMonth(monthIndex: number, direction: CalendarTransitionDirection = "in") {
|
||||
function openYearMonth(monthIndex: number) {
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -4072,7 +4252,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
v-if="contextPickerEnabled"
|
||||
class="context-scope-label"
|
||||
>{{ contextScopeLabel('calendar') }}</span>
|
||||
<div class="grid grid-cols-[auto_1fr_auto] items-center gap-2">
|
||||
<div class="grid grid-cols-[auto_1fr] items-center gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<button class="btn btn-xs" @click="setToday">Today</button>
|
||||
</div>
|
||||
@@ -4081,14 +4261,6 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
{{ calendarPeriodLabel }}
|
||||
</div>
|
||||
|
||||
<div class="justify-self-end flex items-center gap-2">
|
||||
<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>
|
||||
|
||||
<article
|
||||
@@ -4103,7 +4275,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
<p class="mt-1 text-xs text-base-content/80">{{ focusedCalendarEvent.note || "No note" }}</p>
|
||||
</article>
|
||||
|
||||
<div class="calendar-content-wrap min-h-0 flex-1">
|
||||
<div ref="calendarContentWrapRef" class="calendar-content-wrap min-h-0 flex-1">
|
||||
<button
|
||||
class="calendar-side-nav calendar-side-nav-left"
|
||||
type="button"
|
||||
@@ -4112,24 +4284,43 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
>
|
||||
<span>←</span>
|
||||
</button>
|
||||
<button
|
||||
class="calendar-side-nav calendar-side-nav-right"
|
||||
<button
|
||||
class="calendar-side-nav calendar-side-nav-right"
|
||||
type="button"
|
||||
title="Next period"
|
||||
@click="shiftCalendar(1)"
|
||||
>
|
||||
<span>→</span>
|
||||
</button>
|
||||
<div
|
||||
class="calendar-content-scroll min-h-0 h-full overflow-y-auto pr-1"
|
||||
@wheel.prevent="onCalendarHierarchyWheel"
|
||||
>
|
||||
<Transition :name="calendarTransitionName" mode="out-in">
|
||||
<div
|
||||
:key="calendarSceneKey"
|
||||
class="calendar-scene"
|
||||
@mouseleave="calendarHoveredMonthIndex = null; calendarHoveredWeekStartKey = ''; calendarHoveredDayKey = ''"
|
||||
>
|
||||
<span>→</span>
|
||||
</button>
|
||||
<div class="calendar-zoom-slider-shell" @click.stop>
|
||||
<input
|
||||
class="calendar-zoom-slider"
|
||||
type="range"
|
||||
min="0"
|
||||
max="3"
|
||||
step="1"
|
||||
:value="3 - calendarZoomLevelIndex"
|
||||
aria-label="Calendar zoom level"
|
||||
@input="onCalendarZoomSliderInput"
|
||||
>
|
||||
<div class="calendar-zoom-slider-labels" aria-hidden="true">
|
||||
<span
|
||||
v-for="item in calendarZoomStops"
|
||||
:key="`calendar-zoom-label-${item.view}`"
|
||||
:class="item.view === normalizedCalendarView ? 'calendar-zoom-slider-label-active' : ''"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="calendar-content-scroll min-h-0 h-full overflow-y-auto pr-1"
|
||||
@wheel.prevent="onCalendarHierarchyWheel"
|
||||
>
|
||||
<div
|
||||
:class="normalizedCalendarView === 'day' ? 'calendar-scene cursor-zoom-out' : 'calendar-scene cursor-zoom-in'"
|
||||
@mouseleave="calendarHoveredMonthIndex = null; calendarHoveredWeekStartKey = ''; calendarHoveredDayKey = ''"
|
||||
>
|
||||
<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>
|
||||
@@ -4149,17 +4340,6 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
: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)"
|
||||
>
|
||||
<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"
|
||||
@@ -4175,17 +4355,6 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
@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"
|
||||
@@ -4214,17 +4383,6 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
>
|
||||
<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
|
||||
@@ -4265,19 +4423,8 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
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)"
|
||||
@click="zoomToMonth(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
|
||||
@@ -4304,11 +4451,15 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
<p class="mt-1 text-sm text-base-content/80">{{ event.note }}</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="calendarZoomOverlay.active"
|
||||
class="calendar-zoom-overlay"
|
||||
:style="calendarZoomOverlayStyle"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="selectedTab === 'communications' && false" class="space-y-3">
|
||||
<div class="mb-1 flex justify-end">
|
||||
@@ -5374,7 +5525,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
.calendar-content-wrap {
|
||||
position: relative;
|
||||
padding-left: 40px;
|
||||
padding-right: 40px;
|
||||
padding-right: 128px;
|
||||
}
|
||||
|
||||
.calendar-content-scroll {
|
||||
@@ -5386,7 +5537,16 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
min-height: 100%;
|
||||
min-width: 100%;
|
||||
transform-origin: center center;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.calendar-scene.cursor-zoom-in,
|
||||
.calendar-scene.cursor-zoom-in * {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.calendar-scene.cursor-zoom-out,
|
||||
.calendar-scene.cursor-zoom-out * {
|
||||
cursor: zoom-out;
|
||||
}
|
||||
|
||||
.calendar-week-grid {
|
||||
@@ -5424,117 +5584,102 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
}
|
||||
|
||||
.calendar-side-nav-right {
|
||||
right: 4px;
|
||||
right: 56px;
|
||||
}
|
||||
|
||||
.calendar-hover-jump {
|
||||
.calendar-zoom-slider-shell {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
z-index: 3;
|
||||
display: inline-flex;
|
||||
top: 50%;
|
||||
right: 8px;
|
||||
z-index: 5;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 26px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid color-mix(in oklab, var(--color-primary) 35%, transparent);
|
||||
background: color-mix(in oklab, var(--color-base-100) 86%, transparent);
|
||||
color: color-mix(in oklab, var(--color-base-content) 78%, transparent);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease, transform 120ms ease, background-color 120ms ease;
|
||||
transform: translateY(-2px);
|
||||
gap: 8px;
|
||||
border: 1px solid color-mix(in oklab, var(--color-base-content) 16%, transparent);
|
||||
border-radius: 12px;
|
||||
background: color-mix(in oklab, var(--color-base-100) 90%, transparent);
|
||||
padding: 8px 7px;
|
||||
}
|
||||
|
||||
.calendar-zoom-slider {
|
||||
width: 124px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
transform: rotate(-90deg);
|
||||
transform-origin: center;
|
||||
accent-color: color-mix(in oklab, var(--color-primary) 82%, transparent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.group:hover > .calendar-hover-jump,
|
||||
.group:focus-within > .calendar-hover-jump {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0);
|
||||
.calendar-zoom-slider:focus-visible {
|
||||
outline: 2px solid color-mix(in oklab, var(--color-primary) 52%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.calendar-hover-jump:focus-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0);
|
||||
outline: 2px solid color-mix(in oklab, var(--color-primary) 58%, transparent);
|
||||
outline-offset: 1px;
|
||||
.calendar-zoom-slider::-webkit-slider-runnable-track {
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--color-base-content) 26%, transparent);
|
||||
}
|
||||
|
||||
.calendar-hover-jump:hover {
|
||||
background: color-mix(in oklab, var(--color-primary) 12%, var(--color-base-100));
|
||||
.calendar-zoom-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
margin-top: -5px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in oklab, var(--color-primary) 42%, transparent);
|
||||
background: color-mix(in oklab, var(--color-primary) 86%, var(--color-base-100));
|
||||
}
|
||||
|
||||
.calendar-hover-jump-week,
|
||||
.calendar-hover-jump-row {
|
||||
top: 8px;
|
||||
right: 10px;
|
||||
.calendar-zoom-slider::-moz-range-track {
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--color-base-content) 26%, transparent);
|
||||
}
|
||||
|
||||
.calendar-hover-jump-month {
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
.calendar-zoom-slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in oklab, var(--color-primary) 42%, transparent);
|
||||
background: color-mix(in oklab, var(--color-primary) 86%, var(--color-base-100));
|
||||
}
|
||||
|
||||
.calendar-hover-jump-row {
|
||||
top: 50%;
|
||||
right: -14px;
|
||||
transform: translate(6px, -50%);
|
||||
.calendar-zoom-slider-labels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
height: 124px;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
color: color-mix(in oklab, var(--color-base-content) 56%, transparent);
|
||||
}
|
||||
|
||||
.group:hover > .calendar-hover-jump-row,
|
||||
.group:focus-within > .calendar-hover-jump-row,
|
||||
.calendar-hover-jump-row:focus-visible {
|
||||
transform: translate(0, -50%);
|
||||
.calendar-zoom-slider-label-active {
|
||||
color: color-mix(in oklab, var(--color-primary) 88%, var(--color-base-content));
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.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);
|
||||
.calendar-zoom-overlay {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
border: 2px solid color-mix(in oklab, var(--color-primary) 60%, transparent);
|
||||
border-radius: 12px;
|
||||
background: color-mix(in oklab, var(--color-primary) 12%, transparent);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
left 180ms cubic-bezier(0.2, 0.85, 0.25, 1),
|
||||
top 180ms cubic-bezier(0.2, 0.85, 0.25, 1),
|
||||
width 180ms cubic-bezier(0.2, 0.85, 0.25, 1),
|
||||
height 180ms cubic-bezier(0.2, 0.85, 0.25, 1);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.calendar-content-wrap {
|
||||
padding-left: 32px;
|
||||
padding-right: 32px;
|
||||
padding-right: 104px;
|
||||
}
|
||||
|
||||
.calendar-week-grid {
|
||||
@@ -5546,6 +5691,25 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.calendar-side-nav-right {
|
||||
right: 44px;
|
||||
}
|
||||
|
||||
.calendar-zoom-slider-shell {
|
||||
right: 4px;
|
||||
padding: 7px 6px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.calendar-zoom-slider {
|
||||
width: 102px;
|
||||
}
|
||||
|
||||
.calendar-zoom-slider-labels {
|
||||
height: 102px;
|
||||
font-size: 9px;
|
||||
}
|
||||
}
|
||||
|
||||
.pilot-shell {
|
||||
|
||||
Reference in New Issue
Block a user