- Media player. Multiple formats
This commit is contained in:
@@ -0,0 +1,14 @@
|
|||||||
|
# Protect the data directory from direct web access
|
||||||
|
<IfModule mod_rewrite.c>
|
||||||
|
RewriteEngine On
|
||||||
|
# Block direct access to data/
|
||||||
|
RewriteRule ^data/ - [F,L]
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
# PHP settings for file uploads
|
||||||
|
<IfModule mod_php.c>
|
||||||
|
php_value upload_max_filesize 2048M
|
||||||
|
php_value post_max_size 2100M
|
||||||
|
php_value max_execution_time 300
|
||||||
|
php_value memory_limit 256M
|
||||||
|
</IfModule>
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# TyClifford.com — Media Player Site
|
||||||
|
|
||||||
|
A self-hosted multimedia player website built with PHP + SQLite + Video.js.
|
||||||
|
Styled to match the existing `gate.php` / `index.php` dark theme exactly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- PHP 8.0+ with `pdo_sqlite`, `fileinfo` extensions
|
||||||
|
- Write permissions on the `data/` and `media/` directories
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. **Upload** all files to your web server (e.g. `/live/` or wherever `/index.php` lives).
|
||||||
|
|
||||||
|
2. **Set permissions** so PHP can write to these directories:
|
||||||
|
```bash
|
||||||
|
mkdir -p data media/videos media/thumbs
|
||||||
|
chmod 755 data media media/videos media/thumbs
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Visit** `index.php` — the SQLite database is created automatically on first load.
|
||||||
|
|
||||||
|
4. **Log into admin** at `admin/login.php`
|
||||||
|
- Default password: **`admin`**
|
||||||
|
- ⚠️ Change it immediately in **Admin → Settings → Change Password**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
/ ← web root (deploy here)
|
||||||
|
├── index.php ← Main player + catalogue (home page)
|
||||||
|
├── catalogue.php ← Full video catalogue browse page
|
||||||
|
├── includes/
|
||||||
|
│ ├── db.php ← SQLite bootstrap + helper functions
|
||||||
|
│ └── layout.php ← Shared HTML head, topbar, footer, pagination
|
||||||
|
├── admin/
|
||||||
|
│ ├── index.php ← Video list with search + pagination
|
||||||
|
│ ├── add.php ← Add new video with multi-format sources
|
||||||
|
│ ├── edit.php ← Edit video metadata + manage sources
|
||||||
|
│ ├── settings.php ← Site settings, per-page count, password
|
||||||
|
│ ├── login.php ← Admin login
|
||||||
|
│ ├── logout.php ← Session destroy + redirect
|
||||||
|
│ └── auth.php ← Session auth helpers
|
||||||
|
├── media/ ← All media files (created automatically)
|
||||||
|
│ ├── videos/ ← Uploaded video files
|
||||||
|
│ └── thumbs/ ← Uploaded thumbnail images
|
||||||
|
└── data/
|
||||||
|
└── media.db ← SQLite database (auto-created)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Adding Videos (Admin)
|
||||||
|
|
||||||
|
1. Go to `admin/add.php`
|
||||||
|
2. Fill in title, description, duration, sort order
|
||||||
|
3. Upload a thumbnail (JPG/PNG/WebP)
|
||||||
|
4. Upload one or more video files — each can be a different format or quality:
|
||||||
|
- **MP4** (H.264) — widest compatibility
|
||||||
|
- **WebM** (VP9) — smaller size, modern browsers
|
||||||
|
- **OGV** — Firefox fallback (legacy)
|
||||||
|
5. Set quality labels (1080p, 720p, 480p, etc.)
|
||||||
|
6. Save — Video.js will auto-select the best format the browser supports
|
||||||
|
|
||||||
|
### Catalogue Per-Page
|
||||||
|
|
||||||
|
Configurable in **Admin → Settings → Videos per page** (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`
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/includes/db.php';
|
||||||
|
require_once __DIR__ . '/includes/layout.php';
|
||||||
|
|
||||||
|
$per_page = (int)setting('catalogue_per_page', '10');
|
||||||
|
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||||
|
$paged = get_videos_paginated($page, $per_page);
|
||||||
|
|
||||||
|
render_head('Videos — TyClifford.com', '
|
||||||
|
.cat-grid {
|
||||||
|
display:grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap:1rem;
|
||||||
|
}
|
||||||
|
@media(min-width:600px) { .cat-grid { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); } }
|
||||||
|
@media(min-width:1000px) { .cat-grid { grid-template-columns: repeat(4, 1fr); } }
|
||||||
|
|
||||||
|
.cat-thumb {
|
||||||
|
background:var(--surface-2); border:1px solid var(--border);
|
||||||
|
border-radius:calc(var(--r)+2px); overflow:hidden; text-decoration:none;
|
||||||
|
color:var(--text); display:flex; flex-direction:column;
|
||||||
|
transition:border-color .2s, transform .15s, box-shadow .2s;
|
||||||
|
position:relative; cursor:pointer;
|
||||||
|
}
|
||||||
|
.cat-thumb:hover { border-color:var(--purple); transform:translateY(-2px); box-shadow:0 6px 24px rgba(176,96,255,.18); }
|
||||||
|
.cat-thumb-img { position:relative; width:100%; padding-top:56.25%; background:#000; overflow:hidden; }
|
||||||
|
.cat-thumb-img img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; transition:transform .3s; }
|
||||||
|
.cat-thumb:hover .cat-thumb-img img { transform:scale(1.04); }
|
||||||
|
.cat-thumb-img .no-thumb {
|
||||||
|
position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
|
||||||
|
font-family:"Share Tech Mono",monospace; font-size:.6rem; color:var(--text-3); letter-spacing:.1em;
|
||||||
|
background:var(--surface);
|
||||||
|
}
|
||||||
|
.cat-thumb-dur {
|
||||||
|
position:absolute; bottom:4px; right:6px;
|
||||||
|
background:rgba(0,0,0,.75); backdrop-filter:blur(4px);
|
||||||
|
font-family:"Share Tech Mono",monospace; font-size:.55rem;
|
||||||
|
color:#fff; padding:.15em .45em; border-radius:3px;
|
||||||
|
}
|
||||||
|
.play-overlay {
|
||||||
|
position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
|
||||||
|
background:rgba(0,0,0,.35); opacity:0; transition:opacity .2s;
|
||||||
|
}
|
||||||
|
.cat-thumb:hover .play-overlay { opacity:1; }
|
||||||
|
.cat-thumb-body { padding:.65rem .75rem; display:flex; flex-direction:column; gap:.25rem; flex:1; }
|
||||||
|
.cat-thumb-title { font-size:.85rem; font-weight:600; color:var(--text); line-height:1.35; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
|
||||||
|
.cat-thumb-desc { font-size:.75rem; color:var(--text-3); line-height:1.5; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
|
||||||
|
.cat-thumb-meta { font-family:"Share Tech Mono",monospace; font-size:.6rem; color:var(--text-3); letter-spacing:.06em; margin-top:auto; padding-top:.4rem; }
|
||||||
|
|
||||||
|
.page-header { display:flex; flex-direction:column; gap:.5rem; }
|
||||||
|
.page-header-row { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; }
|
||||||
|
|
||||||
|
.cat-footer { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; margin-top:.5rem; }
|
||||||
|
.cat-count { font-family:"Share Tech Mono",monospace; font-size:.65rem; color:var(--text-3); letter-spacing:.06em; }
|
||||||
|
|
||||||
|
.empty-state { padding:4rem 1rem; text-align:center; display:flex; flex-direction:column; align-items:center; gap:1rem; }
|
||||||
|
.empty-state svg { color:var(--text-3); opacity:.35; }
|
||||||
|
.empty-state p { font-family:"Share Tech Mono",monospace; font-size:.75rem; color:var(--text-3); letter-spacing:.1em; }
|
||||||
|
');
|
||||||
|
?>
|
||||||
|
<main class="page" role="main">
|
||||||
|
|
||||||
|
<?php render_topbar('catalogue'); ?>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="page-header-row">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow eyebrow-purple" style="margin-bottom:.3rem">Library</p>
|
||||||
|
<h1 style="font-size:1.1rem;font-weight:700;color:var(--text);">Video Catalogue</h1>
|
||||||
|
</div>
|
||||||
|
<span class="cat-count"><?= $paged['total'] ?> video<?= $paged['total'] != 1 ? 's' : '' ?></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (empty($paged['videos'])): ?>
|
||||||
|
<div class="card">
|
||||||
|
<div class="empty-state">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||||
|
<p>no videos in the catalogue yet</p>
|
||||||
|
<a href="admin/index.php" class="btn btn-secondary btn-sm">Go to Admin →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="cat-grid">
|
||||||
|
<?php foreach ($paged['videos'] as $v):
|
||||||
|
$vslug = $v['slug'];
|
||||||
|
$vtitle = $v['title'];
|
||||||
|
$vdesc = $v['description'];
|
||||||
|
$vthumb = $v['thumbnail'];
|
||||||
|
$vdur = (int)$v['duration'];
|
||||||
|
$created = substr($v['created_at'], 0, 10);
|
||||||
|
?>
|
||||||
|
<a class="cat-thumb" href="../index.php?v=<?= urlencode($vslug) ?>" title="<?= h($vtitle) ?>">
|
||||||
|
<div class="cat-thumb-img">
|
||||||
|
<?php if ($vthumb): ?>
|
||||||
|
<img src="<?= h('../' . MEDIA_URL . $vthumb) ?>" alt="<?= h($vtitle) ?>" loading="lazy">
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="no-thumb">NO THUMBNAIL</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($vdur): ?>
|
||||||
|
<span class="cat-thumb-dur"><?= format_duration($vdur) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div class="play-overlay">
|
||||||
|
<svg width="36" height="36" viewBox="0 0 24 24" fill="white"><circle cx="12" cy="12" r="12" fill="rgba(0,0,0,.4)"/><polygon points="10,8 18,12 10,16" fill="white"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cat-thumb-body">
|
||||||
|
<span class="cat-thumb-title"><?= h($vtitle) ?></span>
|
||||||
|
<?php if ($vdesc): ?>
|
||||||
|
<span class="cat-thumb-desc"><?= h($vdesc) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<span class="cat-thumb-meta"><?= h($created) ?><?= $vdur ? ' · ' . format_duration($vdur) : '' ?></span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($paged['total_pages'] > 1): ?>
|
||||||
|
<div class="cat-footer">
|
||||||
|
<span class="cat-count">page <?= $paged['page'] ?> of <?= $paged['total_pages'] ?></span>
|
||||||
|
<?php render_pagination($page, $paged['total_pages'], 'catalogue.php?page=%d'); ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php render_footer(); ?>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Require all denied
|
||||||
@@ -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,275 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* layout.php — shared HTML head, header, and footer partials
|
||||||
|
*/
|
||||||
|
|
||||||
|
function render_head(string $title = 'Media — TyClifford.com', string $extra_css = ''): void { ?>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title><?= h($title) ?></title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
|
||||||
|
<!-- Video.js -->
|
||||||
|
<link href="https://vjs.zencdn.net/8.10.0/video-js.css" rel="stylesheet" />
|
||||||
|
<script src="https://vjs.zencdn.net/8.10.0/video.min.js"></script>
|
||||||
|
<style>
|
||||||
|
/* ── Reset & design tokens (mirrors gate.php exactly) ──────────────────── */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #09090f;
|
||||||
|
--surface: #0f0f1a;
|
||||||
|
--surface-2: #141425;
|
||||||
|
--border: #1e1e38;
|
||||||
|
--border-2: #2a2a48;
|
||||||
|
--text: #e8e8f8;
|
||||||
|
--text-2: #9898c0;
|
||||||
|
--text-3: #58587a;
|
||||||
|
|
||||||
|
--green: #00e87a;
|
||||||
|
--blue: #00c8ff;
|
||||||
|
--purple: #b060ff;
|
||||||
|
--red: #ff3355;
|
||||||
|
--orange: #ff8800;
|
||||||
|
--yellow: #ffd040;
|
||||||
|
|
||||||
|
--pat: #ff424d;
|
||||||
|
--pat-lite: rgba(255,66,77,.10);
|
||||||
|
--pat-border:rgba(255,66,77,.32);
|
||||||
|
--pat-glow: rgba(255,66,77,.18);
|
||||||
|
|
||||||
|
--r: 6px;
|
||||||
|
--gap: 1.25rem;
|
||||||
|
--page-pad: 1rem;
|
||||||
|
}
|
||||||
|
@media (min-width:600px) { :root { --page-pad: 1.5rem; } }
|
||||||
|
@media (min-width:1000px) { :root { --page-pad: 2rem; } }
|
||||||
|
|
||||||
|
html { font-size:16px; -webkit-text-size-adjust:100%; scroll-behavior:smooth; }
|
||||||
|
body {
|
||||||
|
min-height: 100dvh;
|
||||||
|
background: var(--bg);
|
||||||
|
background-image:
|
||||||
|
radial-gradient(ellipse 70% 50% at 20% 10%, rgba(176,96,255,.05) 0%, transparent 60%),
|
||||||
|
radial-gradient(ellipse 60% 40% at 80% 80%, rgba(0,200,255,.04) 0%, transparent 60%),
|
||||||
|
radial-gradient(ellipse 50% 60% at 50% 50%, rgba(255,66,77,.03) 0%, transparent 70%);
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
body::after {
|
||||||
|
content:''; position:fixed; inset:0; pointer-events:none; z-index:999;
|
||||||
|
background: repeating-linear-gradient(0deg,transparent 0px,transparent 3px,rgba(0,0,0,.06) 3px,rgba(0,0,0,.06) 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rise { from{opacity:0;transform:translateY(14px) scale(.98)} to{opacity:1;transform:translateY(0) scale(1)} }
|
||||||
|
@keyframes shimmer{ from{background-position:0% 50%} to{background-position:200% 50%} }
|
||||||
|
@keyframes pulse-border { 0%,100%{box-shadow:0 0 0 0 var(--pat-glow)} 50%{box-shadow:0 0 0 6px transparent} }
|
||||||
|
@keyframes live-pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
|
||||||
|
|
||||||
|
/* ── Page wrapper ── */
|
||||||
|
.page { width:100%; max-width:1100px; padding:var(--page-pad); display:flex; flex-direction:column; gap:1.5rem; animation:rise .45s cubic-bezier(.22,.68,0,1.2) both; }
|
||||||
|
|
||||||
|
/* ── Top bar ── */
|
||||||
|
.topbar { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; }
|
||||||
|
.topbar-brand { display:flex; align-items:center; gap:.75rem; }
|
||||||
|
.brand-name { font-size:1rem; font-weight:700; letter-spacing:-.01em; color:var(--text); text-decoration:none; }
|
||||||
|
.brand-sub { font-family:'Share Tech Mono',monospace; font-size:.62rem; letter-spacing:.18em; text-transform:uppercase; color:var(--text-3); }
|
||||||
|
.topbar-social { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; }
|
||||||
|
.social-pill {
|
||||||
|
display:inline-flex; align-items:center; gap:.4rem;
|
||||||
|
padding:.35rem .75rem;
|
||||||
|
background:rgba(255,255,255,.03);
|
||||||
|
border:1px solid var(--border-2);
|
||||||
|
border-radius:99px;
|
||||||
|
font-family:'Share Tech Mono',monospace; font-size:.65rem; letter-spacing:.08em;
|
||||||
|
color:var(--text-3); text-decoration:none;
|
||||||
|
transition:border-color .2s, color .2s, background .2s;
|
||||||
|
}
|
||||||
|
.social-pill:hover { color:var(--text-2); background:rgba(255,255,255,.06); }
|
||||||
|
.social-pill svg { flex-shrink:0; }
|
||||||
|
.social-pill.active { border-color:var(--purple); color:var(--purple); }
|
||||||
|
|
||||||
|
/* ── Cards ── */
|
||||||
|
.card {
|
||||||
|
background:var(--surface);
|
||||||
|
border:1px solid var(--border);
|
||||||
|
border-radius:calc(var(--r) + 2px);
|
||||||
|
padding:1.4rem 1.5rem;
|
||||||
|
display:flex; flex-direction:column; gap:.9rem;
|
||||||
|
position:relative; overflow:hidden;
|
||||||
|
}
|
||||||
|
.card::after {
|
||||||
|
content:''; position:absolute; bottom:-1px; right:-1px;
|
||||||
|
width:14px; height:14px;
|
||||||
|
border-bottom:2px solid var(--purple); border-right:2px solid var(--purple);
|
||||||
|
border-radius:0 0 calc(var(--r)+2px) 0; opacity:.5;
|
||||||
|
}
|
||||||
|
.card-title { font-size:.95rem; font-weight:700; letter-spacing:-.01em; color:var(--text); line-height:1.2; }
|
||||||
|
.card-body { font-size:.85rem; color:var(--text-2); line-height:1.7; }
|
||||||
|
.card-body strong { color:var(--text); font-weight:600; }
|
||||||
|
.card-body a { color:var(--pat); text-decoration:none; border-bottom:1px solid rgba(255,66,77,.25); transition:border-color .15s; }
|
||||||
|
.card-body a:hover { border-color:rgba(255,66,77,.6); }
|
||||||
|
|
||||||
|
/* ── Eyebrow ── */
|
||||||
|
.eyebrow { font-family:'Share Tech Mono',monospace; font-size:.62rem; letter-spacing:.2em; text-transform:uppercase; }
|
||||||
|
.eyebrow-green { color:var(--green); text-shadow:0 0 10px rgba(0,232,122,.5); }
|
||||||
|
.eyebrow-green::before { content:'> '; opacity:.5; }
|
||||||
|
.eyebrow-purple { color:var(--purple); }
|
||||||
|
.eyebrow-blue { color:var(--blue); }
|
||||||
|
|
||||||
|
/* ── Stream card / player card ── */
|
||||||
|
.stream-card {
|
||||||
|
background:var(--surface); border:1px solid var(--border);
|
||||||
|
border-radius:calc(var(--r)+2px); overflow:hidden; position:relative;
|
||||||
|
box-shadow:0 0 0 1px rgba(255,255,255,.03), 0 4px 50px rgba(0,0,0,.6);
|
||||||
|
}
|
||||||
|
.stream-card::before {
|
||||||
|
content:''; position:absolute; top:0; left:0; right:0; height:2px; z-index:2;
|
||||||
|
background:linear-gradient(90deg,var(--pat),var(--purple),var(--blue),var(--green),var(--blue),var(--purple),var(--pat));
|
||||||
|
background-size:300% 100%; animation:shimmer 5s linear infinite;
|
||||||
|
}
|
||||||
|
.stream-ratio { position:relative; width:100%; padding-top:56.25%; background:#000; }
|
||||||
|
.stream-ratio > * { position:absolute; inset:0; width:100%; height:100%; border:0; display:block; }
|
||||||
|
.stream-footer {
|
||||||
|
display:flex; align-items:center; justify-content:space-between; gap:.75rem;
|
||||||
|
padding:.75rem 1.1rem; border-top:1px solid var(--border); flex-wrap:wrap;
|
||||||
|
}
|
||||||
|
.stream-meta { font-family:'Share Tech Mono',monospace; font-size:.65rem; letter-spacing:.1em; color:var(--text-3); }
|
||||||
|
.stream-meta span { color:var(--text-2); }
|
||||||
|
|
||||||
|
/* ── Footer ── */
|
||||||
|
.page-footer {
|
||||||
|
padding:.5rem 0 1rem; display:flex; align-items:center; justify-content:center;
|
||||||
|
gap:1.5rem; flex-wrap:wrap; border-top:1px solid var(--border);
|
||||||
|
}
|
||||||
|
.footer-note { font-family:'Share Tech Mono',monospace; font-size:.63rem; color:var(--text-3); letter-spacing:.06em; text-align:center; }
|
||||||
|
.footer-note::before { content:'// '; opacity:.4; }
|
||||||
|
.footer-note a { color:var(--text-3); text-decoration:none; transition:color .15s; }
|
||||||
|
.footer-note a:hover { color:var(--text-2); }
|
||||||
|
|
||||||
|
/* ── Buttons ── */
|
||||||
|
.btn {
|
||||||
|
display:inline-flex; align-items:center; gap:.45rem;
|
||||||
|
padding:.6rem 1.1rem; border-radius:var(--r);
|
||||||
|
font-family:'Inter',sans-serif; font-size:.82rem; font-weight:600;
|
||||||
|
text-decoration:none; cursor:pointer; border:none;
|
||||||
|
transition:transform .15s, filter .15s, background .2s;
|
||||||
|
}
|
||||||
|
.btn:hover { transform:translateY(-1px); filter:brightness(1.08); }
|
||||||
|
.btn:active { transform:scale(.98); }
|
||||||
|
.btn-primary { background:var(--purple); color:#fff; box-shadow:0 4px 18px rgba(176,96,255,.25); }
|
||||||
|
.btn-secondary { background:rgba(255,255,255,.06); color:var(--text-2); border:1px solid var(--border-2); }
|
||||||
|
.btn-secondary:hover { color:var(--text); background:rgba(255,255,255,.1); }
|
||||||
|
.btn-danger { background:rgba(255,51,85,.15); color:var(--red); border:1px solid rgba(255,51,85,.3); }
|
||||||
|
.btn-danger:hover { background:rgba(255,51,85,.25); }
|
||||||
|
.btn-green { background:rgba(0,232,122,.12); color:var(--green); border:1px solid rgba(0,232,122,.3); }
|
||||||
|
.btn-green:hover { background:rgba(0,232,122,.2); }
|
||||||
|
.btn-sm { padding:.38rem .75rem; font-size:.75rem; }
|
||||||
|
|
||||||
|
/* ── Form controls ── */
|
||||||
|
.form-group { display:flex; flex-direction:column; gap:.4rem; }
|
||||||
|
.form-label { font-family:'Share Tech Mono',monospace; font-size:.65rem; letter-spacing:.12em; text-transform:uppercase; color:var(--text-3); }
|
||||||
|
.form-input, .form-select, .form-textarea {
|
||||||
|
background:var(--surface-2); border:1px solid var(--border-2); border-radius:var(--r);
|
||||||
|
color:var(--text); padding:.6rem .85rem; font-family:'Inter',sans-serif; font-size:.85rem;
|
||||||
|
transition:border-color .2s;
|
||||||
|
width:100%;
|
||||||
|
}
|
||||||
|
.form-input:focus, .form-select:focus, .form-textarea:focus {
|
||||||
|
outline:none; border-color:var(--purple);
|
||||||
|
box-shadow:0 0 0 2px rgba(176,96,255,.15);
|
||||||
|
}
|
||||||
|
.form-textarea { resize:vertical; min-height:80px; }
|
||||||
|
.form-select option { background:var(--surface-2); }
|
||||||
|
|
||||||
|
/* ── Notice/alert ── */
|
||||||
|
.notice {
|
||||||
|
padding:.75rem 1rem; border-radius:var(--r); font-size:.82rem;
|
||||||
|
border:1px solid; margin-bottom:1rem;
|
||||||
|
}
|
||||||
|
.notice-success { background:rgba(0,232,122,.08); border-color:rgba(0,232,122,.3); color:var(--green); }
|
||||||
|
.notice-error { background:rgba(255,51,85,.08); border-color:rgba(255,51,85,.3); color:var(--red); }
|
||||||
|
.notice-info { background:rgba(0,200,255,.08); border-color:rgba(0,200,255,.3); color:var(--blue); }
|
||||||
|
|
||||||
|
/* ── Pagination ── */
|
||||||
|
.pagination { display:flex; align-items:center; gap:.4rem; flex-wrap:wrap; }
|
||||||
|
.pagination a, .pagination span {
|
||||||
|
display:inline-flex; align-items:center; justify-content:center;
|
||||||
|
min-width:2rem; height:2rem; padding:0 .5rem;
|
||||||
|
border-radius:var(--r); font-family:'Share Tech Mono',monospace; font-size:.72rem;
|
||||||
|
text-decoration:none; transition:background .2s, color .2s, border-color .2s;
|
||||||
|
border:1px solid var(--border-2); color:var(--text-3);
|
||||||
|
}
|
||||||
|
.pagination a:hover { border-color:var(--purple); color:var(--purple); background:rgba(176,96,255,.08); }
|
||||||
|
.pagination .current { border-color:var(--purple); color:var(--purple); background:rgba(176,96,255,.15); }
|
||||||
|
.pagination .disabled { opacity:.3; pointer-events:none; }
|
||||||
|
|
||||||
|
<?= $extra_css ?>
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<?php }
|
||||||
|
|
||||||
|
function render_topbar(string $active = ''): void {
|
||||||
|
$title = setting('site_title', 'Ty Clifford');
|
||||||
|
$sub = setting('site_sub', 'tyclifford.com / media');
|
||||||
|
?>
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar-brand">
|
||||||
|
<div>
|
||||||
|
<a href="index.php" class="brand-name"><?= h($title) ?></a>
|
||||||
|
<div class="brand-sub"><?= h($sub) ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav class="topbar-social" aria-label="Navigation">
|
||||||
|
<a class="social-pill <?= $active==='home' ? 'active' : '' ?>" href="index.php">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9,22 9,12 15,12 15,22"/></svg>
|
||||||
|
Home
|
||||||
|
</a>
|
||||||
|
<a class="social-pill <?= $active==='catalogue' ? 'active' : '' ?>" href="catalogue.php">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||||
|
Videos
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<?php }
|
||||||
|
|
||||||
|
function render_footer(): void { ?>
|
||||||
|
<footer class="page-footer" role="contentinfo">
|
||||||
|
<p class="footer-note">TyClifford.com · media</p>
|
||||||
|
<p class="footer-note">hobbies are my hobby</p>
|
||||||
|
<p class="footer-note"><a href="mailto:ty@tyclifford.com">ty@tyclifford.com</a></p>
|
||||||
|
</footer>
|
||||||
|
<?php }
|
||||||
|
|
||||||
|
function render_pagination(int $current, int $total, string $url_pattern): void {
|
||||||
|
if ($total <= 1) return;
|
||||||
|
echo '<div class="pagination">';
|
||||||
|
$prev = $current - 1;
|
||||||
|
$next = $current + 1;
|
||||||
|
if ($prev >= 1) {
|
||||||
|
echo '<a href="' . sprintf($url_pattern, $prev) . '">‹</a>';
|
||||||
|
} else {
|
||||||
|
echo '<span class="disabled">‹</span>';
|
||||||
|
}
|
||||||
|
$range = range(max(1, $current-2), min($total, $current+2));
|
||||||
|
if (!in_array(1, $range)) { echo '<a href="' . sprintf($url_pattern, 1) . '">1</a><span>…</span>'; }
|
||||||
|
foreach ($range as $p) {
|
||||||
|
if ($p === $current) echo '<span class="current">' . $p . '</span>';
|
||||||
|
else echo '<a href="' . sprintf($url_pattern, $p) . '">' . $p . '</a>';
|
||||||
|
}
|
||||||
|
if (!in_array($total, $range)) { echo '<span>…</span><a href="' . sprintf($url_pattern, $total) . '">' . $total . '</a>'; }
|
||||||
|
if ($next <= $total) {
|
||||||
|
echo '<a href="' . sprintf($url_pattern, $next) . '">›</a>';
|
||||||
|
} else {
|
||||||
|
echo '<span class="disabled">›</span>';
|
||||||
|
}
|
||||||
|
echo '</div>';
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/includes/db.php';
|
||||||
|
require_once __DIR__ . '/includes/layout.php';
|
||||||
|
|
||||||
|
// Determine which video to play
|
||||||
|
$slug = $_GET['v'] ?? '';
|
||||||
|
$video = $slug ? get_video_by_slug($slug) : null;
|
||||||
|
|
||||||
|
// Latest video as default if none specified
|
||||||
|
if (!$video) {
|
||||||
|
$db = get_db();
|
||||||
|
$latest = $db->query("SELECT slug FROM videos WHERE published=1 ORDER BY sort_order ASC, id DESC LIMIT 1")->fetch();
|
||||||
|
if ($latest) $video = get_video_by_slug($latest['slug']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$per_page = (int)setting('catalogue_per_page', '10');
|
||||||
|
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||||
|
$paged = get_videos_paginated($page, $per_page);
|
||||||
|
|
||||||
|
render_head('Media — TyClifford.com', '
|
||||||
|
/* ── Catalogue grid ── */
|
||||||
|
.cat-section { display:flex; flex-direction:column; gap:1rem; }
|
||||||
|
.cat-header { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; }
|
||||||
|
.cat-grid {
|
||||||
|
display:grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap:.85rem;
|
||||||
|
}
|
||||||
|
@media(min-width:600px) { .cat-grid { grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); } }
|
||||||
|
@media(min-width:1000px) { .cat-grid { grid-template-columns: repeat(5, 1fr); } }
|
||||||
|
|
||||||
|
.cat-thumb {
|
||||||
|
background:var(--surface-2);
|
||||||
|
border:1px solid var(--border);
|
||||||
|
border-radius:calc(var(--r)+2px);
|
||||||
|
overflow:hidden;
|
||||||
|
text-decoration:none;
|
||||||
|
color:var(--text);
|
||||||
|
display:flex; flex-direction:column;
|
||||||
|
transition:border-color .2s, transform .15s, box-shadow .2s;
|
||||||
|
position:relative;
|
||||||
|
cursor:pointer;
|
||||||
|
}
|
||||||
|
.cat-thumb:hover { border-color:var(--purple); transform:translateY(-2px); box-shadow:0 6px 24px rgba(176,96,255,.18); }
|
||||||
|
.cat-thumb.active { border-color:var(--green); box-shadow:0 0 0 1px var(--green), 0 4px 20px rgba(0,232,122,.2); }
|
||||||
|
.cat-thumb-img {
|
||||||
|
position:relative; width:100%; padding-top:56.25%; background:#000;
|
||||||
|
overflow:hidden;
|
||||||
|
}
|
||||||
|
.cat-thumb-img img {
|
||||||
|
position:absolute; inset:0; width:100%; height:100%; object-fit:cover;
|
||||||
|
transition:transform .3s;
|
||||||
|
}
|
||||||
|
.cat-thumb:hover .cat-thumb-img img { transform:scale(1.04); }
|
||||||
|
.cat-thumb-img .no-thumb {
|
||||||
|
position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
|
||||||
|
font-family:"Share Tech Mono",monospace; font-size:.6rem; color:var(--text-3); letter-spacing:.1em;
|
||||||
|
background:var(--surface);
|
||||||
|
}
|
||||||
|
.cat-thumb-dur {
|
||||||
|
position:absolute; bottom:4px; right:6px;
|
||||||
|
background:rgba(0,0,0,.75); backdrop-filter:blur(4px);
|
||||||
|
font-family:"Share Tech Mono",monospace; font-size:.55rem; letter-spacing:.05em;
|
||||||
|
color:#fff; padding:.15em .45em; border-radius:3px;
|
||||||
|
}
|
||||||
|
.play-overlay {
|
||||||
|
position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
|
||||||
|
background:rgba(0,0,0,.35); opacity:0; transition:opacity .2s;
|
||||||
|
}
|
||||||
|
.cat-thumb:hover .play-overlay { opacity:1; }
|
||||||
|
.play-overlay svg { filter:drop-shadow(0 2px 8px rgba(0,0,0,.5)); }
|
||||||
|
.cat-thumb-body { padding:.55rem .65rem; display:flex; flex-direction:column; gap:.2rem; flex:1; }
|
||||||
|
.cat-thumb-title { font-size:.78rem; font-weight:600; color:var(--text); line-height:1.35; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
|
||||||
|
.cat-thumb-meta { font-family:"Share Tech Mono",monospace; font-size:.58rem; color:var(--text-3); letter-spacing:.06em; margin-top:auto; padding-top:.35rem; }
|
||||||
|
.cat-active-indicator {
|
||||||
|
position:absolute; top:0; left:0; right:0; height:2px;
|
||||||
|
background:var(--green); opacity:0; transition:opacity .2s;
|
||||||
|
}
|
||||||
|
.cat-thumb.active .cat-active-indicator { opacity:1; }
|
||||||
|
|
||||||
|
/* ── Video.js custom skin ── */
|
||||||
|
.video-js {
|
||||||
|
width:100% !important;
|
||||||
|
height:100% !important;
|
||||||
|
position:absolute !important;
|
||||||
|
inset:0 !important;
|
||||||
|
}
|
||||||
|
.video-js .vjs-big-play-button {
|
||||||
|
background:rgba(176,96,255,.75) !important;
|
||||||
|
border:2px solid rgba(176,96,255,.9) !important;
|
||||||
|
border-radius:50% !important;
|
||||||
|
width:64px !important; height:64px !important;
|
||||||
|
top:50% !important; left:50% !important;
|
||||||
|
transform:translate(-50%,-50%) !important;
|
||||||
|
margin:0 !important;
|
||||||
|
line-height:64px !important;
|
||||||
|
transition:background .2s !important;
|
||||||
|
}
|
||||||
|
.video-js:hover .vjs-big-play-button { background:rgba(176,96,255,.95) !important; }
|
||||||
|
.video-js .vjs-control-bar {
|
||||||
|
background:linear-gradient(transparent,rgba(0,0,0,.85)) !important;
|
||||||
|
height:3.2em !important;
|
||||||
|
}
|
||||||
|
.video-js .vjs-play-progress { background:var(--green) !important; }
|
||||||
|
.video-js .vjs-volume-level { background:var(--green) !important; }
|
||||||
|
.video-js .vjs-slider { background:rgba(255,255,255,.15) !important; }
|
||||||
|
.video-js .vjs-load-progress { background:rgba(255,255,255,.1) !important; }
|
||||||
|
.video-js .vjs-current-time,
|
||||||
|
.video-js .vjs-duration,
|
||||||
|
.video-js .vjs-time-divider { color:rgba(255,255,255,.8) !important; font-size:.7em !important; }
|
||||||
|
|
||||||
|
/* ── Lower grid ── */
|
||||||
|
.lower-grid { display:grid; grid-template-columns:1fr; gap:1rem; }
|
||||||
|
@media(min-width:640px) { .lower-grid { grid-template-columns:1fr 1fr; } }
|
||||||
|
@media(min-width:900px) { .lower-grid { grid-template-columns:2fr 1fr 1fr; } }
|
||||||
|
.card-about { background:var(--surface-2); }
|
||||||
|
.card-social { background:var(--surface-2); }
|
||||||
|
.social-list { list-style:none; display:flex; flex-direction:column; gap:.5rem; }
|
||||||
|
.social-list a {
|
||||||
|
display:flex; align-items:center; gap:.65rem;
|
||||||
|
padding:.6rem .8rem; border:1px solid var(--border-2); border-radius:var(--r);
|
||||||
|
font-family:"Share Tech Mono",monospace; font-size:.78rem; color:var(--text-2);
|
||||||
|
text-decoration:none; transition:border-color .2s, color .2s, background .2s;
|
||||||
|
}
|
||||||
|
.social-list a:hover { border-color:var(--green); color:var(--text); background:rgba(0,232,122,.04); }
|
||||||
|
.social-list a svg { flex-shrink:0; color:var(--text-3); transition:color .2s; }
|
||||||
|
.social-list a:hover svg { color:var(--green); }
|
||||||
|
|
||||||
|
/* empty state */
|
||||||
|
.empty-state {
|
||||||
|
padding:3rem 1rem; text-align:center; display:flex; flex-direction:column;
|
||||||
|
align-items:center; gap:1rem;
|
||||||
|
}
|
||||||
|
.empty-state svg { color:var(--text-3); opacity:.4; }
|
||||||
|
.empty-state p { font-family:"Share Tech Mono",monospace; font-size:.75rem; color:var(--text-3); letter-spacing:.1em; }
|
||||||
|
|
||||||
|
/* no-video placeholder */
|
||||||
|
.no-video { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:.75rem; }
|
||||||
|
.no-video p { font-family:"Share Tech Mono",monospace; font-size:.7rem; color:var(--text-3); letter-spacing:.1em; }
|
||||||
|
|
||||||
|
/* pagination row */
|
||||||
|
.cat-footer { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; }
|
||||||
|
.cat-count { font-family:"Share Tech Mono",monospace; font-size:.65rem; color:var(--text-3); letter-spacing:.06em; }
|
||||||
|
');
|
||||||
|
?>
|
||||||
|
<main class="page" role="main">
|
||||||
|
|
||||||
|
<?php render_topbar('home'); ?>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════ PLAYER CARD ════════════════════════════════ -->
|
||||||
|
<section class="stream-card" aria-label="Media player">
|
||||||
|
<div class="stream-ratio">
|
||||||
|
<?php if ($video && !empty($video['sources'])): ?>
|
||||||
|
<video id="main-player"
|
||||||
|
class="video-js vjs-default-skin vjs-big-play-centered"
|
||||||
|
controls preload="metadata"
|
||||||
|
<?php if ($video['thumbnail']): ?>
|
||||||
|
poster="<?= h(MEDIA_URL . $video['thumbnail']) ?>"
|
||||||
|
<?php endif; ?>
|
||||||
|
data-setup='{"fluid":false,"responsive":true,"playbackRates":[0.5,1,1.25,1.5,2]}'>
|
||||||
|
<?php foreach ($video['sources'] as $src): ?>
|
||||||
|
<source src="<?= h(MEDIA_URL . $src['file_path']) ?>" type="<?= h($src['mime_type']) ?>" label="<?= h($src['quality'] ?: $src['label']) ?>">
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<p class="vjs-no-js">Your browser does not support HTML video.</p>
|
||||||
|
</video>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="no-video">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity=".25"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||||
|
<p>no video selected</p>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stream-footer">
|
||||||
|
<p class="eyebrow eyebrow-green"><?= $video ? h($video['title']) : 'Media Player' ?></p>
|
||||||
|
<?php if ($video && $video['description']): ?>
|
||||||
|
<p class="stream-meta" style="font-family:Inter,sans-serif;font-size:.78rem;color:var(--text-2);letter-spacing:0;max-width:60ch;line-height:1.5"><?= h($video['description']) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<p class="stream-meta">
|
||||||
|
<?php if ($video && $video['duration']): ?>
|
||||||
|
<span><?= format_duration((int)$video['duration']) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════ VIDEO CATALOGUE ═══════════════════════════ -->
|
||||||
|
<section class="cat-section">
|
||||||
|
<div class="cat-header">
|
||||||
|
<p class="eyebrow eyebrow-purple">Video Catalogue</p>
|
||||||
|
<a href="catalogue.php" class="btn btn-secondary btn-sm">Browse all →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (empty($paged['videos'])): ?>
|
||||||
|
<div class="card">
|
||||||
|
<div class="empty-state">
|
||||||
|
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14,2 14,8 20,8"/></svg>
|
||||||
|
<p>no videos yet</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="cat-grid">
|
||||||
|
<?php foreach ($paged['videos'] as $v):
|
||||||
|
$active = $video && $video['id'] == $v['id'];
|
||||||
|
$vslug = $v['slug'];
|
||||||
|
$vtitle = $v['title'];
|
||||||
|
$vthumb = $v['thumbnail'];
|
||||||
|
$vdur = (int)$v['duration'];
|
||||||
|
$created = substr($v['created_at'], 0, 10);
|
||||||
|
?>
|
||||||
|
<a class="cat-thumb <?= $active ? 'active' : '' ?>"
|
||||||
|
href="?v=<?= urlencode($vslug) ?>"
|
||||||
|
title="<?= h($vtitle) ?>">
|
||||||
|
<div class="cat-active-indicator"></div>
|
||||||
|
<div class="cat-thumb-img">
|
||||||
|
<?php if ($vthumb): ?>
|
||||||
|
<img src="<?= h(MEDIA_URL . $vthumb) ?>" alt="<?= h($vtitle) ?>" loading="lazy">
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="no-thumb">NO THUMB</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($vdur): ?>
|
||||||
|
<span class="cat-thumb-dur"><?= format_duration($vdur) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div class="play-overlay">
|
||||||
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="white"><circle cx="12" cy="12" r="12" fill="rgba(0,0,0,.4)"/><polygon points="10,8 18,12 10,16" fill="white"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cat-thumb-body">
|
||||||
|
<span class="cat-thumb-title"><?= h($vtitle) ?></span>
|
||||||
|
<span class="cat-thumb-meta"><?= h($created) ?></span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($paged['total_pages'] > 1): ?>
|
||||||
|
<div class="cat-footer">
|
||||||
|
<span class="cat-count">
|
||||||
|
<?= $paged['total'] ?> video<?= $paged['total'] != 1 ? 's' : '' ?>
|
||||||
|
· page <?= $paged['page'] ?> of <?= $paged['total_pages'] ?>
|
||||||
|
</span>
|
||||||
|
<?php
|
||||||
|
$url_v = $video ? '&v=' . urlencode($video['slug']) : '';
|
||||||
|
render_pagination($page, $paged['total_pages'], '?page=%d' . $url_v);
|
||||||
|
?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════ LOWER GRID ════════════════════════════════ -->
|
||||||
|
<div class="lower-grid">
|
||||||
|
|
||||||
|
<div class="card card-about">
|
||||||
|
<p class="eyebrow eyebrow-green" style="margin-bottom:.1rem">About</p>
|
||||||
|
<h2 class="card-title">Ty Clifford</h2>
|
||||||
|
<p class="card-body">
|
||||||
|
Based in <strong>Keyser, West Virginia</strong>. Nerd, tinkerer, and creator.
|
||||||
|
Hobbies are my hobby — from tech and podcasting to whatever rabbit hole I fell into this week.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-social">
|
||||||
|
<p class="eyebrow eyebrow-green" style="margin-bottom:.1rem">Links</p>
|
||||||
|
<h2 class="card-title">Find me online</h2>
|
||||||
|
<ul class="social-list" aria-label="Social media links">
|
||||||
|
<li>
|
||||||
|
<a href="https://github.com/snick512" target="_blank" rel="noopener">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 00-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0020 4.77 5.07 5.07 0 0019.91 1S18.73.65 16 2.48a13.38 13.38 0 00-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 005 4.77a5.44 5.44 0 00-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 009 18.13V22"/></svg>
|
||||||
|
github.com/snick512
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="mailto:ty@tyclifford.com">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="2,4 12,13 22,4"/></svg>
|
||||||
|
ty@tyclifford.com
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://go.tyclifford.com/patreon" target="_blank" rel="noopener">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 12 14" fill="currentColor"><ellipse cx="7.5" cy="4.8" rx="4.5" ry="4.5"/><rect x="0" y="0" width="2.8" height="14" rx="1.2"/></svg>
|
||||||
|
patreon.com/tyclifford
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php render_footer(); ?>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Init Video.js if player exists
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var el = document.getElementById('main-player');
|
||||||
|
if (el) {
|
||||||
|
var player = videojs('main-player', {
|
||||||
|
controls: true,
|
||||||
|
autoplay: false,
|
||||||
|
preload: 'metadata',
|
||||||
|
fluid: false,
|
||||||
|
responsive: true,
|
||||||
|
playbackRates: [0.5, 1, 1.25, 1.5, 2],
|
||||||
|
controlBar: {
|
||||||
|
children: [
|
||||||
|
'playToggle','volumePanel','currentTimeDisplay',
|
||||||
|
'timeDivider','durationDisplay','progressControl',
|
||||||
|
'playbackRateMenuButton','fullscreenToggle'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Vendored
BIN
Binary file not shown.
Reference in New Issue
Block a user