OverviewFilesCommitsIssuesDocsReleasesPeers

folio / admin.php

532 lines · php ref:
<?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__">&#9733; 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 = '&#9733;';
        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 &#9733; 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 = '&#9733;';
        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>