seedproject-web/app/src/pages/admin/projects/edit.astro
Carlos Arias a2da708418 Phase 2/3: projects media admin (galleries + Instagram)
- /admin/projects list + /admin/projects/edit gallery editor (AdminLayout chrome,
  session-guarded). Upload/replace images per slot, captions, add/remove,
  Instagram reel URL. Save -> cja_projects -> rebuild -> live.
- adminprojects API (list/get/save); media path validation (local paths only).
- runner /rebuild endpoint: build public/ + commit after structured edits.
- Ownership: app/ + public/ now owned by carlos-arias-agent:caweb so the agent
  can rebuild its own output; www serves via the caweb group. Documented in
  SETUP.md — never build as root.

Verified end to end: upload image -> save to gallery -> rebuild -> image live on
the project page and served.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DoFYZY9gkGPNDqZ7NuEa9a
2026-07-23 22:55:32 +00:00

151 lines
7.2 KiB
Text

---
import AdminLayout from "../../../layouts/AdminLayout.astro";
---
<AdminLayout title="Edit project">
<a href="/admin/projects" class="back">← All projects</a>
<h1 id="ptitle">Project</h1>
<p class="lead">Upload or replace gallery images, set the Instagram reel, then publish.</p>
<section class="block">
<label>Instagram reel URL</label>
<input type="url" id="ig" placeholder="https://instagram.com/reel/…" />
<p class="hint">Shown as the reel beside the case study.</p>
</section>
<section class="block">
<div class="head">
<label>Gallery</label>
<button type="button" class="btn ghost sm" id="addSlot">+ Add image</button>
</div>
<div id="gallery" class="grid"></div>
</section>
<div class="footer">
<button type="button" class="btn" id="save">Save &amp; publish</button>
<p class="msg" id="msg" role="status" aria-live="polite"></p>
</div>
<style>
.back { font-family:var(--mono); font-size:.62rem; letter-spacing:.12em; text-transform:uppercase;
color:var(--ink4); text-decoration:none; display:inline-block; margin-bottom:1.25rem; }
.back:hover { color:var(--ink); }
.block { margin:2rem 0; }
.block .head { display:flex; align-items:center; gap:1rem; margin-bottom:1rem; }
.block .head label { margin:0; }
.hint { font-size:.78rem; color:var(--ink4); margin:.4rem 0 0; }
.btn.sm { padding:.35rem .7rem; font-size:.7rem; }
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(11rem,1fr)); gap:1rem; }
.slot { border:1px solid var(--rule); border-radius:2px; overflow:hidden; display:flex; flex-direction:column; }
.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:.6rem; letter-spacing:.1em; text-transform:uppercase; color:var(--ink4); }
.thumb.busy::after { content:"Uploading…"; position:absolute; inset:0; display:flex; align-items:center;
justify-content:center; background:color-mix(in srgb, var(--paper) 80%, transparent);
font-family:var(--mono); font-size:.65rem; color:var(--ink3); }
.slot .cap { border:0; border-top:1px solid var(--rule); background:transparent; padding:.5rem .6rem;
font-size:.8rem; color:var(--ink); font-family:var(--sans); width:100%; }
.slot .cap:focus { outline:none; }
.slot .acts { display:flex; border-top:1px solid var(--rule); }
.slot .acts label, .slot .acts button { flex:1; text-align:center; padding:.45rem; font-family:var(--mono);
font-size:.58rem; letter-spacing:.08em; text-transform:uppercase; color:var(--ink3); cursor:pointer;
background:none; border:0; }
.slot .acts label { border-right:1px solid var(--rule); }
.slot .acts button:hover { color:var(--seal); } .slot .acts label:hover { color:var(--ink); }
.slot .acts input { display:none; }
.footer { position:sticky; bottom:0; display:flex; align-items:center; gap:1.5rem; padding:1rem 0;
margin-top:2rem; border-top:1px solid var(--rule);
background:color-mix(in srgb, var(--paper) 92%, transparent); }
</style>
<script>
const slug = new URLSearchParams(location.search).get("slug") || "";
const galleryEl = document.getElementById("gallery");
const msg = document.getElementById("msg");
const setMsg = (t, cls = "") => { msg.textContent = t; msg.className = "msg " + cls; };
// in-memory model: [{src, alt, caption}]
let items = [];
function render() {
galleryEl.innerHTML = "";
items.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>
<input class="cap" placeholder="Caption" value="${(it.caption || "").replace(/"/g, "&quot;")}" data-cap>
<div class="acts">
<label>${it.src ? "Replace" : "Upload"}<input type="file" accept="image/*" data-file></label>
<button type="button" data-remove>Remove</button>
</div>`;
slot.querySelector("[data-cap]").addEventListener("input", (e) => { items[i].caption = e.target.value; });
slot.querySelector("[data-remove]").addEventListener("click", () => { items.splice(i, 1); render(); });
slot.querySelector("[data-file]").addEventListener("change", (e) => upload(e.target.files[0], i, slot));
galleryEl.appendChild(slot);
});
}
async function upload(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) {
items[i].src = d.data.url;
render();
} else {
setMsg(d?.error?.message || "Upload failed.", "error");
}
} catch { setMsg("Upload failed.", "error"); }
finally { thumb.classList.remove("busy"); }
}
document.getElementById("addSlot").addEventListener("click", () => {
items.push({ src: "", alt: "", caption: "" });
render();
});
document.getElementById("save").addEventListener("click", async () => {
const btn = document.getElementById("save");
btn.disabled = true; setMsg("Saving…");
try {
const links = {};
const ig = document.getElementById("ig").value.trim();
if (ig) links.instagram = ig;
const save = await fetch("/api/adminprojects/save", {
method: "POST", credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify({ slug, gallery: items, links }),
});
const sd = await save.json();
if (!save.ok || !sd?.data?.ok) { setMsg(sd?.error?.message || "Save failed.", "error"); btn.disabled = false; return; }
setMsg("Saved — publishing to the live site…");
const pub = await fetch("/devconsole/rebuild", { method: "POST", credentials: "same-origin" });
if (pub.ok) setMsg("Published. Changes are live.", "ok");
else setMsg("Saved, but the publish build failed. Try again.", "error");
} catch { setMsg("Something went wrong.", "error"); }
finally { btn.disabled = false; }
});
(async () => {
if (!slug) { setMsg("No project specified.", "error"); return; }
try {
const r = await fetch("/api/adminprojects/get?slug=" + encodeURIComponent(slug), { credentials: "same-origin" });
const d = await r.json();
if (!r.ok || !d?.data) { setMsg("Couldn't load the project.", "error"); return; }
document.getElementById("ptitle").textContent = d.data.title;
document.getElementById("ig").value = d.data.links?.instagram || "";
items = Array.isArray(d.data.gallery) ? d.data.gallery : [];
render();
} catch { setMsg("Couldn't load the project.", "error"); }
})();
</script>
</AdminLayout>