chore(repo): split frontend backend backend_worker into submodules
This commit is contained in:
@@ -1,19 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/vue3";
|
||||
import ContactCollaborativeEditor from "./ContactCollaborativeEditor.client.vue";
|
||||
|
||||
const meta: Meta<typeof ContactCollaborativeEditor> = {
|
||||
title: "Components/ContactCollaborativeEditor",
|
||||
component: ContactCollaborativeEditor,
|
||||
args: {
|
||||
modelValue: "<p>Client summary draft...</p>",
|
||||
room: "storybook-contact-editor-room",
|
||||
placeholder: "Type here...",
|
||||
plain: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof ContactCollaborativeEditor>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -1,238 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { EditorContent, useEditor } from "@tiptap/vue-3";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Collaboration from "@tiptap/extension-collaboration";
|
||||
import CollaborationCursor from "@tiptap/extension-collaboration-cursor";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import * as Y from "yjs";
|
||||
import { WebrtcProvider } from "y-webrtc";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
room: string;
|
||||
placeholder?: string;
|
||||
plain?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "update:modelValue", value: string): void;
|
||||
}>();
|
||||
|
||||
const ydoc = new Y.Doc();
|
||||
const provider = new WebrtcProvider(props.room, ydoc);
|
||||
const isBootstrapped = ref(false);
|
||||
const awarenessVersion = ref(0);
|
||||
|
||||
const userPalette = ["#2563eb", "#0ea5e9", "#14b8a6", "#16a34a", "#eab308", "#f97316", "#ef4444"];
|
||||
const currentUser = {
|
||||
name: `You ${Math.floor(Math.random() * 900 + 100)}`,
|
||||
color: userPalette[Math.floor(Math.random() * userPalette.length)],
|
||||
};
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function normalizeInitialContent(value: string) {
|
||||
const input = value.trim();
|
||||
if (!input) return "<p></p>";
|
||||
if (input.includes("<") && input.includes(">")) return value;
|
||||
|
||||
const blocks = value
|
||||
.replaceAll("\r\n", "\n")
|
||||
.split(/\n\n+/)
|
||||
.map((block) => `<p>${escapeHtml(block).replaceAll("\n", "<br />")}</p>`);
|
||||
|
||||
return blocks.join("");
|
||||
}
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
history: false,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: props.placeholder ?? "Type here...",
|
||||
includeChildren: true,
|
||||
}),
|
||||
Collaboration.configure({
|
||||
document: ydoc,
|
||||
field: "contact",
|
||||
}),
|
||||
CollaborationCursor.configure({
|
||||
provider,
|
||||
user: currentUser,
|
||||
}),
|
||||
],
|
||||
autofocus: true,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "contact-editor-content",
|
||||
spellcheck: "true",
|
||||
},
|
||||
},
|
||||
onCreate: ({ editor: instance }) => {
|
||||
if (instance.isEmpty) {
|
||||
instance.commands.setContent(normalizeInitialContent(props.modelValue), false);
|
||||
}
|
||||
isBootstrapped.value = true;
|
||||
},
|
||||
onUpdate: ({ editor: instance }) => {
|
||||
emit("update:modelValue", instance.getHTML());
|
||||
},
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(incoming) => {
|
||||
const instance = editor.value;
|
||||
if (!instance || !isBootstrapped.value) return;
|
||||
|
||||
const current = instance.getHTML();
|
||||
if (incoming === current || !incoming.trim()) return;
|
||||
|
||||
if (instance.isEmpty) {
|
||||
instance.commands.setContent(normalizeInitialContent(incoming), false);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const peerCount = computed(() => {
|
||||
awarenessVersion.value;
|
||||
const states = Array.from(provider.awareness.getStates().values());
|
||||
return states.length;
|
||||
});
|
||||
|
||||
const onAwarenessChange = () => {
|
||||
awarenessVersion.value += 1;
|
||||
};
|
||||
|
||||
provider.awareness.on("change", onAwarenessChange);
|
||||
|
||||
function runCommand(action: () => void) {
|
||||
const instance = editor.value;
|
||||
if (!instance) return;
|
||||
action();
|
||||
instance.commands.focus();
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
provider.awareness.off("change", onAwarenessChange);
|
||||
editor.value?.destroy();
|
||||
provider.destroy();
|
||||
ydoc.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="props.plain ? 'space-y-2' : 'space-y-3'">
|
||||
<div :class="props.plain ? 'flex flex-wrap items-center justify-between gap-2 bg-transparent p-0' : 'flex flex-wrap items-center justify-between gap-2 rounded-xl border border-base-300 bg-base-100 p-2'">
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<button
|
||||
class="btn btn-xs"
|
||||
:class="editor?.isActive('bold') ? 'btn-primary' : 'btn-ghost'"
|
||||
@click="runCommand(() => editor?.chain().focus().toggleBold().run())"
|
||||
>
|
||||
B
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-xs"
|
||||
:class="editor?.isActive('italic') ? 'btn-primary' : 'btn-ghost'"
|
||||
@click="runCommand(() => editor?.chain().focus().toggleItalic().run())"
|
||||
>
|
||||
I
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-xs"
|
||||
:class="editor?.isActive('bulletList') ? 'btn-primary' : 'btn-ghost'"
|
||||
@click="runCommand(() => editor?.chain().focus().toggleBulletList().run())"
|
||||
>
|
||||
List
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-xs"
|
||||
:class="editor?.isActive('heading', { level: 2 }) ? 'btn-primary' : 'btn-ghost'"
|
||||
@click="runCommand(() => editor?.chain().focus().toggleHeading({ level: 2 }).run())"
|
||||
>
|
||||
H2
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-xs"
|
||||
:class="editor?.isActive('blockquote') ? 'btn-primary' : 'btn-ghost'"
|
||||
@click="runCommand(() => editor?.chain().focus().toggleBlockquote().run())"
|
||||
>
|
||||
Quote
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="px-1 text-xs text-base-content/60">Live: {{ peerCount }}</p>
|
||||
</div>
|
||||
|
||||
<div :class="props.plain ? 'bg-transparent p-0' : 'rounded-xl border border-base-300 bg-base-100 p-2'">
|
||||
<EditorContent :editor="editor" class="contact-editor min-h-[420px]" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.contact-editor :deep(.ProseMirror) {
|
||||
min-height: 390px;
|
||||
padding: 0.75rem;
|
||||
outline: none;
|
||||
line-height: 1.65;
|
||||
color: rgba(17, 24, 39, 0.95);
|
||||
}
|
||||
|
||||
.contact-editor :deep(.ProseMirror p) {
|
||||
margin: 0.45rem 0;
|
||||
}
|
||||
|
||||
.contact-editor :deep(.ProseMirror h1),
|
||||
.contact-editor :deep(.ProseMirror h2),
|
||||
.contact-editor :deep(.ProseMirror h3) {
|
||||
margin: 0.75rem 0 0.45rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.contact-editor :deep(.ProseMirror ul),
|
||||
.contact-editor :deep(.ProseMirror ol) {
|
||||
margin: 0.45rem 0;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.contact-editor :deep(.ProseMirror blockquote) {
|
||||
margin: 0.6rem 0;
|
||||
border-left: 3px solid rgba(30, 107, 255, 0.5);
|
||||
padding-left: 0.75rem;
|
||||
color: rgba(55, 65, 81, 0.95);
|
||||
}
|
||||
|
||||
.contact-editor :deep(.ProseMirror .collaboration-cursor__caret) {
|
||||
margin-left: -1px;
|
||||
margin-right: -1px;
|
||||
border-left: 1px solid currentColor;
|
||||
border-right: 1px solid currentColor;
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.contact-editor :deep(.ProseMirror .collaboration-cursor__label) {
|
||||
position: absolute;
|
||||
top: -1.35em;
|
||||
left: -1px;
|
||||
border-radius: 4px;
|
||||
padding: 0.1rem 0.4rem;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +0,0 @@
|
||||
<template>
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<span class="loading loading-spinner loading-md text-base-content/70" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,47 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
phone: string;
|
||||
password: string;
|
||||
error: string | null;
|
||||
busy: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:phone", value: string): void;
|
||||
(e: "update:password", value: string): void;
|
||||
(e: "submit"): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full items-center justify-center px-3">
|
||||
<div class="card w-full max-w-sm border border-base-300 bg-base-100 shadow-sm">
|
||||
<div class="card-body p-5">
|
||||
<h1 class="text-lg font-semibold">Login</h1>
|
||||
<p class="mt-1 text-xs text-base-content/65">Sign in with phone and password.</p>
|
||||
<div class="mt-4 space-y-2">
|
||||
<input
|
||||
:value="props.phone"
|
||||
type="tel"
|
||||
class="input input-bordered w-full"
|
||||
placeholder="+1 555 000 0001"
|
||||
@input="emit('update:phone', ($event.target as HTMLInputElement).value)"
|
||||
@keyup.enter="emit('submit')"
|
||||
>
|
||||
<input
|
||||
:value="props.password"
|
||||
type="password"
|
||||
class="input input-bordered w-full"
|
||||
placeholder="Password"
|
||||
@input="emit('update:password', ($event.target as HTMLInputElement).value)"
|
||||
@keyup.enter="emit('submit')"
|
||||
>
|
||||
<p v-if="props.error" class="text-xs text-error">{{ props.error }}</p>
|
||||
<button class="btn w-full" :disabled="props.busy" @click="emit('submit')">
|
||||
{{ props.busy ? "Logging in..." : "Login" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,713 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { CalendarEvent } from "~~/app/composables/useCalendar";
|
||||
|
||||
type YearMonthItem = {
|
||||
monthIndex: number;
|
||||
label: string;
|
||||
count: number;
|
||||
first?: CalendarEvent;
|
||||
};
|
||||
|
||||
type MonthCell = {
|
||||
key: string;
|
||||
day: number;
|
||||
inMonth: boolean;
|
||||
events: CalendarEvent[];
|
||||
};
|
||||
|
||||
type MonthRow = {
|
||||
key: string;
|
||||
startKey: string;
|
||||
weekNumber: number;
|
||||
cells: MonthCell[];
|
||||
};
|
||||
|
||||
type WeekDay = {
|
||||
key: string;
|
||||
label: string;
|
||||
day: number;
|
||||
events: CalendarEvent[];
|
||||
};
|
||||
|
||||
defineProps<{
|
||||
contextPickerEnabled: boolean;
|
||||
hasContextScope: (scope: "calendar") => boolean;
|
||||
toggleContextScope: (scope: "calendar") => void;
|
||||
contextScopeLabel: (scope: "calendar") => string;
|
||||
setToday: () => void;
|
||||
calendarPeriodLabel: string;
|
||||
calendarZoomLevelIndex: number;
|
||||
onCalendarZoomSliderInput: (event: Event) => void;
|
||||
focusedCalendarEvent: CalendarEvent | null;
|
||||
formatDay: (iso: string) => string;
|
||||
formatTime: (iso: string) => string;
|
||||
avatarSrcForCalendarEvent: (event: CalendarEvent) => string;
|
||||
markCalendarAvatarBroken: (event: CalendarEvent) => void;
|
||||
contactInitials: (contactName: string) => string;
|
||||
setCalendarContentWrapRef: (element: HTMLDivElement | null) => void;
|
||||
shiftCalendar: (step: number) => void;
|
||||
setCalendarContentScrollRef: (element: HTMLDivElement | null) => void;
|
||||
onCalendarHierarchyWheel: (event: WheelEvent) => void;
|
||||
setCalendarSceneRef: (element: HTMLDivElement | null) => void;
|
||||
calendarViewportHeight: number;
|
||||
normalizedCalendarView: string;
|
||||
onCalendarSceneMouseLeave: () => void;
|
||||
calendarView: string;
|
||||
yearMonths: YearMonthItem[];
|
||||
calendarCursorMonth: number;
|
||||
calendarHoveredMonthIndex: number | null;
|
||||
setCalendarHoveredMonthIndex: (value: number | null) => void;
|
||||
calendarZoomPrimeToken: string;
|
||||
calendarPrimeMonthToken: (monthIndex: number) => string;
|
||||
calendarPrimeStyle: (token: string) => Record<string, string>;
|
||||
zoomToMonth: (monthIndex: number) => void;
|
||||
openThreadFromCalendarItem: (event: CalendarEvent) => void;
|
||||
monthRows: MonthRow[];
|
||||
calendarHoveredWeekStartKey: string;
|
||||
setCalendarHoveredWeekStartKey: (value: string) => void;
|
||||
calendarPrimeWeekToken: (startKey: string) => string;
|
||||
selectedDateKey: string;
|
||||
monthCellHasFocusedEvent: (events: CalendarEvent[]) => boolean;
|
||||
calendarHoveredDayKey: string;
|
||||
setCalendarHoveredDayKey: (value: string) => void;
|
||||
pickDate: (key: string) => void;
|
||||
monthCellEvents: (events: CalendarEvent[]) => CalendarEvent[];
|
||||
isReviewHighlightedEvent: (eventId: string) => boolean;
|
||||
weekDays: WeekDay[];
|
||||
calendarPrimeDayToken: (dayKey: string) => string;
|
||||
selectedDayEvents: CalendarEvent[];
|
||||
calendarFlyVisible: boolean;
|
||||
setCalendarFlyRectRef: (element: HTMLDivElement | null) => void;
|
||||
calendarFlyLabelVisible: boolean;
|
||||
setCalendarFlyLabelRef: (element: HTMLDivElement | null) => void;
|
||||
setCalendarToolbarLabelRef: (element: HTMLDivElement | null) => void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="relative flex h-full min-h-0 flex-col gap-3"
|
||||
:class="[
|
||||
contextPickerEnabled ? 'context-scope-block context-scope-block-active' : '',
|
||||
hasContextScope('calendar') ? 'context-scope-block-selected' : '',
|
||||
]"
|
||||
@click="toggleContextScope('calendar')"
|
||||
>
|
||||
<span
|
||||
v-if="contextPickerEnabled"
|
||||
class="context-scope-label"
|
||||
>{{ contextScopeLabel('calendar') }}</span>
|
||||
<div class="grid grid-cols-[auto_1fr_auto] items-center gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<button class="btn btn-xs" @click="setToday">Today</button>
|
||||
</div>
|
||||
|
||||
<div :ref="setCalendarToolbarLabelRef" class="text-center text-sm font-medium">
|
||||
{{ calendarPeriodLabel }}
|
||||
</div>
|
||||
|
||||
<div class="justify-self-end calendar-zoom-inline" @click.stop>
|
||||
<input
|
||||
class="calendar-zoom-slider"
|
||||
type="range"
|
||||
min="0"
|
||||
max="3"
|
||||
step="1"
|
||||
:value="calendarZoomLevelIndex"
|
||||
aria-label="Calendar zoom level"
|
||||
@input="onCalendarZoomSliderInput"
|
||||
>
|
||||
<div class="calendar-zoom-marks" aria-hidden="true">
|
||||
<span
|
||||
v-for="index in 4"
|
||||
:key="`calendar-zoom-mark-${index}`"
|
||||
class="calendar-zoom-mark"
|
||||
:class="calendarZoomLevelIndex === index - 1 ? 'calendar-zoom-mark-active' : ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article
|
||||
v-if="focusedCalendarEvent"
|
||||
class="rounded-xl border border-success/50 bg-success/10 px-3 py-2"
|
||||
>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-success/80">Review focus event</p>
|
||||
<p class="text-sm font-medium text-base-content">{{ focusedCalendarEvent.title }}</p>
|
||||
<div class="mt-1 flex items-center gap-1.5">
|
||||
<div class="avatar shrink-0">
|
||||
<div class="h-5 w-5 rounded-full ring-1 ring-base-300/70">
|
||||
<img
|
||||
v-if="avatarSrcForCalendarEvent(focusedCalendarEvent)"
|
||||
:src="avatarSrcForCalendarEvent(focusedCalendarEvent)"
|
||||
:alt="focusedCalendarEvent.contact"
|
||||
@error="markCalendarAvatarBroken(focusedCalendarEvent)"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="flex h-full w-full items-center justify-center text-[9px] font-semibold text-base-content/65"
|
||||
>{{ contactInitials(focusedCalendarEvent.contact) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="truncate text-xs text-base-content/70">{{ focusedCalendarEvent.contact || "Unknown contact" }}</p>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/70">
|
||||
{{ formatDay(focusedCalendarEvent.start) }} · {{ formatTime(focusedCalendarEvent.start) }} - {{ formatTime(focusedCalendarEvent.end) }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-base-content/80">{{ focusedCalendarEvent.note || "No note" }}</p>
|
||||
</article>
|
||||
|
||||
<!-- GSAP flying label (title transition overlay) -->
|
||||
<div
|
||||
v-show="calendarFlyLabelVisible"
|
||||
:ref="setCalendarFlyLabelRef"
|
||||
class="calendar-fly-label-el"
|
||||
/>
|
||||
|
||||
<div :ref="setCalendarContentWrapRef" class="calendar-content-wrap min-h-0 flex-1">
|
||||
<button
|
||||
class="calendar-side-nav calendar-side-nav-left"
|
||||
type="button"
|
||||
title="Previous period"
|
||||
@click="shiftCalendar(-1)"
|
||||
>
|
||||
<span>←</span>
|
||||
</button>
|
||||
<button
|
||||
class="calendar-side-nav calendar-side-nav-right"
|
||||
type="button"
|
||||
title="Next period"
|
||||
@click="shiftCalendar(1)"
|
||||
>
|
||||
<span>→</span>
|
||||
</button>
|
||||
|
||||
<!-- GSAP flying rect (zoom transition overlay) -->
|
||||
<div
|
||||
v-show="calendarFlyVisible"
|
||||
:ref="setCalendarFlyRectRef"
|
||||
class="calendar-fly-rect"
|
||||
/>
|
||||
|
||||
<div
|
||||
:ref="setCalendarContentScrollRef"
|
||||
class="calendar-content-scroll min-h-0 h-full overflow-y-auto pr-1"
|
||||
@wheel.prevent="onCalendarHierarchyWheel"
|
||||
>
|
||||
<div
|
||||
:ref="setCalendarSceneRef"
|
||||
:class="[
|
||||
'calendar-scene',
|
||||
normalizedCalendarView === 'day' ? 'cursor-zoom-out' : 'cursor-zoom-in',
|
||||
]"
|
||||
@mouseleave="onCalendarSceneMouseLeave"
|
||||
>
|
||||
<div
|
||||
class="grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3 auto-rows-fr"
|
||||
:style="calendarViewportHeight > 0 ? { minHeight: `${calendarView === 'year' ? Math.max(420, calendarViewportHeight) : calendarViewportHeight}px` } : undefined"
|
||||
>
|
||||
<div
|
||||
v-for="item in yearMonths"
|
||||
:key="`year-month-${item.monthIndex}`"
|
||||
v-show="calendarView === 'year' || item.monthIndex === calendarCursorMonth"
|
||||
:class="[
|
||||
calendarView === 'year' ? 'flex flex-col h-full' : 'sm:col-span-2 xl:col-span-3 flex flex-col',
|
||||
]"
|
||||
>
|
||||
<p
|
||||
v-if="calendarView === 'year'"
|
||||
class="calendar-card-title"
|
||||
>{{ item.label }}</p>
|
||||
|
||||
<article
|
||||
class="group relative rounded-xl border border-base-300 p-3 text-left transition calendar-hover-targetable flex-1"
|
||||
:class="[
|
||||
calendarView === 'year'
|
||||
? 'hover:border-primary/50 hover:bg-primary/5 cursor-zoom-in'
|
||||
: 'cursor-default bg-base-100 flex flex-col',
|
||||
calendarHoveredMonthIndex === item.monthIndex ? 'calendar-hover-target' : '',
|
||||
calendarZoomPrimeToken === calendarPrimeMonthToken(item.monthIndex) ? 'calendar-zoom-prime-active' : '',
|
||||
]"
|
||||
:style="{
|
||||
...calendarPrimeStyle(calendarPrimeMonthToken(item.monthIndex)),
|
||||
...(calendarView !== 'year' && item.monthIndex === calendarCursorMonth && calendarViewportHeight > 0
|
||||
? { minHeight: `${calendarViewportHeight}px` }
|
||||
: {}),
|
||||
}"
|
||||
:data-calendar-month-index="item.monthIndex"
|
||||
@mouseenter="setCalendarHoveredMonthIndex(item.monthIndex)"
|
||||
@click="calendarView === 'year' ? zoomToMonth(item.monthIndex) : undefined"
|
||||
>
|
||||
<p v-if="calendarView === 'year'" class="text-xs text-base-content/60">{{ item.count }} events</p>
|
||||
<button
|
||||
v-if="calendarView === 'year' && 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>
|
||||
|
||||
<div v-if="item.monthIndex === calendarCursorMonth" class="mt-3 calendar-depth-stack">
|
||||
<div
|
||||
class="space-y-1 calendar-depth-layer"
|
||||
data-calendar-layer="month"
|
||||
:class="calendarView === 'month' || calendarView === 'agenda' ? 'calendar-depth-layer-active' : 'calendar-depth-layer-hidden'"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="calendar-week-number" aria-hidden="true"></span>
|
||||
<div class="grid grid-cols-7 gap-1 text-center text-xs font-semibold text-base-content/60 flex-1">
|
||||
<span>Sun</span>
|
||||
<span>Mon</span>
|
||||
<span>Tue</span>
|
||||
<span>Wed</span>
|
||||
<span>Thu</span>
|
||||
<span>Fri</span>
|
||||
<span>Sat</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<div
|
||||
v-for="row in monthRows"
|
||||
:key="row.key"
|
||||
class="group relative flex-1 flex items-stretch gap-1 calendar-hover-targetable"
|
||||
:class="[
|
||||
calendarHoveredWeekStartKey === row.startKey ? 'calendar-hover-target' : '',
|
||||
calendarZoomPrimeToken === calendarPrimeWeekToken(row.startKey) ? 'calendar-zoom-prime-active' : '',
|
||||
]"
|
||||
:style="calendarPrimeStyle(calendarPrimeWeekToken(row.startKey))"
|
||||
:data-calendar-week-start-key="row.startKey"
|
||||
@mouseenter="setCalendarHoveredWeekStartKey(row.startKey)"
|
||||
>
|
||||
<span class="calendar-week-number">{{ row.weekNumber }}</span>
|
||||
<div class="grid grid-cols-7 gap-1 h-full flex-1">
|
||||
<button
|
||||
v-for="cell in row.cells"
|
||||
:key="cell.key"
|
||||
class="group relative 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="setCalendarHoveredDayKey(cell.key)"
|
||||
@click="pickDate(cell.key)"
|
||||
>
|
||||
<p class="mb-1 text-xs font-semibold">{{ cell.day }}</p>
|
||||
<button
|
||||
v-for="event in monthCellEvents(cell.events)"
|
||||
:key="event.id"
|
||||
class="block w-full 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)"
|
||||
>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="avatar shrink-0">
|
||||
<span class="inline-flex h-3.5 w-3.5 rounded-full ring-1 ring-base-300/70">
|
||||
<img
|
||||
v-if="avatarSrcForCalendarEvent(event)"
|
||||
:src="avatarSrcForCalendarEvent(event)"
|
||||
:alt="event.contact"
|
||||
@error="markCalendarAvatarBroken(event)"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="flex h-full w-full items-center justify-center text-[7px] font-semibold text-base-content/65"
|
||||
>{{ contactInitials(event.contact) }}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="truncate">{{ formatTime(event.start) }} {{ event.title }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="calendar-week-scroll h-full min-h-0 overflow-x-auto pb-1 calendar-depth-layer"
|
||||
data-calendar-layer="week"
|
||||
:class="calendarView === 'week' ? 'calendar-depth-layer-active' : 'calendar-depth-layer-hidden'"
|
||||
>
|
||||
<div class="calendar-week-grid">
|
||||
<div
|
||||
v-for="day in weekDays"
|
||||
:key="day.key"
|
||||
class="flex flex-col min-h-full"
|
||||
>
|
||||
<p class="calendar-card-title text-center">{{ day.label }} {{ day.day }}</p>
|
||||
<article
|
||||
class="group relative flex flex-1 flex-col rounded-xl border border-base-300 bg-base-100 p-2.5 cursor-zoom-in calendar-hover-targetable"
|
||||
:class="[
|
||||
selectedDateKey === day.key ? 'border-primary bg-primary/5' : '',
|
||||
calendarHoveredDayKey === day.key ? 'calendar-hover-target' : '',
|
||||
calendarZoomPrimeToken === calendarPrimeDayToken(day.key) ? 'calendar-zoom-prime-active' : '',
|
||||
]"
|
||||
:style="calendarPrimeStyle(calendarPrimeDayToken(day.key))"
|
||||
:data-calendar-day-key="day.key"
|
||||
@mouseenter="setCalendarHoveredDayKey(day.key)"
|
||||
@click="pickDate(day.key)"
|
||||
>
|
||||
<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)"
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="avatar shrink-0">
|
||||
<div class="h-5 w-5 rounded-full ring-1 ring-base-300/70">
|
||||
<img
|
||||
v-if="avatarSrcForCalendarEvent(event)"
|
||||
:src="avatarSrcForCalendarEvent(event)"
|
||||
:alt="event.contact"
|
||||
@error="markCalendarAvatarBroken(event)"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="flex h-full w-full items-center justify-center text-[8px] font-semibold text-base-content/65"
|
||||
>{{ contactInitials(event.contact) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="truncate">{{ formatTime(event.start) }} - {{ event.title }}</span>
|
||||
</div>
|
||||
<p class="ml-7 mt-0.5 truncate text-[11px] text-base-content/65">{{ event.contact }}</p>
|
||||
</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>
|
||||
|
||||
<div
|
||||
class="space-y-2 calendar-depth-layer"
|
||||
data-calendar-layer="day"
|
||||
:class="calendarView === 'day' ? 'calendar-depth-layer-active' : 'calendar-depth-layer-hidden'"
|
||||
>
|
||||
<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>
|
||||
<div class="mt-1 flex items-center gap-1.5">
|
||||
<div class="avatar shrink-0">
|
||||
<div class="h-6 w-6 rounded-full ring-1 ring-base-300/70">
|
||||
<img
|
||||
v-if="avatarSrcForCalendarEvent(event)"
|
||||
:src="avatarSrcForCalendarEvent(event)"
|
||||
:alt="event.contact"
|
||||
@error="markCalendarAvatarBroken(event)"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="flex h-full w-full items-center justify-center text-[9px] font-semibold text-base-content/65"
|
||||
>{{ contactInitials(event.contact) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="truncate text-xs text-base-content/60">{{ event.contact }}</p>
|
||||
</div>
|
||||
<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>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.calendar-content-wrap {
|
||||
position: relative;
|
||||
padding-left: 40px;
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
.calendar-content-scroll {
|
||||
height: 100%;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.calendar-scene {
|
||||
min-height: 100%;
|
||||
min-width: 100%;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(165px, 1fr));
|
||||
gap: 8px;
|
||||
min-width: 1180px;
|
||||
min-height: 100%;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.calendar-depth-stack {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.calendar-depth-layer {
|
||||
transition: opacity 260ms ease, transform 260ms ease;
|
||||
}
|
||||
|
||||
.calendar-depth-layer-active {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
pointer-events: auto;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.calendar-depth-layer-hidden {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.calendar-side-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 4;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid color-mix(in oklab, var(--color-base-content) 16%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--color-base-100) 88%, transparent);
|
||||
color: color-mix(in oklab, var(--color-base-content) 86%, transparent);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 120ms ease, border-color 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.calendar-side-nav:hover {
|
||||
border-color: color-mix(in oklab, var(--color-primary) 50%, transparent);
|
||||
background: color-mix(in oklab, var(--color-primary) 14%, var(--color-base-100));
|
||||
transform: translateY(-50%) scale(1.03);
|
||||
}
|
||||
|
||||
.calendar-side-nav-left {
|
||||
left: 4px;
|
||||
}
|
||||
|
||||
.calendar-side-nav-right {
|
||||
right: 4px;
|
||||
}
|
||||
|
||||
.calendar-card-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: color-mix(in oklab, var(--color-base-content) 55%, transparent);
|
||||
padding: 0 4px 2px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.calendar-week-number {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
min-width: 24px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: color-mix(in oklab, var(--color-base-content) 40%, transparent);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.calendar-hover-targetable {
|
||||
transform-origin: center center;
|
||||
transition: transform 320ms ease, box-shadow 320ms ease, outline-color 320ms ease;
|
||||
}
|
||||
|
||||
.calendar-hover-target {
|
||||
outline: 2px solid color-mix(in oklab, var(--color-primary) 66%, transparent);
|
||||
outline-offset: 1px;
|
||||
box-shadow: 0 0 0 1px color-mix(in oklab, var(--color-primary) 32%, transparent) inset;
|
||||
}
|
||||
|
||||
.calendar-zoom-prime-active {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.calendar-fly-rect {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
pointer-events: none;
|
||||
will-change: left, top, width, height;
|
||||
}
|
||||
|
||||
.calendar-fly-label-el {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
will-change: left, top, font-size;
|
||||
}
|
||||
|
||||
.calendar-zoom-inline {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 128px;
|
||||
height: 22px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.calendar-zoom-slider {
|
||||
width: 100%;
|
||||
height: 18px;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.calendar-zoom-slider:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.calendar-zoom-slider::-webkit-slider-runnable-track {
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--color-base-content) 22%, transparent);
|
||||
}
|
||||
|
||||
.calendar-zoom-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-top: -4px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--color-base-content) 88%, transparent);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.calendar-zoom-slider::-moz-range-track {
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--color-base-content) 22%, transparent);
|
||||
}
|
||||
|
||||
.calendar-zoom-slider::-moz-range-progress {
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.calendar-zoom-slider::-moz-range-thumb {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--color-base-content) 88%, transparent);
|
||||
}
|
||||
|
||||
.calendar-zoom-marks {
|
||||
position: absolute;
|
||||
inset: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.calendar-zoom-mark {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--color-base-content) 35%, transparent);
|
||||
}
|
||||
|
||||
.calendar-zoom-mark-active {
|
||||
background: color-mix(in oklab, var(--color-base-content) 85%, transparent);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.calendar-content-wrap {
|
||||
padding-left: 32px;
|
||||
padding-right: 32px;
|
||||
}
|
||||
|
||||
.calendar-week-grid {
|
||||
grid-template-columns: repeat(7, minmax(150px, 1fr));
|
||||
min-width: 1060px;
|
||||
}
|
||||
|
||||
.calendar-side-nav {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.calendar-zoom-inline {
|
||||
width: 108px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* Non-scoped: fly-rect inner content is injected via innerHTML */
|
||||
.calendar-fly-rect .calendar-fly-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px 16px;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.calendar-fly-rect .calendar-fly-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.calendar-fly-rect .calendar-fly-skeleton-line {
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in oklab, var(--color-base-content) 10%, transparent);
|
||||
animation: calendar-fly-skeleton-pulse 0.8s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes calendar-fly-skeleton-pulse {
|
||||
from { opacity: 0.3; }
|
||||
to { opacity: 0.7; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,43 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
isActive: boolean;
|
||||
isLoading: boolean;
|
||||
isLoaded: boolean;
|
||||
showContent: boolean;
|
||||
pulseScale: number;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="calendar-lab-rect calendar-lab-day"
|
||||
:class="isActive ? 'calendar-lab-rect-active' : ''"
|
||||
:style="{ transform: `scale(${pulseScale})` }"
|
||||
>
|
||||
<header class="calendar-lab-header">
|
||||
<p class="calendar-lab-title">Day</p>
|
||||
<p class="calendar-lab-subtitle">Timeline events</p>
|
||||
</header>
|
||||
|
||||
<template v-if="showContent">
|
||||
<p v-if="isLoading" class="calendar-lab-loading">Loading GraphQL day payload…</p>
|
||||
<p v-else-if="isLoaded" class="calendar-lab-meta">Data ready</p>
|
||||
|
||||
<div class="calendar-lab-timeline">
|
||||
<article class="calendar-lab-event">
|
||||
<span>09:30</span>
|
||||
<p>Call with client</p>
|
||||
</article>
|
||||
<article class="calendar-lab-event">
|
||||
<span>13:00</span>
|
||||
<p>Prepare follow-up summary</p>
|
||||
</article>
|
||||
<article class="calendar-lab-event">
|
||||
<span>16:45</span>
|
||||
<p>Send proposal update</p>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="calendar-lab-hint">Zoom stopped. Day content will render here.</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,41 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
isActive: boolean;
|
||||
isLoading: boolean;
|
||||
isLoaded: boolean;
|
||||
showContent: boolean;
|
||||
nextLabel?: string;
|
||||
pulseScale: number;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="calendar-lab-rect calendar-lab-month"
|
||||
:class="isActive ? 'calendar-lab-rect-active' : ''"
|
||||
:style="{ transform: `scale(${pulseScale})` }"
|
||||
>
|
||||
<header class="calendar-lab-header">
|
||||
<p class="calendar-lab-title">Month</p>
|
||||
<p class="calendar-lab-subtitle">Weeks inside one month</p>
|
||||
</header>
|
||||
|
||||
<template v-if="showContent">
|
||||
<p v-if="isLoading" class="calendar-lab-loading">Loading GraphQL month payload…</p>
|
||||
<p v-else-if="isLoaded" class="calendar-lab-meta">Data ready</p>
|
||||
|
||||
<div class="calendar-lab-grid-month">
|
||||
<div
|
||||
v-for="week in 4"
|
||||
:key="`lab-month-week-${week}`"
|
||||
class="calendar-lab-row"
|
||||
>
|
||||
Week {{ week }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="calendar-lab-hint">
|
||||
Zoom into {{ nextLabel ?? "Week" }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,41 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
isActive: boolean;
|
||||
isLoading: boolean;
|
||||
isLoaded: boolean;
|
||||
showContent: boolean;
|
||||
nextLabel?: string;
|
||||
pulseScale: number;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="calendar-lab-rect calendar-lab-week"
|
||||
:class="isActive ? 'calendar-lab-rect-active' : ''"
|
||||
:style="{ transform: `scale(${pulseScale})` }"
|
||||
>
|
||||
<header class="calendar-lab-header">
|
||||
<p class="calendar-lab-title">Week</p>
|
||||
<p class="calendar-lab-subtitle">7 day columns</p>
|
||||
</header>
|
||||
|
||||
<template v-if="showContent">
|
||||
<p v-if="isLoading" class="calendar-lab-loading">Loading GraphQL week payload…</p>
|
||||
<p v-else-if="isLoaded" class="calendar-lab-meta">Data ready</p>
|
||||
|
||||
<div class="calendar-lab-grid-week">
|
||||
<span
|
||||
v-for="day in 7"
|
||||
:key="`lab-week-day-${day}`"
|
||||
class="calendar-lab-day"
|
||||
>
|
||||
D{{ day }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="calendar-lab-hint">
|
||||
Zoom into {{ nextLabel ?? "Day" }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,41 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
isActive: boolean;
|
||||
isLoading: boolean;
|
||||
isLoaded: boolean;
|
||||
showContent: boolean;
|
||||
nextLabel?: string;
|
||||
pulseScale: number;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="calendar-lab-rect calendar-lab-year"
|
||||
:class="isActive ? 'calendar-lab-rect-active' : ''"
|
||||
:style="{ transform: `scale(${pulseScale})` }"
|
||||
>
|
||||
<header class="calendar-lab-header">
|
||||
<p class="calendar-lab-title">Year</p>
|
||||
<p class="calendar-lab-subtitle">12 months overview</p>
|
||||
</header>
|
||||
|
||||
<template v-if="showContent">
|
||||
<p v-if="isLoading" class="calendar-lab-loading">Loading GraphQL year payload…</p>
|
||||
<p v-else-if="isLoaded" class="calendar-lab-meta">Data ready</p>
|
||||
|
||||
<div class="calendar-lab-grid-year">
|
||||
<span
|
||||
v-for="month in 12"
|
||||
:key="`lab-year-month-${month}`"
|
||||
class="calendar-lab-chip"
|
||||
>
|
||||
{{ month }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="calendar-lab-hint">
|
||||
Zoom into {{ nextLabel ?? "Month" }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,825 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import gsap from "gsap";
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import CrmCalendarLabYearRect from "./CrmCalendarLabYearRect.vue";
|
||||
import CrmCalendarLabMonthRect from "./CrmCalendarLabMonthRect.vue";
|
||||
import CrmCalendarLabWeekRect from "./CrmCalendarLabWeekRect.vue";
|
||||
import CrmCalendarLabDayRect from "./CrmCalendarLabDayRect.vue";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types & constants */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type Level = "year" | "month" | "week" | "day";
|
||||
type Direction = "in" | "out";
|
||||
|
||||
const LEVELS: Level[] = ["year", "month", "week", "day"];
|
||||
const LEVEL_LABELS: Record<Level, string> = {
|
||||
year: "Year",
|
||||
month: "Month",
|
||||
week: "Week",
|
||||
day: "Day",
|
||||
};
|
||||
|
||||
const MONTH_LABELS = [
|
||||
"Jan", "Feb", "Mar", "Apr",
|
||||
"May", "Jun", "Jul", "Aug",
|
||||
"Sep", "Oct", "Nov", "Dec",
|
||||
];
|
||||
const DAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
|
||||
const ZOOM_PRIME_STEPS = 2;
|
||||
const PRIME_SCALE_MAX = 0.10;
|
||||
const PRIME_DECAY_MS = 400;
|
||||
const FLY_DURATION = 0.65;
|
||||
const FADE_DURATION = 0.18;
|
||||
const EASE = "power3.inOut";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Refs */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const viewportRef = ref<HTMLDivElement | null>(null);
|
||||
const flyRectRef = ref<HTMLDivElement | null>(null);
|
||||
const contentRef = ref<HTMLDivElement | null>(null);
|
||||
const gridLayerRef = ref<HTMLDivElement | null>(null);
|
||||
|
||||
const currentLevel = ref<Level>("year");
|
||||
const isAnimating = ref(false);
|
||||
const contentVisible = ref(true);
|
||||
const flyVisible = ref(false);
|
||||
const flyLabel = ref("");
|
||||
|
||||
const vpWidth = ref(0);
|
||||
const vpHeight = ref(0);
|
||||
|
||||
const selectedMonth = ref(0);
|
||||
const selectedWeek = ref(0);
|
||||
const selectedDay = ref(0);
|
||||
|
||||
const hoveredMonth = ref(0);
|
||||
const hoveredWeek = ref(0);
|
||||
const hoveredDay = ref(0);
|
||||
|
||||
const primeCellIndex = ref(-1);
|
||||
const primeProgress = ref(0);
|
||||
const wheelPrimeDirection = ref<"" | Direction>("");
|
||||
const wheelPrimeTicks = ref(0);
|
||||
|
||||
let primeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let activeTweens: gsap.core.Tween[] = [];
|
||||
let sliderTarget = -1;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Computed */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const currentLevelIndex = computed(() => LEVELS.indexOf(currentLevel.value));
|
||||
const canZoomIn = computed(() => currentLevelIndex.value < LEVELS.length - 1);
|
||||
|
||||
const hoveredCellIndex = computed(() => {
|
||||
switch (currentLevel.value) {
|
||||
case "year": return hoveredMonth.value;
|
||||
case "month": return hoveredWeek.value;
|
||||
case "week": return hoveredDay.value;
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Grid definitions */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function getChildCount(level: Level): number {
|
||||
switch (level) {
|
||||
case "year": return 12;
|
||||
case "month": return 6;
|
||||
case "week": return 7;
|
||||
case "day": return 12;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function getGridConfig(level: Level) {
|
||||
switch (level) {
|
||||
case "year": return { cols: 4, rows: 3, gap: 10 };
|
||||
case "month": return { cols: 1, rows: 6, gap: 8 };
|
||||
case "week": return { cols: 7, rows: 1, gap: 6 };
|
||||
case "day": return { cols: 1, rows: 12, gap: 5 };
|
||||
default: return { cols: 1, rows: 1, gap: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
function getChildLabel(level: Level, index: number): string {
|
||||
switch (level) {
|
||||
case "year": return MONTH_LABELS[index] ?? "";
|
||||
case "month": return `W${index + 1}`;
|
||||
case "week": return DAY_LABELS[index] ?? "";
|
||||
case "day": return `${8 + index}:00`;
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
function computeGridRects(level: Level, vw: number, vh: number) {
|
||||
const count = getChildCount(level);
|
||||
const { cols, rows, gap } = getGridConfig(level);
|
||||
const pad = 24;
|
||||
|
||||
const areaW = vw - pad * 2;
|
||||
const areaH = vh - pad * 2;
|
||||
const cellW = (areaW - gap * Math.max(0, cols - 1)) / cols;
|
||||
const cellH = (areaH - gap * Math.max(0, rows - 1)) / rows;
|
||||
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const col = i % cols;
|
||||
const row = Math.floor(i / cols);
|
||||
return {
|
||||
id: `cell-${level}-${i}`,
|
||||
x: pad + col * (cellW + gap),
|
||||
y: pad + row * (cellH + gap),
|
||||
w: cellW,
|
||||
h: cellH,
|
||||
label: getChildLabel(level, i),
|
||||
index: i,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const gridRects = computed(() => {
|
||||
if (vpWidth.value <= 0 || vpHeight.value <= 0) return [];
|
||||
return computeGridRects(currentLevel.value, vpWidth.value, vpHeight.value);
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* GSAP helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function tweenTo(target: gsap.TweenTarget, vars: gsap.TweenVars): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const t = gsap.to(target, {
|
||||
...vars,
|
||||
onComplete: () => {
|
||||
activeTweens = activeTweens.filter((tw) => tw !== t);
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
activeTweens.push(t);
|
||||
});
|
||||
}
|
||||
|
||||
function killAllTweens() {
|
||||
for (const t of activeTweens) t.kill();
|
||||
activeTweens = [];
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Prime (tension) helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function advancePrime(cellIndex: number) {
|
||||
if (primeTimer) {
|
||||
clearTimeout(primeTimer);
|
||||
primeTimer = null;
|
||||
}
|
||||
primeCellIndex.value = cellIndex;
|
||||
primeProgress.value = Math.min(primeProgress.value + 1, ZOOM_PRIME_STEPS);
|
||||
|
||||
primeTimer = setTimeout(() => {
|
||||
primeCellIndex.value = -1;
|
||||
primeProgress.value = 0;
|
||||
wheelPrimeDirection.value = "";
|
||||
wheelPrimeTicks.value = 0;
|
||||
}, PRIME_DECAY_MS);
|
||||
}
|
||||
|
||||
function resetPrime() {
|
||||
if (primeTimer) {
|
||||
clearTimeout(primeTimer);
|
||||
primeTimer = null;
|
||||
}
|
||||
primeCellIndex.value = -1;
|
||||
primeProgress.value = 0;
|
||||
}
|
||||
|
||||
function getCellPrimeScale(idx: number): number {
|
||||
if (primeCellIndex.value !== idx || primeProgress.value <= 0) return 1;
|
||||
return 1 + (primeProgress.value / ZOOM_PRIME_STEPS) * PRIME_SCALE_MAX;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Zoom In */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
async function zoomIn(overrideIndex?: number) {
|
||||
if (isAnimating.value) return;
|
||||
if (currentLevelIndex.value >= LEVELS.length - 1) return;
|
||||
|
||||
const hovIdx = overrideIndex ?? hoveredCellIndex.value;
|
||||
const rects = gridRects.value;
|
||||
const targetRect = rects[hovIdx];
|
||||
if (!targetRect) return;
|
||||
|
||||
const flyEl = flyRectRef.value;
|
||||
const contentEl = contentRef.value;
|
||||
const gridEl = gridLayerRef.value;
|
||||
if (!flyEl || !contentEl || !gridEl) return;
|
||||
|
||||
isAnimating.value = true;
|
||||
killAllTweens();
|
||||
resetPrime();
|
||||
|
||||
const vw = vpWidth.value;
|
||||
const vh = vpHeight.value;
|
||||
const pad = 8;
|
||||
|
||||
// 1. Fade out content + grid
|
||||
await Promise.all([
|
||||
tweenTo(contentEl, { opacity: 0, duration: FADE_DURATION, ease: "power2.in" }),
|
||||
tweenTo(gridEl, { opacity: 0, duration: FADE_DURATION, ease: "power2.in" }),
|
||||
]);
|
||||
|
||||
// 2. Position fly rect at source cell, show it
|
||||
flyLabel.value = targetRect.label;
|
||||
gsap.set(flyEl, {
|
||||
left: targetRect.x,
|
||||
top: targetRect.y,
|
||||
width: targetRect.w,
|
||||
height: targetRect.h,
|
||||
opacity: 1,
|
||||
borderRadius: 12,
|
||||
});
|
||||
flyVisible.value = true;
|
||||
|
||||
// 3. Animate fly rect → full viewport (morphing aspect ratio)
|
||||
await tweenTo(flyEl, {
|
||||
left: pad,
|
||||
top: pad,
|
||||
width: vw - pad * 2,
|
||||
height: vh - pad * 2,
|
||||
borderRadius: 14,
|
||||
duration: FLY_DURATION,
|
||||
ease: EASE,
|
||||
});
|
||||
|
||||
// 4. Update selection
|
||||
switch (currentLevel.value) {
|
||||
case "year":
|
||||
selectedMonth.value = hovIdx;
|
||||
selectedWeek.value = 0;
|
||||
selectedDay.value = 0;
|
||||
break;
|
||||
case "month":
|
||||
selectedWeek.value = hovIdx;
|
||||
selectedDay.value = 0;
|
||||
break;
|
||||
case "week":
|
||||
selectedDay.value = hovIdx;
|
||||
break;
|
||||
}
|
||||
|
||||
// 5. Switch level
|
||||
currentLevel.value = LEVELS[currentLevelIndex.value + 1]!;
|
||||
|
||||
// 6. Hide fly rect, prepare content
|
||||
flyVisible.value = false;
|
||||
contentVisible.value = true;
|
||||
gsap.set(contentEl, { opacity: 0 });
|
||||
gsap.set(gridEl, { opacity: 0 });
|
||||
await nextTick();
|
||||
|
||||
// 7. Fade in new content + grid
|
||||
await Promise.all([
|
||||
tweenTo(contentEl, { opacity: 1, duration: 0.25, ease: "power2.out" }),
|
||||
tweenTo(gridEl, { opacity: 1, duration: 0.25, ease: "power2.out" }),
|
||||
]);
|
||||
|
||||
isAnimating.value = false;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Zoom Out */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
async function zoomOut() {
|
||||
if (isAnimating.value) return;
|
||||
if (currentLevelIndex.value <= 0) return;
|
||||
|
||||
const flyEl = flyRectRef.value;
|
||||
const contentEl = contentRef.value;
|
||||
const gridEl = gridLayerRef.value;
|
||||
if (!flyEl || !contentEl || !gridEl) return;
|
||||
|
||||
isAnimating.value = true;
|
||||
killAllTweens();
|
||||
|
||||
const vw = vpWidth.value;
|
||||
const vh = vpHeight.value;
|
||||
const pad = 8;
|
||||
|
||||
const prevIdx = currentLevelIndex.value - 1;
|
||||
const parentLevel = LEVELS[prevIdx]!;
|
||||
let fromIdx = 0;
|
||||
switch (parentLevel) {
|
||||
case "year": fromIdx = selectedMonth.value; break;
|
||||
case "month": fromIdx = selectedWeek.value; break;
|
||||
case "week": fromIdx = selectedDay.value; break;
|
||||
}
|
||||
|
||||
// 1. Fade out current content + grid
|
||||
await Promise.all([
|
||||
tweenTo(contentEl, { opacity: 0, duration: FADE_DURATION, ease: "power2.in" }),
|
||||
tweenTo(gridEl, { opacity: 0, duration: FADE_DURATION, ease: "power2.in" }),
|
||||
]);
|
||||
|
||||
// 2. Position fly rect at full viewport, show it
|
||||
flyLabel.value = getChildLabel(parentLevel, fromIdx);
|
||||
gsap.set(flyEl, {
|
||||
left: pad,
|
||||
top: pad,
|
||||
width: vw - pad * 2,
|
||||
height: vh - pad * 2,
|
||||
opacity: 1,
|
||||
borderRadius: 14,
|
||||
});
|
||||
flyVisible.value = true;
|
||||
|
||||
// 3. Switch to parent level so gridRects recomputes
|
||||
contentVisible.value = false;
|
||||
currentLevel.value = parentLevel;
|
||||
await nextTick();
|
||||
|
||||
// 4. Get child rect position in the new grid
|
||||
const rects = gridRects.value;
|
||||
const childRect = rects[fromIdx];
|
||||
|
||||
if (childRect) {
|
||||
// 5. Animate fly rect → child cell position (shrink + morph)
|
||||
await tweenTo(flyEl, {
|
||||
left: childRect.x,
|
||||
top: childRect.y,
|
||||
width: childRect.w,
|
||||
height: childRect.h,
|
||||
borderRadius: 12,
|
||||
duration: FLY_DURATION,
|
||||
ease: EASE,
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Hide fly rect, show parent content
|
||||
flyVisible.value = false;
|
||||
contentVisible.value = true;
|
||||
gsap.set(contentEl, { opacity: 0 });
|
||||
gsap.set(gridEl, { opacity: 0 });
|
||||
await nextTick();
|
||||
|
||||
// 7. Fade in parent content + grid
|
||||
await Promise.all([
|
||||
tweenTo(contentEl, { opacity: 1, duration: 0.25, ease: "power2.out" }),
|
||||
tweenTo(gridEl, { opacity: 1, duration: 0.25, ease: "power2.out" }),
|
||||
]);
|
||||
|
||||
isAnimating.value = false;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Reset to year */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
async function resetToYear() {
|
||||
if (isAnimating.value) return;
|
||||
if (currentLevel.value === "year") return;
|
||||
|
||||
const contentEl = contentRef.value;
|
||||
const gridEl = gridLayerRef.value;
|
||||
if (!contentEl || !gridEl) return;
|
||||
|
||||
isAnimating.value = true;
|
||||
killAllTweens();
|
||||
|
||||
await Promise.all([
|
||||
tweenTo(contentEl, { opacity: 0, duration: 0.2, ease: "power2.in" }),
|
||||
tweenTo(gridEl, { opacity: 0, duration: 0.2, ease: "power2.in" }),
|
||||
]);
|
||||
|
||||
currentLevel.value = "year";
|
||||
contentVisible.value = true;
|
||||
gsap.set(contentEl, { opacity: 0 });
|
||||
gsap.set(gridEl, { opacity: 0 });
|
||||
await nextTick();
|
||||
|
||||
await Promise.all([
|
||||
tweenTo(contentEl, { opacity: 1, duration: 0.3, ease: "power2.out" }),
|
||||
tweenTo(gridEl, { opacity: 1, duration: 0.3, ease: "power2.out" }),
|
||||
]);
|
||||
|
||||
isAnimating.value = false;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Wheel / interaction */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function resetWheelPrime() {
|
||||
wheelPrimeDirection.value = "";
|
||||
wheelPrimeTicks.value = 0;
|
||||
resetPrime();
|
||||
}
|
||||
|
||||
function onWheel(event: WheelEvent) {
|
||||
event.preventDefault();
|
||||
if (isAnimating.value) return;
|
||||
|
||||
const direction: Direction = event.deltaY < 0 ? "in" : "out";
|
||||
|
||||
if (direction === "in" && !canZoomIn.value) return;
|
||||
if (direction === "out" && currentLevelIndex.value <= 0) return;
|
||||
|
||||
if (wheelPrimeDirection.value !== direction) {
|
||||
wheelPrimeDirection.value = direction;
|
||||
wheelPrimeTicks.value = 0;
|
||||
resetPrime();
|
||||
}
|
||||
|
||||
if (wheelPrimeTicks.value < ZOOM_PRIME_STEPS) {
|
||||
wheelPrimeTicks.value += 1;
|
||||
|
||||
if (direction === "in") {
|
||||
advancePrime(hoveredCellIndex.value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
resetWheelPrime();
|
||||
|
||||
if (direction === "in") {
|
||||
void zoomIn();
|
||||
} else {
|
||||
void zoomOut();
|
||||
}
|
||||
}
|
||||
|
||||
function onDoubleClick() {
|
||||
resetWheelPrime();
|
||||
void resetToYear();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Zoom slider */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
async function onSliderInput(event: Event) {
|
||||
const value = Number((event.target as HTMLInputElement)?.value ?? NaN);
|
||||
if (!Number.isFinite(value)) return;
|
||||
|
||||
const targetIndex = Math.max(0, Math.min(3, Math.round(value)));
|
||||
sliderTarget = targetIndex;
|
||||
|
||||
if (isAnimating.value) return;
|
||||
if (targetIndex === currentLevelIndex.value) return;
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (currentLevelIndex.value === sliderTarget) break;
|
||||
if (sliderTarget > currentLevelIndex.value) {
|
||||
await zoomIn(0);
|
||||
} else {
|
||||
await zoomOut();
|
||||
}
|
||||
}
|
||||
|
||||
sliderTarget = -1;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Lifecycle */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
if (viewportRef.value) {
|
||||
vpWidth.value = viewportRef.value.clientWidth;
|
||||
vpHeight.value = viewportRef.value.clientHeight;
|
||||
}
|
||||
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
vpWidth.value = entry.contentRect.width;
|
||||
vpHeight.value = entry.contentRect.height;
|
||||
}
|
||||
});
|
||||
if (viewportRef.value) {
|
||||
resizeObserver.observe(viewportRef.value);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (primeTimer) clearTimeout(primeTimer);
|
||||
killAllTweens();
|
||||
resizeObserver?.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="canvas-lab-root">
|
||||
<header class="canvas-lab-toolbar">
|
||||
<p class="canvas-lab-level-text">
|
||||
{{ LEVEL_LABELS[currentLevel] }}
|
||||
</p>
|
||||
|
||||
<div class="canvas-lab-zoom-control" @click.stop>
|
||||
<input
|
||||
class="canvas-lab-zoom-slider"
|
||||
type="range"
|
||||
min="0"
|
||||
max="3"
|
||||
step="1"
|
||||
:value="currentLevelIndex"
|
||||
aria-label="Zoom level"
|
||||
@input="onSliderInput"
|
||||
>
|
||||
<div class="canvas-lab-zoom-marks" aria-hidden="true">
|
||||
<span
|
||||
v-for="index in 4"
|
||||
:key="`zoom-mark-${index}`"
|
||||
class="canvas-lab-zoom-mark"
|
||||
:class="currentLevelIndex === index - 1 ? 'canvas-lab-zoom-mark-active' : ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
ref="viewportRef"
|
||||
class="canvas-lab-viewport"
|
||||
:class="canZoomIn ? 'cursor-zoom-in' : 'cursor-zoom-out'"
|
||||
@wheel.prevent="onWheel"
|
||||
@dblclick="onDoubleClick"
|
||||
>
|
||||
<!-- Grid cells (outline rects for current level) -->
|
||||
<div ref="gridLayerRef" class="canvas-grid-layer">
|
||||
<div
|
||||
v-for="(rect, idx) in gridRects"
|
||||
:key="rect.id"
|
||||
class="canvas-cell"
|
||||
:class="[primeCellIndex === idx ? 'canvas-cell-priming' : '']"
|
||||
:style="{
|
||||
left: `${rect.x}px`,
|
||||
top: `${rect.y}px`,
|
||||
width: `${rect.w}px`,
|
||||
height: `${rect.h}px`,
|
||||
transform: primeCellIndex === idx && primeProgress > 0
|
||||
? `scale(${getCellPrimeScale(idx)})`
|
||||
: undefined,
|
||||
}"
|
||||
@mouseenter="
|
||||
currentLevel === 'year' ? (hoveredMonth = idx) :
|
||||
currentLevel === 'month' ? (hoveredWeek = idx) :
|
||||
currentLevel === 'week' ? (hoveredDay = idx) :
|
||||
undefined
|
||||
"
|
||||
>
|
||||
<span class="canvas-cell-label">{{ rect.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flying rect (GSAP-animated during transitions) -->
|
||||
<div
|
||||
v-show="flyVisible"
|
||||
ref="flyRectRef"
|
||||
class="canvas-fly-rect"
|
||||
>
|
||||
<span class="canvas-fly-label">{{ flyLabel }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Content overlay (always 1:1 scale, opacity managed by GSAP) -->
|
||||
<div
|
||||
ref="contentRef"
|
||||
class="canvas-content-layer"
|
||||
:class="contentVisible ? 'canvas-content-visible' : ''"
|
||||
>
|
||||
<CrmCalendarLabYearRect
|
||||
v-if="currentLevel === 'year'"
|
||||
:is-active="true"
|
||||
:is-loading="false"
|
||||
:is-loaded="true"
|
||||
:show-content="true"
|
||||
:pulse-scale="1"
|
||||
/>
|
||||
<CrmCalendarLabMonthRect
|
||||
v-if="currentLevel === 'month'"
|
||||
:is-active="true"
|
||||
:is-loading="false"
|
||||
:is-loaded="true"
|
||||
:show-content="true"
|
||||
:pulse-scale="1"
|
||||
/>
|
||||
<CrmCalendarLabWeekRect
|
||||
v-if="currentLevel === 'week'"
|
||||
:is-active="true"
|
||||
:is-loading="false"
|
||||
:is-loaded="true"
|
||||
:show-content="true"
|
||||
:pulse-scale="1"
|
||||
/>
|
||||
<CrmCalendarLabDayRect
|
||||
v-if="currentLevel === 'day'"
|
||||
:is-active="true"
|
||||
:is-loading="false"
|
||||
:is-loaded="true"
|
||||
:show-content="true"
|
||||
:pulse-scale="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.canvas-lab-root {
|
||||
height: calc(100dvh - 2.5rem);
|
||||
min-height: 620px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.canvas-lab-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.canvas-lab-level-text {
|
||||
font-size: 0.78rem;
|
||||
color: color-mix(in oklab, var(--color-base-content) 72%, transparent);
|
||||
}
|
||||
|
||||
/* ---- Zoom slider ---- */
|
||||
|
||||
.canvas-lab-zoom-control {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 128px;
|
||||
height: 22px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.canvas-lab-zoom-slider {
|
||||
width: 100%;
|
||||
height: 18px;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.canvas-lab-zoom-slider:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.canvas-lab-zoom-slider::-webkit-slider-runnable-track {
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--color-base-content) 22%, transparent);
|
||||
}
|
||||
|
||||
.canvas-lab-zoom-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-top: -4px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--color-base-content) 88%, transparent);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.canvas-lab-zoom-slider::-moz-range-track {
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--color-base-content) 22%, transparent);
|
||||
}
|
||||
|
||||
.canvas-lab-zoom-slider::-moz-range-progress {
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.canvas-lab-zoom-slider::-moz-range-thumb {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--color-base-content) 88%, transparent);
|
||||
}
|
||||
|
||||
.canvas-lab-zoom-marks {
|
||||
position: absolute;
|
||||
inset: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.canvas-lab-zoom-mark {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--color-base-content) 35%, transparent);
|
||||
}
|
||||
|
||||
.canvas-lab-zoom-mark-active {
|
||||
background: color-mix(in oklab, var(--color-base-content) 85%, transparent);
|
||||
}
|
||||
|
||||
/* ---- Viewport ---- */
|
||||
|
||||
.canvas-lab-viewport {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border-radius: 14px;
|
||||
border: 1px solid color-mix(in oklab, var(--color-base-content) 18%, transparent);
|
||||
background:
|
||||
radial-gradient(circle at 15% 15%, color-mix(in oklab, var(--color-base-content) 6%, transparent), transparent 40%),
|
||||
color-mix(in oklab, var(--color-base-100) 94%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.canvas-grid-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.canvas-cell {
|
||||
position: absolute;
|
||||
border-radius: 12px;
|
||||
border: 1px solid color-mix(in oklab, var(--color-base-content) 20%, transparent);
|
||||
background: color-mix(in oklab, var(--color-base-200) 50%, transparent);
|
||||
transition: border-color 140ms ease, box-shadow 140ms ease, transform 180ms ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.canvas-cell:hover {
|
||||
border-color: color-mix(in oklab, var(--color-primary) 55%, transparent);
|
||||
box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-primary) 20%, transparent) inset;
|
||||
}
|
||||
|
||||
.canvas-cell-priming {
|
||||
z-index: 2;
|
||||
border-color: color-mix(in oklab, var(--color-primary) 80%, transparent);
|
||||
box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-primary) 36%, transparent) inset;
|
||||
}
|
||||
|
||||
.canvas-cell-label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: color-mix(in oklab, var(--color-base-content) 60%, transparent);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.canvas-fly-rect {
|
||||
position: absolute;
|
||||
border-radius: 12px;
|
||||
border: 2px solid color-mix(in oklab, var(--color-primary) 70%, transparent);
|
||||
background: color-mix(in oklab, var(--color-base-200) 60%, transparent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
will-change: left, top, width, height;
|
||||
}
|
||||
|
||||
.canvas-fly-label {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: color-mix(in oklab, var(--color-base-content) 70%, transparent);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.canvas-content-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.canvas-content-visible {
|
||||
/* pointer-events stay none — grid cells underneath must receive hover */
|
||||
}
|
||||
</style>
|
||||
@@ -1,712 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import Panzoom from "@panzoom/panzoom";
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
|
||||
type Level = "year" | "month" | "week" | "day";
|
||||
type Direction = "in" | "out";
|
||||
|
||||
const LEVELS: Level[] = ["year", "month", "week", "day"];
|
||||
const LEVEL_LABELS: Record<Level, string> = {
|
||||
year: "Year",
|
||||
month: "Month",
|
||||
week: "Week",
|
||||
day: "Day",
|
||||
};
|
||||
|
||||
const MONTH_LABELS = [
|
||||
"Jan", "Feb", "Mar", "Apr",
|
||||
"May", "Jun", "Jul", "Aug",
|
||||
"Sep", "Oct", "Nov", "Dec",
|
||||
];
|
||||
|
||||
const ZOOM_PRIME_STEPS = 2;
|
||||
const ZOOM_ANIMATION_MS = 2000;
|
||||
const VIEWPORT_PADDING = 20;
|
||||
|
||||
const viewportRef = ref<HTMLDivElement | null>(null);
|
||||
const sceneRef = ref<HTMLDivElement | null>(null);
|
||||
const panzoomRef = ref<ReturnType<typeof Panzoom> | null>(null);
|
||||
const resizeObserver = ref<ResizeObserver | null>(null);
|
||||
|
||||
const currentLevel = ref<Level>("year");
|
||||
const transitionTarget = ref<Level | null>(null);
|
||||
const isAnimating = ref(false);
|
||||
|
||||
const selectedMonth = ref(0);
|
||||
const selectedWeek = ref(0);
|
||||
const selectedDay = ref(0);
|
||||
|
||||
const hoveredMonth = ref(0);
|
||||
const hoveredWeek = ref(0);
|
||||
const hoveredDay = ref(0);
|
||||
|
||||
const primeFocusId = ref("");
|
||||
|
||||
const wheelPrimeDirection = ref<"" | Direction>("");
|
||||
const wheelPrimeTicks = ref(0);
|
||||
|
||||
let primeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let animationFrameId: number | null = null;
|
||||
let animationToken = 0;
|
||||
|
||||
const currentLevelIndex = computed(() => LEVELS.indexOf(currentLevel.value));
|
||||
const displayLevel = computed(() => transitionTarget.value ?? currentLevel.value);
|
||||
const displayLevelIndex = computed(() => LEVELS.indexOf(displayLevel.value));
|
||||
|
||||
const canZoomIn = computed(() => currentLevelIndex.value < LEVELS.length - 1);
|
||||
const canZoomOut = computed(() => currentLevelIndex.value > 0);
|
||||
|
||||
function getFocusId(level: Level) {
|
||||
if (level === "year") return "focus-year";
|
||||
if (level === "month") return `focus-month-${selectedMonth.value}`;
|
||||
if (level === "week") return `focus-week-${selectedWeek.value}`;
|
||||
return `focus-day-${selectedDay.value}`;
|
||||
}
|
||||
|
||||
function findFocusElement(level: Level) {
|
||||
const scene = sceneRef.value;
|
||||
if (!scene) return null;
|
||||
const id = getFocusId(level);
|
||||
return scene.querySelector<HTMLElement>(`[data-focus-id="${id}"]`);
|
||||
}
|
||||
|
||||
function getRectInScene(element: HTMLElement, scene: HTMLElement) {
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let node: HTMLElement | null = element;
|
||||
let reachedScene = false;
|
||||
|
||||
while (node && node !== scene) {
|
||||
x += node.offsetLeft;
|
||||
y += node.offsetTop;
|
||||
node = node.offsetParent as HTMLElement | null;
|
||||
}
|
||||
|
||||
if (node === scene) {
|
||||
reachedScene = true;
|
||||
}
|
||||
|
||||
if (!reachedScene) {
|
||||
const sceneRect = scene.getBoundingClientRect();
|
||||
const elementRect = element.getBoundingClientRect();
|
||||
const scale = panzoomRef.value?.getScale() ?? 1;
|
||||
return {
|
||||
x: (elementRect.left - sceneRect.left) / Math.max(0.0001, scale),
|
||||
y: (elementRect.top - sceneRect.top) / Math.max(0.0001, scale),
|
||||
width: Math.max(1, elementRect.width / Math.max(0.0001, scale)),
|
||||
height: Math.max(1, elementRect.height / Math.max(0.0001, scale)),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
width: Math.max(1, element.offsetWidth),
|
||||
height: Math.max(1, element.offsetHeight),
|
||||
};
|
||||
}
|
||||
|
||||
function easing(t: number) {
|
||||
return 1 - (1 - t) ** 3;
|
||||
}
|
||||
|
||||
function stopCameraAnimation() {
|
||||
animationToken += 1;
|
||||
if (animationFrameId !== null) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function computeTransformForElement(element: HTMLElement) {
|
||||
const viewport = viewportRef.value;
|
||||
const scene = sceneRef.value;
|
||||
if (!viewport || !scene) return null;
|
||||
|
||||
const targetRect = getRectInScene(element, scene);
|
||||
|
||||
const viewportWidth = Math.max(1, viewport.clientWidth);
|
||||
const viewportHeight = Math.max(1, viewport.clientHeight);
|
||||
const safeWidth = Math.max(1, viewportWidth - VIEWPORT_PADDING * 2);
|
||||
const safeHeight = Math.max(1, viewportHeight - VIEWPORT_PADDING * 2);
|
||||
const scale = Math.min(safeWidth / targetRect.width, safeHeight / targetRect.height);
|
||||
|
||||
const x = VIEWPORT_PADDING + (safeWidth - targetRect.width * scale) / 2 - targetRect.x * scale;
|
||||
const y = VIEWPORT_PADDING + (safeHeight - targetRect.height * scale) / 2 - targetRect.y * scale;
|
||||
|
||||
return { x, y, scale };
|
||||
}
|
||||
|
||||
function applyTransform(transform: { x: number; y: number; scale: number }) {
|
||||
const panzoom = panzoomRef.value;
|
||||
if (!panzoom) return;
|
||||
panzoom.zoom(transform.scale, { animate: false, force: true });
|
||||
panzoom.pan(transform.x, transform.y, { animate: false, force: true });
|
||||
}
|
||||
|
||||
async function applyCameraToElement(element: HTMLElement, animate: boolean) {
|
||||
const panzoom = panzoomRef.value;
|
||||
if (!panzoom) return;
|
||||
const target = computeTransformForElement(element);
|
||||
if (!target) return;
|
||||
|
||||
if (!animate) {
|
||||
stopCameraAnimation();
|
||||
applyTransform(target);
|
||||
return;
|
||||
}
|
||||
|
||||
stopCameraAnimation();
|
||||
const localToken = animationToken;
|
||||
const startPan = panzoom.getPan();
|
||||
const startScale = panzoom.getScale();
|
||||
const startAt = performance.now();
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const step = (now: number) => {
|
||||
if (localToken !== animationToken) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsed = now - startAt;
|
||||
const t = Math.max(0, Math.min(1, elapsed / ZOOM_ANIMATION_MS));
|
||||
const k = easing(t);
|
||||
|
||||
applyTransform({
|
||||
x: startPan.x + (target.x - startPan.x) * k,
|
||||
y: startPan.y + (target.y - startPan.y) * k,
|
||||
scale: startScale + (target.scale - startScale) * k,
|
||||
});
|
||||
|
||||
if (t < 1) {
|
||||
animationFrameId = requestAnimationFrame(step);
|
||||
return;
|
||||
}
|
||||
|
||||
animationFrameId = null;
|
||||
resolve();
|
||||
};
|
||||
|
||||
animationFrameId = requestAnimationFrame(step);
|
||||
});
|
||||
}
|
||||
|
||||
async function applyCameraToLevel(level: Level, animate: boolean) {
|
||||
const element = findFocusElement(level);
|
||||
if (!element) return;
|
||||
await applyCameraToElement(element, animate);
|
||||
}
|
||||
|
||||
function startPrime(focusId: string) {
|
||||
if (primeTimer) {
|
||||
clearTimeout(primeTimer);
|
||||
primeTimer = null;
|
||||
}
|
||||
|
||||
primeFocusId.value = focusId;
|
||||
primeTimer = setTimeout(() => {
|
||||
primeFocusId.value = "";
|
||||
}, 170);
|
||||
}
|
||||
|
||||
function resetWheelPrime() {
|
||||
wheelPrimeDirection.value = "";
|
||||
wheelPrimeTicks.value = 0;
|
||||
}
|
||||
|
||||
function nextLevel(level: Level): Level | null {
|
||||
const idx = LEVELS.indexOf(level);
|
||||
if (idx < 0 || idx >= LEVELS.length - 1) return null;
|
||||
return LEVELS[idx + 1] ?? null;
|
||||
}
|
||||
|
||||
function prevLevel(level: Level): Level | null {
|
||||
const idx = LEVELS.indexOf(level);
|
||||
if (idx <= 0) return null;
|
||||
return LEVELS[idx - 1] ?? null;
|
||||
}
|
||||
|
||||
function prepareZoomTarget(direction: Direction): { level: Level; focusId: string } | null {
|
||||
if (direction === "in") {
|
||||
if (currentLevel.value === "year") {
|
||||
selectedMonth.value = hoveredMonth.value;
|
||||
selectedWeek.value = 0;
|
||||
selectedDay.value = 0;
|
||||
const level = nextLevel(currentLevel.value);
|
||||
if (!level) return null;
|
||||
return { level, focusId: getFocusId(level) };
|
||||
}
|
||||
|
||||
if (currentLevel.value === "month") {
|
||||
selectedWeek.value = hoveredWeek.value;
|
||||
selectedDay.value = 0;
|
||||
const level = nextLevel(currentLevel.value);
|
||||
if (!level) return null;
|
||||
return { level, focusId: getFocusId(level) };
|
||||
}
|
||||
|
||||
if (currentLevel.value === "week") {
|
||||
selectedDay.value = hoveredDay.value;
|
||||
const level = nextLevel(currentLevel.value);
|
||||
if (!level) return null;
|
||||
return { level, focusId: getFocusId(level) };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const level = prevLevel(currentLevel.value);
|
||||
if (!level) return null;
|
||||
return { level, focusId: getFocusId(level) };
|
||||
}
|
||||
|
||||
async function animateToLevel(level: Level) {
|
||||
if (isAnimating.value) return;
|
||||
|
||||
isAnimating.value = true;
|
||||
transitionTarget.value = level;
|
||||
|
||||
await nextTick();
|
||||
await applyCameraToLevel(level, true);
|
||||
|
||||
currentLevel.value = level;
|
||||
transitionTarget.value = null;
|
||||
isAnimating.value = false;
|
||||
}
|
||||
|
||||
async function zoom(direction: Direction) {
|
||||
if (isAnimating.value) return false;
|
||||
|
||||
const target = prepareZoomTarget(direction);
|
||||
if (!target) return false;
|
||||
|
||||
await animateToLevel(target.level);
|
||||
return true;
|
||||
}
|
||||
|
||||
function onWheel(event: WheelEvent) {
|
||||
if (isAnimating.value) return;
|
||||
|
||||
const direction: Direction = event.deltaY < 0 ? "in" : "out";
|
||||
const target = prepareZoomTarget(direction);
|
||||
if (!target) return;
|
||||
|
||||
if (wheelPrimeDirection.value !== direction) {
|
||||
wheelPrimeDirection.value = direction;
|
||||
wheelPrimeTicks.value = 0;
|
||||
}
|
||||
|
||||
if (wheelPrimeTicks.value < ZOOM_PRIME_STEPS) {
|
||||
wheelPrimeTicks.value += 1;
|
||||
startPrime(target.focusId);
|
||||
return;
|
||||
}
|
||||
|
||||
resetWheelPrime();
|
||||
void animateToLevel(target.level);
|
||||
}
|
||||
|
||||
async function onSliderInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement | null;
|
||||
if (!target || isAnimating.value) return;
|
||||
|
||||
resetWheelPrime();
|
||||
|
||||
const targetIndex = Number(target.value);
|
||||
const safeTargetIndex = Math.max(0, Math.min(LEVELS.length - 1, targetIndex));
|
||||
|
||||
while (!isAnimating.value && currentLevelIndex.value < safeTargetIndex) {
|
||||
const moved = await zoom("in");
|
||||
if (!moved) break;
|
||||
}
|
||||
|
||||
while (!isAnimating.value && currentLevelIndex.value > safeTargetIndex) {
|
||||
const moved = await zoom("out");
|
||||
if (!moved) break;
|
||||
}
|
||||
}
|
||||
|
||||
function isPrime(id: string) {
|
||||
return primeFocusId.value === id;
|
||||
}
|
||||
|
||||
function isMonthSelected(index: number) {
|
||||
return selectedMonth.value === index;
|
||||
}
|
||||
|
||||
function isWeekSelected(index: number) {
|
||||
return selectedWeek.value === index;
|
||||
}
|
||||
|
||||
function isDaySelected(index: number) {
|
||||
return selectedDay.value === index;
|
||||
}
|
||||
|
||||
function showMonthContent(index: number) {
|
||||
return isMonthSelected(index) && currentLevel.value !== "year";
|
||||
}
|
||||
|
||||
function showWeekContent(index: number) {
|
||||
return isWeekSelected(index) && (currentLevel.value === "week" || currentLevel.value === "day");
|
||||
}
|
||||
|
||||
function showDayContent(index: number) {
|
||||
return isDaySelected(index) && currentLevel.value === "day";
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
|
||||
if (sceneRef.value) {
|
||||
panzoomRef.value = Panzoom(sceneRef.value, {
|
||||
animate: false,
|
||||
maxScale: 24,
|
||||
minScale: 0.08,
|
||||
disablePan: true,
|
||||
origin: "0 0",
|
||||
});
|
||||
}
|
||||
|
||||
applyCameraToLevel("year", false);
|
||||
|
||||
resizeObserver.value = new ResizeObserver(() => {
|
||||
applyCameraToLevel(displayLevel.value, false);
|
||||
});
|
||||
|
||||
if (viewportRef.value) {
|
||||
resizeObserver.value.observe(viewportRef.value);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (primeTimer) clearTimeout(primeTimer);
|
||||
stopCameraAnimation();
|
||||
resizeObserver.value?.disconnect();
|
||||
panzoomRef.value?.destroy();
|
||||
panzoomRef.value = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="calendar-lab-root">
|
||||
<header class="calendar-lab-toolbar">
|
||||
<p class="calendar-lab-level-text">
|
||||
Current level: {{ LEVEL_LABELS[currentLevel] }}
|
||||
</p>
|
||||
|
||||
<div class="calendar-zoom-inline" @click.stop>
|
||||
<input
|
||||
class="calendar-zoom-slider"
|
||||
type="range"
|
||||
min="0"
|
||||
max="3"
|
||||
step="1"
|
||||
:value="displayLevelIndex"
|
||||
aria-label="Calendar zoom level"
|
||||
@input="onSliderInput"
|
||||
>
|
||||
<div class="calendar-zoom-marks" aria-hidden="true">
|
||||
<span
|
||||
v-for="index in 4"
|
||||
:key="`calendar-lab-zoom-mark-${index}`"
|
||||
class="calendar-zoom-mark"
|
||||
:class="displayLevelIndex === index - 1 ? 'calendar-zoom-mark-active' : ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
ref="viewportRef"
|
||||
class="calendar-lab-viewport"
|
||||
:class="canZoomIn ? 'cursor-zoom-in' : 'cursor-zoom-out'"
|
||||
@wheel.prevent="onWheel"
|
||||
>
|
||||
<div ref="sceneRef" class="calendar-lab-scene">
|
||||
<article class="calendar-year" data-focus-id="focus-year">
|
||||
<div class="calendar-year-grid">
|
||||
<div
|
||||
v-for="(label, monthIndex) in MONTH_LABELS"
|
||||
:key="`month-${label}`"
|
||||
class="calendar-month-card"
|
||||
:class="[
|
||||
isMonthSelected(monthIndex) ? 'calendar-month-card-selected' : '',
|
||||
currentLevel === 'year' && hoveredMonth === monthIndex ? 'calendar-hover-target' : '',
|
||||
isPrime(`focus-month-${monthIndex}`) ? 'calendar-prime-target' : '',
|
||||
]"
|
||||
:data-focus-id="`focus-month-${monthIndex}`"
|
||||
@mouseenter="currentLevel === 'year' ? (hoveredMonth = monthIndex) : undefined"
|
||||
>
|
||||
<p class="calendar-card-label">{{ label }}</p>
|
||||
|
||||
<div v-if="showMonthContent(monthIndex)" class="calendar-week-grid-wrap">
|
||||
<div class="calendar-week-grid">
|
||||
<div
|
||||
v-for="weekIndex in 6"
|
||||
:key="`week-${weekIndex - 1}`"
|
||||
class="calendar-week-card"
|
||||
:class="[
|
||||
isWeekSelected(weekIndex - 1) ? 'calendar-week-card-selected' : '',
|
||||
currentLevel === 'month' && hoveredWeek === weekIndex - 1 ? 'calendar-hover-target' : '',
|
||||
isPrime(`focus-week-${weekIndex - 1}`) ? 'calendar-prime-target' : '',
|
||||
]"
|
||||
:data-focus-id="`focus-week-${weekIndex - 1}`"
|
||||
@mouseenter="currentLevel === 'month' ? (hoveredWeek = weekIndex - 1) : undefined"
|
||||
>
|
||||
<p class="calendar-card-label">Week {{ weekIndex }}</p>
|
||||
|
||||
<div v-if="showWeekContent(weekIndex - 1)" class="calendar-day-grid-wrap">
|
||||
<div class="calendar-day-grid">
|
||||
<div
|
||||
v-for="dayIndex in 7"
|
||||
:key="`day-${dayIndex - 1}`"
|
||||
class="calendar-day-card"
|
||||
:class="[
|
||||
isDaySelected(dayIndex - 1) ? 'calendar-day-card-selected' : '',
|
||||
currentLevel === 'week' && hoveredDay === dayIndex - 1 ? 'calendar-hover-target' : '',
|
||||
isPrime(`focus-day-${dayIndex - 1}`) ? 'calendar-prime-target' : '',
|
||||
]"
|
||||
:data-focus-id="`focus-day-${dayIndex - 1}`"
|
||||
@mouseenter="currentLevel === 'week' ? (hoveredDay = dayIndex - 1) : undefined"
|
||||
>
|
||||
<p class="calendar-card-label">Day {{ dayIndex }}</p>
|
||||
|
||||
<div v-if="showDayContent(dayIndex - 1)" class="calendar-slot-grid-wrap">
|
||||
<div class="calendar-slot-grid">
|
||||
<span
|
||||
v-for="slot in 12"
|
||||
:key="`slot-${slot}`"
|
||||
class="calendar-slot"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.calendar-lab-root {
|
||||
height: calc(100dvh - 2.5rem);
|
||||
min-height: 620px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.calendar-lab-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.calendar-lab-level-text {
|
||||
font-size: 0.78rem;
|
||||
color: color-mix(in oklab, var(--color-base-content) 72%, transparent);
|
||||
}
|
||||
|
||||
.calendar-lab-viewport {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border-radius: 14px;
|
||||
border: 1px solid color-mix(in oklab, var(--color-base-content) 18%, transparent);
|
||||
background:
|
||||
radial-gradient(circle at 15% 15%, color-mix(in oklab, var(--color-base-content) 6%, transparent), transparent 40%),
|
||||
color-mix(in oklab, var(--color-base-100) 94%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.calendar-lab-scene {
|
||||
position: relative;
|
||||
width: 1400px;
|
||||
height: 900px;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.calendar-year {
|
||||
position: absolute;
|
||||
left: 80px;
|
||||
top: 50px;
|
||||
width: 1240px;
|
||||
height: 760px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid color-mix(in oklab, var(--color-base-content) 24%, transparent);
|
||||
background: color-mix(in oklab, var(--color-base-100) 96%, transparent);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.calendar-year-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.calendar-month-card,
|
||||
.calendar-week-card,
|
||||
.calendar-day-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid color-mix(in oklab, var(--color-base-content) 18%, transparent);
|
||||
background: color-mix(in oklab, var(--color-base-200) 72%, transparent);
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.calendar-month-card-selected,
|
||||
.calendar-week-card-selected,
|
||||
.calendar-day-card-selected {
|
||||
border-color: color-mix(in oklab, var(--color-primary) 70%, transparent);
|
||||
}
|
||||
|
||||
.calendar-card-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: color-mix(in oklab, var(--color-base-content) 88%, transparent);
|
||||
}
|
||||
|
||||
.calendar-week-grid-wrap,
|
||||
.calendar-day-grid-wrap,
|
||||
.calendar-slot-grid-wrap {
|
||||
width: 100%;
|
||||
height: calc(100% - 20px);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.calendar-week-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.calendar-day-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.calendar-slot-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(3, minmax(0, 1fr));
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.calendar-slot {
|
||||
border-radius: 8px;
|
||||
border: 1px solid color-mix(in oklab, var(--color-base-content) 16%, transparent);
|
||||
background: color-mix(in oklab, var(--color-base-100) 88%, transparent);
|
||||
}
|
||||
|
||||
.calendar-hover-target {
|
||||
border-color: color-mix(in oklab, var(--color-primary) 70%, transparent);
|
||||
box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-primary) 36%, transparent) inset;
|
||||
}
|
||||
|
||||
.calendar-prime-target {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.calendar-zoom-inline {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 128px;
|
||||
height: 22px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.calendar-zoom-slider {
|
||||
width: 100%;
|
||||
height: 18px;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.calendar-zoom-slider:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.calendar-zoom-slider::-webkit-slider-runnable-track {
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--color-base-content) 22%, transparent);
|
||||
}
|
||||
|
||||
.calendar-zoom-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-top: -4px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--color-base-content) 88%, transparent);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.calendar-zoom-slider::-moz-range-track {
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--color-base-content) 22%, transparent);
|
||||
}
|
||||
|
||||
.calendar-zoom-slider::-moz-range-progress {
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.calendar-zoom-slider::-moz-range-thumb {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--color-base-content) 88%, transparent);
|
||||
}
|
||||
|
||||
.calendar-zoom-marks {
|
||||
position: absolute;
|
||||
inset: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.calendar-zoom-mark {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--color-base-content) 35%, transparent);
|
||||
}
|
||||
|
||||
.calendar-zoom-mark-active {
|
||||
background: color-mix(in oklab, var(--color-base-content) 85%, transparent);
|
||||
}
|
||||
</style>
|
||||
@@ -1,595 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { createElement } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Tldraw, createShapeId, toRichText } from "tldraw";
|
||||
import "tldraw/tldraw.css";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Constants */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const LEVEL_LABELS = ["year", "month", "week", "day"] as const;
|
||||
const MONTH_LABELS = [
|
||||
"January", "February", "March", "April",
|
||||
"May", "June", "July", "August",
|
||||
"September", "October", "November", "December",
|
||||
];
|
||||
const DAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
|
||||
const ZOOM_MS = 800;
|
||||
const PRIME_TICKS_REQUIRED = 2;
|
||||
const PRIME_RESET_MS = 600;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Reactive state */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const hostRef = ref<HTMLDivElement | null>(null);
|
||||
const status = ref("Loading tldraw engine...");
|
||||
|
||||
const activeLevel = ref(0);
|
||||
const activeLabel = ref("Year 2026");
|
||||
|
||||
const debugInfo = computed(
|
||||
() => `${LEVEL_LABELS[activeLevel.value] ?? "year"}: ${activeLabel.value}`,
|
||||
);
|
||||
|
||||
let reactRoot: { unmount: () => void } | null = null;
|
||||
let teardown: (() => void) | null = null;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Shape builders — one function per zoom level */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type ShapeDef = {
|
||||
id: string;
|
||||
type: "geo";
|
||||
x: number;
|
||||
y: number;
|
||||
isLocked: boolean;
|
||||
props: Record<string, unknown>;
|
||||
meta: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/** Level 0 — year view: 1 container + 12 month cards */
|
||||
function buildYearShapes(): { shapes: ShapeDef[]; containerId: string; childIds: string[] } {
|
||||
const containerId = createShapeId("year-2026");
|
||||
const shapes: ShapeDef[] = [];
|
||||
const childIds: string[] = [];
|
||||
|
||||
// Year container
|
||||
shapes.push({
|
||||
id: containerId,
|
||||
type: "geo",
|
||||
x: 0,
|
||||
y: 0,
|
||||
isLocked: true,
|
||||
props: {
|
||||
geo: "rectangle",
|
||||
w: 3200,
|
||||
h: 2200,
|
||||
richText: toRichText("2026"),
|
||||
color: "black",
|
||||
fill: "none",
|
||||
dash: "draw",
|
||||
},
|
||||
meta: { level: 0, label: "Year 2026", role: "container" },
|
||||
});
|
||||
|
||||
const cols = 4;
|
||||
const gap = 34;
|
||||
const cardW = 730;
|
||||
const cardH = 650;
|
||||
const startX = 70;
|
||||
const startY = 90;
|
||||
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const col = i % cols;
|
||||
const row = Math.floor(i / cols);
|
||||
const x = startX + col * (cardW + gap);
|
||||
const y = startY + row * (cardH + gap);
|
||||
const id = createShapeId(`month-${i}`);
|
||||
childIds.push(id);
|
||||
|
||||
shapes.push({
|
||||
id,
|
||||
type: "geo",
|
||||
x,
|
||||
y,
|
||||
isLocked: true,
|
||||
props: {
|
||||
geo: "rectangle",
|
||||
w: cardW,
|
||||
h: cardH,
|
||||
richText: toRichText(MONTH_LABELS[i]!),
|
||||
color: "black",
|
||||
fill: "semi",
|
||||
dash: "draw",
|
||||
},
|
||||
meta: { level: 1, label: MONTH_LABELS[i], role: "child", index: i },
|
||||
});
|
||||
}
|
||||
|
||||
return { shapes, containerId, childIds };
|
||||
}
|
||||
|
||||
/** Level 1 — month view: 1 month container + 6 week rows */
|
||||
function buildMonthShapes(monthIndex: number): { shapes: ShapeDef[]; containerId: string; childIds: string[] } {
|
||||
const containerId = createShapeId(`month-${monthIndex}-expanded`);
|
||||
const shapes: ShapeDef[] = [];
|
||||
const childIds: string[] = [];
|
||||
|
||||
shapes.push({
|
||||
id: containerId,
|
||||
type: "geo",
|
||||
x: 0,
|
||||
y: 0,
|
||||
isLocked: true,
|
||||
props: {
|
||||
geo: "rectangle",
|
||||
w: 2400,
|
||||
h: 1800,
|
||||
richText: toRichText(MONTH_LABELS[monthIndex]!),
|
||||
color: "black",
|
||||
fill: "none",
|
||||
dash: "draw",
|
||||
},
|
||||
meta: { level: 1, label: MONTH_LABELS[monthIndex], role: "container", index: monthIndex },
|
||||
});
|
||||
|
||||
const weekGap = 20;
|
||||
const weekH = (1800 - 100 - weekGap * 5) / 6;
|
||||
const weekW = 2400 - 80;
|
||||
const weekX = 40;
|
||||
const weekStartY = 80;
|
||||
|
||||
for (let w = 0; w < 6; w++) {
|
||||
const y = weekStartY + w * (weekH + weekGap);
|
||||
const id = createShapeId(`month-${monthIndex}-week-${w}`);
|
||||
childIds.push(id);
|
||||
|
||||
shapes.push({
|
||||
id,
|
||||
type: "geo",
|
||||
x: weekX,
|
||||
y,
|
||||
isLocked: true,
|
||||
props: {
|
||||
geo: "rectangle",
|
||||
w: weekW,
|
||||
h: weekH,
|
||||
richText: toRichText(`Week ${w + 1}`),
|
||||
color: "black",
|
||||
fill: "semi",
|
||||
dash: "draw",
|
||||
},
|
||||
meta: { level: 2, label: `Week ${w + 1}`, role: "child", index: w },
|
||||
});
|
||||
}
|
||||
|
||||
return { shapes, containerId, childIds };
|
||||
}
|
||||
|
||||
/** Level 2 — week view: 1 week container + 7 day columns */
|
||||
function buildWeekShapes(monthIndex: number, weekIndex: number): { shapes: ShapeDef[]; containerId: string; childIds: string[] } {
|
||||
const containerId = createShapeId(`week-${monthIndex}-${weekIndex}-expanded`);
|
||||
const shapes: ShapeDef[] = [];
|
||||
const childIds: string[] = [];
|
||||
|
||||
shapes.push({
|
||||
id: containerId,
|
||||
type: "geo",
|
||||
x: 0,
|
||||
y: 0,
|
||||
isLocked: true,
|
||||
props: {
|
||||
geo: "rectangle",
|
||||
w: 2800,
|
||||
h: 1600,
|
||||
richText: toRichText(`${MONTH_LABELS[monthIndex]} · Week ${weekIndex + 1}`),
|
||||
color: "black",
|
||||
fill: "none",
|
||||
dash: "draw",
|
||||
},
|
||||
meta: { level: 2, label: `Week ${weekIndex + 1}`, role: "container", monthIndex, weekIndex },
|
||||
});
|
||||
|
||||
const dayGap = 16;
|
||||
const dayW = (2800 - 80 - dayGap * 6) / 7;
|
||||
const dayH = 1600 - 120;
|
||||
const dayX = 40;
|
||||
const dayY = 80;
|
||||
|
||||
for (let d = 0; d < 7; d++) {
|
||||
const x = dayX + d * (dayW + dayGap);
|
||||
const id = createShapeId(`week-${monthIndex}-${weekIndex}-day-${d}`);
|
||||
childIds.push(id);
|
||||
|
||||
shapes.push({
|
||||
id,
|
||||
type: "geo",
|
||||
x,
|
||||
y: dayY,
|
||||
isLocked: true,
|
||||
props: {
|
||||
geo: "rectangle",
|
||||
w: dayW,
|
||||
h: dayH,
|
||||
richText: toRichText(DAY_LABELS[d]!),
|
||||
color: "black",
|
||||
fill: "semi",
|
||||
dash: "draw",
|
||||
},
|
||||
meta: { level: 3, label: DAY_LABELS[d], role: "child", index: d },
|
||||
});
|
||||
}
|
||||
|
||||
return { shapes, containerId, childIds };
|
||||
}
|
||||
|
||||
/** Level 3 — day view: 1 day container + time slot blocks */
|
||||
function buildDayShapes(monthIndex: number, weekIndex: number, dayIndex: number): { shapes: ShapeDef[]; containerId: string; childIds: string[] } {
|
||||
const containerId = createShapeId(`day-${monthIndex}-${weekIndex}-${dayIndex}-expanded`);
|
||||
const shapes: ShapeDef[] = [];
|
||||
const childIds: string[] = [];
|
||||
|
||||
const dayLabel = DAY_LABELS[dayIndex] ?? `Day ${dayIndex + 1}`;
|
||||
|
||||
shapes.push({
|
||||
id: containerId,
|
||||
type: "geo",
|
||||
x: 0,
|
||||
y: 0,
|
||||
isLocked: true,
|
||||
props: {
|
||||
geo: "rectangle",
|
||||
w: 1200,
|
||||
h: 2400,
|
||||
richText: toRichText(`${MONTH_LABELS[monthIndex]} · Week ${weekIndex + 1} · ${dayLabel}`),
|
||||
color: "black",
|
||||
fill: "none",
|
||||
dash: "draw",
|
||||
},
|
||||
meta: { level: 3, label: dayLabel, role: "container", monthIndex, weekIndex, dayIndex },
|
||||
});
|
||||
|
||||
// Time slots from 8:00 to 20:00 (12 hour-slots)
|
||||
const slotGap = 12;
|
||||
const slotH = (2400 - 100 - slotGap * 11) / 12;
|
||||
const slotW = 1200 - 80;
|
||||
const slotX = 40;
|
||||
const slotStartY = 80;
|
||||
|
||||
for (let s = 0; s < 12; s++) {
|
||||
const y = slotStartY + s * (slotH + slotGap);
|
||||
const hour = 8 + s;
|
||||
const id = createShapeId(`day-${monthIndex}-${weekIndex}-${dayIndex}-slot-${s}`);
|
||||
childIds.push(id);
|
||||
|
||||
shapes.push({
|
||||
id,
|
||||
type: "geo",
|
||||
x: slotX,
|
||||
y,
|
||||
isLocked: true,
|
||||
props: {
|
||||
geo: "rectangle",
|
||||
w: slotW,
|
||||
h: slotH,
|
||||
richText: toRichText(`${hour}:00`),
|
||||
color: "light-violet",
|
||||
fill: "semi",
|
||||
dash: "draw",
|
||||
},
|
||||
meta: { level: 4, label: `${hour}:00`, role: "slot", index: s },
|
||||
});
|
||||
}
|
||||
|
||||
return { shapes, containerId, childIds };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Main zoom flow controller */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function setupZoomFlow(editor: any, host: HTMLDivElement) {
|
||||
let currentLevel = 0;
|
||||
let selectedMonth = 0;
|
||||
let selectedWeek = 0;
|
||||
let selectedDay = 0;
|
||||
|
||||
let containerId = "";
|
||||
let childIds: string[] = [];
|
||||
|
||||
let animating = false;
|
||||
let token = 0;
|
||||
|
||||
// Wheel prime state
|
||||
let primeDirection: "in" | "out" | "" = "";
|
||||
let primeTicks = 0;
|
||||
let primeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** Wipe canvas and draw shapes for a given level */
|
||||
function renderLevel(level: number, immediate: boolean) {
|
||||
// Delete all existing shapes
|
||||
const allShapeIds = editor.getCurrentPageShapeIds();
|
||||
if (allShapeIds.size > 0) {
|
||||
editor.deleteShapes([...allShapeIds]);
|
||||
}
|
||||
|
||||
let result: { shapes: ShapeDef[]; containerId: string; childIds: string[] };
|
||||
|
||||
switch (level) {
|
||||
case 0:
|
||||
result = buildYearShapes();
|
||||
break;
|
||||
case 1:
|
||||
result = buildMonthShapes(selectedMonth);
|
||||
break;
|
||||
case 2:
|
||||
result = buildWeekShapes(selectedMonth, selectedWeek);
|
||||
break;
|
||||
case 3:
|
||||
result = buildDayShapes(selectedMonth, selectedWeek, selectedDay);
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
editor.createShapes(result.shapes);
|
||||
containerId = result.containerId;
|
||||
childIds = result.childIds;
|
||||
currentLevel = level;
|
||||
|
||||
// Update reactive debug state
|
||||
activeLevel.value = level;
|
||||
const containerMeta = result.shapes.find((s) => s.id === containerId)?.meta;
|
||||
activeLabel.value = (containerMeta?.label as string) ?? LEVEL_LABELS[level] ?? "";
|
||||
|
||||
// Zoom camera to fit container
|
||||
const bounds = editor.getShapePageBounds(containerId);
|
||||
if (bounds) {
|
||||
token += 1;
|
||||
const localToken = token;
|
||||
const duration = immediate ? 0 : ZOOM_MS;
|
||||
|
||||
animating = !immediate;
|
||||
editor.stopCameraAnimation();
|
||||
editor.zoomToBounds(bounds, {
|
||||
inset: 40,
|
||||
animation: { duration },
|
||||
immediate,
|
||||
force: true,
|
||||
});
|
||||
|
||||
if (!immediate) {
|
||||
setTimeout(() => {
|
||||
if (localToken === token) animating = false;
|
||||
}, duration + 50);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve which child the mouse is hovering over */
|
||||
function resolveHoveredChild(): string | null {
|
||||
if (childIds.length === 0) return null;
|
||||
|
||||
const pointer = editor.inputs.currentPagePoint;
|
||||
if (!pointer) return childIds[0] ?? null;
|
||||
|
||||
const hit = editor.getShapeAtPoint(pointer, {
|
||||
margin: 0,
|
||||
hitInside: true,
|
||||
hitLocked: true,
|
||||
hitLabels: true,
|
||||
hitFrameInside: true,
|
||||
});
|
||||
|
||||
if (hit && childIds.includes(hit.id)) return hit.id;
|
||||
|
||||
// If hit is the container itself, try first child
|
||||
return childIds[0] ?? null;
|
||||
}
|
||||
|
||||
function resetPrime() {
|
||||
primeDirection = "";
|
||||
primeTicks = 0;
|
||||
if (primeTimer) {
|
||||
clearTimeout(primeTimer);
|
||||
primeTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function zoomIn() {
|
||||
if (currentLevel >= 3) return;
|
||||
|
||||
const targetId = resolveHoveredChild();
|
||||
if (!targetId) return;
|
||||
|
||||
// Find the index of hovered child from its meta
|
||||
const shape = editor.getShape(targetId);
|
||||
const idx = (shape?.meta?.index as number) ?? 0;
|
||||
|
||||
switch (currentLevel) {
|
||||
case 0:
|
||||
selectedMonth = idx;
|
||||
break;
|
||||
case 1:
|
||||
selectedWeek = idx;
|
||||
break;
|
||||
case 2:
|
||||
selectedDay = idx;
|
||||
break;
|
||||
}
|
||||
|
||||
renderLevel(currentLevel + 1, false);
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
if (currentLevel <= 0) return;
|
||||
renderLevel(currentLevel - 1, false);
|
||||
}
|
||||
|
||||
function onWheel(event: WheelEvent) {
|
||||
event.preventDefault();
|
||||
if (animating) return;
|
||||
|
||||
const direction: "in" | "out" = event.deltaY < 0 ? "in" : "out";
|
||||
|
||||
// Check if can go in this direction
|
||||
if (direction === "in" && currentLevel >= 3) return;
|
||||
if (direction === "out" && currentLevel <= 0) return;
|
||||
|
||||
// Reset prime if direction changed
|
||||
if (primeDirection !== direction) {
|
||||
resetPrime();
|
||||
primeDirection = direction;
|
||||
}
|
||||
|
||||
primeTicks += 1;
|
||||
|
||||
// Reset prime timer
|
||||
if (primeTimer) clearTimeout(primeTimer);
|
||||
primeTimer = setTimeout(resetPrime, PRIME_RESET_MS);
|
||||
|
||||
// Highlight target on pre-ticks
|
||||
if (primeTicks < PRIME_TICKS_REQUIRED) {
|
||||
// Visual hint: briefly scale hovered child
|
||||
if (direction === "in") {
|
||||
const targetId = resolveHoveredChild();
|
||||
if (targetId) {
|
||||
const shape = editor.getShape(targetId);
|
||||
if (shape) {
|
||||
// Flash the shape by toggling fill
|
||||
editor.updateShape({
|
||||
id: targetId,
|
||||
type: "geo",
|
||||
props: { fill: "solid" },
|
||||
});
|
||||
setTimeout(() => {
|
||||
try {
|
||||
editor.updateShape({
|
||||
id: targetId,
|
||||
type: "geo",
|
||||
props: { fill: "semi" },
|
||||
});
|
||||
} catch (_) { /* shape might be gone */ }
|
||||
}, 150);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Enough ticks — execute zoom
|
||||
resetPrime();
|
||||
|
||||
if (direction === "in") {
|
||||
zoomIn();
|
||||
} else {
|
||||
zoomOut();
|
||||
}
|
||||
}
|
||||
|
||||
function onDoubleClick() {
|
||||
if (animating) return;
|
||||
resetPrime();
|
||||
if (currentLevel === 0) return;
|
||||
renderLevel(0, false);
|
||||
}
|
||||
|
||||
// Initial render
|
||||
renderLevel(0, true);
|
||||
|
||||
// Event listeners on host element (outside tldraw's event system)
|
||||
host.addEventListener("wheel", onWheel, { passive: false });
|
||||
host.addEventListener("dblclick", onDoubleClick);
|
||||
|
||||
return () => {
|
||||
resetPrime();
|
||||
host.removeEventListener("wheel", onWheel);
|
||||
host.removeEventListener("dblclick", onDoubleClick);
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Lifecycle */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
if (!hostRef.value) return;
|
||||
|
||||
reactRoot = createRoot(hostRef.value);
|
||||
reactRoot.render(
|
||||
createElement(Tldraw, {
|
||||
hideUi: true,
|
||||
onMount: (editor: any) => {
|
||||
status.value = "Wheel up = zoom in · wheel down = zoom out · double click = reset";
|
||||
teardown = setupZoomFlow(editor, hostRef.value as HTMLDivElement);
|
||||
},
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
status.value = "Failed to initialize local tldraw engine";
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
teardown?.();
|
||||
teardown = null;
|
||||
reactRoot?.unmount();
|
||||
reactRoot = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="tldraw-lab-root">
|
||||
<header class="tldraw-lab-toolbar">
|
||||
<p class="tldraw-lab-title">{{ status }}</p>
|
||||
<p class="tldraw-lab-subtitle">{{ debugInfo }}</p>
|
||||
</header>
|
||||
<div ref="hostRef" class="tldraw-lab-canvas" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tldraw-lab-root {
|
||||
height: calc(100dvh - 1rem);
|
||||
min-height: 640px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid color-mix(in oklab, var(--color-base-content) 20%, transparent);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: color-mix(in oklab, var(--color-base-100) 95%, transparent);
|
||||
}
|
||||
|
||||
.tldraw-lab-toolbar {
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 0 10px;
|
||||
border-bottom: 1px solid color-mix(in oklab, var(--color-base-content) 16%, transparent);
|
||||
background: color-mix(in oklab, var(--color-base-100) 94%, transparent);
|
||||
}
|
||||
|
||||
.tldraw-lab-title {
|
||||
font-size: 0.78rem;
|
||||
color: color-mix(in oklab, var(--color-base-content) 72%, transparent);
|
||||
}
|
||||
|
||||
.tldraw-lab-subtitle {
|
||||
font-size: 0.74rem;
|
||||
color: color-mix(in oklab, var(--color-base-content) 60%, transparent);
|
||||
}
|
||||
|
||||
.tldraw-lab-canvas {
|
||||
width: 100%;
|
||||
height: calc(100% - 42px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,493 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
type ContactRightPanelMode = "summary" | "documents";
|
||||
|
||||
const props = defineProps<{
|
||||
selectedWorkspaceContactDocuments: any[];
|
||||
contactRightPanelMode: ContactRightPanelMode;
|
||||
onContactRightPanelModeChange: (mode: ContactRightPanelMode) => void;
|
||||
selectedDocumentId: string;
|
||||
onSelectedDocumentIdChange: (documentId: string) => void;
|
||||
contactDocumentsSearch: string;
|
||||
onContactDocumentsSearchInput: (value: string) => void;
|
||||
filteredSelectedWorkspaceContactDocuments: any[];
|
||||
formatStamp: (iso: string) => string;
|
||||
openDocumentsTab: (focusDocument?: boolean) => void;
|
||||
selectedWorkspaceDeal: any | null;
|
||||
isReviewHighlightedDeal: (dealId: string) => boolean;
|
||||
contextPickerEnabled: boolean;
|
||||
hasContextScope: (scope: "deal" | "summary") => boolean;
|
||||
toggleContextScope: (scope: "deal" | "summary") => void;
|
||||
formatDealHeadline: (deal: any) => string;
|
||||
selectedWorkspaceDealSubtitle: string;
|
||||
selectedWorkspaceDealSteps: any[];
|
||||
selectedDealStepsExpanded: boolean;
|
||||
onSelectedDealStepsExpandedChange: (value: boolean) => void;
|
||||
isDealStepDone: (step: any) => boolean;
|
||||
formatDealStepMeta: (step: any) => string;
|
||||
dealStageOptions: string[];
|
||||
createDealForContact: (input: {
|
||||
contactId: string;
|
||||
title: string;
|
||||
stage: string;
|
||||
amount: string;
|
||||
paidAmount: string;
|
||||
}) => Promise<any>;
|
||||
dealCreateLoading: boolean;
|
||||
updateDealDetails: (input: { dealId: string; stage: string; amount: string; paidAmount: string }) => Promise<boolean>;
|
||||
dealUpdateLoading: boolean;
|
||||
activeReviewContactDiff: {
|
||||
contactId?: string;
|
||||
before?: string;
|
||||
after?: string;
|
||||
} | null;
|
||||
selectedWorkspaceContact: {
|
||||
id: string;
|
||||
name?: string;
|
||||
description: string;
|
||||
} | null;
|
||||
}>();
|
||||
|
||||
function onDocumentsSearchInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement | null;
|
||||
props.onContactDocumentsSearchInput(target?.value ?? "");
|
||||
}
|
||||
|
||||
const dealStageDraft = ref("");
|
||||
const dealAmountDraft = ref("");
|
||||
const dealPaidAmountDraft = ref("");
|
||||
const dealNewStageDraft = ref("");
|
||||
const dealSaveError = ref("");
|
||||
const dealSaveSuccess = ref("");
|
||||
const dealCreateTitleDraft = ref("");
|
||||
const dealCreateStageDraft = ref("");
|
||||
const dealCreateAmountDraft = ref("");
|
||||
const dealCreatePaidAmountDraft = ref("");
|
||||
const dealCreateError = ref("");
|
||||
const dealCreateSuccess = ref("");
|
||||
const visibleDealStageOptions = computed(() => {
|
||||
const unique = new Set<string>(props.dealStageOptions);
|
||||
const current = dealStageDraft.value.trim();
|
||||
if (current) unique.add(current);
|
||||
return [...unique];
|
||||
});
|
||||
const visibleDealCreateStageOptions = computed(() => {
|
||||
const unique = new Set<string>(props.dealStageOptions);
|
||||
const current = dealCreateStageDraft.value.trim();
|
||||
if (current) unique.add(current);
|
||||
return [...unique];
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.selectedWorkspaceDeal?.id ?? "",
|
||||
() => {
|
||||
dealStageDraft.value = String(props.selectedWorkspaceDeal?.stage ?? "").trim();
|
||||
dealAmountDraft.value = String(props.selectedWorkspaceDeal?.amount ?? "").trim();
|
||||
dealPaidAmountDraft.value = String(props.selectedWorkspaceDeal?.paidAmount ?? "").trim();
|
||||
dealNewStageDraft.value = "";
|
||||
dealSaveError.value = "";
|
||||
dealSaveSuccess.value = "";
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.selectedWorkspaceContact?.id ?? "",
|
||||
() => {
|
||||
dealCreateTitleDraft.value = "";
|
||||
dealCreateAmountDraft.value = "";
|
||||
dealCreatePaidAmountDraft.value = "";
|
||||
dealCreateStageDraft.value = props.dealStageOptions[0] ?? "Новый";
|
||||
dealCreateError.value = "";
|
||||
dealCreateSuccess.value = "";
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.dealStageOptions.join("|"),
|
||||
() => {
|
||||
if (!dealCreateStageDraft.value.trim()) {
|
||||
dealCreateStageDraft.value = props.dealStageOptions[0] ?? "Новый";
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function applyNewDealStage() {
|
||||
const value = dealNewStageDraft.value.trim();
|
||||
if (!value) {
|
||||
dealSaveError.value = "Введите название статуса";
|
||||
dealSaveSuccess.value = "";
|
||||
return;
|
||||
}
|
||||
dealStageDraft.value = value;
|
||||
dealNewStageDraft.value = "";
|
||||
dealSaveError.value = "";
|
||||
dealSaveSuccess.value = "";
|
||||
}
|
||||
|
||||
async function saveDealDetails() {
|
||||
if (!props.selectedWorkspaceDeal) return;
|
||||
dealSaveError.value = "";
|
||||
dealSaveSuccess.value = "";
|
||||
|
||||
try {
|
||||
const changed = await props.updateDealDetails({
|
||||
dealId: props.selectedWorkspaceDeal.id,
|
||||
stage: dealStageDraft.value,
|
||||
amount: dealAmountDraft.value,
|
||||
paidAmount: dealPaidAmountDraft.value,
|
||||
});
|
||||
dealSaveSuccess.value = changed ? "Сделка обновлена" : "Изменений нет";
|
||||
} catch (error) {
|
||||
dealSaveError.value = error instanceof Error ? error.message : "Не удалось обновить сделку";
|
||||
}
|
||||
}
|
||||
|
||||
async function createDeal() {
|
||||
if (!props.selectedWorkspaceContact) return;
|
||||
dealCreateError.value = "";
|
||||
dealCreateSuccess.value = "";
|
||||
|
||||
try {
|
||||
const created = await props.createDealForContact({
|
||||
contactId: props.selectedWorkspaceContact.id,
|
||||
title: dealCreateTitleDraft.value,
|
||||
stage: dealCreateStageDraft.value,
|
||||
amount: dealCreateAmountDraft.value,
|
||||
paidAmount: dealCreatePaidAmountDraft.value,
|
||||
});
|
||||
if (!created) {
|
||||
dealCreateError.value = "Не удалось создать сделку";
|
||||
return;
|
||||
}
|
||||
dealCreateSuccess.value = "Сделка создана";
|
||||
dealCreateTitleDraft.value = "";
|
||||
dealCreateAmountDraft.value = "";
|
||||
dealCreatePaidAmountDraft.value = "";
|
||||
} catch (error) {
|
||||
dealCreateError.value = error instanceof Error ? error.message : "Не удалось создать сделку";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="h-full min-h-0">
|
||||
<div class="flex h-full min-h-0 flex-col p-3">
|
||||
<div
|
||||
v-if="props.selectedWorkspaceContactDocuments.length"
|
||||
class="mb-2 flex flex-wrap items-center gap-1.5 rounded-xl border border-base-300 bg-base-200/35 px-2 py-1.5"
|
||||
>
|
||||
<button
|
||||
class="badge badge-sm badge-outline"
|
||||
@click="props.onContactRightPanelModeChange('documents')"
|
||||
>
|
||||
{{ props.selectedWorkspaceContactDocuments.length }} documents
|
||||
</button>
|
||||
<button
|
||||
v-for="doc in props.selectedWorkspaceContactDocuments.slice(0, 15)"
|
||||
:key="`contact-doc-chip-${doc.id}`"
|
||||
class="rounded-full border border-base-300 bg-base-100 px-2 py-0.5 text-[10px] text-base-content/80 hover:bg-base-200/70"
|
||||
@click="props.onContactRightPanelModeChange('documents'); props.onSelectedDocumentIdChange(doc.id)"
|
||||
>
|
||||
{{ doc.title }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="props.contactRightPanelMode === 'documents'" class="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
<div class="sticky top-0 z-10 border-b border-base-300 bg-base-100 pb-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-base-content/60">
|
||||
Contact documents
|
||||
</p>
|
||||
<button class="btn btn-ghost btn-xs" @click="props.onContactRightPanelModeChange('summary')">Summary</button>
|
||||
</div>
|
||||
<input
|
||||
:value="props.contactDocumentsSearch"
|
||||
type="text"
|
||||
class="input input-bordered input-xs mt-2 w-full"
|
||||
placeholder="Search documents..."
|
||||
@input="onDocumentsSearchInput"
|
||||
>
|
||||
</div>
|
||||
<div class="mt-2 space-y-1.5">
|
||||
<article
|
||||
v-for="doc in props.filteredSelectedWorkspaceContactDocuments"
|
||||
:key="`contact-doc-right-${doc.id}`"
|
||||
class="w-full rounded-xl border border-base-300 px-2.5 py-2 text-left transition hover:bg-base-200/50"
|
||||
:class="props.selectedDocumentId === doc.id ? 'border-primary bg-primary/10' : ''"
|
||||
@click="props.onSelectedDocumentIdChange(doc.id)"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<p class="min-w-0 flex-1 truncate text-xs font-semibold">{{ doc.title }}</p>
|
||||
</div>
|
||||
<p class="mt-0.5 line-clamp-2 text-[11px] text-base-content/70">{{ doc.summary }}</p>
|
||||
<div class="mt-1 flex items-center justify-between gap-2">
|
||||
<p class="text-[10px] text-base-content/55">Updated {{ props.formatStamp(doc.updatedAt) }}</p>
|
||||
<button class="btn btn-ghost btn-xs px-1" @click.stop="props.onSelectedDocumentIdChange(doc.id); props.openDocumentsTab(true)">Open</button>
|
||||
</div>
|
||||
</article>
|
||||
<p v-if="props.filteredSelectedWorkspaceContactDocuments.length === 0" class="px-1 py-2 text-xs text-base-content/55">
|
||||
No linked documents.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="min-h-0 flex-1 space-y-3 overflow-y-auto pr-1">
|
||||
<div
|
||||
v-if="props.selectedWorkspaceContact"
|
||||
class="rounded-xl border border-base-300 bg-base-200/25 p-2.5"
|
||||
>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-base-content/60">Новая сделка</p>
|
||||
<input
|
||||
v-model="dealCreateTitleDraft"
|
||||
type="text"
|
||||
class="input input-bordered input-sm mt-2 w-full"
|
||||
:disabled="props.dealCreateLoading"
|
||||
placeholder="Название сделки"
|
||||
>
|
||||
|
||||
<div class="mt-2 grid grid-cols-2 gap-1.5">
|
||||
<div class="space-y-1">
|
||||
<p class="text-[10px] uppercase tracking-wide text-base-content/60">Статус</p>
|
||||
<select
|
||||
v-model="dealCreateStageDraft"
|
||||
class="select select-bordered select-xs w-full"
|
||||
:disabled="props.dealCreateLoading"
|
||||
>
|
||||
<option
|
||||
v-for="stageOption in visibleDealCreateStageOptions"
|
||||
:key="`create-deal-stage-${stageOption}`"
|
||||
:value="stageOption"
|
||||
>
|
||||
{{ stageOption }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<p class="text-[10px] uppercase tracking-wide text-base-content/60">Сумма</p>
|
||||
<input
|
||||
v-model="dealCreateAmountDraft"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
class="input input-bordered input-xs h-7 min-h-7 w-full"
|
||||
:disabled="props.dealCreateLoading"
|
||||
placeholder="0"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-1.5 space-y-1">
|
||||
<p class="text-[10px] uppercase tracking-wide text-base-content/60">Оплачено</p>
|
||||
<input
|
||||
v-model="dealCreatePaidAmountDraft"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
class="input input-bordered input-xs h-7 min-h-7 w-full"
|
||||
:disabled="props.dealCreateLoading"
|
||||
placeholder="0"
|
||||
>
|
||||
</div>
|
||||
|
||||
<p v-if="dealCreateError" class="mt-2 text-[10px] text-error">{{ dealCreateError }}</p>
|
||||
<p v-if="dealCreateSuccess" class="mt-2 text-[10px] text-success">{{ dealCreateSuccess }}</p>
|
||||
|
||||
<div class="mt-2 flex justify-end">
|
||||
<button
|
||||
class="btn btn-primary btn-xs h-7 min-h-7 px-2.5"
|
||||
:disabled="props.dealCreateLoading"
|
||||
@click="createDeal"
|
||||
>
|
||||
Создать сделку
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="props.selectedWorkspaceDeal"
|
||||
class="rounded-xl border border-base-300 bg-base-200/30 p-2.5"
|
||||
:class="[
|
||||
props.isReviewHighlightedDeal(props.selectedWorkspaceDeal.id) ? 'border-primary/60 bg-primary/10' : '',
|
||||
props.contextPickerEnabled ? 'context-scope-block context-scope-block-active' : '',
|
||||
props.hasContextScope('deal') ? 'context-scope-block-selected' : '',
|
||||
]"
|
||||
@click="props.toggleContextScope('deal')"
|
||||
>
|
||||
<span v-if="props.contextPickerEnabled" class="context-scope-label">Сделка</span>
|
||||
<p class="text-sm font-medium">
|
||||
{{ props.formatDealHeadline(props.selectedWorkspaceDeal) }}
|
||||
</p>
|
||||
<p class="mt-1 text-[11px] text-base-content/75">
|
||||
{{ props.selectedWorkspaceDealSubtitle }}
|
||||
</p>
|
||||
<div class="mt-2 space-y-2 rounded-lg border border-base-300/70 bg-base-100/75 p-2" @click.stop>
|
||||
<div class="space-y-1">
|
||||
<p class="text-[10px] uppercase tracking-wide text-base-content/60">Статус сделки</p>
|
||||
<select
|
||||
v-model="dealStageDraft"
|
||||
class="select select-bordered select-xs w-full"
|
||||
:disabled="props.dealUpdateLoading"
|
||||
>
|
||||
<option v-for="stageOption in visibleDealStageOptions" :key="`deal-stage-${stageOption}`" :value="stageOption">
|
||||
{{ stageOption }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1.5">
|
||||
<input
|
||||
v-model="dealNewStageDraft"
|
||||
type="text"
|
||||
class="input input-bordered input-xs h-7 min-h-7 flex-1"
|
||||
:disabled="props.dealUpdateLoading"
|
||||
placeholder="Добавить статус"
|
||||
@keydown.enter.prevent="applyNewDealStage"
|
||||
>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs h-7 min-h-7 px-2"
|
||||
:disabled="props.dealUpdateLoading"
|
||||
@click="applyNewDealStage"
|
||||
>
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-1.5">
|
||||
<div class="space-y-1">
|
||||
<p class="text-[10px] uppercase tracking-wide text-base-content/60">Сумма</p>
|
||||
<input
|
||||
v-model="dealAmountDraft"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
class="input input-bordered input-xs h-7 min-h-7 w-full"
|
||||
:disabled="props.dealUpdateLoading"
|
||||
placeholder="0"
|
||||
>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<p class="text-[10px] uppercase tracking-wide text-base-content/60">Оплачено</p>
|
||||
<input
|
||||
v-model="dealPaidAmountDraft"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
class="input input-bordered input-xs h-7 min-h-7 w-full"
|
||||
:disabled="props.dealUpdateLoading"
|
||||
placeholder="0"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="dealSaveError" class="text-[10px] text-error">{{ dealSaveError }}</p>
|
||||
<p v-if="dealSaveSuccess" class="text-[10px] text-success">{{ dealSaveSuccess }}</p>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
class="btn btn-primary btn-xs h-7 min-h-7 px-2.5"
|
||||
:disabled="props.dealUpdateLoading"
|
||||
@click="saveDealDetails"
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="props.selectedWorkspaceDealSteps.length"
|
||||
class="mt-2 text-[11px] font-medium text-primary hover:underline"
|
||||
@click="props.onSelectedDealStepsExpandedChange(!props.selectedDealStepsExpanded)"
|
||||
>
|
||||
{{ props.selectedDealStepsExpanded ? "Скрыть шаги" : `Показать шаги (${props.selectedWorkspaceDealSteps.length})` }}
|
||||
</button>
|
||||
<div v-if="props.selectedDealStepsExpanded && props.selectedWorkspaceDealSteps.length" class="mt-2 space-y-1.5">
|
||||
<div
|
||||
v-for="step in props.selectedWorkspaceDealSteps"
|
||||
:key="step.id"
|
||||
class="flex items-start gap-2 rounded-lg border border-base-300/70 bg-base-100/80 px-2 py-1.5"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-xs mt-0.5"
|
||||
:checked="props.isDealStepDone(step)"
|
||||
disabled
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-[11px] font-medium" :class="props.isDealStepDone(step) ? 'line-through text-base-content/60' : 'text-base-content/90'">
|
||||
{{ step.title }}
|
||||
</p>
|
||||
<p class="mt-0.5 text-[10px] text-base-content/55">{{ props.formatDealStepMeta(step) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative"
|
||||
:class="[
|
||||
props.contextPickerEnabled ? 'context-scope-block context-scope-block-active' : '',
|
||||
props.hasContextScope('summary') ? 'context-scope-block-selected' : '',
|
||||
]"
|
||||
@click="props.toggleContextScope('summary')"
|
||||
>
|
||||
<span v-if="props.contextPickerEnabled" class="context-scope-label">Summary</span>
|
||||
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-base-content/60">Summary</p>
|
||||
<div
|
||||
v-if="props.activeReviewContactDiff && props.selectedWorkspaceContact && props.activeReviewContactDiff.contactId === props.selectedWorkspaceContact.id"
|
||||
class="mb-2 rounded-xl border border-primary/35 bg-primary/5 p-2"
|
||||
>
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wide text-primary/80">Review diff</p>
|
||||
<p class="mt-1 text-[11px] text-base-content/65">Before</p>
|
||||
<pre class="mt-1 whitespace-pre-wrap rounded-lg border border-base-300/70 bg-base-100 px-2 py-1.5 text-[11px] leading-relaxed text-base-content/65 line-through">{{ props.activeReviewContactDiff.before || "Empty" }}</pre>
|
||||
<p class="mt-2 text-[11px] text-base-content/65">After</p>
|
||||
<pre class="mt-1 whitespace-pre-wrap rounded-lg border border-success/40 bg-success/10 px-2 py-1.5 text-[11px] leading-relaxed text-base-content">{{ props.activeReviewContactDiff.after || "Empty" }}</pre>
|
||||
</div>
|
||||
<ContactCollaborativeEditor
|
||||
v-if="props.selectedWorkspaceContact"
|
||||
:key="`contact-summary-${props.selectedWorkspaceContact.id}`"
|
||||
v-model="props.selectedWorkspaceContact.description"
|
||||
:room="`crm-contact-${props.selectedWorkspaceContact.id}`"
|
||||
placeholder="Contact summary..."
|
||||
:plain="true"
|
||||
/>
|
||||
<p v-else class="text-xs text-base-content/60">No contact selected.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.context-scope-block {
|
||||
position: relative;
|
||||
border-radius: 16px;
|
||||
outline: 1px solid color-mix(in oklab, var(--color-base-content) 14%, transparent);
|
||||
transition: outline-color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.context-scope-block-active {
|
||||
outline-color: color-mix(in oklab, var(--color-primary) 52%, transparent);
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in oklab, var(--color-primary) 30%, transparent) inset,
|
||||
0 0 0 3px color-mix(in oklab, var(--color-primary) 12%, transparent);
|
||||
}
|
||||
|
||||
.context-scope-block-selected {
|
||||
outline-color: color-mix(in oklab, var(--color-primary) 70%, transparent);
|
||||
box-shadow: 0 0 0 1px color-mix(in oklab, var(--color-primary) 40%, transparent) inset;
|
||||
}
|
||||
|
||||
.context-scope-label {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: color-mix(in oklab, var(--color-primary) 18%, var(--color-base-100));
|
||||
color: color-mix(in oklab, var(--color-primary-content) 72%, var(--color-base-content));
|
||||
border: 1px solid color-mix(in oklab, var(--color-primary) 42%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,185 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
type PeopleListMode = "contacts" | "deals";
|
||||
|
||||
defineProps<{
|
||||
peopleListMode: PeopleListMode;
|
||||
peopleSearch: string;
|
||||
peopleSortOptions: Array<{ value: string; label: string }>;
|
||||
peopleSortMode: string;
|
||||
peopleVisibilityOptions: Array<{ value: string; label: string }>;
|
||||
peopleVisibilityMode: string;
|
||||
peopleContactList: any[];
|
||||
selectedCommThreadId: string;
|
||||
isReviewHighlightedContact: (contactId: string) => boolean;
|
||||
openCommunicationThread: (contactName: string) => void;
|
||||
avatarSrcForThread: (thread: any) => string;
|
||||
markAvatarBroken: (threadId: string) => void;
|
||||
contactInitials: (contactName: string) => string;
|
||||
formatThreadTime: (iso: string) => string;
|
||||
threadChannelLabel: (thread: any) => string;
|
||||
peopleDealList: any[];
|
||||
selectedDealId: string;
|
||||
isReviewHighlightedDeal: (dealId: string) => boolean;
|
||||
openDealThread: (deal: any) => void;
|
||||
getDealCurrentStepLabel: (deal: any) => string;
|
||||
onPeopleListModeChange: (mode: PeopleListMode) => void;
|
||||
onPeopleSearchInput: (value: string) => void;
|
||||
onPeopleSortModeChange: (mode: string) => void;
|
||||
onPeopleVisibilityModeChange: (mode: string) => void;
|
||||
}>();
|
||||
|
||||
function onSearchInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement | null;
|
||||
onPeopleSearchInput(target?.value ?? "");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="h-full min-h-0 border-r border-base-300 flex flex-col md:row-span-2">
|
||||
<div class="sticky top-0 z-20 h-12 border-b border-base-300 bg-base-100 px-2">
|
||||
<div class="flex h-full items-center gap-1">
|
||||
<div class="join rounded-lg border border-base-300 overflow-hidden">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm join-item rounded-none"
|
||||
:class="peopleListMode === 'contacts' ? 'bg-base-200/80 text-base-content' : 'text-base-content/65 hover:text-base-content'"
|
||||
title="Contacts"
|
||||
@click="onPeopleListModeChange('contacts')"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" class="h-4 w-4 fill-current">
|
||||
<path d="M12 12a5 5 0 1 0-5-5 5 5 0 0 0 5 5m0 2c-4.42 0-8 2.24-8 5v1h16v-1c0-2.76-3.58-5-8-5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm join-item rounded-none border-l border-base-300/70"
|
||||
:class="peopleListMode === 'deals' ? 'bg-base-200/80 text-base-content' : 'text-base-content/65 hover:text-base-content'"
|
||||
title="Deals"
|
||||
@click="onPeopleListModeChange('deals')"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" class="h-4 w-4 fill-current">
|
||||
<path d="M10 3h4a2 2 0 0 1 2 2v2h3a2 2 0 0 1 2 2v3H3V9a2 2 0 0 1 2-2h3V5a2 2 0 0 1 2-2m0 4h4V5h-4zm11 7v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-5h7v2h4v-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
:value="peopleSearch"
|
||||
type="text"
|
||||
class="input input-bordered input-sm w-full"
|
||||
:placeholder="peopleListMode === 'contacts' ? 'Search contacts' : 'Search deals'"
|
||||
@input="onSearchInput"
|
||||
/>
|
||||
|
||||
<div class="dropdown dropdown-end">
|
||||
<button
|
||||
tabindex="0"
|
||||
class="btn btn-ghost btn-sm btn-square"
|
||||
:title="peopleListMode === 'contacts' ? 'Sort contacts' : 'Sort deals'"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" class="h-4 w-4 fill-current">
|
||||
<path d="M3 5h18v2H3zm3 6h12v2H6zm4 6h4v2h-4z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div tabindex="0" class="dropdown-content z-20 mt-2 w-52 rounded-xl border border-base-300 bg-base-100 p-2 shadow-lg">
|
||||
<template v-if="peopleListMode === 'contacts'">
|
||||
<p class="px-2 pb-1 text-[11px] font-semibold uppercase tracking-wide text-base-content/55">Sort contacts</p>
|
||||
<button
|
||||
v-for="option in peopleSortOptions"
|
||||
:key="`people-sort-${option.value}`"
|
||||
class="btn btn-ghost btn-sm w-full justify-between"
|
||||
@click="onPeopleSortModeChange(option.value)"
|
||||
>
|
||||
<span>{{ option.label }}</span>
|
||||
<span v-if="peopleSortMode === option.value">✓</span>
|
||||
</button>
|
||||
<div class="my-1 h-px bg-base-300/70" />
|
||||
<p class="px-2 pb-1 text-[11px] font-semibold uppercase tracking-wide text-base-content/55">Filter contacts</p>
|
||||
<button
|
||||
v-for="option in peopleVisibilityOptions"
|
||||
:key="`people-visibility-${option.value}`"
|
||||
class="btn btn-ghost btn-sm w-full justify-between"
|
||||
@click="onPeopleVisibilityModeChange(option.value)"
|
||||
>
|
||||
<span>{{ option.label }}</span>
|
||||
<span v-if="peopleVisibilityMode === option.value">✓</span>
|
||||
</button>
|
||||
</template>
|
||||
<p v-else class="px-2 py-1 text-xs text-base-content/60">Deals are sorted by title.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-0">
|
||||
<div
|
||||
v-if="peopleListMode === 'contacts'"
|
||||
v-for="thread in peopleContactList"
|
||||
:key="thread.id"
|
||||
class="w-full border-b border-base-300 px-3 py-2 text-left transition hover:bg-base-200/40"
|
||||
:class="[
|
||||
selectedCommThreadId === thread.id ? 'bg-primary/10' : '',
|
||||
isReviewHighlightedContact(thread.id) ? 'bg-primary/10 ring-1 ring-primary/45' : '',
|
||||
]"
|
||||
@click="openCommunicationThread(thread.contact)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@keydown.enter.prevent="openCommunicationThread(thread.contact)"
|
||||
@keydown.space.prevent="openCommunicationThread(thread.contact)"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="avatar shrink-0">
|
||||
<div class="h-8 w-8 rounded-full ring-1 ring-base-300/70">
|
||||
<img
|
||||
v-if="avatarSrcForThread(thread)"
|
||||
:src="avatarSrcForThread(thread)"
|
||||
:alt="thread.contact"
|
||||
@error="markAvatarBroken(thread.id)"
|
||||
/>
|
||||
<span v-else class="flex h-full w-full items-center justify-center text-[10px] font-semibold text-base-content/65">
|
||||
{{ contactInitials(thread.contact) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<span v-if="thread.hasUnread" class="h-2 w-2 shrink-0 rounded-full bg-primary" />
|
||||
<p class="min-w-0 flex-1 truncate text-xs" :class="thread.hasUnread ? 'font-bold' : 'font-semibold'">{{ thread.contact }}</p>
|
||||
</div>
|
||||
<span class="shrink-0 text-[10px]" :class="thread.hasUnread ? 'font-semibold text-primary' : 'text-base-content/55'">{{ formatThreadTime(thread.lastAt) }}</span>
|
||||
</div>
|
||||
<p class="mt-0.5 min-w-0 truncate text-[11px]" :class="thread.hasUnread ? 'font-semibold text-base-content' : 'text-base-content/75'">
|
||||
{{ thread.lastText || threadChannelLabel(thread) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="peopleListMode === 'deals'"
|
||||
v-for="deal in peopleDealList"
|
||||
:key="deal.id"
|
||||
class="w-full border-b border-base-300 px-3 py-2 text-left transition hover:bg-base-200/40"
|
||||
:class="[
|
||||
selectedDealId === deal.id ? 'bg-primary/10' : '',
|
||||
isReviewHighlightedDeal(deal.id) ? 'bg-primary/10 ring-1 ring-primary/45' : '',
|
||||
]"
|
||||
@click="openDealThread(deal)"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<p class="truncate text-xs font-semibold">{{ deal.title }}</p>
|
||||
<span class="shrink-0 text-[10px] text-base-content/55">{{ deal.amount }}</span>
|
||||
</div>
|
||||
<p class="mt-0.5 truncate text-[11px] text-base-content/75">{{ deal.stage }}</p>
|
||||
<p class="mt-0.5 truncate text-[11px] text-base-content/60">{{ getDealCurrentStepLabel(deal) }}</p>
|
||||
</button>
|
||||
|
||||
<p v-if="peopleListMode === 'contacts' && peopleContactList.length === 0" class="px-1 py-2 text-xs text-base-content/55">
|
||||
{{ peopleVisibilityMode === 'hidden' ? 'No hidden contacts found.' : 'No contacts found.' }}
|
||||
</p>
|
||||
<p v-if="peopleListMode === 'deals' && peopleDealList.length === 0" class="px-1 py-2 text-xs text-base-content/55">
|
||||
No deals found.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -1,168 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from "vue";
|
||||
import { isVoiceCaptureSupported, transcribeAudioBlob } from "~~/app/composables/useVoiceTranscription";
|
||||
|
||||
const props = defineProps<{
|
||||
disabled?: boolean;
|
||||
sessionKey?: string;
|
||||
idleTitle?: string;
|
||||
recordingTitle?: string;
|
||||
transcribingTitle?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:recording", value: boolean): void;
|
||||
(e: "update:transcribing", value: boolean): void;
|
||||
(e: "transcript", value: string): void;
|
||||
(e: "error", value: string): void;
|
||||
}>();
|
||||
|
||||
const recording = ref(false);
|
||||
const transcribing = ref(false);
|
||||
let mediaRecorder: MediaRecorder | null = null;
|
||||
let recorderStream: MediaStream | null = null;
|
||||
let recorderMimeType = "audio/webm";
|
||||
let recordingChunks: Blob[] = [];
|
||||
let discardOnStop = false;
|
||||
|
||||
function setRecording(value: boolean) {
|
||||
recording.value = value;
|
||||
emit("update:recording", value);
|
||||
}
|
||||
|
||||
function setTranscribing(value: boolean) {
|
||||
transcribing.value = value;
|
||||
emit("update:transcribing", value);
|
||||
}
|
||||
|
||||
function clearRecorderResources() {
|
||||
if (recorderStream) {
|
||||
recorderStream.getTracks().forEach((track) => track.stop());
|
||||
recorderStream = null;
|
||||
}
|
||||
mediaRecorder = null;
|
||||
recordingChunks = [];
|
||||
discardOnStop = false;
|
||||
}
|
||||
|
||||
async function startRecording() {
|
||||
if (recording.value || transcribing.value) return;
|
||||
emit("error", "");
|
||||
|
||||
if (!isVoiceCaptureSupported()) {
|
||||
emit("error", "Recording is not supported in this browser");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const preferredMime = "audio/webm;codecs=opus";
|
||||
const recorder = MediaRecorder.isTypeSupported(preferredMime)
|
||||
? new MediaRecorder(stream, { mimeType: preferredMime })
|
||||
: new MediaRecorder(stream);
|
||||
|
||||
recorderStream = stream;
|
||||
recorderMimeType = recorder.mimeType || "audio/webm";
|
||||
mediaRecorder = recorder;
|
||||
recordingChunks = [];
|
||||
discardOnStop = false;
|
||||
setRecording(true);
|
||||
|
||||
recorder.ondataavailable = (event: BlobEvent) => {
|
||||
if (event.data?.size) recordingChunks.push(event.data);
|
||||
};
|
||||
|
||||
recorder.onstop = async () => {
|
||||
const discard = discardOnStop;
|
||||
const audioBlob = new Blob(recordingChunks, { type: recorderMimeType });
|
||||
|
||||
setRecording(false);
|
||||
clearRecorderResources();
|
||||
if (discard || audioBlob.size === 0) return;
|
||||
|
||||
setTranscribing(true);
|
||||
try {
|
||||
const text = await transcribeAudioBlob(audioBlob);
|
||||
if (!text) {
|
||||
emit("error", "Could not recognize speech");
|
||||
return;
|
||||
}
|
||||
emit("error", "");
|
||||
emit("transcript", text);
|
||||
} catch (error: any) {
|
||||
emit("error", String(error?.data?.message ?? error?.message ?? "Voice transcription failed"));
|
||||
} finally {
|
||||
setTranscribing(false);
|
||||
}
|
||||
};
|
||||
|
||||
recorder.start();
|
||||
} catch {
|
||||
setRecording(false);
|
||||
clearRecorderResources();
|
||||
emit("error", "No microphone access");
|
||||
}
|
||||
}
|
||||
|
||||
function stopRecording(discard = false) {
|
||||
if (!mediaRecorder || mediaRecorder.state === "inactive") {
|
||||
setRecording(false);
|
||||
clearRecorderResources();
|
||||
return;
|
||||
}
|
||||
discardOnStop = discard;
|
||||
mediaRecorder.stop();
|
||||
}
|
||||
|
||||
function toggleRecording() {
|
||||
if (props.disabled || transcribing.value) return;
|
||||
if (recording.value) {
|
||||
stopRecording();
|
||||
return;
|
||||
}
|
||||
void startRecording();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.sessionKey,
|
||||
() => {
|
||||
if (recording.value) stopRecording(true);
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.disabled,
|
||||
(disabled) => {
|
||||
if (disabled && recording.value) stopRecording(true);
|
||||
},
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (recording.value) {
|
||||
stopRecording(true);
|
||||
return;
|
||||
}
|
||||
clearRecorderResources();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="Boolean(props.disabled) || transcribing"
|
||||
:title="
|
||||
recording
|
||||
? (props.recordingTitle || 'Stop and insert transcript')
|
||||
: transcribing
|
||||
? (props.transcribingTitle || 'Transcribing...')
|
||||
: (props.idleTitle || 'Voice input')
|
||||
"
|
||||
@click="toggleRecording"
|
||||
>
|
||||
<slot :recording="recording" :transcribing="transcribing">
|
||||
<svg viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
|
||||
<path d="M12 15a3 3 0 0 0 3-3V7a3 3 0 1 0-6 0v5a3 3 0 0 0 3 3m5-3a1 1 0 1 1 2 0 7 7 0 0 1-6 6.92V21h3a1 1 0 1 1 0 2H8a1 1 0 1 1 0-2h3v-2.08A7 7 0 0 1 5 12a1 1 0 1 1 2 0 5 5 0 0 0 10 0" />
|
||||
</svg>
|
||||
</slot>
|
||||
</button>
|
||||
</template>
|
||||
@@ -1,211 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import MarkdownRichEditor from "~~/app/components/workspace/documents/MarkdownRichEditor.client.vue";
|
||||
|
||||
type DocumentSortOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type DocumentListItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
scope: string;
|
||||
summary: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type SelectedDocument = {
|
||||
id: string;
|
||||
title: string;
|
||||
scope: string;
|
||||
owner: string;
|
||||
summary: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
documentSearch: string;
|
||||
documentSortMode: string;
|
||||
documentSortOptions: DocumentSortOption[];
|
||||
filteredDocuments: DocumentListItem[];
|
||||
selectedDocumentId: string;
|
||||
selectedDocument: SelectedDocument | null;
|
||||
formatDocumentScope: (scope: string) => string;
|
||||
formatStamp: (iso: string) => string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:documentSearch", value: string): void;
|
||||
(e: "update:documentSortMode", value: string): void;
|
||||
(e: "select-document", documentId: string): void;
|
||||
(e: "update-selected-document-body", value: string): void;
|
||||
(e: "delete-document", documentId: string): void;
|
||||
}>();
|
||||
|
||||
const documentContextMenu = ref<{
|
||||
open: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
documentId: string;
|
||||
}>({
|
||||
open: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
documentId: "",
|
||||
});
|
||||
|
||||
function closeDocumentContextMenu() {
|
||||
if (!documentContextMenu.value.open) return;
|
||||
documentContextMenu.value = {
|
||||
open: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
documentId: "",
|
||||
};
|
||||
}
|
||||
|
||||
function openDocumentContextMenu(event: MouseEvent, doc: DocumentListItem) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
emit("select-document", doc.id);
|
||||
const padding = 8;
|
||||
const menuWidth = 176;
|
||||
const menuHeight = 44;
|
||||
const maxX = Math.max(padding, window.innerWidth - menuWidth - padding);
|
||||
const maxY = Math.max(padding, window.innerHeight - menuHeight - padding);
|
||||
documentContextMenu.value = {
|
||||
open: true,
|
||||
x: Math.min(maxX, Math.max(padding, event.clientX)),
|
||||
y: Math.min(maxY, Math.max(padding, event.clientY)),
|
||||
documentId: doc.id,
|
||||
};
|
||||
}
|
||||
|
||||
function deleteDocumentFromContextMenu() {
|
||||
const documentId = documentContextMenu.value.documentId;
|
||||
if (!documentId) return;
|
||||
emit("delete-document", documentId);
|
||||
closeDocumentContextMenu();
|
||||
}
|
||||
|
||||
function onWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
closeDocumentContextMenu();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("keydown", onWindowKeydown);
|
||||
window.addEventListener("scroll", closeDocumentContextMenu, true);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("keydown", onWindowKeydown);
|
||||
window.removeEventListener("scroll", closeDocumentContextMenu, true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex h-full min-h-0 flex-col gap-0" @click="closeDocumentContextMenu">
|
||||
<div class="grid h-full min-h-0 flex-1 gap-0 md:grid-cols-[248px_minmax(0,1fr)]">
|
||||
<aside class="h-full min-h-0 border-r border-base-300 flex flex-col">
|
||||
<div class="sticky top-0 z-20 border-b border-base-300 bg-base-100 p-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<input
|
||||
:value="props.documentSearch"
|
||||
type="text"
|
||||
class="input input-bordered input-sm w-full"
|
||||
placeholder="Search documents"
|
||||
@input="emit('update:documentSearch', ($event.target as HTMLInputElement).value)"
|
||||
>
|
||||
|
||||
<div class="dropdown dropdown-end">
|
||||
<button
|
||||
tabindex="0"
|
||||
class="btn btn-ghost btn-sm btn-square"
|
||||
title="Sort documents"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" class="h-4 w-4 fill-current">
|
||||
<path d="M3 5h18v2H3zm3 6h12v2H6zm4 6h4v2h-4z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div tabindex="0" class="dropdown-content z-20 mt-2 w-44 rounded-xl border border-base-300 bg-base-100 p-2 shadow-lg">
|
||||
<p class="px-2 pb-1 text-[11px] font-semibold uppercase tracking-wide text-base-content/55">Sort docs</p>
|
||||
<button
|
||||
v-for="option in props.documentSortOptions"
|
||||
:key="`document-sort-${option.value}`"
|
||||
class="btn btn-ghost btn-sm w-full justify-between"
|
||||
@click="emit('update:documentSortMode', option.value)"
|
||||
>
|
||||
<span>{{ option.label }}</span>
|
||||
<span v-if="props.documentSortMode === option.value">✓</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-0">
|
||||
<button
|
||||
v-for="doc in props.filteredDocuments"
|
||||
:key="doc.id"
|
||||
class="w-full border-b border-base-300 px-3 py-2 text-left transition hover:bg-base-200/40"
|
||||
:class="props.selectedDocumentId === doc.id ? 'bg-primary/10' : ''"
|
||||
@click="emit('select-document', doc.id)"
|
||||
@contextmenu="openDocumentContextMenu($event, doc)"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<p class="min-w-0 flex-1 truncate text-xs font-semibold">{{ doc.title }}</p>
|
||||
</div>
|
||||
<p class="mt-0.5 truncate text-[11px] text-base-content/75">{{ props.formatDocumentScope(doc.scope) }}</p>
|
||||
<p class="mt-0.5 line-clamp-2 text-[11px] text-base-content/70">{{ doc.summary }}</p>
|
||||
<p class="mt-1 text-[10px] text-base-content/55">Updated {{ props.formatStamp(doc.updatedAt) }}</p>
|
||||
</button>
|
||||
<p v-if="props.filteredDocuments.length === 0" class="px-2 py-2 text-xs text-base-content/55">
|
||||
No documents found.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<article class="h-full min-h-0 flex flex-col">
|
||||
<div v-if="props.selectedDocument" class="flex h-full min-h-0 flex-col p-3 md:p-4">
|
||||
<div class="border-b border-base-300 pb-2">
|
||||
<p class="font-medium">{{ props.selectedDocument.title }}</p>
|
||||
<p class="text-xs text-base-content/60">
|
||||
{{ props.formatDocumentScope(props.selectedDocument.scope) }} · {{ props.selectedDocument.owner }}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-base-content/80">{{ props.selectedDocument.summary }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 min-h-0 flex-1 overflow-y-auto">
|
||||
<MarkdownRichEditor
|
||||
:key="`doc-editor-${props.selectedDocument.id}`"
|
||||
:model-value="props.selectedDocument.body"
|
||||
placeholder="Describe policy, steps, rules, and exceptions..."
|
||||
@update:model-value="emit('update-selected-document-body', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex h-full items-center justify-center text-sm text-base-content/60">
|
||||
No document selected.
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="documentContextMenu.open"
|
||||
class="fixed z-50 w-44 rounded-lg border border-base-300 bg-base-100 p-1 shadow-xl"
|
||||
:style="{ left: `${documentContextMenu.x}px`, top: `${documentContextMenu.y}px` }"
|
||||
@click.stop
|
||||
>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm w-full justify-start text-error"
|
||||
@click="deleteDocumentFromContextMenu"
|
||||
>
|
||||
Delete document
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,106 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
|
||||
type ToastUiEditor = {
|
||||
destroy: () => void;
|
||||
getMarkdown: () => string;
|
||||
getHTML: () => string;
|
||||
setMarkdown: (markdown: string, cursorToEnd?: boolean) => void;
|
||||
setHTML: (html: string, cursorToEnd?: boolean) => void;
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
placeholder?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "update:modelValue", value: string): void;
|
||||
}>();
|
||||
|
||||
const mountEl = ref<HTMLDivElement | null>(null);
|
||||
const editor = ref<ToastUiEditor | null>(null);
|
||||
const isSyncing = ref(false);
|
||||
|
||||
function looksLikeHtml(value: string) {
|
||||
return /<([a-z][\w-]*)\b[^>]*>/i.test(value);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!mountEl.value) return;
|
||||
const [{ default: Editor }] = await Promise.all([
|
||||
import("@toast-ui/editor"),
|
||||
import("@toast-ui/editor/dist/toastui-editor.css"),
|
||||
]);
|
||||
|
||||
const initialValue = String(props.modelValue ?? "");
|
||||
const instance = new Editor({
|
||||
el: mountEl.value,
|
||||
initialEditType: "wysiwyg",
|
||||
previewStyle: "tab",
|
||||
initialValue: looksLikeHtml(initialValue) ? "" : initialValue,
|
||||
placeholder: props.placeholder ?? "Write with Markdown...",
|
||||
height: "520px",
|
||||
hideModeSwitch: true,
|
||||
usageStatistics: false,
|
||||
events: {
|
||||
change: () => {
|
||||
if (isSyncing.value) return;
|
||||
emit("update:modelValue", instance.getMarkdown());
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (looksLikeHtml(initialValue)) {
|
||||
isSyncing.value = true;
|
||||
instance.setHTML(initialValue, false);
|
||||
emit("update:modelValue", instance.getMarkdown());
|
||||
isSyncing.value = false;
|
||||
}
|
||||
|
||||
editor.value = instance as unknown as ToastUiEditor;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(incoming) => {
|
||||
const instance = editor.value;
|
||||
if (!instance || isSyncing.value) return;
|
||||
const next = String(incoming ?? "");
|
||||
const currentMarkdown = instance.getMarkdown();
|
||||
const currentHtml = instance.getHTML();
|
||||
if (next === currentMarkdown || next === currentHtml) return;
|
||||
|
||||
isSyncing.value = true;
|
||||
if (looksLikeHtml(next)) {
|
||||
instance.setHTML(next, false);
|
||||
emit("update:modelValue", instance.getMarkdown());
|
||||
} else {
|
||||
instance.setMarkdown(next, false);
|
||||
}
|
||||
isSyncing.value = false;
|
||||
},
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editor.value?.destroy();
|
||||
editor.value = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="markdown-rich-editor min-h-[420px]">
|
||||
<div ref="mountEl" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.markdown-rich-editor :deep(.toastui-editor-defaultUI) {
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid color-mix(in oklab, var(--color-base-content) 14%, transparent);
|
||||
}
|
||||
|
||||
.markdown-rich-editor :deep(.toastui-editor-main-container) {
|
||||
min-height: 360px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,100 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
selectedTab: "communications" | "documents";
|
||||
peopleLeftMode: "contacts" | "calendar";
|
||||
authInitials: string;
|
||||
authDisplayName: string;
|
||||
telegramStatusBadgeClass: string;
|
||||
telegramStatusLabel: string;
|
||||
telegramConnectBusy: boolean;
|
||||
telegramConnectNotice: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "open-contacts"): void;
|
||||
(e: "open-calendar"): void;
|
||||
(e: "open-documents"): void;
|
||||
(e: "start-telegram-connect"): void;
|
||||
(e: "logout"): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="workspace-topbar border-b border-base-300 px-3 py-2 md:px-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="join">
|
||||
<button
|
||||
class="btn btn-sm join-item"
|
||||
:class="
|
||||
props.selectedTab === 'communications' && props.peopleLeftMode === 'contacts'
|
||||
? 'btn-ghost border border-base-300 bg-base-200/70 text-base-content'
|
||||
: 'btn-ghost border border-transparent text-base-content/65 hover:border-base-300/70 hover:text-base-content'
|
||||
"
|
||||
@click="emit('open-contacts')"
|
||||
>
|
||||
Contacts
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm join-item"
|
||||
:class="
|
||||
props.selectedTab === 'communications' && props.peopleLeftMode === 'calendar'
|
||||
? 'btn-ghost border border-base-300 bg-base-200/70 text-base-content'
|
||||
: 'btn-ghost border border-transparent text-base-content/65 hover:border-base-300/70 hover:text-base-content'
|
||||
"
|
||||
@click="emit('open-calendar')"
|
||||
>
|
||||
Calendar
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm join-item"
|
||||
:class="
|
||||
props.selectedTab === 'documents'
|
||||
? 'btn-ghost border border-base-300 bg-base-200/70 text-base-content'
|
||||
: 'btn-ghost border border-transparent text-base-content/65 hover:border-base-300/70 hover:text-base-content'
|
||||
"
|
||||
@click="emit('open-documents')"
|
||||
>
|
||||
Documents
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="dropdown dropdown-end">
|
||||
<button tabindex="0" class="btn btn-sm btn-ghost gap-2">
|
||||
<div class="avatar placeholder">
|
||||
<div class="flex h-7 w-7 items-center justify-center rounded-full bg-primary text-primary-content">
|
||||
<span class="text-[11px] font-semibold leading-none">{{ props.authInitials }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="max-w-[160px] truncate text-xs font-medium">{{ props.authDisplayName }}</span>
|
||||
</button>
|
||||
<div tabindex="0" class="dropdown-content z-30 mt-2 w-80 rounded-box border border-base-300 bg-base-100 p-3 shadow-lg">
|
||||
<div class="mb-2 border-b border-base-300 pb-2">
|
||||
<p class="truncate text-sm font-semibold">{{ props.authDisplayName }}</p>
|
||||
<p class="text-[11px] uppercase tracking-wide text-base-content/60">Settings</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 rounded-lg border border-base-300 bg-base-50/40 p-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-xs font-medium">Telegram Business</span>
|
||||
<span class="badge badge-xs" :class="props.telegramStatusBadgeClass">{{ props.telegramStatusLabel }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-xs btn-primary w-full"
|
||||
:disabled="props.telegramConnectBusy"
|
||||
@click="emit('start-telegram-connect')"
|
||||
>
|
||||
{{ props.telegramConnectBusy ? "Connecting..." : "Connect Telegram" }}
|
||||
</button>
|
||||
<p v-if="props.telegramConnectNotice" class="text-[11px] leading-snug text-base-content/70">
|
||||
{{ props.telegramConnectNotice }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 border-t border-base-300 pt-2">
|
||||
<button class="btn btn-sm w-full btn-ghost justify-start" @click="emit('logout')">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,599 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
type ChatConversation = {
|
||||
id: string;
|
||||
title: string;
|
||||
lastMessageText?: string | null;
|
||||
};
|
||||
|
||||
type PilotMessage = {
|
||||
id: string;
|
||||
role: "user" | "assistant" | "system";
|
||||
text: string;
|
||||
messageKind?: string | null;
|
||||
changeSummary?: string | null;
|
||||
changeItems?: Array<{ id: string; entity: string; action: string; title: string }> | null;
|
||||
changeSetId?: string | null;
|
||||
changeStatus?: string | null;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
type ContextScopeChip = {
|
||||
scope: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
defineProps<{
|
||||
pilotHeaderText: string;
|
||||
chatSwitching: boolean;
|
||||
chatThreadsLoading: boolean;
|
||||
chatConversations: ChatConversation[];
|
||||
authMe: { conversation?: { title?: string | null } | null } | null;
|
||||
chatCreating: boolean;
|
||||
renderedPilotMessages: PilotMessage[];
|
||||
pilotLiveLogs: Array<{ id: string; text: string }>;
|
||||
pilotLiveLogsExpanded: boolean;
|
||||
pilotLiveLogHiddenCount: number;
|
||||
pilotVisibleLogCount: number;
|
||||
pilotVisibleLiveLogs: Array<{ id: string; text: string }>;
|
||||
chatThreadPickerOpen: boolean;
|
||||
selectedChatId: string;
|
||||
chatArchivingId: string;
|
||||
pilotInput: string;
|
||||
pilotRecording: boolean;
|
||||
contextScopeChips: ContextScopeChip[];
|
||||
contextPickerEnabled: boolean;
|
||||
pilotTranscribing: boolean;
|
||||
pilotSending: boolean;
|
||||
pilotMicSupported: boolean;
|
||||
pilotMicError: string | null;
|
||||
toggleChatThreadPicker: () => void;
|
||||
createNewChatConversation: () => void;
|
||||
pilotRoleBadge: (role: PilotMessage["role"]) => string;
|
||||
pilotRoleName: (role: PilotMessage["role"]) => string;
|
||||
formatPilotStamp: (iso?: string) => string;
|
||||
summarizeChangeActions: (items: PilotMessage["changeItems"] | null | undefined) => {
|
||||
created: number;
|
||||
updated: number;
|
||||
deleted: number;
|
||||
};
|
||||
summarizeChangeEntities: (
|
||||
items: PilotMessage["changeItems"] | null | undefined,
|
||||
) => Array<{ entity: string; count: number }>;
|
||||
openChangeReview: (changeSetId: string, step?: number, push?: boolean) => void;
|
||||
togglePilotLiveLogsExpanded: () => void;
|
||||
closeChatThreadPicker: () => void;
|
||||
switchChatConversation: (id: string) => void;
|
||||
formatChatThreadMeta: (conversation: ChatConversation) => string;
|
||||
archiveChatConversation: (id: string) => void;
|
||||
handlePilotComposerEnter: (event: KeyboardEvent) => void;
|
||||
onPilotInput: (value: string) => void;
|
||||
setPilotWaveContainerRef: (element: HTMLDivElement | null) => void;
|
||||
toggleContextPicker: () => void;
|
||||
removeContextScope: (scope: string) => void;
|
||||
togglePilotRecording: () => void;
|
||||
handlePilotSendAction: () => void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="pilot-shell min-h-0 border-r border-base-300">
|
||||
<div class="flex h-full min-h-0 flex-col p-0">
|
||||
<div class="pilot-header">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-white/75">{{ pilotHeaderText }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pilot-threads">
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
<button
|
||||
class="btn btn-ghost btn-xs h-7 min-h-7 max-w-[228px] justify-start px-1 text-xs font-medium text-white/90 hover:bg-white/10"
|
||||
:disabled="chatSwitching || chatThreadsLoading || chatConversations.length === 0"
|
||||
:title="authMe?.conversation?.title || 'Thread'"
|
||||
@click="toggleChatThreadPicker"
|
||||
>
|
||||
<span class="truncate">{{ authMe?.conversation?.title || "Thread" }}</span>
|
||||
<svg viewBox="0 0 20 20" class="ml-1 h-3.5 w-3.5 fill-current opacity-80">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs h-7 min-h-7 px-1 text-white/85 hover:bg-white/10"
|
||||
:disabled="chatCreating"
|
||||
title="New chat"
|
||||
@click="createNewChatConversation"
|
||||
>
|
||||
{{ chatCreating ? "…" : "+" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pilot-stream-wrap min-h-0 flex-1">
|
||||
<div class="pilot-timeline min-h-0 h-full overflow-y-auto">
|
||||
<div
|
||||
v-for="message in renderedPilotMessages"
|
||||
:key="message.id"
|
||||
class="pilot-row"
|
||||
>
|
||||
<div class="pilot-avatar" :class="message.role === 'user' ? 'pilot-avatar-user' : ''">
|
||||
{{ pilotRoleBadge(message.role) }}
|
||||
</div>
|
||||
|
||||
<div class="pilot-body">
|
||||
<div class="pilot-meta">
|
||||
<span class="pilot-author">{{ pilotRoleName(message.role) }}</span>
|
||||
<span class="pilot-time">{{ formatPilotStamp(message.createdAt) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="message.messageKind === 'change_set_summary'" class="rounded-xl border border-amber-300/35 bg-amber-500/10 p-3">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="text-xs font-semibold text-amber-100">
|
||||
{{ message.changeItems?.length || 0 }} changes
|
||||
</p>
|
||||
<button
|
||||
v-if="message.changeSetId"
|
||||
class="btn btn-xs btn-outline"
|
||||
@click="openChangeReview(message.changeSetId, 0, true)"
|
||||
>
|
||||
View changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="pilot-message-text">
|
||||
{{ message.text }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="pilotLiveLogs.length" class="pilot-stream-status">
|
||||
<div class="pilot-stream-head">
|
||||
<p v-if="!pilotLiveLogsExpanded && pilotLiveLogHiddenCount > 0" class="pilot-stream-caption">
|
||||
Showing last {{ pilotVisibleLogCount }} steps
|
||||
</p>
|
||||
<button
|
||||
v-if="pilotLiveLogHiddenCount > 0 || pilotLiveLogsExpanded"
|
||||
type="button"
|
||||
class="pilot-stream-toggle"
|
||||
@click="togglePilotLiveLogsExpanded"
|
||||
>
|
||||
{{ pilotLiveLogsExpanded ? "Show less" : `Show all (+${pilotLiveLogHiddenCount})` }}
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
v-for="log in pilotVisibleLiveLogs"
|
||||
:key="`pilot-log-${log.id}`"
|
||||
class="pilot-stream-line"
|
||||
:class="log.id === pilotLiveLogs[pilotLiveLogs.length - 1]?.id ? 'pilot-stream-line-current' : ''"
|
||||
>
|
||||
{{ log.text }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="chatThreadPickerOpen" class="pilot-thread-overlay">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wide text-white/60">Threads</p>
|
||||
<button class="btn btn-ghost btn-xs btn-square text-white/70 hover:bg-white/10" title="Close" @click="closeChatThreadPicker">
|
||||
<svg viewBox="0 0 20 20" class="h-3.5 w-3.5 fill-current">
|
||||
<path d="M11.06 10 15.53 5.53a.75.75 0 1 0-1.06-1.06L10 8.94 5.53 4.47a.75.75 0 0 0-1.06 1.06L8.94 10l-4.47 4.47a.75.75 0 1 0 1.06 1.06L10 11.06l4.47 4.47a.75.75 0 0 0 1.06-1.06z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="max-h-full space-y-1 overflow-y-auto pr-1">
|
||||
<div
|
||||
v-for="thread in chatConversations"
|
||||
:key="`thread-row-${thread.id}`"
|
||||
class="flex items-center gap-1 rounded-md"
|
||||
>
|
||||
<button
|
||||
class="min-w-0 flex-1 rounded-md px-2 py-1.5 text-left transition hover:bg-white/10"
|
||||
:class="selectedChatId === thread.id ? 'bg-white/12' : ''"
|
||||
:disabled="chatSwitching || chatArchivingId === thread.id"
|
||||
@click="switchChatConversation(thread.id)"
|
||||
>
|
||||
<p class="truncate text-xs font-medium text-white">{{ thread.title }}</p>
|
||||
<p class="truncate text-[11px] text-white/55">
|
||||
{{ thread.lastMessageText || "No messages yet" }} · {{ formatChatThreadMeta(thread) }}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs btn-square text-white/55 hover:bg-white/10 hover:text-red-300"
|
||||
:disabled="chatSwitching || chatArchivingId === thread.id || chatConversations.length <= 1"
|
||||
title="Archive thread"
|
||||
@click="archiveChatConversation(thread.id)"
|
||||
>
|
||||
<span v-if="chatArchivingId === thread.id" class="loading loading-spinner loading-xs" />
|
||||
<svg v-else viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
|
||||
<path d="M20.54 5.23 19 3H5L3.46 5.23A2 2 0 0 0 3 6.36V8a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6.36a2 2 0 0 0-.46-1.13M5.16 5h13.68l.5.73A.5.5 0 0 1 19.5 6H4.5a.5.5 0 0 1-.34-.27zM6 12h12v6a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pilot-input-wrap">
|
||||
<div class="pilot-input-shell">
|
||||
<textarea
|
||||
:value="pilotInput"
|
||||
class="pilot-input-textarea"
|
||||
:placeholder="pilotRecording ? 'Recording... speak, then press mic to fill or send to submit' : 'Type a message for Pilot...'"
|
||||
@input="onPilotInput(($event.target as HTMLTextAreaElement | null)?.value ?? '')"
|
||||
@keydown.enter="handlePilotComposerEnter"
|
||||
/>
|
||||
|
||||
<div v-if="pilotRecording" class="pilot-meter">
|
||||
<div :ref="setPilotWaveContainerRef" class="pilot-wave-canvas" />
|
||||
</div>
|
||||
|
||||
<div v-if="!pilotRecording" class="pilot-input-context">
|
||||
<button
|
||||
v-if="contextScopeChips.length === 0"
|
||||
class="context-pipette-trigger"
|
||||
:class="contextPickerEnabled ? 'context-pipette-active' : ''"
|
||||
:disabled="pilotTranscribing || pilotSending"
|
||||
aria-label="Контекстная пипетка"
|
||||
:title="contextPickerEnabled ? 'Выключить пипетку' : 'Включить пипетку контекста'"
|
||||
@click="toggleContextPicker"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
|
||||
<path d="M19.29 4.71a1 1 0 0 0-1.42 0l-2.58 2.58-1.17-1.17a1 1 0 0 0-1.41 0l-1.42 1.42a1 1 0 0 0 0 1.41l.59.59-5.3 5.3a2 2 0 0 0-.53.92l-.86 3.43a1 1 0 0 0 1.21 1.21l3.43-.86a2 2 0 0 0 .92-.53l5.3-5.3.59.59a1 1 0 0 0 1.41 0l1.42-1.42a1 1 0 0 0 0-1.41l-1.17-1.17 2.58-2.58a1 1 0 0 0 0-1.42z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div v-else class="pilot-context-chips">
|
||||
<button
|
||||
v-for="chip in contextScopeChips"
|
||||
:key="`context-chip-${chip.scope}`"
|
||||
type="button"
|
||||
class="context-pipette-chip"
|
||||
@click="removeContextScope(chip.scope)"
|
||||
>
|
||||
{{ chip.label }}
|
||||
<span class="opacity-70">×</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pilot-input-actions">
|
||||
<button
|
||||
class="btn btn-xs btn-circle border border-white/20 bg-transparent text-white/90 hover:bg-white/10"
|
||||
:class="pilotRecording ? 'pilot-mic-active' : ''"
|
||||
:disabled="!pilotMicSupported || pilotTranscribing || pilotSending"
|
||||
:title="pilotRecording ? 'Stop and insert transcript' : 'Voice input'"
|
||||
@click="togglePilotRecording"
|
||||
>
|
||||
<svg v-if="!pilotTranscribing" viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
|
||||
<path d="M12 15a3 3 0 0 0 3-3V7a3 3 0 1 0-6 0v5a3 3 0 0 0 3 3m5-3a1 1 0 1 1 2 0 7 7 0 0 1-6 6.92V21h3a1 1 0 1 1 0 2H8a1 1 0 1 1 0-2h3v-2.08A7 7 0 0 1 5 12a1 1 0 1 1 2 0 5 5 0 0 0 10 0" />
|
||||
</svg>
|
||||
<span v-else class="loading loading-spinner loading-xs" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-sm btn-circle border-0 bg-[#5865f2] text-white hover:bg-[#4752c4]"
|
||||
:disabled="pilotTranscribing || pilotSending || (!pilotRecording && !String(pilotInput ?? '').trim())"
|
||||
:title="pilotRecording ? 'Transcribe and send' : 'Send message'"
|
||||
@click="handlePilotSendAction"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" class="h-4 w-4 fill-current" :class="pilotSending ? 'opacity-50' : ''">
|
||||
<path d="M4.5 19.5 21 12 4.5 4.5l.02 5.84L15 12l-10.48 1.66z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="pilotMicError" class="pilot-mic-error">{{ pilotMicError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pilot-shell {
|
||||
background:
|
||||
radial-gradient(circle at 10% -10%, rgba(124, 144, 255, 0.25), transparent 40%),
|
||||
radial-gradient(circle at 85% 110%, rgba(88, 101, 242, 0.2), transparent 45%),
|
||||
#151821;
|
||||
color: #f5f7ff;
|
||||
}
|
||||
|
||||
.pilot-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(8, 10, 16, 0.2);
|
||||
}
|
||||
|
||||
.pilot-threads {
|
||||
padding: 10px 10px 8px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.pilot-timeline {
|
||||
padding: 10px 8px;
|
||||
}
|
||||
|
||||
.pilot-stream-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pilot-thread-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
padding: 10px 8px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(15, 18, 28, 0.96), rgba(15, 18, 28, 0.92)),
|
||||
rgba(15, 18, 28, 0.9);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.pilot-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 8px 6px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.pilot-row:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.pilot-avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 30px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
color: #e6ebff;
|
||||
background: linear-gradient(135deg, #5865f2, #7c90ff);
|
||||
}
|
||||
|
||||
.pilot-avatar-user {
|
||||
background: linear-gradient(135deg, #2a9d8f, #38b2a7);
|
||||
}
|
||||
|
||||
.pilot-body {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pilot-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pilot-author {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #f8f9ff;
|
||||
}
|
||||
|
||||
.pilot-time {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.pilot-message-text {
|
||||
margin-top: 2px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.pilot-input-wrap {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.pilot-input-shell {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pilot-input-textarea {
|
||||
width: 100%;
|
||||
min-height: 96px;
|
||||
resize: none;
|
||||
border-radius: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #f5f7ff;
|
||||
padding: 10px 88px 36px 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.pilot-input-textarea::placeholder {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.pilot-input-textarea:focus {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.pilot-input-actions {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pilot-input-context {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
bottom: 8px;
|
||||
right: 96px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.context-pipette-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(245, 247, 255, 0.94);
|
||||
padding: 0;
|
||||
transition: transform 220ms ease, border-color 220ms ease, background-color 220ms ease;
|
||||
}
|
||||
|
||||
.context-pipette-trigger:hover {
|
||||
transform: scale(1.03);
|
||||
border-color: color-mix(in oklab, var(--color-primary) 62%, transparent);
|
||||
background: color-mix(in oklab, var(--color-primary) 18%, transparent);
|
||||
}
|
||||
|
||||
.context-pipette-active {
|
||||
border-color: color-mix(in oklab, var(--color-primary) 72%, transparent);
|
||||
background: color-mix(in oklab, var(--color-primary) 24%, transparent);
|
||||
box-shadow: 0 0 0 1px color-mix(in oklab, var(--color-primary) 38%, transparent) inset;
|
||||
}
|
||||
|
||||
.pilot-context-chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
|
||||
.pilot-context-chips::-webkit-scrollbar {
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.pilot-context-chips::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.24);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.context-pipette-chip {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(245, 247, 255, 0.95);
|
||||
padding: 4px 9px;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
transition: transform 220ms ease, border-color 220ms ease, background-color 220ms ease;
|
||||
}
|
||||
|
||||
.context-pipette-chip:hover {
|
||||
transform: scale(1.03);
|
||||
border-color: color-mix(in oklab, var(--color-primary) 62%, transparent);
|
||||
background: color-mix(in oklab, var(--color-primary) 20%, transparent);
|
||||
}
|
||||
|
||||
.pilot-meter {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 88px;
|
||||
bottom: 9px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.pilot-wave-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pilot-wave-canvas :deep(wave) {
|
||||
display: block;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.pilot-wave-canvas :deep(canvas) {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.pilot-mic-active {
|
||||
border-color: rgba(255, 95, 95, 0.8) !important;
|
||||
background: rgba(255, 95, 95, 0.16) !important;
|
||||
color: #ffd9d9 !important;
|
||||
}
|
||||
|
||||
.pilot-mic-error {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 160, 160, 0.92);
|
||||
}
|
||||
|
||||
.pilot-stream-status {
|
||||
margin-top: 8px;
|
||||
padding: 2px 4px 8px;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.pilot-stream-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.pilot-stream-caption {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
line-height: 1.3;
|
||||
color: rgba(174, 185, 223, 0.72);
|
||||
}
|
||||
|
||||
.pilot-stream-toggle {
|
||||
border: 1px solid rgba(164, 179, 230, 0.35);
|
||||
background: rgba(25, 33, 56, 0.45);
|
||||
color: rgba(229, 235, 255, 0.92);
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
line-height: 1.2;
|
||||
padding: 3px 8px;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.pilot-stream-toggle:hover {
|
||||
border-color: rgba(180, 194, 240, 0.6);
|
||||
background: rgba(35, 45, 72, 0.7);
|
||||
}
|
||||
|
||||
.pilot-stream-line {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
color: rgba(189, 199, 233, 0.72);
|
||||
}
|
||||
|
||||
.pilot-stream-line-current {
|
||||
color: rgba(234, 239, 255, 0.95);
|
||||
}
|
||||
</style>
|
||||
@@ -1,122 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
type ChangeItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
entity: string;
|
||||
action: string;
|
||||
rolledBack?: boolean;
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
activeChangeStepNumber: number;
|
||||
activeChangeItems: ChangeItem[];
|
||||
activeChangeItem: ChangeItem | null;
|
||||
activeChangeIndex: number;
|
||||
rollbackableCount: number;
|
||||
changeActionBusy: boolean;
|
||||
describeChangeEntity: (entity: string) => string;
|
||||
describeChangeAction: (action: string) => string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "close"): void;
|
||||
(e: "open-item-target", item: ChangeItem): void;
|
||||
(e: "rollback-item", itemId: string): void;
|
||||
(e: "rollback-all"): void;
|
||||
(e: "prev-step"): void;
|
||||
(e: "next-step"): void;
|
||||
(e: "done"): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="props.visible"
|
||||
class="pointer-events-none fixed inset-x-2 bottom-2 z-40 md:inset-auto md:right-4 md:bottom-4 md:w-[390px]"
|
||||
>
|
||||
<section class="pointer-events-auto rounded-2xl border border-base-300 bg-base-100/95 p-3 shadow-2xl backdrop-blur">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
|
||||
Review {{ props.activeChangeStepNumber }}/{{ props.activeChangeItems.length }}
|
||||
</p>
|
||||
<p class="truncate text-sm font-semibold text-base-content">
|
||||
{{ props.activeChangeItem?.title || "Change step" }}
|
||||
</p>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-xs" @click="emit('close')">Close</button>
|
||||
</div>
|
||||
|
||||
<div v-if="props.activeChangeItem" class="mt-2 rounded-xl border border-base-300 bg-base-200/35 p-2">
|
||||
<p class="text-xs text-base-content/80">
|
||||
{{ props.describeChangeEntity(props.activeChangeItem.entity) }}
|
||||
{{ props.describeChangeAction(props.activeChangeItem.action) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 max-h-40 space-y-1 overflow-y-auto pr-1">
|
||||
<div
|
||||
v-for="(item, index) in props.activeChangeItems"
|
||||
:key="`review-step-${item.id}`"
|
||||
class="group flex items-center gap-2 rounded-lg border px-2 py-1"
|
||||
:class="index === props.activeChangeIndex ? 'border-primary/45 bg-primary/10' : 'border-base-300 bg-base-100'"
|
||||
>
|
||||
<button
|
||||
class="min-w-0 flex-1 text-left"
|
||||
@click="emit('open-item-target', item)"
|
||||
>
|
||||
<p class="truncate text-xs font-medium text-base-content">
|
||||
{{ index + 1 }}. {{ item.title }}
|
||||
</p>
|
||||
<p class="truncate text-[11px] text-base-content/65">
|
||||
{{ props.describeChangeEntity(item.entity) }}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
v-if="!item.rolledBack"
|
||||
class="btn btn-ghost btn-xs opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
:disabled="props.changeActionBusy"
|
||||
@click="emit('rollback-item', item.id)"
|
||||
>
|
||||
Rollback
|
||||
</button>
|
||||
<span v-else class="text-[10px] font-medium uppercase tracking-wide text-warning">Rolled back</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center justify-between gap-2">
|
||||
<div class="join">
|
||||
<button
|
||||
class="btn btn-xs join-item"
|
||||
:disabled="props.activeChangeIndex <= 0"
|
||||
@click="emit('prev-step')"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-xs join-item"
|
||||
:disabled="props.activeChangeIndex >= props.activeChangeItems.length - 1"
|
||||
@click="emit('next-step')"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-[11px] text-base-content/70">
|
||||
Rollback available: {{ props.rollbackableCount }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<button
|
||||
class="btn btn-xs btn-warning"
|
||||
:disabled="props.changeActionBusy || props.rollbackableCount === 0"
|
||||
@click="emit('rollback-all')"
|
||||
>
|
||||
{{ props.changeActionBusy ? "Applying..." : "Rollback all" }}
|
||||
</button>
|
||||
<button class="btn btn-xs btn-primary ml-auto" @click="emit('done')">Done</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user