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:
parent
02074d43e4
commit
620f56429d
|
|
@ -9,3 +9,4 @@ Dockerfile
|
|||
docker-compose*.yml
|
||||
.dockerignore
|
||||
_static-backup
|
||||
data
|
||||
|
|
|
|||
|
|
@ -5,3 +5,4 @@ out/
|
|||
next-env.d.ts
|
||||
.env*
|
||||
_static-backup/
|
||||
data/
|
||||
|
|
|
|||
19
Dockerfile
19
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"]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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() })
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import type { MetadataRoute } from "next"
|
||||
|
||||
export const dynamic = "force-static"
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import type { MetadataRoute } from "next"
|
||||
|
||||
export const dynamic = "force-static"
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
51
lib/data.ts
51
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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
28
nginx.conf
28
nginx.conf
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue