Files
Ty Clifford 6a1c545f35 - 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.
2026-07-01 15:41:01 -04:00

521 lines
26 KiB
PHP

<?php
require_once __DIR__ . '/auth.php';
require_once __DIR__ . '/../includes/layout.php';
admin_require_auth();
$db = get_db();
$error = '';
$msg = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$title = trim($_POST['title'] ?? '');
$description = media_prepare_description_for_storage($_POST['description'] ?? '');
$duration = (int)($_POST['duration'] ?? 0);
$sort_order = (int)($_POST['sort_order'] ?? 0);
$published = isset($_POST['published']) ? 1 : 0;
$auto_thumb = isset($_POST['auto_thumbnail']);
$source_types = $_POST['source_type'] ?? [];
$source_labels = $_POST['source_label'] ?? [];
$source_quality = $_POST['source_quality'] ?? [];
$source_order = $_POST['source_sort'] ?? [];
$source_urls = $_POST['source_url'] ?? [];
$source_files = $_FILES['source_file'] ?? [];
$source_metadata = [];
$initial_external_view_count = 0;
$source_count = max(
count($source_types),
count($source_labels),
count($source_urls),
isset($source_files['tmp_name']) && is_array($source_files['tmp_name']) ? count($source_files['tmp_name']) : 0
);
for ($i = 0; $i < $source_count; $i++) {
$type = ($source_types[$i] ?? 'local') === 'remote' ? 'remote' : 'local';
if ($type !== 'remote') continue;
$url = normalize_media_url($source_urls[$i] ?? '');
if ($url === '') continue;
$metadata = media_external_metadata($url);
$source_metadata[$url] = $metadata;
if ($title === '' && $metadata['title'] !== '') $title = $metadata['title'];
if ($description === '' && $metadata['description'] !== '') {
$description = media_prepare_description_for_storage($metadata['description']);
}
if ($duration <= 0 && (int)$metadata['duration'] > 0) $duration = (int)$metadata['duration'];
if ((int)$metadata['view_count'] > $initial_external_view_count) {
$initial_external_view_count = (int)$metadata['view_count'];
}
}
$_POST['title'] = $title;
$_POST['description'] = $description;
$_POST['duration'] = (string)$duration;
if (!$title) {
$error = 'Title is required. If you use an external URL, click Fetch Info or leave the title blank and make sure the URL is readable.';
} else {
$slug = make_slug($title);
// Handle thumbnail upload
$thumbnail = '';
if (!empty($_FILES['thumbnail']['tmp_name'])) {
$ext = strtolower(pathinfo($_FILES['thumbnail']['name'], PATHINFO_EXTENSION));
$allowed = ['jpg','jpeg','png','webp','gif'];
if (in_array($ext, $allowed)) {
$thumb_dir = MEDIA_DIR . 'thumbs/';
if (!is_dir($thumb_dir)) mkdir($thumb_dir, 0755, true);
$fname = $slug . '_thumb_' . time() . '.' . $ext;
if (move_uploaded_file($_FILES['thumbnail']['tmp_name'], $thumb_dir . $fname)) {
$thumbnail = 'thumbs/' . $fname;
}
} else {
$error = 'Thumbnail must be JPG, PNG, WebP, or GIF.';
}
}
if (!$error) {
// Insert video
$db->prepare("
INSERT INTO videos (title, description, slug, thumbnail, duration, external_view_count, sort_order, published)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
")->execute([$title, $description, $slug, $thumbnail, $duration, $initial_external_view_count, $sort_order, $published]);
$video_id = (int)$db->lastInsertId();
// Handle local upload and remote URL sources
$videos_dir = MEDIA_DIR . 'videos/';
if (!is_dir($videos_dir)) mkdir($videos_dir, 0755, true);
$inserted_sources = 0;
$moved_files = [];
$allowed_video = ['mp4','webm','ogv','ogg','mov','mkv','avi','m4a'];
for ($i = 0; $i < $source_count; $i++) {
$type = ($source_types[$i] ?? 'local') === 'remote' ? 'remote' : 'local';
$label = trim($source_labels[$i] ?? '');
$quality = trim($source_quality[$i] ?? '');
$sort = (int)($source_order[$i] ?? $i);
if ($type === 'remote') {
$url = normalize_media_url($source_urls[$i] ?? '');
if ($url === '') {
if (trim($source_urls[$i] ?? '') !== '') $error = 'External video URLs must start with http:// or https://.';
continue;
}
$metadata = $source_metadata[$url] ?? [];
if ($label === '' && !empty($metadata['provider'])) {
$label = ucwords(str_replace('-', ' ', (string)$metadata['provider']));
}
$db->prepare("
INSERT INTO video_sources (video_id, label, source_type, file_path, source_url, mime_type, quality, sort_order)
VALUES (?,?,?,?,?,?,?,?)
")->execute([$video_id, $label, 'remote', '', $url, source_storage_mime_from_url($url), $quality, $sort]);
$inserted_sources++;
continue;
}
$tmp = $source_files['tmp_name'][$i] ?? '';
if (empty($tmp) || !is_uploaded_file($tmp)) continue;
$orig_name = $source_files['name'][$i] ?? '';
$ext = strtolower(pathinfo($orig_name, PATHINFO_EXTENSION));
if (!in_array($ext, $allowed_video, true)) continue;
$fname = $slug . '_' . ($i+1) . '_' . time() . '.' . $ext;
if (move_uploaded_file($tmp, $videos_dir . $fname)) {
$file_path = 'videos/' . $fname;
$moved_files[] = $videos_dir . $fname;
if ($ext === 'm4a') {
$converted_path = media_black_video_from_m4a($file_path, $slug, $i);
if ($converted_path !== '') {
$moved_files[] = MEDIA_DIR . $converted_path;
$converted_label = $label !== '' ? $label . ' (MP4)' : 'Converted MP4';
$db->prepare("
INSERT INTO video_sources (video_id, label, source_type, file_path, source_url, mime_type, quality, sort_order)
VALUES (?,?,?,?,?,?,?,?)
")->execute([$video_id, $converted_label, 'local', $converted_path, '', 'video/mp4', $quality, $sort]);
$inserted_sources++;
}
$original_label = $label !== '' ? $label . ' (M4A)' : 'Original M4A';
$db->prepare("
INSERT INTO video_sources (video_id, label, source_type, file_path, source_url, mime_type, quality, sort_order)
VALUES (?,?,?,?,?,?,?,?)
")->execute([$video_id, $original_label, 'local', $file_path, '', mime_from_ext($fname), '', $converted_path !== '' ? $sort + 1 : $sort]);
$inserted_sources++;
} else {
$db->prepare("
INSERT INTO video_sources (video_id, label, source_type, file_path, source_url, mime_type, quality, sort_order)
VALUES (?,?,?,?,?,?,?,?)
")->execute([$video_id, $label, 'local', $file_path, '', mime_from_ext($fname), $quality, $sort]);
$inserted_sources++;
}
}
}
if ($error) {
foreach ($moved_files as $moved) {
if (file_exists($moved)) unlink($moved);
}
if ($thumbnail && file_exists(MEDIA_DIR . $thumbnail)) unlink(MEDIA_DIR . $thumbnail);
$db->prepare("DELETE FROM videos WHERE id=?")->execute([$video_id]);
} else {
if ($thumbnail === '' && $auto_thumb) {
$thumbnail = media_auto_thumbnail_for_video($video_id, $slug);
if ($thumbnail !== '') {
$db->prepare("UPDATE videos SET thumbnail=? WHERE id=?")->execute([$thumbnail, $video_id]);
}
}
media_apply_youtube_timestamp($video_id);
header('Location: edit.php?id=' . $video_id . '&saved=1');
exit;
}
}
}
}
render_head('Add Video — Admin', '
.form-grid { display:grid; grid-template-columns:1fr; gap:1.2rem; }
@media(min-width:700px) { .form-grid { grid-template-columns:1fr 1fr; } }
.form-grid .span2 { grid-column:1/-1; }
.sources-section { display:flex; flex-direction:column; gap:.85rem; }
.source-row {
background:var(--surface-2); border:1px solid var(--border);
border-radius:var(--r); padding:1rem;
display:grid; grid-template-columns:140px minmax(220px,1.5fr) 1fr 1fr 80px auto;
gap:.75rem; align-items:end;
}
@media(max-width:900px) { .source-row { grid-template-columns:1fr 1fr; } }
@media(max-width:560px) { .source-row { grid-template-columns:1fr; } }
.add-source-btn { align-self:flex-start; }
.source-location-panel[hidden] { display:none; }
.source-url-control { display:flex; gap:.45rem; align-items:center; }
.source-url-control input { min-width:0; }
.metadata-status { font-family:"Share Tech Mono",monospace; font-size:.6rem; color:var(--text-3); min-height:1em; }
.metadata-status.success { color:var(--green); }
.metadata-status.error { color:var(--red); }
.section-divider {
border:none; border-top:1px solid var(--border); margin:1.5rem 0 .5rem;
}
.section-heading {
font-family:"Share Tech Mono",monospace; font-size:.65rem;
letter-spacing:.15em; text-transform:uppercase; color:var(--text-3);
margin-bottom:.75rem;
}
.hint { font-size:.72rem; color:var(--text-3); margin-top:.2rem; }
.admin-layout { display:grid; grid-template-columns:1fr; gap:1.5rem; }
@media(min-width:900px) { .admin-layout { grid-template-columns:220px 1fr; } }
.admin-nav {
background:var(--surface); border:1px solid var(--border);
border-radius:calc(var(--r)+2px); padding:1.2rem;
display:flex; flex-direction:column; gap:.4rem;
align-self:start; position:sticky; top:1rem;
}
.admin-nav-label { font-family:"Share Tech Mono",monospace; font-size:.58rem; letter-spacing:.18em; text-transform:uppercase; color:var(--text-3); margin-bottom:.3rem; padding-left:.5rem; }
.admin-nav a { display:flex; align-items:center; gap:.6rem; padding:.55rem .75rem; border-radius:var(--r); font-size:.82rem; color:var(--text-2); text-decoration:none; transition:background .15s,color .15s; }
.admin-nav a:hover { background:rgba(255,255,255,.05); color:var(--text); }
.admin-nav a.active { background:rgba(176,96,255,.12); color:var(--purple); }
.nav-divider { border:none; border-top:1px solid var(--border); margin:.4rem 0; }
');
?>
<main class="page" role="main">
<header class="topbar">
<div class="topbar-brand">
<div>
<div class="brand-name">Ty Clifford</div>
<div class="brand-sub">admin / add video</div>
</div>
</div>
<nav class="topbar-social">
<a class="social-pill" href="<?= h(public_absolute_url(public_home_url())) ?>" target="_blank">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15,3 21,3 21,9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
View Site
</a>
<a class="social-pill" href="logout.php">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16,17 21,12 16,7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
Logout
</a>
</nav>
</header>
<?php if ($error): ?>
<div class="notice notice-error"><?= h($error) ?></div>
<?php endif; ?>
<div class="admin-layout">
<aside class="admin-nav">
<div class="admin-nav-label">Navigation</div>
<a href="index.php">
<svg width="14" height="14" 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>
<a href="add.php" class="active">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
Add Video
</a>
<a href="youtube_import.php">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22.54 6.42a2.78 2.78 0 00-1.95-2C18.88 4 12 4 12 4s-6.88 0-8.59.46a2.78 2.78 0 00-1.95 2A29 29 0 001 12a29 29 0 00.46 5.58 2.78 2.78 0 001.95 2C5.12 20 12 20 12 20s6.88 0 8.59-.46a2.78 2.78 0 001.95-2A29 29 0 0023 12a29 29 0 00-.46-5.58z"/><polygon points="10,15 15,12 10,9"/></svg>
YouTube Importer
</a>
<hr class="nav-divider">
<a href="settings.php">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M4.93 4.93l1.41 1.41M19.07 19.07l-1.41-1.41M4.93 19.07l1.41-1.41M12 2v2M12 20v2M2 12h2M20 12h2"/></svg>
Settings
</a>
</aside>
<div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.25rem;flex-wrap:wrap;gap:.75rem;">
<div>
<p class="eyebrow eyebrow-purple" style="margin-bottom:.3rem">New Entry</p>
<h1 style="font-size:1.05rem;font-weight:700;color:var(--text);">Add Video</h1>
</div>
<a href="index.php" class="btn btn-secondary btn-sm">← Back</a>
</div>
<form method="post" action="add.php" enctype="multipart/form-data">
<!-- ── Video details ── -->
<div class="card" style="margin-bottom:1rem;">
<p class="section-heading">Video Details</p>
<div class="form-grid">
<div class="form-group span2">
<label class="form-label" for="title">Title</label>
<input class="form-input" type="text" id="title" name="title"
value="<?= h($_POST['title'] ?? '') ?>" placeholder="Auto-filled from external URL when available">
<span class="hint">Leave blank when pasting a supported external URL, then use Fetch Info.</span>
</div>
<div class="form-group span2">
<label class="form-label" id="description-label" for="description">Description</label>
<?php render_description_editor($_POST['description'] ?? ''); ?>
</div>
<div class="form-group">
<label class="form-label" for="duration">Duration (seconds)</label>
<input class="form-input" type="number" id="duration" name="duration"
value="<?= h($_POST['duration'] ?? '0') ?>" min="0" placeholder="0">
<span class="hint">e.g. 3600 = 1 hour. Leave 0 if unknown.</span>
</div>
<div class="form-group">
<label class="form-label" for="sort_order">Sort Order</label>
<input class="form-input" type="number" id="sort_order" name="sort_order"
value="<?= h($_POST['sort_order'] ?? '0') ?>" min="0">
<span class="hint">Lower numbers appear first.</span>
</div>
<div class="form-group">
<label class="form-label" for="thumbnail">Thumbnail Image</label>
<input class="form-input" type="file" id="thumbnail" name="thumbnail"
accept="image/jpeg,image/png,image/webp,image/gif" style="padding:.45rem .6rem;">
<label style="display:flex;align-items:center;gap:.55rem;margin-top:.55rem;cursor:pointer;">
<input type="checkbox" name="auto_thumbnail" value="1" <?= isset($_POST['auto_thumbnail']) || $_SERVER['REQUEST_METHOD'] !== 'POST' ? 'checked' : '' ?>>
<span style="font-size:.78rem;color:var(--text-2);">Auto-grab thumbnail from source if no image is uploaded</span>
</label>
<span class="hint">JPG, PNG, WebP or GIF. Auto-grab supports YouTube, Vimeo, Dailymotion and other oEmbed providers; local uploads use ffmpeg when available.</span>
</div>
<div class="form-group" style="justify-content:flex-end;padding-bottom:.2rem;">
<label style="display:flex;align-items:center;gap:.6rem;cursor:pointer;">
<input type="checkbox" name="published" value="1" <?= isset($_POST['published']) || !isset($_POST['title']) ? 'checked' : '' ?>>
<span class="form-label" style="margin:0">Published</span>
</label>
<span class="hint">Uncheck to save as draft.</span>
</div>
</div>
</div>
<!-- ── Video sources ── -->
<div class="card">
<p class="section-heading">Video Sources</p>
<p class="hint" style="margin-bottom:1rem;">
Upload a video file, link a direct media file, or paste a provider page URL such as YouTube or Vimeo. The player will use a provider embed when the URL supports it.
Uploaded files: <strong>MP4, WebM, OGV, MOV, MKV, AVI, M4A</strong>. M4A uploads keep the original source and add a black-frame H.264 MP4 source when ffmpeg is available. Remote URLs must use <strong>http://</strong> or <strong>https://</strong>.
</p>
<div class="sources-section" id="sources-container">
<!-- Row template (JS clones this) -->
<div class="source-row" data-source-row="0">
<div class="form-group">
<label class="form-label">Source</label>
<select class="form-select source-type-select" name="source_type[]">
<option value="local" selected>Upload</option>
<option value="remote">External URL / Embed</option>
</select>
</div>
<div class="form-group source-location-panel source-location-local">
<label class="form-label">Video File</label>
<input class="form-input" type="file" name="source_file[]"
accept="video/mp4,video/webm,video/ogg,audio/mp4,.mp4,.webm,.ogv,.mov,.mkv,.avi,.m4a"
style="padding:.45rem .6rem;">
</div>
<div class="form-group source-location-panel source-location-remote" hidden>
<label class="form-label">External URL</label>
<div class="source-url-control">
<input class="form-input source-url-input" type="url" name="source_url[]" placeholder="https://www.youtube.com/watch?v=...">
<button type="button" class="btn btn-secondary btn-sm fetch-info-btn">Fetch Info</button>
</div>
<span class="metadata-status" aria-live="polite"></span>
</div>
<div class="form-group">
<label class="form-label">Quality Label</label>
<select class="form-select" name="source_quality[]">
<option value="1080p">1080p</option>
<option value="720p" selected>720p</option>
<option value="480p">480p</option>
<option value="360p">360p</option>
<option value="4K">4K</option>
<option value="">—</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Display Label</label>
<input class="form-input" type="text" name="source_label[]" placeholder="e.g. HD">
</div>
<div class="form-group">
<label class="form-label">Sort</label>
<input class="form-input" type="number" name="source_sort[]" value="0" min="0" style="width:70px;">
</div>
</div>
</div>
<button type="button" class="btn btn-secondary btn-sm add-source-btn" id="add-source" style="margin-top:.75rem;align-self:flex-start;">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add Another Source
</button>
</div>
<div style="display:flex;gap:.75rem;margin-top:1rem;flex-wrap:wrap;">
<button class="btn btn-primary" type="submit">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17,21 17,13 7,13 7,21"/><polyline points="7,3 7,8 15,8"/></svg>
Save Video
</button>
<a href="index.php" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
<?php render_footer(); ?>
</main>
<?php render_description_editor_script(); ?>
<script>
function toggleSourceRow(row) {
var select = row.querySelector('.source-type-select');
var isRemote = select && select.value === 'remote';
row.querySelectorAll('.source-location-local').forEach(function(el){ el.hidden = isRemote; });
row.querySelectorAll('.source-location-remote').forEach(function(el){ el.hidden = !isRemote; });
}
function providerLabel(value) {
if (!value) return '';
return value.replace(/-/g, ' ').replace(/\b\w/g, function(ch){ return ch.toUpperCase(); });
}
function setMetadataStatus(row, text, type) {
var status = row.querySelector('.metadata-status');
if (!status) return;
status.textContent = text || '';
status.classList.remove('success', 'error');
if (type) status.classList.add(type);
}
function fillFromMetadata(row, metadata, force) {
if (!metadata) return;
var title = document.getElementById('title');
var description = document.getElementById('description');
var duration = document.getElementById('duration');
var label = row.querySelector('input[name="source_label[]"]');
if (metadata.title && title && (force || !title.value.trim())) title.value = metadata.title;
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 (label && !label.value.trim()) label.value = providerLabel(metadata.provider) || metadata.title || 'External';
}
function fetchInfoForRow(row, force) {
var input = row.querySelector('.source-url-input');
var url = input ? input.value.trim() : '';
if (!url) return;
setMetadataStatus(row, 'Fetching media info...', '');
fetch('media_info.php?url=' + encodeURIComponent(url), { credentials: 'same-origin' })
.then(function(response) {
if (!response.ok) throw new Error('Request failed');
return response.json();
})
.then(function(data) {
if (!data.ok) {
setMetadataStatus(row, data.error || 'No metadata found.', 'error');
return;
}
fillFromMetadata(row, data.metadata, force);
var bits = [];
if (data.metadata.provider) bits.push(providerLabel(data.metadata.provider));
if (data.metadata.view_count) bits.push(Number(data.metadata.view_count).toLocaleString() + ' external views');
setMetadataStatus(row, bits.length ? bits.join(' · ') : 'Media info loaded.', 'success');
})
.catch(function() {
setMetadataStatus(row, 'Unable to fetch media info.', 'error');
});
}
document.querySelectorAll('[data-source-row]').forEach(function(row) {
toggleSourceRow(row);
var select = row.querySelector('.source-type-select');
if (select) select.addEventListener('change', function(){ toggleSourceRow(row); });
});
document.getElementById('sources-container').addEventListener('click', function(event) {
var btn = event.target.closest('.fetch-info-btn');
if (!btn) return;
fetchInfoForRow(btn.closest('[data-source-row]'), true);
});
document.getElementById('sources-container').addEventListener('blur', function(event) {
if (!event.target.classList.contains('source-url-input')) return;
var title = document.getElementById('title');
if (title && title.value.trim()) return;
fetchInfoForRow(event.target.closest('[data-source-row]'), false);
}, true);
document.getElementById('add-source').addEventListener('click', function() {
var container = document.getElementById('sources-container');
var rows = container.querySelectorAll('[data-source-row]');
var idx = rows.length;
var first = rows[0].cloneNode(true);
first.setAttribute('data-source-row', idx);
// Clear file inputs (can't clone values for file inputs)
first.querySelectorAll('input[type="file"]').forEach(function(el){ el.value=''; });
first.querySelectorAll('input[type="text"]').forEach(function(el){ el.value=''; });
first.querySelectorAll('input[type="url"]').forEach(function(el){ el.value=''; });
first.querySelectorAll('input[type="number"]').forEach(function(el){ el.value=idx; });
first.querySelectorAll('.metadata-status').forEach(function(el){ el.textContent=''; el.classList.remove('success','error'); });
first.querySelectorAll('.source-type-select').forEach(function(el){ el.value='local'; });
// Add remove button if not already there
if (!first.querySelector('.remove-row')) {
var btn = document.createElement('button');
btn.type='button';
btn.className='btn btn-danger btn-sm remove-row';
btn.style='align-self:flex-end';
btn.innerHTML='✕';
btn.onclick=function(){ this.closest('[data-source-row]').remove(); };
first.appendChild(btn);
}
toggleSourceRow(first);
var select = first.querySelector('.source-type-select');
if (select) select.addEventListener('change', function(){ toggleSourceRow(first); });
container.appendChild(first);
});
</script>
</body>
</html>