feat(dashboard): group tree, detail page, and members tab

SvelteKit UI for Phase-2 groups, mirroring the apps/users patterns:
- api.ts: Group/GroupDetail/GroupMember types + an api.groups client
  (list/get/create/update/reparent/delete + nested members CRUD); optional
  group selector wired into app create.
- routes/groups: a collapsible tree overview (assembled from parent_id)
  with a New-group form, and a detail page with breadcrumb, subgroups/apps
  lists, a rename form (no slug field — slug is frozen), a reparent
  control, a delete button that surfaces the 409 RESTRICT message, and a
  Members tab reusing RoleChip/ConfirmModal/ActionMenu.
- +layout.svelte: a Groups nav entry beside Apps.

npm run check: 0 errors; npm run build: success.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-24 20:15:44 +02:00
parent eea1d8984e
commit 8725939172
5 changed files with 1270 additions and 2 deletions

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { base } from '$app/paths';
import { api, ApiError, type App } from '$lib/api';
import { api, ApiError, type App, type Group } from '$lib/api';
import { slugify, SLUG_MAX } from '$lib/slugify';
import { canCreateApp } from '$lib/capabilities';
import { currentUser } from '$lib/auth';
@@ -36,6 +36,17 @@
let createSlug = $state('');
let createName = $state('');
let createDescription = $state('');
// Optional parent group for the new app (defaults to root). Loaded
// lazily; failure leaves the picker empty (root-only).
let createGroup = $state('');
let groups = $state<Group[]>([]);
async function loadGroups() {
try {
groups = await api.groups.list();
} catch {
groups = [];
}
}
// Auto-derive slug from name until the user takes manual control of
// the slug field. Clearing the slug input releases the lock so the
// auto-derive resumes — matches the GitLab project-create UX.
@@ -69,6 +80,7 @@
listError = null;
try {
apps = await api.apps.list();
void loadGroups();
if (apps && apps.length > 0) {
void loadDlCounts(apps);
}
@@ -84,6 +96,7 @@
createSlug = '';
createName = '';
createDescription = '';
createGroup = '';
createError = null;
createHistoricalConflict = null;
slugTouched = false;
@@ -99,7 +112,8 @@
slug: createSlug.trim(),
name: createName.trim(),
description: createDescription.trim() || null,
force_takeover: forceTakeover || undefined
force_takeover: forceTakeover || undefined,
group: createGroup.trim() || undefined
});
showCreate = false;
resetCreate();
@@ -172,6 +186,15 @@
<span>Description</span>
<input bind:value={createDescription} placeholder="optional" />
</label>
<label>
<span>Group (optional)</span>
<select bind:value={createGroup}>
<option value="">— root —</option>
{#each groups as g (g.id)}
<option value={g.slug}>{g.name} (/{g.slug})</option>
{/each}
</select>
</label>
{#if createHistoricalConflict}
<div class="warning">
<strong>Slug previously redirected.</strong>