refactor(admin): split admin page into tab sub-components
Split admin/+page.svelte (573 lines) into: - UsersTab.svelte: user table with delete action - TagsTab.svelte: tag list with inline rename and delete - GroupsTab.svelte: groups table with inline edit + create form - SystemTab.svelte: backfill buttons with own state Page drops from 573 → ~40 lines. Part of #75 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,66 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import UsersTab from './UsersTab.svelte';
|
||||||
|
import TagsTab from './TagsTab.svelte';
|
||||||
|
import GroupsTab from './GroupsTab.svelte';
|
||||||
|
import SystemTab from './SystemTab.svelte';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
let activeTab = $state('users');
|
let activeTab = $state('users');
|
||||||
let editingTagId: string | null = $state(null);
|
|
||||||
let editingTagName = $state('');
|
|
||||||
let editingGroupId: string | null = $state(null);
|
|
||||||
let backfillResult: number | null = $state(null);
|
|
||||||
let backfillLoading = $state(false);
|
|
||||||
let backfillHashesResult: number | null = $state(null);
|
|
||||||
let backfillHashesLoading = $state(false);
|
|
||||||
|
|
||||||
const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION'];
|
|
||||||
|
|
||||||
function startEditTag(tag: { id: string; name: string }) {
|
|
||||||
editingTagId = tag.id;
|
|
||||||
editingTagName = tag.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelEditTag() {
|
|
||||||
editingTagId = null;
|
|
||||||
editingTagName = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function startEditGroup(id: string) {
|
|
||||||
editingGroupId = id;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelEditGroup() {
|
|
||||||
editingGroupId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function backfillVersions() {
|
|
||||||
backfillLoading = true;
|
|
||||||
backfillResult = null;
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/admin/backfill-versions', { method: 'POST' });
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
backfillResult = data.count;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
backfillLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function backfillFileHashes() {
|
|
||||||
backfillHashesLoading = true;
|
|
||||||
backfillHashesResult = null;
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/admin/backfill-file-hashes', { method: 'POST' });
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
backfillHashesResult = data.count;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
backfillHashesLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-7xl py-8 font-sans sm:px-6 lg:px-8">
|
<div class="mx-auto max-w-7xl py-8 font-sans sm:px-6 lg:px-8">
|
||||||
@@ -107,466 +55,20 @@ async function backfillFileHashes() {
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if activeTab === 'users'}
|
{#if activeTab === 'users'}
|
||||||
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-sm" in:slide>
|
<div in:slide>
|
||||||
<div class="flex items-center justify-between border-b border-line-2 p-6">
|
<UsersTab users={data.users} />
|
||||||
<h2 class="text-lg font-bold text-ink-2">{m.admin_section_users()}</h2>
|
|
||||||
<a
|
|
||||||
href="/admin/users/new"
|
|
||||||
class="inline-flex items-center gap-1 rounded-sm bg-primary px-4 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
|
|
||||||
>
|
|
||||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M12 4v16m8-8H4"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{m.admin_btn_new_user()}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table class="min-w-full divide-y divide-line">
|
|
||||||
<thead class="bg-muted">
|
|
||||||
<tr>
|
|
||||||
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-ink-2 uppercase"
|
|
||||||
>{m.admin_col_login()}</th
|
|
||||||
>
|
|
||||||
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-ink-2 uppercase"
|
|
||||||
>{m.admin_col_full_name()}</th
|
|
||||||
>
|
|
||||||
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-ink-2 uppercase"
|
|
||||||
>{m.admin_col_groups()}</th
|
|
||||||
>
|
|
||||||
<th class="px-6 py-3 text-right text-xs font-bold tracking-wider text-ink-2 uppercase"
|
|
||||||
>{m.admin_col_actions()}</th
|
|
||||||
>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-line bg-surface">
|
|
||||||
{#each data.users as user (user.id)}
|
|
||||||
<tr class="group/row hover:bg-muted">
|
|
||||||
<td class="px-6 py-4 text-sm font-medium whitespace-nowrap text-ink">
|
|
||||||
{user.username}
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-ink-2">
|
|
||||||
{#if user.firstName || user.lastName}
|
|
||||||
{user.firstName ?? ''} {user.lastName ?? ''}
|
|
||||||
{:else}
|
|
||||||
<span class="text-ink-3 italic">–</span>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 text-sm text-ink-2">
|
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
{#if user.groups && user.groups.length > 0}
|
|
||||||
{#each user.groups as group (group.id)}
|
|
||||||
<span
|
|
||||||
class="rounded-full border border-blue-100 bg-blue-50 px-2 py-0.5 text-[10px] font-bold text-blue-700 uppercase"
|
|
||||||
>
|
|
||||||
{group.name}
|
|
||||||
</span>
|
|
||||||
{/each}
|
|
||||||
{:else}
|
|
||||||
<span class="text-xs text-ink-3 italic">{m.admin_no_groups()}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 text-right whitespace-nowrap">
|
|
||||||
<div class="flex items-center justify-end gap-4">
|
|
||||||
<a
|
|
||||||
href="/admin/users/{user.id}"
|
|
||||||
class="text-sm font-bold tracking-wide text-accent uppercase hover:text-ink"
|
|
||||||
>
|
|
||||||
{m.btn_edit()}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="?/deleteUser"
|
|
||||||
use:enhance={({ cancel }) => {
|
|
||||||
if (!confirm(m.admin_user_delete_confirm({ username: user.username }))) {
|
|
||||||
cancel();
|
|
||||||
}
|
|
||||||
return async ({ update }) => {
|
|
||||||
await update();
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
class="flex items-center"
|
|
||||||
>
|
|
||||||
<input type="hidden" name="id" value={user.id} />
|
|
||||||
<button
|
|
||||||
class="p-1 text-ink-3 transition-colors hover:text-red-600"
|
|
||||||
title={m.admin_btn_delete_user_title()}
|
|
||||||
>
|
|
||||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
{:else if activeTab === 'tags'}
|
{:else if activeTab === 'tags'}
|
||||||
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-sm" in:slide>
|
<div in:slide>
|
||||||
<div class="border-b border-line-2 bg-yellow-50/50 p-6">
|
<TagsTab tags={data.tags} />
|
||||||
<h2 class="text-lg font-bold text-ink-2">{m.admin_section_tags()}</h2>
|
|
||||||
<p class="mt-1 text-xs text-yellow-800">
|
|
||||||
{m.admin_tags_warning()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul class="max-h-[600px] divide-y divide-line-2 overflow-y-auto">
|
|
||||||
{#each data.tags as tag (tag.id)}
|
|
||||||
<li class="group flex items-center justify-between px-6 py-3 hover:bg-muted">
|
|
||||||
{#if editingTagId === tag.id}
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="?/updateTag"
|
|
||||||
use:enhance={() =>
|
|
||||||
async ({ update }) => {
|
|
||||||
await update();
|
|
||||||
cancelEditTag();
|
|
||||||
}}
|
|
||||||
class="flex flex-1 items-center gap-2"
|
|
||||||
>
|
|
||||||
<input type="hidden" name="id" value={tag.id} />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="name"
|
|
||||||
bind:value={editingTagName}
|
|
||||||
class="flex-1 rounded border-accent px-2 py-1 text-sm ring-1 ring-accent"
|
|
||||||
/>
|
|
||||||
<button aria-label={m.btn_save()} class="text-green-600 hover:text-green-800"
|
|
||||||
><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
|
||||||
><path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M5 13l4 4L19 7"
|
|
||||||
/></svg
|
|
||||||
></button
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={cancelEditTag}
|
|
||||||
aria-label={m.btn_cancel()}
|
|
||||||
class="text-ink-3 hover:text-ink-2"
|
|
||||||
><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
|
||||||
><path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/></svg
|
|
||||||
></button
|
|
||||||
>
|
|
||||||
</form>
|
|
||||||
{:else}
|
|
||||||
<span class="rounded bg-muted px-2 py-1 text-sm font-medium text-ink">
|
|
||||||
{tag.name}
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onclick={() => startEditTag(tag)}
|
|
||||||
aria-label={m.admin_btn_edit_tag_label()}
|
|
||||||
class="p-1 text-ink-3 hover:text-ink"
|
|
||||||
>
|
|
||||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
|
||||||
><path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
|
||||||
/></svg
|
|
||||||
>
|
|
||||||
</button>
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="?/deleteTag"
|
|
||||||
use:enhance={({ cancel }) => {
|
|
||||||
if (
|
|
||||||
!confirm(m.admin_tag_delete_confirm())
|
|
||||||
) {
|
|
||||||
cancel();
|
|
||||||
}
|
|
||||||
return async ({ update }) => {
|
|
||||||
await update();
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
class="inline"
|
|
||||||
>
|
|
||||||
<input type="hidden" name="id" value={tag.id} />
|
|
||||||
<button
|
|
||||||
aria-label={m.admin_btn_delete_tag_label()}
|
|
||||||
class="p-1 text-ink-3 hover:text-red-600"
|
|
||||||
>
|
|
||||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
|
||||||
><path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
|
||||||
/></svg
|
|
||||||
>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
{:else if activeTab === 'groups'}
|
{:else if activeTab === 'groups'}
|
||||||
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-sm" in:slide>
|
<div in:slide>
|
||||||
<div class="flex items-center justify-between border-b border-line-2 p-6">
|
<GroupsTab groups={data.groups} />
|
||||||
<h2 class="text-lg font-bold text-ink-2">{m.admin_section_groups()}</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table class="min-w-full divide-y divide-line">
|
|
||||||
<thead class="bg-muted">
|
|
||||||
<tr>
|
|
||||||
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-ink-2 uppercase"
|
|
||||||
>{m.admin_col_name()}</th
|
|
||||||
>
|
|
||||||
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-ink-2 uppercase"
|
|
||||||
>{m.admin_col_permissions()}</th
|
|
||||||
>
|
|
||||||
<th class="px-6 py-3 text-right text-xs font-bold tracking-wider text-ink-2 uppercase"
|
|
||||||
>{m.admin_col_actions()}</th
|
|
||||||
>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-line bg-surface">
|
|
||||||
{#each data.groups as group (group.id)}
|
|
||||||
<tr class="group/row hover:bg-muted">
|
|
||||||
{#if editingGroupId === group.id}
|
|
||||||
<!-- EDIT MODE -->
|
|
||||||
<td colspan="3" class="px-6 py-4">
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="?/updateGroup"
|
|
||||||
use:enhance={() =>
|
|
||||||
async ({ update }) => {
|
|
||||||
await update();
|
|
||||||
cancelEditGroup();
|
|
||||||
}}
|
|
||||||
class="flex w-full flex-col items-start gap-4 sm:flex-row"
|
|
||||||
>
|
|
||||||
<input type="hidden" name="id" value={group.id} />
|
|
||||||
|
|
||||||
<div class="w-full sm:w-1/3">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="name"
|
|
||||||
value={group.name}
|
|
||||||
class="w-full rounded border-accent text-sm"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex h-full flex-1 flex-wrap items-center gap-4 pt-2">
|
|
||||||
{#each availablePermissions as perm (perm)}
|
|
||||||
<label
|
|
||||||
class="inline-flex items-center text-xs font-bold text-ink-2 uppercase"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="permissions"
|
|
||||||
value={perm}
|
|
||||||
checked={group.permissions.includes(perm)}
|
|
||||||
class="mr-2 rounded border-line text-ink focus:ring-accent"
|
|
||||||
/>
|
|
||||||
{perm.replace('_', ' ')}
|
|
||||||
</label>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-2 self-start sm:self-center">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
aria-label={m.btn_save()}
|
|
||||||
class="p-1 text-green-600 hover:text-green-800"
|
|
||||||
>
|
|
||||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
|
||||||
><path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M5 13l4 4L19 7"
|
|
||||||
/></svg
|
|
||||||
>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={cancelEditGroup}
|
|
||||||
aria-label={m.btn_cancel()}
|
|
||||||
class="p-1 text-ink-3 hover:text-red-500"
|
|
||||||
>
|
|
||||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
|
||||||
><path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/></svg
|
|
||||||
>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
{:else}
|
|
||||||
<!-- VIEW MODE -->
|
|
||||||
<td class="px-6 py-4 text-sm font-bold whitespace-nowrap text-ink">
|
|
||||||
{group.name}
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 text-sm text-ink-2">
|
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
{#each group.permissions as perm (perm)}
|
|
||||||
<span
|
|
||||||
class="rounded-full px-2 py-0.5 text-[10px] font-bold uppercase
|
|
||||||
{perm === 'ADMIN'
|
|
||||||
? 'border-red-100 bg-red-50 text-red-700'
|
|
||||||
: 'border-line bg-muted text-ink-2'}"
|
|
||||||
>
|
|
||||||
{perm}
|
|
||||||
</span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 text-right whitespace-nowrap">
|
|
||||||
<div class="flex items-center justify-end gap-3">
|
|
||||||
<button
|
|
||||||
onclick={() => startEditGroup(group.id)}
|
|
||||||
class="text-sm font-bold tracking-wide text-accent uppercase hover:text-ink"
|
|
||||||
>
|
|
||||||
{m.btn_edit()}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="?/deleteGroup"
|
|
||||||
use:enhance={({ cancel }) => {
|
|
||||||
if (!confirm(m.admin_group_delete_confirm())) {
|
|
||||||
cancel();
|
|
||||||
}
|
|
||||||
return async ({ update }) => {
|
|
||||||
await update();
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="id" value={group.id} />
|
|
||||||
<button
|
|
||||||
class="p-1 text-ink-3 transition-colors hover:text-red-600"
|
|
||||||
title={m.btn_delete()}
|
|
||||||
>
|
|
||||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
{/if}
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<!-- CREATE GROUP FORM -->
|
|
||||||
<div class="border-t border-line bg-muted p-6">
|
|
||||||
<h3 class="mb-4 text-xs font-bold tracking-wide text-ink-2 uppercase">
|
|
||||||
{m.admin_section_new_group()}
|
|
||||||
</h3>
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="?/createGroup"
|
|
||||||
use:enhance
|
|
||||||
class="flex flex-col items-start gap-4 md:flex-row md:items-center"
|
|
||||||
>
|
|
||||||
<div class="w-full flex-1">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="name"
|
|
||||||
placeholder={m.admin_group_name_placeholder()}
|
|
||||||
required
|
|
||||||
class="w-full rounded border-line text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
{#each availablePermissions as perm (perm)}
|
|
||||||
<label class="inline-flex items-center text-xs font-bold text-ink-2 uppercase">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="permissions"
|
|
||||||
value={perm}
|
|
||||||
class="mr-2 rounded border-line text-ink focus:ring-accent"
|
|
||||||
/>
|
|
||||||
{perm.replace('_', ' ')}
|
|
||||||
</label>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="w-full rounded bg-primary px-6 py-2 text-sm font-bold text-white uppercase hover:bg-accent hover:text-ink md:w-auto"
|
|
||||||
>
|
|
||||||
{m.btn_create()}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{:else if activeTab === 'system'}
|
{:else if activeTab === 'system'}
|
||||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
<div in:slide>
|
||||||
<h2 class="mb-1 text-lg font-bold text-ink-2">{m.admin_system_backfill_heading()}</h2>
|
<SystemTab />
|
||||||
<p class="mb-4 text-sm text-ink-2">{m.admin_system_backfill_description()}</p>
|
|
||||||
<button
|
|
||||||
onclick={backfillVersions}
|
|
||||||
disabled={backfillLoading}
|
|
||||||
class="rounded bg-primary px-6 py-2 text-sm font-bold text-white uppercase transition hover:bg-accent hover:text-ink disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{backfillLoading ? '…' : m.admin_system_backfill_btn()}
|
|
||||||
</button>
|
|
||||||
{#if backfillResult !== null}
|
|
||||||
<p class="mt-4 text-sm font-medium text-ink">
|
|
||||||
{m.admin_system_backfill_success({ count: backfillResult })}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 rounded-sm border border-line bg-surface p-6 shadow-sm">
|
|
||||||
<h2 class="mb-1 text-lg font-bold text-ink-2">
|
|
||||||
{m.admin_system_backfill_hashes_heading()}
|
|
||||||
</h2>
|
|
||||||
<p class="mb-4 text-sm text-ink-2">{m.admin_system_backfill_hashes_description()}</p>
|
|
||||||
<button
|
|
||||||
onclick={backfillFileHashes}
|
|
||||||
disabled={backfillHashesLoading}
|
|
||||||
class="rounded bg-primary px-6 py-2 text-sm font-bold text-white uppercase transition hover:bg-accent hover:text-ink disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{backfillHashesLoading ? '…' : m.admin_system_backfill_hashes_btn()}
|
|
||||||
</button>
|
|
||||||
{#if backfillHashesResult !== null}
|
|
||||||
<p class="mt-4 text-sm font-medium text-ink">
|
|
||||||
{m.admin_system_backfill_hashes_success({ count: backfillHashesResult })}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
221
frontend/src/routes/admin/GroupsTab.svelte
Normal file
221
frontend/src/routes/admin/GroupsTab.svelte
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
let { groups }: { groups: { id: string; name: string; permissions: string[] }[] } = $props();
|
||||||
|
|
||||||
|
const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION'];
|
||||||
|
|
||||||
|
let editingGroupId: string | null = $state(null);
|
||||||
|
|
||||||
|
function startEditGroup(id: string) {
|
||||||
|
editingGroupId = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditGroup() {
|
||||||
|
editingGroupId = null;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-sm">
|
||||||
|
<div class="flex items-center justify-between border-b border-line-2 p-6">
|
||||||
|
<h2 class="text-lg font-bold text-ink-2">{m.admin_section_groups()}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="min-w-full divide-y divide-line">
|
||||||
|
<thead class="bg-muted">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-ink-2 uppercase"
|
||||||
|
>{m.admin_col_name()}</th
|
||||||
|
>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-ink-2 uppercase"
|
||||||
|
>{m.admin_col_permissions()}</th
|
||||||
|
>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-bold tracking-wider text-ink-2 uppercase"
|
||||||
|
>{m.admin_col_actions()}</th
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-line bg-surface">
|
||||||
|
{#each groups as group (group.id)}
|
||||||
|
<tr class="group/row hover:bg-muted">
|
||||||
|
{#if editingGroupId === group.id}
|
||||||
|
<!-- EDIT MODE -->
|
||||||
|
<td colspan="3" class="px-6 py-4">
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/updateGroup"
|
||||||
|
use:enhance={() =>
|
||||||
|
async ({ update }) => {
|
||||||
|
await update();
|
||||||
|
cancelEditGroup();
|
||||||
|
}}
|
||||||
|
class="flex w-full flex-col items-start gap-4 sm:flex-row"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={group.id} />
|
||||||
|
|
||||||
|
<div class="w-full sm:w-1/3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
value={group.name}
|
||||||
|
class="w-full rounded border-accent text-sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex h-full flex-1 flex-wrap items-center gap-4 pt-2">
|
||||||
|
{#each availablePermissions as perm (perm)}
|
||||||
|
<label class="inline-flex items-center text-xs font-bold text-ink-2 uppercase">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="permissions"
|
||||||
|
value={perm}
|
||||||
|
checked={group.permissions.includes(perm)}
|
||||||
|
class="mr-2 rounded border-line text-ink focus:ring-accent"
|
||||||
|
/>
|
||||||
|
{perm.replace('_', ' ')}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 self-start sm:self-center">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
aria-label={m.btn_save()}
|
||||||
|
class="p-1 text-green-600 hover:text-green-800"
|
||||||
|
>
|
||||||
|
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
|
><path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/></svg
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={cancelEditGroup}
|
||||||
|
aria-label={m.btn_cancel()}
|
||||||
|
class="p-1 text-ink-3 hover:text-red-500"
|
||||||
|
>
|
||||||
|
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
|
><path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/></svg
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
{:else}
|
||||||
|
<!-- VIEW MODE -->
|
||||||
|
<td class="px-6 py-4 text-sm font-bold whitespace-nowrap text-ink">
|
||||||
|
{group.name}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-ink-2">
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{#each group.permissions as perm (perm)}
|
||||||
|
<span
|
||||||
|
class="rounded-full px-2 py-0.5 text-[10px] font-bold uppercase
|
||||||
|
{perm === 'ADMIN'
|
||||||
|
? 'border-red-100 bg-red-50 text-red-700'
|
||||||
|
: 'border-line bg-muted text-ink-2'}"
|
||||||
|
>
|
||||||
|
{perm}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-right whitespace-nowrap">
|
||||||
|
<div class="flex items-center justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onclick={() => startEditGroup(group.id)}
|
||||||
|
class="text-sm font-bold tracking-wide text-accent uppercase hover:text-ink"
|
||||||
|
>
|
||||||
|
{m.btn_edit()}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/deleteGroup"
|
||||||
|
use:enhance={({ cancel }) => {
|
||||||
|
if (!confirm(m.admin_group_delete_confirm())) {
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
return async ({ update }) => {
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={group.id} />
|
||||||
|
<button
|
||||||
|
class="p-1 text-ink-3 transition-colors hover:text-red-600"
|
||||||
|
title={m.btn_delete()}
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{/if}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- CREATE GROUP FORM -->
|
||||||
|
<div class="border-t border-line bg-muted p-6">
|
||||||
|
<h3 class="mb-4 text-xs font-bold tracking-wide text-ink-2 uppercase">
|
||||||
|
{m.admin_section_new_group()}
|
||||||
|
</h3>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/createGroup"
|
||||||
|
use:enhance
|
||||||
|
class="flex flex-col items-start gap-4 md:flex-row md:items-center"
|
||||||
|
>
|
||||||
|
<div class="w-full flex-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
placeholder={m.admin_group_name_placeholder()}
|
||||||
|
required
|
||||||
|
class="w-full rounded border-line text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
{#each availablePermissions as perm (perm)}
|
||||||
|
<label class="inline-flex items-center text-xs font-bold text-ink-2 uppercase">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="permissions"
|
||||||
|
value={perm}
|
||||||
|
class="mr-2 rounded border-line text-ink focus:ring-accent"
|
||||||
|
/>
|
||||||
|
{perm.replace('_', ' ')}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full rounded bg-primary px-6 py-2 text-sm font-bold text-white uppercase hover:bg-accent hover:text-ink md:w-auto"
|
||||||
|
>
|
||||||
|
{m.btn_create()}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
72
frontend/src/routes/admin/SystemTab.svelte
Normal file
72
frontend/src/routes/admin/SystemTab.svelte
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
let backfillResult: number | null = $state(null);
|
||||||
|
let backfillLoading = $state(false);
|
||||||
|
let backfillHashesResult: number | null = $state(null);
|
||||||
|
let backfillHashesLoading = $state(false);
|
||||||
|
|
||||||
|
async function backfillVersions() {
|
||||||
|
backfillLoading = true;
|
||||||
|
backfillResult = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/backfill-versions', { method: 'POST' });
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
backfillResult = data.count;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
backfillLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function backfillFileHashes() {
|
||||||
|
backfillHashesLoading = true;
|
||||||
|
backfillHashesResult = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/backfill-file-hashes', { method: 'POST' });
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
backfillHashesResult = data.count;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
backfillHashesLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
|
<h2 class="mb-1 text-lg font-bold text-ink-2">{m.admin_system_backfill_heading()}</h2>
|
||||||
|
<p class="mb-4 text-sm text-ink-2">{m.admin_system_backfill_description()}</p>
|
||||||
|
<button
|
||||||
|
onclick={backfillVersions}
|
||||||
|
disabled={backfillLoading}
|
||||||
|
class="rounded bg-primary px-6 py-2 text-sm font-bold text-white uppercase transition hover:bg-accent hover:text-ink disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{backfillLoading ? '…' : m.admin_system_backfill_btn()}
|
||||||
|
</button>
|
||||||
|
{#if backfillResult !== null}
|
||||||
|
<p class="mt-4 text-sm font-medium text-ink">
|
||||||
|
{m.admin_system_backfill_success({ count: backfillResult })}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||||
|
<h2 class="mb-1 text-lg font-bold text-ink-2">
|
||||||
|
{m.admin_system_backfill_hashes_heading()}
|
||||||
|
</h2>
|
||||||
|
<p class="mb-4 text-sm text-ink-2">{m.admin_system_backfill_hashes_description()}</p>
|
||||||
|
<button
|
||||||
|
onclick={backfillFileHashes}
|
||||||
|
disabled={backfillHashesLoading}
|
||||||
|
class="rounded bg-primary px-6 py-2 text-sm font-bold text-white uppercase transition hover:bg-accent hover:text-ink disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{backfillHashesLoading ? '…' : m.admin_system_backfill_hashes_btn()}
|
||||||
|
</button>
|
||||||
|
{#if backfillHashesResult !== null}
|
||||||
|
<p class="mt-4 text-sm font-medium text-ink">
|
||||||
|
{m.admin_system_backfill_hashes_success({ count: backfillHashesResult })}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
127
frontend/src/routes/admin/TagsTab.svelte
Normal file
127
frontend/src/routes/admin/TagsTab.svelte
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
let { tags }: { tags: { id: string; name: string }[] } = $props();
|
||||||
|
|
||||||
|
let editingTagId: string | null = $state(null);
|
||||||
|
let editingTagName = $state('');
|
||||||
|
|
||||||
|
function startEditTag(tag: { id: string; name: string }) {
|
||||||
|
editingTagId = tag.id;
|
||||||
|
editingTagName = tag.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditTag() {
|
||||||
|
editingTagId = null;
|
||||||
|
editingTagName = '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-sm">
|
||||||
|
<div class="border-b border-line-2 bg-yellow-50/50 p-6">
|
||||||
|
<h2 class="text-lg font-bold text-ink-2">{m.admin_section_tags()}</h2>
|
||||||
|
<p class="mt-1 text-xs text-yellow-800">
|
||||||
|
{m.admin_tags_warning()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="max-h-[600px] divide-y divide-line-2 overflow-y-auto">
|
||||||
|
{#each tags as tag (tag.id)}
|
||||||
|
<li class="group flex items-center justify-between px-6 py-3 hover:bg-muted">
|
||||||
|
{#if editingTagId === tag.id}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/updateTag"
|
||||||
|
use:enhance={() =>
|
||||||
|
async ({ update }) => {
|
||||||
|
await update();
|
||||||
|
cancelEditTag();
|
||||||
|
}}
|
||||||
|
class="flex flex-1 items-center gap-2"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={tag.id} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
bind:value={editingTagName}
|
||||||
|
class="flex-1 rounded border-accent px-2 py-1 text-sm ring-1 ring-accent"
|
||||||
|
/>
|
||||||
|
<button aria-label={m.btn_save()} class="text-green-600 hover:text-green-800"
|
||||||
|
><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
|
><path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/></svg
|
||||||
|
></button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={cancelEditTag}
|
||||||
|
aria-label={m.btn_cancel()}
|
||||||
|
class="text-ink-3 hover:text-ink-2"
|
||||||
|
><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
|
><path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/></svg
|
||||||
|
></button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<span class="rounded bg-muted px-2 py-1 text-sm font-medium text-ink">
|
||||||
|
{tag.name}
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<button
|
||||||
|
onclick={() => startEditTag(tag)}
|
||||||
|
aria-label={m.admin_btn_edit_tag_label()}
|
||||||
|
class="p-1 text-ink-3 hover:text-ink"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
|
><path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||||
|
/></svg
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/deleteTag"
|
||||||
|
use:enhance={({ cancel }) => {
|
||||||
|
if (!confirm(m.admin_tag_delete_confirm())) {
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
return async ({ update }) => {
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="inline"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={tag.id} />
|
||||||
|
<button
|
||||||
|
aria-label={m.admin_btn_delete_tag_label()}
|
||||||
|
class="p-1 text-ink-3 hover:text-red-600"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
|
><path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/></svg
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
120
frontend/src/routes/admin/UsersTab.svelte
Normal file
120
frontend/src/routes/admin/UsersTab.svelte
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
users
|
||||||
|
}: {
|
||||||
|
users: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
groups?: { id: string; name: string }[];
|
||||||
|
}[];
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-sm">
|
||||||
|
<div class="flex items-center justify-between border-b border-line-2 p-6">
|
||||||
|
<h2 class="text-lg font-bold text-ink-2">{m.admin_section_users()}</h2>
|
||||||
|
<a
|
||||||
|
href="/admin/users/new"
|
||||||
|
class="inline-flex items-center gap-1 rounded-sm bg-primary px-4 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
{m.admin_btn_new_user()}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="min-w-full divide-y divide-line">
|
||||||
|
<thead class="bg-muted">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-ink-2 uppercase"
|
||||||
|
>{m.admin_col_login()}</th
|
||||||
|
>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-ink-2 uppercase"
|
||||||
|
>{m.admin_col_full_name()}</th
|
||||||
|
>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-bold tracking-wider text-ink-2 uppercase"
|
||||||
|
>{m.admin_col_groups()}</th
|
||||||
|
>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-bold tracking-wider text-ink-2 uppercase"
|
||||||
|
>{m.admin_col_actions()}</th
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-line bg-surface">
|
||||||
|
{#each users as user (user.id)}
|
||||||
|
<tr class="group/row hover:bg-muted">
|
||||||
|
<td class="px-6 py-4 text-sm font-medium whitespace-nowrap text-ink">
|
||||||
|
{user.username}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm whitespace-nowrap text-ink-2">
|
||||||
|
{#if user.firstName || user.lastName}
|
||||||
|
{user.firstName ?? ''} {user.lastName ?? ''}
|
||||||
|
{:else}
|
||||||
|
<span class="text-ink-3 italic">–</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-ink-2">
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{#if user.groups && user.groups.length > 0}
|
||||||
|
{#each user.groups as group (group.id)}
|
||||||
|
<span
|
||||||
|
class="rounded-full border border-blue-100 bg-blue-50 px-2 py-0.5 text-[10px] font-bold text-blue-700 uppercase"
|
||||||
|
>
|
||||||
|
{group.name}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<span class="text-xs text-ink-3 italic">{m.admin_no_groups()}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-right whitespace-nowrap">
|
||||||
|
<div class="flex items-center justify-end gap-4">
|
||||||
|
<a
|
||||||
|
href="/admin/users/{user.id}"
|
||||||
|
class="text-sm font-bold tracking-wide text-accent uppercase hover:text-ink"
|
||||||
|
>
|
||||||
|
{m.btn_edit()}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/deleteUser"
|
||||||
|
use:enhance={({ cancel }) => {
|
||||||
|
if (!confirm(m.admin_user_delete_confirm({ username: user.username }))) {
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
return async ({ update }) => {
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="flex items-center"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={user.id} />
|
||||||
|
<button
|
||||||
|
class="p-1 text-ink-3 transition-colors hover:text-red-600"
|
||||||
|
title={m.admin_btn_delete_user_title()}
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user