Compare commits

..

12 Commits

Author SHA1 Message Date
Ty Clifford ea46d147d9 - misc. fixes 2026-06-17 23:43:24 -04:00
Ty Clifford 854518c79b - Standalone video embed player 2026-06-17 10:01:22 -04:00
Ty Clifford 6e96e34c0e - Search, pagination 2026-06-07 12:06:03 -04:00
Ty Clifford c22e525fb7 - Revisit later 2026-06-05 10:27:17 -04:00
Ty Clifford 22628cfacc - a test commit 2026-06-04 09:39:30 -04:00
Ty Clifford a2a02e5e02 - Embed 2026-06-04 08:22:37 -04:00
Ty Clifford cc03ea530b - import.php 2026-06-04 02:11:34 -04:00
Ty Clifford 5d652ae657 - Places, and open source link 2026-06-04 02:11:25 -04:00
Ty Clifford b8ebc17dcd - Thumbnail URL 2026-06-03 02:45:54 -04:00
Ty Clifford 749862bb21 - 2026-06-03 02:45:38 -04:00
Ty Clifford acfb4b3a05 - Media player. Multiple formats 2026-06-03 01:28:23 -04:00
Ty Clifford 1962390bb8 - no longer needed 2026-06-03 01:26:35 -04:00
22 changed files with 4356 additions and 818 deletions
+8
View File
@@ -0,0 +1,8 @@
/bg/
/keyser/
/_mirrored/
/i/
cfgeo.php
cfip.php
db.class.php
BigBuckBunny.mp4
+2
View File
@@ -2,6 +2,8 @@
Under the hood, tyclifford.com. Put together by humans and artificial intelligence.
Commit test
# License
MIT License
-804
View File
@@ -1,804 +0,0 @@
<?php require_once("../members/gate.php"); ?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Live at TyClifford.com/Live — Members Area">
<meta name="author" content="Ty Clifford">
<meta name="keywords" content="Ty Clifford, Live, Podcast, Keyser, West Virginia, Nerd, TikTok">
<meta property="og:url" content="https://tyclifford.com/live/" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Ty Clifford — Live! Hobbies are my hobby." />
<meta property="og:title" content="Live TC | TyClifford.com/Live" />
<meta property="og:image" content="" />
<meta property="og:image:alt" content="Live stream" />
<meta name="twitter:site" content="@_tyclifford" />
<meta name="twitter:title" content="Live TC | TyClifford.com/Live" />
<meta name="twitter:description" content="Members live stream area — TyClifford.com">
<title>Live — TyClifford.com</title>
<link rel="shortcut icon" href="/images/ico/fa-bl.ico">
<link rel="apple-touch-icon" href="/images/ico/fa-bl.jpg">
<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">
<style>
/* ── Reset & tokens (exact match to gate.php) ──────────────────────────── */
*, *::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);
--libera: #f6c915;
--paypal: #009cde;
--r: 6px;
--gap: 1.25rem;
--pp: 1rem; /* page-pad */
/* chat panel */
--chat-w: 320px; /* desktop sidebar width */
--chat-h: 460px; /* mobile stacked height */
}
@media (min-width: 600px) { :root { --pp: 1.5rem; } }
@media (min-width: 1000px) { :root { --pp: 2rem; --chat-w: 340px; } }
@media (min-width: 1280px) { :root { --chat-w: 360px; } }
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;
}
/* scanline overlay */
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
);
}
/* ── Animations ─────────────────────────────────────────────────────────── */
@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: 1280px;
padding: var(--pp);
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); }
.brand-sub {
font-family: 'Share Tech Mono', monospace;
font-size: .62rem; letter-spacing: .18em; text-transform: uppercase; color: var(--text-3);
}
.live-badge {
display: inline-flex; align-items: center; gap: .4rem;
padding: .25rem .65rem;
background: rgba(255,51,85,.12); border: 1px solid rgba(255,51,85,.3); border-radius: 99px;
font-family: 'Share Tech Mono', monospace;
font-size: .6rem; letter-spacing: .15em; text-transform: uppercase;
color: var(--red); text-shadow: 0 0 8px rgba(255,51,85,.4);
}
.live-dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--red); box-shadow: 0 0 6px rgba(255,51,85,.8);
animation: live-pulse 1.4s ease-in-out infinite;
}
.topbar-social { display: flex; align-items: center; gap: .5rem; }
.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: color .2s, background .2s;
}
.social-pill:hover { color: var(--text-2); background: rgba(255,255,255,.06); }
.social-pill svg { flex-shrink: 0; }
/* ══════════════════════════════════════════════════════════════════════════
STREAM + CHAT ZONE
Mobile → column: [stream-col] then [chat-col] stacked
≥900px → row: [stream-col] flex-grows | [chat-col] fixed width sidebar
══════════════════════════════════════════════════════════════════════════ */
.stream-zone {
display: flex;
flex-direction: column; /* mobile: stack */
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);
}
/* rainbow shimmer rule along the top */
.stream-zone::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;
}
@media (min-width: 900px) {
.stream-zone {
flex-direction: row; /* desktop: side-by-side */
align-items: stretch;
}
}
/* ── Stream column ──────────────────────────────────────────────────────── */
.stream-col {
display: flex;
flex-direction: column;
flex: 1 1 auto; /* fills remaining width on desktop */
min-width: 0;
}
/* Pure-CSS 16:9 — zero layout shift, no JS resize loop */
.stream-ratio {
position: relative;
width: 100%;
padding-top: 56.25%; /* 9/16 */
background: #000;
flex-shrink: 0;
}
.stream-ratio iframe {
position: absolute; inset: 0;
width: 100%; height: 100%;
border: 0; display: block;
}
/* bar below the stream video */
.stream-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: .75rem;
padding: .6rem 1rem;
border-top: 1px solid var(--border);
flex-wrap: wrap;
flex-shrink: 0;
}
.stream-bar-right { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
/* ── Chat column ────────────────────────────────────────────────────────── */
.chat-col {
display: flex;
flex-direction: column;
/* mobile: fixed height beneath stream */
height: var(--chat-h);
flex-shrink: 0;
border-top: 1px solid var(--border);
}
@media (min-width: 900px) {
.chat-col {
width: var(--chat-w);
height: auto; /* stretch to full row height */
flex-shrink: 0;
flex-grow: 0;
border-top: none;
border-left: 1px solid var(--border);
min-height: 340px;
}
}
/* thin header strip inside chat column */
.chat-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: .5rem .85rem;
border-bottom: 1px solid var(--border);
background: var(--surface-2);
flex-shrink: 0;
}
.chat-bar-actions { display: flex; align-items: center; gap: .35rem; }
/* iframe wrapper — fills remaining column height */
.chat-embed {
flex: 1;
position: relative;
overflow: hidden;
min-height: 0;
}
.chat-embed iframe {
position: absolute; inset: 0;
width: 100%; height: 100%;
border: 0; display: block;
background: #000;
}
/* placeholder shown when chat is in a popup window */
.chat-placeholder {
display: none; /* JS reveals this */
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
gap: .85rem;
padding: 1.5rem;
text-align: center;
}
.chat-placeholder.visible { display: flex; }
.chat-embed.hidden { display: none; }
.ph-icon {
width: 42px; height: 42px; border-radius: 50%;
background: rgba(0,200,255,.07);
border: 1px solid rgba(0,200,255,.2);
display: flex; align-items: center; justify-content: center;
color: var(--blue);
}
.ph-title { font-size: .85rem; font-weight: 600; color: var(--text-2); }
.ph-sub {
font-family: 'Share Tech Mono', monospace;
font-size: .68rem; color: var(--text-3); line-height: 1.65;
}
.ph-sub strong { color: var(--text-2); }
/* ── Shared 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-blue { color: var(--blue); text-shadow: 0 0 8px rgba(0,200,255,.4); }
.eyebrow-blue::before { content: '> '; opacity: .5; }
.stream-meta {
font-family: 'Share Tech Mono', monospace;
font-size: .63rem; letter-spacing: .08em; color: var(--text-3);
}
.stream-meta span { color: var(--text-2); }
/* ── Small icon-style buttons (popout / re-embed / stream-out) ────────── */
.btn-sm {
display: inline-flex; align-items: center; gap: .38rem;
padding: .28rem .65rem;
background: rgba(255,255,255,.04);
border: 1px solid var(--border-2);
border-radius: calc(var(--r) - 1px);
font-family: 'Share Tech Mono', monospace;
font-size: .6rem; letter-spacing: .07em;
color: var(--text-3);
cursor: pointer; text-decoration: none;
transition: border-color .2s, color .2s, background .2s;
white-space: nowrap; min-height: 28px; line-height: 1;
background: none;
}
.btn-sm svg { flex-shrink: 0; }
.btn-sm:hover { border-color: var(--blue); color: var(--blue); background: rgba(0,200,255,.05); }
.btn-sm.btn-sm-green:hover { border-color: var(--green); color: var(--green); background: rgba(0,232,122,.05); }
/* ══════════════════════════════════════════════════════════════════════════
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; } }
/* shared card */
.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); }
/* Patreon support card */
.card-patreon { border-color: var(--pat-border); box-shadow: 0 0 30px rgba(255,66,77,.05); }
.card-patreon::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;
}
.card-about { background: var(--surface-2); }
.card-social { background: var(--surface-2); }
/* Donate buttons */
.donate-stack { display: flex; flex-direction: column; gap: .55rem; }
.btn-donate {
display: flex; align-items: center; justify-content: center; gap: .55rem;
padding: .75rem 1.1rem; border-radius: var(--r);
font-family: 'Inter', sans-serif; font-size: .85rem; font-weight: 700; letter-spacing: .02em;
text-decoration: none; position: relative; overflow: hidden;
transition: transform .15s, filter .15s; min-height: 44px;
}
.btn-donate::before {
content: ''; position: absolute; inset: 0;
background: linear-gradient(180deg, rgba(255,255,255,.1) 0%, transparent 60%);
pointer-events: none;
}
.btn-donate:hover { transform: translateY(-1px); filter: brightness(1.08); }
.btn-donate:active { transform: scale(.98); filter: brightness(.96); }
.btn-patreon-donate {
background: var(--pat); color: #fff;
box-shadow: 0 0 0 1px rgba(255,255,255,.1) inset, 0 4px 18px rgba(255,66,77,.3);
animation: pulse-border 3s ease-in-out infinite;
}
.btn-libera { background: var(--libera); color: #1a1400; box-shadow: 0 4px 18px rgba(246,201,21,.2); }
.btn-paypal { background: var(--paypal); color: #fff; box-shadow: 0 4px 18px rgba(0,156,222,.2); }
/* Social list */
.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); }
/* Page 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); }
</style>
</head>
<body>
<main class="page" role="main">
<!-- ── Top bar ──────────────────────────────────────────────────────────── -->
<header class="topbar">
<div class="topbar-brand">
<div>
<div class="brand-name">Ty Clifford</div>
<div class="brand-sub">tyclifford.com / live</div>
</div>
<div class="live-badge" aria-label="Live stream indicator">
<span class="live-dot" aria-hidden="true"></span>Live
</div>
</div>
<nav class="topbar-social" aria-label="Social links">
<a class="social-pill" href="https://github.com/snick512"
target="_blank" rel="noopener" aria-label="GitHub">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61
c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77
5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7
0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0
0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"/>
</svg>GitHub
</a>
<a class="social-pill" href="mailto:ty@tyclifford.com" aria-label="Email Ty">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="2" y="4" width="20" height="16" rx="2"/>
<polyline points="2,4 12,13 22,4"/>
</svg>Email
</a>
</nav>
</header>
<!-- ══════════════════════════════════════════════════════════════════════
STREAM + CHAT ZONE
Mobile → stream on top, chat panel below (fixed height)
≥ 900px → stream fills left, chat sidebar pinned to right
═══════════════════════════════════════════════════════════════════════ -->
<section class="stream-zone" aria-label="Live stream and chat">
<!-- ── Stream column ──────────────────────────────────────────────────── -->
<div class="stream-col">
<!-- 16:9 ratio container — pure CSS, no JS resize -->
<div class="stream-ratio">
<iframe
src="https://stream.place/embed/tyclifford.com"
allowfullscreen
allow="autoplay; fullscreen"
title="Ty Clifford live stream"
></iframe>
</div>
<!-- bar: eyebrow + meta + open-in-new-tab -->
<div class="stream-bar">
<p class="eyebrow eyebrow-green">Members stream</p>
<div class="stream-bar-right">
<p class="stream-meta">stream.place / <span>tyclifford.com</span></p>
<!-- Opens the full stream page in a new tab — not a duplicate session -->
<a class="btn-sm btn-sm-green"
href="https://stream.place/embed/tyclifford.com"
target="_blank" rel="noopener"
aria-label="Open stream in new tab">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>
</svg>
Open stream ↗
</a>
</div>
</div>
</div><!-- /stream-col -->
<!-- ── Chat column ────────────────────────────────────────────────────── -->
<div class="chat-col" id="chatCol">
<!-- chat top bar -->
<div class="chat-bar">
<p class="eyebrow eyebrow-blue">Live Chat</p>
<div class="chat-bar-actions">
<!-- Pop-out button (visible when embedded) -->
<button class="btn-sm" id="btnPopout" onclick="popoutChat()"
aria-label="Open chat in a separate window" title="Pop chat out">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
<polyline points="9 21 3 21 3 15"/>
<line x1="14" y1="10" x2="3" y2="21"/>
</svg>
Pop out
</button>
<!-- Re-embed button (visible when popped out) -->
<button class="btn-sm" id="btnReembed" style="display:none"
onclick="reembedChat()"
aria-label="Re-embed chat here" title="Re-embed chat">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="1 4 1 10 7 10"/>
<path d="M3.51 15a9 9 0 1 0 .49-4"/>
</svg>
Re-embed
</button>
</div>
</div>
<!-- live iframe (src set via JS on init to allow clean src-clearing on popout) -->
<div class="chat-embed" id="chatEmbed">
<iframe id="chatIframe" title="Live chat" allow="autoplay"></iframe>
</div>
<!-- placeholder shown while chat is in its own window -->
<div class="chat-placeholder" id="chatPlaceholder" aria-live="polite">
<div class="ph-icon" aria-hidden="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
</div>
<p class="ph-title">Chat is in a separate window</p>
<p class="ph-sub">
Keep that window alongside your stream.<br>
Click <strong>Re-embed</strong> to bring chat back here.
</p>
</div>
</div><!-- /chat-col -->
</section>
<!-- ── Lower grid ──────────────────────────────────────────────────────── -->
<div class="lower-grid">
<!-- Support card -->
<div class="card card-patreon">
<div>
<p class="eyebrow eyebrow-green" style="margin-bottom:.35rem">Support the stream</p>
<h2 class="card-title">Hobbies are my hobby.</h2>
</div>
<p class="card-body">
If you're enjoying the stream, consider throwing a few bucks in the jar.
<strong>Patreon</strong> members keep this going — even a free membership helps.
You can also tip once via Liberapay or PayPal, no account needed.
</p>
<div class="donate-stack">
<a class="btn-donate btn-patreon-donate"
href="https://go.tyclifford.com/patreon"
target="_blank" rel="noopener noreferrer" aria-label="Become a Patreon supporter">
<svg width="12" height="14" viewBox="0 0 12 14" fill="white" aria-hidden="true">
<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>Become a Patron
</a>
<a class="btn-donate btn-libera"
href="https://go.tyclifford.com/liberapay"
target="_blank" rel="noopener noreferrer" aria-label="Support via Liberapay">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
</svg>Donate via Liberapay
</a>
<a class="btn-donate btn-paypal"
href="https://www.paypal.com/donate/?hosted_button_id=ZDJ9TXPPMNZ6U"
target="_blank" rel="noopener noreferrer" aria-label="Donate via PayPal">
<svg width="13" height="15" viewBox="0 0 24 28" fill="white" aria-hidden="true">
<path d="M7.8 27H3.5L6.3 9.5h7.6c3.1 0 5.3.7 6.4 2 1.1 1.3 1.3 3 .5 5.1
-.9 2.4-2.4 4.2-4.5 5.3-2 1-4.5 1.5-7.3 1.5H7l-.8 3.6zm1.1-6.5
h1.6c1.5 0 2.8-.3 3.8-.9 1-.6 1.7-1.5 2.1-2.7.4-1.2.2-2-.5-2.6
-.7-.5-1.9-.8-3.6-.8H10L8.9 20.5z"/>
</svg>Donate via PayPal
</a>
</div>
</div>
<!-- About card -->
<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.
<br><br>
Follow along on
<a href="https://go.tyclifford.com/patreon" target="_blank" rel="noopener">Patreon</a>
and join the conversation.
</p>
</div>
<!-- Social card -->
<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" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61
c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07
0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65
5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5
3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 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" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<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" aria-hidden="true">
<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><!-- /lower-grid -->
<!-- ── Footer ───────────────────────────────────────────────────────────── -->
<footer class="page-footer" role="contentinfo">
<p class="footer-note">TyClifford.com &nbsp;·&nbsp; members area</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>
</main>
<script>
/* ═══════════════════════════════════════════════════════════════════════════
Chat pop-out / re-embed controller
═══════════════════════════════════════════════════════════════════════════
Goals:
1. Embed the chat iframe by default.
2. "Pop out" opens the chat URL in a small named popup window, then
CLEARS the iframe src to eliminate the embedded session — no duplicate.
3. "Re-embed" closes the popup (if still open) and restores the iframe.
4. If the user manually closes the popup, a watcher auto-restores the embed.
5. sessionStorage preserves state across soft navigations in the same tab;
a hard reload always starts embedded (clean state).
No libraries. ~60 lines of vanilla JS.
═══════════════════════════════════════════════════════════════════════════ */
(function () {
var CHAT_SRC = 'https://stream.place/chat-popout/did:plc:wdolp7qjekv5jqgz7h2r5peb';
var STORE_KEY = 'tcChat_popped';
var POPUP_NAME = 'tcChatWin';
var POPUP_OPTS = 'width=400,height=680,resizable=yes,scrollbars=yes,toolbar=no,menubar=no,location=no,status=no';
var iframe = document.getElementById('chatIframe');
var embedWrap = document.getElementById('chatEmbed');
var placeholder = document.getElementById('chatPlaceholder');
var btnPopout = document.getElementById('btnPopout');
var btnReembed = document.getElementById('btnReembed');
var popupWin = null;
var watchTimer = null;
/* ── Render helpers ─────────────────────────────────────────────────── */
function showEmbed() {
iframe.src = CHAT_SRC;
embedWrap.classList.remove('hidden');
placeholder.classList.remove('visible');
btnPopout.style.display = '';
btnReembed.style.display = 'none';
}
function showPlaceholder() {
iframe.src = ''; // kill connection → no duplicate session
embedWrap.classList.add('hidden');
placeholder.classList.add('visible');
btnPopout.style.display = 'none';
btnReembed.style.display = '';
}
/* ── Popup watcher — auto-restore if user closes the window ─────────── */
function startWatcher() {
clearInterval(watchTimer);
watchTimer = setInterval(function () {
if (popupWin && popupWin.closed) {
stopWatcher();
sessionStorage.removeItem(STORE_KEY);
showEmbed();
}
}, 600);
}
function stopWatcher() {
clearInterval(watchTimer);
watchTimer = null;
}
/* ── Public actions (called from onclick) ───────────────────────────── */
window.popoutChat = function () {
popupWin = window.open(CHAT_SRC, POPUP_NAME, POPUP_OPTS);
if (!popupWin || popupWin.closed) {
// Popup was blocked — fall back gracefully; leave embed in place
alert('Popup was blocked.\nPlease allow popups for this site and try again.');
return;
}
sessionStorage.setItem(STORE_KEY, '1');
showPlaceholder();
startWatcher();
};
window.reembedChat = function () {
stopWatcher();
if (popupWin && !popupWin.closed) { popupWin.close(); }
popupWin = null;
sessionStorage.removeItem(STORE_KEY);
showEmbed();
};
/* ── Init ───────────────────────────────────────────────────────────── */
// If this tab session had chat popped (e.g. soft nav back), restore that state.
// On a hard reload sessionStorage is cleared → always start embedded.
if (sessionStorage.getItem(STORE_KEY)) {
showPlaceholder();
startWatcher(); // still watch in case popup was already open
} else {
showEmbed(); // default: embed is live
}
})();
</script>
</body>
</html>
+14
View File
@@ -0,0 +1,14 @@
# Protect the data directory from direct web access
<IfModule mod_rewrite.c>
RewriteEngine On
# Block direct access to data/
RewriteRule ^data/ - [F,L]
</IfModule>
# PHP settings for file uploads
<IfModule mod_php.c>
php_value upload_max_filesize 2048M
php_value post_max_size 2100M
php_value max_execution_time 300
php_value memory_limit 256M
</IfModule>
+118
View File
@@ -0,0 +1,118 @@
# TyClifford.com — Media Player
A self-hosted multimedia player website built with PHP + SQLite + Video.js.
Styled to match the existing `gate.php` / `index.php` dark theme exactly.
---
## Requirements
- PHP 8.0+ with `pdo_sqlite`, `fileinfo` extensions
- Write permissions on the `data/` and `media/` directories
---
## Installation
1. **Upload** all files to your web server (e.g. `/live/` or wherever `/index.php` lives).
2. **Set permissions** so PHP can write to these directories:
```bash
mkdir -p data media/videos media/thumbs
chmod 755 data media media/videos media/thumbs
```
3. **Visit** `index.php` — the SQLite database is created automatically on first load.
4. **Log into admin** at `admin/login.php`
- Default password: **`admin`**
- ⚠️ Change it immediately in **Admin → Settings → Change Password**
---
## File Structure
```
/ ← web root (deploy here)
├── index.php ← Main player + catalogue (home page)
├── catalogue.php ← Full video catalogue browse page
├── includes/
│ ├── db.php ← SQLite bootstrap + helper functions
│ └── layout.php ← Shared HTML head, topbar, footer, pagination
├── admin/
│ ├── index.php ← Video list with search + pagination
│ ├── add.php ← Add new video with multi-format sources
│ ├── edit.php ← Edit video metadata + manage sources
│ ├── settings.php ← Site settings, per-page count, password
│ ├── login.php ← Admin login
│ ├── logout.php ← Session destroy + redirect
│ └── auth.php ← Session auth helpers
├── media/ ← All media files (created automatically)
│ ├── videos/ ← Uploaded video files
│ └── thumbs/ ← Uploaded thumbnail images
└── data/
└── media.db ← SQLite database (auto-created)
```
---
## Usage
### Adding Videos (Admin)
1. Go to `admin/add.php`
2. Fill in title, description, duration, sort order
3. Upload a thumbnail (JPG/PNG/WebP)
4. Upload one or more video files — each can be a different format or quality:
- **MP4** (H.264) — widest compatibility
- **WebM** (VP9) — smaller size, modern browsers
- **OGV** — Firefox fallback (legacy)
5. Set quality labels (1080p, 720p, 480p, etc.)
6. Save — Video.js will auto-select the best format the browser supports
### Catalogue Per-Page
Configurable in **Admin → Settings → Videos per page** (150).
The home page catalogue and the full catalogue page both respect this setting.
### Publishing
Videos can be toggled between **Published** and **Draft** from the admin list.
Only published videos appear on the public-facing pages.
### Sorting
Use the **Sort Order** field — lower numbers appear first. Default is 0.
---
## Player Features
- **Video.js 8.x** — responsive, accessible HTML5 player
- Custom skin matching the site's dark purple/green theme
- Playback rate controls: 0.5×, 1×, 1.25×, 1.5×, 2×
- Multiple source fallback — browser picks best format automatically
- Thumbnail posters
- Keyboard accessible
---
## Security Notes
- Change the default admin password (`admin`) immediately after setup
- Consider adding `.htaccess` HTTP auth or IP restriction to `/admin/`
- The `data/` directory should not be web-accessible; add to `.htaccess`:
```apache
<Directory data>
Require all denied
</Directory>
```
- For production, consider adding CSRF tokens to admin forms
---
## Customisation
- **Branding**: Edit site title/subtitle in Admin → Settings
- **Theme colours**: All CSS variables are in `includes/layout.php` `:root {}`
- **Social links**: Edit the About/Links cards in `index.php`
+307
View File
@@ -0,0 +1,307 @@
<?php
require_once __DIR__ . '/auth.php';
require_once __DIR__ . '/../includes/layout.php';
admin_require_auth();
$db = get_db();
$error = '';
$msg = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$title = trim($_POST['title'] ?? '');
$description = trim($_POST['description'] ?? '');
$duration = (int)($_POST['duration'] ?? 0);
$sort_order = (int)($_POST['sort_order'] ?? 0);
$published = isset($_POST['published']) ? 1 : 0;
if (!$title) {
$error = 'Title is required.';
} else {
$slug = make_slug($title);
// Handle thumbnail upload
$thumbnail = '';
if (!empty($_FILES['thumbnail']['tmp_name'])) {
$ext = strtolower(pathinfo($_FILES['thumbnail']['name'], PATHINFO_EXTENSION));
$allowed = ['jpg','jpeg','png','webp','gif'];
if (in_array($ext, $allowed)) {
$thumb_dir = MEDIA_DIR . 'thumbs/';
if (!is_dir($thumb_dir)) mkdir($thumb_dir, 0755, true);
$fname = $slug . '_thumb_' . time() . '.' . $ext;
if (move_uploaded_file($_FILES['thumbnail']['tmp_name'], $thumb_dir . $fname)) {
$thumbnail = 'thumbs/' . $fname;
}
} else {
$error = 'Thumbnail must be JPG, PNG, WebP, or GIF.';
}
}
if (!$error) {
// Insert video
$db->prepare("
INSERT INTO videos (title, description, slug, thumbnail, duration, sort_order, published)
VALUES (?, ?, ?, ?, ?, ?, ?)
")->execute([$title, $description, $slug, $thumbnail, $duration, $sort_order, $published]);
$video_id = (int)$db->lastInsertId();
// Handle video source files
$source_labels = $_POST['source_label'] ?? [];
$source_quality = $_POST['source_quality'] ?? [];
$source_order = $_POST['source_sort'] ?? [];
$source_files = $_FILES['source_file'] ?? [];
$videos_dir = MEDIA_DIR . 'videos/';
if (!is_dir($videos_dir)) mkdir($videos_dir, 0755, true);
$inserted_sources = 0;
$allowed_video = ['mp4','webm','ogv','ogg','mov','mkv','avi'];
if (!empty($source_files['tmp_name'])) {
foreach ($source_files['tmp_name'] as $i => $tmp) {
if (empty($tmp) || !is_uploaded_file($tmp)) continue;
$orig_name = $source_files['name'][$i];
$ext = strtolower(pathinfo($orig_name, PATHINFO_EXTENSION));
if (!in_array($ext, $allowed_video)) continue;
$fname = $slug . '_' . ($i+1) . '_' . time() . '.' . $ext;
if (move_uploaded_file($tmp, $videos_dir . $fname)) {
$label = trim($source_labels[$i] ?? '');
$quality = trim($source_quality[$i] ?? '');
$mime = mime_from_ext($fname);
$sort = (int)($source_order[$i] ?? $i);
$db->prepare("
INSERT INTO video_sources (video_id, label, file_path, mime_type, quality, sort_order)
VALUES (?,?,?,?,?,?)
")->execute([$video_id, $label, 'videos/' . $fname, $mime, $quality, $sort]);
$inserted_sources++;
}
}
}
header('Location: edit.php?id=' . $video_id . '&saved=1');
exit;
}
}
}
render_head('Add Video — Admin', '
.form-grid { display:grid; grid-template-columns:1fr; gap:1.2rem; }
@media(min-width:700px) { .form-grid { grid-template-columns:1fr 1fr; } }
.form-grid .span2 { grid-column:1/-1; }
.sources-section { display:flex; flex-direction:column; gap:.85rem; }
.source-row {
background:var(--surface-2); border:1px solid var(--border);
border-radius:var(--r); padding:1rem;
display:grid; grid-template-columns:1fr 1fr 1fr auto;
gap:.75rem; align-items:end;
}
@media(max-width:700px) { .source-row { grid-template-columns:1fr 1fr; } }
.add-source-btn { align-self:flex-start; }
.section-divider {
border:none; border-top:1px solid var(--border); margin:1.5rem 0 .5rem;
}
.section-heading {
font-family:"Share Tech Mono",monospace; font-size:.65rem;
letter-spacing:.15em; text-transform:uppercase; color:var(--text-3);
margin-bottom:.75rem;
}
.hint { font-size:.72rem; color:var(--text-3); margin-top:.2rem; }
.admin-layout { display:grid; grid-template-columns:1fr; gap:1.5rem; }
@media(min-width:900px) { .admin-layout { grid-template-columns:220px 1fr; } }
.admin-nav {
background:var(--surface); border:1px solid var(--border);
border-radius:calc(var(--r)+2px); padding:1.2rem;
display:flex; flex-direction:column; gap:.4rem;
align-self:start; position:sticky; top:1rem;
}
.admin-nav-label { font-family:"Share Tech Mono",monospace; font-size:.58rem; letter-spacing:.18em; text-transform:uppercase; color:var(--text-3); margin-bottom:.3rem; padding-left:.5rem; }
.admin-nav a { display:flex; align-items:center; gap:.6rem; padding:.55rem .75rem; border-radius:var(--r); font-size:.82rem; color:var(--text-2); text-decoration:none; transition:background .15s,color .15s; }
.admin-nav a:hover { background:rgba(255,255,255,.05); color:var(--text); }
.admin-nav a.active { background:rgba(176,96,255,.12); color:var(--purple); }
.nav-divider { border:none; border-top:1px solid var(--border); margin:.4rem 0; }
');
?>
<main class="page" role="main">
<header class="topbar">
<div class="topbar-brand">
<div>
<div class="brand-name">Ty Clifford</div>
<div class="brand-sub">admin / add video</div>
</div>
</div>
<nav class="topbar-social">
<a class="social-pill" href="../index.php" target="_blank">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15,3 21,3 21,9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
View Site
</a>
<a class="social-pill" href="logout.php">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16,17 21,12 16,7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
Logout
</a>
</nav>
</header>
<?php if ($error): ?>
<div class="notice notice-error"><?= h($error) ?></div>
<?php endif; ?>
<div class="admin-layout">
<aside class="admin-nav">
<div class="admin-nav-label">Navigation</div>
<a href="index.php">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
Videos
</a>
<a href="add.php" class="active">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
Add Video
</a>
<hr class="nav-divider">
<a href="settings.php">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M4.93 4.93l1.41 1.41M19.07 19.07l-1.41-1.41M4.93 19.07l1.41-1.41M12 2v2M12 20v2M2 12h2M20 12h2"/></svg>
Settings
</a>
</aside>
<div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.25rem;flex-wrap:wrap;gap:.75rem;">
<div>
<p class="eyebrow eyebrow-purple" style="margin-bottom:.3rem">New Entry</p>
<h1 style="font-size:1.05rem;font-weight:700;color:var(--text);">Add Video</h1>
</div>
<a href="index.php" class="btn btn-secondary btn-sm">← Back</a>
</div>
<form method="post" action="add.php" enctype="multipart/form-data">
<!-- ── Video details ── -->
<div class="card" style="margin-bottom:1rem;">
<p class="section-heading">Video Details</p>
<div class="form-grid">
<div class="form-group span2">
<label class="form-label" for="title">Title *</label>
<input class="form-input" type="text" id="title" name="title"
value="<?= h($_POST['title'] ?? '') ?>" required placeholder="My Awesome Video">
</div>
<div class="form-group span2">
<label class="form-label" for="description">Description</label>
<textarea class="form-textarea" id="description" name="description" rows="3"><?= h($_POST['description'] ?? '') ?></textarea>
</div>
<div class="form-group">
<label class="form-label" for="duration">Duration (seconds)</label>
<input class="form-input" type="number" id="duration" name="duration"
value="<?= h($_POST['duration'] ?? '0') ?>" min="0" placeholder="0">
<span class="hint">e.g. 3600 = 1 hour. Leave 0 if unknown.</span>
</div>
<div class="form-group">
<label class="form-label" for="sort_order">Sort Order</label>
<input class="form-input" type="number" id="sort_order" name="sort_order"
value="<?= h($_POST['sort_order'] ?? '0') ?>" min="0">
<span class="hint">Lower numbers appear first.</span>
</div>
<div class="form-group">
<label class="form-label" for="thumbnail">Thumbnail Image</label>
<input class="form-input" type="file" id="thumbnail" name="thumbnail"
accept="image/jpeg,image/png,image/webp,image/gif" style="padding:.45rem .6rem;">
<span class="hint">JPG, PNG, WebP or GIF. Shown as 16:9 preview.</span>
</div>
<div class="form-group" style="justify-content:flex-end;padding-bottom:.2rem;">
<label style="display:flex;align-items:center;gap:.6rem;cursor:pointer;">
<input type="checkbox" name="published" value="1" <?= isset($_POST['published']) || !isset($_POST['title']) ? 'checked' : '' ?>>
<span class="form-label" style="margin:0">Published</span>
</label>
<span class="hint">Uncheck to save as draft.</span>
</div>
</div>
</div>
<!-- ── Video sources ── -->
<div class="card">
<p class="section-heading">Video Sources (Multiple Formats)</p>
<p class="hint" style="margin-bottom:1rem;">
Upload the same video in multiple formats/qualities. The browser will automatically pick the best one it can play.
Accepted: <strong>MP4, WebM, OGV, MOV, MKV, AVI</strong>.
</p>
<div class="sources-section" id="sources-container">
<!-- Row template (JS clones this) -->
<div class="source-row" data-source-row="0">
<div class="form-group">
<label class="form-label">Video File</label>
<input class="form-input" type="file" name="source_file[]"
accept="video/mp4,video/webm,video/ogg,.mp4,.webm,.ogv,.mov,.mkv,.avi"
style="padding:.45rem .6rem;">
</div>
<div class="form-group">
<label class="form-label">Quality Label</label>
<select class="form-select" name="source_quality[]">
<option value="1080p">1080p</option>
<option value="720p" selected>720p</option>
<option value="480p">480p</option>
<option value="360p">360p</option>
<option value="4K">4K</option>
<option value="">—</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Display Label</label>
<input class="form-input" type="text" name="source_label[]" placeholder="e.g. HD">
</div>
<div class="form-group">
<label class="form-label">Sort</label>
<input class="form-input" type="number" name="source_sort[]" value="0" min="0" style="width:70px;">
</div>
</div>
</div>
<button type="button" class="btn btn-secondary btn-sm add-source-btn" id="add-source" style="margin-top:.75rem;align-self:flex-start;">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add Another Format
</button>
</div>
<div style="display:flex;gap:.75rem;margin-top:1rem;flex-wrap:wrap;">
<button class="btn btn-primary" type="submit">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17,21 17,13 7,13 7,21"/><polyline points="7,3 7,8 15,8"/></svg>
Save Video
</button>
<a href="index.php" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
<?php render_footer(); ?>
</main>
<script>
document.getElementById('add-source').addEventListener('click', function() {
var container = document.getElementById('sources-container');
var rows = container.querySelectorAll('[data-source-row]');
var idx = rows.length;
var first = rows[0].cloneNode(true);
first.setAttribute('data-source-row', idx);
// Clear file inputs (can't clone values for file inputs)
first.querySelectorAll('input[type="file"]').forEach(function(el){ el.value=''; });
first.querySelectorAll('input[type="text"]').forEach(function(el){ el.value=''; });
first.querySelectorAll('input[type="number"]').forEach(function(el){ el.value=idx; });
// Add remove button if not already there
if (!first.querySelector('.remove-row')) {
var btn = document.createElement('button');
btn.type='button';
btn.className='btn btn-danger btn-sm remove-row';
btn.style='align-self:flex-end';
btn.innerHTML='✕';
btn.onclick=function(){ this.closest('[data-source-row]').remove(); };
first.appendChild(btn);
}
container.appendChild(first);
});
</script>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
<?php
/**
* auth.php — simple session-based admin auth
*/
require_once __DIR__ . '/../includes/db.php';
session_start();
define('ADMIN_SESSION_KEY', 'tc_admin_auth');
function admin_is_logged_in(): bool {
return !empty($_SESSION[ADMIN_SESSION_KEY]);
}
function admin_require_auth(): void {
if (!admin_is_logged_in()) {
header('Location: login.php');
exit;
}
}
function admin_login(string $password): bool {
$hash = setting('admin_password');
if (password_verify($password, $hash)) {
session_regenerate_id(true);
$_SESSION[ADMIN_SESSION_KEY] = true;
return true;
}
return false;
}
function admin_logout(): void {
$_SESSION[ADMIN_SESSION_KEY] = false;
session_destroy();
}
+381
View File
@@ -0,0 +1,381 @@
<?php
require_once __DIR__ . '/auth.php';
require_once __DIR__ . '/../includes/layout.php';
admin_require_auth();
$db = get_db();
$id = (int)($_GET['id'] ?? 0);
$video = get_video_by_id($id);
if (!$video) {
header('Location: index.php');
exit;
}
$error = '';
$msg = '';
if (isset($_GET['saved'])) $msg = 'Video saved successfully.';
// Handle delete source
if (isset($_GET['del_src'])) {
$sid = (int)$_GET['del_src'];
$src = $db->prepare("SELECT * FROM video_sources WHERE id=? AND video_id=?");
$src->execute([$sid, $id]);
$s = $src->fetch();
if ($s) {
$f = MEDIA_DIR . $s['file_path'];
if (file_exists($f)) unlink($f);
$db->prepare("DELETE FROM video_sources WHERE id=?")->execute([$sid]);
$msg = 'Source file removed.';
}
$video = get_video_by_id($id); // refresh
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? 'save';
if ($action === 'save') {
$title = trim($_POST['title'] ?? '');
$description = trim($_POST['description'] ?? '');
$duration = (int)($_POST['duration'] ?? 0);
$sort_order = (int)($_POST['sort_order'] ?? 0);
$published = isset($_POST['published']) ? 1 : 0;
if (!$title) {
$error = 'Title is required.';
} else {
$slug = make_slug($title, $id);
// Handle thumbnail replacement
$thumbnail = $video['thumbnail'];
if (!empty($_FILES['thumbnail']['tmp_name'])) {
$ext = strtolower(pathinfo($_FILES['thumbnail']['name'], PATHINFO_EXTENSION));
$allowed = ['jpg','jpeg','png','webp','gif'];
if (in_array($ext, $allowed)) {
$thumb_dir = MEDIA_DIR . 'thumbs/';
if (!is_dir($thumb_dir)) mkdir($thumb_dir, 0755, true);
// Remove old thumb
if ($thumbnail && file_exists(MEDIA_DIR . $thumbnail)) unlink(MEDIA_DIR . $thumbnail);
$fname = $slug . '_thumb_' . time() . '.' . $ext;
if (move_uploaded_file($_FILES['thumbnail']['tmp_name'], $thumb_dir . $fname)) {
$thumbnail = 'thumbs/' . $fname;
}
} else {
$error = 'Thumbnail must be JPG, PNG, WebP, or GIF.';
}
}
if (!$error) {
$db->prepare("
UPDATE videos SET title=?, description=?, slug=?, thumbnail=?,
duration=?, sort_order=?, published=?,
updated_at=datetime('now')
WHERE id=?
")->execute([$title, $description, $slug, $thumbnail, $duration, $sort_order, $published, $id]);
// Update existing sources sort/label/quality
$upd_labels = $_POST['src_label'] ?? [];
$upd_quality = $_POST['src_quality'] ?? [];
$upd_sort = $_POST['src_sort'] ?? [];
$upd_ids = $_POST['src_id'] ?? [];
foreach ($upd_ids as $i => $sid) {
$db->prepare("UPDATE video_sources SET label=?, quality=?, sort_order=? WHERE id=? AND video_id=?")
->execute([
$upd_labels[$i] ?? '',
$upd_quality[$i] ?? '',
(int)($upd_sort[$i] ?? $i),
(int)$sid, $id
]);
}
// Add new source files
$new_labels = $_POST['new_source_label'] ?? [];
$new_quality = $_POST['new_source_quality'] ?? [];
$new_sort = $_POST['new_source_sort'] ?? [];
$new_files = $_FILES['new_source_file'] ?? [];
$videos_dir = MEDIA_DIR . 'videos/';
if (!is_dir($videos_dir)) mkdir($videos_dir, 0755, true);
$allowed_video = ['mp4','webm','ogv','ogg','mov','mkv','avi'];
if (!empty($new_files['tmp_name'])) {
foreach ($new_files['tmp_name'] as $i => $tmp) {
if (empty($tmp) || !is_uploaded_file($tmp)) continue;
$orig = $new_files['name'][$i];
$ext = strtolower(pathinfo($orig, PATHINFO_EXTENSION));
if (!in_array($ext, $allowed_video)) continue;
$fname = $slug . '_' . time() . '_' . $i . '.' . $ext;
if (move_uploaded_file($tmp, $videos_dir . $fname)) {
$db->prepare("INSERT INTO video_sources (video_id,label,file_path,mime_type,quality,sort_order) VALUES (?,?,?,?,?,?)")
->execute([$id,
trim($new_labels[$i] ?? ''),
'videos/' . $fname,
mime_from_ext($fname),
trim($new_quality[$i] ?? '720p'),
(int)($new_sort[$i] ?? $i),
]);
}
}
}
header('Location: edit.php?id=' . $id . '&saved=1');
exit;
}
}
}
}
// Refresh
$video = get_video_by_id($id);
render_head('Edit Video — Admin', '
.form-grid { display:grid; grid-template-columns:1fr; gap:1.2rem; }
@media(min-width:700px) { .form-grid { grid-template-columns:1fr 1fr; } }
.form-grid .span2 { grid-column:1/-1; }
.section-heading { font-family:"Share Tech Mono",monospace; font-size:.65rem; letter-spacing:.15em; text-transform:uppercase; color:var(--text-3); margin-bottom:.75rem; }
.hint { font-size:.72rem; color:var(--text-3); margin-top:.2rem; }
.source-list { display:flex; flex-direction:column; gap:.75rem; }
.source-item {
background:var(--surface-2); border:1px solid var(--border);
border-radius:var(--r); padding:.85rem 1rem;
}
.source-item-header { display:flex; align-items:center; justify-content:space-between; gap:.75rem; margin-bottom:.75rem; flex-wrap:wrap; }
.source-item-file { font-family:"Share Tech Mono",monospace; font-size:.68rem; color:var(--text-2); word-break:break-all; }
.source-item-mime { font-family:"Share Tech Mono",monospace; font-size:.6rem; color:var(--text-3); }
.source-item-fields { display:grid; grid-template-columns:1fr 1fr 80px; gap:.6rem; }
@media(max-width:600px) { .source-item-fields { grid-template-columns:1fr 1fr; } }
.add-source-row { background:var(--surface-2); border:1px solid var(--border-2); border-radius:var(--r); padding:1rem; display:grid; grid-template-columns:1fr 1fr 1fr auto; gap:.75rem; align-items:end; }
@media(max-width:700px) { .add-source-row { grid-template-columns:1fr 1fr; } }
.admin-layout { display:grid; grid-template-columns:1fr; gap:1.5rem; }
@media(min-width:900px) { .admin-layout { grid-template-columns:220px 1fr; } }
.admin-nav { background:var(--surface); border:1px solid var(--border); border-radius:calc(var(--r)+2px); padding:1.2rem; display:flex; flex-direction:column; gap:.4rem; align-self:start; position:sticky; top:1rem; }
.admin-nav-label { font-family:"Share Tech Mono",monospace; font-size:.58rem; letter-spacing:.18em; text-transform:uppercase; color:var(--text-3); margin-bottom:.3rem; padding-left:.5rem; }
.admin-nav a { display:flex; align-items:center; gap:.6rem; padding:.55rem .75rem; border-radius:var(--r); font-size:.82rem; color:var(--text-2); text-decoration:none; transition:background .15s,color .15s; }
.admin-nav a:hover { background:rgba(255,255,255,.05); color:var(--text); }
.nav-divider { border:none; border-top:1px solid var(--border); margin:.4rem 0; }
.thumb-preview { width:100%; max-width:240px; aspect-ratio:16/9; object-fit:cover; border-radius:var(--r); border:1px solid var(--border); background:var(--surface-2); display:block; margin-bottom:.5rem; }
');
?>
<main class="page" role="main">
<header class="topbar">
<div class="topbar-brand">
<div>
<div class="brand-name">Ty Clifford</div>
<div class="brand-sub">admin / edit video</div>
</div>
</div>
<nav class="topbar-social">
<a class="social-pill" href="../index.php?v=<?= urlencode($video['slug']) ?>" target="_blank">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15,3 21,3 21,9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Preview
</a>
<a class="social-pill" href="logout.php">Logout</a>
</nav>
</header>
<?php if ($msg): ?>
<div class="notice notice-success"><?= h($msg) ?></div>
<?php endif; ?>
<?php if ($error): ?>
<div class="notice notice-error"><?= h($error) ?></div>
<?php endif; ?>
<div class="admin-layout">
<aside class="admin-nav">
<div class="admin-nav-label">Navigation</div>
<a href="index.php">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
Videos
</a>
<a href="add.php">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
Add Video
</a>
<hr class="nav-divider">
<a href="settings.php">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M4.93 4.93l1.41 1.41M19.07 19.07l-1.41-1.41M4.93 19.07l1.41-1.41M12 2v2M12 20v2M2 12h2M20 12h2"/></svg>
Settings
</a>
</aside>
<div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.25rem;flex-wrap:wrap;gap:.75rem;">
<div>
<p class="eyebrow eyebrow-purple" style="margin-bottom:.3rem">Editing</p>
<h1 style="font-size:1.05rem;font-weight:700;color:var(--text);"><?= h($video['title']) ?></h1>
</div>
<a href="index.php" class="btn btn-secondary btn-sm">← Back</a>
</div>
<form method="post" action="edit.php?id=<?= $id ?>" enctype="multipart/form-data">
<input type="hidden" name="action" value="save">
<!-- ── Details ── -->
<div class="card" style="margin-bottom:1rem;">
<p class="section-heading">Video Details</p>
<div class="form-grid">
<div class="form-group span2">
<label class="form-label" for="title">Title *</label>
<input class="form-input" type="text" id="title" name="title"
value="<?= h($video['title']) ?>" required>
</div>
<div class="form-group span2">
<label class="form-label" for="description">Description</label>
<textarea class="form-textarea" id="description" name="description" rows="3"><?= h($video['description']) ?></textarea>
</div>
<div class="form-group">
<label class="form-label">Slug (auto-generated)</label>
<input class="form-input" type="text" value="<?= h($video['slug']) ?>" disabled style="opacity:.5;">
</div>
<div class="form-group">
<label class="form-label" for="duration">Duration (seconds)</label>
<input class="form-input" type="number" id="duration" name="duration"
value="<?= (int)$video['duration'] ?>" min="0">
</div>
<div class="form-group">
<label class="form-label" for="sort_order">Sort Order</label>
<input class="form-input" type="number" id="sort_order" name="sort_order"
value="<?= (int)$video['sort_order'] ?>" min="0">
</div>
<div class="form-group">
<label class="form-label">Published</label>
<label style="display:flex;align-items:center;gap:.6rem;cursor:pointer;margin-top:.35rem;">
<input type="checkbox" name="published" value="1" <?= $video['published'] ? 'checked' : '' ?>>
<span style="font-size:.82rem;color:var(--text-2);">Published</span>
</label>
</div>
<div class="form-group span2">
<label class="form-label">Thumbnail</label>
<?php if ($video['thumbnail']): ?>
<img class="thumb-preview" src="<?= h('../' . MEDIA_URL . $video['thumbnail']) ?>" alt="Current thumbnail">
<span class="hint" style="display:block;margin-bottom:.5rem;">Current: <?= h($video['thumbnail']) ?></span>
<?php endif; ?>
<input class="form-input" type="file" id="thumbnail" name="thumbnail"
accept="image/jpeg,image/png,image/webp,image/gif" style="padding:.45rem .6rem;">
<span class="hint">Upload new image to replace current thumbnail.</span>
</div>
</div>
</div>
<!-- ── Existing sources ── -->
<?php if (!empty($video['sources'])): ?>
<div class="card" style="margin-bottom:1rem;">
<p class="section-heading">Existing Sources</p>
<div class="source-list">
<?php foreach ($video['sources'] as $i => $s): ?>
<div class="source-item">
<div class="source-item-header">
<div>
<div class="source-item-file"><?= h($s['file_path']) ?></div>
<div class="source-item-mime"><?= h($s['mime_type']) ?></div>
</div>
<a href="edit.php?id=<?= $id ?>&del_src=<?= $s['id'] ?>"
class="btn btn-danger btn-sm"
onclick="return confirm('Remove this source file from disk?')">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3,6 5,6 21,6"/><path d="M19 6l-1 14H6L5 6"/><path d="M9 6V4h6v2"/></svg>
Remove
</a>
</div>
<div class="source-item-fields">
<input type="hidden" name="src_id[]" value="<?= $s['id'] ?>">
<div class="form-group">
<label class="form-label">Quality</label>
<select class="form-select" name="src_quality[]">
<?php foreach (['1080p','720p','480p','360p','4K',''] as $q): ?>
<option value="<?= $q ?>" <?= $s['quality']===$q ? 'selected' : '' ?>><?= $q ?: '—' ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Label</label>
<input class="form-input" type="text" name="src_label[]" value="<?= h($s['label']) ?>" placeholder="e.g. HD">
</div>
<div class="form-group">
<label class="form-label">Sort</label>
<input class="form-input" type="number" name="src_sort[]" value="<?= (int)$s['sort_order'] ?>" min="0">
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<!-- ── Add new sources ── -->
<div class="card" style="margin-bottom:1rem;">
<p class="section-heading">Add New Source Files</p>
<p class="hint" style="margin-bottom:.85rem;">Upload additional formats (MP4, WebM, OGV, MOV, MKV, AVI).</p>
<div class="source-list" id="new-sources-container">
<div class="add-source-row" data-new-row="0">
<div class="form-group">
<label class="form-label">Video File</label>
<input class="form-input" type="file" name="new_source_file[]"
accept="video/mp4,video/webm,video/ogg,.mp4,.webm,.ogv,.mov,.mkv,.avi"
style="padding:.45rem .6rem;">
</div>
<div class="form-group">
<label class="form-label">Quality</label>
<select class="form-select" name="new_source_quality[]">
<option value="1080p">1080p</option>
<option value="720p" selected>720p</option>
<option value="480p">480p</option>
<option value="360p">360p</option>
<option value="4K">4K</option>
<option value="">—</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Label</label>
<input class="form-input" type="text" name="new_source_label[]" placeholder="e.g. HD">
</div>
<div class="form-group">
<label class="form-label">Sort</label>
<input class="form-input" type="number" name="new_source_sort[]" value="0" min="0" style="width:70px;">
</div>
</div>
</div>
<button type="button" id="add-new-source" class="btn btn-secondary btn-sm" style="margin-top:.75rem;align-self:flex-start;">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add Another Format
</button>
</div>
<div style="display:flex;gap:.75rem;flex-wrap:wrap;">
<button class="btn btn-primary" type="submit">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17,21 17,13 7,13 7,21"/><polyline points="7,3 7,8 15,8"/></svg>
Save Changes
</button>
<a href="index.php" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
<?php render_footer(); ?>
</main>
<script>
document.getElementById('add-new-source').addEventListener('click', function() {
var container = document.getElementById('new-sources-container');
var rows = container.querySelectorAll('[data-new-row]');
var idx = rows.length;
var clone = rows[0].cloneNode(true);
clone.setAttribute('data-new-row', idx);
clone.querySelectorAll('input[type="file"]').forEach(function(el){ el.value=''; });
clone.querySelectorAll('input[type="text"]').forEach(function(el){ el.value=''; });
clone.querySelectorAll('input[type="number"]').forEach(function(el){ el.value=idx; });
if (!clone.querySelector('.remove-row')) {
var btn = document.createElement('button');
btn.type='button'; btn.className='btn btn-danger btn-sm remove-row'; btn.style='align-self:flex-end'; btn.innerHTML='✕';
btn.onclick=function(){ this.closest('[data-new-row]').remove(); };
clone.appendChild(btn);
}
container.appendChild(clone);
});
</script>
</body>
</html>
+295
View File
@@ -0,0 +1,295 @@
<?php
require_once __DIR__ . '/auth.php';
require_once __DIR__ . '/../includes/layout.php';
admin_require_auth();
$db = get_db();
// Handle quick-actions
$msg = ''; $msg_type = 'success';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
if ($action === 'delete' && !empty($_POST['id'])) {
$id = (int)$_POST['id'];
$v = get_video_by_id($id);
if ($v) {
// Remove source files + thumbnail
foreach ($v['sources'] as $s) {
$f = MEDIA_DIR . $s['file_path'];
if (file_exists($f)) unlink($f);
}
if ($v['thumbnail']) {
$t = MEDIA_DIR . $v['thumbnail'];
if (file_exists($t)) unlink($t);
}
$db->prepare("DELETE FROM videos WHERE id=?")->execute([$id]);
$msg = 'Video deleted.';
}
}
if ($action === 'toggle_published' && !empty($_POST['id'])) {
$id = (int)$_POST['id'];
$row = $db->prepare("SELECT published FROM videos WHERE id=?");
$row->execute([$id]);
$cur = $row->fetchColumn();
$db->prepare("UPDATE videos SET published=? WHERE id=?")->execute([$cur ? 0 : 1, $id]);
$msg = 'Video ' . ($cur ? 'unpublished' : 'published') . '.';
}
}
// Search + pagination
$search = trim($_GET['q'] ?? '');
$page = max(1, (int)($_GET['page'] ?? 1));
$per_page = (int)setting('catalogue_per_page', '10');
$where_sql = $search ? "WHERE v.title LIKE :q OR v.description LIKE :q" : '';
$bind = $search ? [':q' => '%' . $search . '%'] : [];
$total_stmt = $db->prepare("SELECT COUNT(*) FROM videos v $where_sql");
$total_stmt->execute($bind);
$total = (int)$total_stmt->fetchColumn();
$total_pages = max(1, (int)ceil($total / $per_page));
$page = min($page, $total_pages);
$offset = ($page - 1) * $per_page;
$list_stmt = $db->prepare("
SELECT v.id, v.title, v.slug, v.thumbnail, v.duration, v.published, v.sort_order,
v.created_at,
COUNT(vs.id) AS source_count
FROM videos v
LEFT JOIN video_sources vs ON vs.video_id = v.id
$where_sql
GROUP BY v.id
ORDER BY v.sort_order ASC, v.id DESC
LIMIT :limit OFFSET :offset
");
foreach ($bind as $k => $val) $list_stmt->bindValue($k, $val);
$list_stmt->bindValue(':limit', $per_page, PDO::PARAM_INT);
$list_stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$list_stmt->execute();
$videos = $list_stmt->fetchAll();
$qs = fn($p) => '?' . http_build_query(array_filter(['q' => $search, 'page' => $p]));
render_head('Admin — TyClifford.com', '
.admin-layout { display:grid; grid-template-columns:1fr; gap:1.5rem; }
@media(min-width:900px) { .admin-layout { grid-template-columns:220px 1fr; } }
.admin-nav {
background:var(--surface); border:1px solid var(--border);
border-radius:calc(var(--r)+2px); padding:1.2rem;
display:flex; flex-direction:column; gap:.4rem;
align-self:start; position:sticky; top:1rem;
}
.admin-nav-label { font-family:"Share Tech Mono",monospace; font-size:.58rem; letter-spacing:.18em; text-transform:uppercase; color:var(--text-3); margin-bottom:.3rem; padding-left:.5rem; }
.admin-nav a {
display:flex; align-items:center; gap:.6rem;
padding:.55rem .75rem; border-radius:var(--r);
font-size:.82rem; color:var(--text-2); text-decoration:none;
transition:background .15s, color .15s;
}
.admin-nav a:hover { background:rgba(255,255,255,.05); color:var(--text); }
.admin-nav a.active { background:rgba(176,96,255,.12); color:var(--purple); }
.admin-nav a svg { flex-shrink:0; }
.nav-divider { border:none; border-top:1px solid var(--border); margin:.4rem 0; }
.admin-main { display:flex; flex-direction:column; gap:1rem; }
.admin-toolbar { display:flex; align-items:center; gap:.75rem; flex-wrap:wrap; justify-content:space-between; }
.search-form { display:flex; gap:.5rem; flex:1; min-width:0; max-width:360px; }
.search-form input { flex:1; }
.video-table { width:100%; border-collapse:collapse; }
.video-table th, .video-table td {
padding:.65rem .85rem; text-align:left;
border-bottom:1px solid var(--border);
font-size:.82rem;
}
.video-table th {
font-family:"Share Tech Mono",monospace; font-size:.62rem; letter-spacing:.12em;
text-transform:uppercase; color:var(--text-3); background:var(--surface-2);
}
.video-table tr:hover td { background:rgba(255,255,255,.02); }
.video-table .thumb-cell { width:80px; }
.video-table .thumb-cell img { width:72px; height:40px; object-fit:cover; border-radius:4px; display:block; background:var(--surface-2); }
.video-table .thumb-cell .no-thumb-sm { width:72px; height:40px; border-radius:4px; background:var(--surface-2); display:flex; align-items:center; justify-content:center; font-family:"Share Tech Mono",monospace; font-size:.45rem; color:var(--text-3); }
.video-table .title-cell { max-width:250px; }
.video-table .title-cell a { color:var(--text); text-decoration:none; font-weight:600; }
.video-table .title-cell a:hover { color:var(--purple); }
.video-table .title-cell .slug { font-family:"Share Tech Mono",monospace; font-size:.65rem; color:var(--text-3); display:block; margin-top:.15rem; }
.video-table .actions-cell { white-space:nowrap; }
.badge { display:inline-flex; align-items:center; padding:.18em .55em; border-radius:99px; font-family:"Share Tech Mono",monospace; font-size:.6rem; letter-spacing:.08em; }
.badge-green { background:rgba(0,232,122,.12); color:var(--green); border:1px solid rgba(0,232,122,.25); }
.badge-gray { background:rgba(255,255,255,.05); color:var(--text-3); border:1px solid var(--border-2); }
.table-wrap { overflow-x:auto; background:var(--surface); border:1px solid var(--border); border-radius:calc(var(--r)+2px); }
.admin-footer-bar { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; }
.rec-count { font-family:"Share Tech Mono",monospace; font-size:.63rem; color:var(--text-3); letter-spacing:.06em; }
');
?>
<main class="page" role="main">
<header class="topbar">
<div class="topbar-brand">
<div>
<div class="brand-name">Ty Clifford</div>
<div class="brand-sub">admin / dashboard</div>
</div>
</div>
<nav class="topbar-social">
<a class="social-pill" href="../index.php" target="_blank">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15,3 21,3 21,9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
View Site
</a>
<a class="social-pill" href="logout.php">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16,17 21,12 16,7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
Logout
</a>
</nav>
</header>
<?php if ($msg): ?>
<div class="notice notice-<?= $msg_type ?>"><?= h($msg) ?></div>
<?php endif; ?>
<div class="admin-layout">
<!-- Sidebar nav -->
<aside class="admin-nav">
<div class="admin-nav-label">Navigation</div>
<a href="index.php" class="active">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
Videos
</a>
<a href="add.php">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
Add Video
</a>
<hr class="nav-divider">
<a href="settings.php">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M4.93 4.93l1.41 1.41M19.07 19.07l-1.41-1.41M4.93 19.07l1.41-1.41M12 2v2M12 20v2M2 12h2M20 12h2"/></svg>
Settings
</a>
</aside>
<!-- Main content -->
<div class="admin-main">
<div class="admin-toolbar">
<form class="search-form" method="get" action="index.php">
<input class="form-input" type="search" name="q"
placeholder="Search videos…" value="<?= h($search) ?>">
<button class="btn btn-secondary btn-sm" type="submit">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
Search
</button>
<?php if ($search): ?>
<a href="index.php" class="btn btn-secondary btn-sm">✕ Clear</a>
<?php endif; ?>
</form>
<a href="add.php" class="btn btn-primary btn-sm">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add Video
</a>
</div>
<div class="table-wrap">
<table class="video-table">
<thead>
<tr>
<th class="thumb-cell">Thumb</th>
<th class="title-cell">Title / Slug</th>
<th>Duration</th>
<th>Sources</th>
<th>Status</th>
<th>Added</th>
<th class="actions-cell">Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($videos)): ?>
<tr><td colspan="7" style="text-align:center;padding:3rem;font-family:'Share Tech Mono',monospace;font-size:.7rem;color:var(--text-3);">
<?= $search ? 'No videos match "' . h($search) . '"' : 'No videos yet. <a href="add.php" style="color:var(--purple)">Add one!</a>' ?>
</td></tr>
<?php else: ?>
<?php foreach ($videos as $v): ?>
<tr>
<td class="thumb-cell">
<?php if ($v['thumbnail']): ?>
<img src="<?= h('../' . MEDIA_URL . $v['thumbnail']) ?>" alt="">
<?php else: ?>
<div class="no-thumb-sm">NO IMG</div>
<?php endif; ?>
</td>
<td class="title-cell">
<a href="edit.php?id=<?= $v['id'] ?>"><?= h($v['title']) ?></a>
<span class="slug">/<?= h($v['slug']) ?></span>
</td>
<td style="font-family:'Share Tech Mono',monospace;font-size:.7rem;color:var(--text-3);">
<?= $v['duration'] ? format_duration((int)$v['duration']) : '—' ?>
</td>
<td style="font-family:'Share Tech Mono',monospace;font-size:.7rem;color:var(--text-2);">
<?= $v['source_count'] ?> file<?= $v['source_count'] != 1 ? 's' : '' ?>
</td>
<td>
<span class="badge <?= $v['published'] ? 'badge-green' : 'badge-gray' ?>">
<?= $v['published'] ? 'Published' : 'Draft' ?>
</span>
</td>
<td style="font-family:'Share Tech Mono',monospace;font-size:.65rem;color:var(--text-3);">
<?= substr($v['created_at'], 0, 10) ?>
</td>
<td class="actions-cell">
<div style="display:flex;gap:.4rem;align-items:center;">
<a href="edit.php?id=<?= $v['id'] ?>" class="btn btn-secondary btn-sm" title="Edit">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
Edit
</a>
<form method="post" style="display:inline" onsubmit="return confirm('Delete this video and all its files?')">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="<?= $v['id'] ?>">
<button class="btn btn-danger btn-sm" type="submit" title="Delete">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3,6 5,6 21,6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/></svg>
</button>
</form>
<form method="post" style="display:inline">
<input type="hidden" name="action" value="toggle_published">
<input type="hidden" name="id" value="<?= $v['id'] ?>">
<button class="btn btn-secondary btn-sm" type="submit" title="<?= $v['published'] ? 'Unpublish' : 'Publish' ?>">
<?php if ($v['published']): ?>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
<?php else: ?>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
<?php endif; ?>
</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<?php if ($total_pages > 1 || $total > 0): ?>
<div class="admin-footer-bar">
<span class="rec-count">
<?= $total ?> video<?= $total != 1 ? 's' : '' ?>
<?= $search ? '· filtered' : '' ?>
· page <?= $page ?> of <?= $total_pages ?>
</span>
<?php
render_pagination($page, $total_pages, 'index.php?' . ($search ? 'q=' . urlencode($search) . '&' : '') . 'page=%d');
?>
</div>
<?php endif; ?>
</div><!-- /admin-main -->
</div><!-- /admin-layout -->
<?php render_footer(); ?>
</main>
</body>
</html>
+81
View File
@@ -0,0 +1,81 @@
<?php
require_once __DIR__ . '/auth.php';
require_once __DIR__ . '/../includes/layout.php';
if (admin_is_logged_in()) {
header('Location: index.php');
exit;
}
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$pw = $_POST['password'] ?? '';
if (admin_login($pw)) {
header('Location: index.php');
exit;
}
$error = 'Invalid password.';
}
render_head('Admin Login — TyClifford.com', '
.login-wrap {
display:flex; align-items:center; justify-content:center;
min-height:70vh;
}
.login-card {
background:var(--surface); border:1px solid var(--border);
border-radius:calc(var(--r)+4px); padding:2.5rem 2rem;
width:100%; max-width:380px;
display:flex; flex-direction:column; gap:1.5rem;
position:relative; overflow:hidden;
box-shadow:0 4px 60px rgba(0,0,0,.5);
}
.login-card::before {
content:""; position:absolute; top:0; left:0; right:0; height:2px;
background:linear-gradient(90deg,var(--pat),var(--purple),var(--blue),var(--green),var(--blue),var(--purple),var(--pat));
background-size:300% 100%; animation:shimmer 5s linear infinite;
}
.login-title { font-size:1.1rem; font-weight:700; color:var(--text); }
.login-sub { font-family:"Share Tech Mono",monospace; font-size:.63rem; color:var(--text-3); letter-spacing:.12em; text-transform:uppercase; margin-top:.15rem; }
');
?>
<main class="page" role="main">
<header class="topbar">
<div class="topbar-brand">
<div>
<div class="brand-name">Ty Clifford</div>
<div class="brand-sub">admin dashboard</div>
</div>
</div>
<a href="../index.php" class="btn btn-secondary btn-sm">← Back to site</a>
</header>
<div class="login-wrap">
<div class="login-card">
<div>
<div class="login-title">Admin Login</div>
<div class="login-sub">tyclifford.com / media / admin</div>
</div>
<?php if ($error): ?>
<div class="notice notice-error"><?= h($error) ?></div>
<?php endif; ?>
<form method="post" action="login.php">
<div class="form-group" style="margin-bottom:1rem">
<label class="form-label" for="password">Password</label>
<input class="form-input" type="password" id="password" name="password"
autofocus autocomplete="current-password" placeholder="••••••••">
</div>
<button class="btn btn-primary" type="submit" style="width:100%">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
Sign In
</button>
</form>
</div>
</div>
<?php render_footer(); ?>
</main>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
<?php
require_once __DIR__ . '/auth.php';
admin_logout();
header('Location: login.php');
exit;
+219
View File
@@ -0,0 +1,219 @@
<?php
require_once __DIR__ . '/auth.php';
require_once __DIR__ . '/../includes/layout.php';
admin_require_auth();
$msg = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? 'settings';
if ($action === 'settings') {
$per_page = max(1, min(50, (int)($_POST['catalogue_per_page'] ?? 10)));
$site_title = trim($_POST['site_title'] ?? 'Ty Clifford');
$site_sub = trim($_POST['site_sub'] ?? 'tyclifford.com / media');
set_setting('catalogue_per_page', (string)$per_page);
set_setting('site_title', $site_title);
set_setting('site_sub', $site_sub);
$msg = 'Settings saved.';
}
if ($action === 'change_password') {
$current = $_POST['current_password'] ?? '';
$new1 = $_POST['new_password'] ?? '';
$new2 = $_POST['new_password2'] ?? '';
if (!password_verify($current, setting('admin_password'))) {
$msg = 'ERROR: Current password is incorrect.';
} elseif (strlen($new1) < 6) {
$msg = 'ERROR: New password must be at least 6 characters.';
} elseif ($new1 !== $new2) {
$msg = 'ERROR: New passwords do not match.';
} else {
set_setting('admin_password', password_hash($new1, PASSWORD_DEFAULT));
$msg = 'Password changed successfully.';
}
}
}
$per_page = setting('catalogue_per_page', '10');
$site_title = setting('site_title', 'Ty Clifford');
$site_sub = setting('site_sub', 'tyclifford.com / media');
// DB stats
$db = get_db();
$total_videos = $db->query("SELECT COUNT(*) FROM videos")->fetchColumn();
$total_sources = $db->query("SELECT COUNT(*) FROM video_sources")->fetchColumn();
$db_size = file_exists(DB_PATH) ? round(filesize(DB_PATH)/1024, 1) : 0;
$media_size = 0;
if (is_dir(MEDIA_DIR)) {
$iter = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(MEDIA_DIR, FilesystemIterator::SKIP_DOTS));
foreach ($iter as $f) $media_size += $f->getSize();
$media_size = round($media_size / 1048576, 1);
}
$msg_type = str_starts_with($msg, 'ERROR') ? 'error' : 'success';
render_head('Settings — Admin', '
.admin-layout { display:grid; grid-template-columns:1fr; gap:1.5rem; }
@media(min-width:900px) { .admin-layout { grid-template-columns:220px 1fr; } }
.admin-nav { background:var(--surface); border:1px solid var(--border); border-radius:calc(var(--r)+2px); padding:1.2rem; display:flex; flex-direction:column; gap:.4rem; align-self:start; position:sticky; top:1rem; }
.admin-nav-label { font-family:"Share Tech Mono",monospace; font-size:.58rem; letter-spacing:.18em; text-transform:uppercase; color:var(--text-3); margin-bottom:.3rem; padding-left:.5rem; }
.admin-nav a { display:flex; align-items:center; gap:.6rem; padding:.55rem .75rem; border-radius:var(--r); font-size:.82rem; color:var(--text-2); text-decoration:none; transition:background .15s,color .15s; }
.admin-nav a:hover { background:rgba(255,255,255,.05); color:var(--text); }
.admin-nav a.active { background:rgba(176,96,255,.12); color:var(--purple); }
.nav-divider { border:none; border-top:1px solid var(--border); margin:.4rem 0; }
.form-grid { display:grid; grid-template-columns:1fr; gap:1rem; }
@media(min-width:600px) { .form-grid { grid-template-columns:1fr 1fr; } }
.section-heading { font-family:"Share Tech Mono",monospace; font-size:.65rem; letter-spacing:.15em; text-transform:uppercase; color:var(--text-3); margin-bottom:.75rem; }
.hint { font-size:.72rem; color:var(--text-3); margin-top:.2rem; }
.stat-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(130px,1fr)); gap:.75rem; }
.stat-tile { background:var(--surface-2); border:1px solid var(--border); border-radius:var(--r); padding:1rem; display:flex; flex-direction:column; gap:.3rem; }
.stat-val { font-family:"Share Tech Mono",monospace; font-size:1.4rem; color:var(--text); font-weight:700; }
.stat-label{ font-family:"Share Tech Mono",monospace; font-size:.6rem; letter-spacing:.1em; text-transform:uppercase; color:var(--text-3); }
');
?>
<main class="page" role="main">
<header class="topbar">
<div class="topbar-brand">
<div>
<div class="brand-name">Ty Clifford</div>
<div class="brand-sub">admin / settings</div>
</div>
</div>
<nav class="topbar-social">
<a class="social-pill" href="../index.php" target="_blank">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15,3 21,3 21,9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
View Site
</a>
<a class="social-pill" href="logout.php">Logout</a>
</nav>
</header>
<?php if ($msg): ?>
<div class="notice notice-<?= $msg_type ?>"><?= h($msg) ?></div>
<?php endif; ?>
<div class="admin-layout">
<aside class="admin-nav">
<div class="admin-nav-label">Navigation</div>
<a href="index.php">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
Videos
</a>
<a href="add.php">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
Add Video
</a>
<hr class="nav-divider">
<a href="settings.php" class="active">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M4.93 4.93l1.41 1.41M19.07 19.07l-1.41-1.41M4.93 19.07l1.41-1.41M12 2v2M12 20v2M2 12h2M20 12h2"/></svg>
Settings
</a>
</aside>
<div style="display:flex;flex-direction:column;gap:1.25rem;">
<!-- Stats -->
<div class="card">
<p class="section-heading">Overview</p>
<div class="stat-grid">
<div class="stat-tile">
<span class="stat-val"><?= $total_videos ?></span>
<span class="stat-label">Videos</span>
</div>
<div class="stat-tile">
<span class="stat-val"><?= $total_sources ?></span>
<span class="stat-label">Source Files</span>
</div>
<div class="stat-tile">
<span class="stat-val"><?= $db_size ?> KB</span>
<span class="stat-label">Database</span>
</div>
<div class="stat-tile">
<span class="stat-val"><?= $media_size ?> MB</span>
<span class="stat-label">Media Storage</span>
</div>
</div>
</div>
<!-- General settings -->
<div class="card">
<p class="section-heading">General Settings</p>
<form method="post" action="settings.php">
<input type="hidden" name="action" value="settings">
<div class="form-grid" style="margin-bottom:1rem;">
<div class="form-group">
<label class="form-label" for="site_title">Site Title</label>
<input class="form-input" type="text" id="site_title" name="site_title"
value="<?= h($site_title) ?>">
</div>
<div class="form-group">
<label class="form-label" for="site_sub">Site Subtitle</label>
<input class="form-input" type="text" id="site_sub" name="site_sub"
value="<?= h($site_sub) ?>">
</div>
<div class="form-group">
<label class="form-label" for="catalogue_per_page">Videos per page</label>
<input class="form-input" type="number" id="catalogue_per_page"
name="catalogue_per_page" value="<?= h($per_page) ?>" min="1" max="50">
<span class="hint">How many thumbnails to show per page (150).</span>
</div>
</div>
<button class="btn btn-primary btn-sm" type="submit">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17,21 17,13 7,13 7,21"/><polyline points="7,3 7,8 15,8"/></svg>
Save Settings
</button>
</form>
</div>
<!-- Change password -->
<div class="card">
<p class="section-heading">Change Password</p>
<form method="post" action="settings.php">
<input type="hidden" name="action" value="change_password">
<div class="form-grid" style="margin-bottom:1rem;">
<div class="form-group">
<label class="form-label" for="current_password">Current Password</label>
<input class="form-input" type="password" id="current_password" name="current_password"
autocomplete="current-password">
</div>
<div></div>
<div class="form-group">
<label class="form-label" for="new_password">New Password</label>
<input class="form-input" type="password" id="new_password" name="new_password"
autocomplete="new-password" minlength="6">
<span class="hint">Minimum 6 characters.</span>
</div>
<div class="form-group">
<label class="form-label" for="new_password2">Confirm New Password</label>
<input class="form-input" type="password" id="new_password2" name="new_password2"
autocomplete="new-password">
</div>
</div>
<button class="btn btn-secondary btn-sm" type="submit">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
Change Password
</button>
</form>
</div>
<!-- File locations reference -->
<div class="card">
<p class="section-heading">File Locations</p>
<div style="display:flex;flex-direction:column;gap:.5rem;font-family:'Share Tech Mono',monospace;font-size:.7rem;color:var(--text-3);">
<div><span style="color:var(--text-2);">Database:</span> data/media.db</div>
<div><span style="color:var(--text-2);">Videos:</span> media/videos/</div>
<div><span style="color:var(--text-2);">Thumbnails:</span> media/thumbs/</div>
<div><span style="color:var(--text-2);">Default password:</span> <span style="color:var(--red);">admin</span> — change immediately!</div>
</div>
</div>
</div>
</div>
<?php render_footer(); ?>
</main>
</body>
</html>
+128
View File
@@ -0,0 +1,128 @@
<?php
require_once __DIR__ . '/includes/db.php';
require_once __DIR__ . '/includes/layout.php';
$per_page = (int)setting('catalogue_per_page', '10');
$page = max(1, (int)($_GET['page'] ?? 1));
$paged = get_videos_paginated($page, $per_page);
render_head('Videos — TyClifford.com', '
.cat-grid {
display:grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap:1rem;
}
@media(min-width:600px) { .cat-grid { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); } }
@media(min-width:1000px) { .cat-grid { grid-template-columns: repeat(4, 1fr); } }
.cat-thumb {
background:var(--surface-2); border:1px solid var(--border);
border-radius:calc(var(--r)+2px); overflow:hidden; text-decoration:none;
color:var(--text); display:flex; flex-direction:column;
transition:border-color .2s, transform .15s, box-shadow .2s;
position:relative; cursor:pointer;
}
.cat-thumb:hover { border-color:var(--purple); transform:translateY(-2px); box-shadow:0 6px 24px rgba(176,96,255,.18); }
.cat-thumb-img { position:relative; width:100%; padding-top:56.25%; background:#000; overflow:hidden; }
.cat-thumb-img img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; transition:transform .3s; }
.cat-thumb:hover .cat-thumb-img img { transform:scale(1.04); }
.cat-thumb-img .no-thumb {
position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
font-family:"Share Tech Mono",monospace; font-size:.6rem; color:var(--text-3); letter-spacing:.1em;
background:var(--surface);
}
.cat-thumb-dur {
position:absolute; bottom:4px; right:6px;
background:rgba(0,0,0,.75); backdrop-filter:blur(4px);
font-family:"Share Tech Mono",monospace; font-size:.55rem;
color:#fff; padding:.15em .45em; border-radius:3px;
}
.play-overlay {
position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
background:rgba(0,0,0,.35); opacity:0; transition:opacity .2s;
}
.cat-thumb:hover .play-overlay { opacity:1; }
.cat-thumb-body { padding:.65rem .75rem; display:flex; flex-direction:column; gap:.25rem; flex:1; }
.cat-thumb-title { font-size:.85rem; font-weight:600; color:var(--text); line-height:1.35; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
.cat-thumb-desc { font-size:.75rem; color:var(--text-3); line-height:1.5; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
.cat-thumb-meta { font-family:"Share Tech Mono",monospace; font-size:.6rem; color:var(--text-3); letter-spacing:.06em; margin-top:auto; padding-top:.4rem; }
.page-header { display:flex; flex-direction:column; gap:.5rem; }
.page-header-row { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; }
.cat-footer { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; margin-top:.5rem; }
.cat-count { font-family:"Share Tech Mono",monospace; font-size:.65rem; color:var(--text-3); letter-spacing:.06em; }
.empty-state { padding:4rem 1rem; text-align:center; display:flex; flex-direction:column; align-items:center; gap:1rem; }
.empty-state svg { color:var(--text-3); opacity:.35; }
.empty-state p { font-family:"Share Tech Mono",monospace; font-size:.75rem; color:var(--text-3); letter-spacing:.1em; }
');
?>
<main class="page" role="main">
<?php render_topbar('catalogue'); ?>
<div class="page-header">
<div class="page-header-row">
<div>
<p class="eyebrow eyebrow-purple" style="margin-bottom:.3rem">Library</p>
<h1 style="font-size:1.1rem;font-weight:700;color:var(--text);">Video Catalogue</h1>
</div>
<span class="cat-count"><?= $paged['total'] ?> video<?= $paged['total'] != 1 ? 's' : '' ?></span>
</div>
</div>
<?php if (empty($paged['videos'])): ?>
<div class="card">
<div class="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
<p>no videos in the catalogue yet</p>
</div>
</div>
<?php else: ?>
<div class="cat-grid">
<?php foreach ($paged['videos'] as $v):
$vslug = $v['slug'];
$vtitle = $v['title'];
$vdesc = $v['description'];
$vthumb = $v['thumbnail'];
$vdur = (int)$v['duration'];
$created = substr($v['created_at'], 0, 10);
?>
<a class="cat-thumb" href="https://tyclifford.com/player/?v=<?= urlencode($vslug) ?>" title="<?= h($vtitle) ?>">
<div class="cat-thumb-img">
<?php if ($vthumb): ?>
<img src="<?= h(MEDIA_URL . $vthumb) ?>" alt="<?= h($vtitle) ?>" loading="lazy">
<?php else: ?>
<div class="no-thumb">NO THUMBNAIL</div>
<?php endif; ?>
<?php if ($vdur): ?>
<span class="cat-thumb-dur"><?= format_duration($vdur) ?></span>
<?php endif; ?>
<div class="play-overlay">
<svg width="36" height="36" viewBox="0 0 24 24" fill="white"><circle cx="12" cy="12" r="12" fill="rgba(0,0,0,.4)"/><polygon points="10,8 18,12 10,16" fill="white"/></svg>
</div>
</div>
<div class="cat-thumb-body">
<span class="cat-thumb-title"><?= h($vtitle) ?></span>
<?php if ($vdesc): ?>
<span class="cat-thumb-desc"><?= h($vdesc) ?></span>
<?php endif; ?>
<span class="cat-thumb-meta"><?= h($created) ?><?= $vdur ? ' · ' . format_duration($vdur) : '' ?></span>
</div>
</a>
<?php endforeach; ?>
</div>
<?php if ($paged['total_pages'] > 1): ?>
<div class="cat-footer">
<span class="cat-count">page <?= $paged['page'] ?> of <?= $paged['total_pages'] ?></span>
<?php render_pagination($page, $paged['total_pages'], 'catalogue.php?page=%d'); ?>
</div>
<?php endif; ?>
<?php endif; ?>
<?php render_footer(); ?>
</main>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
Require all denied
+246
View File
@@ -0,0 +1,246 @@
<?php
/**
* embed.php — Standalone embeddable player page.
* Designed to be used inside an <iframe> on third-party sites.
*
* Usage: <iframe src="https://yoursite.com/embed.php?v=video-slug"></iframe>
*/
require_once __DIR__ . '/includes/db.php';
$slug = trim($_GET['v'] ?? '');
$video = $slug ? get_video_by_slug($slug) : null;
// Determine absolute base URL for media files.
// We need absolute URLs here because the embed lives on a foreign domain.
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$script = dirname($_SERVER['SCRIPT_NAME'] ?? '/');
$script = rtrim($script, '/');
$base_url = $scheme . '://' . $host . $script . '/'; // e.g. https://tyclifford.com/live/
// X-Frame-Options: allow embedding from any origin.
// If you want to restrict, change ALLOWALL to SAMEORIGIN or remove for CSP header instead.
header('X-Frame-Options: ALLOWALL');
header('Content-Security-Policy: frame-ancestors *');
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $video ? htmlspecialchars($video['title'], ENT_QUOTES) . ' — TyClifford.com' : 'Video Player' ?></title>
<link href="https://vjs.zencdn.net/8.10.0/video-js.css" rel="stylesheet"/>
<script src="https://vjs.zencdn.net/8.10.0/video.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--green: #00e87a;
--purple: #b060ff;
--bg: #09090f;
--text: #e8e8f8;
--text-2: #9898c0;
--text-3: #58587a;
}
html, body {
width: 100%; height: 100%;
background: var(--bg);
overflow: hidden;
font-family: 'Inter', system-ui, sans-serif;
color: var(--text);
}
.player-wrap {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background: #000;
position: relative;
}
/* Top shimmer bar — matches site theme */
.shimmer-bar {
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
z-index: 10;
background: linear-gradient(90deg,
#ff424d, #b060ff, #00c8ff, #00e87a,
#00c8ff, #b060ff, #ff424d);
background-size: 300% 100%;
animation: shimmer 5s linear infinite;
}
@keyframes shimmer {
from { background-position: 0% 50%; }
to { background-position: 200% 50%; }
}
/* Video.js fills the whole container */
.video-js {
width: 100% !important;
height: 100% !important;
position: absolute !important;
inset: 0 !important;
}
/* Custom VJS skin to match site */
.video-js .vjs-big-play-button {
background: rgba(176,96,255,.8) !important;
border: 2px solid rgba(176,96,255,.95) !important;
border-radius: 50% !important;
width: 64px !important; height: 64px !important;
top: 50% !important; left: 50% !important;
transform: translate(-50%, -50%) !important;
margin: 0 !important;
line-height: 64px !important;
transition: background .2s !important;
}
.video-js:hover .vjs-big-play-button {
background: rgba(176,96,255,1) !important;
}
.video-js .vjs-control-bar {
background: linear-gradient(transparent, rgba(0,0,0,.9)) !important;
height: 3.2em !important;
}
.video-js .vjs-play-progress { background: var(--green) !important; }
.video-js .vjs-volume-level { background: var(--green) !important; }
.video-js .vjs-slider { background: rgba(255,255,255,.15) !important; }
.video-js .vjs-load-progress { background: rgba(255,255,255,.1) !important; }
.video-js .vjs-current-time,
.video-js .vjs-duration,
.video-js .vjs-time-divider { color: rgba(255,255,255,.8) !important; font-size: .7em !important; }
/* Title + branding overlay at top (fades out) */
.title-overlay {
position: absolute;
top: 8px; left: 12px; right: 12px;
z-index: 5;
display: flex;
align-items: flex-start;
justify-content: space-between;
pointer-events: none;
opacity: 1;
transition: opacity .5s;
}
.title-overlay.hidden { opacity: 0; }
.title-text {
font-size: .78rem;
font-weight: 600;
color: #fff;
text-shadow: 0 1px 6px rgba(0,0,0,.8);
line-height: 1.3;
max-width: 70%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.brand-link {
font-family: 'Courier New', monospace;
font-size: .6rem;
letter-spacing: .12em;
color: rgba(255,255,255,.55);
text-decoration: none;
text-shadow: 0 1px 4px rgba(0,0,0,.8);
pointer-events: all;
white-space: nowrap;
transition: color .2s;
}
.brand-link:hover { color: var(--green); }
/* No-video state */
.no-video {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%; height: 100%;
gap: .75rem;
background: var(--bg);
}
.no-video p {
font-family: 'Courier New', monospace;
font-size: .72rem;
color: var(--text-3);
letter-spacing: .1em;
}
</style>
</head>
<body>
<div class="player-wrap" id="player-wrap">
<div class="shimmer-bar"></div>
<?php if ($video && !empty($video['sources'])): ?>
<!-- Title + brand overlay -->
<div class="title-overlay" id="title-overlay">
<span class="title-text"><?= htmlspecialchars($video['title'], ENT_QUOTES) ?></span>
<a class="brand-link" href="<?= $base_url ?>index.php?v=<?= urlencode($video['slug']) ?>"
target="_blank" rel="noopener">tyclifford.com</a>
</div>
<video id="embed-player"
class="video-js vjs-default-skin vjs-big-play-centered"
controls preload="metadata"
<?php if ($video['thumbnail']): ?>
poster="<?= htmlspecialchars($base_url . MEDIA_URL . $video['thumbnail'], ENT_QUOTES) ?>"
<?php endif; ?>>
<?php foreach ($video['sources'] as $src): ?>
<source
src="<?= htmlspecialchars($base_url . MEDIA_URL . $src['file_path'], ENT_QUOTES) ?>"
type="<?= htmlspecialchars($src['mime_type'], ENT_QUOTES) ?>">
<?php endforeach; ?>
<p class="vjs-no-js">Your browser does not support HTML5 video.</p>
</video>
<?php else: ?>
<div class="no-video">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none"
stroke="#58587a" stroke-width="1.5">
<rect x="2" y="3" width="20" height="14" rx="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
<p><?= $slug ? 'video not found' : 'no video specified' ?></p>
</div>
<?php endif; ?>
</div>
<?php if ($video && !empty($video['sources'])): ?>
<script>
document.addEventListener('DOMContentLoaded', function () {
var player = videojs('embed-player', {
controls: true,
autoplay: false,
preload: 'metadata',
fluid: false,
responsive: false,
playbackRates: [0.5, 1, 1.25, 1.5, 2],
controlBar: {
children: [
'playToggle', 'volumePanel', 'currentTimeDisplay',
'timeDivider', 'durationDisplay', 'progressControl',
'playbackRateMenuButton', 'fullscreenToggle'
]
}
});
// Hide title overlay once user starts playing
var overlay = document.getElementById('title-overlay');
player.on('play', function () {
if (overlay) overlay.classList.add('hidden');
});
player.on('pause', function () {
if (overlay) overlay.classList.remove('hidden');
});
});
</script>
<?php endif; ?>
</body>
</html>
+168
View File
@@ -0,0 +1,168 @@
<?php
/**
* db.php — SQLite database bootstrap & helpers
*/
define('DB_PATH', __DIR__ . '/../data/media.db');
define('MEDIA_DIR', __DIR__ . '/../media/');
define('MEDIA_URL', 'media/');
function get_db(): PDO {
static $pdo = null;
if ($pdo) return $pdo;
$dir = dirname(DB_PATH);
if (!is_dir($dir)) mkdir($dir, 0755, true);
$pdo = new PDO('sqlite:' . DB_PATH);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$pdo->exec('PRAGMA journal_mode=WAL');
$pdo->exec('PRAGMA foreign_keys=ON');
// Schema
$pdo->exec("
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS videos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT 'Untitled',
description TEXT NOT NULL DEFAULT '',
slug TEXT NOT NULL UNIQUE,
thumbnail TEXT NOT NULL DEFAULT '',
duration INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
published INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS video_sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
video_id INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
label TEXT NOT NULL DEFAULT '',
file_path TEXT NOT NULL,
mime_type TEXT NOT NULL DEFAULT 'video/mp4',
quality TEXT NOT NULL DEFAULT '720p',
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_videos_published ON videos(published);
CREATE INDEX IF NOT EXISTS idx_videos_sort ON videos(sort_order, id);
CREATE INDEX IF NOT EXISTS idx_sources_video ON video_sources(video_id);
");
// Default settings
$defaults = [
'catalogue_per_page' => '10',
'admin_password' => password_hash('admin', PASSWORD_DEFAULT),
'site_title' => 'Ty Clifford',
'site_sub' => 'tyclifford.com / media',
];
$ins = $pdo->prepare("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)");
foreach ($defaults as $k => $v) $ins->execute([$k, $v]);
return $pdo;
}
function setting(string $key, string $fallback = ''): string {
$row = get_db()->prepare("SELECT value FROM settings WHERE key=?");
$row->execute([$key]);
$r = $row->fetch();
return $r ? $r['value'] : $fallback;
}
function set_setting(string $key, string $value): void {
get_db()->prepare("INSERT OR REPLACE INTO settings (key,value) VALUES (?,?)")
->execute([$key, $value]);
}
function make_slug(string $title, int $id = 0): string {
$slug = strtolower(trim(preg_replace('/[^a-z0-9]+/i', '-', $title), '-'));
if ($id) {
$exists = get_db()->prepare("SELECT id FROM videos WHERE slug=? AND id!=?");
$exists->execute([$slug, $id]);
} else {
$exists = get_db()->prepare("SELECT id FROM videos WHERE slug=?");
$exists->execute([$slug]);
}
if ($exists->fetch()) $slug .= '-' . ($id ?: time());
return $slug ?: 'video-' . time();
}
function mime_from_ext(string $path): string {
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
return match($ext) {
'mp4' => 'video/mp4',
'webm' => 'video/webm',
'ogv' => 'video/ogg',
'mov' => 'video/quicktime',
'mkv' => 'video/x-matroska',
'avi' => 'video/x-msvideo',
default => 'video/mp4',
};
}
function format_duration(int $seconds): string {
if ($seconds <= 0) return '';
$h = intdiv($seconds, 3600);
$m = intdiv($seconds % 3600, 60);
$s = $seconds % 60;
if ($h) return sprintf('%d:%02d:%02d', $h, $m, $s);
return sprintf('%d:%02d', $m, $s);
}
function h(string $s): string {
return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function get_videos_paginated(int $page, int $per_page, bool $published_only = true): array {
$db = get_db();
$where = $published_only ? 'WHERE v.published=1' : '';
$offset = ($page - 1) * $per_page;
$total = $db->query("SELECT COUNT(*) FROM videos v $where")->fetchColumn();
$rows = $db->prepare("
SELECT v.*, GROUP_CONCAT(vs.file_path,'||') AS source_paths
FROM videos v
LEFT JOIN video_sources vs ON vs.video_id = v.id
$where
GROUP BY v.id
ORDER BY v.sort_order ASC, v.id DESC
LIMIT ? OFFSET ?
");
$rows->execute([$per_page, $offset]);
return [
'videos' => $rows->fetchAll(),
'total' => (int)$total,
'page' => $page,
'per_page' => $per_page,
'total_pages' => (int)ceil($total / $per_page),
];
}
function get_video_by_slug(string $slug): ?array {
$db = get_db();
$row = $db->prepare("SELECT * FROM videos WHERE slug=? AND published=1");
$row->execute([$slug]);
$v = $row->fetch();
if (!$v) return null;
$sources = $db->prepare("SELECT * FROM video_sources WHERE video_id=? ORDER BY sort_order ASC");
$sources->execute([$v['id']]);
$v['sources'] = $sources->fetchAll();
return $v;
}
function get_video_by_id(int $id): ?array {
$db = get_db();
$row = $db->prepare("SELECT * FROM videos WHERE id=?");
$row->execute([$id]);
$v = $row->fetch();
if (!$v) return null;
$sources = $db->prepare("SELECT * FROM video_sources WHERE video_id=? ORDER BY sort_order ASC");
$sources->execute([$v['id']]);
$v['sources'] = $sources->fetchAll();
return $v;
}
+276
View File
@@ -0,0 +1,276 @@
<?php
/**
* layout.php — shared HTML head, header, and footer partials
*/
function render_head(string $title = 'Media — TyClifford.com', string $extra_css = ''): void { ?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= h($title) ?></title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<!-- Video.js -->
<link href="https://vjs.zencdn.net/8.10.0/video-js.css" rel="stylesheet" />
<script src="https://vjs.zencdn.net/8.10.0/video.min.js"></script>
<style>
/* ── Reset & design tokens (mirrors gate.php exactly) ──────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #09090f;
--surface: #0f0f1a;
--surface-2: #141425;
--border: #1e1e38;
--border-2: #2a2a48;
--text: #e8e8f8;
--text-2: #9898c0;
--text-3: #58587a;
--green: #00e87a;
--blue: #00c8ff;
--purple: #b060ff;
--red: #ff3355;
--orange: #ff8800;
--yellow: #ffd040;
--pat: #ff424d;
--pat-lite: rgba(255,66,77,.10);
--pat-border:rgba(255,66,77,.32);
--pat-glow: rgba(255,66,77,.18);
--r: 6px;
--gap: 1.25rem;
--page-pad: 1rem;
}
@media (min-width:600px) { :root { --page-pad: 1.5rem; } }
@media (min-width:1000px) { :root { --page-pad: 2rem; } }
html { font-size:16px; -webkit-text-size-adjust:100%; scroll-behavior:smooth; }
body {
min-height: 100dvh;
background: var(--bg);
background-image:
radial-gradient(ellipse 70% 50% at 20% 10%, rgba(176,96,255,.05) 0%, transparent 60%),
radial-gradient(ellipse 60% 40% at 80% 80%, rgba(0,200,255,.04) 0%, transparent 60%),
radial-gradient(ellipse 50% 60% at 50% 50%, rgba(255,66,77,.03) 0%, transparent 70%);
font-family: 'Inter', system-ui, sans-serif;
color: var(--text);
display: flex;
flex-direction: column;
align-items: center;
}
body::after {
content:''; position:fixed; inset:0; pointer-events:none; z-index:999;
background: repeating-linear-gradient(0deg,transparent 0px,transparent 3px,rgba(0,0,0,.06) 3px,rgba(0,0,0,.06) 4px);
}
@keyframes rise { from{opacity:0;transform:translateY(14px) scale(.98)} to{opacity:1;transform:translateY(0) scale(1)} }
@keyframes shimmer{ from{background-position:0% 50%} to{background-position:200% 50%} }
@keyframes pulse-border { 0%,100%{box-shadow:0 0 0 0 var(--pat-glow)} 50%{box-shadow:0 0 0 6px transparent} }
@keyframes live-pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
/* ── Page wrapper ── */
.page { width:100%; max-width:1100px; padding:var(--page-pad); display:flex; flex-direction:column; gap:1.5rem; animation:rise .45s cubic-bezier(.22,.68,0,1.2) both; }
/* ── Top bar ── */
.topbar { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; }
.topbar-brand { display:flex; align-items:center; gap:.75rem; }
.brand-name { font-size:1rem; font-weight:700; letter-spacing:-.01em; color:var(--text); text-decoration:none; }
.brand-sub { font-family:'Share Tech Mono',monospace; font-size:.62rem; letter-spacing:.18em; text-transform:uppercase; color:var(--text-3); }
.topbar-social { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; }
.social-pill {
display:inline-flex; align-items:center; gap:.4rem;
padding:.35rem .75rem;
background:rgba(255,255,255,.03);
border:1px solid var(--border-2);
border-radius:99px;
font-family:'Share Tech Mono',monospace; font-size:.65rem; letter-spacing:.08em;
color:var(--text-3); text-decoration:none;
transition:border-color .2s, color .2s, background .2s;
}
.social-pill:hover { color:var(--text-2); background:rgba(255,255,255,.06); }
.social-pill svg { flex-shrink:0; }
.social-pill.active { border-color:var(--purple); color:var(--purple); }
/* ── Cards ── */
.card {
background:var(--surface);
border:1px solid var(--border);
border-radius:calc(var(--r) + 2px);
padding:1.4rem 1.5rem;
display:flex; flex-direction:column; gap:.9rem;
position:relative; overflow:hidden;
}
.card::after {
content:''; position:absolute; bottom:-1px; right:-1px;
width:14px; height:14px;
border-bottom:2px solid var(--purple); border-right:2px solid var(--purple);
border-radius:0 0 calc(var(--r)+2px) 0; opacity:.5;
}
.card-title { font-size:.95rem; font-weight:700; letter-spacing:-.01em; color:var(--text); line-height:1.2; }
.card-body { font-size:.85rem; color:var(--text-2); line-height:1.7; }
.card-body strong { color:var(--text); font-weight:600; }
.card-body a { color:var(--pat); text-decoration:none; border-bottom:1px solid rgba(255,66,77,.25); transition:border-color .15s; }
.card-body a:hover { border-color:rgba(255,66,77,.6); }
/* ── Eyebrow ── */
.eyebrow { font-family:'Share Tech Mono',monospace; font-size:.62rem; letter-spacing:.2em; text-transform:uppercase; }
.eyebrow-green { color:var(--green); text-shadow:0 0 10px rgba(0,232,122,.5); }
.eyebrow-green::before { content:'> '; opacity:.5; }
.eyebrow-purple { color:var(--purple); }
.eyebrow-blue { color:var(--blue); }
/* ── Stream card / player card ── */
.stream-card {
background:var(--surface); border:1px solid var(--border);
border-radius:calc(var(--r)+2px); overflow:hidden; position:relative;
box-shadow:0 0 0 1px rgba(255,255,255,.03), 0 4px 50px rgba(0,0,0,.6);
}
.stream-card::before {
content:''; position:absolute; top:0; left:0; right:0; height:2px; z-index:2;
background:linear-gradient(90deg,var(--pat),var(--purple),var(--blue),var(--green),var(--blue),var(--purple),var(--pat));
background-size:300% 100%; animation:shimmer 5s linear infinite;
}
.stream-ratio { position:relative; width:100%; padding-top:56.25%; background:#000; }
.stream-ratio > * { position:absolute; inset:0; width:100%; height:100%; border:0; display:block; }
.stream-footer {
display:flex; align-items:center; justify-content:space-between; gap:.75rem;
padding:.75rem 1.1rem; border-top:1px solid var(--border); flex-wrap:wrap;
}
.stream-meta { font-family:'Share Tech Mono',monospace; font-size:.65rem; letter-spacing:.1em; color:var(--text-3); }
.stream-meta span { color:var(--text-2); }
/* ── Footer ── */
.page-footer {
padding:.5rem 0 1rem; display:flex; align-items:center; justify-content:center;
gap:1.5rem; flex-wrap:wrap; border-top:1px solid var(--border);
}
.footer-note { font-family:'Share Tech Mono',monospace; font-size:.63rem; color:var(--text-3); letter-spacing:.06em; text-align:center; }
.footer-note::before { content:'// '; opacity:.4; }
.footer-note a { color:var(--text-3); text-decoration:none; transition:color .15s; }
.footer-note a:hover { color:var(--text-2); }
/* ── Buttons ── */
.btn {
display:inline-flex; align-items:center; gap:.45rem;
padding:.6rem 1.1rem; border-radius:var(--r);
font-family:'Inter',sans-serif; font-size:.82rem; font-weight:600;
text-decoration:none; cursor:pointer; border:none;
transition:transform .15s, filter .15s, background .2s;
}
.btn:hover { transform:translateY(-1px); filter:brightness(1.08); }
.btn:active { transform:scale(.98); }
.btn-primary { background:var(--purple); color:#fff; box-shadow:0 4px 18px rgba(176,96,255,.25); }
.btn-secondary { background:rgba(255,255,255,.06); color:var(--text-2); border:1px solid var(--border-2); }
.btn-secondary:hover { color:var(--text); background:rgba(255,255,255,.1); }
.btn-danger { background:rgba(255,51,85,.15); color:var(--red); border:1px solid rgba(255,51,85,.3); }
.btn-danger:hover { background:rgba(255,51,85,.25); }
.btn-green { background:rgba(0,232,122,.12); color:var(--green); border:1px solid rgba(0,232,122,.3); }
.btn-green:hover { background:rgba(0,232,122,.2); }
.btn-sm { padding:.38rem .75rem; font-size:.75rem; }
/* ── Form controls ── */
.form-group { display:flex; flex-direction:column; gap:.4rem; }
.form-label { font-family:'Share Tech Mono',monospace; font-size:.65rem; letter-spacing:.12em; text-transform:uppercase; color:var(--text-3); }
.form-input, .form-select, .form-textarea {
background:var(--surface-2); border:1px solid var(--border-2); border-radius:var(--r);
color:var(--text); padding:.6rem .85rem; font-family:'Inter',sans-serif; font-size:.85rem;
transition:border-color .2s;
width:100%;
}
.form-input:focus, .form-select:focus, .form-textarea:focus {
outline:none; border-color:var(--purple);
box-shadow:0 0 0 2px rgba(176,96,255,.15);
}
.form-textarea { resize:vertical; min-height:80px; }
.form-select option { background:var(--surface-2); }
/* ── Notice/alert ── */
.notice {
padding:.75rem 1rem; border-radius:var(--r); font-size:.82rem;
border:1px solid; margin-bottom:1rem;
}
.notice-success { background:rgba(0,232,122,.08); border-color:rgba(0,232,122,.3); color:var(--green); }
.notice-error { background:rgba(255,51,85,.08); border-color:rgba(255,51,85,.3); color:var(--red); }
.notice-info { background:rgba(0,200,255,.08); border-color:rgba(0,200,255,.3); color:var(--blue); }
/* ── Pagination ── */
.pagination { display:flex; align-items:center; gap:.4rem; flex-wrap:wrap; }
.pagination a, .pagination span {
display:inline-flex; align-items:center; justify-content:center;
min-width:2rem; height:2rem; padding:0 .5rem;
border-radius:var(--r); font-family:'Share Tech Mono',monospace; font-size:.72rem;
text-decoration:none; transition:background .2s, color .2s, border-color .2s;
border:1px solid var(--border-2); color:var(--text-3);
}
.pagination a:hover { border-color:var(--purple); color:var(--purple); background:rgba(176,96,255,.08); }
.pagination .current { border-color:var(--purple); color:var(--purple); background:rgba(176,96,255,.15); }
.pagination .disabled { opacity:.3; pointer-events:none; }
<?= $extra_css ?>
</style>
</head>
<body>
<?php }
function render_topbar(string $active = ''): void {
$title = setting('site_title', 'Ty Clifford');
$sub = setting('site_sub', 'tyclifford.com / player');
?>
<header class="topbar">
<div class="topbar-brand">
<div>
<a href="index.php" class="brand-name"><?= h($title) ?></a>
<div class="brand-sub"><?= h($sub) ?></div>
</div>
</div>
<nav class="topbar-social" aria-label="Navigation">
<a class="social-pill <?= $active==='home' ? 'active' : '' ?>" href="index.php">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9,22 9,12 15,12 15,22"/></svg>
Home
</a>
<a class="social-pill <?= $active==='catalogue' ? 'active' : '' ?>" href="catalogue.php">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
Videos
</a>
</nav>
</header>
<?php }
function render_footer(): void { ?>
<footer class="page-footer" role="contentinfo">
<p class="footer-note">TyClifford.com &nbsp;·&nbsp; player</p>
<p class="footer-note"><a href="https://git.tyclifford.com/">open source</a></p>
<p class="footer-note">hobbies are my hobby</p>
<p class="footer-note"><a href="mailto:ty@tyclifford.com">ty@tyclifford.com</a></p>
</footer>
<?php }
function render_pagination(int $current, int $total, string $url_pattern): void {
if ($total <= 1) return;
echo '<div class="pagination">';
$prev = $current - 1;
$next = $current + 1;
if ($prev >= 1) {
echo '<a href="' . sprintf($url_pattern, $prev) . '"></a>';
} else {
echo '<span class="disabled"></span>';
}
$range = range(max(1, $current-2), min($total, $current+2));
if (!in_array(1, $range)) { echo '<a href="' . sprintf($url_pattern, 1) . '">1</a><span>…</span>'; }
foreach ($range as $p) {
if ($p === $current) echo '<span class="current">' . $p . '</span>';
else echo '<a href="' . sprintf($url_pattern, $p) . '">' . $p . '</a>';
}
if (!in_array($total, $range)) { echo '<span>…</span><a href="' . sprintf($url_pattern, $total) . '">' . $total . '</a>'; }
if ($next <= $total) {
echo '<a href="' . sprintf($url_pattern, $next) . '"></a>';
} else {
echo '<span class="disabled"></span>';
}
echo '</div>';
}
+452
View File
@@ -0,0 +1,452 @@
<?php
require_once __DIR__ . '/includes/db.php';
require_once __DIR__ . '/includes/layout.php';
// Determine which video to play
$slug = $_GET['v'] ?? '';
$video = $slug ? get_video_by_slug($slug) : null;
// Latest video as default if none specified
if (!$video) {
$db = get_db();
$latest = $db->query("SELECT slug FROM videos WHERE published=1 ORDER BY sort_order ASC, id DESC LIMIT 1")->fetch();
if ($latest) $video = get_video_by_slug($latest['slug']);
}
$per_page = (int)setting('catalogue_per_page', '10');
$page = max(1, (int)($_GET['page'] ?? 1));
$paged = get_videos_paginated($page, $per_page);
// Build absolute base URL used for embed snippet generation
$_scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$_host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$_script = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? '/'), '/');
$_base_url = $_scheme . '://' . $_host . $_script . '/';
render_head('Media — TyClifford.com', '
/* ── Catalogue grid ── */
.cat-section { display:flex; flex-direction:column; gap:1rem; }
.cat-header { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; }
.cat-grid {
display:grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap:.85rem;
}
@media(min-width:600px) { .cat-grid { grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); } }
@media(min-width:1000px) { .cat-grid { grid-template-columns: repeat(5, 1fr); } }
.cat-thumb {
background:var(--surface-2);
border:1px solid var(--border);
border-radius:calc(var(--r)+2px);
overflow:hidden;
text-decoration:none;
color:var(--text);
display:flex; flex-direction:column;
transition:border-color .2s, transform .15s, box-shadow .2s;
position:relative;
cursor:pointer;
}
.cat-thumb:hover { border-color:var(--purple); transform:translateY(-2px); box-shadow:0 6px 24px rgba(176,96,255,.18); }
.cat-thumb.active { border-color:var(--green); box-shadow:0 0 0 1px var(--green), 0 4px 20px rgba(0,232,122,.2); }
.cat-thumb-img {
position:relative; width:100%; padding-top:56.25%; background:#000;
overflow:hidden;
}
.cat-thumb-img img {
position:absolute; inset:0; width:100%; height:100%; object-fit:cover;
transition:transform .3s;
}
.cat-thumb:hover .cat-thumb-img img { transform:scale(1.04); }
.cat-thumb-img .no-thumb {
position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
font-family:"Share Tech Mono",monospace; font-size:.6rem; color:var(--text-3); letter-spacing:.1em;
background:var(--surface);
}
.cat-thumb-dur {
position:absolute; bottom:4px; right:6px;
background:rgba(0,0,0,.75); backdrop-filter:blur(4px);
font-family:"Share Tech Mono",monospace; font-size:.55rem; letter-spacing:.05em;
color:#fff; padding:.15em .45em; border-radius:3px;
}
.play-overlay {
position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
background:rgba(0,0,0,.35); opacity:0; transition:opacity .2s;
}
.cat-thumb:hover .play-overlay { opacity:1; }
.play-overlay svg { filter:drop-shadow(0 2px 8px rgba(0,0,0,.5)); }
.cat-thumb-body { padding:.55rem .65rem; display:flex; flex-direction:column; gap:.2rem; flex:1; }
.cat-thumb-title { font-size:.78rem; font-weight:600; color:var(--text); line-height:1.35; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
.cat-thumb-meta { font-family:"Share Tech Mono",monospace; font-size:.58rem; color:var(--text-3); letter-spacing:.06em; margin-top:auto; padding-top:.35rem; }
.cat-active-indicator {
position:absolute; top:0; left:0; right:0; height:2px;
background:var(--green); opacity:0; transition:opacity .2s;
}
.cat-thumb.active .cat-active-indicator { opacity:1; }
/* ── Video.js custom skin ── */
.video-js {
width:100% !important;
height:100% !important;
position:absolute !important;
inset:0 !important;
}
.video-js .vjs-big-play-button {
background:rgba(176,96,255,.75) !important;
border:2px solid rgba(176,96,255,.9) !important;
border-radius:50% !important;
width:64px !important; height:64px !important;
top:50% !important; left:50% !important;
transform:translate(-50%,-50%) !important;
margin:0 !important;
line-height:64px !important;
transition:background .2s !important;
}
.video-js:hover .vjs-big-play-button { background:rgba(176,96,255,.95) !important; }
.video-js .vjs-control-bar {
background:linear-gradient(transparent,rgba(0,0,0,.85)) !important;
height:3.2em !important;
}
.video-js .vjs-play-progress { background:var(--green) !important; }
.video-js .vjs-volume-level { background:var(--green) !important; }
.video-js .vjs-slider { background:rgba(255,255,255,.15) !important; }
.video-js .vjs-load-progress { background:rgba(255,255,255,.1) !important; }
.video-js .vjs-current-time,
.video-js .vjs-duration,
.video-js .vjs-time-divider { color:rgba(255,255,255,.8) !important; font-size:.7em !important; }
/* ── Lower grid ── */
.lower-grid { display:grid; grid-template-columns:1fr; gap:1rem; }
@media(min-width:640px) { .lower-grid { grid-template-columns:1fr 1fr; } }
@media(min-width:900px) { .lower-grid { grid-template-columns:2fr 1fr 1fr; } }
.card-about { background:var(--surface-2); }
.card-social { background:var(--surface-2); }
.social-list { list-style:none; display:flex; flex-direction:column; gap:.5rem; }
.social-list a {
display:flex; align-items:center; gap:.65rem;
padding:.6rem .8rem; border:1px solid var(--border-2); border-radius:var(--r);
font-family:"Share Tech Mono",monospace; font-size:.78rem; color:var(--text-2);
text-decoration:none; transition:border-color .2s, color .2s, background .2s;
}
.social-list a:hover { border-color:var(--green); color:var(--text); background:rgba(0,232,122,.04); }
.social-list a svg { flex-shrink:0; color:var(--text-3); transition:color .2s; }
.social-list a:hover svg { color:var(--green); }
/* empty state */
.empty-state {
padding:3rem 1rem; text-align:center; display:flex; flex-direction:column;
align-items:center; gap:1rem;
}
.empty-state svg { color:var(--text-3); opacity:.4; }
.empty-state p { font-family:"Share Tech Mono",monospace; font-size:.75rem; color:var(--text-3); letter-spacing:.1em; }
/* no-video placeholder */
.no-video { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:.75rem; }
.no-video p { font-family:"Share Tech Mono",monospace; font-size:.7rem; color:var(--text-3); letter-spacing:.1em; }
/* pagination row */
.cat-footer { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; }
.cat-count { font-family:"Share Tech Mono",monospace; font-size:.65rem; color:var(--text-3); letter-spacing:.06em; }
/* ── Embed panel ── */
.embed-panel {
border-top:1px solid var(--border);
overflow:hidden;
max-height:0;
transition:max-height .35s cubic-bezier(.4,0,.2,1), padding .35s;
padding:0 1.1rem;
}
.embed-panel.open { max-height:320px; padding:.9rem 1.1rem; }
.embed-panel-inner { display:flex; flex-direction:column; gap:.75rem; }
.embed-label { font-family:"Share Tech Mono",monospace; font-size:.6rem; letter-spacing:.14em; text-transform:uppercase; color:var(--text-3); }
.embed-code-wrap { position:relative; }
.embed-code {
display:block; width:100%;
background:var(--surface-2); border:1px solid var(--border-2); border-radius:var(--r);
padding:.65rem .85rem; padding-right:5.5rem;
font-family:"Share Tech Mono",monospace; font-size:.68rem; color:var(--text-2);
white-space:nowrap; overflow-x:auto; line-height:1.6;
resize:none; cursor:text;
scrollbar-width:thin;
}
.embed-copy-btn {
position:absolute; right:.4rem; top:50%; transform:translateY(-50%);
padding:.32rem .7rem; font-size:.65rem; font-weight:600;
background:rgba(176,96,255,.15); color:var(--purple);
border:1px solid rgba(176,96,255,.35); border-radius:calc(var(--r) - 1px);
cursor:pointer; font-family:"Share Tech Mono",monospace; letter-spacing:.06em;
transition:background .2s, color .2s;
white-space:nowrap;
}
.embed-copy-btn:hover { background:rgba(176,96,255,.28); }
.embed-copy-btn.copied { background:rgba(0,232,122,.15); color:var(--green); border-color:rgba(0,232,122,.35); }
.embed-row { display:flex; gap:.6rem; align-items:center; flex-wrap:wrap; }
.embed-direct-link {
font-family:"Share Tech Mono",monospace; font-size:.65rem; color:var(--text-3);
text-decoration:none; border-bottom:1px solid rgba(255,255,255,.1);
transition:color .15s, border-color .15s;
}
.embed-direct-link:hover { color:var(--green); border-color:rgba(0,232,122,.4); }
.embed-preview-link {
font-family:"Share Tech Mono",monospace; font-size:.65rem; color:var(--purple);
text-decoration:none; border-bottom:1px solid rgba(176,96,255,.2);
transition:color .15s;
}
.embed-preview-link:hover { color:#c880ff; }
.embed-toggle-btn {
display:inline-flex; align-items:center; gap:.4rem;
font-family:"Share Tech Mono",monospace; font-size:.65rem; letter-spacing:.08em;
color:var(--text-3); background:transparent; border:1px solid var(--border-2);
border-radius:var(--r); padding:.35rem .7rem; cursor:pointer;
transition:color .2s, border-color .2s, background .2s;
}
.embed-toggle-btn:hover { color:var(--text-2); border-color:var(--purple); background:rgba(176,96,255,.07); }
.embed-toggle-btn.active { color:var(--purple); border-color:var(--purple); background:rgba(176,96,255,.1); }
');
?>
<main class="page" role="main">
<?php render_topbar('home'); ?>
<!-- ═══════════════════════════ PLAYER CARD ════════════════════════════════ -->
<section class="stream-card" aria-label="Media player">
<div class="stream-ratio">
<?php if ($video && !empty($video['sources'])): ?>
<video id="main-player"
class="video-js vjs-default-skin vjs-big-play-centered"
controls preload="metadata"
<?php if ($video['thumbnail']): ?>
poster="<?= h(MEDIA_URL . $video['thumbnail']) ?>"
<?php endif; ?>
data-setup='{"fluid":false,"responsive":true,"playbackRates":[0.5,1,1.25,1.5,2]}'>
<?php foreach ($video['sources'] as $src): ?>
<source src="<?= h(MEDIA_URL . $src['file_path']) ?>" type="<?= h($src['mime_type']) ?>" label="<?= h($src['quality'] ?: $src['label']) ?>">
<?php endforeach; ?>
<p class="vjs-no-js">Your browser does not support HTML video.</p>
</video>
<?php else: ?>
<div class="no-video">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity=".25"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
<p>no video selected</p>
</div>
<?php endif; ?>
</div>
<div class="stream-footer">
<p class="eyebrow eyebrow-green"><?= $video ? h($video['title']) : 'Media Player' ?></p>
<?php if ($video && $video['description']): ?>
<p class="stream-meta" style="font-family:Inter,sans-serif;font-size:.78rem;color:var(--text-2);letter-spacing:0;max-width:60ch;line-height:1.5"><?= h($video['description']) ?></p>
<?php endif; ?>
<p class="stream-meta">
<?php if ($video && $video['duration']): ?>
<span><?= format_duration((int)$video['duration']) ?></span>
<?php endif; ?>
</p>
<?php if ($video): ?>
<button class="embed-toggle-btn" id="embed-toggle" aria-expanded="false" aria-controls="embed-panel">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
Embed
</button>
<?php endif; ?>
</div>
<?php if ($video):
$embed_url = $_base_url . 'embed.php?v=' . urlencode($video['slug']);
$iframe_code = '<iframe src="' . $embed_url . '" width="560" height="315" frameborder="0" allowfullscreen allow="fullscreen"></iframe>';
?>
<div class="embed-panel" id="embed-panel" role="region" aria-label="Embed code">
<div class="embed-panel-inner">
<div class="form-group" style="gap:.35rem;">
<span class="embed-label">iframe embed code</span>
<div class="embed-code-wrap">
<code class="embed-code" id="embed-code-iframe"><?= h($iframe_code) ?></code>
<button class="embed-copy-btn" data-target="embed-code-iframe" onclick="copyEmbed(this)">Copy</button>
</div>
</div>
<div class="embed-row">
<span class="embed-label">Direct player URL —</span>
<a class="embed-direct-link" href="<?= h($embed_url) ?>" target="_blank" rel="noopener">
<?= h($embed_url) ?>
</a>
<a class="embed-preview-link" href="<?= h($embed_url) ?>" target="_blank" rel="noopener">
↗ Preview
</a>
</div>
<div>
<span class="embed-label" style="color:var(--text-3);font-size:.58rem;">
Recommended: width="560" height="315" — any 16:9 size works. Add style="border:0;border-radius:6px" for no border.
</span>
</div>
</div>
</div>
<?php endif; ?>
</section>
<!-- ═══════════════════════════ VIDEO CATALOGUE ═══════════════════════════ -->
<section class="cat-section">
<div class="cat-header">
<p class="eyebrow eyebrow-purple">Video Catalogue</p>
<a href="catalogue.php" class="btn btn-secondary btn-sm">Browse all →</a>
</div>
<?php if (empty($paged['videos'])): ?>
<div class="card">
<div class="empty-state">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14,2 14,8 20,8"/></svg>
<p>no videos yet</p>
</div>
</div>
<?php else: ?>
<div class="cat-grid">
<?php foreach ($paged['videos'] as $v):
$active = $video && $video['id'] == $v['id'];
$vslug = $v['slug'];
$vtitle = $v['title'];
$vthumb = $v['thumbnail'];
$vdur = (int)$v['duration'];
$created = substr($v['created_at'], 0, 10);
?>
<a class="cat-thumb <?= $active ? 'active' : '' ?>"
href="?v=<?= urlencode($vslug) ?>"
title="<?= h($vtitle) ?>">
<div class="cat-active-indicator"></div>
<div class="cat-thumb-img">
<?php if ($vthumb): ?>
<img src="<?= h(MEDIA_URL . $vthumb) ?>" alt="<?= h($vtitle) ?>" loading="lazy">
<?php else: ?>
<div class="no-thumb">NO THUMB</div>
<?php endif; ?>
<?php if ($vdur): ?>
<span class="cat-thumb-dur"><?= format_duration($vdur) ?></span>
<?php endif; ?>
<div class="play-overlay">
<svg width="28" height="28" viewBox="0 0 24 24" fill="white"><circle cx="12" cy="12" r="12" fill="rgba(0,0,0,.4)"/><polygon points="10,8 18,12 10,16" fill="white"/></svg>
</div>
</div>
<div class="cat-thumb-body">
<span class="cat-thumb-title"><?= h($vtitle) ?></span>
<span class="cat-thumb-meta"><?= h($created) ?></span>
</div>
</a>
<?php endforeach; ?>
</div>
<?php if ($paged['total_pages'] > 1): ?>
<div class="cat-footer">
<span class="cat-count">
<?= $paged['total'] ?> video<?= $paged['total'] != 1 ? 's' : '' ?>
· page <?= $paged['page'] ?> of <?= $paged['total_pages'] ?>
</span>
<?php
$url_v = $video ? '&v=' . urlencode($video['slug']) : '';
render_pagination($page, $paged['total_pages'], '?page=%d' . $url_v);
?>
</div>
<?php endif; ?>
<?php endif; ?>
</section>
<!-- ═══════════════════════════ LOWER GRID ════════════════════════════════ -->
<div class="lower-grid">
<div class="card card-about">
<p class="eyebrow eyebrow-green" style="margin-bottom:.1rem">About</p>
<h2 class="card-title">Ty Clifford</h2>
<p class="card-body">
Based in <strong>Keyser, West Virginia</strong>. Nerd, tinkerer, and creator.
Hobbies are my hobby — from tech and podcasting to whatever rabbit hole I fell into this week.
</p>
</div>
<div class="card card-social">
<p class="eyebrow eyebrow-green" style="margin-bottom:.1rem">Links</p>
<h2 class="card-title">Places</h2>
<ul class="social-list" aria-label="Social media links">
<li>
<a href="mailto:ty@tyclifford.com">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="2,4 12,13 22,4"/></svg>
ty@tyclifford.com
</a>
</li>
<li>
<a href="https://go.tyclifford.com/patreon" target="_blank" rel="noopener">
<svg width="14" height="14" viewBox="0 0 12 14" fill="currentColor"><ellipse cx="7.5" cy="4.8" rx="4.5" ry="4.5"/><rect x="0" y="0" width="2.8" height="14" rx="1.2"/></svg>
patreon.com/tyclifford
</a>
</li>
</ul>
</div>
</div>
<?php render_footer(); ?>
</main>
<script>
// Init Video.js if player exists
document.addEventListener('DOMContentLoaded', function() {
var el = document.getElementById('main-player');
if (el) {
var player = videojs('main-player', {
controls: true,
autoplay: false,
preload: 'metadata',
fluid: false,
responsive: true,
playbackRates: [0.5, 1, 1.25, 1.5, 2],
controlBar: {
children: [
'playToggle','volumePanel','currentTimeDisplay',
'timeDivider','durationDisplay','progressControl',
'playbackRateMenuButton','fullscreenToggle'
]
}
});
}
// Embed panel toggle
var toggleBtn = document.getElementById('embed-toggle');
var panel = document.getElementById('embed-panel');
if (toggleBtn && panel) {
toggleBtn.addEventListener('click', function() {
var open = panel.classList.toggle('open');
toggleBtn.classList.toggle('active', open);
toggleBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
});
}
});
// Copy embed code to clipboard
function copyEmbed(btn) {
var targetId = btn.getAttribute('data-target');
var el = document.getElementById(targetId);
if (!el) return;
var text = el.textContent || el.innerText;
navigator.clipboard.writeText(text).then(function() {
btn.textContent = 'Copied!';
btn.classList.add('copied');
setTimeout(function() {
btn.textContent = 'Copy';
btn.classList.remove('copied');
}, 2000);
}).catch(function() {
// Fallback for older browsers
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
btn.textContent = 'Copied!';
btn.classList.add('copied');
setTimeout(function() {
btn.textContent = 'Copy';
btn.classList.remove('copied');
}, 2000);
});
}
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
View File
+297 -14
View File
@@ -404,6 +404,116 @@ define('POST_PAGE_TITLE', 'Updates — Post Manager');
.edit-form.open { display: flex; animation: fade-in .2s ease; }
/* ── Misc ───────────────────────────────────────────────────────────────── */
/* ── Search bar ─────────────────────────────────────────────────────────── */
.search-wrap {
padding: .65rem 1.25rem;
border-bottom: 1px solid var(--border);
position: relative;
}
.search-input-wrap {
position: relative;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: .75rem;
color: var(--text-3);
pointer-events: none;
flex-shrink: 0;
}
.search-input {
width: 100%;
padding: .55rem .9rem .55rem 2.2rem;
font-family: 'Share Tech Mono', monospace;
font-size: .82rem;
color: var(--text);
background: rgba(255,255,255,.03);
border: 1px solid var(--border-2);
border-radius: var(--r);
outline: none;
transition: border-color .2s, box-shadow .2s, background .2s;
}
.search-input:focus {
border-color: var(--blue);
background: rgba(0,200,255,.04);
box-shadow: 0 0 0 3px rgba(0,200,255,.1);
}
.search-input::placeholder { color: var(--text-3); }
.search-clear {
position: absolute;
right: .6rem;
background: none;
border: none;
cursor: pointer;
color: var(--text-3);
padding: .2rem;
display: none;
align-items: center;
transition: color .15s;
}
.search-clear:hover { color: var(--red); }
.search-clear.visible { display: flex; }
/* highlight matched text */
mark {
background: rgba(0,200,255,.2);
color: var(--blue);
border-radius: 2px;
padding: 0 .1em;
}
/* ── Pagination ──────────────────────────────────────────────────────────── */
.pagination-bar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: .5rem;
padding: .65rem 1.25rem;
border-top: 1px solid var(--border);
}
.pagination-info {
font-family: 'Share Tech Mono', monospace;
font-size: .6rem;
letter-spacing: .06em;
color: var(--text-3);
}
.pagination-btns {
display: flex;
align-items: center;
gap: .3rem;
flex-wrap: wrap;
}
.page-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 28px;
padding: 0 .45rem;
border: 1px solid var(--border-2);
border-radius: 4px;
background: rgba(255,255,255,.03);
font-family: 'Share Tech Mono', monospace;
font-size: .65rem;
letter-spacing: .04em;
color: var(--text-3);
cursor: pointer;
transition: border-color .15s, color .15s, background .15s;
}
.page-btn:hover:not(:disabled) {
border-color: var(--green);
color: var(--green);
background: rgba(0,232,122,.05);
}
.page-btn.active {
border-color: var(--green);
color: var(--green);
background: rgba(0,232,122,.08);
}
.page-btn:disabled { opacity: .3; cursor: not-allowed; }
.page-ellipsis { border-color: transparent; background: transparent; cursor: default; pointer-events: none; }
.feed-status {
padding: 2.5rem 1.25rem;
font-family: 'Share Tech Mono', monospace; font-size: .72rem;
@@ -540,9 +650,34 @@ define('POST_PAGE_TITLE', 'Updates — Post Manager');
<span class="section-title">All Posts</span>
<span class="section-badge" id="post-count">—</span>
</div>
<!-- Search bar -->
<div class="search-wrap">
<div class="search-input-wrap">
<svg class="search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input class="search-input" type="text" id="search-input"
placeholder="Search posts by text, tag, or ID…"
autocomplete="off" spellcheck="false"
oninput="onSearchInput(this.value)">
<button class="search-clear" id="search-clear"
onclick="clearSearch()" aria-label="Clear search">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
</div>
<ul id="post-list">
<li class="loading-row"><div class="big-spinner"></div><span>loading posts…</span></li>
</ul>
<!-- Pagination bar (built by JS) -->
<div id="pagination-bar" style="display:none"></div>
</div>
</div><!-- /main-ui -->
@@ -760,35 +895,179 @@ function doAuth() {
function doLogout() { sessionStorage.removeItem(STORE_KEY); location.reload(); }
/* ── Feed ────────────────────────────────────────────────────────────────────── */
/* ════════════════════════════════════════════════════════════════════════════
FEED — search, pagination, rendering
════════════════════════════════════════════════════════════════════════════ */
const PER_PAGE = 15; // posts shown per page
let allPosts = []; // master array from last API fetch
let currentPage = 1;
let searchQuery = '';
let searchTimer = null;
/* Load all posts from API, then filter+render */
function loadFeed() {
const list = document.getElementById('post-list');
list.innerHTML = `<li class="loading-row"><div class="big-spinner"></div><span>loading…</span></li>`;
apiFetch('')
.then(data => renderFeed(data.items, data.total))
.catch(err => { list.innerHTML = `<li class="feed-status">failed: ${esc(err.message)}</li>`; });
apiFetch('limit=1000') // fetch everything; client handles paging
.then(data => {
allPosts = data.items || [];
currentPage = 1;
filterAndPage();
})
.catch(err => {
list.innerHTML = `<li class="feed-status">failed: ${esc(err.message)}</li>`;
hidePagination();
});
}
/* Filter allPosts by searchQuery, then render the requested page */
function filterAndPage() {
const q = searchQuery.trim().toLowerCase();
const filtered = q
? allPosts.filter(p =>
(p.text || '').toLowerCase().includes(q) ||
(p.id || '').toLowerCase().includes(q) ||
(p.tags || []).some(t => t.toLowerCase().includes(q)) ||
(p.link || '').toLowerCase().includes(q) ||
(p.note || '').toLowerCase().includes(q)
)
: allPosts;
const total = filtered.length;
const totalPages = Math.max(1, Math.ceil(total / PER_PAGE));
currentPage = Math.min(currentPage, totalPages);
const start = (currentPage - 1) * PER_PAGE;
const page = filtered.slice(start, start + PER_PAGE);
// Update badge
const badge = document.getElementById('post-count');
if (q) {
badge.textContent = filtered.length + ' of ' + allPosts.length + ' match' + (filtered.length !== 1 ? 'es' : '');
} else {
badge.textContent = allPosts.length + ' post' + (allPosts.length !== 1 ? 's' : '');
}
// Render list
const list = document.getElementById('post-list');
if (!page.length) {
list.innerHTML = q
? `<li class="feed-status">no results for "${esc(q)}"</li>`
: '<li class="feed-status">no posts yet</li>';
hidePagination();
return;
}
list.innerHTML = page.map(p => postHTML(p, q)).join('');
// Render pagination
if (totalPages <= 1) {
hidePagination();
} else {
renderPagination(currentPage, totalPages, total, start);
}
}
/* ── Pagination controls ─────────────────────────────────────────────────── */
function renderPagination(page, totalPages, total, start) {
const bar = document.getElementById('pagination-bar');
bar.style.display = '';
bar.className = 'pagination-bar';
const from = start + 1;
const to = Math.min(start + PER_PAGE, total);
// Build page number buttons with sliding window ± 2
let btns = '';
const lo = Math.max(1, page - 2);
const hi = Math.min(totalPages, page + 2);
btns += `<button class="page-btn" ${page <= 1 ? 'disabled' : ''}
onclick="goPage(${page - 1})"></button>`;
if (lo > 1) {
btns += `<button class="page-btn" onclick="goPage(1)">1</button>`;
if (lo > 2) btns += `<span class="page-btn page-ellipsis">…</span>`;
}
for (let p = lo; p <= hi; p++) {
btns += `<button class="page-btn${p === page ? ' active' : ''}"
onclick="goPage(${p})">${p}</button>`;
}
if (hi < totalPages) {
if (hi < totalPages - 1) btns += `<span class="page-btn page-ellipsis">…</span>`;
btns += `<button class="page-btn" onclick="goPage(${totalPages})">${totalPages}</button>`;
}
btns += `<button class="page-btn" ${page >= totalPages ? 'disabled' : ''}
onclick="goPage(${page + 1})"></button>`;
bar.innerHTML = `
<span class="pagination-info">${from}${to} of ${total}</span>
<div class="pagination-btns">${btns}</div>`;
}
function hidePagination() {
const bar = document.getElementById('pagination-bar');
bar.style.display = 'none';
bar.innerHTML = '';
}
function goPage(n) {
currentPage = n;
filterAndPage();
// Scroll the list back into view smoothly
document.getElementById('post-list').scrollIntoView({ behavior: 'smooth', block: 'start' });
}
/* ── Search input handler (debounced 250 ms) ─────────────────────────────── */
function onSearchInput(val) {
const clearBtn = document.getElementById('search-clear');
clearBtn.classList.toggle('visible', val.length > 0);
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
searchQuery = val;
currentPage = 1;
filterAndPage();
}, 250);
}
function clearSearch() {
document.getElementById('search-input').value = '';
document.getElementById('search-clear').classList.remove('visible');
searchQuery = '';
currentPage = 1;
filterAndPage();
document.getElementById('search-input').focus();
}
/* ── Highlight matched text ──────────────────────────────────────────────── */
function highlight(text, query) {
if (!query) return esc(text);
var safe = esc(text);
// Escape regex special chars safely without regex literal char-class issues
var safeQ = esc(query).replace(/[-[\]{}()*+?.,^$|#\s]/g, '\\$&');
try {
return safe.replace(new RegExp('(' + safeQ + ')', 'gi'), '<mark>$1</mark>');
} catch(e) { return safe; }
}
function renderFeed(posts, total) {
document.getElementById('post-count').textContent = total + ' post' + (total!==1?'s':'');
const list = document.getElementById('post-list');
list.innerHTML = posts.length
? posts.map(p => postHTML(p)).join('')
: '<li class="feed-status">no posts yet</li>';
// Legacy path — called by old code paths if any; route through new system
allPosts = posts;
filterAndPage();
}
function postHTML(p) {
const tags = (p.tags||[]).map(t=>`<span class="post-tag">${esc(t)}</span>`).join('');
function postHTML(p, query) {
query = query || '';
const tags = (p.tags||[]).map(t=>`<span class="post-tag">${highlight(t, query)}</span>`).join('');
const link = p.link ? `<a class="post-link" href="${esc(p.link)}" target="_blank" rel="noopener">${esc(p.link_label||p.link)}</a>` : '';
const media = mediaStripHTML(p.media||[]);
return `<li class="post-item" id="pi-${esc(p.id)}">
<div class="post-meta">
<span class="post-id">#${esc(p.id)}</span>
<span class="post-id">#${highlight(p.id, query)}</span>
<span class="post-date">${esc(relTime(p.date))}</span>
${tags}
</div>
<p class="post-text">${esc(p.text)}</p>
<p class="post-text">${highlight(p.text, query)}</p>
${link}
${media}
<div class="post-actions">
@@ -911,7 +1190,7 @@ function saveEdit(id) {
return apiFetch('id=' + encodeURIComponent(id), { method: 'PUT', body: JSON.stringify(body) });
})
.then(updated => {
document.getElementById('pi-' + id).outerHTML = postHTML(updated);
document.getElementById('pi-' + id).outerHTML = postHTML(updated, searchQuery);
})
.catch(err => showAlert('ef-alert-'+id,'error',err.message));
}
@@ -931,6 +1210,10 @@ function deletePost(id) {
/* ── Enter key + session restore ─────────────────────────────────────────────── */
document.getElementById('key-input').addEventListener('keydown', e => { if (e.key==='Enter') doAuth(); });
/* Search: clear on Escape */
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && searchQuery) clearSearch();
});
(function() {
const saved = sessionStorage.getItem(STORE_KEY);