first commit
This commit is contained in:
96
app/admin/layout.tsx
Normal file
96
app/admin/layout.tsx
Normal 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
12
app/admin/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
182
app/admin/profile/ProfileClient.tsx
Normal file
182
app/admin/profile/ProfileClient.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
195
app/admin/profile/actions.ts
Normal file
195
app/admin/profile/actions.ts
Normal 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ı' }
|
||||
}
|
||||
}
|
||||
7
app/admin/profile/page.tsx
Normal file
7
app/admin/profile/page.tsx
Normal 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} />
|
||||
}
|
||||
389
app/admin/users/UsersClient.tsx
Normal file
389
app/admin/users/UsersClient.tsx
Normal 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
224
app/admin/users/actions.ts
Normal 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
32
app/admin/users/error.tsx
Normal 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
7
app/admin/users/page.tsx
Normal 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} />
|
||||
}
|
||||
6
app/api/auth/[...nextauth]/route.ts
Normal file
6
app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import NextAuth from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
|
||||
const handler = NextAuth(authOptions)
|
||||
|
||||
export { handler as GET, handler as POST }
|
||||
95
app/auth/actions.ts
Normal file
95
app/auth/actions.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
'use server'
|
||||
|
||||
import { cookies, headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getToken } from 'next-auth/jwt'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import {
|
||||
applySessionCookie,
|
||||
encodeSessionJwt,
|
||||
fetchRefreshedBackendJwt,
|
||||
shouldRefreshBackendToken,
|
||||
} from '@/lib/backend-jwt-refresh'
|
||||
|
||||
const API_BASE = process.env.API_BASE_URL ?? 'http://localhost:8080'
|
||||
|
||||
export type AuthFormState = {
|
||||
error?: string
|
||||
success?: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
export async function register(
|
||||
_prev: AuthFormState,
|
||||
formData: FormData
|
||||
): Promise<AuthFormState> {
|
||||
const body = {
|
||||
email: formData.get('email') as string,
|
||||
username: formData.get('username') as string,
|
||||
first_name: formData.get('first_name') as string,
|
||||
last_name: formData.get('last_name') as string,
|
||||
password: formData.get('password') as string,
|
||||
confirm_password: formData.get('confirm_password') as string,
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/v1/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
return { error: data?.error ?? 'Kayıt başarısız' }
|
||||
}
|
||||
|
||||
return { success: true, message: 'Kayıt başarılı. Lütfen giriş yapın.' }
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
redirect('/api/auth/signout?callbackUrl=/auth/login')
|
||||
}
|
||||
|
||||
async function getJwtFromRequest() {
|
||||
const cookieStore = await cookies()
|
||||
const headersList = await headers()
|
||||
const secret = process.env.NEXTAUTH_SECRET ?? process.env.AUTH_SECRET
|
||||
const cookieMap = Object.fromEntries(cookieStore.getAll().map((c) => [c.name, c.value]))
|
||||
return getToken({
|
||||
req: {
|
||||
headers: headersList,
|
||||
cookies: cookieMap,
|
||||
} as unknown as Parameters<typeof getToken>[0]['req'],
|
||||
secret,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend access token’ı yeniler ve NextAuth session çerezini günceller.
|
||||
* Sadece credentials (backend refresh) oturumunda anlamlıdır.
|
||||
*/
|
||||
export async function refreshAccessToken(): Promise<string | null> {
|
||||
const token = await getJwtFromRequest()
|
||||
if (!token?.refreshToken) return null
|
||||
|
||||
if (!shouldRefreshBackendToken(token)) {
|
||||
return typeof token.accessToken === 'string' ? token.accessToken : null
|
||||
}
|
||||
|
||||
const next = await fetchRefreshedBackendJwt(token)
|
||||
if (!next?.accessToken) return null
|
||||
|
||||
const jwt = await encodeSessionJwt(next)
|
||||
const cookieStore = await cookies()
|
||||
applySessionCookie(cookieStore, jwt)
|
||||
|
||||
return next.accessToken as string
|
||||
}
|
||||
|
||||
export async function getAccessToken(): Promise<string | null> {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (session?.error === 'RefreshAccessTokenError') return null
|
||||
if (!session?.accessToken) return null
|
||||
return session.accessToken
|
||||
}
|
||||
115
app/auth/login/page.tsx
Normal file
115
app/auth/login/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { signIn } from 'next-auth/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { AlertTriangle, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function LoginPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pending, setPending] = useState(false)
|
||||
const [providerPending, setProviderPending] = useState<null | 'google' | 'github'>(null)
|
||||
|
||||
async function onSubmit(formData: FormData) {
|
||||
setError(null)
|
||||
setPending(true)
|
||||
|
||||
const email = String(formData.get('email') ?? '')
|
||||
const password = String(formData.get('password') ?? '')
|
||||
|
||||
const result = await signIn('credentials', {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
if (!result || result.error) {
|
||||
setError('Giriş başarısız')
|
||||
setPending(false)
|
||||
return
|
||||
}
|
||||
|
||||
window.location.href = '/admin/users'
|
||||
}
|
||||
|
||||
async function onProviderLogin(provider: 'google' | 'github') {
|
||||
setError(null)
|
||||
setProviderPending(provider)
|
||||
await signIn(provider, { callbackUrl: '/admin/users' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center px-4 py-12">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Hoş Geldiniz</CardTitle>
|
||||
<CardDescription>Hesabınıza giriş yapın</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="mb-4 grid grid-cols-1 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={providerPending !== null}
|
||||
onClick={() => void onProviderLogin('google')}
|
||||
>
|
||||
{providerPending === 'google' && <Loader2 className="size-4 animate-spin" />}
|
||||
Google ile giris yap
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={providerPending !== null}
|
||||
onClick={() => void onProviderLogin('github')}
|
||||
>
|
||||
{providerPending === 'github' && <Loader2 className="size-4 animate-spin" />}
|
||||
GitHub ile giris yap
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="text-xs text-muted-foreground">veya e-posta ile devam et</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
|
||||
<form action={onSubmit} className="space-y-4">
|
||||
{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" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">E-posta</Label>
|
||||
<Input id="email" name="email" type="email" required autoComplete="email" placeholder="ornek@mail.com" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="password">Şifre</Label>
|
||||
<Input id="password" name="password" type="password" required autoComplete="current-password" placeholder="••••••••" />
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={pending} className="w-full">
|
||||
{pending && <Loader2 className="size-4 animate-spin" />}
|
||||
{pending ? 'Giriş yapılıyor…' : 'Giriş Yap'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="justify-center text-sm text-muted-foreground">
|
||||
Hesabınız yok mu?
|
||||
<Link href="/auth/register" className="font-medium text-primary underline-offset-4 hover:underline">
|
||||
Kayıt Ol
|
||||
</Link>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
87
app/auth/register/page.tsx
Normal file
87
app/auth/register/page.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { register, type AuthFormState } from '../actions'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { AlertTriangle, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [state, formAction, pending] = useActionState<AuthFormState, FormData>(
|
||||
register,
|
||||
{}
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center px-4 py-12">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Hesap Oluştur</CardTitle>
|
||||
<CardDescription>Bilgilerinizi girerek kayıt olun</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<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>
|
||||
)}
|
||||
{state.success && (
|
||||
<div className="rounded-lg border border-green-500/30 bg-green-500/10 px-3 py-2.5 text-sm text-green-700 dark:text-green-400">
|
||||
{state.message ?? 'Kayıt başarılı. Lütfen giriş yapın.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="first_name">Ad</Label>
|
||||
<Input id="first_name" name="first_name" type="text" required />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="last_name">Soyad</Label>
|
||||
<Input id="last_name" name="last_name" type="text" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="username">Kullanıcı Adı</Label>
|
||||
<Input id="username" name="username" type="text" required autoComplete="username" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">E-posta</Label>
|
||||
<Input id="email" name="email" type="email" required autoComplete="email" placeholder="ornek@mail.com" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="password">Şifre</Label>
|
||||
<Input id="password" name="password" type="password" required autoComplete="new-password" placeholder="••••••••" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="confirm_password">Şifre Tekrar</Label>
|
||||
<Input id="confirm_password" name="confirm_password" type="password" required autoComplete="new-password" placeholder="••••••••" />
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={pending} className="w-full">
|
||||
{pending && <Loader2 className="size-4 animate-spin" />}
|
||||
{pending ? 'Kayıt yapılıyor…' : 'Kayıt Ol'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="justify-center text-sm text-muted-foreground">
|
||||
Zaten hesabınız var mı?
|
||||
<Link href="/auth/login" className="font-medium text-primary underline-offset-4 hover:underline">
|
||||
Giriş Yap
|
||||
</Link>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
51
app/components/ThemeToggle.tsx
Normal file
51
app/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useSyncExternalStore } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Moon, Sun } from 'lucide-react'
|
||||
|
||||
type Theme = 'light' | 'dark'
|
||||
|
||||
function getSnapshot(): Theme {
|
||||
const saved = localStorage.getItem('theme')
|
||||
if (saved === 'dark' || saved === 'light') return saved
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
function getServerSnapshot(): Theme {
|
||||
return 'light'
|
||||
}
|
||||
|
||||
function subscribe(cb: () => void): () => void {
|
||||
window.addEventListener('storage', cb)
|
||||
return () => window.removeEventListener('storage', cb)
|
||||
}
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark')
|
||||
}, [theme])
|
||||
|
||||
function toggleTheme() {
|
||||
const next: Theme = theme === 'dark' ? 'light' : 'dark'
|
||||
localStorage.setItem('theme', next)
|
||||
window.dispatchEvent(new StorageEvent('storage', { key: 'theme', newValue: next }))
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleTheme}
|
||||
suppressHydrationWarning
|
||||
aria-label="Tema değiştir"
|
||||
>
|
||||
{theme === 'dark'
|
||||
? <Sun className="size-4" />
|
||||
: <Moon className="size-4" />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
59
app/components/TopBar.tsx
Normal file
59
app/components/TopBar.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import ThemeToggle from './ThemeToggle'
|
||||
import { signOut } from 'next-auth/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { LogOut, Zap } from 'lucide-react'
|
||||
|
||||
type Props = {
|
||||
isLoggedIn: boolean
|
||||
}
|
||||
|
||||
export default function TopBar({ isLoggedIn }: Props) {
|
||||
async function onLogout() {
|
||||
await signOut({ callbackUrl: '/auth/login' })
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b bg-background/80 backdrop-blur-sm">
|
||||
<div className="mx-auto flex w-full max-w-7xl items-center justify-between px-4 py-2.5">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<Zap className="size-4" />
|
||||
</div>
|
||||
<span className="text-base font-bold tracking-tight">NextGo</span>
|
||||
</Link>
|
||||
|
||||
{/* Actions */}
|
||||
<nav className="flex items-center gap-1">
|
||||
<ThemeToggle />
|
||||
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/admin/users">Admin</Link>
|
||||
</Button>
|
||||
|
||||
<Button type="button" onClick={onLogout} variant="destructive" size="sm">
|
||||
<LogOut className="size-4" />
|
||||
Çıkış
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/auth/login">Giriş</Link>
|
||||
</Button>
|
||||
<Button size="sm" asChild>
|
||||
<Link href="/auth/register">Kayıt Ol</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
173
app/globals.css
Normal file
173
app/globals.css
Normal file
@@ -0,0 +1,173 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* ── Theme tokens (light) ───────────────────────────── */
|
||||
:root {
|
||||
color-scheme: light;
|
||||
|
||||
--background: oklch(0.97 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.546 0.245 262.881);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.96 0.005 247);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.96 0.005 247);
|
||||
--muted-foreground: oklch(0.5 0.02 247);
|
||||
--accent: oklch(0.96 0.005 247);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.9 0.005 247);
|
||||
--input: oklch(0.9 0.005 247);
|
||||
--ring: oklch(0.546 0.245 262.881);
|
||||
--radius: 0.625rem;
|
||||
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.546 0.245 262.881);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.96 0.005 247);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.9 0.005 247);
|
||||
--sidebar-ring: oklch(0.546 0.245 262.881);
|
||||
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
}
|
||||
|
||||
/* ── Theme tokens (dark) ────────────────────────────── */
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.623 0.214 259.815);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.623 0.214 259.815);
|
||||
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.623 0.214 259.815);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.623 0.214 259.815);
|
||||
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Form controls (dark-mode safe) ───────────────────── */
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
color: var(--foreground);
|
||||
background-color: var(--card);
|
||||
caret-color: var(--foreground);
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--muted-foreground);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
textarea:-webkit-autofill,
|
||||
select:-webkit-autofill {
|
||||
-webkit-text-fill-color: var(--foreground);
|
||||
box-shadow: 0 0 0 1000px var(--card) inset;
|
||||
transition: background-color 9999s ease-in-out 0s;
|
||||
}
|
||||
|
||||
/* ── Scrollbar ─────────────────────────────────────────── */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--muted-foreground); }
|
||||
46
app/layout.tsx
Normal file
46
app/layout.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono, Inter } from "next/font/google";
|
||||
import TopBar from "./components/TopBar";
|
||||
import "./globals.css";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
|
||||
const inter = Inter({subsets:['latin'],variable:'--font-sans'});
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const session = await getServerSession(authOptions);
|
||||
const isLoggedIn = !!session;
|
||||
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
suppressHydrationWarning
|
||||
className={cn("h-full", "antialiased", geistSans.variable, geistMono.variable, "font-sans", inter.variable)}
|
||||
>
|
||||
<body className="min-h-full flex flex-col bg-background text-foreground transition-colors">
|
||||
<TopBar isLoggedIn={isLoggedIn} />
|
||||
<main className="flex flex-1 flex-col">{children}</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
65
app/page.tsx
Normal file
65
app/page.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import Image from "next/image";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user