Remove preset services and move to MariaDB
All checks were successful
docker / build-and-push (push) Successful in 54s

This commit is contained in:
Space-Banane
2026-05-20 20:50:59 +02:00
parent e62b9ee019
commit 556cdc36b6
6 changed files with 128 additions and 122 deletions

View File

@@ -1,6 +1,6 @@
# Jellomator # Jellomator
Dark, SQLite-backed dashboard for Arr* services and custom links. Dark dashboard for Arr* services and custom links.
## Features ## Features
@@ -8,10 +8,10 @@ Dark, SQLite-backed dashboard for Arr* services and custom links.
- Cookie-based admin auth - Cookie-based admin auth
- Public dashboard with search/filter - Public dashboard with search/filter
- Dedicated protected admin page at `/admin` - Dedicated protected admin page at `/admin`
- Link CRUD backed by SQLite - Link CRUD backed by MariaDB
- Icon blobs stored in SQLite - Icon blobs stored in the database
- Single-container deployment - Single-container deployment
- Seeded Arr* presets on first run - Admin-managed service links
## Local Dev ## Local Dev
@@ -30,14 +30,7 @@ Open `/admin` for the protected management page.
docker compose up --build docker compose up --build
``` ```
The app uses exactly one bind mount: The app expects a MariaDB instance configured through environment variables.
`./jellomator.sqlite:/app/data/jellomator.sqlite`
## SQLite
All data lives in SQLite, including uploaded icon blobs. There is no separate uploads directory.
The first-run setup also seeds editable defaults for Sonarr, Radarr, Lidarr, Readarr, Prowlarr, Bazarr, qBittorrent, Jellyfin, Jellyseerr, and Overseerr.
## Gitea CI/CD ## Gitea CI/CD

10
TODO.md
View File

@@ -4,10 +4,10 @@ Concrete follow-up work for Jellomator.
## P0 ## P0
- Add a backup and restore flow for `jellomator.sqlite` in the admin UI. - Add a backup and restore flow for the database in the admin UI.
- Let an admin download the current database. - Let an admin download the current database.
- Let an admin upload a replacement database after confirmation. - Let an admin upload a replacement database after confirmation.
- Validate that the uploaded file is SQLite before swapping it in. - Validate the uploaded file before swapping it in.
- Add a basic health endpoint for Docker and orchestration. - Add a basic health endpoint for Docker and orchestration.
- Return `200` when the app can read and write the database. - Return `200` when the app can read and write the database.
- Return `503` if startup initialization or DB access fails. - Return `503` if startup initialization or DB access fails.
@@ -21,7 +21,7 @@ Concrete follow-up work for Jellomator.
## P1 ## P1
- Add drag-and-drop ordering for service cards. - Add drag-and-drop ordering for service cards.
- Persist display order in SQLite. - Persist display order in the database.
- Support moving a card up, down, or to the top in admin. - Support moving a card up, down, or to the top in admin.
- Add a featured/pinned flag for important links. - Add a featured/pinned flag for important links.
- Keep pinned links above the normal list. - Keep pinned links above the normal list.
@@ -41,9 +41,6 @@ Concrete follow-up work for Jellomator.
## P2 ## P2
- Add more presets for common self-hosted apps.
- Suggested first set: Paperless-ngx, Immich, Grafana, Home Assistant, Vaultwarden.
- Make each preset editable after insertion.
- Add JSON import/export for services. - Add JSON import/export for services.
- Include metadata and icon blobs in the export format. - Include metadata and icon blobs in the export format.
- Support importing a whole dashboard from a single file. - Support importing a whole dashboard from a single file.
@@ -63,5 +60,4 @@ Concrete follow-up work for Jellomator.
- Add a toast system for save, delete, and upload actions. - Add a toast system for save, delete, and upload actions.
- Add Open Graph metadata for better link previews. - Add Open Graph metadata for better link previews.
- Add structured JSON logging for auth and CRUD events. - Add structured JSON logging for auth and CRUD events.
- Add a small command or script to reset seed data for local development.
- Add a CI verification step that builds the container image after publish. - Add a CI verification step that builds the container image after publish.

View File

@@ -1,51 +1,84 @@
from __future__ import annotations from __future__ import annotations
import secrets import secrets
import sqlite3 import os
from datetime import datetime from datetime import datetime
from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import bcrypt import bcrypt
import pymysql
from fastapi import FastAPI, File, Form, HTTPException, Request, Response, UploadFile from fastapi import FastAPI, File, Form, HTTPException, Request, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel from pydantic import BaseModel
DB_PATH = Path("/app/data/jellomator.sqlite")
STATIC_DIR = Path("frontend/dist") STATIC_DIR = Path("frontend/dist")
SESSION_COOKIE = "jellomator_session" SESSION_COOKIE = "jellomator_session"
DB_HOST = os.getenv("DB_HOST", "mariadb")
DB_PORT = int(os.getenv("DB_PORT", "3306"))
DB_USER = os.getenv("DB_USER", "jellomator")
DB_PASSWORD = os.getenv("DB_PASSWORD", "jellomator")
DB_NAME = os.getenv("DB_NAME", "jellomator")
app = FastAPI(title="Jellomator") app = FastAPI(title="Jellomator")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"]) app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
PRESET_LINKS = [ @contextmanager
{"name": "Sonarr", "url": "http://sonarr:8989", "description": "TV library automation", "category": "Arr*", "enabled": True},
{"name": "Radarr", "url": "http://radarr:7878", "description": "Movie library automation", "category": "Arr*", "enabled": True},
{"name": "Lidarr", "url": "http://lidarr:8686", "description": "Music library automation", "category": "Arr*", "enabled": True},
{"name": "Readarr", "url": "http://readarr:8787", "description": "Book library automation", "category": "Arr*", "enabled": True},
{"name": "Prowlarr", "url": "http://prowlarr:9696", "description": "Indexer management", "category": "Arr*", "enabled": True},
{"name": "Bazarr", "url": "http://bazarr:6767", "description": "Subtitle management", "category": "Arr*", "enabled": True},
{"name": "qBittorrent", "url": "http://qbittorrent:8080", "description": "Torrent client", "category": "Downloads", "enabled": True},
{"name": "Jellyfin", "url": "http://jellyfin:8096", "description": "Media server", "category": "Media", "enabled": True},
{"name": "Jellyseerr", "url": "http://jellyseerr:5055", "description": "Request management", "category": "Requests", "enabled": True},
{"name": "Overseerr", "url": "http://overseerr:5055", "description": "Request management", "category": "Requests", "enabled": True},
]
def db(): def db():
conn = sqlite3.connect(DB_PATH) conn = pymysql.connect(
conn.row_factory = sqlite3.Row host=DB_HOST,
return conn port=DB_PORT,
user=DB_USER,
password=DB_PASSWORD,
cursorclass=pymysql.cursors.DictCursor,
autocommit=True,
)
try:
with conn.cursor() as cur:
cur.execute(f"create database if not exists `{DB_NAME}` default charset=utf8mb4")
conn.select_db(DB_NAME)
yield conn
finally:
conn.close()
def init_db(): def init_db():
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
with db() as c: with db() as c:
c.executescript(""" with c.cursor() as cur:
create table if not exists users(id integer primary key, username text unique, password_hash blob, role text not null); cur.execute("""
create table if not exists sessions(token text primary key, user_id integer not null, created_at text not null); create table if not exists users(
create table if not exists links(id integer primary key, name text not null, url text not null, description text, category text, icon_blob blob, icon_mime text, icon_url text, enabled integer not null default 1, created_at text not null, updated_at text not null); id bigint auto_increment primary key,
""") username varchar(255) not null unique,
password_hash varbinary(255) not null,
role varchar(32) not null
) engine=InnoDB default charset=utf8mb4
""")
cur.execute("""
create table if not exists sessions(
token varchar(255) primary key,
user_id bigint not null,
created_at varchar(64) not null,
index (user_id),
constraint sessions_user_fk foreign key (user_id) references users(id) on delete cascade
) engine=InnoDB default charset=utf8mb4
""")
cur.execute("""
create table if not exists links(
id bigint auto_increment primary key,
name varchar(255) not null,
url text not null,
description text,
category varchar(255),
icon_blob longblob,
icon_mime varchar(255),
icon_url text,
enabled tinyint(1) not null default 1,
created_at varchar(64) not null,
updated_at varchar(64) not null
) engine=InnoDB default charset=utf8mb4
""")
init_db() init_db()
class SetupIn(BaseModel): class SetupIn(BaseModel):
@@ -69,8 +102,10 @@ def current_user(request: Request):
if not token: if not token:
return None return None
with db() as c: with db() as c:
row = c.execute("select u.username,u.role from sessions s join users u on u.id=s.user_id where s.token=?", (token,)).fetchone() with c.cursor() as cur:
return dict(row) if row else None cur.execute("select u.username,u.role from sessions s join users u on u.id=s.user_id where s.token=%s", (token,))
row = cur.fetchone()
return row if row else None
def require_admin(request: Request): def require_admin(request: Request):
user = current_user(request) user = current_user(request)
@@ -78,39 +113,36 @@ def require_admin(request: Request):
raise HTTPException(401, "Unauthorized") raise HTTPException(401, "Unauthorized")
return user return user
def seed_preset_links(conn: sqlite3.Connection):
now = datetime.utcnow().isoformat()
for preset in PRESET_LINKS:
conn.execute(
"""insert into links(name,url,description,category,enabled,created_at,updated_at)
values (?,?,?,?,?,?,?)""",
(preset["name"], preset["url"], preset["description"], preset["category"], int(preset["enabled"]), now, now),
)
@app.get("/api/me") @app.get("/api/me")
def me(request: Request): def me(request: Request):
with db() as c: with db() as c:
needs_setup = c.execute("select count(*) from users").fetchone()[0] == 0 with c.cursor() as cur:
cur.execute("select count(*) as count from users")
needs_setup = cur.fetchone()["count"] == 0
return {"needs_setup": needs_setup, "current_user": current_user(request)} return {"needs_setup": needs_setup, "current_user": current_user(request)}
@app.post("/api/setup") @app.post("/api/setup")
def setup(inp: SetupIn): def setup(inp: SetupIn):
with db() as c: with db() as c:
if c.execute("select count(*) from users").fetchone()[0] > 0: with c.cursor() as cur:
raise HTTPException(400, "Setup already complete") cur.execute("select count(*) as count from users")
pw = bcrypt.hashpw(inp.password.encode(), bcrypt.gensalt()) if cur.fetchone()["count"] > 0:
c.execute("insert into users(username,password_hash,role) values (?,?,?)", (inp.username, pw, "admin")) raise HTTPException(400, "Setup already complete")
seed_preset_links(c) pw = bcrypt.hashpw(inp.password.encode(), bcrypt.gensalt())
cur.execute("insert into users(username,password_hash,role) values (%s,%s,%s)", (inp.username, pw, "admin"))
return {"ok": True} return {"ok": True}
@app.post("/api/login") @app.post("/api/login")
def login(inp: LoginIn): def login(inp: LoginIn):
with db() as c: with db() as c:
row = c.execute("select id,password_hash from users where username=?", (inp.username,)).fetchone() with c.cursor() as cur:
cur.execute("select id,password_hash from users where username=%s", (inp.username,))
row = cur.fetchone()
if not row or not bcrypt.checkpw(inp.password.encode(), row["password_hash"]): if not row or not bcrypt.checkpw(inp.password.encode(), row["password_hash"]):
raise HTTPException(401, "Invalid credentials") raise HTTPException(401, "Invalid credentials")
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
c.execute("insert into sessions(token,user_id,created_at) values (?,?,?)", (token, row["id"], datetime.utcnow().isoformat())) with c.cursor() as cur:
cur.execute("insert into sessions(token,user_id,created_at) values (%s,%s,%s)", (token, row["id"], datetime.utcnow().isoformat()))
response = JSONResponse({"ok": True}) response = JSONResponse({"ok": True})
response.set_cookie(SESSION_COOKIE, token, httponly=True, samesite="lax", secure=False, path="/") response.set_cookie(SESSION_COOKIE, token, httponly=True, samesite="lax", secure=False, path="/")
return response return response
@@ -119,7 +151,9 @@ def login(inp: LoginIn):
def logout(request: Request): def logout(request: Request):
token = request.cookies.get(SESSION_COOKIE) token = request.cookies.get(SESSION_COOKIE)
with db() as c: with db() as c:
if token: c.execute("delete from sessions where token=?", (token,)) if token:
with c.cursor() as cur:
cur.execute("delete from sessions where token=%s", (token,))
resp = JSONResponse({"ok": True}) resp = JSONResponse({"ok": True})
resp.delete_cookie(SESSION_COOKIE, path="/") resp.delete_cookie(SESSION_COOKIE, path="/")
return resp return resp
@@ -127,7 +161,9 @@ def logout(request: Request):
@app.get("/api/links") @app.get("/api/links")
def links(): def links():
with db() as c: with db() as c:
rows = c.execute("select * from links order by enabled desc, category, name").fetchall() with c.cursor() as cur:
cur.execute("select * from links order by enabled desc, category, name")
rows = cur.fetchall()
out = [] out = []
for r in rows: for r in rows:
icon_url = None icon_url = None
@@ -141,7 +177,9 @@ def links():
@app.get("/api/links/{link_id}") @app.get("/api/links/{link_id}")
def get_link(link_id: int): def get_link(link_id: int):
with db() as c: with db() as c:
row = c.execute("select * from links where id=?", (link_id,)).fetchone() with c.cursor() as cur:
cur.execute("select * from links where id=%s", (link_id,))
row = cur.fetchone()
if not row: if not row:
raise HTTPException(404, "Not found") raise HTTPException(404, "Not found")
icon_url = f"/api/links/{row['id']}/icon" if row["icon_blob"] else row["icon_url"] icon_url = f"/api/links/{row['id']}/icon" if row["icon_blob"] else row["icon_url"]
@@ -165,15 +203,18 @@ def create_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:
c.execute("""insert into links(name,url,description,category,icon_blob,icon_mime,icon_url,enabled,created_at,updated_at) with c.cursor() as cur:
values (?,?,?,?,?,?,?,?,?,?)""", cur.execute("""insert into links(name,url,description,category,icon_blob,icon_mime,icon_url,enabled,created_at,updated_at)
values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
(name, url, description, category, icon_blob, icon_mime, icon_url, int(enabled), now, now)) (name, url, description, category, icon_blob, icon_mime, icon_url, int(enabled), now, now))
return {"ok": True} return {"ok": True}
@app.get("/api/links/{link_id}/icon") @app.get("/api/links/{link_id}/icon")
def link_icon(link_id: int): def link_icon(link_id: int):
with db() as c: with db() as c:
row = c.execute("select icon_blob,icon_mime from links where id=?", (link_id,)).fetchone() with c.cursor() as cur:
cur.execute("select icon_blob,icon_mime from links where id=%s", (link_id,))
row = cur.fetchone()
if not row or not row["icon_blob"]: if not row or not row["icon_blob"]:
raise HTTPException(404, "Not found") raise HTTPException(404, "Not found")
return Response(content=row["icon_blob"], media_type=row["icon_mime"] or "image/png") return Response(content=row["icon_blob"], media_type=row["icon_mime"] or "image/png")
@@ -182,7 +223,8 @@ def link_icon(link_id: int):
def delete_link(request: Request, link_id: int): def delete_link(request: Request, link_id: int):
require_admin(request) require_admin(request)
with db() as c: with db() as c:
c.execute("delete from links where id=?", (link_id,)) with c.cursor() as cur:
cur.execute("delete from links where id=%s", (link_id,))
return {"ok": True} return {"ok": True}
@app.patch("/api/links/{link_id}") @app.patch("/api/links/{link_id}")
@@ -204,12 +246,13 @@ 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 icon_blob: with c.cursor() as cur:
c.execute("""update links set name=?,url=?,description=?,category=?,icon_blob=?,icon_mime=?,icon_url=?,enabled=?,updated_at=? where id=?""", if icon_blob:
(name,url,description,category,icon_blob,icon_mime,icon_url,int(enabled),now,link_id)) 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""",
else: (name,url,description,category,icon_blob,icon_mime,icon_url,int(enabled),now,link_id))
c.execute("""update links set name=?,url=?,description=?,category=?,icon_url=?,enabled=?,updated_at=? where id=?""", else:
(name,url,description,category,icon_url,int(enabled),now,link_id)) cur.execute("""update links set name=%s,url=%s,description=%s,category=%s,icon_url=%s,enabled=%s,updated_at=%s where id=%s""",
(name,url,description,category,icon_url,int(enabled),now,link_id))
return {"ok": True} return {"ok": True}
if STATIC_DIR.exists(): if STATIC_DIR.exists():

View File

@@ -4,3 +4,4 @@ bcrypt==4.2.1
jinja2==3.1.5 jinja2==3.1.5
pydantic==2.10.6 pydantic==2.10.6
python-multipart==0.0.20 python-multipart==0.0.20
pymysql==1.1.1

View File

@@ -1,7 +1,23 @@
services: services:
mariadb:
image: mariadb:11
environment:
MARIADB_DATABASE: jellomator
MARIADB_USER: jellomator
MARIADB_PASSWORD: jellomator
MARIADB_ROOT_PASSWORD: root
ports:
- "3306:3306"
jellomator: jellomator:
build: . build: .
ports: ports:
- "6363:6363" - "6363:6363"
volumes: depends_on:
- ./jellomator.sqlite:/app/data/jellomator.sqlite - mariadb
environment:
DB_HOST: mariadb
DB_PORT: "3306"
DB_USER: jellomator
DB_PASSWORD: jellomator
DB_NAME: jellomator

View File

@@ -11,19 +11,6 @@ type LinkForm = {
enabled: boolean; enabled: boolean;
}; };
const presetCards = [
{ name: 'Sonarr', category: 'Arr*', url: 'http://sonarr:8989', description: 'TV library automation' },
{ name: 'Radarr', category: 'Arr*', url: 'http://radarr:7878', description: 'Movie library automation' },
{ name: 'Lidarr', category: 'Arr*', url: 'http://lidarr:8686', description: 'Music library automation' },
{ name: 'Readarr', category: 'Arr*', url: 'http://readarr:8787', description: 'Book library automation' },
{ name: 'Prowlarr', category: 'Arr*', url: 'http://prowlarr:9696', description: 'Indexer management' },
{ name: 'Bazarr', category: 'Arr*', url: 'http://bazarr:6767', description: 'Subtitle management' },
{ name: 'qBittorrent', category: 'Downloads', url: 'http://qbittorrent:8080', description: 'Torrent client' },
{ name: 'Jellyfin', category: 'Media', url: 'http://jellyfin:8096', description: 'Media server' },
{ name: 'Jellyseerr', category: 'Requests', url: 'http://jellyseerr:5055', description: 'Request management' },
{ name: 'Overseerr', category: 'Requests', url: 'http://overseerr:5055', description: 'Request management' },
];
export default function App() { export default function App() {
const [state, setState] = useState<SetupState | null>(null); const [state, setState] = useState<SetupState | null>(null);
const [links, setLinks] = useState<LinkItem[]>([]); const [links, setLinks] = useState<LinkItem[]>([]);
@@ -97,18 +84,6 @@ export default function App() {
if (editing?.id === id) setEditing(null); if (editing?.id === id) setEditing(null);
await refresh(); await refresh();
}} }}
onSeedPreset={async (preset) => {
const fd = toFormData({
name: preset.name,
url: preset.url,
description: preset.description,
category: preset.category,
icon_url: '',
enabled: true,
});
await api.request('/api/links', { method: 'POST', body: fd });
await refresh();
}}
/> />
</div> </div>
</Shell> </Shell>
@@ -182,7 +157,7 @@ function Hero({ currentUser, onAdmin, onLogout }: { currentUser: string | null;
<div> <div>
<div className="text-sm uppercase tracking-[0.3em] text-accent-300">Jellomator</div> <div className="text-sm uppercase tracking-[0.3em] text-accent-300">Jellomator</div>
<h1 className="mt-3 text-4xl font-semibold md:text-6xl">Your media stack, one click away.</h1> <h1 className="mt-3 text-4xl font-semibold md:text-6xl">Your media stack, one click away.</h1>
<p className="mt-4 max-w-2xl text-slate-300">Curated links for Arr* services and custom tools, served from a single SQLite-backed container.</p> <p className="mt-4 max-w-2xl text-slate-300">Curated links for Arr* services and custom tools, served from a single database-backed container.</p>
</div> </div>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{currentUser ? <button type="button" className="btn-secondary" onClick={onLogout}>Logout</button> : null} {currentUser ? <button type="button" className="btn-secondary" onClick={onLogout}>Logout</button> : null}
@@ -198,7 +173,7 @@ function AdminHeader({ currentUser, onBack, onLogout }: { currentUser: string |
<div className="mb-6 flex flex-wrap items-center justify-between gap-3"> <div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<div> <div>
<div className="text-sm uppercase tracking-[0.3em] text-accent-300">Protected admin</div> <div className="text-sm uppercase tracking-[0.3em] text-accent-300">Protected admin</div>
<h1 className="mt-2 text-3xl font-semibold">Manage links and presets</h1> <h1 className="mt-2 text-3xl font-semibold">Manage links</h1>
<p className="mt-2 text-sm text-slate-400">{currentUser ? `Signed in as ${currentUser}` : 'Sign in required'}</p> <p className="mt-2 text-sm text-slate-400">{currentUser ? `Signed in as ${currentUser}` : 'Sign in required'}</p>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
@@ -304,14 +279,12 @@ function AdminPage({
setEditing, setEditing,
onSave, onSave,
onDelete, onDelete,
onSeedPreset,
}: { }: {
links: LinkItem[]; links: LinkItem[];
editing: LinkItem | null; editing: LinkItem | null;
setEditing: (link: LinkItem | null) => void; setEditing: (link: LinkItem | null) => void;
onSave: (payload: LinkForm, file?: File | null) => Promise<void>; onSave: (payload: LinkForm, file?: File | null) => Promise<void>;
onDelete: (id: number) => Promise<void>; onDelete: (id: number) => Promise<void>;
onSeedPreset: (preset: (typeof presetCards)[number]) => Promise<void>;
}) { }) {
const [form, setForm] = useState<LinkForm>(emptyForm()); const [form, setForm] = useState<LinkForm>(emptyForm());
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
@@ -387,22 +360,6 @@ function AdminPage({
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
<div className="glass rounded-3xl p-6">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold">Seeded presets</h2>
<p className="mt-1 text-sm text-slate-400">These were inserted on first run and can be edited like any other link.</p>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{presetCards.map((preset) => (
<button key={preset.name} type="button" className="btn-secondary px-3 py-2 text-sm" onClick={() => onSeedPreset(preset)}>
Add {preset.name}
</button>
))}
</div>
</div>
<div className="glass rounded-3xl p-6"> <div className="glass rounded-3xl p-6">
<h2 className="text-xl font-semibold">Existing links</h2> <h2 className="text-xl font-semibold">Existing links</h2>
<div className="mt-4 overflow-hidden rounded-2xl border border-white/10"> <div className="mt-4 overflow-hidden rounded-2xl border border-white/10">