From 3537a556138d3b4c5c04b817e051263422939cd7 Mon Sep 17 00:00:00 2001 From: Javenn0 Date: Wed, 15 Apr 2026 13:18:33 +0400 Subject: [PATCH 01/11] feat(home): enable reordering and toggling home page widgets --- ui/src/components/layout/PageHeader.tsx | 3 + ui/src/pages/WorkspaceHomePage.tsx | 233 +++++++++++++++++++++--- 2 files changed, 215 insertions(+), 21 deletions(-) diff --git a/ui/src/components/layout/PageHeader.tsx b/ui/src/components/layout/PageHeader.tsx index 5993114..93ab2a5 100644 --- a/ui/src/components/layout/PageHeader.tsx +++ b/ui/src/components/layout/PageHeader.tsx @@ -753,6 +753,9 @@ function HomeHeader() { variant="ghost" size="sm" className="gap-1.5 text-[13px] font-medium text-(--txt-secondary)" + onClick={() => { + window.dispatchEvent(new Event('devlane:open-home-widgets')); + }} > Manage widgets diff --git a/ui/src/pages/WorkspaceHomePage.tsx b/ui/src/pages/WorkspaceHomePage.tsx index 88bf73a..282a620 100644 --- a/ui/src/pages/WorkspaceHomePage.tsx +++ b/ui/src/pages/WorkspaceHomePage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, type ReactNode } from 'react'; import { Link, useParams } from 'react-router-dom'; import { Card, CardContent, Button, Modal, Input } from '../components/ui'; import { useAuth } from '../contexts/AuthContext'; @@ -230,6 +230,70 @@ const IconChain = () => ( ); +const IconGripVertical = () => ( + + + + + + + + +); + +type HomeWidgetId = 'quicklinks' | 'recents' | 'stickies'; + +type HomeWidget = { + id: HomeWidgetId; + label: string; + enabled: boolean; +}; + +const DEFAULT_HOME_WIDGETS: HomeWidget[] = [ + { id: 'quicklinks', label: 'Quicklinks', enabled: true }, + { id: 'recents', label: 'Recents', enabled: true }, + { id: 'stickies', label: 'Your stickies', enabled: true }, +]; + +function normalizeWidgets(raw: unknown): HomeWidget[] { + if (!Array.isArray(raw)) return [...DEFAULT_HOME_WIDGETS]; + const byId = new Map(); + const ordered: HomeWidget[] = []; + for (const item of raw) { + if (!item || typeof item !== 'object') continue; + const id = (item as { id?: unknown }).id; + const enabled = (item as { enabled?: unknown }).enabled; + if (id === 'quicklinks' || id === 'recents' || id === 'stickies') { + if (byId.has(id)) continue; + const fallbackLabel = DEFAULT_HOME_WIDGETS.find((widget) => widget.id === id)?.label ?? id; + const rawLabel = (item as { label?: unknown }).label; + const label = + typeof rawLabel === 'string' && rawLabel.trim().length > 0 ? rawLabel : fallbackLabel; + const normalized: HomeWidget = { + id, + label, + enabled: typeof enabled === 'boolean' ? enabled : true, + }; + byId.set(id, normalized); + ordered.push(normalized); + } + } + const merged: HomeWidget[] = [...ordered]; + for (const widget of DEFAULT_HOME_WIDGETS) { + if (!byId.has(widget.id)) merged.push(widget); + } + return merged; +} // --------------------------------------------------------------------------- // Helpers @@ -531,6 +595,43 @@ export function WorkspaceHomePage() { typeof document !== 'undefined' && document.documentElement.getAttribute('data-theme') === 'dark', ); + const [manageWidgetsOpen, setManageWidgetsOpen] = useState(false); + const [widgets, setWidgets] = useState(DEFAULT_HOME_WIDGETS); + const [draggingWidgetId, setDraggingWidgetId] = useState(null); + const [widgetsHydrated, setWidgetsHydrated] = useState(false); + + const widgetsStorageKey = workspaceSlug ? `devlane:home-widgets:${workspaceSlug}` : ''; + + useEffect(() => { + setWidgetsHydrated(false); + if (!widgetsStorageKey) { + setWidgets(DEFAULT_HOME_WIDGETS); + setWidgetsHydrated(true); + return; + } + try { + const raw = localStorage.getItem(widgetsStorageKey); + if (!raw) { + setWidgets(DEFAULT_HOME_WIDGETS); + setWidgetsHydrated(true); + return; + } + setWidgets(normalizeWidgets(JSON.parse(raw))); + } catch { + setWidgets(DEFAULT_HOME_WIDGETS); + } + setWidgetsHydrated(true); + }, [widgetsStorageKey]); + + useEffect(() => { + if (!widgetsStorageKey || !widgetsHydrated) return; + localStorage.setItem(widgetsStorageKey, JSON.stringify(widgets)); + }, [widgetsStorageKey, widgets, widgetsHydrated]); + useEffect(() => { + const openFromHeader = () => setManageWidgetsOpen(true); + window.addEventListener('devlane:open-home-widgets', openFromHeader); + return () => window.removeEventListener('devlane:open-home-widgets', openFromHeader); + }, []); useEffect(() => { const el = document.documentElement; const sync = () => setStickiesDarkTheme(el.getAttribute('data-theme') === 'dark'); @@ -668,6 +769,24 @@ export function WorkspaceHomePage() { // already handled by interceptor } }; + const handleWidgetEnabledChange = (id: HomeWidgetId, enabled: boolean) => { + setWidgets((prev) => prev.map((widget) => (widget.id === id ? { ...widget, enabled } : widget))); + }; + + const handleWidgetDrop = (targetId: HomeWidgetId) => { + if (!draggingWidgetId || draggingWidgetId === targetId) return; + setWidgets((prev) => { + const fromIndex = prev.findIndex((widget) => widget.id === draggingWidgetId); + const targetIndex = prev.findIndex((widget) => widget.id === targetId); + if (fromIndex < 0 || targetIndex < 0 || fromIndex === targetIndex) return prev; + const reordered = [...prev]; + const [moved] = reordered.splice(fromIndex, 1); + const insertIndex = fromIndex < targetIndex ? targetIndex - 1 : targetIndex; + reordered.splice(insertIndex, 0, moved); + return reordered; + }); + setDraggingWidgetId(null); + }; useEffect(() => { if (!recentsFilterOpen) return; @@ -706,22 +825,8 @@ export function WorkspaceHomePage() { ? recents.filter((r) => r.entity_name === 'page') : recents.filter((r) => r.entity_name === 'project'); - return ( -
- {/* Welcome */} -
-

- {getGreeting()}, {user?.name ?? 'User'} -

-

- - - - {formatDateTime(new Date())} -

-
- - {/* Quicklinks */} + const sectionByWidgetId: Record = { + quicklinks: (

Quicklinks

@@ -850,8 +955,8 @@ export function WorkspaceHomePage() { )}
- - {/* Recents */} + ), + recents: (

Recents

@@ -947,8 +1052,8 @@ export function WorkspaceHomePage() {
- - {/* Your stickies */} + ), + stickies: (

Your stickies

@@ -1089,6 +1194,92 @@ export function WorkspaceHomePage() { ); })()}
+ ), + }; + + return ( +
+ { + setManageWidgetsOpen(false); + setDraggingWidgetId(null); + }} + title="Manage widgets" + footer={ + + } + > +
+ {widgets.map((widget) => ( +
setDraggingWidgetId(widget.id)} + onDragEnd={() => setDraggingWidgetId(null)} + onDragOver={(e) => e.preventDefault()} + onDrop={() => handleWidgetDrop(widget.id)} + className={`flex items-center justify-between rounded-(--radius-md) border px-3 py-2 ${ + draggingWidgetId === widget.id + ? 'border-(--border-strong) bg-(--bg-layer-1)' + : 'border-(--border-subtle) bg-(--bg-surface-1)' + }`} + > +
+ + + + {widget.label} +
+
+ +
+
+ ))} +
+
+ {/* Welcome */} +
+

+ {getGreeting()}, {user?.name ?? 'User'} +

+

+ + + + {formatDateTime(new Date())} +

+
+ + {widgets + .filter((widget) => widget.enabled) + .map((widget) => ( +
{sectionByWidgetId[widget.id]}
+ ))}
); } From a66f17df4ff6ccb0b8722bb3efcf4b02231b4da4 Mon Sep 17 00:00:00 2001 From: Javenn0 Date: Wed, 15 Apr 2026 13:47:18 +0400 Subject: [PATCH 02/11] style(homepage): format widget enabled change handler --- ui/src/pages/WorkspaceHomePage.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/src/pages/WorkspaceHomePage.tsx b/ui/src/pages/WorkspaceHomePage.tsx index 282a620..f1f982a 100644 --- a/ui/src/pages/WorkspaceHomePage.tsx +++ b/ui/src/pages/WorkspaceHomePage.tsx @@ -770,7 +770,9 @@ export function WorkspaceHomePage() { } }; const handleWidgetEnabledChange = (id: HomeWidgetId, enabled: boolean) => { - setWidgets((prev) => prev.map((widget) => (widget.id === id ? { ...widget, enabled } : widget))); + setWidgets((prev) => + prev.map((widget) => (widget.id === id ? { ...widget, enabled } : widget)), + ); }; const handleWidgetDrop = (targetId: HomeWidgetId) => { From e2d5e4bd1ff932bc0fe62167651f8f4cc871b81f Mon Sep 17 00:00:00 2001 From: Javenn0 Date: Fri, 17 Apr 2026 09:43:05 +0400 Subject: [PATCH 03/11] refactor(a11y): add aria-labelledby to role=\ --- ui/src/pages/SettingsPage.tsx | 18 ++++++++++++------ ui/src/pages/WorkspaceHomePage.tsx | 5 ++++- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/ui/src/pages/SettingsPage.tsx b/ui/src/pages/SettingsPage.tsx index ddab8be..3dd8867 100644 --- a/ui/src/pages/SettingsPage.tsx +++ b/ui/src/pages/SettingsPage.tsx @@ -1808,13 +1808,14 @@ export function SettingsPage() { className="flex items-start justify-between gap-4 rounded-(--radius-md) border border-(--border-subtle) px-4 py-3" >
-

{label}

+

{label}

{desc}

-

Guest access

+

Guest access

This will allow guests to have view access to all the project work items.

@@ -2555,6 +2556,7 @@ export function SettingsPage() { type="button" role="switch" aria-checked={guestAccess} + aria-labelledby={`guest-toggle-label-${selectedProjectId ?? 'project'}`} onClick={async () => { const next = !guestAccess; setGuestAccess(next); @@ -2796,7 +2798,7 @@ export function SettingsPage() { {id === 'intake' && }
-

{label}

+

{label}

{desc}

@@ -2804,6 +2806,7 @@ export function SettingsPage() { type="button" role="switch" aria-checked={value} + aria-labelledby={`feature-toggle-label-${id}-${selectedProjectId ?? 'project'}`} onClick={async () => { const next = !value; set(next); @@ -2842,7 +2845,7 @@ export function SettingsPage() {
-

Time Tracking

+

Time Tracking

Log time spent on work items and projects.

@@ -2852,6 +2855,7 @@ export function SettingsPage() { type="button" role="switch" aria-checked={featureTimeTracking} + aria-labelledby={`feature-toggle-label-time-tracking-${selectedProjectId ?? 'project'}`} onClick={async () => { const next = !featureTimeTracking; setFeatureTimeTracking(next); @@ -3155,7 +3159,7 @@ export function SettingsPage() {
-

+

Auto-archive closed work items

@@ -3167,6 +3171,7 @@ export function SettingsPage() { type="button" role="switch" aria-checked={autoArchive} + aria-labelledby={`auto-archive-toggle-${selectedProjectId ?? 'project'}`} onClick={() => setAutoArchive(!autoArchive)} className={`relative h-6 w-10 shrink-0 rounded-full transition-colors ${autoArchive ? 'bg-(--brand-default)' : 'bg-(--neutral-400)'}`} > @@ -3181,7 +3186,7 @@ export function SettingsPage() {

-

+

Auto-close work items

@@ -3194,6 +3199,7 @@ export function SettingsPage() { type="button" role="switch" aria-checked={autoClose} + aria-labelledby={`auto-close-toggle-${selectedProjectId ?? 'project'}`} onClick={() => setAutoClose(!autoClose)} className={`relative h-6 w-10 shrink-0 rounded-full transition-colors ${autoClose ? 'bg-(--brand-default)' : 'bg-(--neutral-400)'}`} > diff --git a/ui/src/pages/WorkspaceHomePage.tsx b/ui/src/pages/WorkspaceHomePage.tsx index f1f982a..d9c5793 100644 --- a/ui/src/pages/WorkspaceHomePage.tsx +++ b/ui/src/pages/WorkspaceHomePage.tsx @@ -1239,13 +1239,16 @@ export function WorkspaceHomePage() { - {widget.label} + + {widget.label} +