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
This commit is contained in:
parent
dc69d1fd0d
commit
a2da708418
6 changed files with 425 additions and 0 deletions
|
|
@ -41,3 +41,19 @@ Full edit→preview→serve loop proven end to end.
|
||||||
check → proxy to `127.0.0.1:3011`. Runner never exposed directly.
|
check → proxy to `127.0.0.1:3011`. Runner never exposed directly.
|
||||||
- Wire `DevConsole.astro` to the runner.
|
- Wire `DevConsole.astro` to the runner.
|
||||||
- systemd unit to keep the runner alive.
|
- systemd unit to keep the runner alive.
|
||||||
|
|
||||||
|
## Build ownership (IMPORTANT)
|
||||||
|
- `app/` and `public/` are owned by **carlos-arias-agent:caweb**. The agent runs
|
||||||
|
all builds and must own its output to overwrite it.
|
||||||
|
- **Never run `npm run build` as root** — it creates root-owned files the agent
|
||||||
|
then can't overwrite (EPERM in vite prepare-out-dir). If it happens, fix with:
|
||||||
|
`chown -R carlos-arias-agent:caweb app public`.
|
||||||
|
- Rebuild via the runner (`POST /devconsole/rebuild`) or
|
||||||
|
`sudo -u carlos-arias-agent env HOME=/var/lib/carlos-arias-agent … npm run build`.
|
||||||
|
- www serves `public/` via the shared `caweb` group — no chown-to-www needed.
|
||||||
|
|
||||||
|
## Phase 2/3 delivered
|
||||||
|
- Media upload: `POST /api/media/upload` (GD/ffmpeg optimise → app/public/media).
|
||||||
|
- Projects admin: `/admin/projects` (list) + `/admin/projects/edit?slug=` (gallery
|
||||||
|
upload/replace + Instagram URL). Saves to cja_projects, then rebuilds.
|
||||||
|
- `POST /devconsole/rebuild` regenerates the live site after structured edits.
|
||||||
|
|
|
||||||
|
|
@ -353,6 +353,20 @@ const server = createServer(async (req, res) => {
|
||||||
return json(res, 200, { ok: true, live: job.page || "/" });
|
return json(res, 200, { ok: true, live: job.page || "/" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rebuild the live site — used after structured content edits (projects
|
||||||
|
// gallery, etc.) that changed the DB rather than files. Builds public/ and
|
||||||
|
// commits any new media files (a no-op commit is fine).
|
||||||
|
if (path === "/rebuild" && method === "POST") {
|
||||||
|
if (busy) return json(res, 429, { error: "Busy — try again in a moment." });
|
||||||
|
busy = true;
|
||||||
|
const built = await build({});
|
||||||
|
if (!built.ok) { busy = false; return json(res, 500, { error: "Build failed." }); }
|
||||||
|
await git(["add", "--", "app/public/media", "app", "brand", "api/db", "api/cli"]);
|
||||||
|
await git(["commit", "-q", "-m", "console: content update"]); // ok if nothing to commit
|
||||||
|
busy = false;
|
||||||
|
return json(res, 200, { ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
if (path === "/discard" && method === "POST") {
|
if (path === "/discard" && method === "POST") {
|
||||||
if (busy) return json(res, 429, { error: "Busy — try again in a moment." });
|
if (busy) return json(res, 429, { error: "Busy — try again in a moment." });
|
||||||
const { conversationId } = await readBody(req);
|
const { conversationId } = await readBody(req);
|
||||||
|
|
|
||||||
113
api/public/controllers/adminprojects.php
Normal file
113
api/public/controllers/adminprojects.php
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Controllers\PublicController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured editing of projects for the admin console (galleries, Instagram,
|
||||||
|
* cover). Content lives in cja_projects; the static site reads it at build
|
||||||
|
* time, so a save is followed by a rebuild (the console's /devconsole/rebuild).
|
||||||
|
*
|
||||||
|
* GET /api/adminprojects/list -> [{slug,title,galleryCount,...}]
|
||||||
|
* GET /api/adminprojects/get?slug=... -> full editable record
|
||||||
|
* POST /api/adminprojects/save -> { slug, gallery, links, cover_image }
|
||||||
|
*
|
||||||
|
* All admin-gated. Only media-related fields are writable here; the freeform
|
||||||
|
* agent console handles prose and layout.
|
||||||
|
*/
|
||||||
|
class AdminProjects extends PublicController
|
||||||
|
{
|
||||||
|
public function list(): void
|
||||||
|
{
|
||||||
|
$this->requireAdmin();
|
||||||
|
$rows = \Db::select('SELECT slug, title, status, gallery, links
|
||||||
|
FROM cja_projects ORDER BY sort_order ASC');
|
||||||
|
$out = array_map(function ($r) {
|
||||||
|
$gallery = json_decode($r['gallery'] ?? '[]', true) ?: [];
|
||||||
|
$links = json_decode($r['links'] ?? '{}', true) ?: [];
|
||||||
|
$filled = count(array_filter($gallery, fn($g) => !empty($g['src'])));
|
||||||
|
return [
|
||||||
|
'slug' => $r['slug'],
|
||||||
|
'title' => $r['title'],
|
||||||
|
'status' => $r['status'],
|
||||||
|
'gallery' => count($gallery),
|
||||||
|
'galleryFilled' => $filled,
|
||||||
|
'hasInstagram' => !empty($links['instagram']),
|
||||||
|
];
|
||||||
|
}, $rows);
|
||||||
|
$this->json(['projects' => $out]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(): void
|
||||||
|
{
|
||||||
|
$this->requireAdmin();
|
||||||
|
$slug = (string) ($_GET['slug'] ?? '');
|
||||||
|
$r = \Db::getRow('SELECT slug, title, cover_image, cover_alt, gallery, links
|
||||||
|
FROM cja_projects WHERE slug = ?', [$slug]);
|
||||||
|
if (!$r) {
|
||||||
|
$this->json(null, 404, ['code' => 'not_found', 'message' => 'No such project.']);
|
||||||
|
}
|
||||||
|
$this->json([
|
||||||
|
'slug' => $r['slug'],
|
||||||
|
'title' => $r['title'],
|
||||||
|
'cover' => $r['cover_image'] ?? '',
|
||||||
|
'coverAlt'=> $r['cover_alt'] ?? '',
|
||||||
|
'gallery' => json_decode($r['gallery'] ?? '[]', true) ?: [],
|
||||||
|
'links' => json_decode($r['links'] ?? '{}', true) ?: [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$this->requireAdmin();
|
||||||
|
$this->guardPublic('adminprojects_save', 60, 300);
|
||||||
|
|
||||||
|
$in = json_decode((string) file_get_contents('php://input'), true) ?: [];
|
||||||
|
$slug = (string) ($in['slug'] ?? '');
|
||||||
|
|
||||||
|
$id = \Db::getValue('SELECT project_id FROM cja_projects WHERE slug = ?', [$slug]);
|
||||||
|
if (!$id) {
|
||||||
|
$this->json(null, 404, ['code' => 'not_found', 'message' => 'No such project.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- gallery: array of {src, alt, caption} -------------------------
|
||||||
|
$gallery = [];
|
||||||
|
foreach ((array) ($in['gallery'] ?? []) as $g) {
|
||||||
|
$src = $this->safeMedia((string) ($g['src'] ?? ''));
|
||||||
|
$gallery[] = [
|
||||||
|
'src' => $src,
|
||||||
|
'alt' => mb_substr(trim((string) ($g['alt'] ?? '')), 0, 200),
|
||||||
|
'caption' => mb_substr(trim((string) ($g['caption'] ?? '')), 0, 120),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- links: only known keys, only real URLs ------------------------
|
||||||
|
$links = [];
|
||||||
|
foreach (['live', 'instagram', 'github'] as $k) {
|
||||||
|
$v = trim((string) ($in['links'][$k] ?? ''));
|
||||||
|
if ($v !== '' && filter_var($v, FILTER_VALIDATE_URL)) {
|
||||||
|
$links[$k] = $v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$cover = $this->safeMedia((string) ($in['cover_image'] ?? ''));
|
||||||
|
|
||||||
|
\Db::update('cja_projects', [
|
||||||
|
'gallery' => json_encode($gallery, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
|
||||||
|
'links' => json_encode($links, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
|
||||||
|
'cover_image' => $cover,
|
||||||
|
], 'project_id = ?', [$id]);
|
||||||
|
|
||||||
|
$this->json(['ok' => true, 'slug' => $slug]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only allow local media paths (uploads live under /media/ or /projects/),
|
||||||
|
* never arbitrary or off-site URLs — the src is rendered into the page.
|
||||||
|
*/
|
||||||
|
private function safeMedia(string $s): string
|
||||||
|
{
|
||||||
|
$s = trim($s);
|
||||||
|
if ($s === '') return '';
|
||||||
|
return preg_match('#^/(media|projects|portfolio)/[\w./-]+$#', $s) ? $s : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
86
app/src/layouts/AdminLayout.astro
Normal file
86
app/src/layouts/AdminLayout.astro
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
---
|
||||||
|
// Minimal admin chrome for the dashboard pages. Public HTML (the real gate is
|
||||||
|
// on every API endpoint), but it redirects to /admin if there's no session, so
|
||||||
|
// the pages are never usefully reachable without logging in.
|
||||||
|
const { title = "Admin" } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>{title} — Carlos Arias</title>
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
<style is:global>
|
||||||
|
:root {
|
||||||
|
--paper:#f6f5f2; --ink:#14151a; --ink2:#3d3f47; --ink3:#6e7078; --ink4:#9a9ca3;
|
||||||
|
--rule:#dfddd7; --seal:#d0342c; --card:#efeeea;
|
||||||
|
--serif:"Fraunces",ui-serif,Georgia,serif; --sans:"Inter",ui-sans-serif,system-ui,sans-serif;
|
||||||
|
--mono:"JetBrains Mono",ui-monospace,monospace;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root { --paper:#121317; --ink:#edece8; --ink2:#c2c1bd; --ink3:#8b8d95; --ink4:#5e606a;
|
||||||
|
--rule:#2a2c33; --seal:#e8564a; --card:#191a1f; }
|
||||||
|
}
|
||||||
|
* { box-sizing:border-box; }
|
||||||
|
body { margin:0; background:var(--paper); color:var(--ink); font-family:var(--sans);
|
||||||
|
font-size:15px; line-height:1.5; }
|
||||||
|
a { color:inherit; }
|
||||||
|
.admin-top { position:sticky; top:0; z-index:10; display:flex; align-items:center; gap:.85rem;
|
||||||
|
padding:.9rem 1.25rem; border-bottom:1px solid var(--rule);
|
||||||
|
background:color-mix(in srgb, var(--paper) 90%, transparent); backdrop-filter:blur(8px); }
|
||||||
|
.admin-seal { display:inline-flex; align-items:center; justify-content:center; width:1.6rem; height:1.6rem;
|
||||||
|
border-radius:2px; background:var(--seal); color:#fff; font-family:var(--serif); font-size:.8rem; }
|
||||||
|
.admin-top .name { font-family:var(--mono); font-size:.62rem; letter-spacing:.14em; text-transform:uppercase; color:var(--ink3); }
|
||||||
|
.admin-top nav { margin-left:auto; display:flex; gap:1.25rem; align-items:center; }
|
||||||
|
.admin-top nav a, .admin-top nav button { font-family:var(--mono); font-size:.62rem; letter-spacing:.1em;
|
||||||
|
text-transform:uppercase; color:var(--ink3); text-decoration:none; background:none; border:0; cursor:pointer; }
|
||||||
|
.admin-top nav a:hover, .admin-top nav button:hover { color:var(--ink); }
|
||||||
|
main { max-width:60rem; margin:0 auto; padding:2.5rem 1.25rem 5rem; }
|
||||||
|
h1 { font-family:var(--serif); font-weight:400; font-size:2rem; letter-spacing:-.01em; margin:0 0 .4rem; }
|
||||||
|
.lead { color:var(--ink3); margin:0 0 2rem; }
|
||||||
|
.btn { border:1px solid var(--ink); background:var(--ink); color:var(--paper); border-radius:2px;
|
||||||
|
padding:.7rem 1.3rem; font-size:.85rem; font-weight:500; cursor:pointer; font-family:var(--sans); }
|
||||||
|
.btn:disabled { opacity:.55; cursor:default; }
|
||||||
|
.btn.ghost { background:transparent; color:var(--ink); border-color:var(--rule); }
|
||||||
|
label { font-family:var(--mono); font-size:.6rem; letter-spacing:.14em; text-transform:uppercase; color:var(--ink4);
|
||||||
|
display:block; margin-bottom:.35rem; }
|
||||||
|
input[type=text], input[type=url] { 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); }
|
||||||
|
input:focus { outline:none; border-bottom-color:var(--seal); }
|
||||||
|
.msg { font-size:.85rem; min-height:1.2em; }
|
||||||
|
.msg.error { color:var(--seal); } .msg.ok { color:var(--seal); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="admin-top">
|
||||||
|
<span class="admin-seal" aria-hidden="true">印</span>
|
||||||
|
<span class="name">Carlos Arias · Admin</span>
|
||||||
|
<nav>
|
||||||
|
<a href="/admin/projects">Projects</a>
|
||||||
|
<a href="/" target="_blank">View site ↗</a>
|
||||||
|
<button type="button" data-logout>Sign out</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Guard: no admin session -> back to login.
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/adminauth/me", { credentials: "same-origin" });
|
||||||
|
const d = await r.json();
|
||||||
|
if (!d?.data?.admin) location.replace("/admin");
|
||||||
|
} catch { location.replace("/admin"); }
|
||||||
|
})();
|
||||||
|
document.querySelector("[data-logout]")?.addEventListener("click", async () => {
|
||||||
|
try { await fetch("/api/adminauth/logout", { method: "POST", credentials: "same-origin" }); } catch {}
|
||||||
|
location.replace("/admin");
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
151
app/src/pages/admin/projects/edit.astro
Normal file
151
app/src/pages/admin/projects/edit.astro
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
---
|
||||||
|
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 & 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, """)}" 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>
|
||||||
45
app/src/pages/admin/projects/index.astro
Normal file
45
app/src/pages/admin/projects/index.astro
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
---
|
||||||
|
import AdminLayout from "../../../layouts/AdminLayout.astro";
|
||||||
|
---
|
||||||
|
|
||||||
|
<AdminLayout title="Projects">
|
||||||
|
<h1>Projects</h1>
|
||||||
|
<p class="lead">Add or replace gallery images and Instagram reels for each project.</p>
|
||||||
|
|
||||||
|
<div id="list" style="display:flex;flex-direction:column">Loading…</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.prow { display:flex; align-items:center; gap:1rem; padding:1.1rem 0; border-top:1px solid var(--rule);
|
||||||
|
text-decoration:none; color:inherit; }
|
||||||
|
.prow:last-child { border-bottom:1px solid var(--rule); }
|
||||||
|
.prow .t { font-family:var(--serif); font-size:1.25rem; }
|
||||||
|
.prow .meta { margin-left:auto; display:flex; gap:1.25rem; font-family:var(--mono); font-size:.68rem;
|
||||||
|
letter-spacing:.06em; color:var(--ink4); }
|
||||||
|
.prow .meta .on { color:var(--seal); }
|
||||||
|
.prow:hover .t { text-decoration:underline; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const list = document.getElementById("list");
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/adminprojects/list", { credentials: "same-origin" });
|
||||||
|
const d = await r.json();
|
||||||
|
if (!d?.data?.projects) { list.textContent = "Couldn't load projects."; return; }
|
||||||
|
list.innerHTML = "";
|
||||||
|
for (const p of d.data.projects) {
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = "/admin/projects/edit?slug=" + encodeURIComponent(p.slug);
|
||||||
|
a.className = "prow";
|
||||||
|
a.innerHTML =
|
||||||
|
`<span class="t">${p.title}</span>
|
||||||
|
<span class="meta">
|
||||||
|
<span class="${p.galleryFilled ? "on" : ""}">${p.galleryFilled}/${p.gallery} images</span>
|
||||||
|
<span class="${p.hasInstagram ? "on" : ""}">${p.hasInstagram ? "Reel ✓" : "No reel"}</span>
|
||||||
|
</span>`;
|
||||||
|
list.appendChild(a);
|
||||||
|
}
|
||||||
|
} catch { list.textContent = "Couldn't load projects."; }
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</AdminLayout>
|
||||||
Loading…
Reference in a new issue