Add admin UI for ArtStation re-import

Expose import as a background admin job with status polling, dry-run
and optional publish; share logic with the CLI via a service module.
This commit is contained in:
2026-07-24 10:52:11 -04:00
parent ac7eb17854
commit cef5738044
5 changed files with 718 additions and 324 deletions
+35
View File
@@ -229,8 +229,43 @@ export const api = {
}),
reindex: () =>
request<{ ok: boolean; indexed: number }>("/api/v1/admin/reindex", { method: "POST" }),
importArtStation: (body: {
username?: string;
dryRun?: boolean;
publish?: boolean;
url?: string;
}) =>
request<{
ok: boolean;
message: string;
status: ArtStationImportStatus;
}>("/api/v1/admin/import/artstation", {
method: "POST",
body: JSON.stringify(body),
}),
importArtStationStatus: () =>
request<ArtStationImportStatus>("/api/v1/admin/import/artstation"),
};
export interface ArtStationImportStatus {
running: boolean;
startedAt?: string;
finishedAt?: string;
error?: string;
log: string[];
result?: {
username: string;
dryRun: boolean;
found: number;
imported: number;
skipped: number;
errors: number;
created: { title: string; slug: string }[];
};
}
export function mediaSrc(url?: string | null): string {
if (!url) return "";
if (url.startsWith("http") || url.startsWith("//")) return url;
+160 -5
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../../lib/api";
import { api, type ArtStationImportStatus } from "../../lib/api";
export function AdminDashboard() {
const [stats, setStats] = useState({
@@ -11,8 +11,15 @@ export function AdminDashboard() {
views: 0,
});
const [msg, setMsg] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
const [asUser, setAsUser] = useState("jmartgraphix");
const [asDryRun, setAsDryRun] = useState(false);
const [asPublish, setAsPublish] = useState(false);
const [importStatus, setImportStatus] = useState<ArtStationImportStatus | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
function refreshStats() {
Promise.all([
api.adminProjects({ perPage: 1 }),
api.adminProjects({ visibility: "published", perPage: 1 }),
@@ -28,22 +35,77 @@ export function AdminDashboard() {
views: views.length,
});
});
}
useEffect(() => {
refreshStats();
api.importArtStationStatus().then(setImportStatus).catch(() => {});
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, []);
function startPolling() {
if (pollRef.current) clearInterval(pollRef.current);
pollRef.current = setInterval(async () => {
try {
const s = await api.importArtStationStatus();
setImportStatus(s);
if (!s.running) {
if (pollRef.current) clearInterval(pollRef.current);
pollRef.current = null;
refreshStats();
if (s.result) {
setMsg(
`ArtStation import finished: ${s.result.imported} new, ${s.result.skipped} skipped, ${s.result.errors} errors (${s.result.found} on profile)`
);
} else if (s.error) {
setErr(s.error);
}
}
} catch {
/* keep polling */
}
}, 2000);
}
async function reindex() {
setErr(null);
try {
const r = await api.reindex();
setMsg(`Typesense reindexed ${r.indexed} projects`);
} catch (e) {
setMsg((e as Error).message);
setErr((e as Error).message);
}
}
async function runArtStationImport() {
setErr(null);
setMsg(null);
try {
const res = await api.importArtStation({
username: asUser.trim() || "jmartgraphix",
dryRun: asDryRun,
publish: asPublish,
});
setImportStatus(res.status);
setMsg(res.message);
if (res.status.running) startPolling();
} catch (e) {
setErr((e as Error).message);
}
}
const running = importStatus?.running;
const logLines = importStatus?.log?.slice(-40) || [];
return (
<div className="admin-page">
<h1>Dashboard</h1>
<p className="admin-page__sub">Manage portfolio content protected by Authelia SSO.</p>
{msg && <div className="admin-msg admin-msg--ok">{msg}</div>}
{err && <div className="admin-msg admin-msg--err">{err}</div>}
<div className="admin-cards">
<div className="admin-stat">
<strong>{stats.projects}</strong>
@@ -66,6 +128,7 @@ export function AdminDashboard() {
<span>Portfolio views</span>
</div>
</div>
<div className="admin-page__toolbar">
<Link to="/admin/projects/new" className="btn btn--primary">
New project
@@ -77,7 +140,99 @@ export function AdminDashboard() {
Reindex search
</button>
</div>
<section>
<section className="admin-import" style={{ marginTop: "2.5rem", maxWidth: 720 }}>
<h2 style={{ fontSize: "1.1rem", margin: "0 0 0.35rem" }}>Import from ArtStation</h2>
<p style={{ color: "var(--text-muted)", margin: "0 0 1rem", fontSize: "0.9rem" }}>
Pull new public projects from your ArtStation profile. Existing entries (matched by
ArtStation URL) are skipped. New work lands as drafts unless you choose publish.
</p>
<div className="field">
<label htmlFor="as-user">ArtStation username</label>
<input
id="as-user"
value={asUser}
onChange={(e) => setAsUser(e.target.value)}
disabled={!!running}
placeholder="jmartgraphix"
/>
</div>
<div className="admin-check-grid" style={{ marginBottom: "1rem" }}>
<label>
<input
type="checkbox"
checked={asDryRun}
disabled={!!running}
onChange={(e) => setAsDryRun(e.target.checked)}
/>
Dry run (list only, no writes)
</label>
<label>
<input
type="checkbox"
checked={asPublish}
disabled={!!running || asDryRun}
onChange={(e) => setAsPublish(e.target.checked)}
/>
Publish new imports immediately
</label>
</div>
<div className="admin-page__toolbar">
<button
type="button"
className="btn btn--primary"
disabled={!!running}
onClick={runArtStationImport}
>
{running ? "Import running…" : "Import from ArtStation"}
</button>
{importStatus?.finishedAt && !running && (
<span style={{ color: "var(--text-dim)", fontSize: "0.85rem" }}>
Last run {new Date(importStatus.finishedAt).toLocaleString()}
</span>
)}
</div>
{importStatus?.result && !running && (
<div
className="admin-msg admin-msg--ok"
style={{ marginTop: "1rem", whiteSpace: "pre-wrap" }}
>
Found {importStatus.result.found} · imported {importStatus.result.imported} · skipped{" "}
{importStatus.result.skipped} · errors {importStatus.result.errors}
{importStatus.result.created.length > 0 && (
<>
{"\n"}New:{" "}
{importStatus.result.created.map((c) => c.title).join(", ")}
</>
)}
</div>
)}
{logLines.length > 0 && (
<pre
style={{
marginTop: "1rem",
padding: "0.85rem 1rem",
background: "var(--bg-elevated)",
border: "1px solid var(--border)",
borderRadius: "var(--radius-sm)",
fontSize: "0.78rem",
lineHeight: 1.45,
maxHeight: 280,
overflow: "auto",
color: "var(--text-muted)",
}}
>
{logLines.join("\n")}
</pre>
)}
</section>
<section style={{ marginTop: "2.5rem" }}>
<h2 style={{ fontSize: "1rem", color: "var(--text-muted)" }}>Quick links for audiences</h2>
<ul style={{ color: "var(--text-dim)", lineHeight: 1.9 }}>
<li>