1235 lines
54 KiB
PHP
1235 lines
54 KiB
PHP
<?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,'&').replace(/</g,'<').replace(/>/g,'>')
|
||
.replace(/"/g,'"').replace(/'/g,''');
|
||
}
|
||
|
||
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>
|