From 26fa6e3474b634626aacf3a6a8c95f80dfe75262 Mon Sep 17 00:00:00 2001 From: Rafetikus Date: Mon, 20 Apr 2026 12:54:26 +0400 Subject: [PATCH 1/4] refactor(ui): update layout, pages for ui --- ui/src/components/layout/PageHeader.tsx | 481 +++++++++++++++++++++++- ui/src/pages/ProjectsListPage.tsx | 136 ++++++- 2 files changed, 591 insertions(+), 26 deletions(-) diff --git a/ui/src/components/layout/PageHeader.tsx b/ui/src/components/layout/PageHeader.tsx index 93178e9..e574fae 100644 --- a/ui/src/components/layout/PageHeader.tsx +++ b/ui/src/components/layout/PageHeader.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useEffect } from 'react'; import { Link, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'; -import { Button, Tooltip } from '../ui'; +import { Avatar, Button, Tooltip } from '../ui'; import { Dropdown } from '../work-item'; import { useModulesFilter } from '../../contexts/ModulesFilterContext'; import { useWorkspaceViewsState } from '../../contexts/WorkspaceViewsStateContext'; @@ -11,6 +11,7 @@ import { CreateViewModal, ModuleFiltersPanel, } from '../workspace-views'; +import { CollapsibleSection } from '../workspace-views/WorkspaceViewsFiltersShared'; import { ProjectSavedViewDisplayDropdown } from '../project-saved-view/ProjectSavedViewDisplayDropdown'; import { ProjectSavedViewMoreMenu } from '../project-saved-view/ProjectSavedViewMoreMenu'; import { DateRangeModal } from '../workspace-views/DateRangeModal'; @@ -285,6 +286,40 @@ const IconFilter = () => ( ); +const IconLock = () => ( + + + + +); +const IconGlobe = () => ( + + + + + + +); const IconPlus = () => ( + (searchParams.get(key) ?? '') + .split(',') + .map((v) => v.trim()) + .filter(Boolean); + const selectedAccess = parseCsvParam('access').filter( + (value): value is 'private' | 'public' => value === 'private' || value === 'public', + ); + const selectedLeadIds = parseCsvParam('lead'); + const selectedMemberIds = parseCsvParam('members'); + const myProjectsOnly = searchParams.get('myProjects') === '1'; + const createdDateFilter = + searchParams.get('createdDate') === 'today' || + searchParams.get('createdDate') === 'last7' || + searchParams.get('createdDate') === 'last30' + ? (searchParams.get('createdDate') as 'today' | 'last7' | 'last30') + : ''; + const [projectsDropdownOpen, setProjectsDropdownOpen] = useState(null); const [searchOpen, setSearchOpen] = useState(!!searchQuery); + const [projectsFiltersSearch, setProjectsFiltersSearch] = useState(''); + const [workspaceMembers, setWorkspaceMembers] = useState([]); + const [showAllLeads, setShowAllLeads] = useState(false); + const [showAllMembers, setShowAllMembers] = useState(false); + const [projectsFilterSectionOpen, setProjectsFilterSectionOpen] = useState({ + createdDate: true, + access: true, + lead: true, + members: true, + }); const baseUrl = `/${workspaceSlug}`; + const sortFieldLabelMap: Record = { + manual: 'Manual', + name: 'Name', + created_date: 'Created date', + member_count: 'Number of members', + }; + const activeFilterCount = + (myProjectsOnly ? 1 : 0) + + (createdDateFilter ? 1 : 0) + + selectedAccess.length + + selectedLeadIds.length + + selectedMemberIds.length; + + useEffect(() => { + if (!workspaceSlug) return; + let cancelled = false; + workspaceService + .listMembers(workspaceSlug) + .then((members) => { + if (!cancelled) setWorkspaceMembers(members ?? []); + }) + .catch(() => { + if (!cancelled) setWorkspaceMembers([]); + }); + return () => { + cancelled = true; + }; + }, [workspaceSlug]); + + const updateParam = ( + key: + | 'q' + | 'sort' + | 'sortField' + | 'sortDir' + | 'filter' + | 'access' + | 'lead' + | 'members' + | 'myProjects' + | 'createdDate', + value?: string, + ) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (!value) next.delete(key); + else next.set(key, value); + return next; + }, + { replace: true }, + ); + }; + const updateParams = ( + updates: Partial< + Record< + | 'q' + | 'sort' + | 'sortField' + | 'sortDir' + | 'filter' + | 'access' + | 'lead' + | 'members' + | 'myProjects' + | 'createdDate', + string | undefined + > + >, + ) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + Object.entries(updates).forEach(([key, value]) => { + if (!value) next.delete(key); + else next.set(key, value); + }); + return next; + }, + { replace: true }, + ); + }; + const setCsvParam = (key: 'access' | 'lead' | 'members', values: string[]) => { + updateParam(key, values.length ? values.join(',') : undefined); + }; + const toggleCsvParam = (key: 'access' | 'lead' | 'members', value: string) => { + const current = parseCsvParam(key); + setCsvParam( + key, + current.includes(value) ? current.filter((v) => v !== value) : [...current, value], + ); + }; + + const memberOptions = [ + ...(authUser + ? [{ id: authUser.id, label: 'You', avatarUrl: authUser.avatarUrl, sortLabel: 'You' }] + : []), + ...workspaceMembers + .filter((member) => member.member_id !== authUser?.id) + .map((member) => ({ + id: member.member_id, + label: + member.member_display_name?.trim() || + member.member_email?.trim() || + member.member_id.slice(0, 8), + avatarUrl: member.member_avatar ?? null, + sortLabel: + member.member_display_name?.trim() || member.member_email?.trim() || member.member_id, + })), + ].sort((a, b) => a.sortLabel.localeCompare(b.sortLabel)); + const normalizedFilterSearch = projectsFiltersSearch.trim().toLowerCase(); + const includeBySearch = (label: string) => + !normalizedFilterSearch || label.toLowerCase().includes(normalizedFilterSearch); + const visibleLeadOptions = memberOptions.filter((opt) => includeBySearch(opt.label)); + const visibleMemberOptions = memberOptions.filter((opt) => includeBySearch(opt.label)); + const leadOptionsToRender = showAllLeads ? visibleLeadOptions : visibleLeadOptions.slice(0, 5); + const memberOptionsToRender = showAllMembers + ? visibleMemberOptions + : visibleMemberOptions.slice(0, 5); return ( <> @@ -817,7 +1018,7 @@ function ProjectsHeader({ workspaceSlug }: { workspaceSlug: string }) { + + + {sortFieldLabelMap[sortField]} + {projectsDropdownOpen === 'projects-sort' ? : } + + } + triggerClassName="flex items-center gap-1.5 rounded-md border border-(--border-subtle) bg-(--bg-layer-2) px-2.5 py-1.5 text-[13px] font-medium text-(--txt-secondary) hover:bg-(--bg-layer-2-hover)" > - - Created date - - - + ); + })} +
+ {[ + { value: 'asc', label: 'Ascending' }, + { value: 'desc', label: 'Descending' }, + ].map((opt) => { + const active = sortDir === opt.value; + return ( + + ); + })} + + } + displayValue={activeFilterCount > 0 ? `Filters (${activeFilterCount})` : 'Filters'} + panelClassName="w-80 rounded-md border border-(--border-subtle) bg-(--bg-surface-1) py-1 shadow-(--shadow-raised)" + triggerContent={ + <> + + + + + {activeFilterCount > 0 ? `Filters (${activeFilterCount})` : 'Filters'} + + {projectsDropdownOpen === 'projects-filters' ? ( + + ) : ( + + )} + + } + triggerClassName="flex items-center gap-1.5 rounded-md border border-(--border-subtle) bg-(--bg-layer-2) px-2.5 py-1.5 text-[13px] font-medium text-(--txt-secondary) hover:bg-(--bg-layer-2-hover)" > - - Filters - - +
+
+ + + + setProjectsFiltersSearch(e.target.value)} + placeholder="Search" + className="min-w-0 flex-1 bg-transparent text-sm text-(--txt-primary) placeholder:text-(--txt-placeholder) focus:outline-none" + aria-label="Search project filters" + /> +
+
+
+ + + setProjectsFilterSectionOpen((prev) => ({ + ...prev, + createdDate: !prev.createdDate, + })) + } + > + {[ + { value: 'today', label: 'Today' }, + { value: 'last7', label: 'Last 7 days' }, + { value: 'last30', label: 'Last 30 days' }, + ] + .filter((opt) => includeBySearch(opt.label)) + .map((opt) => ( + + ))} + + + + setProjectsFilterSectionOpen((prev) => ({ ...prev, access: !prev.access })) + } + > + {[ + { value: 'private' as const, label: 'Private', icon: }, + { value: 'public' as const, label: 'Public', icon: }, + ] + .filter((opt) => includeBySearch(opt.label)) + .map((opt) => ( + + ))} + + + setProjectsFilterSectionOpen((prev) => ({ ...prev, lead: !prev.lead })) + } + > + {leadOptionsToRender.map((opt) => ( + + ))} + {visibleLeadOptions.length > 5 && ( + + )} + + + setProjectsFilterSectionOpen((prev) => ({ ...prev, members: !prev.members })) + } + > + {memberOptionsToRender.map((opt) => ( + + ))} + {visibleMemberOptions.length > 5 && ( + + )} + +
+
- {projects.length === 0 &&

No projects yet.

} + {projects.length === 0 && ( +

+ {favoritesOnly || + accessFilters.length > 0 || + leadFilters.length > 0 || + memberFilters.length > 0 || + !!createdDateFilter || + myProjectsOnly + ? 'No projects match the selected filters.' + : 'No projects yet.'} +

+ )} ); } From 50a58070107142c724480156ae1ebc23c1af99d3 Mon Sep 17 00:00:00 2001 From: Rafetikus Date: Mon, 20 Apr 2026 13:10:00 +0400 Subject: [PATCH 2/4] refactor(ui): update layout, pages for ui --- ui/src/components/layout/PageHeader.tsx | 30 +++++++++---------------- ui/src/pages/ProjectsListPage.tsx | 11 +++------ 2 files changed, 13 insertions(+), 28 deletions(-) diff --git a/ui/src/components/layout/PageHeader.tsx b/ui/src/components/layout/PageHeader.tsx index e574fae..653e811 100644 --- a/ui/src/components/layout/PageHeader.tsx +++ b/ui/src/components/layout/PageHeader.tsx @@ -922,15 +922,10 @@ function ProjectsHeader({ workspaceSlug }: { workspaceSlug: string }) { | 'createdDate', value?: string, ) => { - setSearchParams( - (prev) => { - const next = new URLSearchParams(prev); - if (!value) next.delete(key); - else next.set(key, value); - return next; - }, - { replace: true }, - ); + const next = new URLSearchParams(searchParams); + if (!value) next.delete(key); + else next.set(key, value); + setSearchParams(next, { replace: true }); }; const updateParams = ( updates: Partial< @@ -949,17 +944,12 @@ function ProjectsHeader({ workspaceSlug }: { workspaceSlug: string }) { > >, ) => { - setSearchParams( - (prev) => { - const next = new URLSearchParams(prev); - Object.entries(updates).forEach(([key, value]) => { - if (!value) next.delete(key); - else next.set(key, value); - }); - return next; - }, - { replace: true }, - ); + const next = new URLSearchParams(searchParams); + Object.entries(updates).forEach(([key, value]) => { + if (!value) next.delete(key); + else next.set(key, value); + }); + setSearchParams(next, { replace: true }); }; const setCsvParam = (key: 'access' | 'lead' | 'members', values: string[]) => { updateParam(key, values.length ? values.join(',') : undefined); diff --git a/ui/src/pages/ProjectsListPage.tsx b/ui/src/pages/ProjectsListPage.tsx index d881dd2..00a408a 100644 --- a/ui/src/pages/ProjectsListPage.tsx +++ b/ui/src/pages/ProjectsListPage.tsx @@ -106,14 +106,9 @@ export function ProjectsListPage() { const createProjectOpen = searchParams.get('createProject') === '1'; const closeCreateModal = () => { - setSearchParams( - (prev) => { - const next = new URLSearchParams(prev); - next.delete('createProject'); - return next; - }, - { replace: true }, - ); + const next = new URLSearchParams(searchParams); + next.delete('createProject'); + setSearchParams(next, { replace: true }); }; useEffect(() => { From 5862741482be77628bfe56b407058c52e7c24bdd Mon Sep 17 00:00:00 2001 From: Rafetikus Date: Mon, 20 Apr 2026 13:33:16 +0400 Subject: [PATCH 3/4] refactor(ui): update layout, pages for ui --- ui/src/components/layout/PageHeader.tsx | 2 +- ui/src/pages/ProjectsListPage.tsx | 44 ++++++++++++++----------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/ui/src/components/layout/PageHeader.tsx b/ui/src/components/layout/PageHeader.tsx index 653e811..f7d3b70 100644 --- a/ui/src/components/layout/PageHeader.tsx +++ b/ui/src/components/layout/PageHeader.tsx @@ -847,7 +847,7 @@ function ProjectsHeader({ workspaceSlug }: { workspaceSlug: string }) { ? sortDirParam : legacySortParam === 'created_asc' || legacySortParam === 'name_asc' ? 'asc' - : 'desc'; + : 'asc'; const parseCsvParam = (key: string) => (searchParams.get(key) ?? '') .split(',') diff --git a/ui/src/pages/ProjectsListPage.tsx b/ui/src/pages/ProjectsListPage.tsx index 00a408a..5ebac94 100644 --- a/ui/src/pages/ProjectsListPage.tsx +++ b/ui/src/pages/ProjectsListPage.tsx @@ -87,7 +87,7 @@ export function ProjectsListPage() { ? sortDirParam : sortParam === 'created_asc' || sortParam === 'name_asc' ? 'asc' - : 'desc'; + : 'asc'; const createdDateFilter = searchParams.get('createdDate') === 'today' || searchParams.get('createdDate') === 'last7' || @@ -215,7 +215,7 @@ export function ProjectsListPage() { const projectOrderById = new Map(allProjects.map((p, index) => [p.id, index])); - const projects = allProjects + const filteredProjects = allProjects .filter((p) => { if (!searchQuery) return true; return ( @@ -259,37 +259,38 @@ export function ProjectsListPage() { const days = createdDateFilter === 'last7' ? 7 : 30; const threshold = now.getTime() - days * 24 * 60 * 60 * 1000; return createdAtMs >= threshold; - }) - .slice() + }); + + const projects = filteredProjects + .map((project) => ({ + project, + createdAtMs: Date.parse(project.created_at ?? '') || 0, + membersCount: new Set([ + ...(membersByProject[project.id] ?? []), + ...(project.project_lead_id ? [project.project_lead_id] : []), + ]).size, + })) .sort((a, b) => { - const createdA = Date.parse(a.created_at ?? '') || 0; - const createdB = Date.parse(b.created_at ?? '') || 0; - const membersA = new Set([ - ...(membersByProject[a.id] ?? []), - ...(a.project_lead_id ? [a.project_lead_id] : []), - ]).size; - const membersB = new Set([ - ...(membersByProject[b.id] ?? []), - ...(b.project_lead_id ? [b.project_lead_id] : []), - ]).size; let result = 0; switch (sortField) { case 'name': - result = a.name.localeCompare(b.name); + result = a.project.name.localeCompare(b.project.name); break; case 'member_count': - result = membersA - membersB; + result = a.membersCount - b.membersCount; break; case 'manual': - result = (projectOrderById.get(a.id) ?? 0) - (projectOrderById.get(b.id) ?? 0); + result = + (projectOrderById.get(a.project.id) ?? 0) - (projectOrderById.get(b.project.id) ?? 0); break; case 'created_date': default: - result = createdA - createdB; + result = a.createdAtMs - b.createdAtMs; break; } return sortDir === 'desc' ? -result : result; - }); + }) + .map(({ project }) => project); if (loading) { return ( @@ -432,12 +433,15 @@ export function ProjectsListPage() { {projects.length === 0 && (

{favoritesOnly || + !!searchQuery || accessFilters.length > 0 || leadFilters.length > 0 || memberFilters.length > 0 || !!createdDateFilter || myProjectsOnly - ? 'No projects match the selected filters.' + ? searchQuery + ? 'No results match your search' + : 'No projects match the selected filters.' : 'No projects yet.'}

)} From 6174b308373037d73e10e9ea65e6bef091b251fc Mon Sep 17 00:00:00 2001 From: Rafetikus Date: Mon, 20 Apr 2026 13:59:30 +0400 Subject: [PATCH 4/4] refactor(ui): update pages for ui --- ui/src/pages/ProjectsListPage.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/ui/src/pages/ProjectsListPage.tsx b/ui/src/pages/ProjectsListPage.tsx index 5ebac94..21c5359 100644 --- a/ui/src/pages/ProjectsListPage.tsx +++ b/ui/src/pages/ProjectsListPage.tsx @@ -292,6 +292,15 @@ export function ProjectsListPage() { }) .map(({ project }) => project); + const hasActiveFiltersOrSearch = + !!searchQuery || + favoritesOnly || + accessFilters.length > 0 || + leadFilters.length > 0 || + memberFilters.length > 0 || + !!createdDateFilter || + myProjectsOnly; + if (loading) { return (
@@ -432,13 +441,7 @@ export function ProjectsListPage() {
{projects.length === 0 && (

- {favoritesOnly || - !!searchQuery || - accessFilters.length > 0 || - leadFilters.length > 0 || - memberFilters.length > 0 || - !!createdDateFilter || - myProjectsOnly + {hasActiveFiltersOrSearch ? searchQuery ? 'No results match your search' : 'No projects match the selected filters.'