1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00

feat: inline entry title renaming (#14)

* feat: inline entry title renaming

- database.rs: add update_entry_title(conn, entry_uid, title) -> Result<bool>
  targets archived_entries table; returns false for unknown uid
- routes.rs: PATCH /api/archives/:archive_id/entries/:entry_uid (ROLE_USER)
  maps false -> 404; 3 tests (401 unauthed, 204+persists, 404 unknown uid)
- api.js: updateEntryTitle(archiveId, entryUid, title)
- ContextRail.jsx: click title h2 -> inline input; Enter/blur commits,
  Escape cancels (cancel ref prevents double-save on blur after Escape);
  pencil icon fades in on hover as edit affordance
- App.jsx: handleEntryTitleChange mutates entries + selectedEntry in-place
- styles.css: rail-title--editable cursor/hover, pencil icon show/hide,
  rail-title-input underline style
- rebuild frontend bundle

* chore: remove + prefix from Capture button label
This commit is contained in:
TheGeneralist 2026-07-03 13:01:34 +02:00 committed by GitHub
parent 1ff91956a1
commit 2502de45b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 258 additions and 50 deletions

View file

@ -178,6 +178,15 @@ export default function App() {
if (archiveId) fetchTags(archiveId).then(setTagNodes)
}, [archiveId])
const handleEntryTitleChange = useCallback((entryUid, newTitle) => {
setEntries(prev => prev.map(e =>
e.entry_uid === entryUid ? { ...e, title: newTitle } : e
))
setSelectedEntry(prev =>
prev && prev.entry_uid === entryUid ? { ...prev, title: newTitle } : prev
)
}, [])
const handleCaptureClick = useCallback(() => {
setCaptureDialogOpen(true)
}, [])
@ -277,6 +286,7 @@ export default function App() {
onTagFilterSet={handleTagFilterSet}
tagNodes={tagNodes}
onTagsRefresh={handleTagsRefresh}
onEntryTitleChange={handleEntryTitleChange}
/>
</main>
<CaptureDialog

View file

@ -25,6 +25,15 @@ export async function fetchEntryDetail(archiveId, entryUid) {
return getJson(`/api/archives/${archiveId}/entries/${entryUid}`);
}
export async function updateEntryTitle(archiveId, entryUid, title) {
const res = await fetch(`/api/archives/${archiveId}/entries/${entryUid}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: title ?? null }),
});
if (!res.ok) throw new Error(await res.text());
}
export async function fetchEntryTags(archiveId, entryUid) {
return getJson(`/api/archives/${archiveId}/entries/${entryUid}/tags`);
}

View file

@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react'
import { fetchEntryDetail, fetchEntryTags, assignTag, removeTag, listEntryCollections } from '../api'
import { fetchEntryDetail, fetchEntryTags, assignTag, removeTag, listEntryCollections, updateEntryTitle } from '../api'
import { formatTimestamp, formatBytes, valueText, sourceIconSvg } from '../utils'
const VIS_LABEL = { 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' }
@ -10,13 +10,16 @@ const ExternalIcon = () => (
</svg>
)
export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet, tagNodes, onTagsRefresh }) {
export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet, tagNodes, onTagsRefresh, onEntryTitleChange }) {
const [detail, setDetail] = useState(null)
const [tags, setTags] = useState([])
const [assignInput, setAssignInput] = useState('')
const [entryCollections, setEntryCollections] = useState([])
const [assignError, setAssignError] = useState('')
const selectSeqRef = useRef(0)
const titleCancelRef = useRef(false)
const [editingTitle, setEditingTitle] = useState(false)
const [titleDraft, setTitleDraft] = useState('')
useEffect(() => {
if (!selectedEntry || !archiveId) {
@ -25,6 +28,9 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
setEntryCollections([])
return
}
setEditingTitle(false)
setTitleDraft('')
titleCancelRef.current = false
const seq = ++selectSeqRef.current
setDetail(null)
setTags([])
@ -40,6 +46,19 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
}).catch(() => {})
}, [selectedEntry, archiveId])
async function handleTitleSave() {
const newTitle = titleDraft.trim() || null
try {
await updateEntryTitle(archiveId, selectedEntry.entry_uid, newTitle)
setDetail(prev => prev ? { ...prev, summary: { ...prev.summary, title: newTitle } } : prev)
onEntryTitleChange?.(selectedEntry.entry_uid, newTitle)
} catch {
// silently revert
} finally {
setEditingTitle(false)
}
}
async function handleAssignTag() {
const path = assignInput.trim()
if (!path || !selectedEntry) return
@ -84,9 +103,33 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
<p className="tags-empty">Loading</p>
) : (
<>
<h2 className="rail-title">
{valueText(detail.summary.title) || valueText(detail.summary.entry_uid)}
</h2>
{editingTitle ? (
<input
className="rail-title-input"
autoFocus
value={titleDraft}
onChange={e => setTitleDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') e.currentTarget.blur()
if (e.key === 'Escape') { titleCancelRef.current = true; e.currentTarget.blur() }
}}
onBlur={() => { if (titleCancelRef.current) { setEditingTitle(false) } else { handleTitleSave() } titleCancelRef.current = false }}
/>
) : (
<h2
className="rail-title rail-title--editable"
title="Click to rename"
onClick={() => {
setTitleDraft(detail.summary.title ?? '')
setEditingTitle(true)
}}
>
{valueText(detail.summary.title) || valueText(detail.summary.entry_uid)}
<svg className="edit-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M11.5 2.5a1.5 1.5 0 0 1 2 2L5 13l-3 1 1-3 8.5-8.5z"/>
</svg>
</h2>
)}
{detail.summary.original_url && (
<a

View file

@ -30,7 +30,7 @@ export default function Topbar({ archives, archiveId, onArchiveChange, view, onV
</button>
))}
</nav>
<button className="capture-button" onClick={onCaptureClick}>+ Capture</button>
<button className="capture-button" onClick={onCaptureClick}>Capture</button>
{currentUser && (
<div className="user-menu">
<span className="user-name">{currentUser.display_name || currentUser.username}</span>

View file

@ -1349,3 +1349,37 @@ select {
}
.entry-table { min-width: 860px; }
}
/* Inline title edit in ContextRail */
.rail-title--editable {
cursor: pointer;
}
.rail-title--editable:hover {
opacity: 0.75;
}
.rail-title--editable .edit-icon {
display: inline-block;
width: 0.75em;
height: 0.75em;
margin-left: 0.35em;
vertical-align: middle;
opacity: 0;
transition: opacity 0.1s;
flex-shrink: 0;
}
.rail-title--editable:hover .edit-icon {
opacity: 0.5;
}
.rail-title-input {
width: 100%;
font: inherit;
font-size: inherit;
font-weight: 600;
background: transparent;
border: none;
border-bottom: 1px solid currentColor;
color: inherit;
padding: 0;
margin: 0 0 8px;
outline: none;
}