import { getDb } from "../loader.js"; import { getWidgetComponents as getComponentRegistry } from "./components.js"; import type { Widget, WidgetArea, WidgetRow, WidgetComponentDef } from "./types.js"; export type { Widget, WidgetArea, WidgetType, WidgetComponentDef, PropDef, CreateWidgetAreaInput, CreateWidgetInput, UpdateWidgetInput, ReorderWidgetsInput, } from "./types.js"; /** * Get a widget area by name, with all its widgets. * * Single query with a left join rather than area-then-widgets so the * common case costs one round-trip. An area with no widgets yields one * row with null widget columns, which we skip when mapping. */ export async function getWidgetArea(name: string): Promise { const db = await getDb(); const rows = await db .selectFrom("_emdash_widget_areas as a") .leftJoin("_emdash_widgets as w", "w.area_id", "a.id") .select([ "a.id as a_id", "a.name as a_name", "a.label as a_label", "a.description as a_description", "w.id as w_id", "w.type as w_type", "w.title as w_title", "w.content as w_content", "w.menu_name as w_menu_name", "w.component_id as w_component_id", "w.component_props as w_component_props", "w.area_id as w_area_id", "w.sort_order as w_sort_order", "w.created_at as w_created_at", ]) .where("a.name", "=", name) .orderBy("w.sort_order", "asc") .execute(); const first = rows[0]; if (!first) return null; const widgets: Widget[] = []; for (const row of rows) { if (row.w_id === null) continue; // area has no widgets (left-join null row) // Left-join makes every w_* column nullable in the type; at runtime // they're all non-null once w_id is (we match on widgets.area_id, so // a widget row always has the not-null columns filled). Cast is the // price of that structural fact. // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- left-join row is non-null when w_id is set; see above const widgetRow = { id: row.w_id, type: row.w_type, title: row.w_title, content: row.w_content, menu_name: row.w_menu_name, component_id: row.w_component_id, component_props: row.w_component_props, area_id: row.w_area_id, sort_order: row.w_sort_order, created_at: row.w_created_at, } as WidgetRow; widgets.push(rowToWidget(widgetRow)); } return { id: first.a_id, name: first.a_name, label: first.a_label, description: first.a_description ?? undefined, widgets, }; } /** * Get all widget areas with their widgets */ export async function getWidgetAreas(): Promise { const db = await getDb(); // Get all areas const areaRows = await db.selectFrom("_emdash_widget_areas").selectAll().execute(); // Get all widgets const widgetRows = await db .selectFrom("_emdash_widgets") .selectAll() .$castTo() .orderBy("sort_order", "asc") .execute(); // Group widgets by area const widgetsByArea = new Map(); for (const row of widgetRows) { if (!widgetsByArea.has(row.area_id)) { widgetsByArea.set(row.area_id, []); } widgetsByArea.get(row.area_id)!.push(rowToWidget(row)); } // Combine return areaRows.map((areaRow) => ({ id: areaRow.id, name: areaRow.name, label: areaRow.label, description: areaRow.description ?? undefined, widgets: widgetsByArea.get(areaRow.id) || [], })); } /** * Get available widget components (for admin UI) */ export function getWidgetComponents(): WidgetComponentDef[] { return getComponentRegistry(); } /** * Convert a widget row to the API type */ export function rowToWidget(row: WidgetRow): Widget { const widget: Widget = { id: row.id, type: row.type, title: row.title ?? undefined, }; // Type-specific fields if (row.type === "content" && row.content) { try { widget.content = JSON.parse(row.content); } catch { // Invalid JSON, ignore } } if (row.type === "menu" && row.menu_name) { widget.menuName = row.menu_name; } if (row.type === "component" && row.component_id) { widget.componentId = row.component_id; if (row.component_props) { try { widget.componentProps = JSON.parse(row.component_props); } catch { // Invalid JSON, ignore } } } return widget; }