- Descriptions now render Markdown or sanitized HTML via [includes/db.php](/Users/tyemeclifford/Documents/GH/mediaplayer/includes/db.php).
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.
This commit is contained in:
@@ -82,6 +82,8 @@ External URLs must use `http://` or `https://`. Uploaded files and remote URLs c
|
|||||||
|
|
||||||
When adding an external URL, the admin form can fetch title, description, duration, thumbnail metadata, and readable provider view counts through oEmbed, page metadata, and JSON-LD. The save action repeats the metadata lookup as a fallback, so blank titles can be filled from supported external pages even if the browser autofill was not used.
|
When adding an external URL, the admin form can fetch title, description, duration, thumbnail metadata, and readable provider view counts through oEmbed, page metadata, and JSON-LD. The save action repeats the metadata lookup as a fallback, so blank titles can be filled from supported external pages even if the browser autofill was not used.
|
||||||
|
|
||||||
|
Descriptions support Markdown, sanitized HTML, and rich pasted HTML from provider pages such as YouTube. Public pages render formatting while catalogue cards and social metadata use safe plain-text excerpts.
|
||||||
|
|
||||||
If no thumbnail image is uploaded, the add/edit forms can auto-grab one from the first supported source. YouTube and Dailymotion use direct thumbnail URLs; Vimeo, Wistia, TikTok, SoundCloud, and Spotify use oEmbed when available. Local uploaded videos can generate a first-frame thumbnail when `ffmpeg` is installed on the server. Uploaded M4A files also use `ffmpeg` to create a black-frame H.264 MP4 playback source while keeping the original M4A source.
|
If no thumbnail image is uploaded, the add/edit forms can auto-grab one from the first supported source. YouTube and Dailymotion use direct thumbnail URLs; Vimeo, Wistia, TikTok, SoundCloud, and Spotify use oEmbed when available. Local uploaded videos can generate a first-frame thumbnail when `ffmpeg` is installed on the server. Uploaded M4A files also use `ffmpeg` to create a black-frame H.264 MP4 playback source while keeping the original M4A source.
|
||||||
|
|
||||||
### Live Homepage Option
|
### Live Homepage Option
|
||||||
|
|||||||
+11
-5
@@ -9,7 +9,7 @@ $msg = '';
|
|||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
$title = trim($_POST['title'] ?? '');
|
$title = trim($_POST['title'] ?? '');
|
||||||
$description = trim($_POST['description'] ?? '');
|
$description = media_prepare_description_for_storage($_POST['description'] ?? '');
|
||||||
$duration = (int)($_POST['duration'] ?? 0);
|
$duration = (int)($_POST['duration'] ?? 0);
|
||||||
$sort_order = (int)($_POST['sort_order'] ?? 0);
|
$sort_order = (int)($_POST['sort_order'] ?? 0);
|
||||||
$published = isset($_POST['published']) ? 1 : 0;
|
$published = isset($_POST['published']) ? 1 : 0;
|
||||||
@@ -40,7 +40,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$metadata = media_external_metadata($url);
|
$metadata = media_external_metadata($url);
|
||||||
$source_metadata[$url] = $metadata;
|
$source_metadata[$url] = $metadata;
|
||||||
if ($title === '' && $metadata['title'] !== '') $title = $metadata['title'];
|
if ($title === '' && $metadata['title'] !== '') $title = $metadata['title'];
|
||||||
if ($description === '' && $metadata['description'] !== '') $description = $metadata['description'];
|
if ($description === '' && $metadata['description'] !== '') {
|
||||||
|
$description = media_prepare_description_for_storage($metadata['description']);
|
||||||
|
}
|
||||||
if ($duration <= 0 && (int)$metadata['duration'] > 0) $duration = (int)$metadata['duration'];
|
if ($duration <= 0 && (int)$metadata['duration'] > 0) $duration = (int)$metadata['duration'];
|
||||||
if ((int)$metadata['view_count'] > $initial_external_view_count) {
|
if ((int)$metadata['view_count'] > $initial_external_view_count) {
|
||||||
$initial_external_view_count = (int)$metadata['view_count'];
|
$initial_external_view_count = (int)$metadata['view_count'];
|
||||||
@@ -291,8 +293,8 @@ render_head('Add Video — Admin', '
|
|||||||
<span class="hint">Leave blank when pasting a supported external URL, then use Fetch Info.</span>
|
<span class="hint">Leave blank when pasting a supported external URL, then use Fetch Info.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group span2">
|
<div class="form-group span2">
|
||||||
<label class="form-label" for="description">Description</label>
|
<label class="form-label" id="description-label" for="description">Description</label>
|
||||||
<textarea class="form-textarea" id="description" name="description" rows="3"><?= h($_POST['description'] ?? '') ?></textarea>
|
<?php render_description_editor($_POST['description'] ?? ''); ?>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="duration">Duration (seconds)</label>
|
<label class="form-label" for="duration">Duration (seconds)</label>
|
||||||
@@ -401,6 +403,7 @@ render_head('Add Video — Admin', '
|
|||||||
<?php render_footer(); ?>
|
<?php render_footer(); ?>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<?php render_description_editor_script(); ?>
|
||||||
<script>
|
<script>
|
||||||
function toggleSourceRow(row) {
|
function toggleSourceRow(row) {
|
||||||
var select = row.querySelector('.source-type-select');
|
var select = row.querySelector('.source-type-select');
|
||||||
@@ -430,7 +433,10 @@ function fillFromMetadata(row, metadata, force) {
|
|||||||
var label = row.querySelector('input[name="source_label[]"]');
|
var label = row.querySelector('input[name="source_label[]"]');
|
||||||
|
|
||||||
if (metadata.title && title && (force || !title.value.trim())) title.value = metadata.title;
|
if (metadata.title && title && (force || !title.value.trim())) title.value = metadata.title;
|
||||||
if (metadata.description && description && (force || !description.value.trim())) description.value = metadata.description;
|
if (metadata.description && description && (force || !description.value.trim())) {
|
||||||
|
if (window.setDescriptionEditorValue) window.setDescriptionEditorValue(metadata.description);
|
||||||
|
else description.value = metadata.description;
|
||||||
|
}
|
||||||
if (metadata.duration && duration && (force || !parseInt(duration.value || '0', 10))) duration.value = metadata.duration;
|
if (metadata.duration && duration && (force || !parseInt(duration.value || '0', 10))) duration.value = metadata.duration;
|
||||||
if (label && !label.value.trim()) label.value = providerLabel(metadata.provider) || metadata.title || 'External';
|
if (label && !label.value.trim()) label.value = providerLabel(metadata.provider) || metadata.title || 'External';
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-3
@@ -39,7 +39,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
|
|
||||||
if ($action === 'save') {
|
if ($action === 'save') {
|
||||||
$title = trim($_POST['title'] ?? '');
|
$title = trim($_POST['title'] ?? '');
|
||||||
$description = trim($_POST['description'] ?? '');
|
$description = media_prepare_description_for_storage($_POST['description'] ?? '');
|
||||||
$duration = (int)($_POST['duration'] ?? 0);
|
$duration = (int)($_POST['duration'] ?? 0);
|
||||||
$sort_order = (int)($_POST['sort_order'] ?? 0);
|
$sort_order = (int)($_POST['sort_order'] ?? 0);
|
||||||
$view_count = max(0, (int)($_POST['view_count'] ?? $video['view_count']));
|
$view_count = max(0, (int)($_POST['view_count'] ?? $video['view_count']));
|
||||||
@@ -335,8 +335,8 @@ render_head('Edit Video — Admin', '
|
|||||||
value="<?= h($video['title']) ?>" required>
|
value="<?= h($video['title']) ?>" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group span2">
|
<div class="form-group span2">
|
||||||
<label class="form-label" for="description">Description</label>
|
<label class="form-label" id="description-label" for="description">Description</label>
|
||||||
<textarea class="form-textarea" id="description" name="description" rows="3"><?= h($video['description']) ?></textarea>
|
<?php render_description_editor($video['description']); ?>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Slug (auto-generated)</label>
|
<label class="form-label">Slug (auto-generated)</label>
|
||||||
@@ -508,6 +508,7 @@ render_head('Edit Video — Admin', '
|
|||||||
<?php render_footer(); ?>
|
<?php render_footer(); ?>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<?php render_description_editor_script(); ?>
|
||||||
<script>
|
<script>
|
||||||
function toggleSourceRow(row) {
|
function toggleSourceRow(row) {
|
||||||
var select = row.querySelector('.source-type-select');
|
var select = row.querySelector('.source-type-select');
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ function yt_video_record(string $id, string $title, string $description = '', st
|
|||||||
return [
|
return [
|
||||||
'id' => $id,
|
'id' => $id,
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'description' => trim($description),
|
'description' => media_prepare_description_for_storage($description),
|
||||||
'duration' => yt_duration_seconds($duration_text),
|
'duration' => yt_duration_seconds($duration_text),
|
||||||
'duration_text' => trim($duration_text),
|
'duration_text' => trim($duration_text),
|
||||||
'thumbnail_url' => $thumbnail_url ?: 'https://i.ytimg.com/vi/' . rawurlencode($id) . '/hqdefault.jpg',
|
'thumbnail_url' => $thumbnail_url ?: 'https://i.ytimg.com/vi/' . rawurlencode($id) . '/hqdefault.jpg',
|
||||||
|
|||||||
+1
-1
@@ -161,7 +161,7 @@ render_head('Videos — TyClifford.com', '
|
|||||||
<?php foreach ($paged['videos'] as $v):
|
<?php foreach ($paged['videos'] as $v):
|
||||||
$vslug = $v['slug'];
|
$vslug = $v['slug'];
|
||||||
$vtitle = $v['title'];
|
$vtitle = $v['title'];
|
||||||
$vdesc = $v['description'];
|
$vdesc = media_description_plain_text($v['description']);
|
||||||
$vthumb = $v['thumbnail'];
|
$vthumb = $v['thumbnail'];
|
||||||
$vdur = (int)$v['duration'];
|
$vdur = (int)$v['duration'];
|
||||||
$created = substr($v['created_at'], 0, 10);
|
$created = substr($v['created_at'], 0, 10);
|
||||||
|
|||||||
+106
@@ -27,6 +27,10 @@ function cli_main(array $argv): int {
|
|||||||
$command = 'help';
|
$command = 'help';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cli_command_requires_database_write($command)) {
|
||||||
|
cli_assert_database_writable();
|
||||||
|
}
|
||||||
|
|
||||||
return match ($command) {
|
return match ($command) {
|
||||||
'help' => cli_help(),
|
'help' => cli_help(),
|
||||||
'site:status', 'status' => cli_site_status(),
|
'site:status', 'status' => cli_site_status(),
|
||||||
@@ -51,6 +55,7 @@ function cli_main(array $argv): int {
|
|||||||
'maintenance:external-views' => cli_maintenance_external_views($parsed),
|
'maintenance:external-views' => cli_maintenance_external_views($parsed),
|
||||||
'maintenance:all' => cli_maintenance_all($parsed),
|
'maintenance:all' => cli_maintenance_all($parsed),
|
||||||
'db:path' => cli_db_path(),
|
'db:path' => cli_db_path(),
|
||||||
|
'db:doctor' => cli_db_doctor(),
|
||||||
'db:backup' => cli_db_backup($parsed),
|
'db:backup' => cli_db_backup($parsed),
|
||||||
'db:vacuum' => cli_db_vacuum(),
|
'db:vacuum' => cli_db_vacuum(),
|
||||||
default => cli_unknown($command),
|
default => cli_unknown($command),
|
||||||
@@ -157,6 +162,7 @@ Maintenance:
|
|||||||
|
|
||||||
Database:
|
Database:
|
||||||
db:path
|
db:path
|
||||||
|
db:doctor
|
||||||
db:backup [--output=/path/to/media.db]
|
db:backup [--output=/path/to/media.db]
|
||||||
db:vacuum
|
db:vacuum
|
||||||
|
|
||||||
@@ -164,6 +170,46 @@ HELP;
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cli_command_requires_database_write(string $command): bool {
|
||||||
|
return in_array($command, [
|
||||||
|
'settings:set',
|
||||||
|
'admin:password',
|
||||||
|
'password:set',
|
||||||
|
'video:add',
|
||||||
|
'video:update',
|
||||||
|
'video:publish',
|
||||||
|
'video:unpublish',
|
||||||
|
'video:toggle',
|
||||||
|
'video:delete',
|
||||||
|
'source:add',
|
||||||
|
'source:update',
|
||||||
|
'source:delete',
|
||||||
|
'maintenance:catalogue-order',
|
||||||
|
'maintenance:youtube-timestamps',
|
||||||
|
'maintenance:external-views',
|
||||||
|
'maintenance:all',
|
||||||
|
'db:vacuum',
|
||||||
|
], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cli_assert_database_writable(): void {
|
||||||
|
$paths = cli_database_writable_checks();
|
||||||
|
$problems = [];
|
||||||
|
|
||||||
|
foreach ($paths as $check) {
|
||||||
|
if (!$check['ok']) $problems[] = $check['message'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($problems) {
|
||||||
|
$details = implode(PHP_EOL . ' - ', $problems);
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Database is not writable by the current CLI user (" . cli_current_user_label() . ")." .
|
||||||
|
PHP_EOL . " - " . $details .
|
||||||
|
PHP_EOL . "Run db:doctor for details, then make the data directory and media.db writable by this user."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function cli_unknown(string $command): int {
|
function cli_unknown(string $command): int {
|
||||||
fwrite(STDERR, "Unknown command: {$command}" . PHP_EOL . PHP_EOL);
|
fwrite(STDERR, "Unknown command: {$command}" . PHP_EOL . PHP_EOL);
|
||||||
cli_help();
|
cli_help();
|
||||||
@@ -768,6 +814,66 @@ function cli_db_path(): int {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cli_db_doctor(): int {
|
||||||
|
$rows = [
|
||||||
|
['Current user', cli_current_user_label()],
|
||||||
|
['Database path', DB_PATH],
|
||||||
|
['Data directory', dirname(DB_PATH)],
|
||||||
|
['Database exists', cli_yes_no(file_exists(DB_PATH))],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach (cli_database_writable_checks() as $check) {
|
||||||
|
$rows[] = [$check['label'], $check['ok'] ? 'ok' : $check['message']];
|
||||||
|
}
|
||||||
|
|
||||||
|
cli_table(['check', 'result'], $rows);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cli_database_writable_checks(): array {
|
||||||
|
$directory = dirname(DB_PATH);
|
||||||
|
$checks = [];
|
||||||
|
|
||||||
|
$checks[] = [
|
||||||
|
'label' => 'data directory',
|
||||||
|
'ok' => is_dir($directory) && is_writable($directory),
|
||||||
|
'message' => is_dir($directory)
|
||||||
|
? "data directory is not writable: {$directory}"
|
||||||
|
: "data directory does not exist: {$directory}",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (file_exists(DB_PATH)) {
|
||||||
|
$checks[] = [
|
||||||
|
'label' => 'media.db',
|
||||||
|
'ok' => is_writable(DB_PATH),
|
||||||
|
'message' => "database file is not writable: " . DB_PATH,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ([DB_PATH . '-wal', DB_PATH . '-shm'] as $path) {
|
||||||
|
if (!file_exists($path)) continue;
|
||||||
|
$checks[] = [
|
||||||
|
'label' => basename($path),
|
||||||
|
'ok' => is_writable($path),
|
||||||
|
'message' => "SQLite sidecar file is not writable: {$path}",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $checks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cli_current_user_label(): string {
|
||||||
|
if (function_exists('posix_geteuid') && function_exists('posix_getpwuid')) {
|
||||||
|
$uid = posix_geteuid();
|
||||||
|
$info = posix_getpwuid($uid);
|
||||||
|
$name = is_array($info) && isset($info['name']) ? (string)$info['name'] : '';
|
||||||
|
return $name !== '' ? "{$name} (uid {$uid})" : "uid {$uid}";
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = get_current_user();
|
||||||
|
return $user !== '' ? $user : 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
function cli_db_backup(array $parsed): int {
|
function cli_db_backup(array $parsed): int {
|
||||||
cli_db()->exec('PRAGMA wal_checkpoint(FULL)');
|
cli_db()->exec('PRAGMA wal_checkpoint(FULL)');
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -35,7 +35,7 @@ foreach ($player_sources as $player_source) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$meta_title = $video ? $video['title'] . ' — TyClifford.com' : 'Video Player';
|
$meta_title = $video ? $video['title'] . ' — TyClifford.com' : 'Video Player';
|
||||||
$meta_description = $video && trim($video['description'] ?? '') !== '' ? trim($video['description']) : 'TyClifford.com media player';
|
$meta_description = $video && trim($video['description'] ?? '') !== '' ? media_description_plain_text($video['description']) : 'TyClifford.com media player';
|
||||||
$meta_url = $video
|
$meta_url = $video
|
||||||
? public_absolute_url('embed.php?v=' . rawurlencode($video['slug']))
|
? public_absolute_url('embed.php?v=' . rawurlencode($video['slug']))
|
||||||
: public_absolute_url('embed.php');
|
: public_absolute_url('embed.php');
|
||||||
|
|||||||
+420
-4
@@ -595,6 +595,420 @@ function media_pick_longest_text(string $current, string $candidate): string {
|
|||||||
return strlen($candidate) > strlen($current) ? $candidate : $current;
|
return strlen($candidate) > strlen($current) ? $candidate : $current;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function media_description_contains_html(string $value): bool {
|
||||||
|
return (bool)preg_match('/<\/?[a-z][a-z0-9:-]*(?:\s[^>]*)?>/i', $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function media_pick_longest_description(string $current, string $candidate): string {
|
||||||
|
$candidate = trim($candidate);
|
||||||
|
if ($candidate === '') return $current;
|
||||||
|
$current_length = strlen(media_description_plain_text($current));
|
||||||
|
$candidate_length = strlen(media_description_plain_text($candidate));
|
||||||
|
return $candidate_length > $current_length ? $candidate : $current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function media_prepare_description_for_storage(string $value): string {
|
||||||
|
$value = trim($value);
|
||||||
|
if ($value === '') return '';
|
||||||
|
return media_description_contains_html($value) ? media_sanitize_html($value) : $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function media_description_plain_text(string $value): string {
|
||||||
|
$value = trim($value);
|
||||||
|
if ($value === '') return '';
|
||||||
|
$html = media_description_html($value);
|
||||||
|
$text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
|
$text = preg_replace('/[ \t]+/', ' ', $text);
|
||||||
|
$text = preg_replace('/\s*\n\s*/', ' ', (string)$text);
|
||||||
|
return trim((string)$text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function media_description_html(string $value): string {
|
||||||
|
$value = trim($value);
|
||||||
|
if ($value === '') return '';
|
||||||
|
$html = media_description_contains_html($value)
|
||||||
|
? $value
|
||||||
|
: media_markdown_to_html($value);
|
||||||
|
return media_sanitize_html($html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function media_markdown_to_html(string $markdown): string {
|
||||||
|
$markdown = str_replace(["\r\n", "\r"], "\n", trim($markdown));
|
||||||
|
if ($markdown === '') return '';
|
||||||
|
|
||||||
|
$lines = explode("\n", $markdown);
|
||||||
|
$html = [];
|
||||||
|
$paragraph = [];
|
||||||
|
$blockquote = [];
|
||||||
|
$list_type = '';
|
||||||
|
$in_code = false;
|
||||||
|
$code_lines = [];
|
||||||
|
|
||||||
|
$flush_paragraph = function () use (&$html, &$paragraph): void {
|
||||||
|
if (!$paragraph) return;
|
||||||
|
$parts = array_map('media_markdown_inline', $paragraph);
|
||||||
|
$html[] = '<p>' . implode('<br>', $parts) . '</p>';
|
||||||
|
$paragraph = [];
|
||||||
|
};
|
||||||
|
$flush_blockquote = function () use (&$html, &$blockquote): void {
|
||||||
|
if (!$blockquote) return;
|
||||||
|
$parts = array_map('media_markdown_inline', $blockquote);
|
||||||
|
$html[] = '<blockquote><p>' . implode('<br>', $parts) . '</p></blockquote>';
|
||||||
|
$blockquote = [];
|
||||||
|
};
|
||||||
|
$close_list = function () use (&$html, &$list_type): void {
|
||||||
|
if ($list_type === '') return;
|
||||||
|
$html[] = '</' . $list_type . '>';
|
||||||
|
$list_type = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
if (preg_match('/^\s*```/', $line)) {
|
||||||
|
if ($in_code) {
|
||||||
|
$html[] = '<pre><code>' . h(implode("\n", $code_lines)) . '</code></pre>';
|
||||||
|
$code_lines = [];
|
||||||
|
$in_code = false;
|
||||||
|
} else {
|
||||||
|
$flush_paragraph();
|
||||||
|
$flush_blockquote();
|
||||||
|
$close_list();
|
||||||
|
$in_code = true;
|
||||||
|
$code_lines = [];
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($in_code) {
|
||||||
|
$code_lines[] = $line;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trim($line) === '') {
|
||||||
|
$flush_paragraph();
|
||||||
|
$flush_blockquote();
|
||||||
|
$close_list();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^(#{1,6})\s+(.+)$/', $line, $match)) {
|
||||||
|
$flush_paragraph();
|
||||||
|
$flush_blockquote();
|
||||||
|
$close_list();
|
||||||
|
$level = strlen($match[1]);
|
||||||
|
$html[] = '<h' . $level . '>' . media_markdown_inline($match[2]) . '</h' . $level . '>';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^>\s?(.*)$/', $line, $match)) {
|
||||||
|
$flush_paragraph();
|
||||||
|
$close_list();
|
||||||
|
$blockquote[] = $match[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^\s*[-*+]\s+(.+)$/', $line, $match)) {
|
||||||
|
$flush_paragraph();
|
||||||
|
$flush_blockquote();
|
||||||
|
if ($list_type !== 'ul') {
|
||||||
|
$close_list();
|
||||||
|
$html[] = '<ul>';
|
||||||
|
$list_type = 'ul';
|
||||||
|
}
|
||||||
|
$html[] = '<li>' . media_markdown_inline($match[1]) . '</li>';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^\s*\d+[.)]\s+(.+)$/', $line, $match)) {
|
||||||
|
$flush_paragraph();
|
||||||
|
$flush_blockquote();
|
||||||
|
if ($list_type !== 'ol') {
|
||||||
|
$close_list();
|
||||||
|
$html[] = '<ol>';
|
||||||
|
$list_type = 'ol';
|
||||||
|
}
|
||||||
|
$html[] = '<li>' . media_markdown_inline($match[1]) . '</li>';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$close_list();
|
||||||
|
$flush_blockquote();
|
||||||
|
$paragraph[] = $line;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($in_code) $html[] = '<pre><code>' . h(implode("\n", $code_lines)) . '</code></pre>';
|
||||||
|
$flush_paragraph();
|
||||||
|
$flush_blockquote();
|
||||||
|
$close_list();
|
||||||
|
|
||||||
|
return implode("\n", $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function media_markdown_inline(string $text): string {
|
||||||
|
$tokens = [];
|
||||||
|
$text = preg_replace_callback('/`([^`\n]+)`/', function ($match) use (&$tokens): string {
|
||||||
|
$token = '@@MDTOKEN' . count($tokens) . '@@';
|
||||||
|
$tokens[$token] = '<code>' . h($match[1]) . '</code>';
|
||||||
|
return $token;
|
||||||
|
}, $text);
|
||||||
|
|
||||||
|
$html = h((string)$text);
|
||||||
|
|
||||||
|
$html = preg_replace_callback('/\[([^\]\n]+)\]\(([^)\s]+)(?:\s+"[^&]*")?\)/', function ($match): string {
|
||||||
|
$url = html_entity_decode($match[2], ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
|
return media_description_link_html($url, $match[1]);
|
||||||
|
}, $html);
|
||||||
|
|
||||||
|
$html = preg_replace('/\*\*([^*\n]+)\*\*/', '<strong>$1</strong>', (string)$html);
|
||||||
|
$html = preg_replace('/__([^_\n]+)__/', '<strong>$1</strong>', (string)$html);
|
||||||
|
$html = preg_replace('/(?<!\*)\*([^*\n]+)\*(?!\*)/', '<em>$1</em>', (string)$html);
|
||||||
|
$html = preg_replace('/(?<!_)_([^_\n]+)_(?!_)/', '<em>$1</em>', (string)$html);
|
||||||
|
$html = preg_replace('/~~([^~\n]+)~~/', '<s>$1</s>', (string)$html);
|
||||||
|
|
||||||
|
foreach ($tokens as $token => $replacement) {
|
||||||
|
$html = str_replace($token, $replacement, (string)$html);
|
||||||
|
}
|
||||||
|
|
||||||
|
return media_autolink_html_text((string)$html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function media_autolink_html_text(string $html): string {
|
||||||
|
$parts = preg_split('/(<a\b[^>]*>.*?<\/a>|<code\b[^>]*>.*?<\/code>|<[^>]+>)/is', $html, -1, PREG_SPLIT_DELIM_CAPTURE);
|
||||||
|
if (!is_array($parts)) return $html;
|
||||||
|
|
||||||
|
foreach ($parts as $i => $part) {
|
||||||
|
if ($part === '' || str_starts_with($part, '<')) continue;
|
||||||
|
$parts[$i] = preg_replace_callback('~https?://[^\s<]+~i', function ($match): string {
|
||||||
|
$url = $match[0];
|
||||||
|
$trail = '';
|
||||||
|
while ($url !== '' && preg_match('/[.,;:!?)]$/', $url)) {
|
||||||
|
$trail = substr($url, -1) . $trail;
|
||||||
|
$url = substr($url, 0, -1);
|
||||||
|
}
|
||||||
|
return media_description_link_html($url, h($url)) . $trail;
|
||||||
|
}, $part);
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode('', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function media_description_link_html(string $url, string $label_html): string {
|
||||||
|
$href = media_clean_url_attribute($url, false);
|
||||||
|
if ($href === '') return $label_html;
|
||||||
|
return '<a href="' . h($href) . '" target="_blank" rel="noopener noreferrer">' . $label_html . '</a>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function media_clean_url_attribute(string $url, bool $for_src): string {
|
||||||
|
$url = trim(html_entity_decode($url, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||||
|
$url = preg_replace('/[\x00-\x1F\x7F\s]+/', '', $url);
|
||||||
|
$url = (string)$url;
|
||||||
|
if ($url === '') return '';
|
||||||
|
|
||||||
|
if (str_starts_with($url, '//')) return 'https:' . $url;
|
||||||
|
if (str_starts_with($url, '#')) return $for_src ? '' : $url;
|
||||||
|
if (str_starts_with($url, '/')) return $url;
|
||||||
|
|
||||||
|
if (preg_match('/^[a-z][a-z0-9+.-]*:/i', $url, $match)) {
|
||||||
|
$scheme = strtolower(rtrim($match[0], ':'));
|
||||||
|
$allowed = $for_src ? ['http', 'https'] : ['http', 'https', 'mailto', 'tel'];
|
||||||
|
return in_array($scheme, $allowed, true) ? $url : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return preg_match('/^[^<>"\']+$/', $url) ? $url : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function media_clean_style_attribute(string $style): string {
|
||||||
|
$allowed = [
|
||||||
|
'background-color' => true,
|
||||||
|
'color' => true,
|
||||||
|
'font-size' => true,
|
||||||
|
'font-style' => true,
|
||||||
|
'font-weight' => true,
|
||||||
|
'letter-spacing' => true,
|
||||||
|
'line-height' => true,
|
||||||
|
'text-align' => true,
|
||||||
|
'text-decoration' => true,
|
||||||
|
'text-transform' => true,
|
||||||
|
];
|
||||||
|
$clean = [];
|
||||||
|
|
||||||
|
foreach (explode(';', $style) as $rule) {
|
||||||
|
if (!str_contains($rule, ':')) continue;
|
||||||
|
[$property, $value] = array_map('trim', explode(':', $rule, 2));
|
||||||
|
$property = strtolower($property);
|
||||||
|
if (!isset($allowed[$property]) || $value === '') continue;
|
||||||
|
if (preg_match('/url\s*\(|expression\s*\(|javascript:|data:|@|[<>\\\\]/i', $value)) continue;
|
||||||
|
if (strlen($value) > 160) continue;
|
||||||
|
$clean[] = $property . ': ' . $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode('; ', $clean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function media_description_allowed_tags(): array {
|
||||||
|
return [
|
||||||
|
'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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function media_description_drop_tags(): array {
|
||||||
|
return [
|
||||||
|
'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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function media_description_allowed_attrs(string $tag): array {
|
||||||
|
$attrs = ['title' => true, 'style' => true];
|
||||||
|
if ($tag === 'a') {
|
||||||
|
$attrs['href'] = true;
|
||||||
|
$attrs['rel'] = true;
|
||||||
|
$attrs['target'] = true;
|
||||||
|
}
|
||||||
|
if ($tag === 'img') {
|
||||||
|
$attrs['src'] = true;
|
||||||
|
$attrs['alt'] = true;
|
||||||
|
$attrs['width'] = true;
|
||||||
|
$attrs['height'] = true;
|
||||||
|
$attrs['loading'] = true;
|
||||||
|
$attrs['decoding'] = true;
|
||||||
|
}
|
||||||
|
if ($tag === 'ol') $attrs['start'] = true;
|
||||||
|
return $attrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function media_sanitize_html(string $html): string {
|
||||||
|
$html = trim($html);
|
||||||
|
if ($html === '') return '';
|
||||||
|
if (!class_exists('DOMDocument')) {
|
||||||
|
return nl2br(h(html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8')));
|
||||||
|
}
|
||||||
|
|
||||||
|
$doc = new DOMDocument('1.0', 'UTF-8');
|
||||||
|
$previous = function_exists('libxml_use_internal_errors') ? libxml_use_internal_errors(true) : null;
|
||||||
|
$options = 0;
|
||||||
|
if (defined('LIBXML_HTML_NOIMPLIED')) $options |= LIBXML_HTML_NOIMPLIED;
|
||||||
|
if (defined('LIBXML_HTML_NODEFDTD')) $options |= LIBXML_HTML_NODEFDTD;
|
||||||
|
|
||||||
|
$loaded = $doc->loadHTML(
|
||||||
|
'<?xml encoding="UTF-8"><div id="media-description-root">' . $html . '</div>',
|
||||||
|
$options
|
||||||
|
);
|
||||||
|
if (function_exists('libxml_clear_errors')) libxml_clear_errors();
|
||||||
|
if ($previous !== null && function_exists('libxml_use_internal_errors')) libxml_use_internal_errors($previous);
|
||||||
|
if (!$loaded) return '';
|
||||||
|
|
||||||
|
$root = $doc->getElementById('media-description-root');
|
||||||
|
if (!$root) return '';
|
||||||
|
|
||||||
|
media_sanitize_dom_node($root, $doc);
|
||||||
|
|
||||||
|
$out = '';
|
||||||
|
foreach ($root->childNodes as $child) {
|
||||||
|
$out .= $doc->saveHTML($child);
|
||||||
|
}
|
||||||
|
return trim($out);
|
||||||
|
}
|
||||||
|
|
||||||
|
function media_sanitize_dom_node($node, $doc): void {
|
||||||
|
$allowed_tags = media_description_allowed_tags();
|
||||||
|
$drop_tags = media_description_drop_tags();
|
||||||
|
|
||||||
|
for ($child = $node->firstChild; $child !== null; $child = $next) {
|
||||||
|
$next = $child->nextSibling;
|
||||||
|
|
||||||
|
if ($child->nodeType === 8) {
|
||||||
|
$node->removeChild($child);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($child->nodeType !== 1) continue;
|
||||||
|
|
||||||
|
$tag = strtolower($child->nodeName);
|
||||||
|
if (isset($drop_tags[$tag])) {
|
||||||
|
$node->removeChild($child);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
media_sanitize_dom_node($child, $doc);
|
||||||
|
|
||||||
|
if (!isset($allowed_tags[$tag])) {
|
||||||
|
while ($child->firstChild) {
|
||||||
|
$node->insertBefore($child->firstChild, $child);
|
||||||
|
}
|
||||||
|
$node->removeChild($child);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
media_sanitize_dom_attributes($child, $tag);
|
||||||
|
if ($tag === 'img' && !$child->hasAttribute('src')) {
|
||||||
|
$node->removeChild($child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function media_sanitize_dom_attributes($element, string $tag): void {
|
||||||
|
$allowed = media_description_allowed_attrs($tag);
|
||||||
|
$attrs = [];
|
||||||
|
if ($element->hasAttributes()) {
|
||||||
|
foreach ($element->attributes as $attr) $attrs[] = $attr;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($attrs as $attr) {
|
||||||
|
$name = strtolower($attr->nodeName);
|
||||||
|
$value = (string)$attr->nodeValue;
|
||||||
|
if (str_starts_with($name, 'on') || !isset($allowed[$name])) {
|
||||||
|
$element->removeAttribute($attr->nodeName);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($name === 'style') {
|
||||||
|
$clean = media_clean_style_attribute($value);
|
||||||
|
if ($clean === '') $element->removeAttribute($attr->nodeName);
|
||||||
|
else $element->setAttribute('style', $clean);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($name === 'href' || $name === 'src') {
|
||||||
|
$clean = media_clean_url_attribute($value, $name === 'src');
|
||||||
|
if ($clean === '') $element->removeAttribute($attr->nodeName);
|
||||||
|
else $element->setAttribute($name, $clean);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($name, ['width', 'height', 'start'], true)) {
|
||||||
|
$number = max(0, min(4000, (int)$value));
|
||||||
|
if ($number <= 0) $element->removeAttribute($attr->nodeName);
|
||||||
|
else $element->setAttribute($name, (string)$number);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tag === 'a') {
|
||||||
|
if ($element->hasAttribute('href')) {
|
||||||
|
$element->setAttribute('target', '_blank');
|
||||||
|
$element->setAttribute('rel', 'noopener noreferrer');
|
||||||
|
} else {
|
||||||
|
$element->removeAttribute('target');
|
||||||
|
$element->removeAttribute('rel');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tag === 'img') {
|
||||||
|
if (!$element->hasAttribute('src')) return;
|
||||||
|
$element->setAttribute('loading', 'lazy');
|
||||||
|
$element->setAttribute('decoding', 'async');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function media_view_count_from_html(string $html): int {
|
function media_view_count_from_html(string $html): int {
|
||||||
$html = html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
$html = html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
$variants = [$html, stripcslashes($html)];
|
$variants = [$html, stripcslashes($html)];
|
||||||
@@ -984,14 +1398,16 @@ function media_apply_metadata_value(array &$metadata, string $key, $value): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$text = trim(html_entity_decode(strip_tags((string)$value), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
|
||||||
if ($text === '') return;
|
|
||||||
|
|
||||||
if ($key === 'description') {
|
if ($key === 'description') {
|
||||||
$metadata['description'] = media_pick_longest_text((string)$metadata['description'], $text);
|
$description = media_prepare_description_for_storage((string)$value);
|
||||||
|
if ($description === '') return;
|
||||||
|
$metadata['description'] = media_pick_longest_description((string)$metadata['description'], $description);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$text = trim(html_entity_decode(strip_tags((string)$value), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||||
|
if ($text === '') return;
|
||||||
|
|
||||||
if ((string)$metadata[$key] === '') $metadata[$key] = $text;
|
if ((string)$metadata[$key] === '') $metadata[$key] = $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -228,6 +228,43 @@ function render_head(string $title = 'Media — TyClifford.com', string $extra_c
|
|||||||
}
|
}
|
||||||
.form-textarea { resize:vertical; min-height:80px; }
|
.form-textarea { resize:vertical; min-height:80px; }
|
||||||
.form-select option { background:var(--surface-2); }
|
.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/alert ── */
|
||||||
.notice {
|
.notice {
|
||||||
@@ -290,6 +327,204 @@ function render_footer(): void { ?>
|
|||||||
</footer>
|
</footer>
|
||||||
<?php }
|
<?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 {
|
function render_pagination(int $current, int $total, string $url_pattern): void {
|
||||||
if ($total <= 1) return;
|
if ($total <= 1) return;
|
||||||
echo '<div class="pagination">';
|
echo '<div class="pagination">';
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ if ($home_live_first) {
|
|||||||
} else {
|
} else {
|
||||||
$share_title = $video ? $video['title'] . ' — ' . $site_title : 'Media — ' . $site_title;
|
$share_title = $video ? $video['title'] . ' — ' . $site_title : 'Media — ' . $site_title;
|
||||||
$share_description = $video && trim($video['description'] ?? '') !== ''
|
$share_description = $video && trim($video['description'] ?? '') !== ''
|
||||||
? trim($video['description'])
|
? media_description_plain_text($video['description'])
|
||||||
: setting('site_sub', 'tyclifford.com / media');
|
: setting('site_sub', 'tyclifford.com / media');
|
||||||
$share_url = $video ? public_absolute_url(public_video_url($video['slug'])) : public_absolute_url(public_home_url());
|
$share_url = $video ? public_absolute_url(public_video_url($video['slug'])) : public_absolute_url(public_home_url());
|
||||||
$share_image = ($video && !empty($video['thumbnail'])) ? public_absolute_url(public_media_url($video['thumbnail'])) : '';
|
$share_image = ($video && !empty($video['thumbnail'])) ? public_absolute_url(public_media_url($video['thumbnail'])) : '';
|
||||||
@@ -212,6 +212,10 @@ render_head($share_title, '
|
|||||||
.home-mode-panel[hidden] { display:none; }
|
.home-mode-panel[hidden] { display:none; }
|
||||||
.live-footer { justify-content:space-between; }
|
.live-footer { justify-content:space-between; }
|
||||||
.live-footer .stream-meta { max-width:60ch; line-height:1.5; letter-spacing:.06em; }
|
.live-footer .stream-meta { max-width:60ch; line-height:1.5; letter-spacing:.06em; }
|
||||||
|
.stream-description {
|
||||||
|
flex:1 1 100%; max-width:min(72ch, 100%);
|
||||||
|
font-size:.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Lower grid ── */
|
/* ── Lower grid ── */
|
||||||
.lower-grid { display:grid; grid-template-columns:1fr; gap:1rem; }
|
.lower-grid { display:grid; grid-template-columns:1fr; gap:1rem; }
|
||||||
@@ -370,7 +374,7 @@ render_head($share_title, '
|
|||||||
<div class="stream-footer">
|
<div class="stream-footer">
|
||||||
<p class="eyebrow eyebrow-green"><?= $video ? h($video['title']) : 'Media Player' ?></p>
|
<p class="eyebrow eyebrow-green"><?= $video ? h($video['title']) : 'Media Player' ?></p>
|
||||||
<?php if ($video && $video['description']): ?>
|
<?php if ($video && $video['description']): ?>
|
||||||
<p class="stream-meta" style="font-family:Inter,sans-serif;font-size:.78rem;color:var(--text-2);letter-spacing:0;max-width:60ch;line-height:1.5"><?= h($video['description']) ?></p>
|
<div class="stream-description media-description"><?= media_description_html($video['description']) ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php
|
<?php
|
||||||
$stream_meta = [];
|
$stream_meta = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user