diff --git a/internal/models/workspace.go b/internal/models/workspace.go index cca79296..a17e1097 100644 --- a/internal/models/workspace.go +++ b/internal/models/workspace.go @@ -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"` diff --git a/internal/store/workspace_members.go b/internal/store/workspace_members.go index 47729fb2..96adb6d5 100644 --- a/internal/store/workspace_members.go +++ b/internal/store/workspace_members.go @@ -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 { diff --git a/internal/store/workspaces.go b/internal/store/workspaces.go index 1b82af0c..c409c0bf 100644 --- a/internal/store/workspaces.go +++ b/internal/store/workspaces.go @@ -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) diff --git a/web/src/lib/components/ChildItems.svelte b/web/src/lib/components/ChildItems.svelte index 543660fb..95377b07 100644 --- a/web/src/lib/components/ChildItems.svelte +++ b/web/src/lib/components/ChildItems.svelte @@ -14,6 +14,7 @@ interface Props { wsSlug: string; + username?: string; itemSlug: string; itemId: string; parentFields?: Record; @@ -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 @@ {/if} - + {formatItemRef(child) ?? ''} {child.title} {#if fields.priority} @@ -237,7 +238,7 @@ {#if canExpand && isExpanded} - + {/if} {/each} diff --git a/web/src/lib/components/NestedChildren.svelte b/web/src/lib/components/NestedChildren.svelte index 5905b3c2..d1769f48 100644 --- a/web/src/lib/components/NestedChildren.svelte +++ b/web/src/lib/components/NestedChildren.svelte @@ -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} {/if} - + {formatItemRef(child) ?? ''} {child.title} @@ -88,7 +89,7 @@ {/if} {#if canExpand && isExpanded} - + {/if} {/each} diff --git a/web/src/lib/components/OnboardingChecklist.svelte b/web/src/lib/components/OnboardingChecklist.svelte index b6535920..1ef1ae46 100644 --- a/web/src/lib/components/OnboardingChecklist.svelte +++ b/web/src/lib/components/OnboardingChecklist.svelte @@ -3,11 +3,12 @@ interface Props { wsSlug: string; + username?: string; byCollection: Record>; ondismiss?: () => void; } - let { wsSlug, byCollection, ondismiss }: Props = $props(); + let { wsSlug, username = '', byCollection, ondismiss }: Props = $props(); let copiedHint = $state(null); @@ -33,25 +34,25 @@ let steps = $derived([ { 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 @@ - Or browse the library for conventions and playbooks + Or browse the library for conventions and playbooks diff --git a/web/src/lib/components/collections/BoardView.svelte b/web/src/lib/components/collections/BoardView.svelte index fd6adfce..e73cf0a1 100644 --- a/web/src/lib/components/collections/BoardView.svelte +++ b/web/src/lib/components/collections/BoardView.svelte @@ -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(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(null); let dragOverColumn = $state(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)} -
+
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( diff --git a/web/src/lib/components/collections/TableView.svelte b/web/src/lib/components/collections/TableView.svelte index 4a334ce4..4ad61484 100644 --- a/web/src/lib/components/collections/TableView.svelte +++ b/web/src/lib/components/collections/TableView.svelte @@ -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 @@ {formatItemRef(item) ?? ''} - {item.title} + {item.title} {#if itemProgress?.[item.id]} {@const p = itemProgress[item.id]}
diff --git a/web/src/lib/components/layout/CreateWorkspaceModal.svelte b/web/src/lib/components/layout/CreateWorkspaceModal.svelte index 788f441d..9afa8e4b 100644 --- a/web/src/lib/components/layout/CreateWorkspaceModal.svelte +++ b/web/src/lib/components/layout/CreateWorkspaceModal.svelte @@ -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 { diff --git a/web/src/lib/components/layout/Sidebar.svelte b/web/src/lib/components/layout/Sidebar.svelte index 4282b463..d76320f6 100644 --- a/web/src/lib/components/layout/Sidebar.svelte +++ b/web/src/lib/components/layout/Sidebar.svelte @@ -22,13 +22,15 @@ let quickAddInputEl = $state(); 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}
{#each agentCollections as collection (collection.id)} uiStore.onNavigate()} @@ -410,7 +412,7 @@