From 2a471a6a30d010d35737d2b325c4df0c7d8595ad Mon Sep 17 00:00:00 2001 From: Zeus Date: Wed, 4 Feb 2026 18:20:22 -0500 Subject: [PATCH 1/7] =?UTF-8?q?=E2=9A=A1=20Add=20dashboard=20export/import?= =?UTF-8?q?=20API=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/dashboard/export - Export widgets, layout, and theme to .glance.json - POST /api/dashboard/import/preview - Validate import, detect conflicts, report missing credentials - POST /api/dashboard/import - Import with conflict resolution (overwrite/rename/skip) Supports breakpoint-specific layouts (desktop/tablet/mobile) and theme preservation. --- src/app/api/dashboard/export/route.ts | 245 ++++++++++ src/app/api/dashboard/import/preview/route.ts | 294 +++++++++++ src/app/api/dashboard/import/route.ts | 460 ++++++++++++++++++ 3 files changed, 999 insertions(+) create mode 100644 src/app/api/dashboard/export/route.ts create mode 100644 src/app/api/dashboard/import/preview/route.ts create mode 100644 src/app/api/dashboard/import/route.ts diff --git a/src/app/api/dashboard/export/route.ts b/src/app/api/dashboard/export/route.ts new file mode 100644 index 0000000..bc834e8 --- /dev/null +++ b/src/app/api/dashboard/export/route.ts @@ -0,0 +1,245 @@ +import { NextRequest, NextResponse } from "next/server"; + +// Prevent static generation - this route requires runtime database access +export const dynamic = "force-dynamic"; + +import { validateAuthOrInternal } from "@/lib/auth"; +import { + getAllWidgets, + getAllCustomWidgets, + getCustomWidget, + getSetting, +} from "@/lib/db"; + +interface DashboardExportFormat { + version: 1; + name: string; + description?: string; + author?: string; + exported_at: string; + glance_version: string; + widgets: Array<{ + slug: string; + name: string; + description?: string; + source_code: string; + server_code?: string; + server_code_enabled: boolean; + default_size: { w: number; h: number }; + min_size: { w: number; h: number }; + refresh_interval: number; + fetch: unknown; + credentials?: unknown[]; + setup?: unknown; + cache?: unknown; + data_schema?: unknown; + }>; + layout: { + desktop: Array<{ widget: string; x: number; y: number; w: number; h: number }>; + tablet?: Array<{ widget: string; x: number; y: number; w: number; h: number }>; + mobile?: Array<{ widget: string; x: number; y: number; w: number; h: number }>; + }; + theme?: { + name: string; + lightCss?: string; + darkCss?: string; + }; + credentials_needed: Array<{ + provider: string; + description: string; + required: boolean; + }>; +} + +/** + * POST /api/dashboard/export + * + * Export the dashboard configuration to .glance.json format + * + * Body: + * - widgets: ["all"] | ["slug1", "slug2"] - which widgets to export + * - include_theme: boolean - include theme in export + * - include_layout: boolean - include layout in export + * - breakpoints: ["desktop", "tablet", "mobile"] - which breakpoints to include + */ +export async function POST(request: NextRequest) { + const auth = validateAuthOrInternal(request); + if (!auth.authorized) { + return NextResponse.json({ error: auth.error }, { status: 401 }); + } + + try { + const body = await request.json(); + const { + widgets: widgetFilter = ["all"], + include_theme = true, + include_layout = true, + breakpoints = ["desktop", "tablet", "mobile"], + name = "My Dashboard", + description, + author, + } = body; + + // Get all widget instances + const allWidgetInstances = getAllWidgets(); + + // Get all custom widgets + const allCustomWidgets = getAllCustomWidgets(); + + // Filter custom widgets based on what's actually on the dashboard + const customWidgetIdsOnDashboard = new Set( + allWidgetInstances + .map(w => (w as any).custom_widget_id) + .filter(Boolean) + ); + + // Determine which widgets to export + let widgetsToExport = allCustomWidgets.filter(w => + customWidgetIdsOnDashboard.has(w.id) + ); + + if (widgetFilter[0] !== "all") { + // Filter by specified slugs + widgetsToExport = widgetsToExport.filter(w => + widgetFilter.includes(w.slug) + ); + } + + // Build widgets array + const exportWidgets = widgetsToExport.map(widget => ({ + slug: widget.slug, + name: widget.name, + description: widget.description || undefined, + source_code: widget.source_code, + server_code: widget.server_code || undefined, + server_code_enabled: widget.server_code_enabled, + default_size: widget.default_size, + min_size: widget.min_size, + refresh_interval: widget.refresh_interval, + fetch: widget.fetch, + credentials: widget.credentials.length > 0 ? widget.credentials : undefined, + setup: widget.setup || undefined, + cache: widget.cache || undefined, + data_schema: widget.data_schema || undefined, + })); + + // Build layout + const layout: DashboardExportFormat["layout"] = { + desktop: [], + tablet: [], + mobile: [], + }; + + if (include_layout) { + const exportedSlugs = new Set(widgetsToExport.map(w => w.slug)); + + // Build desktop layout from widget positions + for (const instance of allWidgetInstances) { + const customWidgetId = (instance as any).custom_widget_id; + if (!customWidgetId) continue; + + const customWidget = getCustomWidget(customWidgetId); + if (!customWidget || !exportedSlugs.has(customWidget.slug)) continue; + + const position = JSON.parse(instance.position); + const layoutItem = { + widget: customWidget.slug, + x: position.x, + y: position.y, + w: position.w, + h: position.h, + }; + + layout.desktop.push(layoutItem); + } + + // For now, tablet and mobile use the same layout as desktop + // (responsive layouts could be added later from mobile_position column) + if (breakpoints.includes("tablet")) { + layout.tablet = [...layout.desktop]; + } + if (breakpoints.includes("mobile")) { + layout.mobile = [...layout.desktop]; + } + + // Remove empty breakpoints + if (!breakpoints.includes("tablet")) { + delete layout.tablet; + } + if (!breakpoints.includes("mobile")) { + delete layout.mobile; + } + } + + // Get theme + let theme: DashboardExportFormat["theme"] | undefined; + if (include_theme) { + const themeJson = getSetting("custom_theme"); + if (themeJson) { + const customTheme = JSON.parse(themeJson); + theme = { + name: customTheme.name, + lightCss: customTheme.lightCss || undefined, + darkCss: customTheme.darkCss || undefined, + }; + } + } + + // Collect all unique credentials needed + const credentialsNeeded = new Map(); + + for (const widget of widgetsToExport) { + if (widget.credentials && Array.isArray(widget.credentials)) { + for (const cred of widget.credentials) { + const credId = (cred as any).id || (cred as any).provider; + if (credId && !credentialsNeeded.has(credId)) { + credentialsNeeded.set(credId, { + description: (cred as any).description || (cred as any).name || credId, + required: true, + }); + } + } + } + } + + // Build final export + const exportData: DashboardExportFormat = { + version: 1, + name, + description, + author, + exported_at: new Date().toISOString(), + glance_version: process.env.npm_package_version || "0.5.2", + widgets: exportWidgets, + layout, + theme, + credentials_needed: Array.from(credentialsNeeded.entries()).map(([provider, info]) => ({ + provider, + description: info.description, + required: info.required, + })), + }; + + // Return as downloadable file + const filename = `${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.glance.json`; + + return new NextResponse(JSON.stringify(exportData, null, 2), { + status: 200, + headers: { + "Content-Type": "application/json", + "Content-Disposition": `attachment; filename="${filename}"`, + }, + }); + } catch (error) { + console.error("Failed to export dashboard:", error); + return NextResponse.json( + { + error: + error instanceof Error + ? error.message + : "Failed to export dashboard", + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/dashboard/import/preview/route.ts b/src/app/api/dashboard/import/preview/route.ts new file mode 100644 index 0000000..be9799e --- /dev/null +++ b/src/app/api/dashboard/import/preview/route.ts @@ -0,0 +1,294 @@ +import { NextRequest, NextResponse } from "next/server"; + +// Prevent static generation - this route requires runtime database access +export const dynamic = "force-dynamic"; + +import { validateAuthOrInternal } from "@/lib/auth"; +import { getAllCustomWidgets } from "@/lib/db"; +import { hasCredential, type Provider } from "@/lib/credentials"; + +interface DashboardExportFormat { + version: 1; + name: string; + description?: string; + author?: string; + exported_at: string; + glance_version: string; + widgets: Array<{ + slug: string; + name: string; + description?: string; + source_code: string; + server_code?: string; + server_code_enabled: boolean; + default_size: { w: number; h: number }; + min_size: { w: number; h: number }; + refresh_interval: number; + fetch: unknown; + credentials?: unknown[]; + setup?: unknown; + cache?: unknown; + data_schema?: unknown; + }>; + layout: { + desktop: Array<{ widget: string; x: number; y: number; w: number; h: number }>; + tablet?: Array<{ widget: string; x: number; y: number; w: number; h: number }>; + mobile?: Array<{ widget: string; x: number; y: number; w: number; h: number }>; + }; + theme?: { + name: string; + lightCss?: string; + darkCss?: string; + }; + credentials_needed: Array<{ + provider: string; + description: string; + required: boolean; + }>; +} + +interface WidgetConflict { + slug: string; + existing_name: string; + incoming_name: string; + action: "will_overwrite" | "will_rename" | "will_skip"; +} + +interface ImportPreviewResponse { + valid: boolean; + errors: string[]; + warnings: string[]; + dashboard: { + name: string; + description?: string; + author?: string; + exported_at: string; + glance_version: string; + }; + widget_count: number; + widgets: Array<{ + slug: string; + name: string; + has_conflict: boolean; + }>; + conflicts: WidgetConflict[]; + layout: { + desktop_items: number; + tablet_items: number; + mobile_items: number; + }; + has_theme: boolean; + credentials_needed: string[]; + credentials_missing: string[]; +} + +function validateDashboardFormat(data: unknown): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + if (!data || typeof data !== "object") { + return { valid: false, errors: ["Invalid JSON structure"] }; + } + + const dashboard = data as Record; + + if (dashboard.version !== 1) { + errors.push(`Unsupported version: ${dashboard.version}. Expected version 1.`); + } + + if (!dashboard.name || typeof dashboard.name !== "string") { + errors.push("Missing or invalid dashboard name"); + } + + if (!Array.isArray(dashboard.widgets)) { + errors.push("Missing or invalid widgets array"); + } else { + const widgets = dashboard.widgets as Array>; + for (let i = 0; i < widgets.length; i++) { + const widget = widgets[i]; + if (!widget.slug || typeof widget.slug !== "string") { + errors.push(`Widget ${i + 1}: missing or invalid slug`); + } + if (!widget.name || typeof widget.name !== "string") { + errors.push(`Widget ${i + 1}: missing or invalid name`); + } + if (!widget.source_code || typeof widget.source_code !== "string") { + errors.push(`Widget ${widget.slug || i + 1}: missing or invalid source_code`); + } + } + } + + if (!dashboard.layout || typeof dashboard.layout !== "object") { + errors.push("Missing or invalid layout object"); + } else { + const layout = dashboard.layout as Record; + if (!Array.isArray(layout.desktop)) { + errors.push("Missing or invalid layout.desktop array"); + } + } + + return { valid: errors.length === 0, errors }; +} + +/** + * POST /api/dashboard/import/preview + * + * Preview what will happen during import (dry run) + * + * Body: The .glance.json content + */ +export async function POST(request: NextRequest) { + const auth = validateAuthOrInternal(request); + if (!auth.authorized) { + return NextResponse.json({ error: auth.error }, { status: 401 }); + } + + try { + const body = await request.json(); + + // Validate structure + const validation = validateDashboardFormat(body); + if (!validation.valid) { + const response: ImportPreviewResponse = { + valid: false, + errors: validation.errors, + warnings: [], + dashboard: { + name: (body as any)?.name || "Unknown", + exported_at: (body as any)?.exported_at || "Unknown", + glance_version: (body as any)?.glance_version || "Unknown", + }, + widget_count: 0, + widgets: [], + conflicts: [], + layout: { desktop_items: 0, tablet_items: 0, mobile_items: 0 }, + has_theme: false, + credentials_needed: [], + credentials_missing: [], + }; + return NextResponse.json(response, { status: 400 }); + } + + const dashboard = body as DashboardExportFormat; + const warnings: string[] = []; + + // Get existing widgets for conflict detection + const existingWidgets = getAllCustomWidgets(true); + const existingSlugs = new Map(existingWidgets.map(w => [w.slug, w.name])); + + // Check for conflicts + const conflicts: WidgetConflict[] = []; + const widgetPreviews: Array<{ slug: string; name: string; has_conflict: boolean }> = []; + + for (const widget of dashboard.widgets) { + const hasConflict = existingSlugs.has(widget.slug); + + widgetPreviews.push({ + slug: widget.slug, + name: widget.name, + has_conflict: hasConflict, + }); + + if (hasConflict) { + conflicts.push({ + slug: widget.slug, + existing_name: existingSlugs.get(widget.slug)!, + incoming_name: widget.name, + action: "will_overwrite", // Default action (can be changed in import options) + }); + } + } + + // Check credentials + const credentialsNeeded: string[] = []; + const credentialsMissing: string[] = []; + + if (dashboard.credentials_needed) { + for (const cred of dashboard.credentials_needed) { + credentialsNeeded.push(cred.provider); + + // Check if credential is configured + const isConfigured = hasCredential(cred.provider as Provider); + if (!isConfigured) { + credentialsMissing.push(cred.provider); + } + } + } + + // Also scan widget credentials + for (const widget of dashboard.widgets) { + if (widget.credentials && Array.isArray(widget.credentials)) { + for (const cred of widget.credentials) { + const credId = (cred as any).id || (cred as any).provider; + if (credId && !credentialsNeeded.includes(credId)) { + credentialsNeeded.push(credId); + const isConfigured = hasCredential(credId as Provider); + if (!isConfigured && !credentialsMissing.includes(credId)) { + credentialsMissing.push(credId); + } + } + } + } + } + + // Layout info + const layout = { + desktop_items: dashboard.layout.desktop?.length || 0, + tablet_items: dashboard.layout.tablet?.length || 0, + mobile_items: dashboard.layout.mobile?.length || 0, + }; + + // Theme info + const hasTheme = !!dashboard.theme && (!!dashboard.theme.lightCss || !!dashboard.theme.darkCss); + + // Warnings + if (conflicts.length > 0) { + warnings.push(`${conflicts.length} widget(s) already exist and will be affected`); + } + if (credentialsMissing.length > 0) { + warnings.push(`${credentialsMissing.length} credential(s) need to be configured`); + } + if (!hasTheme && dashboard.theme?.name) { + warnings.push("Theme has name but no CSS content"); + } + + const response: ImportPreviewResponse = { + valid: true, + errors: [], + warnings, + dashboard: { + name: dashboard.name, + description: dashboard.description, + author: dashboard.author, + exported_at: dashboard.exported_at, + glance_version: dashboard.glance_version, + }, + widget_count: dashboard.widgets.length, + widgets: widgetPreviews, + conflicts, + layout, + has_theme: hasTheme, + credentials_needed: credentialsNeeded, + credentials_missing: credentialsMissing, + }; + + return NextResponse.json(response); + } catch (error) { + console.error("Failed to preview dashboard import:", error); + return NextResponse.json( + { + valid: false, + errors: [error instanceof Error ? error.message : "Failed to parse dashboard file"], + warnings: [], + dashboard: { name: "Unknown", exported_at: "Unknown", glance_version: "Unknown" }, + widget_count: 0, + widgets: [], + conflicts: [], + layout: { desktop_items: 0, tablet_items: 0, mobile_items: 0 }, + has_theme: false, + credentials_needed: [], + credentials_missing: [], + } satisfies ImportPreviewResponse, + { status: 400 } + ); + } +} diff --git a/src/app/api/dashboard/import/route.ts b/src/app/api/dashboard/import/route.ts new file mode 100644 index 0000000..31c5bfd --- /dev/null +++ b/src/app/api/dashboard/import/route.ts @@ -0,0 +1,460 @@ +import { NextRequest, NextResponse } from "next/server"; +import { nanoid } from "nanoid"; + +// Prevent static generation - this route requires runtime database access +export const dynamic = "force-dynamic"; + +import { validateAuthOrInternal } from "@/lib/auth"; +import { + getAllCustomWidgets, + getCustomWidgetBySlug, + createCustomWidget, + updateCustomWidget, + getAllWidgets, + createWidget, + deleteWidget, + setSetting, + getSetting, + logEvent, + type CredentialRequirement, + type SetupConfig, + type FetchConfig, + type CacheConfig, + type DataSchema, +} from "@/lib/db"; +import { hasCredential, type Provider } from "@/lib/credentials"; +import { generateUniqueSlug } from "@/lib/widget-package"; + +interface DashboardExportFormat { + version: 1; + name: string; + description?: string; + author?: string; + exported_at: string; + glance_version: string; + widgets: Array<{ + slug: string; + name: string; + description?: string; + source_code: string; + server_code?: string; + server_code_enabled: boolean; + default_size: { w: number; h: number }; + min_size: { w: number; h: number }; + refresh_interval: number; + fetch: FetchConfig; + credentials?: CredentialRequirement[]; + setup?: SetupConfig; + cache?: CacheConfig; + data_schema?: DataSchema; + }>; + layout: { + desktop: Array<{ widget: string; x: number; y: number; w: number; h: number }>; + tablet?: Array<{ widget: string; x: number; y: number; w: number; h: number }>; + mobile?: Array<{ widget: string; x: number; y: number; w: number; h: number }>; + }; + theme?: { + name: string; + lightCss?: string; + darkCss?: string; + }; + credentials_needed: Array<{ + provider: string; + description: string; + required: boolean; + }>; +} + +interface ImportOptions { + import_widgets: boolean; + import_theme: boolean; + import_layout: boolean; + conflict_resolution: "overwrite" | "rename" | "skip"; + clear_existing_layout?: boolean; +} + +interface ImportResponse { + success: boolean; + imported: { + widgets: string[]; + widgets_skipped: string[]; + widgets_renamed: Array<{ original: string; renamed: string }>; + theme: boolean; + layout: boolean; + layout_items: number; + }; + credentials_missing: string[]; + errors: string[]; + warnings: string[]; +} + +function validateDashboardFormat(data: unknown): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + if (!data || typeof data !== "object") { + return { valid: false, errors: ["Invalid JSON structure"] }; + } + + const dashboard = data as Record; + + if (dashboard.version !== 1) { + errors.push(`Unsupported version: ${dashboard.version}. Expected version 1.`); + } + + if (!dashboard.name || typeof dashboard.name !== "string") { + errors.push("Missing or invalid dashboard name"); + } + + if (!Array.isArray(dashboard.widgets)) { + errors.push("Missing or invalid widgets array"); + } else { + const widgets = dashboard.widgets as Array>; + for (let i = 0; i < widgets.length; i++) { + const widget = widgets[i]; + if (!widget.slug || typeof widget.slug !== "string") { + errors.push(`Widget ${i + 1}: missing or invalid slug`); + } + if (!widget.name || typeof widget.name !== "string") { + errors.push(`Widget ${i + 1}: missing or invalid name`); + } + if (!widget.source_code || typeof widget.source_code !== "string") { + errors.push(`Widget ${widget.slug || i + 1}: missing or invalid source_code`); + } + } + } + + if (!dashboard.layout || typeof dashboard.layout !== "object") { + errors.push("Missing or invalid layout object"); + } else { + const layout = dashboard.layout as Record; + if (!Array.isArray(layout.desktop)) { + errors.push("Missing or invalid layout.desktop array"); + } + } + + return { valid: errors.length === 0, errors }; +} + +/** + * POST /api/dashboard/import + * + * Import a dashboard from .glance.json format + * + * Body: + * - dashboard: The .glance.json content + * - options: Import options (what to import, how to handle conflicts) + */ +export async function POST(request: NextRequest) { + const auth = validateAuthOrInternal(request); + if (!auth.authorized) { + return NextResponse.json({ error: auth.error }, { status: 401 }); + } + + try { + const body = await request.json(); + const { dashboard: dashboardData, options: rawOptions } = body; + + // Default options + const options: ImportOptions = { + import_widgets: rawOptions?.import_widgets ?? true, + import_theme: rawOptions?.import_theme ?? true, + import_layout: rawOptions?.import_layout ?? true, + conflict_resolution: rawOptions?.conflict_resolution ?? "overwrite", + clear_existing_layout: rawOptions?.clear_existing_layout ?? false, + }; + + // Validate structure + const validation = validateDashboardFormat(dashboardData); + if (!validation.valid) { + return NextResponse.json( + { + success: false, + errors: validation.errors, + imported: { widgets: [], widgets_skipped: [], widgets_renamed: [], theme: false, layout: false, layout_items: 0 }, + credentials_missing: [], + warnings: [], + } satisfies ImportResponse, + { status: 400 } + ); + } + + const dashboard = dashboardData as DashboardExportFormat; + const errors: string[] = []; + const warnings: string[] = []; + const importedWidgets: string[] = []; + const skippedWidgets: string[] = []; + const renamedWidgets: Array<{ original: string; renamed: string }> = []; + + // Map of original slug -> actual slug (for renamed widgets) + const slugMap = new Map(); + + // Import widgets + if (options.import_widgets) { + const existingWidgets = getAllCustomWidgets(true); + const existingSlugs = existingWidgets.map(w => w.slug); + + for (const widget of dashboard.widgets) { + const existingWidget = getCustomWidgetBySlug(widget.slug); + + if (existingWidget) { + // Handle conflict + switch (options.conflict_resolution) { + case "skip": + skippedWidgets.push(widget.slug); + slugMap.set(widget.slug, widget.slug); // Keep original slug reference + continue; + + case "rename": + const newSlug = generateUniqueSlug(widget.slug, existingSlugs); + const newId = `cw_${nanoid(12)}`; + + createCustomWidget( + newId, + widget.name, + newSlug, + widget.description || null, + widget.source_code, + null, // compiled_code + widget.default_size, + widget.min_size, + [], // data_providers (legacy) + widget.refresh_interval, + true, // enabled + widget.server_code || null, + widget.server_code_enabled, + widget.credentials || [], + widget.setup || null, + widget.fetch || { type: "agent_refresh" }, + widget.cache || null, + null, // author + widget.data_schema || null + ); + + existingSlugs.push(newSlug); + renamedWidgets.push({ original: widget.slug, renamed: newSlug }); + slugMap.set(widget.slug, newSlug); + importedWidgets.push(newSlug); + break; + + case "overwrite": + default: + // Update existing widget + updateCustomWidget( + existingWidget.id, + widget.name, + widget.description || null, + widget.source_code, + null, // compiled_code + widget.default_size, + widget.min_size, + [], // data_providers (legacy) + widget.refresh_interval, + true, // enabled + widget.server_code || null, + widget.server_code_enabled, + widget.credentials || [], + widget.setup || null, + widget.fetch || { type: "agent_refresh" }, + widget.cache || null, + null, // author + widget.data_schema || null + ); + + slugMap.set(widget.slug, widget.slug); + importedWidgets.push(widget.slug); + break; + } + } else { + // New widget - create it + const widgetId = `cw_${nanoid(12)}`; + + createCustomWidget( + widgetId, + widget.name, + widget.slug, + widget.description || null, + widget.source_code, + null, // compiled_code + widget.default_size, + widget.min_size, + [], // data_providers (legacy) + widget.refresh_interval, + true, // enabled + widget.server_code || null, + widget.server_code_enabled, + widget.credentials || [], + widget.setup || null, + widget.fetch || { type: "agent_refresh" }, + widget.cache || null, + null, // author + widget.data_schema || null + ); + + existingSlugs.push(widget.slug); + slugMap.set(widget.slug, widget.slug); + importedWidgets.push(widget.slug); + } + } + } + + // Import layout + let layoutImported = false; + let layoutItemsImported = 0; + + if (options.import_layout && dashboard.layout?.desktop) { + // Optionally clear existing layout + if (options.clear_existing_layout) { + const existingWidgetInstances = getAllWidgets(); + for (const instance of existingWidgetInstances) { + // Only delete custom widget instances + if ((instance as any).custom_widget_id) { + deleteWidget(instance.id); + } + } + } + + // Create widget instances from layout + for (const layoutItem of dashboard.layout.desktop) { + // Get the actual slug (may have been renamed) + const actualSlug = slugMap.get(layoutItem.widget) || layoutItem.widget; + const customWidget = getCustomWidgetBySlug(actualSlug); + + if (!customWidget) { + warnings.push(`Layout references widget "${layoutItem.widget}" which was not imported`); + continue; + } + + // Check if this widget is already on the dashboard + const existingInstances = getAllWidgets(); + const existingInstance = existingInstances.find((w) => { + return (w as any).custom_widget_id === customWidget.id; + }); + + if (existingInstance) { + // Update position instead of creating duplicate + warnings.push(`Widget "${actualSlug}" already on dashboard, skipping duplicate`); + continue; + } + + // Create widget instance + const instanceId = `widget_${nanoid(12)}`; + const position = { + x: layoutItem.x, + y: layoutItem.y, + w: layoutItem.w, + h: layoutItem.h, + }; + + createWidget( + instanceId, + "custom", + customWidget.name, + {}, // config + position, + undefined, // dataSource + customWidget.id + ); + + layoutItemsImported++; + } + + layoutImported = true; + } + + // Import theme + let themeImported = false; + + if (options.import_theme && dashboard.theme) { + if (dashboard.theme.lightCss || dashboard.theme.darkCss) { + const existingThemeJson = getSetting("custom_theme"); + const now = new Date().toISOString(); + + const theme = { + name: dashboard.theme.name, + lightCss: dashboard.theme.lightCss || "", + darkCss: dashboard.theme.darkCss || "", + createdAt: existingThemeJson + ? JSON.parse(existingThemeJson).createdAt + : now, + updatedAt: now, + }; + + setSetting("custom_theme", JSON.stringify(theme)); + themeImported = true; + + logEvent("theme_imported", { name: dashboard.theme.name }); + } + } + + // Check for missing credentials + const credentialsMissing: string[] = []; + + if (dashboard.credentials_needed) { + for (const cred of dashboard.credentials_needed) { + const isConfigured = hasCredential(cred.provider as Provider); + if (!isConfigured) { + credentialsMissing.push(cred.provider); + } + } + } + + // Also check individual widget credentials + for (const widget of dashboard.widgets) { + if (widget.credentials && Array.isArray(widget.credentials)) { + for (const cred of widget.credentials) { + const credId = (cred as any).id || (cred as any).provider; + if (credId && !credentialsMissing.includes(credId)) { + const isConfigured = hasCredential(credId as Provider); + if (!isConfigured) { + credentialsMissing.push(credId); + } + } + } + } + } + + // Add warning about missing credentials + if (credentialsMissing.length > 0) { + warnings.push(`${credentialsMissing.length} credential(s) need to be configured for full functionality`); + } + + // Log the import + logEvent("dashboard_imported", { + name: dashboard.name, + widgets_imported: importedWidgets.length, + widgets_skipped: skippedWidgets.length, + widgets_renamed: renamedWidgets.length, + theme: themeImported, + layout_items: layoutItemsImported, + }); + + const response: ImportResponse = { + success: true, + imported: { + widgets: importedWidgets, + widgets_skipped: skippedWidgets, + widgets_renamed: renamedWidgets, + theme: themeImported, + layout: layoutImported, + layout_items: layoutItemsImported, + }, + credentials_missing: credentialsMissing, + errors, + warnings, + }; + + return NextResponse.json(response, { status: 201 }); + } catch (error) { + console.error("Failed to import dashboard:", error); + return NextResponse.json( + { + success: false, + errors: [error instanceof Error ? error.message : "Failed to import dashboard"], + imported: { widgets: [], widgets_skipped: [], widgets_renamed: [], theme: false, layout: false, layout_items: 0 }, + credentials_missing: [], + warnings: [], + } satisfies ImportResponse, + { status: 500 } + ); + } +} From cbc551a64cb626d189d20868ce83b75399ae4796 Mon Sep 17 00:00:00 2001 From: Alex Franzen <29310979+acfranzen@users.noreply.github.com> Date: Wed, 4 Feb 2026 18:27:45 -0500 Subject: [PATCH 2/7] =?UTF-8?q?=E2=9A=A1=20Add=20dashboard=20export/import?= =?UTF-8?q?=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DashboardExportModal: Export entire dashboard as .glance.json file - Configurable name, description, author - Downloads JSON file with all widgets, layout, and theme - DashboardImportModal: Import dashboard from .glance.json file - File upload with drag-and-drop support - Preview step showing widgets, layout, theme, credentials - Conflict resolution: overwrite/rename/skip options - Shows missing credentials that need configuration - Import options: layout, theme, clear existing - Success summary with import results - Updated DashboardHeader with dropdown menu for export/import - Mobile-responsive: buttons also available in mobile menu Tested: - Export API creates valid .glance.json - Import with preview shows conflicts and credentials - Conflict resolution works (overwrite/rename/skip) - No console errors --- .../dashboard/DashboardExportModal.tsx | 202 ++++++ src/components/dashboard/DashboardHeader.tsx | 73 +++ .../dashboard/DashboardImportModal.tsx | 608 ++++++++++++++++++ src/components/dashboard/index.ts | 2 + 4 files changed, 885 insertions(+) create mode 100644 src/components/dashboard/DashboardExportModal.tsx create mode 100644 src/components/dashboard/DashboardImportModal.tsx diff --git a/src/components/dashboard/DashboardExportModal.tsx b/src/components/dashboard/DashboardExportModal.tsx new file mode 100644 index 0000000..ec00f37 --- /dev/null +++ b/src/components/dashboard/DashboardExportModal.tsx @@ -0,0 +1,202 @@ +'use client'; + +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Download, + Loader2, + AlertCircle, + Check, + Package, +} from 'lucide-react'; + +interface DashboardExportModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function DashboardExportModal({ + open, + onOpenChange, +}: DashboardExportModalProps) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const [name, setName] = useState('My Dashboard'); + const [description, setDescription] = useState(''); + const [author, setAuthor] = useState(''); + + const handleClose = () => { + setError(null); + setSuccess(false); + onOpenChange(false); + }; + + const handleExport = async () => { + setLoading(true); + setError(null); + setSuccess(false); + + try { + const response = await fetch('/api/dashboard/export', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: name.trim() || 'My Dashboard', + description: description.trim() || undefined, + author: author.trim() || undefined, + widgets: ['all'], + include_theme: true, + include_layout: true, + breakpoints: ['desktop', 'tablet', 'mobile'], + }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to export dashboard'); + } + + // Get the filename from Content-Disposition header or generate one + const contentDisposition = response.headers.get('Content-Disposition'); + let filename = 'dashboard.glance.json'; + if (contentDisposition) { + const match = contentDisposition.match(/filename="(.+)"/); + if (match) { + filename = match[1]; + } + } + + // Download the file + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + setSuccess(true); + setTimeout(() => { + handleClose(); + }, 1500); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to export dashboard'); + } finally { + setLoading(false); + } + }; + + return ( + + + + + + Export Dashboard + + + Export your entire dashboard as a shareable .glance.json file. + + + + {success ? ( +
+
+ +
+

Export Complete!

+

+ Your dashboard has been downloaded. +

+
+ ) : ( + <> +
+
+ + setName(e.target.value)} + /> +
+ +
+ + setDescription(e.target.value)} + /> +
+ +
+ + setAuthor(e.target.value)} + /> +
+ +
+
+ + Export includes: +
+
    +
  • All widgets on your dashboard
  • +
  • Widget positions and layout
  • +
  • Custom theme (if set)
  • +
  • Required credentials list (not the actual keys)
  • +
+
+ + {error && ( +
+ + {error} +
+ )} +
+ + + + + + + )} +
+
+ ); +} diff --git a/src/components/dashboard/DashboardHeader.tsx b/src/components/dashboard/DashboardHeader.tsx index efa0042..94dce58 100644 --- a/src/components/dashboard/DashboardHeader.tsx +++ b/src/components/dashboard/DashboardHeader.tsx @@ -5,7 +5,16 @@ import { useWidgetStore } from "@/lib/store/widget-store"; import { AddWidgetModal } from "./AddWidgetModal"; import { ThemeImportModal } from "./ThemeImportModal"; import { WidgetImportModal } from "./WidgetImportModal"; +import { DashboardExportModal } from "./DashboardExportModal"; +import { DashboardImportModal } from "./DashboardImportModal"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Pencil, Check, @@ -16,6 +25,10 @@ import { Key, Palette, Upload, + Download, + FolderDown, + FolderUp, + MoreHorizontal, } from "lucide-react"; import { useTheme } from "next-themes"; import { cn } from "@/lib/utils"; @@ -27,6 +40,8 @@ export function DashboardHeader() { const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [credentialsOpen, setCredentialsOpen] = useState(false); const [importOpen, setImportOpen] = useState(false); + const [dashboardExportOpen, setDashboardExportOpen] = useState(false); + const [dashboardImportOpen, setDashboardImportOpen] = useState(false); return (
@@ -82,6 +97,25 @@ export function DashboardHeader() { + {/* Dashboard Menu */} + + + + + + setDashboardExportOpen(true)}> + + Export Dashboard + + setDashboardImportOpen(true)}> + + Import Dashboard + + + + + + @@ -198,6 +258,19 @@ export function DashboardHeader() { refreshWidgets(); }} /> + + + + { + refreshWidgets(); + }} + />
); } diff --git a/src/components/dashboard/DashboardImportModal.tsx b/src/components/dashboard/DashboardImportModal.tsx new file mode 100644 index 0000000..0fba3f6 --- /dev/null +++ b/src/components/dashboard/DashboardImportModal.tsx @@ -0,0 +1,608 @@ +'use client'; + +import { useState, useCallback, useRef } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Upload, + Loader2, + AlertCircle, + Check, + ChevronRight, + FileJson, + Package, + AlertTriangle, + Key, + Palette, + LayoutGrid, +} from 'lucide-react'; + +interface DashboardImportModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onImportComplete: () => void; +} + +interface WidgetPreview { + slug: string; + name: string; + has_conflict: boolean; +} + +interface WidgetConflict { + slug: string; + existing_name: string; + incoming_name: string; + action: 'will_overwrite' | 'will_rename' | 'will_skip'; +} + +interface ImportPreviewResponse { + valid: boolean; + errors: string[]; + warnings: string[]; + dashboard: { + name: string; + description?: string; + author?: string; + exported_at: string; + glance_version: string; + }; + widget_count: number; + widgets: WidgetPreview[]; + conflicts: WidgetConflict[]; + layout: { + desktop_items: number; + tablet_items: number; + mobile_items: number; + }; + has_theme: boolean; + credentials_needed: string[]; + credentials_missing: string[]; +} + +interface ImportResponse { + success: boolean; + imported: { + widgets: string[]; + widgets_skipped: string[]; + widgets_renamed: Array<{ original: string; renamed: string }>; + theme: boolean; + layout: boolean; + layout_items: number; + }; + credentials_missing: string[]; + errors: string[]; + warnings: string[]; +} + +type Step = 'upload' | 'preview' | 'complete'; +type ConflictResolution = 'overwrite' | 'rename' | 'skip'; + +export function DashboardImportModal({ + open, + onOpenChange, + onImportComplete, +}: DashboardImportModalProps) { + const [step, setStep] = useState('upload'); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [fileName, setFileName] = useState(null); + const [dashboardData, setDashboardData] = useState(null); + const [preview, setPreview] = useState(null); + const [importResult, setImportResult] = useState(null); + const [conflictResolution, setConflictResolution] = useState('overwrite'); + const [importLayout, setImportLayout] = useState(true); + const [importTheme, setImportTheme] = useState(true); + const [clearExistingLayout, setClearExistingLayout] = useState(false); + const fileInputRef = useRef(null); + + const handleClose = () => { + setStep('upload'); + setError(null); + setFileName(null); + setDashboardData(null); + setPreview(null); + setImportResult(null); + setConflictResolution('overwrite'); + setImportLayout(true); + setImportTheme(true); + setClearExistingLayout(false); + onOpenChange(false); + }; + + const handleFileSelect = useCallback(async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + // Validate file extension + if (!file.name.endsWith('.glance.json') && !file.name.endsWith('.json')) { + setError('Please select a .glance.json file'); + return; + } + + setLoading(true); + setError(null); + setFileName(file.name); + + try { + const text = await file.text(); + const data = JSON.parse(text); + setDashboardData(data); + + // Get preview from API + const response = await fetch('/api/dashboard/import/preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: text, + }); + + const previewData: ImportPreviewResponse = await response.json(); + + if (!previewData.valid) { + setError(previewData.errors.join(', ')); + return; + } + + setPreview(previewData); + setStep('preview'); + } catch (err) { + if (err instanceof SyntaxError) { + setError('Invalid JSON file'); + } else { + setError(err instanceof Error ? err.message : 'Failed to read file'); + } + } finally { + setLoading(false); + // Reset file input + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }, []); + + const handleImport = async () => { + if (!dashboardData) return; + + setLoading(true); + setError(null); + + try { + const response = await fetch('/api/dashboard/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + dashboard: dashboardData, + options: { + import_widgets: true, + import_theme: importTheme, + import_layout: importLayout, + conflict_resolution: conflictResolution, + clear_existing_layout: clearExistingLayout, + }, + }), + }); + + const data: ImportResponse = await response.json(); + + if (!data.success) { + setError(data.errors.join(', ')); + return; + } + + setImportResult(data); + setStep('complete'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to import dashboard'); + } finally { + setLoading(false); + } + }; + + const handleDone = () => { + onImportComplete(); + handleClose(); + }; + + const triggerFileSelect = () => { + fileInputRef.current?.click(); + }; + + return ( + + + + + + Import Dashboard + + + {step === 'upload' && 'Select a .glance.json file to import a complete dashboard.'} + {step === 'preview' && 'Review what will be imported and configure options.'} + {step === 'complete' && 'Dashboard imported successfully!'} + + + + {/* Step Progress */} + {step !== 'upload' && step !== 'complete' && ( +
+ Upload + + + Preview + + + Import +
+ )} + + {/* Upload Step */} + {step === 'upload' && ( +
+ + +
+ {loading ? ( +
+ +

Reading file...

+
+ ) : ( +
+ +

Click to select a .glance.json file

+

+ or drag and drop here +

+
+ )} +
+ + {fileName && ( +

+ Selected: {fileName} +

+ )} + + {error && ( +
+ + {error} +
+ )} + +
+

What's in a .glance.json file?

+
    +
  • Widget definitions and source code
  • +
  • Dashboard layout configuration
  • +
  • Custom theme settings
  • +
  • Required credentials list
  • +
+
+
+ )} + + {/* Preview Step */} + {step === 'preview' && preview && ( +
+ {/* Dashboard Info */} +
+

{preview.dashboard.name}

+ {preview.dashboard.description && ( +

+ {preview.dashboard.description} +

+ )} +
+ {preview.dashboard.author && By: {preview.dashboard.author}} + Glance v{preview.dashboard.glance_version} +
+
+ + {/* Summary Cards */} +
+
+
+ + Widgets +
+
{preview.widget_count}
+ {preview.conflicts.length > 0 && ( +
+ {preview.conflicts.length} conflict(s) +
+ )} +
+
+
+ + Layout +
+
+ {preview.layout.desktop_items} items +
+
+
+
+ + Theme +
+
+ {preview.has_theme ? 'Included' : 'None'} +
+
+
+ + {/* Warnings */} + {preview.warnings.length > 0 && ( +
+
+ + Warnings +
+
    + {preview.warnings.map((warning, i) => ( +
  • • {warning}
  • + ))} +
+
+ )} + + {/* Missing Credentials */} + {preview.credentials_missing.length > 0 && ( +
+
+ + Credentials Needed +
+

+ These API keys need to be configured for full functionality: +

+
+ {preview.credentials_missing.map((cred) => ( + + {cred} + + ))} +
+
+ )} + + {/* Conflict Resolution */} + {preview.conflicts.length > 0 && ( +
+ +

+ {preview.conflicts.length} widget(s) already exist. How should we handle them? +

+ + + {/* Show conflicts */} +
+ {preview.conflicts.map((conflict) => ( +
+ {conflict.slug} + + "{conflict.existing_name}" → "{conflict.incoming_name}" + +
+ ))} +
+
+ )} + + {/* Import Options */} +
+ +
+ + + +
+
+ + {error && ( +
+ + {error} +
+ )} + + + + + +
+ )} + + {/* Complete Step */} + {step === 'complete' && importResult && ( +
+
+
+ +
+

Import Complete!

+
+ + {/* Results Summary */} +
+
+ Widgets imported: + {importResult.imported.widgets.length} +
+ {importResult.imported.widgets_skipped.length > 0 && ( +
+ Widgets skipped: + + {importResult.imported.widgets_skipped.length} + +
+ )} + {importResult.imported.widgets_renamed.length > 0 && ( +
+ Widgets renamed: + + {importResult.imported.widgets_renamed.length} + +
+ )} +
+ Layout items: + {importResult.imported.layout_items} +
+
+ Theme imported: + {importResult.imported.theme ? 'Yes' : 'No'} +
+
+ + {/* Renamed widgets */} + {importResult.imported.widgets_renamed.length > 0 && ( +
+

Renamed Widgets

+
+ {importResult.imported.widgets_renamed.map(({ original, renamed }) => ( +
+ {original} → {renamed} +
+ ))} +
+
+ )} + + {/* Missing credentials reminder */} + {importResult.credentials_missing.length > 0 && ( +
+
+ + Configure API Keys +
+

+ Remember to configure these credentials for full functionality: +

+
+ {importResult.credentials_missing.map((cred) => ( + + {cred} + + ))} +
+
+ )} + + {/* Warnings */} + {importResult.warnings.length > 0 && ( +
+

Notes:

+
    + {importResult.warnings.map((warning, i) => ( +
  • • {warning}
  • + ))} +
+
+ )} + + + + +
+ )} +
+
+ ); +} diff --git a/src/components/dashboard/index.ts b/src/components/dashboard/index.ts index 699c357..bbc17c3 100644 --- a/src/components/dashboard/index.ts +++ b/src/components/dashboard/index.ts @@ -8,3 +8,5 @@ export { CredentialSetupWizard } from "./CredentialSetupWizard"; export { SetupWizard } from "./SetupWizard"; export { WidgetExportModal } from "./WidgetExportModal"; export { WidgetImportModal } from "./WidgetImportModal"; +export { DashboardExportModal } from "./DashboardExportModal"; +export { DashboardImportModal } from "./DashboardImportModal"; From 085a9d82987efb9ff585c2c58cda6b09fd93b2ea Mon Sep 17 00:00:00 2001 From: Alex Franzen <29310979+acfranzen@users.noreply.github.com> Date: Wed, 4 Feb 2026 18:32:30 -0500 Subject: [PATCH 3/7] =?UTF-8?q?=E2=9A=A1=20Fix=20all=20TypeScript=20any=20?= =?UTF-8?q?usages=20with=20proper=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add custom_widget_id to WidgetRow interface - Use CredentialRequirement type for credential access - Use type guards for unknown body validation - Use RequestOptions type for http/https requests - Update CLAUDE.md: NEVER use any (strict rule) --- CLAUDE.md | 3 ++- src/app/api/dashboard/export/route.ts | 8 ++++---- src/app/api/dashboard/import/preview/route.ts | 14 ++++++++------ src/app/api/dashboard/import/route.ts | 6 +++--- src/app/api/widgets/[slug]/refresh/route.ts | 15 ++++++--------- src/lib/db.ts | 1 + 6 files changed, 24 insertions(+), 23 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cea409f..f3c13ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,7 +91,8 @@ Other routes: ## Conventions -- TypeScript strict mode; avoid `any` +- TypeScript strict mode +- **NEVER use `any`** — always use proper types, generics, `unknown` with type guards, or `Record` for dynamic objects - Tailwind for styling; support light/dark modes ## Critical Rules diff --git a/src/app/api/dashboard/export/route.ts b/src/app/api/dashboard/export/route.ts index bc834e8..4eb271c 100644 --- a/src/app/api/dashboard/export/route.ts +++ b/src/app/api/dashboard/export/route.ts @@ -89,7 +89,7 @@ export async function POST(request: NextRequest) { // Filter custom widgets based on what's actually on the dashboard const customWidgetIdsOnDashboard = new Set( allWidgetInstances - .map(w => (w as any).custom_widget_id) + .map(w => w.custom_widget_id) .filter(Boolean) ); @@ -135,7 +135,7 @@ export async function POST(request: NextRequest) { // Build desktop layout from widget positions for (const instance of allWidgetInstances) { - const customWidgetId = (instance as any).custom_widget_id; + const customWidgetId = instance.custom_widget_id; if (!customWidgetId) continue; const customWidget = getCustomWidget(customWidgetId); @@ -191,10 +191,10 @@ export async function POST(request: NextRequest) { for (const widget of widgetsToExport) { if (widget.credentials && Array.isArray(widget.credentials)) { for (const cred of widget.credentials) { - const credId = (cred as any).id || (cred as any).provider; + const credId = cred.id; if (credId && !credentialsNeeded.has(credId)) { credentialsNeeded.set(credId, { - description: (cred as any).description || (cred as any).name || credId, + description: cred.description || cred.name || credId, required: true, }); } diff --git a/src/app/api/dashboard/import/preview/route.ts b/src/app/api/dashboard/import/preview/route.ts index be9799e..e07e935 100644 --- a/src/app/api/dashboard/import/preview/route.ts +++ b/src/app/api/dashboard/import/preview/route.ts @@ -4,7 +4,7 @@ import { NextRequest, NextResponse } from "next/server"; export const dynamic = "force-dynamic"; import { validateAuthOrInternal } from "@/lib/auth"; -import { getAllCustomWidgets } from "@/lib/db"; +import { getAllCustomWidgets, type CredentialRequirement } from "@/lib/db"; import { hasCredential, type Provider } from "@/lib/credentials"; interface DashboardExportFormat { @@ -25,7 +25,7 @@ interface DashboardExportFormat { min_size: { w: number; h: number }; refresh_interval: number; fetch: unknown; - credentials?: unknown[]; + credentials?: CredentialRequirement[]; setup?: unknown; cache?: unknown; data_schema?: unknown; @@ -148,14 +148,16 @@ export async function POST(request: NextRequest) { // Validate structure const validation = validateDashboardFormat(body); if (!validation.valid) { + // Extract safe fields from unknown body for error response + const bodyObj = (body && typeof body === "object") ? body as Record : {}; const response: ImportPreviewResponse = { valid: false, errors: validation.errors, warnings: [], dashboard: { - name: (body as any)?.name || "Unknown", - exported_at: (body as any)?.exported_at || "Unknown", - glance_version: (body as any)?.glance_version || "Unknown", + name: typeof bodyObj.name === "string" ? bodyObj.name : "Unknown", + exported_at: typeof bodyObj.exported_at === "string" ? bodyObj.exported_at : "Unknown", + glance_version: typeof bodyObj.glance_version === "string" ? bodyObj.glance_version : "Unknown", }, widget_count: 0, widgets: [], @@ -218,7 +220,7 @@ export async function POST(request: NextRequest) { for (const widget of dashboard.widgets) { if (widget.credentials && Array.isArray(widget.credentials)) { for (const cred of widget.credentials) { - const credId = (cred as any).id || (cred as any).provider; + const credId = cred.id; if (credId && !credentialsNeeded.includes(credId)) { credentialsNeeded.push(credId); const isConfigured = hasCredential(credId as Provider); diff --git a/src/app/api/dashboard/import/route.ts b/src/app/api/dashboard/import/route.ts index 31c5bfd..00d582b 100644 --- a/src/app/api/dashboard/import/route.ts +++ b/src/app/api/dashboard/import/route.ts @@ -307,7 +307,7 @@ export async function POST(request: NextRequest) { const existingWidgetInstances = getAllWidgets(); for (const instance of existingWidgetInstances) { // Only delete custom widget instances - if ((instance as any).custom_widget_id) { + if (instance.custom_widget_id) { deleteWidget(instance.id); } } @@ -327,7 +327,7 @@ export async function POST(request: NextRequest) { // Check if this widget is already on the dashboard const existingInstances = getAllWidgets(); const existingInstance = existingInstances.find((w) => { - return (w as any).custom_widget_id === customWidget.id; + return w.custom_widget_id === customWidget.id; }); if (existingInstance) { @@ -402,7 +402,7 @@ export async function POST(request: NextRequest) { for (const widget of dashboard.widgets) { if (widget.credentials && Array.isArray(widget.credentials)) { for (const cred of widget.credentials) { - const credId = (cred as any).id || (cred as any).provider; + const credId = cred.id; if (credId && !credentialsMissing.includes(credId)) { const isConfigured = hasCredential(credId as Provider); if (!isConfigured) { diff --git a/src/app/api/widgets/[slug]/refresh/route.ts b/src/app/api/widgets/[slug]/refresh/route.ts index 9172d73..3dfdafb 100644 --- a/src/app/api/widgets/[slug]/refresh/route.ts +++ b/src/app/api/widgets/[slug]/refresh/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import https from 'https'; +import https, { type RequestOptions } from 'https'; export const dynamic = 'force-dynamic'; @@ -96,21 +96,18 @@ export async function POST( const isHttps = url.protocol === 'https:'; const isLocalhost = url.hostname === 'localhost' || url.hostname === '127.0.0.1'; - const options: any = { + const options: RequestOptions = { method: 'POST', headers: { 'Authorization': `Bearer ${webhookToken}`, 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(payload) + 'Content-Length': Buffer.byteLength(payload).toString() }, - timeout: 5000 + timeout: 5000, + // Allow self-signed certificates for localhost + rejectUnauthorized: !(isHttps && isLocalhost) }; - // Allow self-signed certificates for localhost - if (isHttps && isLocalhost) { - options.rejectUnauthorized = false; - } - const httpModule = isHttps ? https : (await import('http')).default; await new Promise((resolve, reject) => { diff --git a/src/lib/db.ts b/src/lib/db.ts index d6ddab2..a24542b 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -204,6 +204,7 @@ export interface WidgetRow { data_source: string | null; data_cache: string | null; data_updated_at: string | null; + custom_widget_id: string | null; created_at: string; updated_at: string; } From 12cfff05997eea92fcc62edbaaae04ae53c2cfac Mon Sep 17 00:00:00 2001 From: Alex Franzen <29310979+acfranzen@users.noreply.github.com> Date: Wed, 4 Feb 2026 18:40:09 -0500 Subject: [PATCH 4/7] =?UTF-8?q?=E2=9A=A1=20Move=20import/export=20actions?= =?UTF-8?q?=20to=20menu=20for=20cleaner=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/dashboard/DashboardHeader.tsx | 46 +++++++++----------- 1 file changed, 20 insertions(+), 26 deletions(-) diff --git a/src/components/dashboard/DashboardHeader.tsx b/src/components/dashboard/DashboardHeader.tsx index 94dce58..62af310 100644 --- a/src/components/dashboard/DashboardHeader.tsx +++ b/src/components/dashboard/DashboardHeader.tsx @@ -25,7 +25,6 @@ import { Key, Palette, Upload, - Download, FolderDown, FolderUp, MoreHorizontal, @@ -60,15 +59,6 @@ export function DashboardHeader() {
- - + setImportOpen(true)}> + + Import Widget + + setDashboardExportOpen(true)}> Export Dashboard @@ -163,18 +158,6 @@ export function DashboardHeader() {
- - +
+ {/* Widget Details Section */} + {preview.widgets.length > 0 && ( +
+ + {expandedSections.has('widgets') && ( +
+ {preview.widgets.map((widget) => ( +
+ + + {expandedWidgets.has(widget.slug) && ( +
+ {widget.description && ( +

{widget.description}

+ )} + + {/* Credentials for this widget */} + {widget.credentials.length > 0 && ( +
+ {widget.credentials.map((cred) => ( + + + {cred.name} + + ))} +
+ )} + + {/* Code view buttons */} +
+ + {widget.server_code && ( + + )} +
+ + {/* Source code preview */} + {expandedWidgetCode[widget.slug] === 'source' && ( +
+ +
+                                  {widget.source_code}
+                                
+
+ )} + + {/* Server code preview */} + {expandedWidgetCode[widget.slug] === 'server' && widget.server_code && ( +
+ +
+                                  {widget.server_code}
+                                
+
+ )} +
+ )} +
+ ))} +
+ )} +
+ )} + + {/* Credentials Section */} + {preview.credentials_details.length > 0 && ( +
+ + {expandedSections.has('credentials') && ( +
+ {preview.credentials_details.map((cred) => ( +
+
+
+
+ {cred.name} + {getCredentialTypeBadge(cred.type)} +
+

{cred.description}

+
+ + {cred.is_configured ? ( + <> + + Configured + + ) : ( + <> + + Missing + + )} + +
+ {cred.obtain_url && !cred.is_configured && ( + + + Get API Key + + )} + {cred.install_url && !cred.is_configured && ( + + + Install Guide + + )} +
+ ))} +
+ )} +
+ )} + + {/* Theme Preview Section */} + {preview.theme_details && ( +
+ + {expandedSections.has('theme') && ( +
+ {preview.theme_details.lightCss && ( +
+
+ Light Theme + + {preview.theme_details.lightCss_lines} lines + +
+
+                          
+                            {preview.theme_details.lightCss.split('\n').slice(0, 10).join('\n')}
+                            {preview.theme_details.lightCss_lines > 10 && '\n...'}
+                          
+                        
+
+ )} + {preview.theme_details.darkCss && ( +
+
+ Dark Theme + + {preview.theme_details.darkCss_lines} lines + +
+
+                          
+                            {preview.theme_details.darkCss.split('\n').slice(0, 10).join('\n')}
+                            {preview.theme_details.darkCss_lines > 10 && '\n...'}
+                          
+                        
+
+ )} +
+ )} +
+ )} + {/* Warnings */} {preview.warnings.length > 0 && (
@@ -372,8 +752,8 @@ export function DashboardImportModal({
)} - {/* Missing Credentials */} - {preview.credentials_missing.length > 0 && ( + {/* Missing Credentials Warning (only if no detailed credentials info) */} + {preview.credentials_missing.length > 0 && preview.credentials_details.length === 0 && (
From 0897b2fc9809b5b4380ab2134263a52f0510a4d9 Mon Sep 17 00:00:00 2001 From: Alex Franzen Date: Thu, 5 Feb 2026 10:47:04 -0500 Subject: [PATCH 7/7] Consolidate duplicated import/export API types into dashboard-format.ts Move WidgetPreviewDetail, CredentialPreviewDetail, ThemePreviewDetail, WidgetConflict, ImportPreviewResponse, and ImportResponse interfaces to shared dashboard-format.ts to eliminate duplication across API routes and DashboardImportModal. Co-Authored-By: Claude Opus 4.5 --- src/app/api/dashboard/import/preview/route.ts | 69 ++------------- src/app/api/dashboard/import/route.ts | 16 +--- .../dashboard/DashboardImportModal.tsx | 83 +----------------- src/lib/dashboard-format.ts | 86 +++++++++++++++++++ 4 files changed, 96 insertions(+), 158 deletions(-) diff --git a/src/app/api/dashboard/import/preview/route.ts b/src/app/api/dashboard/import/preview/route.ts index 3359372..f3b9eba 100644 --- a/src/app/api/dashboard/import/preview/route.ts +++ b/src/app/api/dashboard/import/preview/route.ts @@ -9,75 +9,16 @@ import { hasCredential, type Provider } from "@/lib/credentials"; import { validateDashboardFormat, type DashboardExportFormat, + type WidgetPreviewDetail, + type CredentialPreviewDetail, + type ThemePreviewDetail, + type WidgetConflict, + type ImportPreviewResponse, } from "@/lib/dashboard-format"; // Maximum import file size (5MB) to prevent DoS const MAX_IMPORT_SIZE = 5 * 1024 * 1024; -interface WidgetConflict { - slug: string; - existing_name: string; - incoming_name: string; - action: "will_overwrite" | "will_rename" | "will_skip"; -} - -interface WidgetPreviewDetail { - slug: string; - name: string; - description?: string; - has_conflict: boolean; - source_code: string; - server_code?: string; - server_code_enabled: boolean; - source_code_lines: number; - server_code_lines?: number; - credentials: Array<{ id: string; name: string; type: string }>; -} - -interface CredentialPreviewDetail { - id: string; - type: "api_key" | "local_software" | "oauth" | "agent"; - name: string; - description: string; - obtain_url?: string; - install_url?: string; - is_configured: boolean; -} - -interface ThemePreviewDetail { - name: string; - lightCss?: string; - darkCss?: string; - lightCss_lines: number; - darkCss_lines: number; -} - -interface ImportPreviewResponse { - valid: boolean; - errors: string[]; - warnings: string[]; - dashboard: { - name: string; - description?: string; - author?: string; - exported_at: string; - glance_version: string; - }; - widget_count: number; - widgets: WidgetPreviewDetail[]; - conflicts: WidgetConflict[]; - layout: { - desktop_items: number; - tablet_items: number; - mobile_items: number; - }; - has_theme: boolean; - theme_details?: ThemePreviewDetail; - credentials_needed: string[]; - credentials_missing: string[]; - credentials_details: CredentialPreviewDetail[]; -} - /** * POST /api/dashboard/import/preview * diff --git a/src/app/api/dashboard/import/route.ts b/src/app/api/dashboard/import/route.ts index c9dd95d..1c57130 100644 --- a/src/app/api/dashboard/import/route.ts +++ b/src/app/api/dashboard/import/route.ts @@ -22,6 +22,7 @@ import { generateUniqueSlug } from "@/lib/widget-package"; import { validateDashboardFormat, type DashboardExportFormat, + type ImportResponse, } from "@/lib/dashboard-format"; // Maximum import file size (5MB) to prevent DoS @@ -35,21 +36,6 @@ interface ImportOptions { clear_existing_layout?: boolean; } -interface ImportResponse { - success: boolean; - imported: { - widgets: string[]; - widgets_skipped: string[]; - widgets_renamed: Array<{ original: string; renamed: string }>; - theme: boolean; - layout: boolean; - layout_items: number; - }; - credentials_missing: string[]; - errors: string[]; - warnings: string[]; -} - /** * POST /api/dashboard/import * diff --git a/src/components/dashboard/DashboardImportModal.tsx b/src/components/dashboard/DashboardImportModal.tsx index eb0a572..6a4d512 100644 --- a/src/components/dashboard/DashboardImportModal.tsx +++ b/src/components/dashboard/DashboardImportModal.tsx @@ -39,6 +39,10 @@ import { CheckCircle, XCircle, } from 'lucide-react'; +import type { + ImportPreviewResponse, + ImportResponse, +} from '@/lib/dashboard-format'; interface DashboardImportModalProps { open: boolean; @@ -46,85 +50,6 @@ interface DashboardImportModalProps { onImportComplete: () => void; } -interface WidgetPreviewDetail { - slug: string; - name: string; - description?: string; - has_conflict: boolean; - source_code: string; - server_code?: string; - server_code_enabled: boolean; - source_code_lines: number; - server_code_lines?: number; - credentials: Array<{ id: string; name: string; type: string }>; -} - -interface CredentialPreviewDetail { - id: string; - type: 'api_key' | 'local_software' | 'oauth' | 'agent'; - name: string; - description: string; - obtain_url?: string; - install_url?: string; - is_configured: boolean; -} - -interface ThemePreviewDetail { - name: string; - lightCss?: string; - darkCss?: string; - lightCss_lines: number; - darkCss_lines: number; -} - -interface WidgetConflict { - slug: string; - existing_name: string; - incoming_name: string; - action: 'will_overwrite' | 'will_rename' | 'will_skip'; -} - -interface ImportPreviewResponse { - valid: boolean; - errors: string[]; - warnings: string[]; - dashboard: { - name: string; - description?: string; - author?: string; - exported_at: string; - glance_version: string; - }; - widget_count: number; - widgets: WidgetPreviewDetail[]; - conflicts: WidgetConflict[]; - layout: { - desktop_items: number; - tablet_items: number; - mobile_items: number; - }; - has_theme: boolean; - theme_details?: ThemePreviewDetail; - credentials_needed: string[]; - credentials_missing: string[]; - credentials_details: CredentialPreviewDetail[]; -} - -interface ImportResponse { - success: boolean; - imported: { - widgets: string[]; - widgets_skipped: string[]; - widgets_renamed: Array<{ original: string; renamed: string }>; - theme: boolean; - layout: boolean; - layout_items: number; - }; - credentials_missing: string[]; - errors: string[]; - warnings: string[]; -} - type Step = 'upload' | 'preview' | 'complete'; type ConflictResolution = 'overwrite' | 'rename' | 'skip'; diff --git a/src/lib/dashboard-format.ts b/src/lib/dashboard-format.ts index 272d666..5137fc4 100644 --- a/src/lib/dashboard-format.ts +++ b/src/lib/dashboard-format.ts @@ -72,6 +72,92 @@ export interface DashboardExportFormat { }>; } +/** + * Validates the structure of a dashboard export file + */ +/** + * Shared types for dashboard import/export API responses + */ + +export interface WidgetPreviewDetail { + slug: string; + name: string; + description?: string; + has_conflict: boolean; + source_code: string; + server_code?: string; + server_code_enabled: boolean; + source_code_lines: number; + server_code_lines?: number; + credentials: Array<{ id: string; name: string; type: string }>; +} + +export interface CredentialPreviewDetail { + id: string; + type: "api_key" | "local_software" | "oauth" | "agent"; + name: string; + description: string; + obtain_url?: string; + install_url?: string; + is_configured: boolean; +} + +export interface ThemePreviewDetail { + name: string; + lightCss?: string; + darkCss?: string; + lightCss_lines: number; + darkCss_lines: number; +} + +export interface WidgetConflict { + slug: string; + existing_name: string; + incoming_name: string; + action: "will_overwrite" | "will_rename" | "will_skip"; +} + +export interface ImportPreviewResponse { + valid: boolean; + errors: string[]; + warnings: string[]; + dashboard: { + name: string; + description?: string; + author?: string; + exported_at: string; + glance_version: string; + }; + widget_count: number; + widgets: WidgetPreviewDetail[]; + conflicts: WidgetConflict[]; + layout: { + desktop_items: number; + tablet_items: number; + mobile_items: number; + }; + has_theme: boolean; + theme_details?: ThemePreviewDetail; + credentials_needed: string[]; + credentials_missing: string[]; + credentials_details: CredentialPreviewDetail[]; +} + +export interface ImportResponse { + success: boolean; + imported: { + widgets: string[]; + widgets_skipped: string[]; + widgets_renamed: Array<{ original: string; renamed: string }>; + theme: boolean; + layout: boolean; + layout_items: number; + }; + credentials_missing: string[]; + errors: string[]; + warnings: string[]; +} + /** * Validates the structure of a dashboard export file */