diff --git a/apps/web/app/components/workspace/object-calendar.tsx b/apps/web/app/components/workspace/object-calendar.tsx new file mode 100644 index 00000000000..8b322929998 --- /dev/null +++ b/apps/web/app/components/workspace/object-calendar.tsx @@ -0,0 +1,770 @@ +"use client"; + +import { useMemo, useState, useCallback, useRef } from "react"; +import { + startOfMonth, endOfMonth, startOfWeek, endOfWeek, + startOfDay, addDays, addWeeks, addMonths, addYears, + subDays, subWeeks, subMonths, subYears, + eachDayOfInterval, format, isSameDay, isSameMonth, + isToday, parseISO, getHours, getMinutes, differenceInMinutes, + startOfYear, eachMonthOfInterval, endOfYear, + addMinutes, +} from "date-fns"; +import type { CalendarMode } from "@/lib/object-filters"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type Field = { + id: string; + name: string; + type: string; + enum_values?: string[]; + enum_colors?: string[]; +}; + +type CalendarEvent = { + id: string; + title: string; + date: Date; + endDate?: Date; + entry: Record; + color?: string; +}; + +export type CalendarDateChangePayload = { + entryId: string; + newDate: string; + newEndDate?: string; +}; + +type ObjectCalendarProps = { + objectName: string; + fields: Field[]; + entries: Record[]; + dateField: string; + endDateField?: string; + mode: CalendarMode; + onModeChange: (mode: CalendarMode) => void; + members?: Array<{ id: string; name: string }>; + onEntryClick?: (entryId: string) => void; + onEntryDateChange?: (payload: CalendarDateChangePayload) => void; +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function safeString(val: unknown): string { + if (val == null) {return "";} + if (typeof val === "string") {return val;} + if (typeof val === "number" || typeof val === "boolean" || typeof val === "bigint") {return String(val);} + if (typeof val === "object") {return JSON.stringify(val);} + return ""; +} + +function resolveTitle(entry: Record, fields: Field[]): string { + const titleField = fields.find((f) => + f.type === "text" && /name|title/i.test(f.name), + ) ?? fields.find((f) => f.type === "text"); + return titleField ? safeString(entry[titleField.name]) : safeString(entry.id); +} + +function resolveColor(entry: Record, fields: Field[]): string | undefined { + const enumField = fields.find((f) => f.type === "enum" && f.enum_colors?.length); + if (!enumField) {return undefined;} + const val = safeString(entry[enumField.name]); + const idx = enumField.enum_values?.indexOf(val) ?? -1; + return idx >= 0 ? enumField.enum_colors![idx] : undefined; +} + +function parseEvents( + entries: Record[], + fields: Field[], + dateField: string, + endDateField?: string, +): CalendarEvent[] { + const events: CalendarEvent[] = []; + for (const entry of entries) { + const raw = safeString(entry[dateField]); + if (!raw) {continue;} + try { + const date = parseISO(raw); + if (Number.isNaN(date.getTime())) {continue;} + let endDate: Date | undefined; + if (endDateField) { + const rawEnd = safeString(entry[endDateField]); + if (rawEnd) { + const ed = parseISO(rawEnd); + if (!Number.isNaN(ed.getTime())) {endDate = ed;} + } + } + events.push({ + id: safeString(entry.entry_id ?? entry.id), + title: resolveTitle(entry, fields), + date, + endDate, + entry, + color: resolveColor(entry, fields), + }); + } catch { /* skip unparseable dates */ } + } + return events; +} + +const HOUR_HEIGHT = 60; +const DAY_START_HOUR = 0; +const DAY_END_HOUR = 24; + +function toISODate(d: Date): string { return format(d, "yyyy-MM-dd"); } +function toISODateTime(d: Date): string { return d.toISOString(); } + +// --------------------------------------------------------------------------- +// Navigation +// --------------------------------------------------------------------------- + +function navigateDate(date: Date, mode: CalendarMode, direction: 1 | -1): Date { + switch (mode) { + case "day": return direction === 1 ? addDays(date, 1) : subDays(date, 1); + case "week": return direction === 1 ? addWeeks(date, 1) : subWeeks(date, 1); + case "month": return direction === 1 ? addMonths(date, 1) : subMonths(date, 1); + case "year": return direction === 1 ? addYears(date, 1) : subYears(date, 1); + } +} + +function formatDateHeader(date: Date, mode: CalendarMode): string { + switch (mode) { + case "day": return format(date, "EEEE, MMMM d, yyyy"); + case "week": { + const start = startOfWeek(date, { weekStartsOn: 1 }); + const end = endOfWeek(date, { weekStartsOn: 1 }); + return `${format(start, "MMM d")} - ${format(end, "MMM d, yyyy")}`; + } + case "month": return format(date, "MMMM yyyy"); + case "year": return format(date, "yyyy"); + } +} + +// --------------------------------------------------------------------------- +// EventChip (used in month view, supports drag) +// --------------------------------------------------------------------------- + +function EventChip({ + event, + compact, + onClick, + draggable, + onDragStart, +}: { + event: CalendarEvent; + compact?: boolean; + onClick?: () => void; + draggable?: boolean; + onDragStart?: (e: React.DragEvent) => void; +}) { + const bg = event.color ?? "var(--color-accent)"; + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Month View (drag events between day cells) +// --------------------------------------------------------------------------- + +function MonthView({ + date, + events, + onEntryClick, + onEntryDateChange, +}: { + date: Date; + events: CalendarEvent[]; + onEntryClick?: (id: string) => void; + onEntryDateChange?: (payload: CalendarDateChangePayload) => void; +}) { + const [dragOverDay, setDragOverDay] = useState(null); + const dragEventRef = useRef(null); + + const monthStart = startOfMonth(date); + const monthEnd = endOfMonth(date); + const calStart = startOfWeek(monthStart, { weekStartsOn: 1 }); + const calEnd = endOfWeek(monthEnd, { weekStartsOn: 1 }); + const days = eachDayOfInterval({ start: calStart, end: calEnd }); + + const eventsByDay = useMemo(() => { + const map = new Map(); + for (const ev of events) { + const key = format(ev.date, "yyyy-MM-dd"); + if (!map.has(key)) {map.set(key, []);} + map.get(key)!.push(ev); + if (ev.endDate && !isSameDay(ev.date, ev.endDate)) { + const spanDays = eachDayOfInterval({ start: addDays(ev.date, 1), end: ev.endDate }); + for (const d of spanDays) { + const dk = format(d, "yyyy-MM-dd"); + if (!map.has(dk)) {map.set(dk, []);} + map.get(dk)!.push(ev); + } + } + } + return map; + }, [events]); + + const handleDragStart = useCallback((ev: CalendarEvent) => { + dragEventRef.current = ev; + }, []); + + const handleDragOver = useCallback((e: React.DragEvent, dayKey: string) => { + e.preventDefault(); + setDragOverDay(dayKey); + }, []); + + const handleDrop = useCallback((e: React.DragEvent, targetDay: Date) => { + e.preventDefault(); + setDragOverDay(null); + const ev = dragEventRef.current; + if (!ev || !onEntryDateChange) {return;} + if (isSameDay(ev.date, targetDay)) {return;} + + const dayDelta = differenceInMinutes(startOfDay(targetDay), startOfDay(ev.date)); + const newDate = addMinutes(ev.date, dayDelta); + const payload: CalendarDateChangePayload = { + entryId: ev.id, + newDate: toISODate(newDate), + }; + if (ev.endDate) { + payload.newEndDate = toISODate(addMinutes(ev.endDate, dayDelta)); + } + onEntryDateChange(payload); + dragEventRef.current = null; + }, [onEntryDateChange]); + + const handleDragLeave = useCallback(() => setDragOverDay(null), []); + + const weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + + return ( +
+
+ {weekdays.map((wd) => ( +
+ {wd} +
+ ))} +
+
+ {days.map((day) => { + const key = format(day, "yyyy-MM-dd"); + const dayEvents = eventsByDay.get(key) ?? []; + const inMonth = isSameMonth(day, date); + const today = isToday(day); + const isDragTarget = dragOverDay === key; + return ( +
handleDragOver(e, key)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, day)} + > +
+ {format(day, "d")} +
+
+ {dayEvents.slice(0, 3).map((ev) => ( + onEntryClick?.(ev.id)} + draggable={!!onEntryDateChange} + onDragStart={() => handleDragStart(ev)} + /> + ))} + {dayEvents.length > 3 && ( + + +{dayEvents.length - 3} more + + )} +
+
+ ); + })} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Time-grid drag (used by Week and Day views) +// --------------------------------------------------------------------------- + +function DraggableTimeEvent({ + event, + hourHeight, + onEntryClick, + onEntryDateChange, +}: { + event: CalendarEvent; + hourHeight: number; + onEntryClick?: (id: string) => void; + onEntryDateChange?: (payload: CalendarDateChangePayload) => void; +}) { + const elRef = useRef(null); + const [dragging, setDragging] = useState<"move" | "resize" | null>(null); + const dragStartRef = useRef({ y: 0, startMinute: 0, duration: 0 }); + + const topOffset = (getMinutes(event.date) / 60) * hourHeight; + const duration = event.endDate + ? Math.max(differenceInMinutes(event.endDate, event.date), 15) + : 30; + const height = (duration / 60) * hourHeight; + + const [offsetY, setOffsetY] = useState(0); + const [resizeDelta, setResizeDelta] = useState(0); + + const handleMoveStart = useCallback((e: React.PointerEvent) => { + if (!onEntryDateChange) {return;} + e.preventDefault(); + e.stopPropagation(); + setDragging("move"); + dragStartRef.current = { y: e.clientY, startMinute: getHours(event.date) * 60 + getMinutes(event.date), duration }; + (e.target as HTMLElement).setPointerCapture(e.pointerId); + }, [onEntryDateChange, event.date, duration]); + + const handleResizeStart = useCallback((e: React.PointerEvent) => { + if (!onEntryDateChange) {return;} + e.preventDefault(); + e.stopPropagation(); + setDragging("resize"); + dragStartRef.current = { y: e.clientY, startMinute: 0, duration }; + (e.target as HTMLElement).setPointerCapture(e.pointerId); + }, [onEntryDateChange, duration]); + + const handlePointerMove = useCallback((e: React.PointerEvent) => { + if (!dragging) {return;} + const deltaY = e.clientY - dragStartRef.current.y; + if (dragging === "move") { + setOffsetY(deltaY); + } else { + setResizeDelta(deltaY); + } + }, [dragging]); + + const handlePointerUp = useCallback((e: React.PointerEvent) => { + if (!dragging || !onEntryDateChange) {return;} + const deltaY = e.clientY - dragStartRef.current.y; + const deltaMinutes = Math.round((deltaY / hourHeight) * 60 / 15) * 15; // snap to 15min + + if (dragging === "move" && deltaMinutes !== 0) { + const newDate = addMinutes(event.date, deltaMinutes); + const payload: CalendarDateChangePayload = { + entryId: event.id, + newDate: toISODateTime(newDate), + }; + if (event.endDate) { + payload.newEndDate = toISODateTime(addMinutes(event.endDate, deltaMinutes)); + } + onEntryDateChange(payload); + } else if (dragging === "resize" && deltaMinutes !== 0) { + const newEndDate = addMinutes(event.endDate ?? addMinutes(event.date, duration), deltaMinutes); + if (newEndDate > event.date) { + onEntryDateChange({ + entryId: event.id, + newDate: toISODateTime(event.date), + newEndDate: toISODateTime(newEndDate), + }); + } + } + + setDragging(null); + setOffsetY(0); + setResizeDelta(0); + }, [dragging, onEntryDateChange, event, hourHeight, duration]); + + const renderTop = topOffset + (dragging === "move" ? offsetY : 0); + const renderHeight = Math.max(height + (dragging === "resize" ? resizeDelta : 0), 15); + + return ( +
{ if (!dragging) { e.stopPropagation(); onEntryClick?.(event.id); } }} + title={event.title} + > + {event.title || "Untitled"} + {/* Resize handle at bottom */} + {onEntryDateChange && ( +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Week View +// --------------------------------------------------------------------------- + +function WeekView({ + date, + events, + onEntryClick, + onEntryDateChange, +}: { + date: Date; + events: CalendarEvent[]; + onEntryClick?: (id: string) => void; + onEntryDateChange?: (payload: CalendarDateChangePayload) => void; +}) { + const weekStart = startOfWeek(date, { weekStartsOn: 1 }); + const days = eachDayOfInterval({ start: weekStart, end: addDays(weekStart, 6) }); + const hours = Array.from({ length: DAY_END_HOUR - DAY_START_HOUR }, (_, i) => i + DAY_START_HOUR); + + const eventsByDay = useMemo(() => { + const map = new Map(); + for (const ev of events) { + const key = format(ev.date, "yyyy-MM-dd"); + if (!map.has(key)) {map.set(key, []);} + map.get(key)!.push(ev); + } + return map; + }, [events]); + + return ( +
+
+
+ {days.map((day) => ( +
+
{format(day, "EEE")}
+
{format(day, "d")}
+
+ ))} + {hours.map((hour) => ( + <> +
+ {format(new Date(2000, 0, 1, hour), "ha")} +
+ {days.map((day) => { + const key = format(day, "yyyy-MM-dd"); + const dayEvents = (eventsByDay.get(key) ?? []).filter( + (ev) => getHours(ev.date) === hour, + ); + return ( +
+ {dayEvents.map((ev) => ( + + ))} +
+ ); + })} + + ))} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Day View +// --------------------------------------------------------------------------- + +function DayView({ + date, + events, + onEntryClick, + onEntryDateChange, +}: { + date: Date; + events: CalendarEvent[]; + onEntryClick?: (id: string) => void; + onEntryDateChange?: (payload: CalendarDateChangePayload) => void; +}) { + const hours = Array.from({ length: DAY_END_HOUR - DAY_START_HOUR }, (_, i) => i + DAY_START_HOUR); + + const dayEvents = useMemo( + () => events.filter((ev) => isSameDay(ev.date, date)), + [events, date], + ); + + return ( +
+
+ {hours.map((hour) => { + const hourEvents = dayEvents.filter((ev) => getHours(ev.date) === hour); + return ( + <> +
+ {format(new Date(2000, 0, 1, hour), "h:mm a")} +
+
+ {hourEvents.map((ev) => ( + + ))} +
+ + ); + })} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Year View (no drag — too compact) +// --------------------------------------------------------------------------- + +function YearView({ + date, + events, +}: { + date: Date; + events: CalendarEvent[]; +}) { + const yearStart = startOfYear(date); + const yearEnd = endOfYear(date); + const months = eachMonthOfInterval({ start: yearStart, end: yearEnd }); + + const eventsByMonth = useMemo(() => { + const map = new Map(); + for (const ev of events) { + const m = ev.date.getMonth(); + if (!map.has(m)) {map.set(m, []);} + map.get(m)!.push(ev); + } + return map; + }, [events]); + + return ( +
+ {months.map((month) => { + const mStart = startOfMonth(month); + const mEnd = endOfMonth(month); + const calStart = startOfWeek(mStart, { weekStartsOn: 1 }); + const calEnd = endOfWeek(mEnd, { weekStartsOn: 1 }); + const days = eachDayOfInterval({ start: calStart, end: calEnd }); + const monthEvents = eventsByMonth.get(month.getMonth()) ?? []; + return ( +
+
+ {format(month, "MMMM")} +
+
+ {["M", "T", "W", "T", "F", "S", "S"].map((d, i) => ( +
{d}
+ ))} + {days.map((day) => { + const inMonth = isSameMonth(day, month); + const today = isToday(day); + const hasEvents = monthEvents.some((ev) => isSameDay(ev.date, day)); + return ( +
+ {format(day, "d")} + {hasEvents && inMonth && ( +
+ )} +
+ ); + })} +
+ {monthEvents.length > 0 && ( +
+ {monthEvents.length} event{monthEvents.length !== 1 ? "s" : ""} +
+ )} +
+ ); + })} +
+ ); +} + +// --------------------------------------------------------------------------- +// Empty state +// --------------------------------------------------------------------------- + +function CalendarEmptyState({ reason }: { reason: "no-date-field" | "no-events" }) { + return ( +
+ + + + + {reason === "no-date-field" + ? "No date field configured for calendar view. Open view settings to select one." + : "No events to display in this range."} + +
+ ); +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export function ObjectCalendar({ + objectName: _objectName, + fields, + entries, + dateField, + endDateField, + mode, + onModeChange, + members: _members, + onEntryClick, + onEntryDateChange, +}: ObjectCalendarProps) { + const [currentDate, setCurrentDate] = useState(() => new Date()); + + const events = useMemo( + () => parseEvents(entries, fields, dateField, endDateField), + [entries, fields, dateField, endDateField], + ); + + const handlePrev = useCallback(() => setCurrentDate((d) => navigateDate(d, mode, -1)), [mode]); + const handleNext = useCallback(() => setCurrentDate((d) => navigateDate(d, mode, 1)), [mode]); + const handleToday = useCallback(() => setCurrentDate(new Date()), []); + + if (!dateField) {return ;} + + const modes: CalendarMode[] = ["day", "week", "month", "year"]; + + return ( +
+
+
+ + + +

+ {formatDateHeader(currentDate, mode)} +

+
+
+ {modes.map((m) => ( + + ))} +
+
+ +
+ {mode === "month" && } + {mode === "week" && } + {mode === "day" && } + {mode === "year" && } +
+
+ ); +} diff --git a/apps/web/app/components/workspace/object-filter-bar.tsx b/apps/web/app/components/workspace/object-filter-bar.tsx index c4fe39c036b..4fbb51bb567 100644 --- a/apps/web/app/components/workspace/object-filter-bar.tsx +++ b/apps/web/app/components/workspace/object-filter-bar.tsx @@ -886,7 +886,18 @@ export function ObjectFilterBar({ active={activeViewName === view.name} > {view.name} - {view.filters && ( + {view.view_type && view.view_type !== "table" && ( + + {view.view_type} + + )} + {view.filters && view.filters.rules.length > 0 && ( []; + titleField?: string; + coverField?: string; + members?: Array<{ id: string; name: string }>; + relationLabels?: Record>; + onEntryClick?: (entryId: string) => void; +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function safeString(val: unknown): string { + if (val == null) {return "";} + if (typeof val === "string") {return val;} + if (typeof val === "number" || typeof val === "boolean" || typeof val === "bigint") {return String(val);} + if (typeof val === "object") {return JSON.stringify(val);} + return ""; +} + +function resolveTitle(entry: Record, fields: Field[], titleField?: string): string { + if (titleField) { + const val = safeString(entry[titleField]); + if (val) {return val;} + } + const autoField = fields.find((f) => + f.type === "text" && /name|title/i.test(f.name), + ) ?? fields.find((f) => f.type === "text"); + return autoField ? safeString(entry[autoField.name]) : safeString(entry.id); +} + +function getEnumBadge( + val: string, + field: Field, +): { text: string; color: string } | null { + if (!val || !field.enum_values) {return null;} + const idx = field.enum_values.indexOf(val); + const color = idx >= 0 && field.enum_colors?.[idx] ? field.enum_colors[idx] : "#94a3b8"; + return { text: val, color }; +} + +// --------------------------------------------------------------------------- +// Card +// --------------------------------------------------------------------------- + +function GalleryCard({ + entry, + fields, + titleField, + coverField, + onEntryClick, +}: { + entry: Record; + fields: Field[]; + titleField?: string; + coverField?: string; + onEntryClick?: (id: string) => void; +}) { + const entryId = safeString(entry.entry_id ?? entry.id); + const title = resolveTitle(entry, fields, titleField); + + // Show up to 4 non-title fields + const displayFields = useMemo(() => { + return fields + .filter((f) => f.name !== titleField && f.name !== coverField) + .slice(0, 4); + }, [fields, titleField, coverField]); + + // Enum badge for the first enum field + const enumField = fields.find((f) => f.type === "enum" && f.enum_values?.length); + const enumVal = enumField ? safeString(entry[enumField.name]) : null; + const badge = enumField && enumVal ? getEnumBadge(enumVal, enumField) : null; + + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Empty state +// --------------------------------------------------------------------------- + +function GalleryEmptyState() { + return ( +
+ + + + + + No entries to display in gallery view. + +
+ ); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +export function ObjectGallery({ + objectName: _objectName, + fields, + entries, + titleField, + coverField, + members: _members, + relationLabels: _relationLabels, + onEntryClick, +}: ObjectGalleryProps) { + if (entries.length === 0) { + return ; + } + + return ( +
+ {entries.map((entry) => ( + + ))} +
+ ); +} diff --git a/apps/web/app/components/workspace/object-list.tsx b/apps/web/app/components/workspace/object-list.tsx new file mode 100644 index 00000000000..31f3c485d33 --- /dev/null +++ b/apps/web/app/components/workspace/object-list.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { useMemo } from "react"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type Field = { + id: string; + name: string; + type: string; + enum_values?: string[]; + enum_colors?: string[]; +}; + +type ObjectListProps = { + objectName: string; + fields: Field[]; + entries: Record[]; + titleField?: string; + subtitleField?: string; + members?: Array<{ id: string; name: string }>; + onEntryClick?: (entryId: string) => void; +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function safeString(val: unknown): string { + if (val == null) {return "";} + if (typeof val === "string") {return val;} + if (typeof val === "number" || typeof val === "boolean" || typeof val === "bigint") { + return String(val); + } + if (typeof val === "object") {return JSON.stringify(val);} + return ""; +} + +function resolveTitle(entry: Record, fields: Field[], titleField?: string): string { + if (titleField) { + const val = safeString(entry[titleField]); + if (val) {return val;} + } + const autoField = fields.find((f) => + f.type === "text" && /name|title/i.test(f.name), + ) ?? fields.find((f) => f.type === "text"); + return autoField ? safeString(entry[autoField.name]) : safeString(entry.id); +} + +function getEnumBadge( + val: string, + field: Field, +): { text: string; color: string } | null { + if (!val || !field.enum_values) {return null;} + const idx = field.enum_values.indexOf(val); + const color = idx >= 0 && field.enum_colors?.[idx] ? field.enum_colors[idx] : "#94a3b8"; + return { text: val, color }; +} + +// --------------------------------------------------------------------------- +// Row +// --------------------------------------------------------------------------- + +function ListRow({ + entry, + fields, + titleField, + subtitleField, + onEntryClick, +}: { + entry: Record; + fields: Field[]; + titleField?: string; + subtitleField?: string; + onEntryClick?: (id: string) => void; +}) { + const entryId = safeString(entry.entry_id ?? entry.id); + const title = resolveTitle(entry, fields, titleField); + + const subtitle = useMemo(() => { + if (subtitleField) { + const val = safeString(entry[subtitleField]); + if (val) {return val;} + } + const autoField = fields.find( + (f) => f.type === "text" && f.name !== titleField && !/name|title/i.test(f.name), + ); + return autoField ? safeString(entry[autoField.name]) : undefined; + }, [entry, fields, titleField, subtitleField]); + + const enumField = fields.find((f) => f.type === "enum" && f.enum_values?.length); + const enumVal = enumField ? safeString(entry[enumField.name]) : null; + const badge = enumField && enumVal ? getEnumBadge(enumVal, enumField) : null; + + const dateField = fields.find((f) => f.type === "date"); + const dateVal = dateField ? safeString(entry[dateField.name]) : null; + + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Empty state +// --------------------------------------------------------------------------- + +function ListEmptyState() { + return ( +
+ + + + + No entries to display in list view. + +
+ ); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +export function ObjectList({ + objectName: _objectName, + fields, + entries, + titleField, + subtitleField, + members: _members, + onEntryClick, +}: ObjectListProps) { + if (entries.length === 0) { + return ; + } + + return ( +
+ {entries.map((entry) => ( + + ))} +
+ ); +} diff --git a/apps/web/app/components/workspace/object-timeline.tsx b/apps/web/app/components/workspace/object-timeline.tsx new file mode 100644 index 00000000000..fa4b48db6e2 --- /dev/null +++ b/apps/web/app/components/workspace/object-timeline.tsx @@ -0,0 +1,535 @@ +"use client"; + +import { useMemo, useState, useCallback, useRef, useEffect } from "react"; +import { + parseISO, format, differenceInDays, addDays, startOfDay, + eachDayOfInterval, eachWeekOfInterval, eachMonthOfInterval, + min as dateMin, max as dateMax, addMonths, subMonths, +} from "date-fns"; +import type { TimelineZoom } from "@/lib/object-filters"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type Field = { + id: string; + name: string; + type: string; + enum_values?: string[]; + enum_colors?: string[]; +}; + +type TimelineItem = { + id: string; + title: string; + startDate: Date; + endDate: Date; + group?: string; + color: string; + entry: Record; +}; + +export type TimelineDateChangePayload = { + entryId: string; + newStartDate: string; + newEndDate: string; +}; + +type ObjectTimelineProps = { + objectName: string; + fields: Field[]; + entries: Record[]; + startDateField: string; + endDateField?: string; + groupField?: string; + zoom: TimelineZoom; + onZoomChange: (zoom: TimelineZoom) => void; + members?: Array<{ id: string; name: string }>; + onEntryClick?: (entryId: string) => void; + onEntryDateChange?: (payload: TimelineDateChangePayload) => void; +}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const ZOOM_CONFIG: Record = { + day: { dayWidth: 80, headerFormat: "MMM d" }, + week: { dayWidth: 30, headerFormat: "MMM d" }, + month: { dayWidth: 10, headerFormat: "MMM yyyy" }, + quarter: { dayWidth: 4, headerFormat: "QQQ yyyy" }, +}; + +const ROW_HEIGHT = 40; +const HEADER_HEIGHT = 50; +const SIDEBAR_WIDTH = 200; +const BAR_HEIGHT = 28; +const HANDLE_WIDTH = 8; + +const PALETTE = [ + "#3b82f6", "#22c55e", "#f59e0b", "#ef4444", "#8b5cf6", + "#06b6d4", "#ec4899", "#14b8a6", "#f97316", "#6366f1", +]; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function safeString(val: unknown): string { + if (val == null) {return "";} + if (typeof val === "string") {return val;} + if (typeof val === "number" || typeof val === "boolean" || typeof val === "bigint") {return String(val);} + if (typeof val === "object") {return JSON.stringify(val);} + return ""; +} + +function resolveTitle(entry: Record, fields: Field[]): string { + const titleField = fields.find((f) => + f.type === "text" && /name|title/i.test(f.name), + ) ?? fields.find((f) => f.type === "text"); + return titleField ? safeString(entry[titleField.name]) : safeString(entry.id); +} + +function resolveColor(entry: Record, fields: Field[], idx: number): string { + const enumField = fields.find((f) => f.type === "enum" && f.enum_colors?.length); + if (enumField) { + const val = safeString(entry[enumField.name]); + const i = enumField.enum_values?.indexOf(val) ?? -1; + if (i >= 0 && enumField.enum_colors![i]) {return enumField.enum_colors![i];} + } + return PALETTE[idx % PALETTE.length]; +} + +function parseItems( + entries: Record[], + fields: Field[], + startDateField: string, + endDateField?: string, + groupField?: string, +): TimelineItem[] { + const items: TimelineItem[] = []; + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + const rawStart = safeString(entry[startDateField]); + if (!rawStart) {continue;} + try { + const startDate = parseISO(rawStart); + if (Number.isNaN(startDate.getTime())) {continue;} + + let endDate: Date; + if (endDateField) { + const rawEnd = safeString(entry[endDateField]); + if (rawEnd) { + const ed = parseISO(rawEnd); + endDate = Number.isNaN(ed.getTime()) ? addDays(startDate, 1) : ed; + } else { + endDate = addDays(startDate, 1); + } + } else { + endDate = addDays(startDate, 1); + } + + if (endDate <= startDate) {endDate = addDays(startDate, 1);} + + items.push({ + id: safeString(entry.entry_id ?? entry.id), + title: resolveTitle(entry, fields), + startDate, + endDate, + group: groupField ? safeString(entry[groupField]) : undefined, + color: resolveColor(entry, fields, i), + entry, + }); + } catch { /* skip */ } + } + return items.toSorted((a, b) => a.startDate.getTime() - b.startDate.getTime()); +} + +function getTimelineBounds(items: TimelineItem[], zoom: TimelineZoom): { start: Date; end: Date } { + if (items.length === 0) { + const now = new Date(); + return { start: subMonths(now, 1), end: addMonths(now, 2) }; + } + const earliest = dateMin(items.map((i) => i.startDate)); + const latest = dateMax(items.map((i) => i.endDate)); + const paddingDays = zoom === "day" ? 3 : zoom === "week" ? 7 : zoom === "month" ? 14 : 30; + return { + start: addDays(earliest, -paddingDays), + end: addDays(latest, paddingDays), + }; +} + +function getHeaderTicks(start: Date, end: Date, zoom: TimelineZoom): { date: Date; label: string }[] { + switch (zoom) { + case "day": + return eachDayOfInterval({ start, end }).map((d) => ({ date: d, label: format(d, "MMM d") })); + case "week": + return eachWeekOfInterval({ start, end }, { weekStartsOn: 1 }).map((d) => ({ date: d, label: format(d, "MMM d") })); + case "month": + return eachMonthOfInterval({ start, end }).map((d) => ({ date: d, label: format(d, "MMM yyyy") })); + case "quarter": + return eachMonthOfInterval({ start, end }) + .filter((d) => d.getMonth() % 3 === 0) + .map((d) => ({ date: d, label: format(d, "QQQ yyyy") })); + } +} + +function toISODate(d: Date): string { return format(d, "yyyy-MM-dd"); } + +// --------------------------------------------------------------------------- +// Draggable bar +// --------------------------------------------------------------------------- + +function DraggableBar({ + item, + x, + w, + dayWidth, + onEntryClick, + onEntryDateChange, +}: { + item: TimelineItem; + x: number; + w: number; + dayWidth: number; + onEntryClick?: (id: string) => void; + onEntryDateChange?: (payload: TimelineDateChangePayload) => void; +}) { + const [drag, setDrag] = useState<"move" | "resize-left" | "resize-right" | null>(null); + const [deltaX, setDeltaX] = useState(0); + const [deltaW, setDeltaW] = useState(0); + const startXRef = useRef(0); + const editable = !!onEntryDateChange; + + const snapToDays = useCallback((px: number) => { + return Math.round(px / dayWidth) * dayWidth; + }, [dayWidth]); + + const pxToDays = useCallback((px: number) => { + return Math.round(px / dayWidth); + }, [dayWidth]); + + const handlePointerDown = useCallback((e: React.PointerEvent, mode: "move" | "resize-left" | "resize-right") => { + if (!editable) {return;} + e.preventDefault(); + e.stopPropagation(); + startXRef.current = e.clientX; + setDrag(mode); + (e.target as HTMLElement).setPointerCapture(e.pointerId); + }, [editable]); + + const handlePointerMove = useCallback((e: React.PointerEvent) => { + if (!drag) {return;} + const rawDelta = e.clientX - startXRef.current; + const snapped = snapToDays(rawDelta); + + if (drag === "move") { + setDeltaX(snapped); + } else if (drag === "resize-left") { + // shrink/grow from left: move x right and shrink width + setDeltaX(snapped); + setDeltaW(-snapped); + } else { + setDeltaW(snapped); + } + }, [drag, snapToDays]); + + const handlePointerUp = useCallback((e: React.PointerEvent) => { + if (!drag || !onEntryDateChange) { setDrag(null); return; } + const rawDelta = e.clientX - startXRef.current; + const daysDelta = pxToDays(rawDelta); + + if (daysDelta === 0 && drag === "move") { + setDrag(null); + setDeltaX(0); + setDeltaW(0); + return; + } + + let newStart = item.startDate; + let newEnd = item.endDate; + + if (drag === "move") { + newStart = addDays(item.startDate, daysDelta); + newEnd = addDays(item.endDate, daysDelta); + } else if (drag === "resize-left") { + newStart = addDays(item.startDate, daysDelta); + if (newStart >= newEnd) {newStart = addDays(newEnd, -1);} + } else { + newEnd = addDays(item.endDate, daysDelta); + if (newEnd <= newStart) {newEnd = addDays(newStart, 1);} + } + + onEntryDateChange({ + entryId: item.id, + newStartDate: toISODate(newStart), + newEndDate: toISODate(newEnd), + }); + + setDrag(null); + setDeltaX(0); + setDeltaW(0); + }, [drag, onEntryDateChange, item, pxToDays]); + + const renderX = x + deltaX; + const renderW = Math.max(w + deltaW, dayWidth * 0.5); + + return ( +
handlePointerDown(e, "move")} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + onClick={(e) => { if (!drag) { e.stopPropagation(); onEntryClick?.(item.id); } }} + title={`${item.title}\n${format(item.startDate, "MMM d")} - ${format(item.endDate, "MMM d")}`} + > + {/* Left resize handle */} + {editable && ( +
handlePointerDown(e, "resize-left")} + /> + )} + + {renderW > 60 ? (item.title || "Untitled") : ""} + + {/* Right resize handle */} + {editable && ( +
handlePointerDown(e, "resize-right")} + /> + )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Empty state +// --------------------------------------------------------------------------- + +function TimelineEmptyState({ reason }: { reason: "no-fields" | "no-items" }) { + return ( +
+ + + + + {reason === "no-fields" + ? "No date fields configured for timeline view. Open view settings to select start/end dates." + : "No items with dates to display in this view."} + +
+ ); +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export function ObjectTimeline({ + objectName: _objectName, + fields, + entries, + startDateField, + endDateField, + groupField, + zoom, + onZoomChange, + members: _members, + onEntryClick, + onEntryDateChange, +}: ObjectTimelineProps) { + const containerRef = useRef(null); + + const items = useMemo( + () => parseItems(entries, fields, startDateField, endDateField, groupField), + [entries, fields, startDateField, endDateField, groupField], + ); + + const { start: timelineStart, end: timelineEnd } = useMemo( + () => getTimelineBounds(items, zoom), + [items, zoom], + ); + + const { dayWidth } = ZOOM_CONFIG[zoom]; + const totalDays = differenceInDays(timelineEnd, timelineStart) + 1; + const totalWidth = totalDays * dayWidth; + + const ticks = useMemo( + () => getHeaderTicks(timelineStart, timelineEnd, zoom), + [timelineStart, timelineEnd, zoom], + ); + + const groups = useMemo(() => { + if (!groupField) {return [{ name: "", items }];} + const groupMap = new Map(); + for (const item of items) { + const g = item.group || "Ungrouped"; + if (!groupMap.has(g)) {groupMap.set(g, []);} + groupMap.get(g)!.push(item); + } + return Array.from(groupMap.entries()).map(([name, gItems]) => ({ name, items: gItems })); + }, [items, groupField]); + + const flatRows = useMemo(() => { + const rows: { type: "group" | "item"; group?: string; item?: TimelineItem }[] = []; + for (const g of groups) { + if (groupField && g.name) {rows.push({ type: "group", group: g.name });} + for (const item of g.items) {rows.push({ type: "item", item });} + } + return rows; + }, [groups, groupField]); + + const dateToX = useCallback( + (date: Date) => differenceInDays(date, timelineStart) * dayWidth, + [timelineStart, dayWidth], + ); + + useEffect(() => { + if (!containerRef.current) {return;} + const todayX = dateToX(new Date()); + containerRef.current.scrollLeft = Math.max(0, todayX - 300); + }, [dateToX]); + + if (!startDateField) {return ;} + if (items.length === 0) {return ;} + + const todayX = dateToX(startOfDay(new Date())); + const zoomLevels: TimelineZoom[] = ["day", "week", "month", "quarter"]; + + return ( +
+
+
+ {items.length} item{items.length !== 1 ? "s" : ""} +
+
+ Zoom: +
+ {zoomLevels.map((z) => ( + + ))} +
+
+
+ +
+
+
+
+ Name +
+ {flatRows.map((row, i) => ( +
row.item && onEntryClick?.(row.item.id)} + > + {row.type === "group" ? row.group : row.item?.title || "Untitled"} +
+ ))} +
+ +
+
+
+ {ticks.map((tick, i) => { + const tx = dateToX(tick.date); + const nextX = i < ticks.length - 1 ? dateToX(ticks[i + 1].date) : totalWidth; + return ( +
+ {tick.label} +
+ ); + })} +
+ + {flatRows.map((row, i) => ( +
+ {row.item && (() => { + const barItem = row.item; + const bx = dateToX(barItem.startDate); + const bw = Math.max(dateToX(barItem.endDate) - bx, dayWidth * 0.5); + return ( + + ); + })()} +
+ ))} + + {todayX >= 0 && todayX <= totalWidth && ( +
+ )} +
+
+
+
+
+ ); +} diff --git a/apps/web/app/components/workspace/view-settings-popover.tsx b/apps/web/app/components/workspace/view-settings-popover.tsx new file mode 100644 index 00000000000..f6baedcca7d --- /dev/null +++ b/apps/web/app/components/workspace/view-settings-popover.tsx @@ -0,0 +1,376 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import type { ViewType, ViewTypeSettings, CalendarMode, TimelineZoom } from "@/lib/object-filters"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type Field = { + id: string; + name: string; + type: string; + enum_values?: string[]; +}; + +type ViewSettingsPopoverProps = { + viewType: ViewType; + settings: ViewTypeSettings; + fields: Field[]; + onSettingsChange: (settings: ViewTypeSettings) => void; +}; + +// --------------------------------------------------------------------------- +// Field picker dropdown +// --------------------------------------------------------------------------- + +function FieldSelect({ + label, + value, + onChange, + fields, + filterType, + allowEmpty, +}: { + label: string; + value: string | undefined; + onChange: (value: string | undefined) => void; + fields: Field[]; + filterType?: string | string[]; + allowEmpty?: boolean; +}) { + const types = filterType + ? Array.isArray(filterType) ? filterType : [filterType] + : null; + const filtered = types ? fields.filter((f) => types.includes(f.type)) : fields; + + return ( +
+ + +
+ ); +} + +function ModeSelect({ + label, + value, + options, + onChange, +}: { + label: string; + value: T; + options: { value: T; label: string }[]; + onChange: (value: T) => void; +}) { + return ( +
+ +
+ {options.map((opt) => ( + + ))} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Settings panels per view type +// --------------------------------------------------------------------------- + +function KanbanSettings({ + settings, + fields, + onSettingsChange, +}: { + settings: ViewTypeSettings; + fields: Field[]; + onSettingsChange: (s: ViewTypeSettings) => void; +}) { + return ( + onSettingsChange({ ...settings, kanbanField: v })} + fields={fields} + filterType="enum" + /> + ); +} + +function CalendarSettings({ + settings, + fields, + onSettingsChange, +}: { + settings: ViewTypeSettings; + fields: Field[]; + onSettingsChange: (s: ViewTypeSettings) => void; +}) { + return ( + <> + onSettingsChange({ ...settings, calendarDateField: v })} + fields={fields} + filterType="date" + /> + onSettingsChange({ ...settings, calendarEndDateField: v })} + fields={fields} + filterType="date" + allowEmpty + /> + + label="Default view" + value={settings.calendarMode ?? "month"} + options={[ + { value: "day", label: "Day" }, + { value: "week", label: "Week" }, + { value: "month", label: "Month" }, + { value: "year", label: "Year" }, + ]} + onChange={(v) => onSettingsChange({ ...settings, calendarMode: v })} + /> + + ); +} + +function TimelineSettings({ + settings, + fields, + onSettingsChange, +}: { + settings: ViewTypeSettings; + fields: Field[]; + onSettingsChange: (s: ViewTypeSettings) => void; +}) { + return ( + <> + onSettingsChange({ ...settings, timelineStartField: v })} + fields={fields} + filterType="date" + /> + onSettingsChange({ ...settings, timelineEndField: v })} + fields={fields} + filterType="date" + allowEmpty + /> + onSettingsChange({ ...settings, timelineGroupField: v })} + fields={fields} + filterType="enum" + allowEmpty + /> + + label="Default zoom" + value={settings.timelineZoom ?? "week"} + options={[ + { value: "day", label: "Day" }, + { value: "week", label: "Week" }, + { value: "month", label: "Month" }, + { value: "quarter", label: "Quarter" }, + ]} + onChange={(v) => onSettingsChange({ ...settings, timelineZoom: v })} + /> + + ); +} + +function GallerySettings({ + settings, + fields, + onSettingsChange, +}: { + settings: ViewTypeSettings; + fields: Field[]; + onSettingsChange: (s: ViewTypeSettings) => void; +}) { + return ( + <> + onSettingsChange({ ...settings, galleryTitleField: v })} + fields={fields} + filterType="text" + /> + onSettingsChange({ ...settings, galleryCoverField: v })} + fields={fields} + allowEmpty + /> + + ); +} + +function ListSettings({ + settings, + fields, + onSettingsChange, +}: { + settings: ViewTypeSettings; + fields: Field[]; + onSettingsChange: (s: ViewTypeSettings) => void; +}) { + return ( + <> + onSettingsChange({ ...settings, listTitleField: v })} + fields={fields} + filterType="text" + /> + onSettingsChange({ ...settings, listSubtitleField: v })} + fields={fields} + filterType={["text", "email", "richtext"]} + allowEmpty + /> + + ); +} + +// --------------------------------------------------------------------------- +// Main popover +// --------------------------------------------------------------------------- + +function GearIcon() { + return ( + + + + + ); +} + +export function ViewSettingsPopover({ + viewType, + settings, + fields, + onSettingsChange, +}: ViewSettingsPopoverProps) { + const [open, setOpen] = useState(false); + const popoverRef = useRef(null); + + useEffect(() => { + if (!open) {return;} + const handler = (e: MouseEvent) => { + if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open]); + + // Table has no settings + if (viewType === "table") {return null;} + + const panelTitle: Record = { + table: "", + kanban: "Board Settings", + calendar: "Calendar Settings", + timeline: "Timeline Settings", + gallery: "Gallery Settings", + list: "List Settings", + }; + + return ( +
+ + + {open && ( +
+
+ {panelTitle[viewType]} +
+ + {viewType === "kanban" && ( + + )} + {viewType === "calendar" && ( + + )} + {viewType === "timeline" && ( + + )} + {viewType === "gallery" && ( + + )} + {viewType === "list" && ( + + )} +
+ )} +
+ ); +} diff --git a/apps/web/app/components/workspace/view-type-switcher.tsx b/apps/web/app/components/workspace/view-type-switcher.tsx new file mode 100644 index 00000000000..9700d42fc69 --- /dev/null +++ b/apps/web/app/components/workspace/view-type-switcher.tsx @@ -0,0 +1,107 @@ +"use client"; + +import type { ReactElement } from "react"; +import { type ViewType, VIEW_TYPES } from "@/lib/object-filters"; + +// --------------------------------------------------------------------------- +// Icons for each view type +// --------------------------------------------------------------------------- + +function TableIcon() { + return ( + + + + ); +} + +function KanbanIcon() { + return ( + + + + ); +} + +function CalendarIcon() { + return ( + + + + ); +} + +function TimelineIcon() { + return ( + + + + ); +} + +function GalleryIcon() { + return ( + + + + + ); +} + +function ListIcon() { + return ( + + + + ); +} + +const VIEW_TYPE_META: Record ReactElement; label: string }> = { + table: { icon: TableIcon, label: "Table" }, + kanban: { icon: KanbanIcon, label: "Board" }, + calendar: { icon: CalendarIcon, label: "Calendar" }, + timeline: { icon: TimelineIcon, label: "Timeline" }, + gallery: { icon: GalleryIcon, label: "Gallery" }, + list: { icon: ListIcon, label: "List" }, +}; + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +type ViewTypeSwitcherProps = { + value: ViewType; + onChange: (type: ViewType) => void; +}; + +export function ViewTypeSwitcher({ value, onChange }: ViewTypeSwitcherProps) { + return ( +
+ {VIEW_TYPES.map((vt) => { + const meta = VIEW_TYPE_META[vt]; + const Icon = meta.icon; + const isActive = vt === value; + return ( + + ); + })} +
+ ); +} diff --git a/apps/web/app/workspace/page.tsx b/apps/web/app/workspace/page.tsx index 365141c5af4..581fb03c3d2 100644 --- a/apps/web/app/workspace/page.tsx +++ b/apps/web/app/workspace/page.tsx @@ -7,12 +7,20 @@ import { type TreeNode } from "../components/workspace/file-manager-tree"; import { useWorkspaceWatcher } from "../hooks/use-workspace-watcher"; import { ObjectTable } from "../components/workspace/object-table"; import { ObjectKanban } from "../components/workspace/object-kanban"; +import { ObjectCalendar, type CalendarDateChangePayload } from "../components/workspace/object-calendar"; +import { ObjectTimeline, type TimelineDateChangePayload } from "../components/workspace/object-timeline"; +import { ObjectGallery } from "../components/workspace/object-gallery"; +import { ObjectList } from "../components/workspace/object-list"; +import { ViewTypeSwitcher } from "../components/workspace/view-type-switcher"; +import { ViewSettingsPopover } from "../components/workspace/view-settings-popover"; import { DocumentView } from "../components/workspace/document-view"; import { FileViewer, isSpreadsheetFile } from "../components/workspace/file-viewer"; +import { SpreadsheetEditor } from "../components/workspace/spreadsheet-editor"; import { HtmlViewer } from "../components/workspace/html-viewer"; -import { CodeViewer } from "../components/workspace/code-viewer"; +import { MonacoCodeEditor } from "../components/workspace/code-editor"; import { MediaViewer, detectMediaType, type MediaType } from "../components/workspace/media-viewer"; import { DatabaseViewer, DuckDBMissing } from "../components/workspace/database-viewer"; +import { RichDocumentEditor, isDocxFile, isTxtFile, textToHtml } from "../components/workspace/rich-document-editor"; import { Breadcrumbs } from "../components/workspace/breadcrumbs"; import { ChatSessionsSidebar } from "../components/workspace/chat-sessions-sidebar"; import { EmptyState } from "../components/workspace/empty-state"; @@ -27,7 +35,12 @@ import { CronJobDetail } from "../components/cron/cron-job-detail"; import type { CronJob, CronJobsResponse } from "../types/cron"; import { useIsMobile } from "../hooks/use-mobile"; import { ObjectFilterBar } from "../components/workspace/object-filter-bar"; -import { type FilterGroup, type SortRule, type SavedView, emptyFilterGroup, serializeFilters } from "@/lib/object-filters"; +import { + type FilterGroup, type SortRule, type SavedView, type ViewType, + type ViewTypeSettings, + emptyFilterGroup, serializeFilters, resolveViewType, resolveViewSettings, + autoDetectViewField, +} from "@/lib/object-filters"; import { UnicodeSpinner } from "../components/unicode-spinner"; import { resolveActiveViewSyncDecision } from "./object-view-active-view"; import { resetWorkspaceStateOnSwitch } from "./workspace-switch"; @@ -81,6 +94,7 @@ type ObjectData = { effectiveDisplayField?: string; savedViews?: import("@/lib/object-filters").SavedView[]; activeView?: string; + viewSettings?: import("@/lib/object-filters").ViewTypeSettings; totalCount?: number; page?: number; pageSize?: number; @@ -97,24 +111,27 @@ type ContentState = | { kind: "object"; data: ObjectData } | { kind: "document"; data: FileData; title: string } | { kind: "file"; data: FileData; filename: string } - | { kind: "code"; data: FileData; filename: string } + | { kind: "code"; data: FileData; filename: string; filePath: string } | { kind: "media"; url: string; mediaType: MediaType; filename: string; filePath: string } - | { kind: "spreadsheet"; url: string; filename: string } + | { kind: "spreadsheet"; url: string; filename: string; filePath: string } | { kind: "html"; rawUrl: string; contentUrl: string; filename: string } | { kind: "database"; dbPath: string; filename: string } | { kind: "report"; reportPath: string; filename: string } | { kind: "directory"; node: TreeNode } | { kind: "cron-dashboard" } | { kind: "cron-job"; jobId: string; job: CronJob } - | { kind: "duckdb-missing" }; + | { kind: "duckdb-missing" } + | { kind: "richDocument"; html: string; filePath: string; mode: "docx" | "txt" }; type SidebarPreviewContent = | { kind: "document"; data: FileData; title: string } | { kind: "file"; data: FileData; filename: string } - | { kind: "code"; data: FileData; filename: string } + | { kind: "code"; data: FileData; filename: string; filePath: string } | { kind: "media"; url: string; mediaType: MediaType; filename: string; filePath: string } + | { kind: "spreadsheet"; url: string; filename: string; filePath: string } | { kind: "database"; dbPath: string; filename: string } - | { kind: "directory"; path: string; name: string }; + | { kind: "directory"; path: string; name: string } + | { kind: "richDocument"; html: string; filePath: string; mode: "docx" | "txt" }; type ChatSidebarPreviewState = | { status: "loading"; path: string; filename: string } @@ -669,10 +686,33 @@ function WorkspacePageInner() { } else if (node.type === "report") { setContent({ kind: "report", reportPath: node.path, filename: node.name }); } else if (node.type === "file") { - // Spreadsheet files get their own binary viewer if (isSpreadsheetFile(node.name)) { const url = rawFileUrl(node.path); - setContent({ kind: "spreadsheet", url, filename: node.name }); + setContent({ kind: "spreadsheet", url, filename: node.name, filePath: node.path }); + return; + } + + // DOCX files: fetch binary, convert to HTML with mammoth + if (isDocxFile(node.name)) { + try { + const rawRes = await fetch(rawFileUrl(node.path)); + if (!rawRes.ok) { setContent({ kind: "none" }); return; } + const arrayBuffer = await rawRes.arrayBuffer(); + const mammoth = await import("mammoth"); + const result = await mammoth.convertToHtml({ arrayBuffer }); + setContent({ kind: "richDocument", html: result.value, filePath: node.path, mode: "docx" }); + } catch { + setContent({ kind: "none" }); + } + return; + } + + // TXT files: fetch text content and open in rich editor + if (isTxtFile(node.name)) { + const res = await fetch(fileApiUrl(node.path)); + if (!res.ok) { setContent({ kind: "none" }); return; } + const data: FileData = await res.json(); + setContent({ kind: "richDocument", html: textToHtml(data.content), filePath: node.path, mode: "txt" }); return; } @@ -697,9 +737,8 @@ function WorkspacePageInner() { return; } const data: FileData = await res.json(); - // Route code files to the syntax-highlighted CodeViewer if (isCodeFile(node.name)) { - setContent({ kind: "code", data, filename: node.name }); + setContent({ kind: "code", data, filename: node.name, filePath: node.path }); } else { setContent({ kind: "file", data, filename: node.name }); } @@ -816,6 +855,37 @@ function WorkspacePageInner() { }; } + if (isSpreadsheetFile(node.name)) { + return { + kind: "spreadsheet", + url: rawFileUrl(node.path), + filename: node.name, + filePath: node.path, + }; + } + + // DOCX: binary fetch -> mammoth -> HTML + if (isDocxFile(node.name)) { + try { + const rawRes = await fetch(rawFileUrl(node.path)); + if (!rawRes.ok) {return null;} + const arrayBuffer = await rawRes.arrayBuffer(); + const mammoth = await import("mammoth"); + const result = await mammoth.convertToHtml({ arrayBuffer }); + return { kind: "richDocument", html: result.value, filePath: node.path, mode: "docx" }; + } catch { + return null; + } + } + + // TXT: text fetch -> wrap in paragraphs + if (isTxtFile(node.name)) { + const txtRes = await fetch(fileApiUrl(node.path)); + if (!txtRes.ok) {return null;} + const txtData: FileData = await txtRes.json(); + return { kind: "richDocument", html: textToHtml(txtData.content), filePath: node.path, mode: "txt" }; + } + const res = await fetch(fileApiUrl(node.path)); if (!res.ok) {return null;} const data: FileData = await res.json(); @@ -828,7 +898,7 @@ function WorkspacePageInner() { }; } if (isCodeFile(node.name)) { - return { kind: "code", data, filename: node.name }; + return { kind: "code", data, filename: node.name, filePath: node.path }; } return { kind: "file", data, filename: node.name }; }, @@ -1861,8 +1931,8 @@ function ChatSidebarPreview({ break; case "code": body = ( -
- +
+
); break; @@ -1873,6 +1943,18 @@ function ChatSidebarPreview({
); break; + case "spreadsheet": + body = ( +
+ +
+ ); + break; case "database": body = (
@@ -1880,6 +1962,18 @@ function ChatSidebarPreview({
); break; + case "richDocument": + body = ( +
+ +
+ ); + break; case "directory": body = (
@@ -2094,9 +2188,10 @@ function ContentRenderer({ case "code": return ( - ); @@ -2112,10 +2207,10 @@ function ContentRenderer({ case "spreadsheet": return ( - ); @@ -2184,6 +2279,16 @@ function ContentRenderer({ case "duckdb-missing": return ; + case "richDocument": + return ( + + ); + case "none": default: if (tree.length === 0) { @@ -2208,8 +2313,24 @@ function ObjectView({ onRefreshObject: () => void; onOpenEntry?: (objectName: string, entryId: string) => void; }) { + const safeEntryId = (e: Record) => { + const candidate = e.entry_id ?? e.id; + if (typeof candidate === "string") {return candidate;} + if (typeof candidate === "number" || typeof candidate === "boolean" || typeof candidate === "bigint") { + return String(candidate); + } + return ""; + }; const [updatingDisplayField, setUpdatingDisplayField] = useState(false); + // --- View type state --- + const [currentViewType, setCurrentViewType] = useState( + () => resolveViewType(undefined, undefined, data.object.default_view), + ); + const [viewSettings, setViewSettings] = useState( + () => data.viewSettings ?? {}, + ); + // --- Filter state --- const [filters, setFilters] = useState(() => emptyFilterGroup()); const [savedViews, setSavedViews] = useState(data.savedViews ?? []); @@ -2238,6 +2359,9 @@ function ObjectView({ for (const field of data.fields) { vis[field.id] = viewColumns.includes(field.name); } + // Synthetic timestamp columns — keyed by their column ID, matched by name + vis["created_at"] = viewColumns.includes("created_at"); + vis["updated_at"] = viewColumns.includes("updated_at"); return vis; }, [viewColumns, data.fields]); @@ -2298,6 +2422,7 @@ function ObjectView({ // Sync saved views when data changes (e.g. SSE refresh from AI editing .object.yaml) useEffect(() => { setSavedViews(data.savedViews ?? []); + if (data.viewSettings) {setViewSettings(data.viewSettings);} const decision = resolveActiveViewSyncDecision({ savedViews: data.savedViews, @@ -2305,11 +2430,15 @@ function ObjectView({ currentActiveViewName: activeViewName, currentFilters: filters, currentViewColumns: viewColumns, + currentViewType: currentViewType, + currentSettings: viewSettings, }); if (decision?.shouldApply) { setFilters(decision.nextFilters); setViewColumns(decision.nextColumns); setActiveViewName(decision.nextActiveViewName); + if (decision.nextViewType) {setCurrentViewType(decision.nextViewType);} + if (decision.nextSettings) {setViewSettings((prev) => ({ ...prev, ...decision.nextSettings }));} // Re-fetch with filters from the synchronized active view. void fetchEntries({ page: 1, filters: decision.nextFilters }); } @@ -2357,7 +2486,13 @@ function ObjectView({ // Save view to .object.yaml via API const handleSaveView = useCallback(async (name: string) => { - const newView: SavedView = { name, filters, columns: viewColumns }; + const newView: SavedView = { + name, + view_type: currentViewType, + filters, + columns: viewColumns, + settings: viewSettings, + }; const updated = [...savedViews.filter((v) => v.name !== name), newView]; setSavedViews(updated); setActiveViewName(name); @@ -2367,19 +2502,21 @@ function ObjectView({ { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ views: updated, activeView: name }), + body: JSON.stringify({ views: updated, activeView: name, viewSettings }), }, ); } catch { // ignore save errors } - }, [filters, savedViews, data.object.name]); + }, [filters, savedViews, data.object.name, currentViewType, viewColumns, viewSettings]); const handleLoadView = useCallback((view: SavedView) => { const newFilters = view.filters ?? emptyFilterGroup(); setFilters(newFilters); setViewColumns(view.columns); setActiveViewName(view.name); + if (view.view_type) {setCurrentViewType(view.view_type);} + if (view.settings) {setViewSettings((prev) => ({ ...prev, ...view.settings }));} setServerPage(1); void fetchEntries({ page: 1, filters: newFilters }); }, [fetchEntries]); @@ -2426,6 +2563,56 @@ function ObjectView({ } }, [savedViews, data.object.name]); + // View type change handler + const handleViewTypeChange = useCallback((vt: ViewType) => { + setCurrentViewType(vt); + }, []); + + // View settings change handler (persists to .object.yaml) + const handleViewSettingsChange = useCallback(async (newSettings: ViewTypeSettings) => { + setViewSettings(newSettings); + try { + await fetch( + `/api/workspace/objects/${encodeURIComponent(data.object.name)}/views`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ views: savedViews, activeView: activeViewName, viewSettings: newSettings }), + }, + ); + } catch { + // ignore + } + }, [savedViews, activeViewName, data.object.name]); + + // Resolve effective settings for current view type with auto-detection fallbacks + const effectiveSettings = useMemo(() => { + const activeView = savedViews.find((v) => v.name === activeViewName); + const merged = resolveViewSettings(activeView?.settings, viewSettings); + const fieldMetas = [ + ...data.fields.map((f) => ({ name: f.name, type: f.type })), + { name: "created_at", type: "date" }, + { name: "updated_at", type: "date" }, + ]; + + if (currentViewType === "kanban" && !merged.kanbanField) { + merged.kanbanField = autoDetectViewField("kanban", "kanbanField", fieldMetas); + } + if (currentViewType === "calendar" && !merged.calendarDateField) { + merged.calendarDateField = autoDetectViewField("calendar", "calendarDateField", fieldMetas); + } + if (currentViewType === "timeline" && !merged.timelineStartField) { + merged.timelineStartField = autoDetectViewField("timeline", "timelineStartField", fieldMetas); + } + if (currentViewType === "gallery" && !merged.galleryTitleField) { + merged.galleryTitleField = autoDetectViewField("gallery", "galleryTitleField", fieldMetas); + } + if (currentViewType === "list" && !merged.listTitleField) { + merged.listTitleField = autoDetectViewField("list", "listTitleField", fieldMetas); + } + return merged; + }, [currentViewType, viewSettings, savedViews, activeViewName, data.fields]); + const handleDisplayFieldChange = async (fieldName: string) => { setUpdatingDisplayField(true); try { @@ -2447,6 +2634,58 @@ function ObjectView({ } }; + // Persist date changes from calendar/timeline drag-and-drop with optimistic UI + const handleCalendarDateChange = useCallback(async (payload: CalendarDateChangePayload) => { + const dateFieldName = effectiveSettings.calendarDateField; + const endDateFieldName = effectiveSettings.calendarEndDateField; + if (!dateFieldName) {return;} + + const fields: Record = { [dateFieldName]: payload.newDate }; + if (endDateFieldName && payload.newEndDate) { + fields[endDateFieldName] = payload.newEndDate; + } + + // Optimistic update + setEntries((prev) => prev.map((e) => { + if (safeEntryId(e) !== payload.entryId) {return e;} + const updated = { ...e, [dateFieldName]: payload.newDate }; + if (endDateFieldName && payload.newEndDate) {updated[endDateFieldName] = payload.newEndDate;} + return updated; + })); + + try { + await fetch( + `/api/workspace/objects/${encodeURIComponent(data.object.name)}/entries/${encodeURIComponent(payload.entryId)}`, + { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fields }) }, + ); + } catch { /* rollback happens on next SSE refresh */ } + }, [effectiveSettings, data.object.name]); + + const handleTimelineDateChange = useCallback(async (payload: TimelineDateChangePayload) => { + const startFieldName = effectiveSettings.timelineStartField; + const endFieldName = effectiveSettings.timelineEndField; + if (!startFieldName) {return;} + + const fields: Record = { [startFieldName]: payload.newStartDate }; + if (endFieldName) { + fields[endFieldName] = payload.newEndDate; + } + + setEntries((prev) => prev.map((e) => { + if (safeEntryId(e) !== payload.entryId) {return e;} + const updated = { ...e, [startFieldName]: payload.newStartDate }; + if (endFieldName) {updated[endFieldName] = payload.newEndDate;} + return updated; + })); + + try { + await fetch( + `/api/workspace/objects/${encodeURIComponent(data.object.name)}/entries/${encodeURIComponent(payload.entryId)}`, + { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fields }) }, + ); + } catch { /* rollback happens on next SSE refresh */ } + }, [effectiveSettings, data.object.name]); + const displayFieldCandidates = data.fields.filter( (f) => !["relation", "boolean", "richtext"].includes(f.type), ); @@ -2462,6 +2701,13 @@ function ObjectView({ [members], ); + // Include synthetic timestamp columns so view settings pickers can find date fields + const fieldsWithTimestamps = useMemo(() => [ + ...data.fields, + { id: "created_at", name: "created_at", type: "date" } as typeof data.fields[number], + { id: "updated_at", name: "updated_at", type: "date" } as typeof data.fields[number], + ], [data.fields]); + return (
{/* Object header */} @@ -2570,7 +2816,7 @@ function ObjectView({ )}
- {/* Filter bar */} + {/* View switcher + Filter bar */}
+
+ + +
- {/* Table or Kanban */} - {data.object.default_view === "kanban" ? ( + {/* View renderer */} + {currentViewType === "kanban" && ( onOpenEntry(data.object.name, entryId) : undefined} onRefresh={handleRefresh} /> - ) : ( + )} + {currentViewType === "table" && ( )} + {currentViewType === "calendar" && ( + handleViewSettingsChange({ ...effectiveSettings, calendarMode: mode })} + members={members} + onEntryClick={onOpenEntry ? (entryId) => onOpenEntry(data.object.name, entryId) : undefined} + onEntryDateChange={handleCalendarDateChange} + /> + )} + {currentViewType === "timeline" && ( + handleViewSettingsChange({ ...effectiveSettings, timelineZoom: zoom })} + members={members} + onEntryClick={onOpenEntry ? (entryId) => onOpenEntry(data.object.name, entryId) : undefined} + onEntryDateChange={handleTimelineDateChange} + /> + )} + {currentViewType === "gallery" && ( + onOpenEntry(data.object.name, entryId) : undefined} + /> + )} + {currentViewType === "list" && ( + onOpenEntry(data.object.name, entryId) : undefined} + /> + )}
); }