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
- Replace browser `alert()` with inline form errors/toasts.
- Show server errors near submit controls.
- Add success toasts for create/update/delete.
- Remove forced reload in auth forms.
- Replace `location.reload()` with state refresh only.
- Keep SPA navigation predictable on setup/login/logout.
- Add drag-and-drop ordering in admin.
- Persist `sort_order` updates.
- Provide keyboard-accessible move controls as fallback.
- Add duplicate/cloning for links.
- Pre-fill form from an existing link.
- Save as new record with unique name validation.
- Add public read-only mode toggle.
- Hide admin entry points and editing affordances for non-admin view.
- [x] Replace browser `alert()` with inline form errors/toasts.
- [x] Show server errors near submit controls.
- [x] Add success toasts for create/update/delete.
- [x] Remove forced reload in auth forms.
- [x] Replace `location.reload()` with state refresh only.
- [x] Keep SPA navigation predictable on setup/login/logout.
- [x] Add drag-and-drop ordering in admin.
- [x] Persist `sort_order` updates.
- [x] Provide keyboard-accessible move controls as fallback.
- [x] Add duplicate/cloning for links.
- [x] Pre-fill form from an existing link.
- [x] Save as new record with unique name validation.
- [x] Add public read-only mode toggle.
- [x] Hide admin entry points and editing affordances for non-admin view.
## P3 - Nice-to-Have

View File

@@ -17,6 +17,7 @@ export default function App() {
const [page, setPage] = useState<Page>('loading');
const [query, setQuery] = useState('');
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() {
const current = await api.request<SetupState>('/api/me');
@@ -29,7 +30,7 @@ export default function App() {
if (!current.current_user) {
setPage(path.startsWith('/admin') ? 'login' : 'dashboard');
} else {
setPage(path.startsWith('/admin') ? 'admin' : 'dashboard');
setPage(path.startsWith('/admin') && !publicMode ? 'admin' : 'dashboard');
}
setLinks(await api.request<LinkItem[]>('/api/links'));
}
@@ -43,7 +44,7 @@ export default function App() {
useEffect(() => {
refresh().catch(() => setPage('setup'));
}, []);
}, [publicMode]);
useEffect(() => {
const handlePopState = () => {
@@ -107,6 +108,14 @@ export default function App() {
query={query}
setQuery={setQuery}
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')}
onLogout={async () => {
await api.request('/api/logout', { method: 'POST' });
@@ -155,6 +164,9 @@ function Dashboard({
query,
setQuery,
canLogout,
canAdmin,
publicMode,
onTogglePublicMode,
onAdmin,
onLogout,
}: {
@@ -162,6 +174,9 @@ function Dashboard({
query: string;
setQuery: (value: string) => void;
canLogout: boolean;
canAdmin: boolean;
publicMode: boolean;
onTogglePublicMode: () => void;
onAdmin: () => void;
onLogout: () => Promise<void>;
}) {
@@ -191,8 +206,11 @@ function Dashboard({
<header className="row-between">
<h1 className="title-sm">Jellomator</h1>
<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}
<button type="button" className="btn-subtle" onClick={onAdmin}>Admin</button>
{canAdmin ? <button type="button" className="btn-subtle" onClick={onAdmin}>Admin</button> : null}
</div>
</header>