mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 21:39:01 +00:00
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:
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface Workspace {
|
||||
name: string;
|
||||
slug: string;
|
||||
owner_id?: string;
|
||||
owner_username?: string;
|
||||
description: string;
|
||||
settings: string;
|
||||
sort_order: number;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+2
-1
@@ -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;
|
||||
+10
-9
@@ -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>
|
||||
+4
-3
@@ -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':
|
||||
+11
-10
@@ -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) -->
|
||||
+2
-1
@@ -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}
|
||||
+2
-1
@@ -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>
|
||||
+2
-1
@@ -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>
|
||||
+1
@@ -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[]>([]);
|
||||
+3
-2
@@ -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>
|
||||
+2
-1
@@ -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}
|
||||
+1
@@ -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('');
|
||||
Reference in New Issue
Block a user