feat(phase2): localization EN/RU + additional widget types

- Add svelte-i18n with 224 translation keys (English + Russian)
- Language switcher in header (EN/RU toggle, persists to localStorage)
- Extract all hardcoded strings from 37 component/page files
- Add 4 new widget types: Bookmark, Note (markdown), Embed (iframe), Status
- WidgetRenderer dispatches by type, WidgetGrid supports full-width widgets
- Type-specific config forms in board editor
- Install marked for markdown rendering
This commit is contained in:
2026-03-24 23:18:05 +03:00
parent bf4e5089ee
commit 477c0e4d52
52 changed files with 1776 additions and 395 deletions
@@ -0,0 +1,50 @@
<script lang="ts">
interface BookmarkConfig {
url: string;
label: string;
icon?: string;
description?: string;
}
interface Props {
config: BookmarkConfig;
}
let { config }: Props = $props();
</script>
<a
href={config.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 config.icon}
<span class="text-2xl">{config.icon}</span>
{:else}
<span class="text-lg font-bold text-muted-foreground">
{config.label.charAt(0).toUpperCase()}
</span>
{/if}
</div>
<!-- Label -->
<span class="w-full truncate text-sm font-medium text-foreground transition-colors group-hover:text-primary">
{config.label}
</span>
<!-- Description -->
{#if config.description}
<span class="line-clamp-2 w-full text-xs text-muted-foreground">
{config.description}
</span>
{/if}
<!-- Badge -->
<span class="inline-flex items-center gap-1.5 text-xs">
<span class="inline-block h-2 w-2 rounded-full bg-blue-500"></span>
<span class="text-muted-foreground">Bookmark</span>
</span>
</a>
@@ -0,0 +1,50 @@
<script lang="ts">
interface EmbedConfig {
url: string;
height: number;
sandbox?: string;
}
interface Props {
config: EmbedConfig;
}
let { config }: Props = $props();
let loading = $state(true);
const iframeHeight = $derived(config.height || 300);
const sandboxValue = $derived(config.sandbox || 'allow-scripts allow-same-origin');
function handleLoad() {
loading = false;
}
</script>
<div class="flex flex-col rounded-xl border border-border bg-card">
<div class="relative" style="height: {iframeHeight}px;">
{#if loading}
<div class="absolute inset-0 flex items-center justify-center bg-muted/50">
<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}
<iframe
src={config.url}
title="Embedded content"
sandbox={sandboxValue}
class="h-full w-full rounded-xl border-0"
onload={handleLoad}
></iframe>
</div>
</div>
@@ -0,0 +1,42 @@
<script lang="ts">
import { marked } from 'marked';
interface NoteConfig {
content: string;
format: 'markdown' | 'text';
}
interface Props {
config: NoteConfig;
}
let { config }: Props = $props();
// Configure marked for security
marked.setOptions({
breaks: true,
gfm: true
});
const renderedContent = $derived.by(() => {
if (config.format === 'text') {
return config.content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
}
// Sanitize by stripping script tags and event handlers from markdown output
const raw = marked.parse(config.content, { async: false }) as string;
return raw
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/\s*on\w+\s*=\s*"[^"]*"/gi, '')
.replace(/\s*on\w+\s*=\s*'[^']*'/gi, '');
});
</script>
<div class="flex h-full flex-col rounded-xl border border-border bg-card p-4">
<div class="prose prose-sm prose-invert max-w-none flex-1 overflow-auto text-foreground">
{@html renderedContent}
</div>
</div>
@@ -0,0 +1,145 @@
<script lang="ts">
interface AppData {
id: string;
name: string;
url: string;
icon: string | null;
iconType: string;
description: string | null;
statuses: Array<{ status: string; responseTime: number | null }>;
}
interface StatusConfig {
appIds: string[];
label?: string;
}
interface Props {
config: StatusConfig;
apps: AppData[];
}
let { config, apps }: Props = $props();
// Filter apps that match the configured appIds
const matchedApps = $derived(
config.appIds
.map((id) => apps.find((a) => a.id === id))
.filter((a): a is AppData => a !== undefined)
);
const statusCounts = $derived.by(() => {
const counts = { online: 0, offline: 0, degraded: 0, unknown: 0 };
for (const app of matchedApps) {
const status = app.statuses[0]?.status ?? 'unknown';
if (status in counts) {
counts[status as keyof typeof counts] += 1;
} else {
counts.unknown += 1;
}
}
return counts;
});
const total = $derived(matchedApps.length);
let expanded = $state(false);
</script>
<div class="flex flex-col rounded-xl border border-border bg-card p-4">
<!-- Header -->
<button
type="button"
onclick={() => (expanded = !expanded)}
class="flex w-full items-center justify-between text-left"
>
<span class="text-sm font-medium text-foreground">
{config.label || 'Service Status'}
</span>
<span class="text-xs text-muted-foreground">{total} services</span>
</button>
<!-- Status bar -->
<div class="mt-3 flex gap-1">
{#if statusCounts.online > 0}
<div
class="h-2 rounded-full bg-green-500"
style="flex: {statusCounts.online}"
title="{statusCounts.online} online"
></div>
{/if}
{#if statusCounts.degraded > 0}
<div
class="h-2 rounded-full bg-yellow-500"
style="flex: {statusCounts.degraded}"
title="{statusCounts.degraded} degraded"
></div>
{/if}
{#if statusCounts.offline > 0}
<div
class="h-2 rounded-full bg-red-500"
style="flex: {statusCounts.offline}"
title="{statusCounts.offline} offline"
></div>
{/if}
{#if statusCounts.unknown > 0}
<div
class="h-2 rounded-full bg-gray-500"
style="flex: {statusCounts.unknown}"
title="{statusCounts.unknown} unknown"
></div>
{/if}
</div>
<!-- Summary counts -->
<div class="mt-2 flex flex-wrap gap-3 text-xs">
{#if statusCounts.online > 0}
<span class="flex items-center gap-1">
<span class="inline-block h-2 w-2 rounded-full bg-green-500"></span>
{statusCounts.online} online
</span>
{/if}
{#if statusCounts.degraded > 0}
<span class="flex items-center gap-1">
<span class="inline-block h-2 w-2 rounded-full bg-yellow-500"></span>
{statusCounts.degraded} degraded
</span>
{/if}
{#if statusCounts.offline > 0}
<span class="flex items-center gap-1">
<span class="inline-block h-2 w-2 rounded-full bg-red-500"></span>
{statusCounts.offline} offline
</span>
{/if}
{#if statusCounts.unknown > 0}
<span class="flex items-center gap-1">
<span class="inline-block h-2 w-2 rounded-full bg-gray-500"></span>
{statusCounts.unknown} unknown
</span>
{/if}
</div>
<!-- Expanded: individual app statuses -->
{#if expanded}
<div class="mt-3 space-y-1 border-t border-border pt-3">
{#each matchedApps as app (app.id)}
{@const status = app.statuses[0]?.status ?? 'unknown'}
{@const statusColor =
status === 'online'
? 'bg-green-500'
: status === 'offline'
? 'bg-red-500'
: status === 'degraded'
? 'bg-yellow-500'
: 'bg-gray-500'}
<div class="flex items-center justify-between text-xs">
<span class="text-foreground">{app.name}</span>
<span class="flex items-center gap-1">
<span class="inline-block h-2 w-2 rounded-full {statusColor}"></span>
<span class="text-muted-foreground">{status}</span>
</span>
</div>
{/each}
</div>
{/if}
</div>
+25 -21
View File
@@ -1,45 +1,49 @@
<script lang="ts">
import AppWidget from './AppWidget.svelte';
import { t } from 'svelte-i18n';
import WidgetRenderer from './WidgetRenderer.svelte';
import WidgetContainer from './WidgetContainer.svelte';
interface AppData {
id: string;
name: string;
url: string;
icon: string | null;
iconType: string;
description: string | null;
statuses: Array<{ status: string; responseTime: number | null }>;
}
interface WidgetData {
id: string;
type: string;
order: number;
config: string;
appId: string | null;
app: {
id: string;
name: string;
url: string;
icon: string | null;
iconType: string;
description: string | null;
statuses: Array<{ status: string; responseTime: number | null }>;
} | null;
app: AppData | null;
}
interface Props {
widgets: WidgetData[];
allApps?: AppData[];
}
let { widgets }: Props = $props();
let { widgets, allApps = [] }: Props = $props();
// Widgets that should span full width
const fullWidthTypes = new Set(['note', 'embed', 'status']);
</script>
{#if widgets.length === 0}
<p class="text-sm text-muted-foreground">No widgets in this section.</p>
<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">
{#each widgets as widget (widget.id)}
<WidgetContainer>
{#if widget.type === 'app' && widget.app}
<AppWidget app={widget.app} />
{: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">{widget.type} widget</span>
</div>
{/if}
</WidgetContainer>
{@const isFullWidth = fullWidthTypes.has(widget.type)}
<div class={isFullWidth ? 'col-span-2 sm:col-span-3 lg:col-span-4' : ''}>
<WidgetContainer>
<WidgetRenderer {widget} {allApps} />
</WidgetContainer>
</div>
{/each}
</div>
{/if}
@@ -0,0 +1,58 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import AppWidget from './AppWidget.svelte';
import BookmarkWidget from './BookmarkWidget.svelte';
import NoteWidget from './NoteWidget.svelte';
import EmbedWidget from './EmbedWidget.svelte';
import StatusWidget from './StatusWidget.svelte';
interface AppData {
id: string;
name: string;
url: string;
icon: string | null;
iconType: string;
description: string | null;
statuses: Array<{ status: string; responseTime: number | null }>;
}
interface WidgetData {
id: string;
type: string;
order: number;
config: string;
appId: string | null;
app: AppData | null;
}
interface Props {
widget: WidgetData;
allApps?: AppData[];
}
let { widget, allApps = [] }: Props = $props();
const parsedConfig = $derived.by(() => {
try {
return JSON.parse(widget.config || '{}');
} catch {
return {};
}
});
</script>
{#if widget.type === 'app' && widget.app}
<AppWidget app={widget.app} />
{:else if widget.type === 'bookmark'}
<BookmarkWidget config={parsedConfig} />
{:else if widget.type === 'note'}
<NoteWidget config={{ content: parsedConfig.content ?? '', format: parsedConfig.format ?? 'markdown' }} />
{:else if widget.type === 'embed'}
<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}
<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>
</div>
{/if}