Fix icon sizing and category filter
All checks were successful
docker / build-and-push (push) Successful in 47s
All checks were successful
docker / build-and-push (push) Successful in 47s
This commit is contained in:
@@ -67,7 +67,7 @@ def init_db():
|
|||||||
cur.execute("""
|
cur.execute("""
|
||||||
create table if not exists links(
|
create table if not exists links(
|
||||||
id bigint auto_increment primary key,
|
id bigint auto_increment primary key,
|
||||||
name varchar(255) not null,
|
name varchar(255) not null unique,
|
||||||
url text not null,
|
url text not null,
|
||||||
description text,
|
description text,
|
||||||
category varchar(255),
|
category varchar(255),
|
||||||
@@ -113,6 +113,15 @@ def require_admin(request: Request):
|
|||||||
raise HTTPException(401, "Unauthorized")
|
raise HTTPException(401, "Unauthorized")
|
||||||
return user
|
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")
|
@app.get("/api/me")
|
||||||
def me(request: Request):
|
def me(request: Request):
|
||||||
with db() as c:
|
with db() as c:
|
||||||
@@ -197,6 +206,9 @@ def create_link(
|
|||||||
icon: UploadFile | None = File(None),
|
icon: UploadFile | None = File(None),
|
||||||
):
|
):
|
||||||
require_admin(request)
|
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
|
icon_blob = icon_mime = None
|
||||||
if icon:
|
if icon:
|
||||||
icon_blob = icon.file.read()
|
icon_blob = icon.file.read()
|
||||||
@@ -246,6 +258,8 @@ def update_link(
|
|||||||
icon_mime = icon.content_type
|
icon_mime = icon.content_type
|
||||||
now = datetime.utcnow().isoformat()
|
now = datetime.utcnow().isoformat()
|
||||||
with db() as c:
|
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:
|
with c.cursor() as cur:
|
||||||
if icon_blob:
|
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""",
|
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""",
|
||||||
|
|||||||
@@ -50,6 +50,14 @@ export default function App() {
|
|||||||
() => links.filter((l) => l.enabled && `${l.name} ${l.description} ${l.category}`.toLowerCase().includes(query.toLowerCase())),
|
() => links.filter((l) => l.enabled && `${l.name} ${l.description} ${l.category}`.toLowerCase().includes(query.toLowerCase())),
|
||||||
[links, query]
|
[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 <Shell><Centered>Loading Jellomator...</Centered></Shell>;
|
if (page === 'loading') return <Shell><Centered>Loading Jellomator...</Centered></Shell>;
|
||||||
if (page === 'setup') return <SetupPage onDone={refresh} />;
|
if (page === 'setup') return <SetupPage onDone={refresh} />;
|
||||||
@@ -75,7 +83,16 @@ export default function App() {
|
|||||||
onSave={async (payload, file) => {
|
onSave={async (payload, file) => {
|
||||||
const fd = toFormData(payload, file);
|
const fd = toFormData(payload, file);
|
||||||
const url = editing ? `/api/links/${editing.id}` : '/api/links';
|
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);
|
setEditing(null);
|
||||||
await refresh();
|
await refresh();
|
||||||
}}
|
}}
|
||||||
@@ -102,7 +119,7 @@ export default function App() {
|
|||||||
<div className="glass rounded-3xl p-4">
|
<div className="glass rounded-3xl p-4">
|
||||||
<input className="input" placeholder="Search services" value={query} onChange={(e) => setQuery(e.target.value)} />
|
<input className="input" placeholder="Search services" value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||||
<div className="mt-4 space-y-2 text-sm text-slate-400">
|
<div className="mt-4 space-y-2 text-sm text-slate-400">
|
||||||
{['All', 'Arr*', 'Downloads', 'Media', 'Requests'].map((category) => (
|
{categories.map((category) => (
|
||||||
<button
|
<button
|
||||||
key={category}
|
key={category}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -188,8 +205,8 @@ function Card({ link }: { link: LinkItem }) {
|
|||||||
return (
|
return (
|
||||||
<a href={link.url} target="_blank" rel="noreferrer" className="glass block rounded-3xl p-5 transition hover:-translate-y-1">
|
<a href={link.url} target="_blank" rel="noreferrer" className="glass block rounded-3xl p-5 transition hover:-translate-y-1">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="grid h-12 w-12 place-items-center overflow-hidden rounded-2xl bg-white/5">
|
<div className="grid h-12 w-12 place-items-center overflow-hidden rounded-2xl bg-white/5 p-1">
|
||||||
{link.icon_url ? <img src={link.icon_url} className="h-full w-full object-cover" alt="" /> : <span className="font-semibold text-accent-300">{link.name[0]}</span>}
|
{link.icon_url ? <img src={link.icon_url} className="h-full w-full object-contain" alt="" /> : <span className="font-semibold text-accent-300">{link.name[0]}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -345,8 +362,8 @@ function AdminPage({
|
|||||||
<div className="text-sm text-slate-400">Live preview</div>
|
<div className="text-sm text-slate-400">Live preview</div>
|
||||||
<div className="mt-3 rounded-2xl border border-white/10 bg-white/5 p-4">
|
<div className="mt-3 rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="grid h-12 w-12 place-items-center overflow-hidden rounded-2xl bg-white/5">
|
<div className="grid h-12 w-12 place-items-center overflow-hidden rounded-2xl bg-white/5 p-1">
|
||||||
{preview ? <img src={preview} className="h-full w-full object-cover" alt="" /> : <span className="font-semibold text-accent-300">{form.name ? form.name[0] : 'S'}</span>}
|
{preview ? <img src={preview} className="h-full w-full object-contain" alt="" /> : <span className="font-semibold text-accent-300">{form.name ? form.name[0] : 'S'}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold">{form.name || 'Service name'}</div>
|
<div className="font-semibold">{form.name || 'Service name'}</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user