mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
feat: tag delete, rename, and per-user humanize-tags display setting (#16)
* feat: add tag delete and rename (backend + frontend)
- DELETE /api/archives/:id/tags/:tag_uid — deletes tag subtree via
recursive CTE; entry_tag_assignments cascade automatically
- PATCH /api/archives/:id/tags/:tag_uid { name } — renames a tag
segment (case-preserving slug), cascades full_path to all descendants
in one transaction using hierarchy CTE (not LIKE), returns updated Tag
- database: rename_tag + delete_tag with 7 unit tests covering subtree
cascade, collision detection, descendant path rewrite, slug stripping
- TagsView: inline rename (pencil icon / double-click → input), × delete
with confirmation dialog (warns about child tags)
- App.jsx: handleTagRenamed rewrites tagFilter for exact + descendant
paths; handleTagDeleted clears filter for deleted subtree
- api.js: renameTag (PATCH, returns Tag) + deleteTag (DELETE)
- ContextRail: tag pills show displayPath(full_path) (humanized) with
raw full_path in title tooltip; humanize-tags setting coming next
* feat: per-user humanize-tags display setting
- auth DB: idempotent migration adds users.humanize_slugs INTEGER DEFAULT 0
- GET /api/auth/me: returns humanize_slugs bool
- PATCH /api/auth/me: accepts { humanize_slugs: bool }, persisted via
database::update_user_humanize_slugs
- displayPath() helper moved to utils.js (was local to ContextRail)
- TagsView: node label shows tag.name vs tag.slug based on humanizeTags
- ContextRail: pill label applies displayPath conditionally
- App.jsx: derives humanizeTags from currentUser.humanize_slugs, passes
to TagsView + ContextRail; filter badge label humanized when on
- Settings > Profile: Display Preferences section with checkbox toggle,
updates backend and currentUser immediately via setCurrentUser
- api.js: patchMe(patch) for generic PATCH /api/auth/me
- Tests: auth_me default=false, PATCH persists=true (2 route tests)
This commit is contained in:
parent
d6b52ba06c
commit
55f85134df
13 changed files with 699 additions and 69 deletions
|
|
@ -12,6 +12,7 @@ import TagsView from './components/TagsView'
|
|||
import CollectionsView from './components/CollectionsView'
|
||||
import SettingsView from './components/SettingsView'
|
||||
import ContextRail from './components/ContextRail'
|
||||
import { displayPath } from './utils'
|
||||
|
||||
export const AuthContext = createContext(null);
|
||||
|
||||
|
|
@ -81,6 +82,8 @@ export default function App() {
|
|||
return saved === 'true'
|
||||
})
|
||||
|
||||
const humanizeTags = currentUser?.humanize_slugs ?? false;
|
||||
|
||||
// Persist captureDialogOpen to sessionStorage
|
||||
useEffect(() => {
|
||||
sessionStorage.setItem('captureDialogOpen', captureDialogOpen)
|
||||
|
|
@ -186,6 +189,20 @@ export default function App() {
|
|||
if (archiveId) fetchTags(archiveId).then(setTagNodes)
|
||||
}, [archiveId])
|
||||
|
||||
const handleTagRenamed = useCallback((oldFullPath, newFullPath) => {
|
||||
if (tagFilter === oldFullPath) {
|
||||
setTagFilter(newFullPath);
|
||||
} else if (tagFilter?.startsWith(oldFullPath + '/')) {
|
||||
setTagFilter(newFullPath + tagFilter.slice(oldFullPath.length));
|
||||
}
|
||||
}, [tagFilter]);
|
||||
|
||||
const handleTagDeleted = useCallback((deletedFullPath) => {
|
||||
if (tagFilter === deletedFullPath || tagFilter?.startsWith(deletedFullPath + '/')) {
|
||||
setTagFilter(null);
|
||||
}
|
||||
}, [tagFilter]);
|
||||
|
||||
const handleEntryTitleChange = useCallback((entryUid, newTitle) => {
|
||||
setEntries(prev => prev.map(e =>
|
||||
e.entry_uid === entryUid ? { ...e, title: newTitle } : e
|
||||
|
|
@ -251,7 +268,7 @@ export default function App() {
|
|||
{resultCount && <><b>{resultCount.split(' ')[0]}</b>{' '}{resultCount.split(' ').slice(1).join(' ')}</>}
|
||||
{tagFilter && (
|
||||
<button className="tag-filter-badge" onClick={handleClearTagFilter}>
|
||||
× {tagFilter}
|
||||
× {humanizeTags ? displayPath(tagFilter) : tagFilter}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
|
|
@ -275,10 +292,15 @@ export default function App() {
|
|||
{view === 'admin' && <AdminView archives={archives} />}
|
||||
{view === 'tags' && (
|
||||
<TagsView
|
||||
archiveId={archiveId}
|
||||
tagNodes={tagNodes}
|
||||
tagFilter={tagFilter}
|
||||
onTagFilterSet={handleTagFilterSet}
|
||||
onViewChange={handleViewChange}
|
||||
onTagRenamed={handleTagRenamed}
|
||||
onTagDeleted={handleTagDeleted}
|
||||
onTagsRefresh={handleTagsRefresh}
|
||||
humanizeTags={humanizeTags}
|
||||
/>
|
||||
)}
|
||||
{view === 'collections' && (
|
||||
|
|
@ -295,6 +317,7 @@ export default function App() {
|
|||
tagNodes={tagNodes}
|
||||
onTagsRefresh={handleTagsRefresh}
|
||||
onEntryTitleChange={handleEntryTitleChange}
|
||||
humanizeTags={humanizeTags}
|
||||
/>
|
||||
</main>
|
||||
<CaptureDialog
|
||||
|
|
|
|||
|
|
@ -58,6 +58,27 @@ export async function removeTag(archiveId, entryUid, tagUid) {
|
|||
if (!resp.ok) throw new Error(`Remove failed (${resp.status})`);
|
||||
}
|
||||
|
||||
export async function renameTag(archiveId, tagUid, name) {
|
||||
const res = await fetch(
|
||||
`/api/archives/${archiveId}/tags/${tagUid}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json(); // returns the updated Tag: { tag_uid, name, slug, full_path }
|
||||
}
|
||||
|
||||
export async function deleteTag(archiveId, tagUid) {
|
||||
const res = await fetch(
|
||||
`/api/archives/${archiveId}/tags/${tagUid}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
}
|
||||
|
||||
export async function fetchRuns(archiveId) {
|
||||
return getJson(`/api/archives/${archiveId}/runs`);
|
||||
}
|
||||
|
|
@ -132,6 +153,15 @@ export async function updateProfile(displayName) {
|
|||
if (!res.ok) throw new Error(await res.text());
|
||||
}
|
||||
|
||||
export async function patchMe(patch) {
|
||||
const res = await fetch('/api/auth/me', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
}
|
||||
|
||||
export async function changePassword(currentPassword, newPassword) {
|
||||
const res = await fetch('/api/auth/me', {
|
||||
method: 'PATCH',
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
import { fetchEntryDetail, fetchEntryTags, assignTag, removeTag, listEntryCollections, updateEntryTitle } from '../api'
|
||||
import { formatTimestamp, formatBytes, valueText, sourceIconSvg } from '../utils'
|
||||
import { formatTimestamp, formatBytes, valueText, sourceIconSvg, displayPath } from '../utils'
|
||||
|
||||
const VIS_LABEL = { 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' }
|
||||
|
||||
|
||||
const ExternalIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M7 17 17 7M9 7h8v8"/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet, tagNodes, onTagsRefresh, onEntryTitleChange }) {
|
||||
export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet, tagNodes, onTagsRefresh, onEntryTitleChange, humanizeTags }) {
|
||||
const [detail, setDetail] = useState(null)
|
||||
const [tags, setTags] = useState([])
|
||||
const [assignInput, setAssignInput] = useState('')
|
||||
|
|
@ -190,7 +191,7 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
|
|||
<div className="tags-wrap">
|
||||
{tags.map(tag => (
|
||||
<span key={tag.tag_uid} className="tag-pill" title={tag.full_path}>
|
||||
{tag.name}
|
||||
{humanizeTags ? displayPath(tag.full_path) : tag.full_path}
|
||||
<button
|
||||
className="remove"
|
||||
title={`Remove tag ${tag.full_path}`}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState, useEffect, useContext, useCallback } from 'react'
|
||||
import { AuthContext } from '../App.jsx'
|
||||
import {
|
||||
updateProfile, changePassword,
|
||||
updateProfile, changePassword, patchMe,
|
||||
listTokens, createToken, deleteToken,
|
||||
getInstanceSettings, updateInstanceSettings,
|
||||
} from '../api.js'
|
||||
|
|
@ -95,6 +95,29 @@ function ProfileTab({ currentUser, setCurrentUser }) {
|
|||
</form>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h2>Display Preferences</h2>
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={currentUser?.humanize_slugs ?? false}
|
||||
onChange={async e => {
|
||||
const checked = e.target.checked;
|
||||
try {
|
||||
await patchMe({ humanize_slugs: checked });
|
||||
setCurrentUser(prev => ({ ...prev, humanize_slugs: checked }));
|
||||
} catch {
|
||||
// silently revert
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="form-label" style={{ margin: 0 }}>Humanize tag display</span>
|
||||
</label>
|
||||
<p className="muted" style={{ fontSize: 13, margin: '4px 0 0' }}>
|
||||
When on, tag paths show as "X / Articles" instead of "x/articles".
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h2>Change Password</h2>
|
||||
<form onSubmit={handleChangePassword}>
|
||||
|
|
|
|||
|
|
@ -1,33 +1,125 @@
|
|||
function TagNode({ node, tagFilter, onTagFilterSet, onViewChange }) {
|
||||
const isActive = tagFilter === node.tag.full_path
|
||||
function handleClick() {
|
||||
const next = isActive ? null : node.tag.full_path
|
||||
onTagFilterSet(next)
|
||||
onViewChange('archive')
|
||||
import { useState, useRef } from 'react';
|
||||
import { renameTag, deleteTag } from '../api';
|
||||
|
||||
function TagNode({ node, archiveId, tagFilter, onTagFilterSet, onViewChange, onTagRenamed, onTagDeleted, onTagsRefresh, humanizeTags }) {
|
||||
const isActive = tagFilter === node.tag.full_path;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState('');
|
||||
const cancelRef = useRef(false);
|
||||
|
||||
function handleFilterClick() {
|
||||
if (editing) return;
|
||||
const next = isActive ? null : node.tag.full_path;
|
||||
onTagFilterSet(next);
|
||||
onViewChange('archive');
|
||||
}
|
||||
|
||||
function startEdit(e) {
|
||||
e.stopPropagation();
|
||||
setDraft(node.tag.slug);
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
async function handleRenameSave() {
|
||||
const value = draft.trim();
|
||||
if (!value || value === node.tag.slug) {
|
||||
setEditing(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const updated = await renameTag(archiveId, node.tag.tag_uid, value);
|
||||
onTagRenamed(node.tag.full_path, updated.full_path);
|
||||
onTagsRefresh();
|
||||
} catch {
|
||||
// silently revert
|
||||
} finally {
|
||||
setEditing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(e) {
|
||||
e.stopPropagation();
|
||||
const msg = node.children?.length > 0
|
||||
? `Delete tag "${node.tag.full_path}" and all its child tags? This cannot be undone.`
|
||||
: `Delete tag "${node.tag.full_path}"? This cannot be undone.`;
|
||||
if (!window.confirm(msg)) return;
|
||||
try {
|
||||
await deleteTag(archiveId, node.tag.tag_uid);
|
||||
onTagDeleted(node.tag.full_path);
|
||||
onTagsRefresh();
|
||||
} catch {
|
||||
// silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
const childProps = { archiveId, tagFilter, onTagFilterSet, onViewChange, onTagRenamed, onTagDeleted, onTagsRefresh, humanizeTags };
|
||||
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
className={`tag-node-btn${isActive ? ' is-active' : ''}`}
|
||||
title={node.tag.full_path}
|
||||
onClick={handleClick}>
|
||||
{node.tag.name}
|
||||
</button>
|
||||
<div className="tag-node-row">
|
||||
{editing ? (
|
||||
<input
|
||||
className="tag-rename-input"
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') e.currentTarget.blur();
|
||||
if (e.key === 'Escape') { cancelRef.current = true; e.currentTarget.blur(); }
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (cancelRef.current) {
|
||||
cancelRef.current = false;
|
||||
setEditing(false);
|
||||
} else {
|
||||
handleRenameSave();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
className={`tag-node-btn${isActive ? ' is-active' : ''}`}
|
||||
title={node.tag.full_path}
|
||||
onClick={handleFilterClick}
|
||||
onDoubleClick={startEdit}
|
||||
>
|
||||
{humanizeTags ? node.tag.name : node.tag.slug}
|
||||
<svg
|
||||
className="edit-icon"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
onClick={e => { e.stopPropagation(); startEdit(e); }}
|
||||
>
|
||||
<path d="M11.5 2.5a1.5 1.5 0 0 1 2 2L5 13l-3 1 1-3 8.5-8.5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="remove tag-node-delete"
|
||||
title={`Delete tag ${node.tag.full_path}`}
|
||||
onClick={handleDelete}
|
||||
aria-label={`Delete tag ${node.tag.full_path}`}
|
||||
>×</button>
|
||||
</div>
|
||||
{node.children?.length > 0 && (
|
||||
<div className="tag-children">
|
||||
<ul className="tag-tree-list">
|
||||
{node.children.map(child => (
|
||||
<TagNode key={child.tag.tag_uid} node={child}
|
||||
tagFilter={tagFilter} onTagFilterSet={onTagFilterSet} onViewChange={onViewChange} />
|
||||
<TagNode key={child.tag.tag_uid} node={child} {...childProps} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default function TagsView({ tagNodes, tagFilter, onTagFilterSet, onViewChange }) {
|
||||
export default function TagsView({ archiveId, tagNodes, tagFilter, onTagFilterSet, onViewChange, onTagRenamed, onTagDeleted, onTagsRefresh, humanizeTags }) {
|
||||
return (
|
||||
<section id="tags-view" className="view is-active">
|
||||
<div className="tag-tree">
|
||||
|
|
@ -43,11 +135,19 @@ export default function TagsView({ tagNodes, tagFilter, onTagFilterSet, onViewCh
|
|||
<ul className="tag-tree-list">
|
||||
{tagNodes.map(node => (
|
||||
<TagNode key={node.tag.tag_uid} node={node}
|
||||
tagFilter={tagFilter} onTagFilterSet={onTagFilterSet} onViewChange={onViewChange} />
|
||||
archiveId={archiveId}
|
||||
tagFilter={tagFilter}
|
||||
onTagFilterSet={onTagFilterSet}
|
||||
onViewChange={onViewChange}
|
||||
onTagRenamed={onTagRenamed}
|
||||
onTagDeleted={onTagDeleted}
|
||||
onTagsRefresh={onTagsRefresh}
|
||||
humanizeTags={humanizeTags}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -673,6 +673,56 @@ select {
|
|||
.tag-node-btn:hover { text-decoration: underline; }
|
||||
.tag-node-btn.is-active { font-weight: 600; color: var(--ink); }
|
||||
|
||||
/* tag-node-row: flex row containing the name button + delete × */
|
||||
.tag-node-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.tag-node-row .tag-node-btn {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: auto;
|
||||
}
|
||||
/* constrain the edit pencil so it doesn't inherit unconstrained SVG dimensions */
|
||||
.tag-node-btn .edit-icon {
|
||||
width: 0.75em;
|
||||
height: 0.75em;
|
||||
flex-shrink: 0;
|
||||
vertical-align: -0.1em;
|
||||
opacity: 0.35;
|
||||
}
|
||||
.tag-node-btn:hover .edit-icon { opacity: 0.65; }
|
||||
/* delete × button next to tag name — small, unobtrusive */
|
||||
.tag-node-delete {
|
||||
flex-shrink: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted-2);
|
||||
cursor: pointer;
|
||||
padding: 0 3px;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
opacity: 0;
|
||||
}
|
||||
.tag-node-row:hover .tag-node-delete { opacity: 1; }
|
||||
.tag-node-delete:hover { color: var(--accent); }
|
||||
/* rename input inside the tag list */
|
||||
.tag-rename-input {
|
||||
font-size: 13px;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
padding: 2px 5px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--r);
|
||||
background: var(--field);
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* ── Collections page (sidebar layout) ──────────────────────────────────── */
|
||||
.collections-view {
|
||||
padding: 24px;
|
||||
|
|
|
|||
|
|
@ -38,3 +38,19 @@ export const SOURCE_ICONS = {
|
|||
export function sourceIconSvg(kind) {
|
||||
return SOURCE_ICONS[kind] ?? SOURCE_ICONS.other;
|
||||
}
|
||||
|
||||
// Humanize each slash-segment of a stored full_path for display.
|
||||
// Matches the backend humanize_slug convention: capitalize first char of each
|
||||
// hyphen-part, replace hyphens with spaces.
|
||||
// e.g. "/x/natural-science" → "/X/Natural Science"
|
||||
// Used only for rendered label text; never mutate state/API values with this.
|
||||
export function displayPath(fullPath) {
|
||||
return fullPath
|
||||
.split('/')
|
||||
.map(seg =>
|
||||
seg
|
||||
? seg.split('-').map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(' ')
|
||||
: ''
|
||||
)
|
||||
.join('/');
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue