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

- Add svelte-i18n with 224 translation keys (English + Russian)
- Language switcher in header (EN/RU toggle, persists to localStorage)
- Extract all hardcoded strings from 37 component/page files
- Add 4 new widget types: Bookmark, Note (markdown), Embed (iframe), Status
- WidgetRenderer dispatches by type, WidgetGrid supports full-width widgets
- Type-specific config forms in board editor
- Install marked for markdown rendering
This commit is contained in:
2026-03-24 23:18:05 +03:00
parent bf4e5089ee
commit 477c0e4d52
52 changed files with 1776 additions and 395 deletions
+19 -18
View File
@@ -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>
+27 -26
View File
@@ -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>
+22 -21
View File
@@ -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">&times;</button>
<button type="submit" class="text-muted-foreground hover:text-destructive" title={$t('admin.remove_from_group')}>&times;</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>