feat: Phases 4-7 — Full Feature Expansion (26 features)

Phase 4 — New Widget Types:
- Clock/Weather, System Stats, RSS/Feed, Calendar, Markdown,
  Metric/Counter, Link Group, Camera/Stream widgets
- Backend services with caching for each data source
- Full creation form with dynamic config fields per type

Phase 5 — Visual & Styling Enhancements:
- Glassmorphism card style (solid/glass/outline)
- Board-level themes with per-board hue/saturation
- Animated SVG status rings replacing static dots
- Card size options (compact/medium/large)
- Custom CSS injection (admin + per-board, sanitized)
- Wallpaper backgrounds with blur/overlay/parallax

Phase 6 — Functional Features:
- Favorites bar with drag-and-drop reordering
- Recent apps tracking with privacy toggle
- Uptime dashboard page (/status, guest-accessible)
- Notifications system (Discord/Slack/Telegram/HTTP webhooks)
- App tags with filtering in board view
- Multi-URL app cards with expandable sub-links
- Personal API tokens with scoped permissions
- Audit log with retention and admin viewer

Phase 7 — Quality of Life:
- Onboarding wizard (5-step first-launch setup)
- App URL health preview with favicon/title detection
- Board templates (4 built-in + custom import/export)
- Keyboard shortcut overlay (j/k nav, 1-9 boards, ? help)

212 files changed, 15641 insertions, 980 deletions.
Build, lint, type check, and 222 tests all pass.
This commit is contained in:
2026-03-25 14:18:10 +03:00
parent 8d7847889e
commit 1c0a7cb850
212 changed files with 15642 additions and 981 deletions
+48 -14
View File
@@ -28,6 +28,10 @@
--destructive: hsl(0 72.2% 50.6%);
--destructive-foreground: hsl(0 0% 98%);
--ring: hsl(var(--primary-h) var(--primary-s) var(--primary-l));
--status-online: #22c55e;
--status-offline: #ef4444;
--status-degraded: #eab308;
--status-unknown: #6b7280;
--radius: 0.5rem;
--sidebar: hsl(0 0% 98%);
--sidebar-foreground: hsl(240 5.3% 26.1%);
@@ -112,7 +116,9 @@
}
body {
@apply bg-background text-foreground;
transition: background-color 0.3s ease, color 0.3s ease;
transition:
background-color 0.3s ease,
color 0.3s ease;
}
}
@@ -134,9 +140,40 @@
color: hsl(142 71% 45%);
}
/* ===== Card Style Variants ===== */
.card-solid {
background: var(--card);
border: 1px solid var(--border);
}
.card-glass {
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
background: color-mix(in srgb, var(--card) 60%, transparent);
border: 1px solid color-mix(in srgb, var(--border) 30%, transparent);
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.05);
}
.dark .card-glass {
background: color-mix(in srgb, var(--card) 50%, transparent);
border: 1px solid color-mix(in srgb, var(--border) 25%, transparent);
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.15);
}
.card-outline {
background: transparent;
border: 1px solid var(--border);
}
.dark .card-outline {
border-color: var(--border);
}
/* ===== Card Hover Effects ===== */
.card-hover {
transition: transform 0.2s ease, box-shadow 0.2s ease;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.card-hover:hover {
@@ -163,24 +200,14 @@
}
.skeleton {
background: linear-gradient(
90deg,
var(--muted) 25%,
hsl(240 4.8% 85%) 50%,
var(--muted) 75%
);
background: linear-gradient(90deg, var(--muted) 25%, hsl(240 4.8% 85%) 50%, var(--muted) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
border-radius: var(--radius);
}
.dark .skeleton {
background: linear-gradient(
90deg,
var(--muted) 25%,
hsl(240 3.7% 22%) 50%,
var(--muted) 75%
);
background: linear-gradient(90deg, var(--muted) 25%, hsl(240 3.7% 22%) 50%, var(--muted) 75%);
background-size: 200% 100%;
}
@@ -204,6 +231,13 @@
opacity: 0.8;
}
/* ===== Keyboard Navigation Selection ===== */
[data-keyboard-selected='true'] {
outline: 2px solid hsl(var(--primary-h) var(--primary-s) var(--primary-l));
outline-offset: 2px;
border-radius: var(--radius, 0.5rem);
}
/* ===== Aurora Keyframes ===== */
@keyframes aurora-shift {
0% {
+1 -3
View File
@@ -16,9 +16,7 @@
try {
var mode = localStorage.getItem('wal-theme-mode') || 'system';
if (mode === 'system') {
mode = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
mode = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.className = mode;
} catch (e) {}
+27
View File
@@ -3,6 +3,8 @@ import { redirect } from '@sveltejs/kit';
import { verifyAccessToken } from '$lib/server/services/authService.js';
import * as authService from '$lib/server/services/authService.js';
import * as userService from '$lib/server/services/userService.js';
import * as apiTokenService from '$lib/server/services/apiTokenService.js';
import { extractBearerToken } from '$lib/server/middleware/authenticate.js';
import { isBoardGuestAccessible } from '$lib/server/middleware/guestAccess.js';
const PUBLIC_PATHS = ['/login', '/register', '/auth/', '/api/health'];
@@ -91,6 +93,31 @@ export const handle: Handle = async ({ event, resolve }) => {
}
}
// If still no valid session, try API token from Authorization header
if (!event.locals.user) {
const bearerToken = extractBearerToken(event);
if (bearerToken) {
try {
const tokenResult = await apiTokenService.validateToken(bearerToken);
if (tokenResult) {
const user = await userService.findById(tokenResult.userId);
event.locals.user = {
id: user.id,
email: user.email,
displayName: user.displayName,
role: user.role as 'admin' | 'user'
};
event.locals.session = {
id: user.id,
expiresAt: new Date(Date.now() + 15 * 60 * 1000)
};
}
} catch {
// API token validation failed — continue as unauthenticated
}
}
}
// Route protection
const { pathname } = event.url;
@@ -0,0 +1,281 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
interface AuditLogEntry {
id: string;
userId: string | null;
action: string;
entityType: string;
entityId: string;
details: string;
createdAt: Date | string;
user?: {
displayName: string;
email: string;
} | null;
}
interface Filters {
action: string;
entityType: string;
dateFrom: string;
dateTo: string;
}
interface Props {
logs: AuditLogEntry[];
filters: Filters;
page: number;
hasMore: boolean;
}
let { logs, filters, page: currentPage, hasMore }: Props = $props();
let expandedId = $state<string | null>(null);
let filterAction = $state(filters.action);
let filterEntityType = $state(filters.entityType);
let filterDateFrom = $state(filters.dateFrom);
let filterDateTo = $state(filters.dateTo);
const actionOptions = [
{ value: '', label: 'All Actions' },
{ value: 'user_created', label: 'User Created' },
{ value: 'user_deleted', label: 'User Deleted' },
{ value: 'user_updated', label: 'User Updated' },
{ value: 'board_created', label: 'Board Created' },
{ value: 'board_deleted', label: 'Board Deleted' },
{ value: 'app_created', label: 'App Created' },
{ value: 'app_deleted', label: 'App Deleted' },
{ value: 'settings_updated', label: 'Settings Updated' },
{ value: 'import', label: 'Import' },
{ value: 'export', label: 'Export' }
];
const entityTypeOptions = [
{ value: '', label: 'All Entities' },
{ value: 'user', label: 'User' },
{ value: 'board', label: 'Board' },
{ value: 'app', label: 'App' },
{ value: 'settings', label: 'Settings' },
{ value: 'data', label: 'Data' }
];
function applyFilters() {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const params = new URLSearchParams();
if (filterAction) params.set('action', filterAction);
if (filterEntityType) params.set('entityType', filterEntityType);
if (filterDateFrom) params.set('dateFrom', filterDateFrom);
if (filterDateTo) params.set('dateTo', filterDateTo);
params.set('page', '1');
goto(`/admin/audit-log?${params.toString()}`, { replaceState: true });
}
function changePage(delta: number) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const params = new URLSearchParams($page.url.searchParams);
params.set('page', String(Math.max(1, currentPage + delta)));
goto(`/admin/audit-log?${params.toString()}`, { replaceState: true });
}
function toggleDetails(id: string) {
expandedId = expandedId === id ? null : id;
}
function formatDetails(details: string): string {
try {
return JSON.stringify(JSON.parse(details), null, 2);
} catch {
return details;
}
}
function actionLabel(action: string): string {
return action.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
function actionBadgeClass(action: string): string {
if (action.includes('deleted')) return 'bg-red-500/10 text-red-500';
if (action.includes('created')) return 'bg-green-500/10 text-green-500';
if (action.includes('updated')) return 'bg-blue-500/10 text-blue-500';
if (action === 'import') return 'bg-purple-500/10 text-purple-500';
if (action === 'export') return 'bg-yellow-500/10 text-yellow-500';
return 'bg-muted text-muted-foreground';
}
function exportCsv() {
const headers = ['Timestamp', 'User', 'Action', 'Entity Type', 'Entity ID', 'Details'];
const rows = logs.map((log) => [
new Date(log.createdAt).toISOString(),
log.user?.displayName ?? log.userId ?? 'System',
log.action,
log.entityType,
log.entityId,
log.details.replace(/"/g, '""')
]);
const csv = [
headers.join(','),
...rows.map((row) => row.map((cell) => `"${cell}"`).join(','))
].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `audit-log-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
}
</script>
<div>
<!-- Filters -->
<div class="mb-4 flex flex-wrap items-end gap-3">
<div>
<label for="filter-action" class="mb-1 block text-xs font-medium text-muted-foreground">Action</label>
<select
id="filter-action"
bind:value={filterAction}
class="rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground"
>
{#each actionOptions as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<div>
<label for="filter-entity" class="mb-1 block text-xs font-medium text-muted-foreground">Entity</label>
<select
id="filter-entity"
bind:value={filterEntityType}
class="rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground"
>
{#each entityTypeOptions as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<div>
<label for="filter-from" class="mb-1 block text-xs font-medium text-muted-foreground">From</label>
<input
id="filter-from"
type="date"
bind:value={filterDateFrom}
class="rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground"
/>
</div>
<div>
<label for="filter-to" class="mb-1 block text-xs font-medium text-muted-foreground">To</label>
<input
id="filter-to"
type="date"
bind:value={filterDateTo}
class="rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground"
/>
</div>
<button
type="button"
onclick={applyFilters}
class="rounded-md bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Apply
</button>
<button
type="button"
onclick={exportCsv}
class="ml-auto rounded-md border border-input px-4 py-1.5 text-sm font-medium text-foreground hover:bg-accent"
>
Export CSV
</button>
</div>
<!-- Table -->
{#if logs.length === 0}
<div class="rounded-xl border border-border bg-card/50 p-12 text-center">
<p class="text-muted-foreground">No audit log entries found</p>
</div>
{:else}
<div class="overflow-x-auto rounded-lg border border-border">
<table class="w-full text-left text-sm">
<thead class="border-b border-border bg-muted/50">
<tr>
<th class="px-4 py-3 font-medium text-muted-foreground">Timestamp</th>
<th class="px-4 py-3 font-medium text-muted-foreground">User</th>
<th class="px-4 py-3 font-medium text-muted-foreground">Action</th>
<th class="px-4 py-3 font-medium text-muted-foreground">Entity</th>
<th class="px-4 py-3 font-medium text-muted-foreground">Details</th>
</tr>
</thead>
<tbody>
{#each logs as log (log.id)}
<tr class="border-b border-border last:border-0">
<td class="whitespace-nowrap px-4 py-3 text-xs text-muted-foreground">
{new Date(log.createdAt).toLocaleString()}
</td>
<td class="px-4 py-3 text-sm text-foreground">
{log.user?.displayName ?? log.userId ?? 'System'}
</td>
<td class="px-4 py-3">
<span class="inline-block rounded-full px-2 py-0.5 text-xs font-medium {actionBadgeClass(log.action)}">
{actionLabel(log.action)}
</span>
</td>
<td class="px-4 py-3">
<span class="text-xs text-foreground">{log.entityType}</span>
<span class="ml-1 text-[10px] text-muted-foreground">{log.entityId.substring(0, 8)}...</span>
</td>
<td class="px-4 py-3">
{#if log.details && log.details !== '{}'}
<button
type="button"
onclick={() => toggleDetails(log.id)}
class="text-xs text-primary hover:underline"
>
{expandedId === log.id ? 'Hide' : 'View'}
</button>
{:else}
<span class="text-xs text-muted-foreground"></span>
{/if}
</td>
</tr>
{#if expandedId === log.id}
<tr>
<td colspan="5" class="bg-muted/30 px-4 py-3">
<pre class="max-h-48 overflow-auto rounded-md bg-background p-3 text-xs text-foreground">{formatDetails(log.details)}</pre>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="mt-4 flex items-center justify-between">
<button
type="button"
disabled={currentPage === 1}
onclick={() => changePage(-1)}
class="rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent disabled:opacity-50"
>
Previous
</button>
<span class="text-sm text-muted-foreground">Page {currentPage}</span>
<button
type="button"
disabled={!hasMore}
onclick={() => changePage(1)}
class="rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent disabled:opacity-50"
>
Next
</button>
</div>
{/if}
</div>
@@ -3,6 +3,7 @@
import { superForm, type SuperValidated } from 'sveltekit-superforms/client';
import type { updateSystemSettingsSchema } from '$lib/utils/validators.js';
import type { z } from 'zod';
import CustomCssEditor from '$lib/components/settings/CustomCssEditor.svelte';
let {
form: formData,
@@ -224,6 +225,18 @@
</div>
</section>
<!-- System Custom CSS -->
<section class="rounded-lg border border-border bg-card p-6">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.custom_css') ?? 'Custom CSS'}</h2>
<p class="mb-4 text-xs text-muted-foreground">{$t('admin.custom_css_description') ?? 'System-wide custom CSS applied to all pages. Scoped to .custom-css-scope to prevent breaking core UI.'}</p>
<input type="hidden" name="customCss" value={$form.customCss ?? ''} />
<CustomCssEditor
value={$form.customCss ?? ''}
onchange={(css) => { $form.customCss = css; }}
label={$t('admin.custom_css_label') ?? 'System-wide CSS'}
/>
</section>
{#if $errors._errors}
<p class="text-sm text-destructive">{$errors._errors}</p>
{/if}
+248
View File
@@ -0,0 +1,248 @@
<script lang="ts">
import { onMount } from 'svelte';
interface Tag {
id: string;
name: string;
color: string | null;
createdAt: string;
}
let tags = $state<Tag[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
// Create form
let newName = $state('');
let newColor = $state('#6366f1');
let showCreateForm = $state(false);
// Edit form
let editingTag = $state<Tag | null>(null);
let editName = $state('');
let editColor = $state('#6366f1');
// Delete confirmation
let confirmDeleteId = $state<string | null>(null);
onMount(async () => {
await loadTags();
});
async function loadTags() {
loading = true;
try {
const res = await fetch('/api/tags');
if (res.ok) {
const json = await res.json();
if (json.success && Array.isArray(json.data)) {
tags = json.data;
}
}
} catch {
error = 'Failed to load tags';
} finally {
loading = false;
}
}
async function createTag() {
error = null;
try {
const res = await fetch('/api/tags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName, color: newColor })
});
if (res.ok) {
newName = '';
newColor = '#6366f1';
showCreateForm = false;
await loadTags();
} else {
const json = await res.json();
error = json.error ?? 'Failed to create tag';
}
} catch {
error = 'Network error creating tag';
}
}
function startEdit(tag: Tag) {
editingTag = tag;
editName = tag.name;
editColor = tag.color ?? '#6366f1';
}
async function saveEdit() {
if (!editingTag) return;
error = null;
try {
const res = await fetch(`/api/tags/${editingTag.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: editName, color: editColor })
});
if (res.ok) {
editingTag = null;
await loadTags();
} else {
const json = await res.json();
error = json.error ?? 'Failed to update tag';
}
} catch {
error = 'Network error updating tag';
}
}
async function deleteTag(tagId: string) {
error = null;
try {
const res = await fetch(`/api/tags/${tagId}`, { method: 'DELETE' });
if (res.ok) {
confirmDeleteId = null;
await loadTags();
} else {
error = 'Failed to delete tag';
}
} catch {
error = 'Network error deleting tag';
}
}
</script>
<div>
<div class="mb-6 flex items-center justify-between">
<h2 class="text-xl font-bold text-card-foreground">Tag Management</h2>
<button
type="button"
onclick={() => (showCreateForm = !showCreateForm)}
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
{showCreateForm ? 'Cancel' : 'New Tag'}
</button>
</div>
{#if error}
<div class="mb-4 rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
{/if}
<!-- Create Form -->
{#if showCreateForm}
<div class="mb-6 rounded-lg border border-border bg-card p-4">
<form onsubmit={(e) => { e.preventDefault(); createTag(); }} class="flex flex-wrap items-end gap-3">
<div>
<label for="tag-name" class="mb-1 block text-sm font-medium text-foreground">Name</label>
<input
id="tag-name"
type="text"
bind:value={newName}
placeholder="Tag name"
class="rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
required
/>
</div>
<div>
<label for="tag-color" class="mb-1 block text-sm font-medium text-foreground">Color</label>
<div class="flex items-center gap-2">
<input
id="tag-color"
type="color"
bind:value={newColor}
class="h-9 w-9 cursor-pointer rounded border border-input"
/>
<span class="text-xs text-muted-foreground">{newColor}</span>
</div>
</div>
<button
type="submit"
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Create Tag
</button>
</form>
</div>
{/if}
<!-- Tags Grid -->
{#if loading}
<div class="py-8 text-center text-muted-foreground">Loading tags...</div>
{:else if tags.length === 0}
<div class="rounded-xl border border-border bg-card/50 p-8 text-center">
<p class="text-muted-foreground">No tags created yet</p>
</div>
{:else}
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each tags as tag (tag.id)}
<div class="flex items-center justify-between rounded-lg border border-border bg-card p-3">
{#if editingTag?.id === tag.id}
<form
onsubmit={(e) => { e.preventDefault(); saveEdit(); }}
class="flex flex-1 items-center gap-2"
>
<input
type="color"
bind:value={editColor}
class="h-6 w-6 cursor-pointer rounded border border-input"
/>
<input
type="text"
bind:value={editName}
class="min-w-0 flex-1 rounded border border-input bg-background px-2 py-1 text-sm text-foreground"
required
/>
<button type="submit" class="text-xs text-primary hover:underline">Save</button>
<button type="button" onclick={() => (editingTag = null)} class="text-xs text-muted-foreground hover:underline">
Cancel
</button>
</form>
{:else}
<div class="flex items-center gap-2">
<span
class="inline-block h-4 w-4 rounded-full"
style="background-color: {tag.color ?? '#6b7280'}"
></span>
<span class="text-sm font-medium text-foreground">{tag.name}</span>
</div>
<div class="flex items-center gap-1">
<button
type="button"
onclick={() => startEdit(tag)}
class="rounded px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
Edit
</button>
{#if confirmDeleteId === tag.id}
<button
type="button"
onclick={() => deleteTag(tag.id)}
class="rounded px-2 py-1 text-xs text-destructive hover:bg-destructive/10"
>
Confirm
</button>
<button
type="button"
onclick={() => (confirmDeleteId = null)}
class="rounded px-2 py-1 text-xs text-muted-foreground"
>
Cancel
</button>
{:else}
<button
type="button"
onclick={() => (confirmDeleteId = tag.id)}
class="rounded px-2 py-1 text-xs text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
>
Delete
</button>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,129 @@
<script lang="ts">
interface Props {
status: string;
size: number;
animated?: boolean;
}
let { status, size, animated = true }: Props = $props();
const strokeWidth = $derived(Math.max(2, size * 0.06));
const radius = $derived((size - strokeWidth) / 2);
const circumference = $derived(2 * Math.PI * radius);
const center = $derived(size / 2);
const ringConfig = $derived.by(() => {
switch (status) {
case 'online':
return {
color: 'var(--status-online, #22c55e)',
dashArray: `${circumference}`,
dashOffset: '0',
animationClass: animated ? 'status-ring-online' : '',
opacity: 1
};
case 'offline':
return {
color: 'var(--status-offline, #ef4444)',
dashArray: `${circumference}`,
dashOffset: '0',
animationClass: animated ? 'status-ring-offline' : '',
opacity: 1
};
case 'degraded':
return {
color: 'var(--status-degraded, #eab308)',
dashArray: `${circumference * 0.75} ${circumference * 0.25}`,
dashOffset: '0',
animationClass: animated ? 'status-ring-degraded' : '',
opacity: 1
};
default:
return {
color: 'var(--status-unknown, #6b7280)',
dashArray: `${circumference * 0.1} ${circumference * 0.1}`,
dashOffset: '0',
animationClass: animated ? 'status-ring-unknown' : '',
opacity: 0.6
};
}
});
</script>
<svg
class="pointer-events-none absolute inset-0"
width={size}
height={size}
viewBox="0 0 {size} {size}"
fill="none"
aria-hidden="true"
>
<circle
cx={center}
cy={center}
r={radius}
stroke={ringConfig.color}
stroke-width={strokeWidth}
fill="none"
stroke-dasharray={ringConfig.dashArray}
stroke-dashoffset={ringConfig.dashOffset}
stroke-linecap="round"
opacity={ringConfig.opacity}
class={ringConfig.animationClass}
style="transform-origin: {center}px {center}px;"
/>
</svg>
<style>
@keyframes ring-fill-sweep {
0% {
stroke-dashoffset: 100%;
}
100% {
stroke-dashoffset: 0;
}
}
@keyframes ring-pulse-opacity {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
@keyframes ring-degraded-pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
@keyframes ring-rotate-dash {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.status-ring-online {
animation: ring-fill-sweep 1.5s ease-out forwards;
}
.status-ring-offline {
animation: ring-pulse-opacity 2s ease-in-out infinite;
}
.status-ring-degraded {
animation: ring-degraded-pulse 3s ease-in-out infinite;
}
.status-ring-unknown {
animation: ring-rotate-dash 8s linear infinite;
}
</style>
+15
View File
@@ -4,6 +4,7 @@
import type { z } from 'zod';
import type { createAppSchema } from '$lib/utils/validators.js';
import AppIconPicker from './AppIconPicker.svelte';
import AppUrlPreview from './AppUrlPreview.svelte';
import IconGrid from '$lib/components/ui/IconGrid.svelte';
import type { IconGridItem } from '$lib/components/ui/IconGrid.svelte';
@@ -65,6 +66,20 @@
</div>
</div>
<!-- URL Preview / Test Connection -->
<AppUrlPreview
url={$form.url ?? ''}
currentIcon={$form.icon ?? ''}
currentName={$form.name ?? ''}
onApplyFavicon={(favicon) => {
$form.icon = favicon;
$form.iconType = 'url';
}}
onApplyTitle={(title) => {
$form.name = title;
}}
/>
<div>
<label for="description" class="mb-1 block text-sm font-medium text-card-foreground">
{$t('app.description')}
@@ -0,0 +1,202 @@
<script lang="ts">
import { dndzone } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
interface LinkItem {
id: string;
label: string;
url: string;
icon: string | null;
}
interface Props {
appId: string;
initialLinks?: LinkItem[];
}
let { appId, initialLinks = [] }: Props = $props();
const flipDurationMs = 200;
let links = $state<LinkItem[]>(initialLinks.map((l) => ({ ...l })));
let saving = $state(false);
let error = $state<string | null>(null);
// New link form
let newLabel = $state('');
let newUrl = $state('');
let newIcon = $state('');
function addLink() {
if (!newLabel.trim() || !newUrl.trim()) return;
const tempId = `temp-${Date.now()}`;
links = [...links, { id: tempId, label: newLabel, url: newUrl, icon: newIcon || null }];
newLabel = '';
newUrl = '';
newIcon = '';
}
function removeLink(id: string) {
links = links.filter((l) => l.id !== id);
}
function handleDndConsider(e: CustomEvent<{ items: LinkItem[] }>) {
links = e.detail.items;
}
function handleDndFinalize(e: CustomEvent<{ items: LinkItem[] }>) {
links = e.detail.items;
}
async function saveLinks() {
saving = true;
error = null;
try {
// First, get existing links from the server to determine what to add/remove
const existingRes = await fetch(`/api/apps/${appId}/links`);
const existingData = existingRes.ok ? await existingRes.json() : { data: [] };
const existingLinks: Array<{ id: string }> = existingData.data ?? [];
const existingIds = new Set(existingLinks.map((l) => l.id));
// Delete links that were removed
for (const existing of existingLinks) {
if (!links.some((l) => l.id === existing.id)) {
await fetch(`/api/apps/${appId}/links`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ linkId: existing.id })
});
}
}
// Add new links (those with temp IDs)
for (let i = 0; i < links.length; i++) {
const link = links[i];
if (!existingIds.has(link.id)) {
await fetch(`/api/apps/${appId}/links`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
label: link.label,
url: link.url,
icon: link.icon,
order: i
})
});
}
}
// Reorder remaining links
const reorderIds = links
.filter((l) => existingIds.has(l.id))
.map((l) => l.id);
if (reorderIds.length > 0) {
await fetch(`/api/apps/${appId}/links`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ linkIds: reorderIds })
});
}
} catch {
error = 'Failed to save links';
} finally {
saving = false;
}
}
</script>
<div class="space-y-4">
<h3 class="text-sm font-semibold text-foreground">Secondary Links</h3>
{#if error}
<p class="text-xs text-destructive">{error}</p>
{/if}
<!-- Links List (draggable) -->
{#if links.length > 0}
<div
use:dndzone={{ items: links, flipDurationMs, type: 'app-links' }}
onconsider={handleDndConsider}
onfinalize={handleDndFinalize}
class="space-y-2"
>
{#each links as link (link.id)}
<div
animate:flip={{ duration: flipDurationMs }}
class="flex items-center gap-2 rounded-md border border-border bg-card p-2"
>
<span class="cursor-grab text-muted-foreground">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="8" y1="6" x2="8" y2="6" />
<line x1="16" y1="6" x2="16" y2="6" />
<line x1="8" y1="12" x2="8" y2="12" />
<line x1="16" y1="12" x2="16" y2="12" />
<line x1="8" y1="18" x2="8" y2="18" />
<line x1="16" y1="18" x2="16" y2="18" />
</svg>
</span>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-foreground">{link.label}</p>
<p class="truncate text-xs text-muted-foreground">{link.url}</p>
</div>
<button
type="button"
onclick={() => removeLink(link.id)}
class="flex-shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{/each}
</div>
{/if}
<!-- Add Link Form -->
<div class="rounded-md border border-dashed border-border p-3">
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<input
type="text"
bind:value={newLabel}
placeholder="Link label"
class="rounded-md border border-input bg-background px-2 py-1.5 text-sm text-foreground"
/>
<input
type="url"
bind:value={newUrl}
placeholder="https://..."
class="rounded-md border border-input bg-background px-2 py-1.5 text-sm text-foreground"
/>
</div>
<div class="mt-2 flex items-center gap-2">
<input
type="text"
bind:value={newIcon}
placeholder="Icon (optional)"
class="flex-1 rounded-md border border-input bg-background px-2 py-1.5 text-sm text-foreground"
/>
<button
type="button"
onclick={addLink}
disabled={!newLabel.trim() || !newUrl.trim()}
class="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Add
</button>
</div>
</div>
<!-- Save Button -->
<button
type="button"
onclick={saveLinks}
disabled={saving}
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save Links'}
</button>
</div>
+169
View File
@@ -0,0 +1,169 @@
<script lang="ts">
interface Props {
url: string;
currentIcon: string;
currentName: string;
onApplyFavicon?: (faviconUrl: string) => void;
onApplyTitle?: (title: string) => void;
}
let { url, currentIcon, currentName, onApplyFavicon, onApplyTitle }: Props = $props();
let loading = $state(false);
let result = $state<{
status: number;
responseTime: number;
favicon: string | null;
title: string | null;
error: string | null;
} | null>(null);
const statusColor = $derived(() => {
if (!result) return '';
if (result.error) return 'text-destructive';
if (result.status >= 200 && result.status < 300) return 'text-green-500';
if (result.status >= 300 && result.status < 400) return 'text-yellow-500';
return 'text-destructive';
});
const canApplyFavicon = $derived(
result?.favicon && !currentIcon && onApplyFavicon
);
const canApplyTitle = $derived(
result?.title && !currentName && onApplyTitle
);
async function testConnection() {
if (!url) return;
loading = true;
result = null;
try {
const res = await fetch('/api/apps/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
const json = await res.json();
if (json.success && json.data) {
result = json.data;
} else {
result = {
status: 0,
responseTime: 0,
favicon: null,
title: null,
error: json.error ?? 'Preview failed'
};
}
} catch {
result = {
status: 0,
responseTime: 0,
favicon: null,
title: null,
error: 'Failed to test connection'
};
} finally {
loading = false;
}
}
</script>
<div class="rounded-lg border border-border p-3">
<div class="flex items-center gap-2">
<button
type="button"
onclick={testConnection}
disabled={loading || !url}
class="rounded-md bg-secondary px-3 py-1.5 text-xs font-medium text-secondary-foreground transition-colors hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if loading}
<span class="inline-flex items-center gap-1">
<svg class="h-3 w-3 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" class="opacity-25" />
<path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" class="opacity-75" />
</svg>
Testing...
</span>
{:else}
Test Connection
{/if}
</button>
{#if !url}
<span class="text-xs text-muted-foreground">Enter a URL first</span>
{/if}
</div>
{#if result}
<div class="mt-3 space-y-2">
{#if result.error}
<div class="flex items-center gap-2 text-sm text-destructive">
<svg class="h-4 w-4 shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
<span>{result.error}</span>
</div>
{:else}
<div class="grid grid-cols-2 gap-2 text-sm">
<div class="flex items-center gap-2">
<span class="text-muted-foreground">Status:</span>
<span class={statusColor()} class:font-medium={true}>
{result.status}
</span>
</div>
<div class="flex items-center gap-2">
<span class="text-muted-foreground">Response:</span>
<span class="font-medium text-foreground">{result.responseTime}ms</span>
</div>
</div>
{#if result.title}
<div class="flex items-center gap-2 text-sm">
<span class="text-muted-foreground">Title:</span>
<span class="truncate text-foreground">{result.title}</span>
{#if canApplyTitle}
<button
type="button"
onclick={() => onApplyTitle?.(result!.title!)}
class="shrink-0 text-xs text-primary hover:underline"
>
Use as name
</button>
{/if}
</div>
{/if}
{#if result.favicon}
<div class="flex items-center gap-2 text-sm">
<span class="text-muted-foreground">Favicon:</span>
<img
src={result.favicon}
alt="Detected favicon"
class="h-4 w-4 shrink-0"
onerror={(e) => {
const target = e.currentTarget as HTMLImageElement;
target.style.display = 'none';
}}
/>
<span class="max-w-[200px] truncate text-xs text-muted-foreground">{result.favicon}</span>
{#if canApplyFavicon}
<button
type="button"
onclick={() => onApplyFavicon?.(result!.favicon!)}
class="shrink-0 text-xs text-primary hover:underline"
>
Use as icon
</button>
{/if}
</div>
{/if}
{/if}
</div>
{/if}
</div>
+45
View File
@@ -0,0 +1,45 @@
<script lang="ts">
interface Props {
name: string;
color?: string | null;
size?: 'sm' | 'md';
removable?: boolean;
onRemove?: () => void;
}
let { name, color = null, size = 'sm', removable = false, onRemove }: Props = $props();
const bgStyle = $derived(
color
? `background-color: ${color}20; border-color: ${color}40; color: ${color}`
: ''
);
</script>
<span
class="inline-flex items-center gap-1 rounded-full border font-medium
{size === 'sm' ? 'px-1.5 py-0.5 text-[10px]' : 'px-2 py-0.5 text-xs'}
{color ? '' : 'border-border bg-muted text-muted-foreground'}"
style={bgStyle}
>
{#if color}
<span
class="inline-block rounded-full {size === 'sm' ? 'h-1.5 w-1.5' : 'h-2 w-2'}"
style="background-color: {color}"
></span>
{/if}
{name}
{#if removable && onRemove}
<button
type="button"
onclick={onRemove}
class="ml-0.5 rounded-full p-0.5 transition-colors hover:bg-black/10 dark:hover:bg-white/10"
title="Remove tag"
>
<svg class="h-2.5 w-2.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
{/if}
</span>
@@ -3,6 +3,15 @@
import MeshGradient from './MeshGradient.svelte';
import ParticleField from './ParticleField.svelte';
import AuroraEffect from './AuroraEffect.svelte';
import WallpaperBackground from './WallpaperBackground.svelte';
interface Props {
wallpaperUrl?: string | null;
wallpaperBlur?: number;
wallpaperOverlay?: number;
}
let { wallpaperUrl = null, wallpaperBlur = 0, wallpaperOverlay = 0.3 }: Props = $props();
</script>
{#if theme.backgroundType !== 'none'}
@@ -13,6 +22,8 @@
<ParticleField />
{:else if theme.backgroundType === 'aurora'}
<AuroraEffect />
{:else if theme.backgroundType === 'wallpaper' && wallpaperUrl}
<WallpaperBackground url={wallpaperUrl} blur={wallpaperBlur} overlayOpacity={wallpaperOverlay} />
{/if}
</div>
{/if}
@@ -0,0 +1,62 @@
<script lang="ts">
interface Props {
url: string;
blur?: number;
overlayOpacity?: number;
parallax?: boolean;
position?: 'fixed' | 'scroll';
}
let {
url,
blur = 0,
overlayOpacity = 0.3,
parallax = false,
position = 'fixed'
}: Props = $props();
let loadError = $state(false);
let loaded = $state(false);
function handleLoad() {
loaded = true;
loadError = false;
}
function handleError() {
loadError = true;
loaded = false;
}
const positionClass = $derived(position === 'fixed' ? 'fixed' : 'absolute');
const blurValue = $derived(`${Math.max(0, Math.min(20, blur))}px`);
const overlayAlpha = $derived(Math.max(0, Math.min(1, overlayOpacity)));
</script>
{#if !loadError}
<div
class="pointer-events-none {positionClass} inset-0 z-0 overflow-hidden"
aria-hidden="true"
>
<!-- Wallpaper image -->
<img
src={url}
alt=""
onload={handleLoad}
onerror={handleError}
class="h-full w-full object-cover transition-opacity duration-500"
class:opacity-0={!loaded}
class:opacity-100={loaded}
style="filter: blur({blurValue});{parallax ? ' transform: translateZ(0); will-change: transform;' : ''}"
draggable="false"
/>
<!-- Overlay -->
{#if overlayAlpha > 0}
<div
class="absolute inset-0"
style="background: rgba(0, 0, 0, {overlayAlpha});"
></div>
{/if}
</div>
{/if}
+5 -2
View File
@@ -36,12 +36,15 @@
statuses: Array<{ status: string; responseTime: number | null }>;
}
import type { CardSize } from '$lib/utils/constants.js';
interface Props {
sections: SectionData[];
allApps?: AppData[];
boardCardSize?: CardSize;
}
let { sections, allApps = [] }: Props = $props();
let { sections, allApps = [], boardCardSize = 'medium' }: Props = $props();
</script>
<div class="space-y-6">
@@ -51,7 +54,7 @@
</div>
{:else}
{#each sections as section (section.id)}
<Section {section} {allApps} />
<Section {section} {allApps} {boardCardSize} />
{/each}
{/if}
</div>
@@ -0,0 +1,63 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import type { Snippet } from 'svelte';
import { theme } from '$lib/stores/theme.svelte.js';
interface BoardTheme {
themeHue?: number | null;
themeSaturation?: number | null;
backgroundType?: string | null;
}
interface Props {
board: BoardTheme;
children: Snippet;
}
let { board, children }: Props = $props();
let containerEl: HTMLDivElement | undefined = $state(undefined);
function restoreGlobalTheme() {
if (typeof document === 'undefined') return;
const html = document.documentElement;
html.style.removeProperty('--board-primary-h');
html.style.removeProperty('--board-primary-s');
// Re-apply the global theme store values
html.style.setProperty('--primary-h', String(theme.primaryHue));
html.style.setProperty('--primary-s', `${theme.primarySaturation}%`);
}
// Apply board-level theme overrides via CSS custom properties
$effect(() => {
if (typeof document === 'undefined') return;
const html = document.documentElement;
if (board.themeHue != null) {
html.style.setProperty('--board-primary-h', String(board.themeHue));
html.style.setProperty('--primary-h', String(board.themeHue));
}
if (board.themeSaturation != null) {
html.style.setProperty('--board-primary-s', `${board.themeSaturation}%`);
html.style.setProperty('--primary-s', `${board.themeSaturation}%`);
}
return () => {
restoreGlobalTheme();
};
});
onDestroy(() => {
restoreGlobalTheme();
});
</script>
<div bind:this={containerEl} class="board-theme-scope">
{@render children()}
</div>
<style>
.board-theme-scope {
transition: --primary-h 0.3s ease, --primary-s 0.3s ease;
}
</style>
@@ -39,6 +39,7 @@
onDeleteSection: (sectionId: string) => void;
onAddWidget: (sectionId: string, widgetData: string) => void;
onDeleteWidget: (widgetId: string) => void;
onUpdateSection?: (sectionId: string, data: Record<string, unknown>) => void;
}
let {
@@ -49,7 +50,8 @@
onToggleAddWidget,
onDeleteSection,
onAddWidget,
onDeleteWidget
onDeleteWidget,
onUpdateSection
}: Props = $props();
let sections = $state<SectionData[]>([...initialSections]);
@@ -135,6 +137,7 @@
{onDeleteSection}
{onAddWidget}
{onDeleteWidget}
{onUpdateSection}
/>
</div>
{/each}
@@ -0,0 +1,157 @@
<script lang="ts">
import { onMount } from 'svelte';
import { theme } from '$lib/stores/theme.svelte.js';
interface RecentApp {
readonly id: string;
readonly appId: string;
readonly clickedAt: string;
readonly app: {
readonly id: string;
readonly name: string;
readonly url: string;
readonly icon: string | null;
readonly iconType: string;
};
}
interface Props {
trackRecentApps?: boolean;
}
let { trackRecentApps = true }: Props = $props();
let recentApps = $state<RecentApp[]>([]);
let loading = $state(true);
let expanded = $state(true);
const cardStyleClass = $derived(`card-${theme.cardStyle}`);
onMount(async () => {
if (!trackRecentApps) {
loading = false;
return;
}
try {
const res = await fetch('/api/recent-apps?limit=10');
if (res.ok) {
const json = await res.json();
if (json.success && Array.isArray(json.data)) {
recentApps = json.data;
}
}
} catch {
// Silently fail — recent apps is non-critical
} finally {
loading = false;
}
});
async function clearHistory() {
try {
const res = await fetch('/api/recent-apps', { method: 'DELETE' });
if (res.ok) {
recentApps = [];
}
} catch {
// Silently fail
}
}
function formatTimeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const minutes = Math.floor(diff / 60_000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function getIconSrc(app: RecentApp['app']): string | null {
if (!app.icon) return null;
switch (app.iconType) {
case 'url':
return app.icon;
case 'simple': {
const slug = app.icon.toLowerCase().replace(/[^a-z0-9]/g, '');
return `https://cdn.simpleicons.org/${slug}`;
}
default:
return null;
}
}
</script>
{#if trackRecentApps && !loading && recentApps.length > 0}
<div class="mb-6">
<div class="mb-2 flex items-center justify-between">
<button
type="button"
class="flex items-center gap-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
onclick={() => (expanded = !expanded)}
>
<svg
class="h-4 w-4 transition-transform duration-200"
class:rotate-90={expanded}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="9 18 15 12 9 6" />
</svg>
Recently Used
</button>
<button
type="button"
class="text-xs text-muted-foreground transition-colors hover:text-destructive"
onclick={clearHistory}
>
Clear history
</button>
</div>
{#if expanded}
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-5 lg:grid-cols-6">
{#each recentApps as recent (recent.id)}
<a
href={recent.app.url}
target="_blank"
rel="noopener noreferrer"
class="group flex items-center gap-2 rounded-lg {cardStyleClass} px-3 py-2 transition-colors hover:border-primary/50"
onclick={() => {
fetch('/api/recent-apps', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ appId: recent.app.id })
});
}}
>
<span class="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted">
{#if recent.app.iconType === 'emoji' && recent.app.icon}
<span class="text-sm">{recent.app.icon}</span>
{:else if getIconSrc(recent.app)}
<img src={getIconSrc(recent.app)} alt="" class="h-4 w-4 object-contain" />
{:else}
<span class="text-[10px] font-bold text-muted-foreground">
{recent.app.name.charAt(0).toUpperCase()}
</span>
{/if}
</span>
<div class="min-w-0 flex-1">
<p class="truncate text-xs font-medium text-foreground group-hover:text-primary">
{recent.app.name}
</p>
<p class="text-[10px] text-muted-foreground">
{formatTimeAgo(recent.clickedAt)}
</p>
</div>
</a>
{/each}
</div>
{/if}
</div>
{/if}
+81
View File
@@ -0,0 +1,81 @@
<script lang="ts">
import { onMount } from 'svelte';
interface Tag {
id: string;
name: string;
color: string | null;
}
interface Props {
activeTags: string[];
onFilterChange: (tagIds: string[]) => void;
}
let { activeTags = [], onFilterChange }: Props = $props();
let tags = $state<Tag[]>([]);
let loading = $state(true);
onMount(async () => {
try {
const res = await fetch('/api/tags');
if (res.ok) {
const json = await res.json();
if (json.success && Array.isArray(json.data)) {
tags = json.data;
}
}
} catch {
// Silently fail
} finally {
loading = false;
}
});
function toggleTag(tagId: string) {
const isActive = activeTags.includes(tagId);
const updated = isActive
? activeTags.filter((id) => id !== tagId)
: [...activeTags, tagId];
onFilterChange(updated);
}
function clearFilters() {
onFilterChange([]);
}
</script>
{#if !loading && tags.length > 0}
<div class="mb-4 flex flex-wrap items-center gap-2">
<span class="text-xs font-medium text-muted-foreground">Filter:</span>
{#each tags as tag (tag.id)}
<button
type="button"
onclick={() => toggleTag(tag.id)}
class="inline-flex items-center gap-1 rounded-full border px-2 py-1 text-xs font-medium transition-colors
{activeTags.includes(tag.id)
? 'border-primary bg-primary/10 text-primary'
: 'border-border bg-muted/50 text-muted-foreground hover:bg-muted'}"
>
{#if tag.color}
<span
class="inline-block h-2 w-2 rounded-full"
style="background-color: {tag.color}"
></span>
{/if}
{tag.name}
</button>
{/each}
{#if activeTags.length > 0}
<button
type="button"
onclick={clearFilters}
class="text-xs text-muted-foreground transition-colors hover:text-foreground"
>
Clear filters
</button>
{/if}
</div>
{/if}
@@ -0,0 +1,185 @@
<script lang="ts">
import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte';
interface TemplateSection {
readonly title: string;
readonly icon: string | null;
readonly order: number;
}
interface Template {
readonly id: string;
readonly name: string;
readonly description: string | null;
readonly icon: string | null;
readonly config: {
readonly sections: readonly TemplateSection[];
};
readonly isBuiltin: boolean;
}
interface Props {
onSelect: (templateId: string | null) => void;
}
let { onSelect }: Props = $props();
let templates = $state<Template[]>([]);
let loading = $state(true);
let errorMsg = $state<string | null>(null);
let selected = $state<string | null>(null);
let importing = $state(false);
async function loadTemplates() {
loading = true;
errorMsg = null;
try {
const res = await fetch('/api/templates');
const json = await res.json();
if (json.success && Array.isArray(json.data)) {
templates = json.data;
} else {
errorMsg = json.error ?? 'Failed to load templates';
}
} catch {
errorMsg = 'Failed to load templates';
} finally {
loading = false;
}
}
function selectTemplate(id: string | null) {
selected = id;
onSelect(id);
}
async function handleImport() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
importing = true;
errorMsg = null;
try {
const text = await file.text();
const data = JSON.parse(text);
const res = await fetch('/api/templates/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const json = await res.json();
if (json.success) {
await loadTemplates();
selectTemplate(json.data.id);
} else {
errorMsg = json.error ?? 'Failed to import template';
}
} catch {
errorMsg = 'Failed to import template: invalid JSON file';
} finally {
importing = false;
}
};
input.click();
}
// Load templates on mount
$effect(() => {
loadTemplates();
});
</script>
<div class="space-y-3">
<p class="text-sm font-medium text-foreground">Choose a template (optional)</p>
{#if loading}
<div class="flex items-center justify-center py-8 text-muted-foreground">
<svg class="mr-2 h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" class="opacity-25" />
<path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" class="opacity-75" />
</svg>
Loading templates...
</div>
{:else}
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
<!-- Blank Board -->
<button
type="button"
onclick={() => selectTemplate(null)}
class="flex flex-col items-center gap-2 rounded-lg border-2 p-4 text-center transition-colors {selected === null ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'}"
>
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<svg class="h-5 w-5 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</div>
<span class="text-sm font-medium text-foreground">Blank Board</span>
<span class="text-xs text-muted-foreground">Start from scratch</span>
</button>
<!-- Templates -->
{#each templates as template (template.id)}
<button
type="button"
onclick={() => selectTemplate(template.id)}
class="flex flex-col items-center gap-2 rounded-lg border-2 p-4 text-center transition-colors {selected === template.id ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'}"
>
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
{#if template.icon}
<DynamicIcon name={template.icon} size={20} />
{:else}
<svg class="h-5 w-5 text-primary" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="9" y1="21" x2="9" y2="9" />
</svg>
{/if}
</div>
<span class="text-sm font-medium text-foreground">{template.name}</span>
{#if template.description}
<span class="line-clamp-2 text-xs text-muted-foreground">{template.description}</span>
{/if}
{#if template.config.sections.length > 0}
<div class="flex flex-wrap justify-center gap-1">
{#each template.config.sections as section (section.title)}
<span class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
{section.title}
</span>
{/each}
</div>
{/if}
</button>
{/each}
</div>
<!-- Import button -->
<div class="flex justify-end">
<button
type="button"
onclick={handleImport}
disabled={importing}
class="text-xs text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
>
{importing ? 'Importing...' : 'Import template from file'}
</button>
</div>
{/if}
{#if errorMsg}
<div class="rounded-lg bg-destructive/10 p-2 text-xs text-destructive">
{errorMsg}
</div>
{/if}
</div>
@@ -0,0 +1,47 @@
<script lang="ts">
interface Props {
css: string;
}
let { css }: Props = $props();
/**
* Sanitize CSS to prevent XSS vectors while keeping valid styling rules.
* All custom CSS is wrapped in .custom-css-scope to prevent breaking critical UI.
*/
const sanitizedCss = $derived.by(() => {
if (!css) return '';
let cleaned = css;
// Remove any HTML tags (including <script>)
cleaned = cleaned.replace(/<\/?[^>]+(>|$)/g, '');
// Remove javascript: URLs
cleaned = cleaned.replace(/javascript\s*:/gi, '');
// Remove expression() calls
cleaned = cleaned.replace(/expression\s*\(/gi, '');
// Remove url() with javascript:
cleaned = cleaned.replace(/url\s*\(\s*['"]?\s*javascript:/gi, 'url(');
// Remove @import rules
cleaned = cleaned.replace(/@import\s+[^;]+;?/gi, '');
// Remove behavior: (IE XSS)
cleaned = cleaned.replace(/behavior\s*:/gi, '');
// Remove -moz-binding (Firefox XSS)
cleaned = cleaned.replace(/-moz-binding\s*:/gi, '');
return cleaned;
});
</script>
{#if sanitizedCss}
<div class="custom-css-scope contents" aria-hidden="true">
<!-- eslint-disable-next-line svelte/no-at-html-tags -- CSS is sanitized -->
{@html `<style>${sanitizedCss}</style>`}
</div>
{/if}
@@ -0,0 +1,114 @@
<script lang="ts">
import { favorites } from '$lib/stores/favorites.svelte.js';
import { dndzone } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
const flipDurationMs = 200;
interface DndItem {
id: string;
appId: string;
order: number;
app: {
id: string;
name: string;
url: string;
icon: string | null;
iconType: string;
};
}
let dndItems = $state<DndItem[]>([]);
// Sync dndItems with store items
$effect(() => {
dndItems = favorites.items.map((f) => ({ ...f }));
});
function handleDndConsider(e: CustomEvent<{ items: DndItem[] }>) {
dndItems = e.detail.items;
}
function handleDndFinalize(e: CustomEvent<{ items: DndItem[] }>) {
dndItems = e.detail.items;
const ids = dndItems.map((item) => item.id);
favorites.reorder(ids);
}
function handleRemove(e: MouseEvent, appId: string) {
e.preventDefault();
e.stopPropagation();
favorites.remove(appId);
}
function getIconSrc(app: DndItem['app']): string | null {
if (!app.icon) return null;
switch (app.iconType) {
case 'url':
return app.icon;
case 'simple': {
const slug = app.icon.toLowerCase().replace(/[^a-z0-9]/g, '');
return `https://cdn.simpleicons.org/${slug}`;
}
default:
return null;
}
}
</script>
{#if favorites.hasFavorites}
<div class="mb-4 rounded-lg border border-border bg-card/50 px-3 py-2 backdrop-blur-sm">
<div
class="flex flex-wrap items-center gap-2"
use:dndzone={{
items: dndItems,
flipDurationMs,
type: 'favorites-bar',
dropTargetStyle: { outline: 'none' }
}}
onconsider={handleDndConsider}
onfinalize={handleDndFinalize}
>
{#each dndItems as item (item.id)}
<div animate:flip={{ duration: flipDurationMs }}>
<a
href={item.app.url}
target="_blank"
rel="noopener noreferrer"
class="group relative flex items-center gap-1.5 rounded-md bg-muted/50 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
title={item.app.name}
oncontextmenu={(e) => handleRemove(e, item.appId)}
>
<span class="flex h-5 w-5 shrink-0 items-center justify-center rounded">
{#if item.app.iconType === 'emoji' && item.app.icon}
<span class="text-sm">{item.app.icon}</span>
{:else if getIconSrc(item.app)}
<img
src={getIconSrc(item.app)}
alt=""
class="h-4 w-4 object-contain"
/>
{:else}
<span class="text-[10px] font-bold text-muted-foreground">
{item.app.name.charAt(0).toUpperCase()}
</span>
{/if}
</span>
<span class="max-w-[80px] truncate">{item.app.name}</span>
<button
type="button"
class="ml-0.5 hidden rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-destructive/20 hover:text-destructive group-hover:inline-flex"
onclick={(e) => handleRemove(e, item.appId)}
title="Remove from favorites"
>
<svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</a>
</div>
{/each}
</div>
</div>
{/if}
+27
View File
@@ -3,6 +3,7 @@
import ThemeToggle from './ThemeToggle.svelte';
import LanguageSwitcher from './LanguageSwitcher.svelte';
import SearchTrigger from '$lib/components/search/SearchTrigger.svelte';
import NotificationBell from '$lib/components/notifications/NotificationBell.svelte';
import { ui } from '$lib/stores/ui.svelte.js';
import { theme, type BackgroundType } from '$lib/stores/theme.svelte.js';
@@ -128,6 +129,11 @@
{/if}
</div>
<!-- Notifications bell (authenticated users only) -->
{#if user}
<NotificationBell />
{/if}
<!-- Theme toggle -->
<ThemeToggle />
@@ -182,6 +188,27 @@
{$t('settings.title')}
</a>
<a
href="/settings/api-tokens"
onclick={() => (showUserMenu = false)}
class="flex w-full items-center gap-2 rounded-sm px-3 py-1.5 text-sm text-popover-foreground transition-colors hover:bg-accent"
>
<svg
class="h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
API Tokens
</a>
<form method="POST" action="/auth/logout">
<button
type="submit"
+46 -2
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import { ui } from '$lib/stores/ui.svelte.js';
import { keyboard } from '$lib/stores/keyboard.svelte.js';
import { page } from '$app/stores';
import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte';
@@ -129,6 +130,28 @@
</svg>
{#if !collapsed}<span>{$t('nav.apps')}</span>{/if}
</a>
<a
href="/status"
class="flex items-center gap-2 rounded-md px-2 py-2 text-sm transition-colors {isActive('/status')
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-sidebar-foreground hover:bg-sidebar-accent/50'}"
title={collapsed ? 'Status Page' : undefined}
>
<svg
class="h-4 w-4 shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
{#if !collapsed}<span>Status</span>{/if}
</a>
</div>
<!-- Board List -->
@@ -207,9 +230,30 @@
{/if}
</nav>
<!-- Collapse Toggle (desktop only) -->
<!-- Keyboard Shortcuts Hint + Collapse Toggle -->
{#if !ui.isMobile}
<div class="border-t border-sidebar-border p-2">
<div class="border-t border-sidebar-border p-2 flex items-center {collapsed ? 'flex-col gap-1' : 'gap-1'}">
<button
type="button"
onclick={() => keyboard.toggleOverlay()}
class="flex items-center justify-center rounded-md p-2 text-sidebar-foreground/50 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
title="Keyboard Shortcuts (?)"
>
<svg
class="h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
</button>
<button
type="button"
onclick={() => ui.toggleSidebar()}
@@ -0,0 +1,175 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { notifications } from '$lib/stores/notifications.svelte.js';
let showDropdown = $state(false);
onMount(() => {
notifications.load();
notifications.startPolling();
});
onDestroy(() => {
notifications.stopPolling();
});
function handleClickOutside(e: MouseEvent) {
const target = e.target as HTMLElement;
if (!target.closest('.notification-bell-container')) {
showDropdown = false;
}
}
function formatTime(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const minutes = Math.floor(diff / 60_000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function eventLabel(event: string): string {
switch (event) {
case 'app_online':
return 'Online';
case 'app_offline':
return 'Offline';
case 'app_degraded':
return 'Degraded';
default:
return event;
}
}
function eventColor(event: string): string {
switch (event) {
case 'app_online':
return 'text-green-500';
case 'app_offline':
return 'text-red-500';
case 'app_degraded':
return 'text-yellow-500';
default:
return 'text-muted-foreground';
}
}
</script>
<svelte:window onclick={handleClickOutside} />
<div class="notification-bell-container relative">
<button
type="button"
onclick={() => (showDropdown = !showDropdown)}
class="relative inline-flex items-center justify-center rounded-md p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Notifications"
aria-label="Notifications"
>
<!-- Bell Icon -->
<svg
class="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
</svg>
<!-- Unread Badge -->
{#if notifications.hasUnread}
<span
class="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-destructive-foreground"
>
{notifications.unreadCount > 99 ? '99+' : notifications.unreadCount}
</span>
{/if}
</button>
{#if showDropdown}
<div
class="absolute right-0 top-full z-50 mt-1 w-80 rounded-lg border border-border bg-popover shadow-lg"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-border px-4 py-3">
<h3 class="text-sm font-semibold text-popover-foreground">Notifications</h3>
{#if notifications.hasUnread}
<button
type="button"
class="text-xs text-primary transition-colors hover:text-primary/80"
onclick={() => notifications.markAllAsRead()}
>
Mark all as read
</button>
{/if}
</div>
<!-- Notification List -->
<div class="max-h-80 overflow-y-auto">
{#if notifications.items.length === 0}
<div class="p-6 text-center">
<p class="text-sm text-muted-foreground">No notifications yet</p>
</div>
{:else}
{#each notifications.items as notification (notification.id)}
<button
type="button"
class="flex w-full items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-accent/50 {notification.readAt === null ? 'bg-primary/5' : ''}"
onclick={() => {
if (notification.readAt === null) {
notifications.markAsRead(notification.id);
}
}}
>
<!-- Unread dot -->
<span class="mt-1.5 flex-shrink-0">
{#if notification.readAt === null}
<span class="inline-block h-2 w-2 rounded-full bg-primary"></span>
{:else}
<span class="inline-block h-2 w-2"></span>
{/if}
</span>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="text-xs font-medium {eventColor(notification.event)}">
{eventLabel(notification.event)}
</span>
{#if notification.app}
<span class="truncate text-xs text-muted-foreground">
{notification.app.name}
</span>
{/if}
</div>
<p class="mt-0.5 line-clamp-2 text-xs text-popover-foreground">
{notification.message}
</p>
<p class="mt-1 text-[10px] text-muted-foreground">
{formatTime(notification.sentAt)}
</p>
</div>
</button>
{/each}
{/if}
</div>
<!-- Footer -->
<div class="border-t border-border p-2">
<a
href="/settings/notifications"
class="block rounded-md px-3 py-1.5 text-center text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onclick={() => (showDropdown = false)}
>
Manage notifications
</a>
</div>
</div>
{/if}
</div>
@@ -0,0 +1,257 @@
<script lang="ts">
import { NotificationType } from '$lib/utils/constants.js';
interface ChannelData {
readonly id?: string;
readonly type: string;
readonly config: string;
readonly enabled: boolean;
}
interface Props {
channel?: ChannelData | null;
onSave: (data: { type: string; config: string; enabled: boolean }) => void;
onCancel: () => void;
}
let { channel = null, onSave, onCancel }: Props = $props();
let channelType = $state(channel?.type ?? NotificationType.DISCORD);
let enabled = $state(channel?.enabled ?? true);
let testing = $state(false);
let testResult = $state<string | null>(null);
// Dynamic config fields
let discordWebhookUrl = $state('');
let slackWebhookUrl = $state('');
let telegramBotToken = $state('');
let telegramChatId = $state('');
let httpUrl = $state('');
let httpMethod = $state('POST');
// Parse existing config
if (channel?.config) {
try {
const parsed = JSON.parse(channel.config);
switch (channel.type) {
case 'discord':
discordWebhookUrl = parsed.webhookUrl ?? '';
break;
case 'slack':
slackWebhookUrl = parsed.webhookUrl ?? '';
break;
case 'telegram':
telegramBotToken = parsed.botToken ?? '';
telegramChatId = parsed.chatId ?? '';
break;
case 'http':
httpUrl = parsed.url ?? '';
httpMethod = parsed.method ?? 'POST';
break;
}
} catch {
// Invalid config
}
}
function buildConfig(): string {
switch (channelType) {
case 'discord':
return JSON.stringify({ webhookUrl: discordWebhookUrl });
case 'slack':
return JSON.stringify({ webhookUrl: slackWebhookUrl });
case 'telegram':
return JSON.stringify({ botToken: telegramBotToken, chatId: telegramChatId });
case 'http':
return JSON.stringify({ url: httpUrl, method: httpMethod });
default:
return '{}';
}
}
function handleSubmit() {
onSave({
type: channelType,
config: buildConfig(),
enabled
});
}
async function sendTest() {
if (!channel?.id) return;
testing = true;
testResult = null;
try {
const res = await fetch(`/api/notifications/channels/${channel.id}/test`, {
method: 'POST'
});
if (res.ok) {
testResult = 'Test notification sent successfully!';
} else {
const json = await res.json();
testResult = `Failed: ${json.error ?? 'Unknown error'}`;
}
} catch {
testResult = 'Failed: Network error';
} finally {
testing = false;
}
}
</script>
<div class="rounded-lg border border-border bg-card p-6">
<h3 class="mb-4 text-lg font-semibold text-card-foreground">
{channel ? 'Edit Channel' : 'Add Notification Channel'}
</h3>
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4">
<!-- Channel Type -->
<div>
<label for="channel-type" class="mb-1 block text-sm font-medium text-foreground">
Channel Type
</label>
<select
id="channel-type"
bind:value={channelType}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
>
<option value="discord">Discord</option>
<option value="slack">Slack</option>
<option value="telegram">Telegram</option>
<option value="http">HTTP Webhook</option>
</select>
</div>
<!-- Dynamic Fields -->
{#if channelType === 'discord'}
<div>
<label for="discord-url" class="mb-1 block text-sm font-medium text-foreground">
Webhook URL
</label>
<input
id="discord-url"
type="url"
bind:value={discordWebhookUrl}
placeholder="https://discord.com/api/webhooks/..."
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
required
/>
</div>
{:else if channelType === 'slack'}
<div>
<label for="slack-url" class="mb-1 block text-sm font-medium text-foreground">
Webhook URL
</label>
<input
id="slack-url"
type="url"
bind:value={slackWebhookUrl}
placeholder="https://hooks.slack.com/services/..."
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
required
/>
</div>
{:else if channelType === 'telegram'}
<div>
<label for="tg-token" class="mb-1 block text-sm font-medium text-foreground">
Bot Token
</label>
<input
id="tg-token"
type="text"
bind:value={telegramBotToken}
placeholder="123456:ABC-DEF..."
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
required
/>
</div>
<div>
<label for="tg-chat" class="mb-1 block text-sm font-medium text-foreground">
Chat ID
</label>
<input
id="tg-chat"
type="text"
bind:value={telegramChatId}
placeholder="-1001234567890"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
required
/>
</div>
{:else if channelType === 'http'}
<div>
<label for="http-url" class="mb-1 block text-sm font-medium text-foreground">
URL
</label>
<input
id="http-url"
type="url"
bind:value={httpUrl}
placeholder="https://example.com/webhook"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
required
/>
</div>
<div>
<label for="http-method" class="mb-1 block text-sm font-medium text-foreground">
Method
</label>
<select
id="http-method"
bind:value={httpMethod}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="PATCH">PATCH</option>
</select>
</div>
{/if}
<!-- Enabled Toggle -->
<div class="flex items-center gap-2">
<input
id="channel-enabled"
type="checkbox"
bind:checked={enabled}
class="h-4 w-4 rounded border-input"
/>
<label for="channel-enabled" class="text-sm text-foreground">Enabled</label>
</div>
<!-- Test Result -->
{#if testResult}
<p class="text-sm {testResult.startsWith('Failed') ? 'text-destructive' : 'text-green-500'}">
{testResult}
</p>
{/if}
<!-- Actions -->
<div class="flex items-center gap-3">
<button
type="submit"
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
{channel ? 'Update' : 'Create'} Channel
</button>
{#if channel?.id}
<button
type="button"
onclick={sendTest}
disabled={testing}
class="rounded-md border border-input px-4 py-2 text-sm font-medium text-foreground hover:bg-accent disabled:opacity-50"
>
{testing ? 'Sending...' : 'Send Test'}
</button>
{/if}
<button
type="button"
onclick={onCancel}
class="rounded-md px-4 py-2 text-sm text-muted-foreground hover:text-foreground"
>
Cancel
</button>
</div>
</form>
</div>
@@ -0,0 +1,169 @@
<script lang="ts">
import { onMount } from 'svelte';
interface NotificationItem {
readonly id: string;
readonly appId: string | null;
readonly event: string;
readonly message: string;
readonly sentAt: string;
readonly readAt: string | null;
readonly app?: {
readonly name: string;
} | null;
}
let allNotifications = $state<NotificationItem[]>([]);
let loading = $state(true);
let currentPage = $state(1);
let hasMore = $state(false);
let filterEvent = $state('');
let filterAppId = $state('');
const PAGE_SIZE = 20;
async function loadNotifications(page: number = 1) {
loading = true;
try {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const params = new URLSearchParams({
limit: String(PAGE_SIZE),
offset: String((page - 1) * PAGE_SIZE)
});
if (filterEvent) params.set('event', filterEvent);
if (filterAppId) params.set('appId', filterAppId);
const res = await fetch(`/api/notifications?${params.toString()}`);
if (res.ok) {
const json = await res.json();
if (json.success && Array.isArray(json.data)) {
allNotifications = json.data;
hasMore = json.data.length === PAGE_SIZE;
}
}
} catch {
// Silently fail
} finally {
loading = false;
}
}
onMount(() => {
loadNotifications();
});
function changePage(delta: number) {
currentPage = Math.max(1, currentPage + delta);
loadNotifications(currentPage);
}
function applyFilters() {
currentPage = 1;
loadNotifications(1);
}
function eventLabel(event: string): string {
switch (event) {
case 'app_online': return 'Online';
case 'app_offline': return 'Offline';
case 'app_degraded': return 'Degraded';
default: return event;
}
}
function eventBadgeClass(event: string): string {
switch (event) {
case 'app_online': return 'bg-green-500/10 text-green-500';
case 'app_offline': return 'bg-red-500/10 text-red-500';
case 'app_degraded': return 'bg-yellow-500/10 text-yellow-500';
default: return 'bg-muted text-muted-foreground';
}
}
</script>
<div>
<!-- Filters -->
<div class="mb-4 flex flex-wrap items-center gap-3">
<select
bind:value={filterEvent}
onchange={applyFilters}
class="rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground"
>
<option value="">All Events</option>
<option value="app_online">Online</option>
<option value="app_offline">Offline</option>
<option value="app_degraded">Degraded</option>
</select>
</div>
<!-- Table -->
{#if loading}
<div class="py-12 text-center text-muted-foreground">Loading...</div>
{:else if allNotifications.length === 0}
<div class="rounded-xl border border-border bg-card/50 p-12 text-center">
<p class="text-muted-foreground">No notifications found</p>
</div>
{:else}
<div class="overflow-x-auto rounded-lg border border-border">
<table class="w-full text-left text-sm">
<thead class="border-b border-border bg-muted/50">
<tr>
<th class="px-4 py-3 font-medium text-muted-foreground">Time</th>
<th class="px-4 py-3 font-medium text-muted-foreground">Event</th>
<th class="px-4 py-3 font-medium text-muted-foreground">App</th>
<th class="px-4 py-3 font-medium text-muted-foreground">Message</th>
<th class="px-4 py-3 font-medium text-muted-foreground">Status</th>
</tr>
</thead>
<tbody>
{#each allNotifications as notification (notification.id)}
<tr class="border-b border-border last:border-0">
<td class="whitespace-nowrap px-4 py-3 text-xs text-muted-foreground">
{new Date(notification.sentAt).toLocaleString()}
</td>
<td class="px-4 py-3">
<span class="inline-block rounded-full px-2 py-0.5 text-xs font-medium {eventBadgeClass(notification.event)}">
{eventLabel(notification.event)}
</span>
</td>
<td class="px-4 py-3 text-sm text-foreground">
{notification.app?.name ?? '—'}
</td>
<td class="max-w-xs truncate px-4 py-3 text-sm text-foreground">
{notification.message}
</td>
<td class="px-4 py-3">
{#if notification.readAt}
<span class="text-xs text-muted-foreground">Read</span>
{:else}
<span class="text-xs font-medium text-primary">Unread</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="mt-4 flex items-center justify-between">
<button
type="button"
disabled={currentPage === 1}
onclick={() => changePage(-1)}
class="rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent disabled:opacity-50"
>
Previous
</button>
<span class="text-sm text-muted-foreground">Page {currentPage}</span>
<button
type="button"
disabled={!hasMore}
onclick={() => changePage(1)}
class="rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent disabled:opacity-50"
>
Next
</button>
</div>
{/if}
</div>
@@ -0,0 +1,436 @@
<script lang="ts">
import { goto } from '$app/navigation';
const STEPS = ['welcome', 'admin', 'authMode', 'theme', 'complete'] as const;
type Step = (typeof STEPS)[number];
let currentStep = $state<Step>('welcome');
let loading = $state(false);
let errorMsg = $state<string | null>(null);
// Admin form
let adminEmail = $state('');
let adminPassword = $state('');
let adminDisplayName = $state('');
let adminCreated = $state(false);
// Auth mode form
let authMode = $state<'local' | 'oauth' | 'both'>('local');
let oauthClientId = $state('');
let oauthClientSecret = $state('');
let oauthDiscoveryUrl = $state('');
// Theme form
let defaultTheme = $state<'dark' | 'light'>('dark');
let defaultPrimaryColor = $state('#6366f1');
// Board form
let boardName = $state('My Dashboard');
const currentStepIndex = $derived(STEPS.indexOf(currentStep));
const isFirstStep = $derived(currentStepIndex === 0);
const isLastStep = $derived(currentStepIndex === STEPS.length - 1);
function goBack() {
if (currentStepIndex > 0) {
currentStep = STEPS[currentStepIndex - 1];
errorMsg = null;
}
}
function skipStep() {
if (currentStepIndex < STEPS.length - 1) {
currentStep = STEPS[currentStepIndex + 1];
errorMsg = null;
}
}
async function handleNext() {
errorMsg = null;
loading = true;
try {
switch (currentStep) {
case 'welcome':
currentStep = 'admin';
break;
case 'admin': {
if (adminCreated) {
currentStep = 'authMode';
break;
}
if (!adminEmail || !adminPassword || !adminDisplayName) {
errorMsg = 'All fields are required';
break;
}
const adminRes = await fetch('/api/onboarding', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
step: 'admin',
data: {
email: adminEmail,
password: adminPassword,
displayName: adminDisplayName
}
})
});
const adminJson = await adminRes.json();
if (!adminJson.success) {
errorMsg = adminJson.error ?? 'Failed to create admin account';
break;
}
adminCreated = true;
currentStep = 'authMode';
break;
}
case 'authMode': {
const authRes = await fetch('/api/onboarding', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
step: 'authMode',
data: {
authMode,
...(authMode !== 'local'
? {
oauthClientId: oauthClientId || undefined,
oauthClientSecret: oauthClientSecret || undefined,
oauthDiscoveryUrl: oauthDiscoveryUrl || undefined
}
: {})
}
})
});
const authJson = await authRes.json();
if (!authJson.success) {
errorMsg = authJson.error ?? 'Failed to set auth mode';
break;
}
currentStep = 'theme';
break;
}
case 'theme': {
const themeRes = await fetch('/api/onboarding', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
step: 'theme',
data: { defaultTheme, defaultPrimaryColor }
})
});
const themeJson = await themeRes.json();
if (!themeJson.success) {
errorMsg = themeJson.error ?? 'Failed to save theme';
break;
}
currentStep = 'complete';
break;
}
case 'complete': {
const completeRes = await fetch('/api/onboarding', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
step: 'complete',
data: {
boardName: boardName || undefined
}
})
});
const completeJson = await completeRes.json();
if (!completeJson.success) {
errorMsg = completeJson.error ?? 'Failed to complete onboarding';
break;
}
// Redirect to login page
goto('/login');
break;
}
}
} catch {
errorMsg = 'An unexpected error occurred';
} finally {
loading = false;
}
}
const primaryColorOptions = [
{ label: 'Indigo', value: '#6366f1' },
{ label: 'Blue', value: '#3b82f6' },
{ label: 'Emerald', value: '#10b981' },
{ label: 'Rose', value: '#f43f5e' },
{ label: 'Amber', value: '#f59e0b' },
{ label: 'Violet', value: '#8b5cf6' },
{ label: 'Cyan', value: '#06b6d4' },
{ label: 'Orange', value: '#f97316' }
] as const;
</script>
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm">
<div
class="mx-4 w-full max-w-lg rounded-2xl border border-border bg-card shadow-2xl"
>
<!-- Progress bar -->
<div class="border-b border-border px-6 py-4">
<div class="mb-2 flex justify-between text-xs text-muted-foreground">
{#each STEPS as step, i (step)}
<span
class="font-medium capitalize"
class:text-primary={i <= currentStepIndex}
class:text-muted-foreground={i > currentStepIndex}
>
{step === 'authMode' ? 'Auth' : step}
</span>
{/each}
</div>
<div class="h-1.5 w-full rounded-full bg-muted">
<div
class="h-full rounded-full bg-primary transition-all duration-300"
style="width: {((currentStepIndex + 1) / STEPS.length) * 100}%"
></div>
</div>
</div>
<!-- Step content -->
<div class="px-6 py-6">
{#if currentStep === 'welcome'}
<div class="text-center">
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10">
<svg class="h-8 w-8 text-primary" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
</div>
<h2 class="mb-2 text-2xl font-bold text-foreground">Welcome to Web App Launcher</h2>
<p class="text-muted-foreground">
Let's get your dashboard set up. This wizard will guide you through the initial
configuration in a few quick steps.
</p>
</div>
{:else if currentStep === 'admin'}
<h2 class="mb-4 text-xl font-bold text-foreground">Create Admin Account</h2>
{#if adminCreated}
<div class="rounded-lg bg-green-500/10 p-4 text-sm text-green-600 dark:text-green-400">
Admin account created successfully. You can proceed to the next step.
</div>
{:else}
<div class="space-y-3">
<div>
<label for="ob-display-name" class="mb-1 block text-sm font-medium text-foreground">Display Name</label>
<input
id="ob-display-name"
type="text"
bind:value={adminDisplayName}
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
placeholder="Admin"
/>
</div>
<div>
<label for="ob-email" class="mb-1 block text-sm font-medium text-foreground">Email</label>
<input
id="ob-email"
type="email"
bind:value={adminEmail}
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
placeholder="admin@example.com"
/>
</div>
<div>
<label for="ob-password" class="mb-1 block text-sm font-medium text-foreground">Password</label>
<input
id="ob-password"
type="password"
bind:value={adminPassword}
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
placeholder="Min. 6 characters"
/>
</div>
</div>
{/if}
{:else if currentStep === 'authMode'}
<h2 class="mb-4 text-xl font-bold text-foreground">Authentication Mode</h2>
<div class="space-y-3">
<div class="flex flex-col gap-2">
{#each [
{ value: 'local', label: 'Local Only', desc: 'Email + password authentication' },
{ value: 'oauth', label: 'OAuth Only', desc: 'External identity provider (OIDC)' },
{ value: 'both', label: 'Both', desc: 'Local accounts and OAuth' }
] as option (option.value)}
<label
class="flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors {authMode === option.value ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'}"
>
<input
type="radio"
name="authMode"
value={option.value}
bind:group={authMode}
class="mt-0.5"
/>
<div>
<span class="text-sm font-medium text-foreground">{option.label}</span>
<p class="text-xs text-muted-foreground">{option.desc}</p>
</div>
</label>
{/each}
</div>
{#if authMode !== 'local'}
<div class="space-y-2 rounded-lg border border-border p-3">
<p class="text-xs font-medium text-muted-foreground">OAuth Configuration (optional — can be set later)</p>
<input
type="text"
bind:value={oauthClientId}
class="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
placeholder="Client ID"
/>
<input
type="password"
bind:value={oauthClientSecret}
class="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
placeholder="Client Secret"
/>
<input
type="url"
bind:value={oauthDiscoveryUrl}
class="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
placeholder="Discovery URL (https://.../.well-known/openid-configuration)"
/>
</div>
{/if}
</div>
{:else if currentStep === 'theme'}
<h2 class="mb-4 text-xl font-bold text-foreground">Theme & Appearance</h2>
<div class="space-y-4">
<div>
<p class="mb-2 text-sm font-medium text-foreground">Default Theme</p>
<div class="flex gap-3">
<button
type="button"
onclick={() => (defaultTheme = 'dark')}
class="flex-1 rounded-lg border-2 px-4 py-3 text-sm font-medium transition-colors {defaultTheme === 'dark' ? 'border-primary bg-primary/10 text-foreground' : 'border-border text-muted-foreground hover:border-primary/50'}"
>
Dark
</button>
<button
type="button"
onclick={() => (defaultTheme = 'light')}
class="flex-1 rounded-lg border-2 px-4 py-3 text-sm font-medium transition-colors {defaultTheme === 'light' ? 'border-primary bg-primary/10 text-foreground' : 'border-border text-muted-foreground hover:border-primary/50'}"
>
Light
</button>
</div>
</div>
<div>
<p class="mb-2 text-sm font-medium text-foreground">Accent Color</p>
<div class="grid grid-cols-4 gap-2">
{#each primaryColorOptions as color (color.value)}
<button
type="button"
onclick={() => (defaultPrimaryColor = color.value)}
class="flex flex-col items-center gap-1 rounded-lg border-2 p-2 transition-colors {defaultPrimaryColor === color.value ? 'border-primary' : 'border-border hover:border-primary/50'}"
>
<span
class="h-6 w-6 rounded-full"
style="background-color: {color.value}"
></span>
<span class="text-xs text-muted-foreground">{color.label}</span>
</button>
{/each}
</div>
</div>
</div>
{:else if currentStep === 'complete'}
<h2 class="mb-4 text-xl font-bold text-foreground">Create First Board</h2>
<div class="space-y-3">
<div>
<label for="ob-board-name" class="mb-1 block text-sm font-medium text-foreground">Board Name</label>
<input
id="ob-board-name"
type="text"
bind:value={boardName}
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
placeholder="My Dashboard"
/>
</div>
<p class="text-xs text-muted-foreground">
A default board will be created for you. You can customize it later.
</p>
</div>
{/if}
{#if errorMsg}
<div class="mt-4 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
{errorMsg}
</div>
{/if}
</div>
<!-- Actions -->
<div class="flex items-center justify-between border-t border-border px-6 py-4">
<div>
{#if !isFirstStep}
<button
type="button"
onclick={goBack}
disabled={loading}
class="rounded-lg border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-accent disabled:opacity-50"
>
Back
</button>
{/if}
</div>
<div class="flex gap-2">
{#if !isFirstStep && !isLastStep && currentStep !== 'admin'}
<button
type="button"
onclick={skipStep}
disabled={loading}
class="rounded-lg px-4 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
>
Skip
</button>
{/if}
<button
type="button"
onclick={handleNext}
disabled={loading}
class="rounded-lg bg-primary px-6 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
>
{#if loading}
Processing...
{:else if isLastStep}
Finish Setup
{:else if isFirstStep}
Get Started
{:else}
Next
{/if}
</button>
</div>
</div>
</div>
</div>
@@ -28,6 +28,7 @@
icon: string | null;
order: number;
isExpandedByDefault: boolean;
cardSize?: string | null;
widgets: WidgetData[];
}
@@ -40,6 +41,7 @@
onDeleteSection: (sectionId: string) => void;
onAddWidget: (sectionId: string, widgetData: string) => void;
onDeleteWidget: (widgetId: string) => void;
onUpdateSection?: (sectionId: string, data: Record<string, unknown>) => void;
}
let {
@@ -50,9 +52,18 @@
onToggleAddWidget,
onDeleteSection,
onAddWidget,
onDeleteWidget
onDeleteWidget,
onUpdateSection
}: Props = $props();
const cardSizeOptions = ['compact', 'medium', 'large'] as const;
function handleCardSizeChange(event: Event) {
const select = event.target as HTMLSelectElement;
const value = select.value || null;
onUpdateSection?.(section.id, { cardSize: value });
}
let widgets = $state<WidgetData[]>([...section.widgets]);
let dirty = $state(false);
@@ -128,6 +139,17 @@
{/if}
</div>
<div class="flex items-center gap-2">
<!-- Card size selector -->
<select
onchange={handleCardSizeChange}
class="rounded-md border border-input bg-background px-2 py-1 text-xs text-foreground transition-colors focus:border-primary focus:outline-none"
title={$t('board.card_size') ?? 'Card size'}
>
<option value="" selected={!section.cardSize}>{$t('section.inherit_size') ?? 'Inherit'}</option>
{#each cardSizeOptions as size (size)}
<option value={size} selected={section.cardSize === size}>{size}</option>
{/each}
</select>
<button
type="button"
onclick={() => onToggleAddWidget(section.id)}
+11 -2
View File
@@ -20,12 +20,15 @@
} | null;
}
import type { CardSize } from '$lib/utils/constants.js';
interface SectionData {
id: string;
title: string;
icon: string | null;
order: number;
isExpandedByDefault: boolean;
cardSize?: string | null;
widgets: WidgetData[];
}
@@ -42,9 +45,15 @@
interface Props {
section: SectionData;
allApps?: AppData[];
boardCardSize?: CardSize;
}
let { section, allApps = [] }: Props = $props();
let { section, allApps = [], boardCardSize = 'medium' }: Props = $props();
// Section-level cardSize overrides board-level
const effectiveCardSize = $derived<CardSize>(
(section.cardSize as CardSize) ?? boardCardSize
);
let expanded = $state(section.isExpandedByDefault);
</script>
@@ -59,7 +68,7 @@
<SectionCollapsible {expanded}>
<div class="px-4 pb-4">
<WidgetGrid widgets={section.widgets} {allApps} />
<WidgetGrid widgets={section.widgets} {allApps} cardSize={effectiveCardSize} />
</div>
</SectionCollapsible>
</div>
@@ -0,0 +1,74 @@
<script lang="ts">
import { enhance } from '$app/forms';
interface Props {
onCancel: () => void;
}
let { onCancel }: Props = $props();
</script>
<div class="rounded-lg border border-border bg-card p-6">
<h2 class="mb-4 text-lg font-semibold text-card-foreground">Generate API Token</h2>
<form method="POST" action="?/create" use:enhance class="space-y-4">
<div>
<label for="token-name" class="mb-1 block text-sm font-medium text-foreground">
Token Name
</label>
<input
id="token-name"
name="name"
type="text"
placeholder="e.g., CI/CD Pipeline"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
required
/>
<p class="mt-1 text-xs text-muted-foreground">A descriptive name to identify this token</p>
</div>
<div>
<label for="token-scope" class="mb-1 block text-sm font-medium text-foreground">
Scope
</label>
<select
id="token-scope"
name="scope"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
>
<option value="read">Read — View apps, boards, and status</option>
<option value="write">Write — Modify apps, boards, and settings</option>
<option value="admin">Admin — Full access including user management</option>
</select>
</div>
<div>
<label for="token-expires" class="mb-1 block text-sm font-medium text-foreground">
Expiration (optional)
</label>
<input
id="token-expires"
name="expiresAt"
type="datetime-local"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
/>
<p class="mt-1 text-xs text-muted-foreground">Leave empty for a non-expiring token</p>
</div>
<div class="flex items-center gap-3">
<button
type="submit"
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Generate Token
</button>
<button
type="button"
onclick={onCancel}
class="rounded-md px-4 py-2 text-sm text-muted-foreground hover:text-foreground"
>
Cancel
</button>
</div>
</form>
</div>
@@ -0,0 +1,127 @@
<script lang="ts">
import { enhance } from '$app/forms';
interface Token {
id: string;
name: string;
scope: string;
lastUsedAt: Date | string | null;
expiresAt: Date | string | null;
createdAt: Date | string;
}
interface Props {
tokens: Token[];
}
let { tokens }: Props = $props();
let confirmRevokeId = $state<string | null>(null);
function formatDate(dateVal: Date | string | null): string {
if (!dateVal) return 'Never';
return new Date(dateVal).toLocaleDateString();
}
function isExpired(expiresAt: Date | string | null): boolean {
if (!expiresAt) return false;
return new Date(expiresAt) < new Date();
}
function scopeLabel(scope: string): string {
switch (scope) {
case 'read': return 'Read';
case 'write': return 'Write';
case 'admin': return 'Admin';
default: return scope;
}
}
function scopeBadgeClass(scope: string): string {
switch (scope) {
case 'admin': return 'bg-red-500/10 text-red-500';
case 'write': return 'bg-yellow-500/10 text-yellow-500';
default: return 'bg-green-500/10 text-green-500';
}
}
</script>
{#if tokens.length === 0}
<div class="rounded-xl border border-border bg-card/50 p-8 text-center">
<p class="text-muted-foreground">No API tokens created yet</p>
<p class="mt-1 text-xs text-muted-foreground">
API tokens allow programmatic access to your dashboard
</p>
</div>
{:else}
<div class="overflow-x-auto rounded-lg border border-border">
<table class="w-full text-left text-sm">
<thead class="border-b border-border bg-muted/50">
<tr>
<th class="px-4 py-3 font-medium text-muted-foreground">Name</th>
<th class="px-4 py-3 font-medium text-muted-foreground">Scope</th>
<th class="px-4 py-3 font-medium text-muted-foreground">Created</th>
<th class="px-4 py-3 font-medium text-muted-foreground">Last Used</th>
<th class="px-4 py-3 font-medium text-muted-foreground">Expires</th>
<th class="px-4 py-3 font-medium text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody>
{#each tokens as token (token.id)}
<tr class="border-b border-border last:border-0">
<td class="px-4 py-3 font-medium text-foreground">{token.name}</td>
<td class="px-4 py-3">
<span class="inline-block rounded-full px-2 py-0.5 text-xs font-medium {scopeBadgeClass(token.scope)}">
{scopeLabel(token.scope)}
</span>
</td>
<td class="px-4 py-3 text-xs text-muted-foreground">{formatDate(token.createdAt)}</td>
<td class="px-4 py-3 text-xs text-muted-foreground">{formatDate(token.lastUsedAt)}</td>
<td class="px-4 py-3">
{#if token.expiresAt}
<span class="text-xs {isExpired(token.expiresAt) ? 'text-destructive' : 'text-muted-foreground'}">
{formatDate(token.expiresAt)}
{#if isExpired(token.expiresAt)}
(expired)
{/if}
</span>
{:else}
<span class="text-xs text-muted-foreground">Never</span>
{/if}
</td>
<td class="px-4 py-3">
{#if confirmRevokeId === token.id}
<form method="POST" action="?/revoke" use:enhance>
<input type="hidden" name="tokenId" value={token.id} />
<div class="flex items-center gap-1">
<button
type="submit"
class="rounded px-2 py-1 text-xs font-medium text-destructive hover:bg-destructive/10"
>
Confirm
</button>
<button
type="button"
onclick={() => (confirmRevokeId = null)}
class="rounded px-2 py-1 text-xs text-muted-foreground"
>
Cancel
</button>
</div>
</form>
{:else}
<button
type="button"
onclick={() => (confirmRevokeId = token.id)}
class="rounded px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
>
Revoke
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
@@ -0,0 +1,120 @@
<script lang="ts">
import { t } from 'svelte-i18n';
interface Props {
value: string;
onchange?: (css: string) => void;
label?: string;
}
let { value, onchange, label }: Props = $props();
let localValue = $state(value);
let livePreview = $state(false);
let validationError = $state('');
// Sync external value changes
$effect(() => {
localValue = value;
});
/**
* Sanitize CSS to prevent script injection and limit scope.
* Strips dangerous patterns while allowing normal CSS within .custom-css-scope.
*/
function sanitizeCss(css: string): string {
// Remove any <script> or HTML tags
let cleaned = css.replace(/<\/?[^>]+(>|$)/g, '');
// Remove javascript: URLs
cleaned = cleaned.replace(/javascript\s*:/gi, '');
// Remove expression() calls (IE legacy XSS vector)
cleaned = cleaned.replace(/expression\s*\(/gi, '');
// Remove url() calls that contain javascript:
cleaned = cleaned.replace(/url\s*\(\s*['"]?\s*javascript:/gi, 'url(');
// Remove @import rules (prevent external resource loading)
cleaned = cleaned.replace(/@import\s+[^;]+;?/gi, '');
// Remove behavior: property (IE-specific XSS)
cleaned = cleaned.replace(/behavior\s*:/gi, '');
// Remove -moz-binding (Firefox XSS vector)
cleaned = cleaned.replace(/-moz-binding\s*:/gi, '');
return cleaned;
}
function validate(css: string): boolean {
validationError = '';
// Check for dangerous patterns
if (/<script/i.test(css)) {
validationError = 'Script tags are not allowed in custom CSS';
return false;
}
if (/javascript\s*:/i.test(css)) {
validationError = 'JavaScript URLs are not allowed';
return false;
}
if (/expression\s*\(/i.test(css)) {
validationError = 'CSS expressions are not allowed';
return false;
}
return true;
}
function handleInput(event: Event) {
const target = event.target as HTMLTextAreaElement;
localValue = target.value;
if (validate(localValue)) {
const sanitized = sanitizeCss(localValue);
onchange?.(sanitized);
}
}
</script>
<div class="space-y-3">
{#if label}
<label class="block text-sm font-medium text-foreground">{label}</label>
{/if}
<textarea
value={localValue}
oninput={handleInput}
rows="8"
class="w-full rounded-lg border border-input bg-background px-3 py-2 font-mono text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
placeholder={`/* Custom CSS */\n.custom-css-scope .my-widget {\n background: #333;\n}`}
spellcheck="false"
></textarea>
{#if validationError}
<p class="text-xs text-destructive">{validationError}</p>
{/if}
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
bind:checked={livePreview}
class="h-4 w-4 rounded border-input accent-primary"
/>
{$t('settings.live_preview') ?? 'Live preview'}
</label>
<span class="text-xs text-muted-foreground">
{$t('settings.custom_css_hint') ?? 'CSS is scoped to .custom-css-scope to prevent breaking core UI'}
</span>
</div>
{#if livePreview && localValue && !validationError}
<div class="custom-css-scope rounded-lg border border-border bg-card/50 p-4">
<p class="text-sm text-muted-foreground">{$t('settings.preview_area') ?? 'Preview area'}</p>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- CSS is sanitized -->
{@html `<style>${sanitizeCss(localValue)}</style>`}
</div>
{/if}
</div>
@@ -1,6 +1,6 @@
<script lang="ts">
import { t, locale as i18nLocale } from 'svelte-i18n';
import { theme, type ThemeMode, type BackgroundType } from '$lib/stores/theme.svelte.js';
import { theme, type ThemeMode, type BackgroundType, type CardStyle } from '$lib/stores/theme.svelte.js';
interface UserPreferences {
themeMode: string | null;
@@ -34,6 +34,12 @@
{ value: 'aurora', labelKey: 'bg.aurora' }
];
const cardStyleOptions: { value: CardStyle; labelKey: string }[] = [
{ value: 'solid', labelKey: 'card_style.solid' },
{ value: 'glass', labelKey: 'card_style.glass' },
{ value: 'outline', labelKey: 'card_style.outline' }
];
const localeOptions = [
{ value: 'en', label: 'English' },
{ value: 'ru', label: 'Русский' }
@@ -66,6 +72,10 @@
theme.setBackground(bg);
}
function setCardStyle(style: CardStyle) {
theme.setCardStyle(style);
}
function setLocale(loc: string) {
i18nLocale.set(loc);
}
@@ -201,6 +211,24 @@
</div>
</section>
<!-- Card Style -->
<section>
<h2 class="mb-3 text-lg font-semibold text-foreground">{$t('settings.card_style') ?? 'Card Style'}</h2>
<div class="flex gap-1 rounded-lg border border-border bg-muted/50 p-1">
{#each cardStyleOptions as opt (opt.value)}
<button
type="button"
onclick={() => setCardStyle(opt.value)}
class="flex-1 rounded-md px-3 py-2 text-sm font-medium transition-colors {theme.cardStyle === opt.value
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
>
{$t(opt.labelKey) ?? opt.value}
</button>
{/each}
</div>
</section>
<!-- Locale -->
<section>
<h2 class="mb-3 text-lg font-semibold text-foreground">{$t('settings.language')}</h2>
@@ -118,6 +118,7 @@
// Reset highlight when query changes
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
query; // track
highlightIdx = 0;
});
@@ -0,0 +1,108 @@
<script lang="ts">
import { keyboard } from '$lib/stores/keyboard.svelte.js';
interface ShortcutItem {
readonly keys: string;
readonly description: string;
}
interface ShortcutCategory {
readonly name: string;
readonly shortcuts: readonly ShortcutItem[];
}
const categories: readonly ShortcutCategory[] = [
{
name: 'Global',
shortcuts: [
{ keys: 'Ctrl+K / Cmd+K', description: 'Open search' },
{ keys: '?', description: 'Toggle keyboard shortcuts' },
{ keys: '1-9', description: 'Switch to board by index' },
{ keys: 'f', description: 'Toggle favorites bar' },
{ keys: 'Escape', description: 'Close dialogs / overlays' }
]
},
{
name: 'Board View',
shortcuts: [
{ keys: 'j', description: 'Navigate to next app' },
{ keys: 'k', description: 'Navigate to previous app' },
{ keys: 'Enter', description: 'Open selected app' },
{ keys: 'e', description: 'Toggle edit mode' }
]
}
] as const;
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
keyboard.closeOverlay();
}
}
</script>
{#if keyboard.overlayOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-[90] flex items-center justify-center bg-black/60 backdrop-blur-sm"
onclick={handleBackdropClick}
onkeydown={(e) => e.key === 'Escape' && keyboard.closeOverlay()}
>
<div
class="mx-4 w-full max-w-xl rounded-2xl border border-border bg-card shadow-2xl"
role="dialog"
aria-label="Keyboard Shortcuts"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-border px-6 py-4">
<h2 class="text-lg font-semibold text-foreground">Keyboard Shortcuts</h2>
<button
type="button"
onclick={() => keyboard.closeOverlay()}
class="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
aria-label="Close"
>
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<!-- Body -->
<div class="px-6 py-5">
<div class="grid gap-6 sm:grid-cols-2">
{#each categories as category (category.name)}
<div>
<h3 class="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{category.name}
</h3>
<div class="space-y-2">
{#each category.shortcuts as shortcut (shortcut.keys)}
<div class="flex items-center justify-between gap-4">
<span class="text-sm text-foreground">{shortcut.description}</span>
<div class="flex shrink-0 gap-1">
{#each shortcut.keys.split(' / ') as keyCombo (keyCombo)}
<kbd
class="inline-flex items-center rounded border border-border bg-muted px-1.5 py-0.5 text-xs font-mono text-muted-foreground"
>
{keyCombo}
</kbd>
{/each}
</div>
</div>
{/each}
</div>
</div>
{/each}
</div>
</div>
<!-- Footer hint -->
<div class="border-t border-border px-6 py-3 text-center">
<p class="text-xs text-muted-foreground">
Shortcuts are disabled when typing in text fields
</p>
</div>
</div>
</div>
{/if}
+320 -40
View File
@@ -1,7 +1,26 @@
<script lang="ts">
import { onMount } from 'svelte';
import AppHealthBadge from '$lib/components/app/AppHealthBadge.svelte';
import AnimatedStatusRing from '$lib/components/app/AnimatedStatusRing.svelte';
import SparklineChart from '$lib/components/app/SparklineChart.svelte';
import TagBadge from '$lib/components/app/TagBadge.svelte';
import { theme } from '$lib/stores/theme.svelte.js';
import { favorites } from '$lib/stores/favorites.svelte.js';
import { slide } from 'svelte/transition';
interface AppLink {
id: string;
label: string;
url: string;
icon: string | null;
order: number;
}
interface AppTag {
id: string;
name: string;
color: string | null;
}
interface AppData {
id: string;
@@ -11,6 +30,8 @@
iconType: string;
description: string | null;
statuses: Array<{ status: string; responseTime: number | null }>;
links?: AppLink[];
tags?: AppTag[];
}
interface StatusPoint {
@@ -20,15 +41,23 @@
interface Props {
app: AppData;
cardSize?: 'compact' | 'medium' | 'large';
}
let { app }: Props = $props();
let { app, cardSize = 'medium' }: Props = $props();
const cardStyleClass = $derived(`card-${theme.cardStyle}`);
let historyData: StatusPoint[] = $state([]);
let uptimePercent: number | null = $state(null);
let historyLoading = $state(true);
let linksExpanded = $state(false);
let showContextMenu = $state(false);
let contextMenuPos = $state({ x: 0, y: 0 });
const latestStatus = $derived(app.statuses[0]?.status ?? 'unknown');
const hasLinks = $derived(Array.isArray(app.links) && app.links.length > 0);
const hasTags = $derived(Array.isArray(app.tags) && app.tags.length > 0);
const iconSrc = $derived.by(() => {
if (!app.icon) return null;
@@ -61,48 +90,299 @@
historyLoading = false;
}
});
function handleContextMenu(e: MouseEvent) {
e.preventDefault();
contextMenuPos = { x: e.clientX, y: e.clientY };
showContextMenu = true;
}
function handleWindowClick() {
showContextMenu = false;
}
function toggleFavorite() {
showContextMenu = false;
if (favorites.isFavorite(app.id)) {
favorites.remove(app.id);
} else {
favorites.add(app.id);
}
}
function recordClick() {
fetch('/api/recent-apps', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ appId: app.id })
}).catch(() => {});
}
function toggleLinks(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
linksExpanded = !linksExpanded;
}
</script>
<a
href={app.url}
target="_blank"
rel="noopener noreferrer"
class="card-hover group flex flex-col items-center gap-2 rounded-xl border border-border bg-card p-4 text-center transition-colors hover:border-primary/50"
>
<!-- Icon -->
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-muted transition-colors group-hover:bg-accent">
{#if app.iconType === 'emoji' && app.icon}
<span class="text-2xl">{app.icon}</span>
{:else if iconSrc}
<img
src={iconSrc}
alt="{app.name} icon"
class="h-8 w-8 object-contain"
/>
{:else}
<span class="text-lg font-bold text-muted-foreground">
{app.name.charAt(0).toUpperCase()}
</span>
<svelte:window onclick={handleWindowClick} />
{#if cardSize === 'compact'}
<!-- Compact: icon + name only, inline layout -->
<a
href={app.url}
target="_blank"
rel="noopener noreferrer"
class="card-hover group flex items-center gap-2 rounded-lg {cardStyleClass} px-3 py-2 text-left transition-colors hover:border-primary/50"
oncontextmenu={handleContextMenu}
onclick={recordClick}
>
<div class="relative flex-shrink-0">
<div class="flex h-8 w-8 items-center justify-center rounded-md bg-muted transition-colors group-hover:bg-accent">
{#if app.iconType === 'emoji' && app.icon}
<span class="text-base">{app.icon}</span>
{:else if iconSrc}
<img src={iconSrc} alt="{app.name} icon" class="h-5 w-5 object-contain" />
{:else}
<span class="text-xs font-bold text-muted-foreground">{app.name.charAt(0).toUpperCase()}</span>
{/if}
</div>
<AnimatedStatusRing status={latestStatus} size={32} animated />
</div>
<span class="truncate text-xs font-medium text-foreground transition-colors group-hover:text-primary">
{app.name}
</span>
{#if hasLinks}
<button
type="button"
onclick={toggleLinks}
class="ml-auto flex-shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground"
title="Show links"
>
<svg class="h-3 w-3 transition-transform" class:rotate-90={linksExpanded} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
{/if}
</div>
</a>
<!-- Name -->
<span class="w-full truncate text-sm font-medium text-foreground transition-colors group-hover:text-primary">
{app.name}
</span>
<!-- Status -->
<AppHealthBadge status={latestStatus} />
<!-- Sparkline -->
{#if historyLoading}
<div class="h-5 w-20 animate-pulse rounded bg-muted"></div>
{:else if historyData.length > 0}
<div class="flex items-center gap-1">
<SparklineChart data={historyData} />
{#if uptimePercent !== null}
<span class="text-[10px] text-muted-foreground">{uptimePercent}%</span>
{/if}
<!-- Expanded links for compact -->
{#if linksExpanded && hasLinks}
<div transition:slide={{ duration: 200 }} class="ml-10 space-y-0.5 pb-1">
{#each app.links ?? [] as link (link.id)}
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1.5 rounded px-2 py-1 text-[11px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<svg class="h-3 w-3 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
{link.label}
</a>
{/each}
</div>
{/if}
</a>
{:else if cardSize === 'large'}
<!-- Large: icon + name + description + sparkline + tags + links -->
<div
class="card-hover group rounded-xl {cardStyleClass} p-5 transition-colors hover:border-primary/50"
oncontextmenu={handleContextMenu}
>
<a
href={app.url}
target="_blank"
rel="noopener noreferrer"
class="flex flex-col items-center gap-3 text-center"
onclick={recordClick}
>
<div class="relative">
<div class="flex h-16 w-16 items-center justify-center rounded-xl bg-muted transition-colors group-hover:bg-accent">
{#if app.iconType === 'emoji' && app.icon}
<span class="text-3xl">{app.icon}</span>
{:else if iconSrc}
<img src={iconSrc} alt="{app.name} icon" class="h-10 w-10 object-contain" />
{:else}
<span class="text-xl font-bold text-muted-foreground">{app.name.charAt(0).toUpperCase()}</span>
{/if}
</div>
<AnimatedStatusRing status={latestStatus} size={64} animated />
</div>
<span class="w-full truncate text-base font-semibold text-foreground transition-colors group-hover:text-primary">
{app.name}
</span>
{#if app.description}
<p class="line-clamp-2 w-full text-xs text-muted-foreground">{app.description}</p>
{/if}
<AppHealthBadge status={latestStatus} />
{#if historyLoading}
<div class="h-5 w-24 animate-pulse rounded bg-muted"></div>
{:else if historyData.length > 0}
<div class="flex items-center gap-1">
<SparklineChart data={historyData} />
{#if uptimePercent !== null}
<span class="text-[10px] text-muted-foreground">{uptimePercent}%</span>
{/if}
</div>
{/if}
</a>
<!-- Tags -->
{#if hasTags}
<div class="mt-2 flex flex-wrap justify-center gap-1">
{#each app.tags ?? [] as tag (tag.id)}
<TagBadge name={tag.name} color={tag.color} size="sm" />
{/each}
</div>
{/if}
<!-- Expandable Links -->
{#if hasLinks}
<div class="mt-2 border-t border-border pt-2">
<button
type="button"
onclick={toggleLinks}
class="flex w-full items-center justify-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
<svg class="h-3 w-3 transition-transform" class:rotate-180={linksExpanded} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9" />
</svg>
{linksExpanded ? 'Hide' : `${app.links?.length ?? 0} more`} links
</button>
{#if linksExpanded}
<div transition:slide={{ duration: 200 }} class="mt-1.5 space-y-1">
{#each app.links ?? [] as link (link.id)}
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<svg class="h-3.5 w-3.5 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
{link.label}
</a>
{/each}
</div>
{/if}
</div>
{/if}
</div>
{:else}
<!-- Medium (default): icon + name + status + sparkline on hover + links -->
<div
class="card-hover group rounded-xl {cardStyleClass} p-4 transition-colors hover:border-primary/50"
oncontextmenu={handleContextMenu}
>
<a
href={app.url}
target="_blank"
rel="noopener noreferrer"
class="flex flex-col items-center gap-2 text-center"
onclick={recordClick}
>
<div class="relative">
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-muted transition-colors group-hover:bg-accent">
{#if app.iconType === 'emoji' && app.icon}
<span class="text-2xl">{app.icon}</span>
{:else if iconSrc}
<img src={iconSrc} alt="{app.name} icon" class="h-8 w-8 object-contain" />
{:else}
<span class="text-lg font-bold text-muted-foreground">{app.name.charAt(0).toUpperCase()}</span>
{/if}
</div>
<AnimatedStatusRing status={latestStatus} size={48} animated />
</div>
<span class="w-full truncate text-sm font-medium text-foreground transition-colors group-hover:text-primary">
{app.name}
</span>
<AppHealthBadge status={latestStatus} />
{#if historyLoading}
<div class="h-5 w-20 animate-pulse rounded bg-muted"></div>
{:else if historyData.length > 0}
<div class="flex items-center gap-1">
<SparklineChart data={historyData} />
{#if uptimePercent !== null}
<span class="text-[10px] text-muted-foreground">{uptimePercent}%</span>
{/if}
</div>
{/if}
</a>
<!-- Expandable Links -->
{#if hasLinks}
<div class="mt-2 border-t border-border pt-2">
<button
type="button"
onclick={toggleLinks}
class="flex w-full items-center justify-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
<svg class="h-3 w-3 transition-transform" class:rotate-180={linksExpanded} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9" />
</svg>
{linksExpanded ? 'Hide' : `${app.links?.length ?? 0} more`}
</button>
{#if linksExpanded}
<div transition:slide={{ duration: 200 }} class="mt-1 space-y-0.5">
{#each app.links ?? [] as link (link.id)}
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1.5 rounded px-2 py-1 text-[11px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<svg class="h-3 w-3 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
{link.label}
</a>
{/each}
</div>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Context Menu -->
{#if showContextMenu}
<div
class="fixed z-50 rounded-lg border border-border bg-popover p-1 shadow-lg"
style="left: {contextMenuPos.x}px; top: {contextMenuPos.y}px"
>
<button
type="button"
class="flex w-full items-center gap-2 rounded-sm px-3 py-1.5 text-sm text-popover-foreground transition-colors hover:bg-accent"
onclick={toggleFavorite}
>
{#if favorites.isFavorite(app.id)}
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
Remove from favorites
{:else}
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
Add to favorites
{/if}
</button>
</div>
{/if}
@@ -0,0 +1,177 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Calendar, MapPin, Clock } from 'lucide-svelte';
import type { CalendarWidgetConfig } from '$lib/types/widget.js';
interface Props {
config: CalendarWidgetConfig;
}
let { config }: Props = $props();
interface CalendarEvent {
summary: string;
start: string;
end: string;
location?: string;
calendarLabel?: string;
calendarColor?: string;
}
let events: CalendarEvent[] = $state([]);
let loading = $state(true);
let error = $state(false);
function groupLabel(dateStr: string): string {
const date = new Date(dateStr);
/* eslint-disable svelte/prefer-svelte-reactivity */
const today = new Date();
const tomorrow = new Date();
/* eslint-enable svelte/prefer-svelte-reactivity */
tomorrow.setDate(today.getDate() + 1);
if (date.toDateString() === today.toDateString()) return 'Today';
if (date.toDateString() === tomorrow.toDateString()) return 'Tomorrow';
return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
}
function formatTimeRange(start: string, end: string): string {
const s = new Date(start);
const e = new Date(end);
// Check if all-day (midnight to midnight or close to it)
if (s.getHours() === 0 && s.getMinutes() === 0 && e.getHours() === 0 && e.getMinutes() === 0) {
return 'All day';
}
const fmt = new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit' });
return `${fmt.format(s)} - ${fmt.format(e)}`;
}
interface GroupedEvents {
label: string;
events: CalendarEvent[];
}
const grouped = $derived.by((): GroupedEvents[] => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const groups: Map<string, CalendarEvent[]> = new Map();
for (const evt of events) {
const key = new Date(evt.start).toDateString();
const existing = groups.get(key);
if (existing) {
existing.push(evt);
} else {
groups.set(key, [evt]);
}
}
const result: GroupedEvents[] = [];
for (const [, evts] of groups) {
result.push({
label: groupLabel(evts[0].start),
events: evts
});
}
return result;
});
async function fetchEvents() {
error = false;
try {
const res = await fetch('/api/widgets/calendar', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
icalUrls: config.icalUrls,
daysAhead: config.daysAhead ?? 7
})
});
if (res.ok) {
const json = await res.json();
if (json.success && json.data) {
events = json.data;
}
} else {
error = true;
}
} catch {
error = true;
} finally {
loading = false;
}
}
onMount(() => {
fetchEvents();
});
// Refresh every 30 minutes
$effect(() => {
const interval = setInterval(fetchEvents, 30 * 60 * 1000);
return () => clearInterval(interval);
});
</script>
<div class="flex h-full flex-col rounded-xl border border-border bg-card p-4">
<div class="mb-3 flex items-center gap-2">
<Calendar class="h-4 w-4 text-muted-foreground" />
<span class="text-sm font-medium text-foreground">Calendar</span>
</div>
{#if loading}
<div class="space-y-3">
{#each [1, 2, 3] as _n (_n)}
<div class="space-y-1">
<div class="h-3 w-16 animate-pulse rounded bg-muted"></div>
<div class="h-4 w-3/4 animate-pulse rounded bg-muted"></div>
<div class="h-3 w-1/3 animate-pulse rounded bg-muted"></div>
</div>
{/each}
</div>
{:else if error}
<div class="flex flex-1 items-center justify-center">
<span class="text-xs text-muted-foreground">Failed to load events</span>
</div>
{:else if events.length === 0}
<div class="flex flex-1 items-center justify-center text-center">
<div>
<Calendar class="mx-auto mb-2 h-8 w-8 text-muted-foreground/50" />
<span class="text-xs text-muted-foreground">No upcoming events</span>
</div>
</div>
{:else}
<div class="flex-1 space-y-3 overflow-y-auto">
{#each grouped as group (group.label)}
<div>
<p class="mb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{group.label}
</p>
<div class="space-y-1.5">
{#each group.events as evt (evt.summary + evt.start)}
<div class="flex items-start gap-2 rounded-lg px-2 py-1.5 transition-colors hover:bg-muted/50">
<!-- Color dot -->
<span
class="mt-1.5 inline-block h-2 w-2 flex-shrink-0 rounded-full"
style="background-color: {evt.calendarColor || 'hsl(var(--primary))'}"
></span>
<div class="min-w-0 flex-1">
<p class="text-sm leading-tight text-foreground">{evt.summary}</p>
<div class="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5">
<span class="flex items-center gap-1 text-xs text-muted-foreground">
<Clock class="h-3 w-3" />
{formatTimeRange(evt.start, evt.end)}
</span>
{#if evt.location}
<span class="flex items-center gap-1 text-xs text-muted-foreground">
<MapPin class="h-3 w-3" />
<span class="truncate">{evt.location}</span>
</span>
{/if}
</div>
</div>
</div>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,226 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Maximize2, X, AlertCircle } from 'lucide-svelte';
import type { CameraWidgetConfig } from '$lib/types/widget.js';
interface Props {
config: CameraWidgetConfig;
}
let { config }: Props = $props();
let imgSrc = $state('');
let loading = $state(true);
let error = $state(false);
let fullscreen = $state(false);
let videoEl: HTMLVideoElement | null = $state(null);
const streamType = $derived(config.type ?? 'image');
const refreshMs = $derived((config.refreshInterval ?? 10) * 1000);
const aspectRatio = $derived(config.aspectRatio ?? '16/9');
// For snapshot mode, fetch through our proxy
function buildProxyUrl(): string {
const params = new URLSearchParams({ streamUrl: config.streamUrl });
return `/api/widgets/camera?${params}&t=${Date.now()}`;
}
async function fetchSnapshot() {
error = false;
try {
const url = buildProxyUrl();
// Pre-validate the response
const res = await fetch(url);
if (res.ok) {
const blob = await res.blob();
if (imgSrc) URL.revokeObjectURL(imgSrc);
imgSrc = URL.createObjectURL(blob);
} else {
error = true;
}
} catch {
error = true;
} finally {
loading = false;
}
}
onMount(() => {
if (streamType === 'image') {
fetchSnapshot();
} else if (streamType === 'mjpeg') {
// MJPEG streams directly via img src
imgSrc = config.streamUrl;
loading = false;
} else if (streamType === 'hls') {
loading = false;
// HLS setup via $effect below
}
return () => {
if (imgSrc && streamType === 'image') {
URL.revokeObjectURL(imgSrc);
}
};
});
// Auto-refresh for snapshot mode
$effect(() => {
if (streamType !== 'image') return;
const interval = setInterval(fetchSnapshot, refreshMs);
return () => clearInterval(interval);
});
// HLS.js lazy loading
$effect(() => {
if (streamType !== 'hls' || !videoEl) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let hls: any = null;
(async () => {
try {
const { default: Hls } = await import('hls.js');
if (Hls.isSupported()) {
hls = new Hls();
hls.loadSource(config.streamUrl);
hls.attachMedia(videoEl!);
} else if (videoEl!.canPlayType('application/vnd.apple.mpegurl')) {
videoEl!.src = config.streamUrl;
} else {
error = true;
}
} catch {
// HLS.js not available — try native
if (videoEl!.canPlayType('application/vnd.apple.mpegurl')) {
videoEl!.src = config.streamUrl;
} else {
error = true;
}
}
})();
return () => {
if (hls) {
hls.destroy();
}
};
});
function handleImgError() {
error = true;
loading = false;
}
function handleImgLoad() {
loading = false;
error = false;
}
function openFullscreen() {
fullscreen = true;
}
function closeFullscreen() {
fullscreen = false;
}
</script>
<div class="flex h-full flex-col rounded-xl border border-border bg-card overflow-hidden">
<!-- Stream view -->
<div
class="relative w-full bg-black"
style="aspect-ratio: {aspectRatio}"
>
{#if loading}
<div class="absolute inset-0 flex items-center justify-center">
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<svg
class="h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Loading...
</div>
</div>
{/if}
{#if error}
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-muted/80">
<AlertCircle class="h-8 w-8 text-muted-foreground" />
<span class="text-xs text-muted-foreground">Stream unavailable</span>
</div>
{/if}
{#if streamType === 'hls'}
<video
bind:this={videoEl}
autoplay
muted
playsinline
class="h-full w-full object-contain"
></video>
{:else}
<img
src={imgSrc}
alt="Camera stream"
class="h-full w-full object-contain {loading ? 'opacity-0' : 'opacity-100'} transition-opacity"
onload={handleImgLoad}
onerror={handleImgError}
/>
{/if}
<!-- Fullscreen button overlay -->
{#if !error}
<button
type="button"
onclick={openFullscreen}
class="absolute bottom-2 right-2 rounded-md bg-black/50 p-1.5 text-white/80 transition-colors hover:bg-black/70 hover:text-white"
title="Fullscreen"
>
<Maximize2 class="h-4 w-4" />
</button>
{/if}
</div>
</div>
<!-- Fullscreen modal -->
{#if fullscreen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/90"
onclick={closeFullscreen}
onkeydown={(e) => e.key === 'Escape' && closeFullscreen()}
>
<button
type="button"
onclick={closeFullscreen}
class="absolute right-4 top-4 rounded-md p-2 text-white/80 transition-colors hover:text-white"
>
<X class="h-6 w-6" />
</button>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="max-h-[90vh] max-w-[90vw]" onclick={(e) => e.stopPropagation()}>
{#if streamType === 'hls'}
<video
src={config.streamUrl}
autoplay
muted
playsinline
controls
class="max-h-[90vh] max-w-[90vw] object-contain"
></video>
{:else}
<img
src={imgSrc}
alt="Camera stream fullscreen"
class="max-h-[90vh] max-w-[90vw] object-contain"
/>
{/if}
</div>
</div>
{/if}
@@ -0,0 +1,182 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Cloud, Sun, CloudRain, CloudSnow, CloudLightning, CloudDrizzle, Wind, Thermometer } from 'lucide-svelte';
import type { ClockWeatherWidgetConfig } from '$lib/types/widget.js';
interface Props {
config: ClockWeatherWidgetConfig;
}
let { config }: Props = $props();
let now = $state(new Date());
let weatherData: { temp: number; condition: string; location?: string } | null = $state(null);
let weatherError = $state(false);
let weatherLoading = $state(false);
const clockStyle = $derived(config.clockStyle ?? 'digital');
const showWeather = $derived(config.showWeather ?? false);
const timeFormatter = $derived(
new Intl.DateTimeFormat('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: clockStyle !== '24h',
timeZone: config.timezone || undefined
})
);
const dateFormatter = $derived(
new Intl.DateTimeFormat('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
timeZone: config.timezone || undefined
})
);
const timeStr = $derived(timeFormatter.format(now));
const dateStr = $derived(dateFormatter.format(now));
// Analog clock hand angles
const hours = $derived.by(() => {
const d = new Date(now.toLocaleString('en-US', { timeZone: config.timezone || undefined }));
return d.getHours() % 12;
});
const minutes = $derived.by(() => {
const d = new Date(now.toLocaleString('en-US', { timeZone: config.timezone || undefined }));
return d.getMinutes();
});
const seconds = $derived.by(() => {
const d = new Date(now.toLocaleString('en-US', { timeZone: config.timezone || undefined }));
return d.getSeconds();
});
const hourAngle = $derived((hours + minutes / 60) * 30);
const minuteAngle = $derived((minutes + seconds / 60) * 6);
const secondAngle = $derived(seconds * 6);
async function fetchWeather() {
if (!showWeather || !config.latitude || !config.longitude) return;
weatherLoading = true;
weatherError = false;
try {
const res = await fetch(
`/api/widgets/weather?lat=${config.latitude}&lng=${config.longitude}`
);
if (res.ok) {
const json = await res.json();
if (json.success && json.data) {
weatherData = json.data;
}
} else {
weatherError = true;
}
} catch {
weatherError = true;
} finally {
weatherLoading = false;
}
}
onMount(() => {
fetchWeather();
});
// Tick clock every second
$effect(() => {
const interval = setInterval(() => {
now = new Date();
}, 1000);
return () => clearInterval(interval);
});
// Refresh weather every 30 minutes
$effect(() => {
if (!showWeather) return;
const interval = setInterval(fetchWeather, 30 * 60 * 1000);
return () => clearInterval(interval);
});
function getWeatherIcon(condition: string) {
const c = condition.toLowerCase();
if (c.includes('rain')) return CloudRain;
if (c.includes('drizzle')) return CloudDrizzle;
if (c.includes('snow')) return CloudSnow;
if (c.includes('thunder') || c.includes('lightning')) return CloudLightning;
if (c.includes('wind')) return Wind;
if (c.includes('cloud') || c.includes('overcast')) return Cloud;
return Sun;
}
</script>
<div class="flex h-full flex-col items-center justify-center rounded-xl border border-border bg-card p-4">
{#if clockStyle === 'analog'}
<!-- Analog clock face -->
<svg viewBox="0 0 100 100" class="h-32 w-32">
<!-- Clock face -->
<circle cx="50" cy="50" r="48" fill="none" stroke="currentColor" stroke-width="1.5" class="text-border" />
<!-- Hour markers -->
<!-- eslint-disable-next-line @typescript-eslint/no-unused-vars -->
{#each {length: 12} as _, i (i)}
{@const angle = (i * 30 * Math.PI) / 180}
{@const x1 = 50 + 42 * Math.sin(angle)}
{@const y1 = 50 - 42 * Math.cos(angle)}
{@const x2 = 50 + 46 * Math.sin(angle)}
{@const y2 = 50 - 46 * Math.cos(angle)}
<line {x1} {y1} {x2} {y2} stroke="currentColor" stroke-width={i % 3 === 0 ? '2' : '1'} class="text-foreground" />
{/each}
<!-- Hour hand -->
<line
x1="50" y1="50"
x2={50 + 24 * Math.sin((hourAngle * Math.PI) / 180)}
y2={50 - 24 * Math.cos((hourAngle * Math.PI) / 180)}
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" class="text-foreground"
/>
<!-- Minute hand -->
<line
x1="50" y1="50"
x2={50 + 34 * Math.sin((minuteAngle * Math.PI) / 180)}
y2={50 - 34 * Math.cos((minuteAngle * Math.PI) / 180)}
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" class="text-foreground"
/>
<!-- Second hand -->
<line
x1="50" y1="50"
x2={50 + 38 * Math.sin((secondAngle * Math.PI) / 180)}
y2={50 - 38 * Math.cos((secondAngle * Math.PI) / 180)}
stroke="currentColor" stroke-width="0.8" stroke-linecap="round" class="text-primary"
/>
<!-- Center dot -->
<circle cx="50" cy="50" r="2" fill="currentColor" class="text-primary" />
</svg>
<p class="mt-2 text-xs text-muted-foreground">{dateStr}</p>
{:else}
<!-- Digital clock -->
<p class="font-mono text-4xl font-bold tabular-nums text-foreground">{timeStr}</p>
<p class="mt-1 text-sm text-muted-foreground">{dateStr}</p>
{#if config.timezone}
<p class="mt-0.5 text-xs text-muted-foreground/70">{config.timezone.replace(/_/g, ' ')}</p>
{/if}
{/if}
<!-- Weather section -->
{#if showWeather}
<div class="mt-3 flex items-center gap-2 border-t border-border pt-3">
{#if weatherLoading}
<div class="h-5 w-20 animate-pulse rounded bg-muted"></div>
{:else if weatherError}
<span class="text-xs text-muted-foreground">Weather unavailable</span>
{:else if weatherData}
{@const WeatherIcon = getWeatherIcon(weatherData.condition)}
<WeatherIcon class="h-5 w-5 text-muted-foreground" />
<span class="text-lg font-semibold text-foreground">{Math.round(weatherData.temp)}°</span>
<span class="text-xs text-muted-foreground">{weatherData.condition}</span>
{:else}
<Thermometer class="h-4 w-4 text-muted-foreground" />
<span class="text-xs text-muted-foreground">No weather data</span>
{/if}
</div>
{/if}
</div>
@@ -0,0 +1,68 @@
<script lang="ts">
import { ExternalLink, ChevronDown, Link } from 'lucide-svelte';
import { slide } from 'svelte/transition';
import type { LinkGroupWidgetConfig } from '$lib/types/widget.js';
interface Props {
config: LinkGroupWidgetConfig;
}
let { config }: Props = $props();
let collapsed = $state(false);
const isCollapsible = $derived(config.collapsible ?? false);
const links = $derived(config.links ?? []);
</script>
<div class="flex h-full flex-col rounded-xl border border-border bg-card p-4">
<!-- Header -->
{#if isCollapsible}
<button
type="button"
onclick={() => (collapsed = !collapsed)}
class="mb-2 flex w-full items-center justify-between text-left"
>
<div class="flex items-center gap-2">
<Link class="h-4 w-4 text-muted-foreground" />
<span class="text-sm font-medium text-foreground">Links</span>
<span class="text-xs text-muted-foreground">({links.length})</span>
</div>
<ChevronDown
class="h-4 w-4 text-muted-foreground transition-transform {collapsed ? '-rotate-90' : ''}"
/>
</button>
{:else}
<div class="mb-2 flex items-center gap-2">
<Link class="h-4 w-4 text-muted-foreground" />
<span class="text-sm font-medium text-foreground">Links</span>
</div>
{/if}
<!-- Links list -->
{#if !collapsed}
<div transition:slide={{ duration: 200 }}>
{#if links.length === 0}
<p class="text-xs text-muted-foreground">No links configured</p>
{:else}
<div class="space-y-0.5">
{#each links as link (link.url + link.label)}
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm text-foreground transition-colors hover:bg-muted/50 hover:text-primary"
>
{#if link.icon}
<span class="flex-shrink-0 text-base">{link.icon}</span>
{:else}
<ExternalLink class="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
{/if}
<span class="min-w-0 flex-1 truncate">{link.label}</span>
</a>
{/each}
</div>
{/if}
</div>
{/if}
</div>
@@ -0,0 +1,101 @@
<script lang="ts">
import { marked } from 'marked';
import DOMPurify from 'isomorphic-dompurify';
import { Pencil, Eye } from 'lucide-svelte';
import type { MarkdownWidgetConfig } from '$lib/types/widget.js';
interface Props {
config: MarkdownWidgetConfig;
widgetId?: string;
}
let { config, widgetId }: Props = $props();
let editMode = $state(false);
let editContent = $state(config.content ?? '');
let saving = $state(false);
marked.setOptions({
breaks: true,
gfm: true
});
const renderedHtml = $derived.by(() => {
const source = editMode ? editContent : (config.content ?? '');
const raw = marked.parse(source, { async: false }) as string;
return DOMPurify.sanitize(raw);
});
async function saveContent() {
if (!widgetId) return;
saving = true;
try {
const newConfig = { ...config, content: editContent };
await fetch(`/api/widgets/${widgetId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config: JSON.stringify(newConfig) })
});
} catch {
// Silently fail — user can retry
} finally {
saving = false;
}
}
function toggleEdit() {
if (editMode) {
saveContent();
} else {
editContent = config.content ?? '';
}
editMode = !editMode;
}
</script>
<div class="flex h-full flex-col rounded-xl border border-border bg-card">
<!-- Toolbar -->
<div class="flex items-center justify-end border-b border-border px-3 py-1.5">
<button
type="button"
onclick={toggleEdit}
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
disabled={saving}
>
{#if editMode}
<Eye class="h-3.5 w-3.5" />
<span>{saving ? 'Saving...' : 'Preview'}</span>
{:else}
<Pencil class="h-3.5 w-3.5" />
<span>Edit</span>
{/if}
</button>
</div>
{#if editMode}
<!-- Split pane: editor + preview -->
<div class="flex flex-1 divide-x divide-border overflow-hidden">
<div class="flex-1 overflow-hidden">
<textarea
bind:value={editContent}
class="h-full w-full resize-none border-0 bg-background p-3 font-mono text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
placeholder="Write markdown here..."
></textarea>
</div>
<div class="flex-1 overflow-auto p-3">
<div class="prose prose-sm prose-invert max-w-none text-foreground">
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized with DOMPurify -->
{@html renderedHtml}
</div>
</div>
</div>
{:else}
<!-- View mode -->
<div class="flex-1 overflow-auto p-4">
<div class="prose prose-sm prose-invert max-w-none text-foreground">
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized with DOMPurify -->
{@html renderedHtml}
</div>
</div>
{/if}
</div>
@@ -0,0 +1,108 @@
<script lang="ts">
import { onMount } from 'svelte';
import { TrendingUp, TrendingDown, Minus } from 'lucide-svelte';
import type { MetricWidgetConfig } from '$lib/types/widget.js';
interface Props {
config: MetricWidgetConfig;
}
let { config }: Props = $props();
let currentValue: number | null = $state(null);
let trend: 'up' | 'down' | 'flat' | null = $state(null);
let loading = $state(true);
let error = $state(false);
const refreshMs = $derived((config.refreshInterval ?? 60) * 1000);
function formatNumber(n: number): string {
if (Math.abs(n) >= 1_000_000) {
return (n / 1_000_000).toFixed(1) + 'M';
}
if (Math.abs(n) >= 1_000) {
return (n / 1_000).toFixed(1) + 'K';
}
// Use locale formatting for smaller numbers
return n.toLocaleString('en-US', { maximumFractionDigits: 2 });
}
async function fetchMetric() {
error = false;
try {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const params = new URLSearchParams({ source: config.source });
if (config.value) params.set('value', config.value);
if (config.url) params.set('url', config.url);
if (config.jsonPath) params.set('jsonPath', config.jsonPath);
if (config.query) params.set('query', config.query);
const res = await fetch(`/api/widgets/metric?${params}`);
if (res.ok) {
const json = await res.json();
if (json.success && json.data) {
currentValue = json.data.value;
trend = json.data.trend ?? null;
}
} else {
error = true;
}
} catch {
error = true;
} finally {
loading = false;
}
}
onMount(() => {
fetchMetric();
});
$effect(() => {
const interval = setInterval(fetchMetric, refreshMs);
return () => clearInterval(interval);
});
const trendColor = $derived.by(() => {
if (trend === 'up') return 'text-green-500';
if (trend === 'down') return 'text-red-500';
return 'text-muted-foreground';
});
</script>
<div class="flex h-full flex-col items-center justify-center rounded-xl border border-border bg-card p-4">
{#if loading}
<div class="flex flex-col items-center gap-2">
<div class="h-10 w-24 animate-pulse rounded bg-muted"></div>
<div class="h-4 w-16 animate-pulse rounded bg-muted"></div>
</div>
{:else if error}
<span class="text-xs text-muted-foreground">Failed to load metric</span>
{:else if currentValue !== null}
<!-- Trend arrow -->
<div class="mb-1 {trendColor}">
{#if trend === 'up'}
<TrendingUp class="h-5 w-5" />
{:else if trend === 'down'}
<TrendingDown class="h-5 w-5" />
{:else}
<Minus class="h-5 w-5" />
{/if}
</div>
<!-- Big number -->
<div class="flex items-baseline gap-1">
<span class="text-4xl font-bold tabular-nums text-foreground">
{formatNumber(currentValue)}
</span>
{#if config.unit}
<span class="text-lg text-muted-foreground">{config.unit}</span>
{/if}
</div>
<!-- Label -->
<p class="mt-1 text-sm text-muted-foreground">{config.label}</p>
{:else}
<span class="text-xs text-muted-foreground">No data</span>
{/if}
</div>
@@ -0,0 +1,148 @@
<script lang="ts">
import { onMount } from 'svelte';
import { ExternalLink, ChevronDown, Rss } from 'lucide-svelte';
import { slide } from 'svelte/transition';
import type { RssWidgetConfig } from '$lib/types/widget.js';
interface Props {
config: RssWidgetConfig;
}
let { config }: Props = $props();
interface FeedItem {
title: string;
link: string;
pubDate: string;
summary: string;
}
let items: FeedItem[] = $state([]);
let loading = $state(true);
let error = $state(false);
let expandedIndex: number | null = $state(null);
const showSummary = $derived(config.showSummary ?? true);
function relativeTime(dateStr: string): string {
try {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return 'just now';
if (diffMin < 60) return `${diffMin}m ago`;
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return `${diffHr}h ago`;
const diffDays = Math.floor(diffHr / 24);
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
} catch {
return '';
}
}
async function fetchFeed() {
error = false;
try {
const params = new URLSearchParams({
feedUrl: config.feedUrl,
maxItems: String(config.maxItems ?? 10)
});
const res = await fetch(`/api/widgets/rss?${params}`);
if (res.ok) {
const json = await res.json();
if (json.success && json.data) {
items = json.data;
}
} else {
error = true;
}
} catch {
error = true;
} finally {
loading = false;
}
}
onMount(() => {
fetchFeed();
});
// Refresh every 15 minutes
$effect(() => {
const interval = setInterval(fetchFeed, 15 * 60 * 1000);
return () => clearInterval(interval);
});
function toggleExpand(index: number) {
expandedIndex = expandedIndex === index ? null : index;
}
</script>
<div class="flex h-full flex-col rounded-xl border border-border bg-card p-4">
<div class="mb-3 flex items-center gap-2">
<Rss class="h-4 w-4 text-muted-foreground" />
<span class="text-sm font-medium text-foreground">RSS Feed</span>
</div>
{#if loading}
<div class="space-y-3">
{#each [1, 2, 3, 4] as _n (_n)}
<div class="space-y-1">
<div class="h-4 w-3/4 animate-pulse rounded bg-muted"></div>
<div class="h-3 w-1/4 animate-pulse rounded bg-muted"></div>
</div>
{/each}
</div>
{:else if error}
<div class="flex flex-1 items-center justify-center">
<span class="text-xs text-muted-foreground">Failed to load feed</span>
</div>
{:else if items.length === 0}
<div class="flex flex-1 items-center justify-center">
<span class="text-xs text-muted-foreground">No feed items</span>
</div>
{:else}
<div class="flex-1 space-y-1 overflow-y-auto">
{#each items as item, i (item.link + i)}
<div class="rounded-lg px-2 py-1.5 transition-colors hover:bg-muted/50">
<div class="flex items-start justify-between gap-2">
<button
type="button"
class="flex flex-1 items-start gap-1.5 text-left"
onclick={() => toggleExpand(i)}
>
{#if showSummary && item.summary}
<ChevronDown
class="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-muted-foreground transition-transform {expandedIndex === i ? 'rotate-180' : ''}"
/>
{/if}
<div class="min-w-0 flex-1">
<p class="text-sm leading-tight text-foreground line-clamp-2">{item.title}</p>
<p class="mt-0.5 text-xs text-muted-foreground">{relativeTime(item.pubDate)}</p>
</div>
</button>
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
class="mt-0.5 flex-shrink-0 text-muted-foreground transition-colors hover:text-primary"
title="Open in new tab"
>
<ExternalLink class="h-3.5 w-3.5" />
</a>
</div>
{#if showSummary && expandedIndex === i && item.summary}
<div transition:slide={{ duration: 200 }}>
<p class="mt-1.5 pl-5 text-xs leading-relaxed text-muted-foreground">
{item.summary}
</p>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,142 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { SystemStatsWidgetConfig } from '$lib/types/widget.js';
interface Props {
config: SystemStatsWidgetConfig;
}
let { config }: Props = $props();
interface MetricData {
metric: string;
value: number;
unit: string;
}
let metrics: MetricData[] = $state([]);
let loading = $state(true);
let error = $state(false);
const refreshMs = $derived((config.refreshInterval ?? 30) * 1000);
function thresholdColor(value: number): string {
if (value >= 85) return 'text-red-500';
if (value >= 60) return 'text-yellow-500';
return 'text-green-500';
}
function thresholdStroke(value: number): string {
if (value >= 85) return 'stroke-red-500';
if (value >= 60) return 'stroke-yellow-500';
return 'stroke-green-500';
}
function thresholdTrack(_value: number): string {
return 'stroke-muted';
}
async function fetchStats() {
error = false;
try {
const params = new URLSearchParams({
sourceUrl: config.sourceUrl,
sourceType: config.sourceType
});
const res = await fetch(`/api/widgets/system-stats?${params}`);
if (res.ok) {
const json = await res.json();
if (json.success && json.data) {
// Filter to only configured metrics if specified
const allMetrics: MetricData[] = json.data;
metrics =
config.metrics.length > 0
? allMetrics.filter((m) =>
config.metrics.some((cm) => m.metric.toLowerCase().includes(cm.toLowerCase()))
)
: allMetrics;
}
} else {
error = true;
}
} catch {
error = true;
} finally {
loading = false;
}
}
onMount(() => {
fetchStats();
});
$effect(() => {
const interval = setInterval(fetchStats, refreshMs);
return () => clearInterval(interval);
});
// SVG donut chart constants
const RADIUS = 36;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
</script>
<div class="flex h-full flex-col rounded-xl border border-border bg-card p-4">
<span class="mb-3 text-sm font-medium text-foreground">System Stats</span>
{#if loading}
<div class="flex flex-1 items-center justify-center gap-4">
{#each [1, 2, 3] as _n (_n)}
<div class="flex flex-col items-center gap-2">
<div class="h-20 w-20 animate-pulse rounded-full bg-muted"></div>
<div class="h-3 w-12 animate-pulse rounded bg-muted"></div>
</div>
{/each}
</div>
{:else if error}
<div class="flex flex-1 items-center justify-center">
<span class="text-xs text-muted-foreground">Failed to load stats</span>
</div>
{:else if metrics.length === 0}
<div class="flex flex-1 items-center justify-center">
<span class="text-xs text-muted-foreground">No metrics available</span>
</div>
{:else}
<div class="flex flex-1 flex-wrap items-center justify-center gap-4">
{#each metrics as m (m.metric)}
{@const pct = Math.min(100, Math.max(0, m.value))}
{@const dashOffset = CIRCUMFERENCE - (pct / 100) * CIRCUMFERENCE}
<div class="flex flex-col items-center gap-1">
<div class="relative h-20 w-20">
<svg viewBox="0 0 80 80" class="h-full w-full -rotate-90">
<!-- Track -->
<circle
cx="40" cy="40" r={RADIUS}
fill="none"
stroke-width="6"
class={thresholdTrack(pct)}
/>
<!-- Value arc -->
<circle
cx="40" cy="40" r={RADIUS}
fill="none"
stroke-width="6"
stroke-linecap="round"
stroke-dasharray={CIRCUMFERENCE}
stroke-dashoffset={dashOffset}
class={thresholdStroke(pct)}
style="transition: stroke-dashoffset 0.5s ease"
/>
</svg>
<!-- Center text -->
<div class="absolute inset-0 flex items-center justify-center">
<span class="text-sm font-bold {thresholdColor(pct)}">
{Math.round(pct)}{m.unit}
</span>
</div>
</div>
<span class="text-xs text-muted-foreground capitalize">{m.metric}</span>
</div>
{/each}
</div>
{/if}
</div>
@@ -35,12 +35,69 @@
let statusLabel = $state('');
let statusAppIds = $state<string[]>([]);
// Clock/Weather fields
let clockTimezone = $state('');
let clockStyle = $state<'digital' | 'analog' | '24h'>('digital');
let clockShowWeather = $state(false);
let clockLatitude = $state('');
let clockLongitude = $state('');
// System Stats fields
let sysStatsSourceUrl = $state('');
let sysStatsSourceType = $state<'glances' | 'prometheus' | 'custom'>('custom');
let sysStatsMetrics = $state<string[]>(['cpu', 'ram', 'disk']);
let sysStatsRefreshInterval = $state(30);
// RSS fields
let rssFeedUrl = $state('');
let rssMaxItems = $state(10);
let rssShowSummary = $state(true);
// Calendar fields
let calendarUrls = $state<Array<{ url: string; color: string; label: string }>>([
{ url: '', color: '#6366f1', label: '' }
]);
let calendarDaysAhead = $state(7);
// Markdown fields
let markdownContent = $state('');
// Metric fields
let metricLabel = $state('');
let metricSource = $state<'static' | 'json' | 'prometheus'>('static');
let metricValue = $state('');
let metricUrl = $state('');
let metricJsonPath = $state('');
let metricQuery = $state('');
let metricUnit = $state('');
let metricRefreshInterval = $state(60);
// Link Group fields
let linkGroupLinks = $state<Array<{ label: string; url: string; icon: string }>>([
{ label: '', url: '', icon: '' }
]);
let linkGroupCollapsible = $state(false);
// Camera fields
let cameraStreamUrl = $state('');
let cameraType = $state<'image' | 'mjpeg' | 'hls'>('image');
let cameraRefreshInterval = $state(10);
let cameraAspectRatio = $state('16/9');
const widgetTypeItems: IconGridItem[] = [
{ value: 'app', icon: '🖥️', label: 'App' },
{ value: 'bookmark', icon: '🔖', label: 'Bookmark' },
{ value: 'note', icon: '📝', label: 'Note' },
{ value: 'embed', icon: '🧩', label: 'Embed' },
{ value: 'status', icon: '📊', label: 'Status' }
{ value: 'status', icon: '📊', label: 'Status' },
{ value: 'clock', icon: '🕐', label: 'Clock' },
{ value: 'system_stats', icon: '💻', label: 'System' },
{ value: 'rss', icon: '📡', label: 'RSS' },
{ value: 'calendar', icon: '📅', label: 'Calendar' },
{ value: 'markdown', icon: '📄', label: 'Markdown' },
{ value: 'metric', icon: '📈', label: 'Metric' },
{ value: 'link_group', icon: '🔗', label: 'Links' },
{ value: 'camera', icon: '📷', label: 'Camera' }
];
const noteFormatItems: IconGridItem[] = [
@@ -48,6 +105,18 @@
{ value: 'text', icon: '📄', label: 'Plain Text' }
];
const clockStyleItems: IconGridItem[] = [
{ value: 'digital', icon: '🔢', label: 'Digital' },
{ value: 'analog', icon: '🕐', label: 'Analog' },
{ value: '24h', icon: '⏰', label: '24h' }
];
const metricSourceItems: IconGridItem[] = [
{ value: 'static', icon: '📌', label: 'Static' },
{ value: 'json', icon: '🔗', label: 'JSON' },
{ value: 'prometheus', icon: '📊', label: 'Prometheus' }
];
const appPickerItems: EntityPickerItem[] = $derived(
apps.map((app) => ({
value: app.id,
@@ -68,6 +137,35 @@
embedHeight = 300;
statusLabel = '';
statusAppIds = [];
clockTimezone = '';
clockStyle = 'digital';
clockShowWeather = false;
clockLatitude = '';
clockLongitude = '';
sysStatsSourceUrl = '';
sysStatsSourceType = 'custom';
sysStatsMetrics = ['cpu', 'ram', 'disk'];
sysStatsRefreshInterval = 30;
rssFeedUrl = '';
rssMaxItems = 10;
rssShowSummary = true;
calendarUrls = [{ url: '', color: '#6366f1', label: '' }];
calendarDaysAhead = 7;
markdownContent = '';
metricLabel = '';
metricSource = 'static';
metricValue = '';
metricUrl = '';
metricJsonPath = '';
metricQuery = '';
metricUnit = '';
metricRefreshInterval = 60;
linkGroupLinks = [{ label: '', url: '', icon: '' }];
linkGroupCollapsible = false;
cameraStreamUrl = '';
cameraType = 'image';
cameraRefreshInterval = 10;
cameraAspectRatio = '16/9';
}
function handleSubmitWidget() {
@@ -100,6 +198,73 @@
widgetData.appIds = statusAppIds;
if (statusLabel) widgetData.label = statusLabel;
break;
case 'clock':
if (clockTimezone) widgetData.timezone = clockTimezone;
widgetData.clockStyle = clockStyle;
widgetData.showWeather = clockShowWeather;
if (clockShowWeather && clockLatitude && clockLongitude) {
widgetData.latitude = parseFloat(clockLatitude);
widgetData.longitude = parseFloat(clockLongitude);
}
break;
case 'system_stats':
if (!sysStatsSourceUrl) return;
widgetData.sourceUrl = sysStatsSourceUrl;
widgetData.sourceType = sysStatsSourceType;
widgetData.metrics = sysStatsMetrics;
widgetData.refreshInterval = sysStatsRefreshInterval;
break;
case 'rss':
if (!rssFeedUrl) return;
widgetData.feedUrl = rssFeedUrl;
widgetData.maxItems = rssMaxItems;
widgetData.showSummary = rssShowSummary;
break;
case 'calendar': {
const validUrls = calendarUrls.filter((c) => c.url.trim() !== '');
if (validUrls.length === 0) return;
widgetData.icalUrls = validUrls;
widgetData.daysAhead = calendarDaysAhead;
break;
}
case 'markdown':
if (!markdownContent) return;
widgetData.content = markdownContent;
break;
case 'metric':
if (!metricLabel) return;
widgetData.label = metricLabel;
widgetData.source = metricSource;
if (metricSource === 'static') widgetData.value = metricValue;
if (metricSource === 'json') {
widgetData.url = metricUrl;
widgetData.jsonPath = metricJsonPath;
}
if (metricSource === 'prometheus') {
widgetData.url = metricUrl;
widgetData.query = metricQuery;
}
if (metricUnit) widgetData.unit = metricUnit;
widgetData.refreshInterval = metricRefreshInterval;
break;
case 'link_group': {
const validLinks = linkGroupLinks.filter((l) => l.label.trim() && l.url.trim());
if (validLinks.length === 0) return;
widgetData.links = validLinks.map((l) => ({
label: l.label,
url: l.url,
...(l.icon ? { icon: l.icon } : {})
}));
widgetData.collapsible = linkGroupCollapsible;
break;
}
case 'camera':
if (!cameraStreamUrl) return;
widgetData.streamUrl = cameraStreamUrl;
widgetData.type = cameraType;
widgetData.refreshInterval = cameraRefreshInterval;
widgetData.aspectRatio = cameraAspectRatio;
break;
default:
return;
}
@@ -115,6 +280,34 @@
statusAppIds = [...statusAppIds, appId];
}
}
function toggleSysStatsMetric(metric: string) {
if (sysStatsMetrics.includes(metric)) {
sysStatsMetrics = sysStatsMetrics.filter((m) => m !== metric);
} else {
sysStatsMetrics = [...sysStatsMetrics, metric];
}
}
function addCalendarUrl() {
calendarUrls = [...calendarUrls, { url: '', color: '#6366f1', label: '' }];
}
function removeCalendarUrl(index: number) {
calendarUrls = calendarUrls.filter((_, i) => i !== index);
}
function addLinkGroupLink() {
linkGroupLinks = [...linkGroupLinks, { label: '', url: '', icon: '' }];
}
function removeLinkGroupLink(index: number) {
linkGroupLinks = linkGroupLinks.filter((_, i) => i !== index);
}
// Input CSS class for reuse
const inputClass =
'w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30';
</script>
<div class="mb-3 rounded-lg border border-border bg-muted/50 p-3">
@@ -152,7 +345,7 @@
type="url"
bind:value={bookmarkUrl}
placeholder="https://example.com"
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
class={inputClass}
required
/>
</div>
@@ -163,7 +356,7 @@
type="text"
bind:value={bookmarkLabel}
placeholder="My Bookmark"
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
class={inputClass}
required
/>
</div>
@@ -174,7 +367,7 @@
type="text"
bind:value={bookmarkIcon}
placeholder="e.g. an emoji or icon name"
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
class={inputClass}
/>
</div>
<div>
@@ -184,7 +377,7 @@
type="text"
bind:value={bookmarkDescription}
placeholder="A short description"
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
class={inputClass}
/>
</div>
</div>
@@ -205,7 +398,7 @@
bind:value={noteContent}
rows="4"
placeholder="Write your note here..."
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
class={inputClass}
required
></textarea>
</div>
@@ -219,7 +412,7 @@
type="url"
bind:value={embedUrl}
placeholder="https://example.com/embed"
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
class={inputClass}
required
/>
</div>
@@ -231,7 +424,7 @@
bind:value={embedHeight}
min="100"
max="2000"
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
class={inputClass}
/>
</div>
</div>
@@ -244,7 +437,7 @@
type="text"
bind:value={statusLabel}
placeholder="e.g. Production Services"
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
class={inputClass}
/>
</div>
<div>
@@ -267,6 +460,463 @@
{/if}
</div>
</div>
<!-- === NEW WIDGET TYPE FORMS === -->
{:else if selectedWidgetType === 'clock'}
<div class="space-y-3">
<div>
<label class="mb-1 block text-sm font-medium text-foreground">Clock Style</label>
<IconGrid
items={clockStyleItems}
bind:value={clockStyle}
columns={3}
/>
</div>
<div>
<label for="clock-tz-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Timezone (optional)</label>
<input
id="clock-tz-{sectionId}"
type="text"
bind:value={clockTimezone}
placeholder="e.g. America/New_York"
class={inputClass}
/>
<p class="mt-0.5 text-xs text-muted-foreground">Leave empty for local time</p>
</div>
<div>
<label class="flex items-center gap-2 text-sm font-medium text-foreground">
<input
type="checkbox"
bind:checked={clockShowWeather}
class="h-4 w-4 rounded border-input accent-primary"
/>
Show Weather
</label>
</div>
{#if clockShowWeather}
<div class="grid gap-3 sm:grid-cols-2">
<div>
<label for="clock-lat-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Latitude</label>
<input
id="clock-lat-{sectionId}"
type="text"
bind:value={clockLatitude}
placeholder="e.g. 40.7128"
class={inputClass}
/>
</div>
<div>
<label for="clock-lng-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Longitude</label>
<input
id="clock-lng-{sectionId}"
type="text"
bind:value={clockLongitude}
placeholder="e.g. -74.0060"
class={inputClass}
/>
</div>
</div>
{/if}
</div>
{:else if selectedWidgetType === 'system_stats'}
<div class="space-y-3">
<div>
<label for="sys-url-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Source URL</label>
<input
id="sys-url-{sectionId}"
type="url"
bind:value={sysStatsSourceUrl}
placeholder="https://your-server:61208/api/3"
class={inputClass}
required
/>
</div>
<div>
<label for="sys-type-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Source Type</label>
<select
id="sys-type-{sectionId}"
bind:value={sysStatsSourceType}
class={inputClass}
>
<option value="glances">Glances</option>
<option value="prometheus">Prometheus</option>
<option value="custom">Custom JSON</option>
</select>
</div>
<div>
<span class="mb-1 block text-sm font-medium text-foreground">Metrics</span>
<div class="flex flex-wrap gap-2">
{#each ['cpu', 'ram', 'disk', 'swap', 'network'] as metric (metric)}
<label class="flex items-center gap-1.5 rounded-md border border-input px-2 py-1 text-sm text-foreground hover:bg-accent">
<input
type="checkbox"
checked={sysStatsMetrics.includes(metric)}
onchange={() => toggleSysStatsMetric(metric)}
class="h-3.5 w-3.5 rounded border-input accent-primary"
/>
<span class="capitalize">{metric}</span>
</label>
{/each}
</div>
</div>
<div>
<label for="sys-refresh-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
Refresh Interval: {sysStatsRefreshInterval}s
</label>
<input
id="sys-refresh-{sectionId}"
type="range"
bind:value={sysStatsRefreshInterval}
min="5"
max="300"
step="5"
class="w-full accent-primary"
/>
</div>
</div>
{:else if selectedWidgetType === 'rss'}
<div class="space-y-3">
<div>
<label for="rss-url-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Feed URL</label>
<input
id="rss-url-{sectionId}"
type="url"
bind:value={rssFeedUrl}
placeholder="https://example.com/feed.xml"
class={inputClass}
required
/>
</div>
<div>
<label for="rss-max-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
Max Items: {rssMaxItems}
</label>
<input
id="rss-max-{sectionId}"
type="range"
bind:value={rssMaxItems}
min="3"
max="30"
step="1"
class="w-full accent-primary"
/>
</div>
<div>
<label class="flex items-center gap-2 text-sm font-medium text-foreground">
<input
type="checkbox"
bind:checked={rssShowSummary}
class="h-4 w-4 rounded border-input accent-primary"
/>
Show Summaries
</label>
</div>
</div>
{:else if selectedWidgetType === 'calendar'}
<div class="space-y-3">
<div>
<span class="mb-1 block text-sm font-medium text-foreground">iCal URLs</span>
<div class="space-y-2">
{#each calendarUrls as _cal, i (i)}
<div class="flex items-start gap-2">
<div class="flex-1 space-y-1">
<input
type="url"
bind:value={calendarUrls[i].url}
placeholder="https://example.com/calendar.ics"
class={inputClass}
/>
<div class="flex gap-2">
<input
type="text"
bind:value={calendarUrls[i].label}
placeholder="Label (optional)"
class="{inputClass} flex-1"
/>
<input
type="color"
bind:value={calendarUrls[i].color}
class="h-9 w-9 cursor-pointer rounded-lg border border-input bg-background"
title="Calendar color"
/>
</div>
</div>
{#if calendarUrls.length > 1}
<button
type="button"
onclick={() => removeCalendarUrl(i)}
class="mt-2 text-xs text-muted-foreground transition-colors hover:text-destructive"
>
Remove
</button>
{/if}
</div>
{/each}
</div>
<button
type="button"
onclick={addCalendarUrl}
class="mt-1 text-xs text-primary transition-colors hover:text-primary/80"
>
+ Add calendar
</button>
</div>
<div>
<label for="cal-days-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
Days Ahead: {calendarDaysAhead}
</label>
<input
id="cal-days-{sectionId}"
type="range"
bind:value={calendarDaysAhead}
min="1"
max="30"
step="1"
class="w-full accent-primary"
/>
</div>
</div>
{:else if selectedWidgetType === 'markdown'}
<div class="space-y-3">
<div>
<label for="md-content-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Markdown Content</label>
<textarea
id="md-content-{sectionId}"
bind:value={markdownContent}
rows="8"
placeholder="# Hello World&#10;&#10;Write your markdown here..."
class="{inputClass} font-mono"
required
></textarea>
</div>
</div>
{:else if selectedWidgetType === 'metric'}
<div class="space-y-3">
<div>
<label for="metric-label-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Label</label>
<input
id="metric-label-{sectionId}"
type="text"
bind:value={metricLabel}
placeholder="e.g. Active Users"
class={inputClass}
required
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-foreground">Source Type</label>
<IconGrid
items={metricSourceItems}
bind:value={metricSource}
columns={3}
/>
</div>
{#if metricSource === 'static'}
<div>
<label for="metric-val-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Value</label>
<input
id="metric-val-{sectionId}"
type="text"
bind:value={metricValue}
placeholder="e.g. 42"
class={inputClass}
/>
</div>
{:else if metricSource === 'json'}
<div class="grid gap-3 sm:grid-cols-2">
<div class="sm:col-span-2">
<label for="metric-url-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">JSON URL</label>
<input
id="metric-url-{sectionId}"
type="url"
bind:value={metricUrl}
placeholder="https://api.example.com/stats"
class={inputClass}
/>
</div>
<div class="sm:col-span-2">
<label for="metric-path-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">JSON Path</label>
<input
id="metric-path-{sectionId}"
type="text"
bind:value={metricJsonPath}
placeholder="e.g. data.count"
class={inputClass}
/>
</div>
</div>
{:else if metricSource === 'prometheus'}
<div class="grid gap-3 sm:grid-cols-2">
<div class="sm:col-span-2">
<label for="metric-prom-url-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Prometheus URL</label>
<input
id="metric-prom-url-{sectionId}"
type="url"
bind:value={metricUrl}
placeholder="https://prometheus.example.com"
class={inputClass}
/>
</div>
<div class="sm:col-span-2">
<label for="metric-query-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">PromQL Query</label>
<input
id="metric-query-{sectionId}"
type="text"
bind:value={metricQuery}
placeholder='e.g. sum(rate(http_requests_total[5m]))'
class={inputClass}
/>
</div>
</div>
{/if}
<div class="grid gap-3 sm:grid-cols-2">
<div>
<label for="metric-unit-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Unit (optional)</label>
<input
id="metric-unit-{sectionId}"
type="text"
bind:value={metricUnit}
placeholder="e.g. req/s, %, ms"
class={inputClass}
/>
</div>
<div>
<label for="metric-refresh-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
Refresh: {metricRefreshInterval}s
</label>
<input
id="metric-refresh-{sectionId}"
type="range"
bind:value={metricRefreshInterval}
min="10"
max="600"
step="10"
class="w-full accent-primary"
/>
</div>
</div>
</div>
{:else if selectedWidgetType === 'link_group'}
<div class="space-y-3">
<div>
<span class="mb-1 block text-sm font-medium text-foreground">Links</span>
<div class="space-y-2">
{#each linkGroupLinks as _link, i (i)}
<div class="flex items-start gap-2">
<div class="flex-1 grid gap-2 sm:grid-cols-3">
<input
type="text"
bind:value={linkGroupLinks[i].label}
placeholder="Label"
class={inputClass}
/>
<input
type="url"
bind:value={linkGroupLinks[i].url}
placeholder="https://..."
class={inputClass}
/>
<input
type="text"
bind:value={linkGroupLinks[i].icon}
placeholder="Icon (emoji)"
class={inputClass}
/>
</div>
{#if linkGroupLinks.length > 1}
<button
type="button"
onclick={() => removeLinkGroupLink(i)}
class="mt-2 text-xs text-muted-foreground transition-colors hover:text-destructive"
>
Remove
</button>
{/if}
</div>
{/each}
</div>
<button
type="button"
onclick={addLinkGroupLink}
class="mt-1 text-xs text-primary transition-colors hover:text-primary/80"
>
+ Add link
</button>
</div>
<div>
<label class="flex items-center gap-2 text-sm font-medium text-foreground">
<input
type="checkbox"
bind:checked={linkGroupCollapsible}
class="h-4 w-4 rounded border-input accent-primary"
/>
Collapsible
</label>
</div>
</div>
{:else if selectedWidgetType === 'camera'}
<div class="space-y-3">
<div>
<label for="cam-url-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Stream URL</label>
<input
id="cam-url-{sectionId}"
type="url"
bind:value={cameraStreamUrl}
placeholder="https://camera.example.com/stream"
class={inputClass}
required
/>
</div>
<div>
<label for="cam-type-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Stream Type</label>
<select
id="cam-type-{sectionId}"
bind:value={cameraType}
class={inputClass}
>
<option value="image">Snapshot (Image)</option>
<option value="mjpeg">MJPEG Stream</option>
<option value="hls">HLS Stream</option>
</select>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div>
<label for="cam-refresh-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
Refresh: {cameraRefreshInterval}s
</label>
<input
id="cam-refresh-{sectionId}"
type="range"
bind:value={cameraRefreshInterval}
min="1"
max="120"
step="1"
class="w-full accent-primary"
/>
</div>
<div>
<label for="cam-ratio-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Aspect Ratio</label>
<select
id="cam-ratio-{sectionId}"
bind:value={cameraAspectRatio}
class={inputClass}
>
<option value="16/9">16:9</option>
<option value="4/3">4:3</option>
<option value="1/1">1:1</option>
<option value="21/9">21:9</option>
</select>
</div>
</div>
</div>
{/if}
<div class="mt-3">
+30 -5
View File
@@ -2,6 +2,7 @@
import { t } from 'svelte-i18n';
import WidgetRenderer from './WidgetRenderer.svelte';
import WidgetContainer from './WidgetContainer.svelte';
import type { CardSize } from '$lib/utils/constants.js';
interface AppData {
id: string;
@@ -25,23 +26,47 @@
interface Props {
widgets: WidgetData[];
allApps?: AppData[];
cardSize?: CardSize;
}
let { widgets, allApps = [] }: Props = $props();
let { widgets, allApps = [], cardSize = 'medium' }: Props = $props();
// Widgets that should span full width
const fullWidthTypes = new Set(['note', 'embed', 'status']);
const fullWidthTypes = new Set(['note', 'embed', 'status', 'system_stats', 'rss', 'calendar', 'markdown', 'camera']);
// Grid column classes based on card size
const gridClass = $derived.by(() => {
switch (cardSize) {
case 'compact':
return 'grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6';
case 'large':
return 'grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3';
default:
return 'grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4';
}
});
const fullWidthClass = $derived.by(() => {
switch (cardSize) {
case 'compact':
return 'col-span-2 sm:col-span-3 md:col-span-4 lg:col-span-6';
case 'large':
return 'col-span-1 sm:col-span-2 lg:col-span-3';
default:
return 'col-span-2 sm:col-span-3 lg:col-span-4';
}
});
</script>
{#if widgets.length === 0}
<p class="text-sm text-muted-foreground">{$t('widget.no_widgets')}</p>
{:else}
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
<div class={gridClass}>
{#each widgets as widget (widget.id)}
{@const isFullWidth = fullWidthTypes.has(widget.type)}
<div class={isFullWidth ? 'col-span-2 sm:col-span-3 lg:col-span-4' : ''}>
<div class={isFullWidth ? fullWidthClass : ''}>
<WidgetContainer>
<WidgetRenderer {widget} {allApps} />
<WidgetRenderer {widget} {allApps} {cardSize} />
</WidgetContainer>
</div>
{/each}
@@ -5,6 +5,14 @@
import NoteWidget from './NoteWidget.svelte';
import EmbedWidget from './EmbedWidget.svelte';
import StatusWidget from './StatusWidget.svelte';
import ClockWeatherWidget from './ClockWeatherWidget.svelte';
import SystemStatsWidget from './SystemStatsWidget.svelte';
import RssFeedWidget from './RssFeedWidget.svelte';
import CalendarWidget from './CalendarWidget.svelte';
import MarkdownWidget from './MarkdownWidget.svelte';
import MetricWidget from './MetricWidget.svelte';
import LinkGroupWidget from './LinkGroupWidget.svelte';
import CameraStreamWidget from './CameraStreamWidget.svelte';
interface AppData {
id: string;
@@ -25,12 +33,15 @@
app: AppData | null;
}
import type { CardSize } from '$lib/utils/constants.js';
interface Props {
widget: WidgetData;
allApps?: AppData[];
cardSize?: CardSize;
}
let { widget, allApps = [] }: Props = $props();
let { widget, allApps = [], cardSize = 'medium' }: Props = $props();
const parsedConfig = $derived.by(() => {
try {
@@ -42,7 +53,7 @@
</script>
{#if widget.type === 'app' && widget.app}
<AppWidget app={widget.app} />
<AppWidget app={widget.app} {cardSize} />
{:else if widget.type === 'bookmark'}
<BookmarkWidget config={parsedConfig} />
{:else if widget.type === 'note'}
@@ -51,6 +62,60 @@
<EmbedWidget config={{ url: parsedConfig.url ?? '', height: parsedConfig.height ?? 300, sandbox: parsedConfig.sandbox }} />
{:else if widget.type === 'status'}
<StatusWidget config={{ appIds: parsedConfig.appIds ?? [], label: parsedConfig.label }} apps={allApps} />
{:else if widget.type === 'clock'}
<ClockWeatherWidget config={{
timezone: parsedConfig.timezone,
showWeather: parsedConfig.showWeather ?? false,
latitude: parsedConfig.latitude,
longitude: parsedConfig.longitude,
clockStyle: parsedConfig.clockStyle ?? 'digital'
}} />
{:else if widget.type === 'system_stats'}
<SystemStatsWidget config={{
sourceUrl: parsedConfig.sourceUrl ?? '',
sourceType: parsedConfig.sourceType ?? 'custom',
metrics: parsedConfig.metrics ?? [],
refreshInterval: parsedConfig.refreshInterval ?? 30
}} />
{:else if widget.type === 'rss'}
<RssFeedWidget config={{
feedUrl: parsedConfig.feedUrl ?? '',
maxItems: parsedConfig.maxItems ?? 10,
showSummary: parsedConfig.showSummary ?? true
}} />
{:else if widget.type === 'calendar'}
<CalendarWidget config={{
icalUrls: parsedConfig.icalUrls ?? [],
daysAhead: parsedConfig.daysAhead ?? 7
}} />
{:else if widget.type === 'markdown'}
<MarkdownWidget
config={{ content: parsedConfig.content ?? '', syntaxTheme: parsedConfig.syntaxTheme }}
widgetId={widget.id}
/>
{:else if widget.type === 'metric'}
<MetricWidget config={{
label: parsedConfig.label ?? 'Metric',
source: parsedConfig.source ?? 'static',
value: parsedConfig.value,
url: parsedConfig.url,
jsonPath: parsedConfig.jsonPath,
query: parsedConfig.query,
unit: parsedConfig.unit,
refreshInterval: parsedConfig.refreshInterval ?? 60
}} />
{:else if widget.type === 'link_group'}
<LinkGroupWidget config={{
links: parsedConfig.links ?? [],
collapsible: parsedConfig.collapsible ?? false
}} />
{:else if widget.type === 'camera'}
<CameraStreamWidget config={{
streamUrl: parsedConfig.streamUrl ?? '',
type: parsedConfig.type ?? 'image',
refreshInterval: parsedConfig.refreshInterval ?? 10,
aspectRatio: parsedConfig.aspectRatio ?? '16/9'
}} />
{:else}
<div class="flex h-full items-center justify-center rounded-xl border border-border bg-card p-4">
<span class="text-xs text-muted-foreground">{$t('widget.type', { values: { type: widget.type } })}</span>
+312 -312
View File
@@ -1,341 +1,341 @@
{
"app_name": "App Launcher",
"app_title": "Web App Launcher",
"app_name": "App Launcher",
"app_title": "Web App Launcher",
"nav.navigation": "Navigation",
"nav.boards": "Boards",
"nav.apps": "Apps",
"nav.admin": "Admin",
"nav.admin_panel": "Admin Panel",
"nav.navigation": "Navigation",
"nav.boards": "Boards",
"nav.apps": "Apps",
"nav.admin": "Admin",
"nav.admin_panel": "Admin Panel",
"auth.login": "Sign In",
"auth.login_title": "Welcome back",
"auth.login_subtitle": "Sign in to your account",
"auth.login_submit": "Sign In",
"auth.login_submitting": "Signing in...",
"auth.register": "Register",
"auth.register_title": "Create Account",
"auth.register_subtitle": "Get started with App Launcher",
"auth.register_submit": "Create Account",
"auth.register_submitting": "Creating account...",
"auth.email": "Email",
"auth.email_placeholder": "you@example.com",
"auth.password": "Password",
"auth.password_placeholder": "Enter your password",
"auth.password_placeholder_register": "At least 6 characters",
"auth.display_name": "Display Name",
"auth.display_name_placeholder": "Your name",
"auth.logout": "Sign Out",
"auth.oauth_signin": "Sign in with OAuth",
"auth.or": "or",
"auth.no_account": "Don't have an account?",
"auth.have_account": "Already have an account?",
"auth.sign_in_link": "Sign in",
"auth.login": "Sign In",
"auth.login_title": "Welcome back",
"auth.login_subtitle": "Sign in to your account",
"auth.login_submit": "Sign In",
"auth.login_submitting": "Signing in...",
"auth.register": "Register",
"auth.register_title": "Create Account",
"auth.register_subtitle": "Get started with App Launcher",
"auth.register_submit": "Create Account",
"auth.register_submitting": "Creating account...",
"auth.email": "Email",
"auth.email_placeholder": "you@example.com",
"auth.password": "Password",
"auth.password_placeholder": "Enter your password",
"auth.password_placeholder_register": "At least 6 characters",
"auth.display_name": "Display Name",
"auth.display_name_placeholder": "Your name",
"auth.logout": "Sign Out",
"auth.oauth_signin": "Sign in with OAuth",
"auth.or": "or",
"auth.no_account": "Don't have an account?",
"auth.have_account": "Already have an account?",
"auth.sign_in_link": "Sign in",
"board.title": "Boards",
"board.boards_available": "{count} board(s) available",
"board.new": "New Board",
"board.edit": "Edit",
"board.edit_board": "Edit Board",
"board.all_boards": "All Boards",
"board.back_to_boards": "Back to Boards",
"board.back_to_board": "Back to Board",
"board.no_boards": "No boards available.",
"board.sign_in_more": "Sign in to see more boards.",
"board.no_sections": "This board has no sections yet.",
"board.default": "Default",
"board.guest": "Guest",
"board.sections_count": "{count} section(s)",
"board.properties": "Board Properties",
"board.save": "Save Board",
"board.create": "Create Board",
"board.creating": "Creating...",
"board.default_board": "Default board",
"board.guest_accessible": "Guest accessible",
"board.guest_access_title": "Guest Access",
"board.guest_access_description": "When enabled, this board is visible to unauthenticated visitors without requiring sign-in.",
"board.guest_access_enabled": "This board is publicly accessible",
"board.guest_access_disabled": "This board is private",
"board.permissions_title": "Permissions",
"board.permissions_description": "Manage who can view, edit, or administer this board.",
"board.access_grant": "Grant Access",
"board.access_search_placeholder": "Search...",
"board.access_loading": "Loading permissions...",
"board.access_none": "No permissions configured for this board.",
"board.access_private": "Private",
"board.access_shared": "Shared",
"board.share": "Share",
"board.share_title": "Share \"{name}\"",
"board.share_copy_link": "Copy Link",
"board.share_copied": "Copied!",
"board.share_guest_description": "Anyone with the link can view this board without signing in.",
"board.share_add_access": "Add People or Groups",
"board.share_current_access": "Current Access",
"board.title": "Boards",
"board.boards_available": "{count} board(s) available",
"board.new": "New Board",
"board.edit": "Edit",
"board.edit_board": "Edit Board",
"board.all_boards": "All Boards",
"board.back_to_boards": "Back to Boards",
"board.back_to_board": "Back to Board",
"board.no_boards": "No boards available.",
"board.sign_in_more": "Sign in to see more boards.",
"board.no_sections": "This board has no sections yet.",
"board.default": "Default",
"board.guest": "Guest",
"board.sections_count": "{count} section(s)",
"board.properties": "Board Properties",
"board.save": "Save Board",
"board.create": "Create Board",
"board.creating": "Creating...",
"board.default_board": "Default board",
"board.guest_accessible": "Guest accessible",
"board.guest_access_title": "Guest Access",
"board.guest_access_description": "When enabled, this board is visible to unauthenticated visitors without requiring sign-in.",
"board.guest_access_enabled": "This board is publicly accessible",
"board.guest_access_disabled": "This board is private",
"board.permissions_title": "Permissions",
"board.permissions_description": "Manage who can view, edit, or administer this board.",
"board.access_grant": "Grant Access",
"board.access_search_placeholder": "Search...",
"board.access_loading": "Loading permissions...",
"board.access_none": "No permissions configured for this board.",
"board.access_private": "Private",
"board.access_shared": "Shared",
"board.share": "Share",
"board.share_title": "Share \"{name}\"",
"board.share_copy_link": "Copy Link",
"board.share_copied": "Copied!",
"board.share_guest_description": "Anyone with the link can view this board without signing in.",
"board.share_add_access": "Add People or Groups",
"board.share_current_access": "Current Access",
"section.title_label": "Title",
"section.icon_label": "Icon",
"section.icon_placeholder": "Optional",
"section.sections": "Sections",
"section.add": "Add Section",
"section.create": "Create Section",
"section.order": "Order: {order}",
"section.title_label": "Title",
"section.icon_label": "Icon",
"section.icon_placeholder": "Optional",
"section.sections": "Sections",
"section.add": "Add Section",
"section.create": "Create Section",
"section.order": "Order: {order}",
"widget.add": "Add Widget",
"widget.select_app": "Select App",
"widget.choose_app": "Choose an app...",
"widget.no_widgets": "No widgets in this section.",
"widget.no_widgets_dnd": "No widgets. Drag widgets here or add one above.",
"widget.type": "{type} widget",
"widget.number": "Widget #{order}",
"widget.remove": "Remove",
"widget.add": "Add Widget",
"widget.select_app": "Select App",
"widget.choose_app": "Choose an app...",
"widget.no_widgets": "No widgets in this section.",
"widget.no_widgets_dnd": "No widgets. Drag widgets here or add one above.",
"widget.type": "{type} widget",
"widget.number": "Widget #{order}",
"widget.remove": "Remove",
"app.title": "App Registry",
"app.apps_registered": "{count} app(s) registered",
"app.add": "Add App",
"app.new": "New App",
"app.no_apps": "No apps registered yet.",
"app.no_apps_hint": "Click \"Add App\" to register your first application.",
"app.all_categories": "All",
"app.name": "Name",
"app.name_placeholder": "My Application",
"app.url": "URL",
"app.url_placeholder": "https://my-app.local:8080",
"app.description": "Description",
"app.description_placeholder": "Brief description of this app",
"app.category": "Category",
"app.category_placeholder": "e.g. Media, Monitoring, Storage",
"app.tags": "Tags",
"app.tags_placeholder": "Comma-separated tags",
"app.icon": "Icon",
"app.icon_lucide": "Lucide Icon",
"app.icon_simple": "Simple Icons",
"app.icon_url": "Image URL",
"app.icon_emoji": "Emoji",
"app.icon_lucide_placeholder": "e.g. globe, server, home",
"app.icon_simple_placeholder": "e.g. github, docker",
"app.icon_url_placeholder": "https://example.com/icon.png",
"app.icon_emoji_placeholder": "e.g. \ud83c\udf10",
"app.icon_preview": "Icon preview",
"app.save": "Save App",
"app.saving": "Saving...",
"app.healthcheck_toggle": "Healthcheck Settings",
"app.healthcheck_show": "Show",
"app.healthcheck_hide": "Hide",
"app.healthcheck_enabled": "Enable Healthcheck",
"app.healthcheck_method": "Method",
"app.healthcheck_expected_status": "Expected Status",
"app.healthcheck_timeout": "Timeout (ms)",
"app.healthcheck_interval": "Interval (seconds)",
"app.icon_board_label": "Icon (Lucide name)",
"app.uptime": "uptime",
"app.history_loading": "Loading history...",
"app.title": "App Registry",
"app.apps_registered": "{count} app(s) registered",
"app.add": "Add App",
"app.new": "New App",
"app.no_apps": "No apps registered yet.",
"app.no_apps_hint": "Click \"Add App\" to register your first application.",
"app.all_categories": "All",
"app.name": "Name",
"app.name_placeholder": "My Application",
"app.url": "URL",
"app.url_placeholder": "https://my-app.local:8080",
"app.description": "Description",
"app.description_placeholder": "Brief description of this app",
"app.category": "Category",
"app.category_placeholder": "e.g. Media, Monitoring, Storage",
"app.tags": "Tags",
"app.tags_placeholder": "Comma-separated tags",
"app.icon": "Icon",
"app.icon_lucide": "Lucide Icon",
"app.icon_simple": "Simple Icons",
"app.icon_url": "Image URL",
"app.icon_emoji": "Emoji",
"app.icon_lucide_placeholder": "e.g. globe, server, home",
"app.icon_simple_placeholder": "e.g. github, docker",
"app.icon_url_placeholder": "https://example.com/icon.png",
"app.icon_emoji_placeholder": "e.g. \ud83c\udf10",
"app.icon_preview": "Icon preview",
"app.save": "Save App",
"app.saving": "Saving...",
"app.healthcheck_toggle": "Healthcheck Settings",
"app.healthcheck_show": "Show",
"app.healthcheck_hide": "Hide",
"app.healthcheck_enabled": "Enable Healthcheck",
"app.healthcheck_method": "Method",
"app.healthcheck_expected_status": "Expected Status",
"app.healthcheck_timeout": "Timeout (ms)",
"app.healthcheck_interval": "Interval (seconds)",
"app.icon_board_label": "Icon (Lucide name)",
"app.uptime": "uptime",
"app.history_loading": "Loading history...",
"admin.panel": "Admin Panel",
"admin.users": "Users",
"admin.groups": "Groups",
"admin.settings": "Settings",
"admin.panel": "Admin Panel",
"admin.users": "Users",
"admin.groups": "Groups",
"admin.settings": "Settings",
"admin.user_management": "User Management",
"admin.create_user": "Create User",
"admin.new_user": "New User",
"admin.user_column": "User",
"admin.email_column": "Email",
"admin.role_column": "Role",
"admin.provider_column": "Provider",
"admin.groups_column": "Groups",
"admin.actions_column": "Actions",
"admin.role_user": "User",
"admin.role_admin": "Admin",
"admin.select_group": "Select group",
"admin.add_to_group": "+ Add",
"admin.remove_from_group": "Remove from group",
"admin.no_users": "No users found.",
"admin.user_management": "User Management",
"admin.create_user": "Create User",
"admin.new_user": "New User",
"admin.user_column": "User",
"admin.email_column": "Email",
"admin.role_column": "Role",
"admin.provider_column": "Provider",
"admin.groups_column": "Groups",
"admin.actions_column": "Actions",
"admin.role_user": "User",
"admin.role_admin": "Admin",
"admin.select_group": "Select group",
"admin.add_to_group": "+ Add",
"admin.remove_from_group": "Remove from group",
"admin.no_users": "No users found.",
"admin.group_management": "Group Management",
"admin.create_group": "Create Group",
"admin.new_group": "New Group",
"admin.name_column": "Name",
"admin.description_column": "Description",
"admin.members_column": "Members",
"admin.default_column": "Default",
"admin.default_group_hint": "Default group (auto-assign new users)",
"admin.no_groups": "No groups found.",
"admin.yes": "Yes",
"admin.no": "No",
"admin.group_management": "Group Management",
"admin.create_group": "Create Group",
"admin.new_group": "New Group",
"admin.name_column": "Name",
"admin.description_column": "Description",
"admin.members_column": "Members",
"admin.default_column": "Default",
"admin.default_group_hint": "Default group (auto-assign new users)",
"admin.no_groups": "No groups found.",
"admin.yes": "Yes",
"admin.no": "No",
"admin.system_settings": "System Settings",
"admin.settings_description": "Configure global application settings.",
"admin.authentication": "Authentication",
"admin.auth_mode": "Auth Mode",
"admin.auth_local": "Local",
"admin.auth_oauth": "OAuth",
"admin.auth_both": "Both",
"admin.registration_enabled": "Allow user registration",
"admin.oauth_config": "OAuth Configuration",
"admin.oauth_description": "Configure your OIDC provider (e.g. Authentik, Keycloak). Set Auth Mode to \"OAuth\" or \"Both\" above to enable OAuth login.",
"admin.oauth_client_id": "Client ID",
"admin.oauth_client_id_placeholder": "OAuth client ID",
"admin.oauth_client_secret": "Client Secret",
"admin.oauth_client_secret_placeholder": "OAuth client secret",
"admin.oauth_discovery_url": "Discovery URL",
"admin.oauth_discovery_url_placeholder": "https://example.com/.well-known/openid-configuration",
"admin.oauth_test": "Test Connection",
"admin.oauth_testing": "Testing...",
"admin.oauth_connected": "Connected to issuer: {issuer}",
"admin.oauth_network_error": "Network error \u2014 could not reach the server",
"admin.theme_defaults": "Theme Defaults",
"admin.default_theme": "Default Theme",
"admin.default_primary_color": "Default Primary Color",
"admin.healthcheck_defaults": "Healthcheck Defaults",
"admin.healthcheck_defaults_description": "JSON configuration for default healthcheck behavior (interval, timeout, method).",
"admin.healthcheck_defaults_label": "Defaults (JSON)",
"admin.save_settings": "Save Settings",
"admin.saving_settings": "Saving...",
"admin.system_settings": "System Settings",
"admin.settings_description": "Configure global application settings.",
"admin.authentication": "Authentication",
"admin.auth_mode": "Auth Mode",
"admin.auth_local": "Local",
"admin.auth_oauth": "OAuth",
"admin.auth_both": "Both",
"admin.registration_enabled": "Allow user registration",
"admin.oauth_config": "OAuth Configuration",
"admin.oauth_description": "Configure your OIDC provider (e.g. Authentik, Keycloak). Set Auth Mode to \"OAuth\" or \"Both\" above to enable OAuth login.",
"admin.oauth_client_id": "Client ID",
"admin.oauth_client_id_placeholder": "OAuth client ID",
"admin.oauth_client_secret": "Client Secret",
"admin.oauth_client_secret_placeholder": "OAuth client secret",
"admin.oauth_discovery_url": "Discovery URL",
"admin.oauth_discovery_url_placeholder": "https://example.com/.well-known/openid-configuration",
"admin.oauth_test": "Test Connection",
"admin.oauth_testing": "Testing...",
"admin.oauth_connected": "Connected to issuer: {issuer}",
"admin.oauth_network_error": "Network error \u2014 could not reach the server",
"admin.theme_defaults": "Theme Defaults",
"admin.default_theme": "Default Theme",
"admin.default_primary_color": "Default Primary Color",
"admin.healthcheck_defaults": "Healthcheck Defaults",
"admin.healthcheck_defaults_description": "JSON configuration for default healthcheck behavior (interval, timeout, method).",
"admin.healthcheck_defaults_label": "Defaults (JSON)",
"admin.save_settings": "Save Settings",
"admin.saving_settings": "Saving...",
"admin.perm_title": "Grant Permission",
"admin.perm_entity_type": "Entity Type",
"admin.perm_entity": "Entity",
"admin.perm_target_type": "Target Type",
"admin.perm_target": "Target",
"admin.perm_level": "Level",
"admin.perm_board": "Board",
"admin.perm_app": "App",
"admin.perm_user": "User",
"admin.perm_group": "Group",
"admin.perm_view": "View",
"admin.perm_edit": "Edit",
"admin.perm_admin": "Admin",
"admin.perm_grant": "Grant",
"admin.perm_revoke": "Revoke",
"admin.perm_select": "Select...",
"admin.perm_entity_column": "Entity",
"admin.perm_target_column": "Target",
"admin.perm_level_column": "Level",
"admin.perm_action_column": "Action",
"admin.perm_none": "No permissions configured.",
"admin.perm_search_placeholder": "Type to search...",
"admin.perm_title": "Grant Permission",
"admin.perm_entity_type": "Entity Type",
"admin.perm_entity": "Entity",
"admin.perm_target_type": "Target Type",
"admin.perm_target": "Target",
"admin.perm_level": "Level",
"admin.perm_board": "Board",
"admin.perm_app": "App",
"admin.perm_user": "User",
"admin.perm_group": "Group",
"admin.perm_view": "View",
"admin.perm_edit": "Edit",
"admin.perm_admin": "Admin",
"admin.perm_grant": "Grant",
"admin.perm_revoke": "Revoke",
"admin.perm_select": "Select...",
"admin.perm_entity_column": "Entity",
"admin.perm_target_column": "Target",
"admin.perm_level_column": "Level",
"admin.perm_action_column": "Action",
"admin.perm_none": "No permissions configured.",
"admin.perm_search_placeholder": "Type to search...",
"admin.discovery_title": "Service Discovery",
"admin.discovery_description": "Scan Docker containers and Traefik routers to automatically discover running services and register them as apps.",
"admin.discovery_scan": "Scan for Services",
"admin.discovery_scanning": "Scanning...",
"admin.discovery_approve": "Approve Selected",
"admin.discovery_approving": "Approving...",
"admin.discovery_source": "Source",
"admin.discovery_status": "Status",
"admin.discovery_source_docker": "Docker",
"admin.discovery_source_traefik": "Traefik",
"admin.discovery_already_registered": "Already registered",
"admin.discovery_new": "New",
"admin.discovery_no_results": "No services discovered. Check your Docker socket path or Traefik API URL.",
"admin.discovery_config": "Service Discovery Configuration",
"admin.discovery_config_description": "Configure Docker and Traefik endpoints for automatic service discovery. These settings are used by the Discovery panel below.",
"admin.discovery_docker_socket": "Docker Socket Path",
"admin.discovery_docker_socket_hint": "Path to Docker socket (e.g. /var/run/docker.sock). Set via DOCKER_SOCKET_PATH env var.",
"admin.discovery_traefik_url": "Traefik API URL",
"admin.discovery_traefik_url_hint": "Traefik dashboard API base URL (e.g. http://traefik:8080). Set via TRAEFIK_API_URL env var.",
"admin.discovery_title": "Service Discovery",
"admin.discovery_description": "Scan Docker containers and Traefik routers to automatically discover running services and register them as apps.",
"admin.discovery_scan": "Scan for Services",
"admin.discovery_scanning": "Scanning...",
"admin.discovery_approve": "Approve Selected",
"admin.discovery_approving": "Approving...",
"admin.discovery_source": "Source",
"admin.discovery_status": "Status",
"admin.discovery_source_docker": "Docker",
"admin.discovery_source_traefik": "Traefik",
"admin.discovery_already_registered": "Already registered",
"admin.discovery_new": "New",
"admin.discovery_no_results": "No services discovered. Check your Docker socket path or Traefik API URL.",
"admin.discovery_config": "Service Discovery Configuration",
"admin.discovery_config_description": "Configure Docker and Traefik endpoints for automatic service discovery. These settings are used by the Discovery panel below.",
"admin.discovery_docker_socket": "Docker Socket Path",
"admin.discovery_docker_socket_hint": "Path to Docker socket (e.g. /var/run/docker.sock). Set via DOCKER_SOCKET_PATH env var.",
"admin.discovery_traefik_url": "Traefik API URL",
"admin.discovery_traefik_url_hint": "Traefik dashboard API base URL (e.g. http://traefik:8080). Set via TRAEFIK_API_URL env var.",
"admin.import_export_title": "Import / Export",
"admin.import_export_description": "Export all data (apps, boards, groups, settings) as JSON, or import from a previously exported file.",
"admin.export_section": "Export Data",
"admin.export_button": "Export JSON",
"admin.export_exporting": "Exporting...",
"admin.export_success": "Export downloaded successfully.",
"admin.import_section": "Import Data",
"admin.import_select_file": "Select a JSON export file",
"admin.import_preview": "Preview",
"admin.import_mode_label": "Conflict Resolution",
"admin.import_mode_skip": "Skip existing (keep current data)",
"admin.import_mode_overwrite": "Overwrite existing (replace with imported data)",
"admin.import_button": "Import",
"admin.import_importing": "Importing...",
"admin.import_success": "Import completed.",
"admin.import_invalid_json": "Selected file is not valid JSON.",
"admin.import_export_title": "Import / Export",
"admin.import_export_description": "Export all data (apps, boards, groups, settings) as JSON, or import from a previously exported file.",
"admin.export_section": "Export Data",
"admin.export_button": "Export JSON",
"admin.export_exporting": "Exporting...",
"admin.export_success": "Export downloaded successfully.",
"admin.import_section": "Import Data",
"admin.import_select_file": "Select a JSON export file",
"admin.import_preview": "Preview",
"admin.import_mode_label": "Conflict Resolution",
"admin.import_mode_skip": "Skip existing (keep current data)",
"admin.import_mode_overwrite": "Overwrite existing (replace with imported data)",
"admin.import_button": "Import",
"admin.import_importing": "Importing...",
"admin.import_success": "Import completed.",
"admin.import_invalid_json": "Selected file is not valid JSON.",
"search.placeholder": "Search apps and boards...",
"search.trigger": "Search...",
"search.min_chars": "Type at least 2 characters to search",
"search.no_results": "No results for \"{query}\"",
"search.apps": "Apps",
"search.boards": "Boards",
"search.nav_hint": "navigate",
"search.select_hint": "select",
"search.close_hint": "close",
"search.placeholder": "Search apps and boards...",
"search.trigger": "Search...",
"search.min_chars": "Type at least 2 characters to search",
"search.no_results": "No results for \"{query}\"",
"search.apps": "Apps",
"search.boards": "Boards",
"search.nav_hint": "navigate",
"search.select_hint": "select",
"search.close_hint": "close",
"common.search_filter": "Filter...",
"common.search_filter": "Filter...",
"common.save": "Save",
"common.cancel": "Cancel",
"common.delete": "Delete",
"common.create": "Create",
"common.back": "Back",
"common.edit": "Edit",
"common.add": "Add",
"common.confirm": "Confirm?",
"common.yes": "Yes",
"common.no": "No",
"common.name": "Name",
"common.description": "Description",
"common.required": "*",
"common.save": "Save",
"common.cancel": "Cancel",
"common.delete": "Delete",
"common.create": "Create",
"common.back": "Back",
"common.edit": "Edit",
"common.add": "Add",
"common.confirm": "Confirm?",
"common.yes": "Yes",
"common.no": "No",
"common.name": "Name",
"common.description": "Description",
"common.required": "*",
"status.online": "Online",
"status.offline": "Offline",
"status.degraded": "Degraded",
"status.unknown": "Unknown",
"status.online": "Online",
"status.offline": "Offline",
"status.degraded": "Degraded",
"status.unknown": "Unknown",
"theme.dark": "Dark",
"theme.light": "Light",
"theme.system": "System",
"theme.toggle": "Toggle theme (current: {mode})",
"theme.title": "Theme: {mode}",
"theme.dark": "Dark",
"theme.light": "Light",
"theme.system": "System",
"theme.toggle": "Toggle theme (current: {mode})",
"theme.title": "Theme: {mode}",
"bg.mesh": "Mesh Gradient",
"bg.particles": "Particles",
"bg.aurora": "Aurora",
"bg.none": "None",
"bg.title": "Background effect",
"bg.aria_label": "Change background effect",
"bg.mesh": "Mesh Gradient",
"bg.particles": "Particles",
"bg.aurora": "Aurora",
"bg.none": "None",
"bg.title": "Background effect",
"bg.aria_label": "Change background effect",
"sidebar.expand": "Expand sidebar",
"sidebar.collapse": "Collapse sidebar",
"sidebar.toggle": "Toggle sidebar",
"sidebar.close": "Close sidebar",
"sidebar.expand": "Expand sidebar",
"sidebar.collapse": "Collapse sidebar",
"sidebar.toggle": "Toggle sidebar",
"sidebar.close": "Close sidebar",
"home.welcome": "Welcome, {name}. No default board is configured yet.",
"home.view_boards": "View Boards",
"home.browse_apps": "Browse Apps",
"home.welcome": "Welcome, {name}. No default board is configured yet.",
"home.view_boards": "View Boards",
"home.browse_apps": "Browse Apps",
"language.label": "Language",
"language.label": "Language",
"settings.title": "Settings",
"settings.theme": "Theme Mode",
"settings.primary_color": "Primary Color",
"settings.hue": "Hue",
"settings.saturation": "Saturation",
"settings.background": "Background Effect",
"settings.language": "Language",
"settings.save": "Save Preferences",
"settings.saving": "Saving...",
"settings.saved": "Preferences saved!",
"settings.title": "Settings",
"settings.theme": "Theme Mode",
"settings.primary_color": "Primary Color",
"settings.hue": "Hue",
"settings.saturation": "Saturation",
"settings.background": "Background Effect",
"settings.language": "Language",
"settings.save": "Save Preferences",
"settings.saving": "Saving...",
"settings.saved": "Preferences saved!",
"offline.title": "You're Offline",
"offline.description": "It looks like you've lost your internet connection. Check your network and try again.",
"offline.retry": "Retry",
"offline.title": "You're Offline",
"offline.description": "It looks like you've lost your internet connection. Check your network and try again.",
"offline.retry": "Retry",
"install.title": "Install App",
"install.description": "Add Web App Launcher to your home screen for quick access.",
"install.button": "Install",
"install.dismiss": "Dismiss install prompt",
"install.title": "Install App",
"install.description": "Add Web App Launcher to your home screen for quick access.",
"install.button": "Install",
"install.dismiss": "Dismiss install prompt",
"settings.bookmarklet_title": "Quick-Add Bookmarklet",
"settings.bookmarklet_instructions": "Drag the button below to your browser's bookmarks bar. When visiting any web page, click it to quickly add that site to your App Launcher.",
"settings.bookmarklet_drag": "Add to Launcher",
"settings.bookmarklet_drag_hint": "Drag this to your bookmarks bar",
"settings.bookmarklet_show_code": "Show bookmarklet code",
"settings.bookmarklet_title": "Quick-Add Bookmarklet",
"settings.bookmarklet_instructions": "Drag the button below to your browser's bookmarks bar. When visiting any web page, click it to quickly add that site to your App Launcher.",
"settings.bookmarklet_drag": "Add to Launcher",
"settings.bookmarklet_drag_hint": "Drag this to your bookmarks bar",
"settings.bookmarklet_show_code": "Show bookmarklet code",
"app.quick_add_title": "Quick Add App",
"app.quick_add_description": "Review the details below and save to add this app to your launcher.",
"app.quick_add_success": "App added successfully!",
"app.quick_add_view_apps": "View Apps",
"app.quick_add_close": "Close Window"
"app.quick_add_title": "Quick Add App",
"app.quick_add_description": "Review the details below and save to add this app to your launcher.",
"app.quick_add_success": "App added successfully!",
"app.quick_add_view_apps": "View Apps",
"app.quick_add_close": "Close Window"
}
+312 -312
View File
@@ -1,317 +1,317 @@
{
"app_name": "App Launcher",
"app_title": "Web App Launcher",
"nav.navigation": "Навигация",
"nav.boards": "Доски",
"nav.apps": "Приложения",
"nav.admin": "Админ",
"nav.admin_panel": "Панель администратора",
"auth.login": "Войти",
"auth.login_title": "Добро пожаловать",
"auth.login_subtitle": "Войдите в свой аккаунт",
"auth.login_submit": "Войти",
"auth.login_submitting": "Вход...",
"auth.register": "Регистрация",
"auth.register_title": "Создать аккаунт",
"auth.register_subtitle": "Начните работу с App Launcher",
"auth.register_submit": "Создать аккаунт",
"auth.register_submitting": "Создание аккаунта...",
"auth.email": "Электронная почта",
"auth.email_placeholder": "you@example.com",
"auth.password": "Пароль",
"auth.password_placeholder": "Введите пароль",
"auth.password_placeholder_register": "Не менее 6 символов",
"auth.display_name": "Имя",
"auth.display_name_placeholder": "Ваше имя",
"auth.logout": "Выход",
"auth.oauth_signin": "Войти через OAuth",
"auth.or": "или",
"auth.no_account": "Нет аккаунта?",
"auth.have_account": "Уже есть аккаунт?",
"auth.sign_in_link": "Войти",
"board.title": "Доски",
"board.boards_available": "Доступно досок: {count}",
"board.new": "Новая доска",
"board.edit": "Редактировать",
"board.edit_board": "Редактирование доски",
"board.all_boards": "Все доски",
"board.back_to_boards": "Назад к доскам",
"board.back_to_board": "Назад к доске",
"board.no_boards": "Доски не найдены.",
"board.sign_in_more": "Войдите, чтобы увидеть больше досок.",
"board.no_sections": "На этой доске пока нет разделов.",
"board.default": "По умолчанию",
"board.guest": "Гостевая",
"board.sections_count": "Разделов: {count}",
"board.properties": "Свойства доски",
"board.save": "Сохранить доску",
"board.create": "Создать доску",
"board.creating": "Создание...",
"board.default_board": "Доска по умолчанию",
"board.guest_accessible": "Доступна гостям",
"board.guest_access_title": "Гостевой доступ",
"board.guest_access_description": "При включении эта доска видна неавторизованным посетителям без входа в систему.",
"board.guest_access_enabled": "Эта доска общедоступна",
"board.guest_access_disabled": "Эта доска приватна",
"board.permissions_title": "Права доступа",
"board.permissions_description": "Управляйте, кто может просматривать, редактировать или администрировать эту доску.",
"board.access_grant": "Назначить доступ",
"board.access_search_placeholder": "Поиск...",
"board.access_loading": "Загрузка прав...",
"board.access_none": "Права доступа для этой доски не настроены.",
"board.access_private": "Приватная",
"board.access_shared": "Общая",
"board.share": "Поделиться",
"board.share_title": "Поделиться «{name}»",
"board.share_copy_link": "Копировать ссылку",
"board.share_copied": "Скопировано!",
"board.share_guest_description": "Любой с этой ссылкой может просматривать доску без входа.",
"board.share_add_access": "Добавить людей или группы",
"board.share_current_access": "Текущий доступ",
"section.title_label": "Заголовок",
"section.icon_label": "Иконка",
"section.icon_placeholder": "Необязательно",
"section.sections": "Разделы",
"section.add": "Добавить раздел",
"section.create": "Создать раздел",
"section.order": "Порядок: {order}",
"widget.add": "Добавить виджет",
"widget.select_app": "Выберите приложение",
"widget.choose_app": "Выберите приложение...",
"widget.no_widgets": "В этом разделе нет виджетов.",
"widget.no_widgets_dnd": "Нет виджетов. Перетащите сюда или добавьте выше.",
"widget.type": "Виджет {type}",
"widget.number": "Виджет #{order}",
"widget.remove": "Удалить",
"app.title": "Реестр приложений",
"app.apps_registered": "Зарегистрировано приложений: {count}",
"app.add": "Добавить приложение",
"app.new": "Новое приложение",
"app.no_apps": "Приложения ещё не зарегистрированы.",
"app.no_apps_hint": "Нажмите «Добавить приложение», чтобы зарегистрировать первое приложение.",
"app.all_categories": "Все",
"app.name": "Название",
"app.name_placeholder": "Моё приложение",
"app.url": "URL",
"app.url_placeholder": "https://my-app.local:8080",
"app.description": "Описание",
"app.description_placeholder": "Краткое описание приложения",
"app.category": "Категория",
"app.category_placeholder": "напр. Медиа, Мониторинг, Хранилище",
"app.tags": "Теги",
"app.tags_placeholder": "Теги через запятую",
"app.icon": "Иконка",
"app.icon_lucide": "Lucide",
"app.icon_simple": "Simple Icons",
"app.icon_url": "URL изображения",
"app.icon_emoji": "Эмодзи",
"app.icon_lucide_placeholder": "напр. globe, server, home",
"app.icon_simple_placeholder": "напр. github, docker",
"app.icon_url_placeholder": "https://example.com/icon.png",
"app.icon_emoji_placeholder": "напр. 🌐",
"app.icon_preview": "Превью иконки",
"app.save": "Сохранить",
"app.saving": "Сохранение...",
"app.healthcheck_toggle": "Настройки проверки здоровья",
"app.healthcheck_show": "Показать",
"app.healthcheck_hide": "Скрыть",
"app.healthcheck_enabled": "Включить проверку здоровья",
"app.healthcheck_method": "Метод",
"app.healthcheck_expected_status": "Ожидаемый статус",
"app.healthcheck_timeout": "Таймаут (мс)",
"app.healthcheck_interval": "Интервал (секунды)",
"app.icon_board_label": "Иконка (Lucide)",
"app.uptime": "аптайм",
"app.history_loading": "Загрузка истории...",
"admin.panel": "Панель администратора",
"admin.users": "Пользователи",
"admin.groups": "Группы",
"admin.settings": "Настройки",
"admin.user_management": "Управление пользователями",
"admin.create_user": "Создать пользователя",
"admin.new_user": "Новый пользователь",
"admin.user_column": "Пользователь",
"admin.email_column": "Электронная почта",
"admin.role_column": "Роль",
"admin.provider_column": "Провайдер",
"admin.groups_column": "Группы",
"admin.actions_column": "Действия",
"admin.role_user": "Пользователь",
"admin.role_admin": "Администратор",
"admin.select_group": "Выбрать группу",
"admin.add_to_group": "+ Добавить",
"admin.remove_from_group": "Удалить из группы",
"admin.no_users": "Пользователи не найдены.",
"admin.group_management": "Управление группами",
"admin.create_group": "Создать группу",
"admin.new_group": "Новая группа",
"admin.name_column": "Название",
"admin.description_column": "Описание",
"admin.members_column": "Участники",
"admin.default_column": "По умолчанию",
"admin.default_group_hint": "Группа по умолчанию (авто-назначение новым пользователям)",
"admin.no_groups": "Группы не найдены.",
"admin.yes": "Да",
"admin.no": "Нет",
"admin.system_settings": "Системные настройки",
"admin.settings_description": "Настройка глобальных параметров приложения.",
"admin.authentication": "Аутентификация",
"admin.auth_mode": "Режим аутентификации",
"admin.auth_local": "Локальный",
"admin.auth_oauth": "OAuth",
"admin.auth_both": "Оба",
"admin.registration_enabled": "Разрешить регистрацию пользователей",
"admin.oauth_config": "Настройка OAuth",
"admin.oauth_description": "Настройте провайдер OIDC (напр. Authentik, Keycloak). Установите режим аутентификации «OAuth» или «Оба» выше, чтобы включить вход через OAuth.",
"admin.oauth_client_id": "Client ID",
"admin.oauth_client_id_placeholder": "OAuth client ID",
"admin.oauth_client_secret": "Секрет клиента",
"admin.oauth_client_secret_placeholder": "Секрет OAuth клиента",
"admin.oauth_discovery_url": "Discovery URL",
"admin.oauth_discovery_url_placeholder": "https://example.com/.well-known/openid-configuration",
"admin.oauth_test": "Тестировать подключение",
"admin.oauth_testing": "Тестирование...",
"admin.oauth_connected": "Подключено к: {issuer}",
"admin.oauth_network_error": "Ошибка сети — не удалось связаться с сервером",
"admin.theme_defaults": "Настройки темы",
"admin.default_theme": "Тема по умолчанию",
"admin.default_primary_color": "Основной цвет по умолчанию",
"admin.healthcheck_defaults": "Настройки проверки здоровья",
"admin.healthcheck_defaults_description": "JSON-конфигурация проверки здоровья по умолчанию (интервал, таймаут, метод).",
"admin.healthcheck_defaults_label": "Настройки (JSON)",
"admin.save_settings": "Сохранить настройки",
"admin.saving_settings": "Сохранение...",
"admin.perm_title": "Назначить права",
"admin.perm_entity_type": "Тип объекта",
"admin.perm_entity": "Объект",
"admin.perm_target_type": "Тип цели",
"admin.perm_target": "Цель",
"admin.perm_level": "Уровень",
"admin.perm_board": "Доска",
"admin.perm_app": "Приложение",
"admin.perm_user": "Пользователь",
"admin.perm_group": "Группа",
"admin.perm_view": "Просмотр",
"admin.perm_edit": "Редактирование",
"admin.perm_admin": "Администратор",
"admin.perm_grant": "Назначить",
"admin.perm_revoke": "Отозвать",
"admin.perm_select": "Выбрать...",
"admin.perm_entity_column": "Объект",
"admin.perm_target_column": "Цель",
"admin.perm_level_column": "Уровень",
"admin.perm_action_column": "Действие",
"admin.perm_none": "Права не настроены.",
"admin.perm_search_placeholder": "Начните вводить...",
"app_name": "App Launcher",
"app_title": "Web App Launcher",
"nav.navigation": "Навигация",
"nav.boards": "Доски",
"nav.apps": "Приложения",
"nav.admin": "Админ",
"nav.admin_panel": "Панель администратора",
"auth.login": "Войти",
"auth.login_title": "Добро пожаловать",
"auth.login_subtitle": "Войдите в свой аккаунт",
"auth.login_submit": "Войти",
"auth.login_submitting": "Вход...",
"auth.register": "Регистрация",
"auth.register_title": "Создать аккаунт",
"auth.register_subtitle": "Начните работу с App Launcher",
"auth.register_submit": "Создать аккаунт",
"auth.register_submitting": "Создание аккаунта...",
"auth.email": "Электронная почта",
"auth.email_placeholder": "you@example.com",
"auth.password": "Пароль",
"auth.password_placeholder": "Введите пароль",
"auth.password_placeholder_register": "Не менее 6 символов",
"auth.display_name": "Имя",
"auth.display_name_placeholder": "Ваше имя",
"auth.logout": "Выход",
"auth.oauth_signin": "Войти через OAuth",
"auth.or": "или",
"auth.no_account": "Нет аккаунта?",
"auth.have_account": "Уже есть аккаунт?",
"auth.sign_in_link": "Войти",
"board.title": "Доски",
"board.boards_available": "Доступно досок: {count}",
"board.new": "Новая доска",
"board.edit": "Редактировать",
"board.edit_board": "Редактирование доски",
"board.all_boards": "Все доски",
"board.back_to_boards": "Назад к доскам",
"board.back_to_board": "Назад к доске",
"board.no_boards": "Доски не найдены.",
"board.sign_in_more": "Войдите, чтобы увидеть больше досок.",
"board.no_sections": "На этой доске пока нет разделов.",
"board.default": "По умолчанию",
"board.guest": "Гостевая",
"board.sections_count": "Разделов: {count}",
"board.properties": "Свойства доски",
"board.save": "Сохранить доску",
"board.create": "Создать доску",
"board.creating": "Создание...",
"board.default_board": "Доска по умолчанию",
"board.guest_accessible": "Доступна гостям",
"board.guest_access_title": "Гостевой доступ",
"board.guest_access_description": "При включении эта доска видна неавторизованным посетителям без входа в систему.",
"board.guest_access_enabled": "Эта доска общедоступна",
"board.guest_access_disabled": "Эта доска приватна",
"board.permissions_title": "Права доступа",
"board.permissions_description": "Управляйте, кто может просматривать, редактировать или администрировать эту доску.",
"board.access_grant": "Назначить доступ",
"board.access_search_placeholder": "Поиск...",
"board.access_loading": "Загрузка прав...",
"board.access_none": "Права доступа для этой доски не настроены.",
"board.access_private": "Приватная",
"board.access_shared": "Общая",
"board.share": "Поделиться",
"board.share_title": "Поделиться «{name}»",
"board.share_copy_link": "Копировать ссылку",
"board.share_copied": "Скопировано!",
"board.share_guest_description": "Любой с этой ссылкой может просматривать доску без входа.",
"board.share_add_access": "Добавить людей или группы",
"board.share_current_access": "Текущий доступ",
"section.title_label": "Заголовок",
"section.icon_label": "Иконка",
"section.icon_placeholder": "Необязательно",
"section.sections": "Разделы",
"section.add": "Добавить раздел",
"section.create": "Создать раздел",
"section.order": "Порядок: {order}",
"widget.add": "Добавить виджет",
"widget.select_app": "Выберите приложение",
"widget.choose_app": "Выберите приложение...",
"widget.no_widgets": "В этом разделе нет виджетов.",
"widget.no_widgets_dnd": "Нет виджетов. Перетащите сюда или добавьте выше.",
"widget.type": "Виджет {type}",
"widget.number": "Виджет #{order}",
"widget.remove": "Удалить",
"app.title": "Реестр приложений",
"app.apps_registered": "Зарегистрировано приложений: {count}",
"app.add": "Добавить приложение",
"app.new": "Новое приложение",
"app.no_apps": "Приложения ещё не зарегистрированы.",
"app.no_apps_hint": "Нажмите «Добавить приложение», чтобы зарегистрировать первое приложение.",
"app.all_categories": "Все",
"app.name": "Название",
"app.name_placeholder": "Моё приложение",
"app.url": "URL",
"app.url_placeholder": "https://my-app.local:8080",
"app.description": "Описание",
"app.description_placeholder": "Краткое описание приложения",
"app.category": "Категория",
"app.category_placeholder": "напр. Медиа, Мониторинг, Хранилище",
"app.tags": "Теги",
"app.tags_placeholder": "Теги через запятую",
"app.icon": "Иконка",
"app.icon_lucide": "Lucide",
"app.icon_simple": "Simple Icons",
"app.icon_url": "URL изображения",
"app.icon_emoji": "Эмодзи",
"app.icon_lucide_placeholder": "напр. globe, server, home",
"app.icon_simple_placeholder": "напр. github, docker",
"app.icon_url_placeholder": "https://example.com/icon.png",
"app.icon_emoji_placeholder": "напр. 🌐",
"app.icon_preview": "Превью иконки",
"app.save": "Сохранить",
"app.saving": "Сохранение...",
"app.healthcheck_toggle": "Настройки проверки здоровья",
"app.healthcheck_show": "Показать",
"app.healthcheck_hide": "Скрыть",
"app.healthcheck_enabled": "Включить проверку здоровья",
"app.healthcheck_method": "Метод",
"app.healthcheck_expected_status": "Ожидаемый статус",
"app.healthcheck_timeout": "Таймаут (мс)",
"app.healthcheck_interval": "Интервал (секунды)",
"app.icon_board_label": "Иконка (Lucide)",
"app.uptime": "аптайм",
"app.history_loading": "Загрузка истории...",
"admin.panel": "Панель администратора",
"admin.users": "Пользователи",
"admin.groups": "Группы",
"admin.settings": "Настройки",
"admin.user_management": "Управление пользователями",
"admin.create_user": "Создать пользователя",
"admin.new_user": "Новый пользователь",
"admin.user_column": "Пользователь",
"admin.email_column": "Электронная почта",
"admin.role_column": "Роль",
"admin.provider_column": "Провайдер",
"admin.groups_column": "Группы",
"admin.actions_column": "Действия",
"admin.role_user": "Пользователь",
"admin.role_admin": "Администратор",
"admin.select_group": "Выбрать группу",
"admin.add_to_group": "+ Добавить",
"admin.remove_from_group": "Удалить из группы",
"admin.no_users": "Пользователи не найдены.",
"admin.group_management": "Управление группами",
"admin.create_group": "Создать группу",
"admin.new_group": "Новая группа",
"admin.name_column": "Название",
"admin.description_column": "Описание",
"admin.members_column": "Участники",
"admin.default_column": "По умолчанию",
"admin.default_group_hint": "Группа по умолчанию (авто-назначение новым пользователям)",
"admin.no_groups": "Группы не найдены.",
"admin.yes": "Да",
"admin.no": "Нет",
"admin.system_settings": "Системные настройки",
"admin.settings_description": "Настройка глобальных параметров приложения.",
"admin.authentication": "Аутентификация",
"admin.auth_mode": "Режим аутентификации",
"admin.auth_local": "Локальный",
"admin.auth_oauth": "OAuth",
"admin.auth_both": "Оба",
"admin.registration_enabled": "Разрешить регистрацию пользователей",
"admin.oauth_config": "Настройка OAuth",
"admin.oauth_description": "Настройте провайдер OIDC (напр. Authentik, Keycloak). Установите режим аутентификации «OAuth» или «Оба» выше, чтобы включить вход через OAuth.",
"admin.oauth_client_id": "Client ID",
"admin.oauth_client_id_placeholder": "OAuth client ID",
"admin.oauth_client_secret": "Секрет клиента",
"admin.oauth_client_secret_placeholder": "Секрет OAuth клиента",
"admin.oauth_discovery_url": "Discovery URL",
"admin.oauth_discovery_url_placeholder": "https://example.com/.well-known/openid-configuration",
"admin.oauth_test": "Тестировать подключение",
"admin.oauth_testing": "Тестирование...",
"admin.oauth_connected": "Подключено к: {issuer}",
"admin.oauth_network_error": "Ошибка сети — не удалось связаться с сервером",
"admin.theme_defaults": "Настройки темы",
"admin.default_theme": "Тема по умолчанию",
"admin.default_primary_color": "Основной цвет по умолчанию",
"admin.healthcheck_defaults": "Настройки проверки здоровья",
"admin.healthcheck_defaults_description": "JSON-конфигурация проверки здоровья по умолчанию (интервал, таймаут, метод).",
"admin.healthcheck_defaults_label": "Настройки (JSON)",
"admin.save_settings": "Сохранить настройки",
"admin.saving_settings": "Сохранение...",
"admin.perm_title": "Назначить права",
"admin.perm_entity_type": "Тип объекта",
"admin.perm_entity": "Объект",
"admin.perm_target_type": "Тип цели",
"admin.perm_target": "Цель",
"admin.perm_level": "Уровень",
"admin.perm_board": "Доска",
"admin.perm_app": "Приложение",
"admin.perm_user": "Пользователь",
"admin.perm_group": "Группа",
"admin.perm_view": "Просмотр",
"admin.perm_edit": "Редактирование",
"admin.perm_admin": "Администратор",
"admin.perm_grant": "Назначить",
"admin.perm_revoke": "Отозвать",
"admin.perm_select": "Выбрать...",
"admin.perm_entity_column": "Объект",
"admin.perm_target_column": "Цель",
"admin.perm_level_column": "Уровень",
"admin.perm_action_column": "Действие",
"admin.perm_none": "Права не настроены.",
"admin.perm_search_placeholder": "Начните вводить...",
"admin.discovery_title": "Обнаружение сервисов",
"admin.discovery_description": "Сканируйте Docker-контейнеры и маршруты Traefik для автоматического обнаружения работающих сервисов и их регистрации как приложений.",
"admin.discovery_scan": "Сканировать сервисы",
"admin.discovery_scanning": "Сканирование...",
"admin.discovery_approve": "Одобрить выбранные",
"admin.discovery_approving": "Одобрение...",
"admin.discovery_source": "Источник",
"admin.discovery_status": "Статус",
"admin.discovery_source_docker": "Docker",
"admin.discovery_source_traefik": "Traefik",
"admin.discovery_already_registered": "Уже зарегистрировано",
"admin.discovery_new": "Новый",
"admin.discovery_no_results": "Сервисы не обнаружены. Проверьте путь к Docker-сокету или URL API Traefik.",
"admin.discovery_config": "Настройка обнаружения сервисов",
"admin.discovery_config_description": "Настройте конечные точки Docker и Traefik для автоматического обнаружения сервисов. Эти настройки используются панелью обнаружения ниже.",
"admin.discovery_docker_socket": "Путь к Docker-сокету",
"admin.discovery_docker_socket_hint": "Путь к Docker-сокету (напр. /var/run/docker.sock). Задаётся через DOCKER_SOCKET_PATH.",
"admin.discovery_traefik_url": "URL API Traefik",
"admin.discovery_traefik_url_hint": "Базовый URL API Traefik (напр. http://traefik:8080). Задаётся через TRAEFIK_API_URL.",
"admin.discovery_title": "Обнаружение сервисов",
"admin.discovery_description": "Сканируйте Docker-контейнеры и маршруты Traefik для автоматического обнаружения работающих сервисов и их регистрации как приложений.",
"admin.discovery_scan": "Сканировать сервисы",
"admin.discovery_scanning": "Сканирование...",
"admin.discovery_approve": "Одобрить выбранные",
"admin.discovery_approving": "Одобрение...",
"admin.discovery_source": "Источник",
"admin.discovery_status": "Статус",
"admin.discovery_source_docker": "Docker",
"admin.discovery_source_traefik": "Traefik",
"admin.discovery_already_registered": "Уже зарегистрировано",
"admin.discovery_new": "Новый",
"admin.discovery_no_results": "Сервисы не обнаружены. Проверьте путь к Docker-сокету или URL API Traefik.",
"admin.discovery_config": "Настройка обнаружения сервисов",
"admin.discovery_config_description": "Настройте конечные точки Docker и Traefik для автоматического обнаружения сервисов. Эти настройки используются панелью обнаружения ниже.",
"admin.discovery_docker_socket": "Путь к Docker-сокету",
"admin.discovery_docker_socket_hint": "Путь к Docker-сокету (напр. /var/run/docker.sock). Задаётся через DOCKER_SOCKET_PATH.",
"admin.discovery_traefik_url": "URL API Traefik",
"admin.discovery_traefik_url_hint": "Базовый URL API Traefik (напр. http://traefik:8080). Задаётся через TRAEFIK_API_URL.",
"admin.import_export_title": "Импорт / Экспорт",
"admin.import_export_description": "Экспортируйте все данные (приложения, доски, группы, настройки) в формате JSON или импортируйте из ранее экспортированного файла.",
"admin.export_section": "Экспорт данных",
"admin.export_button": "Экспорт JSON",
"admin.export_exporting": "Экспорт...",
"admin.export_success": "Экспорт успешно скачан.",
"admin.import_section": "Импорт данных",
"admin.import_select_file": "Выберите JSON-файл экспорта",
"admin.import_preview": "Предпросмотр",
"admin.import_mode_label": "Разрешение конфликтов",
"admin.import_mode_skip": "Пропустить существующие (оставить текущие данные)",
"admin.import_mode_overwrite": "Перезаписать существующие (заменить импортированными)",
"admin.import_button": "Импортировать",
"admin.import_importing": "Импорт...",
"admin.import_success": "Импорт завершён.",
"admin.import_invalid_json": "Выбранный файл не является корректным JSON.",
"search.placeholder": "Поиск приложений и досок...",
"search.trigger": "Поиск...",
"search.min_chars": "Введите минимум 2 символа для поиска",
"search.no_results": "Ничего не найдено по запросу «{query}»",
"search.apps": "Приложения",
"search.boards": "Доски",
"search.nav_hint": "навигация",
"search.select_hint": "выбрать",
"search.close_hint": "закрыть",
"admin.import_export_title": "Импорт / Экспорт",
"admin.import_export_description": "Экспортируйте все данные (приложения, доски, группы, настройки) в формате JSON или импортируйте из ранее экспортированного файла.",
"admin.export_section": "Экспорт данных",
"admin.export_button": "Экспорт JSON",
"admin.export_exporting": "Экспорт...",
"admin.export_success": "Экспорт успешно скачан.",
"admin.import_section": "Импорт данных",
"admin.import_select_file": "Выберите JSON-файл экспорта",
"admin.import_preview": "Предпросмотр",
"admin.import_mode_label": "Разрешение конфликтов",
"admin.import_mode_skip": "Пропустить существующие (оставить текущие данные)",
"admin.import_mode_overwrite": "Перезаписать существующие (заменить импортированными)",
"admin.import_button": "Импортировать",
"admin.import_importing": "Импорт...",
"admin.import_success": "Импорт завершён.",
"admin.import_invalid_json": "Выбранный файл не является корректным JSON.",
"search.placeholder": "Поиск приложений и досок...",
"search.trigger": "Поиск...",
"search.min_chars": "Введите минимум 2 символа для поиска",
"search.no_results": "Ничего не найдено по запросу «{query}»",
"search.apps": "Приложения",
"search.boards": "Доски",
"search.nav_hint": "навигация",
"search.select_hint": "выбрать",
"search.close_hint": "закрыть",
"common.search_filter": "Фильтр...",
"common.save": "Сохранить",
"common.cancel": "Отмена",
"common.delete": "Удалить",
"common.create": "Создать",
"common.back": "Назад",
"common.edit": "Редактировать",
"common.add": "Добавить",
"common.confirm": "Подтвердить?",
"common.yes": "Да",
"common.no": "Нет",
"common.name": "Название",
"common.description": "Описание",
"common.required": "*",
"status.online": "Онлайн",
"status.offline": "Оффлайн",
"status.degraded": "Нестабильно",
"status.unknown": "Неизвестно",
"theme.dark": "Тёмная",
"theme.light": "Светлая",
"theme.system": "Системная",
"theme.toggle": "Переключить тему (текущая: {mode})",
"theme.title": "Тема: {mode}",
"bg.mesh": "Меш-градиент",
"bg.particles": "Частицы",
"bg.aurora": "Сияние",
"bg.none": "Нет",
"bg.title": "Эффект фона",
"bg.aria_label": "Изменить эффект фона",
"sidebar.expand": "Развернуть боковую панель",
"sidebar.collapse": "Свернуть боковую панель",
"sidebar.toggle": "Переключить боковую панель",
"sidebar.close": "Закрыть боковую панель",
"home.welcome": "Добро пожаловать, {name}. Доска по умолчанию ещё не настроена.",
"home.view_boards": "Посмотреть доски",
"home.browse_apps": "Обзор приложений",
"language.label": "Язык",
"settings.title": "Настройки",
"settings.theme": "Режим темы",
"settings.primary_color": "Основной цвет",
"settings.hue": "Оттенок",
"settings.saturation": "Насыщенность",
"settings.background": "Эффект фона",
"settings.language": "Язык",
"settings.save": "Сохранить настройки",
"settings.saving": "Сохранение...",
"settings.saved": "Настройки сохранены!",
"settings.bookmarklet_title": "Быстрое добавление (букмарклет)",
"settings.bookmarklet_instructions": "Перетащите кнопку ниже на панель закладок браузера. При посещении любой страницы нажмите её, чтобы быстро добавить сайт в App Launcher.",
"settings.bookmarklet_drag": "Добавить в Launcher",
"settings.bookmarklet_drag_hint": "Перетащите на панель закладок",
"settings.bookmarklet_show_code": "Показать код букмарклета",
"app.quick_add_title": "Быстрое добавление приложения",
"app.quick_add_description": "Проверьте данные ниже и сохраните, чтобы добавить приложение в лаунчер.",
"app.quick_add_success": "Приложение успешно добавлено!",
"app.quick_add_view_apps": "Посмотреть приложения",
"app.quick_add_close": "Закрыть окно",
"offline.title": "Нет подключения",
"offline.description": "Похоже, вы потеряли подключение к интернету. Проверьте сеть и попробуйте снова.",
"offline.retry": "Повторить",
"install.title": "Установить приложение",
"install.description": "Добавьте Web App Launcher на главный экран для быстрого доступа.",
"install.button": "Установить",
"install.dismiss": "Скрыть предложение установки"
"common.search_filter": "Фильтр...",
"common.save": "Сохранить",
"common.cancel": "Отмена",
"common.delete": "Удалить",
"common.create": "Создать",
"common.back": "Назад",
"common.edit": "Редактировать",
"common.add": "Добавить",
"common.confirm": "Подтвердить?",
"common.yes": "Да",
"common.no": "Нет",
"common.name": "Название",
"common.description": "Описание",
"common.required": "*",
"status.online": "Онлайн",
"status.offline": "Оффлайн",
"status.degraded": "Нестабильно",
"status.unknown": "Неизвестно",
"theme.dark": "Тёмная",
"theme.light": "Светлая",
"theme.system": "Системная",
"theme.toggle": "Переключить тему (текущая: {mode})",
"theme.title": "Тема: {mode}",
"bg.mesh": "Меш-градиент",
"bg.particles": "Частицы",
"bg.aurora": "Сияние",
"bg.none": "Нет",
"bg.title": "Эффект фона",
"bg.aria_label": "Изменить эффект фона",
"sidebar.expand": "Развернуть боковую панель",
"sidebar.collapse": "Свернуть боковую панель",
"sidebar.toggle": "Переключить боковую панель",
"sidebar.close": "Закрыть боковую панель",
"home.welcome": "Добро пожаловать, {name}. Доска по умолчанию ещё не настроена.",
"home.view_boards": "Посмотреть доски",
"home.browse_apps": "Обзор приложений",
"language.label": "Язык",
"settings.title": "Настройки",
"settings.theme": "Режим темы",
"settings.primary_color": "Основной цвет",
"settings.hue": "Оттенок",
"settings.saturation": "Насыщенность",
"settings.background": "Эффект фона",
"settings.language": "Язык",
"settings.save": "Сохранить настройки",
"settings.saving": "Сохранение...",
"settings.saved": "Настройки сохранены!",
"settings.bookmarklet_title": "Быстрое добавление (букмарклет)",
"settings.bookmarklet_instructions": "Перетащите кнопку ниже на панель закладок браузера. При посещении любой страницы нажмите её, чтобы быстро добавить сайт в App Launcher.",
"settings.bookmarklet_drag": "Добавить в Launcher",
"settings.bookmarklet_drag_hint": "Перетащите на панель закладок",
"settings.bookmarklet_show_code": "Показать код букмарклета",
"app.quick_add_title": "Быстрое добавление приложения",
"app.quick_add_description": "Проверьте данные ниже и сохраните, чтобы добавить приложение в лаунчер.",
"app.quick_add_success": "Приложение успешно добавлено!",
"app.quick_add_view_apps": "Посмотреть приложения",
"app.quick_add_close": "Закрыть окно",
"offline.title": "Нет подключения",
"offline.description": "Похоже, вы потеряли подключение к интернету. Проверьте сеть и попробуйте снова.",
"offline.retry": "Повторить",
"install.title": "Установить приложение",
"install.description": "Добавьте Web App Launcher на главный экран для быстрого доступа.",
"install.button": "Установить",
"install.dismiss": "Скрыть предложение установки"
}
+72 -1
View File
@@ -1,13 +1,49 @@
import cron from 'node-cron';
import { checkAllApps, pruneOldStatuses } from '$lib/server/services/healthcheckService.js';
import { broadcastNotification } from '$lib/server/services/notificationService.js';
import { pruneOldLogs } from '$lib/server/services/auditLogService.js';
import * as appService from '$lib/server/services/appService.js';
import { AppStatusValue, NotificationEvent } from '$lib/utils/constants.js';
let scheduledTask: cron.ScheduledTask | null = null;
let cleanupTask: cron.ScheduledTask | null = null;
let auditPruneTask: cron.ScheduledTask | null = null;
// Track previous status per app to detect transitions
const previousStatuses = new Map<string, string>();
/**
* Check if a status transition warrants a notification.
*/
function getStatusChangeEvent(
previousStatus: string | undefined,
newStatus: string
): string | null {
if (!previousStatus) {
return null; // First check — no transition
}
if (previousStatus === newStatus) {
return null; // No change
}
if (newStatus === AppStatusValue.OFFLINE && previousStatus !== AppStatusValue.OFFLINE) {
return NotificationEvent.APP_OFFLINE;
}
if (newStatus === AppStatusValue.ONLINE && previousStatus !== AppStatusValue.ONLINE) {
return NotificationEvent.APP_ONLINE;
}
if (newStatus === AppStatusValue.DEGRADED && previousStatus !== AppStatusValue.DEGRADED) {
return NotificationEvent.APP_DEGRADED;
}
return null;
}
/**
* Start the healthcheck scheduler.
* Runs checkAllApps on a cron schedule (default: every 60 seconds).
* Also starts an hourly cleanup job to prune old status records.
* Triggers notifications when app status changes.
*/
export function startScheduler(cronExpression: string = '* * * * *'): void {
if (scheduledTask) {
@@ -16,7 +52,29 @@ export function startScheduler(cronExpression: string = '* * * * *'): void {
scheduledTask = cron.schedule(cronExpression, async () => {
try {
await checkAllApps();
const results = await checkAllApps();
// Check for status transitions and send notifications
for (const result of results) {
const prevStatus = previousStatuses.get(result.appId);
const event = getStatusChangeEvent(prevStatus, result.status);
if (event) {
// Fire-and-forget notification
appService
.findById(result.appId)
.then((app) => {
const statusLabel = result.status.charAt(0).toUpperCase() + result.status.slice(1);
const message = `${app.name} is now ${statusLabel} (was ${prevStatus ?? 'unknown'})`;
return broadcastNotification(result.appId, event, message);
})
.catch(() => {
// Swallow notification errors
});
}
previousStatuses.set(result.appId, result.status);
}
} catch {
// Swallow errors to prevent scheduler crash
}
@@ -31,6 +89,15 @@ export function startScheduler(cronExpression: string = '* * * * *'): void {
}
});
// Audit log pruning: run daily at midnight
auditPruneTask = cron.schedule('0 0 * * *', async () => {
try {
await pruneOldLogs(90); // Default 90 day retention
} catch {
// Swallow errors to prevent scheduler crash
}
});
// Run an initial check shortly after startup
setTimeout(() => {
checkAllApps().catch(() => {
@@ -51,4 +118,8 @@ export function stopScheduler(): void {
cleanupTask.stop();
cleanupTask = null;
}
if (auditPruneTask) {
auditPruneTask.stop();
auditPruneTask = null;
}
}
+24
View File
@@ -5,10 +5,16 @@ import type { RequestEvent } from '@sveltejs/kit';
* Reusable authentication check helper.
* Throws a redirect to /login if the user is not authenticated.
* Returns the authenticated user from event.locals.
*
* For API routes, also checks for Bearer token in Authorization header.
* If a valid API token is found, the user is set from the token's owner.
*/
export function requireAuth(event: RequestEvent) {
const user = event.locals.user;
if (!user) {
// For API routes, redirect is not appropriate — but we keep the behavior
// consistent with the existing codebase. The hooks.server.ts handles
// API token validation and sets event.locals.user before routes run.
throw redirect(302, '/login');
}
return user;
@@ -20,3 +26,21 @@ export function requireAuth(event: RequestEvent) {
export function isAuthenticated(event: RequestEvent): boolean {
return event.locals.user !== null;
}
/**
* Extract Bearer token from Authorization header, if present.
* Returns the token string or null.
*/
export function extractBearerToken(event: RequestEvent): string | null {
const authHeader = event.request.headers.get('authorization');
if (!authHeader) {
return null;
}
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
return null;
}
return parts[1];
}
@@ -51,7 +51,10 @@ describe('appService', () => {
expect(mockApp.findMany).toHaveBeenCalledWith({
where: {},
orderBy: { name: 'asc' },
include: { statuses: { orderBy: { checkedAt: 'desc' }, take: 1 } }
include: {
links: { orderBy: { order: 'asc' } },
statuses: { orderBy: { checkedAt: 'desc' }, take: 1 }
}
});
});
@@ -152,10 +155,7 @@ describe('appService', () => {
describe('getCategories', () => {
it('returns unique categories', async () => {
mockApp.findMany.mockResolvedValue([
{ category: 'Media' },
{ category: 'Monitoring' }
]);
mockApp.findMany.mockResolvedValue([{ category: 'Media' }, { category: 'Monitoring' }]);
const result = await appService.getCategories();
@@ -152,7 +152,8 @@ describe('boardService', () => {
const result = await boardService.createWidget({
sectionId: 's1',
type: 'app'
type: 'app',
config: JSON.stringify({ appId: 'test-app-1' })
});
expect(result.type).toBe('app');
@@ -148,9 +148,7 @@ describe('discoveryService', () => {
it('returns error on Traefik API failure', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() =>
Promise.resolve({ ok: false, status: 500 })
)
vi.fn(() => Promise.resolve({ ok: false, status: 500 }))
);
const result = await discoverTraefik('http://traefik.local:8080');
@@ -67,9 +67,7 @@ describe('groupService', () => {
it('throws on duplicate name', async () => {
mockGroup.findUnique.mockResolvedValue({ id: '1', name: 'Existing' });
await expect(groupService.create({ name: 'Existing' })).rejects.toThrow(
'already exists'
);
await expect(groupService.create({ name: 'Existing' })).rejects.toThrow('already exists');
});
});
@@ -121,8 +119,9 @@ describe('groupService', () => {
{ id: 'g2', name: 'Default2', isDefault: true }
]);
mockUserGroup.findUnique.mockResolvedValue(null);
mockUserGroup.create.mockImplementation(({ data }: { data: { userId: string; groupId: string } }) =>
Promise.resolve({ id: `ug-${data.groupId}`, ...data })
mockUserGroup.create.mockImplementation(
({ data }: { data: { userId: string; groupId: string } }) =>
Promise.resolve({ id: `ug-${data.groupId}`, ...data })
);
const results = await groupService.addUserToDefaultGroups('u1');
@@ -182,9 +182,7 @@ describe('importService', () => {
icon: null,
order: 0,
isExpandedByDefault: true,
widgets: [
{ type: 'note', order: 0, config: '{}', appName: null }
]
widgets: [{ type: 'note', order: 0, config: '{}', appName: null }]
}
]
}
@@ -95,7 +95,11 @@ describe('oauthService', () => {
new URL('https://auth.example.com/authorize?code_challenge=abc')
);
const url = await generateAuthUrl('https://app.example.com/callback', 'test-challenge', 'test-state');
const url = await generateAuthUrl(
'https://app.example.com/callback',
'test-challenge',
'test-state'
);
expect(url).toBe('https://auth.example.com/authorize?code_challenge=abc');
expect(mockClient.buildAuthorizationUrl).toHaveBeenCalledWith(
@@ -29,12 +29,7 @@ describe('permissionService', () => {
it('grants full access to admins', async () => {
mockUser.findUnique.mockResolvedValue({ role: 'admin' });
const result = await permissionService.checkPermission(
'board',
'b1',
'admin-user',
'edit'
);
const result = await permissionService.checkPermission('board', 'b1', 'admin-user', 'edit');
expect(result.hasPermission).toBe(true);
expect(result.effectiveLevel).toBe('admin');
@@ -45,12 +40,7 @@ describe('permissionService', () => {
mockUser.findUnique.mockResolvedValue({ role: 'user' });
mockPermission.findFirst.mockResolvedValue({ level: 'edit' });
const result = await permissionService.checkPermission(
'board',
'b1',
'user1',
'view'
);
const result = await permissionService.checkPermission('board', 'b1', 'user1', 'view');
expect(result.hasPermission).toBe(true);
expect(result.effectiveLevel).toBe('edit');
@@ -61,12 +51,7 @@ describe('permissionService', () => {
mockUser.findUnique.mockResolvedValue({ role: 'user' });
mockPermission.findFirst.mockResolvedValue({ level: 'view' });
const result = await permissionService.checkPermission(
'board',
'b1',
'user1',
'admin'
);
const result = await permissionService.checkPermission('board', 'b1', 'user1', 'admin');
expect(result.hasPermission).toBe(false);
});
@@ -77,12 +62,7 @@ describe('permissionService', () => {
mockUserGroup.findMany.mockResolvedValue([{ groupId: 'g1' }]);
mockPermission.findMany.mockResolvedValue([{ level: 'edit' }]);
const result = await permissionService.checkPermission(
'board',
'b1',
'user1',
'view'
);
const result = await permissionService.checkPermission('board', 'b1', 'user1', 'view');
expect(result.hasPermission).toBe(true);
expect(result.source).toBe('group');
@@ -93,12 +73,7 @@ describe('permissionService', () => {
mockPermission.findFirst.mockResolvedValue(null);
mockUserGroup.findMany.mockResolvedValue([]);
const result = await permissionService.checkPermission(
'board',
'b1',
'user1',
'view'
);
const result = await permissionService.checkPermission('board', 'b1', 'user1', 'view');
expect(result.hasPermission).toBe(false);
expect(result.effectiveLevel).toBeNull();
@@ -131,9 +131,7 @@ describe('userService', () => {
describe('getUserGroups', () => {
it('returns user group memberships', async () => {
mockUserGroup.findMany.mockResolvedValue([
{ group: { id: 'g1', name: 'Devs' } }
]);
mockUserGroup.findMany.mockResolvedValue([{ group: { id: 'g1', name: 'Devs' } }]);
const result = await userService.getUserGroups('u1');
expect(result).toEqual([{ id: 'g1', name: 'Devs' }]);
+127
View File
@@ -0,0 +1,127 @@
import { randomBytes, createHash } from 'crypto';
import bcrypt from 'bcryptjs';
import { prisma } from '../prisma.js';
const BCRYPT_ROUNDS = 10;
/**
* Hash a token string using SHA-256 for fast lookup, then bcrypt for storage.
* We use SHA-256 as an intermediate to create a fixed-length input for bcrypt
* (bcrypt has a 72-byte limit).
*/
function sha256(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
/**
* Generate a new API token. Returns the plaintext token (shown once) and the DB record.
*/
export async function generateToken(
userId: string,
name: string,
scope: string,
expiresAt?: string
) {
const plainToken = randomBytes(32).toString('hex');
const tokenHash = await bcrypt.hash(sha256(plainToken), BCRYPT_ROUNDS);
const token = await prisma.apiToken.create({
data: {
userId,
name,
tokenHash,
scope,
expiresAt: expiresAt ? new Date(expiresAt) : null
}
});
return {
id: token.id,
name: token.name,
scope: token.scope,
expiresAt: token.expiresAt,
createdAt: token.createdAt,
token: plainToken // Only returned once at creation time
};
}
/**
* Revoke (delete) an API token.
*/
export async function revokeToken(tokenId: string, userId: string) {
const token = await prisma.apiToken.findUnique({ where: { id: tokenId } });
if (!token || token.userId !== userId) {
throw new Error('API token not found');
}
await prisma.apiToken.delete({ where: { id: tokenId } });
}
/**
* List all tokens for a user (without the hash).
*/
export async function listTokens(userId: string) {
const tokens = await prisma.apiToken.findMany({
where: { userId },
select: {
id: true,
name: true,
scope: true,
lastUsedAt: true,
expiresAt: true,
createdAt: true
},
orderBy: { createdAt: 'desc' }
});
return tokens;
}
/**
* Validate a plaintext token string. Returns the user info if valid, null otherwise.
* Updates lastUsedAt on successful validation.
*/
export async function validateToken(tokenString: string): Promise<{
readonly userId: string;
readonly scope: string;
} | null> {
const tokenSha = sha256(tokenString);
// We need to check against all tokens since bcrypt hashes are unique per-hash.
// For better performance at scale, consider indexing on a prefix or using a different scheme.
const allTokens = await prisma.apiToken.findMany({
select: {
id: true,
userId: true,
tokenHash: true,
scope: true,
expiresAt: true
}
});
for (const token of allTokens) {
const isMatch = await bcrypt.compare(tokenSha, token.tokenHash);
if (isMatch) {
// Check expiry
if (token.expiresAt && token.expiresAt < new Date()) {
return null; // Token expired
}
// Update lastUsedAt (fire-and-forget)
prisma.apiToken
.update({
where: { id: token.id },
data: { lastUsedAt: new Date() }
})
.catch(() => {
// Swallow errors from lastUsedAt update
});
return {
userId: token.userId,
scope: token.scope
};
}
}
return null;
}
+88 -6
View File
@@ -23,6 +23,9 @@ export async function findAll(options?: { category?: string; search?: string })
statuses: {
orderBy: { checkedAt: 'desc' },
take: 1
},
links: {
orderBy: { order: 'asc' }
}
}
});
@@ -38,6 +41,9 @@ export async function findById(id: string) {
},
createdBy: {
select: { id: true, displayName: true }
},
links: {
orderBy: { order: 'asc' }
}
}
});
@@ -81,7 +87,8 @@ export async function update(id: string, input: UpdateAppInput) {
if (input.healthcheckEnabled !== undefined) data.healthcheckEnabled = input.healthcheckEnabled;
if (input.healthcheckInterval !== undefined) data.healthcheckInterval = input.healthcheckInterval;
if (input.healthcheckMethod !== undefined) data.healthcheckMethod = input.healthcheckMethod;
if (input.healthcheckExpectedStatus !== undefined) data.healthcheckExpectedStatus = input.healthcheckExpectedStatus;
if (input.healthcheckExpectedStatus !== undefined)
data.healthcheckExpectedStatus = input.healthcheckExpectedStatus;
if (input.healthcheckTimeout !== undefined) data.healthcheckTimeout = input.healthcheckTimeout;
return prisma.app.update({
@@ -95,11 +102,7 @@ export async function remove(id: string) {
await prisma.app.delete({ where: { id } });
}
export async function recordStatus(
appId: string,
status: string,
responseTime: number | null
) {
export async function recordStatus(appId: string, status: string, responseTime: number | null) {
return prisma.appStatus.create({
data: {
appId,
@@ -138,6 +141,85 @@ export async function getHealthcheckTargets() {
});
}
// --- App Links (Multi-URL) ---
export async function addAppLink(
appId: string,
input: { label: string; url: string; icon?: string | null; order?: number }
) {
await findById(appId);
let order = input.order;
if (order === undefined) {
const maxLink = await prisma.appLink.findFirst({
where: { appId },
orderBy: { order: 'desc' },
select: { order: true }
});
order = (maxLink?.order ?? -1) + 1;
}
return prisma.appLink.create({
data: {
appId,
label: input.label,
url: input.url,
icon: input.icon ?? null,
order
}
});
}
export async function updateAppLink(
linkId: string,
input: { label?: string; url?: string; icon?: string | null; order?: number }
) {
const link = await prisma.appLink.findUnique({ where: { id: linkId } });
if (!link) {
throw new Error(`App link not found: ${linkId}`);
}
const data: Record<string, unknown> = {};
if (input.label !== undefined) data.label = input.label;
if (input.url !== undefined) data.url = input.url;
if (input.icon !== undefined) data.icon = input.icon;
if (input.order !== undefined) data.order = input.order;
return prisma.appLink.update({
where: { id: linkId },
data
});
}
export async function removeAppLink(linkId: string) {
const link = await prisma.appLink.findUnique({ where: { id: linkId } });
if (!link) {
throw new Error(`App link not found: ${linkId}`);
}
await prisma.appLink.delete({ where: { id: linkId } });
}
export async function reorderAppLinks(appId: string, linkIds: string[]) {
await findById(appId);
const updates = linkIds.map((id, index) =>
prisma.appLink.update({
where: { id },
data: { order: index }
})
);
return prisma.$transaction(updates);
}
export async function getAppLinks(appId: string) {
return prisma.appLink.findMany({
where: { appId },
orderBy: { order: 'asc' }
});
}
export async function getCategories() {
const apps = await prisma.app.findMany({
where: { category: { not: null } },
@@ -0,0 +1,98 @@
import { prisma } from '../prisma.js';
/**
* Record an audit log entry. Non-blocking: catches and swallows errors
* to avoid slowing down the operation being audited.
*/
export function logAction(
userId: string | null,
action: string,
entityType: string,
entityId: string,
details?: Record<string, unknown>
): void {
prisma.auditLog
.create({
data: {
userId,
action,
entityType,
entityId,
details: details ? JSON.stringify(details) : '{}'
}
})
.catch(() => {
// Non-blocking: swallow errors so the parent operation is unaffected
});
}
/**
* Query audit logs with filters and pagination.
*/
export async function getAuditLogs(options?: {
action?: string;
entityType?: string;
userId?: string;
startDate?: string;
endDate?: string;
limit?: number;
offset?: number;
}) {
const where: Record<string, unknown> = {};
if (options?.action) {
where.action = options.action;
}
if (options?.entityType) {
where.entityType = options.entityType;
}
if (options?.userId) {
where.userId = options.userId;
}
const dateFilter: Record<string, Date> = {};
if (options?.startDate) {
dateFilter.gte = new Date(options.startDate);
}
if (options?.endDate) {
dateFilter.lte = new Date(options.endDate);
}
if (Object.keys(dateFilter).length > 0) {
where.createdAt = dateFilter;
}
const limit = options?.limit ?? 50;
const offset = options?.offset ?? 0;
const [logs, total] = await Promise.all([
prisma.auditLog.findMany({
where,
orderBy: { createdAt: 'desc' },
take: limit,
skip: offset,
include: {
user: {
select: { id: true, displayName: true, email: true }
}
}
}),
prisma.auditLog.count({ where })
]);
return { logs, total };
}
/**
* Delete audit logs older than the given retention period.
*/
export async function pruneOldLogs(retentionDays: number = 90) {
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000);
const result = await prisma.auditLog.deleteMany({
where: {
createdAt: { lt: cutoff }
}
});
return result.count;
}
+6 -5
View File
@@ -78,10 +78,7 @@ export async function saveRefreshToken(userId: string, refreshToken: string): Pr
});
}
export async function validateRefreshToken(
userId: string,
refreshToken: string
): Promise<boolean> {
export async function validateRefreshToken(userId: string, refreshToken: string): Promise<boolean> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { refreshToken: true, refreshTokenExpiresAt: true }
@@ -108,7 +105,11 @@ export async function revokeRefreshToken(userId: string): Promise<void> {
});
}
export async function rotateTokens(userId: string, email: string, role: string): Promise<TokenPair> {
export async function rotateTokens(
userId: string,
email: string,
role: string
): Promise<TokenPair> {
const accessToken = signAccessToken({ userId, email, role });
const refreshToken = generateRefreshToken();
await saveRefreshToken(userId, refreshToken);
+89 -4
View File
@@ -1,6 +1,74 @@
import { prisma } from '../prisma.js';
import type { CreateBoardInput, UpdateBoardInput, CreateSectionInput, UpdateSectionInput } from '$lib/types/board.js';
import type {
CreateBoardInput,
UpdateBoardInput,
CreateSectionInput,
UpdateSectionInput
} from '$lib/types/board.js';
import type { CreateWidgetInput, UpdateWidgetInput } from '$lib/types/widget.js';
import { WidgetType } from '$lib/utils/constants.js';
import {
appWidgetConfigSchema,
bookmarkWidgetConfigSchema,
noteWidgetConfigSchema,
embedWidgetConfigSchema,
statusWidgetConfigSchema,
clockWeatherWidgetConfigSchema,
systemStatsWidgetConfigSchema,
rssWidgetConfigSchema,
calendarWidgetConfigSchema,
markdownWidgetConfigSchema,
metricWidgetConfigSchema,
linkGroupWidgetConfigSchema,
cameraWidgetConfigSchema
} from '$lib/utils/validators.js';
import type { ZodTypeAny } from 'zod';
/**
* Map of widget types to their config validation schemas.
*/
const widgetConfigSchemas: Record<string, ZodTypeAny> = {
[WidgetType.APP]: appWidgetConfigSchema,
[WidgetType.BOOKMARK]: bookmarkWidgetConfigSchema,
[WidgetType.NOTE]: noteWidgetConfigSchema,
[WidgetType.EMBED]: embedWidgetConfigSchema,
[WidgetType.STATUS]: statusWidgetConfigSchema,
[WidgetType.CLOCK]: clockWeatherWidgetConfigSchema,
[WidgetType.SYSTEM_STATS]: systemStatsWidgetConfigSchema,
[WidgetType.RSS]: rssWidgetConfigSchema,
[WidgetType.CALENDAR]: calendarWidgetConfigSchema,
[WidgetType.MARKDOWN]: markdownWidgetConfigSchema,
[WidgetType.METRIC]: metricWidgetConfigSchema,
[WidgetType.LINK_GROUP]: linkGroupWidgetConfigSchema,
[WidgetType.CAMERA]: cameraWidgetConfigSchema
};
/**
* Validate widget config JSON string against the schema for its widget type.
* Returns the validated config string, or throws if invalid.
*/
function validateWidgetConfig(type: string, configStr: string): string {
const schema = widgetConfigSchemas[type];
if (!schema) {
// Unknown widget type — allow any config to avoid breaking extensibility
return configStr;
}
let parsed: unknown;
try {
parsed = JSON.parse(configStr);
} catch {
throw new Error('Widget config is not valid JSON');
}
const result = schema.safeParse(parsed);
if (!result.success) {
const messages = result.error.errors.map((e: { message: string }) => e.message).join(', ');
throw new Error(`Invalid widget config: ${messages}`);
}
return configStr;
}
// --- Board ---
@@ -135,6 +203,14 @@ export async function updateBoard(id: string, input: UpdateBoardInput) {
if (input.isDefault !== undefined) data.isDefault = input.isDefault;
if (input.isGuestAccessible !== undefined) data.isGuestAccessible = input.isGuestAccessible;
if (input.backgroundConfig !== undefined) data.backgroundConfig = input.backgroundConfig;
if (input.themeHue !== undefined) data.themeHue = input.themeHue;
if (input.themeSaturation !== undefined) data.themeSaturation = input.themeSaturation;
if (input.backgroundType !== undefined) data.backgroundType = input.backgroundType;
if (input.cardSize !== undefined) data.cardSize = input.cardSize;
if (input.wallpaperUrl !== undefined) data.wallpaperUrl = input.wallpaperUrl;
if (input.wallpaperBlur !== undefined) data.wallpaperBlur = input.wallpaperBlur;
if (input.wallpaperOverlay !== undefined) data.wallpaperOverlay = input.wallpaperOverlay;
if (input.customCss !== undefined) data.customCss = input.customCss;
return prisma.board.update({
where: { id },
@@ -195,6 +271,7 @@ export async function updateSection(id: string, input: UpdateSectionInput) {
if (input.icon !== undefined) data.icon = input.icon;
if (input.order !== undefined) data.order = input.order;
if (input.isExpandedByDefault !== undefined) data.isExpandedByDefault = input.isExpandedByDefault;
if (input.cardSize !== undefined) data.cardSize = input.cardSize;
return prisma.section.update({
where: { id },
@@ -231,24 +308,32 @@ export async function createWidget(input: CreateWidgetInput) {
order = (maxWidget?.order ?? -1) + 1;
}
const configStr = input.config ?? '{}';
validateWidgetConfig(input.type, configStr);
return prisma.widget.create({
data: {
sectionId: input.sectionId,
type: input.type,
order,
config: input.config ?? '{}',
config: configStr,
appId: input.appId ?? null
}
});
}
export async function updateWidget(id: string, input: UpdateWidgetInput) {
await findWidgetById(id);
const existing = await findWidgetById(id);
const data: Record<string, unknown> = {};
if (input.type !== undefined) data.type = input.type;
if (input.order !== undefined) data.order = input.order;
if (input.config !== undefined) data.config = input.config;
if (input.config !== undefined) {
// Validate config against the widget type (use new type if provided, else existing type)
const effectiveType = input.type ?? existing.type;
validateWidgetConfig(effectiveType, input.config);
data.config = input.config;
}
if (input.appId !== undefined) data.appId = input.appId;
return prisma.widget.update({
+238
View File
@@ -0,0 +1,238 @@
/**
* Calendar service — fetches and parses iCal (.ics) files.
* Uses lightweight hand-parsing of VEVENT blocks (no heavy dependencies).
*/
const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
const FETCH_TIMEOUT_MS = 10_000;
const DEFAULT_DAYS_AHEAD = 14;
interface CacheEntry {
readonly data: string; // raw ical text
readonly expiresAt: number;
}
export interface CalendarEvent {
readonly summary: string;
readonly start: string;
readonly end: string;
readonly location: string | null;
readonly calendarLabel: string;
readonly calendarColor: string;
}
export interface CalendarSource {
readonly url: string;
readonly color?: string;
readonly label?: string;
}
const cache = new Map<string, CacheEntry>();
function getCached(key: string): string | null {
const entry = cache.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
cache.delete(key);
return null;
}
return entry.data;
}
function setCache(key: string, data: string): void {
cache.set(key, {
data,
expiresAt: Date.now() + CACHE_TTL_MS
});
}
/**
* Parse an iCal date string (YYYYMMDD, YYYYMMDDTHHmmssZ, YYYYMMDDTHHmmss).
*/
function parseIcalDate(dateStr: string): Date | null {
if (!dateStr) return null;
// Remove TZID parameter prefix if present
const clean = dateStr.replace(/^.*:/, '').trim();
// All-day event: YYYYMMDD
if (/^\d{8}$/.test(clean)) {
const year = parseInt(clean.substring(0, 4), 10);
const month = parseInt(clean.substring(4, 6), 10) - 1;
const day = parseInt(clean.substring(6, 8), 10);
return new Date(year, month, day);
}
// DateTime: YYYYMMDDTHHmmss or YYYYMMDDTHHmmssZ
const dtMatch = clean.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/);
if (dtMatch) {
const [, year, month, day, hour, minute, second, utc] = dtMatch;
if (utc === 'Z') {
return new Date(
Date.UTC(
parseInt(year, 10),
parseInt(month, 10) - 1,
parseInt(day, 10),
parseInt(hour, 10),
parseInt(minute, 10),
parseInt(second, 10)
)
);
}
return new Date(
parseInt(year, 10),
parseInt(month, 10) - 1,
parseInt(day, 10),
parseInt(hour, 10),
parseInt(minute, 10),
parseInt(second, 10)
);
}
return null;
}
/**
* Extract a property value from an iCal VEVENT block.
* Handles folded lines (continuation lines starting with space/tab).
*/
function extractProperty(block: string, property: string): string {
// Match property with optional parameters (e.g., DTSTART;TZID=...:value)
const regex = new RegExp(`^${property}[;:](.*)$`, 'im');
const match = block.match(regex);
if (!match) return '';
const value = match[1];
// If the property had parameters (;PARAM=value:actualValue), extract just the value
if (property === 'DTSTART' || property === 'DTEND') {
// Keep the full string — parseIcalDate handles TZID prefix
return value.trim();
}
return value.trim();
}
/**
* Parse VEVENT blocks from iCal text.
*/
function parseVEvents(
icalText: string
): Array<{ summary: string; start: string; end: string; location: string }> {
const events: Array<{ summary: string; start: string; end: string; location: string }> = [];
// Unfold continuation lines (RFC 5545: lines starting with space/tab are continuations)
const unfolded = icalText.replace(/\r?\n[ \t]/g, '');
const eventRegex = /BEGIN:VEVENT([\s\S]*?)END:VEVENT/gi;
let match: RegExpExecArray | null;
while ((match = eventRegex.exec(unfolded)) !== null) {
const block = match[1];
const summary = extractProperty(block, 'SUMMARY');
const dtStart = extractProperty(block, 'DTSTART');
const dtEnd = extractProperty(block, 'DTEND');
const location = extractProperty(block, 'LOCATION');
const startDate = parseIcalDate(dtStart);
if (!startDate) continue;
const endDate = parseIcalDate(dtEnd);
events.push({
summary: summary || 'Untitled Event',
start: startDate.toISOString(),
end: endDate ? endDate.toISOString() : startDate.toISOString(),
location: location || ''
});
}
return events;
}
/**
* Fetch iCal text from a URL.
*/
async function fetchIcalText(url: string): Promise<string> {
const cached = getCached(url);
if (cached) return cached;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(url, {
signal: controller.signal,
headers: {
'User-Agent': 'WebAppLauncher/1.0',
Accept: 'text/calendar, application/ics'
}
});
if (!response.ok) {
throw new Error(`Calendar source returned ${response.status}`);
}
const text = await response.text();
setCache(url, text);
return text;
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
throw new Error('Calendar request timed out');
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}
/**
* Fetch and parse events from multiple iCal URLs, merged and sorted by start time.
*/
export async function fetchCalendarEvents(
sources: readonly CalendarSource[],
daysAhead?: number
): Promise<readonly CalendarEvent[]> {
const days = daysAhead ?? DEFAULT_DAYS_AHEAD;
const now = new Date();
const cutoff = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
const allEvents: CalendarEvent[] = [];
const results = await Promise.allSettled(
sources.map(async (source) => {
const icalText = await fetchIcalText(source.url);
const events = parseVEvents(icalText);
return events
.filter((event) => {
const start = new Date(event.start);
return start >= now && start <= cutoff;
})
.map((event) => ({
summary: event.summary,
start: event.start,
end: event.end,
location: event.location || null,
calendarLabel: source.label ?? 'Calendar',
calendarColor: source.color ?? '#6366f1'
}));
})
);
for (const result of results) {
if (result.status === 'fulfilled') {
allEvents.push(...result.value);
}
}
// Sort by start time ascending
return [...allEvents].sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
}
/**
* Clear the calendar cache.
*/
export function clearCache(): void {
cache.clear();
}
+149
View File
@@ -0,0 +1,149 @@
/**
* Camera/Stream proxy service — proxies image requests to camera URLs.
* Includes SSRF protection to reject private IP ranges.
*/
const FETCH_TIMEOUT_MS = 10_000;
const RATE_LIMIT_INTERVAL_MS = 5_000; // Max 1 request per 5s per URL
const lastFetchTimes = new Map<string, number>();
/**
* Check if a hostname resolves to a private/reserved IP range.
* Prevents SSRF attacks by blocking requests to internal networks.
*/
function isPrivateOrReservedHost(hostname: string): boolean {
// Block obvious private hostnames
if (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '::1' ||
hostname === '0.0.0.0'
) {
return true;
}
// Check IPv4 private ranges
const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (ipv4Match) {
const [, a, b] = ipv4Match.map(Number);
// 10.0.0.0/8
if (a === 10) return true;
// 172.16.0.0/12
if (a === 172 && b >= 16 && b <= 31) return true;
// 192.168.0.0/16
if (a === 192 && b === 168) return true;
// 127.0.0.0/8
if (a === 127) return true;
// 169.254.0.0/16 (link-local)
if (a === 169 && b === 254) return true;
// 0.0.0.0/8
if (a === 0) return true;
}
// Block IPv6 private ranges (simplified check)
if (hostname.startsWith('fe80:') || hostname.startsWith('fc') || hostname.startsWith('fd')) {
return true;
}
return false;
}
/**
* Validate a URL for camera proxying.
* Only allows http/https and rejects private IPs.
*/
export function validateStreamUrl(urlStr: string): { valid: boolean; error?: string } {
let parsed: URL;
try {
parsed = new URL(urlStr);
} catch {
return { valid: false, error: 'Invalid URL format' };
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return { valid: false, error: 'Only http and https protocols are allowed' };
}
if (isPrivateOrReservedHost(parsed.hostname)) {
return { valid: false, error: 'Requests to private/reserved IP ranges are not allowed' };
}
return { valid: true };
}
/**
* Check rate limit for a given URL.
*/
function checkRateLimit(url: string): boolean {
const lastFetch = lastFetchTimes.get(url);
if (!lastFetch) return true;
return Date.now() - lastFetch >= RATE_LIMIT_INTERVAL_MS;
}
function recordFetch(url: string): void {
lastFetchTimes.set(url, Date.now());
}
export interface CameraSnapshot {
readonly buffer: Buffer;
readonly contentType: string;
}
/**
* Fetch a snapshot image from a camera URL.
* Proxies the HTTP request and returns the image buffer.
*/
export async function fetchSnapshot(url: string): Promise<CameraSnapshot> {
// Validate URL
const validation = validateStreamUrl(url);
if (!validation.valid) {
throw new Error(validation.error ?? 'Invalid stream URL');
}
// Rate limit check
if (!checkRateLimit(url)) {
throw new Error('Rate limited: please wait before requesting this camera again');
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
recordFetch(url);
const response = await fetch(url, {
signal: controller.signal,
headers: { 'User-Agent': 'WebAppLauncher/1.0' }
});
if (!response.ok) {
throw new Error(`Camera returned ${response.status}`);
}
const contentType = response.headers.get('content-type') ?? 'image/jpeg';
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
if (buffer.length === 0) {
throw new Error('Camera returned empty response');
}
return { buffer, contentType };
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
throw new Error('Camera request timed out');
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}
/**
* Clear the rate limit tracking.
*/
export function clearRateLimits(): void {
lastFetchTimes.clear();
}
+4 -5
View File
@@ -123,8 +123,9 @@ export async function discoverDocker(socketPath: string): Promise<{
continue; // Skip containers without accessible URLs
}
const description = container.Labels['org.opencontainers.image.description']
?? `Docker container: ${container.Image}`;
const description =
container.Labels['org.opencontainers.image.description'] ??
`Docker container: ${container.Image}`;
services.push({
name,
@@ -187,9 +188,7 @@ export async function discoverTraefik(apiUrl: string): Promise<{
const host = extractHostFromRule(router.rule);
if (!host) continue;
const isSecure = router.entryPoints?.some(
(ep) => ep === 'websecure' || ep === 'https'
);
const isSecure = router.entryPoints?.some((ep) => ep === 'websecure' || ep === 'https');
const frontendUrl = `${isSecure ? 'https' : 'http'}://${host}`;
// Derive a clean name from the router name (strip @provider suffix)
@@ -0,0 +1,83 @@
import { prisma } from '../prisma.js';
/**
* Get user's favorite apps, ordered by position.
*/
export async function getUserFavorites(userId: string) {
return prisma.userFavorite.findMany({
where: { userId },
orderBy: { order: 'asc' },
include: {
app: {
include: {
statuses: {
orderBy: { checkedAt: 'desc' },
take: 1
}
}
}
}
});
}
/**
* Add an app to user's favorites (append to end).
*/
export async function addFavorite(userId: string, appId: string) {
// Check if already favorited
const existing = await prisma.userFavorite.findUnique({
where: { userId_appId: { userId, appId } }
});
if (existing) {
throw new Error('App is already in favorites');
}
// Get the next order value
const maxFav = await prisma.userFavorite.findFirst({
where: { userId },
orderBy: { order: 'desc' },
select: { order: true }
});
const nextOrder = (maxFav?.order ?? -1) + 1;
return prisma.userFavorite.create({
data: {
userId,
appId,
order: nextOrder
},
include: {
app: true
}
});
}
/**
* Remove an app from user's favorites.
*/
export async function removeFavorite(userId: string, appId: string) {
const existing = await prisma.userFavorite.findUnique({
where: { userId_appId: { userId, appId } }
});
if (!existing) {
throw new Error('App is not in favorites');
}
await prisma.userFavorite.delete({
where: { userId_appId: { userId, appId } }
});
}
/**
* Reorder user's favorites by setting order based on array position.
*/
export async function reorderFavorites(userId: string, favoriteIds: string[]) {
const updates = favoriteIds.map((id, index) =>
prisma.userFavorite.update({
where: { id },
data: { order: index }
})
);
return prisma.$transaction(updates);
}
+1 -3
View File
@@ -118,8 +118,6 @@ export async function getGroupMembers(groupId: string) {
export async function addUserToDefaultGroups(userId: string) {
const defaultGroups = await findDefaultGroups();
const results = await Promise.all(
defaultGroups.map((group) => addUser(group.id, userId))
);
const results = await Promise.all(defaultGroups.map((group) => addUser(group.id, userId)));
return results;
}
+9 -4
View File
@@ -12,7 +12,9 @@ export interface ImportResult {
readonly settingsUpdated: boolean;
}
export function validateImportData(data: unknown): { success: true; data: ExportData } | { success: false; errors: string[] } {
export function validateImportData(
data: unknown
): { success: true; data: ExportData } | { success: false; errors: string[] } {
const parsed = importDataSchema.safeParse(data);
if (!parsed.success) {
const errors = parsed.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`);
@@ -206,10 +208,13 @@ export async function importData(data: ExportData, mode: ImportMode): Promise<Im
const s = data.settings;
if (s.authMode !== undefined) settingsData.authMode = s.authMode;
if (s.registrationEnabled !== undefined) settingsData.registrationEnabled = s.registrationEnabled;
if (s.registrationEnabled !== undefined)
settingsData.registrationEnabled = s.registrationEnabled;
if (s.defaultTheme !== undefined) settingsData.defaultTheme = s.defaultTheme;
if (s.defaultPrimaryColor !== undefined) settingsData.defaultPrimaryColor = s.defaultPrimaryColor;
if (s.healthcheckDefaults !== undefined) settingsData.healthcheckDefaults = s.healthcheckDefaults;
if (s.defaultPrimaryColor !== undefined)
settingsData.defaultPrimaryColor = s.defaultPrimaryColor;
if (s.healthcheckDefaults !== undefined)
settingsData.healthcheckDefaults = s.healthcheckDefaults;
if (Object.keys(settingsData).length > 0) {
await tx.systemSettings.upsert({
+227
View File
@@ -0,0 +1,227 @@
/**
* Metric/Counter service — fetches single numeric values from various sources.
* Supports static values, JSON endpoints with dot-path extraction, and Prometheus queries.
* Tracks previous values for trend calculation.
*/
const DEFAULT_CACHE_TTL_MS = 60_000; // 1 minute
const FETCH_TIMEOUT_MS = 10_000;
interface CacheEntry {
readonly data: MetricResult;
readonly expiresAt: number;
}
export interface MetricResult {
readonly value: number;
readonly previousValue: number | null;
readonly trend: 'up' | 'down' | 'flat';
readonly unit: string;
readonly fetchedAt: string;
}
export type MetricSource = 'static' | 'json' | 'prometheus';
const cache = new Map<string, CacheEntry>();
const previousValues = new Map<string, number>();
function getCached(key: string): MetricResult | null {
const entry = cache.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
cache.delete(key);
return null;
}
return entry.data;
}
function setCache(key: string, data: MetricResult, ttlMs: number): void {
cache.set(key, {
data,
expiresAt: Date.now() + ttlMs
});
}
/**
* Traverse an object using dot-notation path (e.g., "data.cpu.percent").
*/
function extractByPath(obj: unknown, path: string): unknown {
const parts = path.split('.');
let current: unknown = obj;
for (const part of parts) {
if (current === null || current === undefined) return undefined;
if (typeof current !== 'object') return undefined;
// Handle array indexing: "items.0.value"
const index = parseInt(part, 10);
if (Array.isArray(current) && !isNaN(index)) {
current = current[index];
} else {
current = (current as Record<string, unknown>)[part];
}
}
return current;
}
/**
* Calculate trend based on current and previous values.
*/
function calculateTrend(current: number, previous: number | null): 'up' | 'down' | 'flat' {
if (previous === null) return 'flat';
if (current > previous) return 'up';
if (current < previous) return 'down';
return 'flat';
}
async function fetchWithTimeout(url: string): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(url, {
signal: controller.signal,
headers: { 'User-Agent': 'WebAppLauncher/1.0' }
});
return response;
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
throw new Error('Metric request timed out');
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}
/**
* Fetch a metric value from a JSON HTTP endpoint, extracting via dot-path.
*/
export async function fetchHttpMetric(url: string, jsonPath: string): Promise<number> {
const response = await fetchWithTimeout(url);
if (!response.ok) {
throw new Error(`Metric endpoint returned ${response.status}`);
}
const data = await response.json();
const extracted = extractByPath(data, jsonPath);
if (typeof extracted === 'number') return extracted;
if (typeof extracted === 'string') {
const parsed = parseFloat(extracted);
if (!isNaN(parsed)) return parsed;
}
throw new Error(`Could not extract numeric value at path "${jsonPath}"`);
}
/**
* Fetch a metric value from Prometheus instant query API.
*/
export async function fetchPrometheusMetric(url: string, query: string): Promise<number> {
const baseUrl = url.replace(/\/$/, '');
const endpoint = `${baseUrl}/api/v1/query?query=${encodeURIComponent(query)}`;
const response = await fetchWithTimeout(endpoint);
if (!response.ok) {
throw new Error(`Prometheus returned ${response.status}`);
}
const data = (await response.json()) as Record<string, unknown>;
if (data.status !== 'success') {
throw new Error('Prometheus query failed');
}
const resultData = data.data as Record<string, unknown>;
const results = resultData?.result as Array<Record<string, unknown>> | undefined;
if (results && results.length > 0) {
const value = results[0].value as [number, string] | undefined;
if (value && value.length === 2) {
return parseFloat(value[1]) || 0;
}
}
throw new Error('No result from Prometheus query');
}
/**
* Get a static metric value (passthrough).
*/
export function getStaticMetric(value: string): number {
const parsed = parseFloat(value);
if (isNaN(parsed)) {
throw new Error(`Invalid static metric value: "${value}"`);
}
return parsed;
}
/**
* Fetch a metric from any supported source type.
*/
export async function fetchMetric(options: {
readonly source: MetricSource;
readonly value?: string;
readonly url?: string;
readonly jsonPath?: string;
readonly query?: string;
readonly unit?: string;
readonly refreshInterval?: number;
}): Promise<MetricResult> {
const cacheKey = `${options.source}:${options.url ?? ''}:${options.jsonPath ?? ''}:${options.query ?? ''}:${options.value ?? ''}`;
const cached = getCached(cacheKey);
if (cached) return cached;
let numericValue: number;
switch (options.source) {
case 'static': {
if (!options.value) throw new Error('Static metric requires a value');
numericValue = getStaticMetric(options.value);
break;
}
case 'json': {
if (!options.url) throw new Error('JSON metric requires a url');
if (!options.jsonPath) throw new Error('JSON metric requires a jsonPath');
numericValue = await fetchHttpMetric(options.url, options.jsonPath);
break;
}
case 'prometheus': {
if (!options.url) throw new Error('Prometheus metric requires a url');
if (!options.query) throw new Error('Prometheus metric requires a query');
numericValue = await fetchPrometheusMetric(options.url, options.query);
break;
}
default:
throw new Error(`Unknown metric source: ${options.source}`);
}
const prevValue = previousValues.get(cacheKey) ?? null;
const trend = calculateTrend(numericValue, prevValue);
previousValues.set(cacheKey, numericValue);
const result: MetricResult = {
value: numericValue,
previousValue: prevValue,
trend,
unit: options.unit ?? '',
fetchedAt: new Date().toISOString()
};
const ttl = options.refreshInterval ? options.refreshInterval * 1000 : DEFAULT_CACHE_TTL_MS;
setCache(cacheKey, result, ttl);
return result;
}
/**
* Clear the metric cache and previous values.
*/
export function clearCache(): void {
cache.clear();
previousValues.clear();
}
@@ -0,0 +1,315 @@
import { prisma } from '../prisma.js';
import { NotificationType } from '$lib/utils/constants.js';
// --- Channel Management ---
export async function createChannel(
userId: string,
type: string,
config: string,
enabled: boolean = true
) {
return prisma.notificationChannel.create({
data: { userId, type, config, enabled }
});
}
export async function updateChannel(
id: string,
userId: string,
data: { type?: string; config?: string; enabled?: boolean }
) {
const channel = await prisma.notificationChannel.findUnique({ where: { id } });
if (!channel || channel.userId !== userId) {
throw new Error('Notification channel not found');
}
const updateData: Record<string, unknown> = {};
if (data.type !== undefined) updateData.type = data.type;
if (data.config !== undefined) updateData.config = data.config;
if (data.enabled !== undefined) updateData.enabled = data.enabled;
return prisma.notificationChannel.update({
where: { id },
data: updateData
});
}
export async function deleteChannel(id: string, userId: string) {
const channel = await prisma.notificationChannel.findUnique({ where: { id } });
if (!channel || channel.userId !== userId) {
throw new Error('Notification channel not found');
}
await prisma.notificationChannel.delete({ where: { id } });
}
export async function listChannels(userId: string) {
return prisma.notificationChannel.findMany({
where: { userId },
orderBy: { createdAt: 'asc' }
});
}
export async function getChannelById(id: string, userId: string) {
const channel = await prisma.notificationChannel.findUnique({ where: { id } });
if (!channel || channel.userId !== userId) {
throw new Error('Notification channel not found');
}
return channel;
}
// --- Notification Dispatchers ---
interface DiscordConfig {
readonly webhookUrl: string;
}
interface SlackConfig {
readonly webhookUrl: string;
}
interface TelegramConfig {
readonly botToken: string;
readonly chatId: string;
}
interface HttpConfig {
readonly url: string;
readonly headers?: Record<string, string>;
}
async function sendDiscord(webhookUrl: string, message: string): Promise<void> {
try {
await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
embeds: [
{
title: 'Web App Launcher Notification',
description: message,
color: 0x6366f1,
timestamp: new Date().toISOString()
}
]
})
});
} catch {
// Fire-and-forget: swallow errors
}
}
async function sendSlack(webhookUrl: string, message: string): Promise<void> {
try {
await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*Web App Launcher*\n${message}`
}
}
]
})
});
} catch {
// Fire-and-forget: swallow errors
}
}
async function sendTelegram(botToken: string, chatId: string, message: string): Promise<void> {
try {
await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text: message,
parse_mode: 'HTML'
})
});
} catch {
// Fire-and-forget: swallow errors
}
}
async function sendHttp(
url: string,
payload: unknown,
headers?: Record<string, string>
): Promise<void> {
try {
await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(headers ?? {})
},
body: JSON.stringify(payload)
});
} catch {
// Fire-and-forget: swallow errors
}
}
/**
* Dispatch a message to a single notification channel.
*/
async function dispatchToChannel(
channel: { type: string; config: string },
message: string
): Promise<void> {
let config: unknown;
try {
config = JSON.parse(channel.config);
} catch {
return; // Invalid config — skip
}
switch (channel.type) {
case NotificationType.DISCORD: {
const dc = config as DiscordConfig;
if (dc.webhookUrl) {
await sendDiscord(dc.webhookUrl, message);
}
break;
}
case NotificationType.SLACK: {
const sc = config as SlackConfig;
if (sc.webhookUrl) {
await sendSlack(sc.webhookUrl, message);
}
break;
}
case NotificationType.TELEGRAM: {
const tc = config as TelegramConfig;
if (tc.botToken && tc.chatId) {
await sendTelegram(tc.botToken, tc.chatId, message);
}
break;
}
case NotificationType.HTTP: {
const hc = config as HttpConfig;
if (hc.url) {
await sendHttp(hc.url, { message, timestamp: new Date().toISOString() }, hc.headers);
}
break;
}
}
}
// --- Send Notification ---
/**
* Send a notification to all enabled channels for a user.
* Creates a Notification record and dispatches to all channels.
*/
export async function sendNotification(
userId: string,
appId: string | null,
event: string,
message: string
) {
// Create notification record
const notification = await prisma.notification.create({
data: {
userId,
appId,
event,
message
}
});
// Get all enabled channels for the user and dispatch
const channels = await prisma.notificationChannel.findMany({
where: { userId, enabled: true }
});
// Fire-and-forget: dispatch to all channels in parallel
Promise.allSettled(channels.map((ch) => dispatchToChannel(ch, message))).catch(() => {
// Swallow any unexpected errors
});
return notification;
}
/**
* Send a notification to all users that have notification channels set up.
* Used by the healthcheck scheduler for broadcast status change events.
*/
export async function broadcastNotification(appId: string, event: string, message: string) {
// Find all users that have at least one enabled notification channel
const channels = await prisma.notificationChannel.findMany({
where: { enabled: true },
select: { userId: true }
});
const uniqueUserIds = [...new Set(channels.map((ch) => ch.userId))];
// Create notifications and dispatch for each user
await Promise.allSettled(
uniqueUserIds.map((userId) => sendNotification(userId, appId, event, message))
);
}
/**
* Send a test notification to a specific channel.
*/
export async function sendTestNotification(channelId: string, userId: string): Promise<void> {
const channel = await getChannelById(channelId, userId);
const message = 'This is a test notification from Web App Launcher.';
await dispatchToChannel(channel, message);
}
// --- Notification Queries ---
export async function getNotifications(
userId: string,
options?: { unreadOnly?: boolean; limit?: number; offset?: number }
) {
const where: Record<string, unknown> = { userId };
if (options?.unreadOnly) {
where.readAt = null;
}
const [notifications, total] = await Promise.all([
prisma.notification.findMany({
where,
orderBy: { sentAt: 'desc' },
take: options?.limit ?? 50,
skip: options?.offset ?? 0,
include: {
app: {
select: { id: true, name: true, icon: true }
}
}
}),
prisma.notification.count({ where })
]);
return { notifications, total };
}
export async function markAsRead(notificationId: string, userId: string) {
const notification = await prisma.notification.findUnique({ where: { id: notificationId } });
if (!notification || notification.userId !== userId) {
throw new Error('Notification not found');
}
return prisma.notification.update({
where: { id: notificationId },
data: { readAt: new Date() }
});
}
export async function markAllAsRead(userId: string) {
await prisma.notification.updateMany({
where: { userId, readAt: null },
data: { readAt: new Date() }
});
}
+4 -6
View File
@@ -68,11 +68,7 @@ async function getOIDCConfig(): Promise<client.Configuration> {
}
const issuerUrl = deriveIssuerUrl(oauthConfig.discoveryUrl);
const config = await client.discovery(
issuerUrl,
oauthConfig.clientId,
oauthConfig.clientSecret
);
const config = await client.discovery(issuerUrl, oauthConfig.clientId, oauthConfig.clientSecret);
cachedConfig = config;
cachedConfigKey = cacheKey;
@@ -157,7 +153,9 @@ export async function handleCallback(
const email = (userInfo.email as string) || '';
if (!email) {
throw new Error('OAuth provider did not return an email address. Ensure the "email" scope is configured.');
throw new Error(
'OAuth provider did not return an email address. Ensure the "email" scope is configured.'
);
}
return {
@@ -0,0 +1,73 @@
import { prisma } from '../prisma.js';
import { DEFAULTS } from '$lib/utils/constants.js';
/**
* Check whether the onboarding wizard should be shown.
* Returns true if no users exist OR SystemSettings.onboardingComplete is false.
*/
export async function isOnboardingNeeded(): Promise<boolean> {
const userCount = await prisma.user.count();
if (userCount === 0) {
return true;
}
try {
const settings = await prisma.systemSettings.findUnique({
where: { id: DEFAULTS.SYSTEM_SETTINGS_ID },
select: { onboardingComplete: true }
});
// If no settings record exists yet, onboarding is needed
if (!settings) {
return true;
}
return !settings.onboardingComplete;
} catch {
// If SystemSettings table doesn't exist yet, onboarding is needed
return true;
}
}
/**
* Mark onboarding as complete in SystemSettings.
*/
export async function completeOnboarding(): Promise<void> {
await prisma.systemSettings.upsert({
where: { id: DEFAULTS.SYSTEM_SETTINGS_ID },
update: { onboardingComplete: true },
create: {
id: DEFAULTS.SYSTEM_SETTINGS_ID,
onboardingComplete: true
}
});
}
/**
* Get the current onboarding status.
*/
export async function getOnboardingStatus(): Promise<{
readonly needed: boolean;
readonly hasUsers: boolean;
readonly onboardingComplete: boolean;
}> {
const userCount = await prisma.user.count();
const hasUsers = userCount > 0;
let onboardingComplete = false;
try {
const settings = await prisma.systemSettings.findUnique({
where: { id: DEFAULTS.SYSTEM_SETTINGS_ID },
select: { onboardingComplete: true }
});
onboardingComplete = settings?.onboardingComplete ?? false;
} catch {
onboardingComplete = false;
}
return {
needed: !hasUsers || !onboardingComplete,
hasUsers,
onboardingComplete
};
}
+3 -10
View File
@@ -74,8 +74,7 @@ export async function checkPermission(
return permLevel > highestScore ? perm.level : highest;
}, groupPermissions[0].level);
const hasAccess =
PERMISSION_HIERARCHY[highestLevel] >= PERMISSION_HIERARCHY[requiredLevel];
const hasAccess = PERMISSION_HIERARCHY[highestLevel] >= PERMISSION_HIERARCHY[requiredLevel];
return {
hasPermission: hasAccess,
effectiveLevel: highestLevel as PermissionCheckResult['effectiveLevel'],
@@ -137,20 +136,14 @@ export async function getPermissionsForEntity(entityType: EntityType, entityId:
});
}
export async function getPermissionsForTarget(
targetType: TargetTypeType,
targetId: string
) {
export async function getPermissionsForTarget(targetType: TargetTypeType, targetId: string) {
return prisma.permission.findMany({
where: { targetType, targetId },
orderBy: { createdAt: 'asc' }
});
}
export async function removeAllPermissionsForEntity(
entityType: EntityType,
entityId: string
) {
export async function removeAllPermissionsForEntity(entityType: EntityType, entityId: string) {
await prisma.permission.deleteMany({
where: { entityType, entityId }
});
@@ -0,0 +1,58 @@
import { prisma } from '../prisma.js';
/**
* Record a click on an app for a user.
*/
export async function recordClick(userId: string, appId: string) {
return prisma.appClick.create({
data: {
userId,
appId
}
});
}
/**
* Get recent unique apps for a user, most recent first.
*/
export async function getRecentApps(userId: string, limit: number = 10) {
// Get distinct most-recent clicks per app
const clicks = await prisma.appClick.findMany({
where: { userId },
orderBy: { clickedAt: 'desc' },
include: {
app: {
include: {
statuses: {
orderBy: { checkedAt: 'desc' },
take: 1
}
}
}
}
});
// Deduplicate by appId, keeping the most recent click per app
const seen = new Set<string>();
const uniqueClicks = [];
for (const click of clicks) {
if (!seen.has(click.appId)) {
seen.add(click.appId);
uniqueClicks.push(click);
}
if (uniqueClicks.length >= limit) {
break;
}
}
return uniqueClicks;
}
/**
* Clear all click history for a user.
*/
export async function clearHistory(userId: string) {
await prisma.appClick.deleteMany({
where: { userId }
});
}
+189
View File
@@ -0,0 +1,189 @@
/**
* RSS/Atom feed service — fetches and parses RSS/Atom feeds.
* Uses lightweight XML parsing without heavy dependencies.
*/
const CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes
const FETCH_TIMEOUT_MS = 10_000;
const DEFAULT_MAX_ITEMS = 10;
interface CacheEntry {
readonly data: readonly FeedItem[];
readonly expiresAt: number;
}
export interface FeedItem {
readonly title: string;
readonly link: string;
readonly pubDate: string;
readonly summary: string;
}
const cache = new Map<string, CacheEntry>();
function getCached(key: string): readonly FeedItem[] | null {
const entry = cache.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
cache.delete(key);
return null;
}
return entry.data;
}
function setCache(key: string, data: readonly FeedItem[]): void {
cache.set(key, {
data,
expiresAt: Date.now() + CACHE_TTL_MS
});
}
/**
* Extract text content between XML tags.
*/
function extractTag(xml: string, tag: string): string {
// Handle CDATA sections
const cdataPattern = new RegExp(
`<${tag}[^>]*>\\s*<!\\[CDATA\\[([\\s\\S]*?)\\]\\]>\\s*</${tag}>`,
'i'
);
const cdataMatch = xml.match(cdataPattern);
if (cdataMatch) return cdataMatch[1].trim();
// Handle regular content
const pattern = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, 'i');
const match = xml.match(pattern);
if (match) return match[1].trim();
return '';
}
/**
* Extract href from Atom link tag.
*/
function extractAtomLink(entryXml: string): string {
// Look for link with rel="alternate" or no rel
const altMatch = entryXml.match(/<link[^>]*rel=["']alternate["'][^>]*href=["']([^"']+)["']/i);
if (altMatch) return altMatch[1];
const hrefMatch = entryXml.match(/<link[^>]*href=["']([^"']+)["']/i);
if (hrefMatch) return hrefMatch[1];
return '';
}
/**
* Parse RSS 2.0 feed XML.
*/
function parseRss(xml: string, maxItems: number): readonly FeedItem[] {
const items: FeedItem[] = [];
const itemRegex = /<item>([\s\S]*?)<\/item>/gi;
let match: RegExpExecArray | null;
while ((match = itemRegex.exec(xml)) !== null && items.length < maxItems) {
const itemXml = match[1];
items.push({
title: extractTag(itemXml, 'title') || 'Untitled',
link: extractTag(itemXml, 'link') || '',
pubDate: extractTag(itemXml, 'pubDate') || '',
summary: extractTag(itemXml, 'description') || ''
});
}
return items;
}
/**
* Parse Atom feed XML.
*/
function parseAtom(xml: string, maxItems: number): readonly FeedItem[] {
const items: FeedItem[] = [];
const entryRegex = /<entry>([\s\S]*?)<\/entry>/gi;
let match: RegExpExecArray | null;
while ((match = entryRegex.exec(xml)) !== null && items.length < maxItems) {
const entryXml = match[1];
items.push({
title: extractTag(entryXml, 'title') || 'Untitled',
link: extractAtomLink(entryXml) || '',
pubDate: extractTag(entryXml, 'published') || extractTag(entryXml, 'updated') || '',
summary: extractTag(entryXml, 'summary') || extractTag(entryXml, 'content') || ''
});
}
return items;
}
/**
* Strip HTML tags from a string (for summaries).
*/
function stripHtml(html: string): string {
return html
.replace(/<[^>]*>/g, '')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.trim();
}
/**
* Fetch and parse an RSS or Atom feed from a URL.
*/
export async function fetchFeed(feedUrl: string, maxItems?: number): Promise<readonly FeedItem[]> {
const limit = maxItems ?? DEFAULT_MAX_ITEMS;
const cacheKey = `${feedUrl}:${limit}`;
const cached = getCached(cacheKey);
if (cached) return cached;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(feedUrl, {
signal: controller.signal,
headers: {
'User-Agent': 'WebAppLauncher/1.0',
Accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml'
}
});
if (!response.ok) {
throw new Error(`Feed returned ${response.status}`);
}
const xml = await response.text();
// Detect feed type and parse
let items: readonly FeedItem[];
if (xml.includes('<feed') && xml.includes('xmlns="http://www.w3.org/2005/Atom"')) {
items = parseAtom(xml, limit);
} else {
items = parseRss(xml, limit);
}
// Strip HTML from summaries
const cleanItems = items.map((item) => ({
...item,
summary: stripHtml(item.summary).substring(0, 500)
}));
setCache(cacheKey, cleanItems);
return cleanItems;
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
throw new Error('Feed request timed out');
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}
/**
* Clear the RSS feed cache.
*/
export function clearCache(): void {
cache.clear();
}
@@ -0,0 +1,217 @@
/**
* System stats service — fetches metrics from various sources using an adapter pattern.
* Supports Glances, Prometheus, and custom JSON endpoints.
*/
const DEFAULT_CACHE_TTL_MS = 30_000; // 30 seconds
const FETCH_TIMEOUT_MS = 10_000;
interface CacheEntry {
readonly data: readonly SystemMetric[];
readonly expiresAt: number;
}
export interface SystemMetric {
readonly metric: string;
readonly value: number;
readonly unit: string;
}
const cache = new Map<string, CacheEntry>();
function getCached(key: string): readonly SystemMetric[] | null {
const entry = cache.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
cache.delete(key);
return null;
}
return entry.data;
}
function setCache(key: string, data: readonly SystemMetric[], ttlMs: number): void {
cache.set(key, {
data,
expiresAt: Date.now() + ttlMs
});
}
async function fetchWithTimeout(url: string): Promise<unknown> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(url, {
signal: controller.signal,
headers: { 'User-Agent': 'WebAppLauncher/1.0' }
});
if (!response.ok) {
throw new Error(`Source returned ${response.status}`);
}
return await response.json();
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
throw new Error('System stats request timed out');
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}
/**
* Glances adapter — fetches from Glances REST API.
* Expects endpoints like /api/3/cpu, /api/3/mem, /api/3/fs
*/
async function fetchGlancesMetrics(
sourceUrl: string,
metrics: readonly string[]
): Promise<readonly SystemMetric[]> {
const results: SystemMetric[] = [];
for (const metric of metrics) {
try {
const endpoint = `${sourceUrl.replace(/\/$/, '')}/api/3/${metric}`;
const data = await fetchWithTimeout(endpoint);
if (metric === 'cpu' && typeof data === 'object' && data !== null) {
const cpuData = data as Record<string, unknown>;
const total = typeof cpuData.total === 'number' ? cpuData.total : 0;
results.push({ metric: 'cpu', value: total, unit: '%' });
} else if (metric === 'mem' && typeof data === 'object' && data !== null) {
const memData = data as Record<string, unknown>;
const percent = typeof memData.percent === 'number' ? memData.percent : 0;
results.push({ metric: 'memory', value: percent, unit: '%' });
} else if (metric === 'fs' && Array.isArray(data)) {
for (const disk of data) {
const d = disk as Record<string, unknown>;
const percent = typeof d.percent === 'number' ? d.percent : 0;
const mnt = typeof d.mnt_point === 'string' ? d.mnt_point : '/';
results.push({ metric: `disk:${mnt}`, value: percent, unit: '%' });
}
}
} catch {
// Skip unreachable metric endpoints
results.push({ metric, value: -1, unit: 'error' });
}
}
return results;
}
/**
* Prometheus adapter — queries Prometheus instant query API.
*/
async function fetchPrometheusMetrics(
sourceUrl: string,
metrics: readonly string[]
): Promise<readonly SystemMetric[]> {
const results: SystemMetric[] = [];
const baseUrl = sourceUrl.replace(/\/$/, '');
for (const query of metrics) {
try {
const endpoint = `${baseUrl}/api/v1/query?query=${encodeURIComponent(query)}`;
const data = (await fetchWithTimeout(endpoint)) as Record<string, unknown>;
if (data.status === 'success') {
const result = data.data as Record<string, unknown>;
const resultArray = result?.result as Array<Record<string, unknown>> | undefined;
if (resultArray && resultArray.length > 0) {
const value = resultArray[0].value as [number, string] | undefined;
if (value && value.length === 2) {
results.push({
metric: query,
value: parseFloat(value[1]) || 0,
unit: ''
});
continue;
}
}
}
results.push({ metric: query, value: 0, unit: '' });
} catch {
results.push({ metric: query, value: -1, unit: 'error' });
}
}
return results;
}
/**
* Custom adapter — fetches from a generic JSON endpoint.
* Expects the response to be an object with metric names as keys and numeric values.
*/
async function fetchCustomMetrics(
sourceUrl: string,
metrics: readonly string[]
): Promise<readonly SystemMetric[]> {
const results: SystemMetric[] = [];
try {
const data = (await fetchWithTimeout(sourceUrl)) as Record<string, unknown>;
for (const metric of metrics) {
const value = data[metric];
if (typeof value === 'number') {
results.push({ metric, value, unit: '' });
} else {
results.push({ metric, value: 0, unit: '' });
}
}
} catch {
for (const metric of metrics) {
results.push({ metric, value: -1, unit: 'error' });
}
}
return results;
}
export type SourceType = 'glances' | 'prometheus' | 'custom';
/**
* Fetch system stats from the specified source.
*/
export async function fetchSystemStats(
sourceUrl: string,
sourceType: SourceType,
metrics: readonly string[],
refreshInterval?: number
): Promise<readonly SystemMetric[]> {
const cacheKey = `${sourceType}:${sourceUrl}:${metrics.join(',')}`;
const cached = getCached(cacheKey);
if (cached) return cached;
let result: readonly SystemMetric[];
switch (sourceType) {
case 'glances':
result = await fetchGlancesMetrics(sourceUrl, metrics);
break;
case 'prometheus':
result = await fetchPrometheusMetrics(sourceUrl, metrics);
break;
case 'custom':
result = await fetchCustomMetrics(sourceUrl, metrics);
break;
default:
throw new Error(`Unknown source type: ${sourceType}`);
}
const ttl = refreshInterval ? refreshInterval * 1000 : DEFAULT_CACHE_TTL_MS;
setCache(cacheKey, result, ttl);
return result;
}
/**
* Clear the system stats cache.
*/
export function clearCache(): void {
cache.clear();
}
+112
View File
@@ -0,0 +1,112 @@
import { prisma } from '../prisma.js';
// --- Tag CRUD ---
export async function findAll() {
return prisma.tag.findMany({
orderBy: { name: 'asc' },
include: {
_count: { select: { appTags: true } }
}
});
}
export async function findById(id: string) {
const tag = await prisma.tag.findUnique({
where: { id },
include: {
_count: { select: { appTags: true } }
}
});
if (!tag) {
throw new Error(`Tag not found: ${id}`);
}
return tag;
}
export async function create(name: string, color?: string | null) {
const existing = await prisma.tag.findUnique({ where: { name } });
if (existing) {
throw new Error(`Tag already exists: ${name}`);
}
return prisma.tag.create({
data: {
name,
color: color ?? null
}
});
}
export async function update(id: string, data: { name?: string; color?: string | null }) {
await findById(id);
const updateData: Record<string, unknown> = {};
if (data.name !== undefined) updateData.name = data.name;
if (data.color !== undefined) updateData.color = data.color;
return prisma.tag.update({
where: { id },
data: updateData
});
}
export async function remove(id: string) {
await findById(id);
await prisma.tag.delete({ where: { id } });
}
// --- App-Tag Associations ---
export async function addTagToApp(appId: string, tagId: string) {
const existing = await prisma.appTag.findUnique({
where: { appId_tagId: { appId, tagId } }
});
if (existing) {
throw new Error('Tag is already assigned to this app');
}
return prisma.appTag.create({
data: { appId, tagId },
include: { tag: true }
});
}
export async function removeTagFromApp(appId: string, tagId: string) {
const existing = await prisma.appTag.findUnique({
where: { appId_tagId: { appId, tagId } }
});
if (!existing) {
throw new Error('Tag is not assigned to this app');
}
await prisma.appTag.delete({
where: { appId_tagId: { appId, tagId } }
});
}
export async function getTagsForApp(appId: string) {
const appTags = await prisma.appTag.findMany({
where: { appId },
include: { tag: true },
orderBy: { tag: { name: 'asc' } }
});
return appTags.map((at) => at.tag);
}
export async function getAppsByTag(tagId: string) {
const appTags = await prisma.appTag.findMany({
where: { tagId },
include: {
app: {
include: {
statuses: {
orderBy: { checkedAt: 'desc' },
take: 1
}
}
}
}
});
return appTags.map((at) => at.app);
}
+319
View File
@@ -0,0 +1,319 @@
import { prisma } from '../prisma.js';
export interface TemplateSection {
readonly title: string;
readonly icon?: string | null;
readonly order?: number;
}
export interface TemplateConfig {
readonly sections: readonly TemplateSection[];
}
export interface Template {
readonly id: string;
readonly name: string;
readonly description: string | null;
readonly icon: string | null;
readonly config: TemplateConfig;
readonly isBuiltin: boolean;
readonly createdById: string | null;
readonly createdAt: Date;
}
/**
* Built-in templates that are always available, not stored in DB.
*/
const BUILTIN_TEMPLATES: readonly Template[] = [
{
id: 'builtin-home-server',
name: 'Home Server',
description:
'Typical home server dashboard with media, networking, storage, and monitoring sections',
icon: 'server',
config: {
sections: [
{ title: 'Media', icon: 'play-circle', order: 0 },
{ title: 'Networking', icon: 'network', order: 1 },
{ title: 'Storage', icon: 'hard-drive', order: 2 },
{ title: 'Monitoring', icon: 'activity', order: 3 }
]
},
isBuiltin: true,
createdById: null,
createdAt: new Date('2024-01-01')
},
{
id: 'builtin-media-stack',
name: 'Media Stack',
description: 'Media management with streaming, downloads, and library management sections',
icon: 'film',
config: {
sections: [
{ title: 'Streaming', icon: 'tv', order: 0 },
{ title: 'Downloads', icon: 'download', order: 1 },
{ title: 'Management', icon: 'folder', order: 2 }
]
},
isBuiltin: true,
createdById: null,
createdAt: new Date('2024-01-01')
},
{
id: 'builtin-dev-tools',
name: 'Dev Tools',
description: 'Developer-focused layout with Git, CI/CD, databases, and documentation sections',
icon: 'code',
config: {
sections: [
{ title: 'Git', icon: 'git-branch', order: 0 },
{ title: 'CI/CD', icon: 'rocket', order: 1 },
{ title: 'Databases', icon: 'database', order: 2 },
{ title: 'Docs', icon: 'book-open', order: 3 }
]
},
isBuiltin: true,
createdById: null,
createdAt: new Date('2024-01-01')
},
{
id: 'builtin-monitoring',
name: 'Monitoring',
description: 'Infrastructure monitoring with metrics, logs, alerts, and status sections',
icon: 'activity',
config: {
sections: [
{ title: 'Metrics', icon: 'bar-chart-2', order: 0 },
{ title: 'Logs', icon: 'file-text', order: 1 },
{ title: 'Alerts', icon: 'bell', order: 2 },
{ title: 'Status', icon: 'check-circle', order: 3 }
]
},
isBuiltin: true,
createdById: null,
createdAt: new Date('2024-01-01')
}
] as const;
/**
* Get all built-in templates.
*/
export function getBuiltinTemplates(): readonly Template[] {
return BUILTIN_TEMPLATES;
}
/**
* Get user-created templates from DB.
*/
export async function getUserTemplates(userId?: string): Promise<Template[]> {
const where = userId ? { createdById: userId, isBuiltin: false } : { isBuiltin: false };
const dbTemplates = await prisma.boardTemplate.findMany({
where,
orderBy: { createdAt: 'desc' }
});
return dbTemplates.map((t) => ({
id: t.id,
name: t.name,
description: t.description,
icon: t.icon,
config: parseConfig(t.config),
isBuiltin: t.isBuiltin,
createdById: t.createdById,
createdAt: t.createdAt
}));
}
/**
* Get all templates (builtin + user-created).
*/
export async function getAllTemplates(userId?: string): Promise<Template[]> {
const userTemplates = await getUserTemplates(userId);
return [...BUILTIN_TEMPLATES, ...userTemplates];
}
/**
* Get a single template by ID (checks builtins first, then DB).
*/
export async function getTemplateById(id: string): Promise<Template | null> {
const builtin = BUILTIN_TEMPLATES.find((t) => t.id === id);
if (builtin) return builtin;
const dbTemplate = await prisma.boardTemplate.findUnique({ where: { id } });
if (!dbTemplate) return null;
return {
id: dbTemplate.id,
name: dbTemplate.name,
description: dbTemplate.description,
icon: dbTemplate.icon,
config: parseConfig(dbTemplate.config),
isBuiltin: dbTemplate.isBuiltin,
createdById: dbTemplate.createdById,
createdAt: dbTemplate.createdAt
};
}
/**
* Create a new user template from a config.
*/
export async function createTemplate(input: {
name: string;
description?: string | null;
icon?: string | null;
config: TemplateConfig;
createdById?: string | null;
}): Promise<Template> {
const dbTemplate = await prisma.boardTemplate.create({
data: {
name: input.name,
description: input.description ?? null,
icon: input.icon ?? null,
config: JSON.stringify(input.config),
isBuiltin: false,
createdById: input.createdById ?? null
}
});
return {
id: dbTemplate.id,
name: dbTemplate.name,
description: dbTemplate.description,
icon: dbTemplate.icon,
config: parseConfig(dbTemplate.config),
isBuiltin: dbTemplate.isBuiltin,
createdById: dbTemplate.createdById,
createdAt: dbTemplate.createdAt
};
}
/**
* Delete a user-created template. Cannot delete builtins.
*/
export async function deleteTemplate(id: string): Promise<void> {
const builtin = BUILTIN_TEMPLATES.find((t) => t.id === id);
if (builtin) {
throw new Error('Cannot delete built-in templates');
}
const existing = await prisma.boardTemplate.findUnique({ where: { id } });
if (!existing) {
throw new Error(`Template not found: ${id}`);
}
await prisma.boardTemplate.delete({ where: { id } });
}
/**
* Apply a template to a board: create sections from the template config.
*/
export async function applyTemplate(templateId: string, boardId: string): Promise<void> {
const template = await getTemplateById(templateId);
if (!template) {
throw new Error(`Template not found: ${templateId}`);
}
// Create sections from template config
const createOps = template.config.sections.map((section) =>
prisma.section.create({
data: {
boardId,
title: section.title,
icon: section.icon,
order: section.order,
isExpandedByDefault: true
}
})
);
await prisma.$transaction(createOps);
}
/**
* Export a board's layout as a template config JSON.
*/
export async function exportTemplate(boardId: string): Promise<{
readonly name: string;
readonly description: string | null;
readonly config: TemplateConfig;
}> {
const board = await prisma.board.findUnique({
where: { id: boardId },
include: {
sections: {
orderBy: { order: 'asc' },
select: { title: true, icon: true, order: true }
}
}
});
if (!board) {
throw new Error(`Board not found: ${boardId}`);
}
return {
name: board.name,
description: board.description,
config: {
sections: board.sections.map((s) => ({
title: s.title,
icon: s.icon,
order: s.order
}))
}
};
}
/**
* Import a template from JSON data.
*/
export async function importTemplate(
data: {
name: string;
description?: string | null;
icon?: string | null;
config: TemplateConfig;
},
createdById?: string | null
): Promise<Template> {
// Validate config structure
if (!data.config?.sections || !Array.isArray(data.config.sections)) {
throw new Error('Invalid template config: sections array is required');
}
for (const section of data.config.sections) {
if (!section.title || typeof section.title !== 'string') {
throw new Error('Invalid template config: each section must have a title');
}
}
return createTemplate({
name: data.name,
description: data.description ?? null,
icon: data.icon ?? null,
config: {
sections: data.config.sections.map((s, i) => ({
title: s.title,
icon: s.icon ?? null,
order: s.order ?? i
}))
},
createdById: createdById ?? null
});
}
/**
* Parse a JSON config string into TemplateConfig.
*/
function parseConfig(configStr: string): TemplateConfig {
try {
const parsed = JSON.parse(configStr);
if (parsed?.sections && Array.isArray(parsed.sections)) {
return parsed as TemplateConfig;
}
return { sections: [] };
} catch {
return { sections: [] };
}
}
+213
View File
@@ -0,0 +1,213 @@
import { prisma } from '../prisma.js';
import { AppStatusValue } from '$lib/utils/constants.js';
type TimeRange = '24h' | '7d' | '30d';
function getTimeRangeCutoff(timeRange: TimeRange): Date {
const now = Date.now();
const hours = timeRange === '24h' ? 24 : timeRange === '7d' ? 168 : 720;
return new Date(now - hours * 60 * 60 * 1000);
}
/**
* Get uptime statistics for a single app.
*/
export async function getUptimeStats(appId: string, timeRange: TimeRange = '24h') {
const cutoff = getTimeRangeCutoff(timeRange);
const statuses = await prisma.appStatus.findMany({
where: {
appId,
checkedAt: { gte: cutoff }
},
orderBy: { checkedAt: 'asc' }
});
if (statuses.length === 0) {
return {
appId,
timeRange,
currentStatus: null as string | null,
uptimePercentage: null,
avgResponseTime: null,
totalChecks: 0,
onlineChecks: 0,
offlineChecks: 0,
degradedChecks: 0
};
}
const onlineChecks = statuses.filter((s) => s.status === AppStatusValue.ONLINE).length;
const offlineChecks = statuses.filter((s) => s.status === AppStatusValue.OFFLINE).length;
const degradedChecks = statuses.filter((s) => s.status === AppStatusValue.DEGRADED).length;
const relevantChecks = onlineChecks + offlineChecks + degradedChecks;
const uptimePercentage =
relevantChecks > 0 ? Math.round((onlineChecks / relevantChecks) * 10000) / 100 : null;
const responseTimes = statuses
.map((s) => s.responseTime)
.filter((rt): rt is number => rt !== null);
const avgResponseTime =
responseTimes.length > 0
? Math.round(responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length)
: null;
// Most recent status check determines current status
const currentStatus = statuses[statuses.length - 1].status;
return {
appId,
timeRange,
currentStatus,
uptimePercentage,
avgResponseTime,
totalChecks: statuses.length,
onlineChecks,
offlineChecks,
degradedChecks
};
}
/**
* Get uptime timeline for a single app (status history with timestamps).
*/
export async function getUptimeTimeline(appId: string, timeRange: TimeRange = '24h') {
const cutoff = getTimeRangeCutoff(timeRange);
const statuses = await prisma.appStatus.findMany({
where: {
appId,
checkedAt: { gte: cutoff }
},
orderBy: { checkedAt: 'asc' },
select: {
status: true,
responseTime: true,
checkedAt: true
}
});
return statuses;
}
/**
* Get aggregated uptime for all apps with healthcheck enabled.
*/
export async function getAllAppsUptime(timeRange: TimeRange = '24h') {
const apps = await prisma.app.findMany({
where: { healthcheckEnabled: true },
select: { id: true, name: true, url: true }
});
const results = await Promise.all(
apps.map(async (app) => {
const stats = await getUptimeStats(app.id, timeRange);
return {
...stats,
appName: app.name,
appUrl: app.url
};
})
);
return results;
}
/**
* Get incidents (down periods) for an app or all apps.
*/
export async function getIncidents(appId?: string, timeRange: TimeRange = '24h') {
const cutoff = getTimeRangeCutoff(timeRange);
const where: Record<string, unknown> = {
checkedAt: { gte: cutoff },
status: { in: [AppStatusValue.OFFLINE, AppStatusValue.DEGRADED] }
};
if (appId) {
where.appId = appId;
}
const statuses = await prisma.appStatus.findMany({
where,
orderBy: { checkedAt: 'asc' },
include: {
app: {
select: { id: true, name: true }
}
}
});
// Group consecutive offline/degraded statuses into incidents
const incidents: Array<{
readonly appId: string;
readonly appName: string;
readonly status: string;
readonly startedAt: Date;
readonly endedAt: Date;
readonly durationMs: number;
readonly checkCount: number;
}> = [];
let currentIncident: {
appId: string;
appName: string;
status: string;
startedAt: Date;
endedAt: Date;
checkCount: number;
} | null = null;
for (const status of statuses) {
if (
currentIncident &&
currentIncident.appId === status.appId &&
currentIncident.status === status.status
) {
// Continue current incident
const prev: {
appId: string;
appName: string;
status: string;
startedAt: Date;
endedAt: Date;
checkCount: number;
} = currentIncident;
currentIncident = {
appId: prev.appId,
appName: prev.appName,
status: prev.status,
startedAt: prev.startedAt,
endedAt: status.checkedAt,
checkCount: prev.checkCount + 1
};
} else {
// Save previous incident
if (currentIncident) {
incidents.push({
...currentIncident,
durationMs: currentIncident.endedAt.getTime() - currentIncident.startedAt.getTime()
});
}
// Start new incident
currentIncident = {
appId: status.appId,
appName: status.app.name,
status: status.status,
startedAt: status.checkedAt,
endedAt: status.checkedAt,
checkCount: 1
};
}
}
// Don't forget the last incident
if (currentIncident) {
incidents.push({
...currentIncident,
durationMs: currentIncident.endedAt.getTime() - currentIncident.startedAt.getTime()
});
}
return incidents;
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Weather service — fetches current weather from OpenMeteo API.
* No API key required.
*/
const OPEN_METEO_BASE = 'https://api.open-meteo.com/v1/forecast';
const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
const FETCH_TIMEOUT_MS = 10_000;
interface CacheEntry {
readonly data: WeatherData;
readonly expiresAt: number;
}
export interface WeatherData {
readonly temperature: number;
readonly windSpeed: number;
readonly weatherCode: number;
readonly isDay: boolean;
readonly time: string;
}
const cache = new Map<string, CacheEntry>();
function buildCacheKey(latitude: number, longitude: number): string {
// Round to 2 decimal places for cache key stability
return `${latitude.toFixed(2)},${longitude.toFixed(2)}`;
}
function getCached(key: string): WeatherData | null {
const entry = cache.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
cache.delete(key);
return null;
}
return entry.data;
}
function setCache(key: string, data: WeatherData): void {
cache.set(key, {
data,
expiresAt: Date.now() + CACHE_TTL_MS
});
}
/**
* Fetch current weather for given coordinates.
*/
export async function fetchWeather(latitude: number, longitude: number): Promise<WeatherData> {
const cacheKey = buildCacheKey(latitude, longitude);
const cached = getCached(cacheKey);
if (cached) return cached;
const url = `${OPEN_METEO_BASE}?latitude=${latitude}&longitude=${longitude}&current_weather=true`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(url, {
signal: controller.signal,
headers: { 'User-Agent': 'WebAppLauncher/1.0' }
});
if (!response.ok) {
throw new Error(`OpenMeteo API returned ${response.status}`);
}
const json = await response.json();
const current = json?.current_weather;
if (!current || typeof current.temperature !== 'number') {
throw new Error('Unexpected response format from OpenMeteo API');
}
const data: WeatherData = {
temperature: current.temperature,
windSpeed: current.windspeed ?? 0,
weatherCode: current.weathercode ?? 0,
isDay: current.is_day === 1,
time: current.time ?? new Date().toISOString()
};
setCache(cacheKey, data);
return data;
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
throw new Error('Weather API request timed out');
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}
/**
* Clear the weather cache (useful for testing or manual refresh).
*/
export function clearCache(): void {
cache.clear();
}
+1 -6
View File
@@ -26,12 +26,7 @@ export function error(message: string): ApiResponse<null> {
};
}
export function paginated<T>(
data: T,
total: number,
page: number,
limit: number
): ApiResponse<T> {
export function paginated<T>(data: T, total: number, page: number, limit: number): ApiResponse<T> {
return {
success: true,
data,
+127
View File
@@ -0,0 +1,127 @@
interface FavoriteApp {
readonly id: string;
readonly appId: string;
readonly order: number;
readonly app: {
readonly id: string;
readonly name: string;
readonly url: string;
readonly icon: string | null;
readonly iconType: string;
};
}
class FavoritesStore {
items = $state<FavoriteApp[]>([]);
loading = $state(false);
error = $state<string | null>(null);
get count(): number {
return this.items.length;
}
get hasFavorites(): boolean {
return this.items.length > 0;
}
async load() {
this.loading = true;
this.error = null;
try {
const res = await fetch('/api/favorites');
if (!res.ok) {
this.error = 'Failed to load favorites';
return;
}
const json = await res.json();
if (json.success && Array.isArray(json.data)) {
this.items = json.data;
}
} catch {
this.error = 'Failed to load favorites';
} finally {
this.loading = false;
}
}
async add(appId: string) {
this.error = null;
try {
const res = await fetch('/api/favorites', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ appId })
});
if (!res.ok) {
this.error = 'Failed to add favorite';
return;
}
// Reload favorites from server to get full app data
await this.load();
} catch {
this.error = 'Failed to add favorite';
}
}
async remove(appId: string) {
// Optimistic update
const previousItems = [...this.items];
this.items = this.items.filter((f) => f.appId !== appId);
this.error = null;
try {
const res = await fetch('/api/favorites', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ appId })
});
if (!res.ok) {
// Rollback on failure
this.items = previousItems;
this.error = 'Failed to remove favorite';
}
} catch {
this.items = previousItems;
this.error = 'Failed to remove favorite';
}
}
async reorder(favoriteIds: string[]) {
// Optimistic: reorder items locally
const reordered = favoriteIds
.map((id) => this.items.find((f) => f.id === id))
.filter((f): f is FavoriteApp => f !== undefined);
const previousItems = [...this.items];
this.items = reordered;
this.error = null;
try {
const res = await fetch('/api/favorites/reorder', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ favoriteIds })
});
if (!res.ok) {
this.items = previousItems;
this.error = 'Failed to reorder favorites';
}
} catch {
this.items = previousItems;
this.error = 'Failed to reorder favorites';
}
}
isFavorite(appId: string): boolean {
return this.items.some((f) => f.appId === appId);
}
}
export const favorites = new FavoritesStore();
+214
View File
@@ -0,0 +1,214 @@
/**
* Global keyboard shortcut store.
* Manages keyboard listeners for navigation and actions.
* Disables shortcuts when input/textarea is focused.
*/
class KeyboardStore {
overlayOpen = $state(false);
selectedAppIndex = $state(-1);
editMode = $state(false);
#cleanup: (() => void) | null = null;
/**
* Check if the active element is a text input (input, textarea, contenteditable).
*/
#isInputFocused(): boolean {
if (typeof document === 'undefined') return false;
const active = document.activeElement;
if (!active) return false;
const tagName = active.tagName.toLowerCase();
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') {
return true;
}
if (active.getAttribute('contenteditable') === 'true') {
return true;
}
return false;
}
/**
* Get all visible app widget elements in order.
*/
#getAppWidgets(): HTMLElement[] {
if (typeof document === 'undefined') return [];
return Array.from(document.querySelectorAll<HTMLElement>('[data-app-widget]'));
}
/**
* Initialize global keyboard listeners.
* Must be called from a component context (to support cleanup via onDestroy).
*/
init() {
if (typeof window === 'undefined') return;
if (this.#cleanup) return; // Already initialized
const handleKeyDown = (e: KeyboardEvent) => {
// Never intercept when in input fields (except Escape)
if (this.#isInputFocused() && e.key !== 'Escape') {
return;
}
// Don't intercept if modifier keys are held (except for known combos)
if (e.ctrlKey || e.metaKey || e.altKey) {
return;
}
switch (e.key) {
case '?': {
e.preventDefault();
this.overlayOpen = !this.overlayOpen;
break;
}
case 'Escape': {
if (this.overlayOpen) {
e.preventDefault();
this.overlayOpen = false;
}
break;
}
case 'j': {
// Navigate down through app widgets
e.preventDefault();
const widgets = this.#getAppWidgets();
if (widgets.length === 0) break;
this.selectedAppIndex = Math.min(this.selectedAppIndex + 1, widgets.length - 1);
this.#focusSelectedWidget(widgets);
break;
}
case 'k': {
// Navigate up through app widgets
e.preventDefault();
const widgetsUp = this.#getAppWidgets();
if (widgetsUp.length === 0) break;
this.selectedAppIndex = Math.max(this.selectedAppIndex - 1, 0);
this.#focusSelectedWidget(widgetsUp);
break;
}
case 'Enter': {
// Open selected app
if (this.selectedAppIndex >= 0) {
const widgetsEnter = this.#getAppWidgets();
const selected = widgetsEnter[this.selectedAppIndex];
if (selected) {
e.preventDefault();
const link = selected.querySelector('a[href]') as HTMLAnchorElement | null;
if (link) {
window.open(link.href, '_blank');
}
}
}
break;
}
case 'e': {
// Toggle edit mode
e.preventDefault();
this.editMode = !this.editMode;
break;
}
case 'f': {
// Toggle favorites bar visibility — dispatch custom event
e.preventDefault();
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('toggle-favorites'));
}
break;
}
default: {
// 1-9: switch to board by index
const num = parseInt(e.key, 10);
if (num >= 1 && num <= 9) {
e.preventDefault();
this.#switchToBoard(num - 1);
}
break;
}
}
};
window.addEventListener('keydown', handleKeyDown);
this.#cleanup = () => {
window.removeEventListener('keydown', handleKeyDown);
};
}
/**
* Clean up listeners. Call from onDestroy.
*/
destroy() {
if (this.#cleanup) {
this.#cleanup();
this.#cleanup = null;
}
}
/**
* Focus and scroll to the selected app widget.
*/
#focusSelectedWidget(widgets: HTMLElement[]) {
const widget = widgets[this.selectedAppIndex];
if (!widget) return;
// Remove highlight from all widgets
for (const w of widgets) {
w.removeAttribute('data-keyboard-selected');
}
// Highlight and scroll to selected
widget.setAttribute('data-keyboard-selected', 'true');
widget.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
/**
* Switch to board by sidebar index.
*/
#switchToBoard(index: number) {
if (typeof document === 'undefined') return;
const boardLinks = document.querySelectorAll<HTMLAnchorElement>(
'aside nav a[href^="/boards/"]'
);
const link = boardLinks[index];
if (link) {
link.click();
}
}
/**
* Reset selection state (e.g., on page navigation).
*/
resetSelection() {
this.selectedAppIndex = -1;
if (typeof document !== 'undefined') {
const widgets = document.querySelectorAll('[data-keyboard-selected]');
for (const w of widgets) {
w.removeAttribute('data-keyboard-selected');
}
}
}
toggleOverlay() {
this.overlayOpen = !this.overlayOpen;
}
closeOverlay() {
this.overlayOpen = false;
}
}
export const keyboard = new KeyboardStore();
+103
View File
@@ -0,0 +1,103 @@
interface NotificationItem {
readonly id: string;
readonly appId: string | null;
readonly event: string;
readonly message: string;
readonly sentAt: string;
readonly readAt: string | null;
readonly app?: {
readonly name: string;
readonly icon: string | null;
} | null;
}
class NotificationsStore {
items = $state<NotificationItem[]>([]);
unreadCount = $state(0);
loading = $state(false);
error = $state<string | null>(null);
#pollTimer: ReturnType<typeof setInterval> | null = null;
get hasUnread(): boolean {
return this.unreadCount > 0;
}
async load() {
this.loading = true;
this.error = null;
try {
const res = await fetch('/api/notifications?limit=20');
if (!res.ok) {
this.error = 'Failed to load notifications';
return;
}
const json = await res.json();
if (json.success && Array.isArray(json.data)) {
this.items = json.data;
this.unreadCount = json.data.filter((n: NotificationItem) => n.readAt === null).length;
}
} catch {
this.error = 'Failed to load notifications';
} finally {
this.loading = false;
}
}
async markAsRead(notificationId: string) {
// Optimistic update
this.items = this.items.map((n) =>
n.id === notificationId ? { ...n, readAt: new Date().toISOString() } : n
);
this.unreadCount = Math.max(0, this.unreadCount - 1);
try {
await fetch('/api/notifications', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notificationId })
});
} catch {
// Reload on failure
await this.load();
}
}
async markAllAsRead() {
// Optimistic update
this.items = this.items.map((n) => ({
...n,
readAt: n.readAt ?? new Date().toISOString()
}));
this.unreadCount = 0;
try {
await fetch('/api/notifications', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ markAll: true })
});
} catch {
await this.load();
}
}
startPolling() {
this.stopPolling();
// Poll every 60 seconds
this.#pollTimer = setInterval(() => {
this.load();
}, 60_000);
}
stopPolling() {
if (this.#pollTimer) {
clearInterval(this.#pollTimer);
this.#pollTimer = null;
}
}
}
export const notifications = new NotificationsStore();
+1
View File
@@ -29,6 +29,7 @@ class SearchStore {
/** Grouped results for rendering, preserving group order. */
get grouped(): SearchGroup[] {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const map = new Map<string, SearchResultItem[]>();
for (const item of this.results) {
const existing = map.get(item.type);
+22 -2
View File
@@ -4,9 +4,11 @@ const THEME_STORAGE_KEY = 'wal-theme-mode';
const PRIMARY_HUE_KEY = 'wal-primary-hue';
const PRIMARY_SAT_KEY = 'wal-primary-sat';
const BG_TYPE_KEY = 'wal-bg-type';
const CARD_STYLE_KEY = 'wal-card-style';
export type ThemeMode = 'dark' | 'light' | 'system';
export type BackgroundType = 'mesh' | 'particles' | 'aurora' | 'none';
export type BackgroundType = 'mesh' | 'particles' | 'aurora' | 'none' | 'wallpaper';
export type CardStyle = 'solid' | 'glass' | 'outline';
function getStoredValue<T>(key: string, fallback: T): T {
if (typeof window === 'undefined') return fallback;
@@ -36,6 +38,7 @@ class ThemeStore {
primaryHue = $state(220);
primarySaturation = $state(70);
backgroundType = $state<BackgroundType>('mesh');
cardStyle = $state<CardStyle>('solid');
#systemPreference: 'dark' | 'light' = 'dark';
#suppressBroadcast = false;
@@ -52,6 +55,7 @@ class ThemeStore {
this.primaryHue = getStoredNumber(PRIMARY_HUE_KEY, 220);
this.primarySaturation = getStoredNumber(PRIMARY_SAT_KEY, 70);
this.backgroundType = getStoredValue<BackgroundType>(BG_TYPE_KEY, 'mesh');
this.cardStyle = getStoredValue<CardStyle>(CARD_STYLE_KEY, 'solid');
const mql = window.matchMedia('(prefers-color-scheme: dark)');
this.#systemPreference = mql.matches ? 'dark' : 'light';
@@ -83,6 +87,11 @@ class ThemeStore {
localStorage.setItem(BG_TYPE_KEY, this.backgroundType);
});
$effect(() => {
if (typeof window === 'undefined') return;
localStorage.setItem(CARD_STYLE_KEY, this.cardStyle);
});
$effect(() => {
if (typeof document === 'undefined') return;
const html = document.documentElement;
@@ -109,7 +118,8 @@ class ThemeStore {
mode: this.mode,
primaryHue: this.primaryHue,
primarySaturation: this.primarySaturation,
backgroundType: this.backgroundType
backgroundType: this.backgroundType,
cardStyle: this.cardStyle
};
if (typeof window === 'undefined') return;
if (this.#suppressBroadcast) return;
@@ -131,6 +141,10 @@ class ThemeStore {
this.backgroundType = bg;
}
setCardStyle(style: CardStyle) {
this.cardStyle = style;
}
setPrimaryColor(hue: number, saturation: number) {
this.primaryHue = Math.max(0, Math.min(360, hue));
this.primarySaturation = Math.max(0, Math.min(100, saturation));
@@ -145,12 +159,14 @@ class ThemeStore {
primaryHue: number;
primarySaturation: number;
backgroundType: BackgroundType;
cardStyle?: CardStyle;
}) {
this.#suppressBroadcast = true;
this.mode = values.mode;
this.primaryHue = values.primaryHue;
this.primarySaturation = values.primarySaturation;
this.backgroundType = values.backgroundType;
if (values.cardStyle) this.cardStyle = values.cardStyle;
// Use setTimeout to ensure all Svelte 5 effects have fired before re-enabling broadcast
setTimeout(() => {
this.#suppressBroadcast = false;
@@ -166,6 +182,7 @@ class ThemeStore {
primaryHue?: number | null;
primarySaturation?: number | null;
backgroundType?: string | null;
cardStyle?: string | null;
}) {
if (prefs.themeMode != null) {
this.mode = prefs.themeMode as ThemeMode;
@@ -179,6 +196,9 @@ class ThemeStore {
if (prefs.backgroundType != null) {
this.backgroundType = prefs.backgroundType as BackgroundType;
}
if (prefs.cardStyle != null) {
this.cardStyle = prefs.cardStyle as CardStyle;
}
}
}
+21
View File
@@ -0,0 +1,21 @@
import type { ApiTokenScope } from '$lib/utils/constants';
export interface ApiTokenRecord {
readonly id: string;
readonly userId: string;
readonly name: string;
readonly tokenHash: string;
readonly scope: ApiTokenScope;
readonly lastUsedAt: Date | null;
readonly expiresAt: Date | null;
readonly createdAt: Date;
}
export interface CreateApiTokenInput {
readonly userId: string;
readonly name: string;
readonly scope: ApiTokenScope;
readonly expiresAt?: Date;
}
export type TokenScope = ApiTokenScope;
+36
View File
@@ -57,3 +57,39 @@ export interface AppStatusRecord {
readonly responseTime: number | null;
readonly checkedAt: Date;
}
// --- New types for Phases 4-7 ---
export interface AppLinkRecord {
readonly id: string;
readonly appId: string;
readonly label: string;
readonly url: string;
readonly icon: string | null;
readonly order: number;
}
export interface CreateAppLinkInput {
readonly appId: string;
readonly label: string;
readonly url: string;
readonly icon?: string;
readonly order?: number;
}
export interface UpdateAppLinkInput {
readonly label?: string;
readonly url?: string;
readonly icon?: string | null;
readonly order?: number;
}
export interface AppWithRelations extends Omit<AppRecord, 'tags'> {
readonly tags: string;
readonly links: readonly AppLinkRecord[];
readonly structuredTags: readonly {
readonly id: string;
readonly name: string;
readonly color: string | null;
}[];
}
+21
View File
@@ -0,0 +1,21 @@
import type { AuditAction } from '$lib/utils/constants';
export type { AuditAction };
export interface AuditLogRecord {
readonly id: string;
readonly userId: string | null;
readonly action: AuditAction;
readonly entityType: string;
readonly entityId: string;
readonly details: string;
readonly createdAt: Date;
}
export interface CreateAuditLogInput {
readonly userId?: string;
readonly action: AuditAction;
readonly entityType: string;
readonly entityId: string;
readonly details?: string;
}
+18
View File
@@ -6,6 +6,14 @@ export interface BoardRecord {
readonly isDefault: boolean;
readonly isGuestAccessible: boolean;
readonly backgroundConfig: string | null;
readonly themeHue: number | null;
readonly themeSaturation: number | null;
readonly backgroundType: string | null;
readonly cardSize: string | null;
readonly wallpaperUrl: string | null;
readonly wallpaperBlur: number | null;
readonly wallpaperOverlay: number | null;
readonly customCss: string | null;
readonly createdById: string | null;
readonly createdAt: Date;
readonly updatedAt: Date;
@@ -28,6 +36,14 @@ export interface UpdateBoardInput {
readonly isDefault?: boolean;
readonly isGuestAccessible?: boolean;
readonly backgroundConfig?: string | null;
readonly themeHue?: number | null;
readonly themeSaturation?: number | null;
readonly backgroundType?: string | null;
readonly cardSize?: string | null;
readonly wallpaperUrl?: string | null;
readonly wallpaperBlur?: number | null;
readonly wallpaperOverlay?: number | null;
readonly customCss?: string | null;
}
export interface SectionRecord {
@@ -37,6 +53,7 @@ export interface SectionRecord {
readonly icon: string | null;
readonly order: number;
readonly isExpandedByDefault: boolean;
readonly cardSize: string | null;
readonly createdAt: Date;
readonly updatedAt: Date;
}
@@ -54,4 +71,5 @@ export interface UpdateSectionInput {
readonly icon?: string | null;
readonly order?: number;
readonly isExpandedByDefault?: boolean;
readonly cardSize?: string | null;
}
+5
View File
@@ -5,3 +5,8 @@ export type * from './app.js';
export type * from './board.js';
export type * from './widget.js';
export type * from './permission.js';
export type * from './tag.js';
export type * from './notification.js';
export type * from './apiToken.js';
export type * from './auditLog.js';
export type * from './template.js';
+38
View File
@@ -0,0 +1,38 @@
import type { NotificationType, NotificationEvent } from '$lib/utils/constants';
export interface NotificationChannelRecord {
readonly id: string;
readonly userId: string;
readonly type: NotificationType;
readonly config: string;
readonly enabled: boolean;
readonly createdAt: Date;
}
export interface NotificationRecord {
readonly id: string;
readonly userId: string;
readonly appId: string | null;
readonly event: NotificationEvent;
readonly message: string;
readonly sentAt: Date;
readonly readAt: Date | null;
}
export interface CreateNotificationChannelInput {
readonly userId: string;
readonly type: NotificationType;
readonly config: string;
readonly enabled?: boolean;
}
export interface UpdateNotificationChannelInput {
readonly type?: NotificationType;
readonly config?: string;
readonly enabled?: boolean;
}
export interface NotificationPreferences {
readonly channels: readonly NotificationChannelRecord[];
readonly enabledEvents: readonly NotificationEvent[];
}
+22
View File
@@ -0,0 +1,22 @@
export interface TagRecord {
readonly id: string;
readonly name: string;
readonly color: string | null;
readonly createdAt: Date;
}
export interface AppTagRecord {
readonly id: string;
readonly appId: string;
readonly tagId: string;
}
export interface CreateTagInput {
readonly name: string;
readonly color?: string;
}
export interface UpdateTagInput {
readonly name?: string;
readonly color?: string | null;
}
+19
View File
@@ -0,0 +1,19 @@
export interface BoardTemplateRecord {
readonly id: string;
readonly name: string;
readonly description: string | null;
readonly icon: string | null;
readonly config: string;
readonly isBuiltin: boolean;
readonly createdById: string | null;
readonly createdAt: Date;
}
export interface CreateBoardTemplateInput {
readonly name: string;
readonly description?: string;
readonly icon?: string;
readonly config: string;
readonly isBuiltin?: boolean;
readonly createdById?: string;
}
+23
View File
@@ -24,4 +24,27 @@ export interface UpdateUserInput {
readonly displayName?: string;
readonly avatarUrl?: string | null;
readonly role?: UserRole;
readonly onboardingComplete?: boolean;
readonly trackRecentApps?: boolean;
}
// --- New types for Phases 4-7 ---
export interface UserFavoriteRecord {
readonly id: string;
readonly userId: string;
readonly appId: string;
readonly order: number;
}
export interface AppClickRecord {
readonly id: string;
readonly userId: string;
readonly appId: string;
readonly clickedAt: Date;
}
export interface UserWithPreferences extends UserRecord {
readonly onboardingComplete: boolean;
readonly trackRecentApps: boolean;
}
+64
View File
@@ -53,3 +53,67 @@ export interface StatusWidgetConfig {
readonly appIds: readonly string[];
readonly label?: string;
}
// --- New widget config types for Phases 4-7 ---
export interface ClockWeatherWidgetConfig {
readonly timezone?: string;
readonly showWeather?: boolean;
readonly latitude?: number;
readonly longitude?: number;
readonly clockStyle?: 'analog' | 'digital' | '24h';
}
export interface SystemStatsWidgetConfig {
readonly sourceUrl: string;
readonly sourceType: 'glances' | 'prometheus' | 'custom';
readonly metrics: readonly string[];
readonly refreshInterval?: number;
}
export interface RssWidgetConfig {
readonly feedUrl: string;
readonly maxItems?: number;
readonly showSummary?: boolean;
}
export interface CalendarWidgetConfig {
readonly icalUrls: readonly {
readonly url: string;
readonly color?: string;
readonly label?: string;
}[];
readonly daysAhead?: number;
}
export interface MarkdownWidgetConfig {
readonly content: string;
readonly syntaxTheme?: string;
}
export interface MetricWidgetConfig {
readonly label: string;
readonly source: 'static' | 'json' | 'prometheus';
readonly value?: string;
readonly url?: string;
readonly jsonPath?: string;
readonly query?: string;
readonly unit?: string;
readonly refreshInterval?: number;
}
export interface LinkGroupWidgetConfig {
readonly links: readonly {
readonly label: string;
readonly url: string;
readonly icon?: string;
}[];
readonly collapsible?: boolean;
}
export interface CameraWidgetConfig {
readonly streamUrl: string;
readonly type: 'mjpeg' | 'hls' | 'image';
readonly refreshInterval?: number;
readonly aspectRatio?: string;
}
@@ -31,9 +31,7 @@ class MockBroadcastChannel {
}
removeEventListener(type: string, handler: (event: { data: unknown }) => void) {
this.listeners = this.listeners.filter(
(l) => !(l.type === type && l.handler === handler)
);
this.listeners = this.listeners.filter((l) => !(l.type === type && l.handler === handler));
}
close() {
+2 -1
View File
@@ -1,4 +1,4 @@
import type { ThemeMode, BackgroundType } from '$lib/stores/theme.svelte';
import type { ThemeMode, BackgroundType, CardStyle } from '$lib/stores/theme.svelte';
const CHANNEL_NAME = 'wal-sync';
@@ -9,6 +9,7 @@ export interface ThemeChangeMessage {
readonly primaryHue: number;
readonly primarySaturation: number;
readonly backgroundType: BackgroundType;
readonly cardStyle?: CardStyle;
};
}

Some files were not shown because too many files have changed in this diff Show More