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 new file mode 100644 index 0000000..9984623 --- /dev/null +++ b/src/app/api/dashboard/export/route.ts @@ -0,0 +1,228 @@ +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"; +import type { DashboardExportFormat } from "@/lib/dashboard-format"; + +interface ExportRequestBody { + widgets?: string[]; + include_theme?: boolean; + include_layout?: boolean; + breakpoints?: Array<"desktop" | "tablet" | "mobile">; + name?: string; + description?: string; + author?: string; +} + +/** + * 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: ExportRequestBody = 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.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.custom_widget_id; + if (!customWidgetId) continue; + + const customWidget = getCustomWidget(customWidgetId); + if (!customWidget || !exportedSlugs.has(customWidget.slug)) continue; + + // Safely parse position with fallback + let position: { x: number; y: number; w: number; h: number }; + try { + position = JSON.parse(instance.position); + } catch { + // Skip widgets with malformed position data + console.warn(`Skipping widget with invalid position: ${instance.id}`); + continue; + } + + 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< + string, + { description: string; required: boolean } + >(); + + for (const widget of widgetsToExport) { + if (widget.credentials && Array.isArray(widget.credentials)) { + for (const cred of widget.credentials) { + const credId = cred.id; + if (credId && !credentialsNeeded.has(credId)) { + credentialsNeeded.set(credId, { + description: cred.description || cred.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..f3b9eba --- /dev/null +++ b/src/app/api/dashboard/import/preview/route.ts @@ -0,0 +1,302 @@ +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"; +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; + +/** + * 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 }); + } + + // Check content length to prevent oversized uploads + const contentLength = request.headers.get("content-length"); + if (contentLength && parseInt(contentLength, 10) > MAX_IMPORT_SIZE) { + return NextResponse.json( + { + valid: false, + errors: [`File too large. Maximum size is ${MAX_IMPORT_SIZE / 1024 / 1024}MB`], + 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: [], + credentials_details: [], + } satisfies ImportPreviewResponse, + { status: 413 } + ); + } + + try { + const body = await request.json(); + + // 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: 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: [], + conflicts: [], + layout: { desktop_items: 0, tablet_items: 0, mobile_items: 0 }, + has_theme: false, + credentials_needed: [], + credentials_missing: [], + credentials_details: [], + }; + 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])); + + // Helper to count lines + const countLines = (str?: string): number => { + if (!str) return 0; + return str.split("\n").length; + }; + + // Check for conflicts and build detailed widget previews + const conflicts: WidgetConflict[] = []; + const widgetPreviews: WidgetPreviewDetail[] = []; + + for (const widget of dashboard.widgets) { + const hasConflict = existingSlugs.has(widget.slug); + + // Extract credential info for this widget + const widgetCredentials = (widget.credentials || []).map((cred) => ({ + id: cred.id, + name: cred.name, + type: cred.type, + })); + + widgetPreviews.push({ + slug: widget.slug, + name: widget.name, + description: widget.description, + has_conflict: hasConflict, + source_code: widget.source_code, + server_code: widget.server_code, + server_code_enabled: widget.server_code_enabled, + source_code_lines: countLines(widget.source_code), + server_code_lines: widget.server_code + ? countLines(widget.server_code) + : undefined, + credentials: widgetCredentials, + }); + + 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 and build detailed credential info + const credentialsNeeded: string[] = []; + const credentialsMissing: string[] = []; + const credentialsDetails: CredentialPreviewDetail[] = []; + const seenCredentialIds = new Set(); + + 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 and build detailed list + for (const widget of dashboard.widgets) { + if (widget.credentials && Array.isArray(widget.credentials)) { + for (const cred of widget.credentials) { + const credId = cred.id; + if (credId && !credentialsNeeded.includes(credId)) { + credentialsNeeded.push(credId); + const isConfigured = hasCredential(credId as Provider); + if (!isConfigured && !credentialsMissing.includes(credId)) { + credentialsMissing.push(credId); + } + } + + // Add to detailed list if not seen + if (credId && !seenCredentialIds.has(credId)) { + seenCredentialIds.add(credId); + const isConfigured = hasCredential(credId as Provider); + credentialsDetails.push({ + id: credId, + type: cred.type, + name: cred.name, + description: cred.description, + obtain_url: cred.obtain_url, + install_url: cred.install_url, + is_configured: isConfigured, + }); + } + } + } + } + + // Also add credentials from credentials_needed that weren't in widgets + if (dashboard.credentials_needed) { + for (const cred of dashboard.credentials_needed) { + if (!seenCredentialIds.has(cred.provider)) { + seenCredentialIds.add(cred.provider); + const isConfigured = hasCredential(cred.provider as Provider); + credentialsDetails.push({ + id: cred.provider, + type: "api_key", // Default type for credentials_needed entries + name: cred.provider, + description: cred.description, + is_configured: isConfigured, + }); + } + } + } + + // 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); + + // Build theme details + const themeDetails: ThemePreviewDetail | undefined = hasTheme + ? { + name: dashboard.theme!.name, + lightCss: dashboard.theme!.lightCss, + darkCss: dashboard.theme!.darkCss, + lightCss_lines: countLines(dashboard.theme!.lightCss), + darkCss_lines: countLines(dashboard.theme!.darkCss), + } + : undefined; + + // 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, + theme_details: themeDetails, + credentials_needed: credentialsNeeded, + credentials_missing: credentialsMissing, + credentials_details: credentialsDetails, + }; + + 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: [], + credentials_details: [], + } 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..1c57130 --- /dev/null +++ b/src/app/api/dashboard/import/route.ts @@ -0,0 +1,427 @@ +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, +} from "@/lib/db"; +import { hasCredential, type Provider } from "@/lib/credentials"; +import { generateUniqueSlug } from "@/lib/widget-package"; +import { + validateDashboardFormat, + type DashboardExportFormat, + type ImportResponse, +} from "@/lib/dashboard-format"; + +// Maximum import file size (5MB) to prevent DoS +const MAX_IMPORT_SIZE = 5 * 1024 * 1024; + +interface ImportOptions { + import_widgets: boolean; + import_theme: boolean; + import_layout: boolean; + conflict_resolution: "overwrite" | "rename" | "skip"; + clear_existing_layout?: boolean; +} + +/** + * 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 }); + } + + // Check content length to prevent oversized uploads + const contentLength = request.headers.get("content-length"); + if (contentLength && parseInt(contentLength, 10) > MAX_IMPORT_SIZE) { + return NextResponse.json( + { + success: false, + errors: [`File too large. Maximum size is ${MAX_IMPORT_SIZE / 1024 / 1024}MB`], + imported: { + widgets: [], + widgets_skipped: [], + widgets_renamed: [], + theme: false, + layout: false, + layout_items: 0, + }, + credentials_missing: [], + warnings: [], + } satisfies ImportResponse, + { status: 413 } + ); + } + + 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(); + + // Map of actual slug -> custom widget id (for layout creation) + const slugToWidgetId = new Map(); + + // Import widgets + if (options.import_widgets) { + const existingWidgets = getAllCustomWidgets(true); + // Track all existing slugs AND slugs we create during import + const existingSlugs = new Set(existingWidgets.map((w) => w.slug)); + + // Pre-populate slugToWidgetId with existing widgets + for (const w of existingWidgets) { + slugToWidgetId.set(w.slug, w.id); + } + + for (const widget of dashboard.widgets) { + // Check conflict against our tracked set, not the database + // This prevents false conflicts with just-renamed widgets + const hasConflict = existingSlugs.has(widget.slug); + + if (hasConflict) { + // 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, + Array.from(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.add(newSlug); + slugToWidgetId.set(newSlug, newId); + renamedWidgets.push({ original: widget.slug, renamed: newSlug }); + slugMap.set(widget.slug, newSlug); + importedWidgets.push(newSlug); + break; + } + + case "overwrite": + default: { + // Update existing widget + const existingWidget = getCustomWidgetBySlug(widget.slug); + if (existingWidget) { + 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); + slugToWidgetId.set(widget.slug, existingWidget.id); + 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.add(widget.slug); + slugToWidgetId.set(widget.slug, widgetId); + 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.custom_widget_id) { + deleteWidget(instance.id); + } + } + } + + // Create widget instances from layout + // Important: We create ALL instances, allowing multiple instances of the same widget + for (const layoutItem of dashboard.layout.desktop) { + // Get the actual slug (may have been renamed) + const actualSlug = slugMap.get(layoutItem.widget) || layoutItem.widget; + + // Get widget id from our map first (handles just-imported widgets) + // Fall back to database query for widgets that weren't in the import + let customWidgetId = slugToWidgetId.get(actualSlug); + if (!customWidgetId) { + const customWidget = getCustomWidgetBySlug(actualSlug); + if (customWidget) { + customWidgetId = customWidget.id; + } + } + + if (!customWidgetId) { + warnings.push( + `Layout references widget "${layoutItem.widget}" which was not imported` + ); + continue; + } + + // Get widget name for the instance + const customWidget = getCustomWidgetBySlug(actualSlug); + const widgetName = customWidget?.name || actualSlug; + + // Create widget instance - always create, allowing multiple instances + const instanceId = `widget_${nanoid(12)}`; + const position = { + x: layoutItem.x, + y: layoutItem.y, + w: layoutItem.w, + h: layoutItem.h, + }; + + createWidget( + instanceId, + "custom", + widgetName, + {}, // config + position, + undefined, // dataSource + customWidgetId + ); + + 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.id; + 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 } + ); + } +} 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/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..62af310 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,9 @@ import { Key, Palette, Upload, + FolderDown, + FolderUp, + MoreHorizontal, } from "lucide-react"; import { useTheme } from "next-themes"; import { cn } from "@/lib/utils"; @@ -27,6 +39,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 (
@@ -45,15 +59,6 @@ export function DashboardHeader() {
- - + + + setImportOpen(true)}> + + Import Widget + + + setDashboardExportOpen(true)}> + + Export Dashboard + + setDashboardImportOpen(true)}> + + Import Dashboard + + + + - } /> + +
+

Import & Export

+ + + +
@@ -198,6 +252,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..6a4d512 --- /dev/null +++ b/src/components/dashboard/DashboardImportModal.tsx @@ -0,0 +1,913 @@ +'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, + ChevronDown, + ChevronUp, + FileJson, + Package, + AlertTriangle, + Key, + Palette, + LayoutGrid, + Code, + Server, + ExternalLink, + Copy, + CheckCircle, + XCircle, +} from 'lucide-react'; +import type { + ImportPreviewResponse, + ImportResponse, +} from '@/lib/dashboard-format'; + +interface DashboardImportModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onImportComplete: () => void; +} + +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 [expandedSections, setExpandedSections] = useState>(new Set()); + const [expandedWidgets, setExpandedWidgets] = useState>(new Set()); + const [expandedWidgetCode, setExpandedWidgetCode] = useState>({}); + const fileInputRef = useRef(null); + + const toggleSection = (section: string) => { + setExpandedSections((prev) => { + const next = new Set(prev); + if (next.has(section)) { + next.delete(section); + } else { + next.add(section); + } + return next; + }); + }; + + const toggleWidget = (slug: string) => { + setExpandedWidgets((prev) => { + const next = new Set(prev); + if (next.has(slug)) { + next.delete(slug); + } else { + next.add(slug); + } + return next; + }); + }; + + const toggleWidgetCode = (slug: string, codeType: 'source' | 'server') => { + setExpandedWidgetCode((prev) => ({ + ...prev, + [slug]: prev[slug] === codeType ? null : codeType, + })); + }; + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + }; + + const getCredentialTypeBadge = (type: string) => { + switch (type) { + case 'api_key': + return API Key; + case 'local_software': + return Local Software; + case 'oauth': + return OAuth; + case 'agent': + return Agent; + default: + return null; + } + }; + + const handleClose = () => { + setStep('upload'); + setError(null); + setFileName(null); + setDashboardData(null); + setPreview(null); + setImportResult(null); + setConflictResolution('overwrite'); + setImportLayout(true); + setImportTheme(true); + setClearExistingLayout(false); + setExpandedSections(new Set()); + setExpandedWidgets(new Set()); + setExpandedWidgetCode({}); + 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'} +
+
+
+ + {/* 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 && ( +
+
+ + Warnings +
+
    + {preview.warnings.map((warning, i) => ( +
  • • {warning}
  • + ))} +
+
+ )} + + {/* Missing Credentials Warning (only if no detailed credentials info) */} + {preview.credentials_missing.length > 0 && preview.credentials_details.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"; diff --git a/src/lib/dashboard-format.ts b/src/lib/dashboard-format.ts new file mode 100644 index 0000000..5137fc4 --- /dev/null +++ b/src/lib/dashboard-format.ts @@ -0,0 +1,216 @@ +/** + * Shared types and utilities for dashboard export/import + */ + +import type { + FetchConfig, + CacheConfig, + SetupConfig, + DataSchema, + CredentialRequirement, +} from "@/lib/db"; + +/** + * Dashboard export format v1 + * Used for .glance.json files + */ +export 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; + }>; +} + +/** + * 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 + */ +export 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 }; +} 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; }