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 {
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);
}

View File

@@ -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}>
<Button
size="sm"
variant="outline"
onClick={fetchContent}
loading={isLoading}
>
<LuRefreshCw /> Refresh
</Button>
</HStack>
{/* 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 /> 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}