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:
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.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>
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user