generated from fahricansecer/boilerplate-fe
main
Some checks failed
UI Deploy (Next-Auth Support) 🎨 / build-and-deploy (push) Has been cancelled
Some checks failed
UI Deploy (Next-Auth Support) 🎨 / build-and-deploy (push) Has been cancelled
This commit is contained in:
@@ -56,8 +56,10 @@ export default function SourceAccountsPage() {
|
||||
try {
|
||||
const res = await fetch('/api/backend/source-accounts', { headers: getHeaders() });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setAccounts(Array.isArray(data) ? data : []);
|
||||
const responseData = await res.json();
|
||||
// Handle wrapped response from global interceptor: { success, data }
|
||||
const items = responseData?.data || responseData;
|
||||
setAccounts(Array.isArray(items) ? items : []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fetch error:', error);
|
||||
@@ -80,16 +82,19 @@ export default function SourceAccountsPage() {
|
||||
body: JSON.stringify({ url: newAccountUrl }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toaster.create({ title: "Source account added", type: "success" });
|
||||
const responseData = await res.json();
|
||||
|
||||
if (res.ok && responseData?.success !== false) {
|
||||
toaster.create({ title: "Kaynak hesap eklendi", type: "success" });
|
||||
setNewAccountUrl("");
|
||||
setShowAddModal(false);
|
||||
fetchAccounts();
|
||||
} else {
|
||||
throw new Error('Failed to add account');
|
||||
const errorMsg = responseData?.message || 'Hesap eklenemedi';
|
||||
toaster.create({ title: errorMsg, type: "error" });
|
||||
}
|
||||
} catch (error) {
|
||||
toaster.create({ title: "Failed to add account", type: "error" });
|
||||
toaster.create({ title: "Hesap eklenemedi", type: "error" });
|
||||
} finally {
|
||||
setIsAdding(false);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Table, Badge, HStack, IconButton, Button } from "@chakra-ui/react";
|
||||
import { Box, Table, Badge, HStack, IconButton, Button, Text, Flex, Checkbox } from "@chakra-ui/react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { LuEye, LuPencil, LuTrash2, LuRefreshCw } from "react-icons/lu";
|
||||
import { LuEye, LuPencil, LuTrash2, LuRefreshCw, LuArrowUp, LuArrowDown, LuChevronLeft, LuChevronRight } from "react-icons/lu";
|
||||
import { toaster } from "@/components/ui/feedback/toaster";
|
||||
import { ContentPreviewDialog } from "./ContentPreviewDialog";
|
||||
|
||||
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status?.toLowerCase()) {
|
||||
case "published": return "green";
|
||||
@@ -19,6 +17,8 @@ const getStatusColor = (status: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [25, 50, 100];
|
||||
|
||||
export function ContentTable() {
|
||||
const { data: session } = useSession();
|
||||
const [selectedItem, setSelectedItem] = useState<any>(null);
|
||||
@@ -26,6 +26,19 @@ export function ContentTable() {
|
||||
const [contentList, setContentList] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Pagination
|
||||
const [pageSize, setPageSize] = useState(100);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
|
||||
// Sorting
|
||||
const [sortBy, setSortBy] = useState<string>("createdAt");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
||||
|
||||
// Multi-select
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const fetchContent = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
@@ -34,26 +47,81 @@ export function ContentTable() {
|
||||
headers['Authorization'] = `Bearer ${session.accessToken}`;
|
||||
}
|
||||
|
||||
const res = await fetch('/api/backend/content', {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(pageSize),
|
||||
offset: String(currentPage * pageSize),
|
||||
sortBy,
|
||||
sortOrder,
|
||||
});
|
||||
|
||||
const res = await fetch(`/api/backend/content?${params.toString()}`, {
|
||||
headers,
|
||||
});
|
||||
if (res.ok) {
|
||||
const responseData = await res.json();
|
||||
// Handle wrapped response from global interceptor: { success, data, message }
|
||||
const items = responseData?.data || responseData;
|
||||
setContentList(Array.isArray(items) ? items : []);
|
||||
const data = responseData?.data || responseData;
|
||||
// Handle new { items, total } format
|
||||
if (data?.items && typeof data.total === 'number') {
|
||||
setContentList(Array.isArray(data.items) ? data.items : []);
|
||||
setTotalItems(data.total);
|
||||
} else {
|
||||
// Fallback for old array format
|
||||
const items = Array.isArray(data) ? data : [];
|
||||
setContentList(items);
|
||||
setTotalItems(items.length);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch content:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [session]);
|
||||
}, [session, pageSize, currentPage, sortBy, sortOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchContent();
|
||||
}, [fetchContent]);
|
||||
|
||||
// Reset page when pageSize changes
|
||||
useEffect(() => {
|
||||
setCurrentPage(0);
|
||||
}, [pageSize]);
|
||||
|
||||
// Clear selection when data changes
|
||||
useEffect(() => {
|
||||
setSelectedIds(new Set());
|
||||
}, [contentList]);
|
||||
|
||||
const handleSort = (field: string) => {
|
||||
if (sortBy === field) {
|
||||
setSortOrder(prev => prev === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortBy(field);
|
||||
setSortOrder("desc");
|
||||
}
|
||||
setCurrentPage(0);
|
||||
};
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedIds(new Set(contentList.map(item => item.id)));
|
||||
} else {
|
||||
setSelectedIds(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectItem = (id: string, checked: boolean) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (checked) {
|
||||
next.add(id);
|
||||
} else {
|
||||
next.delete(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = async (item: any) => {
|
||||
if (!session?.accessToken) return;
|
||||
try {
|
||||
@@ -64,24 +132,54 @@ export function ContentTable() {
|
||||
}
|
||||
});
|
||||
if (res.ok) {
|
||||
toaster.create({ title: "Deleted", description: `"${item.title || 'Content'}" removed.`, type: "success" });
|
||||
toaster.create({ title: "Silindi", description: `İçerik kaldırıldı.`, type: "success" });
|
||||
fetchContent();
|
||||
} else {
|
||||
toaster.create({ title: "Delete failed", type: "error" });
|
||||
toaster.create({ title: "Silme başarısız", type: "error" });
|
||||
}
|
||||
} catch {
|
||||
toaster.create({ title: "Delete failed", type: "error" });
|
||||
toaster.create({ title: "Silme başarısız", type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (session?.accessToken) {
|
||||
headers['Authorization'] = `Bearer ${session.accessToken}`;
|
||||
}
|
||||
|
||||
const res = await fetch('/api/backend/content/bulk-delete', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ ids: Array.from(selectedIds) }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const result = await res.json();
|
||||
const deletedCount = result?.data?.deleted || result?.deleted || selectedIds.size;
|
||||
toaster.create({
|
||||
title: "Toplu silme başarılı",
|
||||
description: `${deletedCount} içerik silindi.`,
|
||||
type: "success",
|
||||
});
|
||||
setSelectedIds(new Set());
|
||||
fetchContent();
|
||||
} else {
|
||||
toaster.create({ title: "Toplu silme başarısız", type: "error" });
|
||||
}
|
||||
} catch {
|
||||
toaster.create({ title: "Toplu silme başarısız", type: "error" });
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = (action: string, item: any) => {
|
||||
if (action === 'View') {
|
||||
setSelectedItem(item);
|
||||
setIsPreviewOpen(true);
|
||||
return;
|
||||
}
|
||||
if (action === 'Edit') {
|
||||
// Open the preview in edit mode
|
||||
if (action === 'View' || action === 'Edit') {
|
||||
setSelectedItem(item);
|
||||
setIsPreviewOpen(true);
|
||||
return;
|
||||
@@ -92,63 +190,162 @@ export function ContentTable() {
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(totalItems / pageSize);
|
||||
const isAllSelected = contentList.length > 0 && selectedIds.size === contentList.length;
|
||||
|
||||
const SortIcon = ({ field }: { field: string }) => {
|
||||
if (sortBy !== field) return null;
|
||||
return sortOrder === "asc"
|
||||
? <LuArrowUp style={{ display: "inline", marginLeft: 4 }} size={14} />
|
||||
: <LuArrowDown style={{ display: "inline", marginLeft: 4 }} size={14} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<HStack justify="flex-end" mb={3}>
|
||||
{/* Top toolbar */}
|
||||
<Flex justify="space-between" align="center" mb={3} gap={3} wrap="wrap">
|
||||
<HStack gap={2}>
|
||||
{selectedIds.size > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
colorPalette="red"
|
||||
onClick={handleBulkDelete}
|
||||
loading={isDeleting}
|
||||
>
|
||||
<LuTrash2 /> {selectedIds.size} öğeyi sil
|
||||
</Button>
|
||||
)}
|
||||
<Text fontSize="sm" color="fg.muted">
|
||||
Toplam {totalItems} içerik
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
<HStack gap={2}>
|
||||
{/* Page size selector */}
|
||||
<HStack gap={1}>
|
||||
<Text fontSize="sm" color="fg.muted">Göster:</Text>
|
||||
{PAGE_SIZE_OPTIONS.map(size => (
|
||||
<Button
|
||||
key={size}
|
||||
size="xs"
|
||||
variant={pageSize === size ? "solid" : "outline"}
|
||||
onClick={() => setPageSize(size)}
|
||||
>
|
||||
{size}
|
||||
</Button>
|
||||
))}
|
||||
</HStack>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={fetchContent}
|
||||
loading={isLoading}
|
||||
>
|
||||
<LuRefreshCw /> Refresh
|
||||
<LuRefreshCw /> Yenile
|
||||
</Button>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
<Box borderWidth="1px" borderRadius="lg" overflow="hidden" bg="bg.panel">
|
||||
<Table.Root striped>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeader>Title</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>Platform</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>Status</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>Date</Table.ColumnHeader>
|
||||
<Table.ColumnHeader textAlign="right">Actions</Table.ColumnHeader>
|
||||
<Table.ColumnHeader width="40px">
|
||||
<Checkbox.Root
|
||||
checked={isAllSelected}
|
||||
onCheckedChange={(e) => handleSelectAll(!!e.checked)}
|
||||
>
|
||||
<Checkbox.HiddenInput />
|
||||
<Checkbox.Control />
|
||||
</Checkbox.Root>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader
|
||||
cursor="pointer"
|
||||
onClick={() => handleSort("createdAt")}
|
||||
_hover={{ color: "fg" }}
|
||||
>
|
||||
Başlık
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader
|
||||
cursor="pointer"
|
||||
onClick={() => handleSort("type")}
|
||||
_hover={{ color: "fg" }}
|
||||
>
|
||||
Platform <SortIcon field="type" />
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader
|
||||
cursor="pointer"
|
||||
onClick={() => handleSort("status")}
|
||||
_hover={{ color: "fg" }}
|
||||
>
|
||||
Durum <SortIcon field="status" />
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader
|
||||
cursor="pointer"
|
||||
onClick={() => handleSort("createdAt")}
|
||||
_hover={{ color: "fg" }}
|
||||
>
|
||||
Tarih <SortIcon field="createdAt" />
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader textAlign="right">İşlemler</Table.ColumnHeader>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{isLoading ? (
|
||||
<Table.Row>
|
||||
<Table.Cell colSpan={5} textAlign="center" py={10}>
|
||||
<Table.Cell colSpan={6} textAlign="center" py={10}>
|
||||
<HStack justify="center" gap={2}>
|
||||
<LuRefreshCw className="animate-spin" />
|
||||
<Box>Loading content...</Box>
|
||||
<Box>İçerikler yükleniyor...</Box>
|
||||
</HStack>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
) : contentList.length === 0 ? (
|
||||
<Table.Row>
|
||||
<Table.Cell colSpan={5} textAlign="center" py={10}>
|
||||
No content found. Start by generating some!
|
||||
<Table.Cell colSpan={6} textAlign="center" py={10}>
|
||||
İçerik bulunamadı. Önce içerik üretin!
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
) : contentList.map((item) => (
|
||||
<Table.Row key={item.id}>
|
||||
<Table.Cell fontWeight="medium">
|
||||
<Table.Row key={item.id} bg={selectedIds.has(item.id) ? "blue.50" : undefined} _dark={selectedIds.has(item.id) ? { bg: "blue.900/20" } : undefined}>
|
||||
<Table.Cell>
|
||||
<Checkbox.Root
|
||||
checked={selectedIds.has(item.id)}
|
||||
onCheckedChange={(e) => handleSelectItem(item.id, !!e.checked)}
|
||||
>
|
||||
<Checkbox.HiddenInput />
|
||||
<Checkbox.Control />
|
||||
</Checkbox.Root>
|
||||
</Table.Cell>
|
||||
<Table.Cell fontWeight="medium" maxW="300px" truncate>
|
||||
{item.title || item.body?.substring(0, 60) + '...' || 'Untitled'}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{item.type || item.platform}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge colorPalette={getStatusColor(item.status)} variant="solid">
|
||||
<Badge variant="subtle" size="sm">
|
||||
{item.type || item.platform}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge colorPalette={getStatusColor(item.status)} variant="solid" size="sm">
|
||||
{item.status}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{new Date(item.createdAt || item.date).toLocaleDateString()}</Table.Cell>
|
||||
<Table.Cell fontSize="sm" color="fg.muted">
|
||||
{new Date(item.createdAt || item.date).toLocaleDateString('tr-TR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</Table.Cell>
|
||||
<Table.Cell textAlign="right">
|
||||
<HStack justify="flex-end" gap={2}>
|
||||
<HStack justify="flex-end" gap={1}>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="View"
|
||||
aria-label="Görüntüle"
|
||||
onClick={() => handleAction('View', item)}
|
||||
>
|
||||
<LuEye />
|
||||
@@ -156,7 +353,7 @@ export function ContentTable() {
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="Edit"
|
||||
aria-label="Düzenle"
|
||||
onClick={() => handleAction('Edit', item)}
|
||||
>
|
||||
<LuPencil />
|
||||
@@ -165,7 +362,7 @@ export function ContentTable() {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
colorPalette="red"
|
||||
aria-label="Delete"
|
||||
aria-label="Sil"
|
||||
onClick={() => handleAction('Delete', item)}
|
||||
>
|
||||
<LuTrash2 />
|
||||
@@ -178,6 +375,57 @@ export function ContentTable() {
|
||||
</Table.Root>
|
||||
</Box>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<Flex justify="space-between" align="center" mt={3}>
|
||||
<Text fontSize="sm" color="fg.muted">
|
||||
Sayfa {currentPage + 1} / {totalPages} ({totalItems} içerik)
|
||||
</Text>
|
||||
<HStack gap={1}>
|
||||
<IconButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="Önceki sayfa"
|
||||
disabled={currentPage === 0}
|
||||
onClick={() => setCurrentPage(prev => Math.max(0, prev - 1))}
|
||||
>
|
||||
<LuChevronLeft />
|
||||
</IconButton>
|
||||
{/* Show page numbers */}
|
||||
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
|
||||
let pageNum: number;
|
||||
if (totalPages <= 5) {
|
||||
pageNum = i;
|
||||
} else if (currentPage < 3) {
|
||||
pageNum = i;
|
||||
} else if (currentPage > totalPages - 4) {
|
||||
pageNum = totalPages - 5 + i;
|
||||
} else {
|
||||
pageNum = currentPage - 2 + i;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={pageNum}
|
||||
size="sm"
|
||||
variant={pageNum === currentPage ? "solid" : "outline"}
|
||||
onClick={() => setCurrentPage(pageNum)}
|
||||
>
|
||||
{pageNum + 1}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
<IconButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="Sonraki sayfa"
|
||||
disabled={currentPage >= totalPages - 1}
|
||||
onClick={() => setCurrentPage(prev => Math.min(totalPages - 1, prev + 1))}
|
||||
>
|
||||
<LuChevronRight />
|
||||
</IconButton>
|
||||
</HStack>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<ContentPreviewDialog
|
||||
item={selectedItem}
|
||||
|
||||
Reference in New Issue
Block a user