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:
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import { superForm, type SuperValidated } from 'sveltekit-superforms';
|
||||
import type { z } from 'zod';
|
||||
import type { createAppSchema } from '$lib/utils/validators.js';
|
||||
@@ -24,7 +25,7 @@
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="name" class="mb-1 block text-sm font-medium text-card-foreground">
|
||||
Name <span class="text-destructive">*</span>
|
||||
{$t('app.name')} <span class="text-destructive">{$t('common.required')}</span>
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
@@ -32,7 +33,7 @@
|
||||
type="text"
|
||||
bind:value={$form.name}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="My Application"
|
||||
placeholder={$t('app.name_placeholder')}
|
||||
/>
|
||||
{#if $errors.name}
|
||||
<p class="mt-1 text-sm text-destructive">{$errors.name[0]}</p>
|
||||
@@ -41,7 +42,7 @@
|
||||
|
||||
<div>
|
||||
<label for="url" class="mb-1 block text-sm font-medium text-card-foreground">
|
||||
URL <span class="text-destructive">*</span>
|
||||
{$t('app.url')} <span class="text-destructive">{$t('common.required')}</span>
|
||||
</label>
|
||||
<input
|
||||
id="url"
|
||||
@@ -49,7 +50,7 @@
|
||||
type="url"
|
||||
bind:value={$form.url}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="https://my-app.local:8080"
|
||||
placeholder={$t('app.url_placeholder')}
|
||||
/>
|
||||
{#if $errors.url}
|
||||
<p class="mt-1 text-sm text-destructive">{$errors.url[0]}</p>
|
||||
@@ -59,7 +60,7 @@
|
||||
|
||||
<div>
|
||||
<label for="description" class="mb-1 block text-sm font-medium text-card-foreground">
|
||||
Description
|
||||
{$t('app.description')}
|
||||
</label>
|
||||
<input
|
||||
id="description"
|
||||
@@ -67,14 +68,14 @@
|
||||
type="text"
|
||||
bind:value={$form.description}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="Brief description of this app"
|
||||
placeholder={$t('app.description_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="category" class="mb-1 block text-sm font-medium text-card-foreground">
|
||||
Category
|
||||
{$t('app.category')}
|
||||
</label>
|
||||
<input
|
||||
id="category"
|
||||
@@ -82,13 +83,13 @@
|
||||
type="text"
|
||||
bind:value={$form.category}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="e.g. Media, Monitoring, Storage"
|
||||
placeholder={$t('app.category_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="tags" class="mb-1 block text-sm font-medium text-card-foreground">
|
||||
Tags
|
||||
{$t('app.tags')}
|
||||
</label>
|
||||
<input
|
||||
id="tags"
|
||||
@@ -96,7 +97,7 @@
|
||||
type="text"
|
||||
bind:value={$form.tags}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="Comma-separated tags"
|
||||
placeholder={$t('app.tags_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,7 +118,7 @@
|
||||
onclick={() => (showAdvanced = !showAdvanced)}
|
||||
class="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showAdvanced ? 'Hide' : 'Show'} Healthcheck Settings
|
||||
{showAdvanced ? $t('app.healthcheck_hide') : $t('app.healthcheck_show')} {$t('app.healthcheck_toggle')}
|
||||
</button>
|
||||
|
||||
{#if showAdvanced}
|
||||
@@ -131,7 +132,7 @@
|
||||
class="rounded border-input"
|
||||
/>
|
||||
<label for="healthcheckEnabled" class="text-sm text-card-foreground">
|
||||
Enable Healthcheck
|
||||
{$t('app.healthcheck_enabled')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -142,7 +143,7 @@
|
||||
for="healthcheckMethod"
|
||||
class="mb-1 block text-sm font-medium text-card-foreground"
|
||||
>
|
||||
Method
|
||||
{$t('app.healthcheck_method')}
|
||||
</label>
|
||||
<select
|
||||
id="healthcheckMethod"
|
||||
@@ -160,7 +161,7 @@
|
||||
for="healthcheckExpectedStatus"
|
||||
class="mb-1 block text-sm font-medium text-card-foreground"
|
||||
>
|
||||
Expected Status
|
||||
{$t('app.healthcheck_expected_status')}
|
||||
</label>
|
||||
<input
|
||||
id="healthcheckExpectedStatus"
|
||||
@@ -178,7 +179,7 @@
|
||||
for="healthcheckTimeout"
|
||||
class="mb-1 block text-sm font-medium text-card-foreground"
|
||||
>
|
||||
Timeout (ms)
|
||||
{$t('app.healthcheck_timeout')}
|
||||
</label>
|
||||
<input
|
||||
id="healthcheckTimeout"
|
||||
@@ -198,7 +199,7 @@
|
||||
for="healthcheckInterval"
|
||||
class="mb-1 block text-sm font-medium text-card-foreground"
|
||||
>
|
||||
Interval (seconds)
|
||||
{$t('app.healthcheck_interval')}
|
||||
</label>
|
||||
<input
|
||||
id="healthcheckInterval"
|
||||
@@ -221,9 +222,9 @@
|
||||
class="rounded-md bg-primary px-6 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#if $submitting}
|
||||
Saving...
|
||||
{$t('app.saving')}
|
||||
{:else}
|
||||
Save App
|
||||
{$t('app.save')}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
status: string;
|
||||
}
|
||||
@@ -8,18 +10,18 @@
|
||||
const config = $derived.by(() => {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
return { color: 'bg-green-500', cssClass: 'status-online', text: 'Online' };
|
||||
return { color: 'bg-green-500', cssClass: 'status-online', textKey: 'status.online' };
|
||||
case 'offline':
|
||||
return { color: 'bg-red-500', cssClass: '', text: 'Offline' };
|
||||
return { color: 'bg-red-500', cssClass: '', textKey: 'status.offline' };
|
||||
case 'degraded':
|
||||
return { color: 'bg-yellow-500', cssClass: '', text: 'Degraded' };
|
||||
return { color: 'bg-yellow-500', cssClass: '', textKey: 'status.degraded' };
|
||||
default:
|
||||
return { color: 'bg-gray-500', cssClass: '', text: 'Unknown' };
|
||||
return { color: 'bg-gray-500', cssClass: '', textKey: 'status.unknown' };
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<span class="inline-flex items-center gap-1.5 text-xs">
|
||||
<span class="inline-block h-2 w-2 rounded-full {config.color} {config.cssClass}"></span>
|
||||
<span class="text-muted-foreground">{config.text}</span>
|
||||
<span class="text-muted-foreground">{$t(config.textKey)}</span>
|
||||
</span>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
iconType: string;
|
||||
iconValue: string;
|
||||
@@ -22,7 +24,7 @@
|
||||
</script>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium text-card-foreground">Icon</label>
|
||||
<label class="block text-sm font-medium text-card-foreground">{$t('app.icon')}</label>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<select
|
||||
@@ -30,10 +32,10 @@
|
||||
onchange={handleTypeChange}
|
||||
class="rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="lucide">Lucide Icon</option>
|
||||
<option value="simple">Simple Icons</option>
|
||||
<option value="url">Image URL</option>
|
||||
<option value="emoji">Emoji</option>
|
||||
<option value="lucide">{$t('app.icon_lucide')}</option>
|
||||
<option value="simple">{$t('app.icon_simple')}</option>
|
||||
<option value="url">{$t('app.icon_url')}</option>
|
||||
<option value="emoji">{$t('app.icon_emoji')}</option>
|
||||
</select>
|
||||
|
||||
<input
|
||||
@@ -41,12 +43,12 @@
|
||||
value={iconValue}
|
||||
oninput={handleValueChange}
|
||||
placeholder={iconType === 'lucide'
|
||||
? 'e.g. globe, server, home'
|
||||
? $t('app.icon_lucide_placeholder')
|
||||
: iconType === 'simple'
|
||||
? 'e.g. github, docker'
|
||||
? $t('app.icon_simple_placeholder')
|
||||
: iconType === 'url'
|
||||
? 'https://example.com/icon.png'
|
||||
: 'e.g. 🌐'}
|
||||
? $t('app.icon_url_placeholder')
|
||||
: $t('app.icon_emoji_placeholder')}
|
||||
class="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
@@ -54,7 +56,7 @@
|
||||
{#if iconType === 'emoji' && iconValue}
|
||||
<div class="text-2xl">{iconValue}</div>
|
||||
{:else if iconType === 'url' && iconValue}
|
||||
<img src={iconValue} alt="Icon preview" class="h-8 w-8 rounded object-contain" />
|
||||
<img src={iconValue} alt={$t('app.icon_preview')} class="h-8 w-8 rounded object-contain" />
|
||||
{:else if iconType === 'simple' && iconValue}
|
||||
<img
|
||||
src="https://cdn.simpleicons.org/{iconValue.toLowerCase()}"
|
||||
|
||||
Reference in New Issue
Block a user