Files
TREK/client/src/components/Studio/SaveIndicator.tsx
T
Maurice 30382dc366 feat(studio): a book saved by a newer Studio opens read-only instead of losing what it cannot read
- A book carries schemaRev. The reader that meets a newer one opens the book
  read-only with a reload banner and saves nothing, where it used to drop the
  elements it could not read on the next save.
- The server refuses a save, a restore or a tool edit that would write an older
  revision over a newer book or one newer than itself (409 book_schema_newer),
  on REST and through every book tool alike.
- normalizeBookDocument moves to book-normalize.ts.
- Print waits for photos eagerly and up to 15 s for an export of more than 40
  photos, and skips images without a source.
2026-10-23 14:15:01 +02:00

114 lines
3.7 KiB
TypeScript

import { AlertTriangle, Check, CloudOff, Eye, RefreshCw } from 'lucide-react'
import type { BookRecord } from '@trek/shared'
import type { SaveState } from './useBookStore'
/**
* Whether the book is safe.
*
* Autosave without a sign of it is a promise you are asking someone to take on
* faith, and the moment they do not is the moment they close the tab to check.
* So: a tick when it is written, a spinner while it is being written, and — the
* one that matters — a real choice when someone else got there first.
*
* Quiet by design. This sits in the top bar of an editor, and a status that
* announces every successful save is a status people learn to stop reading,
* which is exactly the wrong habit for the one time it says something else.
*/
export function SaveIndicator({
state, t, onAcceptTheirs, onKeepMine, onRetry, onReload = reloadStudio,
}: {
state: SaveState
t: (k: string) => string
onAcceptTheirs: (current: BookRecord) => void
onKeepMine: (current: BookRecord) => void
onRetry: () => void
/** Load this Studio again, for a book a newer one saved. A full reload by default. */
onReload?: () => void
}) {
if (state.status === 'conflict') {
return (
<div className="st-save is-conflict" role="alert">
<AlertTriangle size={13} />
<span>{t('journey.studio.saveConflict')}</span>
{/*
Two ways out, both stated plainly. There is no merge here and
pretending otherwise would be worse than saying so: one of these
documents is going to be the book.
*/}
<button type="button" onClick={() => onAcceptTheirs(state.current)}>
{t('journey.studio.saveTakeTheirs')}
</button>
<button type="button" onClick={() => onKeepMine(state.current)}>
{t('journey.studio.saveKeepMine')}
</button>
</div>
)
}
if (state.status === 'newer') {
/*
* The one state with a way out the user has to take themselves: a newer
* Studio saved this book, this tab cannot write it without losing what
* that one added, and only a reload brings the newer Studio here. Loud
* like a conflict, because until then nothing done here is kept.
*/
return (
<div className="st-save is-conflict" role="alert">
<AlertTriangle size={13} />
<span>{t('journey.studio.saveNewer')}</span>
<button type="button" onClick={onReload}>
{t('journey.studio.saveReload')}
</button>
</div>
)
}
if (state.status === 'readonly') {
/*
* Not an error and not retryable: this person was invited to read the
* journey, not to write it. Said once, plainly, and left standing — the
* alternative is an editor that looks like it is saving and is not.
*/
return (
<div className="st-save is-error" role="status">
<Eye size={13} />
<span>{t('journey.studio.saveReadOnly')}</span>
</div>
)
}
if (state.status === 'error') {
return (
<button type="button" className="st-save is-error" onClick={onRetry} title={t('journey.studio.saveRetry')}>
<CloudOff size={13} />
<span>{t('journey.studio.saveFailed')}</span>
</button>
)
}
if (state.status === 'saving') {
return (
<div className="st-save">
<RefreshCw size={13} className="st-spin" />
<span>{t('journey.studio.saving')}</span>
</div>
)
}
if (state.status === 'saved') {
return (
<div className="st-save is-quiet">
<Check size={13} />
<span>{t('journey.studio.saved')}</span>
</div>
)
}
return null
}
/** The default way back to a current Studio: the page again, which loads the newest build. */
function reloadStudio() {
window.location.reload()
}