348 lines
11 KiB
TypeScript
348 lines
11 KiB
TypeScript
"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>
|
|
)
|
|
}
|