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,55 @@
'use client'
import { useState } from 'react'
import { deleteUserAction } from '../actions'
export default function DeleteUserButton({ id, email }: { id: string; email: string }) {
const [confirmText, setConfirmText] = useState('')
const [open, setOpen] = useState(false)
const canDelete = confirmText.trim().toLowerCase() === email.toLowerCase()
if (!open) {
return (
<button
type="button"
onClick={() => setOpen(true)}
className="rounded-md border border-red-300 dark:border-red-900 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/30 text-sm font-medium px-4 py-2 transition-colors"
>
Kullanıcıyı Sil
</button>
)
}
return (
<form action={deleteUserAction} className="space-y-3">
<input type="hidden" name="id" value={id} />
<p className="text-sm text-gray-600 dark:text-gray-400">
Bu işlem geri alınamaz kullanıcının Supabase Auth hesabı ve profili silinir. Bu kullanıcıya bağlı lisanslar askıda kalabilir, gerekirse Lisanslar sayfasından bağlantısını kaldırın.
Onaylamak için e-postasını yazın: <span className="font-mono text-gray-900 dark:text-white">{email}</span>
</p>
<input
type="text"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={email}
className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-red-500"
/>
<div className="flex items-center gap-3">
<button
type="submit"
disabled={!canDelete}
className="rounded-md bg-red-600 hover:bg-red-700 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium px-4 py-2 transition-colors"
>
Kalıcı Olarak Sil
</button>
<button
type="button"
onClick={() => { setOpen(false); setConfirmText('') }}
className="text-sm text-gray-500 hover:text-gray-900 dark:hover:text-white"
>
Vazgeç
</button>
</div>
</form>
)
}
+198
View File
@@ -0,0 +1,198 @@
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { supabaseAdmin, listAuthUsers } from '@/lib/supabaseAdmin'
import { updateUserAction } from '../actions'
import DeleteUserButton from './DeleteUserButton'
interface ProfileRow {
id: string
full_name: string | null
bar_association_no: string | null
bar_name: string | null
phone: string | null
license_status: string | null
license_expires_at: string | null
license_started_at: string | null
trial_start_date: string | null
trial_end_date: string | null
org_id: string | null
created_at: string
}
interface LicenseRow {
id: string
plan: string
status: string
max_devices: number
expires_at: string | null
}
function toDateInputValue(iso: string | null) {
if (!iso) return ''
return new Date(iso).toISOString().slice(0, 10)
}
function formatDate(iso: string | null) {
if (!iso) return '—'
return new Date(iso).toLocaleDateString('tr-TR', { day: 'numeric', month: 'short', year: 'numeric' })
}
export default async function UserEditPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>
searchParams: Promise<{ error?: string; success?: string }>
}) {
const { id } = await params
const { error, success } = await searchParams
const [{ data: profile }, authUsers, { data: licenses }] = await Promise.all([
supabaseAdmin
.from('profiles')
.select('id, full_name, bar_association_no, bar_name, phone, license_status, license_expires_at, license_started_at, trial_start_date, trial_end_date, org_id, created_at')
.eq('id', id)
.maybeSingle(),
listAuthUsers(),
supabaseAdmin.from('licenses').select('id, plan, status, max_devices, expires_at').eq('customer_id', id),
])
if (!profile) notFound()
const p = profile as ProfileRow
const email = authUsers.get(p.id)?.email || ''
const linkedLicenses = (licenses as LicenseRow[] | null) || []
return (
<div className="space-y-6 max-w-2xl">
<div>
<Link href="/admin/users" className="text-sm text-gray-500 hover:text-gray-900 dark:hover:text-white"> Kullanıcılar</Link>
<h2 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-white mt-2">Kullanıcı Düzenle</h2>
<p className="text-gray-500 dark:text-gray-400 mt-1 text-sm">{email || p.id}</p>
</div>
{error && (
<div className="rounded-md bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-900 px-4 py-3 text-sm text-red-700 dark:text-red-400">
{error}
</div>
)}
{success && (
<div className="rounded-md bg-green-50 dark:bg-green-950/40 border border-green-200 dark:border-green-900 px-4 py-3 text-sm text-green-700 dark:text-green-400">
Değişiklikler kaydedildi.
</div>
)}
<form action={updateUserAction} className="rounded-lg bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 shadow-sm p-6 space-y-5">
<input type="hidden" name="id" value={p.id} />
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Ad Soyad</label>
<input
type="text"
name="full_name"
defaultValue={p.full_name || ''}
className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Baro</label>
<input
type="text"
name="bar_name"
defaultValue={p.bar_name || ''}
className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Baro Sicil No</label>
<input
type="text"
name="bar_association_no"
defaultValue={p.bar_association_no || ''}
className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Telefon (WhatsApp)</label>
<input
type="text"
name="phone"
placeholder="+905XXXXXXXXX"
defaultValue={p.phone || ''}
className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
<p className="mt-1 text-xs text-gray-400">Ülke koduyla (+90) girin WhatsApp AI hattının bu numarayı hesapla eşleştirmesi için kullanılır.</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Lisans Durumu</label>
<select
name="license_status"
defaultValue={p.license_status || 'active'}
className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="trial">trial</option>
<option value="active">active</option>
<option value="expired">expired</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Lisans Bitiş</label>
<input
type="date"
name="license_expires_at"
defaultValue={toDateInputValue(p.license_expires_at)}
className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Deneme Bitiş Tarihi</label>
<input
type="date"
name="trial_end_date"
defaultValue={toDateInputValue(p.trial_end_date)}
className="w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
</div>
<div className="flex items-center gap-3 pt-2">
<button
type="submit"
className="rounded-md bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium px-4 py-2 transition-colors"
>
Kaydet
</button>
<Link href="/admin/users" className="text-sm text-gray-500 hover:text-gray-900 dark:hover:text-white">
Vazgeç
</Link>
</div>
</form>
<div className="rounded-lg bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 shadow-sm p-6">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-3">Bağlı Lisanslar</h3>
{linkedLicenses.length === 0 ? (
<p className="text-sm text-gray-500">Bu kullanıcıya bağlı lisans yok.</p>
) : (
<ul className="divide-y divide-gray-100 dark:divide-gray-800">
{linkedLicenses.map((l) => (
<li key={l.id} className="py-2.5 flex items-center justify-between text-sm">
<span className="text-gray-700 dark:text-gray-300">{l.plan} · {l.status} · {l.max_devices} cihaz · bitiş {formatDate(l.expires_at)}</span>
<Link href={`/admin/licenses/${l.id}`} className="text-indigo-600 dark:text-indigo-400 hover:underline">Düzenle</Link>
</li>
))}
</ul>
)}
</div>
<div className="rounded-lg bg-white dark:bg-gray-950 border border-red-200 dark:border-red-900 shadow-sm p-6">
<h3 className="text-sm font-semibold text-red-700 dark:text-red-400 uppercase tracking-wide mb-3">Tehlikeli Bölge</h3>
<DeleteUserButton id={p.id} email={email || p.id} />
</div>
</div>
)
}
+89
View File
@@ -0,0 +1,89 @@
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { auth } from '@/lib/auth'
import { supabaseAdmin, deleteAuthUser } from '@/lib/supabaseAdmin'
async function requireAdmin() {
const session = await auth()
if (!session || (session.user as any)?.role !== 'ADMIN') {
throw new Error('Yetkisiz erişim')
}
}
function toIsoOrNull(dateInput: FormDataEntryValue | null): string | null {
const value = String(dateInput || '').trim()
if (!value) return null
const d = new Date(`${value}T23:59:59.999Z`)
if (isNaN(d.getTime())) return null
return d.toISOString()
}
// profiles.license_status masaüstü uygulamasının (laawos/Electron) TEK gerçek
// erişim kapısı (bkz. LicenseGate.tsx::fetchActiveStatus) ve laawos-backend'in
// API middleware'i de aynı alana bakıyor — bu yüzden bunu yazan HER yol (Kullanıcılar
// sayfası, Lisanslar sayfası) bu tek fonksiyondan geçmeli, yoksa iki ekran
// birbirinden habersiz farklı değerler yazıp "veriler eşleşmiyor" durumuna düşer.
export async function setProfileAccess(userId: string, status: string, expiresAt: string | null) {
await supabaseAdmin
.from('profiles')
.update({ license_status: status, license_expires_at: expiresAt })
.eq('id', userId)
}
export async function updateUserAction(formData: FormData) {
await requireAdmin()
const id = String(formData.get('id') || '')
if (!id) throw new Error('Kullanıcı id eksik')
const fullName = String(formData.get('full_name') || '').trim()
const barName = String(formData.get('bar_name') || '').trim()
const barNo = String(formData.get('bar_association_no') || '').trim()
const phone = String(formData.get('phone') || '').trim()
const licenseStatus = String(formData.get('license_status') || '').trim()
const licenseExpiresAt = toIsoOrNull(formData.get('license_expires_at'))
const trialEndDate = toIsoOrNull(formData.get('trial_end_date'))
const update: Record<string, unknown> = {
full_name: fullName || null,
bar_name: barName || null,
bar_association_no: barNo || null,
phone: phone || null,
trial_end_date: trialEndDate,
}
if (licenseStatus) {
// license_status + license_expires_at ikilisi setProfileAccess üzerinden yazılıyor,
// ki Lisanslar sayfasıyla aynı tek yoldan geçsin.
await setProfileAccess(id, licenseStatus, licenseExpiresAt)
} else {
update.license_expires_at = licenseExpiresAt
}
const { error } = await supabaseAdmin.from('profiles').update(update).eq('id', id)
if (error) {
redirect(`/admin/users/${id}?error=${encodeURIComponent(error.message)}`)
}
revalidatePath('/admin/users')
revalidatePath(`/admin/users/${id}`)
redirect(`/admin/users/${id}?success=1`)
}
export async function deleteUserAction(formData: FormData) {
await requireAdmin()
const id = String(formData.get('id') || '')
if (!id) throw new Error('Kullanıcı id eksik')
const { error: authError } = await deleteAuthUser(id)
if (authError) {
redirect(`/admin/users/${id}?error=${encodeURIComponent(authError)}`)
}
await supabaseAdmin.from('profiles').delete().eq('id', id)
revalidatePath('/admin/users')
redirect('/admin/users?deleted=1')
}
+104
View File
@@ -0,0 +1,104 @@
import Link from 'next/link'
import { supabaseAdmin, listAuthUsers } from '@/lib/supabaseAdmin'
interface ProfileRow {
id: string
full_name: string | null
bar_association_no: string | null
bar_name: string | null
license_status: string | null
license_expires_at: string | null
trial_end_date: string | null
org_id: string | null
created_at: string
}
function statusBadge(status: string | null) {
const map: Record<string, string> = {
active: 'bg-green-50 text-green-700 border-green-200 dark:bg-green-950/40 dark:text-green-400 dark:border-green-900',
trial: 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-950/40 dark:text-amber-400 dark:border-amber-900',
expired: 'bg-red-50 text-red-700 border-red-200 dark:bg-red-950/40 dark:text-red-400 dark:border-red-900',
}
const cls = map[status || ''] || 'bg-gray-50 text-gray-600 border-gray-200 dark:bg-gray-900 dark:text-gray-400 dark:border-gray-800'
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border ${cls}`}>
{status || 'bilinmiyor'}
</span>
)
}
function formatDate(iso: string | null) {
if (!iso) return '—'
return new Date(iso).toLocaleDateString('tr-TR', { day: 'numeric', month: 'short', year: 'numeric' })
}
export default async function UsersPage({ searchParams }: { searchParams: Promise<{ deleted?: string }> }) {
const { deleted } = await searchParams
const [{ data: profiles }, authUsers] = await Promise.all([
supabaseAdmin
.from('profiles')
.select('id, full_name, bar_association_no, bar_name, license_status, license_expires_at, trial_end_date, org_id, created_at')
.order('created_at', { ascending: false }),
listAuthUsers(),
])
const rows = (profiles as ProfileRow[] | null) || []
return (
<div className="space-y-6">
{deleted && (
<div className="rounded-md bg-green-50 dark:bg-green-950/40 border border-green-200 dark:border-green-900 px-4 py-3 text-sm text-green-700 dark:text-green-400">
Kullanıcı silindi.
</div>
)}
<div>
<h2 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-white">Kullanıcılar</h2>
<p className="text-gray-500 dark:text-gray-400 mt-2">Toplam {rows.length} kullanıcı.</p>
</div>
<div className="rounded-lg bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
<thead className="bg-gray-50 dark:bg-gray-900">
<tr>
{['Kullanıcı', 'Baro', 'Durum', 'Bitiş Tarihi', 'Kayıt', ''].map((h) => (
<th key={h} className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{rows.map((p) => {
const authUser = authUsers.get(p.id)
const expiryDate = p.license_status === 'trial' ? p.trial_end_date : p.license_expires_at
return (
<tr key={p.id} className="hover:bg-gray-50 dark:hover:bg-gray-900/50">
<td className="px-4 py-3 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900 dark:text-white">{p.full_name || '(isimsiz)'}</div>
<div className="text-xs text-gray-500">{authUser?.email || p.id}</div>
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">
{p.bar_name || '—'}{p.bar_association_no ? ` · ${p.bar_association_no}` : ''}
</td>
<td className="px-4 py-3 whitespace-nowrap">{statusBadge(p.license_status)}</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">{formatDate(expiryDate)}</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500">{formatDate(p.created_at)}</td>
<td className="px-4 py-3 whitespace-nowrap text-right text-sm">
<Link href={`/admin/users/${p.id}`} className="text-indigo-600 dark:text-indigo-400 hover:underline">
Düzenle
</Link>
</td>
</tr>
)
})}
{rows.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-sm text-gray-500">Henüz kullanıcı yok.</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
)
}