Files
comcurrent/post.php
T
Ty Clifford 5f7c62f8a7 v3.1.1
2026-06-24 09:19:08 -04:00

1235 lines
54 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* post.php — Browser admin UI for updates.json with media upload support
* ─────────────────────────────────────────────────────────────────────────────
* Requires: api.php · media_upload.php · index_config/api_keys.txt
* Media saved to: /media/ (configured in media_upload.php)
*/
// ══════════════════════════════════════════════════════════════════════════════
// CONFIG
// ══════════════════════════════════════════════════════════════════════════════
define('POST_API_URL', './api.php');
define('POST_UPLOAD_URL', './media_upload.php');
define('POST_PAGE_TITLE', 'Updates — Post Manager');
// ══════════════════════════════════════════════════════════════════════════════
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars(POST_PAGE_TITLE) ?></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 — exact gate.php 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 wrapper ───────────────────────────────────────────────────────── */
.page {
width: 100%; max-width: 760px;
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,.88);
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;
}
/* ── Top bar ────────────────────────────────────────────────────────────── */
.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; }
.topbar-actions { display: flex; align-items: center; gap: .5rem; }
/* ── Auth ───────────────────────────────────────────────────────────────── */
#auth-screen {
padding: 2rem 1.75rem 2.25rem;
display: flex; flex-direction: column; gap: 1.25rem;
}
.auth-heading {
font-size: 1.25rem; font-weight: 700; letter-spacing: -.01em;
background: linear-gradient(135deg, var(--blue), var(--purple));
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.auth-sub { font-family: 'Share Tech Mono', monospace; font-size: .72rem; color: var(--text-3); }
/* ── Form elements ──────────────────────────────────────────────────────── */
.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[type="text"],
.field input[type="password"],
.field input[type="url"],
.field textarea {
width: 100%; padding: .72rem .95rem;
font-family: 'Share Tech Mono', monospace; font-size: .88rem;
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;
resize: vertical;
}
.field input: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 textarea { min-height: 90px; line-height: 1.6; }
.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); }
/* ── File drop zone ─────────────────────────────────────────────────────── */
.drop-zone {
position: relative;
border: 2px dashed var(--border-2);
border-radius: var(--r);
padding: 1.25rem 1rem;
text-align: center;
cursor: pointer;
transition: border-color .2s, background .2s;
background: rgba(255,255,255,.02);
}
.drop-zone.drag-over {
border-color: var(--green);
background: rgba(0,232,122,.05);
}
.drop-zone input[type="file"] {
position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%;
}
.drop-zone-label {
pointer-events: none;
display: flex; flex-direction: column; align-items: center; gap: .5rem;
}
.drop-zone-icon { color: var(--text-3); }
.drop-zone-text {
font-family: 'Share Tech Mono', monospace;
font-size: .68rem; letter-spacing: .08em; color: var(--text-3);
}
.drop-zone-hint { font-size: .62rem; color: var(--text-3); opacity: .6; }
/* ── Upload progress bar ────────────────────────────────────────────────── */
.upload-progress {
display: none;
height: 3px;
background: var(--border-2);
border-radius: 99px;
overflow: hidden;
margin-top: .4rem;
}
.upload-progress.visible { display: block; }
.upload-progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--green), var(--blue));
border-radius: 99px;
transition: width .2s ease;
width: 0%;
}
/* ── Media preview grid (in compose / edit forms) ───────────────────────── */
.media-previews {
display: flex;
flex-wrap: wrap;
gap: .5rem;
margin-top: .25rem;
}
.media-preview-item {
position: relative;
border: 1px solid var(--border-2);
border-radius: var(--r);
overflow: hidden;
width: 90px; height: 70px;
background: var(--surface-2);
flex-shrink: 0;
}
.media-preview-item img,
.media-preview-item video {
width: 100%; height: 100%;
object-fit: cover;
display: block;
}
.media-preview-item .media-remove {
position: absolute; top: 3px; right: 3px;
width: 18px; height: 18px;
background: rgba(255,51,85,.85);
border: none; border-radius: 50%;
color: #fff; cursor: pointer;
display: flex; align-items: center; justify-content: center;
font-size: 10px; font-weight: 700;
line-height: 1;
transition: background .15s;
}
.media-preview-item .media-remove:hover { background: var(--red); }
.media-preview-type {
position: absolute; bottom: 3px; left: 4px;
font-family: 'Share Tech Mono', monospace;
font-size: .48rem; letter-spacing: .06em; text-transform: uppercase;
color: rgba(255,255,255,.8);
background: rgba(0,0,0,.55);
padding: .05em .3em; border-radius: 2px;
}
/* ── 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, box-shadow .2s;
min-height: 38px; 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); filter: brightness(.95); }
.btn:disabled { opacity: .45; 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-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: .35rem .75rem; font-size: .76rem; min-height: 30px; }
.btn .spinner {
width: 13px; height: 13px; 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); }
/* ── 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; }
.section-badge { font-family: 'Share Tech Mono', monospace; font-size: .58rem; color: var(--text-3); }
/* ── Compose ────────────────────────────────────────────────────────────── */
#compose-form {
padding: 1rem 1.25rem 1.25rem;
display: flex; flex-direction: column; gap: .85rem;
}
.compose-actions {
display: flex; align-items: center; justify-content: space-between; gap: .5rem; flex-wrap: wrap;
}
/* ── Post list ──────────────────────────────────────────────────────────── */
#post-list { list-style: none; }
.post-item {
border-bottom: 1px solid var(--border);
padding: 1rem 1.25rem;
display: flex; flex-direction: column; gap: .6rem;
animation: fade-in .25s ease both;
}
.post-item:last-child { border-bottom: none; }
.post-meta { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
.post-id {
font-family: 'Share Tech Mono', monospace; font-size: .6rem; color: var(--text-3);
background: rgba(255,255,255,.04); border: 1px solid var(--border-2);
border-radius: 3px; padding: .05em .4em;
}
.post-date { font-family: 'Share Tech Mono', monospace; font-size: .6rem; color: var(--text-3); }
.post-tag {
font-family: 'Share Tech Mono', monospace; font-size: .58rem; letter-spacing: .07em;
text-transform: uppercase; padding: .05em .4em;
border: 1px solid var(--border-2); border-radius: 3px; color: var(--blue);
}
.post-text { font-size: .9rem; color: var(--text); line-height: 1.65; }
.post-link {
font-family: 'Share Tech Mono', monospace; font-size: .65rem;
color: var(--blue); text-decoration: none; opacity: .8;
}
.post-link:hover { opacity: 1; }
/* ── Media strip in feed (admin view — compact) ─────────────────────────── */
.post-media-strip {
display: flex; flex-wrap: wrap; gap: .4rem;
}
.post-media-thumb {
width: 72px; height: 56px;
border: 1px solid var(--border-2); border-radius: 4px;
overflow: hidden; background: var(--surface-2);
position: relative; flex-shrink: 0;
}
.post-media-thumb img {
width: 100%; height: 100%; object-fit: cover; display: block;
}
.post-media-thumb .vid-icon {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,.55);
color: var(--green);
}
.post-media-thumb .media-thumb-label {
position: absolute; bottom: 2px; left: 3px;
font-family: 'Share Tech Mono', monospace; font-size: .45rem;
color: rgba(255,255,255,.7); background: rgba(0,0,0,.5);
padding: .05em .25em; border-radius: 2px; text-transform: uppercase;
}
.post-actions { display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; }
/* ── Inline edit form ───────────────────────────────────────────────────── */
.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; }
/* ── Misc ───────────────────────────────────────────────────────────────── */
/* ── Search bar ─────────────────────────────────────────────────────────── */
.search-wrap {
padding: .65rem 1.25rem;
border-bottom: 1px solid var(--border);
position: relative;
}
.search-input-wrap {
position: relative;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: .75rem;
color: var(--text-3);
pointer-events: none;
flex-shrink: 0;
}
.search-input {
width: 100%;
padding: .55rem .9rem .55rem 2.2rem;
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, background .2s;
}
.search-input:focus {
border-color: var(--blue);
background: rgba(0,200,255,.04);
box-shadow: 0 0 0 3px rgba(0,200,255,.1);
}
.search-input::placeholder { color: var(--text-3); }
.search-clear {
position: absolute;
right: .6rem;
background: none;
border: none;
cursor: pointer;
color: var(--text-3);
padding: .2rem;
display: none;
align-items: center;
transition: color .15s;
}
.search-clear:hover { color: var(--red); }
.search-clear.visible { display: flex; }
/* highlight matched text */
mark {
background: rgba(0,200,255,.2);
color: var(--blue);
border-radius: 2px;
padding: 0 .1em;
}
/* ── Pagination ──────────────────────────────────────────────────────────── */
.pagination-bar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: .5rem;
padding: .65rem 1.25rem;
border-top: 1px solid var(--border);
}
.pagination-info {
font-family: 'Share Tech Mono', monospace;
font-size: .6rem;
letter-spacing: .06em;
color: var(--text-3);
}
.pagination-btns {
display: flex;
align-items: center;
gap: .3rem;
flex-wrap: wrap;
}
.page-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 28px;
padding: 0 .45rem;
border: 1px solid var(--border-2);
border-radius: 4px;
background: rgba(255,255,255,.03);
font-family: 'Share Tech Mono', monospace;
font-size: .65rem;
letter-spacing: .04em;
color: var(--text-3);
cursor: pointer;
transition: border-color .15s, color .15s, background .15s;
}
.page-btn:hover:not(:disabled) {
border-color: var(--green);
color: var(--green);
background: rgba(0,232,122,.05);
}
.page-btn.active {
border-color: var(--green);
color: var(--green);
background: rgba(0,232,122,.08);
}
.page-btn:disabled { opacity: .3; cursor: not-allowed; }
.page-ellipsis { border-color: transparent; background: transparent; cursor: default; pointer-events: none; }
.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); }
.key-badge {
display: inline-flex; align-items: center; gap: .35rem; padding: .25rem .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); }
</style>
</head>
<body>
<div class="page">
<!-- Auth card -->
<div class="card" id="auth-card">
<div id="auth-screen">
<div>
<p class="auth-heading">Post Manager</p>
<p class="auth-sub">Enter your API key to continue</p>
</div>
<div id="auth-alert"></div>
<div class="field">
<label for="key-input">API Key</label>
<input type="password" id="key-input" placeholder="your-api-key" autocomplete="current-password" autofocus>
</div>
<div>
<button class="btn btn-green" id="auth-btn" onclick="doAuth()">
<svg width="13" height="13" 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>
</div>
<!-- Main UI -->
<div id="main-ui" style="display:none;flex-direction:column;gap:1.25rem;">
<!-- Topbar -->
<div class="card">
<div class="topbar">
<div class="topbar-brand">
<span class="topbar-eyebrow">Updates</span>
<span class="topbar-title"><?= htmlspecialchars(POST_PAGE_TITLE) ?></span>
</div>
<div class="topbar-actions">
<span class="key-badge" id="key-indicator"><span class="key-dot"></span>authenticated</span>
<button class="btn btn-ghost btn-sm" onclick="doLogout()">
<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="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
Sign out
</button>
</div>
</div>
</div>
<!-- Compose -->
<div class="card">
<div class="section-head"><span class="section-title">New Post</span></div>
<div id="compose-form">
<div id="compose-alert"></div>
<div class="field">
<label for="c-text">Text <span style="color:var(--red)">*</span></label>
<textarea id="c-text" placeholder="What's happening?" rows="3"></textarea>
</div>
<div class="field-row">
<div class="field">
<label for="c-tags">Tags</label>
<input type="text" id="c-tags" placeholder="stream, update, blog">
<span class="field-hint">comma-separated</span>
</div>
<div class="field">
<label for="c-date">Date</label>
<input type="text" id="c-date" placeholder="leave blank for now">
<span class="field-hint">ISO-8601 or blank = now</span>
</div>
</div>
<div class="field-row">
<div class="field">
<label for="c-link">Link URL</label>
<input type="url" id="c-link" placeholder="https://…">
</div>
<div class="field">
<label for="c-link-label">Link label</label>
<input type="text" id="c-link-label" placeholder="Read more">
</div>
</div>
<!-- Media upload -->
<div class="field">
<label>Media (images / video)</label>
<div class="drop-zone" id="c-drop-zone">
<input type="file" id="c-media-input" multiple
accept="image/jpeg,image/png,image/gif,image/webp,image/avif,image/svg+xml,video/mp4,video/quicktime,video/webm,video/ogg,video/x-msvideo,video/x-matroska">
<div class="drop-zone-label">
<svg class="drop-zone-icon" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
<span class="drop-zone-text">Drop files here or click to browse</span>
<span class="drop-zone-hint">JPEG · PNG · GIF · WEBP · MP4 · MOV · WEBM — up to 200 MB each</span>
</div>
</div>
<div class="upload-progress" id="c-upload-progress">
<div class="upload-progress-bar" id="c-progress-bar"></div>
</div>
<div class="media-previews" id="c-media-previews"></div>
</div>
<div class="compose-actions">
<button class="btn btn-green" id="compose-btn" onclick="submitPost()">
<svg width="13" height="13" 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>
Post update
</button>
<button class="btn btn-ghost btn-sm" onclick="clearCompose()">Clear</button>
</div>
</div>
</div>
<!-- Feed -->
<div class="card">
<div class="section-head">
<span class="section-title">All Posts</span>
<span class="section-badge" id="post-count">—</span>
</div>
<!-- Search bar -->
<div class="search-wrap">
<div class="search-input-wrap">
<svg class="search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input class="search-input" type="text" id="search-input"
placeholder="Search posts by text, tag, or ID…"
autocomplete="off" spellcheck="false"
oninput="onSearchInput(this.value)">
<button class="search-clear" id="search-clear"
onclick="clearSearch()" aria-label="Clear search">
<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="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
</div>
<ul id="post-list">
<li class="loading-row"><div class="big-spinner"></div><span>loading posts…</span></li>
</ul>
<!-- Pagination bar (built by JS) -->
<div id="pagination-bar" style="display:none"></div>
</div>
</div><!-- /main-ui -->
</div><!-- /page -->
<script>
/* ════════════════════════════════════════════════════════════════════════════
Post Manager — vanilla JS with media upload support
════════════════════════════════════════════════════════════════════════════ */
const API = '<?= htmlspecialchars(POST_API_URL) ?>';
const UPLOAD_URL = '<?= htmlspecialchars(POST_UPLOAD_URL) ?>';
const STORE_KEY = 'tc_post_mgr_key';
let apiKey = '';
/* ── SVG icons ──────────────────────────────────────────────────────────────── */
const ICO = {
edit: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`,
trash: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>`,
save: `<svg width="12" height="12" 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>`,
cancel: `<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="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`,
warn: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>`,
ok: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`,
play: `<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>`,
};
/* ── Helpers ────────────────────────────────────────────────────────────────── */
function esc(str) {
return String(str ?? '')
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
function showAlert(id, type, msg) {
const el = document.getElementById(id);
if (!el) return;
el.innerHTML = `<div class="alert alert-${type}">${ICO[type==='error'?'warn':'ok']} ${esc(msg)}</div>`;
setTimeout(() => { el.innerHTML = ''; }, 6000);
}
function setLoading(btnId, on) {
const btn = document.getElementById(btnId);
if (!btn) return;
btn.disabled = on;
const sp = btn.querySelector('.spinner');
if (on && !sp) btn.insertAdjacentHTML('afterbegin', '<span class="spinner"></span>');
if (!on && sp) sp.remove();
}
async function apiFetch(path, opts = {}) {
const res = await fetch(API + (path ? '?' + path : ''), {
...opts,
headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey, ...(opts.headers||{}) },
});
const json = await res.json();
if (!json.ok) throw new Error(json.error || 'API error');
return json.data;
}
function parseTags(str) { return str.split(',').map(t=>t.trim()).filter(Boolean); }
function relTime(iso) {
if (!iso) return '';
const d = Math.floor((Date.now() - new Date(iso)) / 1000);
if (d < 60) return 'just now';
if (d < 3600) return Math.floor(d/60) + 'm ago';
if (d < 86400) return Math.floor(d/3600) + 'h ago';
if (d < 604800) return Math.floor(d/86400) + 'd ago';
return new Date(iso).toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'});
}
/* ════════════════════════════════════════════════════════════════════════════
MEDIA UPLOAD SYSTEM
Each compose / edit form has its own pending media array.
Uploads happen immediately on file selection; results stored in pendingMedia[formId].
════════════════════════════════════════════════════════════════════════════ */
const pendingMedia = {}; // formId → [{url, type, mime, filename}, …]
function initDropZone(formId, dropZoneId, inputId, progressId, barId, previewsId) {
pendingMedia[formId] = pendingMedia[formId] || [];
const zone = document.getElementById(dropZoneId);
const input = document.getElementById(inputId);
const progress = document.getElementById(progressId);
const bar = document.getElementById(barId);
const previews = document.getElementById(previewsId);
if (!zone) return;
// Drag-over highlight
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag-over'); });
zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'));
zone.addEventListener('drop', e => {
e.preventDefault();
zone.classList.remove('drag-over');
uploadFiles(formId, e.dataTransfer.files, progress, bar, previews);
});
input.addEventListener('change', () => {
uploadFiles(formId, input.files, progress, bar, previews);
input.value = ''; // allow re-selecting the same file
});
}
async function uploadFiles(formId, fileList, progress, bar, previews) {
for (const file of fileList) {
progress.classList.add('visible');
bar.style.width = '0%';
try {
const fd = new FormData();
fd.append('file', file);
// XHR for progress events
const result = await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', UPLOAD_URL);
xhr.setRequestHeader('X-API-Key', apiKey);
xhr.upload.addEventListener('progress', e => {
if (e.lengthComputable) bar.style.width = Math.round(e.loaded / e.total * 100) + '%';
});
xhr.addEventListener('load', () => {
try { resolve(JSON.parse(xhr.responseText)); }
catch(e) { reject(new Error('Bad response')); }
});
xhr.addEventListener('error', () => reject(new Error('Network error')));
xhr.send(fd);
});
if (!result.ok) throw new Error(result.error || 'Upload failed');
bar.style.width = '100%';
pendingMedia[formId].push({ url: result.url, type: result.type, mime: result.mime, filename: result.filename });
addPreviewThumb(formId, result, previews);
} catch (err) {
alert('Upload failed: ' + err.message);
} finally {
setTimeout(() => { progress.classList.remove('visible'); bar.style.width = '0%'; }, 600);
}
}
}
function addPreviewThumb(formId, item, previewsEl) {
const idx = pendingMedia[formId].length - 1;
const wrap = document.createElement('div');
wrap.className = 'media-preview-item';
wrap.dataset.idx = idx;
if (item.type === 'image') {
wrap.innerHTML = `<img src="${esc(item.url)}" alt="">
<span class="media-preview-type">img</span>
<button class="media-remove" title="Remove" onclick="removeMedia('${formId}',${idx})">✕</button>`;
} else {
wrap.innerHTML = `<div style="width:100%;height:100%;background:var(--surface-2);display:flex;align-items:center;justify-content:center;">
<svg width="22" height="22" viewBox="0 0 24 24" fill="var(--green)"><polygon points="5 3 19 12 5 21 5 3"/></svg>
</div>
<span class="media-preview-type">vid</span>
<button class="media-remove" title="Remove" onclick="removeMedia('${formId}',${idx})">✕</button>`;
}
previewsEl.appendChild(wrap);
}
function removeMedia(formId, idx) {
pendingMedia[formId][idx] = null; // null = removed (preserve index alignment)
const previews = document.querySelectorAll(`[data-idx="${idx}"]`);
previews.forEach(el => el.remove());
}
function getMedia(formId) {
return (pendingMedia[formId] || []).filter(Boolean);
}
function clearMedia(formId, previewsId) {
pendingMedia[formId] = [];
const el = document.getElementById(previewsId);
if (el) el.innerHTML = '';
}
/* ── Render media strip in admin feed ───────────────────────────────────────── */
function mediaStripHTML(mediaArr) {
if (!mediaArr || !mediaArr.length) return '';
const items = mediaArr.map(m => {
if (m.type === 'image') {
return `<div class="post-media-thumb"><img src="${esc(m.url)}" alt="" loading="lazy"><span class="media-thumb-label">img</span></div>`;
}
return `<div class="post-media-thumb">
<div class="vid-icon">${ICO.play}</div>
<span class="media-thumb-label">vid</span>
</div>`;
}).join('');
return `<div class="post-media-strip">${items}</div>`;
}
/* ── Auth ────────────────────────────────────────────────────────────────────── */
function doAuth() {
const key = document.getElementById('key-input').value.trim();
if (!key) { showAlert('auth-alert','error','Enter your API key.'); return; }
apiKey = key;
setLoading('auth-btn', true);
apiFetch('')
.then(() => {
sessionStorage.setItem(STORE_KEY, key);
document.getElementById('auth-card').style.display = 'none';
document.getElementById('main-ui').style.display = 'flex';
initDropZone('compose', 'c-drop-zone', 'c-media-input', 'c-upload-progress', 'c-progress-bar', 'c-media-previews');
loadFeed();
})
.catch(err => {
apiKey = '';
setLoading('auth-btn', false);
showAlert('auth-alert','error','Invalid key: ' + err.message);
});
}
function doLogout() { sessionStorage.removeItem(STORE_KEY); location.reload(); }
/* ════════════════════════════════════════════════════════════════════════════
FEED — search, pagination, rendering
════════════════════════════════════════════════════════════════════════════ */
const PER_PAGE = 15; // posts shown per page
let allPosts = []; // master array from last API fetch
let currentPage = 1;
let searchQuery = '';
let searchTimer = null;
/* Load all posts from API, then filter+render */
function loadFeed() {
const list = document.getElementById('post-list');
list.innerHTML = `<li class="loading-row"><div class="big-spinner"></div><span>loading…</span></li>`;
apiFetch('limit=1000') // fetch everything; client handles paging
.then(data => {
allPosts = data.items || [];
currentPage = 1;
filterAndPage();
})
.catch(err => {
list.innerHTML = `<li class="feed-status">failed: ${esc(err.message)}</li>`;
hidePagination();
});
}
/* Filter allPosts by searchQuery, then render the requested page */
function filterAndPage() {
const q = searchQuery.trim().toLowerCase();
const filtered = q
? allPosts.filter(p =>
(p.text || '').toLowerCase().includes(q) ||
(p.id || '').toLowerCase().includes(q) ||
(p.tags || []).some(t => t.toLowerCase().includes(q)) ||
(p.link || '').toLowerCase().includes(q) ||
(p.note || '').toLowerCase().includes(q)
)
: allPosts;
const total = filtered.length;
const totalPages = Math.max(1, Math.ceil(total / PER_PAGE));
currentPage = Math.min(currentPage, totalPages);
const start = (currentPage - 1) * PER_PAGE;
const page = filtered.slice(start, start + PER_PAGE);
// Update badge
const badge = document.getElementById('post-count');
if (q) {
badge.textContent = filtered.length + ' of ' + allPosts.length + ' match' + (filtered.length !== 1 ? 'es' : '');
} else {
badge.textContent = allPosts.length + ' post' + (allPosts.length !== 1 ? 's' : '');
}
// Render list
const list = document.getElementById('post-list');
if (!page.length) {
list.innerHTML = q
? `<li class="feed-status">no results for "${esc(q)}"</li>`
: '<li class="feed-status">no posts yet</li>';
hidePagination();
return;
}
list.innerHTML = page.map(p => postHTML(p, q)).join('');
// Render pagination
if (totalPages <= 1) {
hidePagination();
} else {
renderPagination(currentPage, totalPages, total, start);
}
}
/* ── Pagination controls ─────────────────────────────────────────────────── */
function renderPagination(page, totalPages, total, start) {
const bar = document.getElementById('pagination-bar');
bar.style.display = '';
bar.className = 'pagination-bar';
const from = start + 1;
const to = Math.min(start + PER_PAGE, total);
// Build page number buttons with sliding window ± 2
let btns = '';
const lo = Math.max(1, page - 2);
const hi = Math.min(totalPages, page + 2);
btns += `<button class="page-btn" ${page <= 1 ? 'disabled' : ''}
onclick="goPage(${page - 1})"></button>`;
if (lo > 1) {
btns += `<button class="page-btn" onclick="goPage(1)">1</button>`;
if (lo > 2) btns += `<span class="page-btn page-ellipsis">…</span>`;
}
for (let p = lo; p <= hi; p++) {
btns += `<button class="page-btn${p === page ? ' active' : ''}"
onclick="goPage(${p})">${p}</button>`;
}
if (hi < totalPages) {
if (hi < totalPages - 1) btns += `<span class="page-btn page-ellipsis">…</span>`;
btns += `<button class="page-btn" onclick="goPage(${totalPages})">${totalPages}</button>`;
}
btns += `<button class="page-btn" ${page >= totalPages ? 'disabled' : ''}
onclick="goPage(${page + 1})"></button>`;
bar.innerHTML = `
<span class="pagination-info">${from}${to} of ${total}</span>
<div class="pagination-btns">${btns}</div>`;
}
function hidePagination() {
const bar = document.getElementById('pagination-bar');
bar.style.display = 'none';
bar.innerHTML = '';
}
function goPage(n) {
currentPage = n;
filterAndPage();
// Scroll the list back into view smoothly
document.getElementById('post-list').scrollIntoView({ behavior: 'smooth', block: 'start' });
}
/* ── Search input handler (debounced 250 ms) ─────────────────────────────── */
function onSearchInput(val) {
const clearBtn = document.getElementById('search-clear');
clearBtn.classList.toggle('visible', val.length > 0);
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
searchQuery = val;
currentPage = 1;
filterAndPage();
}, 250);
}
function clearSearch() {
document.getElementById('search-input').value = '';
document.getElementById('search-clear').classList.remove('visible');
searchQuery = '';
currentPage = 1;
filterAndPage();
document.getElementById('search-input').focus();
}
/* ── Highlight matched text ──────────────────────────────────────────────── */
function highlight(text, query) {
if (!query) return esc(text);
var safe = esc(text);
// Escape regex special chars safely without regex literal char-class issues
var safeQ = esc(query).replace(/[-[\]{}()*+?.,^$|#\s]/g, '\\$&');
try {
return safe.replace(new RegExp('(' + safeQ + ')', 'gi'), '<mark>$1</mark>');
} catch(e) { return safe; }
}
function renderFeed(posts, total) {
// Legacy path — called by old code paths if any; route through new system
allPosts = posts;
filterAndPage();
}
function postHTML(p, query) {
query = query || '';
const tags = (p.tags||[]).map(t=>`<span class="post-tag">${highlight(t, query)}</span>`).join('');
const link = p.link ? `<a class="post-link" href="${esc(p.link)}" target="_blank" rel="noopener">${esc(p.link_label||p.link)}</a>` : '';
const media = mediaStripHTML(p.media||[]);
return `<li class="post-item" id="pi-${esc(p.id)}">
<div class="post-meta">
<span class="post-id">#${highlight(p.id, query)}</span>
<span class="post-date">${esc(relTime(p.date))}</span>
${tags}
</div>
<p class="post-text">${highlight(p.text, query)}</p>
${link}
${media}
<div class="post-actions">
<button class="btn btn-ghost btn-sm" onclick="toggleEdit('${esc(p.id)}')">${ICO.edit} Edit</button>
<button class="btn btn-red btn-sm" onclick="deletePost('${esc(p.id)}')">${ICO.trash} Delete</button>
</div>
<div class="edit-form" id="ef-${esc(p.id)}">
<div class="field">
<label>Text</label>
<textarea id="ef-text-${esc(p.id)}" rows="3">${esc(p.text)}</textarea>
</div>
<div class="field-row">
<div class="field">
<label>Tags</label>
<input type="text" id="ef-tags-${esc(p.id)}" value="${esc((p.tags||[]).join(', '))}">
<span class="field-hint">comma-separated</span>
</div>
<div class="field">
<label>Date (ISO-8601)</label>
<input type="text" id="ef-date-${esc(p.id)}" value="${esc(p.date||'')}">
</div>
</div>
<div class="field-row">
<div class="field">
<label>Link URL</label>
<input type="url" id="ef-link-${esc(p.id)}" value="${esc(p.link||'')}">
</div>
<div class="field">
<label>Link label</label>
<input type="text" id="ef-linklabel-${esc(p.id)}" value="${esc(p.link_label||'')}">
</div>
</div>
<!-- Edit: add more media (cannot remove existing via UI — use api.php directly) -->
<div class="field">
<label>Add more media</label>
<div class="drop-zone" id="ef-drop-${esc(p.id)}">
<input type="file" id="ef-media-input-${esc(p.id)}" multiple
accept="image/jpeg,image/png,image/gif,image/webp,image/avif,image/svg+xml,video/mp4,video/quicktime,video/webm,video/ogg,video/x-msvideo,video/x-matroska">
<div class="drop-zone-label">
<span class="drop-zone-text">Drop or click to add media</span>
</div>
</div>
<div class="upload-progress" id="ef-progress-${esc(p.id)}">
<div class="upload-progress-bar" id="ef-bar-${esc(p.id)}"></div>
</div>
<div class="media-previews" id="ef-previews-${esc(p.id)}"></div>
</div>
<div style="display:flex;gap:.5rem;flex-wrap:wrap;">
<button class="btn btn-blue btn-sm" onclick="saveEdit('${esc(p.id)}')">${ICO.save} Save</button>
<button class="btn btn-ghost btn-sm" onclick="toggleEdit('${esc(p.id)}')">${ICO.cancel} Cancel</button>
</div>
<div id="ef-alert-${esc(p.id)}"></div>
</div>
</li>`;
}
/* ── Compose ─────────────────────────────────────────────────────────────────── */
function clearCompose() {
['c-text','c-tags','c-date','c-link','c-link-label'].forEach(id => {
document.getElementById(id).value = '';
});
clearMedia('compose', 'c-media-previews');
}
function submitPost() {
const text = document.getElementById('c-text').value.trim();
if (!text) { showAlert('compose-alert','error','Text is required.'); return; }
const body = {
text,
tags: parseTags(document.getElementById('c-tags').value),
link: document.getElementById('c-link').value.trim(),
link_label: document.getElementById('c-link-label').value.trim(),
media: getMedia('compose'),
};
const date = document.getElementById('c-date').value.trim();
if (date) body.date = date;
setLoading('compose-btn', true);
apiFetch('', { method: 'POST', body: JSON.stringify(body) })
.then(post => {
clearCompose();
setLoading('compose-btn', false);
showAlert('compose-alert','success','Posted: #' + post.id);
loadFeed();
})
.catch(err => { setLoading('compose-btn', false); showAlert('compose-alert','error',err.message); });
}
/* ── Edit ────────────────────────────────────────────────────────────────────── */
function toggleEdit(id) {
const ef = document.getElementById('ef-' + id);
ef.classList.toggle('open');
if (ef.classList.contains('open')) {
// Init drop zone for this edit form
initDropZone('ef-'+id, 'ef-drop-'+id, 'ef-media-input-'+id,
'ef-progress-'+id, 'ef-bar-'+id, 'ef-previews-'+id);
}
}
function saveEdit(id) {
const text = document.getElementById('ef-text-' + id).value.trim();
if (!text) { showAlert('ef-alert-'+id,'error','Text cannot be empty.'); return; }
// Fetch current post to merge existing media
apiFetch('id=' + encodeURIComponent(id))
.then(current => {
const existingMedia = current.media || [];
const newMedia = getMedia('ef-' + id);
const body = {
text,
tags: parseTags(document.getElementById('ef-tags-' + id).value),
link: document.getElementById('ef-link-' + id).value.trim(),
link_label: document.getElementById('ef-linklabel-' + id).value.trim(),
media: [...existingMedia, ...newMedia],
};
const date = document.getElementById('ef-date-' + id).value.trim();
if (date) body.date = date;
return apiFetch('id=' + encodeURIComponent(id), { method: 'PUT', body: JSON.stringify(body) });
})
.then(updated => {
document.getElementById('pi-' + id).outerHTML = postHTML(updated, searchQuery);
})
.catch(err => showAlert('ef-alert-'+id,'error',err.message));
}
/* ── Delete ──────────────────────────────────────────────────────────────────── */
function deletePost(id) {
if (!confirm('Delete post #' + id + '? This cannot be undone.')) return;
apiFetch('id=' + encodeURIComponent(id), { method: 'DELETE' })
.then(() => {
const li = document.getElementById('pi-' + id);
li.style.transition = 'opacity .2s, transform .2s';
li.style.opacity = '0'; li.style.transform = 'translateX(10px)';
setTimeout(() => { li.remove(); loadFeed(); }, 220);
})
.catch(err => alert('Delete failed: ' + err.message));
}
/* ── Enter key + session restore ─────────────────────────────────────────────── */
document.getElementById('key-input').addEventListener('keydown', e => { if (e.key==='Enter') doAuth(); });
/* Search: clear on Escape */
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && searchQuery) clearSearch();
});
(function() {
const saved = sessionStorage.getItem(STORE_KEY);
if (!saved) return;
document.getElementById('key-input').value = saved;
apiKey = saved;
apiFetch('')
.then(() => {
document.getElementById('auth-card').style.display = 'none';
document.getElementById('main-ui').style.display = 'flex';
initDropZone('compose','c-drop-zone','c-media-input','c-upload-progress','c-progress-bar','c-media-previews');
loadFeed();
})
.catch(() => { sessionStorage.removeItem(STORE_KEY); apiKey = ''; });
})();
</script>
</body>
</html>