feat(web): add calendar timeline gallery and list object views

This commit is contained in:
kumarabhirup 2026-03-04 11:07:10 -08:00
parent 3f6f181552
commit cd7ea43a91
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
8 changed files with 2542 additions and 26 deletions

View File

@ -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<string, unknown>;
color?: string;
};
export type CalendarDateChangePayload = {
entryId: string;
newDate: string;
newEndDate?: string;
};
type ObjectCalendarProps = {
objectName: string;
fields: Field[];
entries: Record<string, unknown>[];
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<string, unknown>, 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<string, unknown>, 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<string, unknown>[],
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 (
<button
type="button"
draggable={draggable}
onDragStart={onDragStart}
onClick={(e) => { e.stopPropagation(); onClick?.(); }}
className={`text-left rounded px-1.5 truncate cursor-pointer transition-opacity hover:opacity-80 ${compact ? "text-[10px] py-0" : "text-[11px] py-0.5"}`}
style={{
backgroundColor: bg,
color: "#fff",
border: "none",
width: "100%",
lineHeight: compact ? "16px" : "18px",
}}
title={event.title}
>
{!compact && <span className="opacity-70 mr-1">{format(event.date, "HH:mm")}</span>}
{event.title || "Untitled"}
</button>
);
}
// ---------------------------------------------------------------------------
// 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<string | null>(null);
const dragEventRef = useRef<CalendarEvent | null>(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<string, CalendarEvent[]>();
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 (
<div className="w-full">
<div className="grid grid-cols-7 border-b" style={{ borderColor: "var(--color-border)" }}>
{weekdays.map((wd) => (
<div key={wd} className="text-center text-[11px] font-medium py-2" style={{ color: "var(--color-text-muted)" }}>
{wd}
</div>
))}
</div>
<div className="grid grid-cols-7">
{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 (
<div
key={key}
className="min-h-[100px] border-b border-r p-1 transition-colors"
style={{
borderColor: "var(--color-border)",
opacity: inMonth ? 1 : 0.4,
background: isDragTarget ? "var(--color-accent-light, rgba(99,102,241,0.12))" : today ? "var(--color-surface-hover)" : undefined,
}}
onDragOver={(e) => handleDragOver(e, key)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, day)}
>
<div
className={`text-[11px] mb-0.5 ${today ? "font-bold" : ""}`}
style={{ color: today ? "var(--color-accent)" : "var(--color-text-muted)" }}
>
{format(day, "d")}
</div>
<div className="flex flex-col gap-0.5">
{dayEvents.slice(0, 3).map((ev) => (
<EventChip
key={ev.id}
event={ev}
compact
onClick={() => onEntryClick?.(ev.id)}
draggable={!!onEntryDateChange}
onDragStart={() => handleDragStart(ev)}
/>
))}
{dayEvents.length > 3 && (
<span className="text-[10px] pl-1" style={{ color: "var(--color-text-muted)" }}>
+{dayEvents.length - 3} more
</span>
)}
</div>
</div>
);
})}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// 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<HTMLDivElement>(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 (
<div
ref={elRef}
className="absolute left-0.5 right-0.5 rounded px-1 text-[10px] truncate select-none"
style={{
top: renderTop,
height: Math.min(renderHeight, hourHeight * 6),
backgroundColor: event.color ?? "var(--color-accent)",
color: "#fff",
lineHeight: "16px",
zIndex: dragging ? 20 : 2,
cursor: onEntryDateChange ? (dragging === "move" ? "grabbing" : "grab") : "pointer",
opacity: dragging ? 0.85 : 1,
transition: dragging ? "none" : "top 0.15s, height 0.15s",
}}
onPointerDown={handleMoveStart}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onClick={(e) => { if (!dragging) { e.stopPropagation(); onEntryClick?.(event.id); } }}
title={event.title}
>
{event.title || "Untitled"}
{/* Resize handle at bottom */}
{onEntryDateChange && (
<div
className="absolute bottom-0 left-0 right-0 h-2 cursor-ns-resize"
style={{ borderRadius: "0 0 4px 4px" }}
onPointerDown={handleResizeStart}
/>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// 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<string, CalendarEvent[]>();
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 (
<div className="w-full overflow-auto" style={{ maxHeight: "calc(100vh - 280px)" }}>
<div className="grid" style={{ gridTemplateColumns: "50px repeat(7, 1fr)" }}>
<div className="sticky top-0 z-10" style={{ background: "var(--color-bg)" }} />
{days.map((day) => (
<div
key={day.toISOString()}
className="sticky top-0 z-10 text-center py-2 border-b border-l text-[12px] font-medium"
style={{
borderColor: "var(--color-border)",
background: "var(--color-bg)",
color: isToday(day) ? "var(--color-accent)" : "var(--color-text)",
}}
>
<div>{format(day, "EEE")}</div>
<div className={`text-lg ${isToday(day) ? "font-bold" : ""}`}>{format(day, "d")}</div>
</div>
))}
{hours.map((hour) => (
<>
<div
key={`label-${hour}`}
className="text-[10px] text-right pr-2 pt-0.5"
style={{ color: "var(--color-text-muted)", height: HOUR_HEIGHT }}
>
{format(new Date(2000, 0, 1, hour), "ha")}
</div>
{days.map((day) => {
const key = format(day, "yyyy-MM-dd");
const dayEvents = (eventsByDay.get(key) ?? []).filter(
(ev) => getHours(ev.date) === hour,
);
return (
<div
key={`${key}-${hour}`}
className="border-b border-l relative"
style={{ borderColor: "var(--color-border)", height: HOUR_HEIGHT }}
>
{dayEvents.map((ev) => (
<DraggableTimeEvent
key={ev.id}
event={ev}
hourHeight={HOUR_HEIGHT}
onEntryClick={onEntryClick}
onEntryDateChange={onEntryDateChange}
/>
))}
</div>
);
})}
</>
))}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// 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 (
<div className="w-full overflow-auto" style={{ maxHeight: "calc(100vh - 280px)" }}>
<div className="grid" style={{ gridTemplateColumns: "60px 1fr" }}>
{hours.map((hour) => {
const hourEvents = dayEvents.filter((ev) => getHours(ev.date) === hour);
return (
<>
<div
key={`label-${hour}`}
className="text-[11px] text-right pr-3 pt-0.5"
style={{ color: "var(--color-text-muted)", height: HOUR_HEIGHT }}
>
{format(new Date(2000, 0, 1, hour), "h:mm a")}
</div>
<div
key={`cell-${hour}`}
className="border-b border-l relative"
style={{ borderColor: "var(--color-border)", height: HOUR_HEIGHT }}
>
{hourEvents.map((ev) => (
<DraggableTimeEvent
key={ev.id}
event={ev}
hourHeight={HOUR_HEIGHT}
onEntryClick={onEntryClick}
onEntryDateChange={onEntryDateChange}
/>
))}
</div>
</>
);
})}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// 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<number, CalendarEvent[]>();
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 (
<div className="grid grid-cols-3 md:grid-cols-4 gap-4 p-2">
{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 (
<div
key={month.toISOString()}
className="rounded-lg border p-2"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<div className="text-[12px] font-semibold mb-1" style={{ color: "var(--color-text)" }}>
{format(month, "MMMM")}
</div>
<div className="grid grid-cols-7 gap-px">
{["M", "T", "W", "T", "F", "S", "S"].map((d, i) => (
<div key={i} className="text-[9px] text-center" style={{ color: "var(--color-text-muted)" }}>{d}</div>
))}
{days.map((day) => {
const inMonth = isSameMonth(day, month);
const today = isToday(day);
const hasEvents = monthEvents.some((ev) => isSameDay(ev.date, day));
return (
<div
key={day.toISOString()}
className="text-[9px] text-center py-0.5 relative"
style={{
color: !inMonth ? "transparent" : today ? "var(--color-accent)" : "var(--color-text)",
fontWeight: today ? 700 : 400,
}}
>
{format(day, "d")}
{hasEvents && inMonth && (
<div
className="absolute bottom-0 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full"
style={{ backgroundColor: "var(--color-accent)" }}
/>
)}
</div>
);
})}
</div>
{monthEvents.length > 0 && (
<div className="mt-1 text-[10px]" style={{ color: "var(--color-text-muted)" }}>
{monthEvents.length} event{monthEvents.length !== 1 ? "s" : ""}
</div>
)}
</div>
);
})}
</div>
);
}
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
function CalendarEmptyState({ reason }: { reason: "no-date-field" | "no-events" }) {
return (
<div className="flex flex-col items-center justify-center py-16 gap-2">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ color: "var(--color-text-muted)", opacity: 0.4 }}>
<rect width="18" height="18" x="3" y="4" rx="2" /><path d="M16 2v4" /><path d="M8 2v4" /><path d="M3 10h18" />
</svg>
<span className="text-sm" style={{ color: "var(--color-text-muted)" }}>
{reason === "no-date-field"
? "No date field configured for calendar view. Open view settings to select one."
: "No events to display in this range."}
</span>
</div>
);
}
// ---------------------------------------------------------------------------
// 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 <CalendarEmptyState reason="no-date-field" />;}
const modes: CalendarMode[] = ["day", "week", "month", "year"];
return (
<div className="w-full">
<div className="flex items-center justify-between mb-3 px-1">
<div className="flex items-center gap-2">
<button type="button" onClick={handlePrev} className="p-1.5 rounded-md hover:bg-[var(--color-surface-hover)] transition-colors" style={{ color: "var(--color-text)" }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="m15 18-6-6 6-6" /></svg>
</button>
<button type="button" onClick={handleNext} className="p-1.5 rounded-md hover:bg-[var(--color-surface-hover)] transition-colors" style={{ color: "var(--color-text)" }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="m9 18 6-6-6-6" /></svg>
</button>
<button type="button" onClick={handleToday} className="text-[12px] px-2.5 py-1 rounded-md border hover:bg-[var(--color-surface-hover)] transition-colors" style={{ color: "var(--color-text)", borderColor: "var(--color-border)" }}>
Today
</button>
<h3 className="text-sm font-semibold ml-2" style={{ color: "var(--color-text)" }}>
{formatDateHeader(currentDate, mode)}
</h3>
</div>
<div className="flex rounded-lg border overflow-hidden" style={{ borderColor: "var(--color-border)" }}>
{modes.map((m) => (
<button
key={m}
type="button"
onClick={() => onModeChange(m)}
className="text-[11px] px-3 py-1 capitalize transition-colors"
style={{
background: m === mode ? "var(--color-accent)" : "var(--color-surface)",
color: m === mode ? "#fff" : "var(--color-text-muted)",
borderRight: m !== "year" ? "1px solid var(--color-border)" : undefined,
}}
>
{m}
</button>
))}
</div>
</div>
<div className="rounded-lg border overflow-hidden" style={{ borderColor: "var(--color-border)", background: "var(--color-bg)" }}>
{mode === "month" && <MonthView date={currentDate} events={events} onEntryClick={onEntryClick} onEntryDateChange={onEntryDateChange} />}
{mode === "week" && <WeekView date={currentDate} events={events} onEntryClick={onEntryClick} onEntryDateChange={onEntryDateChange} />}
{mode === "day" && <DayView date={currentDate} events={events} onEntryClick={onEntryClick} onEntryDateChange={onEntryDateChange} />}
{mode === "year" && <YearView date={currentDate} events={events} />}
</div>
</div>
);
}

View File

@ -886,7 +886,18 @@ export function ObjectFilterBar({
active={activeViewName === view.name}
>
<span className="flex-1 truncate">{view.name}</span>
{view.filters && (
{view.view_type && view.view_type !== "table" && (
<span
className="text-[9px] px-1.5 py-0.5 rounded ml-1 capitalize"
style={{
background: "var(--color-surface-hover)",
color: "var(--color-text-muted)",
}}
>
{view.view_type}
</span>
)}
{view.filters && view.filters.rules.length > 0 && (
<span
className="text-[10px] ml-1"
style={{ color: "var(--color-text-muted)" }}

View File

@ -0,0 +1,204 @@
"use client";
import { useMemo } from "react";
import { FormattedFieldValue } from "./formatted-field-value";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type Field = {
id: string;
name: string;
type: string;
enum_values?: string[];
enum_colors?: string[];
related_object_name?: string;
};
type ObjectGalleryProps = {
objectName: string;
fields: Field[];
entries: Record<string, unknown>[];
titleField?: string;
coverField?: string;
members?: Array<{ id: string; name: string }>;
relationLabels?: Record<string, Record<string, 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<string, unknown>, 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<string, unknown>;
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 (
<button
type="button"
onClick={() => onEntryClick?.(entryId)}
className="text-left rounded-xl border p-4 hover:shadow-md transition-all cursor-pointer w-full group"
style={{
borderColor: "var(--color-border)",
background: "var(--color-surface)",
}}
>
{/* Title + badge row */}
<div className="flex items-start justify-between gap-2 mb-2">
<h4
className="text-[13px] font-semibold leading-tight line-clamp-2 group-hover:underline"
style={{ color: "var(--color-text)" }}
>
{title || "Untitled"}
</h4>
{badge && (
<span
className="text-[10px] px-2 py-0.5 rounded-full flex-shrink-0 whitespace-nowrap"
style={{ backgroundColor: badge.color, color: "#fff" }}
>
{badge.text}
</span>
)}
</div>
{/* Field values */}
<div className="flex flex-col gap-1.5">
{displayFields.map((field) => {
const val = entry[field.name];
if (val == null || safeString(val) === "") {return null;}
return (
<div key={field.id} className="flex items-baseline gap-2">
<span
className="text-[10px] flex-shrink-0 min-w-[60px]"
style={{ color: "var(--color-text-muted)" }}
>
{field.name}
</span>
<div className="text-[12px] truncate" style={{ color: "var(--color-text)" }}>
<FormattedFieldValue value={val} fieldType={field.type} mode="table" />
</div>
</div>
);
})}
</div>
{/* Timestamp */}
{entry.created_at != null && (
<div className="mt-3 text-[10px]" style={{ color: "var(--color-text-muted)", opacity: 0.6 }}>
{safeString(entry.created_at).slice(0, 10)}
</div>
)}
</button>
);
}
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
function GalleryEmptyState() {
return (
<div className="flex flex-col items-center justify-center py-16 gap-2">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ color: "var(--color-text-muted)", opacity: 0.4 }}>
<rect width="7" height="7" x="3" y="3" rx="1" /><rect width="7" height="7" x="14" y="3" rx="1" />
<rect width="7" height="7" x="3" y="14" rx="1" /><rect width="7" height="7" x="14" y="14" rx="1" />
</svg>
<span className="text-sm" style={{ color: "var(--color-text-muted)" }}>
No entries to display in gallery view.
</span>
</div>
);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
export function ObjectGallery({
objectName: _objectName,
fields,
entries,
titleField,
coverField,
members: _members,
relationLabels: _relationLabels,
onEntryClick,
}: ObjectGalleryProps) {
if (entries.length === 0) {
return <GalleryEmptyState />;
}
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{entries.map((entry) => (
<GalleryCard
key={safeString(entry.entry_id ?? entry.id)}
entry={entry}
fields={fields}
titleField={titleField}
coverField={coverField}
onEntryClick={onEntryClick}
/>
))}
</div>
);
}

View File

@ -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<string, unknown>[];
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<string, unknown>, 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<string, unknown>;
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 (
<button
type="button"
onClick={() => onEntryClick?.(entryId)}
className="w-full text-left flex items-center gap-3 px-4 py-3 border-b hover:bg-[var(--color-surface-hover)] transition-colors cursor-pointer group"
style={{ borderColor: "var(--color-border)" }}
>
{/* Checkbox placeholder / bullet */}
<div
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: badge?.color ?? "var(--color-text-muted)", opacity: 0.6 }}
/>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span
className="text-[13px] font-medium truncate group-hover:underline"
style={{ color: "var(--color-text)" }}
>
{title || "Untitled"}
</span>
{badge && (
<span
className="text-[10px] px-2 py-0.5 rounded-full flex-shrink-0"
style={{ backgroundColor: badge.color, color: "#fff" }}
>
{badge.text}
</span>
)}
</div>
{subtitle && (
<div
className="text-[11px] truncate mt-0.5"
style={{ color: "var(--color-text-muted)" }}
>
{subtitle}
</div>
)}
</div>
{/* Date on the right */}
{dateVal && (
<span
className="text-[11px] flex-shrink-0 tabular-nums"
style={{ color: "var(--color-text-muted)" }}
>
{dateVal.slice(0, 10)}
</span>
)}
</button>
);
}
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
function ListEmptyState() {
return (
<div className="flex flex-col items-center justify-center py-16 gap-2">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ color: "var(--color-text-muted)", opacity: 0.4 }}>
<path d="M8 6h13" /><path d="M8 12h13" /><path d="M8 18h13" /><path d="M3 6h.01" /><path d="M3 12h.01" /><path d="M3 18h.01" />
</svg>
<span className="text-sm" style={{ color: "var(--color-text-muted)" }}>
No entries to display in list view.
</span>
</div>
);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
export function ObjectList({
objectName: _objectName,
fields,
entries,
titleField,
subtitleField,
members: _members,
onEntryClick,
}: ObjectListProps) {
if (entries.length === 0) {
return <ListEmptyState />;
}
return (
<div
className="rounded-lg border overflow-hidden"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
{entries.map((entry) => (
<ListRow
key={safeString(entry.entry_id ?? entry.id)}
entry={entry}
fields={fields}
titleField={titleField}
subtitleField={subtitleField}
onEntryClick={onEntryClick}
/>
))}
</div>
);
}

View File

@ -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<string, unknown>;
};
export type TimelineDateChangePayload = {
entryId: string;
newStartDate: string;
newEndDate: string;
};
type ObjectTimelineProps = {
objectName: string;
fields: Field[];
entries: Record<string, unknown>[];
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<TimelineZoom, { dayWidth: number; headerFormat: string }> = {
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<string, unknown>, 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<string, unknown>, 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<string, unknown>[],
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 (
<div
className="absolute rounded flex items-center text-[11px] truncate select-none"
style={{
left: renderX,
width: renderW,
top: (ROW_HEIGHT - BAR_HEIGHT) / 2,
height: BAR_HEIGHT,
backgroundColor: item.color,
color: "#fff",
zIndex: drag ? 20 : 2,
cursor: editable ? (drag === "move" ? "grabbing" : "grab") : "pointer",
opacity: drag ? 0.85 : 1,
transition: drag ? "none" : "left 0.15s, width 0.15s",
}}
onPointerDown={(e) => 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 && (
<div
className="absolute left-0 top-0 bottom-0 cursor-ew-resize hover:bg-white/20 rounded-l"
style={{ width: HANDLE_WIDTH }}
onPointerDown={(e) => handlePointerDown(e, "resize-left")}
/>
)}
<span className="px-2 truncate flex-1">{renderW > 60 ? (item.title || "Untitled") : ""}</span>
{/* Right resize handle */}
{editable && (
<div
className="absolute right-0 top-0 bottom-0 cursor-ew-resize hover:bg-white/20 rounded-r"
style={{ width: HANDLE_WIDTH }}
onPointerDown={(e) => handlePointerDown(e, "resize-right")}
/>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
function TimelineEmptyState({ reason }: { reason: "no-fields" | "no-items" }) {
return (
<div className="flex flex-col items-center justify-center py-16 gap-2">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ color: "var(--color-text-muted)", opacity: 0.4 }}>
<path d="M8 6h13" /><path d="M8 12h13" /><path d="M8 18h13" /><path d="M3 6h.01" /><path d="M3 12h.01" /><path d="M3 18h.01" />
</svg>
<span className="text-sm" style={{ color: "var(--color-text-muted)" }}>
{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."}
</span>
</div>
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export function ObjectTimeline({
objectName: _objectName,
fields,
entries,
startDateField,
endDateField,
groupField,
zoom,
onZoomChange,
members: _members,
onEntryClick,
onEntryDateChange,
}: ObjectTimelineProps) {
const containerRef = useRef<HTMLDivElement>(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<string, TimelineItem[]>();
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 <TimelineEmptyState reason="no-fields" />;}
if (items.length === 0) {return <TimelineEmptyState reason="no-items" />;}
const todayX = dateToX(startOfDay(new Date()));
const zoomLevels: TimelineZoom[] = ["day", "week", "month", "quarter"];
return (
<div className="w-full">
<div className="flex items-center justify-between mb-3 px-1">
<div className="text-[12px]" style={{ color: "var(--color-text-muted)" }}>
{items.length} item{items.length !== 1 ? "s" : ""}
</div>
<div className="flex items-center gap-2">
<span className="text-[11px]" style={{ color: "var(--color-text-muted)" }}>Zoom:</span>
<div className="flex rounded-lg border overflow-hidden" style={{ borderColor: "var(--color-border)" }}>
{zoomLevels.map((z) => (
<button
key={z}
type="button"
onClick={() => onZoomChange(z)}
className="text-[11px] px-3 py-1 capitalize transition-colors"
style={{
background: z === zoom ? "var(--color-accent)" : "var(--color-surface)",
color: z === zoom ? "#fff" : "var(--color-text-muted)",
borderRight: z !== "quarter" ? "1px solid var(--color-border)" : undefined,
}}
>
{z}
</button>
))}
</div>
</div>
</div>
<div className="rounded-lg border overflow-hidden" style={{ borderColor: "var(--color-border)", background: "var(--color-bg)" }}>
<div className="flex">
<div
className="flex-shrink-0 border-r"
style={{ width: SIDEBAR_WIDTH, borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<div
className="border-b px-3 flex items-center text-[11px] font-medium"
style={{ height: HEADER_HEIGHT, borderColor: "var(--color-border)", color: "var(--color-text-muted)" }}
>
Name
</div>
{flatRows.map((row, i) => (
<div
key={i}
className={`px-3 flex items-center border-b truncate ${row.type === "group" ? "text-[10px] font-semibold uppercase tracking-wider" : "text-[12px] cursor-pointer hover:bg-[var(--color-surface-hover)]"}`}
style={{
height: ROW_HEIGHT,
borderColor: "var(--color-border)",
color: row.type === "group" ? "var(--color-text-muted)" : "var(--color-text)",
background: row.type === "group" ? "var(--color-surface-hover)" : undefined,
}}
onClick={() => row.item && onEntryClick?.(row.item.id)}
>
{row.type === "group" ? row.group : row.item?.title || "Untitled"}
</div>
))}
</div>
<div
ref={containerRef}
className="flex-1 overflow-x-auto overflow-y-hidden"
style={{ maxHeight: `calc(100vh - 280px)` }}
>
<div style={{ width: totalWidth, position: "relative" }}>
<div className="border-b flex" style={{ height: HEADER_HEIGHT, borderColor: "var(--color-border)" }}>
{ticks.map((tick, i) => {
const tx = dateToX(tick.date);
const nextX = i < ticks.length - 1 ? dateToX(ticks[i + 1].date) : totalWidth;
return (
<div
key={i}
className="absolute border-r flex items-end pb-1 px-2 text-[10px]"
style={{ left: tx, width: nextX - tx, height: HEADER_HEIGHT, borderColor: "var(--color-border)", color: "var(--color-text-muted)" }}
>
{tick.label}
</div>
);
})}
</div>
{flatRows.map((row, i) => (
<div
key={i}
className="relative border-b"
style={{
height: ROW_HEIGHT,
borderColor: "var(--color-border)",
background: row.type === "group" ? "var(--color-surface-hover)" : undefined,
}}
>
{row.item && (() => {
const barItem = row.item;
const bx = dateToX(barItem.startDate);
const bw = Math.max(dateToX(barItem.endDate) - bx, dayWidth * 0.5);
return (
<DraggableBar
item={barItem}
x={bx}
w={bw}
dayWidth={dayWidth}
onEntryClick={onEntryClick}
onEntryDateChange={onEntryDateChange}
/>
);
})()}
</div>
))}
{todayX >= 0 && todayX <= totalWidth && (
<div
className="absolute top-0 bottom-0 pointer-events-none"
style={{ left: todayX, width: 2, backgroundColor: "var(--color-accent)", opacity: 0.6, zIndex: 10 }}
/>
)}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -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 (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-medium" style={{ color: "var(--color-text-muted)" }}>
{label}
</label>
<select
value={value ?? ""}
onChange={(e) => onChange(e.target.value || undefined)}
className="text-[12px] rounded-md border px-2 py-1.5 bg-transparent"
style={{
borderColor: "var(--color-border)",
color: "var(--color-text)",
background: "var(--color-surface)",
}}
>
{allowEmpty && <option value="">None</option>}
{filtered.length === 0 && (
<option value="" disabled>No matching fields</option>
)}
{filtered.map((f) => (
<option key={f.id} value={f.name}>{f.name}</option>
))}
</select>
</div>
);
}
function ModeSelect<T extends string>({
label,
value,
options,
onChange,
}: {
label: string;
value: T;
options: { value: T; label: string }[];
onChange: (value: T) => void;
}) {
return (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-medium" style={{ color: "var(--color-text-muted)" }}>
{label}
</label>
<div
className="flex rounded-md border overflow-hidden"
style={{ borderColor: "var(--color-border)" }}
>
{options.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => onChange(opt.value)}
className="text-[11px] px-2.5 py-1 flex-1 transition-colors capitalize"
style={{
background: opt.value === value ? "var(--color-accent)" : "var(--color-surface)",
color: opt.value === value ? "#fff" : "var(--color-text-muted)",
}}
>
{opt.label}
</button>
))}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Settings panels per view type
// ---------------------------------------------------------------------------
function KanbanSettings({
settings,
fields,
onSettingsChange,
}: {
settings: ViewTypeSettings;
fields: Field[];
onSettingsChange: (s: ViewTypeSettings) => void;
}) {
return (
<FieldSelect
label="Group by field"
value={settings.kanbanField}
onChange={(v) => onSettingsChange({ ...settings, kanbanField: v })}
fields={fields}
filterType="enum"
/>
);
}
function CalendarSettings({
settings,
fields,
onSettingsChange,
}: {
settings: ViewTypeSettings;
fields: Field[];
onSettingsChange: (s: ViewTypeSettings) => void;
}) {
return (
<>
<FieldSelect
label="Date field"
value={settings.calendarDateField}
onChange={(v) => onSettingsChange({ ...settings, calendarDateField: v })}
fields={fields}
filterType="date"
/>
<FieldSelect
label="End date field (optional)"
value={settings.calendarEndDateField}
onChange={(v) => onSettingsChange({ ...settings, calendarEndDateField: v })}
fields={fields}
filterType="date"
allowEmpty
/>
<ModeSelect<CalendarMode>
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 (
<>
<FieldSelect
label="Start date field"
value={settings.timelineStartField}
onChange={(v) => onSettingsChange({ ...settings, timelineStartField: v })}
fields={fields}
filterType="date"
/>
<FieldSelect
label="End date field"
value={settings.timelineEndField}
onChange={(v) => onSettingsChange({ ...settings, timelineEndField: v })}
fields={fields}
filterType="date"
allowEmpty
/>
<FieldSelect
label="Group by (optional)"
value={settings.timelineGroupField}
onChange={(v) => onSettingsChange({ ...settings, timelineGroupField: v })}
fields={fields}
filterType="enum"
allowEmpty
/>
<ModeSelect<TimelineZoom>
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 (
<>
<FieldSelect
label="Title field"
value={settings.galleryTitleField}
onChange={(v) => onSettingsChange({ ...settings, galleryTitleField: v })}
fields={fields}
filterType="text"
/>
<FieldSelect
label="Cover field (optional)"
value={settings.galleryCoverField}
onChange={(v) => onSettingsChange({ ...settings, galleryCoverField: v })}
fields={fields}
allowEmpty
/>
</>
);
}
function ListSettings({
settings,
fields,
onSettingsChange,
}: {
settings: ViewTypeSettings;
fields: Field[];
onSettingsChange: (s: ViewTypeSettings) => void;
}) {
return (
<>
<FieldSelect
label="Title field"
value={settings.listTitleField}
onChange={(v) => onSettingsChange({ ...settings, listTitleField: v })}
fields={fields}
filterType="text"
/>
<FieldSelect
label="Subtitle field (optional)"
value={settings.listSubtitleField}
onChange={(v) => onSettingsChange({ ...settings, listSubtitleField: v })}
fields={fields}
filterType={["text", "email", "richtext"]}
allowEmpty
/>
</>
);
}
// ---------------------------------------------------------------------------
// Main popover
// ---------------------------------------------------------------------------
function GearIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
}
export function ViewSettingsPopover({
viewType,
settings,
fields,
onSettingsChange,
}: ViewSettingsPopoverProps) {
const [open, setOpen] = useState(false);
const popoverRef = useRef<HTMLDivElement>(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<ViewType, string> = {
table: "",
kanban: "Board Settings",
calendar: "Calendar Settings",
timeline: "Timeline Settings",
gallery: "Gallery Settings",
list: "List Settings",
};
return (
<div className="relative" ref={popoverRef}>
<button
type="button"
onClick={() => setOpen(!open)}
className="p-1.5 rounded-md hover:bg-[var(--color-surface-hover)] transition-colors"
style={{ color: "var(--color-text-muted)" }}
title="View settings"
>
<GearIcon />
</button>
{open && (
<div
className="absolute right-0 top-full mt-1 z-50 rounded-lg border shadow-lg p-3 min-w-[240px] flex flex-col gap-3"
style={{
borderColor: "var(--color-border)",
background: "var(--color-surface)",
boxShadow: "0 8px 24px rgba(0,0,0,0.12)",
}}
>
<div className="text-[11px] font-semibold" style={{ color: "var(--color-text)" }}>
{panelTitle[viewType]}
</div>
{viewType === "kanban" && (
<KanbanSettings settings={settings} fields={fields} onSettingsChange={onSettingsChange} />
)}
{viewType === "calendar" && (
<CalendarSettings settings={settings} fields={fields} onSettingsChange={onSettingsChange} />
)}
{viewType === "timeline" && (
<TimelineSettings settings={settings} fields={fields} onSettingsChange={onSettingsChange} />
)}
{viewType === "gallery" && (
<GallerySettings settings={settings} fields={fields} onSettingsChange={onSettingsChange} />
)}
{viewType === "list" && (
<ListSettings settings={settings} fields={fields} onSettingsChange={onSettingsChange} />
)}
</div>
)}
</div>
);
}

View File

@ -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 (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3v18" /><path d="M3 12h18" /><rect width="18" height="18" x="3" y="3" rx="2" />
</svg>
);
}
function KanbanIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="6" height="14" x="3" y="5" rx="1" /><rect width="6" height="10" x="9" y="9" rx="1" /><rect width="6" height="16" x="15" y="3" rx="1" />
</svg>
);
}
function CalendarIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="18" height="18" x="3" y="4" rx="2" /><path d="M16 2v4" /><path d="M8 2v4" /><path d="M3 10h18" />
</svg>
);
}
function TimelineIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h7" /><path d="M6 12h10" /><path d="M5 18h5" /><path d="M14 6h7" /><path d="M18 12h3" /><path d="M12 18h9" />
</svg>
);
}
function GalleryIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="7" height="7" x="3" y="3" rx="1" /><rect width="7" height="7" x="14" y="3" rx="1" />
<rect width="7" height="7" x="3" y="14" rx="1" /><rect width="7" height="7" x="14" y="14" rx="1" />
</svg>
);
}
function ListIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M8 6h13" /><path d="M8 12h13" /><path d="M8 18h13" /><path d="M3 6h.01" /><path d="M3 12h.01" /><path d="M3 18h.01" />
</svg>
);
}
const VIEW_TYPE_META: Record<ViewType, { icon: () => 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 (
<div
className="flex items-center rounded-lg border overflow-hidden"
style={{ borderColor: "var(--color-border)" }}
>
{VIEW_TYPES.map((vt) => {
const meta = VIEW_TYPE_META[vt];
const Icon = meta.icon;
const isActive = vt === value;
return (
<button
key={vt}
type="button"
onClick={() => onChange(vt)}
className="flex items-center gap-1 px-2.5 py-1.5 text-[11px] transition-colors"
style={{
background: isActive ? "var(--color-accent)" : "var(--color-surface)",
color: isActive ? "#fff" : "var(--color-text-muted)",
borderRight: vt !== "list" ? "1px solid var(--color-border)" : undefined,
}}
title={meta.label}
>
<Icon />
<span className="hidden sm:inline">{meta.label}</span>
</button>
);
})}
</div>
);
}

View File

@ -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 = (
<div className="overflow-auto h-full">
<CodeViewer content={c.data.content} filename={c.filename} />
<div className="h-full">
<MonacoCodeEditor content={c.data.content} filename={c.filename} filePath={c.filePath} />
</div>
);
break;
@ -1873,6 +1943,18 @@ function ChatSidebarPreview({
</div>
);
break;
case "spreadsheet":
body = (
<div className="h-full">
<SpreadsheetEditor
url={c.url}
filename={c.filename}
filePath={c.filePath}
compact
/>
</div>
);
break;
case "database":
body = (
<div className="overflow-auto h-full">
@ -1880,6 +1962,18 @@ function ChatSidebarPreview({
</div>
);
break;
case "richDocument":
body = (
<div className="h-full">
<RichDocumentEditor
mode={c.mode}
initialHtml={c.html}
filePath={c.filePath}
compact
/>
</div>
);
break;
case "directory":
body = (
<div className="flex flex-col items-center justify-center h-full gap-4 px-6">
@ -2094,9 +2188,10 @@ function ContentRenderer({
case "code":
return (
<CodeViewer
<MonacoCodeEditor
content={content.data.content}
filename={content.filename}
filePath={content.filePath}
/>
);
@ -2112,10 +2207,10 @@ function ContentRenderer({
case "spreadsheet":
return (
<FileViewer
filename={content.filename}
type="spreadsheet"
<SpreadsheetEditor
url={content.url}
filename={content.filename}
filePath={content.filePath}
/>
);
@ -2184,6 +2279,16 @@ function ContentRenderer({
case "duckdb-missing":
return <DuckDBMissing />;
case "richDocument":
return (
<RichDocumentEditor
mode={content.mode}
initialHtml={content.html}
filePath={content.filePath}
onSave={onRefreshTree}
/>
);
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<string, unknown>) => {
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<ViewType>(
() => resolveViewType(undefined, undefined, data.object.default_view),
);
const [viewSettings, setViewSettings] = useState<ViewTypeSettings>(
() => data.viewSettings ?? {},
);
// --- Filter state ---
const [filters, setFilters] = useState<FilterGroup>(() => emptyFilterGroup());
const [savedViews, setSavedViews] = useState<SavedView[]>(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<string, string> = { [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<string, string> = { [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 (
<div className="p-6">
{/* Object header */}
@ -2570,7 +2816,7 @@ function ObjectView({
)}
</div>
{/* Filter bar */}
{/* View switcher + Filter bar */}
<div
className="mb-4 py-3 px-4 rounded-lg border"
style={{
@ -2578,6 +2824,15 @@ function ObjectView({
background: "var(--color-surface)",
}}
>
<div className="flex items-center justify-between gap-3 mb-2">
<ViewTypeSwitcher value={currentViewType} onChange={handleViewTypeChange} />
<ViewSettingsPopover
viewType={currentViewType}
settings={effectiveSettings}
fields={fieldsWithTimestamps}
onSettingsChange={handleViewSettingsChange}
/>
</div>
<ObjectFilterBar
fields={data.fields}
filters={filters}
@ -2592,8 +2847,8 @@ function ObjectView({
/>
</div>
{/* Table or Kanban */}
{data.object.default_view === "kanban" ? (
{/* View renderer */}
{currentViewType === "kanban" && (
<ObjectKanban
objectName={data.object.name}
fields={data.fields}
@ -2604,7 +2859,8 @@ function ObjectView({
onEntryClick={onOpenEntry ? (entryId) => onOpenEntry(data.object.name, entryId) : undefined}
onRefresh={handleRefresh}
/>
) : (
)}
{currentViewType === "table" && (
<ObjectTable
objectName={data.object.name}
fields={data.fields}
@ -2627,6 +2883,58 @@ function ObjectView({
onServerSearch={handleServerSearch}
/>
)}
{currentViewType === "calendar" && (
<ObjectCalendar
objectName={data.object.name}
fields={data.fields}
entries={filteredEntries}
dateField={effectiveSettings.calendarDateField ?? ""}
endDateField={effectiveSettings.calendarEndDateField}
mode={effectiveSettings.calendarMode ?? "month"}
onModeChange={(mode) => handleViewSettingsChange({ ...effectiveSettings, calendarMode: mode })}
members={members}
onEntryClick={onOpenEntry ? (entryId) => onOpenEntry(data.object.name, entryId) : undefined}
onEntryDateChange={handleCalendarDateChange}
/>
)}
{currentViewType === "timeline" && (
<ObjectTimeline
objectName={data.object.name}
fields={data.fields}
entries={filteredEntries}
startDateField={effectiveSettings.timelineStartField ?? ""}
endDateField={effectiveSettings.timelineEndField}
groupField={effectiveSettings.timelineGroupField}
zoom={effectiveSettings.timelineZoom ?? "week"}
onZoomChange={(zoom) => handleViewSettingsChange({ ...effectiveSettings, timelineZoom: zoom })}
members={members}
onEntryClick={onOpenEntry ? (entryId) => onOpenEntry(data.object.name, entryId) : undefined}
onEntryDateChange={handleTimelineDateChange}
/>
)}
{currentViewType === "gallery" && (
<ObjectGallery
objectName={data.object.name}
fields={data.fields}
entries={filteredEntries}
titleField={effectiveSettings.galleryTitleField}
coverField={effectiveSettings.galleryCoverField}
members={members}
relationLabels={data.relationLabels}
onEntryClick={onOpenEntry ? (entryId) => onOpenEntry(data.object.name, entryId) : undefined}
/>
)}
{currentViewType === "list" && (
<ObjectList
objectName={data.object.name}
fields={data.fields}
entries={filteredEntries}
titleField={effectiveSettings.listTitleField}
subtitleField={effectiveSettings.listSubtitleField}
members={members}
onEntryClick={onOpenEntry ? (entryId) => onOpenEntry(data.object.name, entryId) : undefined}
/>
)}
</div>
);
}