main
Some checks failed
UI Deploy (Next-Auth Support) 🎨 / build-and-deploy (push) Has been cancelled

This commit is contained in:
Harun CAN
2026-03-28 17:16:46 +03:00
parent 863d8ed3d9
commit 9b4ee36fca
2 changed files with 306 additions and 53 deletions

View File

@@ -56,8 +56,10 @@ export default function SourceAccountsPage() {
try { try {
const res = await fetch('/api/backend/source-accounts', { headers: getHeaders() }); const res = await fetch('/api/backend/source-accounts', { headers: getHeaders() });
if (res.ok) { if (res.ok) {
const data = await res.json(); const responseData = await res.json();
setAccounts(Array.isArray(data) ? data : []); // Handle wrapped response from global interceptor: { success, data }
const items = responseData?.data || responseData;
setAccounts(Array.isArray(items) ? items : []);
} }
} catch (error) { } catch (error) {
console.error('Fetch error:', error); console.error('Fetch error:', error);
@@ -80,16 +82,19 @@ export default function SourceAccountsPage() {
body: JSON.stringify({ url: newAccountUrl }), body: JSON.stringify({ url: newAccountUrl }),
}); });
if (res.ok) { const responseData = await res.json();
toaster.create({ title: "Source account added", type: "success" });
if (res.ok && responseData?.success !== false) {
toaster.create({ title: "Kaynak hesap eklendi", type: "success" });
setNewAccountUrl(""); setNewAccountUrl("");
setShowAddModal(false); setShowAddModal(false);
fetchAccounts(); fetchAccounts();
} else { } else {
throw new Error('Failed to add account'); const errorMsg = responseData?.message || 'Hesap eklenemedi';
toaster.create({ title: errorMsg, type: "error" });
} }
} catch (error) { } catch (error) {
toaster.create({ title: "Failed to add account", type: "error" }); toaster.create({ title: "Hesap eklenemedi", type: "error" });
} finally { } finally {
setIsAdding(false); setIsAdding(false);
} }

View File

@@ -1,14 +1,12 @@
"use client"; "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 { useState, useEffect, useCallback } from "react";
import { useSession } from "next-auth/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 { toaster } from "@/components/ui/feedback/toaster";
import { ContentPreviewDialog } from "./ContentPreviewDialog"; import { ContentPreviewDialog } from "./ContentPreviewDialog";
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
switch (status?.toLowerCase()) { switch (status?.toLowerCase()) {
case "published": return "green"; case "published": return "green";
@@ -19,6 +17,8 @@ const getStatusColor = (status: string) => {
} }
}; };
const PAGE_SIZE_OPTIONS = [25, 50, 100];
export function ContentTable() { export function ContentTable() {
const { data: session } = useSession(); const { data: session } = useSession();
const [selectedItem, setSelectedItem] = useState<any>(null); const [selectedItem, setSelectedItem] = useState<any>(null);
@@ -26,6 +26,19 @@ export function ContentTable() {
const [contentList, setContentList] = useState<any[]>([]); const [contentList, setContentList] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false); 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 () => { const fetchContent = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
try { try {
@@ -34,26 +47,81 @@ export function ContentTable() {
headers['Authorization'] = `Bearer ${session.accessToken}`; 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, headers,
}); });
if (res.ok) { if (res.ok) {
const responseData = await res.json(); const responseData = await res.json();
// Handle wrapped response from global interceptor: { success, data, message } const data = responseData?.data || responseData;
const items = responseData?.data || responseData; // Handle new { items, total } format
setContentList(Array.isArray(items) ? items : []); 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) { } catch (error) {
console.error("Failed to fetch content:", error); console.error("Failed to fetch content:", error);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [session]); }, [session, pageSize, currentPage, sortBy, sortOrder]);
useEffect(() => { useEffect(() => {
fetchContent(); fetchContent();
}, [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) => { const handleDelete = async (item: any) => {
if (!session?.accessToken) return; if (!session?.accessToken) return;
try { try {
@@ -64,24 +132,54 @@ export function ContentTable() {
} }
}); });
if (res.ok) { 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(); fetchContent();
} else { } else {
toaster.create({ title: "Delete failed", type: "error" }); toaster.create({ title: "Silme başarısız", type: "error" });
} }
} catch { } 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) => { const handleAction = (action: string, item: any) => {
if (action === 'View') { if (action === 'View' || action === 'Edit') {
setSelectedItem(item);
setIsPreviewOpen(true);
return;
}
if (action === 'Edit') {
// Open the preview in edit mode
setSelectedItem(item); setSelectedItem(item);
setIsPreviewOpen(true); setIsPreviewOpen(true);
return; 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 ( return (
<> <>
<HStack justify="flex-end" mb={3}> {/* Top toolbar */}
<Button <Flex justify="space-between" align="center" mb={3} gap={3} wrap="wrap">
size="sm" <HStack gap={2}>
variant="outline" {selectedIds.size > 0 && (
onClick={fetchContent} <Button
loading={isLoading} size="sm"
> colorPalette="red"
<LuRefreshCw /> Refresh onClick={handleBulkDelete}
</Button> loading={isDeleting}
</HStack> >
<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 /> Yenile
</Button>
</HStack>
</Flex>
<Box borderWidth="1px" borderRadius="lg" overflow="hidden" bg="bg.panel"> <Box borderWidth="1px" borderRadius="lg" overflow="hidden" bg="bg.panel">
<Table.Root striped> <Table.Root striped>
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.ColumnHeader>Title</Table.ColumnHeader> <Table.ColumnHeader width="40px">
<Table.ColumnHeader>Platform</Table.ColumnHeader> <Checkbox.Root
<Table.ColumnHeader>Status</Table.ColumnHeader> checked={isAllSelected}
<Table.ColumnHeader>Date</Table.ColumnHeader> onCheckedChange={(e) => handleSelectAll(!!e.checked)}
<Table.ColumnHeader textAlign="right">Actions</Table.ColumnHeader> >
<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.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{isLoading ? ( {isLoading ? (
<Table.Row> <Table.Row>
<Table.Cell colSpan={5} textAlign="center" py={10}> <Table.Cell colSpan={6} textAlign="center" py={10}>
<HStack justify="center" gap={2}> <HStack justify="center" gap={2}>
<LuRefreshCw className="animate-spin" /> <LuRefreshCw className="animate-spin" />
<Box>Loading content...</Box> <Box>İçerikler yükleniyor...</Box>
</HStack> </HStack>
</Table.Cell> </Table.Cell>
</Table.Row> </Table.Row>
) : contentList.length === 0 ? ( ) : contentList.length === 0 ? (
<Table.Row> <Table.Row>
<Table.Cell colSpan={5} textAlign="center" py={10}> <Table.Cell colSpan={6} textAlign="center" py={10}>
No content found. Start by generating some! İçerik bulunamadı. Önce içerik üretin!
</Table.Cell> </Table.Cell>
</Table.Row> </Table.Row>
) : contentList.map((item) => ( ) : contentList.map((item) => (
<Table.Row key={item.id}> <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 fontWeight="medium"> <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'} {item.title || item.body?.substring(0, 60) + '...' || 'Untitled'}
</Table.Cell> </Table.Cell>
<Table.Cell>{item.type || item.platform}</Table.Cell>
<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} {item.status}
</Badge> </Badge>
</Table.Cell> </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"> <Table.Cell textAlign="right">
<HStack justify="flex-end" gap={2}> <HStack justify="flex-end" gap={1}>
<IconButton <IconButton
variant="ghost" variant="ghost"
size="sm" size="sm"
aria-label="View" aria-label="Görüntüle"
onClick={() => handleAction('View', item)} onClick={() => handleAction('View', item)}
> >
<LuEye /> <LuEye />
@@ -156,7 +353,7 @@ export function ContentTable() {
<IconButton <IconButton
variant="ghost" variant="ghost"
size="sm" size="sm"
aria-label="Edit" aria-label="Düzenle"
onClick={() => handleAction('Edit', item)} onClick={() => handleAction('Edit', item)}
> >
<LuPencil /> <LuPencil />
@@ -165,7 +362,7 @@ export function ContentTable() {
variant="ghost" variant="ghost"
size="sm" size="sm"
colorPalette="red" colorPalette="red"
aria-label="Delete" aria-label="Sil"
onClick={() => handleAction('Delete', item)} onClick={() => handleAction('Delete', item)}
> >
<LuTrash2 /> <LuTrash2 />
@@ -178,6 +375,57 @@ export function ContentTable() {
</Table.Root> </Table.Root>
</Box> </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 <ContentPreviewDialog
item={selectedItem} item={selectedItem}