Compare commits

6 Commits

Author SHA1 Message Date
3969d3a0c6 Merge pull request 'ci: verify Docker image build on pull requests' (#1) from chore/pr-docker-build-verify into main
All checks were successful
docker / test (push) Successful in 11s
docker / build-and-push (push) Successful in 45s
docker / build-verify-pr (push) Has been skipped
Reviewed-on: #1
2026-05-22 22:19:17 +02:00
1edb89884b ci: verify Docker image build on pull requests
All checks were successful
docker / test (pull_request) Successful in 37s
docker / build-and-push (pull_request) Has been skipped
docker / build-verify-pr (pull_request) Successful in 40s
2026-05-22 13:52:40 +00:00
0f106e3544 Add public read-only mode toggle
All checks were successful
docker / test (push) Successful in 15s
docker / build-and-push (push) Successful in 45s
2026-05-21 19:47:28 +00:00
a4b645bab6 Add duplicate link action with unique name prefill 2026-05-21 19:46:34 +00:00
87c610b8d7 Remove auth form reload and keep SPA navigation 2026-05-21 19:46:16 +00:00
58f7702074 Add drag-drop and keyboard link ordering 2026-05-21 19:45:51 +00:00
5 changed files with 180 additions and 23 deletions

View File

@@ -6,6 +6,11 @@ on:
paths-ignore: paths-ignore:
- '**/*.md' - '**/*.md'
- '**/*.txt' - '**/*.txt'
pull_request:
branches: [main]
paths-ignore:
- '**/*.md'
- '**/*.txt'
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -44,6 +49,7 @@ jobs:
build-and-push: build-and-push:
needs: test needs: test
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -134,3 +140,19 @@ jobs:
print(f"link failed: status={exc.code} body={body}") print(f"link failed: status={exc.code} body={body}")
raise raise
PY PY
build-verify-pr:
needs: test
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: false
build-args: |
VCS_REF=${{ github.sha }}
VCS_URL=${{ github.server_url }}/${{ github.repository }}

30
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
@@ -65,4 +65,4 @@ Concrete follow-up work for Jellomator, prioritized by implementation risk and u
- Add JSON import/export for services with icons. - Add JSON import/export for services with icons.
- Add keyboard shortcuts for search/quick launch. - Add keyboard shortcuts for search/quick launch.
- Add Open Graph metadata and richer SEO tags. - Add Open Graph metadata and richer SEO tags.
- Add CI verification that builds container image for pull requests. - [x] Add CI verification that builds container image for pull requests.

View File

@@ -198,6 +198,15 @@ class RestoreIn(BaseModel):
confirm: bool = False confirm: bool = False
class ReorderItem(BaseModel):
id: int
sort_order: int
class ReorderIn(BaseModel):
items: list[ReorderItem]
def utc_now_iso() -> str: def utc_now_iso() -> str:
return datetime.utcnow().isoformat() return datetime.utcnow().isoformat()
@@ -637,6 +646,32 @@ def update_link(
return {"ok": True} return {"ok": True}
@app.patch("/api/links/order")
def reorder_links(request: Request, inp: ReorderIn):
require_admin(request)
require_csrf(request)
if not inp.items:
raise HTTPException(422, "items must not be empty")
seen: set[int] = set()
with db() as c:
c.begin()
try:
with c.cursor() as cur:
for item in inp.items:
if item.id in seen:
raise HTTPException(422, "duplicate link id in reorder payload")
seen.add(item.id)
cur.execute("update links set sort_order=%s,updated_at=%s where id=%s", (item.sort_order, utc_now_db(), item.id))
if cur.rowcount == 0:
raise HTTPException(404, f"link {item.id} not found")
c.commit()
except Exception:
c.rollback()
raise
log_event(request, "links.reorder", count=len(inp.items))
return {"ok": True}
@app.get("/api/admin/backup") @app.get("/api/admin/backup")
def backup(request: Request): def backup(request: Request):
require_admin(request) require_admin(request)

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 = () => {
@@ -84,6 +85,14 @@ export default function App() {
showToast('Link deleted.'); showToast('Link deleted.');
await refresh(); await refresh();
}} }}
onReorder={async (items) => {
await api.request('/api/links/order', {
method: 'PATCH',
body: JSON.stringify({ items: items.map((item, index) => ({ id: item.id, sort_order: index })) }),
});
showToast('Order saved.');
await refresh();
}}
showToast={showToast} showToast={showToast}
/> />
</div> </div>
@@ -99,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' });
@@ -147,6 +164,9 @@ function Dashboard({
query, query,
setQuery, setQuery,
canLogout, canLogout,
canAdmin,
publicMode,
onTogglePublicMode,
onAdmin, onAdmin,
onLogout, onLogout,
}: { }: {
@@ -154,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>;
}) { }) {
@@ -183,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>
@@ -254,6 +280,7 @@ function SetupPage({ onDone }: { onDone: () => Promise<void> }) {
action="Create admin" action="Create admin"
onSubmit={async (fd) => { onSubmit={async (fd) => {
await api.request('/api/setup', { method: 'POST', body: JSON.stringify(Object.fromEntries(fd)) }); await api.request('/api/setup', { method: 'POST', body: JSON.stringify(Object.fromEntries(fd)) });
nav('/admin');
await onDone(); await onDone();
}} }}
fields={['username', 'password']} fields={['username', 'password']}
@@ -268,7 +295,6 @@ function LoginPage({ onDone }: { onDone: () => Promise<void> }) {
action="Sign in" action="Sign in"
onSubmit={async (fd) => { onSubmit={async (fd) => {
await api.request('/api/login', { method: 'POST', body: JSON.stringify(Object.fromEntries(fd)) }); await api.request('/api/login', { method: 'POST', body: JSON.stringify(Object.fromEntries(fd)) });
nav('/');
await onDone(); await onDone();
}} }}
fields={['username', 'password']} fields={['username', 'password']}
@@ -287,20 +313,28 @@ function AuthCard({
fields: string[]; fields: string[];
onSubmit: (fd: FormData) => Promise<void>; onSubmit: (fd: FormData) => Promise<void>;
}) { }) {
const [submitError, setSubmitError] = useState<string | null>(null);
return ( return (
<div className="grid min-h-screen place-items-center p-4"> <div className="grid min-h-screen place-items-center p-4">
<form <form
className="panel w-full max-w-sm space-y-3" className="panel w-full max-w-sm space-y-3"
onSubmit={async (e) => { onSubmit={async (e) => {
e.preventDefault(); e.preventDefault();
setSubmitError(null);
try {
await onSubmit(new FormData(e.currentTarget)); await onSubmit(new FormData(e.currentTarget));
location.reload(); } catch (err) {
const message = err instanceof Error ? err.message : String(err);
setSubmitError(message || 'Request failed. Please try again.');
}
}} }}
> >
<h1 className="title-sm">{title}</h1> <h1 className="title-sm">{title}</h1>
{fields.map((f) => ( {fields.map((f) => (
<input key={f} name={f} type={f === 'password' ? 'password' : 'text'} className="input" placeholder={f} required /> <input key={f} name={f} type={f === 'password' ? 'password' : 'text'} className="input" placeholder={f} required />
))} ))}
{submitError ? <p className="text-xs text-rose-400">{submitError}</p> : null}
<button className="btn-subtle w-full" type="submit">{action}</button> <button className="btn-subtle w-full" type="submit">{action}</button>
</form> </form>
</div> </div>
@@ -311,11 +345,13 @@ function AdminPage({
links, links,
onSave, onSave,
onDelete, onDelete,
onReorder,
showToast, showToast,
}: { }: {
links: LinkItem[]; links: LinkItem[];
onSave: (payload: LinkForm, file?: File | null, editingId?: number) => Promise<void>; onSave: (payload: LinkForm, file?: File | null, editingId?: number) => Promise<void>;
onDelete: (id: number) => Promise<void>; onDelete: (id: number) => Promise<void>;
onReorder: (items: LinkItem[]) => Promise<void>;
showToast: (message: string, tone?: 'success' | 'error') => void; showToast: (message: string, tone?: 'success' | 'error') => void;
}) { }) {
const [form, setForm] = useState<LinkForm>(emptyForm()); const [form, setForm] = useState<LinkForm>(emptyForm());
@@ -323,6 +359,12 @@ function AdminPage({
const [editingId, setEditingId] = useState<number | null>(null); const [editingId, setEditingId] = useState<number | null>(null);
const [restoreText, setRestoreText] = useState(''); const [restoreText, setRestoreText] = useState('');
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
const [orderedLinks, setOrderedLinks] = useState<LinkItem[]>(links);
const [draggingId, setDraggingId] = useState<number | null>(null);
useEffect(() => {
setOrderedLinks(links);
}, [links]);
const startEdit = (link: LinkItem) => { const startEdit = (link: LinkItem) => {
setEditingId(link.id); setEditingId(link.id);
@@ -337,12 +379,57 @@ function AdminPage({
}); });
}; };
const startDuplicate = (link: LinkItem) => {
const existingNames = new Set(orderedLinks.map((item) => item.name.toLowerCase()));
const base = `${link.name} (copy)`;
let candidate = base;
let counter = 2;
while (existingNames.has(candidate.toLowerCase())) {
candidate = `${base} ${counter}`;
counter += 1;
}
setEditingId(null);
setFile(null);
setForm({
name: candidate,
url: link.url,
description: link.description,
category: link.category,
icon_url: link.icon_url ?? '',
enabled: link.enabled,
});
};
const reset = () => { const reset = () => {
setEditingId(null); setEditingId(null);
setFile(null); setFile(null);
setForm(emptyForm()); setForm(emptyForm());
}; };
const moveBy = async (id: number, delta: number) => {
const index = orderedLinks.findIndex((link) => link.id === id);
const nextIndex = index + delta;
if (index < 0 || nextIndex < 0 || nextIndex >= orderedLinks.length) return;
const next = [...orderedLinks];
const [item] = next.splice(index, 1);
next.splice(nextIndex, 0, item);
setOrderedLinks(next);
await onReorder(next);
};
const handleDropOn = async (targetId: number) => {
if (draggingId === null || draggingId === targetId) return;
const from = orderedLinks.findIndex((link) => link.id === draggingId);
const to = orderedLinks.findIndex((link) => link.id === targetId);
if (from < 0 || to < 0) return;
const next = [...orderedLinks];
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
setOrderedLinks(next);
setDraggingId(null);
await onReorder(next);
};
return ( return (
<div className="mt-4 grid gap-4 lg:grid-cols-[320px_1fr]"> <div className="mt-4 grid gap-4 lg:grid-cols-[320px_1fr]">
<div className="space-y-4"> <div className="space-y-4">
@@ -445,13 +532,25 @@ function AdminPage({
<div className="panel"> <div className="panel">
<ul className="admin-list"> <ul className="admin-list">
{links.map((link) => ( {orderedLinks.map((link, index) => (
<li key={link.id} className="admin-row"> <li
key={link.id}
className="admin-row"
draggable
onDragStart={() => setDraggingId(link.id)}
onDragOver={(event) => event.preventDefault()}
onDrop={async () => {
await handleDropOn(link.id);
}}
>
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-sm text-slate-100">{link.name}</div> <div className="truncate text-sm text-slate-100">{link.name}</div>
<div className="truncate text-xs text-slate-500">{link.category || 'General'} | {link.enabled ? 'enabled' : 'disabled'}</div> <div className="truncate text-xs text-slate-500">{link.category || 'General'} | {link.enabled ? 'enabled' : 'disabled'}</div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button type="button" className="btn-subtle" onClick={() => moveBy(link.id, -1)} disabled={index === 0}>Up</button>
<button type="button" className="btn-subtle" onClick={() => moveBy(link.id, 1)} disabled={index === orderedLinks.length - 1}>Down</button>
<button type="button" className="btn-subtle" onClick={() => startDuplicate(link)}>Duplicate</button>
<button type="button" className="btn-subtle" onClick={() => startEdit(link)}>Edit</button> <button type="button" className="btn-subtle" onClick={() => startEdit(link)}>Edit</button>
<button type="button" className="btn-subtle" onClick={() => onDelete(link.id)}>Delete</button> <button type="button" className="btn-subtle" onClick={() => onDelete(link.id)}>Delete</button>
</div> </div>

View File

@@ -5,6 +5,7 @@ export type LinkItem = {
url: string; url: string;
description: string; description: string;
category: string; category: string;
sort_order?: number;
enabled: boolean; enabled: boolean;
icon_url: string | null; icon_url: string | null;
}; };