- Media player. Multiple formats

This commit is contained in:
Ty Clifford
2026-06-03 01:28:23 -04:00
parent 1962390bb8
commit acfb4b3a05
16 changed files with 2346 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# Protect the data directory from direct web access
<IfModule mod_rewrite.c>
RewriteEngine On
# Block direct access to data/
RewriteRule ^data/ - [F,L]
</IfModule>
# PHP settings for file uploads
<IfModule mod_php.c>
php_value upload_max_filesize 2048M
php_value post_max_size 2100M
php_value max_execution_time 300
php_value memory_limit 256M
</IfModule>
+118
View File
@@ -0,0 +1,118 @@
# TyClifford.com — Media Player Site
A self-hosted multimedia player website built with PHP + SQLite + Video.js.
Styled to match the existing `gate.php` / `index.php` dark theme exactly.
---
## Requirements
- PHP 8.0+ with `pdo_sqlite`, `fileinfo` extensions
- Write permissions on the `data/` and `media/` directories
---
## Installation
1. **Upload** all files to your web server (e.g. `/live/` or wherever `/index.php` lives).
2. **Set permissions** so PHP can write to these directories:
```bash
mkdir -p data media/videos media/thumbs
chmod 755 data media media/videos media/thumbs
```
3. **Visit** `index.php` — the SQLite database is created automatically on first load.
4. **Log into admin** at `admin/login.php`
- Default password: **`admin`**
- ⚠️ Change it immediately in **Admin → Settings → Change Password**
---
## File Structure
```
/ ← web root (deploy here)
├── index.php ← Main player + catalogue (home page)
├── catalogue.php ← Full video catalogue browse page
├── includes/
│ ├── db.php ← SQLite bootstrap + helper functions
│ └── layout.php ← Shared HTML head, topbar, footer, pagination
├── admin/
│ ├── index.php ← Video list with search + pagination
│ ├── add.php ← Add new video with multi-format sources
│ ├── edit.php ← Edit video metadata + manage sources
│ ├── settings.php ← Site settings, per-page count, password
│ ├── login.php ← Admin login
│ ├── logout.php ← Session destroy + redirect
│ └── auth.php ← Session auth helpers
├── media/ ← All media files (created automatically)
│ ├── videos/ ← Uploaded video files
│ └── thumbs/ ← Uploaded thumbnail images
└── data/
└── media.db ← SQLite database (auto-created)
```
---
## Usage
### Adding Videos (Admin)
1. Go to `admin/add.php`
2. Fill in title, description, duration, sort order
3. Upload a thumbnail (JPG/PNG/WebP)
4. Upload one or more video files — each can be a different format or quality:
- **MP4** (H.264) — widest compatibility
- **WebM** (VP9) — smaller size, modern browsers
- **OGV** — Firefox fallback (legacy)
5. Set quality labels (1080p, 720p, 480p, etc.)
6. Save — Video.js will auto-select the best format the browser supports
### Catalogue Per-Page
Configurable in **Admin → Settings → Videos per page** (150).
The home page catalogue and the full catalogue page both respect this setting.
### Publishing
Videos can be toggled between **Published** and **Draft** from the admin list.
Only published videos appear on the public-facing pages.
### Sorting
Use the **Sort Order** field — lower numbers appear first. Default is 0.
---
## Player Features
- **Video.js 8.x** — responsive, accessible HTML5 player
- Custom skin matching the site's dark purple/green theme
- Playback rate controls: 0.5×, 1×, 1.25×, 1.5×, 2×
- Multiple source fallback — browser picks best format automatically
- Thumbnail posters
- Keyboard accessible
---
## Security Notes
- Change the default admin password (`admin`) immediately after setup
- Consider adding `.htaccess` HTTP auth or IP restriction to `/admin/`
- The `data/` directory should not be web-accessible; add to `.htaccess`:
```apache
<Directory data>
Require all denied
</Directory>
```
- For production, consider adding CSRF tokens to admin forms
---
## Customisation
- **Branding**: Edit site title/subtitle in Admin → Settings
- **Theme colours**: All CSS variables are in `includes/layout.php` `:root {}`
- **Social links**: Edit the About/Links cards in `index.php`
+307
View File
@@ -0,0 +1,307 @@
<?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 = trim($_POST['description'] ?? '');
$duration = (int)($_POST['duration'] ?? 0);
$sort_order = (int)($_POST['sort_order'] ?? 0);
$published = isset($_POST['published']) ? 1 : 0;
if (!$title) {
$error = 'Title is required.';
} 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, sort_order, published)
VALUES (?, ?, ?, ?, ?, ?, ?)
")->execute([$title, $description, $slug, $thumbnail, $duration, $sort_order, $published]);
$video_id = (int)$db->lastInsertId();
// Handle video source files
$source_labels = $_POST['source_label'] ?? [];
$source_quality = $_POST['source_quality'] ?? [];
$source_order = $_POST['source_sort'] ?? [];
$source_files = $_FILES['source_file'] ?? [];
$videos_dir = MEDIA_DIR . 'videos/';
if (!is_dir($videos_dir)) mkdir($videos_dir, 0755, true);
$inserted_sources = 0;
$allowed_video = ['mp4','webm','ogv','ogg','mov','mkv','avi'];
if (!empty($source_files['tmp_name'])) {
foreach ($source_files['tmp_name'] as $i => $tmp) {
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)) continue;
$fname = $slug . '_' . ($i+1) . '_' . time() . '.' . $ext;
if (move_uploaded_file($tmp, $videos_dir . $fname)) {
$label = trim($source_labels[$i] ?? '');
$quality = trim($source_quality[$i] ?? '');
$mime = mime_from_ext($fname);
$sort = (int)($source_order[$i] ?? $i);
$db->prepare("
INSERT INTO video_sources (video_id, label, file_path, mime_type, quality, sort_order)
VALUES (?,?,?,?,?,?)
")->execute([$video_id, $label, 'videos/' . $fname, $mime, $quality, $sort]);
$inserted_sources++;
}
}
}
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:1fr 1fr 1fr auto;
gap:.75rem; align-items:end;
}
@media(max-width:700px) { .source-row { grid-template-columns:1fr 1fr; } }
.add-source-btn { align-self:flex-start; }
.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="../index.php" 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>
<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'] ?? '') ?>" required placeholder="My Awesome Video">
</div>
<div class="form-group span2">
<label class="form-label" for="description">Description</label>
<textarea class="form-textarea" id="description" name="description" rows="3"><?= h($_POST['description'] ?? '') ?></textarea>
</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;">
<span class="hint">JPG, PNG, WebP or GIF. Shown as 16:9 preview.</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 (Multiple Formats)</p>
<p class="hint" style="margin-bottom:1rem;">
Upload the same video in multiple formats/qualities. The browser will automatically pick the best one it can play.
Accepted: <strong>MP4, WebM, OGV, MOV, MKV, AVI</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">Video File</label>
<input class="form-input" type="file" name="source_file[]"
accept="video/mp4,video/webm,video/ogg,.mp4,.webm,.ogv,.mov,.mkv,.avi"
style="padding:.45rem .6rem;">
</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 Format
</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>
<script>
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="number"]').forEach(function(el){ el.value=idx; });
// 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);
}
container.appendChild(first);
});
</script>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
<?php
/**
* auth.php simple session-based admin auth
*/
require_once __DIR__ . '/../includes/db.php';
session_start();
define('ADMIN_SESSION_KEY', 'tc_admin_auth');
function admin_is_logged_in(): bool {
return !empty($_SESSION[ADMIN_SESSION_KEY]);
}
function admin_require_auth(): void {
if (!admin_is_logged_in()) {
header('Location: login.php');
exit;
}
}
function admin_login(string $password): bool {
$hash = setting('admin_password');
if (password_verify($password, $hash)) {
session_regenerate_id(true);
$_SESSION[ADMIN_SESSION_KEY] = true;
return true;
}
return false;
}
function admin_logout(): void {
$_SESSION[ADMIN_SESSION_KEY] = false;
session_destroy();
}
+381
View File
@@ -0,0 +1,381 @@
<?php
require_once __DIR__ . '/auth.php';
require_once __DIR__ . '/../includes/layout.php';
admin_require_auth();
$db = get_db();
$id = (int)($_GET['id'] ?? 0);
$video = get_video_by_id($id);
if (!$video) {
header('Location: index.php');
exit;
}
$error = '';
$msg = '';
if (isset($_GET['saved'])) $msg = 'Video saved successfully.';
// Handle delete source
if (isset($_GET['del_src'])) {
$sid = (int)$_GET['del_src'];
$src = $db->prepare("SELECT * FROM video_sources WHERE id=? AND video_id=?");
$src->execute([$sid, $id]);
$s = $src->fetch();
if ($s) {
$f = MEDIA_DIR . $s['file_path'];
if (file_exists($f)) unlink($f);
$db->prepare("DELETE FROM video_sources WHERE id=?")->execute([$sid]);
$msg = 'Source file removed.';
}
$video = get_video_by_id($id); // refresh
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? 'save';
if ($action === 'save') {
$title = trim($_POST['title'] ?? '');
$description = trim($_POST['description'] ?? '');
$duration = (int)($_POST['duration'] ?? 0);
$sort_order = (int)($_POST['sort_order'] ?? 0);
$published = isset($_POST['published']) ? 1 : 0;
if (!$title) {
$error = 'Title is required.';
} else {
$slug = make_slug($title, $id);
// Handle thumbnail replacement
$thumbnail = $video['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);
// Remove old thumb
if ($thumbnail && file_exists(MEDIA_DIR . $thumbnail)) unlink(MEDIA_DIR . $thumbnail);
$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) {
$db->prepare("
UPDATE videos SET title=?, description=?, slug=?, thumbnail=?,
duration=?, sort_order=?, published=?,
updated_at=datetime('now')
WHERE id=?
")->execute([$title, $description, $slug, $thumbnail, $duration, $sort_order, $published, $id]);
// Update existing sources sort/label/quality
$upd_labels = $_POST['src_label'] ?? [];
$upd_quality = $_POST['src_quality'] ?? [];
$upd_sort = $_POST['src_sort'] ?? [];
$upd_ids = $_POST['src_id'] ?? [];
foreach ($upd_ids as $i => $sid) {
$db->prepare("UPDATE video_sources SET label=?, quality=?, sort_order=? WHERE id=? AND video_id=?")
->execute([
$upd_labels[$i] ?? '',
$upd_quality[$i] ?? '',
(int)($upd_sort[$i] ?? $i),
(int)$sid, $id
]);
}
// Add new source files
$new_labels = $_POST['new_source_label'] ?? [];
$new_quality = $_POST['new_source_quality'] ?? [];
$new_sort = $_POST['new_source_sort'] ?? [];
$new_files = $_FILES['new_source_file'] ?? [];
$videos_dir = MEDIA_DIR . 'videos/';
if (!is_dir($videos_dir)) mkdir($videos_dir, 0755, true);
$allowed_video = ['mp4','webm','ogv','ogg','mov','mkv','avi'];
if (!empty($new_files['tmp_name'])) {
foreach ($new_files['tmp_name'] as $i => $tmp) {
if (empty($tmp) || !is_uploaded_file($tmp)) continue;
$orig = $new_files['name'][$i];
$ext = strtolower(pathinfo($orig, PATHINFO_EXTENSION));
if (!in_array($ext, $allowed_video)) continue;
$fname = $slug . '_' . time() . '_' . $i . '.' . $ext;
if (move_uploaded_file($tmp, $videos_dir . $fname)) {
$db->prepare("INSERT INTO video_sources (video_id,label,file_path,mime_type,quality,sort_order) VALUES (?,?,?,?,?,?)")
->execute([$id,
trim($new_labels[$i] ?? ''),
'videos/' . $fname,
mime_from_ext($fname),
trim($new_quality[$i] ?? '720p'),
(int)($new_sort[$i] ?? $i),
]);
}
}
}
header('Location: edit.php?id=' . $id . '&saved=1');
exit;
}
}
}
}
// Refresh
$video = get_video_by_id($id);
render_head('Edit 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; }
.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; }
.source-list { display:flex; flex-direction:column; gap:.75rem; }
.source-item {
background:var(--surface-2); border:1px solid var(--border);
border-radius:var(--r); padding:.85rem 1rem;
}
.source-item-header { display:flex; align-items:center; justify-content:space-between; gap:.75rem; margin-bottom:.75rem; flex-wrap:wrap; }
.source-item-file { font-family:"Share Tech Mono",monospace; font-size:.68rem; color:var(--text-2); word-break:break-all; }
.source-item-mime { font-family:"Share Tech Mono",monospace; font-size:.6rem; color:var(--text-3); }
.source-item-fields { display:grid; grid-template-columns:1fr 1fr 80px; gap:.6rem; }
@media(max-width:600px) { .source-item-fields { grid-template-columns:1fr 1fr; } }
.add-source-row { background:var(--surface-2); border:1px solid var(--border-2); border-radius:var(--r); padding:1rem; display:grid; grid-template-columns:1fr 1fr 1fr auto; gap:.75rem; align-items:end; }
@media(max-width:700px) { .add-source-row { grid-template-columns:1fr 1fr; } }
.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); }
.nav-divider { border:none; border-top:1px solid var(--border); margin:.4rem 0; }
.thumb-preview { width:100%; max-width:240px; aspect-ratio:16/9; object-fit:cover; border-radius:var(--r); border:1px solid var(--border); background:var(--surface-2); display:block; margin-bottom:.5rem; }
');
?>
<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 / edit video</div>
</div>
</div>
<nav class="topbar-social">
<a class="social-pill" href="../index.php?v=<?= urlencode($video['slug']) ?>" 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>
Preview
</a>
<a class="social-pill" href="logout.php">Logout</a>
</nav>
</header>
<?php if ($msg): ?>
<div class="notice notice-success"><?= h($msg) ?></div>
<?php endif; ?>
<?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">
<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>
<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">Editing</p>
<h1 style="font-size:1.05rem;font-weight:700;color:var(--text);"><?= h($video['title']) ?></h1>
</div>
<a href="index.php" class="btn btn-secondary btn-sm"> Back</a>
</div>
<form method="post" action="edit.php?id=<?= $id ?>" enctype="multipart/form-data">
<input type="hidden" name="action" value="save">
<!-- ── 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($video['title']) ?>" required>
</div>
<div class="form-group span2">
<label class="form-label" for="description">Description</label>
<textarea class="form-textarea" id="description" name="description" rows="3"><?= h($video['description']) ?></textarea>
</div>
<div class="form-group">
<label class="form-label">Slug (auto-generated)</label>
<input class="form-input" type="text" value="<?= h($video['slug']) ?>" disabled style="opacity:.5;">
</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="<?= (int)$video['duration'] ?>" min="0">
</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="<?= (int)$video['sort_order'] ?>" min="0">
</div>
<div class="form-group">
<label class="form-label">Published</label>
<label style="display:flex;align-items:center;gap:.6rem;cursor:pointer;margin-top:.35rem;">
<input type="checkbox" name="published" value="1" <?= $video['published'] ? 'checked' : '' ?>>
<span style="font-size:.82rem;color:var(--text-2);">Published</span>
</label>
</div>
<div class="form-group span2">
<label class="form-label">Thumbnail</label>
<?php if ($video['thumbnail']): ?>
<img class="thumb-preview" src="<?= h('../' . MEDIA_URL . $video['thumbnail']) ?>" alt="Current thumbnail">
<span class="hint" style="display:block;margin-bottom:.5rem;">Current: <?= h($video['thumbnail']) ?></span>
<?php endif; ?>
<input class="form-input" type="file" id="thumbnail" name="thumbnail"
accept="image/jpeg,image/png,image/webp,image/gif" style="padding:.45rem .6rem;">
<span class="hint">Upload new image to replace current thumbnail.</span>
</div>
</div>
</div>
<!-- ── Existing sources ── -->
<?php if (!empty($video['sources'])): ?>
<div class="card" style="margin-bottom:1rem;">
<p class="section-heading">Existing Sources</p>
<div class="source-list">
<?php foreach ($video['sources'] as $i => $s): ?>
<div class="source-item">
<div class="source-item-header">
<div>
<div class="source-item-file"><?= h($s['file_path']) ?></div>
<div class="source-item-mime"><?= h($s['mime_type']) ?></div>
</div>
<a href="edit.php?id=<?= $id ?>&del_src=<?= $s['id'] ?>"
class="btn btn-danger btn-sm"
onclick="return confirm('Remove this source file from disk?')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3,6 5,6 21,6"/><path d="M19 6l-1 14H6L5 6"/><path d="M9 6V4h6v2"/></svg>
Remove
</a>
</div>
<div class="source-item-fields">
<input type="hidden" name="src_id[]" value="<?= $s['id'] ?>">
<div class="form-group">
<label class="form-label">Quality</label>
<select class="form-select" name="src_quality[]">
<?php foreach (['1080p','720p','480p','360p','4K',''] as $q): ?>
<option value="<?= $q ?>" <?= $s['quality']===$q ? 'selected' : '' ?>><?= $q ?: '—' ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Label</label>
<input class="form-input" type="text" name="src_label[]" value="<?= h($s['label']) ?>" placeholder="e.g. HD">
</div>
<div class="form-group">
<label class="form-label">Sort</label>
<input class="form-input" type="number" name="src_sort[]" value="<?= (int)$s['sort_order'] ?>" min="0">
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<!-- ── Add new sources ── -->
<div class="card" style="margin-bottom:1rem;">
<p class="section-heading">Add New Source Files</p>
<p class="hint" style="margin-bottom:.85rem;">Upload additional formats (MP4, WebM, OGV, MOV, MKV, AVI).</p>
<div class="source-list" id="new-sources-container">
<div class="add-source-row" data-new-row="0">
<div class="form-group">
<label class="form-label">Video File</label>
<input class="form-input" type="file" name="new_source_file[]"
accept="video/mp4,video/webm,video/ogg,.mp4,.webm,.ogv,.mov,.mkv,.avi"
style="padding:.45rem .6rem;">
</div>
<div class="form-group">
<label class="form-label">Quality</label>
<select class="form-select" name="new_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">Label</label>
<input class="form-input" type="text" name="new_source_label[]" placeholder="e.g. HD">
</div>
<div class="form-group">
<label class="form-label">Sort</label>
<input class="form-input" type="number" name="new_source_sort[]" value="0" min="0" style="width:70px;">
</div>
</div>
</div>
<button type="button" id="add-new-source" class="btn btn-secondary btn-sm" 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 Format
</button>
</div>
<div style="display:flex;gap:.75rem;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 Changes
</button>
<a href="index.php" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
<?php render_footer(); ?>
</main>
<script>
document.getElementById('add-new-source').addEventListener('click', function() {
var container = document.getElementById('new-sources-container');
var rows = container.querySelectorAll('[data-new-row]');
var idx = rows.length;
var clone = rows[0].cloneNode(true);
clone.setAttribute('data-new-row', idx);
clone.querySelectorAll('input[type="file"]').forEach(function(el){ el.value=''; });
clone.querySelectorAll('input[type="text"]').forEach(function(el){ el.value=''; });
clone.querySelectorAll('input[type="number"]').forEach(function(el){ el.value=idx; });
if (!clone.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-new-row]').remove(); };
clone.appendChild(btn);
}
container.appendChild(clone);
});
</script>
</body>
</html>
+295
View File
@@ -0,0 +1,295 @@
<?php
require_once __DIR__ . '/auth.php';
require_once __DIR__ . '/../includes/layout.php';
admin_require_auth();
$db = get_db();
// Handle quick-actions
$msg = ''; $msg_type = 'success';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
if ($action === 'delete' && !empty($_POST['id'])) {
$id = (int)$_POST['id'];
$v = get_video_by_id($id);
if ($v) {
// Remove source files + thumbnail
foreach ($v['sources'] as $s) {
$f = MEDIA_DIR . $s['file_path'];
if (file_exists($f)) unlink($f);
}
if ($v['thumbnail']) {
$t = MEDIA_DIR . $v['thumbnail'];
if (file_exists($t)) unlink($t);
}
$db->prepare("DELETE FROM videos WHERE id=?")->execute([$id]);
$msg = 'Video deleted.';
}
}
if ($action === 'toggle_published' && !empty($_POST['id'])) {
$id = (int)$_POST['id'];
$row = $db->prepare("SELECT published FROM videos WHERE id=?");
$row->execute([$id]);
$cur = $row->fetchColumn();
$db->prepare("UPDATE videos SET published=? WHERE id=?")->execute([$cur ? 0 : 1, $id]);
$msg = 'Video ' . ($cur ? 'unpublished' : 'published') . '.';
}
}
// Search + pagination
$search = trim($_GET['q'] ?? '');
$page = max(1, (int)($_GET['page'] ?? 1));
$per_page = (int)setting('catalogue_per_page', '10');
$where_sql = $search ? "WHERE v.title LIKE :q OR v.description LIKE :q" : '';
$bind = $search ? [':q' => '%' . $search . '%'] : [];
$total_stmt = $db->prepare("SELECT COUNT(*) FROM videos v $where_sql");
$total_stmt->execute($bind);
$total = (int)$total_stmt->fetchColumn();
$total_pages = max(1, (int)ceil($total / $per_page));
$page = min($page, $total_pages);
$offset = ($page - 1) * $per_page;
$list_stmt = $db->prepare("
SELECT v.id, v.title, v.slug, v.thumbnail, v.duration, v.published, v.sort_order,
v.created_at,
COUNT(vs.id) AS source_count
FROM videos v
LEFT JOIN video_sources vs ON vs.video_id = v.id
$where_sql
GROUP BY v.id
ORDER BY v.sort_order ASC, v.id DESC
LIMIT :limit OFFSET :offset
");
foreach ($bind as $k => $val) $list_stmt->bindValue($k, $val);
$list_stmt->bindValue(':limit', $per_page, PDO::PARAM_INT);
$list_stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$list_stmt->execute();
$videos = $list_stmt->fetchAll();
$qs = fn($p) => '?' . http_build_query(array_filter(['q' => $search, 'page' => $p]));
render_head('Admin — TyClifford.com', '
.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); }
.admin-nav a svg { flex-shrink:0; }
.nav-divider { border:none; border-top:1px solid var(--border); margin:.4rem 0; }
.admin-main { display:flex; flex-direction:column; gap:1rem; }
.admin-toolbar { display:flex; align-items:center; gap:.75rem; flex-wrap:wrap; justify-content:space-between; }
.search-form { display:flex; gap:.5rem; flex:1; min-width:0; max-width:360px; }
.search-form input { flex:1; }
.video-table { width:100%; border-collapse:collapse; }
.video-table th, .video-table td {
padding:.65rem .85rem; text-align:left;
border-bottom:1px solid var(--border);
font-size:.82rem;
}
.video-table th {
font-family:"Share Tech Mono",monospace; font-size:.62rem; letter-spacing:.12em;
text-transform:uppercase; color:var(--text-3); background:var(--surface-2);
}
.video-table tr:hover td { background:rgba(255,255,255,.02); }
.video-table .thumb-cell { width:80px; }
.video-table .thumb-cell img { width:72px; height:40px; object-fit:cover; border-radius:4px; display:block; background:var(--surface-2); }
.video-table .thumb-cell .no-thumb-sm { width:72px; height:40px; border-radius:4px; background:var(--surface-2); display:flex; align-items:center; justify-content:center; font-family:"Share Tech Mono",monospace; font-size:.45rem; color:var(--text-3); }
.video-table .title-cell { max-width:250px; }
.video-table .title-cell a { color:var(--text); text-decoration:none; font-weight:600; }
.video-table .title-cell a:hover { color:var(--purple); }
.video-table .title-cell .slug { font-family:"Share Tech Mono",monospace; font-size:.65rem; color:var(--text-3); display:block; margin-top:.15rem; }
.video-table .actions-cell { white-space:nowrap; }
.badge { display:inline-flex; align-items:center; padding:.18em .55em; border-radius:99px; font-family:"Share Tech Mono",monospace; font-size:.6rem; letter-spacing:.08em; }
.badge-green { background:rgba(0,232,122,.12); color:var(--green); border:1px solid rgba(0,232,122,.25); }
.badge-gray { background:rgba(255,255,255,.05); color:var(--text-3); border:1px solid var(--border-2); }
.table-wrap { overflow-x:auto; background:var(--surface); border:1px solid var(--border); border-radius:calc(var(--r)+2px); }
.admin-footer-bar { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; }
.rec-count { font-family:"Share Tech Mono",monospace; font-size:.63rem; color:var(--text-3); letter-spacing:.06em; }
');
?>
<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 / dashboard</div>
</div>
</div>
<nav class="topbar-social">
<a class="social-pill" href="../index.php" 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 ($msg): ?>
<div class="notice notice-<?= $msg_type ?>"><?= h($msg) ?></div>
<?php endif; ?>
<div class="admin-layout">
<!-- Sidebar nav -->
<aside class="admin-nav">
<div class="admin-nav-label">Navigation</div>
<a href="index.php" class="active">
<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">
<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>
<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>
<!-- Main content -->
<div class="admin-main">
<div class="admin-toolbar">
<form class="search-form" method="get" action="index.php">
<input class="form-input" type="search" name="q"
placeholder="Search videos…" value="<?= h($search) ?>">
<button class="btn btn-secondary btn-sm" type="submit">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
Search
</button>
<?php if ($search): ?>
<a href="index.php" class="btn btn-secondary btn-sm"> Clear</a>
<?php endif; ?>
</form>
<a href="add.php" class="btn btn-primary btn-sm">
<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 Video
</a>
</div>
<div class="table-wrap">
<table class="video-table">
<thead>
<tr>
<th class="thumb-cell">Thumb</th>
<th class="title-cell">Title / Slug</th>
<th>Duration</th>
<th>Sources</th>
<th>Status</th>
<th>Added</th>
<th class="actions-cell">Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($videos)): ?>
<tr><td colspan="7" style="text-align:center;padding:3rem;font-family:'Share Tech Mono',monospace;font-size:.7rem;color:var(--text-3);">
<?= $search ? 'No videos match "' . h($search) . '"' : 'No videos yet. <a href="add.php" style="color:var(--purple)">Add one!</a>' ?>
</td></tr>
<?php else: ?>
<?php foreach ($videos as $v): ?>
<tr>
<td class="thumb-cell">
<?php if ($v['thumbnail']): ?>
<img src="<?= h('../' . MEDIA_URL . $v['thumbnail']) ?>" alt="">
<?php else: ?>
<div class="no-thumb-sm">NO IMG</div>
<?php endif; ?>
</td>
<td class="title-cell">
<a href="edit.php?id=<?= $v['id'] ?>"><?= h($v['title']) ?></a>
<span class="slug">/<?= h($v['slug']) ?></span>
</td>
<td style="font-family:'Share Tech Mono',monospace;font-size:.7rem;color:var(--text-3);">
<?= $v['duration'] ? format_duration((int)$v['duration']) : '—' ?>
</td>
<td style="font-family:'Share Tech Mono',monospace;font-size:.7rem;color:var(--text-2);">
<?= $v['source_count'] ?> file<?= $v['source_count'] != 1 ? 's' : '' ?>
</td>
<td>
<span class="badge <?= $v['published'] ? 'badge-green' : 'badge-gray' ?>">
<?= $v['published'] ? 'Published' : 'Draft' ?>
</span>
</td>
<td style="font-family:'Share Tech Mono',monospace;font-size:.65rem;color:var(--text-3);">
<?= substr($v['created_at'], 0, 10) ?>
</td>
<td class="actions-cell">
<div style="display:flex;gap:.4rem;align-items:center;">
<a href="edit.php?id=<?= $v['id'] ?>" class="btn btn-secondary btn-sm" title="Edit">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
Edit
</a>
<form method="post" style="display:inline" onsubmit="return confirm('Delete this video and all its files?')">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="<?= $v['id'] ?>">
<button class="btn btn-danger btn-sm" type="submit" title="Delete">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3,6 5,6 21,6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/></svg>
</button>
</form>
<form method="post" style="display:inline">
<input type="hidden" name="action" value="toggle_published">
<input type="hidden" name="id" value="<?= $v['id'] ?>">
<button class="btn btn-secondary btn-sm" type="submit" title="<?= $v['published'] ? 'Unpublish' : 'Publish' ?>">
<?php if ($v['published']): ?>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
<?php else: ?>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
<?php endif; ?>
</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<?php if ($total_pages > 1 || $total > 0): ?>
<div class="admin-footer-bar">
<span class="rec-count">
<?= $total ?> video<?= $total != 1 ? 's' : '' ?>
<?= $search ? '· filtered' : '' ?>
· page <?= $page ?> of <?= $total_pages ?>
</span>
<?php
render_pagination($page, $total_pages, 'index.php?' . ($search ? 'q=' . urlencode($search) . '&' : '') . 'page=%d');
?>
</div>
<?php endif; ?>
</div><!-- /admin-main -->
</div><!-- /admin-layout -->
<?php render_footer(); ?>
</main>
</body>
</html>
+81
View File
@@ -0,0 +1,81 @@
<?php
require_once __DIR__ . '/auth.php';
require_once __DIR__ . '/../includes/layout.php';
if (admin_is_logged_in()) {
header('Location: index.php');
exit;
}
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$pw = $_POST['password'] ?? '';
if (admin_login($pw)) {
header('Location: index.php');
exit;
}
$error = 'Invalid password.';
}
render_head('Admin Login — TyClifford.com', '
.login-wrap {
display:flex; align-items:center; justify-content:center;
min-height:70vh;
}
.login-card {
background:var(--surface); border:1px solid var(--border);
border-radius:calc(var(--r)+4px); padding:2.5rem 2rem;
width:100%; max-width:380px;
display:flex; flex-direction:column; gap:1.5rem;
position:relative; overflow:hidden;
box-shadow:0 4px 60px rgba(0,0,0,.5);
}
.login-card::before {
content:""; position:absolute; top:0; left:0; right:0; height:2px;
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;
}
.login-title { font-size:1.1rem; font-weight:700; color:var(--text); }
.login-sub { font-family:"Share Tech Mono",monospace; font-size:.63rem; color:var(--text-3); letter-spacing:.12em; text-transform:uppercase; margin-top:.15rem; }
');
?>
<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 dashboard</div>
</div>
</div>
<a href="../index.php" class="btn btn-secondary btn-sm"> Back to site</a>
</header>
<div class="login-wrap">
<div class="login-card">
<div>
<div class="login-title">Admin Login</div>
<div class="login-sub">tyclifford.com / media / admin</div>
</div>
<?php if ($error): ?>
<div class="notice notice-error"><?= h($error) ?></div>
<?php endif; ?>
<form method="post" action="login.php">
<div class="form-group" style="margin-bottom:1rem">
<label class="form-label" for="password">Password</label>
<input class="form-input" type="password" id="password" name="password"
autofocus autocomplete="current-password" placeholder="••••••••">
</div>
<button class="btn btn-primary" type="submit" style="width:100%">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
Sign In
</button>
</form>
</div>
</div>
<?php render_footer(); ?>
</main>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
<?php
require_once __DIR__ . '/auth.php';
admin_logout();
header('Location: login.php');
exit;
+219
View File
@@ -0,0 +1,219 @@
<?php
require_once __DIR__ . '/auth.php';
require_once __DIR__ . '/../includes/layout.php';
admin_require_auth();
$msg = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? 'settings';
if ($action === 'settings') {
$per_page = max(1, min(50, (int)($_POST['catalogue_per_page'] ?? 10)));
$site_title = trim($_POST['site_title'] ?? 'Ty Clifford');
$site_sub = trim($_POST['site_sub'] ?? 'tyclifford.com / media');
set_setting('catalogue_per_page', (string)$per_page);
set_setting('site_title', $site_title);
set_setting('site_sub', $site_sub);
$msg = 'Settings saved.';
}
if ($action === 'change_password') {
$current = $_POST['current_password'] ?? '';
$new1 = $_POST['new_password'] ?? '';
$new2 = $_POST['new_password2'] ?? '';
if (!password_verify($current, setting('admin_password'))) {
$msg = 'ERROR: Current password is incorrect.';
} elseif (strlen($new1) < 6) {
$msg = 'ERROR: New password must be at least 6 characters.';
} elseif ($new1 !== $new2) {
$msg = 'ERROR: New passwords do not match.';
} else {
set_setting('admin_password', password_hash($new1, PASSWORD_DEFAULT));
$msg = 'Password changed successfully.';
}
}
}
$per_page = setting('catalogue_per_page', '10');
$site_title = setting('site_title', 'Ty Clifford');
$site_sub = setting('site_sub', 'tyclifford.com / media');
// DB stats
$db = get_db();
$total_videos = $db->query("SELECT COUNT(*) FROM videos")->fetchColumn();
$total_sources = $db->query("SELECT COUNT(*) FROM video_sources")->fetchColumn();
$db_size = file_exists(DB_PATH) ? round(filesize(DB_PATH)/1024, 1) : 0;
$media_size = 0;
if (is_dir(MEDIA_DIR)) {
$iter = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(MEDIA_DIR, FilesystemIterator::SKIP_DOTS));
foreach ($iter as $f) $media_size += $f->getSize();
$media_size = round($media_size / 1048576, 1);
}
$msg_type = str_starts_with($msg, 'ERROR') ? 'error' : 'success';
render_head('Settings — Admin', '
.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; }
.form-grid { display:grid; grid-template-columns:1fr; gap:1rem; }
@media(min-width:600px) { .form-grid { grid-template-columns:1fr 1fr; } }
.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; }
.stat-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(130px,1fr)); gap:.75rem; }
.stat-tile { background:var(--surface-2); border:1px solid var(--border); border-radius:var(--r); padding:1rem; display:flex; flex-direction:column; gap:.3rem; }
.stat-val { font-family:"Share Tech Mono",monospace; font-size:1.4rem; color:var(--text); font-weight:700; }
.stat-label{ font-family:"Share Tech Mono",monospace; font-size:.6rem; letter-spacing:.1em; text-transform:uppercase; color:var(--text-3); }
');
?>
<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 / settings</div>
</div>
</div>
<nav class="topbar-social">
<a class="social-pill" href="../index.php" 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">Logout</a>
</nav>
</header>
<?php if ($msg): ?>
<div class="notice notice-<?= $msg_type ?>"><?= h($msg) ?></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">
<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>
<hr class="nav-divider">
<a href="settings.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="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 style="display:flex;flex-direction:column;gap:1.25rem;">
<!-- Stats -->
<div class="card">
<p class="section-heading">Overview</p>
<div class="stat-grid">
<div class="stat-tile">
<span class="stat-val"><?= $total_videos ?></span>
<span class="stat-label">Videos</span>
</div>
<div class="stat-tile">
<span class="stat-val"><?= $total_sources ?></span>
<span class="stat-label">Source Files</span>
</div>
<div class="stat-tile">
<span class="stat-val"><?= $db_size ?> KB</span>
<span class="stat-label">Database</span>
</div>
<div class="stat-tile">
<span class="stat-val"><?= $media_size ?> MB</span>
<span class="stat-label">Media Storage</span>
</div>
</div>
</div>
<!-- General settings -->
<div class="card">
<p class="section-heading">General Settings</p>
<form method="post" action="settings.php">
<input type="hidden" name="action" value="settings">
<div class="form-grid" style="margin-bottom:1rem;">
<div class="form-group">
<label class="form-label" for="site_title">Site Title</label>
<input class="form-input" type="text" id="site_title" name="site_title"
value="<?= h($site_title) ?>">
</div>
<div class="form-group">
<label class="form-label" for="site_sub">Site Subtitle</label>
<input class="form-input" type="text" id="site_sub" name="site_sub"
value="<?= h($site_sub) ?>">
</div>
<div class="form-group">
<label class="form-label" for="catalogue_per_page">Videos per page</label>
<input class="form-input" type="number" id="catalogue_per_page"
name="catalogue_per_page" value="<?= h($per_page) ?>" min="1" max="50">
<span class="hint">How many thumbnails to show per page (150).</span>
</div>
</div>
<button class="btn btn-primary btn-sm" type="submit">
<svg width="13" height="13" 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 Settings
</button>
</form>
</div>
<!-- Change password -->
<div class="card">
<p class="section-heading">Change Password</p>
<form method="post" action="settings.php">
<input type="hidden" name="action" value="change_password">
<div class="form-grid" style="margin-bottom:1rem;">
<div class="form-group">
<label class="form-label" for="current_password">Current Password</label>
<input class="form-input" type="password" id="current_password" name="current_password"
autocomplete="current-password">
</div>
<div></div>
<div class="form-group">
<label class="form-label" for="new_password">New Password</label>
<input class="form-input" type="password" id="new_password" name="new_password"
autocomplete="new-password" minlength="6">
<span class="hint">Minimum 6 characters.</span>
</div>
<div class="form-group">
<label class="form-label" for="new_password2">Confirm New Password</label>
<input class="form-input" type="password" id="new_password2" name="new_password2"
autocomplete="new-password">
</div>
</div>
<button class="btn btn-secondary btn-sm" type="submit">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
Change Password
</button>
</form>
</div>
<!-- File locations reference -->
<div class="card">
<p class="section-heading">File Locations</p>
<div style="display:flex;flex-direction:column;gap:.5rem;font-family:'Share Tech Mono',monospace;font-size:.7rem;color:var(--text-3);">
<div><span style="color:var(--text-2);">Database:</span> data/media.db</div>
<div><span style="color:var(--text-2);">Videos:</span> media/videos/</div>
<div><span style="color:var(--text-2);">Thumbnails:</span> media/thumbs/</div>
<div><span style="color:var(--text-2);">Default password:</span> <span style="color:var(--red);">admin</span> change immediately!</div>
</div>
</div>
</div>
</div>
<?php render_footer(); ?>
</main>
</body>
</html>
+129
View File
@@ -0,0 +1,129 @@
<?php
require_once __DIR__ . '/includes/db.php';
require_once __DIR__ . '/includes/layout.php';
$per_page = (int)setting('catalogue_per_page', '10');
$page = max(1, (int)($_GET['page'] ?? 1));
$paged = get_videos_paginated($page, $per_page);
render_head('Videos — TyClifford.com', '
.cat-grid {
display:grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap:1rem;
}
@media(min-width:600px) { .cat-grid { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); } }
@media(min-width:1000px) { .cat-grid { grid-template-columns: repeat(4, 1fr); } }
.cat-thumb {
background:var(--surface-2); border:1px solid var(--border);
border-radius:calc(var(--r)+2px); overflow:hidden; text-decoration:none;
color:var(--text); display:flex; flex-direction:column;
transition:border-color .2s, transform .15s, box-shadow .2s;
position:relative; cursor:pointer;
}
.cat-thumb:hover { border-color:var(--purple); transform:translateY(-2px); box-shadow:0 6px 24px rgba(176,96,255,.18); }
.cat-thumb-img { position:relative; width:100%; padding-top:56.25%; background:#000; overflow:hidden; }
.cat-thumb-img img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; transition:transform .3s; }
.cat-thumb:hover .cat-thumb-img img { transform:scale(1.04); }
.cat-thumb-img .no-thumb {
position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
font-family:"Share Tech Mono",monospace; font-size:.6rem; color:var(--text-3); letter-spacing:.1em;
background:var(--surface);
}
.cat-thumb-dur {
position:absolute; bottom:4px; right:6px;
background:rgba(0,0,0,.75); backdrop-filter:blur(4px);
font-family:"Share Tech Mono",monospace; font-size:.55rem;
color:#fff; padding:.15em .45em; border-radius:3px;
}
.play-overlay {
position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
background:rgba(0,0,0,.35); opacity:0; transition:opacity .2s;
}
.cat-thumb:hover .play-overlay { opacity:1; }
.cat-thumb-body { padding:.65rem .75rem; display:flex; flex-direction:column; gap:.25rem; flex:1; }
.cat-thumb-title { font-size:.85rem; font-weight:600; color:var(--text); line-height:1.35; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
.cat-thumb-desc { font-size:.75rem; color:var(--text-3); line-height:1.5; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
.cat-thumb-meta { font-family:"Share Tech Mono",monospace; font-size:.6rem; color:var(--text-3); letter-spacing:.06em; margin-top:auto; padding-top:.4rem; }
.page-header { display:flex; flex-direction:column; gap:.5rem; }
.page-header-row { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; }
.cat-footer { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; margin-top:.5rem; }
.cat-count { font-family:"Share Tech Mono",monospace; font-size:.65rem; color:var(--text-3); letter-spacing:.06em; }
.empty-state { padding:4rem 1rem; text-align:center; display:flex; flex-direction:column; align-items:center; gap:1rem; }
.empty-state svg { color:var(--text-3); opacity:.35; }
.empty-state p { font-family:"Share Tech Mono",monospace; font-size:.75rem; color:var(--text-3); letter-spacing:.1em; }
');
?>
<main class="page" role="main">
<?php render_topbar('catalogue'); ?>
<div class="page-header">
<div class="page-header-row">
<div>
<p class="eyebrow eyebrow-purple" style="margin-bottom:.3rem">Library</p>
<h1 style="font-size:1.1rem;font-weight:700;color:var(--text);">Video Catalogue</h1>
</div>
<span class="cat-count"><?= $paged['total'] ?> video<?= $paged['total'] != 1 ? 's' : '' ?></span>
</div>
</div>
<?php if (empty($paged['videos'])): ?>
<div class="card">
<div class="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><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>
<p>no videos in the catalogue yet</p>
<a href="admin/index.php" class="btn btn-secondary btn-sm">Go to Admin </a>
</div>
</div>
<?php else: ?>
<div class="cat-grid">
<?php foreach ($paged['videos'] as $v):
$vslug = $v['slug'];
$vtitle = $v['title'];
$vdesc = $v['description'];
$vthumb = $v['thumbnail'];
$vdur = (int)$v['duration'];
$created = substr($v['created_at'], 0, 10);
?>
<a class="cat-thumb" href="../index.php?v=<?= urlencode($vslug) ?>" title="<?= h($vtitle) ?>">
<div class="cat-thumb-img">
<?php if ($vthumb): ?>
<img src="<?= h('../' . MEDIA_URL . $vthumb) ?>" alt="<?= h($vtitle) ?>" loading="lazy">
<?php else: ?>
<div class="no-thumb">NO THUMBNAIL</div>
<?php endif; ?>
<?php if ($vdur): ?>
<span class="cat-thumb-dur"><?= format_duration($vdur) ?></span>
<?php endif; ?>
<div class="play-overlay">
<svg width="36" height="36" viewBox="0 0 24 24" fill="white"><circle cx="12" cy="12" r="12" fill="rgba(0,0,0,.4)"/><polygon points="10,8 18,12 10,16" fill="white"/></svg>
</div>
</div>
<div class="cat-thumb-body">
<span class="cat-thumb-title"><?= h($vtitle) ?></span>
<?php if ($vdesc): ?>
<span class="cat-thumb-desc"><?= h($vdesc) ?></span>
<?php endif; ?>
<span class="cat-thumb-meta"><?= h($created) ?><?= $vdur ? ' · ' . format_duration($vdur) : '' ?></span>
</div>
</a>
<?php endforeach; ?>
</div>
<?php if ($paged['total_pages'] > 1): ?>
<div class="cat-footer">
<span class="cat-count">page <?= $paged['page'] ?> of <?= $paged['total_pages'] ?></span>
<?php render_pagination($page, $paged['total_pages'], 'catalogue.php?page=%d'); ?>
</div>
<?php endif; ?>
<?php endif; ?>
<?php render_footer(); ?>
</main>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
Require all denied
+168
View File
@@ -0,0 +1,168 @@
<?php
/**
* db.php SQLite database bootstrap & helpers
*/
define('DB_PATH', __DIR__ . '/../data/media.db');
define('MEDIA_DIR', __DIR__ . '/../media/');
define('MEDIA_URL', 'media/');
function get_db(): PDO {
static $pdo = null;
if ($pdo) return $pdo;
$dir = dirname(DB_PATH);
if (!is_dir($dir)) mkdir($dir, 0755, true);
$pdo = new PDO('sqlite:' . DB_PATH);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$pdo->exec('PRAGMA journal_mode=WAL');
$pdo->exec('PRAGMA foreign_keys=ON');
// Schema
$pdo->exec("
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS videos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT 'Untitled',
description TEXT NOT NULL DEFAULT '',
slug TEXT NOT NULL UNIQUE,
thumbnail TEXT NOT NULL DEFAULT '',
duration INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
published INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS video_sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
video_id INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
label TEXT NOT NULL DEFAULT '',
file_path TEXT NOT NULL,
mime_type TEXT NOT NULL DEFAULT 'video/mp4',
quality TEXT NOT NULL DEFAULT '720p',
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_videos_published ON videos(published);
CREATE INDEX IF NOT EXISTS idx_videos_sort ON videos(sort_order, id);
CREATE INDEX IF NOT EXISTS idx_sources_video ON video_sources(video_id);
");
// Default settings
$defaults = [
'catalogue_per_page' => '10',
'admin_password' => password_hash('admin', PASSWORD_DEFAULT),
'site_title' => 'Ty Clifford',
'site_sub' => 'tyclifford.com / media',
];
$ins = $pdo->prepare("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)");
foreach ($defaults as $k => $v) $ins->execute([$k, $v]);
return $pdo;
}
function setting(string $key, string $fallback = ''): string {
$row = get_db()->prepare("SELECT value FROM settings WHERE key=?");
$row->execute([$key]);
$r = $row->fetch();
return $r ? $r['value'] : $fallback;
}
function set_setting(string $key, string $value): void {
get_db()->prepare("INSERT OR REPLACE INTO settings (key,value) VALUES (?,?)")
->execute([$key, $value]);
}
function make_slug(string $title, int $id = 0): string {
$slug = strtolower(trim(preg_replace('/[^a-z0-9]+/i', '-', $title), '-'));
if ($id) {
$exists = get_db()->prepare("SELECT id FROM videos WHERE slug=? AND id!=?");
$exists->execute([$slug, $id]);
} else {
$exists = get_db()->prepare("SELECT id FROM videos WHERE slug=?");
$exists->execute([$slug]);
}
if ($exists->fetch()) $slug .= '-' . ($id ?: time());
return $slug ?: 'video-' . time();
}
function mime_from_ext(string $path): string {
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
return match($ext) {
'mp4' => 'video/mp4',
'webm' => 'video/webm',
'ogv' => 'video/ogg',
'mov' => 'video/quicktime',
'mkv' => 'video/x-matroska',
'avi' => 'video/x-msvideo',
default => 'video/mp4',
};
}
function format_duration(int $seconds): string {
if ($seconds <= 0) return '';
$h = intdiv($seconds, 3600);
$m = intdiv($seconds % 3600, 60);
$s = $seconds % 60;
if ($h) return sprintf('%d:%02d:%02d', $h, $m, $s);
return sprintf('%d:%02d', $m, $s);
}
function h(string $s): string {
return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function get_videos_paginated(int $page, int $per_page, bool $published_only = true): array {
$db = get_db();
$where = $published_only ? 'WHERE v.published=1' : '';
$offset = ($page - 1) * $per_page;
$total = $db->query("SELECT COUNT(*) FROM videos v $where")->fetchColumn();
$rows = $db->prepare("
SELECT v.*, GROUP_CONCAT(vs.file_path,'||') AS source_paths
FROM videos v
LEFT JOIN video_sources vs ON vs.video_id = v.id
$where
GROUP BY v.id
ORDER BY v.sort_order ASC, v.id DESC
LIMIT ? OFFSET ?
");
$rows->execute([$per_page, $offset]);
return [
'videos' => $rows->fetchAll(),
'total' => (int)$total,
'page' => $page,
'per_page' => $per_page,
'total_pages' => (int)ceil($total / $per_page),
];
}
function get_video_by_slug(string $slug): ?array {
$db = get_db();
$row = $db->prepare("SELECT * FROM videos WHERE slug=? AND published=1");
$row->execute([$slug]);
$v = $row->fetch();
if (!$v) return null;
$sources = $db->prepare("SELECT * FROM video_sources WHERE video_id=? ORDER BY sort_order ASC");
$sources->execute([$v['id']]);
$v['sources'] = $sources->fetchAll();
return $v;
}
function get_video_by_id(int $id): ?array {
$db = get_db();
$row = $db->prepare("SELECT * FROM videos WHERE id=?");
$row->execute([$id]);
$v = $row->fetch();
if (!$v) return null;
$sources = $db->prepare("SELECT * FROM video_sources WHERE video_id=? ORDER BY sort_order ASC");
$sources->execute([$v['id']]);
$v['sources'] = $sources->fetchAll();
return $v;
}
+275
View File
@@ -0,0 +1,275 @@
<?php
/**
* layout.php shared HTML head, header, and footer partials
*/
function render_head(string $title = 'Media — TyClifford.com', string $extra_css = ''): 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>
<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); }
/* ── 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 / media');
?>
<header class="topbar">
<div class="topbar-brand">
<div>
<a href="index.php" 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="index.php">
<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="catalogue.php">
<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 &nbsp;·&nbsp; media</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_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>';
}
+317
View File
@@ -0,0 +1,317 @@
<?php
require_once __DIR__ . '/includes/db.php';
require_once __DIR__ . '/includes/layout.php';
// Determine which video to play
$slug = $_GET['v'] ?? '';
$video = $slug ? get_video_by_slug($slug) : null;
// Latest video as default if none specified
if (!$video) {
$db = get_db();
$latest = $db->query("SELECT slug FROM videos WHERE published=1 ORDER BY sort_order ASC, id DESC LIMIT 1")->fetch();
if ($latest) $video = get_video_by_slug($latest['slug']);
}
$per_page = (int)setting('catalogue_per_page', '10');
$page = max(1, (int)($_GET['page'] ?? 1));
$paged = get_videos_paginated($page, $per_page);
render_head('Media — TyClifford.com', '
/* ── Catalogue grid ── */
.cat-section { display:flex; flex-direction:column; gap:1rem; }
.cat-header { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; }
.cat-grid {
display:grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap:.85rem;
}
@media(min-width:600px) { .cat-grid { grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); } }
@media(min-width:1000px) { .cat-grid { grid-template-columns: repeat(5, 1fr); } }
.cat-thumb {
background:var(--surface-2);
border:1px solid var(--border);
border-radius:calc(var(--r)+2px);
overflow:hidden;
text-decoration:none;
color:var(--text);
display:flex; flex-direction:column;
transition:border-color .2s, transform .15s, box-shadow .2s;
position:relative;
cursor:pointer;
}
.cat-thumb:hover { border-color:var(--purple); transform:translateY(-2px); box-shadow:0 6px 24px rgba(176,96,255,.18); }
.cat-thumb.active { border-color:var(--green); box-shadow:0 0 0 1px var(--green), 0 4px 20px rgba(0,232,122,.2); }
.cat-thumb-img {
position:relative; width:100%; padding-top:56.25%; background:#000;
overflow:hidden;
}
.cat-thumb-img img {
position:absolute; inset:0; width:100%; height:100%; object-fit:cover;
transition:transform .3s;
}
.cat-thumb:hover .cat-thumb-img img { transform:scale(1.04); }
.cat-thumb-img .no-thumb {
position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
font-family:"Share Tech Mono",monospace; font-size:.6rem; color:var(--text-3); letter-spacing:.1em;
background:var(--surface);
}
.cat-thumb-dur {
position:absolute; bottom:4px; right:6px;
background:rgba(0,0,0,.75); backdrop-filter:blur(4px);
font-family:"Share Tech Mono",monospace; font-size:.55rem; letter-spacing:.05em;
color:#fff; padding:.15em .45em; border-radius:3px;
}
.play-overlay {
position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
background:rgba(0,0,0,.35); opacity:0; transition:opacity .2s;
}
.cat-thumb:hover .play-overlay { opacity:1; }
.play-overlay svg { filter:drop-shadow(0 2px 8px rgba(0,0,0,.5)); }
.cat-thumb-body { padding:.55rem .65rem; display:flex; flex-direction:column; gap:.2rem; flex:1; }
.cat-thumb-title { font-size:.78rem; font-weight:600; color:var(--text); line-height:1.35; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
.cat-thumb-meta { font-family:"Share Tech Mono",monospace; font-size:.58rem; color:var(--text-3); letter-spacing:.06em; margin-top:auto; padding-top:.35rem; }
.cat-active-indicator {
position:absolute; top:0; left:0; right:0; height:2px;
background:var(--green); opacity:0; transition:opacity .2s;
}
.cat-thumb.active .cat-active-indicator { opacity:1; }
/* ── Video.js custom skin ── */
.video-js {
width:100% !important;
height:100% !important;
position:absolute !important;
inset:0 !important;
}
.video-js .vjs-big-play-button {
background:rgba(176,96,255,.75) !important;
border:2px solid rgba(176,96,255,.9) !important;
border-radius:50% !important;
width:64px !important; height:64px !important;
top:50% !important; left:50% !important;
transform:translate(-50%,-50%) !important;
margin:0 !important;
line-height:64px !important;
transition:background .2s !important;
}
.video-js:hover .vjs-big-play-button { background:rgba(176,96,255,.95) !important; }
.video-js .vjs-control-bar {
background:linear-gradient(transparent,rgba(0,0,0,.85)) !important;
height:3.2em !important;
}
.video-js .vjs-play-progress { background:var(--green) !important; }
.video-js .vjs-volume-level { background:var(--green) !important; }
.video-js .vjs-slider { background:rgba(255,255,255,.15) !important; }
.video-js .vjs-load-progress { background:rgba(255,255,255,.1) !important; }
.video-js .vjs-current-time,
.video-js .vjs-duration,
.video-js .vjs-time-divider { color:rgba(255,255,255,.8) !important; font-size:.7em !important; }
/* ── Lower grid ── */
.lower-grid { display:grid; grid-template-columns:1fr; gap:1rem; }
@media(min-width:640px) { .lower-grid { grid-template-columns:1fr 1fr; } }
@media(min-width:900px) { .lower-grid { grid-template-columns:2fr 1fr 1fr; } }
.card-about { background:var(--surface-2); }
.card-social { background:var(--surface-2); }
.social-list { list-style:none; display:flex; flex-direction:column; gap:.5rem; }
.social-list a {
display:flex; align-items:center; gap:.65rem;
padding:.6rem .8rem; border:1px solid var(--border-2); border-radius:var(--r);
font-family:"Share Tech Mono",monospace; font-size:.78rem; color:var(--text-2);
text-decoration:none; transition:border-color .2s, color .2s, background .2s;
}
.social-list a:hover { border-color:var(--green); color:var(--text); background:rgba(0,232,122,.04); }
.social-list a svg { flex-shrink:0; color:var(--text-3); transition:color .2s; }
.social-list a:hover svg { color:var(--green); }
/* empty state */
.empty-state {
padding:3rem 1rem; text-align:center; display:flex; flex-direction:column;
align-items:center; gap:1rem;
}
.empty-state svg { color:var(--text-3); opacity:.4; }
.empty-state p { font-family:"Share Tech Mono",monospace; font-size:.75rem; color:var(--text-3); letter-spacing:.1em; }
/* no-video placeholder */
.no-video { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:.75rem; }
.no-video p { font-family:"Share Tech Mono",monospace; font-size:.7rem; color:var(--text-3); letter-spacing:.1em; }
/* pagination row */
.cat-footer { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; }
.cat-count { font-family:"Share Tech Mono",monospace; font-size:.65rem; color:var(--text-3); letter-spacing:.06em; }
');
?>
<main class="page" role="main">
<?php render_topbar('home'); ?>
<!-- ═══════════════════════════ PLAYER CARD ════════════════════════════════ -->
<section class="stream-card" aria-label="Media player">
<div class="stream-ratio">
<?php if ($video && !empty($video['sources'])): ?>
<video id="main-player"
class="video-js vjs-default-skin vjs-big-play-centered"
controls preload="metadata"
<?php if ($video['thumbnail']): ?>
poster="<?= h(MEDIA_URL . $video['thumbnail']) ?>"
<?php endif; ?>
data-setup='{"fluid":false,"responsive":true,"playbackRates":[0.5,1,1.25,1.5,2]}'>
<?php foreach ($video['sources'] as $src): ?>
<source src="<?= h(MEDIA_URL . $src['file_path']) ?>" type="<?= h($src['mime_type']) ?>" label="<?= h($src['quality'] ?: $src['label']) ?>">
<?php endforeach; ?>
<p class="vjs-no-js">Your browser does not support HTML video.</p>
</video>
<?php else: ?>
<div class="no-video">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity=".25"><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>
<p>no video selected</p>
</div>
<?php endif; ?>
</div>
<div class="stream-footer">
<p class="eyebrow eyebrow-green"><?= $video ? h($video['title']) : 'Media Player' ?></p>
<?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>
<?php endif; ?>
<p class="stream-meta">
<?php if ($video && $video['duration']): ?>
<span><?= format_duration((int)$video['duration']) ?></span>
<?php endif; ?>
</p>
</div>
</section>
<!-- ═══════════════════════════ VIDEO CATALOGUE ═══════════════════════════ -->
<section class="cat-section">
<div class="cat-header">
<p class="eyebrow eyebrow-purple">Video Catalogue</p>
<a href="catalogue.php" class="btn btn-secondary btn-sm">Browse all </a>
</div>
<?php if (empty($paged['videos'])): ?>
<div class="card">
<div class="empty-state">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14,2 14,8 20,8"/></svg>
<p>no videos yet</p>
</div>
</div>
<?php else: ?>
<div class="cat-grid">
<?php foreach ($paged['videos'] as $v):
$active = $video && $video['id'] == $v['id'];
$vslug = $v['slug'];
$vtitle = $v['title'];
$vthumb = $v['thumbnail'];
$vdur = (int)$v['duration'];
$created = substr($v['created_at'], 0, 10);
?>
<a class="cat-thumb <?= $active ? 'active' : '' ?>"
href="?v=<?= urlencode($vslug) ?>"
title="<?= h($vtitle) ?>">
<div class="cat-active-indicator"></div>
<div class="cat-thumb-img">
<?php if ($vthumb): ?>
<img src="<?= h(MEDIA_URL . $vthumb) ?>" alt="<?= h($vtitle) ?>" loading="lazy">
<?php else: ?>
<div class="no-thumb">NO THUMB</div>
<?php endif; ?>
<?php if ($vdur): ?>
<span class="cat-thumb-dur"><?= format_duration($vdur) ?></span>
<?php endif; ?>
<div class="play-overlay">
<svg width="28" height="28" viewBox="0 0 24 24" fill="white"><circle cx="12" cy="12" r="12" fill="rgba(0,0,0,.4)"/><polygon points="10,8 18,12 10,16" fill="white"/></svg>
</div>
</div>
<div class="cat-thumb-body">
<span class="cat-thumb-title"><?= h($vtitle) ?></span>
<span class="cat-thumb-meta"><?= h($created) ?></span>
</div>
</a>
<?php endforeach; ?>
</div>
<?php if ($paged['total_pages'] > 1): ?>
<div class="cat-footer">
<span class="cat-count">
<?= $paged['total'] ?> video<?= $paged['total'] != 1 ? 's' : '' ?>
· page <?= $paged['page'] ?> of <?= $paged['total_pages'] ?>
</span>
<?php
$url_v = $video ? '&v=' . urlencode($video['slug']) : '';
render_pagination($page, $paged['total_pages'], '?page=%d' . $url_v);
?>
</div>
<?php endif; ?>
<?php endif; ?>
</section>
<!-- ═══════════════════════════ LOWER GRID ════════════════════════════════ -->
<div class="lower-grid">
<div class="card card-about">
<p class="eyebrow eyebrow-green" style="margin-bottom:.1rem">About</p>
<h2 class="card-title">Ty Clifford</h2>
<p class="card-body">
Based in <strong>Keyser, West Virginia</strong>. Nerd, tinkerer, and creator.
Hobbies are my hobby from tech and podcasting to whatever rabbit hole I fell into this week.
</p>
</div>
<div class="card card-social">
<p class="eyebrow eyebrow-green" style="margin-bottom:.1rem">Links</p>
<h2 class="card-title">Find me online</h2>
<ul class="social-list" aria-label="Social media links">
<li>
<a href="https://github.com/snick512" target="_blank" rel="noopener">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 00-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0020 4.77 5.07 5.07 0 0019.91 1S18.73.65 16 2.48a13.38 13.38 0 00-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 005 4.77a5.44 5.44 0 00-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 009 18.13V22"/></svg>
github.com/snick512
</a>
</li>
<li>
<a href="mailto:ty@tyclifford.com">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="2,4 12,13 22,4"/></svg>
ty@tyclifford.com
</a>
</li>
<li>
<a href="https://go.tyclifford.com/patreon" target="_blank" rel="noopener">
<svg width="14" height="14" viewBox="0 0 12 14" fill="currentColor"><ellipse cx="7.5" cy="4.8" rx="4.5" ry="4.5"/><rect x="0" y="0" width="2.8" height="14" rx="1.2"/></svg>
patreon.com/tyclifford
</a>
</li>
</ul>
</div>
</div>
<?php render_footer(); ?>
</main>
<script>
// Init Video.js if player exists
document.addEventListener('DOMContentLoaded', function() {
var el = document.getElementById('main-player');
if (el) {
var player = videojs('main-player', {
controls: true,
autoplay: false,
preload: 'metadata',
fluid: false,
responsive: true,
playbackRates: [0.5, 1, 1.25, 1.5, 2],
controlBar: {
children: [
'playToggle','volumePanel','currentTimeDisplay',
'timeDivider','durationDisplay','progressControl',
'playbackRateMenuButton','fullscreenToggle'
]
}
});
}
});
</script>
</body>
</html>
BIN
View File
Binary file not shown.
View File