Add public read-only mode toggle
All checks were successful
docker / test (push) Successful in 15s
docker / build-and-push (push) Successful in 45s

This commit is contained in:
2026-05-21 19:47:28 +00:00
parent a4b645bab6
commit 0f106e3544
2 changed files with 35 additions and 17 deletions

28
TODO.md
View File

@@ -43,20 +43,20 @@ Concrete follow-up work for Jellomator, prioritized by implementation risk and u
## P2 - UX and Product Improvements ## P2 - UX and Product Improvements
- Replace browser `alert()` with inline form errors/toasts. - [x] Replace browser `alert()` with inline form errors/toasts.
- Show server errors near submit controls. - [x] Show server errors near submit controls.
- Add success toasts for create/update/delete. - [x] Add success toasts for create/update/delete.
- Remove forced reload in auth forms. - [x] Remove forced reload in auth forms.
- Replace `location.reload()` with state refresh only. - [x] Replace `location.reload()` with state refresh only.
- Keep SPA navigation predictable on setup/login/logout. - [x] Keep SPA navigation predictable on setup/login/logout.
- Add drag-and-drop ordering in admin. - [x] Add drag-and-drop ordering in admin.
- Persist `sort_order` updates. - [x] Persist `sort_order` updates.
- Provide keyboard-accessible move controls as fallback. - [x] Provide keyboard-accessible move controls as fallback.
- Add duplicate/cloning for links. - [x] Add duplicate/cloning for links.
- Pre-fill form from an existing link. - [x] Pre-fill form from an existing link.
- Save as new record with unique name validation. - [x] Save as new record with unique name validation.
- Add public read-only mode toggle. - [x] Add public read-only mode toggle.
- Hide admin entry points and editing affordances for non-admin view. - [x] Hide admin entry points and editing affordances for non-admin view.
## P3 - Nice-to-Have ## P3 - Nice-to-Have

View File

@@ -17,6 +17,7 @@ export default function App() {
const [page, setPage] = useState<Page>('loading'); const [page, setPage] = useState<Page>('loading');
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [toast, setToast] = useState<{ message: string; tone: 'success' | 'error' } | null>(null); const [toast, setToast] = useState<{ message: string; tone: 'success' | 'error' } | null>(null);
const [publicMode, setPublicMode] = useState<boolean>(() => window.localStorage.getItem('public_mode') === '1');
async function refresh() { async function refresh() {
const current = await api.request<SetupState>('/api/me'); const current = await api.request<SetupState>('/api/me');
@@ -29,7 +30,7 @@ export default function App() {
if (!current.current_user) { if (!current.current_user) {
setPage(path.startsWith('/admin') ? 'login' : 'dashboard'); setPage(path.startsWith('/admin') ? 'login' : 'dashboard');
} else { } else {
setPage(path.startsWith('/admin') ? 'admin' : 'dashboard'); setPage(path.startsWith('/admin') && !publicMode ? 'admin' : 'dashboard');
} }
setLinks(await api.request<LinkItem[]>('/api/links')); setLinks(await api.request<LinkItem[]>('/api/links'));
} }
@@ -43,7 +44,7 @@ export default function App() {
useEffect(() => { useEffect(() => {
refresh().catch(() => setPage('setup')); refresh().catch(() => setPage('setup'));
}, []); }, [publicMode]);
useEffect(() => { useEffect(() => {
const handlePopState = () => { const handlePopState = () => {
@@ -107,6 +108,14 @@ export default function App() {
query={query} query={query}
setQuery={setQuery} setQuery={setQuery}
canLogout={Boolean(state?.current_user)} canLogout={Boolean(state?.current_user)}
canAdmin={Boolean(state?.current_user) && !publicMode}
publicMode={publicMode}
onTogglePublicMode={() => {
const next = !publicMode;
setPublicMode(next);
window.localStorage.setItem('public_mode', next ? '1' : '0');
if (next && window.location.pathname.startsWith('/admin')) nav('/');
}}
onAdmin={() => nav('/admin')} onAdmin={() => nav('/admin')}
onLogout={async () => { onLogout={async () => {
await api.request('/api/logout', { method: 'POST' }); await api.request('/api/logout', { method: 'POST' });
@@ -155,6 +164,9 @@ function Dashboard({
query, query,
setQuery, setQuery,
canLogout, canLogout,
canAdmin,
publicMode,
onTogglePublicMode,
onAdmin, onAdmin,
onLogout, onLogout,
}: { }: {
@@ -162,6 +174,9 @@ function Dashboard({
query: string; query: string;
setQuery: (value: string) => void; setQuery: (value: string) => void;
canLogout: boolean; canLogout: boolean;
canAdmin: boolean;
publicMode: boolean;
onTogglePublicMode: () => void;
onAdmin: () => void; onAdmin: () => void;
onLogout: () => Promise<void>; onLogout: () => Promise<void>;
}) { }) {
@@ -191,8 +206,11 @@ function Dashboard({
<header className="row-between"> <header className="row-between">
<h1 className="title-sm">Jellomator</h1> <h1 className="title-sm">Jellomator</h1>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button type="button" className="btn-subtle" onClick={onTogglePublicMode}>
{publicMode ? 'Exit public mode' : 'Public mode'}
</button>
{canLogout ? <button type="button" className="btn-subtle" onClick={onLogout}>Logout</button> : null} {canLogout ? <button type="button" className="btn-subtle" onClick={onLogout}>Logout</button> : null}
<button type="button" className="btn-subtle" onClick={onAdmin}>Admin</button> {canAdmin ? <button type="button" className="btn-subtle" onClick={onAdmin}>Admin</button> : null}
</div> </div>
</header> </header>