first commit
This commit is contained in:
325
app/pages/admin/blog/posts/[id].vue
Normal file
325
app/pages/admin/blog/posts/[id].vue
Normal 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>
|
||||
253
app/pages/admin/blog/posts/create.vue
Normal file
253
app/pages/admin/blog/posts/create.vue
Normal 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>
|
||||
252
app/pages/admin/blog/posts/index.vue
Normal file
252
app/pages/admin/blog/posts/index.vue
Normal 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>
|
||||
Reference in New Issue
Block a user