<?php
// Admin panel — localhost only
if (!in_array($_SERVER['REMOTE_ADDR'], ['127.0.0.1', '::1']) &&
!str_starts_with($_SERVER['REMOTE_ADDR'], '192.168.') &&
$_SERVER['REMOTE_ADDR'] !== $_SERVER['SERVER_ADDR']) {
http_response_code(403);
die('Admin panel is localhost only.');
}
$ADMIN_FILE = __DIR__ . '/admin.json';
$MANIFEST_FILE = __DIR__ . '/manifest.json';
function load_admin() {
global $ADMIN_FILE;
if (file_exists($ADMIN_FILE)) {
return json_decode(file_get_contents($ADMIN_FILE), true) ?: ['order' => [], 'home' => []];
}
return ['order' => [], 'home' => []];
}
function save_admin($data) {
global $ADMIN_FILE;
file_put_contents($ADMIN_FILE, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
}
function load_manifest() {
global $MANIFEST_FILE;
if (file_exists($MANIFEST_FILE)) {
return json_decode(file_get_contents($MANIFEST_FILE), true) ?: ['collections' => [], 'items' => []];
}
return ['collections' => [], 'items' => []];
}
// API endpoint
if (isset($_GET['action'])) {
header('Content-Type: application/json');
if ($_GET['action'] === 'load') {
echo json_encode([
'admin' => load_admin(),
'manifest' => load_manifest(),
]);
exit;
}
if ($_GET['action'] === 'save' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
if ($input) {
save_admin($input);
echo json_encode(['ok' => true]);
} else {
http_response_code(400);
echo json_encode(['error' => 'Invalid JSON']);
}
exit;
}
http_response_code(400);
echo json_encode(['error' => 'Unknown action']);
exit;
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin — Folio</title>
<style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #111;
color: #ccc;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-weight: 300;
font-size: 14px;
}
/* Layout */
.admin-layout {
display: flex;
min-height: 100vh;
}
/* Sidebar */
.admin-sidebar {
width: 200px;
flex-shrink: 0;
padding: 20px;
border-right: 1px solid #333;
background: #0a0a0a;
}
.admin-sidebar h2 {
font-size: 12px;
letter-spacing: 2px;
text-transform: uppercase;
color: #666;
margin-bottom: 16px;
}
.admin-sidebar a {
display: block;
padding: 8px 12px;
margin-bottom: 4px;
color: #999;
text-decoration: none;
font-size: 13px;
border-radius: 4px;
cursor: pointer;
}
.admin-sidebar a:hover { background: #222; color: #fff; }
.admin-sidebar a.active { background: #333; color: #fff; }
.admin-sidebar .home-link {
color: #e8b84b;
border: 1px solid #333;
margin-bottom: 12px;
}
.admin-sidebar .home-link.active { background: #2a2210; border-color: #e8b84b; }
/* Main */
.admin-main {
flex: 1;
padding: 24px;
overflow-y: auto;
}
.admin-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.admin-header h1 {
font-size: 16px;
font-weight: 400;
letter-spacing: 2px;
text-transform: uppercase;
}
.status {
font-size: 12px;
color: #666;
transition: color 0.3s;
}
.status.saved { color: #4a4; }
/* Grid */
.admin-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
}
.admin-item {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 6px;
overflow: hidden;
transition: border-color 0.2s;
}
.admin-item:hover { border-color: #555; }
.admin-item.home-flagged { border-color: #e8b84b; }
.admin-item img, .admin-item video {
width: 100%;
height: 140px;
object-fit: cover;
display: block;
}
.admin-item-controls {
padding: 8px;
display: flex;
align-items: center;
gap: 8px;
}
.admin-item-controls input[type="number"] {
width: 50px;
background: #222;
border: 1px solid #444;
color: #fff;
padding: 4px 6px;
font-size: 13px;
border-radius: 3px;
text-align: center;
}
.admin-item-controls input[type="number"]:focus {
outline: none;
border-color: #888;
}
.admin-item-name {
flex: 1;
font-size: 11px;
color: #888;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.home-star {
cursor: pointer;
font-size: 18px;
color: #444;
transition: color 0.2s;
background: none;
border: none;
padding: 0 4px;
}
.home-star:hover { color: #e8b84b; }
.home-star.active { color: #e8b84b; }
/* Video play badge */
.admin-item .video-badge {
position: absolute;
top: 4px;
right: 4px;
background: rgba(0,0,0,0.7);
color: #fff;
font-size: 10px;
padding: 2px 6px;
border-radius: 3px;
}
.admin-item-thumb {
position: relative;
}
</style>
</head>
<body>
<div class="admin-layout">
<nav class="admin-sidebar">
<h2>Collections</h2>
<a class="home-link" data-collection="__home__">★ Home</a>
<div id="collection-links"></div>
</nav>
<main class="admin-main">
<div class="admin-header">
<h1 id="collection-title">Select a collection</h1>
<span class="status" id="status"></span>
</div>
<div class="admin-grid" id="grid"></div>
</main>
</div>
<script>
let manifest = { collections: [], items: [] };
let admin = { order: {}, home: {} };
let activeCollection = null;
let saveTimeout = null;
// ── Load data ── //
async function loadData() {
const res = await fetch('admin.php?action=load');
const data = await res.json();
manifest = data.manifest;
admin = data.admin;
if (!admin.order) admin.order = {};
if (!admin.home) admin.home = {};
renderSidebar();
// Default to home view
selectCollection('__home__');
}
// ── Save (debounced) ── //
function scheduleSave() {
const status = document.getElementById('status');
status.textContent = 'unsaved...';
status.className = 'status';
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = setTimeout(async () => {
await fetch('admin.php?action=save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(admin),
});
status.textContent = 'saved';
status.className = 'status saved';
setTimeout(() => { status.textContent = ''; }, 2000);
}, 500);
}
// ── Sidebar ── //
function renderSidebar() {
const container = document.getElementById('collection-links');
container.innerHTML = '';
for (const coll of manifest.collections) {
const a = document.createElement('a');
a.textContent = coll;
a.dataset.collection = coll;
a.addEventListener('click', () => selectCollection(coll));
container.appendChild(a);
}
// Home link handler
document.querySelector('.home-link').addEventListener('click', () => selectCollection('__home__'));
}
// ── Select collection ── //
function selectCollection(coll) {
activeCollection = coll;
// Update sidebar active state
document.querySelectorAll('.admin-sidebar a').forEach(a => a.classList.remove('active'));
if (coll === '__home__') {
document.querySelector('.home-link').classList.add('active');
} else {
const link = document.querySelector(`.admin-sidebar a[data-collection="${coll}"]`);
if (link) link.classList.add('active');
}
renderGrid();
}
// ── Render grid ── //
function renderGrid() {
const grid = document.getElementById('grid');
const title = document.getElementById('collection-title');
grid.innerHTML = '';
if (activeCollection === '__home__') {
title.textContent = 'Home — Curated Landing Page';
renderHomeGrid(grid);
} else {
title.textContent = activeCollection;
renderCollectionGrid(grid);
}
}
function renderCollectionGrid(grid) {
const coll = activeCollection;
const items = manifest.items.filter(i => i.collection === coll);
const order = admin.order[coll] || {};
// Sort: numbered first (by number), then unnumbered alphabetically
items.sort((a, b) => {
const oa = order[a.name];
const ob = order[b.name];
if (oa != null && ob != null) return oa - ob;
if (oa != null) return -1;
if (ob != null) return 1;
return a.name.localeCompare(b.name);
});
for (const item of items) {
const key = `${item.collection}/${item.name}`;
const isHome = key in admin.home;
const orderNum = order[item.name];
const card = document.createElement('div');
card.className = 'admin-item' + (isHome ? ' home-flagged' : '');
// Thumbnail
const thumbWrap = document.createElement('div');
thumbWrap.className = 'admin-item-thumb';
if (item.type === 'video') {
const badge = document.createElement('span');
badge.className = 'video-badge';
badge.textContent = 'VIDEO';
thumbWrap.appendChild(badge);
}
const img = document.createElement('img');
img.src = item.thumb;
img.loading = 'lazy';
thumbWrap.appendChild(img);
card.appendChild(thumbWrap);
// Controls row
const controls = document.createElement('div');
controls.className = 'admin-item-controls';
// Order number
const numInput = document.createElement('input');
numInput.type = 'number';
numInput.min = '1';
numInput.placeholder = '#';
if (orderNum != null) numInput.value = orderNum;
numInput.addEventListener('input', () => {
if (!admin.order[coll]) admin.order[coll] = {};
const val = numInput.value.trim();
if (val === '') {
delete admin.order[coll][item.name];
} else {
admin.order[coll][item.name] = parseInt(val, 10);
}
scheduleSave();
// Re-sort visually
renderGrid();
});
controls.appendChild(numInput);
// Filename
const nameSpan = document.createElement('span');
nameSpan.className = 'admin-item-name';
nameSpan.textContent = item.name;
nameSpan.title = item.name;
controls.appendChild(nameSpan);
// Home star
const star = document.createElement('button');
star.className = 'home-star' + (isHome ? ' active' : '');
star.innerHTML = '★';
star.title = isHome ? 'Remove from Home' : 'Add to Home';
star.addEventListener('click', () => {
if (key in admin.home) {
delete admin.home[key];
card.classList.remove('home-flagged');
star.classList.remove('active');
star.title = 'Add to Home';
} else {
// Auto-assign next priority
const maxP = Math.max(0, ...Object.values(admin.home));
admin.home[key] = maxP + 1;
card.classList.add('home-flagged');
star.classList.add('active');
star.title = 'Remove from Home';
}
scheduleSave();
});
controls.appendChild(star);
card.appendChild(controls);
grid.appendChild(card);
}
}
function renderHomeGrid(grid) {
const homeItems = [];
for (const [key, priority] of Object.entries(admin.home)) {
const [coll, name] = key.split('/');
const item = manifest.items.find(i => i.collection === coll && i.name === name);
if (item) {
homeItems.push({ ...item, homeKey: key, homePriority: priority });
}
}
homeItems.sort((a, b) => a.homePriority - b.homePriority);
if (homeItems.length === 0) {
grid.innerHTML = '<p style="color:#666; padding: 40px;">No images flagged for Home yet. Open a collection and click the ★ star to add images.</p>';
return;
}
for (const item of homeItems) {
const card = document.createElement('div');
card.className = 'admin-item home-flagged';
// Thumbnail
const thumbWrap = document.createElement('div');
thumbWrap.className = 'admin-item-thumb';
if (item.type === 'video') {
const badge = document.createElement('span');
badge.className = 'video-badge';
badge.textContent = 'VIDEO';
thumbWrap.appendChild(badge);
}
const img = document.createElement('img');
img.src = item.thumb;
img.loading = 'lazy';
thumbWrap.appendChild(img);
card.appendChild(thumbWrap);
// Controls
const controls = document.createElement('div');
controls.className = 'admin-item-controls';
// Priority number
const numInput = document.createElement('input');
numInput.type = 'number';
numInput.min = '1';
numInput.value = item.homePriority;
numInput.addEventListener('input', () => {
const val = numInput.value.trim();
if (val === '') {
delete admin.home[item.homeKey];
} else {
admin.home[item.homeKey] = parseInt(val, 10);
}
scheduleSave();
renderGrid();
});
controls.appendChild(numInput);
// Name (collection/filename)
const nameSpan = document.createElement('span');
nameSpan.className = 'admin-item-name';
nameSpan.textContent = item.homeKey;
nameSpan.title = item.homeKey;
controls.appendChild(nameSpan);
// Remove from home
const star = document.createElement('button');
star.className = 'home-star active';
star.innerHTML = '★';
star.title = 'Remove from Home';
star.addEventListener('click', () => {
delete admin.home[item.homeKey];
scheduleSave();
renderGrid();
});
controls.appendChild(star);
card.appendChild(controls);
grid.appendChild(card);
}
}
// ── Init ── //
loadData();
</script>
</body>
</html>