Files
Beyhan Oğur 5285a0dd86 first commit
2026-04-26 22:07:47 +03:00

457 lines
17 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div>
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Kullanıcı Yönetimi</h2>
</div>
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center bg-white">
<h5 class="mb-0">Kullanıcı Listesi</h5>
<div class="d-flex align-items-center">
<div class="form-check form-switch me-3 mb-0">
<input class="form-check-input" type="checkbox" v-model="showDeleted" id="userDeleted">
<label class="form-check-label" for="userDeleted">Silinenler</label>
</div>
<button class="btn btn-success btn-sm" @click="openModal(null)"><i class="fas fa-plus me-2"></i> Yeni
Kullanıcı</button>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>ID</th>
<th>Avatar</th>
<th>Kullanıcı Adı</th>
<th>Email</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.ID">
<td>{{ user.ID }}</td>
<td>
<img :src="getAvatarUrl(user)" alt="Avatar" class="rounded-circle border" width="40" height="40"
style="object-fit: cover;"
onerror="this.src='https://ui-avatars.com/api/?name=User&background=random'">
</td>
<td>{{ user.username }}</td>
<td>
{{ user.email }}
<span v-if="user.email_verified" class="badge bg-success ms-1"><i class="fas fa-check"></i></span>
<span v-else class="badge bg-warning ms-1" title="Email Doğrulanmadı"><i
class="fas fa-exclamation"></i></span>
</td>
<td>
<span v-if="user.profiles && user.profiles.length > 0">
{{ user.profiles[0]?.first_name }} {{ user.profiles[0]?.last_name }}
</span>
<span v-else>-</span>
</td>
<td>
<span class="badge" :class="user.is_admin ? 'bg-primary' : 'bg-secondary'">
{{ user.is_admin ? 'Admin' : 'User' }}
</span>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm" role="group">
<template v-if="!user.DeletedAt">
<button class="btn btn-outline-primary" @click="openModal(user)" title="Düzenle">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-outline-danger" @click="deleteUser(user.ID)" title="Sil">
<i class="fas fa-trash"></i>
</button>
</template>
<template v-else>
<button class="btn btn-outline-success" @click="restoreUser(user.ID)" title="Geri Yükle">
<i class="fas fa-trash-restore"></i>
</button>
<button class="btn btn-outline-danger" @click="deleteUser(user.ID)" title="Kalıcı Sil">
<i class="fas fa-ban"></i>
</button>
</template>
</div>
</td>
</tr>
<tr v-if="users.length === 0">
<td colspan="6" class="text-center py-4">Kayıt bulunamadı.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- MODAL -->
<div class="modal fade" id="userModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ isEditing ? 'Kullanıcı Düzenle' : 'Yeni Kullanıcı Ekle' }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form @submit.prevent="saveUser">
<div class="mb-3">
<label class="form-label">Kullanıcı Adı</label>
<input v-model="formData.username" type="text" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Email</label>
<input v-model="formData.email" type="email" class="form-control" required>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">İsim</label>
<input v-model="formData.first_name" type="text" class="form-control" required>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Soyisim</label>
<input v-model="formData.last_name" type="text" class="form-control" required>
</div>
</div>
<div class="mb-3">
<label class="form-label">Avatar</label>
<div class="d-flex align-items-center mb-2" v-if="formData.avatar_url">
<img :src="config.public.BASE_API_URL + formData.avatar_url" alt="Avatar Preview"
class="rounded-circle me-3"
style="width: 50px; height: 50px; object-fit: cover; border: 1px solid #dee2e6;">
<button type="button" class="btn btn-sm btn-outline-danger"
@click="formData.avatar_url = ''">Kaldır</button>
</div>
<input type="file" class="form-control" @change="handleFileUpload" accept="image/*">
<input v-model="formData.avatar_url" type="text" class="form-control mt-2"
placeholder="veya URL girin (/uploads/...)">
<!-- Image Optimization Settings -->
<div class="mt-3 p-3 border rounded bg-light">
<div class="d-flex justify-content-between align-items-center mb-2">
<small class="fw-bold text-primary"><i class="fas fa-magic me-1"></i> Resim Optimizasyonu</small>
<span class="badge bg-secondary" style="font-size: 0.7em">İsteğe Bağlı</span>
</div>
<div class="row g-2">
<div class="col-6 col-md-3">
<label class="form-label small text-muted">Format</label>
<select v-model="imageSettings.format" class="form-select form-select-sm">
<option value="avif">AVIF</option>
<option value="webp">WEBP</option>
<option value="png">PNG</option>
<option value="jpg">JPG</option>
</select>
</div>
<div class="col-6 col-md-3">
<label class="form-label small text-muted">Kalite (1-100)</label>
<input v-model="imageSettings.quality" type="number" class="form-control form-control-sm" min="1"
max="100">
</div>
<div class="col-6 col-md-3">
<label class="form-label small text-muted">Genişlik (px)</label>
<input v-model="imageSettings.width" type="number" class="form-control form-control-sm">
</div>
<div class="col-6 col-md-3">
<label class="form-label small text-muted">Yükseklik (px)</label>
<input v-model="imageSettings.height" type="number" class="form-control form-control-sm">
</div>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Şifre <span v-if="isEditing" class="text-muted small">(Boş bırakılırsa
değişmez)</span></label>
<input v-model="formData.password" type="password" class="form-control" :required="!isEditing"
minlength="6">
</div>
<div class="row mb-3">
<div class="col-md-6">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" v-model="formData.is_admin" id="isAdminCheck">
<label class="form-check-label" for="isAdminCheck">Admin Yetkisi</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" v-model="formData.email_verified"
id="emailVerifiedCheck">
<label class="form-check-label" for="emailVerifiedCheck">Email Onaylı</label>
</div>
</div>
</div>
<div class="text-end">
<button type="button" class="btn btn-secondary me-2" data-bs-dismiss="modal">İptal</button>
<button type="submit" class="btn btn-primary" :disabled="loading">
<span v-if="loading" class="spinner-border spinner-border-sm me-2"></span>
Kaydet
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { User } from '~~/types/user';
import Swal from 'sweetalert2';
definePageMeta({
layout: 'admin',
middleware: 'admin'
});
const config = useRuntimeConfig();
const { data: authData } = useAuth();
const loading = ref(false);
const users = ref<User[]>([]);
const showDeleted = ref(false);
const formData = ref<any>({});
const isEditing = ref(false);
const avatarFile = ref<File | null>(null);
let userModal: any = null;
// -- API HANDLERS --
const fetchUsers = async () => {
try {
const endpoint = showDeleted.value ? '/api/v1/users/list/deleted' : '/api/v1/users/list';
const res = await $fetch<any>(endpoint, {
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` }
});
users.value = res.users || [];
} catch (error) {
console.error('Fetch error:', error);
users.value = [];
}
};
watch(showDeleted, () => {
fetchUsers();
});
const openModal = (user: User | null = null) => {
if (user) {
isEditing.value = true;
// Flatten the structure for the form
// Check if avatar_url is on user or profile
const avatarUrl = user.avatar_url || user.profiles?.[0]?.avatar_url || '';
formData.value = {
...user,
first_name: user.profiles?.[0]?.first_name || '',
last_name: user.profiles?.[0]?.last_name || '',
avatar_url: avatarUrl,
password: '' // Don't prefill password
};
} else {
isEditing.value = false;
formData.value = { is_admin: false, email_verified: false };
}
const modalEl = document.getElementById('userModal');
if (modalEl) {
// Reset image settings
imageSettings.value = {
format: 'avif',
quality: 80,
width: null,
height: null
};
// @ts-ignore
userModal = new bootstrap.Modal(modalEl);
userModal.show();
}
};
const getAvatarUrl = (user: User) => {
// Priority: avatar_url (from User or Profile) -> UI Avatar
// Check user.avatar_url first (if backend sends it on user object)
// Then check profiles[0].avatar
const avatarPath = user.avatar_url || user.profiles?.[0]?.avatar_url;
if (avatarPath) {
// If it's a full URL (e.g. googlelh3...) return as is
if (avatarPath.startsWith('http')) return avatarPath;
// If local path, prepend base API URL
return config.public.BASE_API_URL + avatarPath;
}
// Default: UI Avatars
const name = (user.profiles?.[0]?.first_name && user.profiles?.[0]?.last_name)
? `${user.profiles[0].first_name} ${user.profiles[0].last_name}`
: user.username;
return `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&color=fff`;
};
const handleFileUpload = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files && target.files[0]) {
avatarFile.value = target.files[0];
}
};
const imageSettings = ref({
format: 'avif',
quality: 80,
width: null as number | null,
height: null as number | null
});
const optimizeImage = async (file: File): Promise<File> => {
const fd = new FormData();
fd.append('file', file);
fd.append('format', imageSettings.value.format);
fd.append('quality', String(imageSettings.value.quality));
if (imageSettings.value.width) fd.append('width', String(imageSettings.value.width));
if (imageSettings.value.height) fd.append('height', String(imageSettings.value.height));
const optimizedBlob = await $fetch<Blob>('/api/optimize', {
method: 'POST',
body: fd,
responseType: 'blob'
});
const ext = imageSettings.value.format === 'jpg' ? 'jpeg' : imageSettings.value.format;
const filename = `avatar-${Date.now()}.${ext === 'jpeg' ? 'jpg' : ext}`;
return new File([optimizedBlob], filename, { type: `image/${ext}` });
};
const saveUser = async () => {
loading.value = true;
try {
if (isEditing.value) {
// Context: PUT /api/v1/users/:id
// Using Multipart/Form-Data for strict compliance with backend requirements
const userId = formData.value.ID;
const fd = new FormData();
// Append Avatar if selected
if (avatarFile.value) {
try {
const optimized = await optimizeImage(avatarFile.value);
fd.append('avatar', optimized);
} catch (e) {
console.error(e);
fd.append('avatar', avatarFile.value);
}
}
// Append other fields
// Explicitly mapping known fields to ensure order and presence matches expectation,
// or just iterating keys. Iterating keys is safer for "all fields".
// However, we must ensure boolean -> string conversion.
Object.keys(formData.value).forEach(key => {
const val = formData.value[key];
// Filter out non-form fields and sensitive/system fields
if (key !== 'ID' && key !== 'profiles' && key !== 'avatar_url' && key !== 'CreatedAt' && key !== 'UpdatedAt' && key !== 'DeletedAt') {
if (key === 'password' && !val) return; // Skip empty password
fd.append(key, String(val));
}
});
await $fetch(`/api/v1/users/${userId}`, {
method: 'PUT',
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` },
body: fd
});
// Reset file
avatarFile.value = null;
Swal.fire('Başarılı', 'Kullanıcı güncellendi.', 'success');
} else {
// Context: Create (Register) - POST JSON
await $fetch('/api/v1/auth/register', {
method: 'POST',
baseURL: config.public.BASE_API_URL,
body: formData.value
});
Swal.fire('Başarılı', 'Kullanıcı oluşturuldu.', 'success');
}
if (userModal) userModal.hide();
fetchUsers();
} catch (error) {
console.error(error);
Swal.fire('Hata', 'İşlem başarısız.', 'error');
} finally {
loading.value = false;
}
};
const deleteUser = async (id: number) => {
const isHardDelete = showDeleted.value; // If showing deleted, then next action is hard delete
const result = await Swal.fire({
title: isHardDelete ? 'Kalıcı Silinecek?' : 'Silinecek?',
text: isHardDelete ? "Bu işlem geri alınamaz!" : "Kullanıcı arşivlenecek.",
icon: 'warning',
showCancelButton: true,
confirmButtonText: isHardDelete ? 'Evet, Kalıcı Sil' : 'Evet, Sil',
cancelButtonText: 'İptal',
confirmButtonColor: '#d33'
});
if (!result.isConfirmed) return;
try {
let endpoint = `/api/v1/users/${id}`;
if (isHardDelete) endpoint += '/hard';
await $fetch(endpoint, {
method: 'DELETE',
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` }
});
Swal.fire('Silindi!', 'İşlem başarılı.', 'success');
fetchUsers();
} catch (error) {
console.error(error);
Swal.fire('Hata', 'Silme işlemi başarısız.', 'error');
}
};
const restoreUser = async (id: number) => {
const result = await Swal.fire({
title: 'Geri Yükle?',
text: "Kullanıcı aktif edilecek.",
icon: 'question',
showCancelButton: true,
confirmButtonText: 'Evet',
cancelButtonText: 'İptal'
});
if (!result.isConfirmed) return;
try {
await $fetch(`/api/v1/users/${id}/restore`, {
method: 'POST',
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` }
});
Swal.fire('Başarılı', 'Kullanıcı geri yüklendi.', 'success');
fetchUsers();
} catch (error) {
console.error(error);
Swal.fire('Hata', 'Geri yükleme başarısız.', 'error');
}
};
onMounted(() => {
fetchUsers();
});
</script>