Add Flickr photostream import as Photography
Import public photos from samuraijkm (NSID resolve + Flickr REST), tag Photography, with admin UI re-run and CLI support.
This commit is contained in:
@@ -251,6 +251,19 @@ export const api = {
|
||||
|
||||
importArtStationStatus: () =>
|
||||
request<ArtStationImportStatus>("/api/v1/admin/import/artstation"),
|
||||
|
||||
importFlickr: (body: { username?: string; dryRun?: boolean; publish?: boolean }) =>
|
||||
request<{
|
||||
ok: boolean;
|
||||
message: string;
|
||||
status: ArtStationImportStatus;
|
||||
}>("/api/v1/admin/import/flickr", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
importFlickrStatus: () =>
|
||||
request<ArtStationImportStatus>("/api/v1/admin/import/flickr"),
|
||||
};
|
||||
|
||||
export interface ArtStationImportStatus {
|
||||
|
||||
@@ -19,6 +19,12 @@ export function AdminDashboard() {
|
||||
const [importStatus, setImportStatus] = useState<ArtStationImportStatus | null>(null);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const [flUser, setFlUser] = useState("samuraijkm");
|
||||
const [flDryRun, setFlDryRun] = useState(false);
|
||||
const [flPublish, setFlPublish] = useState(false);
|
||||
const [flStatus, setFlStatus] = useState<ArtStationImportStatus | null>(null);
|
||||
const flPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
function refreshStats() {
|
||||
Promise.all([
|
||||
api.adminProjects({ perPage: 1 }),
|
||||
@@ -40,8 +46,10 @@ export function AdminDashboard() {
|
||||
useEffect(() => {
|
||||
refreshStats();
|
||||
api.importArtStationStatus().then(setImportStatus).catch(() => {});
|
||||
api.importFlickrStatus().then(setFlStatus).catch(() => {});
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
if (flPollRef.current) clearInterval(flPollRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -69,6 +77,30 @@ export function AdminDashboard() {
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function startFlickrPolling() {
|
||||
if (flPollRef.current) clearInterval(flPollRef.current);
|
||||
flPollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const s = await api.importFlickrStatus();
|
||||
setFlStatus(s);
|
||||
if (!s.running) {
|
||||
if (flPollRef.current) clearInterval(flPollRef.current);
|
||||
flPollRef.current = null;
|
||||
refreshStats();
|
||||
if (s.result) {
|
||||
setMsg(
|
||||
`Flickr import finished: ${s.result.imported} new, ${s.result.skipped} skipped, ${s.result.errors} errors (${s.result.found} on photostream)`
|
||||
);
|
||||
} else if (s.error) {
|
||||
setErr(s.error);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* keep polling */
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
async function reindex() {
|
||||
setErr(null);
|
||||
try {
|
||||
@@ -96,8 +128,27 @@ export function AdminDashboard() {
|
||||
}
|
||||
}
|
||||
|
||||
async function runFlickrImport() {
|
||||
setErr(null);
|
||||
setMsg(null);
|
||||
try {
|
||||
const res = await api.importFlickr({
|
||||
username: flUser.trim() || "samuraijkm",
|
||||
dryRun: flDryRun,
|
||||
publish: flPublish,
|
||||
});
|
||||
setFlStatus(res.status);
|
||||
setMsg(res.message);
|
||||
if (res.status.running) startFlickrPolling();
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
const running = importStatus?.running;
|
||||
const logLines = importStatus?.log?.slice(-40) || [];
|
||||
const flRunning = flStatus?.running;
|
||||
const flLogLines = flStatus?.log?.slice(-40) || [];
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
@@ -232,6 +283,96 @@ export function AdminDashboard() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="admin-import" style={{ marginTop: "2.5rem", maxWidth: 720 }}>
|
||||
<h2 style={{ fontSize: "1.1rem", margin: "0 0 0.35rem" }}>Import from Flickr</h2>
|
||||
<p style={{ color: "var(--text-muted)", margin: "0 0 1rem", fontSize: "0.9rem" }}>
|
||||
Pull public photos from your Flickr photostream and tag them as{" "}
|
||||
<strong>Photography</strong>. Existing entries (matched by Flickr URL) are skipped.
|
||||
</p>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="fl-user">Flickr username / path alias</label>
|
||||
<input
|
||||
id="fl-user"
|
||||
value={flUser}
|
||||
onChange={(e) => setFlUser(e.target.value)}
|
||||
disabled={!!flRunning}
|
||||
placeholder="samuraijkm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="admin-check-grid" style={{ marginBottom: "1rem" }}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={flDryRun}
|
||||
disabled={!!flRunning}
|
||||
onChange={(e) => setFlDryRun(e.target.checked)}
|
||||
/>
|
||||
Dry run (list only, no writes)
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={flPublish}
|
||||
disabled={!!flRunning || flDryRun}
|
||||
onChange={(e) => setFlPublish(e.target.checked)}
|
||||
/>
|
||||
Publish new imports immediately
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="admin-page__toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--primary"
|
||||
disabled={!!flRunning}
|
||||
onClick={runFlickrImport}
|
||||
>
|
||||
{flRunning ? "Import running…" : "Import from Flickr"}
|
||||
</button>
|
||||
{flStatus?.finishedAt && !flRunning && (
|
||||
<span style={{ color: "var(--text-dim)", fontSize: "0.85rem" }}>
|
||||
Last run {new Date(flStatus.finishedAt).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{flStatus?.result && !flRunning && (
|
||||
<div
|
||||
className="admin-msg admin-msg--ok"
|
||||
style={{ marginTop: "1rem", whiteSpace: "pre-wrap" }}
|
||||
>
|
||||
Found {flStatus.result.found} · imported {flStatus.result.imported} · skipped{" "}
|
||||
{flStatus.result.skipped} · errors {flStatus.result.errors}
|
||||
{flStatus.result.created.length > 0 && (
|
||||
<>
|
||||
{"\n"}New: {flStatus.result.created.map((c) => c.title).join(", ")}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{flLogLines.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)",
|
||||
}}
|
||||
>
|
||||
{flLogLines.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 }}>
|
||||
|
||||
Reference in New Issue
Block a user