first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 22:07:47 +03:00
commit 5285a0dd86
522 changed files with 41738 additions and 0 deletions

View File

@@ -0,0 +1,457 @@
<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>