feat(web): add calendar timeline gallery and list object views
This commit is contained in:
parent
3f6f181552
commit
cd7ea43a91
770
apps/web/app/components/workspace/object-calendar.tsx
Normal file
770
apps/web/app/components/workspace/object-calendar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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)" }}
|
||||
|
||||
204
apps/web/app/components/workspace/object-gallery.tsx
Normal file
204
apps/web/app/components/workspace/object-gallery.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
205
apps/web/app/components/workspace/object-list.tsx
Normal file
205
apps/web/app/components/workspace/object-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
535
apps/web/app/components/workspace/object-timeline.tsx
Normal file
535
apps/web/app/components/workspace/object-timeline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
376
apps/web/app/components/workspace/view-settings-popover.tsx
Normal file
376
apps/web/app/components/workspace/view-settings-popover.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
apps/web/app/components/workspace/view-type-switcher.tsx
Normal file
107
apps/web/app/components/workspace/view-type-switcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user