6a1c545f35
Public player descriptions render formatted HTML on the homepage. Catalogue cards, embed meta tags, and social metadata use safe plain-text excerpts. Add/edit admin pages now use a rich description editor that preserves pasted HTML styling, with a textarea fallback for no-JS. External/YouTube-imported descriptions are sanitized before storage. README now documents Markdown/HTML description support.
552 lines
24 KiB
PHP
552 lines
24 KiB
PHP
<?php
|
||
/**
|
||
* layout.php — shared HTML head, header, and footer partials
|
||
*/
|
||
|
||
function render_social_meta(string $title, array $meta = []): void {
|
||
$site_name = trim((string)($meta['site_name'] ?? setting('site_title', 'Ty Clifford')));
|
||
$description = trim((string)($meta['description'] ?? setting('site_sub', 'tyclifford.com / media')));
|
||
$type = trim((string)($meta['type'] ?? 'website'));
|
||
$url = trim((string)($meta['url'] ?? public_absolute_url($_SERVER['REQUEST_URI'] ?? public_home_url())));
|
||
$image = trim((string)($meta['image'] ?? ''));
|
||
$image_alt = trim((string)($meta['image_alt'] ?? $title));
|
||
$image_width = (int)($meta['image_width'] ?? 0);
|
||
$image_height = (int)($meta['image_height'] ?? 0);
|
||
$twitter_card = $image !== '' ? 'summary_large_image' : 'summary';
|
||
|
||
$url = public_absolute_url($url);
|
||
if ($image !== '') $image = public_absolute_url($image);
|
||
?>
|
||
<meta name="description" content="<?= h($description) ?>">
|
||
<link rel="canonical" href="<?= h($url) ?>">
|
||
<meta property="og:type" content="<?= h($type) ?>">
|
||
<meta property="og:site_name" content="<?= h($site_name) ?>">
|
||
<meta property="og:title" content="<?= h($title) ?>">
|
||
<meta property="og:description" content="<?= h($description) ?>">
|
||
<meta property="og:url" content="<?= h($url) ?>">
|
||
<?php if ($image !== ''): ?>
|
||
<meta property="og:image" content="<?= h($image) ?>">
|
||
<meta property="og:image:secure_url" content="<?= h($image) ?>">
|
||
<meta property="og:image:alt" content="<?= h($image_alt) ?>">
|
||
<?php if ($image_width > 0 && $image_height > 0): ?>
|
||
<meta property="og:image:width" content="<?= $image_width ?>">
|
||
<meta property="og:image:height" content="<?= $image_height ?>">
|
||
<?php endif; ?>
|
||
<?php endif; ?>
|
||
<meta name="twitter:card" content="<?= h($twitter_card) ?>">
|
||
<meta name="twitter:title" content="<?= h($title) ?>">
|
||
<meta name="twitter:description" content="<?= h($description) ?>">
|
||
<?php if ($image !== ''): ?>
|
||
<meta name="twitter:image" content="<?= h($image) ?>">
|
||
<meta name="twitter:image:alt" content="<?= h($image_alt) ?>">
|
||
<?php endif; ?>
|
||
<?php }
|
||
|
||
function render_head(string $title = 'Media — TyClifford.com', string $extra_css = '', array $meta = []): void { ?>
|
||
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title><?= h($title) ?></title>
|
||
<?php render_social_meta($title, $meta); ?>
|
||
<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">
|
||
<!-- Video.js -->
|
||
<link href="https://vjs.zencdn.net/8.10.0/video-js.css" rel="stylesheet" />
|
||
<script src="https://vjs.zencdn.net/8.10.0/video.min.js"></script>
|
||
<style>
|
||
/* ── Reset & design tokens (mirrors gate.php exactly) ──────────────────── */
|
||
*, *::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;
|
||
--yellow: #ffd040;
|
||
|
||
--pat: #ff424d;
|
||
--pat-lite: rgba(255,66,77,.10);
|
||
--pat-border:rgba(255,66,77,.32);
|
||
--pat-glow: rgba(255,66,77,.18);
|
||
|
||
--r: 6px;
|
||
--gap: 1.25rem;
|
||
--page-pad: 1rem;
|
||
}
|
||
@media (min-width:600px) { :root { --page-pad: 1.5rem; } }
|
||
@media (min-width:1000px) { :root { --page-pad: 2rem; } }
|
||
|
||
html { font-size:16px; -webkit-text-size-adjust:100%; scroll-behavior:smooth; }
|
||
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;
|
||
}
|
||
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,.06) 3px,rgba(0,0,0,.06) 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 pulse-border { 0%,100%{box-shadow:0 0 0 0 var(--pat-glow)} 50%{box-shadow:0 0 0 6px transparent} }
|
||
@keyframes live-pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
|
||
|
||
/* ── Page wrapper ── */
|
||
.page { width:100%; max-width:1100px; padding:var(--page-pad); display:flex; flex-direction:column; gap:1.5rem; animation:rise .45s cubic-bezier(.22,.68,0,1.2) both; }
|
||
|
||
/* ── Top bar ── */
|
||
.topbar { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; }
|
||
.topbar-brand { display:flex; align-items:center; gap:.75rem; }
|
||
.brand-name { font-size:1rem; font-weight:700; letter-spacing:-.01em; color:var(--text); text-decoration:none; }
|
||
.brand-sub { font-family:'Share Tech Mono',monospace; font-size:.62rem; letter-spacing:.18em; text-transform:uppercase; color:var(--text-3); }
|
||
.topbar-social { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; }
|
||
.social-pill {
|
||
display:inline-flex; align-items:center; gap:.4rem;
|
||
padding:.35rem .75rem;
|
||
background:rgba(255,255,255,.03);
|
||
border:1px solid var(--border-2);
|
||
border-radius:99px;
|
||
font-family:'Share Tech Mono',monospace; font-size:.65rem; letter-spacing:.08em;
|
||
color:var(--text-3); text-decoration:none;
|
||
transition:border-color .2s, color .2s, background .2s;
|
||
}
|
||
.social-pill:hover { color:var(--text-2); background:rgba(255,255,255,.06); }
|
||
.social-pill svg { flex-shrink:0; }
|
||
.social-pill.active { border-color:var(--purple); color:var(--purple); }
|
||
|
||
/* ── Cards ── */
|
||
.card {
|
||
background:var(--surface);
|
||
border:1px solid var(--border);
|
||
border-radius:calc(var(--r) + 2px);
|
||
padding:1.4rem 1.5rem;
|
||
display:flex; flex-direction:column; gap:.9rem;
|
||
position:relative; overflow:hidden;
|
||
}
|
||
.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)+2px) 0; opacity:.5;
|
||
}
|
||
.card-title { font-size:.95rem; font-weight:700; letter-spacing:-.01em; color:var(--text); line-height:1.2; }
|
||
.card-body { font-size:.85rem; color:var(--text-2); line-height:1.7; }
|
||
.card-body strong { color:var(--text); font-weight:600; }
|
||
.card-body a { color:var(--pat); text-decoration:none; border-bottom:1px solid rgba(255,66,77,.25); transition:border-color .15s; }
|
||
.card-body a:hover { border-color:rgba(255,66,77,.6); }
|
||
|
||
/* ── Eyebrow ── */
|
||
.eyebrow { font-family:'Share Tech Mono',monospace; font-size:.62rem; letter-spacing:.2em; text-transform:uppercase; }
|
||
.eyebrow-green { color:var(--green); text-shadow:0 0 10px rgba(0,232,122,.5); }
|
||
.eyebrow-green::before { content:'> '; opacity:.5; }
|
||
.eyebrow-purple { color:var(--purple); }
|
||
.eyebrow-blue { color:var(--blue); }
|
||
|
||
/* ── Stream card / player card ── */
|
||
.stream-card {
|
||
background:var(--surface); border:1px solid var(--border);
|
||
border-radius:calc(var(--r)+2px); overflow:hidden; position:relative;
|
||
box-shadow:0 0 0 1px rgba(255,255,255,.03), 0 4px 50px rgba(0,0,0,.6);
|
||
}
|
||
.stream-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;
|
||
}
|
||
.stream-ratio { position:relative; width:100%; padding-top:56.25%; background:#000; }
|
||
.stream-ratio > * { position:absolute; inset:0; width:100%; height:100%; border:0; display:block; }
|
||
.stream-footer {
|
||
display:flex; align-items:center; justify-content:space-between; gap:.75rem;
|
||
padding:.75rem 1.1rem; border-top:1px solid var(--border); flex-wrap:wrap;
|
||
}
|
||
.stream-meta { font-family:'Share Tech Mono',monospace; font-size:.65rem; letter-spacing:.1em; color:var(--text-3); }
|
||
.stream-meta span { color:var(--text-2); }
|
||
|
||
/* ── Footer ── */
|
||
.page-footer {
|
||
padding:.5rem 0 1rem; display:flex; align-items:center; justify-content:center;
|
||
gap:1.5rem; flex-wrap:wrap; border-top:1px solid var(--border);
|
||
}
|
||
.footer-note { font-family:'Share Tech Mono',monospace; font-size:.63rem; color:var(--text-3); letter-spacing:.06em; text-align:center; }
|
||
.footer-note::before { content:'// '; opacity:.4; }
|
||
.footer-note a { color:var(--text-3); text-decoration:none; transition:color .15s; }
|
||
.footer-note a:hover { color:var(--text-2); }
|
||
|
||
/* ── Buttons ── */
|
||
.btn {
|
||
display:inline-flex; align-items:center; gap:.45rem;
|
||
padding:.6rem 1.1rem; border-radius:var(--r);
|
||
font-family:'Inter',sans-serif; font-size:.82rem; font-weight:600;
|
||
text-decoration:none; cursor:pointer; border:none;
|
||
transition:transform .15s, filter .15s, background .2s;
|
||
}
|
||
.btn:hover { transform:translateY(-1px); filter:brightness(1.08); }
|
||
.btn:active { transform:scale(.98); }
|
||
.btn-primary { background:var(--purple); color:#fff; box-shadow:0 4px 18px rgba(176,96,255,.25); }
|
||
.btn-secondary { background:rgba(255,255,255,.06); color:var(--text-2); border:1px solid var(--border-2); }
|
||
.btn-secondary:hover { color:var(--text); background:rgba(255,255,255,.1); }
|
||
.btn-danger { background:rgba(255,51,85,.15); color:var(--red); border:1px solid rgba(255,51,85,.3); }
|
||
.btn-danger:hover { background:rgba(255,51,85,.25); }
|
||
.btn-green { background:rgba(0,232,122,.12); color:var(--green); border:1px solid rgba(0,232,122,.3); }
|
||
.btn-green:hover { background:rgba(0,232,122,.2); }
|
||
.btn-sm { padding:.38rem .75rem; font-size:.75rem; }
|
||
|
||
/* ── Form controls ── */
|
||
.form-group { display:flex; flex-direction:column; gap:.4rem; }
|
||
.form-label { font-family:'Share Tech Mono',monospace; font-size:.65rem; letter-spacing:.12em; text-transform:uppercase; color:var(--text-3); }
|
||
.form-input, .form-select, .form-textarea {
|
||
background:var(--surface-2); border:1px solid var(--border-2); border-radius:var(--r);
|
||
color:var(--text); padding:.6rem .85rem; font-family:'Inter',sans-serif; font-size:.85rem;
|
||
transition:border-color .2s;
|
||
width:100%;
|
||
}
|
||
.form-input:focus, .form-select:focus, .form-textarea:focus {
|
||
outline:none; border-color:var(--purple);
|
||
box-shadow:0 0 0 2px rgba(176,96,255,.15);
|
||
}
|
||
.form-textarea { resize:vertical; min-height:80px; }
|
||
.form-select option { background:var(--surface-2); }
|
||
.description-editor {
|
||
min-height:130px; line-height:1.55; overflow:auto; white-space:pre-wrap;
|
||
}
|
||
.description-editor:empty::before {
|
||
content:attr(data-placeholder); color:var(--text-3); pointer-events:none;
|
||
}
|
||
.description-editor :where(p, ul, ol, blockquote, pre, h1, h2, h3, h4, h5, h6) { margin:.45rem 0; }
|
||
.description-editor :where(ul, ol) { padding-left:1.35rem; }
|
||
.description-editor a,
|
||
.media-description a {
|
||
color:var(--blue); text-decoration:none; border-bottom:1px solid rgba(0,200,255,.35);
|
||
}
|
||
.description-editor a:hover,
|
||
.media-description a:hover { border-color:rgba(0,200,255,.7); }
|
||
.media-description {
|
||
font-family:"Inter",sans-serif; color:var(--text-2);
|
||
letter-spacing:0; line-height:1.6; overflow-wrap:anywhere;
|
||
}
|
||
.media-description :where(p, ul, ol, blockquote, pre, h1, h2, h3, h4, h5, h6) { margin:.55rem 0; }
|
||
.media-description :where(p:first-child, ul:first-child, ol:first-child, blockquote:first-child, pre:first-child, h1:first-child, h2:first-child, h3:first-child, h4:first-child, h5:first-child, h6:first-child) { margin-top:0; }
|
||
.media-description :where(p:last-child, ul:last-child, ol:last-child, blockquote:last-child, pre:last-child, h1:last-child, h2:last-child, h3:last-child, h4:last-child, h5:last-child, h6:last-child) { margin-bottom:0; }
|
||
.media-description :where(ul, ol) { padding-left:1.35rem; }
|
||
.media-description :where(h1, h2, h3, h4, h5, h6) { color:var(--text); line-height:1.25; font-size:.95rem; }
|
||
.media-description blockquote {
|
||
padding-left:.85rem; border-left:2px solid var(--border-2); color:var(--text-2);
|
||
}
|
||
.media-description code {
|
||
font-family:"Share Tech Mono",monospace; font-size:.9em;
|
||
background:rgba(255,255,255,.06); border:1px solid var(--border-2);
|
||
border-radius:4px; padding:.08rem .28rem;
|
||
}
|
||
.media-description pre {
|
||
overflow:auto; background:rgba(0,0,0,.24); border:1px solid var(--border);
|
||
border-radius:var(--r); padding:.7rem;
|
||
}
|
||
.media-description pre code { background:transparent; border:0; padding:0; }
|
||
.media-description img { max-width:100%; height:auto; border-radius:var(--r); }
|
||
|
||
/* ── Notice/alert ── */
|
||
.notice {
|
||
padding:.75rem 1rem; border-radius:var(--r); font-size:.82rem;
|
||
border:1px solid; margin-bottom:1rem;
|
||
}
|
||
.notice-success { background:rgba(0,232,122,.08); border-color:rgba(0,232,122,.3); color:var(--green); }
|
||
.notice-error { background:rgba(255,51,85,.08); border-color:rgba(255,51,85,.3); color:var(--red); }
|
||
.notice-info { background:rgba(0,200,255,.08); border-color:rgba(0,200,255,.3); color:var(--blue); }
|
||
|
||
/* ── Pagination ── */
|
||
.pagination { display:flex; align-items:center; gap:.4rem; flex-wrap:wrap; }
|
||
.pagination a, .pagination span {
|
||
display:inline-flex; align-items:center; justify-content:center;
|
||
min-width:2rem; height:2rem; padding:0 .5rem;
|
||
border-radius:var(--r); font-family:'Share Tech Mono',monospace; font-size:.72rem;
|
||
text-decoration:none; transition:background .2s, color .2s, border-color .2s;
|
||
border:1px solid var(--border-2); color:var(--text-3);
|
||
}
|
||
.pagination a:hover { border-color:var(--purple); color:var(--purple); background:rgba(176,96,255,.08); }
|
||
.pagination .current { border-color:var(--purple); color:var(--purple); background:rgba(176,96,255,.15); }
|
||
.pagination .disabled { opacity:.3; pointer-events:none; }
|
||
|
||
<?= $extra_css ?>
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<?php }
|
||
|
||
function render_topbar(string $active = ''): void {
|
||
$title = setting('site_title', 'Ty Clifford');
|
||
$sub = setting('site_sub', 'tyclifford.com / player');
|
||
?>
|
||
<header class="topbar">
|
||
<div class="topbar-brand">
|
||
<div>
|
||
<a href="<?= h(public_home_url()) ?>" class="brand-name"><?= h($title) ?></a>
|
||
<div class="brand-sub"><?= h($sub) ?></div>
|
||
</div>
|
||
</div>
|
||
<nav class="topbar-social" aria-label="Navigation">
|
||
<a class="social-pill <?= $active==='home' ? 'active' : '' ?>" href="<?= h(public_home_url()) ?>">
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9,22 9,12 15,12 15,22"/></svg>
|
||
Home
|
||
</a>
|
||
<a class="social-pill <?= $active==='catalogue' ? 'active' : '' ?>" href="<?= h(public_catalogue_url()) ?>">
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||
Videos
|
||
</a>
|
||
</nav>
|
||
</header>
|
||
<?php }
|
||
|
||
function render_footer(): void { ?>
|
||
<footer class="page-footer" role="contentinfo">
|
||
<p class="footer-note">TyClifford.com · player</p>
|
||
<p class="footer-note"><a href="https://git.tyclifford.com/">open source</a></p>
|
||
<p class="footer-note">hobbies are my hobby</p>
|
||
<p class="footer-note"><a href="mailto:ty@tyclifford.com">ty@tyclifford.com</a></p>
|
||
</footer>
|
||
<?php }
|
||
|
||
function render_description_editor(string $description): void {
|
||
$description = trim($description);
|
||
$is_html = media_description_contains_html($description);
|
||
?>
|
||
<textarea class="form-textarea description-fallback" id="description" name="description" rows="5"><?= h($description) ?></textarea>
|
||
<div class="description-editor form-textarea"
|
||
id="description-editor"
|
||
hidden
|
||
contenteditable="true"
|
||
role="textbox"
|
||
aria-labelledby="description-label"
|
||
aria-multiline="true"
|
||
data-rich="<?= $is_html ? '1' : '0' ?>"
|
||
data-placeholder="Description"><?php if ($is_html): ?><?= media_description_html($description) ?><?php else: ?><?= h($description) ?><?php endif; ?></div>
|
||
<?php }
|
||
|
||
function render_description_editor_script(): void { ?>
|
||
<script>
|
||
(function() {
|
||
var textarea = document.getElementById('description');
|
||
var editor = document.getElementById('description-editor');
|
||
if (!textarea || !editor) return;
|
||
|
||
textarea.hidden = true;
|
||
editor.hidden = false;
|
||
|
||
var rich = editor.dataset.rich === '1';
|
||
var allowedTags = {
|
||
A: true, B: true, BLOCKQUOTE: true, BR: true, CODE: true, DIV: true,
|
||
EM: true, H1: true, H2: true, H3: true, H4: true, H5: true, H6: true,
|
||
I: true, IMG: true, LI: true, OL: true, P: true, PRE: true, S: true,
|
||
SPAN: true, STRONG: true, U: true, UL: true
|
||
};
|
||
var dropTags = {
|
||
AUDIO: true, BASE: true, BUTTON: true, CANVAS: true, EMBED: true,
|
||
FORM: true, IFRAME: true, INPUT: true, LINK: true, MATH: true,
|
||
META: true, OBJECT: true, PICTURE: true, SCRIPT: true, SELECT: true,
|
||
SOURCE: true, STYLE: true, SVG: true, TEXTAREA: true, TRACK: true,
|
||
VIDEO: true
|
||
};
|
||
var styleProps = [
|
||
'background-color', 'color', 'font-size', 'font-style', 'font-weight',
|
||
'letter-spacing', 'line-height', 'text-align', 'text-decoration',
|
||
'text-transform'
|
||
];
|
||
|
||
function isSafeUrl(value, forSrc) {
|
||
value = (value || '').trim();
|
||
if (!value) return '';
|
||
if (value.indexOf('//') === 0) return 'https:' + value;
|
||
if (value.charAt(0) === '#') return forSrc ? '' : value;
|
||
if (value.charAt(0) === '/') return value;
|
||
try {
|
||
var parsed = new URL(value, window.location.origin);
|
||
var protocol = parsed.protocol.replace(':', '').toLowerCase();
|
||
if (forSrc) return (protocol === 'http' || protocol === 'https') ? value : '';
|
||
return ['http', 'https', 'mailto', 'tel'].indexOf(protocol) !== -1 ? value : '';
|
||
} catch (e) {
|
||
return /^[^<>"']+$/.test(value) ? value : '';
|
||
}
|
||
}
|
||
|
||
function cleanStyle(value) {
|
||
var probe = document.createElement('span');
|
||
probe.setAttribute('style', value || '');
|
||
var kept = [];
|
||
styleProps.forEach(function(prop) {
|
||
var v = probe.style.getPropertyValue(prop);
|
||
if (!v || /url\s*\(|expression\s*\(|javascript:|data:|@|[<>\\]/i.test(v)) return;
|
||
kept.push(prop + ': ' + v);
|
||
});
|
||
return kept.join('; ');
|
||
}
|
||
|
||
function sanitizeElement(el) {
|
||
Array.prototype.slice.call(el.childNodes).forEach(function(child) {
|
||
if (child.nodeType === Node.TEXT_NODE) return;
|
||
if (child.nodeType !== Node.ELEMENT_NODE) {
|
||
child.remove();
|
||
return;
|
||
}
|
||
if (dropTags[child.tagName]) {
|
||
child.remove();
|
||
return;
|
||
}
|
||
sanitizeElement(child);
|
||
if (!allowedTags[child.tagName]) {
|
||
while (child.firstChild) child.parentNode.insertBefore(child.firstChild, child);
|
||
child.remove();
|
||
return;
|
||
}
|
||
|
||
Array.prototype.slice.call(child.attributes).forEach(function(attr) {
|
||
var name = attr.name.toLowerCase();
|
||
var value = attr.value;
|
||
var keep = name === 'title' || name === 'style';
|
||
if (child.tagName === 'A') keep = keep || name === 'href' || name === 'target' || name === 'rel';
|
||
if (child.tagName === 'IMG') keep = keep || name === 'src' || name === 'alt' || name === 'width' || name === 'height';
|
||
if (child.tagName === 'OL') keep = keep || name === 'start';
|
||
if (name.indexOf('on') === 0 || !keep) {
|
||
child.removeAttribute(attr.name);
|
||
return;
|
||
}
|
||
if (name === 'style') {
|
||
var style = cleanStyle(value);
|
||
if (style) child.setAttribute('style', style);
|
||
else child.removeAttribute(attr.name);
|
||
}
|
||
if (name === 'href' || name === 'src') {
|
||
var url = isSafeUrl(value, name === 'src');
|
||
if (url) child.setAttribute(name, url);
|
||
else child.removeAttribute(attr.name);
|
||
}
|
||
});
|
||
|
||
if (child.tagName === 'A') {
|
||
if (child.getAttribute('href')) {
|
||
child.setAttribute('target', '_blank');
|
||
child.setAttribute('rel', 'noopener noreferrer');
|
||
} else {
|
||
child.removeAttribute('target');
|
||
child.removeAttribute('rel');
|
||
}
|
||
}
|
||
if (child.tagName === 'IMG' && child.getAttribute('src')) {
|
||
child.setAttribute('loading', 'lazy');
|
||
child.setAttribute('decoding', 'async');
|
||
}
|
||
if (child.tagName === 'IMG' && !child.getAttribute('src')) child.remove();
|
||
});
|
||
}
|
||
|
||
function sanitizeHtml(html) {
|
||
var template = document.createElement('template');
|
||
template.innerHTML = html || '';
|
||
sanitizeElement(template.content);
|
||
return template.innerHTML.trim();
|
||
}
|
||
|
||
function plainText() {
|
||
return editor.innerText.replace(/\u00a0/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
|
||
}
|
||
|
||
function syncDescription() {
|
||
textarea.value = rich ? sanitizeHtml(editor.innerHTML) : plainText();
|
||
}
|
||
|
||
function insertHtml(html) {
|
||
var selection = window.getSelection();
|
||
if (!selection || !selection.rangeCount) {
|
||
editor.insertAdjacentHTML('beforeend', html);
|
||
return;
|
||
}
|
||
var range = selection.getRangeAt(0);
|
||
range.deleteContents();
|
||
var fragment = range.createContextualFragment(html);
|
||
var last = fragment.lastChild;
|
||
range.insertNode(fragment);
|
||
if (last) {
|
||
range.setStartAfter(last);
|
||
range.collapse(true);
|
||
selection.removeAllRanges();
|
||
selection.addRange(range);
|
||
}
|
||
}
|
||
|
||
window.setDescriptionEditorValue = function(value) {
|
||
value = value || '';
|
||
if (/<\/?[a-z][\s\S]*>/i.test(value)) {
|
||
rich = true;
|
||
editor.dataset.rich = '1';
|
||
editor.innerHTML = sanitizeHtml(value);
|
||
} else {
|
||
rich = false;
|
||
editor.dataset.rich = '0';
|
||
editor.textContent = value;
|
||
}
|
||
syncDescription();
|
||
};
|
||
|
||
editor.addEventListener('paste', function(event) {
|
||
var data = event.clipboardData || window.clipboardData;
|
||
var html = data ? data.getData('text/html') : '';
|
||
if (!html) return;
|
||
event.preventDefault();
|
||
rich = true;
|
||
editor.dataset.rich = '1';
|
||
insertHtml(sanitizeHtml(html));
|
||
syncDescription();
|
||
});
|
||
editor.addEventListener('input', syncDescription);
|
||
var form = editor.closest('form');
|
||
if (form) form.addEventListener('submit', syncDescription);
|
||
syncDescription();
|
||
})();
|
||
</script>
|
||
<?php }
|
||
|
||
function render_pagination(int $current, int $total, string $url_pattern): void {
|
||
if ($total <= 1) return;
|
||
echo '<div class="pagination">';
|
||
$prev = $current - 1;
|
||
$next = $current + 1;
|
||
if ($prev >= 1) {
|
||
echo '<a href="' . sprintf($url_pattern, $prev) . '">‹</a>';
|
||
} else {
|
||
echo '<span class="disabled">‹</span>';
|
||
}
|
||
$range = range(max(1, $current-2), min($total, $current+2));
|
||
if (!in_array(1, $range)) { echo '<a href="' . sprintf($url_pattern, 1) . '">1</a><span>…</span>'; }
|
||
foreach ($range as $p) {
|
||
if ($p === $current) echo '<span class="current">' . $p . '</span>';
|
||
else echo '<a href="' . sprintf($url_pattern, $p) . '">' . $p . '</a>';
|
||
}
|
||
if (!in_array($total, $range)) { echo '<span>…</span><a href="' . sprintf($url_pattern, $total) . '">' . $total . '</a>'; }
|
||
if ($next <= $total) {
|
||
echo '<a href="' . sprintf($url_pattern, $next) . '">›</a>';
|
||
} else {
|
||
echo '<span class="disabled">›</span>';
|
||
}
|
||
echo '</div>';
|
||
}
|