This commit is contained in:
Ty Clifford
2026-05-20 21:08:23 -04:00
commit 6ab3058a92
187 changed files with 18355 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
[]
+98
View File
@@ -0,0 +1,98 @@
<?php
/**
* admanager/config.php
* ─────────────────────────────────────────────────────────────────────────────
* Central configuration for the ad management system.
* Both admanager/index.php and live/index.php read from here.
*/
// ── Data store ────────────────────────────────────────────────────────────────
// Absolute path to the ads JSON file.
define('ADS_JSON_PATH', __DIR__ . '/ads.json');
// ── Authentication ────────────────────────────────────────────────────────────
// Plain string key — change this before deploying.
// Used by save.php to gate all write operations.
define('ADS_ADMIN_KEY', 'change-me-before-deploy');
// ── Site identity (used in manager UI) ───────────────────────────────────────
define('ADS_SITE_NAME', 'TyClifford.com');
define('ADS_MANAGER_TITLE', 'Ad Manager');
// ── Placement labels ──────────────────────────────────────────────────────────
// These map to the 'placement' field on each ad.
define('ADS_PLACEMENTS', serialize([
'above' => 'Above stream',
'below' => 'Below stream',
]));
// ── Standard ad sizes ─────────────────────────────────────────────────────────
// Each size has:
// key — internal identifier (used as CSS class suffix)
// label — human-readable name shown in the manager
// width — px width of the creative
// height — px height of the creative
// min_screen — minimum viewport width (px) at which this size is shown
// max_screen — maximum viewport width (px) at which this size is shown
// (null = no upper limit)
//
// An ad can have multiple sizes. The live page renders ALL size variants
// for each ad and uses CSS media queries to show exactly one at a time.
define('ADS_SIZES', serialize([
[
'key' => 'mobile-banner',
'label' => 'Mobile Banner (320×50)',
'width' => 320,
'height' => 50,
'min_screen' => 0,
'max_screen' => 479,
],
[
'key' => 'mobile-rect',
'label' => 'Mobile Rectangle (300×250)',
'width' => 300,
'height' => 250,
'min_screen' => 0,
'max_screen' => 599,
],
[
'key' => 'tablet-banner',
'label' => 'Tablet Banner (468×60)',
'width' => 468,
'height' => 60,
'min_screen' => 480,
'max_screen' => 767,
],
[
'key' => 'leaderboard',
'label' => 'Leaderboard (728×90)',
'width' => 728,
'height' => 90,
'min_screen' => 768,
'max_screen' => 1023,
],
[
'key' => 'billboard',
'label' => 'Billboard (970×90)',
'width' => 970,
'height' => 90,
'min_screen' => 1024,
'max_screen' => null,
],
[
'key' => 'large-rect',
'label' => 'Large Rectangle (336×280)',
'width' => 336,
'height' => 280,
'min_screen' => 600,
'max_screen' => null,
],
[
'key' => 'custom',
'label' => 'Custom size',
'width' => null,
'height' => null,
'min_screen' => 0,
'max_screen' => null,
],
]));
+683
View File
@@ -0,0 +1,683 @@
<?php
require_once __DIR__ . '/config.php';
$all_ads = [];
if (file_exists(ADS_JSON_PATH)) {
$raw = json_decode(file_get_contents(ADS_JSON_PATH), true);
if (is_array($raw)) $all_ads = $raw;
}
$placements = unserialize(ADS_PLACEMENTS);
$sizes = unserialize(ADS_SIZES);
$sizes_map = array_column($sizes, null, 'key');
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars(ADS_MANAGER_TITLE) ?> — <?= htmlspecialchars(ADS_SITE_NAME) ?></title>
<meta name="robots" content="noindex, nofollow">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<style>
/* ── Design tokens — gate.php exact match ─────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #09090f;
--surface: #0f0f1a;
--surface-2: #141425;
--border: #1e1e38;
--border-2: #2a2a48;
--text: #e8e8f8;
--text-2: #9898c0;
--text-3: #58587a;
--green: #00e87a;
--blue: #00c8ff;
--purple: #b060ff;
--red: #ff3355;
--orange: #ff8800;
--pink: #ff50a0;
--pat: #ff424d;
--pat-glow: rgba(255,66,77,.18);
--r: 6px;
}
html { font-size: 16px; -webkit-text-size-adjust: 100%; }
body {
min-height: 100dvh;
background: var(--bg);
background-image:
radial-gradient(ellipse 70% 50% at 20% 10%, rgba(176,96,255,.05) 0%, transparent 60%),
radial-gradient(ellipse 60% 40% at 80% 80%, rgba(0,200,255,.04) 0%, transparent 60%),
radial-gradient(ellipse 50% 60% at 50% 50%, rgba(255,66,77,.03) 0%, transparent 70%);
font-family: 'Inter', system-ui, sans-serif;
color: var(--text);
display: flex; flex-direction: column; align-items: center;
padding: 1.5rem 1rem 3rem;
}
body::after {
content: ''; position: fixed; inset: 0; pointer-events: none; z-index: 999;
background: repeating-linear-gradient(0deg, transparent 0px, transparent 3px, rgba(0,0,0,.07) 3px, rgba(0,0,0,.07) 4px);
}
@keyframes rise { from{opacity:0;transform:translateY(14px) scale(.98)} to{opacity:1;transform:translateY(0) scale(1)} }
@keyframes shimmer { from{background-position:0% 50%} to{background-position:200% 50%} }
@keyframes spin { to{transform:rotate(360deg)} }
@keyframes shake { 0%,100%{transform:translateX(0)} 25%{transform:translateX(-5px)} 75%{transform:translateX(5px)} }
@keyframes fade-in { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:translateY(0)} }
.page { width:100%; max-width:860px; display:flex; flex-direction:column; gap:1.25rem; animation:rise .4s cubic-bezier(.22,.68,0,1.2) both; position:relative; z-index:1; }
/* ── Card ─────────────────────────────────────────────────────────────── */
.card {
background: rgba(15,15,26,.9); border:1px solid var(--border);
border-radius: calc(var(--r)+4px); overflow:hidden; position:relative;
backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px);
box-shadow: 0 0 0 1px rgba(255,255,255,.03), 0 6px 40px rgba(0,0,0,.6);
}
.card::before {
content:''; position:absolute; top:0; left:0; right:0; height:2px; z-index:2;
background:linear-gradient(90deg,var(--pat),var(--purple),var(--blue),var(--green),var(--blue),var(--purple),var(--pat));
background-size:300% 100%; animation:shimmer 5s linear infinite;
}
.card::after {
content:''; position:absolute; bottom:-1px; right:-1px; width:14px; height:14px;
border-bottom:2px solid var(--purple); border-right:2px solid var(--purple);
border-radius:0 0 calc(var(--r)+4px) 0; opacity:.5;
}
/* ── Topbar ───────────────────────────────────────────────────────────── */
.topbar { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:.75rem; padding:1rem 1.25rem; }
.topbar-brand { display:flex; flex-direction:column; gap:.15rem; }
.topbar-eyebrow { font-family:'Share Tech Mono',monospace; font-size:.6rem; letter-spacing:.2em; text-transform:uppercase; color:var(--green); text-shadow:0 0 10px rgba(0,232,122,.5); }
.topbar-eyebrow::before { content:'> '; opacity:.5; }
.topbar-title { font-size:1.05rem; font-weight:700; letter-spacing:-.01em; }
/* ── Auth bar ─────────────────────────────────────────────────────────── */
.auth-bar { padding:.75rem 1.25rem; display:flex; align-items:center; gap:.75rem; border-bottom:1px solid var(--border); flex-wrap:wrap; }
.auth-bar label { font-family:'Share Tech Mono',monospace; font-size:.62rem; letter-spacing:.14em; text-transform:uppercase; color:var(--blue); white-space:nowrap; }
.auth-bar input { flex:1; min-width:180px; padding:.5rem .85rem; font-family:'Share Tech Mono',monospace; font-size:.82rem; color:var(--text); background:rgba(255,255,255,.03); border:1px solid var(--border-2); border-radius:var(--r); outline:none; transition:border-color .2s,box-shadow .2s; }
.auth-bar input:focus { border-color:var(--blue); box-shadow:0 0 0 3px rgba(0,200,255,.1); }
.key-badge { display:inline-flex; align-items:center; gap:.35rem; padding:.22rem .6rem; background:rgba(0,232,122,.07); border:1px solid rgba(0,232,122,.2); border-radius:99px; font-family:'Share Tech Mono',monospace; font-size:.6rem; color:var(--green); }
.key-dot { width:6px; height:6px; border-radius:50%; background:var(--green); box-shadow:0 0 5px var(--green); }
/* ── Section head ─────────────────────────────────────────────────────── */
.section-head { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:.5rem; padding:.85rem 1.25rem .7rem; border-bottom:1px solid var(--border); }
.section-title { font-family:'Share Tech Mono',monospace; font-size:.62rem; letter-spacing:.2em; text-transform:uppercase; color:var(--green); text-shadow:0 0 10px rgba(0,232,122,.5); }
.section-title::before { content:'> '; opacity:.5; }
/* ── Form fields ──────────────────────────────────────────────────────── */
.form-wrap { padding:1.1rem 1.25rem 1.25rem; display:flex; flex-direction:column; gap:.85rem; }
.field { display:flex; flex-direction:column; gap:.4rem; }
.field label { font-family:'Share Tech Mono',monospace; font-size:.62rem; letter-spacing:.14em; text-transform:uppercase; color:var(--blue); text-shadow:0 0 8px rgba(0,200,255,.35); }
.field input, .field select, .field textarea {
width:100%; padding:.68rem .9rem; font-family:'Share Tech Mono',monospace; font-size:.85rem;
color:var(--text); background:rgba(255,255,255,.03); border:1px solid var(--border-2);
border-radius:var(--r); outline:none; transition:border-color .2s,box-shadow .2s,background .2s;
}
.field select { cursor:pointer; }
.field input:focus,.field select:focus,.field textarea:focus { border-color:var(--blue); background:rgba(0,200,255,.04); box-shadow:0 0 0 3px rgba(0,200,255,.1); }
.field input::placeholder,.field textarea::placeholder { color:var(--text-3); }
.field-row { display:grid; grid-template-columns:1fr 1fr; gap:.75rem; }
@media(max-width:520px){.field-row{grid-template-columns:1fr}}
.field-hint { font-family:'Share Tech Mono',monospace; font-size:.6rem; color:var(--text-3); }
/* ── Size entries ─────────────────────────────────────────────────────── */
.sizes-label { font-family:'Share Tech Mono',monospace; font-size:.62rem; letter-spacing:.14em; text-transform:uppercase; color:var(--blue); }
.size-list { display:flex; flex-direction:column; gap:.55rem; }
.size-entry {
background:rgba(255,255,255,.025); border:1px solid var(--border-2);
border-radius:var(--r); padding:.75rem .9rem;
display:flex; flex-direction:column; gap:.55rem; position:relative;
}
.size-entry-head { display:flex; align-items:center; justify-content:space-between; gap:.5rem; }
.size-entry-label { font-family:'Share Tech Mono',monospace; font-size:.68rem; color:var(--green); letter-spacing:.06em; }
.size-entry-grid { display:grid; grid-template-columns:1fr 1fr; gap:.55rem; }
@media(max-width:520px){.size-entry-grid{grid-template-columns:1fr}}
.size-add-btn {
display:inline-flex; align-items:center; gap:.4rem; padding:.45rem .9rem;
border:1px dashed var(--border-2); border-radius:var(--r);
background:rgba(255,255,255,.02); font-family:'Share Tech Mono',monospace;
font-size:.65rem; color:var(--text-3); cursor:pointer;
transition:border-color .15s,color .15s;
}
.size-add-btn:hover { border-color:var(--green); color:var(--green); }
/* ── Buttons ──────────────────────────────────────────────────────────── */
.btn { display:inline-flex; align-items:center; justify-content:center; gap:.4rem; padding:.6rem 1.1rem; border-radius:var(--r); font-family:'Inter',sans-serif; font-size:.84rem; font-weight:600; border:none; cursor:pointer; text-decoration:none; white-space:nowrap; transition:transform .15s,filter .15s; min-height:36px; position:relative; overflow:hidden; }
.btn::before { content:''; position:absolute; inset:0; background:linear-gradient(180deg,rgba(255,255,255,.09) 0%,transparent 60%); pointer-events:none; }
.btn:hover { transform:translateY(-1px); filter:brightness(1.08); }
.btn:active { transform:scale(.97); }
.btn:disabled { opacity:.4; cursor:not-allowed; transform:none; filter:none; }
.btn-green { background:var(--green); color:#050510; box-shadow:0 4px 16px rgba(0,232,122,.25); }
.btn-blue { background:var(--blue); color:#050510; box-shadow:0 4px 14px rgba(0,200,255,.2); }
.btn-red { background:var(--red); color:#fff; box-shadow:0 4px 14px rgba(255,51,85,.25); }
.btn-orange { background:var(--orange); color:#050510; box-shadow:0 4px 14px rgba(255,136,0,.2); }
.btn-ghost { background:rgba(255,255,255,.04); color:var(--text-2); border:1px solid var(--border-2); }
.btn-ghost:hover { border-color:var(--green); color:var(--text); }
.btn-sm { padding:.3rem .7rem; font-size:.74rem; min-height:28px; }
.btn .spinner { width:12px; height:12px; border-radius:50%; border:2px solid rgba(0,0,0,.2); border-top-color:currentColor; animation:spin .6s linear infinite; }
/* ── Alerts ───────────────────────────────────────────────────────────── */
.alert { display:flex; align-items:flex-start; gap:.6rem; border-radius:var(--r); padding:.7rem 1rem; font-family:'Share Tech Mono',monospace; font-size:.78rem; line-height:1.55; }
.alert-error { background:rgba(255,51,85,.08); border:1px solid rgba(255,51,85,.3); color:var(--red); animation:shake .35s ease; }
.alert-success { background:rgba(0,232,122,.07); border:1px solid rgba(0,232,122,.25); color:var(--green); }
.alert-info { background:rgba(0,200,255,.07); border:1px solid rgba(0,200,255,.25); color:var(--blue); }
/* ── Ad list ──────────────────────────────────────────────────────────── */
#ad-list { list-style:none; }
.ad-item { border-bottom:1px solid var(--border); padding:1rem 1.25rem; display:flex; flex-direction:column; gap:.6rem; animation:fade-in .25s ease both; }
.ad-item:last-child { border-bottom:none; }
.ad-item-head { display:flex; align-items:flex-start; justify-content:space-between; gap:.75rem; flex-wrap:wrap; }
.ad-item-title { font-size:.9rem; font-weight:600; color:var(--text); }
.ad-item-meta { display:flex; align-items:center; gap:.45rem; flex-wrap:wrap; }
.ad-badge {
font-family:'Share Tech Mono',monospace; font-size:.58rem; letter-spacing:.08em; text-transform:uppercase;
padding:.1em .45em; border-radius:3px; border:1px solid; white-space:nowrap;
}
.ad-badge-placement { color:var(--blue); border-color:rgba(0,200,255,.3); background:rgba(0,200,255,.07); }
.ad-badge-active { color:var(--green); border-color:rgba(0,232,122,.3); background:rgba(0,232,122,.07); }
.ad-badge-inactive { color:var(--text-3); border-color:var(--border-2); background:rgba(255,255,255,.02); }
.ad-badge-sizes { color:var(--purple); border-color:rgba(176,96,255,.3); background:rgba(176,96,255,.07); }
.ad-item-actions { display:flex; align-items:center; gap:.35rem; }
/* size thumb previews in list */
.ad-size-previews { display:flex; flex-wrap:wrap; gap:.35rem; }
.ad-size-pill {
display:inline-flex; align-items:center; gap:.35rem;
font-family:'Share Tech Mono',monospace; font-size:.58rem; letter-spacing:.05em;
color:var(--text-3); border:1px solid var(--border-2); border-radius:3px;
padding:.1em .45em; background:rgba(255,255,255,.02);
}
.ad-size-pill .media-dot { width:6px; height:6px; border-radius:50%; flex-shrink:0; }
.ad-size-pill .media-dot.img-dot { background:var(--blue); box-shadow:0 0 4px var(--blue); }
.ad-size-pill .media-dot.vid-dot { background:var(--orange); box-shadow:0 0 4px var(--orange); }
/* ── Edit form inline ─────────────────────────────────────────────────── */
.edit-form { display:none; flex-direction:column; gap:.75rem; padding-top:.75rem; border-top:1px solid var(--border); margin-top:.25rem; }
.edit-form.open { display:flex; animation:fade-in .2s ease; }
/* ── Feed status / loading ────────────────────────────────────────────── */
.feed-status { padding:2.5rem 1.25rem; font-family:'Share Tech Mono',monospace; font-size:.72rem; color:var(--text-3); text-align:center; letter-spacing:.06em; }
.feed-status::before { content:'// '; opacity:.4; }
.loading-row { display:flex; align-items:center; justify-content:center; gap:.6rem; padding:2rem; }
.big-spinner { width:20px; height:20px; border-radius:50%; border:2px solid var(--border-2); border-top-color:var(--green); animation:spin .7s linear infinite; }
.loading-row span { font-family:'Share Tech Mono',monospace; font-size:.7rem; color:var(--text-3); }
/* ── Toggle switch ────────────────────────────────────────────────────── */
.toggle-wrap { display:inline-flex; align-items:center; gap:.5rem; cursor:pointer; }
.toggle { position:relative; width:36px; height:20px; }
.toggle input { opacity:0; width:0; height:0; }
.toggle-track { position:absolute; inset:0; background:var(--border-2); border-radius:99px; transition:background .2s; }
.toggle input:checked + .toggle-track { background:var(--green); }
.toggle-thumb { position:absolute; width:14px; height:14px; border-radius:50%; background:#fff; top:3px; left:3px; transition:transform .2s; }
.toggle input:checked ~ .toggle-thumb { transform:translateX(16px); }
.toggle-label { font-family:'Share Tech Mono',monospace; font-size:.65rem; color:var(--text-3); letter-spacing:.06em; }
/* ── Section divider ──────────────────────────────────────────────────── */
.divider { display:flex; align-items:center; gap:.6rem; font-family:'Share Tech Mono',monospace; font-size:.58rem; letter-spacing:.14em; text-transform:uppercase; color:var(--text-3); margin:.25rem 0; }
.divider::before,.divider::after { content:''; flex:1; height:1px; background:var(--border); }
</style>
</head>
<body>
<div class="page">
<!-- Topbar -->
<div class="card">
<div class="topbar">
<div class="topbar-brand">
<span class="topbar-eyebrow">Ad Manager</span>
<span class="topbar-title"><?= htmlspecialchars(ADS_SITE_NAME) ?> — <?= htmlspecialchars(ADS_MANAGER_TITLE) ?></span>
</div>
<span class="key-badge" id="key-indicator" style="display:none">
<span class="key-dot"></span>authenticated
</span>
</div>
<!-- Auth key input -->
<div class="auth-bar">
<label for="admin-key">Admin Key</label>
<input type="password" id="admin-key" placeholder="Enter admin key…" autocomplete="current-password">
<button class="btn btn-green btn-sm" onclick="setKey()">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
Unlock
</button>
</div>
<div id="auth-alert" style="padding:0 1.25rem .75rem"></div>
</div>
<!-- Create new ad -->
<div class="card">
<div class="section-head">
<span class="section-title">New Ad</span>
</div>
<div class="form-wrap" id="create-form">
<div id="create-alert"></div>
<div class="field-row">
<div class="field">
<label for="c-title">Ad Title / Label <span style="color:var(--red)">*</span></label>
<input type="text" id="c-title" placeholder="e.g. Stream sponsor banner">
</div>
<div class="field">
<label for="c-placement">Placement <span style="color:var(--red)">*</span></label>
<select id="c-placement">
<?php foreach ($placements as $val => $label): ?>
<option value="<?= htmlspecialchars($val) ?>"><?= htmlspecialchars($label) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="field">
<label for="c-click-url">Click-through URL</label>
<input type="url" id="c-click-url" placeholder="https://…">
<span class="field-hint">Where clicking the ad takes the visitor. Leave blank for no link.</span>
</div>
<div class="field">
<label for="c-note">Internal note</label>
<input type="text" id="c-note" placeholder="Optional — campaign name, dates, etc.">
</div>
<div class="divider">Size variants</div>
<span class="field-hint" style="margin-top:-.4rem">Add one entry per screen size. The live page shows the best-fitting variant.</span>
<div class="sizes-label">Creative sizes</div>
<div class="size-list" id="c-size-list"></div>
<button class="size-add-btn" onclick="addSizeEntry('c-size-list')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add size variant
</button>
<div style="display:flex;gap:.5rem;flex-wrap:wrap;padding-top:.25rem">
<button class="btn btn-green" id="create-btn" onclick="submitAd()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Save ad
</button>
<button class="btn btn-ghost btn-sm" onclick="clearCreate()">Clear</button>
</div>
</div>
</div>
<!-- Ad list -->
<div class="card">
<div class="section-head">
<span class="section-title">All Ads</span>
<span id="ad-count" style="font-family:'Share Tech Mono',monospace;font-size:.58rem;color:var(--text-3)">
<?= count($all_ads) ?> ad<?= count($all_ads) !== 1 ? 's' : '' ?>
</span>
</div>
<ul id="ad-list">
<?php if (empty($all_ads)): ?>
<li class="feed-status">No ads yet — create one above.</li>
<?php else: ?>
<?php foreach ($all_ads as $ad): ?>
<li class="ad-item" id="ai-<?= htmlspecialchars($ad['id']) ?>">
<?php
$is_active = $ad['active'] ?? true;
$pl_label = $placements[$ad['placement'] ?? 'above'] ?? ($ad['placement'] ?? '');
$size_count = count($ad['sizes'] ?? []);
?>
<div class="ad-item-head">
<div>
<div class="ad-item-title"><?= htmlspecialchars($ad['title'] ?? 'Untitled') ?></div>
<?php if (!empty($ad['note'])): ?>
<div style="font-family:'Share Tech Mono',monospace;font-size:.62rem;color:var(--text-3);margin-top:.2rem"><?= htmlspecialchars($ad['note']) ?></div>
<?php endif; ?>
</div>
<div class="ad-item-actions">
<button class="btn btn-ghost btn-sm" onclick="toggleEdit('<?= htmlspecialchars($ad['id']) ?>')">Edit</button>
<button class="btn btn-red btn-sm" onclick="deleteAd('<?= htmlspecialchars($ad['id']) ?>')">Delete</button>
</div>
</div>
<div class="ad-item-meta">
<span class="ad-badge ad-badge-placement"><?= htmlspecialchars($pl_label) ?></span>
<span class="ad-badge <?= $is_active ? 'ad-badge-active' : 'ad-badge-inactive' ?>" id="badge-active-<?= htmlspecialchars($ad['id']) ?>">
<?= $is_active ? 'Active' : 'Paused' ?>
</span>
<?php if ($size_count > 0): ?>
<span class="ad-badge ad-badge-sizes"><?= $size_count ?> size<?= $size_count !== 1 ? 's' : '' ?></span>
<?php endif; ?>
<label class="toggle-wrap" title="Toggle active">
<span class="toggle">
<input type="checkbox" <?= $is_active ? 'checked' : '' ?>
onchange="toggleActive('<?= htmlspecialchars($ad['id']) ?>', this.checked)">
<span class="toggle-track"></span>
<span class="toggle-thumb"></span>
</span>
<span class="toggle-label">Active</span>
</label>
</div>
<?php if (!empty($ad['sizes'])): ?>
<div class="ad-size-previews">
<?php foreach ($ad['sizes'] as $s): ?>
<?php
$sk = $s['size_key'] ?? '';
$sm = $sizes_map[$sk] ?? null;
$sz_label = $sm ? $sm['label'] : $sk;
$sz_type = $s['media_type'] ?? 'image';
?>
<span class="ad-size-pill">
<span class="media-dot <?= $sz_type === 'image' ? 'img-dot' : 'vid-dot' ?>"></span>
<?= htmlspecialchars($sz_label) ?>
</span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<!-- Inline edit form -->
<div class="edit-form" id="ef-<?= htmlspecialchars($ad['id']) ?>">
<div class="field-row">
<div class="field">
<label>Title</label>
<input type="text" id="ef-title-<?= htmlspecialchars($ad['id']) ?>"
value="<?= htmlspecialchars($ad['title'] ?? '') ?>">
</div>
<div class="field">
<label>Placement</label>
<select id="ef-placement-<?= htmlspecialchars($ad['id']) ?>">
<?php foreach ($placements as $val => $label): ?>
<option value="<?= htmlspecialchars($val) ?>" <?= ($ad['placement'] ?? '') === $val ? 'selected' : '' ?>>
<?= htmlspecialchars($label) ?>
</option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="field">
<label>Click-through URL</label>
<input type="url" id="ef-click-url-<?= htmlspecialchars($ad['id']) ?>"
value="<?= htmlspecialchars($ad['click_url'] ?? '') ?>">
</div>
<div class="field">
<label>Internal note</label>
<input type="text" id="ef-note-<?= htmlspecialchars($ad['id']) ?>"
value="<?= htmlspecialchars($ad['note'] ?? '') ?>">
</div>
<div class="divider">Size variants</div>
<div class="sizes-label">Creative sizes</div>
<div class="size-list" id="ef-size-list-<?= htmlspecialchars($ad['id']) ?>">
<?php foreach (($ad['sizes'] ?? []) as $si => $s): ?>
<?php $this_key = htmlspecialchars($ad['id']) . '-' . $si; ?>
<div class="size-entry" data-idx="<?= $si ?>">
<div class="size-entry-head">
<span class="size-entry-label">Variant <?= $si + 1 ?></span>
<button class="btn btn-ghost btn-sm" onclick="this.closest('.size-entry').remove()">✕ Remove</button>
</div>
<div class="size-entry-grid">
<div class="field">
<label>Size preset</label>
<select class="size-key-sel">
<?php foreach ($sizes as $sz): ?>
<option value="<?= htmlspecialchars($sz['key']) ?>"
<?= ($s['size_key'] ?? '') === $sz['key'] ? 'selected' : '' ?>>
<?= htmlspecialchars($sz['label']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="field">
<label>Media type</label>
<select class="size-mtype-sel">
<option value="image" <?= ($s['media_type'] ?? 'image') === 'image' ? 'selected' : '' ?>>Image</option>
<option value="video" <?= ($s['media_type'] ?? '') === 'video' ? 'selected' : '' ?>>Video</option>
</select>
</div>
</div>
<div class="field">
<label>Media URL (image or video)</label>
<input type="url" class="size-url-inp" placeholder="https://…"
value="<?= htmlspecialchars($s['media_url'] ?? '') ?>">
<span class="field-hint">Paste a full URL. For local files: /media/filename.jpg</span>
</div>
<div class="size-entry-grid custom-dims" style="<?= ($s['size_key'] ?? '') !== 'custom' ? 'display:none' : '' ?>">
<div class="field">
<label>Custom width (px)</label>
<input type="number" class="size-cw-inp" placeholder="728"
value="<?= htmlspecialchars((string)($s['custom_w'] ?? '')) ?>">
</div>
<div class="field">
<label>Custom height (px)</label>
<input type="number" class="size-ch-inp" placeholder="90"
value="<?= htmlspecialchars((string)($s['custom_h'] ?? '')) ?>">
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<button class="size-add-btn" onclick="addSizeEntry('ef-size-list-<?= htmlspecialchars($ad['id']) ?>')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add size variant
</button>
<div style="display:flex;gap:.5rem;flex-wrap:wrap;">
<button class="btn btn-blue btn-sm" onclick="saveEdit('<?= htmlspecialchars($ad['id']) ?>')">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
Save changes
</button>
<button class="btn btn-ghost btn-sm" onclick="toggleEdit('<?= htmlspecialchars($ad['id']) ?>')">Cancel</button>
</div>
<div id="ef-alert-<?= htmlspecialchars($ad['id']) ?>"></div>
</div>
</li>
<?php endforeach; ?>
<?php endif; ?>
</ul>
</div>
</div><!-- /page -->
<script>
/* ── Ad size presets (from PHP config) ───────────────────────────────────── */
const SIZE_PRESETS = <?= json_encode($sizes) ?>;
const SAVE_URL = './save.php';
const STORE_KEY = 'tc_admanager_key';
let adminKey = '';
/* ── Helpers ────────────────────────────────────────────────────────────── */
function esc(s) {
return String(s??'').replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function showAlert(id, type, msg) {
const el = document.getElementById(id);
if (!el) return;
el.innerHTML = `<div class="alert alert-${type}">${esc(msg)}</div>`;
setTimeout(() => el.innerHTML = '', 6000);
}
function setLoading(id, on) {
const b = document.getElementById(id); if (!b) return;
b.disabled = on;
const sp = b.querySelector('.spinner');
if (on && !sp) b.insertAdjacentHTML('afterbegin','<span class="spinner"></span>');
if (!on && sp) sp.remove();
}
async function apiFetch(qs, opts={}) {
const res = await fetch(SAVE_URL + (qs?'?'+qs:''), {
...opts, headers: {'Content-Type':'application/json','X-Ad-Key':adminKey,...(opts.headers||{})}
});
const j = await res.json();
if (!j.ok) throw new Error(j.error||'API error');
return j.data;
}
/* ── Auth ───────────────────────────────────────────────────────────────── */
function setKey() {
const k = document.getElementById('admin-key').value.trim();
if (!k) { showAlert('auth-alert','error','Enter the admin key.'); return; }
adminKey = k;
// Test with a harmless fetch (try to load list)
fetch(SAVE_URL + '?_test=1', {method:'DELETE', headers:{'X-Ad-Key':k}})
.then(r => r.json()).then(j => {
if (j.error && j.error.includes('id')) {
// Got a real API response → key accepted
sessionStorage.setItem(STORE_KEY, k);
document.getElementById('key-indicator').style.display = '';
showAlert('auth-alert','success','Authenticated successfully.');
} else if (!j.ok && j.error && j.error.includes('Invalid')) {
adminKey = '';
showAlert('auth-alert','error','Invalid key.');
} else {
sessionStorage.setItem(STORE_KEY, k);
document.getElementById('key-indicator').style.display = '';
showAlert('auth-alert','success','Authenticated.');
}
}).catch(() => showAlert('auth-alert','error','Could not reach save.php.'));
}
document.getElementById('admin-key').addEventListener('keydown', e => { if(e.key==='Enter') setKey(); });
(function() {
const saved = sessionStorage.getItem(STORE_KEY);
if (saved) {
document.getElementById('admin-key').value = saved;
adminKey = saved;
document.getElementById('key-indicator').style.display = '';
}
})();
/* ── Size entry builder ─────────────────────────────────────────────────── */
function addSizeEntry(listId) {
const list = document.getElementById(listId);
const idx = list.querySelectorAll('.size-entry').length;
const presetOpts = SIZE_PRESETS.map(s =>
`<option value="${esc(s.key)}">${esc(s.label)}</option>`).join('');
const div = document.createElement('div');
div.className = 'size-entry';
div.innerHTML = `
<div class="size-entry-head">
<span class="size-entry-label">Variant ${idx + 1}</span>
<button class="btn btn-ghost btn-sm" onclick="this.closest('.size-entry').remove()">✕ Remove</button>
</div>
<div class="size-entry-grid">
<div class="field">
<label>Size preset</label>
<select class="size-key-sel" onchange="toggleCustomDims(this)">${presetOpts}</select>
</div>
<div class="field">
<label>Media type</label>
<select class="size-mtype-sel">
<option value="image">Image</option>
<option value="video">Video</option>
</select>
</div>
</div>
<div class="field">
<label>Media URL (image or video)</label>
<input type="url" class="size-url-inp" placeholder="https://… or /media/file.jpg">
<span class="field-hint">Full URL or server-relative path. Supports JPEG, PNG, GIF, WEBP, MP4, WEBM.</span>
</div>
<div class="size-entry-grid custom-dims" style="display:none">
<div class="field">
<label>Custom width (px)</label>
<input type="number" class="size-cw-inp" placeholder="728">
</div>
<div class="field">
<label>Custom height (px)</label>
<input type="number" class="size-ch-inp" placeholder="90">
</div>
</div>`;
list.appendChild(div);
}
function toggleCustomDims(sel) {
const entry = sel.closest('.size-entry');
const dims = entry.querySelector('.custom-dims');
if (dims) dims.style.display = sel.value === 'custom' ? '' : 'none';
}
function collectSizes(listId) {
const entries = document.querySelectorAll('#' + listId + ' .size-entry');
const sizes = [];
entries.forEach(e => {
const key = e.querySelector('.size-key-sel')?.value || '';
const url = e.querySelector('.size-url-inp')?.value.trim() || '';
const type = e.querySelector('.size-mtype-sel')?.value || 'image';
const cw = e.querySelector('.size-cw-inp')?.value || null;
const ch = e.querySelector('.size-ch-inp')?.value || null;
if (key && url) sizes.push({ size_key: key, media_url: url, media_type: type,
custom_w: cw ? parseInt(cw) : null, custom_h: ch ? parseInt(ch) : null });
});
return sizes;
}
/* ── Create ─────────────────────────────────────────────────────────────── */
function clearCreate() {
document.getElementById('c-title').value = '';
document.getElementById('c-click-url').value = '';
document.getElementById('c-note').value = '';
document.getElementById('c-size-list').innerHTML = '';
}
function submitAd() {
if (!adminKey) { showAlert('create-alert','error','Enter admin key first.'); return; }
const body = {
title: document.getElementById('c-title').value.trim(),
placement: document.getElementById('c-placement').value,
click_url: document.getElementById('c-click-url').value.trim(),
note: document.getElementById('c-note').value.trim(),
active: true,
sizes: collectSizes('c-size-list'),
};
if (!body.title) { showAlert('create-alert','error','Title is required.'); return; }
setLoading('create-btn', true);
apiFetch('', { method:'POST', body: JSON.stringify(body) })
.then(() => { clearCreate(); setLoading('create-btn',false); showAlert('create-alert','success','Ad saved — reload to see it in the list.'); })
.catch(e => { setLoading('create-btn',false); showAlert('create-alert','error',e.message); });
}
/* ── Edit / save ────────────────────────────────────────────────────────── */
function toggleEdit(id) {
const ef = document.getElementById('ef-' + id);
ef.classList.toggle('open');
// Wire custom-dims toggles on existing selects when form opens
ef.querySelectorAll('.size-key-sel').forEach(sel => {
sel.onchange = () => toggleCustomDims(sel);
});
}
function saveEdit(id) {
if (!adminKey) { showAlert('ef-alert-'+id,'error','Enter admin key first.'); return; }
const body = {
title: document.getElementById('ef-title-'+id).value.trim(),
placement: document.getElementById('ef-placement-'+id).value,
click_url: document.getElementById('ef-click-url-'+id).value.trim(),
note: document.getElementById('ef-note-'+id).value.trim(),
sizes: collectSizes('ef-size-list-'+id),
};
apiFetch('id='+encodeURIComponent(id), { method:'PUT', body: JSON.stringify(body) })
.then(() => { showAlert('ef-alert-'+id,'success','Saved — reload to refresh the list.'); })
.catch(e => showAlert('ef-alert-'+id,'error',e.message));
}
/* ── Toggle active ──────────────────────────────────────────────────────── */
function toggleActive(id, active) {
if (!adminKey) return;
const badge = document.getElementById('badge-active-'+id);
apiFetch('action=toggle', { method:'POST', body: JSON.stringify({id, active}) })
.then(() => {
if (badge) { badge.textContent = active ? 'Active' : 'Paused'; badge.className = 'ad-badge ' + (active ? 'ad-badge-active' : 'ad-badge-inactive'); }
})
.catch(e => alert('Toggle failed: ' + e.message));
}
/* ── Delete ─────────────────────────────────────────────────────────────── */
function deleteAd(id) {
if (!adminKey) { alert('Enter admin key first.'); return; }
if (!confirm('Delete this ad? This cannot be undone.')) return;
apiFetch('id='+encodeURIComponent(id), { method:'DELETE' })
.then(() => {
const li = document.getElementById('ai-'+id);
li.style.transition='opacity .2s,transform .2s'; li.style.opacity='0'; li.style.transform='translateX(8px)';
setTimeout(() => { li.remove(); const cnt=document.getElementById('ad-count'); if(cnt){const n=document.querySelectorAll('.ad-item').length;cnt.textContent=n+' ad'+(n!==1?'s':'')} }, 220);
})
.catch(e => alert('Delete failed: ' + e.message));
}
</script>
</body>
</html>
+184
View File
@@ -0,0 +1,184 @@
<?php
/**
* admanager/save.php — Authenticated ad CRUD API
* ─────────────────────────────────────────────────────────────────────────────
* All requests require the X-Ad-Key header matching ADS_ADMIN_KEY in config.php.
*
* POST save.php body: ad object → create
* PUT save.php?id=<id> body: partial ad fields → update
* DELETE save.php?id=<id> → delete
* POST save.php?action=reorder body: {ids:[…]} → reorder
* POST save.php?action=toggle body: {id,active} → toggle active
*/
require_once __DIR__ . '/config.php';
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
function ad_ok(mixed $data, int $code = 200): never {
http_response_code($code);
echo json_encode(['ok' => true, 'data' => $data], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
exit;
}
function ad_err(string $msg, int $code = 400): never {
http_response_code($code);
echo json_encode(['ok' => false, 'error' => $msg]);
exit;
}
// ── Auth ──────────────────────────────────────────────────────────────────────
$supplied = trim($_SERVER['HTTP_X_AD_KEY'] ?? $_SERVER['HTTP_X_Ad_Key'] ?? ($_GET['ad_key'] ?? ''));
if ($supplied === '') ad_err('Missing X-Ad-Key header.', 401);
if (!hash_equals(ADS_ADMIN_KEY, $supplied)) ad_err('Invalid key.', 403);
// ── Data helpers ──────────────────────────────────────────────────────────────
function load_ads(): array {
if (!file_exists(ADS_JSON_PATH)) return [];
$raw = json_decode(file_get_contents(ADS_JSON_PATH), true);
return is_array($raw) ? $raw : [];
}
function save_ads(array $ads): void {
$json = json_encode(array_values($ads), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
$tmp = ADS_JSON_PATH . '.tmp.' . getmypid();
if (file_put_contents($tmp, $json, LOCK_EX) === false) ad_err('Write failed.', 500);
if (!rename($tmp, ADS_JSON_PATH)) { @unlink($tmp); ad_err('Rename failed.', 500); }
}
function find_ad(array $ads, string $id): int|false {
foreach ($ads as $i => $a) { if (($a['id'] ?? '') === $id) return $i; }
return false;
}
function new_id(array $ads): string {
$existing = array_column($ads, 'id');
do { $id = 'ad_' . strtolower(bin2hex(random_bytes(4))); } while (in_array($id, $existing));
return $id;
}
function read_body(): array {
$raw = file_get_contents('php://input');
if (!$raw) return [];
$d = json_decode($raw, true);
if (!is_array($d)) ad_err('Body must be valid JSON.');
return $d;
}
function sanitise_ad(array $in, bool $require_fields = true): array {
$errors = [];
if ($require_fields) {
if (trim($in['title'] ?? '') === '') $errors[] = '"title" is required.';
if (trim($in['placement'] ?? '') === '') $errors[] = '"placement" is required.';
$valid_placements = array_keys(unserialize(ADS_PLACEMENTS));
if (!in_array($in['placement'] ?? '', $valid_placements)) $errors[] = 'Invalid placement.';
}
if ($errors) ad_err(implode(' ', $errors));
$out = [];
if (isset($in['title'])) $out['title'] = trim((string)$in['title']);
if (isset($in['placement'])) $out['placement'] = trim((string)$in['placement']);
if (isset($in['click_url'])) $out['click_url'] = trim((string)$in['click_url']);
if (isset($in['active'])) $out['active'] = (bool)$in['active'];
if (isset($in['note'])) $out['note'] = trim((string)$in['note']);
// sizes: array of {size_key, media_url, media_type, custom_w, custom_h}
if (isset($in['sizes']) && is_array($in['sizes'])) {
$clean_sizes = [];
foreach ($in['sizes'] as $s) {
if (empty($s['size_key']) || empty($s['media_url'])) continue;
$clean_sizes[] = [
'size_key' => trim((string)$s['size_key']),
'media_url' => trim((string)$s['media_url']),
'media_type' => in_array($s['media_type'] ?? '', ['image','video']) ? $s['media_type'] : 'image',
'custom_w' => isset($s['custom_w']) ? (int)$s['custom_w'] : null,
'custom_h' => isset($s['custom_h']) ? (int)$s['custom_h'] : null,
];
}
$out['sizes'] = $clean_sizes;
}
return $out;
}
// ── Routing ───────────────────────────────────────────────────────────────────
$method = $_SERVER['REQUEST_METHOD'];
$id = trim($_GET['id'] ?? '');
$action = trim($_GET['action'] ?? '');
// Toggle active
if ($method === 'POST' && $action === 'toggle') {
$body = read_body();
$tid = trim((string)($body['id'] ?? ''));
$ads = load_ads();
$idx = find_ad($ads, $tid);
if ($idx === false) ad_err("Ad \"$tid\" not found.", 404);
$ads[$idx]['active'] = (bool)($body['active'] ?? !$ads[$idx]['active']);
save_ads($ads);
ad_ok($ads[$idx]);
}
// Reorder
if ($method === 'POST' && $action === 'reorder') {
$body = read_body();
if (!isset($body['ids']) || !is_array($body['ids'])) ad_err('"ids" required.');
$ads = load_ads();
$indexed = [];
foreach ($ads as $a) $indexed[$a['id']] = $a;
$reordered = [];
foreach ($body['ids'] as $rid) {
if (!isset($indexed[$rid])) ad_err("Unknown id \"$rid\".");
$reordered[] = $indexed[$rid];
unset($indexed[$rid]);
}
foreach ($indexed as $a) $reordered[] = $a;
save_ads($reordered);
ad_ok(['reordered' => count($reordered)]);
}
// Create
if ($method === 'POST') {
$body = read_body();
$clean = sanitise_ad($body, true);
$ads = load_ads();
$ad = array_merge([
'id' => new_id($ads),
'title' => '',
'placement' => 'above',
'click_url' => '',
'active' => true,
'note' => '',
'sizes' => [],
'created' => (new DateTime('now', new DateTimeZone('UTC')))->format(DateTime::ATOM),
], $clean);
array_unshift($ads, $ad);
save_ads($ads);
ad_ok($ad, 201);
}
// Update
if ($method === 'PUT') {
if ($id === '') ad_err('?id= required for PUT.');
$ads = load_ads();
$idx = find_ad($ads, $id);
if ($idx === false) ad_err("Ad \"$id\" not found.", 404);
$body = read_body();
$clean = sanitise_ad($body, false);
$ads[$idx] = array_merge($ads[$idx], $clean);
$ads[$idx]['id'] = $id;
save_ads($ads);
ad_ok($ads[$idx]);
}
// Delete
if ($method === 'DELETE') {
if ($id === '') ad_err('?id= required for DELETE.');
$ads = load_ads();
$idx = find_ad($ads, $id);
if ($idx === false) ad_err("Ad \"$id\" not found.", 404);
$deleted = $ads[$idx];
array_splice($ads, $idx, 1);
save_ads($ads);
ad_ok($deleted);
}
ad_err('Method not allowed.', 405);