mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-21 10:03:29 +00:00
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:
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user