first commit
This commit is contained in:
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} />
|
||||
}
|
||||
Reference in New Issue
Block a user