261 lines
14 KiB
Text
261 lines
14 KiB
Text
|
|
---
|
|||
|
|
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 & 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, """)}" 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>
|