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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user