feat: add admin video manager and dynamic video library

Switch from static export (nginx) to standalone Next.js server with API
routes. Admin can manage YouTube tutorial videos at /admin with a
separate password. Videos stored in JSON file persisted via Docker volume.
Also links Facebook page in CTA section.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-10 20:28:10 +00:00
parent 02074d43e4
commit 620f56429d
18 changed files with 646 additions and 103 deletions

View File

@ -9,3 +9,4 @@ Dockerfile
docker-compose*.yml
.dockerignore
_static-backup
data

1
.gitignore vendored
View File

@ -5,3 +5,4 @@ out/
next-env.d.ts
.env*
_static-backup/
data/

View File

@ -9,11 +9,18 @@ COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# Production stage - serve static files with nginx
FROM nginx:alpine AS runner
# Production stage - Next.js standalone server
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/out /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
RUN mkdir -p /app/data
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]

347
app/admin/page.tsx Normal file
View File

@ -0,0 +1,347 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Trash2, Plus, LogOut, ExternalLink } from "lucide-react"
interface Video {
id: string
title: string
description: string
youtubeUrl: string
thumbnail: string
createdAt: string
}
const ADMIN_STORAGE_KEY = "higgys_admin_token"
export default function AdminPage() {
const [token, setToken] = useState("")
const [password, setPassword] = useState("")
const [loginError, setLoginError] = useState("")
const [loginLoading, setLoginLoading] = useState(false)
const [videos, setVideos] = useState<Video[]>([])
const [loading, setLoading] = useState(false)
const [newUrl, setNewUrl] = useState("")
const [newTitle, setNewTitle] = useState("")
const [newDescription, setNewDescription] = useState("")
const [addLoading, setAddLoading] = useState(false)
const [addError, setAddError] = useState("")
const [previewThumbnail, setPreviewThumbnail] = useState("")
useEffect(() => {
const stored = localStorage.getItem(ADMIN_STORAGE_KEY)
if (stored) setToken(stored)
}, [])
const fetchVideos = useCallback(async () => {
setLoading(true)
try {
const res = await fetch("/api/videos")
if (res.ok) {
const data = await res.json()
setVideos(data)
}
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (token) fetchVideos()
}, [token, fetchVideos])
async function handleLogin(e: React.FormEvent) {
e.preventDefault()
setLoginError("")
setLoginLoading(true)
try {
const res = await fetch("/api/admin/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
})
if (res.ok) {
const data = await res.json()
localStorage.setItem(ADMIN_STORAGE_KEY, data.token)
setToken(data.token)
setPassword("")
} else {
setLoginError("Incorrect admin password")
}
} catch {
setLoginError("Login failed. Please try again.")
} finally {
setLoginLoading(false)
}
}
function handleLogout() {
localStorage.removeItem(ADMIN_STORAGE_KEY)
setToken("")
setVideos([])
}
function extractVideoId(url: string): string | null {
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/,
/youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/,
]
for (const pattern of patterns) {
const match = url.match(pattern)
if (match) return match[1]
}
return null
}
function handleUrlChange(url: string) {
setNewUrl(url)
setAddError("")
const videoId = extractVideoId(url)
if (videoId) {
setPreviewThumbnail(`https://img.youtube.com/vi/${videoId}/hqdefault.jpg`)
} else {
setPreviewThumbnail("")
}
}
async function handleAddVideo(e: React.FormEvent) {
e.preventDefault()
setAddError("")
setAddLoading(true)
try {
const res = await fetch("/api/videos", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
youtubeUrl: newUrl,
title: newTitle || undefined,
description: newDescription || undefined,
}),
})
if (res.status === 401) {
setAddError("Session expired. Please log in again.")
handleLogout()
return
}
if (res.ok) {
setNewUrl("")
setNewTitle("")
setNewDescription("")
setPreviewThumbnail("")
await fetchVideos()
} else {
const data = await res.json()
setAddError(data.error || "Failed to add video")
}
} catch {
setAddError("Failed to add video. Please try again.")
} finally {
setAddLoading(false)
}
}
async function handleDelete(id: string) {
if (!confirm("Remove this video?")) return
const res = await fetch(`/api/videos/${id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
})
if (res.status === 401) {
handleLogout()
return
}
if (res.ok) {
await fetchVideos()
}
}
// Login form
if (!token) {
return (
<div className="min-h-screen flex items-center justify-center py-12 px-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold">Admin Access</CardTitle>
<CardDescription>
Enter the admin password to manage videos
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="admin-password">Admin Password</Label>
<Input
id="admin-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter admin password"
required
/>
{loginError && <p className="text-sm text-red-500">{loginError}</p>}
</div>
<Button
type="submit"
className="w-full bg-[#8BC34A] hover:bg-[#7CB342] text-white"
disabled={loginLoading}
>
{loginLoading ? "Checking..." : "Log In"}
</Button>
</form>
</CardContent>
</Card>
</div>
)
}
// Admin dashboard
return (
<div className="min-h-screen py-8 px-4">
<div className="container mx-auto max-w-4xl space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Video Manager</h1>
<p className="text-muted-foreground">Add and manage YouTube tutorial videos</p>
</div>
<Button variant="ghost" onClick={handleLogout}>
<LogOut className="w-4 h-4 mr-2" />
Log Out
</Button>
</div>
{/* Add Video Form */}
<Card className="border-2 border-[#8BC34A]">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Plus className="w-5 h-5" />
Add New Video
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleAddVideo} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="youtube-url">YouTube URL *</Label>
<Input
id="youtube-url"
type="url"
value={newUrl}
onChange={(e) => handleUrlChange(e.target.value)}
placeholder="https://www.youtube.com/watch?v=..."
required
/>
</div>
{previewThumbnail && (
<div className="rounded-lg overflow-hidden border max-w-xs">
<img src={previewThumbnail} alt="Video preview" className="w-full" />
</div>
)}
<div className="space-y-2">
<Label htmlFor="title">Title (auto-fetched from YouTube if blank)</Label>
<Input
id="title"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="Leave blank to auto-fetch from YouTube"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description (optional)</Label>
<Input
id="description"
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
placeholder="Brief description of the video"
/>
</div>
{addError && <p className="text-sm text-red-500">{addError}</p>}
<Button
type="submit"
className="bg-[#8BC34A] hover:bg-[#7CB342] text-white"
disabled={addLoading}
>
{addLoading ? "Adding..." : "Add Video"}
</Button>
</form>
</CardContent>
</Card>
{/* Video List */}
<div className="space-y-4">
<h2 className="text-xl font-bold">
Current Videos ({videos.length})
</h2>
{loading && <p className="text-muted-foreground">Loading videos...</p>}
{videos.length === 0 && !loading && (
<p className="text-muted-foreground">No videos yet. Add your first video above.</p>
)}
{videos.map((video) => (
<Card key={video.id} className="overflow-hidden">
<div className="flex gap-4 p-4">
<div className="w-40 flex-shrink-0">
<img
src={video.thumbnail}
alt={video.title}
className="w-full rounded-md"
/>
</div>
<div className="flex-1 min-w-0 space-y-1">
<h3 className="font-bold truncate">{video.title}</h3>
{video.description && (
<p className="text-sm text-muted-foreground line-clamp-2">
{video.description}
</p>
)}
<a
href={video.youtubeUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-[#8BC34A] hover:underline inline-flex items-center gap-1"
>
{video.youtubeUrl === "#" ? "No URL set" : video.youtubeUrl}
{video.youtubeUrl !== "#" && <ExternalLink className="w-3 h-3" />}
</a>
</div>
<div className="flex-shrink-0">
<Button
variant="ghost"
size="sm"
className="text-red-500 hover:text-red-700 hover:bg-red-50"
onClick={() => handleDelete(video.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</Card>
))}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,13 @@
import { NextResponse } from "next/server"
import { validateAdminPassword, getAdminToken } from "@/lib/admin-auth"
export async function POST(request: Request) {
const body = await request.json()
const { password } = body
if (!password || !validateAdminPassword(password)) {
return NextResponse.json({ error: "Invalid password" }, { status: 401 })
}
return NextResponse.json({ success: true, token: getAdminToken() })
}

View File

@ -0,0 +1,24 @@
import { NextResponse } from "next/server"
import { deleteVideo } from "@/lib/videos"
import { validateAdminToken } from "@/lib/admin-auth"
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const authHeader = request.headers.get("Authorization")
const token = authHeader?.replace("Bearer ", "")
if (!token || !validateAdminToken(token)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { id } = await params
const deleted = deleteVideo(id)
if (!deleted) {
return NextResponse.json({ error: "Video not found" }, { status: 404 })
}
return NextResponse.json({ success: true })
}

52
app/api/videos/route.ts Normal file
View File

@ -0,0 +1,52 @@
import { NextResponse } from "next/server"
import { getVideos, addVideo, getYoutubeThumbnail } from "@/lib/videos"
import { validateAdminToken } from "@/lib/admin-auth"
export const dynamic = "force-dynamic"
export async function GET() {
const videos = getVideos()
return NextResponse.json(videos)
}
export async function POST(request: Request) {
const authHeader = request.headers.get("Authorization")
const token = authHeader?.replace("Bearer ", "")
if (!token || !validateAdminToken(token)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const body = await request.json()
const { youtubeUrl, title, description } = body
if (!youtubeUrl) {
return NextResponse.json({ error: "YouTube URL is required" }, { status: 400 })
}
// Auto-fetch title from YouTube oEmbed if not provided
let videoTitle = title || ""
if (!videoTitle) {
try {
const oembedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(youtubeUrl)}&format=json`
const res = await fetch(oembedUrl)
if (res.ok) {
const data = await res.json()
videoTitle = data.title || "Untitled Video"
}
} catch {
videoTitle = "Untitled Video"
}
}
const thumbnail = getYoutubeThumbnail(youtubeUrl)
const video = addVideo({
title: videoTitle,
description: description || "",
youtubeUrl,
thumbnail,
})
return NextResponse.json(video, { status: 201 })
}

View File

@ -140,7 +140,7 @@ export default function HomePage() {
asChild
>
<a
href="#"
href="https://www.facebook.com/p/Higgys-Android-Boxes-100090633582181/"
target="_blank"
rel="noopener noreferrer"
>

View File

@ -1,7 +1,5 @@
import type { MetadataRoute } from "next"
export const dynamic = "force-static"
export default function robots(): MetadataRoute.Robots {
return {
rules: {

View File

@ -1,7 +1,5 @@
import type { MetadataRoute } from "next"
export const dynamic = "force-static"
export default function sitemap(): MetadataRoute.Sitemap {
return [
{

View File

@ -1,10 +1,29 @@
"use client"
import { useState, useEffect } from "react"
import { AuthGate } from "@/components/auth-gate"
import { VideoCard } from "@/components/video-card"
import { videos } from "@/lib/data"
interface Video {
id: string
title: string
description: string
youtubeUrl: string
thumbnail: string
}
export function VideosContent() {
const [videos, setVideos] = useState<Video[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch("/api/videos")
.then((r) => r.json())
.then((data) => setVideos(data))
.catch(() => {})
.finally(() => setLoading(false))
}, [])
return (
<AuthGate>
<div className="min-h-screen py-12 px-4">
@ -18,11 +37,21 @@ export function VideosContent() {
step-by-step video guides
</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{videos.map((video) => (
<VideoCard key={video.id} {...video} />
))}
</div>
{loading ? (
<div className="text-center text-muted-foreground animate-pulse">
Loading videos...
</div>
) : videos.length === 0 ? (
<div className="text-center text-muted-foreground">
No videos available yet. Check back soon!
</div>
) : (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{videos.map((video) => (
<VideoCard key={video.id} {...video} />
))}
</div>
)}
</div>
</div>
</AuthGate>

View File

@ -5,7 +5,6 @@ interface VideoCardProps {
title: string
description: string
thumbnail: string
duration: string
youtubeUrl: string
}
@ -13,7 +12,6 @@ export function VideoCard({
title,
description,
thumbnail,
duration,
youtubeUrl,
}: VideoCardProps) {
return (
@ -30,9 +28,6 @@ export function VideoCard({
<Play className="w-8 h-8 text-[#8BC34A] ml-1" />
</div>
</div>
<span className="absolute bottom-2 right-2 bg-black/80 text-white text-xs px-2 py-1 rounded">
{duration}
</span>
</div>
<CardContent className="pt-4 space-y-2">
<h3 className="font-bold text-lg">{title}</h3>

View File

@ -3,14 +3,21 @@ services:
build: .
container_name: higgys-android
restart: unless-stopped
environment:
- ADMIN_PASSWORD=higgyadmin
volumes:
- video-data:/app/data
labels:
- "traefik.enable=true"
- "traefik.http.routers.higgys-android.rule=Host(`higgysandroidboxes.com`) || Host(`www.higgysandroidboxes.com`)"
- "traefik.http.routers.higgys-android.entrypoints=web"
- "traefik.http.services.higgys-android.loadbalancer.server.port=80"
- "traefik.http.services.higgys-android.loadbalancer.server.port=3000"
networks:
- traefik-public
volumes:
video-data:
networks:
traefik-public:
external: true

19
lib/admin-auth.ts Normal file
View File

@ -0,0 +1,19 @@
import { createHash } from "crypto"
function hashPassword(password: string): string {
return createHash("sha256").update(password).digest("hex")
}
export function getAdminToken(): string {
const password = process.env.ADMIN_PASSWORD || "higgyadmin"
return hashPassword(password)
}
export function validateAdminToken(token: string): boolean {
return token === getAdminToken()
}
export function validateAdminPassword(password: string): boolean {
const expected = process.env.ADMIN_PASSWORD || "higgyadmin"
return password === expected
}

View File

@ -1,54 +1,3 @@
export const videos = [
{
id: 1,
title: "Getting Started with Your Android Box",
description: "Learn how to set up your new Android box and connect it to your TV",
thumbnail: "/images/android-box-setup.jpg",
duration: "8:45",
youtubeUrl: "#",
},
{
id: 2,
title: "Installing Apps and Adding Channels",
description: "Step-by-step guide to installing your favorite streaming apps",
thumbnail: "/images/android-apps-streaming.jpg",
duration: "12:30",
youtubeUrl: "#",
},
{
id: 3,
title: "Troubleshooting Common Issues",
description: "Quick fixes for buffering, connection issues, and more",
thumbnail: "/images/troubleshooting-tech-support.jpg",
duration: "10:15",
youtubeUrl: "#",
},
{
id: 4,
title: "Optimizing Your Streaming Experience",
description: "Tips and tricks to get the best performance from your box",
thumbnail: "/images/streaming-optimization.jpg",
duration: "9:20",
youtubeUrl: "#",
},
{
id: 5,
title: "Using the Remote Control",
description: "Master all the features of your remote control",
thumbnail: "/images/remote-control-android.jpg",
duration: "6:50",
youtubeUrl: "#",
},
{
id: 6,
title: "Advanced Settings and Customization",
description: "Customize your Android box to match your viewing preferences",
thumbnail: "/images/android-settings-customization.jpg",
duration: "11:40",
youtubeUrl: "#",
},
]
export const testimonials = [
{
quote:

125
lib/videos.ts Normal file
View File

@ -0,0 +1,125 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs"
import { randomUUID } from "crypto"
import { join } from "path"
const DATA_DIR = join(process.cwd(), "data")
const DATA_FILE = join(DATA_DIR, "videos.json")
export interface Video {
id: string
title: string
description: string
youtubeUrl: string
thumbnail: string
createdAt: string
}
function ensureDataDir() {
if (!existsSync(DATA_DIR)) {
mkdirSync(DATA_DIR, { recursive: true })
}
}
function seedDefaultVideos(): Video[] {
return [
{
id: randomUUID(),
title: "Getting Started with Your Android Box",
description: "Learn how to set up your new Android box and connect it to your TV",
youtubeUrl: "#",
thumbnail: "/images/android-box-setup.jpg",
createdAt: new Date().toISOString(),
},
{
id: randomUUID(),
title: "Installing Apps and Adding Channels",
description: "Step-by-step guide to installing your favorite streaming apps",
youtubeUrl: "#",
thumbnail: "/images/android-apps-streaming.jpg",
createdAt: new Date().toISOString(),
},
{
id: randomUUID(),
title: "Troubleshooting Common Issues",
description: "Quick fixes for buffering, connection issues, and more",
youtubeUrl: "#",
thumbnail: "/images/troubleshooting-tech-support.jpg",
createdAt: new Date().toISOString(),
},
{
id: randomUUID(),
title: "Optimizing Your Streaming Experience",
description: "Tips and tricks to get the best performance from your box",
youtubeUrl: "#",
thumbnail: "/images/streaming-optimization.jpg",
createdAt: new Date().toISOString(),
},
{
id: randomUUID(),
title: "Using the Remote Control",
description: "Master all the features of your remote control",
youtubeUrl: "#",
thumbnail: "/images/remote-control-android.jpg",
createdAt: new Date().toISOString(),
},
{
id: randomUUID(),
title: "Advanced Settings and Customization",
description: "Customize your Android box to match your viewing preferences",
youtubeUrl: "#",
thumbnail: "/images/android-settings-customization.jpg",
createdAt: new Date().toISOString(),
},
]
}
export function getVideos(): Video[] {
ensureDataDir()
if (!existsSync(DATA_FILE)) {
const defaults = seedDefaultVideos()
writeFileSync(DATA_FILE, JSON.stringify(defaults, null, 2))
return defaults
}
const raw = readFileSync(DATA_FILE, "utf-8")
return JSON.parse(raw)
}
export function addVideo(video: Omit<Video, "id" | "createdAt">): Video {
const videos = getVideos()
const newVideo: Video = {
...video,
id: randomUUID(),
createdAt: new Date().toISOString(),
}
videos.unshift(newVideo)
writeFileSync(DATA_FILE, JSON.stringify(videos, null, 2))
return newVideo
}
export function deleteVideo(id: string): boolean {
const videos = getVideos()
const filtered = videos.filter((v) => v.id !== id)
if (filtered.length === videos.length) return false
writeFileSync(DATA_FILE, JSON.stringify(filtered, null, 2))
return true
}
export function extractYoutubeId(url: string): string | null {
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/,
/youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/,
]
for (const pattern of patterns) {
const match = url.match(pattern)
if (match) return match[1]
}
return null
}
export function getYoutubeThumbnail(youtubeUrl: string): string {
const videoId = extractYoutubeId(youtubeUrl)
if (videoId) {
return `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`
}
return "/images/android-box-setup.jpg"
}

View File

@ -1,6 +1,12 @@
import { dirname } from "path"
import { fileURLToPath } from "url"
const __dirname = dirname(fileURLToPath(import.meta.url))
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
output: 'standalone',
outputFileTracingRoot: __dirname,
images: {
unoptimized: true,
},

View File

@ -1,28 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Clean URLs - serve .html files without extension
location / {
try_files $uri $uri.html $uri/ /404.html;
}
# Cache static assets
location ~* \.(jpg|jpeg|png|gif|ico|svg|css|js|woff2)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_types text/plain text/css application/json application/javascript text/xml image/svg+xml;
}