feat(tutorials): add video training academy management with YouTube parser and live preview

This commit is contained in:
mstfyldz
2026-08-22 13:49:00 +03:00
parent 2d149f1178
commit 7f690cc6d3
29 changed files with 2476 additions and 1034 deletions
@@ -0,0 +1,557 @@
'use client'
import React, { useState } from 'react'
import { Plus, Play, Edit, Trash2, CheckCircle2, XCircle, Search, ExternalLink, Video } from 'lucide-react'
import {
createTutorialAction,
updateTutorialAction,
deleteTutorialAction,
toggleTutorialStatusAction,
} from './actions'
import { extractYoutubeId } from './utils'
export interface TutorialItem {
id: string
title: string
description: string
category: string
category_label: string
duration: string
youtube_id: string
target_view: string | null
target_view_label: string | null
highlights: string[]
sort_order: number
is_active: boolean
created_at: string
}
const CATEGORY_OPTIONS = [
{ id: 'basics', label: '🚀 Hızlı Başlangıç' },
{ id: 'uyap', label: '⚖️ UYAP & Eklenti' },
{ id: 'ai_drafting', label: '🤖 AI Dilekçe & Savunma' },
{ id: 'jurisprudence', label: '🔍 18M İçtihat' },
{ id: 'cases', label: '📑 Dava & Tensip Analizi' },
{ id: 'templates', label: '📚 Şablonlar & Mevzuat' },
]
const TARGET_VIEWS = [
{ id: 'overview', label: 'Genel Bakış' },
{ id: 'cases', label: 'Dava Dosyaları' },
{ id: 'research', label: 'İçtihat Arama' },
{ id: 'drafting', label: 'Dilekçe Yazımı' },
{ id: 'templates', label: 'Şablon Kütüphanesi' },
{ id: 'clients', label: 'Müvekkiller' },
{ id: 'chat', label: 'AI Hukuk Asistanı' },
]
export function TutorialsClient({ initialTutorials }: { initialTutorials: TutorialItem[] }) {
const [tutorials, setTutorials] = useState<TutorialItem[]>(initialTutorials)
const [search, setSearch] = useState('')
const [categoryFilter, setCategoryFilter] = useState('all')
const [previewVideo, setPreviewVideo] = useState<TutorialItem | null>(null)
// Form modal state
const [isFormOpen, setIsFormOpen] = useState(false)
const [editingItem, setEditingItem] = useState<TutorialItem | null>(null)
const [isSaving, setIsSaving] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
// Form Fields
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [category, setCategory] = useState('basics')
const [duration, setDuration] = useState('03:45')
const [youtubeInput, setYoutubeInput] = useState('')
const [targetView, setTargetView] = useState('')
const [targetViewLabel, setTargetViewLabel] = useState('')
const [highlightsText, setHighlightsText] = useState('')
const [sortOrder, setSortOrder] = useState('0')
const [isActive, setIsActive] = useState(true)
const openNewForm = () => {
setEditingItem(null)
setTitle('')
setDescription('')
setCategory('basics')
setDuration('03:00')
setYoutubeInput('')
setTargetView('overview')
setTargetViewLabel('Genel Bakışa Git')
setHighlightsText('')
setSortOrder((tutorials.length + 1).toString())
setIsActive(true)
setErrorMessage(null)
setIsFormOpen(true)
}
const openEditForm = (item: TutorialItem) => {
setEditingItem(item)
setTitle(item.title)
setDescription(item.description)
setCategory(item.category)
setDuration(item.duration)
setYoutubeInput(`https://www.youtube.com/watch?v=/${item.youtube_id}`)
setTargetView(item.target_view || '')
setTargetViewLabel(item.target_view_label || '')
setHighlightsText((item.highlights || []).join('\n'))
setSortOrder(item.sort_order.toString())
setIsActive(item.is_active)
setErrorMessage(null)
setIsFormOpen(true)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSaving(true)
setErrorMessage(null)
try {
const parsedCat = CATEGORY_OPTIONS.find((c) => c.id === category)
const catLabel = parsedCat ? parsedCat.label : '🚀 Hızlı Başlangıç'
const formData = new FormData()
if (editingItem) formData.append('id', editingItem.id)
formData.append('title', title)
formData.append('description', description)
formData.append('category', category)
formData.append('category_label', catLabel)
formData.append('duration', duration)
formData.append('youtube_id', youtubeInput)
formData.append('target_view', targetView)
formData.append('target_view_label', targetViewLabel)
formData.append('highlights', highlightsText)
formData.append('sort_order', sortOrder)
if (isActive) formData.append('is_active', 'true')
if (editingItem) {
await updateTutorialAction(formData)
const updatedYt = extractYoutubeId(youtubeInput)
setTutorials((prev) =>
prev.map((t) =>
t.id === editingItem.id
? {
...t,
title,
description,
category,
category_label: catLabel,
duration,
youtube_id: updatedYt,
target_view: targetView || null,
target_view_label: targetViewLabel || null,
highlights: highlightsText.split('\n').filter(Boolean),
sort_order: parseInt(sortOrder, 10) || 0,
is_active: isActive,
}
: t
)
)
} else {
await createTutorialAction(formData)
// Refresh full page or optimistic append
window.location.reload()
}
setIsFormOpen(false)
} catch (err: any) {
setErrorMessage(err?.message || 'Bir hata oluştu')
} finally {
setIsSaving(false)
}
}
const handleDelete = async (id: string) => {
if (!window.confirm('Bu eğitim videosunu silmek istediğinize emin misiniz?')) return
try {
await deleteTutorialAction(id)
setTutorials((prev) => prev.filter((t) => t.id !== id))
} catch (err: any) {
alert(`Silinemedi: ${err?.message}`)
}
}
const handleToggleActive = async (item: TutorialItem) => {
const nextStatus = !item.is_active
try {
await toggleTutorialStatusAction(item.id, nextStatus)
setTutorials((prev) =>
prev.map((t) => (t.id === item.id ? { ...t, is_active: nextStatus } : t))
)
} catch (err: any) {
alert(`Güncellenemedi: ${err?.message}`)
}
}
const filtered = tutorials.filter((t) => {
const matchesCat = categoryFilter === 'all' || t.category === categoryFilter
const matchesSearch =
search.trim() === '' ||
t.title.toLowerCase().includes(search.toLowerCase()) ||
t.description.toLowerCase().includes(search.toLowerCase())
return matchesCat && matchesSearch
})
return (
<div className="space-y-6">
{/* Header Bar */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-white flex items-center gap-2.5">
<Video className="w-7 h-7 text-indigo-600 dark:text-indigo-400" />
Eğitim Videoları & Akademi
</h2>
<p className="text-gray-500 dark:text-gray-400 mt-1">
AyrisLegal masaüstü uygulamasında görünen YouTube kullanım ve eğitim videolarını yönetin.
</p>
</div>
<button
onClick={openNewForm}
className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold px-4 py-2.5 shadow-sm transition-colors cursor-pointer shrink-0"
>
<Plus className="w-4 h-4" />
+ Yeni Video Ekle
</button>
</div>
{/* Filter & Search Bar */}
<div className="flex flex-col sm:flex-row items-center gap-3">
<div className="relative flex-1 w-full">
<Search className="w-4 h-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="text"
placeholder="Video başlığı veya açıklamasında ara..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2 text-sm bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-lg outline-none focus:border-indigo-500 text-gray-900 dark:text-white"
/>
</div>
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="w-full sm:w-56 px-3 py-2 text-sm bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-lg outline-none text-gray-900 dark:text-white"
>
<option value="all">Tüm Kategoriler ({tutorials.length})</option>
{CATEGORY_OPTIONS.map((c) => (
<option key={c.id} value={c.id}>
{c.label}
</option>
))}
</select>
</div>
{/* Video Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{filtered.map((item) => (
<div
key={item.id}
className="rounded-xl bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 shadow-sm overflow-hidden flex flex-col justify-between transition-all hover:shadow-md"
>
<div>
{/* Thumbnail / Embed Preview */}
<div className="relative aspect-video w-full bg-gray-900 group flex items-center justify-center overflow-hidden">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`https://img.youtube.com/vi/${item.youtube_id}/mqdefault.jpg`}
alt={item.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
/>
<button
onClick={() => setPreviewVideo(item)}
className="absolute inset-0 flex items-center justify-center bg-black/40 hover:bg-black/60 transition-colors group-hover:scale-110 cursor-pointer"
title="Önizlemeyi Başlat"
>
<div className="w-12 h-12 rounded-full bg-red-600 flex items-center justify-center text-white shadow-lg">
<Play className="w-5 h-5 fill-current ml-0.5" />
</div>
</button>
<div className="absolute bottom-2 right-2 px-2 py-0.5 rounded text-[11px] font-mono bg-black/80 text-white font-bold">
{item.duration}
</div>
<div className="absolute top-2 left-2 px-2.5 py-0.5 rounded-md text-xs font-semibold bg-gray-900/90 text-indigo-300 border border-indigo-500/30">
{item.category_label}
</div>
</div>
{/* Body */}
<div className="p-4">
<div className="flex items-start justify-between gap-2 mb-1.5">
<h3 className="text-sm font-bold text-gray-900 dark:text-white line-clamp-1">
{item.title}
</h3>
<span className="text-xs font-mono text-gray-400 shrink-0">#{item.sort_order}</span>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 line-clamp-2 mb-3">
{item.description}
</p>
{item.highlights && item.highlights.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3">
{item.highlights.slice(0, 2).map((h, i) => (
<span
key={i}
className="text-[10.5px] px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-900 text-gray-600 dark:text-gray-400"
>
{h}
</span>
))}
{item.highlights.length > 2 && (
<span className="text-[10.5px] px-1.5 py-0.5 text-gray-400">
+{item.highlights.length - 2}
</span>
)}
</div>
)}
</div>
</div>
{/* Card Footer Actions */}
<div className="px-4 py-3 bg-gray-50 dark:bg-gray-900/60 border-t border-gray-100 dark:border-gray-800 flex items-center justify-between">
<button
onClick={() => handleToggleActive(item)}
className={`inline-flex items-center gap-1 text-xs font-medium px-2 py-1 rounded-md transition-colors ${
item.is_active
? 'bg-green-100 dark:bg-green-950/50 text-green-700 dark:text-green-400 hover:bg-green-200'
: 'bg-gray-200 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-300'
}`}
>
{item.is_active ? <CheckCircle2 className="w-3.5 h-3.5" /> : <XCircle className="w-3.5 h-3.5" />}
{item.is_active ? 'Yayında' : 'Pasif'}
</button>
<div className="flex items-center gap-2">
<button
onClick={() => openEditForm(item)}
className="p-1.5 text-gray-500 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
title="Düzenle"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(item.id)}
className="p-1.5 text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors"
title="Sil"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
</div>
{filtered.length === 0 && (
<div className="p-12 text-center bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl text-gray-500">
Aramanıza uygun video bulunamadı.
</div>
)}
{/* YOUTUBE LIVE PREVIEW MODAL */}
{previewVideo && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm">
<div className="relative w-full max-w-3xl bg-gray-950 border border-gray-800 rounded-2xl overflow-hidden shadow-2xl">
<div className="flex items-center justify-between px-5 py-3.5 border-b border-gray-800 bg-gray-900/70">
<h3 className="text-sm font-bold text-white truncate">{previewVideo.title}</h3>
<button
onClick={() => setPreviewVideo(null)}
className="text-gray-400 hover:text-white text-lg"
>
</button>
</div>
<div className="aspect-video w-full">
<iframe
src={`https://www.youtube.com/embed/${previewVideo.youtube_id}?autoplay=1`}
title={previewVideo.title}
className="w-full h-full border-0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
</div>
</div>
)}
{/* CREATE / EDIT VIDEO DRAWER MODAL */}
{isFormOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm overflow-y-auto">
<div className="relative w-full max-w-xl bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl shadow-2xl p-6 my-8">
<div className="flex items-center justify-between pb-4 border-b border-gray-100 dark:border-gray-800 mb-5">
<h3 className="text-lg font-bold text-gray-900 dark:text-white">
{editingItem ? 'Eğitim Videosunu Düzenle' : 'Yeni Eğitim Videosu Ekle'}
</h3>
<button
onClick={() => setIsFormOpen(false)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-white"
>
</button>
</div>
{errorMessage && (
<div className="p-3 mb-4 rounded-lg bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-900 text-xs text-red-700 dark:text-red-400">
{errorMessage}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1">
Video Başlığı *
</label>
<input
type="text"
required
placeholder="Örn: 18 Milyon İçtihat Arama Rehberi"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-3 py-2 text-sm bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg outline-none focus:border-indigo-500 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1">
Açıklama
</label>
<textarea
rows={2}
placeholder="Videonun içeriği ve kullanıcıya faydası..."
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full px-3 py-2 text-sm bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg outline-none focus:border-indigo-500 text-gray-900 dark:text-white"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1">
Kategori
</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full px-3 py-2 text-sm bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg outline-none text-gray-900 dark:text-white"
>
{CATEGORY_OPTIONS.map((c) => (
<option key={c.id} value={c.id}>
{c.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1">
Süre (Örn: 04:30)
</label>
<input
type="text"
value={duration}
onChange={(e) => setDuration(e.target.value)}
className="w-full px-3 py-2 text-sm bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg outline-none text-gray-900 dark:text-white font-mono"
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1">
YouTube Video Linki veya Video ID *
</label>
<input
type="text"
required
placeholder="https://www.youtube.com/watch?v=... veya dQw4w9WgXcQ"
value={youtubeInput}
onChange={(e) => setYoutubeInput(e.target.value)}
className="w-full px-3 py-2 text-sm bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg outline-none focus:border-indigo-500 text-gray-900 dark:text-white font-mono"
/>
<p className="text-[11px] text-gray-400 mt-1">
Normal YouTube linki, Shorts veya direct ID girebilirsiniz. Otomatik ayrıştırılır.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1">
Yönlendirilecek Sayfa (Opsiyonel)
</label>
<select
value={targetView}
onChange={(e) => {
setTargetView(e.target.value)
const found = TARGET_VIEWS.find((v) => v.id === e.target.value)
if (found) setTargetViewLabel(`${found.label} Sayfasına Git`)
}}
className="w-full px-3 py-2 text-sm bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg outline-none text-gray-900 dark:text-white"
>
<option value="">Seçiniz</option>
{TARGET_VIEWS.map((v) => (
<option key={v.id} value={v.id}>
{v.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1">
Sıralama Önceliği
</label>
<input
type="number"
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value)}
className="w-full px-3 py-2 text-sm bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg outline-none text-gray-900 dark:text-white font-mono"
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1">
Öğrenilecek Başlıklar (Her satıra bir madde)
</label>
<textarea
rows={3}
placeholder="Panel kullanımı&#10;Emsal arama filtreleri&#10;Dilekçe taslağı çıkarma"
value={highlightsText}
onChange={(e) => setHighlightsText(e.target.value)}
className="w-full px-3 py-2 text-sm bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg outline-none focus:border-indigo-500 text-gray-900 dark:text-white"
/>
</div>
<div className="flex items-center gap-2 pt-2">
<input
type="checkbox"
id="isActiveToggle"
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
className="w-4 h-4 rounded text-indigo-600 focus:ring-indigo-500 border-gray-300"
/>
<label htmlFor="isActiveToggle" className="text-xs font-semibold text-gray-700 dark:text-gray-300">
Bu videoyu uygulamada yayına al (Aktif)
</label>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-gray-100 dark:border-gray-800">
<button
type="button"
onClick={() => setIsFormOpen(false)}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
İptal
</button>
<button
type="submit"
disabled={isSaving}
className="px-5 py-2 text-sm font-semibold text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors shadow-sm disabled:opacity-50"
>
{isSaving ? 'Kaydediliyor...' : editingItem ? 'Güncelle' : 'Kaydet ve Yayınla'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}