Emdash source with visual editor image upload fix

Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
2026-05-03 10:44:54 +07:00
parent 78f81bebb6
commit 2d1be52177
2352 changed files with 662964 additions and 0 deletions

View File

@@ -0,0 +1,105 @@
import type { WidgetComponentDef } from "./types.js";
/**
* Core widget components registry
* These are built-in widgets that ship with EmDash
*/
export const coreWidgetComponents: WidgetComponentDef[] = [
{
id: "core:recent-posts",
label: "Recent Posts",
description: "Display a list of recent posts",
props: {
count: {
type: "number",
label: "Number of posts",
default: 5,
},
showThumbnails: {
type: "boolean",
label: "Show thumbnails",
default: false,
},
showDate: {
type: "boolean",
label: "Show date",
default: true,
},
},
},
{
id: "core:categories",
label: "Categories",
description: "Display category list",
props: {
showCount: {
type: "boolean",
label: "Show post count",
default: true,
},
hierarchical: {
type: "boolean",
label: "Show hierarchy",
default: true,
},
},
},
{
id: "core:tags",
label: "Tags",
description: "Display tag cloud",
props: {
showCount: {
type: "boolean",
label: "Show count",
default: false,
},
limit: {
type: "number",
label: "Maximum tags",
default: 20,
},
},
},
{
id: "core:search",
label: "Search",
description: "Search form",
props: {
placeholder: {
type: "string",
label: "Placeholder text",
default: "Search...",
},
},
},
{
id: "core:archives",
label: "Archives",
description: "Monthly/yearly archives",
props: {
type: {
type: "select",
label: "Group by",
default: "monthly",
options: [
{ value: "monthly", label: "Monthly" },
{ value: "yearly", label: "Yearly" },
],
},
limit: {
type: "number",
label: "Limit",
default: 12,
},
},
},
];
/**
* Get all widget component definitions (core + plugin-registered)
* For now, only returns core components. Plugin widgets will be added later.
*/
export function getWidgetComponents(): WidgetComponentDef[] {
return [...coreWidgetComponents];
}

View File

@@ -0,0 +1,160 @@
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<WidgetArea | null> {
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<WidgetArea[]> {
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<WidgetRow>()
.orderBy("sort_order", "asc")
.execute();
// Group widgets by area
const widgetsByArea = new Map<string, Widget[]>();
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;
}

View File

@@ -0,0 +1,81 @@
import type { PortableTextBlock } from "../fields/index.js";
export type WidgetType = "content" | "menu" | "component";
export interface Widget {
id: string;
type: WidgetType;
title?: string;
// Type-specific fields
content?: PortableTextBlock[]; // For content type
menuName?: string; // For menu type
componentId?: string; // For component type
componentProps?: Record<string, unknown>;
}
export interface WidgetArea {
id: string;
name: string;
label: string;
description?: string;
widgets: Widget[];
}
// For DB layer
export interface WidgetRow {
id: string;
area_id: string;
sort_order: number;
type: WidgetType;
title: string | null;
content: string | null; // JSON string
menu_name: string | null;
component_id: string | null;
component_props: string | null; // JSON string
created_at: string;
}
export interface WidgetAreaRow {
id: string;
name: string;
label: string;
description: string | null;
created_at: string;
}
// Component registration
export interface WidgetComponentDef {
id: string; // 'core:recent-posts'
label: string; // 'Recent Posts'
description?: string;
props: Record<string, PropDef>;
}
export interface PropDef {
type: "string" | "number" | "boolean" | "select";
label: string;
default?: unknown;
options?: Array<{ value: string; label: string }>; // For select
}
// Admin API types
export interface CreateWidgetAreaInput {
name: string;
label: string;
description?: string;
}
export interface CreateWidgetInput {
type: WidgetType;
title?: string;
content?: PortableTextBlock[];
menuName?: string;
componentId?: string;
componentProps?: Record<string, unknown>;
}
export interface UpdateWidgetInput extends Partial<CreateWidgetInput> {}
export interface ReorderWidgetsInput {
widgetIds: string[];
}