Files
web-app-launcher/src/lib/utils/validators.ts
T
alexei.dolgolyov a6b09aae9c feat(inline-edit): add WYSIWYG inline dashboard editing mode
Replace the disconnected board edit page with inline editing directly
on the board view. Toggle with Ctrl+E or the Edit button. Features:

- Edit mode store with changeset accumulation and batch save
- Floating toolbar (save, discard, add section, board settings, exit)
- Widget hover overlays with edit/delete/drag controls
- Type-specific widget config panels for all 14 widget types
- Section inline editing (title, icon picker, delete)
- "+" buttons for adding widgets and sections inline
- Section-level drag-and-drop reordering via svelte-dnd-action
- Batch save API endpoint (single Prisma transaction)
- Board properties side panel with live theme/wallpaper preview
- Modal widget type picker with search filtering
- Icon picker component with visual grid and search
- Confirmation dialog modal for all destructive actions
- HTML format support for Note widget (in addition to markdown/text)
- Full i18n support (en + ru) for all new UI strings
- Legacy edit page banner linking to new inline mode
2026-04-03 00:01:29 +03:00

426 lines
13 KiB
TypeScript

import { z } from 'zod';
import {
UserRole,
AuthMode,
WidgetType,
IconType,
PermissionLevel,
EntityType,
TargetType,
HealthcheckMethod,
CardSize,
NotificationType,
ApiTokenScope,
AuditAction
} from './constants.js';
// --- Auth ---
export const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(1, 'Password is required')
});
export const registerSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(6, 'Password must be at least 6 characters'),
displayName: z.string().min(1, 'Display name is required').max(100)
});
// --- User ---
export const createUserSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(6).optional(),
displayName: z.string().min(1).max(100),
avatarUrl: z.string().url().optional(),
authProvider: z.enum([AuthMode.LOCAL, AuthMode.OAUTH]).optional(),
role: z.enum([UserRole.ADMIN, UserRole.USER]).optional()
});
export const updateUserSchema = z.object({
displayName: z.string().min(1).max(100).optional(),
avatarUrl: z.string().url().nullable().optional(),
role: z.enum([UserRole.ADMIN, UserRole.USER]).optional(),
onboardingComplete: z.boolean().optional(),
trackRecentApps: z.boolean().optional()
});
// --- Group ---
export const createGroupSchema = z.object({
name: z.string().min(1, 'Group name is required').max(100),
description: z.string().max(500).optional(),
isDefault: z.boolean().optional()
});
export const updateGroupSchema = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().max(500).nullable().optional(),
isDefault: z.boolean().optional()
});
// --- App ---
export const createAppSchema = z.object({
name: z.string().min(1, 'App name is required').max(200),
url: z.string().url('Invalid URL'),
icon: z.string().max(500).optional(),
iconType: z.enum([IconType.LUCIDE, IconType.SIMPLE, IconType.URL, IconType.EMOJI]).optional(),
description: z.string().max(1000).optional(),
category: z.string().max(100).optional(),
tags: z.string().max(500).optional(),
healthcheckEnabled: z.boolean().optional(),
healthcheckInterval: z.number().int().min(30).max(86400).optional(),
healthcheckMethod: z.enum([HealthcheckMethod.GET, HealthcheckMethod.HEAD]).optional(),
healthcheckExpectedStatus: z.number().int().min(100).max(599).optional(),
healthcheckTimeout: z.number().int().min(1000).max(30000).optional(),
integrationType: z.string().max(50).nullable().optional(),
integrationConfig: z.string().max(10000).nullable().optional(),
integrationEnabled: z.boolean().optional()
});
export const updateAppSchema = z.object({
name: z.string().min(1).max(200).optional(),
url: z.string().url().optional(),
icon: z.string().max(500).nullable().optional(),
iconType: z.enum([IconType.LUCIDE, IconType.SIMPLE, IconType.URL, IconType.EMOJI]).optional(),
description: z.string().max(1000).nullable().optional(),
category: z.string().max(100).nullable().optional(),
tags: z.string().max(500).optional(),
healthcheckEnabled: z.boolean().optional(),
healthcheckInterval: z.number().int().min(30).max(86400).optional(),
healthcheckMethod: z.enum([HealthcheckMethod.GET, HealthcheckMethod.HEAD]).optional(),
healthcheckExpectedStatus: z.number().int().min(100).max(599).optional(),
healthcheckTimeout: z.number().int().min(1000).max(30000).optional(),
integrationType: z.string().max(50).nullable().optional(),
integrationConfig: z.string().max(10000).nullable().optional(),
integrationEnabled: z.boolean().optional()
});
// --- Board ---
export const createBoardSchema = z.object({
name: z.string().min(1, 'Board name is required').max(200),
icon: z.string().max(500).optional(),
description: z.string().max(1000).optional(),
isDefault: z.boolean().optional(),
isGuestAccessible: z.boolean().optional(),
backgroundConfig: z.string().optional()
});
export const updateBoardSchema = z.object({
name: z.string().min(1).max(200).optional(),
icon: z.string().max(500).nullable().optional(),
description: z.string().max(1000).nullable().optional(),
isDefault: z.boolean().optional(),
isGuestAccessible: z.boolean().optional(),
backgroundConfig: z.string().nullable().optional(),
themeHue: z.number().int().min(0).max(360).nullable().optional(),
themeSaturation: z.number().int().min(0).max(100).nullable().optional(),
backgroundType: z
.enum(['mesh', 'particles', 'aurora', 'wallpaper', 'none'])
.nullable()
.optional(),
cardSize: z.enum([CardSize.COMPACT, CardSize.MEDIUM, CardSize.LARGE]).nullable().optional(),
wallpaperUrl: z.string().url().max(2000).nullable().optional(),
wallpaperBlur: z.number().int().min(0).max(50).nullable().optional(),
wallpaperOverlay: z.number().min(0).max(1).nullable().optional(),
customCss: z.string().max(10000).nullable().optional()
});
// --- Section ---
export const createSectionSchema = z.object({
boardId: z.string().cuid(),
title: z.string().min(1, 'Section title is required').max(200),
icon: z.string().max(500).optional(),
order: z.number().int().min(0).optional(),
isExpandedByDefault: z.boolean().optional()
});
export const updateSectionSchema = z.object({
title: z.string().min(1).max(200).optional(),
icon: z.string().max(500).nullable().optional(),
order: z.number().int().min(0).optional(),
isExpandedByDefault: z.boolean().optional(),
cardSize: z.enum([CardSize.COMPACT, CardSize.MEDIUM, CardSize.LARGE]).nullable().optional()
});
// --- Widget Config Schemas ---
export const appWidgetConfigSchema = z.object({
appId: z.string().min(1, 'App ID is required')
});
export const bookmarkWidgetConfigSchema = z.object({
url: z.string().url('Invalid URL'),
label: z.string().min(1, 'Label is required').max(200),
icon: z.string().max(100).optional(),
description: z.string().max(500).optional()
});
export const noteWidgetConfigSchema = z.object({
content: z.string().max(10000, 'Content too long'),
format: z.enum(['markdown', 'text', 'html']).default('markdown')
});
export const embedWidgetConfigSchema = z.object({
url: z.string().url('Invalid URL'),
height: z.number().int().min(100).max(2000).default(300),
sandbox: z.string().max(200).optional()
});
export const statusWidgetConfigSchema = z.object({
appIds: z.array(z.string().min(1)).min(1, 'At least one app is required'),
label: z.string().max(200).optional()
});
// --- Widget ---
const allWidgetTypes = [
WidgetType.APP,
WidgetType.BOOKMARK,
WidgetType.NOTE,
WidgetType.EMBED,
WidgetType.STATUS,
WidgetType.CLOCK,
WidgetType.SYSTEM_STATS,
WidgetType.RSS,
WidgetType.CALENDAR,
WidgetType.MARKDOWN,
WidgetType.METRIC,
WidgetType.LINK_GROUP,
WidgetType.CAMERA,
WidgetType.INTEGRATION
] as const;
export const createWidgetSchema = z.object({
sectionId: z.string().cuid(),
type: z.enum(allWidgetTypes),
order: z.number().int().min(0).optional(),
config: z.string().optional(),
appId: z.string().cuid().optional()
});
export const updateWidgetSchema = z.object({
type: z.enum(allWidgetTypes).optional(),
order: z.number().int().min(0).optional(),
config: z.string().optional(),
appId: z.string().cuid().nullable().optional()
});
// --- Permission ---
export const createPermissionSchema = z.object({
entityType: z.enum([EntityType.BOARD, EntityType.APP]),
entityId: z.string().cuid(),
targetType: z.enum([TargetType.USER, TargetType.GROUP]),
targetId: z.string().cuid(),
level: z.enum([PermissionLevel.VIEW, PermissionLevel.EDIT, PermissionLevel.ADMIN])
});
// --- Backup Schedule ---
export const updateBackupScheduleSchema = z.object({
backupEnabled: z.boolean().optional(),
backupCronExpression: z.string().min(1).max(100).optional(),
backupMaxCount: z.number().int().min(1).max(100).optional()
});
// --- System Settings ---
export const updateSystemSettingsSchema = z.object({
authMode: z.enum([AuthMode.LOCAL, AuthMode.OAUTH, AuthMode.BOTH]).optional(),
registrationEnabled: z.boolean().optional(),
oauthClientId: z.string().nullable().optional(),
oauthClientSecret: z.string().nullable().optional(),
oauthDiscoveryUrl: z.string().url().nullable().optional(),
defaultTheme: z.enum(['dark', 'light']).optional(),
defaultPrimaryColor: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/, 'Invalid hex color')
.optional(),
healthcheckDefaults: z.string().optional(),
customCss: z.string().max(10000).nullable().optional(),
onboardingComplete: z.boolean().optional()
});
// --- New widget config schemas for Phases 4-7 ---
export const clockWeatherWidgetConfigSchema = z.object({
timezone: z.string().max(100).optional(),
showWeather: z.boolean().optional(),
latitude: z.number().min(-90).max(90).optional(),
longitude: z.number().min(-180).max(180).optional(),
clockStyle: z.enum(['analog', 'digital', '24h']).optional()
});
export const systemStatsWidgetConfigSchema = z.object({
sourceUrl: z.string().url('Invalid source URL'),
sourceType: z.enum(['glances', 'prometheus', 'custom']),
metrics: z.array(z.string().min(1)).min(1, 'At least one metric is required'),
refreshInterval: z.number().int().min(5).max(3600).optional()
});
export const rssWidgetConfigSchema = z.object({
feedUrl: z.string().url('Invalid feed URL'),
maxItems: z.number().int().min(1).max(50).optional(),
showSummary: z.boolean().optional()
});
export const calendarWidgetConfigSchema = z.object({
icalUrls: z
.array(
z.object({
url: z.string().url('Invalid iCal URL'),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/, 'Invalid hex color')
.optional(),
label: z.string().max(100).optional()
})
)
.min(1, 'At least one calendar URL is required'),
daysAhead: z.number().int().min(1).max(90).optional()
});
export const markdownWidgetConfigSchema = z.object({
content: z.string().max(50000, 'Content too long'),
syntaxTheme: z.string().max(50).optional()
});
export const metricWidgetConfigSchema = z.object({
label: z.string().min(1, 'Label is required').max(200),
source: z.enum(['static', 'json', 'prometheus']),
value: z.string().max(200).optional(),
url: z.string().url().optional(),
jsonPath: z.string().max(200).optional(),
query: z.string().max(500).optional(),
unit: z.string().max(50).optional(),
refreshInterval: z.number().int().min(5).max(3600).optional()
});
export const linkGroupWidgetConfigSchema = z.object({
links: z
.array(
z.object({
label: z.string().min(1).max(200),
url: z.string().url('Invalid URL'),
icon: z.string().max(100).optional()
})
)
.min(1, 'At least one link is required'),
collapsible: z.boolean().optional()
});
export const cameraWidgetConfigSchema = z.object({
streamUrl: z.string().url('Invalid stream URL'),
type: z.enum(['mjpeg', 'hls', 'image']),
refreshInterval: z.number().int().min(1).max(3600).optional(),
aspectRatio: z.string().max(20).optional()
});
export const integrationWidgetConfigSchema = z.object({
appId: z.string().min(1, 'App ID is required'),
endpointId: z.string().min(1, 'Endpoint ID is required'),
refreshInterval: z.number().int().min(5).max(3600).optional()
});
// --- New entity schemas for Phases 4-7 ---
export const createTagSchema = z.object({
name: z.string().min(1, 'Tag name is required').max(50),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/, 'Invalid hex color')
.optional()
});
export const updateTagSchema = z.object({
name: z.string().min(1).max(50).optional(),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/, 'Invalid hex color')
.nullable()
.optional()
});
export const createAppLinkSchema = z.object({
appId: z.string().cuid(),
label: z.string().min(1, 'Label is required').max(200),
url: z.string().url('Invalid URL'),
icon: z.string().max(100).optional(),
order: z.number().int().min(0).optional()
});
export const updateAppLinkSchema = z.object({
label: z.string().min(1).max(200).optional(),
url: z.string().url().optional(),
icon: z.string().max(100).nullable().optional(),
order: z.number().int().min(0).optional()
});
export const createNotificationChannelSchema = z.object({
type: z.enum([
NotificationType.DISCORD,
NotificationType.SLACK,
NotificationType.TELEGRAM,
NotificationType.HTTP
]),
config: z.string().min(1, 'Config is required'),
enabled: z.boolean().optional()
});
export const updateNotificationChannelSchema = z.object({
type: z
.enum([
NotificationType.DISCORD,
NotificationType.SLACK,
NotificationType.TELEGRAM,
NotificationType.HTTP
])
.optional(),
config: z.string().min(1).optional(),
enabled: z.boolean().optional()
});
export const createApiTokenSchema = z.object({
name: z.string().min(1, 'Token name is required').max(100),
scope: z.enum([ApiTokenScope.READ, ApiTokenScope.WRITE, ApiTokenScope.ADMIN]),
expiresAt: z.string().datetime().optional()
});
export const createBoardTemplateSchema = z.object({
name: z.string().min(1, 'Template name is required').max(200),
description: z.string().max(1000).optional(),
icon: z.string().max(500).optional(),
config: z.string().min(1, 'Config is required'),
isBuiltin: z.boolean().optional()
});
export const auditLogQuerySchema = z.object({
action: z
.enum([
AuditAction.USER_CREATED,
AuditAction.USER_DELETED,
AuditAction.USER_UPDATED,
AuditAction.BOARD_CREATED,
AuditAction.BOARD_DELETED,
AuditAction.APP_CREATED,
AuditAction.APP_DELETED,
AuditAction.SETTINGS_UPDATED,
AuditAction.IMPORT,
AuditAction.EXPORT,
AuditAction.BACKUP_CREATED,
AuditAction.BACKUP_RESTORED,
AuditAction.BACKUP_DELETED
])
.optional(),
entityType: z.string().max(50).optional(),
dateFrom: z.string().datetime().optional(),
dateTo: z.string().datetime().optional(),
page: z.number().int().min(1).optional(),
limit: z.number().int().min(1).max(100).optional()
});