espial-0.0.34: frontend/src/components/BMark.tsx
import React from 'react';
import { archiveBookmark, destroy, editBookmark, lookupTitle, markRead, toggleStar } from '../api';
import { app, setFocus, toLocaleDateString } from '../globals';
import { useTagSuggestions } from '../hooks/useTagSuggestions';
import type { Bookmark } from '../types';
import { encodeTag, fromNullableStr, normalizeTags } from '../util';
import { Markdown } from './Markdown';
import { TagSuggestionsDropdown } from './TagSuggestionsDropdown';
/** Displays a single bookmark with inline editing, starring, and deletion. */
export function BMark({
initial,
onNotifyRemove,
onUpdated,
}: {
initial: Bookmark;
onNotifyRemove: () => void;
onUpdated: (bm: Bookmark) => void;
}) {
const a = app();
const [bm, setBm] = React.useState<Bookmark>(initial);
const [editBm, setEditBm] = React.useState<Bookmark>(initial);
const [deleteAsk, setDeleteAsk] = React.useState(false);
const [edit, setEdit] = React.useState(false);
const [loading, setLoading] = React.useState(false);
const [archiving, setArchiving] = React.useState(false);
const [apiError, setApiError] = React.useState<string | null>(null);
const tagInputId = `${bm.bid.toString()}_tags`;
const linkToFilterSingle = (slug: string) => `${fromNullableStr(a.userR)}/b:${slug}`;
const linkToFilterTag = (tag: string) => `${fromNullableStr(a.userR)}/t:${encodeTag(tag)}`;
const shdate = toLocaleDateString(bm.time);
const shdatetime = `${bm.time.slice(0, 16)}Z`;
const suggestEnabled = a.dat.suggestTags === true;
const {
tagInputRef,
suggestionState,
closeSuggestions,
onTagsChange,
onTagsKeyDown,
onTagsSelect,
onSuggestionHover,
onSuggestionPick,
} = useTagSuggestions({
enabled: suggestEnabled,
tags: editBm.tags,
onTagsUpdate: (tags) => {
setEditBm((x) => ({ ...x, tags }));
},
});
async function onStar(next: boolean) {
await toggleStar(bm.bid, next ? 'star' : 'unstar');
const updated = { ...bm, selected: next };
setBm(updated);
setEditBm((x) => ({ ...x, selected: next }));
onUpdated(updated);
}
async function onDestroy() {
await destroy(bm.bid);
onNotifyRemove();
}
async function onMarkRead() {
await markRead(bm.bid);
const updated = { ...bm, toread: false };
setBm(updated);
onUpdated(updated);
}
function startEdit(next: boolean) {
setEditBm(bm);
setEdit(next);
setApiError(null);
setArchiving(false);
closeSuggestions();
if (next) setFocus(tagInputId);
}
async function onFetchTitle() {
setLoading(true);
try {
const title = await lookupTitle(editBm);
if (title != null) setEditBm((x) => ({ ...x, title }));
} finally {
setLoading(false);
}
}
async function onArchive() {
setArchiving(true);
await archiveBookmark(bm.bid);
}
const onSubmit = async (e: React.SyntheticEvent<HTMLFormElement, SubmitEvent>): Promise<void> => {
e.preventDefault();
setApiError(null);
const editBm2 = { ...editBm, tags: normalizeTags(editBm.tags) };
const res = await editBookmark(editBm2);
if (res.ok && res.status >= 200 && res.status < 300) {
setBm(editBm2);
setEdit(false);
onUpdated(editBm2);
} else {
setApiError(res.bodyText);
}
};
const archivingDisabled = archiving || editBm.private || bm.url !== editBm.url;
return (
<div
id={String(bm.bid)}
className={`bookmark w-100 mw7 pa1 mb3${bm.private ? ' private' : ''}`}
>
{a.dat.isowner ? (
<div className={`star fl pointer${bm.selected ? ' selected' : ''}`}>
<button
className="thm-text-border"
onClick={() => void onStar(!bm.selected)}
type="button"
>
✭
</button>
</div>
) : null}
{!edit ? (
<div className="display">
<a
href={bm.url}
target="_blank"
rel="noreferrer"
className={`link f5 lh-title${bm.toread ? ' unread' : ''}`}
>
{bm.title === '' ? '[no title]' : bm.title}
</a>
<br />
<a href={bm.url} className="link f7 thm-text-tertiary thm-hover-link-color">
{bm.url}
</a>
{bm.archiveUrl ? (
<a
href={bm.archiveUrl}
className="link f7 thm-text-tertiary thm-hover-link-color ml2 thm-text-success"
target="_blank"
rel="noreferrer"
title="archive link"
>
☑
</a>
) : null}
<br />
<div className="description mt1 thm-text-secondary">
<Markdown text={bm.description} />
</div>
<div className="tags">
{bm.tags !== ''
? bm.tags.split(' ').map((tag) => (
<a
key={tag}
className={`link tag mr1${tag.slice(0, 1) === '.' ? ' private' : ''}`}
href={linkToFilterTag(tag)}
>
{tag}
</a>
))
: null}
</div>
<a
className="link f7 di mr5 thm-text-tertiary"
href={linkToFilterSingle(bm.slug)}
title={shdatetime}
>
{shdate}
</a>
{a.dat.isowner ? (
<div className="edit_links di">
<button
type="button"
onClick={() => {
startEdit(true);
}}
className="edit thm-text-muted thm-hover-link-color"
>
edit
</button>
<div className="delete_link di">
<button
type="button"
onClick={() => {
setDeleteAsk(true);
}}
className={`delete thm-text-muted thm-hover-link-color${deleteAsk ? ' dn' : ''}`}
>
delete
</button>
<span className={`confirm thm-text-error${!deleteAsk ? ' dn' : ''}`}>
<button
type="button"
onClick={() => {
setDeleteAsk(false);
}}
>
cancel /
</button>
<button type="button" onClick={() => void onDestroy()} className="thm-text-error">
destroy
</button>
</span>
</div>
</div>
) : null}
{a.dat.isowner && bm.toread ? (
<div className="read di">
<button onClick={() => void onMarkRead()} className="mark_read" type="button">
mark as read
</button>
</div>
) : null}
</div>
) : (
<div className="edit_bookmark_form pa2 pt0 thm-bg-surface">
{apiError ? <div className="alert alert-err">{apiError}</div> : null}
<form onSubmit={(e) => void onSubmit(e)}>
<div>url</div>
<input
type="url"
className="url w-100 mb2 pt1 edit_form_input"
required
name="url"
value={editBm.url}
onChange={(e) => {
setEditBm((x) => ({ ...x, url: e.target.value }));
}}
/>
<div>title</div>
<div className="flex">
<input
type="text"
className="title w-100 mb2 pt1 edit_form_input"
name="title"
value={editBm.title}
onChange={(e) => {
setEditBm((x) => ({ ...x, title: e.target.value }));
}}
/>
<button
disabled={loading}
type="button"
onClick={() => void onFetchTitle()}
className={`ml1 pa1 mb2 thm-text-primary ba thm-border-default thm-bg-secondary pointer rdim f7${
loading ? ' thm-bg-disabled' : ''
}`}
>
fetch
</button>
</div>
<div>description</div>
<textarea
className="description w-100 mb1 pt1 edit_form_input"
name="description"
rows={5}
value={editBm.description}
onChange={(e) => {
setEditBm((x) => ({ ...x, description: e.target.value }));
}}
/>
<div id="tags_input_box">
<div>tags</div>
<div className="relative">
<input
id={tagInputId}
ref={tagInputRef}
type="text"
className="tags w-100 mb1 pt1 edit_form_input"
name="tags"
autoComplete="off"
autoCapitalize="off"
value={editBm.tags}
onChange={onTagsChange}
onKeyDown={onTagsKeyDown}
onClick={onTagsSelect}
onSelect={onTagsSelect}
onBlur={() => {
window.setTimeout(() => {
closeSuggestions();
}, 0);
}}
/>
{suggestionState != null ? (
<TagSuggestionsDropdown
suggestionState={suggestionState}
onHover={onSuggestionHover}
onPick={onSuggestionPick}
/>
) : null}
</div>
</div>
<div className="edit_form_checkboxes mv3">
<input
type="checkbox"
className="private pointer"
id="edit_private"
name="private"
checked={editBm.private}
onChange={(e) => {
setEditBm((x) => ({ ...x, private: e.target.checked }));
}}
/>{' '}
<label htmlFor="edit_private" className="mr2">
private
</label>{' '}
<input
type="checkbox"
className="toread pointer"
id="edit_toread"
name="toread"
checked={editBm.toread}
onChange={(e) => {
setEditBm((x) => ({ ...x, toread: e.target.checked }));
}}
/>{' '}
<label htmlFor="edit_toread">to-read</label>
</div>
<div className="flex justify-between items-center">
<div>
<input
type="submit"
className="mr1 pv1 ph2 thm-text-primary ba thm-border-default thm-bg-secondary pointer rdim"
value="save"
/>{' '}
<input
type="reset"
className="pv1 ph2 thm-text-primary ba thm-border-default thm-bg-secondary pointer rdim"
value="cancel"
onClick={() => {
startEdit(false);
}}
/>
</div>
{a.dat.archiveBackendEnabled ? (
<button
type="button"
disabled={archivingDisabled}
onClick={() => void onArchive()}
className={`pv1 ph2 thm-text-primary ba thm-border-default thm-bg-secondary${archiving ? ' thm-bg-disabled' : ''}${archivingDisabled ? ' not-allowed o-20' : ''}`}
>
{archiving ? 'archiving' : 'archive'}
</button>
) : null}
</div>
</form>
</div>
)}
</div>
);
}