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:
2026-07-24 11:35:57 -04:00
parent f29efb7924
commit ff37533037
7 changed files with 655 additions and 2 deletions
+13
View File
@@ -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 {
+141
View File
@@ -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 }}>