From 491586456b940d55a21c96f068c9dc8733bd5eeb Mon Sep 17 00:00:00 2001 From: Jurie Smit Date: Sun, 12 Jul 2026 17:04:32 +0200 Subject: [PATCH 1/2] feat(frontend): add release task deep links --- frontend/src/App.tsx | 31 ++++++++++++++++++++++++------ frontend/src/components/Header.tsx | 21 ++++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4d08da9..674ff61 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -57,9 +57,10 @@ function AppShell() { const setSelectedLabelIds = (ids: string[]) => setLabelFilter({ projectId: selectedProjectId, ids }); // URL-derived project key (slug or id), captured once on mount and resolved - // against the project list as soon as it loads. Cleared after resolution. + // against the project list as soon as it loads. Release links use + // /projects/:key/releases/:release and apply the board release filter. const [urlProjectKey, setUrlProjectKey] = useState(() => { - const m = window.location.pathname.match(/^\/projects\/([^/]+)$/); + const m = window.location.pathname.match(/^\/projects\/([^/]+)(?:\/releases\/([^/]+))?$/); if (!m) return null; try { return decodeURIComponent(m[1]); @@ -69,6 +70,15 @@ function AppShell() { return m[1]; } }); + const [urlReleaseTarget, setUrlReleaseTarget] = useState(() => { + const m = window.location.pathname.match(/^\/projects\/([^/]+)\/releases\/([^/]+)$/); + if (!m) return null; + try { + return decodeURIComponent(m[2]); + } catch { + return m[2]; + } + }); const [settingsProjectId, setSettingsProjectId] = useState(null); const [search, setSearch] = useState(''); const [syncing, setSyncing] = useState(false); @@ -122,6 +132,11 @@ function AppShell() { setUrlProjectKey(null); if (found) { setSelectedProjectId(found.id); + if (urlReleaseTarget !== null) { + setReleaseTarget(urlReleaseTarget); + setBoardGrouping('release'); + setUrlReleaseTarget(null); + } return; } } @@ -130,9 +145,9 @@ function AppShell() { if (persistedStillExists) return; const first = projects.find(p => p.status === 'active') ?? projects[0]; setSelectedProjectId(first ? first.id : null); - }, [projects, selectedProjectId, setSelectedProjectId, urlProjectKey]); + }, [projects, selectedProjectId, setSelectedProjectId, setReleaseTarget, urlProjectKey, urlReleaseTarget]); - // Keep the URL in sync with the selected project, except while a /tasks, + // Keep the URL in sync with the selected project/release, except while a /tasks, // /users, or /agents deep link is active or still loading. Use replaceState // so the back button still goes back to wherever the user came from. React.useEffect(() => { @@ -143,11 +158,15 @@ function AppShell() { if (/^\/(tasks|users|agents)\//.test(window.location.pathname)) return; const project = projects.find(p => p.id === selectedProjectId); if (!project) return; - const href = `/projects/${project.slug || project.id}`; + const projectHref = `/projects/${project.slug || project.id}`; + const release = releaseTarget.trim(); + const href = release + ? `${projectHref}/releases/${encodeURIComponent(release)}` + : projectHref; if (window.location.pathname !== href) { window.history.replaceState(null, '', href); } - }, [selectedProjectId, projects, linkedTask, linkedResource, urlProjectKey]); + }, [selectedProjectId, projects, linkedTask, linkedResource, urlProjectKey, releaseTarget]); // Global Cmd+K / Ctrl+K to open the command palette. React.useEffect(() => { diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 0f96b9a..4ef08b5 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -65,6 +65,9 @@ export function Header({ const projectHref = selectedProject ? `/projects/${selectedProject.slug || selectedProject.id}` : null; + const releaseHref = projectHref && releaseTarget.trim() + ? `${projectHref}/releases/${encodeURIComponent(releaseTarget.trim())}` + : null; const copyProjectLink = async () => { if (!projectHref) return; @@ -76,6 +79,16 @@ export function Header({ } }; + const copyReleaseLink = async () => { + if (!releaseHref) return; + try { + await navigator.clipboard.writeText(`${window.location.origin}${releaseHref}`); + showToast('Release link copied', 'success'); + } catch { + showToast('Copy failed', 'error'); + } + }; + useEffect(() => { if (!accountOpen) return; const handler = (e: MouseEvent) => { @@ -238,6 +251,14 @@ export function Header({ aria-label="Release target" className="w-24 bg-[#111] border border-[#1f1f1f] rounded text-xs text-zinc-300 placeholder-zinc-600 px-2 py-1.5 outline-none focus:border-amber-500/30" /> + + +
🔥 - baton + baton
-
+
-
+ -
+
onSearch(e.target.value)} @@ -204,13 +216,13 @@ export function Header({ K -
+
@@ -277,7 +289,7 @@ export function Header({ @@ -296,7 +308,7 @@ export function Header({ onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')} title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'} aria-label="Toggle theme" - className="text-xs text-zinc-500 hover:text-amber-400 px-2 py-1.5 rounded hover:bg-amber-500/5 transition-colors" + className="hidden md:block text-xs text-zinc-500 hover:text-amber-400 px-2 py-1.5 rounded hover:bg-amber-500/5 transition-colors" > {theme === 'dark' ? '☼' : '☾'} @@ -305,7 +317,7 @@ export function Header({
diff --git a/frontend/src/components/KanbanBoard.tsx b/frontend/src/components/KanbanBoard.tsx index b5c2ba4..2df7c59 100644 --- a/frontend/src/components/KanbanBoard.tsx +++ b/frontend/src/components/KanbanBoard.tsx @@ -235,6 +235,10 @@ function releaseBuckets(tasks: Task[], status: Status): Bucket[] { export function KanbanBoard({ tasks, projectId, users, agents, density, onCreateTask, onUpdateTask, onMoveTask, onTaskOpen, estimateUnit = 'points', onTaskLabelsChange, rootOnly, grouping }: Props) { const [selectedTask, setSelectedTask] = useState(null); + const [mobileStatus, setMobileStatus] = useState('todo'); + const [isMobile, setIsMobile] = useState( + typeof window !== 'undefined' && window.innerWidth < 768, + ); const [addingTo, setAddingTo] = useState(null); const [, setDraggingId] = useState(null); const [dragOverStatus, setDragOverStatus] = useState(null); @@ -274,6 +278,12 @@ export function KanbanBoard({ tasks, projectId, users, agents, density, onCreate columnWidths[status] ? `${columnWidths[status]}px` : `minmax(${COLUMN_MIN_WIDTH}px, 1fr)` )).join(' '), [columnWidths]); + React.useEffect(() => { + const handler = () => setIsMobile(window.innerWidth < 768); + window.addEventListener('resize', handler); + return () => window.removeEventListener('resize', handler); + }, []); + const setColumnWidth = (status: Status, value: number) => { const next = Math.max(COLUMN_MIN_WIDTH, Math.min(COLUMN_MAX_WIDTH, Math.round(value))); setColumnWidths(prev => ({ ...prev, [status]: next })); @@ -481,7 +491,27 @@ export function KanbanBoard({ tasks, projectId, users, agents, density, onCreate return ( <> -
+
+ {COLUMNS.map(({ status, label }) => ( + + ))} +
+
{COLUMNS.map(({ status, label }) => { const columnTasks = visibleColumnTasks(status); const isDragTarget = dragOverStatus === status; @@ -491,7 +521,7 @@ export function KanbanBoard({ tasks, projectId, users, agents, density, onCreate return (
handleDrop(e, status)} onDragOver={e => handleDragOver(e, status)} @@ -568,7 +598,7 @@ export function KanbanBoard({ tasks, projectId, users, agents, density, onCreate onPointerUp={stopColumnResize} onPointerCancel={stopColumnResize} onKeyDown={e => handleColumnResizeKeyDown(e, status)} - className="absolute top-3 right-0 h-[calc(100%-1.5rem)] w-2 translate-x-1 cursor-col-resize rounded-full hover:bg-amber-500/20" + className="hidden md:block absolute top-3 right-0 h-[calc(100%-1.5rem)] w-2 translate-x-1 cursor-col-resize rounded-full hover:bg-amber-500/20" />
); diff --git a/frontend/src/components/MobileGate.tsx b/frontend/src/components/MobileGate.tsx index 940a65c..c3fa99f 100644 --- a/frontend/src/components/MobileGate.tsx +++ b/frontend/src/components/MobileGate.tsx @@ -1,36 +1,3 @@ -import React, { useEffect, useState } from 'react'; - -// Below this width, the kanban + drawer layout doesn't fit. We show a banner -// that the user can dismiss for the current session — no drastic redirect. -const BREAKPOINT = 768; - export function MobileGate() { - const [isNarrow, setIsNarrow] = useState( - typeof window !== 'undefined' && window.innerWidth < BREAKPOINT, - ); - const [dismissed, setDismissed] = useState(false); - - useEffect(() => { - const handler = () => setIsNarrow(window.innerWidth < BREAKPOINT); - window.addEventListener('resize', handler); - return () => window.removeEventListener('resize', handler); - }, []); - - if (!isNarrow || dismissed) return null; - - return ( -
- - - baton works best on a tablet or wider screen. Some controls may overflow on this device. - - -
- ); + return null; }