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>