1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-22 11:15:41 +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:
TheGeneralist 2026-07-03 14:26:45 +02:00 committed by GitHub
parent d6b52ba06c
commit 55f85134df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 699 additions and 69 deletions

View file

@ -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}`}

View file

@ -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}>

View file

@ -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>
)
);
}