web designer: /admin/designer workspace (submit brief + poll queue)

This commit is contained in:
Carlos Arias 2026-07-24 00:19:46 +00:00
parent 7fc99415b4
commit 50f6db55d8
3 changed files with 264 additions and 0 deletions

3
.gitignore vendored
View file

@ -42,3 +42,6 @@ agents/research-scan.json
.astroagent/work/
app/.astroagent/jobs/
app/.astroagent/work/
# Claude Code local machine settings (not shared)
.claude/settings.local.json

View file

@ -59,6 +59,7 @@ const { title = "Admin" } = Astro.props;
<span class="admin-seal" aria-hidden="true">印</span>
<span class="name">Carlos Arias · Admin</span>
<nav>
<a href="/admin/designer">Web Designer</a>
<a href="/admin/projects">Projects</a>
<a href="/" target="_blank">View site ↗</a>
<button type="button" data-logout>Sign out</button>

View file

@ -0,0 +1,260 @@
---
import AdminLayout from "../../layouts/AdminLayout.astro";
// Convenience list for the target-page picker. The agent can reach any page;
// this is just the common set plus "new page".
const PAGES = [
{ value: "/", label: "Home" },
{ value: "/about", label: "About" },
{ value: "/services", label: "Services" },
{ value: "/services/website-design", label: "Services — Website design" },
{ value: "/projects", label: "Projects (index)" },
{ value: "/resume", label: "Resume" },
{ value: "/contact", label: "Contact" },
{ value: "/changelog", label: "Changelog" },
{ value: "/faq", label: "FAQ" },
];
---
<AdminLayout title="Web Designer">
<h1>Web Designer</h1>
<p class="lead">Give the design agent a task for any page — instructions, a draft, images, and short-video links. Tasks queue up and publish themselves one at a time; you review the results here.</p>
<section class="panel">
<div class="two">
<div>
<label for="f-page">Page</label>
<div class="sel">
<select id="f-page">
{PAGES.map((p) => <option value={p.value}>{p.label}</option>)}
<option value="__new"> New page…</option>
</select>
</div>
</div>
<div id="newWrap" hidden>
<label for="f-slug">New page path</label>
<div class="slug"><span>/</span><input type="text" id="f-slug" placeholder="accounting" /></div>
</div>
</div>
<label for="f-title" class="mt">Task title <span class="opt">optional — for the queue list</span></label>
<input type="text" id="f-title" placeholder="Tighten the About intro" />
<label for="f-prompt" class="mt">What should the Web Designer do?</label>
<textarea id="f-prompt" rows="4" placeholder="e.g. Rebuild the hero: shorter headline, add a one-line pull-quote, and place the photo to the right. Keep it on brand."></textarea>
<label for="f-draft" class="mt">Draft content <span class="opt">optional</span></label>
<textarea id="f-draft" rows="4" placeholder="Any copy or notes to work from — rough is fine."></textarea>
<div class="mt">
<div class="head"><label>Images <span class="opt">optional</span></label><button type="button" class="btn ghost sm" id="addImg">+ Add image</button></div>
<div id="gallery" class="grid"></div>
</div>
<div class="mt">
<div class="head"><label>Short video links <span class="opt">optional</span></label><button type="button" class="btn ghost sm" id="addVid">+ Add link</button></div>
<div id="videos" class="rows"></div>
</div>
<div class="submit">
<button type="button" class="btn" id="submit">Add to queue &amp; run</button>
<p class="msg" id="msg" role="status" aria-live="polite"></p>
</div>
</section>
<section class="queue">
<div class="head"><h2>Queue</h2><button type="button" class="btn ghost sm" id="runBtn">Run queue</button></div>
<div id="list" class="tasks">Loading…</div>
</section>
<style>
h2 { font-family:var(--serif); font-weight:400; font-size:1.3rem; margin:0; }
.panel { border:1px solid var(--rule); border-radius:2px; padding:1.5rem; margin-bottom:2.5rem; }
.two { display:grid; grid-template-columns:1fr 1fr; gap:1.25rem 2rem; }
.mt { margin-top:1.4rem; }
.opt { font-size:.55rem; color:var(--ink4); letter-spacing:.08em; }
.head { display:flex; align-items:center; gap:1rem; margin-bottom:.9rem; }
.head label, .head h2 { margin:0; }
textarea { width:100%; border:1px solid var(--rule); background:transparent; border-radius:2px;
padding:.6rem .7rem; font-size:.9rem; color:var(--ink); font-family:var(--sans); line-height:1.55; resize:vertical; }
textarea:focus { outline:none; border-color:var(--seal); }
.sel select { width:100%; border:0; border-bottom:1px solid var(--rule); background:transparent;
padding:.5rem 0; font-size:.95rem; color:var(--ink); font-family:var(--sans); }
.sel select:focus { outline:none; border-bottom-color:var(--seal); }
.slug { display:flex; align-items:baseline; gap:.15rem; border-bottom:1px solid var(--rule); }
.slug span { color:var(--ink4); font-family:var(--mono); }
.slug input { border:0; }
.btn.sm { padding:.35rem .7rem; font-size:.7rem; }
.rows { display:flex; flex-direction:column; gap:.5rem; }
.row { display:flex; gap:.5rem; align-items:center; }
.row input { flex:1; }
.row .rm { flex:0 0 auto; width:1.9rem; height:1.9rem; border:1px solid var(--rule); background:transparent;
color:var(--ink3); border-radius:2px; cursor:pointer; }
.row .rm:hover { color:var(--seal); border-color:var(--seal); }
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(9rem,1fr)); gap:.75rem; }
.slot { border:1px solid var(--rule); border-radius:2px; overflow:hidden; }
.thumb { position:relative; aspect-ratio:4/3; background:var(--card); display:flex; align-items:center; justify-content:center; overflow:hidden; }
.thumb img { width:100%; height:100%; object-fit:cover; }
.thumb .empty { font-family:var(--mono); font-size:.55rem; letter-spacing:.1em; text-transform:uppercase; color:var(--ink4); }
.thumb.busy::after { content:"…"; position:absolute; inset:0; display:flex; align-items:center; justify-content:center; background:color-mix(in srgb,var(--paper) 80%,transparent); }
.slot .acts { display:flex; border-top:1px solid var(--rule); }
.slot .acts label, .slot .acts button { flex:1; text-align:center; padding:.4rem; font-family:var(--mono); font-size:.55rem; letter-spacing:.06em; text-transform:uppercase; color:var(--ink3); cursor:pointer; background:none; border:0; }
.slot .acts label { border-right:1px solid var(--rule); }
.slot .acts input { display:none; }
.submit { display:flex; align-items:center; gap:1.25rem; margin-top:1.75rem; padding-top:1.25rem; border-top:1px solid var(--rule); }
.tasks { display:flex; flex-direction:column; }
.task { display:flex; align-items:center; gap:1rem; padding:.9rem 0; border-top:1px solid var(--rule); }
.task:last-child { border-bottom:1px solid var(--rule); }
.task .t { font-family:var(--sans); font-size:.95rem; }
.task .tgt { font-family:var(--mono); font-size:.62rem; color:var(--ink4); letter-spacing:.04em; }
.task .sp { margin-left:auto; display:flex; align-items:center; gap:1rem; }
.badge { font-family:var(--mono); font-size:.58rem; letter-spacing:.1em; text-transform:uppercase; padding:.2rem .5rem; border:1px solid var(--rule); border-radius:2px; color:var(--ink3); white-space:nowrap; }
.badge.running { color:var(--seal); border-color:var(--seal); }
.badge.published { color:var(--ink); }
.badge.failed { color:var(--seal); border-color:var(--seal); }
.task a.view { font-family:var(--mono); font-size:.6rem; letter-spacing:.08em; text-transform:uppercase; color:var(--ink3); text-decoration:none; }
.task a.view:hover { color:var(--ink); }
.task .err { font-size:.78rem; color:var(--seal); }
.task .cancel { font-family:var(--mono); font-size:.58rem; letter-spacing:.06em; text-transform:uppercase; color:var(--ink4); background:none; border:0; cursor:pointer; }
.task .cancel:hover { color:var(--seal); }
</style>
<script>
const $ = (id) => document.getElementById(id);
const msg = $("msg");
const setMsg = (t, cls = "") => { msg.textContent = t; msg.className = "msg " + cls; };
let images = []; // [{src, alt}]
let videos = []; // [string]
// ---- target page: reveal slug input for "new page" --------------------
$("f-page").addEventListener("change", (e) => { $("newWrap").hidden = e.target.value !== "__new"; });
// ---- images (upload) --------------------------------------------------
function renderImages() {
const g = $("gallery"); g.innerHTML = "";
images.forEach((it, i) => {
const slot = document.createElement("div");
slot.className = "slot";
slot.innerHTML = `
<div class="thumb" data-thumb>${it.src ? `<img src="${it.src}" alt="">` : `<span class="empty">No image</span>`}</div>
<div class="acts">
<label>${it.src ? "Replace" : "Upload"}<input type="file" accept="image/*" data-file></label>
<button type="button" data-rm>Remove</button>
</div>`;
slot.querySelector("[data-rm]").addEventListener("click", () => { images.splice(i, 1); renderImages(); });
slot.querySelector("[data-file]").addEventListener("change", (e) => uploadImg(e.target.files[0], i, slot));
g.appendChild(slot);
});
}
async function uploadImg(file, i, slot) {
if (!file) return;
const thumb = slot.querySelector("[data-thumb]"); thumb.classList.add("busy");
try {
const fd = new FormData(); fd.append("file", file);
const r = await fetch("/api/media/upload", { method: "POST", credentials: "same-origin", body: fd });
const d = await r.json();
if (r.ok && d?.data?.url) { images[i].src = d.data.url; renderImages(); }
else setMsg(d?.error?.message || "Upload failed.", "error");
} catch { setMsg("Upload failed.", "error"); }
finally { thumb.classList.remove("busy"); }
}
$("addImg").addEventListener("click", () => { images.push({ src: "", alt: "" }); renderImages(); });
// ---- video links ------------------------------------------------------
function renderVideos() {
const v = $("videos"); v.innerHTML = "";
videos.forEach((url, i) => {
const row = document.createElement("div");
row.className = "row";
row.innerHTML = `<input type="url" placeholder="https://instagram.com/reel/…" value="${(url || "").replace(/"/g, "&quot;")}" data-url>
<button type="button" class="rm" title="Remove" data-rm>×</button>`;
row.querySelector("[data-url]").addEventListener("input", (e) => { videos[i] = e.target.value; });
row.querySelector("[data-rm]").addEventListener("click", () => { videos.splice(i, 1); renderVideos(); });
v.appendChild(row);
});
}
$("addVid").addEventListener("click", () => { videos.push(""); renderVideos(); });
// ---- submit -----------------------------------------------------------
$("submit").addEventListener("click", async () => {
const prompt = $("f-prompt").value.trim();
if (!prompt) { setMsg("Say what you'd like done.", "error"); return; }
let target = $("f-page").value;
if (target === "__new") {
const slug = $("f-slug").value.trim().replace(/^\/+/, "");
if (!slug) { setMsg("Give the new page a path.", "error"); return; }
target = "new:" + slug;
}
const btn = $("submit"); btn.disabled = true; setMsg("Queuing…");
try {
const payload = {
title: $("f-title").value.trim(),
target_page: target,
prompt,
draft: $("f-draft").value,
assets: { images: images.filter((i) => i.src).map((i) => ({ url: i.src, alt: i.alt || "" })), videos: videos.filter(Boolean) },
};
const r = await fetch("/api/admintasks/create", {
method: "POST", credentials: "same-origin",
headers: { "content-type": "application/json" }, body: JSON.stringify(payload),
});
const d = await r.json();
if (!r.ok || !d?.data?.ok) { setMsg(d?.error?.message || "Couldn't queue the task.", "error"); btn.disabled = false; return; }
await fetch("/devconsole/tasks/run", { method: "POST", credentials: "same-origin" });
// reset the brief
$("f-prompt").value = ""; $("f-draft").value = ""; $("f-title").value = "";
images = []; videos = []; renderImages(); renderVideos();
setMsg("Queued — the Web Designer is on it.", "ok");
load();
} catch { setMsg("Something went wrong.", "error"); }
finally { btn.disabled = false; }
});
$("runBtn").addEventListener("click", async () => {
try { await fetch("/devconsole/tasks/run", { method: "POST", credentials: "same-origin" }); setMsg("Queue kicked.", "ok"); load(); } catch {}
});
// ---- queue list (poll) ------------------------------------------------
const badge = (s) => {
const label = s === "running" ? "running…" : s;
return `<span class="badge ${s}">${label}</span>`;
};
async function load() {
try {
const r = await fetch("/api/admintasks/list", { credentials: "same-origin" });
const d = await r.json();
const tasks = d?.data?.tasks || [];
const list = $("list");
if (!tasks.length) { list.textContent = "No tasks yet."; return; }
list.innerHTML = "";
for (const t of tasks) {
const row = document.createElement("div");
row.className = "task";
let right = badge(t.status);
if (t.status === "published") {
right += ` <a class="view" href="${t.live || "/"}" target="_blank">View live ↗</a>`;
if (t.commit) right += ` <span class="tgt">${t.commit}</span>`;
} else if (t.status === "failed" && t.error) {
right = `<span class="err">${t.error}</span> ` + right;
} else if (t.status === "queued") {
right += ` <button class="cancel" data-cancel="${t.id}">Cancel</button>`;
}
row.innerHTML = `<span class="t">${t.title || "(untitled)"}</span><span class="tgt">${t.target}</span><span class="sp">${right}</span>`;
const cx = row.querySelector("[data-cancel]");
if (cx) cx.addEventListener("click", async () => {
await fetch("/api/admintasks/cancel", { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json" }, body: JSON.stringify({ id: Number(cx.dataset.cancel) }) });
load();
});
list.appendChild(row);
}
} catch { $("list").textContent = "Couldn't load the queue."; }
}
load();
setInterval(load, 4000);
</script>
</AdminLayout>