feat: rewrite web UI routing to /{username}/{workspace}/... pattern

Restructure all workspace-scoped web URLs to include the owner's
username as a prefix (TASK-411).

Route structure:
- Moved web/src/routes/[workspace]/ → [username]/[workspace]/
- All workspace pages extract both username and workspace from URL
- Auth routes (/login, /register, /join, etc.) unchanged

Backend:
- Workspace model adds OwnerUsername field (populated by JOIN)
- All workspace queries JOIN users table for owner_username
- TypeScript Workspace type updated with owner_username

Frontend (24 files updated):
- All route pages: added username derived, updated URL constructions
- Sidebar, TopBar, WorkspaceSwitcher: use owner_username for links
- ItemCard, TableView, ChildItems, NestedChildren: username in links
- CommandPalette, OnboardingChecklist, CreateWorkspaceModal: updated
- Root page redirect includes owner_username
- Wiki-link markdown utility accepts username parameter

What did NOT change:
- API client (client.ts) — still uses workspace slug for API calls
- Go API routes — unchanged
- CLI — unchanged
This commit is contained in:
xarmian
2026-04-10 23:47:25 +00:00
parent 117221c73d
commit 38e6b4e5b1
29 changed files with 140 additions and 89 deletions
+2 -1
View File
@@ -6,7 +6,8 @@ type Workspace struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
OwnerID string `json:"owner_id,omitempty"` // User ID of workspace owner
OwnerID string `json:"owner_id,omitempty"` // User ID of workspace owner
OwnerUsername string `json:"owner_username,omitempty"` // Populated by JOIN (not stored)
Description string `json:"description"`
Settings string `json:"settings"` // JSON
SortOrder int `json:"sort_order"`
+3 -2
View File
@@ -99,10 +99,11 @@ func (s *Store) ListWorkspaceMembers(workspaceID string) ([]models.WorkspaceMemb
// sorted by the user's custom sort order (then name as tiebreaker).
func (s *Store) GetUserWorkspaces(userID string) ([]models.Workspace, error) {
rows, err := s.db.Query(s.q(`
SELECT w.id, w.name, w.slug, w.owner_id, w.description, w.settings, w.created_at, w.updated_at, w.deleted_at,
SELECT w.id, w.name, w.slug, w.owner_id, COALESCE(ou.username, ''), w.description, w.settings, w.created_at, w.updated_at, w.deleted_at,
wm.sort_order
FROM workspaces w
JOIN workspace_members wm ON wm.workspace_id = w.id
LEFT JOIN users ou ON ou.id = w.owner_id
WHERE wm.user_id = ? AND w.deleted_at IS NULL
ORDER BY wm.sort_order ASC, w.name ASC
`), userID)
@@ -117,7 +118,7 @@ func (s *Store) GetUserWorkspaces(userID string) ([]models.Workspace, error) {
var createdAt, updatedAt string
var deletedAt *string
if err := rows.Scan(
&ws.ID, &ws.Name, &ws.Slug, &ws.OwnerID, &ws.Description, &ws.Settings,
&ws.ID, &ws.Name, &ws.Slug, &ws.OwnerID, &ws.OwnerUsername, &ws.Description, &ws.Settings,
&createdAt, &updatedAt, &deletedAt,
&ws.SortOrder,
); err != nil {
+18 -13
View File
@@ -19,18 +19,20 @@ func (s *Store) ListWorkspacesForUser(userID string) ([]models.Workspace, error)
if userID != "" {
rows, err = s.db.Query(s.q(`
SELECT w.id, w.name, w.slug, w.owner_id, w.description, w.settings, w.created_at, w.updated_at,
SELECT w.id, w.name, w.slug, w.owner_id, COALESCE(ou.username, ''), w.description, w.settings, w.created_at, w.updated_at,
COALESCE(wm.sort_order, 0)
FROM workspaces w
LEFT JOIN workspace_members wm ON wm.workspace_id = w.id AND wm.user_id = ?
LEFT JOIN users ou ON ou.id = w.owner_id
WHERE w.deleted_at IS NULL
ORDER BY COALESCE(wm.sort_order, 0) ASC, w.name ASC
`), userID)
} else {
rows, err = s.db.Query(s.q(`
SELECT w.id, w.name, w.slug, w.owner_id, w.description, w.settings, w.created_at, w.updated_at,
SELECT w.id, w.name, w.slug, w.owner_id, COALESCE(ou.username, ''), w.description, w.settings, w.created_at, w.updated_at,
0 as sort_order
FROM workspaces w
LEFT JOIN users ou ON ou.id = w.owner_id
WHERE w.deleted_at IS NULL
ORDER BY w.name ASC
`))
@@ -44,7 +46,7 @@ func (s *Store) ListWorkspacesForUser(userID string) ([]models.Workspace, error)
for rows.Next() {
var w models.Workspace
var createdAt, updatedAt string
if err := rows.Scan(&w.ID, &w.Name, &w.Slug, &w.OwnerID, &w.Description, &w.Settings, &createdAt, &updatedAt, &w.SortOrder); err != nil {
if err := rows.Scan(&w.ID, &w.Name, &w.Slug, &w.OwnerID, &w.OwnerUsername, &w.Description, &w.Settings, &createdAt, &updatedAt, &w.SortOrder); err != nil {
return nil, err
}
w.CreatedAt = parseTime(createdAt)
@@ -122,10 +124,11 @@ func (s *Store) GetWorkspaceBySlug(slug string) (*models.Workspace, error) {
var deletedAt *string
err := s.db.QueryRow(s.q(`
SELECT id, name, slug, owner_id, description, settings, created_at, updated_at, deleted_at
FROM workspaces
WHERE slug = ? AND deleted_at IS NULL
`), slug).Scan(&w.ID, &w.Name, &w.Slug, &w.OwnerID, &w.Description, &w.Settings, &createdAt, &updatedAt, &deletedAt)
SELECT w.id, w.name, w.slug, w.owner_id, COALESCE(ou.username, ''), w.description, w.settings, w.created_at, w.updated_at, w.deleted_at
FROM workspaces w
LEFT JOIN users ou ON ou.id = w.owner_id
WHERE w.slug = ? AND w.deleted_at IS NULL
`), slug).Scan(&w.ID, &w.Name, &w.Slug, &w.OwnerID, &w.OwnerUsername, &w.Description, &w.Settings, &createdAt, &updatedAt, &deletedAt)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -146,10 +149,11 @@ func (s *Store) GetWorkspaceByID(id string) (*models.Workspace, error) {
var deletedAt *string
err := s.db.QueryRow(s.q(`
SELECT id, name, slug, owner_id, description, settings, created_at, updated_at, deleted_at
FROM workspaces
WHERE id = ? AND deleted_at IS NULL
`), id).Scan(&w.ID, &w.Name, &w.Slug, &w.OwnerID, &w.Description, &w.Settings, &createdAt, &updatedAt, &deletedAt)
SELECT w.id, w.name, w.slug, w.owner_id, COALESCE(ou.username, ''), w.description, w.settings, w.created_at, w.updated_at, w.deleted_at
FROM workspaces w
LEFT JOIN users ou ON ou.id = w.owner_id
WHERE w.id = ? AND w.deleted_at IS NULL
`), id).Scan(&w.ID, &w.Name, &w.Slug, &w.OwnerID, &w.OwnerUsername, &w.Description, &w.Settings, &createdAt, &updatedAt, &deletedAt)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -168,9 +172,10 @@ func (s *Store) GetWorkspaceByID(id string) (*models.Workspace, error) {
// to the given user (owned by them or where they are a member).
func (s *Store) GetWorkspacesBySlugForUser(slug, userID string) ([]models.Workspace, error) {
rows, err := s.db.Query(s.q(`
SELECT DISTINCT w.id, w.name, w.slug, w.owner_id, w.description, w.settings, w.created_at, w.updated_at
SELECT DISTINCT w.id, w.name, w.slug, w.owner_id, COALESCE(ou.username, ''), w.description, w.settings, w.created_at, w.updated_at
FROM workspaces w
LEFT JOIN workspace_members wm ON wm.workspace_id = w.id AND wm.user_id = ?
LEFT JOIN users ou ON ou.id = w.owner_id
WHERE w.slug = ? AND w.deleted_at IS NULL
AND (w.owner_id = ? OR wm.user_id IS NOT NULL)
`), userID, slug, userID)
@@ -183,7 +188,7 @@ func (s *Store) GetWorkspacesBySlugForUser(slug, userID string) ([]models.Worksp
for rows.Next() {
var w models.Workspace
var createdAt, updatedAt string
if err := rows.Scan(&w.ID, &w.Name, &w.Slug, &w.OwnerID, &w.Description, &w.Settings, &createdAt, &updatedAt); err != nil {
if err := rows.Scan(&w.ID, &w.Name, &w.Slug, &w.OwnerID, &w.OwnerUsername, &w.Description, &w.Settings, &createdAt, &updatedAt); err != nil {
return nil, err
}
w.CreatedAt = parseTime(createdAt)
+4 -3
View File
@@ -14,6 +14,7 @@
interface Props {
wsSlug: string;
username?: string;
itemSlug: string;
itemId: string;
parentFields?: Record<string, any>;
@@ -21,7 +22,7 @@
onChildrenChange?: (children: Item[]) => void;
}
let { wsSlug, itemSlug, itemId, parentFields, terminalStatuses, onChildrenChange }: Props = $props();
let { wsSlug, username = '', itemSlug, itemId, parentFields, terminalStatuses, onChildrenChange }: Props = $props();
const defaultTerminal = ['done', 'completed', 'resolved', 'cancelled', 'rejected', 'wontfix', 'fixed', 'implemented', 'archived', 'disabled', 'deprecated'];
const terminal = $derived(terminalStatuses ?? defaultTerminal);
@@ -222,7 +223,7 @@
<span class="expand-icon" class:expanded={isExpanded}>▸</span>
</button>
{/if}
<a href="/{wsSlug}/{child.collection_slug}/{child.slug}" class="child-row" class:has-toggle={canExpand}>
<a href="/{username}/{wsSlug}/{child.collection_slug}/{child.slug}" class="child-row" class:has-toggle={canExpand}>
<span class="child-ref">{formatItemRef(child) ?? ''}</span>
<span class="child-title" class:done={isDone}>{child.title}</span>
{#if fields.priority}
@@ -237,7 +238,7 @@
</a>
</div>
{#if canExpand && isExpanded}
<NestedChildren {wsSlug} parentSlug={child.slug} depth={1} maxDepth={3} {terminalStatuses} />
<NestedChildren {wsSlug} {username} parentSlug={child.slug} depth={1} maxDepth={3} {terminalStatuses} />
{/if}
</div>
{/each}
+4 -3
View File
@@ -5,13 +5,14 @@
interface Props {
wsSlug: string;
username?: string;
parentSlug: string;
depth?: number;
maxDepth?: number;
terminalStatuses?: string[];
}
let { wsSlug, parentSlug, depth = 1, maxDepth = 3, terminalStatuses }: Props = $props();
let { wsSlug, username = '', parentSlug, depth = 1, maxDepth = 3, terminalStatuses }: Props = $props();
const defaultTerminal = ['done', 'completed', 'resolved', 'cancelled', 'rejected', 'wontfix', 'fixed', 'implemented', 'archived', 'disabled', 'deprecated'];
const terminal = $derived(terminalStatuses ?? defaultTerminal);
@@ -73,7 +74,7 @@
{:else}
<span class="expand-spacer"></span>
{/if}
<a href="/{wsSlug}/{child.collection_slug}/{child.slug}" class="nested-link">
<a href="/{username}/{wsSlug}/{child.collection_slug}/{child.slug}" class="nested-link">
<span class="nested-ref">{formatItemRef(child) ?? ''}</span>
<span class="nested-title" class:done={isDone}>{child.title}</span>
</a>
@@ -88,7 +89,7 @@
{/if}
</div>
{#if canExpand && isExpanded}
<svelte:self wsSlug={wsSlug} parentSlug={child.slug} depth={depth + 1} {maxDepth} {terminalStatuses} />
<svelte:self wsSlug={wsSlug} {username} parentSlug={child.slug} depth={depth + 1} {maxDepth} {terminalStatuses} />
{/if}
</div>
{/each}
@@ -3,11 +3,12 @@
interface Props {
wsSlug: string;
username?: string;
byCollection: Record<string, Record<string, number>>;
ondismiss?: () => void;
}
let { wsSlug, byCollection, ondismiss }: Props = $props();
let { wsSlug, username = '', byCollection, ondismiss }: Props = $props();
let copiedHint = $state<string | null>(null);
@@ -33,25 +34,25 @@
let steps = $derived<Step[]>([
{
title: 'Add project conventions',
href: `/${wsSlug}/library`,
href: `/${username}/${wsSlug}/library`,
done: collectionHasItems('conventions'),
hint: '/pad what conventions should this project follow?'
},
{
title: 'Create your first plan',
href: `/${wsSlug}/plans`,
href: `/${username}/${wsSlug}/plans`,
done: collectionHasItems('plans'),
hint: '/pad create a plan for what I\'m working on'
},
{
title: 'Add a few tasks',
href: `/${wsSlug}/tasks`,
href: `/${username}/${wsSlug}/tasks`,
done: collectionItemCount('tasks') >= 3,
hint: '/pad break down my current work into tasks'
},
{
title: 'Write an architecture doc',
href: `/${wsSlug}/docs`,
href: `/${username}/${wsSlug}/docs`,
done: collectionHasItems('docs'),
hint: '/pad document the architecture of this project'
}
@@ -129,7 +130,7 @@
<p class="footer-instructions">
Install the Pad skill in your project with <code>pad agent install</code>, then paste a prompt above into Claude Code or your favorite AI agent.
</p>
<a href="/{wsSlug}/library" class="footer-link">Or browse the library for conventions and playbooks</a>
<a href="/{username}/{wsSlug}/library" class="footer-link">Or browse the library for conventions and playbooks</a>
</div>
</div>
@@ -25,6 +25,7 @@
let { items, collection, wsSlug = '', groupField = 'status', focusedItemId = null, onStatusChange, onReorder, onArchiveColumn, onGroupReorder, oncreate, itemProgress, progressLabel = 'tasks' }: Props = $props();
let confirmArchiveColumn = $state<string | null>(null);
let isMobile = $state(false);
const flipDurationMs = 200;
const touchDragDelayMs = 500;
@@ -40,6 +41,18 @@
columnOrder = [...columns];
});
$effect(() => {
const mql = window.matchMedia('(max-width: 768px)');
isMobile = mql.matches;
function onChange(e: MediaQueryListEvent) {
isMobile = e.matches;
}
mql.addEventListener('change', onChange);
return () => {
mql.removeEventListener('change', onChange);
};
});
// Native HTML5 drag-and-drop for column reordering
let draggedColumn = $state<string | null>(null);
let dragOverColumn = $state<string | null>(null);
@@ -242,14 +255,15 @@
flipDurationMs,
type: 'board-card',
dropTargetClasses: ['drop-target'],
delayTouchStart: touchDragDelayMs
delayTouchStart: touchDragDelayMs,
dragDisabled: isMobile
}}
onconsider={(e) => handleConsider(colValue, e)}
onfinalize={(e) => handleFinalize(colValue, e)}
oncontextmenu={(e) => e.preventDefault()}
>
{#each colItems as item (item.id)}
<div class="card-wrapper">
<div class="card-wrapper" class:no-drag={isMobile}>
<ItemCard
{item}
{collection}
@@ -451,6 +465,14 @@
cursor: grabbing;
}
.card-wrapper.no-drag {
cursor: default;
}
.card-wrapper.no-drag:active {
cursor: default;
}
.column-empty {
text-align: center;
padding: var(--space-4);
@@ -18,12 +18,13 @@
let { item, collection, compact = false, focused = false, showCollection = false, statusOptions, onStatusClick, progress = null, progressLabel = 'tasks' }: Props = $props();
let wsSlug = $derived(page.params.workspace ?? '');
let username = $derived(page.params.username ?? '');
let fields = $derived(parseFields(item));
let schema = $derived(parseSchema(collection));
let statusField = $derived(schema.fields.find((f) => f.key === 'status'));
let priorityField = $derived(schema.fields.find((f) => f.key === 'priority'));
let itemUrl = $derived(`/${wsSlug}/${collection.slug}/${itemUrlId(item)}`);
let itemUrl = $derived(`/${username}/${wsSlug}/${collection.slug}/${itemUrlId(item)}`);
let itemRef = $derived(formatItemRef(item));
let statusCyclable = $derived(
@@ -25,6 +25,7 @@
}: Props = $props();
let resolvedWsSlug = $derived(wsSlug || page.params.workspace || '');
let resolvedUsername = $derived(page.params.username || '');
let schema = $derived(parseSchema(collection));
let visibleFields = $derived(schema.fields.filter((f) => !f.computed));
@@ -127,7 +128,7 @@
<tr>
<td class="col-ref"><span class="ref">{formatItemRef(item) ?? ''}</span></td>
<td class="col-title">
<a href="/{resolvedWsSlug}/{collection.slug}/{itemUrlId(item)}" class="title-link">{item.title}</a>
<a href="/{resolvedUsername}/{resolvedWsSlug}/{collection.slug}/{itemUrlId(item)}" class="title-link">{item.title}</a>
{#if itemProgress?.[item.id]}
{@const p = itemProgress[item.id]}
<div class="cell-progress">
@@ -48,7 +48,7 @@
template: selectedTemplate || undefined
});
close();
goto(`/${ws.slug}`);
goto(`/${ws.owner_username}/${ws.slug}`);
} catch {
toastStore.show('Failed to create workspace', 'error');
}
@@ -68,7 +68,7 @@
await workspaceStore.loadAll();
close();
toastStore.show(`Imported workspace "${ws.name}"`, 'success');
goto(`/${ws.slug}`);
goto(`/${ws.owner_username}/${ws.slug}`);
} catch (err) {
toastStore.show(`Import failed: ${err instanceof Error ? err.message : 'Unknown error'}`, 'error');
} finally {
+14 -12
View File
@@ -22,13 +22,15 @@
let quickAddInputEl = $state<HTMLTextAreaElement>();
let wsSlug = $derived(workspaceStore.current?.slug);
let isDashboardPage = $derived(wsSlug ? page.url.pathname === `/${wsSlug}` : false);
let isRolesPage = $derived(wsSlug ? page.url.pathname === `/${wsSlug}/roles` : false);
let isActivityPage = $derived(wsSlug ? page.url.pathname === `/${wsSlug}/activity` : false);
let wsUsername = $derived(workspaceStore.current?.owner_username ?? '');
let wsPrefix = $derived(wsUsername && wsSlug ? `/${wsUsername}/${wsSlug}` : '');
let isDashboardPage = $derived(wsPrefix ? page.url.pathname === wsPrefix : false);
let isRolesPage = $derived(wsPrefix ? page.url.pathname === `${wsPrefix}/roles` : false);
let isActivityPage = $derived(wsPrefix ? page.url.pathname === `${wsPrefix}/activity` : false);
let activeCollectionSlug = $derived.by(() => {
if (!wsSlug) return null;
const prefix = `/${wsSlug}/`;
if (!wsPrefix) return null;
const prefix = `${wsPrefix}/`;
const path = page.url.pathname;
if (!path.startsWith(prefix)) return null;
const rest = path.slice(prefix.length);
@@ -142,7 +144,7 @@
source: 'web'
});
uiStore.onNavigate();
goto(`/${wsSlug}/${coll.slug}/${itemUrlId(item)}?new=1`);
goto(`${wsPrefix}/${coll.slug}/${itemUrlId(item)}?new=1`);
} catch (err: any) {
toastStore.show(err?.message || 'Failed to create item', 'error');
}
@@ -302,7 +304,7 @@
{#if wsSlug}
<nav class="collection-nav">
<a
href="/{wsSlug}"
href="{wsPrefix}"
class="nav-item dashboard"
class:active={isDashboardPage}
onclick={() => uiStore.onNavigate()}
@@ -311,7 +313,7 @@
<span class="nav-label">Dashboard</span>
</a>
<a
href="/{wsSlug}/roles"
href="{wsPrefix}/roles"
class="nav-item"
class:active={isRolesPage}
onclick={() => uiStore.onNavigate()}
@@ -320,7 +322,7 @@
<span class="nav-label">Roles</span>
</a>
<a
href="/{wsSlug}/activity"
href="{wsPrefix}/activity"
class="nav-item"
class:active={isActivityPage}
onclick={() => uiStore.onNavigate()}
@@ -348,7 +350,7 @@
>
{#each sidebarCollections as collection (collection.id)}
<a
href="/{wsSlug}/{collection.slug}"
href="{wsPrefix}/{collection.slug}"
class="nav-item draggable"
class:active={activeCollectionSlug === collection.slug}
onclick={() => uiStore.onNavigate()}
@@ -377,7 +379,7 @@
</div>
{#each agentCollections as collection (collection.id)}
<a
href="/{wsSlug}/{collection.slug}"
href="{wsPrefix}/{collection.slug}"
class="nav-item"
class:active={activeCollectionSlug === collection.slug}
onclick={() => uiStore.onNavigate()}
@@ -410,7 +412,7 @@
</button>
<div class="footer-row">
{#if wsSlug}
<a href="/{wsSlug}/settings" class="settings-btn" onclick={() => uiStore.onNavigate()}>
<a href="{wsPrefix}/settings" class="settings-btn" onclick={() => uiStore.onNavigate()}>
⚙ Settings
</a>
{/if}
+4 -3
View File
@@ -15,6 +15,7 @@
let currentTheme = $state<'dark' | 'light'>('dark');
let currentSlug = $derived(workspaceStore.current?.slug ?? '');
let currentUsername = $derived(workspaceStore.current?.owner_username ?? '');
// DnD state — local copy of workspaces for reordering
let dndWorkspaces: Workspace[] = $state([]);
@@ -145,7 +146,7 @@
>
{#each dndWorkspaces as ws (ws.id)}
<a
href="/{ws.slug}"
href="/{ws.owner_username}/{ws.slug}"
class="workspace-item"
class:active={ws.slug === currentSlug}
title={ws.name}
@@ -198,7 +199,7 @@
</div>
<div class="dropdown-divider"></div>
{#if currentSlug}
<a href="/{currentSlug}/settings" class="dropdown-item" onclick={closeUserMenu}>
<a href="/{currentUsername}/{currentSlug}/settings" class="dropdown-item" onclick={closeUserMenu}>
Settings
</a>
{/if}
@@ -224,7 +225,7 @@
<div class="workspace-list">
{#each workspaceStore.workspaces as ws (ws.id)}
<a
href="/{ws.slug}"
href="/{ws.owner_username}/{ws.slug}"
class="workspace-item"
class:active={ws.slug === currentSlug}
onclick={() => uiStore.onNavigate()}
@@ -5,9 +5,9 @@
let open = $state(false);
function select(slug: string) {
function select(ws: { slug: string; owner_username?: string }) {
open = false;
goto(`/${slug}`);
goto(`/${ws.owner_username}/${ws.slug}`);
}
function openCreateModal() {
@@ -31,7 +31,7 @@
<button
class="item"
class:active={ws.slug === workspaceStore.current?.slug}
onclick={() => select(ws.slug)}
onclick={() => select(ws)}
>
{ws.name}
</button>
@@ -57,9 +57,10 @@
function selectResult(r: SearchResult) {
const ws = workspaceStore.current?.slug;
const wsUsername = workspaceStore.current?.owner_username;
const collSlug = r.item.collection_slug;
if (ws && collSlug) {
goto(`/${ws}/${collSlug}/${itemUrlId(r.item)}`);
if (ws && wsUsername && collSlug) {
goto(`/${wsUsername}/${ws}/${collSlug}/${itemUrlId(r.item)}`);
}
uiStore.closeSearch();
}
+1
View File
@@ -41,6 +41,7 @@ export interface Workspace {
name: string;
slug: string;
owner_id?: string;
owner_username?: string;
description: string;
settings: string;
sort_order: number;
+5 -4
View File
@@ -59,7 +59,7 @@ export function unescapeDocLinks(markdown: string): string {
* Tiptap doesn't understand [[]] syntax, so we convert to standard
* markdown links before feeding content to the editor.
*/
export function wikiLinksToMarkdown(content: string, items: Item[], workspaceSlug: string): string {
export function wikiLinksToMarkdown(content: string, items: Item[], workspaceSlug: string, username?: string): string {
return content.replace(/\[\[([^\]]+)\]\]/g, (_match, title: string) => {
// Support optional collection/ prefix: [[tasks/My Task]]
let searchTitle = title;
@@ -79,7 +79,8 @@ export function wikiLinksToMarkdown(content: string, items: Item[], workspaceSlu
});
if (item && item.collection_slug) {
return `[${searchTitle}](/${workspaceSlug}/${item.collection_slug}/${itemUrlId(item)})`;
const prefix = username ? `/${username}/${workspaceSlug}` : `/${workspaceSlug}`;
return `[${searchTitle}](${prefix}/${item.collection_slug}/${itemUrlId(item)})`;
}
// Unresolved — render as styled text (editor will show it as plain text)
return `[${searchTitle}](broken)`;
@@ -91,8 +92,8 @@ export function wikiLinksToMarkdown(content: string, items: Item[], workspaceSlu
* Reverses wikiLinksToMarkdown() so we store [[]] not []() in the database.
*/
export function markdownToWikiLinks(markdown: string, items: Item[]): string {
// Match [Title](/workspace/collection/slug-or-REF) pattern
return markdown.replace(/\[([^\]]+)\]\(\/[^/]+\/[^/]+\/([^)]+)\)/g, (_match, title: string, slugOrRef: string) => {
// Match [Title](/username/workspace/collection/slug-or-REF) or [Title](/workspace/collection/slug-or-REF) pattern
return markdown.replace(/\[([^\]]+)\]\(\/(?:[^/]+\/){2,3}([^)]+)\)/g, (_match, title: string, slugOrRef: string) => {
const item = items.find(i => {
if (i.slug === slugOrRef) return true;
// Also match PREFIX-NUMBER refs
+1 -1
View File
@@ -159,7 +159,7 @@
<rect y="15" width="20" height="2" rx="1" fill="currentColor"/>
</svg>
</button>
<a href="/{workspaceStore.current?.slug ?? ''}" class="mobile-title">{workspaceStore.current?.name ?? 'Pad'}</a>
<a href="/{workspaceStore.current?.owner_username ?? ''}/${workspaceStore.current?.slug ?? ''}" class="mobile-title">{workspaceStore.current?.name ?? 'Pad'}</a>
</div>
{/if}
{@render children()}
+1 -1
View File
@@ -11,7 +11,7 @@
const sorted = [...ws].sort((a, b) =>
new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
);
goto(`/${sorted[0].slug}`, { replaceState: true });
goto(`/${sorted[0].owner_username}/${sorted[0].slug}`, { replaceState: true });
}
});
</script>
@@ -12,6 +12,7 @@
let { children } = $props();
let wsSlug = $derived(page.params.workspace ?? '');
let username = $derived(page.params.username ?? '');
let unsubscribeSSE: (() => void) | null = null;
let unsubscribeSync: (() => void) | null = null;
@@ -68,7 +69,7 @@
}
if (isExternal) {
const who = event.actor === 'agent' ? 'Agent' : (event.actor_name || 'CLI');
const link = event.collection ? `/${wsSlug}/${event.collection}/${event.item_id}` : undefined;
const link = event.collection ? `/${username}/${wsSlug}/${event.collection}/${event.item_id}` : undefined;
toastStore.show(`${who} created: ${event.title}`, 'info', 4000, link);
}
break;
@@ -11,6 +11,7 @@
import type { DashboardResponse, Collection } from '$lib/types';
let wsSlug = $derived(page.params.workspace ?? '');
let username = $derived(page.params.username ?? '');
let loading = $state(true);
let dashboard = $state<DashboardResponse | null>(null);
@@ -183,7 +184,7 @@
<!-- 2. Onboarding -->
{#if totalItems === 0 && !onboardingDismissed}
<div class="onboarding-wrapper">
<OnboardingChecklist {wsSlug} byCollection={dashboard.summary.by_collection} ondismiss={dismissOnboarding} />
<OnboardingChecklist {wsSlug} {username} byCollection={dashboard.summary.by_collection} ondismiss={dismissOnboarding} />
</div>
{:else if totalItems === 0 && onboardingDismissed}
<div class="onboarding-reshow">
@@ -200,7 +201,7 @@
</div>
<div class="active-grid">
{#each dashboard.active_items as item (item.slug)}
<a href="/{wsSlug}/{item.collection_slug}/{item.slug}" class="active-card">
<a href="/{username}/{wsSlug}/{item.collection_slug}/{item.slug}" class="active-card">
<div class="active-card-top">
{#if item.item_ref}
<span class="active-ref">{item.item_ref}</span>
@@ -231,7 +232,7 @@
</div>
<div class="plan-list">
{#each dashboard.active_plans as plan (plan.slug)}
<a href="/{wsSlug}/plans/{plan.slug}" class="plan-row">
<a href="/{username}/{wsSlug}/plans/{plan.slug}" class="plan-row">
<span class="plan-title">{plan.title}</span>
<div class="progress-bar">
<div class="progress-fill" style="width: {plan.progress}%"></div>
@@ -252,7 +253,7 @@
{#each collections as coll (coll.slug)}
{@const breakdown = dashboard.summary.by_collection[coll.name] ?? dashboard.summary.by_collection[coll.slug] ?? {}}
{@const prog = collProgress(coll)}
<a href="/{wsSlug}/{coll.slug}" class="coll-card">
<a href="/{username}/{wsSlug}/{coll.slug}" class="coll-card">
<div class="coll-card-header">
<span class="coll-card-name">
{#if coll.icon}<span class="coll-icon">{coll.icon}</span>{/if}
@@ -278,7 +279,7 @@
</div>
</a>
{/each}
<a href="/{wsSlug}/settings" class="coll-card coll-card-new">
<a href="/{username}/{wsSlug}/settings" class="coll-card coll-card-new">
<div class="coll-card-header">
<span class="coll-card-name">
<span class="coll-icon">+</span>
@@ -306,7 +307,7 @@
<div class="attention-card">
<span class="attention-icon">{attentionIcon(alert.type)}</span>
<div class="attention-content">
<a href="/{wsSlug}/{alert.collection}/{alert.item_slug}" class="attention-title">{alert.item_title}</a>
<a href="/{username}/{wsSlug}/{alert.collection}/{alert.item_slug}" class="attention-title">{alert.item_title}</a>
<span class="attention-reason">{alert.reason}</span>
</div>
</div>
@@ -324,7 +325,7 @@
<div class="suggested-card">
<span class="sug-num">{i + 1}</span>
<div class="sug-content">
<a href="/{wsSlug}/{sug.collection}/{sug.item_slug}" class="sug-title">{sug.item_title}</a>
<a href="/{username}/{wsSlug}/{sug.collection}/{sug.item_slug}" class="sug-title">{sug.item_title}</a>
<span class="sug-reason">{sug.reason}</span>
</div>
</div>
@@ -340,7 +341,7 @@
<section class="section">
<div class="section-header">
<span class="section-label">Recent Activity</span>
<a href="/{wsSlug}/activity" class="section-link">View all</a>
<a href="/{username}/{wsSlug}/activity" class="section-link">View all</a>
</div>
<div class="activity-list">
{#each dashboard.recent_activity.slice(0, 10) as activity, i (i)}
@@ -356,7 +357,7 @@
<span class="activity-dot" style="color: {activity.action === 'created' ? 'var(--accent-green)' : activity.action === 'archived' ? 'var(--text-muted)' : 'var(--accent-blue)'};">{activityIcon(activity.action)}</span>
<span class="activity-verb">{activityVerb(activity.action)}</span>
{#if activity.item_title}
<a href="/{wsSlug}/{activity.collection_slug}/{activity.item_slug}" class="activity-item">{activity.item_title}</a>
<a href="/{username}/{wsSlug}/{activity.collection_slug}/{activity.item_slug}" class="activity-item">{activity.item_title}</a>
{/if}
{#if changes}
<span class="activity-changes">{changes}</span>
@@ -36,6 +36,7 @@
let saveViewInput = $state<HTMLInputElement>();
let wsSlug = $derived(page.params.workspace ?? '');
let username = $derived(page.params.username ?? '');
let collSlug = $derived(page.params.collection ?? '');
// Persist view mode to localStorage per collection
@@ -64,7 +65,7 @@
}
if (searchQuery) params.set('q', searchQuery);
const qs = params.toString();
const newUrl = `/${wsSlug}/${collSlug}${qs ? '?' + qs : ''}`;
const newUrl = `/${username}/${wsSlug}/${collSlug}${qs ? '?' + qs : ''}`;
goto(newUrl, { replaceState: true, noScroll: true, keepFocus: true });
}
@@ -465,7 +466,7 @@
fields: JSON.stringify(defaultFields),
source: 'web'
});
goto(`/${wsSlug}/${collSlug}/${itemUrlId(item)}?new=1`);
goto(`/${username}/${wsSlug}/${collSlug}/${itemUrlId(item)}?new=1`);
} catch (err: any) {
toastStore.show(err?.message || 'Failed to create item', 'error');
} finally {
@@ -564,7 +565,7 @@
if (focusedIndex >= 0 && focusedIndex < filteredItems.length) {
e.preventDefault();
const item = filteredItems[focusedIndex];
goto(`/${wsSlug}/${collSlug}/${itemUrlId(item)}`);
goto(`/${username}/${wsSlug}/${collSlug}/${itemUrlId(item)}`);
}
break;
case 'Escape':
@@ -36,6 +36,7 @@
};
let wsSlug = $derived(page.params.workspace ?? '');
let username = $derived(page.params.username ?? '');
let collSlug = $derived(page.params.collection ?? '');
let itemSlug = $derived(page.params.slug ?? '');
@@ -62,7 +63,7 @@
const raw = item.content ?? '';
const allItems = collectionStore.items ?? [];
if (allItems.length > 0 && raw.includes('[[')) {
return wikiLinksToMarkdown(raw, allItems, wsSlug);
return wikiLinksToMarkdown(raw, allItems, wsSlug, username);
}
return raw;
});
@@ -113,8 +114,8 @@
// Check if our item was deleted
if (result.changes.deleted.includes(item!.id)) {
// Item was deleted — navigate back to collection
goto(`/${wsSlug}/${collSlug}`);
}
goto(`/${username}/${wsSlug}/${collSlug}`);
}
return;
}
@@ -193,7 +194,7 @@
// Auto-start title editing for newly created items
if (page.url.searchParams.get('new') === '1' && item) {
// Clean up the URL param first, then focus title after DOM settles
goto(`/${wsSlug}/${collSlug}/${itemSlug}`, { replaceState: true, noScroll: true });
goto(`/${username}/${wsSlug}/${collSlug}/${itemSlug}`, { replaceState: true, noScroll: true });
await startEditTitle();
}
}
@@ -389,7 +390,7 @@
function relationHref(collectionSlug?: string, refOrSlug?: string): string | null {
if (!collectionSlug || !refOrSlug) return null;
return `/${wsSlug}/${collectionSlug}/${refOrSlug}`;
return `/${username}/${wsSlug}/${collectionSlug}/${refOrSlug}`;
}
function linkEntry(link: ItemLink, useSource: boolean): RelationshipEntry {
@@ -499,7 +500,7 @@
try {
await api.items.delete(wsSlug, item.id);
toastStore.show('Item deleted', 'success');
goto(`/${wsSlug}/${collSlug}`);
goto(`/${username}/${wsSlug}/${collSlug}`);
} catch {
toastStore.show('Failed to delete item', 'error');
deleting = false;
@@ -579,7 +580,7 @@
try {
const moved = await api.items.move(wsSlug, item.slug, targetSlug);
toastStore.show(`Moved to ${targetSlug}`, 'success');
goto(`/${wsSlug}/${targetSlug}/${moved.slug}`);
goto(`/${username}/${wsSlug}/${targetSlug}/${moved.slug}`);
} catch (e: any) {
toastStore.show(e.message ?? 'Failed to move item', 'error');
} finally {
@@ -596,9 +597,9 @@
<div class="item-page">
<!-- Breadcrumb -->
<nav class="breadcrumb">
<a href="/{wsSlug}">Home</a>
<a href="/{username}/{wsSlug}">Home</a>
<span class="sep">/</span>
<a href="/{wsSlug}/{collSlug}">{collection.icon} {collection.name}</a>
<a href="/{username}/{wsSlug}/{collSlug}">{collection.icon} {collection.name}</a>
<span class="sep">/</span>
<span class="current">{formatItemRef(item) || item.title}</span>
</nav>
@@ -920,7 +921,7 @@
<!-- Child Items: always mounted so SSE subscriptions stay active even when starting with 0 children -->
{#if item}
<ChildItems {wsSlug} {itemSlug} itemId={item.id} parentFields={fields} terminalStatuses={childTerminalStatuses} onChildrenChange={handleChildrenChange} />
<ChildItems {wsSlug} {username} {itemSlug} itemId={item.id} parentFields={fields} terminalStatuses={childTerminalStatuses} onChildrenChange={handleChildrenChange} />
{/if}
<!-- Unified Timeline (comments + activity + versions) -->
@@ -7,6 +7,7 @@
import type { Activity, Collection } from '$lib/types';
let wsSlug = $derived(page.params.workspace ?? '');
let username = $derived(page.params.username ?? '');
// Data
let activities = $state<Activity[]>([]);
@@ -317,7 +318,7 @@
<span class="entry-verb">{activityVerb(activity.action)}</span>
{#if itemTitle && itemSlug && collSlug}
<a
href="/{wsSlug}/{collSlug}/{itemSlug}"
href="/{username}/{wsSlug}/{collSlug}/{itemSlug}"
class="entry-item-link">{itemTitle}</a
>
{:else if itemTitle}
@@ -26,6 +26,7 @@
const ENFORCEMENT_LEVELS = ['must','should','nice-to-have'] as const;
let workspace = $derived(page.params.workspace ?? '');
let username = $derived(page.params.username ?? '');
let conventions = $state<Item[]>([]);
let loading = $state(true);
let expandedSlug = $state<string | null>(null);
@@ -301,7 +302,7 @@
<p class="subtitle">Rules that guide agent behavior in this project</p>
</div>
<div class="header-actions">
<a href="/{workspace}/library" class="btn btn-secondary">Browse Library</a>
<a href="/{username}/{workspace}/library" class="btn btn-secondary">Browse Library</a>
<button class="btn btn-primary" onclick={() => (showCreate = !showCreate)}>
{showCreate ? 'Cancel' : '+ New Convention'}
</button>
@@ -4,8 +4,9 @@
// Dashboard is now the workspace home page — redirect there
let wsSlug = $derived(page.params.workspace ?? '');
let username = $derived(page.params.username ?? '');
$effect(() => {
if (wsSlug) goto(`/${wsSlug}`, { replaceState: true });
if (wsSlug) goto(`/${username}/${wsSlug}`, { replaceState: true });
});
</script>
@@ -4,6 +4,7 @@
import type { LibraryCategory, LibraryConvention, PlaybookCategory, LibraryPlaybook, Item } from '$lib/types';
let wsSlug = $derived(page.params.workspace ?? '');
let username = $derived(page.params.username ?? '');
let categories = $state<LibraryCategory[]>([]);
let playbookCategories = $state<PlaybookCategory[]>([]);
@@ -9,6 +9,7 @@
const SCOPES = ['all', 'backend', 'frontend', 'mobile', 'devops'] as const;
const STATUS_ORDER: Record<string, number> = { active: 0, draft: 1, deprecated: 2 };
let wsSlug = $derived(page.params.workspace ?? '');
let username = $derived(page.params.username ?? '');
let playbooks = $state<Item[]>([]);
let loading = $state(true);
let expandedId = $state<string | null>(null);
@@ -145,7 +146,7 @@
</div>
{#if !showNewForm}
<div style="display:flex;gap:var(--space-2);align-items:center;">
<a href="/{wsSlug}/library?tab=playbooks" class="new-btn" style="background:var(--bg-secondary);color:var(--text-primary);border:1px solid var(--border);">📚 Browse Library</a>
<a href="/{username}/{wsSlug}/library?tab=playbooks" class="new-btn" style="background:var(--bg-secondary);color:var(--text-primary);border:1px solid var(--border);">📚 Browse Library</a>
<button class="new-btn" onclick={() => (showNewForm = true)}>+ New Playbook</button>
</div>
{/if}
@@ -264,7 +265,7 @@
</div>
<div class="card-divider"></div>
<div class="card-actions">
<button class="action-btn" onclick={() => goto(`/${wsSlug}/playbooks/${itemUrlId(item)}`)}>Edit</button>
<button class="action-btn" onclick={() => goto(`/${username}/${wsSlug}/playbooks/${itemUrlId(item)}`)}>Edit</button>
<button class="action-btn" disabled={togglingStatus === item.slug} onclick={() => toggleStatus(item)}>
{togglingStatus === item.slug ? '...' : nextStatusLabel(status)}
</button>
@@ -13,6 +13,7 @@
import type { DndEvent } from 'svelte-dnd-action';
let wsSlug = $derived(page.params.workspace ?? '');
let username = $derived(page.params.username ?? '');
// Data
let lanes = $state<RoleBoardLane[]>([]);
@@ -577,7 +578,7 @@
{#if coll}
<ItemCard {item} collection={coll} compact={true} showCollection={true} />
{:else}
<a href="/{wsSlug}/{item.collection_slug}/{itemUrlId(item)}" class="fallback-card">
<a href="/{username}/{wsSlug}/{item.collection_slug}/{itemUrlId(item)}" class="fallback-card">
<span class="card-title">{item.title}</span>
</a>
{/if}
@@ -13,6 +13,7 @@
import { copyToClipboard } from '$lib/utils/clipboard';
let wsSlug = $derived(page.params.workspace ?? '');
let username = $derived(page.params.username ?? '');
let loading = $state(true);
let collections = $state<Collection[]>([]);
let wsName = $state('');