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 { enhance } from '$app/forms';
|
||||
|
||||
interface GroupWithCount {
|
||||
@@ -30,11 +31,11 @@
|
||||
<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">Description</th>
|
||||
<th class="px-4 py-3 font-medium text-muted-foreground">Members</th>
|
||||
<th class="px-4 py-3 font-medium text-muted-foreground">Default</th>
|
||||
<th class="px-4 py-3 font-medium text-muted-foreground">Actions</th>
|
||||
<th class="px-4 py-3 font-medium text-muted-foreground">{$t('admin.name_column')}</th>
|
||||
<th class="px-4 py-3 font-medium text-muted-foreground">{$t('admin.description_column')}</th>
|
||||
<th class="px-4 py-3 font-medium text-muted-foreground">{$t('admin.members_column')}</th>
|
||||
<th class="px-4 py-3 font-medium text-muted-foreground">{$t('admin.default_column')}</th>
|
||||
<th class="px-4 py-3 font-medium text-muted-foreground">{$t('admin.actions_column')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -60,26 +61,26 @@
|
||||
name="description"
|
||||
type="text"
|
||||
bind:value={editDescription}
|
||||
placeholder="Description"
|
||||
placeholder={$t('common.description')}
|
||||
class="rounded border border-input bg-background px-2 py-1 text-sm text-foreground"
|
||||
/>
|
||||
<label class="flex items-center gap-1 text-xs text-foreground">
|
||||
<input name="isDefault" type="checkbox" bind:checked={editIsDefault} class="h-3.5 w-3.5" />
|
||||
Default
|
||||
{$t('admin.default_column')}
|
||||
</label>
|
||||
<button type="submit" class="text-xs text-primary hover:underline">Save</button>
|
||||
<button type="button" onclick={() => (editingGroupId = null)} class="text-xs text-muted-foreground hover:underline">Cancel</button>
|
||||
<button type="submit" class="text-xs text-primary hover:underline">{$t('common.save')}</button>
|
||||
<button type="button" onclick={() => (editingGroupId = null)} class="text-xs text-muted-foreground hover:underline">{$t('common.cancel')}</button>
|
||||
</form>
|
||||
</td>
|
||||
{:else}
|
||||
<td class="px-4 py-3 font-medium text-foreground">{group.name}</td>
|
||||
<td class="px-4 py-3 text-muted-foreground">{group.description ?? '—'}</td>
|
||||
<td class="px-4 py-3 text-muted-foreground">{group.description ?? '\u2014'}</td>
|
||||
<td class="px-4 py-3 text-muted-foreground">{group._count.users}</td>
|
||||
<td class="px-4 py-3">
|
||||
{#if group.isDefault}
|
||||
<span class="inline-flex rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary">Yes</span>
|
||||
<span class="inline-flex rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary">{$t('admin.yes')}</span>
|
||||
{:else}
|
||||
<span class="text-xs text-muted-foreground">No</span>
|
||||
<span class="text-xs text-muted-foreground">{$t('admin.no')}</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
@@ -89,7 +90,7 @@
|
||||
onclick={() => startEdit(group)}
|
||||
class="text-xs text-primary hover:underline"
|
||||
>
|
||||
Edit
|
||||
{$t('common.edit')}
|
||||
</button>
|
||||
{#if confirmDeleteId === group.id}
|
||||
<form method="POST" action="?/delete" use:enhance={() => {
|
||||
@@ -99,9 +100,9 @@
|
||||
};
|
||||
}}>
|
||||
<input type="hidden" name="groupId" value={group.id} />
|
||||
<span class="text-xs text-destructive">Confirm?</span>
|
||||
<button type="submit" class="ml-1 text-xs font-medium text-destructive hover:underline">Yes</button>
|
||||
<button type="button" onclick={() => (confirmDeleteId = null)} class="ml-1 text-xs text-muted-foreground hover:underline">No</button>
|
||||
<span class="text-xs text-destructive">{$t('common.confirm')}</span>
|
||||
<button type="submit" class="ml-1 text-xs font-medium text-destructive hover:underline">{$t('common.yes')}</button>
|
||||
<button type="button" onclick={() => (confirmDeleteId = null)} class="ml-1 text-xs text-muted-foreground hover:underline">{$t('common.no')}</button>
|
||||
</form>
|
||||
{:else}
|
||||
<button
|
||||
@@ -109,7 +110,7 @@
|
||||
onclick={() => (confirmDeleteId = group.id)}
|
||||
class="text-xs text-destructive hover:underline"
|
||||
>
|
||||
Delete
|
||||
{$t('common.delete')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -121,6 +122,6 @@
|
||||
</table>
|
||||
|
||||
{#if groups.length === 0}
|
||||
<div class="py-8 text-center text-sm text-muted-foreground">No groups found.</div>
|
||||
<div class="py-8 text-center text-sm text-muted-foreground">{$t('admin.no_groups')}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import { EntityType, TargetType, PermissionLevel } from '$lib/utils/constants.js';
|
||||
|
||||
interface PermissionRecord {
|
||||
@@ -95,69 +96,69 @@
|
||||
<div class="space-y-4">
|
||||
<!-- Grant form -->
|
||||
<div class="rounded-lg border border-border bg-card p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold text-card-foreground">Grant Permission</h3>
|
||||
<h3 class="mb-3 text-sm font-semibold text-card-foreground">{$t('admin.perm_title')}</h3>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-5">
|
||||
<div>
|
||||
<label for="perm-entity-type" class="mb-1 block text-xs text-muted-foreground">Entity Type</label>
|
||||
<label for="perm-entity-type" class="mb-1 block text-xs text-muted-foreground">{$t('admin.perm_entity_type')}</label>
|
||||
<select
|
||||
id="perm-entity-type"
|
||||
bind:value={selectedEntityType}
|
||||
onchange={() => (selectedEntityId = '')}
|
||||
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
|
||||
>
|
||||
<option value={EntityType.BOARD}>Board</option>
|
||||
<option value={EntityType.APP}>App</option>
|
||||
<option value={EntityType.BOARD}>{$t('admin.perm_board')}</option>
|
||||
<option value={EntityType.APP}>{$t('admin.perm_app')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="perm-entity" class="mb-1 block text-xs text-muted-foreground">Entity</label>
|
||||
<label for="perm-entity" class="mb-1 block text-xs text-muted-foreground">{$t('admin.perm_entity')}</label>
|
||||
<select
|
||||
id="perm-entity"
|
||||
bind:value={selectedEntityId}
|
||||
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
|
||||
>
|
||||
<option value="" disabled>Select...</option>
|
||||
<option value="" disabled>{$t('admin.perm_select')}</option>
|
||||
{#each entityOptions as option (option.id)}
|
||||
<option value={option.id}>{option.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="perm-target-type" class="mb-1 block text-xs text-muted-foreground">Target Type</label>
|
||||
<label for="perm-target-type" class="mb-1 block text-xs text-muted-foreground">{$t('admin.perm_target_type')}</label>
|
||||
<select
|
||||
id="perm-target-type"
|
||||
bind:value={selectedTargetType}
|
||||
onchange={() => (selectedTargetId = '')}
|
||||
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
|
||||
>
|
||||
<option value={TargetType.USER}>User</option>
|
||||
<option value={TargetType.GROUP}>Group</option>
|
||||
<option value={TargetType.USER}>{$t('admin.perm_user')}</option>
|
||||
<option value={TargetType.GROUP}>{$t('admin.perm_group')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="perm-target" class="mb-1 block text-xs text-muted-foreground">Target</label>
|
||||
<label for="perm-target" class="mb-1 block text-xs text-muted-foreground">{$t('admin.perm_target')}</label>
|
||||
<select
|
||||
id="perm-target"
|
||||
bind:value={selectedTargetId}
|
||||
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
|
||||
>
|
||||
<option value="" disabled>Select...</option>
|
||||
<option value="" disabled>{$t('admin.perm_select')}</option>
|
||||
{#each targetOptions as option (option.id)}
|
||||
<option value={option.id}>{option.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="perm-level" class="mb-1 block text-xs text-muted-foreground">Level</label>
|
||||
<label for="perm-level" class="mb-1 block text-xs text-muted-foreground">{$t('admin.perm_level')}</label>
|
||||
<div class="flex gap-1">
|
||||
<select
|
||||
id="perm-level"
|
||||
bind:value={selectedLevel}
|
||||
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
|
||||
>
|
||||
<option value={PermissionLevel.VIEW}>View</option>
|
||||
<option value={PermissionLevel.EDIT}>Edit</option>
|
||||
<option value={PermissionLevel.ADMIN}>Admin</option>
|
||||
<option value={PermissionLevel.VIEW}>{$t('admin.perm_view')}</option>
|
||||
<option value={PermissionLevel.EDIT}>{$t('admin.perm_edit')}</option>
|
||||
<option value={PermissionLevel.ADMIN}>{$t('admin.perm_admin')}</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
@@ -165,7 +166,7 @@
|
||||
disabled={!selectedEntityId || !selectedTargetId}
|
||||
class="shrink-0 rounded bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
Grant
|
||||
{$t('admin.perm_grant')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -178,10 +179,10 @@
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="border-b border-border bg-muted/50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-xs font-medium text-muted-foreground">Entity</th>
|
||||
<th class="px-4 py-2 text-xs font-medium text-muted-foreground">Target</th>
|
||||
<th class="px-4 py-2 text-xs font-medium text-muted-foreground">Level</th>
|
||||
<th class="px-4 py-2 text-xs font-medium text-muted-foreground">Action</th>
|
||||
<th class="px-4 py-2 text-xs font-medium text-muted-foreground">{$t('admin.perm_entity_column')}</th>
|
||||
<th class="px-4 py-2 text-xs font-medium text-muted-foreground">{$t('admin.perm_target_column')}</th>
|
||||
<th class="px-4 py-2 text-xs font-medium text-muted-foreground">{$t('admin.perm_level_column')}</th>
|
||||
<th class="px-4 py-2 text-xs font-medium text-muted-foreground">{$t('admin.perm_action_column')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -206,7 +207,7 @@
|
||||
onclick={() => handleRevoke(perm)}
|
||||
class="text-xs text-destructive hover:underline"
|
||||
>
|
||||
Revoke
|
||||
{$t('admin.perm_revoke')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -215,6 +216,6 @@
|
||||
</table>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-muted-foreground">No permissions configured.</p>
|
||||
<p class="text-sm text-muted-foreground">{$t('admin.perm_none')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import { superForm, type SuperValidated } from 'sveltekit-superforms/client';
|
||||
import type { updateSystemSettingsSchema } from '$lib/utils/validators.js';
|
||||
import type { z } from 'zod';
|
||||
@@ -22,12 +23,12 @@
|
||||
|
||||
if (response.ok && data.success) {
|
||||
oauthTestSuccess = true;
|
||||
oauthTestResult = `Connected to issuer: ${data.issuer}`;
|
||||
oauthTestResult = $t('admin.oauth_connected', { values: { issuer: data.issuer } });
|
||||
} else {
|
||||
oauthTestResult = data.error || 'Connection test failed';
|
||||
}
|
||||
} catch {
|
||||
oauthTestResult = 'Network error — could not reach the server';
|
||||
oauthTestResult = $t('admin.oauth_network_error');
|
||||
} finally {
|
||||
oauthTesting = false;
|
||||
}
|
||||
@@ -37,19 +38,19 @@
|
||||
<form method="POST" action="?/update" use:enhance class="space-y-8">
|
||||
<!-- Authentication -->
|
||||
<section class="rounded-lg border border-border bg-card p-6">
|
||||
<h2 class="mb-4 text-lg font-semibold text-card-foreground">Authentication</h2>
|
||||
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.authentication')}</h2>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="authMode" class="mb-1 block text-sm font-medium text-foreground">Auth Mode</label>
|
||||
<label for="authMode" class="mb-1 block text-sm font-medium text-foreground">{$t('admin.auth_mode')}</label>
|
||||
<select
|
||||
id="authMode"
|
||||
name="authMode"
|
||||
bind:value={$form.authMode}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
>
|
||||
<option value="local">Local</option>
|
||||
<option value="oauth">OAuth</option>
|
||||
<option value="both">Both</option>
|
||||
<option value="local">{$t('admin.auth_local')}</option>
|
||||
<option value="oauth">{$t('admin.auth_oauth')}</option>
|
||||
<option value="both">{$t('admin.auth_both')}</option>
|
||||
</select>
|
||||
{#if $errors.authMode}<span class="text-xs text-destructive">{$errors.authMode}</span>{/if}
|
||||
</div>
|
||||
@@ -62,7 +63,7 @@
|
||||
class="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<label for="registrationEnabled" class="text-sm font-medium text-foreground">
|
||||
Allow user registration
|
||||
{$t('admin.registration_enabled')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -70,42 +71,42 @@
|
||||
|
||||
<!-- OAuth Configuration -->
|
||||
<section class="rounded-lg border border-border bg-card p-6">
|
||||
<h2 class="mb-4 text-lg font-semibold text-card-foreground">OAuth Configuration</h2>
|
||||
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.oauth_config')}</h2>
|
||||
<p class="mb-4 text-xs text-muted-foreground">
|
||||
Configure your OIDC provider (e.g. Authentik, Keycloak). Set Auth Mode to "OAuth" or "Both" above to enable OAuth login.
|
||||
{$t('admin.oauth_description')}
|
||||
</p>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="oauthClientId" class="mb-1 block text-sm font-medium text-foreground">Client ID</label>
|
||||
<label for="oauthClientId" class="mb-1 block text-sm font-medium text-foreground">{$t('admin.oauth_client_id')}</label>
|
||||
<input
|
||||
id="oauthClientId"
|
||||
name="oauthClientId"
|
||||
type="text"
|
||||
bind:value={$form.oauthClientId}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
placeholder="OAuth client ID"
|
||||
placeholder={$t('admin.oauth_client_id_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="oauthClientSecret" class="mb-1 block text-sm font-medium text-foreground">Client Secret</label>
|
||||
<label for="oauthClientSecret" class="mb-1 block text-sm font-medium text-foreground">{$t('admin.oauth_client_secret')}</label>
|
||||
<input
|
||||
id="oauthClientSecret"
|
||||
name="oauthClientSecret"
|
||||
type="password"
|
||||
bind:value={$form.oauthClientSecret}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
placeholder="OAuth client secret"
|
||||
placeholder={$t('admin.oauth_client_secret_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label for="oauthDiscoveryUrl" class="mb-1 block text-sm font-medium text-foreground">Discovery URL</label>
|
||||
<label for="oauthDiscoveryUrl" class="mb-1 block text-sm font-medium text-foreground">{$t('admin.oauth_discovery_url')}</label>
|
||||
<input
|
||||
id="oauthDiscoveryUrl"
|
||||
name="oauthDiscoveryUrl"
|
||||
type="url"
|
||||
bind:value={$form.oauthDiscoveryUrl}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
placeholder="https://example.com/.well-known/openid-configuration"
|
||||
placeholder={$t('admin.oauth_discovery_url_placeholder')}
|
||||
/>
|
||||
{#if $errors.oauthDiscoveryUrl}<span class="text-xs text-destructive">{$errors.oauthDiscoveryUrl}</span>{/if}
|
||||
</div>
|
||||
@@ -116,7 +117,7 @@
|
||||
disabled={oauthTesting}
|
||||
class="rounded-md border border-border bg-background px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{oauthTesting ? 'Testing...' : 'Test Connection'}
|
||||
{oauthTesting ? $t('admin.oauth_testing') : $t('admin.oauth_test')}
|
||||
</button>
|
||||
{#if oauthTestResult}
|
||||
<span class="ml-3 text-sm {oauthTestSuccess ? 'text-green-600 dark:text-green-400' : 'text-destructive'}">
|
||||
@@ -129,22 +130,22 @@
|
||||
|
||||
<!-- Theme Defaults -->
|
||||
<section class="rounded-lg border border-border bg-card p-6">
|
||||
<h2 class="mb-4 text-lg font-semibold text-card-foreground">Theme Defaults</h2>
|
||||
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.theme_defaults')}</h2>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="defaultTheme" class="mb-1 block text-sm font-medium text-foreground">Default Theme</label>
|
||||
<label for="defaultTheme" class="mb-1 block text-sm font-medium text-foreground">{$t('admin.default_theme')}</label>
|
||||
<select
|
||||
id="defaultTheme"
|
||||
name="defaultTheme"
|
||||
bind:value={$form.defaultTheme}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">{$t('theme.dark')}</option>
|
||||
<option value="light">{$t('theme.light')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="defaultPrimaryColor" class="mb-1 block text-sm font-medium text-foreground">Default Primary Color</label>
|
||||
<label for="defaultPrimaryColor" class="mb-1 block text-sm font-medium text-foreground">{$t('admin.default_primary_color')}</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="defaultPrimaryColor"
|
||||
@@ -169,10 +170,10 @@
|
||||
|
||||
<!-- Healthcheck Defaults -->
|
||||
<section class="rounded-lg border border-border bg-card p-6">
|
||||
<h2 class="mb-4 text-lg font-semibold text-card-foreground">Healthcheck Defaults</h2>
|
||||
<p class="mb-4 text-xs text-muted-foreground">JSON configuration for default healthcheck behavior (interval, timeout, method).</p>
|
||||
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.healthcheck_defaults')}</h2>
|
||||
<p class="mb-4 text-xs text-muted-foreground">{$t('admin.healthcheck_defaults_description')}</p>
|
||||
<div>
|
||||
<label for="healthcheckDefaults" class="mb-1 block text-sm font-medium text-foreground">Defaults (JSON)</label>
|
||||
<label for="healthcheckDefaults" class="mb-1 block text-sm font-medium text-foreground">{$t('admin.healthcheck_defaults_label')}</label>
|
||||
<textarea
|
||||
id="healthcheckDefaults"
|
||||
name="healthcheckDefaults"
|
||||
@@ -195,7 +196,7 @@
|
||||
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={$delayed}
|
||||
>
|
||||
{$delayed ? 'Saving...' : 'Save Settings'}
|
||||
{$delayed ? $t('admin.saving_settings') : $t('admin.save_settings')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
interface UserWithGroups {
|
||||
@@ -38,12 +39,12 @@
|
||||
<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">User</th>
|
||||
<th class="px-4 py-3 font-medium text-muted-foreground">Email</th>
|
||||
<th class="px-4 py-3 font-medium text-muted-foreground">Role</th>
|
||||
<th class="px-4 py-3 font-medium text-muted-foreground">Provider</th>
|
||||
<th class="px-4 py-3 font-medium text-muted-foreground">Groups</th>
|
||||
<th class="px-4 py-3 font-medium text-muted-foreground">Actions</th>
|
||||
<th class="px-4 py-3 font-medium text-muted-foreground">{$t('admin.user_column')}</th>
|
||||
<th class="px-4 py-3 font-medium text-muted-foreground">{$t('admin.email_column')}</th>
|
||||
<th class="px-4 py-3 font-medium text-muted-foreground">{$t('admin.role_column')}</th>
|
||||
<th class="px-4 py-3 font-medium text-muted-foreground">{$t('admin.provider_column')}</th>
|
||||
<th class="px-4 py-3 font-medium text-muted-foreground">{$t('admin.groups_column')}</th>
|
||||
<th class="px-4 py-3 font-medium text-muted-foreground">{$t('admin.actions_column')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -64,11 +65,11 @@
|
||||
name="role"
|
||||
class="rounded border border-input bg-background px-2 py-1 text-xs text-foreground"
|
||||
>
|
||||
<option value="user" selected={user.role === 'user'}>User</option>
|
||||
<option value="admin" selected={user.role === 'admin'}>Admin</option>
|
||||
<option value="user" selected={user.role === 'user'}>{$t('admin.role_user')}</option>
|
||||
<option value="admin" selected={user.role === 'admin'}>{$t('admin.role_admin')}</option>
|
||||
</select>
|
||||
<button type="submit" class="ml-1 text-xs text-primary hover:underline">Save</button>
|
||||
<button type="button" onclick={() => (editingUserId = null)} class="ml-1 text-xs text-muted-foreground hover:underline">Cancel</button>
|
||||
<button type="submit" class="ml-1 text-xs text-primary hover:underline">{$t('common.save')}</button>
|
||||
<button type="button" onclick={() => (editingUserId = null)} class="ml-1 text-xs text-muted-foreground hover:underline">{$t('common.cancel')}</button>
|
||||
</form>
|
||||
{:else}
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {user.role === 'admin' ? 'bg-primary/20 text-primary' : 'bg-muted text-muted-foreground'}">
|
||||
@@ -85,7 +86,7 @@
|
||||
<form method="POST" action="?/removeFromGroup" use:enhance class="inline">
|
||||
<input type="hidden" name="userId" value={user.id} />
|
||||
<input type="hidden" name="groupId" value={group.id} />
|
||||
<button type="submit" class="text-muted-foreground hover:text-destructive" title="Remove from group">×</button>
|
||||
<button type="submit" class="text-muted-foreground hover:text-destructive" title={$t('admin.remove_from_group')}>×</button>
|
||||
</form>
|
||||
</span>
|
||||
{/each}
|
||||
@@ -103,13 +104,13 @@
|
||||
bind:value={selectedGroupId}
|
||||
class="rounded border border-input bg-background px-2 py-0.5 text-xs text-foreground"
|
||||
>
|
||||
<option value="" disabled>Select group</option>
|
||||
<option value="" disabled>{$t('admin.select_group')}</option>
|
||||
{#each groups.filter((g) => !user.groups.some((ug) => ug.id === g.id)) as group (group.id)}
|
||||
<option value={group.id}>{group.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button type="submit" class="text-xs text-primary hover:underline" disabled={!selectedGroupId}>Add</button>
|
||||
<button type="button" onclick={() => (addGroupUserId = null)} class="text-xs text-muted-foreground hover:underline">Cancel</button>
|
||||
<button type="submit" class="text-xs text-primary hover:underline" disabled={!selectedGroupId}>{$t('common.add')}</button>
|
||||
<button type="button" onclick={() => (addGroupUserId = null)} class="text-xs text-muted-foreground hover:underline">{$t('common.cancel')}</button>
|
||||
</form>
|
||||
{:else}
|
||||
<button
|
||||
@@ -117,7 +118,7 @@
|
||||
onclick={() => (addGroupUserId = user.id)}
|
||||
class="rounded-full border border-dashed border-border px-2 py-0.5 text-xs text-muted-foreground hover:border-primary hover:text-primary"
|
||||
>
|
||||
+ Add
|
||||
{$t('admin.add_to_group')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -129,7 +130,7 @@
|
||||
onclick={() => (editingUserId = editingUserId === user.id ? null : user.id)}
|
||||
class="text-xs text-primary hover:underline"
|
||||
>
|
||||
Edit
|
||||
{$t('common.edit')}
|
||||
</button>
|
||||
{#if confirmDeleteId === user.id}
|
||||
<form method="POST" action="?/delete" use:enhance={() => {
|
||||
@@ -139,9 +140,9 @@
|
||||
};
|
||||
}}>
|
||||
<input type="hidden" name="userId" value={user.id} />
|
||||
<span class="text-xs text-destructive">Confirm?</span>
|
||||
<button type="submit" class="ml-1 text-xs font-medium text-destructive hover:underline">Yes</button>
|
||||
<button type="button" onclick={() => (confirmDeleteId = null)} class="ml-1 text-xs text-muted-foreground hover:underline">No</button>
|
||||
<span class="text-xs text-destructive">{$t('common.confirm')}</span>
|
||||
<button type="submit" class="ml-1 text-xs font-medium text-destructive hover:underline">{$t('common.yes')}</button>
|
||||
<button type="button" onclick={() => (confirmDeleteId = null)} class="ml-1 text-xs text-muted-foreground hover:underline">{$t('common.no')}</button>
|
||||
</form>
|
||||
{:else}
|
||||
<button
|
||||
@@ -149,7 +150,7 @@
|
||||
onclick={() => (confirmDeleteId = user.id)}
|
||||
class="text-xs text-destructive hover:underline"
|
||||
>
|
||||
Delete
|
||||
{$t('common.delete')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -160,6 +161,6 @@
|
||||
</table>
|
||||
|
||||
{#if users.length === 0}
|
||||
<div class="py-8 text-center text-sm text-muted-foreground">No users found.</div>
|
||||
<div class="py-8 text-center text-sm text-muted-foreground">{$t('admin.no_users')}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -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()}"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import Section from '$lib/components/section/Section.svelte';
|
||||
|
||||
interface SectionData {
|
||||
@@ -25,21 +26,32 @@
|
||||
}>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
sections: SectionData[];
|
||||
interface AppData {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
icon: string | null;
|
||||
iconType: string;
|
||||
description: string | null;
|
||||
statuses: Array<{ status: string; responseTime: number | null }>;
|
||||
}
|
||||
|
||||
let { sections }: Props = $props();
|
||||
interface Props {
|
||||
sections: SectionData[];
|
||||
allApps?: AppData[];
|
||||
}
|
||||
|
||||
let { sections, allApps = [] }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
{#if sections.length === 0}
|
||||
<div class="rounded-xl border border-border bg-card/50 p-12 text-center">
|
||||
<p class="text-muted-foreground">This board has no sections yet.</p>
|
||||
<p class="text-muted-foreground">{$t('board.no_sections')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each sections as section (section.id)}
|
||||
<Section {section} />
|
||||
<Section {section} {allApps} />
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte';
|
||||
|
||||
interface BoardSummary {
|
||||
@@ -39,12 +40,12 @@
|
||||
</h3>
|
||||
{#if board.isDefault}
|
||||
<span class="shrink-0 rounded bg-primary/15 px-1.5 py-0.5 text-xs text-primary">
|
||||
Default
|
||||
{$t('board.default')}
|
||||
</span>
|
||||
{/if}
|
||||
{#if board.isGuestAccessible}
|
||||
<span class="shrink-0 rounded bg-accent px-1.5 py-0.5 text-xs text-accent-foreground">
|
||||
Guest
|
||||
{$t('board.guest')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -52,7 +53,7 @@
|
||||
<p class="mt-1 line-clamp-2 text-sm text-muted-foreground">{board.description}</p>
|
||||
{/if}
|
||||
<p class="mt-2 text-xs text-muted-foreground/70">
|
||||
{sectionCount} section{sectionCount === 1 ? '' : 's'}
|
||||
{$t('board.sections_count', { values: { count: sectionCount } })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -30,14 +31,14 @@
|
||||
href="/boards"
|
||||
class="rounded-lg border border-border px-3 py-2 text-sm text-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
All Boards
|
||||
{$t('board.all_boards')}
|
||||
</a>
|
||||
{#if canEdit}
|
||||
<a
|
||||
href="/boards/{boardId}/edit"
|
||||
class="rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Edit
|
||||
{$t('board.edit')}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import { dndzone } from 'svelte-dnd-action';
|
||||
import DraggableSection from '$lib/components/section/DraggableSection.svelte';
|
||||
|
||||
@@ -36,7 +37,7 @@
|
||||
addWidgetSectionId: string | null;
|
||||
onToggleAddWidget: (sectionId: string) => void;
|
||||
onDeleteSection: (sectionId: string) => void;
|
||||
onAddWidget: (sectionId: string, appId: string) => void;
|
||||
onAddWidget: (sectionId: string, widgetData: string) => void;
|
||||
onDeleteWidget: (widgetId: string) => void;
|
||||
}
|
||||
|
||||
@@ -99,7 +100,7 @@
|
||||
|
||||
{#if sections.length === 0}
|
||||
<div class="rounded-xl border border-border bg-card/50 p-8 text-center">
|
||||
<p class="text-muted-foreground">No sections yet. Add one to get started.</p>
|
||||
<p class="text-muted-foreground">{$t('board.no_sections')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import ThemeToggle from './ThemeToggle.svelte';
|
||||
import LanguageSwitcher from './LanguageSwitcher.svelte';
|
||||
import SearchTrigger from '$lib/components/search/SearchTrigger.svelte';
|
||||
import { ui } from '$lib/stores/ui.svelte.js';
|
||||
import { theme, type BackgroundType } from '$lib/stores/theme.svelte.js';
|
||||
@@ -13,11 +15,11 @@
|
||||
let showUserMenu = $state(false);
|
||||
let showBgMenu = $state(false);
|
||||
|
||||
const bgOptions: { value: BackgroundType; label: string }[] = [
|
||||
{ value: 'mesh', label: 'Mesh Gradient' },
|
||||
{ value: 'particles', label: 'Particles' },
|
||||
{ value: 'aurora', label: 'Aurora' },
|
||||
{ value: 'none', label: 'None' }
|
||||
const bgOptions: { value: BackgroundType; labelKey: string }[] = [
|
||||
{ value: 'mesh', labelKey: 'bg.mesh' },
|
||||
{ value: 'particles', labelKey: 'bg.particles' },
|
||||
{ value: 'aurora', labelKey: 'bg.aurora' },
|
||||
{ value: 'none', labelKey: 'bg.none' }
|
||||
];
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
@@ -42,7 +44,7 @@
|
||||
type="button"
|
||||
onclick={() => ui.toggleSidebar()}
|
||||
class="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
aria-label="Toggle sidebar"
|
||||
aria-label={$t('sidebar.toggle')}
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
@@ -72,8 +74,8 @@
|
||||
type="button"
|
||||
onclick={() => (showBgMenu = !showBgMenu)}
|
||||
class="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
title="Background effect"
|
||||
aria-label="Change background effect"
|
||||
title={$t('bg.title')}
|
||||
aria-label={$t('bg.aria_label')}
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
@@ -119,7 +121,7 @@
|
||||
{:else}
|
||||
<span class="h-3 w-3"></span>
|
||||
{/if}
|
||||
{opt.label}
|
||||
{$t(opt.labelKey)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -129,6 +131,9 @@
|
||||
<!-- Theme toggle -->
|
||||
<ThemeToggle />
|
||||
|
||||
<!-- Language switcher -->
|
||||
<LanguageSwitcher />
|
||||
|
||||
<!-- User menu -->
|
||||
{#if user}
|
||||
<div class="user-menu-container relative">
|
||||
@@ -175,7 +180,7 @@
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" />
|
||||
</svg>
|
||||
Sign Out
|
||||
{$t('auth.logout')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -186,7 +191,7 @@
|
||||
href="/login"
|
||||
class="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Sign In
|
||||
{$t('auth.login')}
|
||||
</a>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { storeLocale } from '$lib/i18n/index.js';
|
||||
|
||||
function toggleLocale() {
|
||||
const next = $locale === 'ru' ? 'en' : 'ru';
|
||||
locale.set(next);
|
||||
storeLocale(next);
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleLocale}
|
||||
class="inline-flex items-center justify-center rounded-md px-2 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
title={$locale === 'ru' ? 'Switch to English' : 'Переключить на русский'}
|
||||
>
|
||||
{$locale === 'ru' ? 'RU' : 'EN'}
|
||||
</button>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { Snippet } from 'svelte';
|
||||
import Sidebar from './Sidebar.svelte';
|
||||
import Header from './Header.svelte';
|
||||
@@ -40,7 +41,7 @@
|
||||
type="button"
|
||||
class="fixed inset-0 z-30 bg-black/50"
|
||||
onclick={() => ui.closeMobileSidebar()}
|
||||
aria-label="Close sidebar"
|
||||
aria-label={$t('sidebar.close')}
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import { ui } from '$lib/stores/ui.svelte.js';
|
||||
import { page } from '$app/stores';
|
||||
import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte';
|
||||
@@ -46,10 +47,10 @@
|
||||
<rect x="14" y="14" width="7" height="7" />
|
||||
<rect x="3" y="14" width="7" height="7" />
|
||||
</svg>
|
||||
<span class="text-sm font-semibold">App Launcher</span>
|
||||
<span class="text-sm font-semibold">{$t('app_name')}</span>
|
||||
</a>
|
||||
{:else}
|
||||
<a href="/" class="mx-auto text-sidebar-primary" title="App Launcher">
|
||||
<a href="/" class="mx-auto text-sidebar-primary" title={$t('app_name')}>
|
||||
<svg
|
||||
class="h-6 w-6"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -75,7 +76,7 @@
|
||||
<div class="mb-3">
|
||||
{#if !collapsed}
|
||||
<p class="mb-1 px-2 text-xs font-medium uppercase tracking-wider text-sidebar-foreground/50">
|
||||
Navigation
|
||||
{$t('nav.navigation')}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
@@ -84,7 +85,7 @@
|
||||
class="flex items-center gap-2 rounded-md px-2 py-2 text-sm transition-colors {isActive('/boards')
|
||||
? 'bg-sidebar-accent text-sidebar-accent-foreground'
|
||||
: 'text-sidebar-foreground hover:bg-sidebar-accent/50'}"
|
||||
title={collapsed ? 'Boards' : undefined}
|
||||
title={collapsed ? $t('nav.boards') : undefined}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 shrink-0"
|
||||
@@ -100,7 +101,7 @@
|
||||
<line x1="3" y1="9" x2="21" y2="9" />
|
||||
<line x1="9" y1="21" x2="9" y2="9" />
|
||||
</svg>
|
||||
{#if !collapsed}<span>Boards</span>{/if}
|
||||
{#if !collapsed}<span>{$t('nav.boards')}</span>{/if}
|
||||
</a>
|
||||
|
||||
<a
|
||||
@@ -108,7 +109,7 @@
|
||||
class="flex items-center gap-2 rounded-md px-2 py-2 text-sm transition-colors {isActive('/apps')
|
||||
? 'bg-sidebar-accent text-sidebar-accent-foreground'
|
||||
: 'text-sidebar-foreground hover:bg-sidebar-accent/50'}"
|
||||
title={collapsed ? 'Apps' : undefined}
|
||||
title={collapsed ? $t('nav.apps') : undefined}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 shrink-0"
|
||||
@@ -126,7 +127,7 @@
|
||||
d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
|
||||
/>
|
||||
</svg>
|
||||
{#if !collapsed}<span>Apps</span>{/if}
|
||||
{#if !collapsed}<span>{$t('nav.apps')}</span>{/if}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -137,7 +138,7 @@
|
||||
<p
|
||||
class="mb-1 px-2 text-xs font-medium uppercase tracking-wider text-sidebar-foreground/50"
|
||||
>
|
||||
Boards
|
||||
{$t('nav.boards')}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
@@ -174,7 +175,7 @@
|
||||
<p
|
||||
class="mb-1 px-2 text-xs font-medium uppercase tracking-wider text-sidebar-foreground/50"
|
||||
>
|
||||
Admin
|
||||
{$t('nav.admin')}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
@@ -183,7 +184,7 @@
|
||||
class="flex items-center gap-2 rounded-md px-2 py-2 text-sm transition-colors {isActive('/admin')
|
||||
? 'bg-sidebar-accent text-sidebar-accent-foreground'
|
||||
: 'text-sidebar-foreground hover:bg-sidebar-accent/50'}"
|
||||
title={collapsed ? 'Admin Panel' : undefined}
|
||||
title={collapsed ? $t('nav.admin_panel') : undefined}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 shrink-0"
|
||||
@@ -200,7 +201,7 @@
|
||||
/>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
{#if !collapsed}<span>Admin Panel</span>{/if}
|
||||
{#if !collapsed}<span>{$t('nav.admin_panel')}</span>{/if}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -213,7 +214,7 @@
|
||||
type="button"
|
||||
onclick={() => ui.toggleSidebar()}
|
||||
class="flex w-full items-center justify-center rounded-md p-2 text-sidebar-foreground transition-colors hover:bg-sidebar-accent"
|
||||
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
title={collapsed ? $t('sidebar.expand') : $t('sidebar.collapse')}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 transition-transform duration-200"
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import { theme } from '$lib/stores/theme.svelte.js';
|
||||
|
||||
const modeIcons: Record<string, { path: string; label: string }> = {
|
||||
const modeIcons: Record<string, { path: string; labelKey: string }> = {
|
||||
light: {
|
||||
path: 'M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z',
|
||||
label: 'Light'
|
||||
labelKey: 'theme.light'
|
||||
},
|
||||
dark: {
|
||||
path: 'M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z',
|
||||
label: 'Dark'
|
||||
labelKey: 'theme.dark'
|
||||
},
|
||||
system: {
|
||||
path: 'M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z',
|
||||
label: 'System'
|
||||
labelKey: 'theme.system'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -23,8 +24,8 @@
|
||||
type="button"
|
||||
onclick={() => theme.cycleMode()}
|
||||
class="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
title="Theme: {currentIcon.label}"
|
||||
aria-label="Toggle theme (current: {currentIcon.label})"
|
||||
title={$t('theme.title', { values: { mode: $t(currentIcon.labelKey) } })}
|
||||
aria-label={$t('theme.toggle', { values: { mode: $t(currentIcon.labelKey) } })}
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import { search } from '$lib/stores/search.svelte.js';
|
||||
import SearchResult from './SearchResult.svelte';
|
||||
|
||||
@@ -33,7 +34,7 @@
|
||||
<div
|
||||
class="w-full max-w-lg rounded-lg border border-border bg-popover shadow-2xl"
|
||||
role="dialog"
|
||||
aria-label="Search"
|
||||
aria-label={$t('search.placeholder')}
|
||||
>
|
||||
<!-- Input -->
|
||||
<div class="flex items-center gap-2 border-b border-border px-4 py-3">
|
||||
@@ -54,7 +55,7 @@
|
||||
bind:this={inputEl}
|
||||
bind:value={search.query}
|
||||
type="text"
|
||||
placeholder="Search apps and boards..."
|
||||
placeholder={$t('search.placeholder')}
|
||||
class="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
|
||||
/>
|
||||
<kbd
|
||||
@@ -76,17 +77,17 @@
|
||||
<p class="py-6 text-center text-sm text-destructive">{search.error}</p>
|
||||
{:else if search.query.length < 2}
|
||||
<p class="py-6 text-center text-sm text-muted-foreground">
|
||||
Type at least 2 characters to search
|
||||
{$t('search.min_chars')}
|
||||
</p>
|
||||
{:else if search.results.length === 0}
|
||||
<p class="py-6 text-center text-sm text-muted-foreground">
|
||||
No results for "{search.query}"
|
||||
{$t('search.no_results', { values: { query: search.query } })}
|
||||
</p>
|
||||
{:else}
|
||||
{#if appResults.length > 0}
|
||||
<div class="mb-2">
|
||||
<p class="mb-1 px-3 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Apps
|
||||
{$t('search.apps')}
|
||||
</p>
|
||||
{#each appResults as result (result.id)}
|
||||
<SearchResult {result} onselect={() => search.close()} />
|
||||
@@ -97,7 +98,7 @@
|
||||
{#if boardResults.length > 0}
|
||||
<div>
|
||||
<p class="mb-1 px-3 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Boards
|
||||
{$t('search.boards')}
|
||||
</p>
|
||||
{#each boardResults as result (result.id)}
|
||||
<SearchResult {result} onselect={() => search.close()} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import { search } from '$lib/stores/search.svelte.js';
|
||||
|
||||
const isMac = $derived(
|
||||
@@ -24,10 +25,10 @@
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<span class="flex-1 text-left">Search...</span>
|
||||
<span class="flex-1 text-left">{$t('search.trigger')}</span>
|
||||
<kbd
|
||||
class="hidden rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground sm:inline"
|
||||
>
|
||||
{isMac ? '⌘' : 'Ctrl'}K
|
||||
{isMac ? '\u2318' : 'Ctrl'}K
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import { dndzone } from 'svelte-dnd-action';
|
||||
import DraggableWidget from '$lib/components/widget/DraggableWidget.svelte';
|
||||
|
||||
@@ -37,7 +38,7 @@
|
||||
addWidgetSectionId: string | null;
|
||||
onToggleAddWidget: (sectionId: string) => void;
|
||||
onDeleteSection: (sectionId: string) => void;
|
||||
onAddWidget: (sectionId: string, appId: string) => void;
|
||||
onAddWidget: (sectionId: string, widgetData: string) => void;
|
||||
onDeleteWidget: (widgetId: string) => void;
|
||||
}
|
||||
|
||||
@@ -71,7 +72,104 @@
|
||||
onWidgetsUpdate(section.id, widgets);
|
||||
}
|
||||
|
||||
// Widget form state
|
||||
let selectedWidgetType = $state('app');
|
||||
let selectedAppId = $state('');
|
||||
|
||||
// Bookmark fields
|
||||
let bookmarkUrl = $state('');
|
||||
let bookmarkLabel = $state('');
|
||||
let bookmarkIcon = $state('');
|
||||
let bookmarkDescription = $state('');
|
||||
|
||||
// Note fields
|
||||
let noteContent = $state('');
|
||||
let noteFormat = $state<'markdown' | 'text'>('markdown');
|
||||
|
||||
// Embed fields
|
||||
let embedUrl = $state('');
|
||||
let embedHeight = $state(300);
|
||||
|
||||
// Status fields
|
||||
let statusLabel = $state('');
|
||||
let statusAppIds = $state<string[]>([]);
|
||||
|
||||
function resetForm() {
|
||||
selectedWidgetType = 'app';
|
||||
selectedAppId = '';
|
||||
bookmarkUrl = '';
|
||||
bookmarkLabel = '';
|
||||
bookmarkIcon = '';
|
||||
bookmarkDescription = '';
|
||||
noteContent = '';
|
||||
noteFormat = 'markdown';
|
||||
embedUrl = '';
|
||||
embedHeight = 300;
|
||||
statusLabel = '';
|
||||
statusAppIds = [];
|
||||
}
|
||||
|
||||
function handleSubmitWidget() {
|
||||
let widgetData: Record<string, unknown> = { type: selectedWidgetType };
|
||||
|
||||
switch (selectedWidgetType) {
|
||||
case 'app':
|
||||
if (!selectedAppId) return;
|
||||
widgetData.appId = selectedAppId;
|
||||
break;
|
||||
case 'bookmark':
|
||||
if (!bookmarkUrl || !bookmarkLabel) return;
|
||||
widgetData.url = bookmarkUrl;
|
||||
widgetData.label = bookmarkLabel;
|
||||
if (bookmarkIcon) widgetData.icon = bookmarkIcon;
|
||||
if (bookmarkDescription) widgetData.description = bookmarkDescription;
|
||||
break;
|
||||
case 'note':
|
||||
if (!noteContent) return;
|
||||
widgetData.content = noteContent;
|
||||
widgetData.format = noteFormat;
|
||||
break;
|
||||
case 'embed':
|
||||
if (!embedUrl) return;
|
||||
widgetData.url = embedUrl;
|
||||
widgetData.height = embedHeight;
|
||||
break;
|
||||
case 'status':
|
||||
if (statusAppIds.length === 0) return;
|
||||
widgetData.appIds = statusAppIds;
|
||||
if (statusLabel) widgetData.label = statusLabel;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
onAddWidget(section.id, JSON.stringify(widgetData));
|
||||
resetForm();
|
||||
}
|
||||
|
||||
function toggleStatusApp(appId: string) {
|
||||
if (statusAppIds.includes(appId)) {
|
||||
statusAppIds = statusAppIds.filter((id) => id !== appId);
|
||||
} else {
|
||||
statusAppIds = [...statusAppIds, appId];
|
||||
}
|
||||
}
|
||||
|
||||
function getWidgetLabel(widget: WidgetData): string {
|
||||
if (widget.type === 'app' && widget.app) {
|
||||
return widget.app.name;
|
||||
}
|
||||
try {
|
||||
const cfg = JSON.parse(widget.config || '{}');
|
||||
if (widget.type === 'bookmark') return cfg.label || 'Bookmark';
|
||||
if (widget.type === 'note') return (cfg.content || '').substring(0, 40) || 'Note';
|
||||
if (widget.type === 'embed') return cfg.url || 'Embed';
|
||||
if (widget.type === 'status') return cfg.label || 'Status';
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return `Widget #${widget.order}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-xl border border-border bg-card p-4 shadow-sm">
|
||||
@@ -102,7 +200,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
<span class="font-medium text-foreground">{section.title}</span>
|
||||
<span class="text-xs text-muted-foreground">Order: {section.order}</span>
|
||||
<span class="text-xs text-muted-foreground">{$t('section.order', { values: { order: section.order } })}</span>
|
||||
{#if section.icon}
|
||||
<span class="text-xs text-muted-foreground">({section.icon})</span>
|
||||
{/if}
|
||||
@@ -113,48 +211,191 @@
|
||||
onclick={() => onToggleAddWidget(section.id)}
|
||||
class="rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Add Widget
|
||||
{$t('widget.add')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onDeleteSection(section.id)}
|
||||
class="rounded-md bg-destructive px-2 py-1 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
{$t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if addWidgetSectionId === section.id}
|
||||
<div class="mb-3 rounded-lg border border-border bg-muted/50 p-3">
|
||||
<div>
|
||||
<label for="widget-app-{section.id}" class="mb-1 block text-sm font-medium text-foreground"
|
||||
>Select App</label
|
||||
>
|
||||
<!-- Widget Type Selector -->
|
||||
<div class="mb-3">
|
||||
<label for="widget-type-{section.id}" class="mb-1 block text-sm font-medium text-foreground">
|
||||
Widget Type
|
||||
</label>
|
||||
<select
|
||||
id="widget-app-{section.id}"
|
||||
bind:value={selectedAppId}
|
||||
id="widget-type-{section.id}"
|
||||
bind:value={selectedWidgetType}
|
||||
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
|
||||
>
|
||||
<option value="">Choose an app...</option>
|
||||
{#each apps as app (app.id)}
|
||||
<option value={app.id}>{app.name}</option>
|
||||
{/each}
|
||||
<option value="app">App</option>
|
||||
<option value="bookmark">Bookmark</option>
|
||||
<option value="note">Note</option>
|
||||
<option value="embed">Embed</option>
|
||||
<option value="status">Status</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
|
||||
<!-- Type-specific config forms -->
|
||||
{#if selectedWidgetType === 'app'}
|
||||
<div>
|
||||
<label for="widget-app-{section.id}" class="mb-1 block text-sm font-medium text-foreground">
|
||||
{$t('widget.select_app')}
|
||||
</label>
|
||||
<select
|
||||
id="widget-app-{section.id}"
|
||||
bind:value={selectedAppId}
|
||||
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
|
||||
>
|
||||
<option value="">{$t('widget.choose_app')}</option>
|
||||
{#each apps as app (app.id)}
|
||||
<option value={app.id}>{app.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{:else if selectedWidgetType === 'bookmark'}
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="bm-url-{section.id}" class="mb-1 block text-sm font-medium text-foreground">URL</label>
|
||||
<input
|
||||
id="bm-url-{section.id}"
|
||||
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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="bm-label-{section.id}" class="mb-1 block text-sm font-medium text-foreground">Label</label>
|
||||
<input
|
||||
id="bm-label-{section.id}"
|
||||
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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="bm-icon-{section.id}" class="mb-1 block text-sm font-medium text-foreground">Icon (optional)</label>
|
||||
<input
|
||||
id="bm-icon-{section.id}"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="bm-desc-{section.id}" class="mb-1 block text-sm font-medium text-foreground">Description (optional)</label>
|
||||
<input
|
||||
id="bm-desc-{section.id}"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else if selectedWidgetType === 'note'}
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label for="note-format-{section.id}" class="mb-1 block text-sm font-medium text-foreground">Format</label>
|
||||
<select
|
||||
id="note-format-{section.id}"
|
||||
bind:value={noteFormat}
|
||||
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
|
||||
>
|
||||
<option value="markdown">Markdown</option>
|
||||
<option value="text">Plain Text</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="note-content-{section.id}" class="mb-1 block text-sm font-medium text-foreground">Content</label>
|
||||
<textarea
|
||||
id="note-content-{section.id}"
|
||||
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"
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
{:else if selectedWidgetType === 'embed'}
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="sm:col-span-2">
|
||||
<label for="embed-url-{section.id}" class="mb-1 block text-sm font-medium text-foreground">URL</label>
|
||||
<input
|
||||
id="embed-url-{section.id}"
|
||||
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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="embed-height-{section.id}" class="mb-1 block text-sm font-medium text-foreground">Height (px)</label>
|
||||
<input
|
||||
id="embed-height-{section.id}"
|
||||
type="number"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else if selectedWidgetType === 'status'}
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label for="status-label-{section.id}" class="mb-1 block text-sm font-medium text-foreground">Label (optional)</label>
|
||||
<input
|
||||
id="status-label-{section.id}"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span class="mb-1 block text-sm font-medium text-foreground">Select Apps</span>
|
||||
<div class="max-h-40 space-y-1 overflow-y-auto rounded-lg border border-input bg-background p-2">
|
||||
{#each apps as app (app.id)}
|
||||
<label class="flex items-center gap-2 rounded px-2 py-1 text-sm text-foreground hover:bg-accent">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={statusAppIds.includes(app.id)}
|
||||
onchange={() => toggleStatusApp(app.id)}
|
||||
class="h-4 w-4 rounded border-input accent-primary"
|
||||
/>
|
||||
{app.name}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{#if statusAppIds.length > 0}
|
||||
<p class="mt-1 text-xs text-muted-foreground">{statusAppIds.length} app(s) selected</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
if (selectedAppId) {
|
||||
onAddWidget(section.id, selectedAppId);
|
||||
selectedAppId = '';
|
||||
}
|
||||
}}
|
||||
disabled={!selectedAppId}
|
||||
onclick={handleSubmitWidget}
|
||||
class="rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
Add
|
||||
{$t('common.add')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -169,7 +410,7 @@
|
||||
class="min-h-[48px] rounded-lg border-2 border-dashed border-border/50 p-2 transition-colors"
|
||||
>
|
||||
<p class="text-center text-sm text-muted-foreground">
|
||||
No widgets. Drag widgets here or add one above.
|
||||
{$t('widget.no_widgets_dnd')}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -185,19 +426,14 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-medium uppercase text-primary">{widget.type}</span>
|
||||
{#if widget.app}
|
||||
<span class="text-sm text-foreground">{widget.app.name}</span>
|
||||
<span class="text-xs text-muted-foreground">({widget.app.url})</span>
|
||||
{:else}
|
||||
<span class="text-sm text-muted-foreground">Widget #{widget.order}</span>
|
||||
{/if}
|
||||
<span class="text-sm text-foreground">{getWidgetLabel(widget)}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onDeleteWidget(widget.id)}
|
||||
class="rounded-md bg-destructive px-2 py-1 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
|
||||
>
|
||||
Remove
|
||||
{$t('widget.remove')}
|
||||
</button>
|
||||
</div>
|
||||
</DraggableWidget>
|
||||
|
||||
@@ -29,11 +29,22 @@
|
||||
widgets: WidgetData[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
section: SectionData;
|
||||
interface AppData {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
icon: string | null;
|
||||
iconType: string;
|
||||
description: string | null;
|
||||
statuses: Array<{ status: string; responseTime: number | null }>;
|
||||
}
|
||||
|
||||
let { section }: Props = $props();
|
||||
interface Props {
|
||||
section: SectionData;
|
||||
allApps?: AppData[];
|
||||
}
|
||||
|
||||
let { section, allApps = [] }: Props = $props();
|
||||
|
||||
let expanded = $state(section.isExpandedByDefault);
|
||||
</script>
|
||||
@@ -48,7 +59,7 @@
|
||||
|
||||
<SectionCollapsible {expanded}>
|
||||
<div class="px-4 pb-4">
|
||||
<WidgetGrid widgets={section.widgets} />
|
||||
<WidgetGrid widgets={section.widgets} {allApps} />
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
</div>
|
||||
|
||||
@@ -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