feat(tutorials): add video training academy management with YouTube parser and live preview
This commit is contained in:
@@ -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ı Emsal arama filtreleri 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { supabaseAdmin } from '@/lib/supabaseAdmin'
|
||||
import { extractYoutubeId } from './utils'
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await auth()
|
||||
if (!session || (session.user as any)?.role !== 'ADMIN') {
|
||||
throw new Error('Yetkisiz erişim')
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTutorialAction(formData: FormData) {
|
||||
await requireAdmin()
|
||||
|
||||
const title = String(formData.get('title') || '').trim()
|
||||
const description = String(formData.get('description') || '').trim()
|
||||
const category = String(formData.get('category') || 'basics').trim()
|
||||
const categoryLabel = String(formData.get('category_label') || '🚀 Hızlı Başlangıç').trim()
|
||||
const duration = String(formData.get('duration') || '03:00').trim()
|
||||
const rawYoutube = String(formData.get('youtube_id') || '').trim()
|
||||
const youtubeId = extractYoutubeId(rawYoutube)
|
||||
const targetView = String(formData.get('target_view') || '').trim() || null
|
||||
const targetViewLabel = String(formData.get('target_view_label') || '').trim() || null
|
||||
const highlightsRaw = String(formData.get('highlights') || '').trim()
|
||||
const sortOrder = parseInt(String(formData.get('sort_order') || '0'), 10) || 0
|
||||
const isActive = formData.get('is_active') === 'on' || formData.get('is_active') === 'true'
|
||||
|
||||
const highlights = highlightsRaw
|
||||
? highlightsRaw.split('\n').map((s) => s.trim()).filter(Boolean)
|
||||
: []
|
||||
|
||||
if (!title) throw new Error('Video başlığı zorunludur')
|
||||
if (!youtubeId) throw new Error('Geçerli bir YouTube video linki veya ID giriniz')
|
||||
|
||||
const { error } = await supabaseAdmin.from('tutorials').insert({
|
||||
title,
|
||||
description,
|
||||
category,
|
||||
category_label: categoryLabel,
|
||||
duration,
|
||||
youtube_id: youtubeId,
|
||||
target_view: targetView,
|
||||
target_view_label: targetViewLabel,
|
||||
highlights,
|
||||
sort_order: sortOrder,
|
||||
is_active: isActive,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Kayıt hatası: ${error.message}`)
|
||||
}
|
||||
|
||||
revalidatePath('/admin/tutorials')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function updateTutorialAction(formData: FormData) {
|
||||
await requireAdmin()
|
||||
|
||||
const id = String(formData.get('id') || '').trim()
|
||||
if (!id) throw new Error('Video ID eksik')
|
||||
|
||||
const title = String(formData.get('title') || '').trim()
|
||||
const description = String(formData.get('description') || '').trim()
|
||||
const category = String(formData.get('category') || 'basics').trim()
|
||||
const categoryLabel = String(formData.get('category_label') || '🚀 Hızlı Başlangıç').trim()
|
||||
const duration = String(formData.get('duration') || '03:00').trim()
|
||||
const rawYoutube = String(formData.get('youtube_id') || '').trim()
|
||||
const youtubeId = extractYoutubeId(rawYoutube)
|
||||
const targetView = String(formData.get('target_view') || '').trim() || null
|
||||
const targetViewLabel = String(formData.get('target_view_label') || '').trim() || null
|
||||
const highlightsRaw = String(formData.get('highlights') || '').trim()
|
||||
const sortOrder = parseInt(String(formData.get('sort_order') || '0'), 10) || 0
|
||||
const isActive = formData.get('is_active') === 'on' || formData.get('is_active') === 'true'
|
||||
|
||||
const highlights = highlightsRaw
|
||||
? highlightsRaw.split('\n').map((s) => s.trim()).filter(Boolean)
|
||||
: []
|
||||
|
||||
if (!title) throw new Error('Video başlığı zorunludur')
|
||||
if (!youtubeId) throw new Error('Geçerli bir YouTube video linki veya ID giriniz')
|
||||
|
||||
const { error } = await supabaseAdmin
|
||||
.from('tutorials')
|
||||
.update({
|
||||
title,
|
||||
description,
|
||||
category,
|
||||
category_label: categoryLabel,
|
||||
duration,
|
||||
youtube_id: youtubeId,
|
||||
target_view: targetView,
|
||||
target_view_label: targetViewLabel,
|
||||
highlights,
|
||||
sort_order: sortOrder,
|
||||
is_active: isActive,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', id)
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Güncelleme hatası: ${error.message}`)
|
||||
}
|
||||
|
||||
revalidatePath('/admin/tutorials')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function deleteTutorialAction(id: string) {
|
||||
await requireAdmin()
|
||||
|
||||
if (!id) throw new Error('Video ID eksik')
|
||||
|
||||
const { error } = await supabaseAdmin.from('tutorials').delete().eq('id', id)
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Silme hatası: ${error.message}`)
|
||||
}
|
||||
|
||||
revalidatePath('/admin/tutorials')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function toggleTutorialStatusAction(id: string, newStatus: boolean) {
|
||||
await requireAdmin()
|
||||
|
||||
const { error } = await supabaseAdmin
|
||||
.from('tutorials')
|
||||
.update({ is_active: newStatus, updated_at: new Date().toISOString() })
|
||||
.eq('id', id)
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Durum güncelleme hatası: ${error.message}`)
|
||||
}
|
||||
|
||||
revalidatePath('/admin/tutorials')
|
||||
return { success: true }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { supabaseAdmin } from '@/lib/supabaseAdmin'
|
||||
import { TutorialsClient, TutorialItem } from './TutorialsClient'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function AdminTutorialsPage() {
|
||||
const { data: tutorials, error } = await supabaseAdmin
|
||||
.from('tutorials')
|
||||
.select('*')
|
||||
.order('sort_order', { ascending: true })
|
||||
|
||||
const items: TutorialItem[] = (tutorials || []).map((t: any) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
description: t.description || '',
|
||||
category: t.category || 'basics',
|
||||
category_label: t.category_label || '🚀 Hızlı Başlangıç',
|
||||
duration: t.duration || '03:00',
|
||||
youtube_id: t.youtube_id,
|
||||
target_view: t.target_view,
|
||||
target_view_label: t.target_view_label,
|
||||
highlights: Array.isArray(t.highlights) ? t.highlights : [],
|
||||
sort_order: t.sort_order || 0,
|
||||
is_active: t.is_active !== false,
|
||||
created_at: t.created_at,
|
||||
}))
|
||||
|
||||
return <TutorialsClient initialTutorials={items} />
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export function extractYoutubeId(input: string): string {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) return ''
|
||||
|
||||
// Direct 11-char ID
|
||||
if (/^[a-zA-Z0-9_-]{11}$/.test(trimmed)) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// youtu.be/ID
|
||||
const shortMatch = trimmed.match(/youtu\.be\/([a-zA-Z0-9_-]{11})/)
|
||||
if (shortMatch) return shortMatch[1]
|
||||
|
||||
// youtube.com/watch?v=ID
|
||||
const watchMatch = trimmed.match(/[?&]v=([a-zA-Z0-9_-]{11})/)
|
||||
if (watchMatch) return watchMatch[1]
|
||||
|
||||
// youtube.com/embed/ID
|
||||
const embedMatch = trimmed.match(/youtube\.com\/embed\/([a-zA-Z0-9_-]{11})/)
|
||||
if (embedMatch) return embedMatch[1]
|
||||
|
||||
// youtube.com/shorts/ID
|
||||
const shortsMatch = trimmed.match(/youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/)
|
||||
if (shortsMatch) return shortsMatch[1]
|
||||
|
||||
return trimmed
|
||||
}
|
||||
Reference in New Issue
Block a user