summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhaoyuren <13851610112@163.com>2026-07-28 05:18:55 +0800
committerhaoyuren <13851610112@163.com>2026-07-28 05:18:55 +0800
commit40207a1bda984ee6e65e3c5b48c73a52ed5d9b07 (patch)
tree0d1e0b9e362ca3af6084d975c71cfe234113c509
parent2fbcf18010bf79d3c1517c920325f12b122ec96e (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>
-rw-r--r--src/renderer/src/App.css36
-rw-r--r--src/renderer/src/components/ProjectList.tsx87
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>