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:
parent
1ff91956a1
commit
2502de45b6
11 changed files with 258 additions and 50 deletions
|
|
@ -752,6 +752,16 @@ pub fn update_user_display_name(conn: &Connection, user_id: i64, display_name: O
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Updates the user-visible title of an archived entry.
|
||||||
|
/// Returns `Ok(true)` if a row was updated, `Ok(false)` if the entry_uid was not found.
|
||||||
|
pub fn update_entry_title(conn: &Connection, entry_uid: &str, title: Option<&str>) -> Result<bool> {
|
||||||
|
let n = conn.execute(
|
||||||
|
"UPDATE archived_entries SET title = ?1 WHERE entry_uid = ?2",
|
||||||
|
params![title, entry_uid],
|
||||||
|
)?;
|
||||||
|
Ok(n > 0)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_user_display_name(conn: &Connection, user_id: i64) -> Result<Option<String>> {
|
pub fn get_user_display_name(conn: &Connection, user_id: i64) -> Result<Option<String>> {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT display_name FROM users WHERE id = ?1",
|
"SELECT display_name FROM users WHERE id = ?1",
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
// POST /api/archives/:id/captures
|
// POST /api/archives/:id/captures
|
||||||
// POST /api/archives/:id/tags
|
// POST /api/archives/:id/tags
|
||||||
// POST/DELETE /api/archives/:id/entries/:uid/tags
|
// POST/DELETE /api/archives/:id/entries/:uid/tags
|
||||||
|
// PATCH /api/archives/:id/entries/:uid
|
||||||
// ADMIN — requires ROLE_ADMIN: (future)
|
// ADMIN — requires ROLE_ADMIN: (future)
|
||||||
// OWNER — requires ROLE_OWNER: (future)
|
// OWNER — requires ROLE_OWNER: (future)
|
||||||
// AUTH_SELF — own resources, require_auth() only:
|
// AUTH_SELF — own resources, require_auth() only:
|
||||||
|
|
@ -214,7 +215,7 @@ pub fn app_with_state(state: AppState) -> Router {
|
||||||
.route("/api/archives/:archive_id/entries/search", get(search_entries_handler))
|
.route("/api/archives/:archive_id/entries/search", get(search_entries_handler))
|
||||||
.route(
|
.route(
|
||||||
"/api/archives/:archive_id/entries/:entry_uid",
|
"/api/archives/:archive_id/entries/:entry_uid",
|
||||||
get(entry_detail),
|
get(entry_detail).patch(patch_entry_handler),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/archives/:archive_id/entries/:entry_uid/artifacts/:artifact_index",
|
"/api/archives/:archive_id/entries/:entry_uid/artifacts/:artifact_index",
|
||||||
|
|
@ -555,6 +556,26 @@ async fn remove_entry_tag_handler(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn patch_entry_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
Path((archive_id, entry_uid)): Path<(String, String)>,
|
||||||
|
Json(body): Json<PatchEntryBody>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
auth_user.require_role(ROLE_USER)?;
|
||||||
|
let mounted = mounted_archive(&state, &archive_id)?;
|
||||||
|
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||||
|
let title = body.title.as_deref().map(|s| {
|
||||||
|
let t = s.trim();
|
||||||
|
if t.is_empty() { None } else { Some(t) }
|
||||||
|
}).flatten();
|
||||||
|
if database::update_entry_title(&conn, &entry_uid, title)? {
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
} else {
|
||||||
|
Err(ApiError::not_found("entry not found"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
#[derive(Debug, serde::Deserialize)]
|
||||||
struct CaptureBody {
|
struct CaptureBody {
|
||||||
locator: String,
|
locator: String,
|
||||||
|
|
@ -577,6 +598,11 @@ struct CreateTokenBody {
|
||||||
name: String,
|
name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct PatchEntryBody {
|
||||||
|
title: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
async fn capture_handler(
|
async fn capture_handler(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
|
|
@ -2733,4 +2759,80 @@ mod tests {
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn patch_entry_title_requires_auth() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let (registry, _, auth_path) = make_test_registry(&dir);
|
||||||
|
let response = app(registry, auth_path)
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("PATCH")
|
||||||
|
.uri("/api/archives/test/entries/nonexistent")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(r#"{"title":"New Title"}"#))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn patch_entry_title_persists_and_reflects_in_list() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let (registry, archive_path, auth_path) = make_test_registry(&dir);
|
||||||
|
let session_cookie = make_test_session(&auth_path);
|
||||||
|
let entry = make_test_entry(&archive_path);
|
||||||
|
|
||||||
|
// PATCH the title
|
||||||
|
let response = app(registry.clone(), auth_path.clone())
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("PATCH")
|
||||||
|
.uri(&format!("/api/archives/test/entries/{}", entry.entry_uid))
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("cookie", &session_cookie)
|
||||||
|
.body(Body::from(r#"{"title":"Renamed Title"}"#))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
// Verify via entry detail
|
||||||
|
let get_resp = app(registry, auth_path)
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri(&format!("/api/archives/test/entries/{}", entry.entry_uid))
|
||||||
|
.header("cookie", &session_cookie)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(get_resp.status(), StatusCode::OK);
|
||||||
|
let json = body_json(get_resp).await;
|
||||||
|
assert_eq!(json["summary"]["title"], "Renamed Title");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn patch_entry_title_returns_404_for_unknown_uid() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let (registry, _, auth_path) = make_test_registry(&dir);
|
||||||
|
let session_cookie = make_test_session(&auth_path);
|
||||||
|
let response = app(registry, auth_path)
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("PATCH")
|
||||||
|
.uri("/api/archives/test/entries/no-such-uid")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("cookie", &session_cookie)
|
||||||
|
.body(Body::from(r#"{"title":"Anything"}"#))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
40
crates/archivr-server/static/assets/index-MmGgQJvV.js
Normal file
40
crates/archivr-server/static/assets/index-MmGgQJvV.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -4,8 +4,8 @@
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Archivr</title>
|
<title>Archivr</title>
|
||||||
<script type="module" crossorigin src="/assets/index-DnX_j2fa.js"></script>
|
<script type="module" crossorigin src="/assets/index-MmGgQJvV.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DXhicc6w.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CprXPmri.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,15 @@ export default function App() {
|
||||||
if (archiveId) fetchTags(archiveId).then(setTagNodes)
|
if (archiveId) fetchTags(archiveId).then(setTagNodes)
|
||||||
}, [archiveId])
|
}, [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(() => {
|
const handleCaptureClick = useCallback(() => {
|
||||||
setCaptureDialogOpen(true)
|
setCaptureDialogOpen(true)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
@ -277,6 +286,7 @@ export default function App() {
|
||||||
onTagFilterSet={handleTagFilterSet}
|
onTagFilterSet={handleTagFilterSet}
|
||||||
tagNodes={tagNodes}
|
tagNodes={tagNodes}
|
||||||
onTagsRefresh={handleTagsRefresh}
|
onTagsRefresh={handleTagsRefresh}
|
||||||
|
onEntryTitleChange={handleEntryTitleChange}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
<CaptureDialog
|
<CaptureDialog
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,15 @@ export async function fetchEntryDetail(archiveId, entryUid) {
|
||||||
return getJson(`/api/archives/${archiveId}/entries/${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) {
|
export async function fetchEntryTags(archiveId, entryUid) {
|
||||||
return getJson(`/api/archives/${archiveId}/entries/${entryUid}/tags`);
|
return getJson(`/api/archives/${archiveId}/entries/${entryUid}/tags`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect, useRef } from 'react'
|
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'
|
import { formatTimestamp, formatBytes, valueText, sourceIconSvg } from '../utils'
|
||||||
|
|
||||||
const VIS_LABEL = { 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' }
|
const VIS_LABEL = { 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' }
|
||||||
|
|
@ -10,13 +10,16 @@ const ExternalIcon = () => (
|
||||||
</svg>
|
</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 [detail, setDetail] = useState(null)
|
||||||
const [tags, setTags] = useState([])
|
const [tags, setTags] = useState([])
|
||||||
const [assignInput, setAssignInput] = useState('')
|
const [assignInput, setAssignInput] = useState('')
|
||||||
const [entryCollections, setEntryCollections] = useState([])
|
const [entryCollections, setEntryCollections] = useState([])
|
||||||
const [assignError, setAssignError] = useState('')
|
const [assignError, setAssignError] = useState('')
|
||||||
const selectSeqRef = useRef(0)
|
const selectSeqRef = useRef(0)
|
||||||
|
const titleCancelRef = useRef(false)
|
||||||
|
const [editingTitle, setEditingTitle] = useState(false)
|
||||||
|
const [titleDraft, setTitleDraft] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedEntry || !archiveId) {
|
if (!selectedEntry || !archiveId) {
|
||||||
|
|
@ -25,6 +28,9 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
|
||||||
setEntryCollections([])
|
setEntryCollections([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
setEditingTitle(false)
|
||||||
|
setTitleDraft('')
|
||||||
|
titleCancelRef.current = false
|
||||||
const seq = ++selectSeqRef.current
|
const seq = ++selectSeqRef.current
|
||||||
setDetail(null)
|
setDetail(null)
|
||||||
setTags([])
|
setTags([])
|
||||||
|
|
@ -40,6 +46,19 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}, [selectedEntry, archiveId])
|
}, [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() {
|
async function handleAssignTag() {
|
||||||
const path = assignInput.trim()
|
const path = assignInput.trim()
|
||||||
if (!path || !selectedEntry) return
|
if (!path || !selectedEntry) return
|
||||||
|
|
@ -84,9 +103,33 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
|
||||||
<p className="tags-empty">Loading…</p>
|
<p className="tags-empty">Loading…</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<h2 className="rail-title">
|
{editingTitle ? (
|
||||||
{valueText(detail.summary.title) || valueText(detail.summary.entry_uid)}
|
<input
|
||||||
</h2>
|
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 && (
|
{detail.summary.original_url && (
|
||||||
<a
|
<a
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ export default function Topbar({ archives, archiveId, onArchiveChange, view, onV
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<button className="capture-button" onClick={onCaptureClick}>+ Capture</button>
|
<button className="capture-button" onClick={onCaptureClick}>Capture</button>
|
||||||
{currentUser && (
|
{currentUser && (
|
||||||
<div className="user-menu">
|
<div className="user-menu">
|
||||||
<span className="user-name">{currentUser.display_name || currentUser.username}</span>
|
<span className="user-name">{currentUser.display_name || currentUser.username}</span>
|
||||||
|
|
|
||||||
|
|
@ -1349,3 +1349,37 @@ select {
|
||||||
}
|
}
|
||||||
.entry-table { min-width: 860px; }
|
.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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue