-
This commit is contained in:
@@ -1,3 +1,118 @@
|
||||
# mediaplayer
|
||||
# TyClifford.com — Media Player
|
||||
|
||||
A media player.
|
||||
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** (1–50).
|
||||
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
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/auth.php';
|
||||
admin_logout();
|
||||
header('Location: login.php');
|
||||
exit;
|
||||
@@ -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 (1–50).</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>
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
<?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>
|
||||
</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="https://tyclifford.com/player/?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>
|
||||
@@ -0,0 +1 @@
|
||||
Require all denied
|
||||
@@ -0,0 +1,246 @@
|
||||
<?php
|
||||
/**
|
||||
* embed.php — Standalone embeddable player page.
|
||||
* Designed to be used inside an <iframe> on third-party sites.
|
||||
*
|
||||
* Usage: <iframe src="https://yoursite.com/embed.php?v=video-slug"></iframe>
|
||||
*/
|
||||
require_once __DIR__ . '/includes/db.php';
|
||||
|
||||
$slug = trim($_GET['v'] ?? '');
|
||||
$video = $slug ? get_video_by_slug($slug) : null;
|
||||
|
||||
// Determine absolute base URL for media files.
|
||||
// We need absolute URLs here because the embed lives on a foreign domain.
|
||||
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
$script = dirname($_SERVER['SCRIPT_NAME'] ?? '/');
|
||||
$script = rtrim($script, '/');
|
||||
$base_url = $scheme . '://' . $host . $script . '/'; // e.g. https://tyclifford.com/live/
|
||||
|
||||
// X-Frame-Options: allow embedding from any origin.
|
||||
// If you want to restrict, change ALLOWALL to SAMEORIGIN or remove for CSP header instead.
|
||||
header('X-Frame-Options: ALLOWALL');
|
||||
header('Content-Security-Policy: frame-ancestors *');
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= $video ? htmlspecialchars($video['title'], ENT_QUOTES) . ' — TyClifford.com' : 'Video Player' ?></title>
|
||||
<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>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--green: #00e87a;
|
||||
--purple: #b060ff;
|
||||
--bg: #09090f;
|
||||
--text: #e8e8f8;
|
||||
--text-2: #9898c0;
|
||||
--text-3: #58587a;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%; height: 100%;
|
||||
background: var(--bg);
|
||||
overflow: hidden;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.player-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #000;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Top shimmer bar — matches site theme */
|
||||
.shimmer-bar {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 2px;
|
||||
z-index: 10;
|
||||
background: linear-gradient(90deg,
|
||||
#ff424d, #b060ff, #00c8ff, #00e87a,
|
||||
#00c8ff, #b060ff, #ff424d);
|
||||
background-size: 300% 100%;
|
||||
animation: shimmer 5s linear infinite;
|
||||
}
|
||||
@keyframes shimmer {
|
||||
from { background-position: 0% 50%; }
|
||||
to { background-position: 200% 50%; }
|
||||
}
|
||||
|
||||
/* Video.js fills the whole container */
|
||||
.video-js {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
position: absolute !important;
|
||||
inset: 0 !important;
|
||||
}
|
||||
|
||||
/* Custom VJS skin to match site */
|
||||
.video-js .vjs-big-play-button {
|
||||
background: rgba(176,96,255,.8) !important;
|
||||
border: 2px solid rgba(176,96,255,.95) !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,1) !important;
|
||||
}
|
||||
.video-js .vjs-control-bar {
|
||||
background: linear-gradient(transparent, rgba(0,0,0,.9)) !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; }
|
||||
|
||||
/* Title + branding overlay at top (fades out) */
|
||||
.title-overlay {
|
||||
position: absolute;
|
||||
top: 8px; left: 12px; right: 12px;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
pointer-events: none;
|
||||
opacity: 1;
|
||||
transition: opacity .5s;
|
||||
}
|
||||
.title-overlay.hidden { opacity: 0; }
|
||||
|
||||
.title-text {
|
||||
font-size: .78rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 6px rgba(0,0,0,.8);
|
||||
line-height: 1.3;
|
||||
max-width: 70%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.brand-link {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: .6rem;
|
||||
letter-spacing: .12em;
|
||||
color: rgba(255,255,255,.55);
|
||||
text-decoration: none;
|
||||
text-shadow: 0 1px 4px rgba(0,0,0,.8);
|
||||
pointer-events: all;
|
||||
white-space: nowrap;
|
||||
transition: color .2s;
|
||||
}
|
||||
.brand-link:hover { color: var(--green); }
|
||||
|
||||
/* No-video state */
|
||||
.no-video {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%; height: 100%;
|
||||
gap: .75rem;
|
||||
background: var(--bg);
|
||||
}
|
||||
.no-video p {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: .72rem;
|
||||
color: var(--text-3);
|
||||
letter-spacing: .1em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="player-wrap" id="player-wrap">
|
||||
<div class="shimmer-bar"></div>
|
||||
|
||||
<?php if ($video && !empty($video['sources'])): ?>
|
||||
|
||||
<!-- Title + brand overlay -->
|
||||
<div class="title-overlay" id="title-overlay">
|
||||
<span class="title-text"><?= htmlspecialchars($video['title'], ENT_QUOTES) ?></span>
|
||||
<a class="brand-link" href="<?= $base_url ?>index.php?v=<?= urlencode($video['slug']) ?>"
|
||||
target="_blank" rel="noopener">tyclifford.com</a>
|
||||
</div>
|
||||
|
||||
<video id="embed-player"
|
||||
class="video-js vjs-default-skin vjs-big-play-centered"
|
||||
controls preload="metadata"
|
||||
<?php if ($video['thumbnail']): ?>
|
||||
poster="<?= htmlspecialchars($base_url . MEDIA_URL . $video['thumbnail'], ENT_QUOTES) ?>"
|
||||
<?php endif; ?>>
|
||||
<?php foreach ($video['sources'] as $src): ?>
|
||||
<source
|
||||
src="<?= htmlspecialchars($base_url . MEDIA_URL . $src['file_path'], ENT_QUOTES) ?>"
|
||||
type="<?= htmlspecialchars($src['mime_type'], ENT_QUOTES) ?>">
|
||||
<?php endforeach; ?>
|
||||
<p class="vjs-no-js">Your browser does not support HTML5 video.</p>
|
||||
</video>
|
||||
|
||||
<?php else: ?>
|
||||
|
||||
<div class="no-video">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none"
|
||||
stroke="#58587a" 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><?= $slug ? 'video not found' : 'no video specified' ?></p>
|
||||
</div>
|
||||
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($video && !empty($video['sources'])): ?>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var player = videojs('embed-player', {
|
||||
controls: true,
|
||||
autoplay: false,
|
||||
preload: 'metadata',
|
||||
fluid: false,
|
||||
responsive: false,
|
||||
playbackRates: [0.5, 1, 1.25, 1.5, 2],
|
||||
controlBar: {
|
||||
children: [
|
||||
'playToggle', 'volumePanel', 'currentTimeDisplay',
|
||||
'timeDivider', 'durationDisplay', 'progressControl',
|
||||
'playbackRateMenuButton', 'fullscreenToggle'
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
// Hide title overlay once user starts playing
|
||||
var overlay = document.getElementById('title-overlay');
|
||||
player.on('play', function () {
|
||||
if (overlay) overlay.classList.add('hidden');
|
||||
});
|
||||
player.on('pause', function () {
|
||||
if (overlay) overlay.classList.remove('hidden');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+168
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
<?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 / player');
|
||||
?>
|
||||
<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 · player</p>
|
||||
<p class="footer-note"><a href="https://git.tyclifford.com/">open source</a></p>
|
||||
<p class="footer-note">hobbies are my hobby</p>
|
||||
<p class="footer-note"><a href="mailto:ty@tyclifford.com">ty@tyclifford.com</a></p>
|
||||
</footer>
|
||||
<?php }
|
||||
|
||||
function render_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>';
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
<?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);
|
||||
|
||||
// Build absolute base URL used for embed snippet generation
|
||||
$_scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||
$_host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
$_script = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? '/'), '/');
|
||||
$_base_url = $_scheme . '://' . $_host . $_script . '/';
|
||||
|
||||
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; }
|
||||
|
||||
/* ── Embed panel ── */
|
||||
.embed-panel {
|
||||
border-top:1px solid var(--border);
|
||||
overflow:hidden;
|
||||
max-height:0;
|
||||
transition:max-height .35s cubic-bezier(.4,0,.2,1), padding .35s;
|
||||
padding:0 1.1rem;
|
||||
}
|
||||
.embed-panel.open { max-height:320px; padding:.9rem 1.1rem; }
|
||||
.embed-panel-inner { display:flex; flex-direction:column; gap:.75rem; }
|
||||
.embed-label { font-family:"Share Tech Mono",monospace; font-size:.6rem; letter-spacing:.14em; text-transform:uppercase; color:var(--text-3); }
|
||||
.embed-code-wrap { position:relative; }
|
||||
.embed-code {
|
||||
display:block; width:100%;
|
||||
background:var(--surface-2); border:1px solid var(--border-2); border-radius:var(--r);
|
||||
padding:.65rem .85rem; padding-right:5.5rem;
|
||||
font-family:"Share Tech Mono",monospace; font-size:.68rem; color:var(--text-2);
|
||||
white-space:nowrap; overflow-x:auto; line-height:1.6;
|
||||
resize:none; cursor:text;
|
||||
scrollbar-width:thin;
|
||||
}
|
||||
.embed-copy-btn {
|
||||
position:absolute; right:.4rem; top:50%; transform:translateY(-50%);
|
||||
padding:.32rem .7rem; font-size:.65rem; font-weight:600;
|
||||
background:rgba(176,96,255,.15); color:var(--purple);
|
||||
border:1px solid rgba(176,96,255,.35); border-radius:calc(var(--r) - 1px);
|
||||
cursor:pointer; font-family:"Share Tech Mono",monospace; letter-spacing:.06em;
|
||||
transition:background .2s, color .2s;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.embed-copy-btn:hover { background:rgba(176,96,255,.28); }
|
||||
.embed-copy-btn.copied { background:rgba(0,232,122,.15); color:var(--green); border-color:rgba(0,232,122,.35); }
|
||||
.embed-row { display:flex; gap:.6rem; align-items:center; flex-wrap:wrap; }
|
||||
.embed-direct-link {
|
||||
font-family:"Share Tech Mono",monospace; font-size:.65rem; color:var(--text-3);
|
||||
text-decoration:none; border-bottom:1px solid rgba(255,255,255,.1);
|
||||
transition:color .15s, border-color .15s;
|
||||
}
|
||||
.embed-direct-link:hover { color:var(--green); border-color:rgba(0,232,122,.4); }
|
||||
.embed-preview-link {
|
||||
font-family:"Share Tech Mono",monospace; font-size:.65rem; color:var(--purple);
|
||||
text-decoration:none; border-bottom:1px solid rgba(176,96,255,.2);
|
||||
transition:color .15s;
|
||||
}
|
||||
.embed-preview-link:hover { color:#c880ff; }
|
||||
.embed-toggle-btn {
|
||||
display:inline-flex; align-items:center; gap:.4rem;
|
||||
font-family:"Share Tech Mono",monospace; font-size:.65rem; letter-spacing:.08em;
|
||||
color:var(--text-3); background:transparent; border:1px solid var(--border-2);
|
||||
border-radius:var(--r); padding:.35rem .7rem; cursor:pointer;
|
||||
transition:color .2s, border-color .2s, background .2s;
|
||||
}
|
||||
.embed-toggle-btn:hover { color:var(--text-2); border-color:var(--purple); background:rgba(176,96,255,.07); }
|
||||
.embed-toggle-btn.active { color:var(--purple); border-color:var(--purple); background:rgba(176,96,255,.1); }
|
||||
');
|
||||
?>
|
||||
<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>
|
||||
<?php if ($video): ?>
|
||||
<button class="embed-toggle-btn" id="embed-toggle" aria-expanded="false" aria-controls="embed-panel">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
||||
Embed
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($video):
|
||||
$embed_url = $_base_url . 'embed.php?v=' . urlencode($video['slug']);
|
||||
$iframe_code = '<iframe src="' . $embed_url . '" width="560" height="315" frameborder="0" allowfullscreen allow="fullscreen"></iframe>';
|
||||
?>
|
||||
<div class="embed-panel" id="embed-panel" role="region" aria-label="Embed code">
|
||||
<div class="embed-panel-inner">
|
||||
<div class="form-group" style="gap:.35rem;">
|
||||
<span class="embed-label">iframe embed code</span>
|
||||
<div class="embed-code-wrap">
|
||||
<code class="embed-code" id="embed-code-iframe"><?= h($iframe_code) ?></code>
|
||||
<button class="embed-copy-btn" data-target="embed-code-iframe" onclick="copyEmbed(this)">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="embed-row">
|
||||
<span class="embed-label">Direct player URL —</span>
|
||||
<a class="embed-direct-link" href="<?= h($embed_url) ?>" target="_blank" rel="noopener">
|
||||
<?= h($embed_url) ?>
|
||||
</a>
|
||||
<a class="embed-preview-link" href="<?= h($embed_url) ?>" target="_blank" rel="noopener">
|
||||
↗ Preview
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<span class="embed-label" style="color:var(--text-3);font-size:.58rem;">
|
||||
Recommended: width="560" height="315" — any 16:9 size works. Add style="border:0;border-radius:6px" for no border.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</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">Places</h2>
|
||||
<ul class="social-list" aria-label="Social media links">
|
||||
<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'
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Embed panel toggle
|
||||
var toggleBtn = document.getElementById('embed-toggle');
|
||||
var panel = document.getElementById('embed-panel');
|
||||
if (toggleBtn && panel) {
|
||||
toggleBtn.addEventListener('click', function() {
|
||||
var open = panel.classList.toggle('open');
|
||||
toggleBtn.classList.toggle('active', open);
|
||||
toggleBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Copy embed code to clipboard
|
||||
function copyEmbed(btn) {
|
||||
var targetId = btn.getAttribute('data-target');
|
||||
var el = document.getElementById(targetId);
|
||||
if (!el) return;
|
||||
var text = el.textContent || el.innerText;
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
btn.textContent = 'Copied!';
|
||||
btn.classList.add('copied');
|
||||
setTimeout(function() {
|
||||
btn.textContent = 'Copy';
|
||||
btn.classList.remove('copied');
|
||||
}, 2000);
|
||||
}).catch(function() {
|
||||
// Fallback for older browsers
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
btn.textContent = 'Copied!';
|
||||
btn.classList.add('copied');
|
||||
setTimeout(function() {
|
||||
btn.textContent = 'Copy';
|
||||
btn.classList.remove('copied');
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
BIN
Binary file not shown.
Reference in New Issue
Block a user