Reported from a phone, all of it: - The menu button opened a drawer and then did nothing. Pressing the same button is how a thumb closes a drawer; the only way out was the strip of page beside it. It toggles now — chats, contents, a session's questions and a finished attempt's rail, all four. - AI Mode's chat list started at the top of the window, so its first row sat behind the header: unreadable, untappable, and covering the button that would have closed it. It starts below the header now, the way an article's contents already did, and the measurement they share is one hook rather than two. - The star that saves an article hung its panel from its right edge. That star is the first thing in the reading bar, so on a phone two hundred pixels of the panel were off the left of the screen, over the title. It measures and picks a side. - Cited questions were listed under "Sources". A question is not something you read, it is something you sit — so it stays out of the list and out of the count, and still counts towards the session the button builds. - The session offer counted its questions out loud, which invites haggling over a number the learner does not set. "Practise this", then "Your session is ready". Twenty is the cap, as it was. - Asked for five questions, the model explained itself: how many it had looked at, what it could go and fetch. It is now told to ignore the number, not to apologise for it, not to offer to find more, and to say the same thing again if asked again. Also: AI refine is off the reading page. Drafting is drafting — it belongs in the editor, next to Save, not on the page a learner is reading. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
156 lines
6.6 KiB
JavaScript
156 lines
6.6 KiB
JavaScript
import { useEffect, useRef, useState } from 'react'
|
|
import api from '../api/client'
|
|
import './ArticleSaveButton.css'
|
|
|
|
/**
|
|
* Keep this article.
|
|
*
|
|
* A library holds both articles and questions, because putting one of each
|
|
* aside is the same act to whoever is doing it. The article goes in as itself
|
|
* rather than standing in for the questions written against it — a topic with
|
|
* no questions is still worth keeping, and a reader who saved the reading did
|
|
* not ask for a session.
|
|
*
|
|
* Which libraries already hold it is asked of the server, as one question. It
|
|
* was kept on the device for a while, because the API could not answer; that
|
|
* was wrong on the second machine and silently so.
|
|
*/
|
|
//: What the panel is wide enough to want. Matches `width` in the stylesheet.
|
|
const PANEL = 280
|
|
|
|
export default function ArticleSaveButton({ articleId }) {
|
|
const [open, setOpen] = useState(false)
|
|
// Which edge the panel hangs from. It hung from the right always, which is
|
|
// right for a star at the right of a toolbar and wrong for this one, which
|
|
// is the first thing in the article's bar: on a phone 200px of the panel sat
|
|
// off the left of the screen, over the title, unreadable.
|
|
const [side, setSide] = useState('right')
|
|
const [libraries, setLibraries] = useState([])
|
|
const [query, setQuery] = useState('')
|
|
const [busy, setBusy] = useState(false)
|
|
const [saved, setSaved] = useState([])
|
|
const wrap = useRef(null)
|
|
const anchor = useRef(null)
|
|
|
|
// Asked once per article rather than per library: fetching each library's
|
|
// contents to find out would also stamp every one of them as used and
|
|
// reorder the reader's own list.
|
|
useEffect(() => {
|
|
if (!articleId) return undefined
|
|
let live = true
|
|
api.get(`/collections/for-article/${articleId}`)
|
|
.then(res => { if (live) setSaved(res.data?.collection_ids || []) })
|
|
.catch(() => { if (live) setSaved([]) })
|
|
return () => { live = false }
|
|
}, [articleId])
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
api.get('/collections/').then(res => setLibraries(Array.isArray(res.data) ? res.data : []))
|
|
.catch(() => setLibraries([]))
|
|
}, [open])
|
|
|
|
// A popover that outlives the click that dismissed it is a popover you have
|
|
// to close twice.
|
|
useEffect(() => {
|
|
if (!open) return undefined
|
|
const away = (event) => { if (!wrap.current?.contains(event.target)) setOpen(false) }
|
|
document.addEventListener('mousedown', away)
|
|
return () => document.removeEventListener('mousedown', away)
|
|
}, [open])
|
|
|
|
const saveInto = async (collectionId) => {
|
|
setBusy(true)
|
|
try {
|
|
await api.put(`/collections/${collectionId}/articles/${articleId}`)
|
|
setSaved(prev => (prev.includes(collectionId) ? prev : [...prev, collectionId]))
|
|
setQuery('')
|
|
} catch { /* nothing is marked, which is the honest signal */ }
|
|
finally { setBusy(false) }
|
|
}
|
|
|
|
// Putting it back is the same control, so the star is a toggle per library
|
|
// rather than a one-way door with the undo somewhere else.
|
|
const removeFrom = async (collectionId) => {
|
|
setBusy(true)
|
|
try {
|
|
await api.delete(`/collections/${collectionId}/articles/${articleId}`)
|
|
setSaved(prev => prev.filter(id => id !== collectionId))
|
|
} catch { /* it stays marked, which is what the server still believes */ }
|
|
finally { setBusy(false) }
|
|
}
|
|
|
|
const createAndSave = async (title) => {
|
|
const name = title.trim()
|
|
if (!name) return
|
|
setBusy(true)
|
|
try {
|
|
const made = await api.post('/collections/', { title: name })
|
|
setLibraries(list => [...list, made.data])
|
|
setBusy(false)
|
|
await saveInto(made.data.id)
|
|
} catch { setBusy(false) }
|
|
}
|
|
|
|
const needle = query.trim().toLowerCase()
|
|
const found = libraries.filter(row => row.title.toLowerCase().includes(needle))
|
|
// Offered only when nothing you already have is called this — two libraries
|
|
// of the same name is a filing system nobody can use.
|
|
const canCreate = !!needle && !libraries.some(row => row.title.trim().toLowerCase() === needle)
|
|
const isSaved = saved.length > 0
|
|
|
|
return (
|
|
<span className="asave" ref={wrap}>
|
|
<button type="button" ref={anchor} className={`article-tool${isSaved ? ' is-on' : ''}`}
|
|
aria-expanded={open} aria-haspopup="dialog"
|
|
aria-label={isSaved ? `Saved to ${saved.length} librar${saved.length === 1 ? 'y' : 'ies'}` : 'Save this article to a library'}
|
|
onClick={() => {
|
|
// Measured when it opens rather than guessed: the same star sits at
|
|
// the left of the reading bar and could sit anywhere else later.
|
|
const box = anchor.current?.getBoundingClientRect?.()
|
|
if (box) setSide(box.right - PANEL < 8 ? 'left' : 'right')
|
|
setOpen(v => !v)
|
|
}}>
|
|
<span aria-hidden="true">{isSaved ? '★' : '☆'}</span>
|
|
</button>
|
|
{open && (
|
|
<div className={`asave-pop is-${side}`} role="dialog" aria-label="Save this article to a library">
|
|
<p className="asave-title">Save this article</p>
|
|
{/* One box for both. Searching what you have and naming what you do
|
|
not are the same act — you type the name of the library you want
|
|
and it either exists or it doesn't. */}
|
|
<input className="asave-find" value={query} disabled={busy}
|
|
placeholder="Create or find a library" aria-label="Create or find a library"
|
|
onChange={event => setQuery(event.target.value)}
|
|
onKeyDown={event => {
|
|
if (event.key === 'Enter' && canCreate) { event.preventDefault(); createAndSave(query) }
|
|
}} />
|
|
{canCreate && (
|
|
<button type="button" className="asave-new" disabled={busy} onClick={() => createAndSave(query)}>
|
|
<span>{query.trim()}</span><span aria-hidden="true">+</span>
|
|
</button>
|
|
)}
|
|
{found.length > 0 && (
|
|
<ul className="asave-list">
|
|
{found.map(row => {
|
|
const inIt = saved.includes(row.id)
|
|
return (
|
|
<li key={row.id}>
|
|
<button type="button" className={inIt ? 'is-in' : ''} disabled={busy}
|
|
aria-pressed={inIt}
|
|
onClick={() => (inIt ? removeFrom(row.id) : saveInto(row.id))}>
|
|
{inIt ? '✓ ' : '+ '}{row.title}
|
|
</button>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
)}
|
|
{!found.length && !canCreate && (
|
|
<p className="asave-empty">{libraries.length ? 'No library by that name.' : 'Type a name to make your first library.'}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</span>
|
|
)
|
|
}
|