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,350 @@
<template>
<div>
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Kategori Yönetimi</h2>
</div>
<!-- Filter / Search -->
<div class="card mb-4">
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<select v-model="filterStatus" class="form-select" @change="fetchCategories(1)">
<option value="">Aktif Kategoriler</option>
<option value="only">Silinenler (Çöp Kutusu)</option>
<option value="with">Tümü (Silinenler Dahil)</option>
</select>
</div>
</div>
</div>
</div>
<!-- Categories List Card -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center bg-white">
<h5 class="mb-0">Kategoriler</h5>
<button class="btn btn-success btn-sm" @click="openModal(null)">
<i class="fas fa-plus me-2"></i> Yeni Kategori
</button>
</div>
<div class="card-body">
<div v-if="loading" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Yükleniyor...</span>
</div>
</div>
<div v-else-if="categories.length === 0" class="text-center py-5 text-muted">
Kayıt bulunamadı.
</div>
<div v-else class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>ID</th>
<th>Başlık</th>
<th>Slug</th>
<th>ıklama</th>
<th>Durum</th>
<th class="text-end">İşlemler</th>
</tr>
</thead>
<tbody>
<tr v-for="category in categories" :key="category.ID">
<td>{{ category.ID }}</td>
<td>
<!-- Indentation for hierarchy if available, otherwise flat -->
<div :style="{ paddingLeft: (category.parent_id ? 20 : 0) + 'px' }">
<span v-if="category.parent_id" class="text-muted me-1"></span>
{{ category.title }}
</div>
</td>
<td><span class="badge bg-light text-dark">{{ category.slug }}</span></td>
<td>{{ category.description }}</td>
<td>
<span v-if="category.DeletedAt" class="badge bg-danger">Silindi</span>
<span v-else class="badge bg-success">Aktif</span>
</td>
<td class="text-end">
<div v-if="!category.DeletedAt">
<button class="btn btn-sm btn-outline-primary me-2" @click="openModal(category)">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-sm btn-outline-danger" @click="deleteCategory(category.ID)">
<i class="fas fa-trash"></i>
</button>
</div>
<div v-else>
<button class="btn btn-sm btn-outline-success me-2" @click="restoreCategory(category.ID)">
<i class="fas fa-trash-restore"></i>
</button>
<button class="btn btn-sm btn-outline-danger" @click="hardDeleteCategory(category.ID)">
<i class="fas fa-times-circle"></i> Kalıcı Sil
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<nav v-if="pagination.total > pagination.per_page" class="mt-4">
<ul class="pagination justify-content-center">
<li class="page-item" :class="{ disabled: pagination.page === 1 }">
<button class="page-link" @click="fetchCategories(pagination.page - 1)">Önceki</button>
</li>
<li v-for="page in Math.ceil(pagination.total / pagination.per_page)" :key="page"
class="page-item" :class="{ active: pagination.page === page }">
<button class="page-link" @click="fetchCategories(page)">{{ page }}</button>
</li>
<li class="page-item" :class="{ disabled: pagination.page >= Math.ceil(pagination.total / pagination.per_page) }">
<button class="page-link" @click="fetchCategories(pagination.page + 1)">Sonraki</button>
</li>
</ul>
</nav>
</div>
</div>
<!-- Create/Edit Modal -->
<div class="modal fade" id="categoryModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ isEditing ? 'Kategoriyi Düzenle' : 'Yeni Kategori' }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form @submit.prevent="saveCategory">
<div class="mb-3">
<label class="form-label">Başlık</label>
<input v-model="formData.title" type="text" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">ıklama</label>
<textarea v-model="formData.description" class="form-control" rows="3"></textarea>
</div>
<div class="mb-3">
<label class="form-label">Üst Kategori (Opsiyonel)</label>
<select v-model="formData.parent_id" class="form-select">
<option :value="null">Ana Kategori</option>
<!-- Only show active categories as parents to avoid confusion -->
<option v-for="cat in activeCategories" :key="cat.ID" :value="cat.ID"
:disabled="cat.ID === formData.ID">
{{ cat.title }}
</option>
</select>
</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 { Category } from '~~/types/category';
import Swal from 'sweetalert2';
definePageMeta({
layout: 'admin',
middleware: 'admin'
});
const config = useRuntimeConfig();
const { data: authData } = useAuth();
const categories = ref<Category[]>([]);
const activeCategories = ref<Category[]>([]); // For parent selection
const loading = ref(false);
const isEditing = ref(false);
const formData = ref<Partial<Category>>({});
const filterStatus = ref(''); // '' = active, 'only' = trashed, 'with' = all
let categoryModal: any = null;
const pagination = ref({
page: 1,
per_page: 10,
total: 0
});
// -- API --
const fetchCategories = async (page = 1) => {
loading.value = true;
try {
const query: any = { page }; // Pagination if supported by admin endpoint
if (filterStatus.value) {
query.trashed = filterStatus.value;
}
// Use Admin endpoint for management
const url = '/api/v1/admin/categories';
const res = await $fetch<any>(url, {
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` },
query
});
if (res.data) {
categories.value = res.data;
pagination.value = res.meta || { page: 1, per_page: 100, total: res.data.length }; // Fallback if no meta
} else if (Array.isArray(res)) {
// Fallback for non-paginated response (if API changes or for active list)
categories.value = res;
pagination.value = { page: 1, per_page: 100, total: res.length };
} else {
categories.value = [];
}
// Fetch active categories for parent selection (always needs active ones)
if (filterStatus.value === '') {
activeCategories.value = categories.value;
} else {
// If we are viewing trashed, we still need active categories for the dropdown
// We can fetch them separately/lightweight if needed, or just allow 'null' parent
// checking if we have them cached or fetch explicitly
// For now, let's just fetch active ones if we don't have them
const activeRes = await $fetch<Category[]>('/api/v1/categories', {
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` }
});
activeCategories.value = activeRes || [];
}
} catch (e) {
console.error(e);
categories.value = [];
} finally {
loading.value = false;
}
};
const openModal = (category: Category | null) => {
isEditing.value = !!category;
formData.value = category ? { ...category } : { title: '', description: '', parent_id: null };
// Check if modal instance exists
const el = document.getElementById('categoryModal');
if (el) {
// @ts-ignore
categoryModal = new bootstrap.Modal(el);
categoryModal.show();
}
};
const saveCategory = async () => {
loading.value = true;
try {
const url = isEditing.value
? `/api/v1/categories/${formData.value.ID}`
: '/api/v1/categories';
const method = isEditing.value ? 'PUT' : 'POST';
await $fetch(url, {
method,
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` },
body: formData.value
});
Swal.fire('Başarılı', `Kategori ${isEditing.value ? 'güncellendi' : 'eklendi'}.`, 'success');
if (categoryModal) categoryModal.hide();
fetchCategories(pagination.value.page);
} catch (error) {
console.error(error);
Swal.fire('Hata', 'İşlem başarısız.', 'error');
} finally {
loading.value = false;
}
};
const deleteCategory = async (id: number) => {
const result = await Swal.fire({
title: 'Emin misiniz?',
text: "Kategori silinecek (Çöp kutusuna taşınacak).",
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Evet, Sil',
cancelButtonText: 'İptal',
confirmButtonColor: '#d33'
});
if (!result.isConfirmed) return;
try {
await $fetch(`/api/v1/categories/${id}`, {
method: 'DELETE',
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` }
});
Swal.fire('Silindi', 'Kategori silindi.', 'success');
fetchCategories(pagination.value.page);
} catch (error) {
console.error(error);
Swal.fire('Hata', 'Silme işlemi başarısız.', 'error');
}
};
const restoreCategory = async (id: number) => {
try {
await $fetch(`/api/v1/admin/categories/${id}/restore`, {
method: 'POST',
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` }
});
Swal.fire('Başarılı', 'Kategori geri yüklendi.', 'success');
fetchCategories(pagination.value.page);
} catch (error) {
console.error(error);
Swal.fire('Hata', 'Geri yükleme başarısız.', 'error');
}
};
const hardDeleteCategory = async (id: number) => {
const result = await Swal.fire({
title: 'Kalıcı Olarak Sil?',
text: "Bu işlem geri alınamaz!",
icon: 'error',
showCancelButton: true,
confirmButtonText: 'Evet, Kalıcı Sil',
cancelButtonText: 'İptal',
confirmButtonColor: '#d33'
});
if (!result.isConfirmed) return;
try {
await $fetch(`/api/v1/admin/categories/${id}/hard`, {
method: 'DELETE',
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` }
});
Swal.fire('Silindi', 'Kategori kalıcı olarak silindi.', 'success');
fetchCategories(pagination.value.page);
} catch (error) {
console.error(error);
Swal.fire('Hata', 'Silme işlemi başarısız.', 'error');
}
};
onMounted(() => {
fetchCategories();
});
</script>

View File

@@ -0,0 +1,325 @@
<template>
<div>
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Gönderi Düzenle</h2>
<NuxtLink to="/admin/blog/posts" class="btn btn-secondary">
<i class="fas fa-arrow-left me-2"></i> Geri Dön
</NuxtLink>
</div>
<div v-if="loadingData" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Yükleniyor...</span>
</div>
</div>
<div v-else class="card">
<div class="card-body">
<form @submit.prevent="updatePost">
<div class="row">
<div class="col-md-8">
<!-- Title -->
<div class="mb-3">
<label class="form-label">Başlık</label>
<input v-model="formData.title" type="text" class="form-control" required>
</div>
<!-- Content -->
<div class="mb-3">
<label class="form-label">İçerik</label>
<textarea v-model="formData.content" class="form-control" rows="10" required></textarea>
<div class="form-text">HTML içeriği girebilirsiniz.</div>
</div>
<!-- Tags -->
<div class="card mb-3">
<div class="card-header">Etiketler</div>
<div class="card-body" style="max-height: 200px; overflow-y: auto;">
<div v-if="availableTags.length === 0" class="text-muted small">Etiket bulunamadı.</div>
<div v-for="tag in availableTags" :key="tag.ID" class="form-check">
<input class="form-check-input" type="checkbox" :value="tag.ID" v-model="selectedTags" :id="`tag-${tag.ID}`">
<label class="form-check-label" :for="`tag-${tag.ID}`">
{{ tag.name }}
</label>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<!-- Categories -->
<div class="card mb-3">
<div class="card-header">Kategoriler</div>
<div class="card-body" style="max-height: 300px; overflow-y: auto;">
<div v-for="cat in categories" :key="cat.ID" class="form-check">
<input class="form-check-input" type="checkbox" :value="cat.ID" v-model="formData.category_ids" :id="`cat-${cat.ID}`">
<label class="form-check-label" :for="`cat-${cat.ID}`">
{{ cat.title }}
</label>
</div>
</div>
</div>
<!-- Image Upload & Options -->
<div class="card mb-3">
<div class="card-header">Öne Çıkan Görsel</div>
<div class="card-body">
<div class="mb-3 text-center">
<div v-if="imagePreview" class="mb-3">
<img :src="imagePreview" class="img-fluid rounded" style="max-height: 200px;">
<button type="button" class="btn btn-sm btn-danger mt-2" @click="removeImage">Değiştir / Kaldır</button>
</div>
<div v-else>
<div class="mb-3">
<i class="fas fa-image fa-3x text-muted"></i>
</div>
<input type="file" class="form-control" @change="handleFileUpload" accept="image/*">
</div>
</div>
<div v-if="imageFile">
<hr>
<h6>Resim Ayarları</h6>
<div class="row g-2">
<div class="col-6">
<label class="form-label small">Genişlik (px)</label>
<input v-model="imageOptions.width" type="number" class="form-control form-control-sm" placeholder="Opsiyonel">
</div>
<div class="col-6">
<label class="form-label small">Yükseklik (px)</label>
<input v-model="imageOptions.height" type="number" class="form-control form-control-sm" placeholder="Opsiyonel">
</div>
<div class="col-6">
<label class="form-label small">Kalite (1-100)</label>
<input v-model="imageOptions.quality" type="number" class="form-control form-control-sm" min="1" max="100">
</div>
<div class="col-6">
<label class="form-label small">Format</label>
<select v-model="imageOptions.format" class="form-select form-select-sm">
<option value="jpeg">JPEG</option>
<option value="png">PNG</option>
<option value="webp">WebP</option>
<option value="avif">AVIF</option>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- Update Button -->
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary" :disabled="saving">
<span v-if="saving" class="spinner-border spinner-border-sm me-2"></span>
{{ saving ? 'Güncelleniyor...' : 'Güncelle' }}
</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Category } from '~~/types/category';
import type { Post } from '~~/types/post';
import type { Tag, TagResponse } from '~~/types/tag';
import Swal from 'sweetalert2';
definePageMeta({
layout: 'admin',
middleware: 'admin'
});
const config = useRuntimeConfig();
const { data: authData } = useAuth();
const route = useRoute();
const router = useRouter();
const loadingData = ref(true);
const saving = ref(false);
const categories = ref<Category[]>([]);
const availableTags = ref<Tag[]>([]);
const selectedTags = ref<number[]>([]);
const imageFile = ref<File | null>(null);
const imagePreview = ref<string | null>(null);
const formData = ref({
title: '',
content: '',
category_ids: [] as number[],
});
const imageOptions = ref({
width: null as number | null,
height: null as number | null,
quality: 80,
format: 'jpeg'
});
const postId = route.params.id as string;
const fetchData = async () => {
try {
const [catRes, tagRes] = await Promise.all([
$fetch<Category[]>('/api/v1/categories', {
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` }
}),
$fetch<TagResponse>('/api/v1/admin/tags', { // Fetching all tags
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` },
params: { limit: 100 }
})
]);
categories.value = catRes || [];
availableTags.value = tagRes?.data || [];
// After fetching data, fetch post
await fetchPost();
} catch (e) {
console.error('Veriler yüklenemedi', e);
loadingData.value = false;
}
};
const fetchPost = async () => {
try {
const res = await $fetch<Post>(`/api/v1/posts/${postId}`, {
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` }
});
if (res) {
formData.value.title = res.title;
formData.value.content = res.content;
formData.value.category_ids = res.categories?.map(c => c.ID) || [];
// Pre-select tags
if (res.tags) {
selectedTags.value = res.tags.map(t => t.ID);
}
// Image
if (res.images) {
// Should parse if JSON or string
let imgPath = res.images;
if (imgPath.startsWith('[') || imgPath.startsWith('{')) {
try {
const parsed = JSON.parse(imgPath);
if (Array.isArray(parsed) && parsed.length > 0) imgPath = parsed[0];
} catch(e) {}
}
imagePreview.value = getImageUrl(imgPath);
}
}
} catch (e) {
console.error('Post yüklenemedi', e);
Swal.fire('Hata', 'Gönderi bulunamadı veya yüklenemedi.', 'error');
router.push('/admin/blog/posts');
} finally {
loadingData.value = false;
}
};
const getImageUrl = (path: string) => {
if (path.startsWith('http')) return path;
if (path.startsWith('/')) return `${config.public.BASE_API_URL}${path}`;
return `${config.public.BASE_API_URL}/${path}`;
};
const handleFileUpload = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files && target.files[0]) {
const file = target.files[0];
imageFile.value = file;
imagePreview.value = URL.createObjectURL(file);
// Default format based on uploaded file or keep jpeg
if (file.type === 'image/png') imageOptions.value.format = 'png';
else if (file.type === 'image/webp') imageOptions.value.format = 'webp';
else imageOptions.value.format = 'jpeg';
}
};
const removeImage = () => {
imageFile.value = null;
imagePreview.value = null;
};
const updatePost = async () => {
saving.value = true;
try {
const data = new FormData();
data.append('title', formData.value.title);
data.append('content', formData.value.content);
if (formData.value.category_ids.length > 0) {
data.append('category_ids', formData.value.category_ids.join(','));
}
if (selectedTags.value.length > 0) {
data.append('tag_ids', selectedTags.value.join(','));
}
// Optimize & Append Image if exists
if (imageFile.value) {
const optimizeData = new FormData();
optimizeData.append('file', imageFile.value);
if (imageOptions.value.width) optimizeData.append('width', imageOptions.value.width.toString());
if (imageOptions.value.height) optimizeData.append('height', imageOptions.value.height.toString());
optimizeData.append('quality', imageOptions.value.quality.toString());
optimizeData.append('format', imageOptions.value.format);
// Fetch as Blob from server/api/optimize
const optimizedBlob = await $fetch<Blob>('/api/optimize', {
method: 'POST',
body: optimizeData,
responseType: 'blob'
});
const ext = imageOptions.value.format === 'jpg' ? 'jpeg' : imageOptions.value.format;
const filename = `img-${Date.now()}.${ext === 'jpeg' ? 'jpg' : ext}`;
const optimizedFile = new File([optimizedBlob], filename, { type: `image/${ext}` });
data.append('images', optimizedFile);
}
await $fetch(`/api/v1/posts/${postId}`, {
method: 'PUT',
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` },
body: data
});
Swal.fire({
title: 'Başarılı',
text: 'Gönderi güncellendi.',
icon: 'success',
showCancelButton: true,
confirmButtonText: 'Listeye Dön',
cancelButtonText: 'Kalsın'
}).then((result) => {
if (result.isConfirmed) {
router.push('/admin/blog/posts');
}
});
} catch (error: any) {
console.error(error);
Swal.fire('Hata', error.data?.message || 'Bir hata oluştu.', 'error');
} finally {
saving.value = false;
}
};
onMounted(() => {
fetchData();
});
</script>

View File

@@ -0,0 +1,253 @@
<template>
<div>
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Yeni Gönderi Ekle</h2>
<NuxtLink to="/admin/blog/posts" class="btn btn-secondary">
<i class="fas fa-arrow-left me-2"></i> Geri Dön
</NuxtLink>
</div>
<div class="card">
<div class="card-body">
<form @submit.prevent="savePost">
<div class="row">
<div class="col-md-8">
<!-- Title -->
<div class="mb-3">
<label class="form-label">Başlık</label>
<input v-model="formData.title" type="text" class="form-control" required>
</div>
<!-- Content -->
<div class="mb-3">
<label class="form-label">İçerik</label>
<textarea v-model="formData.content" class="form-control" rows="10" required></textarea>
<div class="form-text">HTML içeriği girebilirsiniz.</div>
</div>
<!-- Tags -->
<div class="card mb-3">
<div class="card-header">Etiketler</div>
<div class="card-body" style="max-height: 200px; overflow-y: auto;">
<div v-if="availableTags.length === 0" class="text-muted small">Etiket bulunamadı.</div>
<div v-for="tag in availableTags" :key="tag.ID" class="form-check">
<input class="form-check-input" type="checkbox" :value="tag.ID" v-model="selectedTags" :id="`tag-${tag.ID}`">
<label class="form-check-label" :for="`tag-${tag.ID}`">
{{ tag.name }}
</label>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<!-- Categories -->
<div class="card mb-3">
<div class="card-header">Kategoriler</div>
<div class="card-body" style="max-height: 300px; overflow-y: auto;">
<div v-if="categories.length === 0" class="text-muted small">Kategori bulunamadı.</div>
<div v-for="cat in categories" :key="cat.ID" class="form-check">
<input class="form-check-input" type="checkbox" :value="cat.ID" v-model="formData.category_ids" :id="`cat-${cat.ID}`">
<label class="form-check-label" :for="`cat-${cat.ID}`">
{{ cat.title }}
</label>
</div>
</div>
</div>
<!-- Image Upload & Options -->
<div class="card mb-3">
<div class="card-header">Öne Çıkan Görsel</div>
<div class="card-body">
<div class="mb-3 text-center">
<div v-if="imagePreview" class="mb-3">
<img :src="imagePreview" class="img-fluid rounded" style="max-height: 200px;">
<button type="button" class="btn btn-sm btn-danger mt-2" @click="removeImage">Kaldır</button>
</div>
<div v-else>
<div class="mb-3">
<i class="fas fa-image fa-3x text-muted"></i>
</div>
<input type="file" class="form-control" @change="handleFileUpload" accept="image/*">
</div>
</div>
<div v-if="imageFile">
<hr>
<h6>Resim Ayarları</h6>
<div class="row g-2">
<div class="col-6">
<label class="form-label small">Genişlik (px)</label>
<input v-model="imageOptions.width" type="number" class="form-control form-control-sm">
</div>
<div class="col-6">
<label class="form-label small">Yükseklik (px)</label>
<input v-model="imageOptions.height" type="number" class="form-control form-control-sm" placeholder="Opsiyonel">
</div>
<div class="col-6">
<label class="form-label small">Kalite (1-100)</label>
<input v-model="imageOptions.quality" type="number" class="form-control form-control-sm" min="1" max="100">
</div>
<div class="col-6">
<label class="form-label small">Format</label>
<select v-model="imageOptions.format" class="form-select form-select-sm">
<option value="jpeg">JPEG</option>
<option value="png">PNG</option>
<option value="webp">WebP</option>
<option value="avif">AVIF</option>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- Publish Button -->
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary" :disabled="loading">
<span v-if="loading" class="spinner-border spinner-border-sm me-2"></span>
{{ loading ? 'İşleniyor...' : 'Yayınla' }}
</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Category } from '~~/types/category';
import type { Tag, TagResponse } from '~~/types/tag';
import Swal from 'sweetalert2';
definePageMeta({
layout: 'admin',
middleware: 'admin'
});
const config = useRuntimeConfig();
const { data: authData } = useAuth();
const router = useRouter();
const loading = ref(false);
const categories = ref<Category[]>([]);
const availableTags = ref<Tag[]>([]);
const selectedTags = ref<number[]>([]);
const imageFile = ref<File | null>(null);
const imagePreview = ref<string | null>(null);
const formData = ref({
title: '',
content: '',
category_ids: [] as number[],
});
const imageOptions = ref({
width: null as number | null,
height: null as number | null,
quality: 80,
format: 'jpeg'
});
// Fetch Categories and Tags
const fetchData = async () => {
try {
const [catRes, tagRes] = await Promise.all([
$fetch<Category[]>('/api/v1/categories', {
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` }
}),
$fetch<TagResponse>('/api/v1/admin/tags', { // Fetching all tags (admin endpoint for safety/completeness)
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` },
params: { limit: 100 } // Get a reasonable amount of tags
})
]);
categories.value = catRes || [];
availableTags.value = tagRes?.data || [];
} catch (e) {
console.error(e);
}
};
const handleFileUpload = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files && target.files[0]) {
const file = target.files[0];
imageFile.value = file;
imagePreview.value = URL.createObjectURL(file);
// Default format based on uploaded file or keep jpeg
if (file.type === 'image/png') imageOptions.value.format = 'png';
else if (file.type === 'image/webp') imageOptions.value.format = 'webp';
else imageOptions.value.format = 'jpeg';
}
};
const removeImage = () => {
imageFile.value = null;
imagePreview.value = null;
};
const savePost = async () => {
loading.value = true;
try {
const data = new FormData();
data.append('title', formData.value.title);
data.append('content', formData.value.content);
if (formData.value.category_ids.length > 0) {
data.append('category_ids', formData.value.category_ids.join(','));
}
if (selectedTags.value.length > 0) {
data.append('tag_ids', selectedTags.value.join(','));
}
// Optimize Image and append to FormData
if (imageFile.value) {
const optimizeData = new FormData();
optimizeData.append('file', imageFile.value);
if (imageOptions.value.width) optimizeData.append('width', imageOptions.value.width.toString());
if (imageOptions.value.height) optimizeData.append('height', imageOptions.value.height.toString());
optimizeData.append('quality', imageOptions.value.quality.toString());
optimizeData.append('format', imageOptions.value.format);
const optimizedBlob = await $fetch<Blob>('/api/optimize', {
method: 'POST',
body: optimizeData,
responseType: 'blob'
});
const ext = imageOptions.value.format === 'jpg' ? 'jpeg' : imageOptions.value.format;
const filename = `img-${Date.now()}.${ext === 'jpeg' ? 'jpg' : ext}`;
const optimizedFile = new File([optimizedBlob], filename, { type: `image/${ext}` });
data.append('images', optimizedFile);
}
await $fetch('/api/v1/posts', {
method: 'POST',
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` },
body: data
});
Swal.fire('Başarılı', 'Gönderi başarıyla oluşturuldu.', 'success');
router.push('/admin/blog/posts');
} catch (error: any) {
console.error(error);
Swal.fire('Hata', error.data?.message || 'Bir hata oluştu.', 'error');
} finally {
loading.value = false;
}
};
onMounted(() => {
fetchData();
});
</script>

View File

@@ -0,0 +1,252 @@
<template>
<div>
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Gönderi Yönetimi</h2>
<NuxtLink to="/admin/blog/posts/create" class="btn btn-primary">
<i class="fas fa-plus me-2"></i> Yeni Gönderi
</NuxtLink>
</div>
<!-- Filter / Search -->
<div class="card mb-4">
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<select v-model="filterStatus" class="form-select" @change="fetchPosts(1)">
<option value="">Tümü</option>
<option value="only">Silinenler (Çöp Kutusu)</option>
<option value="with">Tümü (Silinenler Dahil)</option>
</select>
</div>
<!-- Future: Add search input here -->
</div>
</div>
</div>
<!-- Posts List -->
<div class="card mb-4">
<div class="card-body">
<div v-if="loading" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Yükleniyor...</span>
</div>
</div>
<div v-else-if="posts.length === 0" class="text-center py-5 text-muted">
Kayıt bulunamadı.
</div>
<div v-else class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>ID</th>
<th>Resim</th>
<th>Başlık</th>
<th>Kategoriler</th>
<th>Etiketler</th>
<th>Durum</th>
<th>Tarih</th>
<th class="text-end">İşlemler</th>
</tr>
</thead>
<tbody>
<tr v-for="post in posts" :key="post.ID">
<td>{{ post.ID }}</td>
<td>
<img v-if="getFirstImage(post.images)" :src="getImageUrl(getFirstImage(post.images)!)"
alt="thumbnail" class="rounded" style="width: 50px; height: 50px; object-fit: cover;">
<div v-else class="bg-light rounded d-flex align-items-center justify-content-center text-muted"
style="width: 50px; height: 50px;">
<i class="fas fa-image"></i>
</div>
</td>
<td>
<div class="fw-bold">{{ post.title }}</div>
<small class="text-muted">{{ post.slug }}</small>
</td>
<td>
<span v-for="cat in post.categories" :key="cat.ID" class="badge bg-info text-dark me-1">
{{ cat.title }}
</span>
</td>
<td>
<span v-for="tag in post.tags" :key="tag.ID" class="badge bg-secondary me-1">
{{ tag.name }}
</span>
</td>
<td>
<span v-if="post.DeletedAt" class="badge bg-danger">Silindi</span>
<span v-else class="badge bg-success">Aktif</span>
</td>
<td>
<div>{{ formatDate(post.CreatedAt) }}</div>
</td>
<td class="text-end">
<div v-if="!post.DeletedAt">
<NuxtLink :to="`/admin/blog/posts/${post.ID}`" class="btn btn-sm btn-outline-primary me-2">
<i class="fas fa-edit"></i>
</NuxtLink>
<button class="btn btn-sm btn-outline-danger" @click="deletePost(post.ID)">
<i class="fas fa-trash"></i>
</button>
</div>
<div v-else>
<button class="btn btn-sm btn-outline-success" @click="restorePost(post.ID)">
<i class="fas fa-trash-restore"></i> Geri Yükle
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<nav v-if="pagination.total > pagination.per_page" class="mt-4">
<ul class="pagination justify-content-center">
<li class="page-item" :class="{ disabled: pagination.page === 1 }">
<button class="page-link" @click="fetchPosts(pagination.page - 1)">Önceki</button>
</li>
<li v-for="page in Math.ceil(pagination.total / pagination.per_page)" :key="page"
class="page-item" :class="{ active: pagination.page === page }">
<button class="page-link" @click="fetchPosts(page)">{{ page }}</button>
</li>
<li class="page-item" :class="{ disabled: pagination.page >= Math.ceil(pagination.total / pagination.per_page) }">
<button class="page-link" @click="fetchPosts(pagination.page + 1)">Sonraki</button>
</li>
</ul>
</nav>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Post, PostResponse } from '~~/types/post';
import Swal from 'sweetalert2';
definePageMeta({
layout: 'admin',
middleware: 'admin'
});
const config = useRuntimeConfig();
const { data: authData } = useAuth();
const posts = ref<Post[]>([]);
const loading = ref(false);
const filterStatus = ref(''); // '' = active (default from API perspective usually?), or we need to handle default
// Default listing in doc: /api/v1/admin/posts
// Trashed only: ?trashed=only
// With trashed: ?trashed=with
const pagination = ref({
page: 1,
per_page: 10,
total: 0
});
const fetchPosts = async (page = 1) => {
loading.value = true;
try {
const query: any = { page, limit: pagination.value.per_page };
if (filterStatus.value) {
query.trashed = filterStatus.value;
}
const res = await $fetch<PostResponse>('/api/v1/admin/posts', {
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` },
query
});
posts.value = res.data || [];
pagination.value = res.meta;
} catch (e) {
console.error(e);
posts.value = [];
} finally {
loading.value = false;
}
};
const deletePost = async (id: number) => {
const result = await Swal.fire({
title: 'Emin misiniz?',
text: "Bu gönderi silinecek (Çöp kutusuna taşınacak).",
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Evet, Sil',
cancelButtonText: 'İptal',
confirmButtonColor: '#d33'
});
if (!result.isConfirmed) return;
try {
await $fetch(`/api/v1/posts/${id}`, {
method: 'DELETE',
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` }
});
Swal.fire('Silindi', 'Gönderi silindi.', 'success');
fetchPosts(pagination.value.page);
} catch (error) {
console.error(error);
Swal.fire('Hata', 'Silme işlemi başarısız.', 'error');
}
};
const restorePost = async (id: number) => {
// Implement restore logic if API supports it, otherwise basic delete is usually soft delete.
// If current delete IS soft delete, usually there's a restore endpoint or update with deleted_at=null.
// Assuming for now we just show it for concept, but strict restore endpoint might be needed.
// For now, let's keep it simple. If no explicit restore endpoint documented, maybe we can't restore easily from UI yet.
// But wait, the user asked to implement based on admin_post.md.
// Use case "sadece soft delete olmuslar listesi" implies we can see them.
// Usually a PUT to /api/v1/admin/posts/{id}/restore or similar.
// Since not documented, I will skip implementation of ACTUAL restore call but keep the button or maybe comment it out
// until confirmed. Or I can assume standard behaviour if I knew the backend.
// Let's assume standard soft delete REST pattern often allows toggle or specific endpoint.
// I'll leave the button but functionality might be missing.
// Actually, let's look at the docs again.
// Docs only show GET lists.
// I will remove Restore button functionality for now to avoid errors, or just show alert 'Not implemented'.
Swal.fire('Bilgi', 'Geri yükleme özelliği henüz API tarafında dökümante edilmedi.', 'info');
};
const getFirstImage = (imagesStr: string): string | null => {
try {
// API returns "images": "uploads/posts/..." OR JSON string "[\"...\"]"
// Let's try to parse if it looks like JSON
if (imagesStr.startsWith('[') || imagesStr.startsWith('{')) {
const parsed = JSON.parse(imagesStr);
if (Array.isArray(parsed) && parsed.length > 0) return parsed[0];
}
return imagesStr;
} catch (e) {
return imagesStr;
}
};
const getImageUrl = (path: string) => {
if (path.startsWith('http')) return path;
if (path.startsWith('/')) return `${config.public.BASE_API_URL}${path}`;
return `${config.public.BASE_API_URL}/${path}`;
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('tr-TR', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
onMounted(() => {
fetchPosts();
});
</script>

View File

@@ -0,0 +1,317 @@
<template>
<div>
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Etiket Yönetimi</h2>
<button class="btn btn-primary" @click="openModal()">
<i class="fas fa-plus me-2"></i> Yeni Etiket
</button>
</div>
<div class="card mb-4">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="btn-group" role="group">
<button type="button" class="btn btn-outline-secondary" :class="{ active: filterTrashed === 'without' }" @click="changeFilter('without')">
Aktif
</button>
<button type="button" class="btn btn-outline-secondary" :class="{ active: filterTrashed === 'only' }" @click="changeFilter('only')">
Çöp Kutusu
</button>
<button type="button" class="btn btn-outline-secondary" :class="{ active: filterTrashed === 'with' }" @click="changeFilter('with')">
Hepsi
</button>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-body">
<div v-if="loading" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Yükleniyor...</span>
</div>
</div>
<div v-else class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>ID</th>
<th>Etiket Adı</th>
<th>Oluşturulma</th>
<th>Durum</th>
<th class="text-end">İşlemler</th>
</tr>
</thead>
<tbody>
<tr v-for="tag in tags" :key="tag.ID">
<td>#{{ tag.ID }}</td>
<td>
<span class="fw-bold">{{ tag.name }}</span>
</td>
<td>{{ new Date(tag.CreatedAt).toLocaleDateString('tr-TR') }}</td>
<td>
<span v-if="tag.DeletedAt" class="badge bg-danger">Silinmiş</span>
<span v-else class="badge bg-success">Aktif</span>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<button v-if="!tag.DeletedAt" class="btn btn-outline-primary" @click="openModal(tag)" title="Düzenle">
<i class="fas fa-edit"></i>
</button>
<button v-if="!tag.DeletedAt" class="btn btn-outline-danger" @click="deleteTag(tag.ID)" title="Sil">
<i class="fas fa-trash"></i>
</button>
<button v-if="tag.DeletedAt" class="btn btn-outline-success" @click="restoreTag(tag.ID)" title="Geri Yükle">
<i class="fas fa-trash-restore"></i>
</button>
<button v-if="tag.DeletedAt" class="btn btn-outline-dark" @click="hardDeleteTag(tag.ID)" title="Kalıcı Sil">
<i class="fas fa-times"></i>
</button>
</div>
</td>
</tr>
<tr v-if="tags.length === 0">
<td colspan="5" class="text-center py-4">Kayıt bulunamadı.</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<nav v-if="totalPages > 1" class="mt-4">
<ul class="pagination justify-content-center">
<li class="page-item" :class="{ disabled: currentPage === 1 }">
<button class="page-link" @click="changePage(currentPage - 1)">Önceki</button>
</li>
<li v-for="page in totalPages" :key="page" class="page-item" :class="{ active: currentPage === page }">
<button class="page-link" @click="changePage(page)">{{ page }}</button>
</li>
<li class="page-item" :class="{ disabled: currentPage === totalPages }">
<button class="page-link" @click="changePage(currentPage + 1)">Sonraki</button>
</li>
</ul>
</nav>
</div>
</div>
<!-- Tag Modal -->
<div class="modal fade" id="tagModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ isEditing ? 'Etiket Düzenle' : 'Yeni Etiket Ekle' }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form @submit.prevent="saveTag">
<div class="mb-3">
<label class="form-label">Etiket Adı</label>
<input v-model="tagForm.name" type="text" class="form-control" required>
</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="saving">
<span v-if="saving" 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 { Tag, TagResponse } from '~~/types/tag';
import Swal from 'sweetalert2';
definePageMeta({
layout: 'admin',
middleware: 'admin'
});
const config = useRuntimeConfig();
const { data: authData } = useAuth();
const loading = ref(false);
const saving = ref(false);
const tags = ref<Tag[]>([]);
const currentPage = ref(1);
const totalPages = ref(1);
const filterTrashed = ref<'without' | 'with' | 'only'>('without');
const tagForm = ref({
ID: 0,
name: ''
});
const isEditing = ref(false);
let tagModal: any = null;
const fetchTags = async () => {
loading.value = true;
try {
const res = await $fetch<TagResponse>('/api/v1/admin/tags', {
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` },
params: {
page: currentPage.value,
limit: 10,
...(filterTrashed.value !== 'without' && { trashed: filterTrashed.value })
}
});
if (res && res.data) {
tags.value = res.data;
totalPages.value = Math.ceil(res.meta.total / res.meta.per_page);
} else {
tags.value = [];
}
} catch (error) {
console.error(error);
Swal.fire('Hata', 'Etiketler yüklenirken bir hata oluştu.', 'error');
} finally {
loading.value = false;
}
};
const changePage = (page: number) => {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page;
fetchTags();
}
};
const changeFilter = (filter: 'without' | 'with' | 'only') => {
filterTrashed.value = filter;
currentPage.value = 1;
fetchTags();
};
const openModal = (tag: Tag | null = null) => {
if (tag) {
isEditing.value = true;
tagForm.value = { ID: tag.ID, name: tag.name };
} else {
isEditing.value = false;
tagForm.value = { ID: 0, name: '' };
}
const modalEl = document.getElementById('tagModal');
if (modalEl) {
// @ts-ignore
tagModal = new bootstrap.Modal(modalEl);
tagModal.show();
}
};
const saveTag = async () => {
saving.value = true;
try {
let url = '/api/v1/tags';
let method: 'POST' | 'PUT' = 'POST';
if (isEditing.value && tagForm.value.ID) {
url = `/api/v1/tags/${tagForm.value.ID}`;
method = 'PUT';
}
await $fetch(url, {
method: method,
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` },
body: { name: tagForm.value.name }
});
if (tagModal) tagModal.hide();
Swal.fire('Başarılı', `Etiket ${isEditing.value ? 'güncellendi' : 'eklendi'}.`, 'success');
fetchTags();
} catch (error: any) {
console.error(error);
Swal.fire('Hata', error.data?.message || 'İşlem başarısız.', 'error');
} finally {
saving.value = false;
}
};
const deleteTag = async (id: number) => {
const result = await Swal.fire({
title: 'Emin misiniz?',
text: "Bu etiketi çöp kutusuna taşımak istediğinize emin misiniz?",
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Evet, Sil',
cancelButtonText: 'İptal'
});
if (result.isConfirmed) {
try {
await $fetch(`/api/v1/tags/${id}`, {
method: 'DELETE',
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` }
});
Swal.fire('Silindi', 'Etiket çöp kutusuna taşındı.', 'success');
fetchTags();
} catch (error) {
console.error(error);
Swal.fire('Hata', 'Silme işlemi başarısız.', 'error');
}
}
};
const hardDeleteTag = async (id: number) => {
const result = await Swal.fire({
title: 'DİKKAT! Kalıcı Silme',
text: "Bu işlem geri alınamaz! Etiketi tamamen silmek istediğinize emin misiniz?",
icon: 'error',
showCancelButton: true,
confirmButtonText: 'Evet, Tamamen Sil',
cancelButtonText: 'İptal'
});
if (result.isConfirmed) {
try {
await $fetch(`/api/v1/admin/tags/${id}/hard`, {
method: 'DELETE',
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` }
});
Swal.fire('Silindi', 'Etiket kalıcı olarak silindi.', 'success');
fetchTags();
} catch (error) {
console.error(error);
Swal.fire('Hata', 'Kalıcı silme işlemi başarısız.', 'error');
}
}
};
const restoreTag = async (id: number) => {
try {
await $fetch(`/api/v1/admin/tags/${id}/restore`, {
method: 'POST',
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` }
});
Swal.fire('Geri Yüklendi', 'Etiket başarıyla geri yüklendi.', 'success');
fetchTags();
} catch (error) {
console.error(error);
Swal.fire('Hata', 'Geri yükleme işlemi başarısız.', 'error');
}
};
onMounted(() => {
fetchTags();
});
</script>

14
app/pages/admin/index.vue Normal file
View File

@@ -0,0 +1,14 @@
<template>
<div>
admin index
</div>
</template>
<script lang="ts" setup>
definePageMeta({
layout: 'admin',
middleware: 'admin'
});
</script>
<style></style>

View File

@@ -0,0 +1,501 @@
<template>
<div>
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Güvenlik & Yönetimi</h2>
</div>
<ul class="nav nav-tabs mb-4" id="securityTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="whitelist-tab" data-bs-toggle="tab" data-bs-target="#whitelist"
type="button" role="tab" aria-controls="whitelist" aria-selected="true">CORS Whitelist</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="blacklist-tab" data-bs-toggle="tab" data-bs-target="#blacklist"
type="button" role="tab" aria-controls="blacklist" aria-selected="false">CORS Blacklist</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="ratelimit-tab" data-bs-toggle="tab" data-bs-target="#ratelimit"
type="button" role="tab" aria-controls="ratelimit" aria-selected="false">Rate Limit</button>
</li>
</ul>
<div class="tab-content" id="securityTabContent">
<!-- WHITELIST TAB -->
<div class="tab-pane fade show active" id="whitelist" role="tabpanel" aria-labelledby="whitelist-tab">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center bg-white">
<h5 class="mb-0">Whitelist Origins</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="whitelistDeleted">
<label class="form-check-label" for="whitelistDeleted">Silinenler</label>
</div>
<button class="btn btn-success btn-sm" @click="openModal('whitelist')"><i
class="fas fa-plus me-2"></i> Yeni
Ekle</button>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Origin</th>
<th>ıklama</th>
<th>Durum</th>
<th class="text-end" width="150">İşlemler</th>
</tr>
</thead>
<tbody>
<tr v-for="item in whitelistItems" :key="item.ID">
<td>{{ item.origin }}</td>
<td>{{ item.description }}</td>
<td>
<span class="badge" :class="item.is_active ? 'bg-success' : 'bg-secondary'">
{{ item.is_active ? 'Aktif' : 'Pasif' }}
</span>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm" role="group">
<template v-if="!item.DeletedAt">
<button class="btn btn-outline-primary"
@click="openModal('whitelist', item)" title="Düzenle">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-outline-danger"
@click="deleteItem('whitelist', item.ID)" title="Sil">
<i class="fas fa-trash"></i>
</button>
</template>
<button v-else class="btn btn-outline-success"
@click="restoreItem('whitelist', item.ID)" title="Geri Yükle">
<i class="fas fa-trash-restore"></i>
</button>
</div>
</td>
</tr>
<tr v-if="whitelistItems.length === 0">
<td colspan="4" class="text-center py-4">Kayıt bulunamadı.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- BLACKLIST TAB -->
<div class="tab-pane fade" id="blacklist" role="tabpanel" aria-labelledby="blacklist-tab">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center bg-white">
<h5 class="mb-0">Blacklist Origins</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="blacklistDeleted">
<label class="form-check-label" for="blacklistDeleted">Silinenler</label>
</div>
<button class="btn btn-success btn-sm" @click="openModal('blacklist')"><i
class="fas fa-plus me-2"></i>
Yeni
Ekle</button>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Origin</th>
<th>Sebep</th>
<th>Durum</th>
<th class="text-end" width="150">İşlemler</th>
</tr>
</thead>
<tbody>
<tr v-for="item in blacklistItems" :key="item.ID">
<td>{{ item.origin }}</td>
<td>{{ item.reason }}</td>
<td>
<span class="badge" :class="item.is_active ? 'bg-danger' : 'bg-secondary'">
{{ item.is_active ? 'Aktif' : 'Pasif' }}
</span>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm" role="group">
<template v-if="!item.DeletedAt">
<button class="btn btn-outline-primary"
@click="openModal('blacklist', item)" title="Düzenle">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-outline-danger"
@click="deleteItem('blacklist', item.ID)" title="Sil">
<i class="fas fa-trash"></i>
</button>
</template>
<button v-else class="btn btn-outline-success"
@click="restoreItem('blacklist', item.ID)" title="Geri Yükle">
<i class="fas fa-trash-restore"></i>
</button>
</div>
</td>
</tr>
<tr v-if="blacklistItems.length === 0">
<td colspan="4" class="text-center py-4">Kayıt bulunamadı.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- RATE LIMIT TAB -->
<div class="tab-pane fade" id="ratelimit" role="tabpanel" aria-labelledby="ratelimit-tab">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center bg-white">
<h5 class="mb-0">Rate Limits</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="ratelimitDeleted">
<label class="form-check-label" for="ratelimitDeleted">Silinenler</label>
</div>
<button class="btn btn-success btn-sm" @click="openModal('ratelimit')"><i
class="fas fa-plus me-2"></i>
Yeni
Ekle</button>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>İsim</th>
<th>Max İstek / Süre (sn)</th>
<th>ıklama</th>
<th>Durum</th>
<th class="text-end" width="150">İşlemler</th>
</tr>
</thead>
<tbody>
<tr v-for="item in rateLimitItems" :key="item.ID">
<td>{{ item.name }}</td>
<td>{{ item.max_requests }} / {{ item.window_seconds }}sn</td>
<td>{{ item.description }}</td>
<td>
<span class="badge" :class="item.is_active ? 'bg-success' : 'bg-secondary'">
{{ item.is_active ? 'Aktif' : 'Pasif' }}
</span>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm" role="group">
<template v-if="!item.DeletedAt">
<button class="btn btn-outline-primary"
@click="openModal('ratelimit', item)" title="Düzenle">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-outline-danger"
@click="deleteItem('ratelimit', item.ID)" title="Sil">
<i class="fas fa-trash"></i>
</button>
</template>
<button v-else class="btn btn-outline-success"
@click="restoreItem('ratelimit', item.ID)" title="Geri Yükle">
<i class="fas fa-trash-restore"></i>
</button>
</div>
</td>
</tr>
<tr v-if="rateLimitItems.length === 0">
<td colspan="5" class="text-center py-4">Kayıt bulunamadı.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- MODAL -->
<div class="modal fade" id="securityModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ modalTitle }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form @submit.prevent="saveItem">
<!-- Common Fields -->
<div v-if="activeTab === 'whitelist' || activeTab === 'blacklist'" class="mb-3">
<label class="form-label">Origin (URL)</label>
<input v-model="formData.origin" type="text" class="form-control"
placeholder="http://example.com" required>
</div>
<div v-if="activeTab === 'whitelist'" class="mb-3">
<label class="form-label">ıklama</label>
<input v-model="formData.description" type="text" class="form-control">
</div>
<div v-if="activeTab === 'blacklist'" class="mb-3">
<label class="form-label">Sebep</label>
<input v-model="formData.reason" type="text" class="form-control">
</div>
<!-- Rate Limit Fields -->
<div v-if="activeTab === 'ratelimit'" class="mb-3">
<label class="form-label">İsim (Endpoint/Key)</label>
<input v-model="formData.name" type="text" class="form-control" required>
</div>
<div v-if="activeTab === 'ratelimit'" class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Max İstek</label>
<input v-model="formData.max_requests" type="number" class="form-control" required>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Süre (Saniye)</label>
<input v-model="formData.window_seconds" type="number" class="form-control"
required>
</div>
</div>
<div v-if="activeTab === 'ratelimit'" class="mb-3">
<label class="form-label">ıklama</label>
<input v-model="formData.description" type="text" class="form-control">
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" v-model="formData.is_active"
id="isActiveCheck">
<label class="form-check-label" for="isActiveCheck">Aktif</label>
</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 { CorsWhitelistItem, CorsBlacklistItem, RateLimitItem } from '~~/types/security';
import Swal from 'sweetalert2';
definePageMeta({
layout: 'admin',
middleware: 'admin'
});
const config = useRuntimeConfig();
const { data: authData } = useAuth();
const loading = ref(false);
const activeTab = ref<'whitelist' | 'blacklist' | 'ratelimit'>('whitelist');
const showDeleted = ref(false); // Global toggle for simplicity, or could be per tab
const whitelistItems = ref<CorsWhitelistItem[]>([]);
const blacklistItems = ref<CorsBlacklistItem[]>([]);
const rateLimitItems = ref<RateLimitItem[]>([]);
const formData = ref<any>({});
const isEditing = ref(false);
let securityModal: any = null;
const modalTitle = computed(() => {
const action = isEditing.value ? 'Düzenle' : 'Ekle';
if (activeTab.value === 'whitelist') return `Whitelist ${action}`;
if (activeTab.value === 'blacklist') return `Blacklist ${action}`;
return `Rate Limit ${action}`;
});
// Watch for tab or filter changes
watch([activeTab, showDeleted], () => {
fetchItems();
});
// -- API HANDLERS --
const fetchItems = async () => {
try {
const query = showDeleted.value ? { deleted: 'true' } : {};
const headers = { Authorization: `Bearer ${(authData.value as any)?.accessToken}` };
if (activeTab.value === 'whitelist') {
const res = await $fetch<any>('/api/v1/admin/cors/whitelist', { baseURL: config.public.BASE_API_URL, headers, query });
whitelistItems.value = res.items || [];
} else if (activeTab.value === 'blacklist') {
const res = await $fetch<any>('/api/v1/admin/cors/blacklist', { baseURL: config.public.BASE_API_URL, headers, query });
blacklistItems.value = res.items || [];
} else {
const res = await $fetch<any>('/api/v1/admin/rate-limit', { baseURL: config.public.BASE_API_URL, headers, query });
rateLimitItems.value = res.items || [];
}
} catch (error) {
console.error('Fetch error:', error);
// Fallback or empty list on error
if (activeTab.value === 'whitelist') whitelistItems.value = [];
else if (activeTab.value === 'blacklist') blacklistItems.value = [];
else rateLimitItems.value = [];
}
};
const openModal = (tab: 'whitelist' | 'blacklist' | 'ratelimit', item: any = null) => {
activeTab.value = tab;
if (item) {
isEditing.value = true;
formData.value = { ...item };
} else {
isEditing.value = false;
formData.value = { is_active: true };
}
const modalEl = document.getElementById('securityModal');
if (modalEl) {
// @ts-ignore
securityModal = new bootstrap.Modal(modalEl);
securityModal.show();
}
};
const saveItem = async () => {
loading.value = true;
try {
let endpoint = '';
if (activeTab.value === 'whitelist') endpoint = '/api/v1/admin/cors/whitelist';
else if (activeTab.value === 'blacklist') endpoint = '/api/v1/admin/cors/blacklist';
else endpoint = '/api/v1/admin/rate-limit';
let method: 'POST' | 'PUT' = 'POST';
if (isEditing.value && formData.value.ID) {
endpoint += `/${formData.value.ID}`;
method = 'PUT';
}
// Convert numbers for RateLimit
if (activeTab.value === 'ratelimit') {
formData.value.max_requests = Number(formData.value.max_requests);
formData.value.window_seconds = Number(formData.value.window_seconds);
}
await $fetch(endpoint, {
method,
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` },
body: formData.value
});
if (securityModal) securityModal.hide();
Swal.fire('Başarılı', 'İşlem başarıyla tamamlandı.', 'success');
fetchItems();
} catch (error) {
console.error(error);
Swal.fire('Hata', 'İşlem başarısız.', 'error');
} finally {
loading.value = false;
}
};
const deleteItem = async (tab: string, id: number) => {
const result = await Swal.fire({
title: 'Silme Tipi?',
text: "Kayıt nasıl silinsin?",
icon: 'warning',
showCancelButton: true,
showDenyButton: true,
confirmButtonText: 'Soft Sil (Arşivle)',
denyButtonText: 'Hard Sil (Kalıcı)',
cancelButtonText: 'İptal'
});
if (result.isDismissed) return;
const isHard = result.isDenied;
try {
let endpoint = '';
if (tab === 'whitelist') endpoint = `/api/v1/admin/cors/whitelist/${id}`;
else if (tab === 'blacklist') endpoint = `/api/v1/admin/cors/blacklist/${id}`;
else endpoint = `/api/v1/admin/rate-limit/${id}`;
if (isHard) endpoint += '/hard';
await $fetch(endpoint, {
method: 'DELETE',
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` }
});
Swal.fire('Silindi!', isHard ? 'Kayıt kalıcı olarak silindi.' : 'Kayıt arşivlendi (soft delete).', 'success');
fetchItems();
} catch (error) {
console.error(error);
Swal.fire('Hata', 'Silme işlemi başarısız.', 'error');
}
};
const restoreItem = async (tab: string, id: number) => {
const result = await Swal.fire({
title: 'Emin misiniz?',
text: "Kayıt geri yüklenecek.",
icon: 'question',
showCancelButton: true,
confirmButtonText: 'Geri Yükle',
cancelButtonText: 'İptal'
});
if (!result.isConfirmed) return;
try {
let endpoint = '';
if (tab === 'whitelist') endpoint = `/api/v1/admin/cors/whitelist/${id}/restore`;
else if (tab === 'blacklist') endpoint = `/api/v1/admin/cors/blacklist/${id}/restore`;
else endpoint = `/api/v1/admin/rate-limit/${id}/restore`;
await $fetch(endpoint, {
method: 'POST', // Assuming POST for restore action
baseURL: config.public.BASE_API_URL,
headers: { Authorization: `Bearer ${(authData.value as any)?.accessToken}` }
});
Swal.fire('Başarılı', 'Kayıt geri yüklendi.', 'success');
fetchItems();
} catch (error) {
console.error(error);
Swal.fire('Hata', 'Geri yükleme başarısız. Endpoint mevcut olmayabilir.', 'error');
}
};
onMounted(() => {
// Bootstrap tabs events
const tabEls = document.querySelectorAll('button[data-bs-toggle="tab"]');
tabEls.forEach(tabEl => {
tabEl.addEventListener('shown.bs.tab', (event: any) => {
const targetId = event.target.getAttribute('data-bs-target');
if (targetId === '#whitelist') activeTab.value = 'whitelist';
if (targetId === '#blacklist') activeTab.value = 'blacklist';
if (targetId === '#ratelimit') activeTab.value = 'ratelimit';
// fetchItems() is called by watcher
});
});
fetchItems(); // Initial fetch
});
</script>
```

View File

@@ -0,0 +1,578 @@
<template>
<div>
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Ayarlar</h2>
</div>
<ul class="nav nav-tabs mb-4" id="settingsTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="general-tab" data-bs-toggle="tab" data-bs-target="#general" type="button"
role="tab" aria-controls="general" aria-selected="true">Genel Ayarlar</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="hero-tab" data-bs-toggle="tab" data-bs-target="#hero" type="button" role="tab"
aria-controls="hero" aria-selected="false">Hero (Banner) Ayarları</button>
</li>
</ul>
<div class="tab-content" id="settingsTabContent">
<!-- GENERAL SETTINGS TAB -->
<div class="tab-pane fade show active" id="general" role="tabpanel" aria-labelledby="general-tab">
<div class="card">
<div class="card-body">
<form @submit.prevent="updateSettings">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Site Başlığı</label>
<input v-model="settingForm.title" type="text" class="form-control">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Slogan</label>
<input v-model="settingForm.slogan" type="text" class="form-control">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Telefon</label>
<input v-model="settingForm.phone" type="text" class="form-control">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Email</label>
<input v-model="settingForm.email" type="email" class="form-control">
</div>
<div class="col-md-12 mb-3">
<label class="form-label">Adres</label>
<textarea v-model="settingForm.address" class="form-control" rows="2"></textarea>
</div>
<div class="col-md-12 mb-3">
<label class="form-label">Harita Embed Code</label>
<textarea v-model="settingForm.map_embed" class="form-control" rows="3"></textarea>
</div>
<div class="col-md-12 mb-3">
<label class="form-label">Copyright Metni</label>
<input v-model="settingForm.copyright" type="text" class="form-control">
</div>
</div>
<h5 class="mt-4">SEO Ayarları</h5>
<hr>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Meta Başlık</label>
<input v-model="settingForm.meta_title" type="text" class="form-control">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Meta ıklama</label>
<input v-model="settingForm.meta_description" type="text" class="form-control">
</div>
</div>
<h5 class="mt-4">Sosyal Medya</h5>
<hr>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">Facebook</label>
<input v-model="settingForm.facebook" type="text" class="form-control">
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Twitter / X</label>
<input v-model="settingForm.x" type="text" class="form-control">
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Instagram</label>
<input v-model="settingForm.instagram" type="text" class="form-control">
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Linkedin</label>
<input v-model="settingForm.linkedin" type="text" class="form-control">
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Pinterest</label>
<input v-model="settingForm.pinterest" type="text" class="form-control">
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Whatsapp</label>
<input v-model="settingForm.whatsapp" type="text" class="form-control">
</div>
</div>
<h5 class="mt-4">Logolar</h5>
<hr>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Beyaz Logo (Dark Mod için)</label>
<input @change="handleFileUpload($event, 'w_logo')" type="file" class="form-control">
<div v-if="settingForm.w_logo && typeof settingForm.w_logo === 'string'" class="mt-2">
<img :src="config.public.BASE_API_URL + settingForm.w_logo" height="50" class="bg-dark p-1">
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Siyah Logo (Light Mod için)</label>
<input @change="handleFileUpload($event, 'b_logo')" type="file" class="form-control">
<div v-if="settingForm.b_logo && typeof settingForm.b_logo === 'string'" class="mt-2">
<img :src="config.public.BASE_API_URL + settingForm.b_logo" height="50" class="bg-light border p-1">
</div>
</div>
</div>
<h5 class="mt-4">Resim Optimizasyon Ayarları (Logo İçin)</h5>
<hr>
<div class="row bg-light p-3 rounded mx-1">
<div class="col-md-3 mb-2">
<label class="form-label">Format</label>
<select v-model="imageSettings.format" class="form-select">
<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-md-3 mb-2">
<label class="form-label">Kalite (1-100)</label>
<input v-model="imageSettings.quality" type="number" min="1" max="100" class="form-control">
</div>
<div class="col-md-3 mb-2">
<label class="form-label">Genişlik (px)</label>
<input v-model="imageSettings.width" type="number" class="form-control" placeholder="Opsiyonel">
</div>
<div class="col-md-3 mb-2">
<label class="form-label">Yükseklik (px)</label>
<input v-model="imageSettings.height" type="number" class="form-control" placeholder="Opsiyonel">
</div>
</div>
<div class="mt-4 text-end">
<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>
<!-- HERO SETTINGS TAB -->
<div class="tab-pane fade" id="hero" role="tabpanel" aria-labelledby="hero-tab">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center bg-white">
<h5 class="mb-0">Hero Listesi</h5>
<button class="btn btn-success btn-sm" @click="openHeroModal()"><i class="fas fa-plus me-2"></i> Yeni
Ekle</button>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Resim</th>
<th>Başlık</th>
<th>Alt Başlıklar</th>
<th>Durum</th>
<th class="text-end" width="100">İşlemler</th>
</tr>
</thead>
<tbody>
<tr v-for="hero in heroes" :key="hero.ID">
<td>
<img v-if="hero.image" :src="config.public.BASE_API_URL + hero.image"
style="max-width: 60px; height: 40px; object-fit: cover;" class="rounded border">
<span v-else>-</span>
</td>
<td>{{ hero.title }}</td>
<td>
<small class="d-block text-muted">{{ hero.text1 }}</small>
<small class="d-block text-muted">{{ hero.text2 }}</small>
</td>
<td>
<span class="badge" :class="hero.is_active ? 'bg-success' : 'bg-secondary'">
{{ hero.is_active ? 'Aktif' : 'Pasif' }}
</span>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-primary" @click="openHeroModal(hero)" title="Düzenle">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-outline-danger" @click="deleteHero(hero.ID)" title="Sil">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
<tr v-if="heroes.length === 0">
<td colspan="5" class="text-center py-4">Kayıt bulunamadı.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- HERO MODAL -->
<div class="modal fade" id="heroModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ isEditingHero ? 'Hero Düzenle' : 'Yeni Hero Ekle' }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form @submit.prevent="saveHero">
<div class="row">
<div class="col-md-12 mb-3">
<label class="form-label">Başlık</label>
<input v-model="heroForm.title" type="text" class="form-control" required>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Text 1</label>
<input v-model="heroForm.text1" type="text" class="form-control">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Text 2</label>
<input v-model="heroForm.text2" type="text" class="form-control">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Text 4</label>
<input v-model="heroForm.text4" type="text" class="form-control">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Text 5</label>
<input v-model="heroForm.text5" type="text" class="form-control">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Renk</label>
<div class="input-group">
<input v-model="heroForm.color" type="color" class="form-control form-control-color"
title="Renk Seçin">
<input v-model="heroForm.color" type="text" class="form-control" placeholder="#ffffff">
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Durum</label>
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" v-model="heroForm.is_active" id="heroActive">
<label class="form-check-label" for="heroActive">Aktif</label>
</div>
</div>
<div class="col-md-12 mb-3">
<label class="form-label">Görsel</label>
<input @change="handleHeroFileUpload($event)" type="file" class="form-control">
<div v-if="heroForm.image && typeof heroForm.image === 'string'" class="mt-2">
<small>Mevcut Görsel:</small>
<img :src="config.public.BASE_API_URL + heroForm.image" height="80" class="d-block mt-1 rounded">
</div>
</div>
<div class="col-md-12 mb-3">
<div class="p-3 bg-light rounded border">
<h6>Resim Optimizasyon Ayarları</h6>
<div class="row">
<div class="col-md-3 mb-2">
<label class="form-label">Format</label>
<select v-model="imageSettings.format" class="form-select">
<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-md-3 mb-2">
<label class="form-label">Kalite</label>
<input v-model="imageSettings.quality" type="number" min="1" max="100" class="form-control">
</div>
<div class="col-md-3 mb-2">
<label class="form-label">Genişlik</label>
<input v-model="imageSettings.width" type="number" class="form-control" placeholder="Opsiyonel">
</div>
<div class="col-md-3 mb-2">
<label class="form-label">Yükseklik</label>
<input v-model="imageSettings.height" type="number" class="form-control"
placeholder="Opsiyonel">
</div>
</div>
</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 { Setting } from '~~/types/setting';
import type { Hero } from '~~/types/hero';
import Swal from 'sweetalert2';
definePageMeta({
layout: 'admin',
middleware: 'admin'
});
const config = useRuntimeConfig();
const { data: authData } = useAuth();
const loading = ref(false);
// --- SETTINGS ---
const settingForm = ref<Partial<Setting>>({});
const w_logo_file = ref<File | null>(null);
const b_logo_file = ref<File | null>(null);
const imageSettings = ref({
format: 'avif',
quality: 80,
width: null as number | null,
height: null as number | null
});
// Fetch Settings
const fetchSettings = async () => {
try {
const data = await $fetch<Setting>('/api/v1/setting', {
baseURL: config.public.BASE_API_URL,
});
if (data) {
settingForm.value = { ...data };
}
} catch (error) {
console.error('Settings fetch error:', error);
}
};
const handleFileUpload = (event: Event, type: 'w_logo' | 'b_logo') => {
const target = event.target as HTMLInputElement;
if (target.files && target.files[0]) {
if (type === 'w_logo') w_logo_file.value = target.files[0];
if (type === 'b_logo') b_logo_file.value = target.files[0];
}
};
const optimizeImage = async (file: File): Promise<File> => {
const formData = new FormData();
formData.append('file', file);
formData.append('format', imageSettings.value.format);
formData.append('quality', String(imageSettings.value.quality));
if (imageSettings.value.width) formData.append('width', String(imageSettings.value.width));
if (imageSettings.value.height) formData.append('height', String(imageSettings.value.height));
const optimizedBlob = await $fetch<Blob>('/api/optimize', {
method: 'POST',
body: formData,
responseType: 'blob'
});
const ext = imageSettings.value.format === 'jpg' ? 'jpeg' : imageSettings.value.format;
const filename = `img-${Date.now()}-${Math.floor(Math.random() * 10000)}.${ext === 'jpeg' ? 'jpg' : ext}`;
const optimizedFile = new File([optimizedBlob], filename, {
type: `image/${ext}`
});
return optimizedFile;
};
const updateSettings = async () => {
loading.value = true;
try {
const formData = new FormData();
Object.keys(settingForm.value).forEach(key => {
const value = (settingForm.value as any)[key];
if (value !== null && value !== undefined && key !== 'w_logo' && key !== 'b_logo' && key !== 'ID' && key !== 'CreatedAt' && key !== 'UpdatedAt' && key !== 'DeletedAt') {
formData.append(key, String(value));
}
});
if (w_logo_file.value) {
const optimized = await optimizeImage(w_logo_file.value);
formData.append('w_logo', optimized);
}
if (b_logo_file.value) {
const optimized = await optimizeImage(b_logo_file.value);
formData.append('b_logo', optimized);
}
formData.append('is_active', 'true');
let url = '/api/v1/setting';
let method: 'POST' | 'PUT' = 'POST';
if (settingForm.value.ID) {
url = `/api/v1/setting/${settingForm.value.ID}`;
method = 'PUT';
}
await $fetch(url, {
method: method,
baseURL: config.public.BASE_API_URL,
body: formData,
headers: {
Authorization: `Bearer ${(authData.value as any)?.accessToken}`
}
});
Swal.fire('Başarılı', 'Ayarlar güncellendi.', 'success');
fetchSettings(); // Refresh
} catch (error) {
console.error(error);
Swal.fire('Hata', 'Güncelleme sırasında hata oluştu.', 'error');
} finally {
loading.value = false;
}
};
// --- HERO ---
const heroes = ref<Hero[]>([]);
const heroForm = ref<Partial<Hero>>({});
const heroImageFile = ref<File | null>(null);
const isEditingHero = ref(false);
let heroModal: any = null;
const fetchHeroes = async () => {
try {
const response = await $fetch<any>('/api/v1/heroes', {
baseURL: config.public.BASE_API_URL,
});
if (response && Array.isArray(response.data)) {
heroes.value = response.data;
} else if (Array.isArray(response)) {
heroes.value = response;
} else if (response && typeof response === 'object') {
heroes.value = [response]; // Wrap single object
} else {
heroes.value = [];
}
} catch (error) {
console.error('Hero fetch error:', error);
}
};
const openHeroModal = (hero: Hero | null = null) => {
if (hero) {
isEditingHero.value = true;
heroForm.value = { ...hero };
} else {
isEditingHero.value = false;
heroForm.value = { is_active: true };
}
heroImageFile.value = null;
// Initialize Modal
const modalEl = document.getElementById('heroModal');
if (modalEl) {
// @ts-ignore
heroModal = new bootstrap.Modal(modalEl);
heroModal.show();
}
};
const handleHeroFileUpload = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files && target.files[0]) {
heroImageFile.value = target.files[0];
}
};
const saveHero = async () => {
loading.value = true;
try {
const formData = new FormData();
Object.keys(heroForm.value).forEach(key => {
const value = (heroForm.value as any)[key];
if (value !== null && value !== undefined && key !== 'image' && key !== 'ID' && key !== 'CreatedAt' && key !== 'UpdatedAt' && key !== 'DeletedAt') {
formData.append(key, String(value));
}
});
if (heroImageFile.value) {
const optimized = await optimizeImage(heroImageFile.value);
formData.append('image', optimized);
}
// Ensure is_active sent as string 'true'/'false' if backend expects it
// formData.set('is_active', String(heroForm.value.is_active));
let url = '/api/v1/hero';
let method: 'POST' | 'PUT' = 'POST';
if (isEditingHero.value && heroForm.value.ID) {
url = `/api/v1/hero/${heroForm.value.ID}`;
method = 'PUT';
}
await $fetch(url, {
method: method,
baseURL: config.public.BASE_API_URL,
body: formData,
headers: {
Authorization: `Bearer ${(authData.value as any)?.accessToken}`
}
});
if (heroModal) heroModal.hide();
Swal.fire('Başarılı', `Hero ${isEditingHero.value ? 'güncellendi' : 'eklendi'}.`, 'success');
fetchHeroes();
} catch (error) {
console.error(error);
Swal.fire('Hata', 'İşlem başarısız.', 'error');
} finally {
loading.value = false;
}
};
const deleteHero = async (id: number) => {
const result = await Swal.fire({
title: 'Emin misiniz?',
text: "Bu hero kaydını silmek istediğinize emin misiniz?",
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Evet, Sil',
cancelButtonText: 'İptal'
});
if (result.isConfirmed) {
try {
await $fetch(`/api/v1/hero/${id}`, {
method: 'DELETE',
baseURL: config.public.BASE_API_URL,
headers: {
Authorization: `Bearer ${(authData.value as any)?.accessToken}`
}
});
Swal.fire('Silindi!', 'Kayıt başarıyla silindi.', 'success');
fetchHeroes();
} catch (error) {
console.error(error);
Swal.fire('Hata', 'Silme işlemi başarısız.', 'error');
}
}
};
onMounted(() => {
fetchSettings();
fetchHeroes();
});
</script>
<style scoped>
/* Tabs styling */
.nav-tabs .nav-link {
color: #495057;
}
.nav-tabs .nav-link.active {
font-weight: bold;
color: #0d6efd;
}
</style>

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>

177
app/pages/auth/login.vue Normal file
View File

@@ -0,0 +1,177 @@
<template>
<div class="login-area pt-120 pb-120">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-6 col-md-8">
<div class="login-form-wrap shadow-lg p-5 rounded bg-white">
<div class="login-header text-center mb-4">
<h2 class="mb-2">Giriş Yap</h2>
<p>
Hesabınız yok mu?
<NuxtLink to="/auth/register" class="text-primary fw-bold">Kayıt Olun</NuxtLink>
</p>
</div>
<form @submit.prevent="handleLogin">
<div class="mb-3">
<label for="email" class="form-label">E-posta Adresi</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-envelope"></i></span>
<input type="email" class="form-control" id="email" v-model="form.email"
placeholder="E-posta adresinizi girin" required>
</div>
<div v-if="errors.email" class="text-danger small mt-1">{{ errors.email }}</div>
</div>
<div class="mb-3">
<label for="password" class="form-label">Şifre</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-lock"></i></span>
<input type="password" class="form-control" id="password" v-model="form.password"
placeholder="Şifrenizi girin" required>
</div>
<div v-if="errors.password" class="text-danger small mt-1">{{ errors.password }}</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="remember-me">
<label class="form-check-label" for="remember-me">Beni Hatırla</label>
</div>
<a href="#" class="text-muted small">Şifremi Unuttum?</a>
</div>
<!-- Turnstile Component -->
<ClientOnly>
<div class="mb-4 d-flex justify-content-center">
<NuxtTurnstile v-model="turnstileToken" />
</div>
</ClientOnly>
<button type="submit" class="btn btn-primary w-100 py-2" :disabled="loading">
<span v-if="loading" class="spinner-border spinner-border-sm me-2" role="status"
aria-hidden="true"></span>
{{ loading ? 'Giriş Yapılıyor...' : 'Giriş Yap' }}
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { loginSchema, type LoginInput } from '~~/utils/validations';
import Swal from 'sweetalert2';
definePageMeta({
middleware: 'guest-only',
auth: { unauthenticatedOnly: true, navigateAuthenticatedTo: '/' }
});
const { signIn } = useAuth();
const router = useRouter();
// Reactive form state
const form = reactive<LoginInput>({
email: '',
password: ''
});
// Validation errors state
const errors = reactive<Partial<Record<keyof LoginInput, string>>>({});
const loading = ref(false);
const turnstileToken = ref('');
const validate = () => {
const result = loginSchema.safeParse(form);
// Clear previous errors
Object.keys(errors).forEach(key => delete errors[key as keyof LoginInput]);
if (!result.success) {
result.error.errors.forEach(err => {
if (err.path[0]) {
errors[err.path[0] as keyof LoginInput] = err.message;
}
});
return false;
}
return true;
};
const handleLogin = async () => {
if (!validate()) return;
if (!turnstileToken.value) {
Swal.fire({
icon: 'warning',
title: 'Güvenlik Kontrolü',
text: 'Lütfen Turnstile güvenlik kontrolünü tamamlayın.',
timer: 3000,
toast: true,
position: 'top-end',
showConfirmButton: false
});
return;
}
loading.value = true;
try {
const result = await signIn('credentials', {
email: form.email,
password: form.password,
redirect: false,
});
if (result?.error) {
Swal.fire({
icon: 'error',
title: 'Giriş Başarısız',
text: 'E-posta veya şifre hatalı.',
toast: true,
position: 'top-end',
showConfirmButton: false,
timer: 3000
});
} else {
Swal.fire({
icon: 'success',
title: 'Giriş Başarılı',
text: 'Yönlendiriliyorsunuz...',
toast: true,
position: 'top-end',
showConfirmButton: false,
timer: 1500
});
setTimeout(() => {
router.push('/');
}, 1000);
}
} catch (error) {
console.error(error);
Swal.fire({
icon: 'error',
title: 'Hata',
text: 'Bir şeyler ters gitti, lütfen tekrar deneyin.',
});
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.login-area {
background-color: #f8f9fa;
min-height: 80vh;
display: flex;
align-items: center;
}
.login-form-wrap {
border-top: 5px solid #0d6efd;
}
</style>

222
app/pages/auth/register.vue Normal file
View File

@@ -0,0 +1,222 @@
<template>
<div class="register-area pt-120 pb-120">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-8 col-md-10">
<div class="register-form-wrap shadow-lg p-5 rounded bg-white">
<div class="register-header text-center mb-4">
<h2 class="mb-2">Hesap Oluşturun</h2>
<p>
Zaten hesabınız var mı?
<NuxtLink to="/auth/login" class="text-primary fw-bold">Giriş Yapın</NuxtLink>
</p>
</div>
<form @submit.prevent="handleRegister">
<div class="mb-3">
<label for="username" class="form-label">Kullanıcı Adı</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-user"></i></span>
<input type="text" class="form-control" id="username" v-model="form.username"
placeholder="Kullanıcı adı seçin" required>
</div>
<div v-if="errors.username" class="text-danger small mt-1">{{ errors.username }}</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="first_name" class="form-label">Ad</label>
<input type="text" class="form-control" id="first_name" v-model="form.first_name"
placeholder="Adınız" required>
<div v-if="errors.first_name" class="text-danger small mt-1">{{ errors.first_name }}
</div>
</div>
<div class="col-md-6 mb-3">
<label for="last_name" class="form-label">Soyad</label>
<input type="text" class="form-control" id="last_name" v-model="form.last_name"
placeholder="Soyadınız" required>
<div v-if="errors.last_name" class="text-danger small mt-1">{{ errors.last_name }}
</div>
</div>
</div>
<div class="mb-3">
<label for="email" class="form-label">E-posta Adresi</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-envelope"></i></span>
<input type="email" class="form-control" id="email" v-model="form.email"
placeholder="E-posta adresiniz" required>
</div>
<div v-if="errors.email" class="text-danger small mt-1">{{ errors.email }}</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="password" class="form-label">Şifre</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-lock"></i></span>
<input type="password" class="form-control" id="password"
v-model="form.password" placeholder="Şifreniz" required>
</div>
<div v-if="errors.password" class="text-danger small mt-1">{{ errors.password }}
</div>
</div>
<div class="col-md-6 mb-3">
<label for="passwordConfirm" class="form-label">Şifre Tekrar</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-lock"></i></span>
<input type="password" class="form-control" id="passwordConfirm"
v-model="form.passwordConfirm" placeholder="Şifrenizi tekrar girin"
required>
</div>
<div v-if="errors.passwordConfirm" class="text-danger small mt-1">{{
errors.passwordConfirm }}</div>
</div>
</div>
<!-- Turnstile Component -->
<ClientOnly>
<div class="mb-4 d-flex justify-content-center">
<NuxtTurnstile v-model="turnstileToken" />
</div>
</ClientOnly>
<button type="submit" class="btn btn-success w-100 py-2" :disabled="loading">
<span v-if="loading" class="spinner-border spinner-border-sm me-2" role="status"
aria-hidden="true"></span>
{{ loading ? 'Kayıt Yapılıyor...' : 'Kayıt Ol' }}
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { registerSchema, type RegisterInput } from '~~/utils/validations';
import Swal from 'sweetalert2';
definePageMeta({
middleware: 'guest-only',
auth: { unauthenticatedOnly: true, navigateAuthenticatedTo: '/' }
});
const config = useRuntimeConfig();
const router = useRouter();
const form = reactive<RegisterInput>({
username: '',
first_name: '',
last_name: '',
email: '',
password: '',
passwordConfirm: ''
});
const errors = reactive<Partial<Record<keyof RegisterInput, string>>>({});
const loading = ref(false);
const turnstileToken = ref('');
const validate = () => {
// Zod validasyonu
const result = registerSchema.safeParse(form);
// Clear previous errors
Object.keys(errors).forEach(key => delete errors[key as keyof RegisterInput]);
if (!result.success) {
result.error.errors.forEach(err => {
if (err.path[0]) {
errors[err.path[0] as keyof RegisterInput] = err.message;
}
});
return false;
}
return true;
};
const handleRegister = async () => {
if (!validate()) return;
if (!turnstileToken.value) {
Swal.fire({
icon: 'warning',
title: 'Güvenlik Kontrolü',
text: 'Lütfen Turnstile güvenlik kontrolünü tamamlayın.',
timer: 3000,
toast: true,
position: 'top-end',
showConfirmButton: false
});
return;
}
loading.value = true;
try {
const apiUrl = config.public.NUXT_PUBLIC_API_BASE || 'http://127.0.0.1:8080';
// Backend API çağrısı
const response = await fetch(`${apiUrl}/api/v1/auth/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: form.username,
first_name: form.first_name,
last_name: form.last_name,
email: form.email,
password: form.password
})
});
if (response.ok) {
const data = await response.json();
Swal.fire({
icon: 'success',
title: 'Kayıt Başarılı!',
text: data.message || 'Lütfen e-posta adresinizi doğrulayın.',
confirmButtonText: 'Giriş Yap'
}).then((result) => {
if (result.isConfirmed) {
router.push('/auth/login');
}
});
} else {
const errorData = await response.json();
Swal.fire({
icon: 'error',
title: 'Kayıt Hatası',
text: errorData.message || 'Kayıt işlemi sırasında bir hata oluştu.',
});
}
} catch (error) {
console.error(error);
Swal.fire({
icon: 'error',
title: 'Hata',
text: 'Sunucu ile iletişim hatası.',
});
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.register-area {
background-color: #f8f9fa;
min-height: 80vh;
display: flex;
align-items: center;
}
.register-form-wrap {
border-top: 5px solid #198754;
}
</style>

View File

@@ -0,0 +1,150 @@
<template>
<div class="resend-verify-area pt-120 pb-120">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-6 col-md-8">
<div class="resend-form-wrap shadow-lg p-5 rounded bg-white">
<div class="header text-center mb-4">
<h2 class="mb-2">Doğrulama E-postası Gönder</h2>
<p class="text-muted">
E-posta adresinizi girin, doğrulama bağlantısını tekrar gönderelim.
</p>
</div>
<form @submit.prevent="handleResend">
<div class="mb-3">
<label for="email" class="form-label">E-posta Adresi</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-envelope"></i></span>
<input type="email" class="form-control" id="email" v-model="email"
placeholder="E-posta adresinizi girin" required>
</div>
<div v-if="error" class="text-danger small mt-1">{{ error }}</div>
</div>
<!-- Turnstile Component -->
<ClientOnly>
<div class="mb-4 d-flex justify-content-center">
<NuxtTurnstile v-model="turnstileToken" />
</div>
</ClientOnly>
<button type="submit" class="btn btn-primary w-100 py-2" :disabled="loading">
<span v-if="loading" class="spinner-border spinner-border-sm me-2" role="status"
aria-hidden="true"></span>
{{ loading ? 'Gönderiliyor...' : 'Gönder' }}
</button>
</form>
<div class="text-center mt-4">
<NuxtLink to="/auth/login" class="text-decoration-none">
<i class="fas fa-arrow-left me-1"></i> Giriş sayfasına dön
</NuxtLink>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { z } from 'zod'; // Import z directly if not exported from validations, or use from validations if available
import Swal from 'sweetalert2';
definePageMeta({
middleware: 'guest-only',
auth: { unauthenticatedOnly: true, navigateAuthenticatedTo: '/' }
});
const config = useRuntimeConfig();
const email = ref('');
const error = ref('');
const loading = ref(false);
const turnstileToken = ref('');
const emailSchema = z.string().email({ message: 'Geçerli bir e-posta adresi giriniz' });
const validate = () => {
const result = emailSchema.safeParse(email.value);
if (!result.success) {
error.value = result.error.errors[0].message;
return false;
}
error.value = '';
return true;
};
const handleResend = async () => {
if (!validate()) return;
if (!turnstileToken.value) {
Swal.fire({
icon: 'warning',
title: 'Güvenlik Kontrolü',
text: 'Lütfen Turnstile güvenlik kontrolünü tamamlayın.',
timer: 3000,
toast: true,
position: 'top-end',
showConfirmButton: false
});
return;
}
loading.value = true;
try {
const apiUrl = config.public.NUXT_PUBLIC_API_BASE || 'http://127.0.0.1:8080';
const response = await fetch(`${apiUrl}/api/v1/auth/resend-verification`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: email.value
})
});
if (response.ok) {
Swal.fire({
icon: 'success',
title: 'Başarılı!',
text: 'Doğrulama bağlantısı e-posta adresinize gönderildi.',
confirmButtonText: 'Tamam'
});
email.value = ''; // Reset form
turnstileToken.value = ''; // Reset token ideally, but might need manual reset
} else {
const data = await response.json();
Swal.fire({
icon: 'error',
title: 'Hata',
text: data.message || data.error || 'İşlem başarısız oldu.',
});
}
} catch (err) {
console.error(err);
Swal.fire({
icon: 'error',
title: 'Hata',
text: 'Sunucu ile iletişim hatası.',
});
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.resend-verify-area {
background-color: #f8f9fa;
min-height: 80vh;
display: flex;
align-items: center;
}
.resend-form-wrap {
border-top: 5px solid #0d6efd;
}
</style>

114
app/pages/auth/verify.vue Normal file
View File

@@ -0,0 +1,114 @@
<template>
<div class="verify-area pt-120 pb-120">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-6 col-md-8">
<div class="verify-content text-center shadow-lg p-5 rounded bg-white">
<div v-if="loading" class="text-center py-5">
<div class="spinner-border text-primary" role="status" style="width: 3rem; height: 3rem;">
<span class="visually-hidden">Yükleniyor...</span>
</div>
<p class="mt-3 text-muted fs-5">E-posta adresiniz doğrulanıyor...</p>
</div>
<div v-else-if="success">
<div class="mb-4">
<div class="success-icon d-inline-flex align-items-center justify-content-center bg-success text-white rounded-circle"
style="width: 80px; height: 80px;">
<i class="fas fa-check fa-3x"></i>
</div>
</div>
<h3 class="mb-3 text-success">Doğrulama Başarılı!</h3>
<p class="text-muted mb-4 fs-5">Hesabınız başarıyla doğrulandı. Artık giriş yapabilirsiniz.
</p>
<NuxtLink to="/auth/login" class="btn btn-primary btn-lg w-100">
Giriş Yap
</NuxtLink>
</div>
<div v-else>
<div class="mb-4">
<div class="error-icon d-inline-flex align-items-center justify-content-center bg-danger text-white rounded-circle"
style="width: 80px; height: 80px;">
<i class="fas fa-times fa-3x"></i>
</div>
</div>
<h3 class="mb-3 text-danger">Doğrulama Hatası</h3>
<p class="text-muted mb-4 fs-5">{{ displayError }}</p>
<NuxtLink to="/auth/login" class="btn btn-outline-primary">
Giriş sayfasına dön
</NuxtLink>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const route = useRoute();
const config = useRuntimeConfig();
definePageMeta({
middleware: 'guest-only',
auth: { unauthenticatedOnly: true, navigateAuthenticatedTo: '/' }
});
const loading = ref(true);
const success = ref(false);
const errorMessage = ref('');
const displayError = computed(() => {
return errorMessage.value || 'Doğrulama işlemi sırasında bir hata oluştu.';
});
onMounted(async () => {
const token = route.query.token as string;
if (!token) {
loading.value = false;
errorMessage.value = "Geçersiz doğrulama bağlantısı (token eksik).";
return;
}
try {
const apiUrl = config.public.NUXT_PUBLIC_API_BASE || 'http://127.0.0.1:8080';
// Backend API: GET /api/v1/auth/verify-email?token=...
const response = await fetch(`${apiUrl}/api/v1/auth/verify-email?token=${token}`, {
method: 'GET',
headers: {
'accept': 'application/json'
}
});
if (response.ok) {
success.value = true;
} else {
const data = await response.json();
errorMessage.value = data.message || 'Token geçersiz veya süresi dolmuş.';
}
} catch (error) {
console.error("Verify error:", error);
errorMessage.value = 'Sunucu ile bağlantı kurulamadı.';
} finally {
loading.value = false;
}
});
</script>
<style scoped>
.verify-area {
background-color: #f8f9fa;
min-height: 80vh;
display: flex;
align-items: center;
}
.verify-content {
border-top: 5px solid #0d6efd;
}
</style>

61
app/pages/index.vue Normal file
View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import Paginate from "~/components/Paginate.vue";
import FooterArea from "~/components/FooterArea.vue";
import SubsribeStart from "~/components/SubsribeStart.vue";
import TagsClouds from "~/components/TagsClouds.vue";
import WidgetSidebarImgBox from "~/components/home/WidgetSidebarImgBox.vue";
import WidgetRecentPost from "~/components/home/WidgetRecentPost.vue";
import WidgetCategories from "~/components/home/WidgetCategories.vue";
import WidgetSearch from "~/components/home/WidgetSearch.vue";
import BlogPage from "~/components/home/BlogPage.vue";
import PagesHeroHeader from "~/components/home/PagesHeroHeader.vue";
import MobileHeader from "~/components/MobileHeader.vue";
import HeaderSection from "~/components/HeaderSection.vue";
import Preloader from "~/components/home/Preloader.vue";
import PaginaContainer from "~/components/PaginaContainer.vue";
</script>
<template>
<div>
<Preloader/>
<!--=====progress START=======-->
<PaginaContainer/>
<!--=====progress END=======-->
<!--=====HEADER START=======-->
<HeaderSection/>
<!--=====HEADER END=======-->
<!--=====Mobile header start=======-->
<MobileHeader/>
<!--=====Mobile header end=======-->
<!--=====pages hero header end=======-->
<PagesHeroHeader/>
<!--=====pages hero header end=======-->
<div class="blog-page sp2">
<div class="container">
<div class="row">
<BlogPage/>
<div class="col-lg-4 col-md-6">
<div class="widgets lg-ml-15">
<WidgetSearch/>
<WidgetCategories/>
<WidgetSidebarImgBox/>
<TagsClouds/>
</div>
</div>
</div>
</div>
</div>
<!--=====SUBSRIBE START=======-->
<SubsribeStart/>
<!--=====SUBSRIBE END=======-->
<!--=====Footer start=======-->
<FooterArea/>
<!--=====Footer end=======-->
<!--================== sidebar===================== -->
</div>
</template>
<style scoped>
</style>