Compare commits
6 Commits
7cdf9b95f7
...
chore/pr-d
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d956adce5 | |||
| 1edb89884b | |||
| 0f106e3544 | |||
| a4b645bab6 | |||
| 87c610b8d7 | |||
| 58f7702074 |
@@ -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 }}
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@ data
|
|||||||
dist
|
dist
|
||||||
*.log
|
*.log
|
||||||
__pycache__
|
__pycache__
|
||||||
|
.venv/
|
||||||
|
|||||||
30
TODO.md
30
TODO.md
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user