first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 22:15:25 +03:00
commit 9eb7aea821
56 changed files with 20630 additions and 0 deletions

96
app/admin/layout.tsx Normal file
View File

@@ -0,0 +1,96 @@
import Link from 'next/link'
import type { ReactNode } from 'react'
import { Users, FileText, ShoppingBag, UserRound } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
const navItems = [
{
label: 'Kullanıcılar',
href: '/admin/users',
icon: Users,
disabled: false,
},
{
label: 'Profilim',
href: '/admin/profile',
icon: UserRound,
disabled: false,
},
{
label: 'Posts',
href: '#',
icon: FileText,
disabled: true,
},
{
label: 'Shop',
href: '#',
icon: ShoppingBag,
disabled: true,
},
]
export default function AdminLayout({ children }: { children: ReactNode }) {
return (
<div className="flex w-full flex-1 gap-3 pl-2 pr-3 pt-0 pb-0 lg:gap-4 lg:pl-3 lg:pr-4">
{/* ── Sidebar (sol) ── */}
<aside className="hidden w-64 shrink-0 lg:block">
<div className="sticky top-12 h-[calc(100dvh-3rem)] overflow-hidden rounded-none border-y-0 border-l-0 border-r bg-sidebar text-sidebar-foreground">
<div className="border-b border-sidebar-border px-4 py-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sidebar-foreground/70">
Admin Panel
</p>
<p className="mt-1 text-sm font-medium">Yonetim Menusu</p>
</div>
<div className="border-b border-sidebar-border px-4 py-3">
<p className="text-xs text-sidebar-foreground/70">Hizli Erisim</p>
<p className="text-sm font-medium">Kullanicilar ve Icerik</p>
</div>
<nav className="space-y-1 p-2">
{navItems.map((item) => {
const Icon = item.icon
if (item.disabled) {
return (
<div
key={item.label}
className="flex cursor-not-allowed items-center gap-3 rounded-md px-3 py-2 text-sm text-sidebar-foreground/60"
>
<Icon className="size-4" />
<span>{item.label}</span>
<Badge variant="secondary" className="ml-auto border-0 bg-sidebar-accent text-[10px] text-sidebar-accent-foreground">
Yakinda
</Badge>
</div>
)
}
return (
<Button
key={item.label}
variant="ghost"
size="sm"
className="h-10 w-full justify-start gap-3 rounded-md text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
asChild
>
<Link href={item.href}>
<Icon className="size-4" />
{item.label}
</Link>
</Button>
)
})}
</nav>
</div>
</aside>
{/* ── İçerik (sağ) ── */}
<main className="min-w-0 flex-1 overflow-hidden rounded-xl border bg-card shadow-sm">
{children}
</main>
</div>
)
}

12
app/admin/page.tsx Normal file
View File

@@ -0,0 +1,12 @@
export default function AdminPage() {
return (
<section>
<h1 className="text-2xl font-semibold text-[var(--foreground)]">
Admin Panel
</h1>
<p className="mt-2 text-sm text-[var(--foreground)]/75">
Sağdaki menüden Users, Posts ve Shop bölümlerine geçiş yapabilirsiniz.
</p>
</section>
)
}

View File

@@ -0,0 +1,182 @@
'use client'
import { useActionState, useEffect, useState } from 'react'
import Swal from 'sweetalert2'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { updateProfile, type Profile, type ProfileFormState } from './actions'
function getSwalThemeOptions() {
const isDark = document.documentElement.classList.contains('dark')
return isDark
? { background: '#111827', color: '#f3f4f6' }
: { background: '#ffffff', color: '#111827' }
}
const MAX_AVATAR_BYTES = 5 * 1024 * 1024
function showToast(icon: 'success' | 'error', title: string) {
void Swal.fire({
...getSwalThemeOptions(),
toast: true,
position: 'top-end',
showConfirmButton: false,
timer: 2200,
timerProgressBar: true,
icon,
title,
})
}
function showErrorDialog(title: string, text?: string) {
void Swal.fire({
...getSwalThemeOptions(),
icon: 'error',
title,
text,
confirmButtonText: 'Tamam',
})
}
export default function ProfileClient({ initialProfile }: { initialProfile: Profile }) {
const [state, formAction, pending] = useActionState<ProfileFormState, FormData>(updateProfile, {})
const [avatarFilePreviewUrl, setAvatarFilePreviewUrl] = useState<string>('')
useEffect(() => {
if (state.success) showToast('success', 'Profil güncellendi')
}, [state.success])
useEffect(() => {
if (state.error) showToast('error', state.error)
}, [state.error])
const profile = state.profile ?? initialProfile
const avatarSrc = avatarFilePreviewUrl || profile.avatar_url || ''
useEffect(() => {
return () => {
if (avatarFilePreviewUrl.startsWith('blob:')) URL.revokeObjectURL(avatarFilePreviewUrl)
}
}, [avatarFilePreviewUrl])
function onAvatarFileChange(file: File | null) {
if (!file) {
console.log('[profile:avatar] dosya seçimi temizlendi')
setAvatarFilePreviewUrl('')
return
}
console.log('[profile:avatar] dosya seçildi (henüz yüklenmedi)', {
name: file.name,
type: file.type,
size: file.size,
})
if (!file.type.startsWith('image/')) {
showToast('error', 'Lütfen geçerli bir görsel seçin')
return
}
if (file.size > MAX_AVATAR_BYTES) {
showErrorDialog(
'Dosya çok büyük',
`Avatar en fazla 5 MB olabilir (seçilen: ${(file.size / (1024 * 1024)).toFixed(2)} MB).`,
)
return
}
const previewUrl = URL.createObjectURL(file)
setAvatarFilePreviewUrl(previewUrl)
}
return (
<div className="mx-auto w-full max-w-3xl p-6">
<Card>
<CardHeader>
<CardTitle>Profilim</CardTitle>
<CardDescription>Ad, soyad ve avatar bilgilerinizi güncelleyin.</CardDescription>
</CardHeader>
<CardContent>
<form
action={formAction}
encType="multipart/form-data"
className="space-y-4"
onSubmit={(event) => {
const fd = new FormData(event.currentTarget)
const f = fd.get('avatar')
if (f instanceof File && f.size > MAX_AVATAR_BYTES) {
event.preventDefault()
showErrorDialog(
'Dosya çok büyük',
`Avatar en fazla 5 MB olabilir (seçilen: ${(f.size / (1024 * 1024)).toFixed(2)} MB). Sunucu gönderim limiti de 5 MB.`,
)
return
}
const hidden = String(fd.get('avatar_url') ?? '')
console.log('[profile:avatar] form gönderiliyor', {
hasFile: f instanceof File && f.size > 0,
file:
f instanceof File && f.size > 0
? { name: f.name, type: f.type, size: f.size }
: null,
avatar_url_hidden_preview: hidden.slice(0, 120),
})
}}
>
<div className="space-y-3 rounded-lg border p-4">
<Label htmlFor="avatar">Avatar</Label>
<div className="flex flex-col gap-3 md:flex-row md:items-center">
<Avatar data-size="lg" className="size-16">
<AvatarImage src={avatarSrc || undefined} alt={`${profile.first_name} ${profile.last_name}`} />
<AvatarFallback>
{(profile.first_name?.[0] ?? 'U') + (profile.last_name?.[0] ?? '')}
</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-2">
<Input
id="avatar"
name="avatar"
type="file"
accept="image/*"
onChange={(event) => void onAvatarFileChange(event.target.files?.[0] ?? null)}
/>
<p className="text-xs text-muted-foreground">
Görsel, form submit edildiğinde sunucuya yüklenir ve profile kaydedilir.
</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="first_name">Ad</Label>
<Input id="first_name" name="first_name" defaultValue={profile.first_name ?? ''} required />
</div>
<div className="space-y-1.5">
<Label htmlFor="last_name">Soyad</Label>
<Input id="last_name" name="last_name" defaultValue={profile.last_name ?? ''} required />
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="avatar_manual">Avatar URL (Opsiyonel)</Label>
<Input
id="avatar_manual"
name="avatar_url"
key={profile.avatar_url ?? ''}
type="url"
defaultValue={profile.avatar_url ?? ''}
placeholder="https://..."
/>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={pending}>
{pending ? 'Kaydediliyor…' : 'Profili Kaydet'}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,195 @@
'use server'
import path from 'node:path'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { getAccessToken, refreshAccessToken } from '@/app/auth/actions'
const API_BASE = process.env.API_BASE_URL ?? 'http://localhost:8080'
type ApiError = Record<string, unknown>
export type Profile = {
user_id: number
first_name: string
last_name: string
avatar_url: string
}
export type ProfileFormState = {
success?: boolean
error?: string
profile?: Profile
}
async function getToken(): Promise<string> {
const token = await getAccessToken()
if (token) return token
const fresh = await refreshAccessToken()
if (fresh) return fresh
redirect('/auth/login?from=/admin/profile')
}
function extractError(data: ApiError, fallback: string): string {
if (typeof data?.error === 'string' && data.error) return data.error
if (typeof data?.message === 'string' && data.message) return data.message
if (typeof data?.detail === 'string' && data.detail) return data.detail
return fallback
}
/** API'den gelen göreli yolu (/uploads/...) img ve multipart `avatar_url` için tam adrese çevirir. */
function absoluteProfileAvatarUrl(avatar_url: string): string {
const v = avatar_url.trim()
if (!v) return ''
if (/^https?:\/\//i.test(v)) return v
const pathPart = v.startsWith('/') ? v : `/${v}`
const base = API_BASE.replace(/\/$/, '')
return `${base}${pathPart}`
}
function extensionFromFilename(name: string): string | null {
const ext = path.extname(name).replace('.', '').toLowerCase()
if (['jpg', 'jpeg', 'png', 'webp', 'gif'].includes(ext)) return ext === 'jpeg' ? 'jpg' : ext
return null
}
function extensionFromMime(mimeType: string): string | null {
if (mimeType === 'image/jpeg' || mimeType === 'image/jpg') return 'jpg'
if (mimeType === 'image/png') return 'png'
if (mimeType === 'image/webp') return 'webp'
if (mimeType === 'image/gif') return 'gif'
return null
}
function validateAvatarFile(file: File): void {
const looksLikeImage =
file.type.startsWith('image/') ||
file.type === '' ||
file.type === 'application/octet-stream'
if (!looksLikeImage) {
throw new Error('Lütfen geçerli bir görsel seçin')
}
if (file.size > 5 * 1024 * 1024) {
throw new Error('Avatar en fazla 5MB olabilir')
}
const ext =
extensionFromMime(file.type) ?? extensionFromFilename(file.name)
if (!ext) {
throw new Error('Desteklenmeyen dosya türü (jpg, png, webp, gif)')
}
}
export async function getProfile(): Promise<Profile> {
let token = await getToken()
const url = `${API_BASE}/api/v1/me/profile`
const doFetch = () =>
fetch(url, {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
})
let res: Response
try {
res = await doFetch()
} catch (error) {
console.error('[getProfile] fetch failed', error)
throw new Error('API sunucusuna bağlanılamadı. Backend çalışıyor mu kontrol edin.')
}
if (res.status === 401) {
const fresh = await refreshAccessToken()
if (!fresh) throw new Error('Oturum süresi doldu')
token = fresh
try {
res = await doFetch()
} catch (error) {
console.error('[getProfile] fetch failed after refresh', error)
throw new Error('API sunucusuna bağlanılamadı. Backend çalışıyor mu kontrol edin.')
}
}
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as ApiError
throw new Error(extractError(data, 'Profil bilgisi alınamadı'))
}
const raw = (await res.json()) as Profile
return {
...raw,
avatar_url: absoluteProfileAvatarUrl(raw.avatar_url ?? ''),
}
}
export async function updateProfile(
_prev: ProfileFormState,
formData: FormData
): Promise<ProfileFormState> {
try {
const token = await getToken()
const avatarFile = formData.get('avatar')
const avatarUrl = String(formData.get('avatar_url') ?? '')
const first_name = String(formData.get('first_name') ?? '')
const last_name = String(formData.get('last_name') ?? '')
const hasNewFile = avatarFile instanceof File && avatarFile.size > 0
if (hasNewFile && avatarFile instanceof File) {
validateAvatarFile(avatarFile)
}
const outbound = new FormData()
outbound.append('first_name', first_name)
outbound.append('last_name', last_name)
if (hasNewFile && avatarFile instanceof File) {
const filename = avatarFile.name?.trim() || 'avatar.jpg'
outbound.append('avatar', avatarFile, filename)
} else {
const backendAvatarUrl = absoluteProfileAvatarUrl(avatarUrl.trim())
if (backendAvatarUrl) outbound.append('avatar_url', backendAvatarUrl)
}
console.log('[profile:avatar] PUT /api/v1/me/profile (multipart)', {
API_BASE,
hasFile: hasNewFile,
has_avatar_url_field: !hasNewFile && Boolean(absoluteProfileAvatarUrl(avatarUrl.trim())),
first_name,
last_name,
})
const res = await fetch(`${API_BASE}/api/v1/me/profile`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`,
},
body: outbound,
})
console.log('[profile:avatar] backend yanıt', {
status: res.status,
ok: res.ok,
})
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as ApiError
console.log('[profile:avatar] backend hata gövdesi', data)
return { error: extractError(data, 'Profil güncellenemedi') }
}
const raw = (await res.json()) as Profile
const profile: Profile = {
...raw,
avatar_url: absoluteProfileAvatarUrl(raw.avatar_url ?? ''),
}
console.log('[profile:avatar] başarı', {
avatar_url: profile.avatar_url,
})
revalidatePath('/admin/profile')
return { success: true, profile }
} catch (error) {
console.error('[updateProfile]', error)
return { error: 'Sunucu hatası' }
}
}

View File

@@ -0,0 +1,7 @@
import ProfileClient from './ProfileClient'
import { getProfile } from './actions'
export default async function ProfilePage() {
const profile = await getProfile()
return <ProfileClient initialProfile={profile} />
}

View File

@@ -0,0 +1,389 @@
'use client'
import { useActionState, useCallback, useEffect, useState, useTransition } from 'react'
import Swal, { type SweetAlertIcon } from 'sweetalert2'
import {
createUser,
updateUser,
deleteUser,
changeUserStatus,
type User,
type UserFormState,
type UsersResponse,
} from './actions'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Loader2, Plus, Pencil, Trash2, AlertTriangle } from 'lucide-react'
type Props = { initialData: UsersResponse }
type ModalMode = 'create' | 'edit' | null
function getSwalThemeOptions() {
const isDark = document.documentElement.classList.contains('dark')
return isDark
? { background: '#111827', color: '#f3f4f6' }
: { background: '#ffffff', color: '#111827' }
}
function showToast(icon: SweetAlertIcon, title: string) {
void Swal.fire({
...getSwalThemeOptions(),
toast: true,
position: 'top-end',
showConfirmButton: false,
timer: 2200,
timerProgressBar: true,
icon,
title,
})
}
/* ─── Field ─────────────────────────────────────────── */
function Field({ id, label, children }: { id?: string; label: string; children: React.ReactNode }) {
return (
<div className="space-y-1.5">
<Label htmlFor={id}>{label}</Label>
{children}
</div>
)
}
/* ─── UserModal ─────────────────────────────────────── */
function UserModal({
open,
mode,
user,
onSaved,
onClose,
}: {
open: boolean
mode: 'create' | 'edit'
user?: User
onSaved: (savedUser: User, mode: 'create' | 'edit') => void
onClose: () => void
}) {
const isEdit = mode === 'edit' && user != null
const boundAction = isEdit ? updateUser.bind(null, user.id) : createUser
const [state, formAction, pending] = useActionState<UserFormState, FormData>(boundAction, {})
useEffect(() => {
if (!state.success) return
if (state.user) onSaved(state.user, mode)
showToast('success', mode === 'edit' ? 'Kullanıcı güncellendi' : 'Kullanıcı oluşturuldu')
onClose()
}, [state.success, state.user, mode, onSaved, onClose])
useEffect(() => {
if (!state.error) return
showToast('error', state.error)
}, [state.error])
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{isEdit ? 'Kullanıcıyı Düzenle' : 'Yeni Kullanıcı Ekle'}</DialogTitle>
<DialogDescription>
{isEdit ? 'Kullanıcı bilgilerini güncelleyin.' : 'Yeni bir kullanıcı hesabı oluşturun.'}
</DialogDescription>
</DialogHeader>
<form action={formAction} className="space-y-4">
{state.error && (
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-sm text-destructive">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
{state.error}
</div>
)}
<Field id="username" label="Kullanıcı Adı">
<Input id="username" name="username" defaultValue={user?.username} required />
</Field>
<Field id="email" label="E-posta">
<Input id="email" name="email" type="email" defaultValue={user?.email} required />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field id="password" label="Şifre">
<Input
id="password"
name="password"
type="password"
required={!isEdit}
placeholder={isEdit ? '••••••' : ''}
/>
</Field>
<Field id="confirm_password" label="Şifre Tekrar">
<Input
id="confirm_password"
name="confirm_password"
type="password"
required={!isEdit}
placeholder={isEdit ? '••••••' : ''}
/>
</Field>
</div>
<div className="flex gap-6">
<div className="flex items-center gap-2">
<Checkbox
id="is_active"
name="is_active"
value="true"
defaultChecked={user?.is_active ?? true}
/>
<Label htmlFor="is_active" className="cursor-pointer font-normal">Aktif</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="is_admin"
name="is_admin"
value="true"
defaultChecked={user?.is_admin ?? false}
/>
<Label htmlFor="is_admin" className="cursor-pointer font-normal">Admin</Label>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>İptal</Button>
<Button type="submit" disabled={pending}>
{pending && <Loader2 className="size-4 animate-spin" />}
{pending ? 'Kaydediliyor…' : 'Kaydet'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
/* ─── UsersClient ────────────────────────────────────── */
export default function UsersClient({ initialData }: Props) {
const [data, setData] = useState<UsersResponse>(initialData)
const [modal, setModal] = useState<ModalMode>(null)
const [selectedUser, setSelectedUser] = useState<User | undefined>()
const [, startTransition] = useTransition()
const openCreate = useCallback(() => {
setSelectedUser(undefined)
setModal('create')
}, [])
const openEdit = useCallback((user: User) => {
setSelectedUser(user)
setModal('edit')
}, [])
const closeModal = useCallback(() => {
setModal(null)
setSelectedUser(undefined)
}, [])
const handleUserSaved = useCallback((savedUser: User, mode: 'create' | 'edit') => {
setData((prev) => {
const existingIndex = prev.items.findIndex((u) => u.id === savedUser.id)
if (mode === 'edit' || existingIndex >= 0) {
return {
...prev,
items: prev.items.map((u) => (u.id === savedUser.id ? savedUser : u)),
}
}
return {
...prev,
items: [savedUser, ...prev.items],
meta: { ...prev.meta, total: prev.meta.total + 1 },
}
})
}, [])
const handleUserDeleted = useCallback((id: number) => {
setData((prev) => {
const nextItems = prev.items.filter((u) => u.id !== id)
const removed = nextItems.length !== prev.items.length
return {
...prev,
items: nextItems,
meta: {
...prev.meta,
total: removed ? Math.max(0, prev.meta.total - 1) : prev.meta.total,
},
}
})
}, [])
const handleDelete = useCallback(async (user: User) => {
const result = await Swal.fire({
...getSwalThemeOptions(),
icon: 'warning',
title: 'Kullanıcı silinsin mi?',
text: `${user.username} kalıcı olarak silinecek.`,
showCancelButton: true,
confirmButtonText: 'Evet, sil',
cancelButtonText: 'İptal',
confirmButtonColor: '#dc2626',
reverseButtons: true,
focusCancel: true,
})
if (!result.isConfirmed) return
const formData = new FormData()
formData.set('id', String(user.id))
const deleted = await deleteUser({}, formData)
if (!deleted.success) {
showToast('error', deleted.error ?? 'Kullanıcı silinemedi')
return
}
handleUserDeleted(deleted.deletedId ?? user.id)
showToast('success', 'Kullanıcı silindi')
}, [handleUserDeleted])
async function handleStatusToggle(user: User) {
const nextStatus = !user.is_active
const statusLabel = nextStatus ? 'aktif' : 'pasif'
const decision = await Swal.fire({
...getSwalThemeOptions(),
icon: 'question',
title: 'Durum değiştirilsin mi?',
text: `${user.username} kullanıcısı ${statusLabel} yapılacak.`,
showCancelButton: true,
confirmButtonText: 'Evet',
cancelButtonText: 'İptal',
reverseButtons: true,
focusCancel: true,
})
if (!decision.isConfirmed) return
startTransition(async () => {
const result = await changeUserStatus(user.id, nextStatus)
if (!result.success || !result.user) {
showToast('error', result.error ?? 'Durum güncellenemedi')
return
}
setData((prev) => ({
...prev,
items: prev.items.map((u) => (u.id === result.user!.id ? result.user! : u)),
}))
showToast('success', `Kullanıcı ${statusLabel} yapıldı`)
})
}
return (
<div className="p-6">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold">Kullanıcılar</h1>
<p className="text-sm text-muted-foreground">
Toplam <strong className="text-foreground">{data.meta.total}</strong> kullanıcı
</p>
</div>
<Button onClick={openCreate}>
<Plus className="size-4" />
Yeni Kullanıcı
</Button>
</div>
{/* Table */}
<div className="rounded-xl border overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">ID</TableHead>
<TableHead>Kullanıcı Adı</TableHead>
<TableHead>E-posta</TableHead>
<TableHead>Durum</TableHead>
<TableHead>Rol</TableHead>
<TableHead>Kayıt Tarihi</TableHead>
<TableHead className="text-right">İşlemler</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-mono text-xs text-muted-foreground">{user.id}</TableCell>
<TableCell className="font-medium">{user.username}</TableCell>
<TableCell className="text-muted-foreground">{user.email}</TableCell>
<TableCell>
<button onClick={() => void handleStatusToggle(user)} className="transition hover:opacity-75">
{user.is_active
? <Badge variant="outline" className="border-green-500/40 bg-green-500/10 text-green-600 dark:text-green-400"> Aktif</Badge>
: <Badge variant="outline" className="border-red-500/40 bg-red-500/10 text-red-600 dark:text-red-400"> Pasif</Badge>
}
</button>
</TableCell>
<TableCell>
{user.is_admin
? <Badge variant="outline" className="border-violet-500/40 bg-violet-500/10 text-violet-600 dark:text-violet-400">Admin</Badge>
: <Badge variant="secondary">Kullanıcı</Badge>
}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{new Date(user.created_at).toLocaleDateString('tr-TR')}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon-sm" onClick={() => openEdit(user)} aria-label="Düzenle">
<Pencil className="size-3.5" />
</Button>
<Button variant="ghost" size="icon-sm" onClick={() => void handleDelete(user)} aria-label="Sil" className="text-destructive hover:text-destructive">
<Trash2 className="size-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{data.items.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="h-32 text-center text-muted-foreground">
Henüz kullanıcı yok
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{data.meta.total > data.meta.limit && (
<p className="mt-4 text-sm text-muted-foreground">
Sayfa {data.meta.page} / {Math.ceil(data.meta.total / data.meta.limit)}
</p>
)}
{/* Modals */}
<UserModal
open={modal === 'create' || modal === 'edit'}
mode={modal === 'edit' ? 'edit' : 'create'}
user={selectedUser}
onSaved={handleUserSaved}
onClose={closeModal}
/>
</div>
)
}

224
app/admin/users/actions.ts Normal file
View File

@@ -0,0 +1,224 @@
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { getAccessToken, refreshAccessToken } from '@/app/auth/actions'
const API_BASE = process.env.API_BASE_URL ?? 'http://localhost:8080'
async function getToken(): Promise<string> {
const token = await getAccessToken()
if (token) return token
const fresh = await refreshAccessToken()
if (fresh) return fresh
redirect('/auth/login?from=/admin/users')
}
/** API'nin döndürdüğü hata yanıtından okunabilir bir mesaj çıkarır. */
function extractError(data: Record<string, unknown>, fallback: string): string {
if (typeof data?.error === 'string' && data.error) return data.error
if (typeof data?.message === 'string' && data.message) return data.message
if (typeof data?.detail === 'string' && data.detail) return data.detail
if (data?.errors && typeof data.errors === 'object') {
const msgs = Object.values(data.errors as Record<string, string>).filter(Boolean)
if (msgs.length > 0) return msgs.join(', ')
}
console.error('[API Error]', JSON.stringify(data))
return fallback
}
export type User = {
id: number
username: string
email: string
email_verified: boolean
is_active: boolean
is_admin: boolean
created_at: string
updated_at: string
}
export type UsersResponse = {
items: User[]
meta: { page: number; limit: number; total: number }
}
export type UserFormState = {
error?: string
success?: boolean
user?: User
deletedId?: number
}
export async function getUsers(page = 1, limit = 10): Promise<UsersResponse> {
let token = await getToken()
const url = `${API_BASE}/api/v1/admin/users?page=${page}&limit=${limit}`
const doFetch = () =>
fetch(url, { headers: { Authorization: `Bearer ${token}` }, cache: 'no-store' })
let res: Response
try {
res = await doFetch()
} catch (error) {
console.error('[getUsers] fetch failed', error)
throw new Error('API sunucusuna bağlanılamadı. Backend çalışıyor mu kontrol edin.')
}
// Token süresi dolmuşsa yenile ve bir kez daha dene
if (res.status === 401) {
const fresh = await refreshAccessToken()
if (!fresh) throw new Error('Oturum süresi doldu')
token = fresh
try {
res = await doFetch()
} catch (error) {
console.error('[getUsers] fetch failed after refresh', error)
throw new Error('API sunucusuna bağlanılamadı. Backend çalışıyor mu kontrol edin.')
}
}
// Rate limit aşıldıysa kısa aralıkla sınırlı sayıda tekrar dene
if (res.status === 429) {
for (const waitMs of [500, 1000, 1500]) {
await new Promise((r) => setTimeout(r, waitMs))
res = await doFetch()
if (res.ok) break
if (res.status !== 429) break
}
}
if (!res.ok) {
console.error(`[getUsers] API error: ${res.status} ${res.statusText}`)
if (res.status === 429) {
throw new Error('Çok fazla istek gönderildi, lütfen birkaç saniye sonra tekrar deneyin (429)')
}
throw new Error(`Kullanıcılar alınamadı (${res.status})`)
}
return res.json()
}
export async function createUser(
_prev: UserFormState,
formData: FormData
): Promise<UserFormState> {
'use server'
try {
const token = await getToken()
const body = {
username: formData.get('username') as string,
email: formData.get('email') as string,
password: formData.get('password') as string,
confirm_password: formData.get('confirm_password') as string,
is_active: formData.get('is_active') === 'true',
is_admin: formData.get('is_admin') === 'true',
}
const res = await fetch(`${API_BASE}/api/v1/admin/users`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
return { error: extractError(data, 'Kullanıcı oluşturulamadı') }
}
revalidatePath('/admin/users')
const created = (await res.json()) as User
return { success: true, user: created }
} catch (e) {
console.error('[createUser]', e)
return { error: 'Sunucu hatası' }
}
}
export async function updateUser(
id: number,
_prev: UserFormState,
formData: FormData
): Promise<UserFormState> {
'use server'
try {
const token = await getToken()
const body = {
username: formData.get('username') as string,
email: formData.get('email') as string,
password: formData.get('password') as string,
confirm_password: formData.get('confirm_password') as string,
is_active: formData.get('is_active') === 'true',
is_admin: formData.get('is_admin') === 'true',
}
const res = await fetch(`${API_BASE}/api/v1/admin/users/${id}`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
return { error: extractError(data, 'Kullanıcı güncellenemedi') }
}
revalidatePath('/admin/users')
const updated = (await res.json()) as User
return { success: true, user: updated }
} catch (e) {
console.error('[updateUser]', e)
return { error: 'Sunucu hatası' }
}
}
export async function deleteUser(
_prev: UserFormState,
formData: FormData
): Promise<UserFormState> {
'use server'
try {
const token = await getToken()
const id = formData.get('id') as string
const res = await fetch(`${API_BASE}/api/v1/admin/users/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
return { error: extractError(data, 'Kullanıcı silinemedi') }
}
revalidatePath('/admin/users')
return { success: true, deletedId: Number(id) }
} catch (e) {
console.error('[deleteUser]', e)
return { error: 'Sunucu hatası' }
}
}
export async function changeUserStatus(
id: number,
isActive: boolean
): Promise<UserFormState> {
'use server'
try {
const token = await getToken()
const res = await fetch(`${API_BASE}/api/v1/admin/users/${id}/status`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ is_active: isActive }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
return { error: extractError(data, 'Durum güncellenemedi') }
}
revalidatePath('/admin/users')
const updated = (await res.json()) as User
return { success: true, user: updated }
} catch (e) {
console.error('[changeUserStatus]', e)
return { error: 'Sunucu hatası' }
}
}

32
app/admin/users/error.tsx Normal file
View File

@@ -0,0 +1,32 @@
'use client'
import { useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { AlertTriangle } from 'lucide-react'
export default function UsersError({
error,
unstable_retry,
}: {
error: Error & { digest?: string }
unstable_retry: () => void
}) {
useEffect(() => {
console.error('[UsersPage Error]', error)
}, [error])
return (
<div className="flex h-64 flex-col items-center justify-center gap-4 p-6 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
<AlertTriangle className="size-6 text-destructive" />
</div>
<div>
<h2 className="font-semibold">Kullanıcılar yüklenemedi</h2>
<p className="mt-1 text-sm text-muted-foreground">{error.message}</p>
</div>
<Button variant="outline" onClick={unstable_retry}>
Yeniden Dene
</Button>
</div>
)
}

7
app/admin/users/page.tsx Normal file
View File

@@ -0,0 +1,7 @@
import { getUsers } from './actions'
import UsersClient from './UsersClient'
export default async function UsersPage() {
const data = await getUsers(1, 10)
return <UsersClient initialData={data} />
}