diff --git a/backend/main.py b/backend/main.py index cf466cd..71923c7 100644 --- a/backend/main.py +++ b/backend/main.py @@ -67,7 +67,7 @@ def init_db(): cur.execute(""" create table if not exists links( id bigint auto_increment primary key, - name varchar(255) not null, + name varchar(255) not null unique, url text not null, description text, category varchar(255), @@ -113,6 +113,15 @@ def require_admin(request: Request): raise HTTPException(401, "Unauthorized") return user + +def link_name_exists(conn, name: str, *, exclude_id: int | None = None) -> bool: + with conn.cursor() as cur: + if exclude_id is None: + cur.execute("select 1 from links where name=%s limit 1", (name,)) + else: + cur.execute("select 1 from links where name=%s and id<>%s limit 1", (name, exclude_id)) + return cur.fetchone() is not None + @app.get("/api/me") def me(request: Request): with db() as c: @@ -197,6 +206,9 @@ def create_link( icon: UploadFile | None = File(None), ): require_admin(request) + with db() as c: + if link_name_exists(c, name): + raise HTTPException(409, "Link name already exists") icon_blob = icon_mime = None if icon: icon_blob = icon.file.read() @@ -246,6 +258,8 @@ def update_link( icon_mime = icon.content_type now = datetime.utcnow().isoformat() with db() as c: + if link_name_exists(c, name, exclude_id=link_id): + raise HTTPException(409, "Link name already exists") with c.cursor() as cur: if icon_blob: cur.execute("""update links set name=%s,url=%s,description=%s,category=%s,icon_blob=%s,icon_mime=%s,icon_url=%s,enabled=%s,updated_at=%s where id=%s""", diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index 9f7ee50..3c1873f 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -50,6 +50,14 @@ export default function App() { () => links.filter((l) => l.enabled && `${l.name} ${l.description} ${l.category}`.toLowerCase().includes(query.toLowerCase())), [links, query] ); + const categories = useMemo(() => { + const base = ['All']; + const visible = links + .filter((link) => link.enabled) + .map((link) => link.category) + .filter((value, index, array) => value && array.indexOf(value) === index); + return base.concat(visible.sort((a, b) => a.localeCompare(b))); + }, [links]); if (page === 'loading') return Loading Jellomator...; if (page === 'setup') return ; @@ -75,7 +83,16 @@ export default function App() { onSave={async (payload, file) => { const fd = toFormData(payload, file); const url = editing ? `/api/links/${editing.id}` : '/api/links'; - await api.request(url, { method: editing ? 'PATCH' : 'POST', body: fd }); + try { + await api.request(url, { method: editing ? 'PATCH' : 'POST', body: fd }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message.includes('Link name already exists')) { + alert('Link names must be unique.'); + return; + } + throw err; + } setEditing(null); await refresh(); }} @@ -102,7 +119,7 @@ export default function App() {
setQuery(e.target.value)} />
- {['All', 'Arr*', 'Downloads', 'Media', 'Requests'].map((category) => ( + {categories.map((category) => (