From 620f56429da04a1321aa55745e854832939d5140 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 10 Feb 2026 20:28:10 +0000 Subject: [PATCH] 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 --- .dockerignore | 1 + .gitignore | 1 + Dockerfile | 19 +- app/admin/page.tsx | 347 ++++++++++++++++++++++++++++++++++ app/api/admin/login/route.ts | 13 ++ app/api/videos/[id]/route.ts | 24 +++ app/api/videos/route.ts | 52 +++++ app/page.tsx | 2 +- app/robots.ts | 2 - app/sitemap.ts | 2 - app/videos/videos-content.tsx | 41 +++- components/video-card.tsx | 5 - docker-compose.yml | 9 +- lib/admin-auth.ts | 19 ++ lib/data.ts | 51 ----- lib/videos.ts | 125 ++++++++++++ next.config.mjs | 8 +- nginx.conf | 28 --- 18 files changed, 646 insertions(+), 103 deletions(-) create mode 100644 app/admin/page.tsx create mode 100644 app/api/admin/login/route.ts create mode 100644 app/api/videos/[id]/route.ts create mode 100644 app/api/videos/route.ts create mode 100644 lib/admin-auth.ts create mode 100644 lib/videos.ts delete mode 100644 nginx.conf diff --git a/.dockerignore b/.dockerignore index 8d0a451..101aa8c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,3 +9,4 @@ Dockerfile docker-compose*.yml .dockerignore _static-backup +data diff --git a/.gitignore b/.gitignore index 65ac7ae..610d628 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ out/ next-env.d.ts .env* _static-backup/ +data/ diff --git a/Dockerfile b/Dockerfile index 83b0a03..b7b4bbe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..9e92150 --- /dev/null +++ b/app/admin/page.tsx @@ -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([]) + 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 ( +
+ + + Admin Access + + Enter the admin password to manage videos + + + +
+
+ + setPassword(e.target.value)} + placeholder="Enter admin password" + required + /> + {loginError &&

{loginError}

} +
+ +
+
+
+
+ ) + } + + // Admin dashboard + return ( +
+
+ {/* Header */} +
+
+

Video Manager

+

Add and manage YouTube tutorial videos

+
+ +
+ + {/* Add Video Form */} + + + + + Add New Video + + + +
+
+ + handleUrlChange(e.target.value)} + placeholder="https://www.youtube.com/watch?v=..." + required + /> +
+ + {previewThumbnail && ( +
+ Video preview +
+ )} + +
+ + setNewTitle(e.target.value)} + placeholder="Leave blank to auto-fetch from YouTube" + /> +
+ +
+ + setNewDescription(e.target.value)} + placeholder="Brief description of the video" + /> +
+ + {addError &&

{addError}

} + + +
+
+
+ + {/* Video List */} +
+

+ Current Videos ({videos.length}) +

+ + {loading &&

Loading videos...

} + + {videos.length === 0 && !loading && ( +

No videos yet. Add your first video above.

+ )} + + {videos.map((video) => ( + +
+
+ {video.title} +
+
+

{video.title}

+ {video.description && ( +

+ {video.description} +

+ )} + + {video.youtubeUrl === "#" ? "No URL set" : video.youtubeUrl} + {video.youtubeUrl !== "#" && } + +
+
+ +
+
+
+ ))} +
+
+
+ ) +} diff --git a/app/api/admin/login/route.ts b/app/api/admin/login/route.ts new file mode 100644 index 0000000..70ce3cf --- /dev/null +++ b/app/api/admin/login/route.ts @@ -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() }) +} diff --git a/app/api/videos/[id]/route.ts b/app/api/videos/[id]/route.ts new file mode 100644 index 0000000..2d4bb53 --- /dev/null +++ b/app/api/videos/[id]/route.ts @@ -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 }) +} diff --git a/app/api/videos/route.ts b/app/api/videos/route.ts new file mode 100644 index 0000000..a28efcc --- /dev/null +++ b/app/api/videos/route.ts @@ -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 }) +} diff --git a/app/page.tsx b/app/page.tsx index 7ddf7b4..f949505 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -140,7 +140,7 @@ export default function HomePage() { asChild > diff --git a/app/robots.ts b/app/robots.ts index 18a9976..47e4469 100644 --- a/app/robots.ts +++ b/app/robots.ts @@ -1,7 +1,5 @@ import type { MetadataRoute } from "next" -export const dynamic = "force-static" - export default function robots(): MetadataRoute.Robots { return { rules: { diff --git a/app/sitemap.ts b/app/sitemap.ts index 706a8a2..9bf229e 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,7 +1,5 @@ import type { MetadataRoute } from "next" -export const dynamic = "force-static" - export default function sitemap(): MetadataRoute.Sitemap { return [ { diff --git a/app/videos/videos-content.tsx b/app/videos/videos-content.tsx index 74d53d1..49d3a51 100644 --- a/app/videos/videos-content.tsx +++ b/app/videos/videos-content.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + fetch("/api/videos") + .then((r) => r.json()) + .then((data) => setVideos(data)) + .catch(() => {}) + .finally(() => setLoading(false)) + }, []) + return (
@@ -18,11 +37,21 @@ export function VideosContent() { step-by-step video guides

-
- {videos.map((video) => ( - - ))} -
+ {loading ? ( +
+ Loading videos... +
+ ) : videos.length === 0 ? ( +
+ No videos available yet. Check back soon! +
+ ) : ( +
+ {videos.map((video) => ( + + ))} +
+ )}
diff --git a/components/video-card.tsx b/components/video-card.tsx index a1c45a2..28d9792 100644 --- a/components/video-card.tsx +++ b/components/video-card.tsx @@ -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({ - - {duration} -

{title}

diff --git a/docker-compose.yml b/docker-compose.yml index aff9f50..752b1a6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/lib/admin-auth.ts b/lib/admin-auth.ts new file mode 100644 index 0000000..af63d21 --- /dev/null +++ b/lib/admin-auth.ts @@ -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 +} diff --git a/lib/data.ts b/lib/data.ts index 464275f..2ab0bdf 100644 --- a/lib/data.ts +++ b/lib/data.ts @@ -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: diff --git a/lib/videos.ts b/lib/videos.ts new file mode 100644 index 0000000..ac36f1f --- /dev/null +++ b/lib/videos.ts @@ -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 { + 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" +} diff --git a/next.config.mjs b/next.config.mjs index ea3d1c5..5ace438 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -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, }, diff --git a/nginx.conf b/nginx.conf deleted file mode 100644 index e5da133..0000000 --- a/nginx.conf +++ /dev/null @@ -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; -}