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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user