mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-22 11:15:41 +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:
parent
1ff91956a1
commit
2502de45b6
11 changed files with 258 additions and 50 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue