seedproject-web/app/src/pages/admin/designer.astro

260 lines
14 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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>