feat: reorganize settings page with tabbed layout (IDEA-67) (#22)

Replace the single long-scroll settings page with a 4-tab layout:
General (workspace + theme), Members, Collections, and Danger Zone.
Tab selection persists in the URL hash and survives page refreshes.
This commit is contained in:
xarmian
2026-03-29 10:55:14 -04:00
committed by GitHub
parent 992690f77d
commit 1da91c4cff
+284 -208
View File
@@ -30,6 +30,21 @@
let inviteResult = $state<{ message: string; type: 'success' | 'error' } | null>(null);
let currentUserRole = $state('');
// Tabs
let activeTab = $state('general');
const tabs = [
{ id: 'general', label: 'General', icon: '\u2699\uFE0F' },
{ id: 'members', label: 'Members', icon: '\uD83D\uDC65' },
{ id: 'collections', label: 'Collections', icon: '\uD83D\uDCC1' },
{ id: 'danger', label: 'Danger Zone', icon: '\u26A0\uFE0F' },
];
const validTabIds = tabs.map(t => t.id);
function switchTab(tabId: string) {
activeTab = tabId;
history.replaceState(null, '', `#${tabId}`);
}
$effect(() => {
if (wsSlug) load(wsSlug);
});
@@ -41,6 +56,8 @@
} else {
theme = document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
}
const hash = window.location.hash.slice(1);
if (validTabIds.includes(hash)) activeTab = hash;
});
async function load(slug: string) {
loading = true;
@@ -178,231 +195,289 @@
<header class="settings-header">
<h1>Settings</h1>
</header>
<section class="section">
<h2>Workspace</h2>
<div class="card">
<div class="field-row">
<label for="ws-name">Name</label>
<div class="inline-edit">
<input id="ws-name" type="text" bind:value={wsName} onkeydown={(e) => e.key === 'Enter' && saveName()} />
<button class="btn btn-small" onclick={saveName} disabled={savingName}>
{savingName ? 'Saving...' : 'Save'}
</button>
{#if nameStatus === 'saved'}
<span class="status-saved">Saved</span>
{:else if nameStatus === 'error'}
<span class="status-error">Error</span>
{/if}
</div>
</div>
<div class="field-row">
<span class="field-label">Slug</span>
<span class="field-value mono">{wsSlug}</span>
</div>
{#if createdDate}
<div class="tab-bar" role="tablist">
{#each tabs as tab (tab.id)}
<button
class="tab"
class:active={activeTab === tab.id}
class:danger={tab.id === 'danger'}
role="tab"
aria-selected={activeTab === tab.id}
onclick={() => switchTab(tab.id)}
>
<span class="tab-icon">{tab.icon}</span>
{tab.label}
</button>
{/each}
</div>
{#if activeTab === 'general'}
<section class="section">
<h2>Workspace</h2>
<div class="card">
<div class="field-row">
<span class="field-label">Created</span>
<span class="field-value">{createdDate}</span>
<label for="ws-name">Name</label>
<div class="inline-edit">
<input id="ws-name" type="text" bind:value={wsName} onkeydown={(e) => e.key === 'Enter' && saveName()} />
<button class="btn btn-small" onclick={saveName} disabled={savingName}>
{savingName ? 'Saving...' : 'Save'}
</button>
{#if nameStatus === 'saved'}
<span class="status-saved">Saved</span>
{:else if nameStatus === 'error'}
<span class="status-error">Error</span>
{/if}
</div>
</div>
<div class="field-row">
<span class="field-label">Slug</span>
<span class="field-value mono">{wsSlug}</span>
</div>
{#if createdDate}
<div class="field-row">
<span class="field-label">Created</span>
<span class="field-value">{createdDate}</span>
</div>
{/if}
<div class="field-row">
<span class="field-label">Export</span>
<a href="/api/v1/workspaces/{wsSlug}/export" download="{wsSlug}-export.json" class="btn btn-small">
Download JSON
</a>
</div>
{/if}
<div class="field-row">
<span class="field-label">Export</span>
<a href="/api/v1/workspaces/{wsSlug}/export" download="{wsSlug}-export.json" class="btn btn-small">
Download JSON
</a>
</div>
</div>
</section>
<section class="section">
<h2>Members</h2>
{#if members.length === 0}
<p class="empty-text">No members yet.</p>
{:else}
<div class="members-list">
{#each members as member (member.user_id)}
<div class="card member-row">
<div class="member-info">
<span class="member-avatar">{member.user_name.charAt(0).toUpperCase()}</span>
<div class="member-details">
<span class="member-name">{member.user_name}</span>
<span class="member-email">{member.user_email}</span>
</section>
<section class="section">
<h2>Theme</h2>
<div class="card">
<div class="theme-row">
<span>Appearance</span>
<button class="theme-toggle" onclick={toggleTheme}>
<span class="theme-option" class:active={theme === 'light'}>Light</span>
<span class="theme-option" class:active={theme === 'dark'}>Dark</span>
</button>
</div>
</div>
</section>
{:else if activeTab === 'members'}
<section class="section">
{#if members.length === 0}
<p class="empty-text">No members yet.</p>
{:else}
<div class="members-list">
{#each members as member (member.user_id)}
<div class="card member-row">
<div class="member-info">
<span class="member-avatar">{member.user_name.charAt(0).toUpperCase()}</span>
<div class="member-details">
<span class="member-name">{member.user_name}</span>
<span class="member-email">{member.user_email}</span>
</div>
</div>
<div class="member-actions">
{#if isOwner}
<select
class="role-select"
value={member.role}
onchange={(e) => handleChangeRole(member.user_id, (e.target as HTMLSelectElement).value)}
>
<option value="owner">Owner</option>
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
</select>
<button class="btn btn-small btn-remove" onclick={() => handleRemoveMember(member.user_id, member.user_name)}>
Remove
</button>
{:else}
<span class="role-badge">{member.role}</span>
{/if}
</div>
</div>
<div class="member-actions">
{#if isOwner}
<select
class="role-select"
value={member.role}
onchange={(e) => handleChangeRole(member.user_id, (e.target as HTMLSelectElement).value)}
>
<option value="owner">Owner</option>
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
</select>
<button class="btn btn-small btn-remove" onclick={() => handleRemoveMember(member.user_id, member.user_name)}>
Remove
{/each}
</div>
{/if}
{#if invitations.length > 0}
<div class="invitations-section">
<h3>Pending Invitations</h3>
{#each invitations as inv (inv.id)}
<div class="card invitation-row">
<span class="inv-email">{inv.email}</span>
<span class="role-badge">{inv.role}</span>
{#if inv.join_url}
<button class="btn btn-small copy-link-btn" onclick={() => { navigator.clipboard.writeText(inv.join_url ?? ''); toastStore.show('Link copied!', 'success'); }}>
Copy invite link
</button>
{:else}
<span class="role-badge">{member.role}</span>
<code class="inv-code">{inv.code}</code>
{/if}
</div>
</div>
{/each}
</div>
{/if}
{#if invitations.length > 0}
<div class="invitations-section">
<h3>Pending Invitations</h3>
{#each invitations as inv (inv.id)}
<div class="card invitation-row">
<span class="inv-email">{inv.email}</span>
<span class="role-badge">{inv.role}</span>
{#if inv.join_url}
<button class="btn btn-small copy-link-btn" onclick={() => { navigator.clipboard.writeText(inv.join_url ?? ''); toastStore.show('Link copied!', 'success'); }}>
Copy invite link
</button>
{:else}
<code class="inv-code">{inv.code}</code>
{/if}
</div>
{/each}
</div>
{/if}
{#if isOwner}
<div class="invite-form card">
<h3>Invite Member</h3>
<div class="invite-row">
<input
type="email"
placeholder="Email address"
bind:value={inviteEmail}
onkeydown={(e) => e.key === 'Enter' && handleInvite()}
disabled={inviting}
/>
<select class="role-select" bind:value={inviteRole}>
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
<option value="owner">Owner</option>
</select>
<button class="btn btn-primary btn-small" onclick={handleInvite} disabled={inviting || !inviteEmail.trim()}>
{inviting ? 'Inviting...' : 'Invite'}
</button>
</div>
{#if inviteResult}
<p class="invite-result" class:invite-success={inviteResult.type === 'success'} class:invite-error={inviteResult.type === 'error'}>
{inviteResult.message}
</p>
{/if}
</div>
{/if}
</section>
<section class="section">
<h2>Collections</h2>
{#if collections.length === 0}
<p class="empty-text">No collections yet.</p>
{:else}
<div class="coll-list">
{#each collections as coll (coll.id)}
{@const schema = parseSchema(coll)}
<button class="card coll-card coll-card-btn" onclick={() => (editingCollection = coll)}>
<div class="coll-header">
<span class="coll-icon">{coll.icon || '#'}</span>
<span class="coll-name">{coll.name}</span>
<span class="coll-slug mono">/{coll.slug}</span>
<span class="coll-count">{coll.item_count ?? 0} items</span>
{#if coll.is_default}
<span class="badge">default</span>
{/if}
<span class="edit-hint">Edit</span>
</div>
{#if schema.fields.length > 0}
<div class="field-tags">
{#each schema.fields as field (field.key)}
<span class="field-tag">{field.key}: {field.type}</span>
{/each}
</div>
{/if}
</button>
{/each}
</div>
{/if}
<button class="btn btn-create" onclick={() => (showCreateModal = true)}>
+ Create Collection
</button>
<CreateCollectionModal
open={showCreateModal}
{wsSlug}
oncreated={handleCollectionCreated}
onclose={() => (showCreateModal = false)}
/>
{#if editingCollection}
<EditCollectionModal
open={true}
collection={editingCollection}
{wsSlug}
onupdated={handleCollectionUpdated}
onclose={() => (editingCollection = null)}
/>
{/if}
</section>
<section class="section">
<h2>Theme</h2>
<div class="card">
<div class="theme-row">
<span>Appearance</span>
<button class="theme-toggle" onclick={toggleTheme}>
<span class="theme-option" class:active={theme === 'light'}>Light</span>
<span class="theme-option" class:active={theme === 'dark'}>Dark</span>
</button>
</div>
</div>
</section>
<section class="section danger-section">
<h2>Danger Zone</h2>
<div class="card danger-card">
{#if !confirmDelete}
<div class="danger-row">
<div class="danger-info">
<strong>Archive this workspace</strong>
<p>This will hide the workspace and all its collections, items, and documents. The data is preserved but no longer accessible.</p>
</div>
<button class="btn btn-danger" onclick={() => confirmDelete = true}>
Archive workspace
</button>
</div>
{:else}
<div class="danger-confirm">
<p class="danger-warning">This will archive <strong>{wsName}</strong> and all its contents. To confirm, type the workspace slug below:</p>
<div class="danger-input-row">
<code class="slug-hint">{wsSlug}</code>
<input
type="text"
class="danger-input"
bind:value={deleteInput}
placeholder="Type workspace slug to confirm"
onkeydown={(e) => e.key === 'Enter' && handleDeleteWorkspace()}
/>
</div>
<div class="danger-actions">
<button class="btn btn-danger" onclick={handleDeleteWorkspace} disabled={deleteInput !== wsSlug || deleting}>
{deleting ? 'Archiving...' : 'Archive this workspace'}
</button>
<button class="btn" onclick={() => { confirmDelete = false; deleteInput = ''; }}>Cancel</button>
</div>
{/each}
</div>
{/if}
</div>
</section>
{#if isOwner}
<div class="invite-form card">
<h3>Invite Member</h3>
<div class="invite-row">
<input
type="email"
placeholder="Email address"
bind:value={inviteEmail}
onkeydown={(e) => e.key === 'Enter' && handleInvite()}
disabled={inviting}
/>
<select class="role-select" bind:value={inviteRole}>
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
<option value="owner">Owner</option>
</select>
<button class="btn btn-primary btn-small" onclick={handleInvite} disabled={inviting || !inviteEmail.trim()}>
{inviting ? 'Inviting...' : 'Invite'}
</button>
</div>
{#if inviteResult}
<p class="invite-result" class:invite-success={inviteResult.type === 'success'} class:invite-error={inviteResult.type === 'error'}>
{inviteResult.message}
</p>
{/if}
</div>
{/if}
</section>
{:else if activeTab === 'collections'}
<section class="section">
{#if collections.length === 0}
<p class="empty-text">No collections yet.</p>
{:else}
<div class="coll-list">
{#each collections as coll (coll.id)}
{@const schema = parseSchema(coll)}
<button class="card coll-card coll-card-btn" onclick={() => (editingCollection = coll)}>
<div class="coll-header">
<span class="coll-icon">{coll.icon || '#'}</span>
<span class="coll-name">{coll.name}</span>
<span class="coll-slug mono">/{coll.slug}</span>
<span class="coll-count">{coll.item_count ?? 0} items</span>
{#if coll.is_default}
<span class="badge">default</span>
{/if}
<span class="edit-hint">Edit</span>
</div>
{#if schema.fields.length > 0}
<div class="field-tags">
{#each schema.fields as field (field.key)}
<span class="field-tag">{field.key}: {field.type}</span>
{/each}
</div>
{/if}
</button>
{/each}
</div>
{/if}
<button class="btn btn-create" onclick={() => (showCreateModal = true)}>
+ Create Collection
</button>
<CreateCollectionModal
open={showCreateModal}
{wsSlug}
oncreated={handleCollectionCreated}
onclose={() => (showCreateModal = false)}
/>
{#if editingCollection}
<EditCollectionModal
open={true}
collection={editingCollection}
{wsSlug}
onupdated={handleCollectionUpdated}
onclose={() => (editingCollection = null)}
/>
{/if}
</section>
{:else if activeTab === 'danger'}
<section class="section">
<div class="danger-banner">
<p>These actions are destructive and cannot be easily reversed.</p>
</div>
<div class="card danger-card">
{#if !confirmDelete}
<div class="danger-row">
<div class="danger-info">
<strong>Archive this workspace</strong>
<p>This will hide the workspace and all its collections, items, and documents. The data is preserved but no longer accessible.</p>
</div>
<button class="btn btn-danger" onclick={() => confirmDelete = true}>
Archive workspace
</button>
</div>
{:else}
<div class="danger-confirm">
<p class="danger-warning">This will archive <strong>{wsName}</strong> and all its contents. To confirm, type the workspace slug below:</p>
<div class="danger-input-row">
<code class="slug-hint">{wsSlug}</code>
<input
type="text"
class="danger-input"
bind:value={deleteInput}
placeholder="Type workspace slug to confirm"
onkeydown={(e) => e.key === 'Enter' && handleDeleteWorkspace()}
/>
</div>
<div class="danger-actions">
<button class="btn btn-danger" onclick={handleDeleteWorkspace} disabled={deleteInput !== wsSlug || deleting}>
{deleting ? 'Archiving...' : 'Archive this workspace'}
</button>
<button class="btn" onclick={() => { confirmDelete = false; deleteInput = ''; }}>Cancel</button>
</div>
</div>
{/if}
</div>
</section>
{/if}
{/if}
</div>
<style>
.settings { max-width: var(--content-max-width); margin: 0 auto; padding: var(--space-8) var(--space-6); }
.loading { text-align: center; padding-top: 20vh; color: var(--text-muted); }
.settings-header { margin-bottom: var(--space-8); }
.settings-header { margin-bottom: var(--space-4); }
.settings-header h1 { font-size: 1.6em; }
/* ── Tab bar ──── */
.tab-bar {
display: flex;
gap: var(--space-1);
border-bottom: 1px solid var(--border);
margin-bottom: var(--space-6);
overflow-x: auto;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
.tab-bar::-webkit-scrollbar { display: none; }
.tab {
padding: var(--space-2) var(--space-4);
font-size: 0.9em;
color: var(--text-secondary);
cursor: pointer;
border: none;
background: none;
border-bottom: 2px solid transparent;
white-space: nowrap;
transition: color 0.15s, border-color 0.15s;
display: flex;
align-items: center;
gap: var(--space-2);
}
.tab:hover { color: var(--text-primary); }
.tab.active {
color: var(--text-primary);
border-bottom-color: var(--accent-blue);
font-weight: 500;
}
.tab-icon { font-size: 0.9em; }
.tab.danger { color: var(--text-muted); }
.tab.danger:hover { color: #ef4444; }
.tab.danger.active { color: #ef4444; border-bottom-color: #ef4444; }
/* ── Sections ──── */
.section { margin-bottom: var(--space-8); }
.section h2 { font-size: 1.1em; color: var(--text-secondary); margin-bottom: var(--space-4); }
.card { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--space-4); }
@@ -471,7 +546,8 @@
.invite-success { color: var(--accent-green); }
.invite-error { color: #ef4444; }
/* ── Danger Zone ──── */
.danger-section h2 { color: #ef4444; }
.danger-banner { background: color-mix(in srgb, #ef4444 8%, var(--bg-secondary)); border: 1px solid color-mix(in srgb, #ef4444 25%, var(--border)); border-radius: var(--radius); padding: var(--space-3) var(--space-4); margin-bottom: var(--space-4); }
.danger-banner p { font-size: 0.88em; color: #ef4444; margin: 0; }
.danger-card { border-color: color-mix(in srgb, #ef4444 30%, var(--border)); }
.danger-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); }
.danger-info { flex: 1; }