From 40207a1bda984ee6e65e3c5b48c73a52ed5d9b07 Mon Sep 17 00:00:00 2001 From: haoyuren <13851610112@163.com> Date: Tue, 28 Jul 2026 05:18:55 +0800 Subject: Add quick per-row tagging and sticky list chrome - Every live project row gets an "Add to tag" button with an in-place checklist dropdown (opens upward near the bottom of the window), so tagging no longer requires selecting rows and scrolling back to the toolbar - The toolbar bulk-tag dropdown shares the same checklist component - Page header, search, and bulk actions stay fixed while the project table scrolls; the table header is sticky Co-Authored-By: Claude Fable 5 --- src/renderer/src/App.css | 36 +++++++++++- src/renderer/src/components/ProjectList.tsx | 87 +++++++++++++++++++---------- 2 files changed, 94 insertions(+), 29 deletions(-) diff --git a/src/renderer/src/App.css b/src/renderer/src/App.css index 6601e57..0db81f8 100644 --- a/src/renderer/src/App.css +++ b/src/renderer/src/App.css @@ -1140,8 +1140,16 @@ html, body, #root { min-width: 0; display: flex; flex-direction: column; - padding: 0 24px 24px; + padding: 0 24px; + overflow: hidden; +} + +/* Scrollable table region — header/search/bulk tools above stay in view */ +.pl-scroll { + flex: 1; + min-height: 0; overflow-y: auto; + padding-bottom: 24px; } .pl-main-header { @@ -1175,6 +1183,28 @@ html, body, #root { position: relative; } +/* Per-row tag menu anchor */ +.pl-row-tag-wrap { + position: relative; + display: inline-flex; +} +.pl-row-tag-wrap.open .pl-icon-btn { + opacity: 1; + background: var(--bg-hover); + color: var(--text-primary); +} +.pl-row-tag-wrap .pl-dropdown { + min-width: 200px; + max-height: 320px; + overflow-y: auto; +} + +/* Dropdown that opens upward (rows near the bottom of the window) */ +.pl-dropdown-up { + top: auto; + bottom: calc(100% + 4px); +} + .pl-icon-btn { display: inline-flex; align-items: center; @@ -1237,6 +1267,10 @@ html, body, #root { border-bottom: 1px solid var(--border); user-select: none; gap: 10px; + position: sticky; + top: 0; + background: var(--bg-primary); + z-index: 5; } .pl-sortable { cursor: pointer; diff --git a/src/renderer/src/components/ProjectList.tsx b/src/renderer/src/components/ProjectList.tsx index ec9e997..e4cc0b9 100644 --- a/src/renderer/src/components/ProjectList.tsx +++ b/src/renderer/src/components/ProjectList.tsx @@ -125,6 +125,7 @@ export default function ProjectList({ onOpenProject }: Props) { const [tagsMenuOpen, setTagsMenuOpen] = useState(false) const [newMenuOpen, setNewMenuOpen] = useState(false) const [tagKebabOpen, setTagKebabOpen] = useState(null) + const [rowTagMenu, setRowTagMenu] = useState<{ projectId: string; up: boolean } | null>(null) const [apiKeys, setApiKeys] = useState>({}) const [apiKeysVisible, setApiKeysVisible] = useState>({}) const { setStatusMessage } = useAppStore() @@ -151,7 +152,12 @@ export default function ProjectList({ onOpenProject }: Props) { // Close dropdowns on outside click useEffect(() => { - const close = () => { setTagsMenuOpen(false); setNewMenuOpen(false); setTagKebabOpen(null) } + const close = () => { + setTagsMenuOpen(false) + setNewMenuOpen(false) + setTagKebabOpen(null) + setRowTagMenu(null) + } window.addEventListener('click', close) return () => window.removeEventListener('click', close) }, []) @@ -359,8 +365,8 @@ export default function ProjectList({ onOpenProject }: Props) { )) } - const toggleTagForSelected = async (tag: Tag) => { - const ids = selectedProjects.map((p) => p.id) + /** Toggle tag membership: if every project has the tag, remove it; else add to the missing ones */ + const toggleTagForProjects = async (tag: Tag, ids: string[]) => { const allContained = ids.every((id) => tag.project_ids?.includes(id)) if (allContained) { removeProjectsFromTagInView(tag._id, ids) @@ -614,9 +620,55 @@ export default function ProjectList({ onOpenProject }: Props) { ) + /** Tag checklist dropdown, shared by the toolbar bulk action and per-row menus */ + const tagToggleDropdown = (projectIds: string[], onClose: () => void, extraClass = '') => ( +
+
Add to tag
+ {sortedTags.map((tag) => { + const allIn = projectIds.every((id) => tag.project_ids?.includes(id)) + return ( + + ) + })} + {sortedTags.length > 0 &&
} + +
+ ) + const rowActions = (p: OverleafProject) => { const isOwner = p.accessLevel === 'owner' const buttons: JSX.Element[] = [] + if (!p.archived && !p.trashed) { + const menuOpen = rowTagMenu?.projectId === p.id + buttons.push( +
e.stopPropagation()}> + + {menuOpen && tagToggleDropdown([p.id], () => setRowTagMenu(null), rowTagMenu?.up ? 'pl-dropdown-up' : '')} +
+ ) + } if (!p.archived && !p.trashed) buttons.push(iconBtn('Copy', 'copy', () => openModal({ kind: 'clone', project: p }))) buttons.push(iconBtn('Download .zip file', 'download', () => handleDownload([p]))) if (!p.archived) buttons.push(iconBtn('Archive', 'archive', () => openModal({ kind: 'confirm', action: 'archive', projects: [p] }))) @@ -797,31 +849,8 @@ export default function ProjectList({ onOpenProject }: Props) { - {tagsMenuOpen && ( -
-
Add to tag
- {sortedTags.map((tag) => { - const allIn = selectedProjects.every((p) => tag.project_ids?.includes(p.id)) - return ( - - ) - })} - {sortedTags.length > 0 &&
} - -
- )} + {tagsMenuOpen && + tagToggleDropdown(selectedProjects.map((p) => p.id), () => setTagsMenuOpen(false))} )} @@ -851,6 +880,7 @@ export default function ProjectList({ onOpenProject }: Props) { )} +
@@ -939,6 +969,7 @@ export default function ProjectList({ onOpenProject }: Props) {
)} +
)} -- cgit v1.2.3