diff options
| author | haoyuren <13851610112@163.com> | 2026-07-28 05:18:55 +0800 |
|---|---|---|
| committer | haoyuren <13851610112@163.com> | 2026-07-28 05:18:55 +0800 |
| commit | 40207a1bda984ee6e65e3c5b48c73a52ed5d9b07 (patch) | |
| tree | 0d1e0b9e362ca3af6084d975c71cfe234113c509 /src | |
| parent | 2fbcf18010bf79d3c1517c920325f12b122ec96e (diff) | |
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 <noreply@anthropic.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/renderer/src/App.css | 36 | ||||
| -rw-r--r-- | 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<string | null>(null) + const [rowTagMenu, setRowTagMenu] = useState<{ projectId: string; up: boolean } | null>(null) const [apiKeys, setApiKeys] = useState<Record<string, string>>({}) const [apiKeysVisible, setApiKeysVisible] = useState<Record<string, boolean>>({}) 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) { </button> ) + /** Tag checklist dropdown, shared by the toolbar bulk action and per-row menus */ + const tagToggleDropdown = (projectIds: string[], onClose: () => void, extraClass = '') => ( + <div className={`pl-dropdown pl-dropdown-right ${extraClass}`}> + <div className="pl-dropdown-header">Add to tag</div> + {sortedTags.map((tag) => { + const allIn = projectIds.every((id) => tag.project_ids?.includes(id)) + return ( + <button key={tag._id} className="pl-dropdown-item" onClick={() => toggleTagForProjects(tag, projectIds)}> + <span className="pl-tag-dot" style={{ backgroundColor: getTagColor(tag) }} /> + <span className="pl-dropdown-label">{tag.name}</span> + {allIn && <Icon d={ICONS.check} size={13} />} + </button> + ) + })} + {sortedTags.length > 0 && <hr className="pl-dropdown-divider" />} + <button + className="pl-dropdown-item" + onClick={() => { + onClose() + openModal({ kind: 'createTag', forProjects: projectIds }) + }} + > + <Icon d={ICONS.plus} size={13} /> Create new tag + </button> + </div> + ) + 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( + <div key="tags" className={`pl-row-tag-wrap ${menuOpen ? 'open' : ''}`} onClick={(e) => e.stopPropagation()}> + <button + className="pl-icon-btn" + title="Add to tag" + onClick={(e) => { + // Open upward when the row sits in the lower part of the window + const up = e.currentTarget.getBoundingClientRect().top > window.innerHeight * 0.55 + setRowTagMenu(menuOpen ? null : { projectId: p.id, up }) + }} + > + <Icon d={ICONS.tag} /> + </button> + {menuOpen && tagToggleDropdown([p.id], () => setRowTagMenu(null), rowTagMenu?.up ? 'pl-dropdown-up' : '')} + </div> + ) + } 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) { <button className="pl-icon-btn" title="Add to tag" onClick={() => setTagsMenuOpen((v) => !v)}> <Icon d={ICONS.tag} /> </button> - {tagsMenuOpen && ( - <div className="pl-dropdown pl-dropdown-right"> - <div className="pl-dropdown-header">Add to tag</div> - {sortedTags.map((tag) => { - const allIn = selectedProjects.every((p) => tag.project_ids?.includes(p.id)) - return ( - <button key={tag._id} className="pl-dropdown-item" onClick={() => toggleTagForSelected(tag)}> - <span className="pl-tag-dot" style={{ backgroundColor: getTagColor(tag) }} /> - <span className="pl-dropdown-label">{tag.name}</span> - {allIn && <Icon d={ICONS.check} size={13} />} - </button> - ) - })} - {sortedTags.length > 0 && <hr className="pl-dropdown-divider" />} - <button - className="pl-dropdown-item" - onClick={() => { - setTagsMenuOpen(false) - openModal({ kind: 'createTag', forProjects: selectedProjects.map((p) => p.id) }) - }} - > - <Icon d={ICONS.plus} size={13} /> Create new tag - </button> - </div> - )} + {tagsMenuOpen && + tagToggleDropdown(selectedProjects.map((p) => p.id), () => setTagsMenuOpen(false))} </div> )} </div> @@ -851,6 +880,7 @@ export default function ProjectList({ onOpenProject }: Props) { )} </div> + <div className="pl-scroll"> <div className="pl-table"> <div className="pl-table-header"> <span className="pl-col-check"> @@ -939,6 +969,7 @@ export default function ProjectList({ onOpenProject }: Props) { </span> </div> )} + </div> </> )} </main> |
