mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-22 10:33:27 +00:00
Board view improvements: independent scrolling, unified cards, lane reorder, new-item modal (#61)
* feat: independent board scrolling, unified card style, lane reordering, and new-item modal - BoardView: switch from CSS grid to flex layout with independent per-column scrolling, matching the Roles board UX - ItemCard: redesign to match Roles board card style — top row with optional collection badge + ref, compact meta row with colored status/priority text - Roles board: replace inline card markup with shared ItemCard component, add HTML5 drag-and-drop lane reordering (persisted via new API endpoint), rename "Highlight Mine" to "Mine", add "+ New" button with collection picker modal - Backend: add PUT /roles/board/lane-order endpoint and UpdateAgentRoleOrder store method for batch role sort_order updates - Collection page: board view now fills viewport height so columns have bounded scroll areas * fix: resolve svelte-check type error and remove unused CSS selectors - Add null guard on lane.role in openEditModal onclick - Remove unused .role-edit-actions, .role-btn-create, .role-btn-cancel CSS * fix: correct lane reorder insert index when dragging forward After splicing out the source lane, downstream indices shift left by one. Adjust the insert index when srcIdx < dstIdx to place the lane at the correct drop target position.
This commit is contained in:
@@ -27,6 +27,27 @@ func (s *Server) handleRoleBoardReorder(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// handleRoleBoardLaneReorder updates sort_order for roles (lane ordering).
|
||||
func (s *Server) handleRoleBoardLaneReorder(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID, ok := s.getWorkspaceID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var updates []store.RoleOrderUpdate
|
||||
if err := decodeJSON(r, &updates); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.UpdateAgentRoleOrder(workspaceID, updates); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// handleRoleBoard returns items across all collections grouped by agent role.
|
||||
// This powers the standalone role board page in the web UI.
|
||||
func (s *Server) handleRoleBoard(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -250,6 +250,7 @@ func (s *Server) setupRouter() {
|
||||
// Role Board (cross-collection role-based view)
|
||||
r.Get("/roles/board", s.handleRoleBoard)
|
||||
r.Put("/roles/board/reorder", s.handleRoleBoardReorder)
|
||||
r.Put("/roles/board/lane-order", s.handleRoleBoardLaneReorder)
|
||||
|
||||
// Agent Roles
|
||||
r.Route("/agent-roles", func(r chi.Router) {
|
||||
|
||||
@@ -385,6 +385,36 @@ func (s *Store) GetRoleBoardItems(workspaceID string, params RoleBoardParams) ([
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// RoleOrderUpdate represents a single role's sort_order update for lane reordering.
|
||||
type RoleOrderUpdate struct {
|
||||
RoleID string `json:"role_id"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
// UpdateAgentRoleOrder batch-updates sort_order for a list of roles.
|
||||
func (s *Store) UpdateAgentRoleOrder(workspaceID string, updates []RoleOrderUpdate) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
stmt, err := tx.Prepare("UPDATE agent_roles SET sort_order = ?, updated_at = ? WHERE id = ? AND workspace_id = ?")
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare role order update: %w", err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
ts := now()
|
||||
for _, u := range updates {
|
||||
if _, err := stmt.Exec(u.SortOrder, ts, u.RoleID, workspaceID); err != nil {
|
||||
return fmt.Errorf("update sort order for role %s: %w", u.RoleID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// RoleSortUpdate represents a single item's role_sort_order update.
|
||||
type RoleSortUpdate struct {
|
||||
ItemID string `json:"item_id"`
|
||||
|
||||
@@ -188,6 +188,11 @@ export const api = {
|
||||
request<void>(`/workspaces/${ws}/roles/board/reorder`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updates)
|
||||
}),
|
||||
reorderLanes: (ws: string, updates: { role_id: string; sort_order: number }[]) =>
|
||||
request<void>(`/workspaces/${ws}/roles/board/lane-order`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updates)
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
@@ -177,7 +177,7 @@
|
||||
{#if items.length === 0}
|
||||
<EmptyState {collection} {wsSlug} {oncreate} />
|
||||
{:else}
|
||||
<div class="board-view" style:--col-count={columnOrder.length}>
|
||||
<div class="board-view">
|
||||
{#each columnOrder as colValue (colValue)}
|
||||
{@const colItems = columnData[colValue] ?? []}
|
||||
<div
|
||||
@@ -259,15 +259,18 @@
|
||||
|
||||
<style>
|
||||
.board-view {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--col-count, 3), 1fr);
|
||||
display: flex;
|
||||
gap: var(--space-5);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.kanban-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1 0 0;
|
||||
min-width: 220px;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
@@ -289,6 +292,7 @@
|
||||
font-weight: 600;
|
||||
font-size: 0.9em;
|
||||
cursor: grab;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.column-header:active {
|
||||
@@ -413,6 +417,8 @@
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-2);
|
||||
transition: background 0.15s ease;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.column-cards:global(.drop-target) {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
collection: Collection;
|
||||
compact?: boolean;
|
||||
focused?: boolean;
|
||||
showCollection?: boolean;
|
||||
statusOptions?: string[];
|
||||
onStatusClick?: (item: Item, newStatus: string) => void;
|
||||
progress?: { total: number; done: number } | null;
|
||||
@@ -15,7 +16,7 @@
|
||||
relationLabels?: Record<string, string>;
|
||||
}
|
||||
|
||||
let { item, collection, compact = false, focused = false, statusOptions, onStatusClick, progress = null, progressLabel = 'tasks', relationLabels = {} }: Props = $props();
|
||||
let { item, collection, compact = false, focused = false, showCollection = false, statusOptions, onStatusClick, progress = null, progressLabel = 'tasks', relationLabels = {} }: Props = $props();
|
||||
|
||||
let wsSlug = $derived(page.params.workspace ?? '');
|
||||
let fields = $derived(parseFields(item));
|
||||
@@ -46,20 +47,6 @@
|
||||
onStatusClick(item, nextStatus);
|
||||
}
|
||||
|
||||
function relativeTime(dateStr: string): string {
|
||||
const now = Date.now();
|
||||
const then = new Date(dateStr).getTime();
|
||||
const diff = now - then;
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return 'just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days}d ago`;
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
|
||||
function statusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'open': return 'var(--text-secondary)';
|
||||
@@ -86,44 +73,56 @@
|
||||
</script>
|
||||
|
||||
<a href={itemUrl} class="item-card" class:compact class:focused>
|
||||
<div class="card-title">
|
||||
<div class="card-top-row">
|
||||
{#if showCollection && item.collection_name}
|
||||
<span class="collection-badge">
|
||||
{#if item.collection_icon}{item.collection_icon} {/if}{item.collection_name}
|
||||
</span>
|
||||
{/if}
|
||||
{#if itemRef}<span class="item-ref">{itemRef}</span>{/if}
|
||||
</div>
|
||||
|
||||
<div class="card-title">
|
||||
{item.title}
|
||||
</div>
|
||||
|
||||
<div class="card-badges">
|
||||
<div class="card-meta">
|
||||
{#if statusField && fields.status}
|
||||
{#if statusCyclable}
|
||||
<button
|
||||
class="badge status-badge status-btn"
|
||||
class="meta-status meta-status-btn"
|
||||
class:pulsing
|
||||
style:--badge-color={statusColor(fields.status)}
|
||||
style:color={statusColor(fields.status)}
|
||||
onclick={cycleStatus}
|
||||
title="Click to cycle status"
|
||||
>
|
||||
{formatLabel(fields.status)}
|
||||
{formatLabel(fields.status).toUpperCase()}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="badge status-badge" style:--badge-color={statusColor(fields.status)}>
|
||||
{formatLabel(fields.status)}
|
||||
<span class="meta-status" style:color={statusColor(fields.status)}>
|
||||
{formatLabel(fields.status).toUpperCase()}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if priorityField && fields.priority}
|
||||
<span class="badge priority-badge" style:--badge-color={priorityColor(fields.priority)}>
|
||||
{#if statusField && fields.status}<span class="meta-sep">·</span>{/if}
|
||||
<span class="meta-priority" style:color={priorityColor(fields.priority)}>
|
||||
{formatLabel(fields.priority)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if fields.phase && relationLabels[fields.phase]}
|
||||
<span class="badge phase-badge">
|
||||
{relationLabels[fields.phase]}
|
||||
</span>
|
||||
<span class="meta-sep">·</span>
|
||||
<span class="meta-phase">{relationLabels[fields.phase]}</span>
|
||||
{/if}
|
||||
{#if item.agent_role_name}
|
||||
<span class="badge role-badge">
|
||||
<span class="meta-sep">·</span>
|
||||
<span class="meta-role">
|
||||
{#if item.agent_role_icon}{item.agent_role_icon} {/if}{item.agent_role_name}
|
||||
</span>
|
||||
{/if}
|
||||
{#if item.assigned_user_name}
|
||||
<span class="meta-assignee">{item.assigned_user_name}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if progress && progress.total > 0}
|
||||
@@ -134,23 +133,16 @@
|
||||
<span class="card-progress-text">{progress.done}/{progress.total} {progressLabel}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="card-footer">
|
||||
{#if item.assigned_user_name}
|
||||
<span class="assignee">{item.assigned_user_name}</span>
|
||||
{/if}
|
||||
<span class="updated" title={new Date(item.updated_at).toLocaleString()}>{relativeTime(item.updated_at)}</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.item-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
background: var(--bg-secondary);
|
||||
gap: var(--space-2);
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
@@ -172,92 +164,120 @@
|
||||
padding: var(--space-3) var(--space-4);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 0.95em;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.45;
|
||||
font-weight: 500;
|
||||
.card-top-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.collection-badge {
|
||||
background: var(--bg-tertiary);
|
||||
padding: 1px 7px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.7em;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.item-ref {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8em;
|
||||
font-size: 0.75em;
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
margin-right: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 0.95em;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.45;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.compact .card-title {
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
.card-badges {
|
||||
.card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
row-gap: var(--space-2);
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.8em;
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
.meta-status {
|
||||
font-size: 0.7em;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
color: var(--badge-color);
|
||||
background: color-mix(in srgb, var(--badge-color) 15%, transparent);
|
||||
}
|
||||
|
||||
.status-btn {
|
||||
.meta-status-btn {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
line-height: inherit;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
line-height: inherit;
|
||||
transition: filter 0.1s, transform 0.1s;
|
||||
}
|
||||
|
||||
.status-btn:hover {
|
||||
.meta-status-btn:hover {
|
||||
filter: brightness(1.3);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.status-btn:active {
|
||||
.meta-status-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.status-btn.pulsing {
|
||||
.meta-status-btn.pulsing {
|
||||
animation: status-pulse 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes status-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--badge-color) 50%, transparent);
|
||||
text-shadow: 0 0 0 currentColor;
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 6px color-mix(in srgb, var(--badge-color) 0%, transparent);
|
||||
text-shadow: 0 0 8px currentColor;
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--badge-color) 0%, transparent);
|
||||
text-shadow: 0 0 0 currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
font-size: 0.8em;
|
||||
color: var(--accent-teal, var(--accent-blue));
|
||||
background: color-mix(in srgb, var(--accent-teal, var(--accent-blue)) 12%, transparent);
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
.meta-sep {
|
||||
font-size: 0.7em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.meta-priority {
|
||||
font-size: 0.7em;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.phase-badge {
|
||||
font-size: 0.8em;
|
||||
.meta-phase {
|
||||
font-size: 0.7em;
|
||||
font-weight: 500;
|
||||
color: var(--accent-purple, var(--text-secondary));
|
||||
background: color-mix(in srgb, var(--accent-purple, var(--text-secondary)) 12%, transparent);
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta-role {
|
||||
font-size: 0.7em;
|
||||
font-weight: 500;
|
||||
color: var(--accent-teal, var(--accent-blue));
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta-assignee {
|
||||
font-size: 0.7em;
|
||||
font-weight: 500;
|
||||
color: var(--accent-blue);
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -285,26 +305,4 @@
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.assignee {
|
||||
font-size: 0.8em;
|
||||
color: var(--accent-blue);
|
||||
background: color-mix(in srgb, var(--accent-blue) 15%, transparent);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.updated {
|
||||
font-size: 0.75em;
|
||||
color: var(--text-muted);
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -896,6 +896,13 @@
|
||||
.collection-page.board-active {
|
||||
max-width: none;
|
||||
padding: var(--space-6) var(--space-6);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.board-active .page-header {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.loading {
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api/client';
|
||||
import { workspaceStore } from '$lib/stores/workspace.svelte';
|
||||
import { collectionStore } from '$lib/stores/collections.svelte';
|
||||
import { uiStore } from '$lib/stores/ui.svelte';
|
||||
import { parseFields, formatItemRef, itemUrlId } from '$lib/types';
|
||||
import type { Item, RoleBoardLane, AgentRole } from '$lib/types';
|
||||
import { itemUrlId } from '$lib/types';
|
||||
import type { Item, Collection, RoleBoardLane, AgentRole } from '$lib/types';
|
||||
import ItemCard from '$lib/components/collections/ItemCard.svelte';
|
||||
import { dndzone, TRIGGERS, SHADOW_ITEM_MARKER_PROPERTY_NAME } from 'svelte-dnd-action';
|
||||
import type { DndEvent } from 'svelte-dnd-action';
|
||||
|
||||
@@ -19,6 +21,60 @@
|
||||
// Highlight: dim cards not assigned to current user
|
||||
let highlightMine = $state(false);
|
||||
|
||||
// New item modal state
|
||||
let newItemDialogEl = $state<HTMLDialogElement | null>(null);
|
||||
let newItemCollectionSlug = $state('');
|
||||
let newItemTitle = $state('');
|
||||
let newItemSaving = $state(false);
|
||||
|
||||
let eligibleCollections = $derived(
|
||||
collectionStore.collections.filter(c => !['conventions', 'playbooks'].includes(c.slug))
|
||||
);
|
||||
|
||||
function openNewItem() {
|
||||
newItemCollectionSlug = '';
|
||||
newItemTitle = '';
|
||||
newItemDialogEl?.showModal();
|
||||
}
|
||||
|
||||
function closeNewItem() {
|
||||
newItemDialogEl?.close();
|
||||
newItemCollectionSlug = '';
|
||||
newItemTitle = '';
|
||||
}
|
||||
|
||||
function selectCollection(slug: string) {
|
||||
newItemCollectionSlug = slug;
|
||||
// Focus the title input after selection
|
||||
requestAnimationFrame(() => {
|
||||
const input = newItemDialogEl?.querySelector<HTMLInputElement>('.new-item-title-input');
|
||||
input?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
async function submitNewItem() {
|
||||
if (!newItemTitle.trim() || !newItemCollectionSlug || newItemSaving) return;
|
||||
newItemSaving = true;
|
||||
try {
|
||||
await api.items.create(wsSlug, newItemCollectionSlug, {
|
||||
title: newItemTitle.trim()
|
||||
});
|
||||
closeNewItem();
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
console.error('Failed to create item:', err);
|
||||
} finally {
|
||||
newItemSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleNewItemKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submitNewItem();
|
||||
}
|
||||
}
|
||||
|
||||
// Role editing modal state
|
||||
let dialogEl = $state<HTMLDialogElement | null>(null);
|
||||
let dialogMode = $state<'edit' | 'create'>('create');
|
||||
@@ -67,6 +123,71 @@
|
||||
const touchDragDelayMs = 500;
|
||||
let isDragging = $state(false);
|
||||
|
||||
// Lane (header) drag-and-drop state
|
||||
let draggedLaneKey = $state<string | null>(null);
|
||||
let dragOverLaneKey = $state<string | null>(null);
|
||||
|
||||
function handleLaneDragStart(e: DragEvent, key: string) {
|
||||
if (key === '__unassigned') { e.preventDefault(); return; }
|
||||
draggedLaneKey = key;
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', key);
|
||||
}
|
||||
}
|
||||
|
||||
function handleLaneDragOver(e: DragEvent, key: string) {
|
||||
if (!draggedLaneKey || key === draggedLaneKey || key === '__unassigned') return;
|
||||
e.preventDefault();
|
||||
dragOverLaneKey = key;
|
||||
}
|
||||
|
||||
function handleLaneDragLeave() {
|
||||
dragOverLaneKey = null;
|
||||
}
|
||||
|
||||
async function handleLaneDrop(e: DragEvent, key: string) {
|
||||
e.preventDefault();
|
||||
if (!draggedLaneKey || key === '__unassigned') { draggedLaneKey = null; dragOverLaneKey = null; return; }
|
||||
|
||||
// Reorder the assigned lanes (skip unassigned)
|
||||
const assignedLanes = lanes.filter((l) => l.role);
|
||||
const srcIdx = assignedLanes.findIndex((l) => l.role!.id === draggedLaneKey);
|
||||
const dstIdx = assignedLanes.findIndex((l) => l.role!.id === key);
|
||||
|
||||
if (srcIdx >= 0 && dstIdx >= 0 && srcIdx !== dstIdx) {
|
||||
const [moved] = assignedLanes.splice(srcIdx, 1);
|
||||
// After removing from srcIdx, indices shift left — adjust if moving forward
|
||||
const insertIdx = srcIdx < dstIdx ? dstIdx - 1 : dstIdx;
|
||||
assignedLanes.splice(insertIdx, 0, moved);
|
||||
|
||||
// Rebuild lanes with new order
|
||||
const unassigned = lanes.filter((l) => !l.role);
|
||||
lanes = [...unassigned, ...assignedLanes];
|
||||
|
||||
// Persist new sort order
|
||||
const updates = assignedLanes.map((lane, i) => ({
|
||||
role_id: lane.role!.id,
|
||||
sort_order: i
|
||||
}));
|
||||
|
||||
try {
|
||||
await api.agentRoles.reorderLanes(wsSlug, updates);
|
||||
} catch (err) {
|
||||
console.error('Failed to persist lane order:', err);
|
||||
await loadData();
|
||||
}
|
||||
}
|
||||
|
||||
draggedLaneKey = null;
|
||||
dragOverLaneKey = null;
|
||||
}
|
||||
|
||||
function handleLaneDragEnd() {
|
||||
draggedLaneKey = null;
|
||||
dragOverLaneKey = null;
|
||||
}
|
||||
|
||||
// Mutable lane data for DnD — keyed by role ID (or '__unassigned')
|
||||
let laneData = $state<Record<string, Item[]>>({});
|
||||
|
||||
@@ -242,22 +363,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
function statusColor(status: string): string {
|
||||
const s = status.toLowerCase();
|
||||
if (s === 'done' || s === 'completed' || s === 'closed') return 'var(--accent-green)';
|
||||
if (s === 'in progress' || s === 'in_progress' || s === 'active') return 'var(--accent-blue)';
|
||||
if (s === 'blocked') return 'var(--accent-orange)';
|
||||
if (s === 'todo' || s === 'open' || s === 'backlog') return 'var(--text-muted)';
|
||||
return 'var(--text-secondary)';
|
||||
}
|
||||
|
||||
function priorityColor(priority: string): string {
|
||||
const p = priority.toLowerCase();
|
||||
if (p === 'critical' || p === 'urgent') return 'var(--accent-orange)';
|
||||
if (p === 'high') return 'var(--accent-amber)';
|
||||
if (p === 'medium') return 'var(--accent-blue)';
|
||||
if (p === 'low') return 'var(--accent-teal)';
|
||||
return 'var(--text-muted)';
|
||||
function collectionForItem(item: Item): Collection | undefined {
|
||||
return collectionStore.collections.find(c => c.slug === item.collection_slug);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -279,12 +386,57 @@
|
||||
class:active={highlightMine}
|
||||
onclick={() => highlightMine = !highlightMine}
|
||||
>
|
||||
Highlight Mine
|
||||
Mine
|
||||
</button>
|
||||
<button class="new-item-btn" onclick={openNewItem}>+ New</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
<!-- Role edit/create modal -->
|
||||
<!-- New Item Modal -->
|
||||
<dialog class="new-item-dialog" bind:this={newItemDialogEl} onclick={(e) => { if (e.target === newItemDialogEl) closeNewItem(); }}>
|
||||
<div class="dialog-content new-item-content">
|
||||
{#if !newItemCollectionSlug}
|
||||
<div class="dialog-header">
|
||||
<h2>New Item</h2>
|
||||
<button class="dialog-close" onclick={closeNewItem}>✕</button>
|
||||
</div>
|
||||
<div class="collection-grid">
|
||||
{#each eligibleCollections as coll}
|
||||
<button class="collection-pick" onclick={() => selectCollection(coll.slug)}>
|
||||
<span class="collection-pick-icon">{coll.icon || '📦'}</span>
|
||||
<span class="collection-pick-name">{coll.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
{@const selectedColl = eligibleCollections.find(c => c.slug === newItemCollectionSlug)}
|
||||
<div class="dialog-header">
|
||||
<button class="back-btn" onclick={() => { newItemCollectionSlug = ''; newItemTitle = ''; }} title="Back">←</button>
|
||||
<h2>New {selectedColl?.icon} {selectedColl?.name?.replace(/s$/, '') ?? 'Item'}</h2>
|
||||
<button class="dialog-close" onclick={closeNewItem}>✕</button>
|
||||
</div>
|
||||
<div class="new-item-form">
|
||||
<input
|
||||
class="new-item-title-input"
|
||||
type="text"
|
||||
placeholder="Title…"
|
||||
bind:value={newItemTitle}
|
||||
onkeydown={handleNewItemKeydown}
|
||||
/>
|
||||
<button
|
||||
class="new-item-create-btn"
|
||||
disabled={!newItemTitle.trim() || newItemSaving}
|
||||
onclick={submitNewItem}
|
||||
>
|
||||
{newItemSaving ? 'Creating…' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog class="roles-dialog" bind:this={dialogEl} onclick={(e) => { if (e.target === dialogEl) closeModal(); }}>
|
||||
<div class="dialog-content">
|
||||
<div class="dialog-header">
|
||||
@@ -369,10 +521,26 @@
|
||||
<div class="lanes-container">
|
||||
{#each orderedLanes as lane (lane.role?.id ?? '__unassigned')}
|
||||
{@const isUnassigned = !lane.role}
|
||||
<div class="lane" class:unassigned={isUnassigned}>
|
||||
<div class="lane-header">
|
||||
{@const laneId = lane.role?.id ?? '__unassigned'}
|
||||
<div
|
||||
class="lane"
|
||||
class:unassigned={isUnassigned}
|
||||
class:dragging-source={draggedLaneKey === laneId}
|
||||
class:drag-over-left={dragOverLaneKey === laneId}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="lane-header"
|
||||
draggable={!isUnassigned}
|
||||
ondragstart={(e) => handleLaneDragStart(e, laneId)}
|
||||
ondragover={(e) => handleLaneDragOver(e, laneId)}
|
||||
ondragleave={handleLaneDragLeave}
|
||||
ondrop={(e) => handleLaneDrop(e, laneId)}
|
||||
ondragend={handleLaneDragEnd}
|
||||
>
|
||||
<div class="lane-title-row">
|
||||
{#if lane.role}
|
||||
<span class="lane-drag-handle" title="Drag to reorder">⠿</span>
|
||||
<span class="lane-icon">{lane.role.icon || '🤖'}</span>
|
||||
<span class="lane-name">{lane.role.name}</span>
|
||||
{:else}
|
||||
@@ -380,7 +548,7 @@
|
||||
{/if}
|
||||
<span class="lane-count">{lane.items.length}</span>
|
||||
{#if lane.role}
|
||||
<button class="lane-edit-btn" title="Edit role" onclick={() => openEditModal(lane.role)}>✎</button>
|
||||
<button class="lane-edit-btn" title="Edit role" onclick={() => lane.role && openEditModal(lane.role)}>✎</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if lane.role?.tools}
|
||||
@@ -403,45 +571,15 @@
|
||||
oncontextmenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{#each (laneData[laneKey(lane)] ?? []) as item (item.id)}
|
||||
{@const fields = parseFields(item)}
|
||||
{@const ref = formatItemRef(item)}
|
||||
{@const status = fields.status ?? ''}
|
||||
{@const priority = fields.priority ?? ''}
|
||||
{@const coll = collectionForItem(item)}
|
||||
<div class="card-wrapper" class:dimmed={highlightMine && currentUserId && item.assigned_user_id !== currentUserId}>
|
||||
<a
|
||||
href="/{wsSlug}/{item.collection_slug}/{itemUrlId(item)}"
|
||||
class="item-card"
|
||||
>
|
||||
<div class="card-top-row">
|
||||
{#if item.collection_icon || item.collection_name}
|
||||
<span class="collection-badge">
|
||||
{#if item.collection_icon}<span class="coll-icon">{item.collection_icon}</span>{/if}
|
||||
{#if item.collection_name}<span class="coll-name">{item.collection_name}</span>{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{#if ref}
|
||||
<span class="item-ref">{ref}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="card-title">{item.title}</div>
|
||||
|
||||
<div class="card-meta">
|
||||
{#if status}
|
||||
<span class="status-badge" style="color: {statusColor(status)}">
|
||||
{status}
|
||||
</span>
|
||||
{/if}
|
||||
{#if priority}
|
||||
<span class="priority-badge" style="color: {priorityColor(priority)}">
|
||||
{priority}
|
||||
</span>
|
||||
{/if}
|
||||
{#if item.assigned_user_name}
|
||||
<span class="assigned-user">{item.assigned_user_name}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{#if coll}
|
||||
<ItemCard {item} collection={coll} compact={true} showCollection={true} />
|
||||
{:else}
|
||||
<a href="/{wsSlug}/{item.collection_slug}/{itemUrlId(item)}" class="fallback-card">
|
||||
<span class="card-title">{item.title}</span>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if (laneData[laneKey(lane)] ?? []).length === 0 && !isDragging}
|
||||
@@ -527,6 +665,134 @@
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.new-item-btn {
|
||||
background: var(--accent-blue);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: filter 0.15s;
|
||||
}
|
||||
.new-item-btn:hover {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
/* ── New Item Modal ──────────────────────────────────────────────── */
|
||||
.new-item-dialog {
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 0;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
max-width: 400px;
|
||||
width: 90vw;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.3);
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
margin: 0;
|
||||
}
|
||||
.new-item-dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.new-item-content {
|
||||
padding: var(--space-5);
|
||||
}
|
||||
.new-item-content .dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
.new-item-content .dialog-header h2 {
|
||||
flex: 1;
|
||||
font-size: 1.05em;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 1.1em;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.back-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.collection-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.collection-pick {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4) var(--space-3);
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.collection-pick:hover {
|
||||
border-color: var(--accent-blue);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.collection-pick-icon {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
.collection-pick-name {
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.new-item-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.new-item-title-input {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font-size: 0.95em;
|
||||
width: 100%;
|
||||
}
|
||||
.new-item-title-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
.new-item-create-btn {
|
||||
background: var(--accent-blue);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-3) var(--space-5);
|
||||
font-size: 0.9em;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: filter 0.15s;
|
||||
}
|
||||
.new-item-create-btn:hover:not(:disabled) {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
.new-item-create-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Lanes Container ──────────────────────────────────────────────── */
|
||||
.lanes-container {
|
||||
display: flex;
|
||||
@@ -549,6 +815,26 @@
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.lane.dragging-source {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.lane.drag-over-left {
|
||||
box-shadow: -3px 0 0 0 var(--accent-blue);
|
||||
}
|
||||
.lane-drag-handle {
|
||||
cursor: grab;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85em;
|
||||
user-select: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.lane-header:hover .lane-drag-handle {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.lane-header[draggable="true"] {
|
||||
cursor: grab;
|
||||
}
|
||||
.lane-header {
|
||||
padding: var(--space-4) var(--space-4) var(--space-3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
@@ -636,8 +922,8 @@
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* ── Item Card ────────────────────────────────────────────────────── */
|
||||
.item-card {
|
||||
/* ── Fallback Card ───────────────────────────────────────────────── */
|
||||
.fallback-card {
|
||||
display: block;
|
||||
padding: var(--space-3);
|
||||
background: var(--bg-primary);
|
||||
@@ -645,87 +931,6 @@
|
||||
border-radius: var(--radius);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.item-card:hover {
|
||||
border-color: var(--text-muted);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.card-top-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-1);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.collection-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 0.7em;
|
||||
background: var(--bg-tertiary);
|
||||
padding: 1px 7px;
|
||||
border-radius: 10px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.coll-icon {
|
||||
font-size: 1em;
|
||||
}
|
||||
.coll-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.item-ref {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7em;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 0.875em;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.35;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 0.7em;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.priority-badge {
|
||||
font-size: 0.7em;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.assigned-user {
|
||||
font-size: 0.7em;
|
||||
color: var(--text-muted);
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100px;
|
||||
}
|
||||
|
||||
/* ── Empty State ──────────────────────────────────────────────────── */
|
||||
@@ -1000,10 +1205,6 @@
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
}
|
||||
.role-edit-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.role-btn {
|
||||
padding: 5px 12px;
|
||||
font-size: 0.82em;
|
||||
@@ -1026,24 +1227,6 @@
|
||||
.role-btn-save:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
.role-btn-create {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
background: var(--accent-blue);
|
||||
color: white;
|
||||
border-color: var(--accent-blue);
|
||||
font-weight: 500;
|
||||
}
|
||||
.role-btn-create:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
.role-btn-create:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.role-btn-cancel {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.role-btn-danger {
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user