Initial commit: igny8 project

This commit is contained in:
igny8
2025-11-09 10:27:02 +00:00
commit 60b8188111
27265 changed files with 4360521 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
import PageMeta from "../components/common/PageMeta";
import ComponentCard from "../components/common/ComponentCard";
export default function Analytics() {
return (
<>
<PageMeta title="Analytics - IGNY8" description="Performance analytics" />
<ComponentCard title="Coming Soon" desc="Performance analytics">
<div className="text-center py-8">
<p className="text-gray-600 dark:text-gray-400">
Analytics - Coming Soon
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Performance analytics and reporting for data-driven decisions
</p>
</div>
</ComponentCard>
</>
);
}

View File

@@ -0,0 +1,40 @@
import React from "react";
import GridShape from "../../components/common/GridShape";
import { Link } from "react-router";
import ThemeTogglerTwo from "../../components/common/ThemeTogglerTwo";
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="relative p-6 bg-white z-1 dark:bg-gray-900 sm:p-0">
<div className="relative flex flex-col justify-center w-full h-screen lg:flex-row dark:bg-gray-900 sm:p-0">
{children}
<div className="items-center hidden w-full h-full lg:w-1/2 bg-brand-950 dark:bg-white/5 lg:grid">
<div className="relative flex items-center justify-center z-1">
{/* <!-- ===== Common Grid Shape Start ===== --> */}
<GridShape />
<div className="flex flex-col items-center max-w-xs">
<Link to="/" className="block mb-4">
<img
width={231}
height={48}
src="/images/logo/auth-logo.svg"
alt="Logo"
/>
</Link>
<p className="text-center text-gray-400 dark:text-white/60">
Free and Open-Source Tailwind CSS Admin Dashboard Template
</p>
</div>
</div>
</div>
<div className="fixed z-50 hidden bottom-6 right-6 sm:block">
<ThemeTogglerTwo />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import PageMeta from "../../components/common/PageMeta";
import AuthLayout from "./AuthPageLayout";
import SignInForm from "../../components/auth/SignInForm";
export default function SignIn() {
return (
<>
<PageMeta
title="React.js SignIn Dashboard | TailAdmin - Next.js Admin Dashboard Template"
description="This is React.js SignIn Tables Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
/>
<AuthLayout>
<SignInForm />
</AuthLayout>
</>
);
}

View File

@@ -0,0 +1,17 @@
import PageMeta from "../../components/common/PageMeta";
import AuthLayout from "./AuthPageLayout";
import SignUpForm from "../../components/auth/SignUpForm";
export default function SignUp() {
return (
<>
<PageMeta
title="React.js SignUp Dashboard | TailAdmin - Next.js Admin Dashboard Template"
description="This is React.js SignUp Tables Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
/>
<AuthLayout>
<SignUpForm />
</AuthLayout>
</>
);
}

View File

@@ -0,0 +1,94 @@
import { useState, useEffect } from 'react';
import PageMeta from '../../components/common/PageMeta';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { fetchCreditBalance, CreditBalance } from '../../services/api';
import { Card } from '../../components/ui/card';
import Badge from '../../components/ui/badge/Badge';
export default function Credits() {
const toast = useToast();
const [balance, setBalance] = useState<CreditBalance | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadBalance();
}, []);
const loadBalance = async () => {
try {
setLoading(true);
const data = await fetchCreditBalance();
setBalance(data);
} catch (error: any) {
toast.error(`Failed to load credit balance: ${error.message}`);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="p-6">
<PageMeta title="Credits" />
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading...</div>
</div>
</div>
);
}
return (
<div className="p-6">
<PageMeta title="Credits" />
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Credit Balance</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">Manage your AI credits and usage</p>
</div>
{balance && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Card className="p-6">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400">Current Balance</h3>
</div>
<div className="text-3xl font-bold text-gray-900 dark:text-white">
{balance.credits.toLocaleString()}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">Available credits</p>
</Card>
<Card className="p-6">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400">Monthly Allocation</h3>
</div>
<div className="text-3xl font-bold text-gray-900 dark:text-white">
{balance.plan_credits_per_month.toLocaleString()}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">Credits per month</p>
</Card>
<Card className="p-6">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400">Used This Month</h3>
</div>
<div className="text-3xl font-bold text-gray-900 dark:text-white">
{balance.credits_used_this_month.toLocaleString()}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">Credits consumed</p>
</Card>
<Card className="p-6">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400">Remaining</h3>
</div>
<div className="text-3xl font-bold text-gray-900 dark:text-white">
{balance.credits_remaining.toLocaleString()}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">Credits remaining</p>
</Card>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,103 @@
import { useState, useEffect } from 'react';
import PageMeta from '../../components/common/PageMeta';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { fetchCreditTransactions, CreditTransaction } from '../../services/api';
import { Card } from '../../components/ui/card';
import Badge from '../../components/ui/badge/Badge';
export default function Transactions() {
const toast = useToast();
const [transactions, setTransactions] = useState<CreditTransaction[]>([]);
const [loading, setLoading] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
useEffect(() => {
loadTransactions();
}, [currentPage]);
const loadTransactions = async () => {
try {
setLoading(true);
const response = await fetchCreditTransactions({ page: currentPage });
setTransactions(response.results || []);
setTotalPages(Math.ceil((response.count || 0) / 50));
} catch (error: any) {
toast.error(`Failed to load transactions: ${error.message}`);
} finally {
setLoading(false);
}
};
const getTransactionTypeColor = (type: string) => {
switch (type) {
case 'purchase':
case 'subscription':
return 'success';
case 'deduction':
return 'error';
default:
return 'primary';
}
};
return (
<div className="p-6">
<PageMeta title="Credit Transactions" />
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Credit Transactions</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">View all credit transactions and history</p>
</div>
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading...</div>
</div>
) : (
<Card className="p-6">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700">
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Date</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Type</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Amount</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Balance After</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Description</th>
</tr>
</thead>
<tbody>
{transactions.map((transaction) => (
<tr key={transaction.id} className="border-b border-gray-100 dark:border-gray-800">
<td className="py-3 px-4 text-sm text-gray-900 dark:text-white">
{new Date(transaction.created_at).toLocaleDateString()}
</td>
<td className="py-3 px-4">
<Badge variant="light" color={getTransactionTypeColor(transaction.transaction_type) as any}>
{transaction.transaction_type_display}
</Badge>
</td>
<td className={`py-3 px-4 text-sm font-medium ${
transaction.amount >= 0
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}`}>
{transaction.amount >= 0 ? '+' : ''}{transaction.amount.toLocaleString()}
</td>
<td className="py-3 px-4 text-sm text-gray-900 dark:text-white">
{transaction.balance_after.toLocaleString()}
</td>
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">
{transaction.description}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,302 @@
import { useState, useEffect } from 'react';
import PageMeta from '../../components/common/PageMeta';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { fetchCreditUsage, CreditUsageLog, fetchUsageLimits, LimitCard } from '../../services/api';
import { Card } from '../../components/ui/card';
import Badge from '../../components/ui/badge/Badge';
export default function Usage() {
const toast = useToast();
const [usageLogs, setUsageLogs] = useState<CreditUsageLog[]>([]);
const [limits, setLimits] = useState<LimitCard[]>([]);
const [loading, setLoading] = useState(true);
const [limitsLoading, setLimitsLoading] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
useEffect(() => {
loadUsage();
loadLimits();
}, [currentPage]);
const loadUsage = async () => {
try {
setLoading(true);
const response = await fetchCreditUsage({ page: currentPage });
setUsageLogs(response.results || []);
} catch (error: any) {
toast.error(`Failed to load usage logs: ${error.message}`);
} finally {
setLoading(false);
}
};
const loadLimits = async () => {
try {
setLimitsLoading(true);
const response = await fetchUsageLimits();
console.log('Usage limits response:', response);
setLimits(response.limits || []);
if (!response.limits || response.limits.length === 0) {
console.warn('No limits data received from API');
}
} catch (error: any) {
console.error('Error loading usage limits:', error);
toast.error(`Failed to load usage limits: ${error.message}`);
setLimits([]);
} finally {
setLimitsLoading(false);
}
};
const getCategoryColor = (category: string) => {
switch (category) {
case 'planner': return 'blue';
case 'writer': return 'green';
case 'images': return 'purple';
case 'ai': return 'orange';
case 'general': return 'gray';
default: return 'gray';
}
};
const getUsageStatus = (percentage: number) => {
if (percentage >= 90) return 'danger';
if (percentage >= 75) return 'warning';
return 'success';
};
const groupedLimits = {
planner: limits.filter(l => l.category === 'planner'),
writer: limits.filter(l => l.category === 'writer'),
images: limits.filter(l => l.category === 'images'),
ai: limits.filter(l => l.category === 'ai'),
general: limits.filter(l => l.category === 'general'),
};
// Debug info
console.log('[Usage Component] Render state:', {
limitsLoading,
limitsCount: limits.length,
groupedLimits,
plannerCount: groupedLimits.planner.length,
writerCount: groupedLimits.writer.length,
imagesCount: groupedLimits.images.length,
aiCount: groupedLimits.ai.length,
generalCount: groupedLimits.general.length,
});
return (
<div className="p-6">
<PageMeta title="Usage" />
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Usage</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">Monitor your plan limits and usage statistics</p>
</div>
{/* Debug Info - Remove in production */}
{process.env.NODE_ENV === 'development' && (
<Card className="p-4 mb-4 bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800">
<div className="text-xs text-gray-600 dark:text-gray-400">
<strong>Debug:</strong> Loading={limitsLoading ? 'Yes' : 'No'}, Limits={limits.length},
Planner={groupedLimits.planner.length}, Writer={groupedLimits.writer.length},
Images={groupedLimits.images.length}, AI={groupedLimits.ai.length}, General={groupedLimits.general.length}
</div>
</Card>
)}
{/* Limit Cards by Category */}
{limitsLoading ? (
<Card className="p-6 mb-8">
<div className="flex items-center justify-center h-32">
<div className="text-gray-500">Loading limits...</div>
</div>
</Card>
) : limits.length === 0 ? (
<Card className="p-6 mb-8">
<div className="text-center text-gray-500 dark:text-gray-400">
<p className="mb-2 font-medium">No usage limits data available.</p>
<p className="text-sm">The API endpoint may not be responding or your account may not have a plan configured.</p>
<p className="text-xs mt-2 text-gray-400">Check browser console for errors. Endpoint: /v1/billing/credits/usage/limits/</p>
</div>
</Card>
) : (
<div className="space-y-6 mb-8">
{/* Planner Limits */}
{groupedLimits.planner.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">Planner Limits</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{groupedLimits.planner.map((limit, idx) => (
<LimitCardComponent key={idx} limit={limit} />
))}
</div>
</div>
)}
{/* Writer Limits */}
{groupedLimits.writer.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">Writer Limits</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{groupedLimits.writer.map((limit, idx) => (
<LimitCardComponent key={idx} limit={limit} />
))}
</div>
</div>
)}
{/* Image Limits */}
{groupedLimits.images.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">Image Generation Limits</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{groupedLimits.images.map((limit, idx) => (
<LimitCardComponent key={idx} limit={limit} />
))}
</div>
</div>
)}
{/* AI Credits */}
{groupedLimits.ai.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">AI Credits</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{groupedLimits.ai.map((limit, idx) => (
<LimitCardComponent key={idx} limit={limit} />
))}
</div>
</div>
)}
{/* General Limits */}
{groupedLimits.general.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">General Limits</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{groupedLimits.general.map((limit, idx) => (
<LimitCardComponent key={idx} limit={limit} />
))}
</div>
</div>
)}
</div>
)}
{/* Usage Logs Table */}
<div className="mb-6">
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-4">Usage Logs</h2>
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading...</div>
</div>
) : (
<Card className="p-6">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700">
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Date</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Operation</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Credits Used</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Model</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Cost (USD)</th>
</tr>
</thead>
<tbody>
{usageLogs.map((log) => (
<tr key={log.id} className="border-b border-gray-100 dark:border-gray-800">
<td className="py-3 px-4 text-sm text-gray-900 dark:text-white">
{new Date(log.created_at).toLocaleString()}
</td>
<td className="py-3 px-4">
<Badge variant="light" color="primary">{log.operation_type_display}</Badge>
</td>
<td className="py-3 px-4 text-sm font-medium text-gray-900 dark:text-white">
{log.credits_used}
</td>
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">
{log.model_used || 'N/A'}
</td>
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">
{log.cost_usd ? `$${parseFloat(log.cost_usd).toFixed(4)}` : 'N/A'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
</div>
</div>
);
}
// Limit Card Component
function LimitCardComponent({ limit }: { limit: LimitCard }) {
const getCategoryColor = (category: string) => {
switch (category) {
case 'planner': return 'blue';
case 'writer': return 'green';
case 'images': return 'purple';
case 'ai': return 'orange';
case 'general': return 'gray';
default: return 'gray';
}
};
const getUsageStatus = (percentage: number) => {
if (percentage >= 90) return 'danger';
if (percentage >= 75) return 'warning';
return 'success';
};
const percentage = Math.min(limit.percentage, 100);
const status = getUsageStatus(percentage);
const color = getCategoryColor(limit.category);
const statusColorClass = status === 'danger'
? 'bg-red-500'
: status === 'warning'
? 'bg-yellow-500'
: 'bg-green-500';
const statusTextColor = status === 'danger'
? 'text-red-600 dark:text-red-400'
: status === 'warning'
? 'text-yellow-600 dark:text-yellow-400'
: 'text-green-600 dark:text-green-400';
return (
<Card className="p-4">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">{limit.title}</h3>
<Badge variant="light" color={color as any}>{limit.category}</Badge>
</div>
<div className="mb-3">
<div className="flex items-baseline gap-2">
<span className="text-2xl font-bold text-gray-900 dark:text-white">{limit.used.toLocaleString()}</span>
<span className="text-sm text-gray-500 dark:text-gray-400">/ {limit.limit.toLocaleString()}</span>
<span className="text-xs text-gray-400 dark:text-gray-500">{limit.unit}</span>
</div>
<div className="mt-2">
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className={`h-2 rounded-full ${statusColorClass}`}
style={{ width: `${percentage}%` }}
/>
</div>
</div>
</div>
<div className="flex items-center justify-between text-xs">
<span className={statusTextColor}>
{limit.available.toLocaleString()} available
</span>
<span className="text-gray-500 dark:text-gray-400">
{percentage.toFixed(1)}% used
</span>
</div>
</Card>
);
}

View File

@@ -0,0 +1,24 @@
import PageMeta from "../components/common/PageMeta";
export default function Blank() {
return (
<div>
<PageMeta
title="React.js Blank Dashboard | TailAdmin - Next.js Admin Dashboard Template"
description="This is React.js Blank Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
/>
<div className="min-h-screen rounded-2xl border border-gray-200 bg-white px-5 py-7 dark:border-gray-800 dark:bg-white/[0.03] xl:px-10 xl:py-12">
<div className="mx-auto w-full max-w-[630px] text-center">
<h3 className="mb-4 font-semibold text-gray-800 text-theme-xl dark:text-white/90 sm:text-2xl">
Card Title Here
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 sm:text-base">
Start putting content on grids or panels, you can also use different
combinations of grids.Please check out the dashboard and other pages
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,284 @@
import { useState, useRef, useEffect } from "react";
import FullCalendar from "@fullcalendar/react";
import dayGridPlugin from "@fullcalendar/daygrid";
import timeGridPlugin from "@fullcalendar/timegrid";
import interactionPlugin from "@fullcalendar/interaction";
import { EventInput, DateSelectArg, EventClickArg } from "@fullcalendar/core";
import { Modal } from "../components/ui/modal";
import { useModal } from "../hooks/useModal";
import PageMeta from "../components/common/PageMeta";
interface CalendarEvent extends EventInput {
extendedProps: {
calendar: string;
};
}
const Calendar: React.FC = () => {
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(
null
);
const [eventTitle, setEventTitle] = useState("");
const [eventStartDate, setEventStartDate] = useState("");
const [eventEndDate, setEventEndDate] = useState("");
const [eventLevel, setEventLevel] = useState("");
const [events, setEvents] = useState<CalendarEvent[]>([]);
const calendarRef = useRef<FullCalendar>(null);
const { isOpen, openModal, closeModal } = useModal();
const calendarsEvents = {
Danger: "danger",
Success: "success",
Primary: "primary",
Warning: "warning",
};
useEffect(() => {
// Initialize with some events
setEvents([
{
id: "1",
title: "Event Conf.",
start: new Date().toISOString().split("T")[0],
extendedProps: { calendar: "Danger" },
},
{
id: "2",
title: "Meeting",
start: new Date(Date.now() + 86400000).toISOString().split("T")[0],
extendedProps: { calendar: "Success" },
},
{
id: "3",
title: "Workshop",
start: new Date(Date.now() + 172800000).toISOString().split("T")[0],
end: new Date(Date.now() + 259200000).toISOString().split("T")[0],
extendedProps: { calendar: "Primary" },
},
]);
}, []);
const handleDateSelect = (selectInfo: DateSelectArg) => {
resetModalFields();
setEventStartDate(selectInfo.startStr);
setEventEndDate(selectInfo.endStr || selectInfo.startStr);
openModal();
};
const handleEventClick = (clickInfo: EventClickArg) => {
const event = clickInfo.event;
setSelectedEvent(event as unknown as CalendarEvent);
setEventTitle(event.title);
setEventStartDate(event.start?.toISOString().split("T")[0] || "");
setEventEndDate(event.end?.toISOString().split("T")[0] || "");
setEventLevel(event.extendedProps.calendar);
openModal();
};
const handleAddOrUpdateEvent = () => {
if (selectedEvent) {
// Update existing event
setEvents((prevEvents) =>
prevEvents.map((event) =>
event.id === selectedEvent.id
? {
...event,
title: eventTitle,
start: eventStartDate,
end: eventEndDate,
extendedProps: { calendar: eventLevel },
}
: event
)
);
} else {
// Add new event
const newEvent: CalendarEvent = {
id: Date.now().toString(),
title: eventTitle,
start: eventStartDate,
end: eventEndDate,
allDay: true,
extendedProps: { calendar: eventLevel },
};
setEvents((prevEvents) => [...prevEvents, newEvent]);
}
closeModal();
resetModalFields();
};
const resetModalFields = () => {
setEventTitle("");
setEventStartDate("");
setEventEndDate("");
setEventLevel("");
setSelectedEvent(null);
};
return (
<>
<PageMeta
title="React.js Calendar Dashboard | TailAdmin - Next.js Admin Dashboard Template"
description="This is React.js Calendar Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
/>
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div className="custom-calendar">
<FullCalendar
ref={calendarRef}
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
initialView="dayGridMonth"
headerToolbar={{
left: "prev,next addEventButton",
center: "title",
right: "dayGridMonth,timeGridWeek,timeGridDay",
}}
events={events}
selectable={true}
select={handleDateSelect}
eventClick={handleEventClick}
eventContent={renderEventContent}
customButtons={{
addEventButton: {
text: "Add Event +",
click: openModal,
},
}}
/>
</div>
<Modal
isOpen={isOpen}
onClose={closeModal}
className="max-w-[700px] p-6 lg:p-10"
>
<div className="flex flex-col px-2 overflow-y-auto custom-scrollbar">
<div>
<h5 className="mb-2 font-semibold text-gray-800 modal-title text-theme-xl dark:text-white/90 lg:text-2xl">
{selectedEvent ? "Edit Event" : "Add Event"}
</h5>
<p className="text-sm text-gray-500 dark:text-gray-400">
Plan your next big moment: schedule or edit an event to stay on
track
</p>
</div>
<div className="mt-8">
<div>
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
Event Title
</label>
<input
id="event-title"
type="text"
value={eventTitle}
onChange={(e) => setEventTitle(e.target.value)}
className="dark:bg-dark-900 h-11 w-full rounded-lg border border-gray-300 bg-transparent px-4 py-2.5 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800"
/>
</div>
</div>
<div className="mt-6">
<label className="block mb-4 text-sm font-medium text-gray-700 dark:text-gray-400">
Event Color
</label>
<div className="flex flex-wrap items-center gap-4 sm:gap-5">
{Object.entries(calendarsEvents).map(([key, value]) => (
<div key={key} className="n-chk">
<div
className={`form-check form-check-${value} form-check-inline`}
>
<label
className="flex items-center text-sm text-gray-700 form-check-label dark:text-gray-400"
htmlFor={`modal${key}`}
>
<span className="relative">
<input
className="sr-only form-check-input"
type="radio"
name="event-level"
value={key}
id={`modal${key}`}
checked={eventLevel === key}
onChange={() => setEventLevel(key)}
/>
<span className="flex items-center justify-center w-5 h-5 mr-2 border border-gray-300 rounded-full box dark:border-gray-700">
<span
className={`h-2 w-2 rounded-full bg-white ${
eventLevel === key ? "block" : "hidden"
}`}
></span>
</span>
</span>
{key}
</label>
</div>
</div>
))}
</div>
</div>
<div className="mt-6">
<label className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
Enter Start Date
</label>
<div className="relative">
<input
id="event-start-date"
type="date"
value={eventStartDate}
onChange={(e) => setEventStartDate(e.target.value)}
className="dark:bg-dark-900 h-11 w-full appearance-none rounded-lg border border-gray-300 bg-transparent bg-none px-4 py-2.5 pl-4 pr-11 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800"
/>
</div>
</div>
<div className="mt-6">
<label className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
Enter End Date
</label>
<div className="relative">
<input
id="event-end-date"
type="date"
value={eventEndDate}
onChange={(e) => setEventEndDate(e.target.value)}
className="dark:bg-dark-900 h-11 w-full appearance-none rounded-lg border border-gray-300 bg-transparent bg-none px-4 py-2.5 pl-4 pr-11 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800"
/>
</div>
</div>
</div>
<div className="flex items-center gap-3 mt-6 modal-footer sm:justify-end">
<button
onClick={closeModal}
type="button"
className="flex w-full justify-center rounded-lg border border-gray-300 bg-white px-4 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] sm:w-auto"
>
Close
</button>
<button
onClick={handleAddOrUpdateEvent}
type="button"
className="btn btn-success btn-update-event flex w-full justify-center rounded-lg bg-brand-500 px-4 py-2.5 text-sm font-medium text-white hover:bg-brand-600 sm:w-auto"
>
{selectedEvent ? "Update Changes" : "Add Event"}
</button>
</div>
</div>
</Modal>
</div>
</>
);
};
const renderEventContent = (eventInfo: any) => {
const colorClass = `fc-bg-${eventInfo.event.extendedProps.calendar.toLowerCase()}`;
return (
<div
className={`event-fc-color flex fc-event-main ${colorClass} p-1 rounded-sm`}
>
<div className="fc-daygrid-event-dot"></div>
<div className="fc-event-time">{eventInfo.timeText}</div>
<div className="fc-event-title">{eventInfo.event.title}</div>
</div>
);
};
export default Calendar;

View File

@@ -0,0 +1,19 @@
import ComponentCard from "../../components/common/ComponentCard";
import BarChartOne from "../../components/charts/bar/BarChartOne";
import PageMeta from "../../components/common/PageMeta";
export default function BarChart() {
return (
<div>
<PageMeta
title="React.js Chart Dashboard | TailAdmin - React.js Admin Dashboard Template"
description="This is React.js Chart Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
/>
<div className="space-y-6">
<ComponentCard title="Bar Chart 1">
<BarChartOne />
</ComponentCard>
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import ComponentCard from "../../components/common/ComponentCard";
import LineChartOne from "../../components/charts/line/LineChartOne";
import PageMeta from "../../components/common/PageMeta";
export default function LineChart() {
return (
<>
<PageMeta
title="React.js Chart Dashboard | TailAdmin - React.js Admin Dashboard Template"
description="This is React.js Chart Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
/>
<div className="space-y-6">
<ComponentCard title="Line Chart 1">
<LineChartOne />
</ComponentCard>
</div>
</>
);
}

View File

@@ -0,0 +1,683 @@
import React, { useState } from 'react';
import AlertModal, { AlertModalVariant } from '../components/ui/alert/AlertModal';
import { Modal } from '../components/ui/modal';
import Button from '../components/ui/button/Button';
import { Dropdown } from '../components/ui/dropdown/Dropdown';
import { DropdownItem } from '../components/ui/dropdown/DropdownItem';
import { Pagination } from '../components/ui/pagination/Pagination';
import { Card, CardImage, CardTitle, CardDescription, CardAction, CardIcon } from '../components/ui/card/Card';
import ChartTab from '../components/common/ChartTab';
import PageMeta from '../components/common/PageMeta';
export default function Components() {
// Alert modals state
const [alertModal, setAlertModal] = useState<{
isOpen: boolean;
variant: AlertModalVariant;
}>({
isOpen: false,
variant: 'info',
});
// Regular modals state
const [defaultModal, setDefaultModal] = useState(false);
const [centeredModal, setCenteredModal] = useState(false);
const [formModal, setFormModal] = useState(false);
const [fullScreenModal, setFullScreenModal] = useState(false);
const openAlertModal = (variant: AlertModalVariant) => {
setAlertModal({ isOpen: true, variant });
};
const alertMessages = {
success: {
title: 'Well Done!',
message: 'Lorem ipsum dolor sit amet consectetur. Feugiat ipsum libero tempor felis risus nisi non. Quisque eu ut tempor curabitur.',
},
info: {
title: 'Information Alert!',
message: 'Lorem ipsum dolor sit amet consectetur. Feugiat ipsum libero tempor felis risus nisi non. Quisque eu ut tempor curabitur.',
},
warning: {
title: 'Warning Alert!',
message: 'Lorem ipsum dolor sit amet consectetur. Feugiat ipsum libero tempor felis risus nisi non. Quisque eu ut tempor curabitur.',
},
danger: {
title: 'Danger Alert!',
message: 'Lorem ipsum dolor sit amet consectetur. Feugiat ipsum libero tempor felis risus nisi non. Quisque eu ut tempor curabitur.',
},
};
return (
<>
<PageMeta
title="React.js Components Dashboard | TailAdmin - React.js Admin Dashboard Template"
description="This is React.js Components Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
/>
<div className="space-y-5 sm:space-y-6">
<div className="grid grid-cols-1 gap-5 xl:grid-cols-2 xl:gap-6">
{/* Default Modal Card */}
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
Default Modal
</h3>
</div>
<div className="p-4 border-t border-gray-100 dark:border-gray-800 sm:p-6">
<div className="space-y-6">
<Button
variant="primary"
onClick={() => setDefaultModal(true)}
>
Open Modal
</Button>
</div>
</div>
</div>
{/* Vertically Centered Modal Card */}
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
Vertically Centered Modal
</h3>
</div>
<div className="p-4 border-t border-gray-100 dark:border-gray-800 sm:p-6">
<div className="space-y-6">
<Button
variant="primary"
onClick={() => setCenteredModal(true)}
>
Open Modal
</Button>
</div>
</div>
</div>
{/* Form In Modal Card */}
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
Form In Modal
</h3>
</div>
<div className="p-4 border-t border-gray-100 dark:border-gray-800 sm:p-6">
<div className="space-y-6">
<Button
variant="primary"
onClick={() => setFormModal(true)}
>
Open Modal
</Button>
</div>
</div>
</div>
{/* Full Screen Modal Card */}
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
Full Screen Modal
</h3>
</div>
<div className="p-4 border-t border-gray-100 dark:border-gray-800 sm:p-6">
<div className="space-y-6">
<Button
variant="primary"
onClick={() => setFullScreenModal(true)}
>
Open Modal
</Button>
</div>
</div>
</div>
{/* Modal Based Alerts Card */}
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03] xl:col-span-2">
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
Modal Based Alerts
</h3>
</div>
<div className="p-4 border-t border-gray-100 dark:border-gray-800 sm:p-6">
<div className="space-y-6">
<div className="flex flex-wrap items-center gap-3">
<button
onClick={() => openAlertModal('success')}
className="px-4 py-3 text-sm font-medium text-white rounded-lg bg-success-500 shadow-theme-xs hover:bg-success-600 transition-colors"
>
Success Alert
</button>
<button
onClick={() => openAlertModal('info')}
className="px-4 py-3 text-sm font-medium text-white rounded-lg bg-blue-light-500 shadow-theme-xs hover:bg-blue-light-600 transition-colors"
>
Info Alert
</button>
<button
onClick={() => openAlertModal('warning')}
className="px-4 py-3 text-sm font-medium text-white rounded-lg bg-warning-500 shadow-theme-xs hover:bg-warning-600 transition-colors"
>
Warning Alert
</button>
<button
onClick={() => openAlertModal('danger')}
className="px-4 py-3 text-sm font-medium text-white rounded-lg bg-error-500 shadow-theme-xs hover:bg-error-600 transition-colors"
>
Danger Alert
</button>
</div>
</div>
</div>
</div>
</div>
{/* Dropdowns, Buttons Group, Ribbons, Spinners, Tabs, Tooltips, Pagination, Cards */}
<DropdownsShowcase />
<ButtonGroupsShowcase />
<RibbonsShowcase />
<SpinnersShowcase />
<TabsShowcase />
<TooltipsShowcase />
<PaginationShowcase />
<CardsShowcase />
{/* Alert Modals */}
<AlertModal
isOpen={alertModal.isOpen}
onClose={() => setAlertModal({ ...alertModal, isOpen: false })}
title={alertMessages[alertModal.variant].title}
message={alertMessages[alertModal.variant].message}
variant={alertModal.variant}
/>
{/* Default Modal */}
<Modal
isOpen={defaultModal}
onClose={() => setDefaultModal(false)}
className="max-w-md"
>
<div className="p-6">
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">
Modal Heading
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque
euismod est quis mauris lacinia pharetra.
</p>
<div className="flex justify-end gap-4">
<Button variant="outline" onClick={() => setDefaultModal(false)}>
Close
</Button>
<Button variant="primary" onClick={() => setDefaultModal(false)}>
Save Changes
</Button>
</div>
</div>
</Modal>
{/* Vertically Centered Modal */}
<Modal
isOpen={centeredModal}
onClose={() => setCenteredModal(false)}
className="max-w-md"
>
<div className="p-6">
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">
Vertically Centered Modal
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
This modal is centered vertically on the screen. Lorem ipsum dolor sit
amet, consectetur adipiscing elit.
</p>
<div className="flex justify-end gap-4">
<Button variant="outline" onClick={() => setCenteredModal(false)}>
Close
</Button>
<Button variant="primary" onClick={() => setCenteredModal(false)}>
Save Changes
</Button>
</div>
</div>
</Modal>
{/* Form In Modal */}
<Modal
isOpen={formModal}
onClose={() => setFormModal(false)}
className="max-w-2xl"
>
<div className="p-6">
<h2 className="text-xl font-bold mb-6 text-gray-800 dark:text-white">
Personal Information
</h2>
<form className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
First Name
</label>
<input
type="text"
className="w-full px-3 py-2 border border-gray-300 rounded-lg dark:bg-gray-800 dark:border-gray-700 dark:text-white"
defaultValue="Emirhan"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Last Name
</label>
<input
type="text"
className="w-full px-3 py-2 border border-gray-300 rounded-lg dark:bg-gray-800 dark:border-gray-700 dark:text-white"
defaultValue="Boruch"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Email
</label>
<input
type="email"
className="w-full px-3 py-2 border border-gray-300 rounded-lg dark:bg-gray-800 dark:border-gray-700 dark:text-white"
defaultValue="emirhanboruch55@gmail.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Phone
</label>
<input
type="tel"
className="w-full px-3 py-2 border border-gray-300 rounded-lg dark:bg-gray-800 dark:border-gray-700 dark:text-white"
defaultValue="+09 363 398 46"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Bio
</label>
<textarea
rows={4}
className="w-full px-3 py-2 border border-gray-300 rounded-lg dark:bg-gray-800 dark:border-gray-700 dark:text-white"
defaultValue="Team Manager"
/>
</div>
<div className="flex justify-end gap-4 pt-4">
<Button variant="outline" onClick={() => setFormModal(false)}>
Close
</Button>
<Button variant="primary" onClick={() => setFormModal(false)}>
Save Changes
</Button>
</div>
</form>
</div>
</Modal>
{/* Full Screen Modal */}
<Modal
isOpen={fullScreenModal}
onClose={() => setFullScreenModal(false)}
className="max-w-4xl"
isFullscreen={false}
>
<div className="p-8">
<h2 className="text-2xl font-bold mb-4 text-gray-800 dark:text-white">
Full Screen Modal
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
This is a larger modal that takes up more screen space. Lorem ipsum
dolor sit amet, consectetur adipiscing elit. Pellentesque euismod est
quis mauris lacinia pharetra.
</p>
<div className="flex justify-end gap-4">
<Button variant="outline" onClick={() => setFullScreenModal(false)}>
Close
</Button>
<Button variant="primary" onClick={() => setFullScreenModal(false)}>
Save Changes
</Button>
</div>
</div>
</Modal>
</div>
</>
);
}
// Dropdowns Showcase Component
function DropdownsShowcase() {
const [dropdown1, setDropdown1] = useState(false);
const [dropdown2, setDropdown2] = useState(false);
const [dropdown3, setDropdown3] = useState(false);
return (
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">Dropdowns</h3>
</div>
<div className="p-4 border-t border-gray-100 dark:border-gray-800 sm:p-6">
<div className="space-y-6">
{/* Default Dropdown */}
<div className="relative inline-block">
<button
onClick={() => setDropdown1(!dropdown1)}
className="dropdown-toggle inline-flex px-4 py-3 text-sm font-medium text-white rounded-lg bg-brand-500 shadow-theme-xs hover:bg-brand-600"
>
Dropdown Default
</button>
<Dropdown isOpen={dropdown1} onClose={() => setDropdown1(false)} className="w-48 p-2 mt-2">
<DropdownItem onItemClick={() => setDropdown1(false)} className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg text-theme-sm hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300">
Edit
</DropdownItem>
<DropdownItem onItemClick={() => setDropdown1(false)} className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg text-theme-sm hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300">
Delete
</DropdownItem>
</Dropdown>
</div>
{/* Dropdown with Divider */}
<div className="relative inline-block">
<button
onClick={() => setDropdown2(!dropdown2)}
className="dropdown-toggle inline-flex px-4 py-3 text-sm font-medium text-white rounded-lg bg-brand-500 shadow-theme-xs hover:bg-brand-600"
>
Dropdown with Divider
</button>
<Dropdown isOpen={dropdown2} onClose={() => setDropdown2(false)} className="w-48 p-2 mt-2">
<DropdownItem onItemClick={() => setDropdown2(false)} className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg text-theme-sm hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300">
Edit
</DropdownItem>
<DropdownItem onItemClick={() => setDropdown2(false)} className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg text-theme-sm hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300">
View
</DropdownItem>
<div className="my-2 border-t border-gray-200 dark:border-gray-800"></div>
<DropdownItem onItemClick={() => setDropdown2(false)} className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg text-theme-sm hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300">
Delete
</DropdownItem>
</Dropdown>
</div>
{/* Dropdown with Icon */}
<div className="relative inline-block">
<button
onClick={() => setDropdown3(!dropdown3)}
className="dropdown-toggle inline-flex px-4 py-3 text-sm font-medium text-white rounded-lg bg-brand-500 shadow-theme-xs hover:bg-brand-600"
>
Dropdown with Icon
</button>
<Dropdown isOpen={dropdown3} onClose={() => setDropdown3(false)} className="w-48 p-2 mt-2">
<DropdownItem onItemClick={() => setDropdown3(false)} className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg text-theme-sm hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
Edit
</DropdownItem>
<DropdownItem onItemClick={() => setDropdown3(false)} className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg text-theme-sm hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
View
</DropdownItem>
<div className="my-2 border-t border-gray-200 dark:border-gray-800"></div>
<DropdownItem onItemClick={() => setDropdown3(false)} className="flex items-center gap-3 px-3 py-2 font-medium text-red-600 rounded-lg text-theme-sm hover:bg-red-50 hover:text-red-700 dark:text-red-400 dark:hover:bg-red-900/20 dark:hover:text-red-300">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
Delete
</DropdownItem>
</Dropdown>
</div>
</div>
</div>
</div>
);
}
// Button Groups Showcase Component
function ButtonGroupsShowcase() {
return (
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">Button Groups</h3>
</div>
<div className="p-4 border-t border-gray-100 dark:border-gray-800 sm:p-6">
<div className="space-y-6">
{/* Default Button Group */}
<div className="inline-flex rounded-lg border border-gray-300 bg-white shadow-theme-xs dark:border-gray-700 dark:bg-gray-800">
<button className="px-4 py-2 text-sm font-medium text-gray-700 rounded-l-lg hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white">
Left
</button>
<button className="px-4 py-2 text-sm font-medium text-gray-700 border-l border-r border-gray-300 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:border-gray-700 dark:hover:bg-white/5 dark:hover:text-white">
Center
</button>
<button className="px-4 py-2 text-sm font-medium text-gray-700 rounded-r-lg hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white">
Right
</button>
</div>
{/* Icon Button Group */}
<div className="inline-flex rounded-lg border border-gray-300 bg-white shadow-theme-xs dark:border-gray-700 dark:bg-gray-800">
<button className="p-2 text-gray-700 rounded-l-lg hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clipRule="evenodd" />
</svg>
</button>
<button className="p-2 text-gray-700 border-l border-r border-gray-300 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:border-gray-700 dark:hover:bg-white/5 dark:hover:text-white">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v12a1 1 0 11-2 0V4a1 1 0 011-1z" clipRule="evenodd" />
</svg>
</button>
<button className="p-2 text-gray-700 rounded-r-lg hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 10a1 1 0 011 1h12a1 1 0 110-2H4a1 1 0 01-1-1z" clipRule="evenodd" />
</svg>
</button>
</div>
</div>
</div>
</div>
);
}
// Ribbons Showcase Component
function RibbonsShowcase() {
return (
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
{/* Rounded Ribbon */}
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">Rounded Ribbon</h3>
</div>
<div className="p-4 border-t border-gray-100 dark:border-gray-800 sm:p-6">
<div className="relative overflow-hidden rounded-xl border border-gray-200 dark:border-gray-800 dark:bg-white/[0.03]">
<span className="absolute -left-px mt-3 inline-block rounded-r-full bg-brand-500 px-4 py-1.5 text-sm font-medium text-white">Popular</span>
<div className="p-5 pt-16">
<p className="text-sm text-gray-500 dark:text-gray-400">Lorem ipsum dolor sit amet consectetur. Eget nulla suscipit arcu rutrum amet vel nec fringilla vulputate.</p>
</div>
</div>
</div>
</div>
{/* Filled Ribbon */}
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">Filled Ribbon</h3>
</div>
<div className="p-4 border-t border-gray-100 dark:border-gray-800 sm:p-6">
<div className="relative overflow-hidden rounded-xl border border-gray-200 dark:border-gray-800 dark:bg-white/[0.03]">
<span className="absolute -left-9 -top-7 mt-3 flex h-14 w-24 -rotate-45 items-end justify-center bg-brand-500 px-4 py-1.5 text-sm font-medium text-white shadow-theme-xs">New</span>
<div className="p-5 pt-16">
<p className="text-sm text-gray-500 dark:text-gray-400">Lorem ipsum dolor sit amet consectetur. Eget nulla suscipit arcu rutrum amet vel nec fringilla vulputate.</p>
</div>
</div>
</div>
</div>
</div>
);
}
// Spinners Showcase Component
function SpinnersShowcase() {
return (
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">Spinners</h3>
</div>
<div className="p-4 border-t border-gray-100 dark:border-gray-800 sm:p-6">
<div className="flex flex-wrap items-center gap-6">
{/* Default Spinner */}
<div className="inline-flex h-10 w-10 animate-spin items-center justify-center rounded-full border-4 border-gray-200 border-t-brand-500"></div>
{/* Small Spinner */}
<div className="inline-flex h-6 w-6 animate-spin items-center justify-center rounded-full border-2 border-gray-200 border-t-brand-500"></div>
{/* Large Spinner */}
<div className="inline-flex h-16 w-16 animate-spin items-center justify-center rounded-full border-4 border-gray-200 border-t-brand-500"></div>
{/* Colored Spinners */}
<div className="inline-flex h-10 w-10 animate-spin items-center justify-center rounded-full border-4 border-success-200 border-t-success-500"></div>
<div className="inline-flex h-10 w-10 animate-spin items-center justify-center rounded-full border-4 border-error-200 border-t-error-500"></div>
</div>
</div>
</div>
);
}
// Tabs Showcase Component
function TabsShowcase() {
return (
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">Tabs</h3>
</div>
<div className="p-4 border-t border-gray-100 dark:border-gray-800 sm:p-6">
<div className="space-y-6">
<ChartTab />
</div>
</div>
</div>
);
}
// Tooltips Showcase Component
function TooltipsShowcase() {
return (
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">Tooltips</h3>
</div>
<div className="p-4 border-t border-gray-100 dark:border-gray-800 sm:p-6">
<div className="flex flex-wrap items-center gap-6">
<button
className="relative group inline-flex px-4 py-3 text-sm font-medium text-white rounded-lg bg-brand-500 shadow-theme-xs"
title="Tooltip Top"
>
Tooltip Top
<span className="absolute bottom-full left-1/2 mb-2 -translate-x-1/2 px-3 py-1.5 text-xs font-medium text-white bg-gray-900 rounded-lg opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity whitespace-nowrap">
Tooltip Top
<span className="absolute top-full left-1/2 -translate-x-1/2 -mt-1 border-4 border-transparent border-t-gray-900"></span>
</span>
</button>
<button
className="relative group inline-flex px-4 py-3 text-sm font-medium text-white rounded-lg bg-brand-500 shadow-theme-xs"
title="Tooltip Right"
>
Tooltip Right
<span className="absolute left-full top-1/2 ml-2 -translate-y-1/2 px-3 py-1.5 text-xs font-medium text-white bg-gray-900 rounded-lg opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity whitespace-nowrap">
Tooltip Right
<span className="absolute right-full top-1/2 -translate-y-1/2 -mr-1 border-4 border-transparent border-r-gray-900"></span>
</span>
</button>
</div>
</div>
</div>
);
}
// Pagination Showcase Component
function PaginationShowcase() {
const [page1, setPage1] = useState(1);
const [page2, setPage2] = useState(1);
const [page3, setPage3] = useState(1);
return (
<div className="space-y-5 sm:space-y-6">
{/* Pagination with Text */}
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">Pagination with Text</h3>
</div>
<div className="p-4 border-t border-gray-100 dark:border-gray-800 sm:p-6">
<Pagination currentPage={page1} totalPages={10} onPageChange={setPage1} variant="text" />
</div>
</div>
{/* Pagination with Text and Icon */}
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">Pagination with Text and Icon</h3>
</div>
<div className="p-4 border-t border-gray-100 dark:border-gray-800 sm:p-6">
<Pagination currentPage={page2} totalPages={10} onPageChange={setPage2} variant="text-icon" />
</div>
</div>
{/* Pagination with Icon */}
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">Pagination with Icon</h3>
</div>
<div className="p-4 border-t border-gray-100 dark:border-gray-800 sm:p-6">
<Pagination currentPage={page3} totalPages={10} onPageChange={setPage3} variant="icon" />
</div>
</div>
</div>
);
}
// Cards Showcase Component
function CardsShowcase() {
return (
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
{/* Basic Card */}
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">Basic Card</h3>
</div>
<div className="p-4 border-t border-gray-100 dark:border-gray-800 sm:p-6">
<Card>
<CardTitle>Card Title</CardTitle>
<CardDescription>This is a basic card with title and description.</CardDescription>
</Card>
</div>
</div>
{/* Card with Icon */}
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">Card with Icon</h3>
</div>
<div className="p-4 border-t border-gray-100 dark:border-gray-800 sm:p-6">
<Card>
<CardIcon>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" />
<path fillRule="evenodd" d="M4 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v11a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm3 4a1 1 0 000 2h.01a1 1 0 100-2H7zm3 0a1 1 0 000 2h3a1 1 0 100-2h-3zm-3 4a1 1 0 100 2h.01a1 1 0 100-2H7zm3 0a1 1 0 100 2h3a1 1 0 100-2h-3z" clipRule="evenodd" />
</svg>
</CardIcon>
<CardTitle>Card with Icon</CardTitle>
<CardDescription>This card includes an icon at the top.</CardDescription>
<CardAction>Learn More</CardAction>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import PageMeta from "../../components/common/PageMeta";
import CreditBalanceWidget from "../../components/dashboard/CreditBalanceWidget";
import UsageChartWidget from "../../components/dashboard/UsageChartWidget";
export default function Home() {
return (
<>
<PageMeta
title="Dashboard - IGNY8"
description="IGNY8 Dashboard"
/>
<div className="grid grid-cols-12 gap-4 md:gap-6">
<div className="col-span-12 xl:col-span-4">
<CreditBalanceWidget />
</div>
<div className="col-span-12 xl:col-span-8">
<UsageChartWidget />
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,38 @@
import DefaultInputs from "../../components/form/form-elements/DefaultInputs";
import InputGroup from "../../components/form/form-elements/InputGroup";
import DropzoneComponent from "../../components/form/form-elements/DropZone";
import CheckboxComponents from "../../components/form/form-elements/CheckboxComponents";
import RadioButtons from "../../components/form/form-elements/RadioButtons";
import ToggleSwitch from "../../components/form/form-elements/ToggleSwitch";
import FileInputExample from "../../components/form/form-elements/FileInputExample";
import SelectInputs from "../../components/form/form-elements/SelectInputs";
import TextAreaInput from "../../components/form/form-elements/TextAreaInput";
import InputStates from "../../components/form/form-elements/InputStates";
import PageMeta from "../../components/common/PageMeta";
export default function FormElements() {
return (
<div>
<PageMeta
title="React.js Form Elements Dashboard | TailAdmin - React.js Admin Dashboard Template"
description="This is React.js Form Elements Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
/>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
<div className="space-y-6">
<DefaultInputs />
<SelectInputs />
<TextAreaInput />
<InputStates />
</div>
<div className="space-y-6">
<InputGroup />
<FileInputExample />
<CheckboxComponents />
<RadioButtons />
<ToggleSwitch />
<DropzoneComponent />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import PageMeta from "../../components/common/PageMeta";
import ComponentCard from "../../components/common/ComponentCard";
export default function Docs() {
return (
<>
<PageMeta title="Documentation - IGNY8" description="Complete documentation" />
<ComponentCard title="Coming Soon" desc="Complete documentation">
<div className="text-center py-8">
<p className="text-gray-600 dark:text-gray-400">
Documentation - Coming Soon
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Comprehensive documentation and guides
</p>
</div>
</ComponentCard>
</>
);
}

View File

@@ -0,0 +1,22 @@
import PageMeta from "../../components/common/PageMeta";
import ComponentCard from "../../components/common/ComponentCard";
export default function FunctionTesting() {
return (
<>
<PageMeta title="Function Testing - IGNY8" description="Function testing" />
<ComponentCard title="Coming Soon" desc="Function testing">
<div className="text-center py-8">
<p className="text-gray-600 dark:text-gray-400">
Function Testing - Coming Soon
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Test individual functions and components
</p>
</div>
</ComponentCard>
</>
);
}

View File

@@ -0,0 +1,22 @@
import PageMeta from "../../components/common/PageMeta";
import ComponentCard from "../../components/common/ComponentCard";
export default function Help() {
return (
<>
<PageMeta title="Help & Support - IGNY8" description="Documentation and support" />
<ComponentCard title="Coming Soon" desc="Documentation and support">
<div className="text-center py-8">
<p className="text-gray-600 dark:text-gray-400">
Help & Support - Coming Soon
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Documentation and support resources for getting started
</p>
</div>
</ComponentCard>
</>
);
}

View File

@@ -0,0 +1,22 @@
import PageMeta from "../../components/common/PageMeta";
import ComponentCard from "../../components/common/ComponentCard";
export default function SystemTesting() {
return (
<>
<PageMeta title="System Testing - IGNY8" description="System diagnostics" />
<ComponentCard title="Coming Soon" desc="System diagnostics">
<div className="text-center py-8">
<p className="text-gray-600 dark:text-gray-400">
System Testing - Coming Soon
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Test system functionality and diagnose issues
</p>
</div>
</ComponentCard>
</>
);
}

View File

@@ -0,0 +1,44 @@
import GridShape from "../../components/common/GridShape";
import { Link } from "react-router";
import PageMeta from "../../components/common/PageMeta";
export default function NotFound() {
return (
<>
<PageMeta
title="React.js 404 Dashboard | TailAdmin - React.js Admin Dashboard Template"
description="This is React.js 404 Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
/>
<div className="relative flex flex-col items-center justify-center min-h-screen p-6 overflow-hidden z-1">
<GridShape />
<div className="mx-auto w-full max-w-[242px] text-center sm:max-w-[472px]">
<h1 className="mb-8 font-bold text-gray-800 text-title-md dark:text-white/90 xl:text-title-2xl">
ERROR
</h1>
<img src="/images/error/404.svg" alt="404" className="dark:hidden" />
<img
src="/images/error/404-dark.svg"
alt="404"
className="hidden dark:block"
/>
<p className="mt-10 mb-6 text-base text-gray-700 dark:text-gray-400 sm:text-lg">
We cant seem to find the page you are looking for!
</p>
<Link
to="/"
className="inline-flex items-center justify-center rounded-lg border border-gray-300 bg-white px-5 py-3.5 text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 hover:text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200"
>
Back to Home Page
</Link>
</div>
{/* <!-- Footer --> */}
<p className="absolute text-sm text-center text-gray-500 -translate-x-1/2 bottom-6 left-1/2 dark:text-gray-400">
&copy; {new Date().getFullYear()} - TailAdmin
</p>
</div>
</>
);
}

View File

@@ -0,0 +1,482 @@
/**
* Clusters Page - Refactored to use TablePageTemplate
* Consistent with Keywords page layout, structure and design
*/
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import TablePageTemplate from '../../templates/TablePageTemplate';
import {
fetchClusters,
createCluster,
updateCluster,
deleteCluster,
bulkDeleteClusters,
bulkUpdateClustersStatus,
autoGenerateIdeas,
Cluster,
ClusterFilters,
ClusterCreateData,
} from '../../services/api';
import FormModal from '../../components/common/FormModal';
import ProgressModal from '../../components/common/ProgressModal';
import { useProgressModal } from '../../hooks/useProgressModal';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { GroupIcon, PlusIcon, DownloadIcon } from '../../icons';
import { createClustersPageConfig } from '../../config/pages/clusters.config';
import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
import { getDifficultyLabelFromNumber, getDifficultyRange } from '../../utils/difficulty';
export default function Clusters() {
const toast = useToast();
const { activeSector } = useSectorStore();
const { pageSize } = usePageSizeStore();
// Data state
const [clusters, setClusters] = useState<Cluster[]>([]);
const [loading, setLoading] = useState(true);
// Filter state
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [difficultyFilter, setDifficultyFilter] = useState('');
const [volumeMin, setVolumeMin] = useState<number | ''>('');
const [volumeMax, setVolumeMax] = useState<number | ''>('');
const [isVolumeDropdownOpen, setIsVolumeDropdownOpen] = useState(false);
const [tempVolumeMin, setTempVolumeMin] = useState<number | ''>('');
const [tempVolumeMax, setTempVolumeMax] = useState<number | ''>('');
const volumeDropdownRef = useRef<HTMLDivElement>(null);
const volumeButtonRef = useRef<HTMLButtonElement>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalCount, setTotalCount] = useState(0);
// Sorting state
const [sortBy, setSortBy] = useState<string>('name');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [showContent, setShowContent] = useState(false);
// Modal state
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [editingCluster, setEditingCluster] = useState<Cluster | null>(null);
const [formData, setFormData] = useState<ClusterCreateData>({
name: '',
description: '',
status: 'active',
});
// Progress modal for AI functions
const progressModal = useProgressModal();
// Load clusters - wrapped in useCallback to prevent infinite loops
const loadClusters = useCallback(async () => {
setLoading(true);
setShowContent(false);
try {
const ordering = sortBy ? `${sortDirection === 'desc' ? '-' : ''}${sortBy}` : 'name';
const filters: ClusterFilters = {
...(searchTerm && { search: searchTerm }),
...(statusFilter && { status: statusFilter }),
...(activeSector?.id && { sector_id: activeSector.id }),
page: currentPage,
page_size: pageSize,
ordering,
};
// Add difficulty range filter
if (difficultyFilter) {
const difficultyNum = parseInt(difficultyFilter);
const label = getDifficultyLabelFromNumber(difficultyNum);
if (label !== null) {
const range = getDifficultyRange(label);
if (range) {
filters.difficulty_min = range.min;
filters.difficulty_max = range.max;
}
}
}
// Add volume range filters
if (volumeMin !== '' && volumeMin !== null && volumeMin !== undefined) {
filters.volume_min = Number(volumeMin);
}
if (volumeMax !== '' && volumeMax !== null && volumeMax !== undefined) {
filters.volume_max = Number(volumeMax);
}
const data = await fetchClusters(filters);
setClusters(data.results || []);
setTotalCount(data.count || 0);
setTotalPages(Math.ceil((data.count || 0) / pageSize));
setTimeout(() => {
setShowContent(true);
setLoading(false);
}, 100);
} catch (error: any) {
console.error('Error loading clusters:', error);
toast.error(`Failed to load clusters: ${error.message}`);
setShowContent(true);
setLoading(false);
}
}, [currentPage, statusFilter, sortBy, sortDirection, searchTerm, difficultyFilter, volumeMin, volumeMax, activeSector, pageSize]);
// Load data on mount and when filters change
useEffect(() => {
loadClusters();
}, [loadClusters]);
// Listen for site and sector changes and refresh data
useEffect(() => {
const handleSiteChange = () => {
loadClusters();
};
const handleSectorChange = () => {
loadClusters();
};
window.addEventListener('siteChanged', handleSiteChange);
window.addEventListener('sectorChanged', handleSectorChange);
return () => {
window.removeEventListener('siteChanged', handleSiteChange);
window.removeEventListener('sectorChanged', handleSectorChange);
};
}, [loadClusters]);
// Debounced search
useEffect(() => {
const timer = setTimeout(() => {
if (currentPage === 1) {
loadClusters();
} else {
setCurrentPage(1);
}
}, 500);
return () => clearTimeout(timer);
}, [searchTerm, currentPage, loadClusters]);
// Reset to page 1 when pageSize changes
useEffect(() => {
setCurrentPage(1);
}, [pageSize]);
// Handle sorting
const handleSort = (field: string, direction: 'asc' | 'desc') => {
setSortBy(field || 'name');
setSortDirection(direction);
setCurrentPage(1);
};
// Bulk status update handler
const handleBulkUpdateStatus = useCallback(async (ids: string[], status: string) => {
try {
const numIds = ids.map(id => parseInt(id));
await bulkUpdateClustersStatus(numIds, status);
await loadClusters();
} catch (error: any) {
throw error;
}
}, [loadClusters]);
// Bulk export handler
const handleBulkExport = useCallback(async (ids: string[]) => {
try {
if (!ids || ids.length === 0) {
throw new Error('No records selected for export');
}
// TODO: Implement bulk export endpoint
toast.info('Export functionality coming soon');
} catch (error: any) {
throw error;
}
}, []);
// Row action handler
const handleRowAction = useCallback(async (action: string, row: Cluster) => {
if (action === 'generate_ideas') {
try {
const result = await autoGenerateIdeas([row.id]);
if (result.success && result.task_id) {
// Async task - show progress modal
progressModal.openModal(result.task_id, 'Generating Ideas');
} else if (result.success && result.ideas_created) {
// Synchronous completion
toast.success(result.message || 'Ideas generated successfully');
await loadClusters();
} else {
toast.error(result.error || 'Failed to generate ideas');
}
} catch (error: any) {
toast.error(`Failed to generate ideas: ${error.message}`);
}
}
}, [toast, progressModal, loadClusters]);
// Bulk action handler
const handleBulkAction = useCallback(async (action: string, ids: string[]) => {
if (action === 'auto_generate_ideas') {
if (ids.length === 0) {
toast.error('Please select at least one cluster to generate ideas');
return;
}
if (ids.length > 5) {
toast.error('Maximum 5 clusters allowed for idea generation');
return;
}
try {
const numIds = ids.map(id => parseInt(id));
const result = await autoGenerateIdeas(numIds);
if (result.success) {
if (result.task_id) {
// Async task - show progress modal
progressModal.openModal(result.task_id, 'Generating Content Ideas');
// Don't show toast - progress modal will show status
} else {
// Synchronous completion
toast.success(`Ideas generation complete: ${result.ideas_created || 0} ideas created`);
await loadClusters();
}
} else {
toast.error(result.error || 'Failed to generate ideas');
}
} catch (error: any) {
toast.error(`Failed to generate ideas: ${error.message}`);
}
} else {
toast.info(`Bulk action "${action}" for ${ids.length} items`);
}
}, [toast, loadClusters, progressModal]);
// Close volume dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
volumeDropdownRef.current &&
!volumeDropdownRef.current.contains(event.target as Node) &&
volumeButtonRef.current &&
!volumeButtonRef.current.contains(event.target as Node)
) {
setIsVolumeDropdownOpen(false);
}
};
if (isVolumeDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isVolumeDropdownOpen]);
// Create page config
const pageConfig = useMemo(() => {
return createClustersPageConfig({
activeSector,
formData,
setFormData,
searchTerm,
setSearchTerm,
statusFilter,
setStatusFilter,
difficultyFilter,
setDifficultyFilter,
volumeMin,
volumeMax,
setVolumeMin,
setVolumeMax,
isVolumeDropdownOpen,
setIsVolumeDropdownOpen,
tempVolumeMin,
tempVolumeMax,
setTempVolumeMin,
setTempVolumeMax,
volumeButtonRef,
volumeDropdownRef,
setCurrentPage,
loadClusters,
});
}, [
activeSector,
formData,
searchTerm,
statusFilter,
difficultyFilter,
volumeMin,
volumeMax,
isVolumeDropdownOpen,
tempVolumeMin,
tempVolumeMax,
loadClusters,
]);
// Calculate header metrics
const headerMetrics = useMemo(() => {
if (!pageConfig?.headerMetrics) return [];
return pageConfig.headerMetrics.map((metric) => ({
label: metric.label,
value: metric.calculate({ clusters, totalCount }),
accentColor: metric.accentColor,
}));
}, [pageConfig?.headerMetrics, clusters, totalCount]);
const resetForm = useCallback(() => {
setFormData({
name: '',
description: '',
status: 'active',
});
setIsEditMode(false);
setEditingCluster(null);
}, []);
// Handle create/edit
const handleSave = async () => {
try {
if (isEditMode && editingCluster) {
await updateCluster(editingCluster.id, formData);
toast.success('Cluster updated successfully');
} else {
await createCluster(formData);
toast.success('Cluster created successfully');
}
setIsModalOpen(false);
resetForm();
loadClusters();
} catch (error: any) {
toast.error(`Failed to save: ${error.message}`);
}
};
return (
<>
<TablePageTemplate
title="Keyword Clusters"
titleIcon={<GroupIcon className="text-success-500 size-5" />}
subtitle="Organize keywords into content clusters for better SEO strategy"
columns={pageConfig.columns}
data={clusters}
loading={loading}
showContent={showContent}
filters={pageConfig.filters}
filterValues={{
search: searchTerm,
status: statusFilter,
difficulty: difficultyFilter,
volumeMin: volumeMin,
volumeMax: volumeMax,
}}
onFilterChange={(key, value) => {
const stringValue = value === null || value === undefined ? '' : String(value);
if (key === 'search') {
setSearchTerm(stringValue);
} else if (key === 'status') {
setStatusFilter(stringValue);
} else if (key === 'difficulty') {
setDifficultyFilter(stringValue);
}
setCurrentPage(1);
}}
onEdit={(row) => {
setEditingCluster(row);
setFormData({
name: row.name || '',
description: row.description || '',
status: row.status || 'active',
});
setIsEditMode(true);
setIsModalOpen(true);
}}
onCreate={() => {
resetForm();
setIsModalOpen(true);
}}
createLabel="Create Cluster"
onCreateIcon={<PlusIcon />}
onDelete={async (id: number) => {
await deleteCluster(id);
loadClusters();
}}
onBulkDelete={async (ids: number[]) => {
const result = await bulkDeleteClusters(ids);
loadClusters();
return result;
}}
onBulkExport={handleBulkExport}
onBulkUpdateStatus={handleBulkUpdateStatus}
onBulkAction={handleBulkAction}
onRowAction={handleRowAction}
getItemDisplayName={(row: Cluster) => row.name}
onExport={async () => {
toast.info('Export functionality coming soon');
}}
onExportIcon={<DownloadIcon />}
selectionLabel="cluster"
pagination={{
currentPage,
totalPages,
totalCount,
onPageChange: setCurrentPage,
}}
selection={{
selectedIds,
onSelectionChange: setSelectedIds,
}}
sorting={{
sortBy,
sortDirection,
onSort: handleSort,
}}
headerMetrics={headerMetrics}
onFilterReset={() => {
setSearchTerm('');
setStatusFilter('');
setDifficultyFilter('');
setVolumeMin('');
setVolumeMax('');
setCurrentPage(1);
}}
/>
{/* Progress Modal for AI Functions */}
<ProgressModal
isOpen={progressModal.isOpen}
title={progressModal.title}
percentage={progressModal.progress.percentage}
status={progressModal.progress.status}
message={progressModal.progress.message}
details={progressModal.progress.details}
taskId={progressModal.taskId || undefined}
onClose={() => {
const wasCompleted = progressModal.progress.status === 'completed';
progressModal.closeModal();
// Reload data after modal closes (if completed)
if (wasCompleted) {
loadClusters();
}
}}
/>
{/* Create/Edit Modal */}
<FormModal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false);
resetForm();
}}
onSubmit={handleSave}
title={isEditMode ? 'Edit Cluster' : 'Add Cluster'}
submitLabel={isEditMode ? 'Update' : 'Create'}
fields={pageConfig.formFields()}
/>
</>
);
}

View File

@@ -0,0 +1,308 @@
import { Link } from "react-router";
import PageMeta from "../../components/common/PageMeta";
import ComponentCard from "../../components/common/ComponentCard";
import { ProgressBar } from "../../components/ui/progress";
import { ListIcon, GroupIcon, BoltIcon, PieChartIcon, ArrowRightIcon, CheckCircleIcon, TimeIcon } from "../../icons";
export default function PlannerDashboard() {
// Mock data - will be replaced with API calls
const stats = {
keywords: 245,
clusters: 18,
ideas: 52,
mappedKeywords: 180,
clustersWithIdeas: 12,
queuedIdeas: 35,
};
const keywordMappingPct = stats.keywords > 0 ? Math.round((stats.mappedKeywords / stats.keywords) * 100) : 0;
const clustersIdeasPct = stats.clusters > 0 ? Math.round((stats.clustersWithIdeas / stats.clusters) * 100) : 0;
const ideasQueuedPct = stats.ideas > 0 ? Math.round((stats.queuedIdeas / stats.ideas) * 100) : 0;
const workflowSteps = [
{ number: 1, title: "Add Keywords", status: "completed", count: stats.keywords, path: "/planner/keywords" },
{ number: 2, title: "Select Sector", status: "completed", count: null, path: "/planner" },
{ number: 3, title: "Auto Cluster", status: "pending", count: stats.clusters, path: "/planner/clusters" },
{ number: 4, title: "Generate Ideas", status: "pending", count: stats.ideas, path: "/planner/ideas" },
];
const topClusters = [
{ name: "SEO Optimization", volume: 45800, keywords: 24 },
{ name: "Content Marketing", volume: 32100, keywords: 18 },
{ name: "Link Building", volume: 28700, keywords: 15 },
{ name: "Keyword Research", volume: 24100, keywords: 12 },
{ name: "Analytics", volume: 18900, keywords: 9 },
];
const ideasByStatus = [
{ status: "New", count: 20, color: "blue" },
{ status: "Scheduled", count: 15, color: "amber" },
{ status: "Published", count: 17, color: "green" },
];
const nextActions = [
{ text: "65 keywords unmapped", action: "Map Keywords", path: "/planner/keywords" },
{ text: "6 clusters without ideas", action: "Generate Ideas", path: "/planner/ideas" },
{ text: "17 ideas not queued to writer", action: "Queue to Writer", path: "/writer/tasks" },
];
return (
<>
<PageMeta title="Planner Dashboard - IGNY8" description="Content planning overview" />
<div className="space-y-5 sm:space-y-6">
{/* Top Status Cards */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4 md:gap-6">
<Link
to="/planner/keywords"
className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6 hover:shadow-md transition-shadow cursor-pointer group relative overflow-hidden"
>
<div className="absolute left-0 top-0 bottom-0 w-1 bg-brand-500"></div>
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm text-gray-500 dark:text-gray-400">Keywords Ready</p>
<h4 className="mt-2 font-bold text-gray-800 text-title-sm dark:text-white/90">
{stats.keywords.toLocaleString()}
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Research, analyze, and manage keywords strategy
</p>
</div>
<div className="flex items-center justify-center w-12 h-12 bg-blue-50 rounded-xl dark:bg-blue-500/10 group-hover:bg-blue-100 dark:group-hover:bg-blue-500/20 transition-colors">
<ListIcon className="text-brand-500 size-6" />
</div>
</div>
</Link>
<Link
to="/planner/clusters"
className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6 hover:shadow-md transition-shadow cursor-pointer group relative overflow-hidden"
>
<div className="absolute left-0 top-0 bottom-0 w-1 bg-success-500"></div>
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm text-gray-500 dark:text-gray-400">Clusters Built</p>
<h4 className="mt-2 font-bold text-gray-800 text-title-sm dark:text-white/90">
{stats.clusters.toLocaleString()}
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Organize keywords into strategic topical clusters
</p>
</div>
<div className="flex items-center justify-center w-12 h-12 bg-green-50 rounded-xl dark:bg-green-500/10 group-hover:bg-green-100 dark:group-hover:bg-green-500/20 transition-colors">
<GroupIcon className="text-success-500 size-6" />
</div>
</div>
</Link>
<Link
to="/planner/ideas"
className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6 hover:shadow-md transition-shadow cursor-pointer group relative overflow-hidden"
>
<div className="absolute left-0 top-0 bottom-0 w-1 bg-warning-500"></div>
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm text-gray-500 dark:text-gray-400">Ideas Generated</p>
<h4 className="mt-2 font-bold text-gray-800 text-title-sm dark:text-white/90">
{stats.ideas.toLocaleString()}
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Generate creative content ideas based on semantic strategy
</p>
</div>
<div className="flex items-center justify-center w-12 h-12 bg-amber-50 rounded-xl dark:bg-amber-500/10 group-hover:bg-amber-100 dark:group-hover:bg-amber-500/20 transition-colors">
<BoltIcon className="text-warning-500 size-6" />
</div>
</div>
</Link>
<Link
to="/planner/keywords"
className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6 hover:shadow-md transition-shadow cursor-pointer group relative overflow-hidden"
>
<div className="absolute left-0 top-0 bottom-0 w-1 bg-purple-500"></div>
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm text-gray-500 dark:text-gray-400">Mapped Keywords</p>
<h4 className="mt-2 font-bold text-gray-800 text-title-sm dark:text-white/90">
{stats.mappedKeywords.toLocaleString()}
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Keywords successfully mapped to content pages
</p>
</div>
<div className="flex items-center justify-center w-12 h-12 bg-purple-50 rounded-xl dark:bg-purple-500/10 group-hover:bg-purple-100 dark:group-hover:bg-purple-500/20 transition-colors">
<PieChartIcon className="text-purple-500 size-6" />
</div>
</div>
</Link>
</div>
{/* Planner Workflow Steps */}
<ComponentCard title="Planner Workflow Steps" desc="Track your planning progress">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{workflowSteps.map((step) => (
<Link
key={step.number}
to={step.path}
className="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-900/50 hover:border-brand-300 hover:bg-brand-50 dark:hover:bg-brand-500/10 transition-colors"
>
<div className="flex items-center gap-3 mb-3">
<div className="flex items-center justify-center w-8 h-8 bg-white border-2 border-gray-300 rounded-full text-sm font-semibold text-gray-600 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400">
{step.number}
</div>
<h4 className="font-medium text-gray-800 dark:text-white/90">{step.title}</h4>
</div>
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-1.5">
{step.status === "completed" ? (
<>
<CheckCircleIcon className="size-4 text-success-500" />
<span className="text-gray-600 dark:text-gray-300 font-medium">Completed</span>
</>
) : (
<>
<TimeIcon className="size-4 text-amber-500" />
<span className="text-gray-600 dark:text-gray-300 font-medium">Pending</span>
</>
)}
</div>
</div>
{step.count !== null && (
<p className="mt-2 text-xs text-gray-600 dark:text-gray-400">
{step.count} {step.title.includes("Keywords") ? "keywords" : step.title.includes("Clusters") ? "clusters" : "ideas"}{" "}
{step.status === "completed" ? "added" : ""}
</p>
)}
{step.status === "pending" && (
<Link
to={step.path}
className="mt-3 inline-block text-xs font-medium text-brand-500 hover:text-brand-600"
onClick={(e) => e.stopPropagation()}
>
Start Now
</Link>
)}
</Link>
))}
</div>
</ComponentCard>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Progress Summary */}
<ComponentCard title="Progress & Readiness Summary" desc="Planning workflow progress tracking" className="lg:col-span-1">
<div className="space-y-6">
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Keyword Mapping</span>
<span className="text-sm font-semibold text-gray-800 dark:text-white/90">{keywordMappingPct}%</span>
</div>
<ProgressBar value={keywordMappingPct} color="primary" size="md" />
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{stats.mappedKeywords} of {stats.keywords} keywords mapped
</p>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Clusters With Ideas</span>
<span className="text-sm font-semibold text-gray-800 dark:text-white/90">{clustersIdeasPct}%</span>
</div>
<ProgressBar value={clustersIdeasPct} color="success" size="md" />
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{stats.clustersWithIdeas} of {stats.clusters} clusters have ideas
</p>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Ideas Queued to Writer</span>
<span className="text-sm font-semibold text-gray-800 dark:text-white/90">{ideasQueuedPct}%</span>
</div>
<ProgressBar value={ideasQueuedPct} color="warning" size="md" />
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{stats.queuedIdeas} of {stats.ideas} ideas queued
</p>
</div>
</div>
</ComponentCard>
{/* Top 5 Clusters */}
<ComponentCard title="Top 5 Clusters by Volume" desc="Highest volume keyword clusters" className="lg:col-span-1">
<div className="space-y-4">
{topClusters.map((cluster, index) => {
const maxVolume = topClusters[0].volume;
const percentage = Math.round((cluster.volume / maxVolume) * 100);
return (
<div key={index}>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-gray-800 dark:text-white/90">{cluster.name}</span>
<span className="text-sm font-semibold text-gray-600 dark:text-gray-400">
{cluster.volume.toLocaleString()}
</span>
</div>
<ProgressBar
value={percentage}
color={index % 2 === 0 ? "primary" : "success"}
size="sm"
/>
</div>
);
})}
</div>
</ComponentCard>
{/* Ideas by Status */}
<ComponentCard title="Ideas by Status" desc="Content ideas workflow status" className="lg:col-span-1">
<div className="space-y-4">
{ideasByStatus.map((item, index) => {
const total = ideasByStatus.reduce((sum, i) => sum + i.count, 0);
const percentage = Math.round((item.count / total) * 100);
return (
<div key={index}>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-gray-800 dark:text-white/90">{item.status}</span>
<span className="text-sm font-semibold text-gray-600 dark:text-gray-400">{item.count}</span>
</div>
<ProgressBar
value={percentage}
color={
item.color === "blue"
? "primary"
: item.color === "amber"
? "warning"
: "success"
}
size="sm"
/>
</div>
);
})}
</div>
</ComponentCard>
</div>
{/* Next Actions */}
<ComponentCard title="Next Actions" desc="Actionable items requiring attention">
<div className="space-y-3">
{nextActions.map((action, index) => (
<div
key={index}
className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-800"
>
<span className="text-sm text-gray-700 dark:text-gray-300">{action.text}</span>
<Link
to={action.path}
className="inline-flex items-center gap-1 text-sm font-medium text-brand-500 hover:text-brand-600"
>
{action.action}
<ArrowRightIcon className="size-4" />
</Link>
</div>
))}
</div>
</ComponentCard>
</div>
</>
);
}

View File

@@ -0,0 +1,427 @@
/**
* Ideas Page - Built with TablePageTemplate
* Consistent with Keywords page layout, structure and design
*/
import { useState, useEffect, useMemo, useCallback } from 'react';
import TablePageTemplate from '../../templates/TablePageTemplate';
import {
fetchContentIdeas,
createContentIdea,
updateContentIdea,
deleteContentIdea,
bulkDeleteContentIdeas,
bulkUpdateContentIdeasStatus,
bulkQueueIdeasToWriter,
ContentIdea,
ContentIdeasFilters,
ContentIdeaCreateData,
fetchClusters,
Cluster,
} from '../../services/api';
import FormModal from '../../components/common/FormModal';
import ProgressModal from '../../components/common/ProgressModal';
import { useProgressModal } from '../../hooks/useProgressModal';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { BoltIcon, PlusIcon, DownloadIcon } from '../../icons';
import { createIdeasPageConfig } from '../../config/pages/ideas.config';
import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
export default function Ideas() {
const toast = useToast();
const { activeSector } = useSectorStore();
const { pageSize } = usePageSizeStore();
// Data state
const [ideas, setIdeas] = useState<ContentIdea[]>([]);
const [clusters, setClusters] = useState<Cluster[]>([]);
const [loading, setLoading] = useState(true);
// Filter state
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [clusterFilter, setClusterFilter] = useState('');
const [structureFilter, setStructureFilter] = useState('');
const [typeFilter, setTypeFilter] = useState('');
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalCount, setTotalCount] = useState(0);
// Sorting state
const [sortBy, setSortBy] = useState<string>('created_at');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [showContent, setShowContent] = useState(false);
// Modal state
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [editingIdea, setEditingIdea] = useState<ContentIdea | null>(null);
const [formData, setFormData] = useState<ContentIdeaCreateData>({
idea_title: '',
description: '',
content_structure: 'blog_post',
content_type: 'blog_post',
target_keywords: '',
keyword_cluster_id: null,
status: 'new',
estimated_word_count: 1000,
});
// Progress modal for AI functions
const progressModal = useProgressModal();
// Load clusters for filter dropdown
useEffect(() => {
const loadClusters = async () => {
try {
const data = await fetchClusters({ ordering: 'name' });
setClusters(data.results || []);
} catch (error) {
console.error('Error fetching clusters:', error);
}
};
loadClusters();
}, []);
// Load ideas - wrapped in useCallback
const loadIdeas = useCallback(async () => {
setLoading(true);
setShowContent(false);
try {
const ordering = sortBy ? `${sortDirection === 'desc' ? '-' : ''}${sortBy}` : '-created_at';
const filters: ContentIdeasFilters = {
...(searchTerm && { search: searchTerm }),
...(statusFilter && { status: statusFilter }),
...(clusterFilter && { keyword_cluster_id: clusterFilter }),
...(structureFilter && { content_structure: structureFilter }),
...(typeFilter && { content_type: typeFilter }),
page: currentPage,
page_size: pageSize,
ordering,
};
const data = await fetchContentIdeas(filters);
setIdeas(data.results || []);
setTotalCount(data.count || 0);
setTotalPages(Math.ceil((data.count || 0) / pageSize));
setTimeout(() => {
setShowContent(true);
setLoading(false);
}, 100);
} catch (error: any) {
console.error('Error loading ideas:', error);
toast.error(`Failed to load ideas: ${error.message}`);
setShowContent(true);
setLoading(false);
}
}, [currentPage, statusFilter, clusterFilter, structureFilter, typeFilter, sortBy, sortDirection, searchTerm, activeSector, pageSize]);
useEffect(() => {
loadIdeas();
}, [loadIdeas]);
// Listen for site and sector changes and refresh data
useEffect(() => {
const handleSiteChange = () => {
loadIdeas();
};
const handleSectorChange = () => {
loadIdeas();
};
window.addEventListener('siteChanged', handleSiteChange);
window.addEventListener('sectorChanged', handleSectorChange);
return () => {
window.removeEventListener('siteChanged', handleSiteChange);
window.removeEventListener('sectorChanged', handleSectorChange);
};
}, [loadIdeas]);
// Reset to page 1 when pageSize changes
useEffect(() => {
setCurrentPage(1);
}, [pageSize]);
// Debounced search
useEffect(() => {
const timer = setTimeout(() => {
if (currentPage === 1) {
loadIdeas();
} else {
setCurrentPage(1);
}
}, 500);
return () => clearTimeout(timer);
}, [searchTerm, currentPage, loadIdeas]);
// Handle sorting
const handleSort = (field: string, direction: 'asc' | 'desc') => {
setSortBy(field || 'created_at');
setSortDirection(direction);
setCurrentPage(1);
};
// Bulk status update handler
const handleBulkUpdateStatus = useCallback(async (ids: string[], status: string) => {
try {
const numIds = ids.map(id => parseInt(id));
await bulkUpdateContentIdeasStatus(numIds, status);
await loadIdeas();
} catch (error: any) {
throw error;
}
}, [loadIdeas]);
// Bulk export handler
const handleBulkExport = useCallback(async (ids: string[]) => {
try {
if (!ids || ids.length === 0) {
throw new Error('No records selected for export');
}
toast.info('Export functionality coming soon');
} catch (error: any) {
throw error;
}
}, []);
// Row action handler
const handleRowAction = useCallback(async (action: string, row: ContentIdea) => {
if (action === 'queue_to_writer') {
if (row.status !== 'new') {
toast.error(`Only ideas with status "new" can be queued. Current status: ${row.status}`);
return;
}
try {
const result = await bulkQueueIdeasToWriter([row.id]);
toast.success(`Queue complete: ${result.created_count || 0} task created`);
await loadIdeas();
} catch (error: any) {
toast.error(`Failed to queue idea: ${error.message}`);
}
}
}, [toast, loadIdeas]);
const handleBulkAction = useCallback(async (action: string, ids: string[]) => {
if (action === 'queue_to_writer') {
if (ids.length === 0) {
toast.error('Please select at least one idea to queue');
return;
}
try {
const numIds = ids.map(id => parseInt(id));
const result = await bulkQueueIdeasToWriter(numIds);
toast.success(`Queue complete: ${result.created_count || 0} tasks created from ${ids.length} ideas`);
await loadIdeas();
} catch (error: any) {
toast.error(`Failed to queue ideas: ${error.message}`);
}
} else {
toast.info(`Bulk action "${action}" for ${ids.length} items`);
}
}, [toast, loadIdeas]);
// Create page config
const pageConfig = useMemo(() => {
return createIdeasPageConfig({
clusters,
activeSector,
formData,
setFormData,
searchTerm,
setSearchTerm,
statusFilter,
setStatusFilter,
clusterFilter,
setClusterFilter,
structureFilter,
setStructureFilter,
typeFilter,
setTypeFilter,
setCurrentPage,
});
}, [clusters, activeSector, formData, searchTerm, statusFilter, clusterFilter, structureFilter, typeFilter]);
// Calculate header metrics
const headerMetrics = useMemo(() => {
if (!pageConfig?.headerMetrics) return [];
return pageConfig.headerMetrics.map((metric) => ({
label: metric.label,
value: metric.calculate({ ideas, totalCount }),
accentColor: metric.accentColor,
}));
}, [pageConfig?.headerMetrics, ideas, totalCount]);
const resetForm = useCallback(() => {
setFormData({
idea_title: '',
description: '',
content_structure: 'blog_post',
content_type: 'blog_post',
target_keywords: '',
keyword_cluster_id: null,
status: 'new',
estimated_word_count: 1000,
});
setIsEditMode(false);
setEditingIdea(null);
}, []);
// Handle create/edit
const handleSave = async () => {
try {
if (isEditMode && editingIdea) {
await updateContentIdea(editingIdea.id, formData);
toast.success('Idea updated successfully');
} else {
await createContentIdea(formData);
toast.success('Idea created successfully');
}
setIsModalOpen(false);
resetForm();
loadIdeas();
} catch (error: any) {
toast.error(`Failed to save: ${error.message}`);
}
};
return (
<>
<TablePageTemplate
title="Content Ideas"
titleIcon={<BoltIcon className="text-warning-500 size-5" />}
subtitle="Generate and organize content ideas based on keyword research"
columns={pageConfig.columns}
data={ideas}
loading={loading}
showContent={showContent}
filters={pageConfig.filters}
filterValues={{
search: searchTerm,
status: statusFilter,
keyword_cluster_id: clusterFilter,
content_structure: structureFilter,
content_type: typeFilter,
}}
onFilterChange={(key, value) => {
const stringValue = value === null || value === undefined ? '' : String(value);
if (key === 'search') {
setSearchTerm(stringValue);
} else if (key === 'status') {
setStatusFilter(stringValue);
} else if (key === 'keyword_cluster_id') {
setClusterFilter(stringValue);
} else if (key === 'content_structure') {
setStructureFilter(stringValue);
} else if (key === 'content_type') {
setTypeFilter(stringValue);
}
setCurrentPage(1);
}}
onEdit={(row) => {
setEditingIdea(row);
setFormData({
idea_title: row.idea_title || '',
description: row.description || '',
content_structure: row.content_structure || 'blog_post',
content_type: row.content_type || 'blog_post',
target_keywords: row.target_keywords || '',
keyword_cluster_id: row.keyword_cluster_id || null,
status: row.status || 'new',
estimated_word_count: row.estimated_word_count || 1000,
});
setIsEditMode(true);
setIsModalOpen(true);
}}
onCreate={() => {
resetForm();
setIsModalOpen(true);
}}
createLabel="Add Idea"
onCreateIcon={<PlusIcon />}
onDelete={async (id: number) => {
await deleteContentIdea(id);
loadIdeas();
}}
onBulkDelete={async (ids: number[]) => {
const result = await bulkDeleteContentIdeas(ids);
loadIdeas();
return result;
}}
onBulkExport={handleBulkExport}
onBulkUpdateStatus={handleBulkUpdateStatus}
onBulkAction={handleBulkAction}
onRowAction={handleRowAction}
getItemDisplayName={(row: ContentIdea) => row.idea_title}
onExport={async () => {
toast.info('Export functionality coming soon');
}}
onExportIcon={<DownloadIcon />}
selectionLabel="idea"
pagination={{
currentPage,
totalPages,
totalCount,
onPageChange: setCurrentPage,
}}
selection={{
selectedIds,
onSelectionChange: setSelectedIds,
}}
sorting={{
sortBy,
sortDirection,
onSort: handleSort,
}}
headerMetrics={headerMetrics}
onFilterReset={() => {
setSearchTerm('');
setStatusFilter('');
setClusterFilter('');
setStructureFilter('');
setTypeFilter('');
setCurrentPage(1);
}}
/>
{/* Progress Modal for AI Functions */}
<ProgressModal
isOpen={progressModal.isOpen}
title={progressModal.title}
percentage={progressModal.progress.percentage}
status={progressModal.progress.status}
message={progressModal.progress.message}
details={progressModal.progress.details}
taskId={progressModal.taskId || undefined}
onClose={() => {
const wasCompleted = progressModal.progress.status === 'completed';
progressModal.closeModal();
// Reload data after modal closes (if completed)
if (wasCompleted) {
loadIdeas();
}
}}
/>
{/* Create/Edit Modal */}
<FormModal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false);
resetForm();
}}
onSubmit={handleSave}
title={isEditMode ? 'Edit Idea' : 'Add Idea'}
submitLabel={isEditMode ? 'Update' : 'Create'}
fields={pageConfig.formFields(clusters)}
/>
</>
);
}

View File

@@ -0,0 +1,625 @@
/**
* Keyword Opportunities Page
* Shows available SeedKeywords for the active site/sectors
* Allows users to add keywords to their workflow
*/
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import TablePageTemplate from '../../templates/TablePageTemplate';
import {
fetchSeedKeywords,
SeedKeyword,
SeedKeywordResponse,
addSeedKeywordsToWorkflow,
} from '../../services/api';
import { useSiteStore } from '../../store/siteStore';
import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { getDifficultyLabelFromNumber, getDifficultyRange, getDifficultyNumber } from '../../utils/difficulty';
import Badge from '../../components/ui/badge/Badge';
import { formatRelativeDate } from '../../utils/date';
import { BoltIcon, PlusIcon } from '../../icons';
export default function KeywordOpportunities() {
const toast = useToast();
const { activeSite } = useSiteStore();
const { activeSector, loadSectorsForSite } = useSectorStore();
const { pageSize } = usePageSizeStore();
// Data state
const [seedKeywords, setSeedKeywords] = useState<(SeedKeyword & { isAdded?: boolean })[]>([]);
const [loading, setLoading] = useState(true);
const [showContent, setShowContent] = useState(false);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// Track recently added keywords to preserve their state during reload
const recentlyAddedRef = useRef<Set<number>>(new Set());
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalCount, setTotalCount] = useState(0);
// Sorting state
const [sortBy, setSortBy] = useState<string>('keyword');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
// Filter state
const [searchTerm, setSearchTerm] = useState('');
const [intentFilter, setIntentFilter] = useState('');
const [difficultyFilter, setDifficultyFilter] = useState('');
const [volumeMin, setVolumeMin] = useState<number | ''>('');
const [volumeMax, setVolumeMax] = useState<number | ''>('');
// Load sectors for active site
useEffect(() => {
if (activeSite?.id) {
loadSectorsForSite(activeSite.id);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeSite?.id]); // loadSectorsForSite is stable from Zustand store, no need to include it
// Load seed keywords
const loadSeedKeywords = useCallback(async () => {
if (!activeSite || !activeSite.industry) {
setSeedKeywords([]);
setTotalCount(0);
setTotalPages(1);
setLoading(false);
return;
}
setLoading(true);
setShowContent(false);
try {
// Get already-attached keywords across ALL sectors for this site
let attachedSeedKeywordIds = new Set<number>();
try {
const { fetchKeywords, fetchSiteSectors } = await import('../../services/api');
// Get all sectors for the site
const sectors = await fetchSiteSectors(activeSite.id);
// Check keywords in all sectors
for (const sector of sectors) {
try {
const keywordsData = await fetchKeywords({
site_id: activeSite.id,
sector_id: sector.id,
page_size: 1000, // Get all to check which are attached
});
(keywordsData.results || []).forEach((k: any) => {
// seed_keyword_id is write_only in serializer, so use seed_keyword.id instead
const seedKeywordId = k.seed_keyword_id || (k.seed_keyword && k.seed_keyword.id);
if (seedKeywordId) {
attachedSeedKeywordIds.add(Number(seedKeywordId));
}
});
} catch (err) {
// If keywords fetch fails for a sector, continue with others
console.warn(`Could not fetch attached keywords for sector ${sector.id}:`, err);
}
}
} catch (err) {
// If sectors fetch fails, continue without filtering
console.warn('Could not fetch sectors or attached keywords:', err);
}
// Build filters - fetch ALL results by paginating through all pages
const baseFilters: any = {
industry: activeSite.industry,
page_size: 1000, // Use reasonable page size (API might have max limit)
};
// Add sector filter if active sector is selected
// IMPORTANT: Filter by industry_sector (IndustrySector ID) which is what SeedKeyword.sector references
if (activeSector && activeSector.industry_sector) {
baseFilters.sector = activeSector.industry_sector;
}
if (searchTerm) baseFilters.search = searchTerm;
if (intentFilter) baseFilters.intent = intentFilter;
// Fetch ALL pages to get complete dataset
let allResults: SeedKeyword[] = [];
let currentPageNum = 1;
let hasMore = true;
while (hasMore) {
const filters = { ...baseFilters, page: currentPageNum };
const data: SeedKeywordResponse = await fetchSeedKeywords(filters);
if (data.results && data.results.length > 0) {
allResults = [...allResults, ...data.results];
}
// Check if there are more pages
hasMore = data.next !== null && data.next !== undefined;
currentPageNum++;
// Safety limit to prevent infinite loops
if (currentPageNum > 100) {
console.warn('Reached maximum page limit (100) while fetching seed keywords');
break;
}
}
// Mark already-attached keywords instead of filtering them out
// Also check recentlyAddedRef to preserve state for keywords just added
let filteredResults = allResults.map(sk => {
const isAdded = attachedSeedKeywordIds.has(Number(sk.id)) || recentlyAddedRef.current.has(Number(sk.id));
return {
...sk,
isAdded: Boolean(isAdded) // Explicitly convert to boolean true/false
};
});
if (difficultyFilter) {
const difficultyNum = parseInt(difficultyFilter);
const label = getDifficultyLabelFromNumber(difficultyNum);
if (label !== null) {
const range = getDifficultyRange(label);
if (range) {
filteredResults = filteredResults.filter(
sk => sk.difficulty >= range.min && sk.difficulty <= range.max
);
}
}
}
if (volumeMin !== '' && volumeMin !== null && volumeMin !== undefined) {
filteredResults = filteredResults.filter(sk => sk.volume >= Number(volumeMin));
}
if (volumeMax !== '' && volumeMax !== null && volumeMax !== undefined) {
filteredResults = filteredResults.filter(sk => sk.volume <= Number(volumeMax));
}
// Apply client-side sorting
if (sortBy) {
filteredResults.sort((a, b) => {
let aVal: any;
let bVal: any;
if (sortBy === 'keyword') {
aVal = a.keyword.toLowerCase();
bVal = b.keyword.toLowerCase();
} else if (sortBy === 'volume') {
aVal = a.volume;
bVal = b.volume;
} else if (sortBy === 'difficulty') {
aVal = a.difficulty;
bVal = b.difficulty;
} else if (sortBy === 'intent') {
aVal = a.intent.toLowerCase();
bVal = b.intent.toLowerCase();
} else {
return 0;
}
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1;
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1;
return 0;
});
}
// Calculate total count and pages from filtered results
const totalFiltered = filteredResults.length;
const pageSizeNum = pageSize || 10;
// Apply client-side pagination
const startIndex = (currentPage - 1) * pageSizeNum;
const endIndex = startIndex + pageSizeNum;
const paginatedResults = filteredResults.slice(startIndex, endIndex);
setSeedKeywords(paginatedResults);
setTotalCount(totalFiltered);
setTotalPages(Math.ceil(totalFiltered / pageSizeNum));
setShowContent(true);
} catch (error: any) {
console.error('Error loading seed keywords:', error);
toast.error(`Failed to load keyword opportunities: ${error.message}`);
setSeedKeywords([]);
setTotalCount(0);
setTotalPages(1);
} finally {
setLoading(false);
}
}, [activeSite, activeSector, currentPage, pageSize, searchTerm, intentFilter, difficultyFilter, volumeMin, volumeMax, sortBy, sortDirection]);
// Load data on mount and when filters change (excluding search - handled separately)
useEffect(() => {
loadSeedKeywords();
}, [loadSeedKeywords]);
// Debounced search - reset to page 1 when search term changes
useEffect(() => {
const timer = setTimeout(() => {
setCurrentPage(1);
}, 500);
return () => clearTimeout(timer);
}, [searchTerm]); // Only depend on searchTerm
// Handle pageSize changes - reload data when pageSize changes
// Note: loadSeedKeywords will be recreated when pageSize changes (it's in its dependencies)
// The effect that depends on loadSeedKeywords will handle the reload
// We just need to reset to page 1
useEffect(() => {
setCurrentPage(1);
}, [pageSize]); // Only depend on pageSize
// Handle sorting
const handleSort = (field: string, direction: 'asc' | 'desc') => {
setSortBy(field || 'keyword');
setSortDirection(direction);
setCurrentPage(1);
};
// Handle adding keywords to workflow
const handleAddToWorkflow = useCallback(async (seedKeywordIds: number[]) => {
if (!activeSite) {
toast.error('Please select an active site first');
return;
}
// Get sector to use - use activeSector if available, otherwise get first available sector
let sectorToUse = activeSector;
if (!sectorToUse) {
try {
const { fetchSiteSectors } = await import('../../services/api');
const sectors = await fetchSiteSectors(activeSite.id);
if (sectors.length === 0) {
toast.error('No sectors available for this site. Please create a sector first.');
return;
}
sectorToUse = {
id: sectors[0].id,
name: sectors[0].name,
slug: sectors[0].slug,
site_id: activeSite.id,
is_active: sectors[0].is_active !== false,
industry_sector: sectors[0].industry_sector || null,
};
} catch (error: any) {
toast.error(`Failed to get sectors: ${error.message}`);
return;
}
}
try {
const result = await addSeedKeywordsToWorkflow(
seedKeywordIds,
activeSite.id,
sectorToUse.id
);
if (result.success) {
toast.success(`Successfully added ${result.created} keyword(s) to workflow`);
// Track these as recently added to preserve state during reload
seedKeywordIds.forEach(id => {
recentlyAddedRef.current.add(id);
});
// Clear selection
setSelectedIds([]);
// Immediately update state to mark keywords as added - this gives instant feedback
setSeedKeywords(prevKeywords =>
prevKeywords.map(kw =>
seedKeywordIds.includes(kw.id)
? { ...kw, isAdded: true }
: kw
)
);
// Don't reload immediately - the state is already updated
// The recentlyAddedRef will ensure they stay marked as added
// Only reload if user changes filters/pagination
} else {
toast.error(`Failed to add keywords: ${result.errors?.join(', ') || 'Unknown error'}`);
}
} catch (error: any) {
toast.error(`Failed to add keywords: ${error.message}`);
}
}, [activeSite, activeSector, toast]);
// Handle bulk add selected - filter out already added keywords
const handleBulkAddSelected = useCallback(async (ids: string[]) => {
if (ids.length === 0) {
toast.error('Please select at least one keyword');
return;
}
// Filter out already added keywords
const availableIds = ids.filter(id => {
const keyword = seedKeywords.find(sk => String(sk.id) === id);
return keyword && !keyword.isAdded;
});
if (availableIds.length === 0) {
toast.error('All selected keywords are already added to workflow');
return;
}
if (availableIds.length < ids.length) {
toast.info(`${ids.length - availableIds.length} keyword(s) were already added and were skipped`);
}
const seedKeywordIds = availableIds.map(id => parseInt(id));
await handleAddToWorkflow(seedKeywordIds);
}, [handleAddToWorkflow, toast, seedKeywords]);
// Handle add all - fetch all keywords for site/sectors, not just current page
const handleAddAll = useCallback(async () => {
if (!activeSite || !activeSite.industry) {
toast.error('Please select an active site first');
return;
}
try {
// Fetch ALL seed keywords for the site/sectors (no pagination)
const filters: any = {
industry: activeSite.industry,
page_size: 1000, // Large page size to get all
};
if (activeSector?.industry_sector) {
filters.sector = activeSector.industry_sector;
}
const data: SeedKeywordResponse = await fetchSeedKeywords(filters);
const allSeedKeywords = data.results || [];
if (allSeedKeywords.length === 0) {
toast.error('No keywords available to add');
return;
}
// Get already-added keywords to filter them out
const { fetchKeywords, fetchSiteSectors } = await import('../../services/api');
const sectors = await fetchSiteSectors(activeSite.id);
let attachedSeedKeywordIds = new Set<number>();
for (const sector of sectors) {
try {
const keywordsData = await fetchKeywords({
site_id: activeSite.id,
sector_id: sector.id,
page_size: 1000,
});
(keywordsData.results || []).forEach((k: any) => {
// seed_keyword_id is write_only in serializer, so use seed_keyword.id instead
const seedKeywordId = k.seed_keyword_id || (k.seed_keyword && k.seed_keyword.id);
if (seedKeywordId) {
attachedSeedKeywordIds.add(Number(seedKeywordId));
}
});
} catch (err) {
console.warn(`Could not fetch attached keywords for sector ${sector.id}:`, err);
}
}
// Filter out already added keywords
const availableKeywords = allSeedKeywords.filter(sk => !attachedSeedKeywordIds.has(sk.id));
if (availableKeywords.length === 0) {
toast.error('All keywords are already added to workflow');
return;
}
if (availableKeywords.length < allSeedKeywords.length) {
toast.info(`${allSeedKeywords.length - availableKeywords.length} keyword(s) were already added and were skipped`);
}
const seedKeywordIds = availableKeywords.map(sk => sk.id);
await handleAddToWorkflow(seedKeywordIds);
} catch (error: any) {
toast.error(`Failed to load all keywords: ${error.message}`);
}
}, [activeSite, activeSector, handleAddToWorkflow, toast]);
// Page config
const pageConfig = useMemo(() => {
const showSectorColumn = !activeSector; // Show when viewing all sectors
return {
columns: [
{
key: 'keyword',
label: 'Keyword',
sortable: true,
sortField: 'keyword',
},
...(showSectorColumn ? [{
key: 'sector_name',
label: 'Sector',
sortable: false,
render: (_value: string, row: SeedKeyword) => (
<Badge color="info" size="sm" variant="light">
{row.sector_name || '-'}
</Badge>
),
}] : []),
{
key: 'volume',
label: 'Volume',
sortable: true,
sortField: 'volume',
render: (value: number) => value.toLocaleString(),
},
{
key: 'difficulty',
label: 'Difficulty',
sortable: true,
sortField: 'difficulty',
align: 'center' as const,
render: (value: number) => {
const difficultyNum = getDifficultyNumber(value);
const difficultyBadgeVariant =
typeof difficultyNum === 'number' && difficultyNum === 5
? 'solid'
: typeof difficultyNum === 'number' &&
(difficultyNum === 2 || difficultyNum === 3 || difficultyNum === 4)
? 'light'
: typeof difficultyNum === 'number' && difficultyNum === 1
? 'solid'
: 'light';
const difficultyBadgeColor =
typeof difficultyNum === 'number' && difficultyNum === 1
? 'success'
: typeof difficultyNum === 'number' && difficultyNum === 2
? 'success'
: typeof difficultyNum === 'number' && difficultyNum === 3
? 'warning'
: typeof difficultyNum === 'number' && difficultyNum === 4
? 'error'
: typeof difficultyNum === 'number' && difficultyNum === 5
? 'error'
: 'light';
return typeof difficultyNum === 'number' ? (
<Badge
color={difficultyBadgeColor}
variant={difficultyBadgeVariant}
size="sm"
>
{difficultyNum}
</Badge>
) : (
difficultyNum
);
},
},
{
key: 'intent',
label: 'Intent',
sortable: true,
sortField: 'intent',
render: (value: string) => {
const getIntentColor = (intent: string) => {
const lowerIntent = intent?.toLowerCase() || '';
if (lowerIntent === 'transactional' || lowerIntent === 'commercial') {
return 'success';
} else if (lowerIntent === 'navigational') {
return 'warning';
}
return 'info';
};
return (
<Badge
color={getIntentColor(value)}
size="sm"
variant={value?.toLowerCase() === 'informational' ? 'light' : undefined}
>
{value}
</Badge>
);
},
},
],
filters: [
{
key: 'search',
label: 'Search',
type: 'text',
placeholder: 'Search keywords...',
},
{
key: 'intent',
label: 'Intent',
type: 'select',
options: [
{ value: '', label: 'All Intent' },
{ value: 'informational', label: 'Informational' },
{ value: 'navigational', label: 'Navigational' },
{ value: 'transactional', label: 'Transactional' },
{ value: 'commercial', label: 'Commercial' },
],
},
{
key: 'difficulty',
label: 'Difficulty',
type: 'select',
options: [
{ value: '', label: 'All Difficulty' },
{ value: '1', label: '1 - Very Easy' },
{ value: '2', label: '2 - Easy' },
{ value: '3', label: '3 - Medium' },
{ value: '4', label: '4 - Hard' },
{ value: '5', label: '5 - Very Hard' },
],
},
],
};
}, [activeSector]);
return (
<>
<TablePageTemplate
title="Keyword Opportunities"
titleIcon={<BoltIcon className="text-brand-500 size-5" />}
subtitle="Discover and add keywords to your workflow"
columns={pageConfig.columns}
data={seedKeywords}
loading={loading}
showContent={showContent}
filters={pageConfig.filters}
filterValues={{
search: searchTerm,
intent: intentFilter,
difficulty: difficultyFilter,
}}
onFilterChange={(key, value) => {
const stringValue = value === null || value === undefined ? '' : String(value);
if (key === 'search') {
setSearchTerm(stringValue);
} else if (key === 'intent') {
setIntentFilter(stringValue);
setCurrentPage(1);
} else if (key === 'difficulty') {
setDifficultyFilter(stringValue);
setCurrentPage(1);
}
}}
onRowAction={async (actionKey: string, row: SeedKeyword & { isAdded?: boolean }) => {
if (actionKey === 'add_to_workflow') {
// Don't allow adding already-added keywords
if (row.isAdded) {
toast.info('This keyword is already added to workflow');
return;
}
await handleAddToWorkflow([row.id]);
}
}}
onBulkAction={async (actionKey: string, ids: string[]) => {
if (actionKey === 'add_selected_to_workflow') {
await handleBulkAddSelected(ids);
}
}}
onCreate={handleAddAll}
createLabel="Add All to Workflow"
onCreateIcon={<PlusIcon />}
pagination={{
currentPage,
totalPages,
totalCount,
onPageChange: setCurrentPage,
}}
sorting={{
sortBy,
sortDirection,
onSort: handleSort,
}}
selection={{
selectedIds,
onSelectionChange: setSelectedIds,
}}
/>
</>
);
}

View File

@@ -0,0 +1,718 @@
/**
* Keywords Page - Refactored to use TablePageTemplate
* This demonstrates how to use the config-driven template system
* while maintaining all existing functionality.
*/
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import TablePageTemplate from '../../templates/TablePageTemplate';
import {
fetchKeywords,
createKeyword,
updateKeyword,
deleteKeyword,
bulkDeleteKeywords,
bulkUpdateKeywordsStatus,
Keyword,
KeywordFilters,
KeywordCreateData,
fetchClusters,
Cluster,
API_BASE_URL,
autoClusterKeywords,
fetchSeedKeywords,
SeedKeyword,
} from '../../services/api';
import { useSiteStore } from '../../store/siteStore';
import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
import { getDifficultyLabelFromNumber, getDifficultyRange } from '../../utils/difficulty';
import FormModal from '../../components/common/FormModal';
import ProgressModal from '../../components/common/ProgressModal';
import { useProgressModal } from '../../hooks/useProgressModal';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { ArrowUpIcon, PlusIcon, ListIcon, DownloadIcon } from '../../icons';
import { useKeywordsImportExport } from '../../config/import-export.config';
import { createKeywordsPageConfig } from '../../config/pages/keywords.config';
export default function Keywords() {
const toast = useToast();
const { activeSite } = useSiteStore();
const { activeSector, loadSectorsForSite } = useSectorStore();
const { pageSize } = usePageSizeStore();
// Data state
const [keywords, setKeywords] = useState<Keyword[]>([]);
const [clusters, setClusters] = useState<Cluster[]>([]);
const [availableSeedKeywords, setAvailableSeedKeywords] = useState<SeedKeyword[]>([]);
const [loading, setLoading] = useState(true);
const [loadingSeedKeywords, setLoadingSeedKeywords] = useState(false);
// Filter state - match Keywords.tsx
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [clusterFilter, setClusterFilter] = useState('');
const [intentFilter, setIntentFilter] = useState('');
const [difficultyFilter, setDifficultyFilter] = useState('');
const [volumeMin, setVolumeMin] = useState<number | ''>('');
const [volumeMax, setVolumeMax] = useState<number | ''>('');
const [isVolumeDropdownOpen, setIsVolumeDropdownOpen] = useState(false);
const [tempVolumeMin, setTempVolumeMin] = useState<number | ''>('');
const [tempVolumeMax, setTempVolumeMax] = useState<number | ''>('');
const volumeDropdownRef = useRef<HTMLDivElement>(null);
const volumeButtonRef = useRef<HTMLButtonElement>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalCount, setTotalCount] = useState(0);
// Sorting state (global - not page-specific)
const [sortBy, setSortBy] = useState<string>('created_at');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [showContent, setShowContent] = useState(false);
// Modal state
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [editingKeyword, setEditingKeyword] = useState<Keyword | null>(null);
const [formData, setFormData] = useState<KeywordCreateData>({
seed_keyword_id: 0,
volume_override: null,
difficulty_override: null,
cluster_id: null,
status: 'pending',
});
// Progress modal for AI functions
const progressModal = useProgressModal();
const hasReloadedRef = useRef(false);
// Load sectors for active site using sector store
useEffect(() => {
if (activeSite) {
loadSectorsForSite(activeSite.id);
}
}, [activeSite, loadSectorsForSite]);
// Load available SeedKeywords when site and sector are selected
useEffect(() => {
const loadAvailableSeedKeywords = async () => {
if (!activeSite || !activeSector || !activeSite.industry) {
setAvailableSeedKeywords([]);
return;
}
try {
setLoadingSeedKeywords(true);
// Fetch SeedKeywords for the site's industry and sector's industry_sector
const response = await fetchSeedKeywords({
industry: activeSite.industry,
sector: activeSector.industry_sector || undefined,
});
// Filter out SeedKeywords that are already attached to this site/sector
const attachedSeedKeywordIds = new Set(
keywords.map(k => k.seed_keyword_id)
);
const available = (response.results || []).filter(
sk => !attachedSeedKeywordIds.has(sk.id)
);
setAvailableSeedKeywords(available);
} catch (error: any) {
console.error('Failed to load available seed keywords:', error);
setAvailableSeedKeywords([]);
} finally {
setLoadingSeedKeywords(false);
}
};
loadAvailableSeedKeywords();
}, [activeSite, activeSector, keywords]);
// Load clusters for filter dropdown
useEffect(() => {
const loadClusters = async () => {
try {
const data = await fetchClusters({ ordering: 'name' });
setClusters(data.results || []);
} catch (error) {
console.error('Error fetching clusters:', error);
}
};
loadClusters();
}, []);
// Load keywords - wrapped in useCallback to prevent infinite loops
const loadKeywords = useCallback(async () => {
setLoading(true);
setShowContent(false); // Reset showContent to show loading state
try {
// Build ordering parameter from sort state
const ordering = sortBy ? `${sortDirection === 'desc' ? '-' : ''}${sortBy}` : '-created_at';
// Build filters object from individual filter states
// Only include filters that have actual values (not empty strings)
// Use activeSector from store for sector filtering
const filters: KeywordFilters = {
...(searchTerm && { search: searchTerm }),
...(statusFilter && { status: statusFilter }),
...(clusterFilter && { cluster_id: clusterFilter }),
...(intentFilter && { intent: intentFilter }),
...(activeSector?.id && { sector_id: activeSector.id }),
page: currentPage,
page_size: pageSize || 10, // Ensure we always send a page_size
ordering,
};
// Add difficulty range filter
if (difficultyFilter) {
const difficultyNum = parseInt(difficultyFilter);
const label = getDifficultyLabelFromNumber(difficultyNum);
if (label !== null) {
const range = getDifficultyRange(label);
if (range) {
filters.difficulty_min = range.min;
filters.difficulty_max = range.max;
}
}
}
// Add volume range filters
if (volumeMin !== '' && volumeMin !== null && volumeMin !== undefined) {
filters.volume_min = Number(volumeMin);
}
if (volumeMax !== '' && volumeMax !== null && volumeMax !== undefined) {
filters.volume_max = Number(volumeMax);
}
const data = await fetchKeywords(filters);
setKeywords(data.results || []);
setTotalCount(data.count || 0);
setTotalPages(Math.ceil((data.count || 0) / pageSize));
// Show content after data loads (smooth reveal)
setTimeout(() => {
setShowContent(true);
setLoading(false);
}, 100);
} catch (error: any) {
console.error('Error loading keywords:', error);
toast.error(`Failed to load keywords: ${error.message}`);
setShowContent(true);
setLoading(false);
}
}, [currentPage, statusFilter, clusterFilter, intentFilter, difficultyFilter, volumeMin, volumeMax, sortBy, sortDirection, searchTerm, activeSite, activeSector, pageSize]);
// Listen for site and sector changes and refresh data
useEffect(() => {
const handleSiteChange = () => {
// Reload keywords when site changes (sector store will auto-select first sector)
loadKeywords();
// Reload clusters for new site
const loadClusters = async () => {
try {
const data = await fetchClusters({ ordering: 'name' });
setClusters(data.results || []);
} catch (error) {
console.error('Error fetching clusters:', error);
}
};
loadClusters();
};
const handleSectorChange = () => {
// Reload keywords when sector changes
loadKeywords();
};
window.addEventListener('siteChanged', handleSiteChange);
window.addEventListener('sectorChanged', handleSectorChange);
return () => {
window.removeEventListener('siteChanged', handleSiteChange);
window.removeEventListener('sectorChanged', handleSectorChange);
};
}, [loadKeywords]);
// Handle click outside volume dropdown
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
volumeDropdownRef.current &&
!volumeDropdownRef.current.contains(event.target as Node) &&
volumeButtonRef.current &&
!volumeButtonRef.current.contains(event.target as Node)
) {
setIsVolumeDropdownOpen(false);
setTempVolumeMin(volumeMin);
setTempVolumeMax(volumeMax);
}
};
if (isVolumeDropdownOpen) {
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}
}, [isVolumeDropdownOpen, volumeMin, volumeMax]);
// Load data on mount and when filters change (excluding search - handled separately)
useEffect(() => {
loadKeywords();
}, [loadKeywords]);
// Debounced search - reset to page 1 when search term changes
useEffect(() => {
const timer = setTimeout(() => {
// Always reset to page 1 when search changes
// The main useEffect will handle reloading when currentPage changes
setCurrentPage(1);
}, 500);
return () => clearTimeout(timer);
}, [searchTerm]); // Only depend on searchTerm
// Handle pageSize changes - reload data when pageSize changes
// Note: TablePageTemplate already calls onPageChange(1), but we need to ensure reload happens
useEffect(() => {
// When pageSize changes:
// 1. Reset to page 1 (TablePageTemplate does this, but we ensure it)
// 2. loadKeywords will be recreated because pageSize is in its dependency array
// 3. The useEffect that depends on loadKeywords will fire and reload data
// But we need to ensure reload happens even if currentPage is already 1
const wasOnPage1 = currentPage === 1;
setCurrentPage(1);
// If we were already on page 1, explicitly trigger reload
// Otherwise, the currentPage change will trigger reload via loadKeywords dependency
if (wasOnPage1) {
// Use a small timeout to ensure state has updated
setTimeout(() => {
loadKeywords();
}, 0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageSize]); // Only depend on pageSize - loadKeywords will be recreated when pageSize changes
// Handle sorting (global)
const handleSort = (field: string, direction: 'asc' | 'desc') => {
setSortBy(field || 'created_at');
setSortDirection(direction);
setCurrentPage(1); // Reset to first page when sorting changes
};
// Import/Export handlers
const { handleExport, handleImportClick, ImportModal } = useKeywordsImportExport(
() => {
toast.success('Import successful', 'Keywords imported successfully.');
loadKeywords();
},
(error) => {
toast.error('Import failed', error.message);
},
// Pass active site_id and active sector_id for import
activeSite && activeSector
? { site_id: activeSite.id, sector_id: activeSector.id }
: undefined
);
// Handle bulk actions (delete, export, update_status are now handled by TablePageTemplate)
// This is only for actions that don't have modals (like auto_cluster)
const handleBulkAction = useCallback(async (action: string, ids: string[]) => {
if (action === 'auto_cluster') {
if (ids.length === 0) {
toast.error('Please select at least one keyword to cluster');
return;
}
if (ids.length > 20) {
toast.error('Maximum 20 keywords allowed for clustering');
return;
}
try {
const numIds = ids.map(id => parseInt(id));
const sectorId = activeSector?.id;
const result = await autoClusterKeywords(numIds, sectorId);
// Check if result has success field - if false, it's an error response
if (result && result.success === false) {
// Error response from API
const errorMsg = result.error || 'Failed to cluster keywords';
toast.error(errorMsg);
return;
}
if (result && result.success) {
if (result.task_id) {
// Async task - open progress modal
hasReloadedRef.current = false;
progressModal.openModal(result.task_id, 'Auto-Clustering Keywords');
// Don't show toast - progress modal will show status
} else {
// Synchronous completion
toast.success(`Clustering complete: ${result.clusters_created || 0} clusters created, ${result.keywords_updated || 0} keywords updated`);
if (!hasReloadedRef.current) {
hasReloadedRef.current = true;
loadKeywords();
}
}
} else {
// Unexpected response format - show error
const errorMsg = result?.error || 'Unexpected response format';
toast.error(errorMsg);
}
} catch (error: any) {
// API error (network error, parse error, etc.)
let errorMsg = 'Failed to cluster keywords';
if (error.message) {
// Extract clean error message from API error format
errorMsg = error.message.replace(/^API Error \(\d+\): [^-]+ - /, '').trim();
if (!errorMsg || errorMsg === error.message) {
errorMsg = error.message;
}
}
toast.error(errorMsg);
}
} else {
toast.info(`Bulk action "${action}" for ${ids.length} items`);
}
}, [toast, activeSector, loadKeywords, progressModal]);
const resetForm = useCallback(() => {
setFormData({
seed_keyword_id: 0,
volume_override: null,
difficulty_override: null,
cluster_id: null,
status: 'pending',
});
setIsEditMode(false);
setEditingKeyword(null);
}, []);
// Bulk export handler for selected items (used by TablePageTemplate modal)
const handleBulkExport = useCallback(async (ids: string[]) => {
try {
// Ensure we have valid IDs
if (!ids || ids.length === 0) {
throw new Error('No records selected for export');
}
// For bulk export, ONLY export selected IDs - ignore ALL other filters
// Build URL directly to ensure only 'ids' parameter is sent
const idsParam = ids.join(',');
const exportUrl = `${API_BASE_URL}/v1/planner/keywords/export/?ids=${encodeURIComponent(idsParam)}`;
const response = await fetch(exportUrl, {
method: 'GET',
credentials: 'include',
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Export failed: ${response.statusText} - ${errorText}`);
}
const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = 'keywords.csv';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(downloadUrl);
} catch (error: any) {
throw error; // Let TablePageTemplate handle toast
}
}, []);
// Bulk status update handler (used by TablePageTemplate modal)
const handleBulkUpdateStatus = useCallback(async (ids: string[], status: string) => {
try {
const numIds = ids.map(id => parseInt(id));
await bulkUpdateKeywordsStatus(numIds, status);
await loadKeywords(); // Reload data after status update
} catch (error: any) {
throw error; // Let TablePageTemplate handle toast
}
}, [loadKeywords]);
// Create page config using factory function - all config comes from keywords.config.tsx
const pageConfig = useMemo(() => {
return createKeywordsPageConfig({
clusters,
activeSector,
availableSeedKeywords,
formData,
setFormData,
// Filter state handlers
searchTerm,
setSearchTerm,
statusFilter,
setStatusFilter,
intentFilter,
setIntentFilter,
difficultyFilter,
setDifficultyFilter,
clusterFilter,
setClusterFilter,
volumeMin,
volumeMax,
setVolumeMin,
setVolumeMax,
isVolumeDropdownOpen,
setIsVolumeDropdownOpen,
tempVolumeMin,
tempVolumeMax,
setTempVolumeMin,
setTempVolumeMax,
volumeButtonRef,
volumeDropdownRef,
setCurrentPage,
loadKeywords,
});
}, [
clusters,
activeSector,
availableSeedKeywords,
formData,
searchTerm,
statusFilter,
intentFilter,
difficultyFilter,
clusterFilter,
volumeMin,
volumeMax,
isVolumeDropdownOpen,
tempVolumeMin,
tempVolumeMax,
loadKeywords,
activeSite,
]);
// Calculate header metrics from config (matching reference plugin KPIs from kpi-config.php)
const headerMetrics = useMemo(() => {
if (!pageConfig?.headerMetrics) return [];
return pageConfig.headerMetrics.map((metric) => ({
label: metric.label,
value: metric.calculate({ keywords, totalCount, clusters }),
accentColor: metric.accentColor,
}));
}, [pageConfig?.headerMetrics, keywords, totalCount, clusters]);
// Handle create/edit
const handleSave = async () => {
try {
if (!activeSite) {
toast.error('Please select an active site first');
return;
}
if (isEditMode && editingKeyword) {
await updateKeyword(editingKeyword.id, formData);
toast.success('Keyword updated successfully');
} else {
// For new keywords, add site_id and sector_id
// Use active sector from store
if (!activeSector) {
toast.error('Please select a sector for this site first');
return;
}
if (!formData.seed_keyword_id) {
toast.error('Please select a seed keyword');
return;
}
const sectorId = activeSector.id;
const keywordData: any = {
...formData,
site_id: activeSite.id,
sector_id: sectorId,
};
await createKeyword(keywordData);
toast.success('Keyword attached successfully');
}
setIsModalOpen(false);
resetForm();
loadKeywords();
} catch (error: any) {
toast.error(`Failed to save: ${error.message}`);
}
};
// Handle edit - populate form with existing keyword data
const handleEdit = useCallback((keyword: Keyword) => {
setEditingKeyword(keyword);
setIsEditMode(true);
setFormData({
seed_keyword_id: keyword.seed_keyword_id,
volume_override: keyword.volume_override || null,
difficulty_override: keyword.difficulty_override || null,
cluster_id: keyword.cluster_id,
status: keyword.status,
});
setIsModalOpen(true);
}, []);
return (
<>
<TablePageTemplate
title="Keywords"
titleIcon={<ListIcon className="text-brand-500 size-5" />}
subtitle="Manage and organize SEO keywords for content planning"
columns={pageConfig.columns}
data={keywords}
loading={loading}
showContent={showContent}
filters={pageConfig.filters}
filterValues={{
search: searchTerm,
status: statusFilter,
intent: intentFilter,
difficulty: difficultyFilter,
cluster_id: clusterFilter,
volumeMin: volumeMin,
volumeMax: volumeMax,
}}
onFilterChange={(key, value) => {
// Normalize value to string, preserving empty strings
const stringValue = value === null || value === undefined ? '' : String(value);
// Map filter keys to state setters
if (key === 'search') {
setSearchTerm(stringValue);
} else if (key === 'status') {
setStatusFilter(stringValue);
setCurrentPage(1);
} else if (key === 'intent') {
setIntentFilter(stringValue);
setCurrentPage(1);
} else if (key === 'difficulty') {
setDifficultyFilter(stringValue);
setCurrentPage(1);
} else if (key === 'cluster_id') {
setClusterFilter(stringValue);
setCurrentPage(1);
}
// Note: volume filter is handled by custom render, cluster options updated dynamically
}}
onEdit={handleEdit}
onCreate={() => {
resetForm();
setIsModalOpen(true);
}}
createLabel="Add Keyword"
onCreateIcon={<PlusIcon />}
onDelete={async (id: number) => {
await deleteKeyword(id);
loadKeywords();
}}
onBulkDelete={async (ids: number[]) => {
const result = await bulkDeleteKeywords(ids);
loadKeywords();
return result;
}}
onBulkExport={handleBulkExport}
onBulkUpdateStatus={handleBulkUpdateStatus}
onBulkAction={handleBulkAction}
getItemDisplayName={(row: Keyword) => row.keyword}
onExport={async () => {
try {
const filterValues = {
search: searchTerm,
status: statusFilter,
cluster_id: clusterFilter,
intent: intentFilter,
difficulty: difficultyFilter,
};
await handleExport('csv', filterValues);
toast.success('Export successful', 'Keywords exported successfully.');
} catch (error: any) {
toast.error('Export failed', error.message);
}
}}
onExportIcon={<DownloadIcon />}
onImport={handleImportClick}
onImportIcon={<ArrowUpIcon />}
selectionLabel="keyword"
pagination={{
currentPage,
totalPages,
totalCount,
onPageChange: (page: number) => {
setCurrentPage(page);
},
}}
selection={{
selectedIds,
onSelectionChange: setSelectedIds,
}}
sorting={{
sortBy,
sortDirection,
onSort: handleSort,
}}
headerMetrics={headerMetrics}
onFilterReset={() => {
setSearchTerm('');
setStatusFilter('');
setClusterFilter('');
setIntentFilter('');
setDifficultyFilter('');
setVolumeMin('');
setVolumeMax('');
setTempVolumeMin('');
setTempVolumeMax('');
setIsVolumeDropdownOpen(false);
setCurrentPage(1);
}}
/>
{/* Create/Edit Modal */}
<FormModal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false);
resetForm();
}}
onSubmit={handleSave}
title={isEditMode ? 'Edit Keyword' : 'Add Keyword'}
submitLabel={isEditMode ? 'Update' : 'Create'}
fields={pageConfig.formFields(clusters)}
/>
{/* Import Modal */}
<ImportModal />
{/* Progress Modal for AI Functions */}
<ProgressModal
isOpen={progressModal.isOpen}
title={progressModal.title}
percentage={progressModal.progress.percentage}
status={progressModal.progress.status}
message={progressModal.progress.message}
details={progressModal.progress.details}
taskId={progressModal.taskId || undefined}
onClose={() => {
progressModal.closeModal();
// Reload once when modal closes if task was completed
if (progressModal.progress.status === 'completed' && !hasReloadedRef.current) {
hasReloadedRef.current = true;
loadKeywords();
}
}}
/>
</>
);
}

View File

@@ -0,0 +1,22 @@
import PageMeta from "../../components/common/PageMeta";
import ComponentCard from "../../components/common/ComponentCard";
export default function Mapping() {
return (
<>
<PageMeta title="Content Mapping - IGNY8" description="Keyword to content mapping" />
<ComponentCard title="Coming Soon" desc="Keyword to content mapping">
<div className="text-center py-8">
<p className="text-gray-600 dark:text-gray-400">
Content Mapping - Coming Soon
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Map keywords and clusters to existing pages and content
</p>
</div>
</ComponentCard>
</>
);
}

View File

@@ -0,0 +1,64 @@
import { useState, useEffect } from 'react';
import PageMeta from '../../components/common/PageMeta';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { fetchIndustries, Industry } from '../../services/api';
import { Card } from '../../components/ui/card';
import Badge from '../../components/ui/badge/Badge';
export default function Industries() {
const toast = useToast();
const [industries, setIndustries] = useState<Industry[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadIndustries();
}, []);
const loadIndustries = async () => {
try {
setLoading(true);
const response = await fetchIndustries();
setIndustries(response.industries || []);
} catch (error: any) {
toast.error(`Failed to load industries: ${error.message}`);
} finally {
setLoading(false);
}
};
return (
<div className="p-6">
<PageMeta title="Industries" />
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Industries</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">Global industry reference data</p>
</div>
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading...</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{industries.map((industry) => (
<Card key={industry.id} className="p-6">
<div className="flex justify-between items-start mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{industry.name}</h3>
<Badge variant="light" color={industry.is_active ? 'success' : 'dark'}>
{industry.is_active ? 'Active' : 'Inactive'}
</Badge>
</div>
{industry.description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">{industry.description}</p>
)}
<p className="text-sm text-gray-500 dark:text-gray-400">
Sectors: {industry.sectors_count || 0}
</p>
</Card>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,124 @@
import { useState, useEffect } from 'react';
import PageMeta from '../../components/common/PageMeta';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { fetchSeedKeywords, SeedKeyword, fetchIndustries, Industry } from '../../services/api';
import { Card } from '../../components/ui/card';
import Badge from '../../components/ui/badge/Badge';
export default function SeedKeywords() {
const toast = useToast();
const [keywords, setKeywords] = useState<SeedKeyword[]>([]);
const [industries, setIndustries] = useState<Industry[]>([]);
const [loading, setLoading] = useState(true);
const [selectedIndustry, setSelectedIndustry] = useState<number | null>(null);
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
loadIndustries();
loadKeywords();
}, [selectedIndustry, searchTerm]);
const loadIndustries = async () => {
try {
const response = await fetchIndustries();
setIndustries(response.industries || []);
} catch (error: any) {
toast.error(`Failed to load industries: ${error.message}`);
}
};
const loadKeywords = async () => {
try {
setLoading(true);
const response = await fetchSeedKeywords({
industry: selectedIndustry || undefined,
search: searchTerm || undefined,
});
setKeywords(response.results || []);
} catch (error: any) {
toast.error(`Failed to load seed keywords: ${error.message}`);
} finally {
setLoading(false);
}
};
return (
<div className="p-6">
<PageMeta title="Seed Keywords" />
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Seed Keywords</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">Global keyword library for reference</p>
</div>
<div className="mb-6 flex gap-4">
<select
className="px-4 py-2 border border-gray-300 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800"
value={selectedIndustry || ''}
onChange={(e) => setSelectedIndustry(e.target.value ? parseInt(e.target.value) : null)}
>
<option value="">All Industries</option>
{industries.map((industry) => (
<option key={industry.id} value={industry.id}>
{industry.name}
</option>
))}
</select>
<input
type="text"
placeholder="Search keywords..."
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading...</div>
</div>
) : (
<Card className="p-6">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700">
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Keyword</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Industry</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Sector</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Volume</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Difficulty</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Intent</th>
</tr>
</thead>
<tbody>
{keywords.map((keyword) => (
<tr key={keyword.id} className="border-b border-gray-100 dark:border-gray-800">
<td className="py-3 px-4 text-sm font-medium text-gray-900 dark:text-white">
{keyword.keyword}
</td>
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">
{keyword.industry_name}
</td>
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">
{keyword.sector_name}
</td>
<td className="py-3 px-4 text-sm text-gray-900 dark:text-white">
{keyword.volume.toLocaleString()}
</td>
<td className="py-3 px-4 text-sm text-gray-900 dark:text-white">
{keyword.difficulty}
</td>
<td className="py-3 px-4">
<Badge variant="light" color="primary">{keyword.intent_display}</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,22 @@
import PageMeta from "../components/common/PageMeta";
import ComponentCard from "../components/common/ComponentCard";
export default function Schedules() {
return (
<>
<PageMeta title="Schedules - IGNY8" description="Automation schedules" />
<ComponentCard title="Coming Soon" desc="Automation schedules">
<div className="text-center py-8">
<p className="text-gray-600 dark:text-gray-400">
Schedules - Coming Soon
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Content scheduling and automation for consistent publishing
</p>
</div>
</ComponentCard>
</>
);
}

View File

@@ -0,0 +1,48 @@
import { useState, useEffect } from 'react';
import PageMeta from '../../components/common/PageMeta';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { fetchAPI } from '../../services/api';
import { Card } from '../../components/ui/card';
export default function AISettings() {
const toast = useToast();
const [settings, setSettings] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
try {
setLoading(true);
const response = await fetchAPI('/v1/system/settings/ai/');
setSettings(response.results || []);
} catch (error: any) {
toast.error(`Failed to load AI settings: ${error.message}`);
} finally {
setLoading(false);
}
};
return (
<div className="p-6">
<PageMeta title="AI Settings" />
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">AI Settings</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">AI-specific configuration</p>
</div>
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading...</div>
</div>
) : (
<Card className="p-6">
<p className="text-gray-600 dark:text-gray-400">AI settings management interface coming soon.</p>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { useState, useEffect } from 'react';
import PageMeta from '../../components/common/PageMeta';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { fetchAPI } from '../../services/api';
import { Card } from '../../components/ui/card';
export default function AccountSettings() {
const toast = useToast();
const [settings, setSettings] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
try {
setLoading(true);
const response = await fetchAPI('/v1/system/settings/account/');
setSettings(response.results || []);
} catch (error: any) {
toast.error(`Failed to load account settings: ${error.message}`);
} finally {
setLoading(false);
}
};
return (
<div className="p-6">
<PageMeta title="Account Settings" />
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Account Settings</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">Account-level configuration</p>
</div>
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading...</div>
</div>
) : (
<Card className="p-6">
<p className="text-gray-600 dark:text-gray-400">Account settings management interface coming soon.</p>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,112 @@
import { useState, useEffect } from 'react';
import PageMeta from "../../components/common/PageMeta";
import ComponentCard from "../../components/common/ComponentCard";
import { useSettingsStore } from '../../store/settingsStore';
import { useToast } from '../../components/ui/toast/ToastContainer';
import Button from '../../components/ui/button/Button';
import Label from '../../components/form/Label';
export default function GeneralSettings() {
const toast = useToast();
const { accountSettings, loading, loadAccountSettings, updateAccountSetting } = useSettingsStore();
// Form state
const [tableSettings, setTableSettings] = useState({
records_per_page: 20,
default_sort: 'created_at',
default_sort_direction: 'desc',
});
useEffect(() => {
loadAccountSettings();
}, [loadAccountSettings]);
useEffect(() => {
if (accountSettings['table_settings']) {
setTableSettings(accountSettings['table_settings'].config);
}
}, [accountSettings]);
const handleSave = async () => {
try {
await updateAccountSetting('table_settings', tableSettings);
toast.success('Settings saved successfully');
} catch (error: any) {
toast.error(`Failed to save settings: ${error.message}`);
}
};
return (
<>
<PageMeta title="General Settings - IGNY8" description="Plugin configuration" />
<ComponentCard title="General Settings" desc="Configure plugin settings, automation, and table preferences">
<div className="space-y-6">
{/* Table Settings */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Table Settings</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="records_per_page">Records Per Page</Label>
<input
id="records_per_page"
type="number"
min="5"
max="100"
className="h-9 w-full rounded-lg border border-gray-300 bg-transparent px-3 py-2 text-sm shadow-theme-xs text-gray-800 placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800"
value={tableSettings.records_per_page}
onChange={(e) => setTableSettings({
...tableSettings,
records_per_page: parseInt(e.target.value) || 20
})}
/>
</div>
<div>
<Label htmlFor="default_sort">Default Sort Field</Label>
<input
id="default_sort"
type="text"
className="h-9 w-full rounded-lg border border-gray-300 bg-transparent px-3 py-2 text-sm shadow-theme-xs text-gray-800 placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800"
value={tableSettings.default_sort}
onChange={(e) => setTableSettings({
...tableSettings,
default_sort: e.target.value
})}
/>
</div>
<div>
<Label htmlFor="default_sort_direction">Default Sort Direction</Label>
<select
id="default_sort_direction"
className="h-9 w-full rounded-lg border border-gray-300 bg-transparent px-3 py-2 text-sm shadow-theme-xs text-gray-800 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:focus:border-brand-800"
value={tableSettings.default_sort_direction}
onChange={(e) => setTableSettings({
...tableSettings,
default_sort_direction: e.target.value as 'asc' | 'desc'
})}
>
<option value="asc">Ascending</option>
<option value="desc">Descending</option>
</select>
</div>
</div>
</div>
<div className="flex justify-end">
<Button
onClick={handleSave}
disabled={loading}
className="px-6"
>
{loading ? 'Saving...' : 'Save Settings'}
</Button>
</div>
</div>
</ComponentCard>
</>
);
}

View File

@@ -0,0 +1,22 @@
import PageMeta from "../../components/common/PageMeta";
import ComponentCard from "../../components/common/ComponentCard";
export default function ImportExport() {
return (
<>
<PageMeta title="Import/Export - IGNY8" description="Data management" />
<ComponentCard title="Coming Soon" desc="Data management">
<div className="text-center py-8">
<p className="text-gray-600 dark:text-gray-400">
Import/Export - Coming Soon
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Import and export data, manage backups, and transfer content
</p>
</div>
</ComponentCard>
</>
);
}

View File

@@ -0,0 +1,64 @@
import { useState, useEffect } from 'react';
import PageMeta from '../../components/common/PageMeta';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { fetchIndustries, Industry } from '../../services/api';
import { Card } from '../../components/ui/card';
import Badge from '../../components/ui/badge/Badge';
export default function Industries() {
const toast = useToast();
const [industries, setIndustries] = useState<Industry[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadIndustries();
}, []);
const loadIndustries = async () => {
try {
setLoading(true);
const response = await fetchIndustries();
setIndustries(response.industries || []);
} catch (error: any) {
toast.error(`Failed to load industries: ${error.message}`);
} finally {
setLoading(false);
}
};
return (
<div className="p-6">
<PageMeta title="Industries" />
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Industries</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">Manage global industry templates (Admin Only)</p>
</div>
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading...</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{industries.map((industry) => (
<Card key={industry.id} className="p-6">
<div className="flex justify-between items-start mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{industry.name}</h3>
<Badge variant="light" color={industry.is_active ? 'success' : 'dark'}>
{industry.is_active ? 'Active' : 'Inactive'}
</Badge>
</div>
{industry.description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">{industry.description}</p>
)}
<p className="text-sm text-gray-500 dark:text-gray-400">
Sectors: {industry.sectors_count || 0}
</p>
</Card>
))}
</div>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
import { useState, useEffect } from 'react';
import PageMeta from '../../components/common/PageMeta';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { fetchAPI } from '../../services/api';
import { Card } from '../../components/ui/card';
export default function ModuleSettings() {
const toast = useToast();
const [settings, setSettings] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
try {
setLoading(true);
const response = await fetchAPI('/v1/system/settings/modules/');
setSettings(response.results || []);
} catch (error: any) {
toast.error(`Failed to load module settings: ${error.message}`);
} finally {
setLoading(false);
}
};
return (
<div className="p-6">
<PageMeta title="Module Settings" />
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Module Settings</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">Module-specific configuration</p>
</div>
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading...</div>
</div>
) : (
<Card className="p-6">
<p className="text-gray-600 dark:text-gray-400">Module settings management interface coming soon.</p>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,213 @@
import { useState, useEffect } from 'react';
import PageMeta from '../../components/common/PageMeta';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { fetchAPI } from '../../services/api';
import { PricingTable, PricingPlan } from '../../components/ui/pricing-table';
interface Plan {
id: number;
name: string;
slug: string;
price: string | number;
billing_cycle: string;
is_active: boolean;
max_users: number;
max_sites: number;
max_keywords: number;
max_clusters: number;
max_content_ideas: number;
monthly_word_count_limit: number;
monthly_ai_credit_limit: number;
monthly_image_count: number;
daily_content_tasks: number;
daily_ai_request_limit: number;
daily_image_generation_limit: number;
included_credits: number;
image_model_choices: string[];
features: string[];
}
interface PlanResponse {
count: number;
next: string | null;
previous: string | null;
results: Plan[];
}
// Helper function to format numbers with commas
const formatNumber = (num: number): string => {
return num.toLocaleString();
};
// Helper function to format word count
const formatWordCount = (num: number): string => {
if (num >= 1000000) {
return `${(num / 1000000).toFixed(1)}M`;
}
if (num >= 1000) {
return `${(num / 1000).toFixed(0)}K`;
}
return num.toString();
};
// Extract major features from plan data
const extractFeatures = (plan: Plan): string[] => {
const features: string[] = [];
// Sites and Users
features.push(`${plan.max_sites} ${plan.max_sites === 1 ? 'Site' : 'Sites'}`);
features.push(`${plan.max_users} ${plan.max_users === 1 ? 'User' : 'Users'}`);
// Planner features
features.push(`${formatNumber(plan.max_keywords)} Keywords`);
features.push(`${formatNumber(plan.max_clusters)} Clusters`);
features.push(`${formatNumber(plan.max_content_ideas)} Content Ideas`);
// Writer features
features.push(`${formatWordCount(plan.monthly_word_count_limit)} Words/Month`);
features.push(`${plan.daily_content_tasks} Daily Content Tasks`);
// Image features
features.push(`${plan.monthly_image_count} Images/Month`);
if (plan.image_model_choices && plan.image_model_choices.length > 0) {
const models = plan.image_model_choices.map((m: string) => m.toUpperCase()).join(', ');
features.push(`${models} Image Models`);
}
// AI Credits
features.push(`${formatNumber(plan.included_credits)} AI Credits Included`);
features.push(`${formatNumber(plan.monthly_ai_credit_limit)} Monthly AI Credit Limit`);
// Feature flags
if (plan.features && Array.isArray(plan.features)) {
if (plan.features.includes('ai_writer')) {
features.push('AI Writer');
}
if (plan.features.includes('image_gen')) {
features.push('Image Generation');
}
if (plan.features.includes('auto_publish')) {
features.push('Auto Publish');
}
if (plan.features.includes('custom_prompts')) {
features.push('Custom Prompts');
}
}
return features;
};
// Transform Plan to PricingPlan
const transformPlanToPricingPlan = (plan: Plan, index: number, totalPlans: number): PricingPlan => {
const monthlyPrice = typeof plan.price === 'number' ? plan.price : parseFloat(String(plan.price || 0));
// Only highlight Growth plan (by slug)
const highlighted = plan.slug.toLowerCase() === 'growth';
return {
id: plan.id,
name: plan.name,
monthlyPrice: monthlyPrice,
price: monthlyPrice, // Will be calculated by component based on period
period: '/month',
description: getPlanDescription(plan),
features: extractFeatures(plan),
buttonText: 'Choose Plan',
highlighted: highlighted,
};
};
// Get plan description based on plan name or features
const getPlanDescription = (plan: Plan): string => {
const slug = plan.slug.toLowerCase();
if (slug.includes('free')) {
return 'Perfect for getting started';
} else if (slug.includes('starter')) {
return 'For solo designers & freelancers';
} else if (slug.includes('growth')) {
return 'For growing businesses';
} else if (slug.includes('scale') || slug.includes('enterprise')) {
return 'For teams and large organizations';
}
return 'Choose the perfect plan for your needs';
};
export default function Plans() {
const toast = useToast();
const [plans, setPlans] = useState<Plan[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadPlans();
}, []);
const loadPlans = async () => {
try {
setLoading(true);
const response: PlanResponse = await fetchAPI('/v1/auth/plans/');
// Filter only active plans and sort by price
const activePlans = (response.results || [])
.filter((plan) => plan.is_active)
.sort((a, b) => {
const priceA = typeof a.price === 'number' ? a.price : parseFloat(String(a.price || 0));
const priceB = typeof b.price === 'number' ? b.price : parseFloat(String(b.price || 0));
return priceA - priceB;
});
setPlans(activePlans);
} catch (error: any) {
toast.error(`Failed to load plans: ${error.message}`);
} finally {
setLoading(false);
}
};
const handlePlanSelect = (plan: PricingPlan) => {
console.log('Selected plan:', plan);
// TODO: Implement plan selection/subscription logic
toast.success(`Selected plan: ${plan.name}`);
};
const pricingPlans: PricingPlan[] = plans.map((plan, index) =>
transformPlanToPricingPlan(plan, index, plans.length)
);
return (
<div className="p-6">
<PageMeta title="Plans" />
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Plans</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Choose the perfect plan for your needs. All plans include our core features.
</p>
</div>
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading plans...</div>
</div>
) : pricingPlans.length === 0 ? (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">No active plans available</div>
</div>
) : (
<>
<PricingTable
variant="1"
title="Flexible Plans Tailored to Fit Your Unique Needs!"
plans={pricingPlans}
showToggle={true}
onPlanSelect={handlePlanSelect}
/>
{/* Future: Add "View All Features" section here */}
<div className="mt-8 text-center">
<p className="text-sm text-gray-500 dark:text-gray-400">
Need more details? View all features and limits for each plan.
</p>
{/* TODO: Add expandable feature list component */}
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,597 @@
import { useState, useEffect, useCallback } from 'react';
import PageMeta from '../../components/common/PageMeta';
import SiteCard from '../../components/common/SiteCard';
import FormModal, { FormField } from '../../components/common/FormModal';
import Button from '../../components/ui/button/Button';
import { useToast } from '../../components/ui/toast/ToastContainer';
import Alert from '../../components/ui/alert/Alert';
import {
fetchSites,
createSite,
updateSite,
deleteSite,
setActiveSite,
selectSectorsForSite,
fetchIndustries,
fetchSiteSectors,
Site,
Industry,
Sector,
} from '../../services/api';
import Badge from '../../components/ui/badge/Badge';
// Site Icon SVG
const SiteIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40" fill="none">
<rect width="40" height="40" rx="8" fill="#3B82F6"/>
<path d="M12 16L20 10L28 16V28C28 28.5304 27.7893 29.0391 27.4142 29.4142C27.0391 29.7893 26.5304 30 26 30H14C13.4696 30 12.9609 29.7893 12.5858 29.4142C12.2107 29.0391 12 28.5304 12 28V16Z" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M16 30V20H24V30" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
export default function Sites() {
const toast = useToast();
const [sites, setSites] = useState<Site[]>([]);
const [loading, setLoading] = useState(true);
const [selectedSite, setSelectedSite] = useState<Site | null>(null);
const [showSiteModal, setShowSiteModal] = useState(false);
const [showSectorsModal, setShowSectorsModal] = useState(false);
const [showDetailsModal, setShowDetailsModal] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [togglingSiteId, setTogglingSiteId] = useState<number | null>(null);
const [industries, setIndustries] = useState<Industry[]>([]);
const [selectedIndustry, setSelectedIndustry] = useState<string>('');
const [selectedSectors, setSelectedSectors] = useState<string[]>([]);
const [isSelectingSectors, setIsSelectingSectors] = useState(false);
// Form state for site creation/editing
const [formData, setFormData] = useState({
name: '',
domain: '',
description: '',
is_active: true, // Default to true to match backend model default
});
// Load sites and industries
useEffect(() => {
loadSites();
loadIndustries();
}, []);
const loadSites = async () => {
try {
setLoading(true);
const response = await fetchSites();
setSites(response.results || []);
} catch (error: any) {
toast.error(`Failed to load sites: ${error.message}`);
} finally {
setLoading(false);
}
};
const loadIndustries = async () => {
try {
const response = await fetchIndustries();
setIndustries(response.industries || []);
} catch (error: any) {
toast.error(`Failed to load industries: ${error.message}`);
}
};
const handleToggle = async (siteId: number, enabled: boolean) => {
// Prevent multiple simultaneous toggle operations
if (togglingSiteId !== null) {
toast.error('Please wait for the current operation to complete');
return;
}
try {
setTogglingSiteId(siteId);
if (enabled) {
// Activate site (multiple sites can be active simultaneously)
await setActiveSite(siteId);
toast.success('Site activated successfully');
} else {
// Deactivate site - only this specific site
const site = sites.find(s => s.id === siteId);
if (site) {
await updateSite(siteId, { is_active: false });
toast.success('Site deactivated successfully');
}
}
await loadSites();
} catch (error: any) {
toast.error(`Failed to update site: ${error.message}`);
} finally {
setTogglingSiteId(null);
}
};
const handleSettings = (site: Site) => {
setSelectedSite(site);
setShowSectorsModal(true);
// Load current sectors for this site
loadSiteSectors(site);
};
const loadSiteSectors = async (site: Site) => {
try {
const sectors = await fetchSiteSectors(site.id);
const sectorSlugs = sectors.map((s: any) => s.slug);
setSelectedSectors(sectorSlugs);
// Use site's industry if available, otherwise try to determine from sectors
if (site.industry_slug) {
setSelectedIndustry(site.industry_slug);
} else {
// Fallback: try to determine industry from sectors
for (const industry of industries) {
const matchingSectors = industry.sectors.filter(s => sectorSlugs.includes(s.slug));
if (matchingSectors.length > 0) {
setSelectedIndustry(industry.slug);
break;
}
}
}
} catch (error: any) {
console.error('Failed to load site sectors:', error);
}
};
const handleDetails = (site: Site) => {
setSelectedSite(site);
setFormData({
name: site.name || '',
domain: site.domain || '',
description: site.description || '',
is_active: site.is_active || false,
});
setShowDetailsModal(true);
};
const handleSaveDetails = async () => {
if (!selectedSite) return;
try {
setIsSaving(true);
// Normalize domain before sending
const normalizedFormData = {
...formData,
domain: formData.domain ? normalizeDomain(formData.domain) : formData.domain,
};
await updateSite(selectedSite.id, normalizedFormData);
toast.success('Site updated successfully');
setShowDetailsModal(false);
await loadSites();
} catch (error: any) {
toast.error(`Failed to update site: ${error.message}`);
} finally {
setIsSaving(false);
}
};
const handleCreateSite = () => {
setSelectedSite(null);
setFormData({
name: '',
domain: '',
description: '',
is_active: true, // Default to true to match backend model default
});
setShowSiteModal(true);
};
const handleEditSite = (site: Site) => {
setSelectedSite(site);
setFormData({
name: site.name || '',
domain: site.domain || '',
description: site.description || '',
is_active: site.is_active || false,
});
setShowSiteModal(true);
};
// Helper function to normalize domain URL
const normalizeDomain = (domain: string): string => {
if (!domain || !domain.trim()) {
return domain;
}
const trimmed = domain.trim();
// If it already starts with https://, keep it as is
if (trimmed.startsWith('https://')) {
return trimmed;
}
// If it starts with http://, replace with https://
if (trimmed.startsWith('http://')) {
return trimmed.replace('http://', 'https://');
}
// Otherwise, add https://
return `https://${trimmed}`;
};
const handleSaveSite = async () => {
try {
setIsSaving(true);
// Normalize domain before sending
const normalizedFormData = {
...formData,
domain: formData.domain ? normalizeDomain(formData.domain) : formData.domain,
};
if (selectedSite) {
// Update existing site
await updateSite(selectedSite.id, normalizedFormData);
toast.success('Site updated successfully');
} else {
// Create new site
const newSite = await createSite({
...normalizedFormData,
is_active: normalizedFormData.is_active || false,
});
toast.success('Site created successfully');
// If this is the first site or user wants it active, activate it
if (sites.length === 0 || normalizedFormData.is_active) {
await setActiveSite(newSite.id);
}
}
setShowSiteModal(false);
setSelectedSite(null);
setFormData({
name: '',
domain: '',
description: '',
is_active: false,
});
await loadSites();
} catch (error: any) {
toast.error(`Failed to save site: ${error.message}`);
} finally {
setIsSaving(false);
}
};
const handleSelectSectors = async () => {
if (!selectedSite || !selectedIndustry || selectedSectors.length === 0) {
toast.error('Please select an industry and at least one sector');
return;
}
if (selectedSectors.length > 5) {
toast.error('Maximum 5 sectors allowed per site');
return;
}
try {
setIsSelectingSectors(true);
const result = await selectSectorsForSite(
selectedSite.id,
selectedIndustry,
selectedSectors
);
toast.success(result.message || 'Sectors selected successfully');
setShowSectorsModal(false);
await loadSites();
} catch (error: any) {
toast.error(`Failed to select sectors: ${error.message}`);
} finally {
setIsSelectingSectors(false);
}
};
const handleDeleteSite = async (site: Site) => {
if (!window.confirm(`Are you sure you want to delete "${site.name}"? This action cannot be undone.`)) {
return;
}
try {
await deleteSite(site.id);
toast.success('Site deleted successfully');
await loadSites();
if (showDetailsModal) {
setShowDetailsModal(false);
}
} catch (error: any) {
toast.error(`Failed to delete site: ${error.message}`);
}
};
const getSiteFormFields = (): FormField[] => [
{
key: 'name',
label: 'Site Name',
type: 'text',
value: formData.name,
onChange: (value: any) => setFormData({ ...formData, name: value }),
required: true,
placeholder: 'Enter site name',
},
{
key: 'domain',
label: 'Domain',
type: 'text',
value: formData.domain,
onChange: (value: any) => setFormData({ ...formData, domain: value }),
required: false,
placeholder: 'example.com (https:// will be added automatically)',
},
{
key: 'description',
label: 'Description',
type: 'textarea',
value: formData.description,
onChange: (value: any) => setFormData({ ...formData, description: value }),
required: false,
placeholder: 'Enter site description',
rows: 4,
},
{
key: 'is_active',
label: 'Set as Active Site',
type: 'select',
value: formData.is_active ? 'true' : 'false',
onChange: (value: any) => setFormData({ ...formData, is_active: value === 'true' }),
required: false,
options: [
{ value: 'true', label: 'Active' },
{ value: 'false', label: 'Inactive' },
],
},
];
const getIndustrySectors = () => {
if (!selectedIndustry) return [];
const industry = industries.find(i => i.slug === selectedIndustry);
return industry?.sectors || [];
};
if (loading) {
return (
<div className="flex h-screen items-center justify-center">
<div className="text-center">
<div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600 mx-auto"></div>
<p className="text-gray-600 dark:text-gray-400">Loading sites...</p>
</div>
</div>
);
}
return (
<>
<PageMeta title="Sites Management" description="Manage your sites and configure industries and sectors" />
<div className="space-y-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Sites Management</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Manage your sites, configure industries, and select sectors. Multiple sites can be active simultaneously.
</p>
</div>
<Button onClick={handleCreateSite} variant="primary">
+ Add Site
</Button>
</div>
{/* Info Alert */}
<Alert
variant="info"
title="Sites Configuration"
message="Each site can have up to 5 sectors selected from 15 major industries. Keywords and clusters are automatically associated with sectors. Multiple sites can be active simultaneously."
/>
{/* Sites Grid */}
{sites.length === 0 ? (
<div className="rounded-2xl border border-gray-200 bg-white p-12 text-center dark:border-gray-800 dark:bg-white/3">
<SiteIcon />
<h3 className="mt-4 text-lg font-semibold text-gray-900 dark:text-white">
No sites yet
</h3>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
Create your first site to get started
</p>
<Button onClick={handleCreateSite} variant="primary" className="mt-4">
Create Site
</Button>
</div>
) : (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3">
{sites.map((site) => (
<SiteCard
key={site.id}
site={site}
icon={<SiteIcon />}
onToggle={handleToggle}
onSettings={handleSettings}
onDetails={handleDetails}
isToggling={togglingSiteId === site.id}
/>
))}
</div>
)}
{/* Create/Edit Site Modal */}
<FormModal
isOpen={showSiteModal}
onClose={() => {
setShowSiteModal(false);
setSelectedSite(null);
setFormData({
name: '',
domain: '',
description: '',
is_active: false,
});
}}
onSubmit={handleSaveSite}
title={selectedSite ? 'Edit Site' : 'Create New Site'}
submitLabel={selectedSite ? 'Update Site' : 'Create Site'}
fields={getSiteFormFields()}
isLoading={isSaving}
/>
{/* Sectors Selection Modal */}
<FormModal
isOpen={showSectorsModal}
onClose={() => setShowSectorsModal(false)}
onSubmit={handleSelectSectors}
title={selectedSite ? `Configure Sectors for ${selectedSite.name}` : 'Configure Sectors'}
submitLabel={isSelectingSectors ? 'Saving...' : 'Save Sectors'}
cancelLabel="Cancel"
isLoading={isSelectingSectors}
className="max-w-2xl"
customBody={
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Select Industry
</label>
<select
value={selectedIndustry}
onChange={(e) => {
setSelectedIndustry(e.target.value);
setSelectedSectors([]); // Reset sectors when industry changes
}}
className="h-9 w-full rounded-lg border border-gray-300 bg-transparent px-3 py-2 text-sm shadow-theme-xs text-gray-800 placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800"
>
<option value="">Select an industry...</option>
{industries.map((industry) => (
<option key={industry.slug} value={industry.slug}>
{industry.name}
</option>
))}
</select>
{selectedIndustry && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{industries.find(i => i.slug === selectedIndustry)?.description}
</p>
)}
</div>
{selectedIndustry && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Select Sectors (max 5)
</label>
<div className="space-y-2 max-h-64 overflow-y-auto border border-gray-200 rounded-lg p-4 dark:border-gray-700">
{getIndustrySectors().map((sector) => (
<label
key={sector.slug}
className="flex items-start space-x-3 p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer"
>
<input
type="checkbox"
checked={selectedSectors.includes(sector.slug)}
onChange={(e) => {
if (e.target.checked) {
if (selectedSectors.length >= 5) {
toast.error('Maximum 5 sectors allowed per site');
return;
}
setSelectedSectors([...selectedSectors, sector.slug]);
} else {
setSelectedSectors(selectedSectors.filter(s => s !== sector.slug));
}
}}
className="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div className="flex-1">
<div className="font-medium text-sm text-gray-900 dark:text-white">
{sector.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{sector.description}
</div>
</div>
</label>
))}
</div>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Selected: {selectedSectors.length} / 5 sectors
</p>
</div>
)}
</div>
}
customFooter={
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<Button
type="button"
variant="outline"
onClick={() => setShowSectorsModal(false)}
disabled={isSelectingSectors}
>
Cancel
</Button>
<Button
type="submit"
variant="primary"
disabled={!selectedIndustry || selectedSectors.length === 0 || isSelectingSectors}
>
{isSelectingSectors ? 'Saving...' : 'Save Sectors'}
</Button>
</div>
}
/>
{/* Site Details Modal - Editable */}
{selectedSite && (
<FormModal
isOpen={showDetailsModal}
onClose={() => {
setShowDetailsModal(false);
setSelectedSite(null);
}}
onSubmit={handleSaveDetails}
title={`Edit Site: ${selectedSite.name}`}
submitLabel="Save Changes"
fields={getSiteFormFields()}
isLoading={isSaving}
customFooter={
<div className="flex justify-between items-center pt-4 border-t border-gray-200 dark:border-gray-700">
<Button
variant="danger"
onClick={() => {
if (selectedSite) {
handleDeleteSite(selectedSite);
}
}}
disabled={isSaving}
>
Delete Site
</Button>
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => {
setShowDetailsModal(false);
setSelectedSite(null);
}}
disabled={isSaving}
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleSaveDetails}
disabled={isSaving}
>
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
}
/>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,323 @@
import { useState, useEffect } from "react";
import PageMeta from "../../components/common/PageMeta";
import ComponentCard from "../../components/common/ComponentCard";
import { fetchAPI } from "../../services/api";
interface SystemStatus {
timestamp: string;
system: {
cpu: { usage_percent: number; cores: number; status: string };
memory: { total_gb: number; used_gb: number; available_gb: number; usage_percent: number; status: string };
disk: { total_gb: number; used_gb: number; free_gb: number; usage_percent: number; status: string };
};
database: {
connected: boolean;
version: string;
size: string;
active_connections: number;
status: string;
};
redis: {
connected: boolean;
status: string;
};
celery: {
workers: string[];
worker_count: number;
tasks: { active: number; scheduled: number; reserved: number };
status: string;
};
processes: {
by_stack: {
[key: string]: { count: number; cpu: number; memory_mb: number };
};
};
modules: {
planner: { keywords: number; clusters: number; content_ideas: number };
writer: { tasks: number; images: number };
};
}
const getStatusColor = (status: string) => {
switch (status) {
case 'healthy': return 'text-green-600 dark:text-green-400';
case 'warning': return 'text-yellow-600 dark:text-yellow-400';
case 'critical': return 'text-red-600 dark:text-red-400';
default: return 'text-gray-600 dark:text-gray-400';
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'healthy': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400';
case 'warning': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400';
case 'critical': return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
default: return 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400';
}
};
export default function Status() {
const [status, setStatus] = useState<SystemStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchStatus = async () => {
try {
const data = await fetchAPI('/v1/system/status/');
setStatus(data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchStatus();
const interval = setInterval(fetchStatus, 30000); // Refresh every 30 seconds
return () => clearInterval(interval);
}, []);
if (loading) {
return (
<>
<PageMeta title="System Status - IGNY8" description="System monitoring" />
<ComponentCard title="System Status" desc="Loading system information...">
<div className="text-center py-8">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900 dark:border-white mx-auto"></div>
</div>
</ComponentCard>
</>
);
}
if (error || !status) {
return (
<>
<PageMeta title="System Status - IGNY8" description="System monitoring" />
<ComponentCard title="System Status" desc="Error loading system information">
<div className="text-center py-8 text-red-600 dark:text-red-400">
{error || 'Failed to load system status'}
</div>
</ComponentCard>
</>
);
}
return (
<>
<PageMeta title="System Status - IGNY8" description="System monitoring" />
<div className="space-y-6">
{/* System Resources */}
<ComponentCard title="System Resources" desc="CPU, Memory, and Disk Usage">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* CPU */}
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">CPU</span>
<span className={`text-xs px-2 py-1 rounded ${getStatusBadge(status.system?.cpu?.status || 'unknown')}`}>
{status.system?.cpu?.status || 'unknown'}
</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-4">
<div
className={`h-4 rounded-full ${
(status.system?.cpu?.usage_percent || 0) < 80 ? 'bg-green-500' :
(status.system?.cpu?.usage_percent || 0) < 95 ? 'bg-yellow-500' : 'bg-red-500'
}`}
style={{ width: `${status.system?.cpu?.usage_percent || 0}%` }}
></div>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{status.system?.cpu?.usage_percent?.toFixed(1)}% used ({status.system?.cpu?.cores} cores)
</div>
</div>
{/* Memory */}
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Memory</span>
<span className={`text-xs px-2 py-1 rounded ${getStatusBadge(status.system?.memory?.status || 'unknown')}`}>
{status.system?.memory?.status || 'unknown'}
</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-4">
<div
className={`h-4 rounded-full ${
(status.system?.memory?.usage_percent || 0) < 80 ? 'bg-green-500' :
(status.system?.memory?.usage_percent || 0) < 95 ? 'bg-yellow-500' : 'bg-red-500'
}`}
style={{ width: `${status.system?.memory?.usage_percent || 0}%` }}
></div>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{status.system?.memory?.used_gb?.toFixed(1)} GB / {status.system?.memory?.total_gb?.toFixed(1)} GB
</div>
</div>
{/* Disk */}
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Disk</span>
<span className={`text-xs px-2 py-1 rounded ${getStatusBadge(status.system?.disk?.status || 'unknown')}`}>
{status.system?.disk?.status || 'unknown'}
</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-4">
<div
className={`h-4 rounded-full ${
(status.system?.disk?.usage_percent || 0) < 80 ? 'bg-green-500' :
(status.system?.disk?.usage_percent || 0) < 95 ? 'bg-yellow-500' : 'bg-red-500'
}`}
style={{ width: `${status.system?.disk?.usage_percent || 0}%` }}
></div>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{status.system?.disk?.used_gb?.toFixed(1)} GB / {status.system?.disk?.total_gb?.toFixed(1)} GB
</div>
</div>
</div>
</ComponentCard>
{/* Services Status */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Database */}
<ComponentCard title="Database" desc="PostgreSQL Status">
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600 dark:text-gray-400">Status</span>
<span className={`text-xs px-2 py-1 rounded ${getStatusBadge(status.database?.status || 'unknown')}`}>
{status.database?.connected ? 'Connected' : 'Disconnected'}
</span>
</div>
{status.database?.version && (
<div className="text-sm">
<span className="text-gray-600 dark:text-gray-400">Version:</span>
<span className="ml-2 text-gray-800 dark:text-gray-200">{status.database.version.split(',')[0]}</span>
</div>
)}
{status.database?.size && (
<div className="text-sm">
<span className="text-gray-600 dark:text-gray-400">Size:</span>
<span className="ml-2 text-gray-800 dark:text-gray-200">{status.database.size}</span>
</div>
)}
<div className="text-sm">
<span className="text-gray-600 dark:text-gray-400">Active Connections:</span>
<span className="ml-2 text-gray-800 dark:text-gray-200">{status.database?.active_connections || 0}</span>
</div>
</div>
</ComponentCard>
{/* Redis */}
<ComponentCard title="Redis" desc="Cache & Message Broker">
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600 dark:text-gray-400">Status</span>
<span className={`text-xs px-2 py-1 rounded ${getStatusBadge(status.redis?.status || 'unknown')}`}>
{status.redis?.connected ? 'Connected' : 'Disconnected'}
</span>
</div>
</div>
</ComponentCard>
{/* Celery */}
<ComponentCard title="Celery" desc="Task Queue Workers">
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600 dark:text-gray-400">Workers</span>
<span className={`text-xs px-2 py-1 rounded ${getStatusBadge(status.celery?.status || 'unknown')}`}>
{status.celery?.worker_count || 0} active
</span>
</div>
<div className="text-sm">
<span className="text-gray-600 dark:text-gray-400">Active Tasks:</span>
<span className="ml-2 text-gray-800 dark:text-gray-200">{status.celery?.tasks?.active || 0}</span>
</div>
<div className="text-sm">
<span className="text-gray-600 dark:text-gray-400">Scheduled:</span>
<span className="ml-2 text-gray-800 dark:text-gray-200">{status.celery?.tasks?.scheduled || 0}</span>
</div>
<div className="text-sm">
<span className="text-gray-600 dark:text-gray-400">Reserved:</span>
<span className="ml-2 text-gray-800 dark:text-gray-200">{status.celery?.tasks?.reserved || 0}</span>
</div>
</div>
</ComponentCard>
</div>
{/* Process Monitoring by Stack */}
<ComponentCard title="Process Monitoring" desc="Resource usage by technology stack">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead>
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Stack</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Processes</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">CPU %</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Memory (MB)</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{Object.entries(status.processes?.by_stack || {}).map(([stack, stats]) => (
<tr key={stack}>
<td className="px-4 py-3 text-sm font-medium text-gray-800 dark:text-gray-200 capitalize">{stack}</td>
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">{stats.count}</td>
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">{stats.cpu.toFixed(2)}%</td>
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">{stats.memory_mb.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
</ComponentCard>
{/* Module Statistics */}
<ComponentCard title="Module Statistics" desc="Data counts by module">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Planner Module */}
<div className="space-y-3">
<h4 className="text-sm font-semibold text-gray-800 dark:text-gray-200">Planner Module</h4>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">Keywords:</span>
<span className="text-gray-800 dark:text-gray-200">{status.modules?.planner?.keywords?.toLocaleString() || 0}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">Clusters:</span>
<span className="text-gray-800 dark:text-gray-200">{status.modules?.planner?.clusters?.toLocaleString() || 0}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">Content Ideas:</span>
<span className="text-gray-800 dark:text-gray-200">{status.modules?.planner?.content_ideas?.toLocaleString() || 0}</span>
</div>
</div>
</div>
{/* Writer Module */}
<div className="space-y-3">
<h4 className="text-sm font-semibold text-gray-800 dark:text-gray-200">Writer Module</h4>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">Tasks:</span>
<span className="text-gray-800 dark:text-gray-200">{status.modules?.writer?.tasks?.toLocaleString() || 0}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">Images:</span>
<span className="text-gray-800 dark:text-gray-200">{status.modules?.writer?.images?.toLocaleString() || 0}</span>
</div>
</div>
</div>
</div>
</ComponentCard>
{/* Last Updated */}
<div className="text-center text-sm text-gray-500 dark:text-gray-400">
Last updated: {new Date(status.timestamp).toLocaleString()}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,99 @@
import { useState, useEffect } from 'react';
import PageMeta from '../../components/common/PageMeta';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { fetchAPI } from '../../services/api';
import { Card } from '../../components/ui/card';
import Badge from '../../components/ui/badge/Badge';
interface Subscription {
id: number;
account_name: string;
status: string;
current_period_start: string;
current_period_end: string;
}
export default function Subscriptions() {
const toast = useToast();
const [subscriptions, setSubscriptions] = useState<Subscription[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadSubscriptions();
}, []);
const loadSubscriptions = async () => {
try {
setLoading(true);
const response = await fetchAPI('/v1/auth/subscriptions/');
setSubscriptions(response.results || []);
} catch (error: any) {
toast.error(`Failed to load subscriptions: ${error.message}`);
} finally {
setLoading(false);
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'active':
return 'success';
case 'past_due':
return 'warning';
case 'canceled':
return 'error';
default:
return 'primary';
}
};
return (
<div className="p-6">
<PageMeta title="Subscriptions" />
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Subscriptions</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">Manage account subscriptions</p>
</div>
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading...</div>
</div>
) : (
<Card className="p-6">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700">
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Account</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Status</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Period Start</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Period End</th>
</tr>
</thead>
<tbody>
{subscriptions.map((subscription) => (
<tr key={subscription.id} className="border-b border-gray-100 dark:border-gray-800">
<td className="py-3 px-4 text-sm text-gray-900 dark:text-white">{subscription.account_name}</td>
<td className="py-3 px-4">
<Badge variant="light" color={getStatusColor(subscription.status) as any}>
{subscription.status}
</Badge>
</td>
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">
{new Date(subscription.current_period_start).toLocaleDateString()}
</td>
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">
{new Date(subscription.current_period_end).toLocaleDateString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { useState, useEffect } from 'react';
import PageMeta from '../../components/common/PageMeta';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { fetchAPI } from '../../services/api';
import { Card } from '../../components/ui/card';
export default function SystemSettings() {
const toast = useToast();
const [settings, setSettings] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
try {
setLoading(true);
const response = await fetchAPI('/v1/system/settings/system/');
setSettings(response.results || []);
} catch (error: any) {
toast.error(`Failed to load system settings: ${error.message}`);
} finally {
setLoading(false);
}
};
return (
<div className="p-6">
<PageMeta title="System Settings" />
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">System Settings</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">Global platform-wide settings</p>
</div>
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading...</div>
</div>
) : (
<Card className="p-6">
<p className="text-gray-600 dark:text-gray-400">System settings management interface coming soon.</p>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,192 @@
import { useState } from "react";
import ComponentCard from "../../../components/common/ComponentCard";
import Alert from "../../../components/ui/alert/Alert";
import PageMeta from "../../../components/common/PageMeta";
import Button from "../../../components/ui/button/Button";
export default function Alerts() {
const [notifications, setNotifications] = useState<
Array<{ id: number; variant: "success" | "error" | "warning" | "info"; title: string; message: string }>
>([]);
const addNotification = (variant: "success" | "error" | "warning" | "info") => {
const titles = {
success: "Success!",
error: "Error Occurred",
warning: "Warning",
info: "Information",
};
const messages = {
success: "Operation completed successfully.",
error: "Something went wrong. Please try again.",
warning: "Please review this action carefully.",
info: "Here's some useful information for you.",
};
const newNotification = {
id: Date.now(),
variant,
title: titles[variant],
message: messages[variant],
};
setNotifications((prev) => [...prev, newNotification]);
// Auto-remove after 5 seconds
setTimeout(() => {
setNotifications((prev) => prev.filter((n) => n.id !== newNotification.id));
}, 5000);
};
const removeNotification = (id: number) => {
setNotifications((prev) => prev.filter((n) => n.id !== id));
};
return (
<>
<PageMeta
title="React.js Alerts Dashboard | TailAdmin - React.js Admin Dashboard Template"
description="This is React.js Alerts Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
/>
<div className="space-y-5 sm:space-y-6">
{/* Interactive Notifications */}
<ComponentCard title="Interactive Notifications" desc="Click buttons to add notifications">
<div className="flex flex-wrap gap-3 mb-4">
<Button onClick={() => addNotification("success")} variant="primary">
Add Success
</Button>
<Button onClick={() => addNotification("error")} variant="primary">
Add Error
</Button>
<Button onClick={() => addNotification("warning")} variant="primary">
Add Warning
</Button>
<Button onClick={() => addNotification("info")} variant="primary">
Add Info
</Button>
{notifications.length > 0 && (
<Button onClick={() => setNotifications([])} variant="outline">
Clear All
</Button>
)}
</div>
{/* Notification Stack */}
<div className="fixed top-4 right-4 z-50 space-y-2 max-w-md w-full pointer-events-none">
{notifications.map((notification) => (
<div
key={notification.id}
className="pointer-events-auto animate-in slide-in-from-top duration-300"
>
<div className="relative">
<Alert
variant={notification.variant}
title={notification.title}
message={notification.message}
showLink={false}
/>
<button
onClick={() => removeNotification(notification.id)}
className="absolute top-2 right-2 p-1 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
))}
</div>
</ComponentCard>
{/* Static Alert Examples */}
<ComponentCard title="Success Alert">
<div className="space-y-4">
<Alert
variant="success"
title="Success Message"
message="Operation completed successfully."
showLink={true}
linkHref="/"
linkText="Learn more"
/>
<Alert
variant="success"
title="Success Message"
message="Your changes have been saved."
showLink={false}
/>
</div>
</ComponentCard>
<ComponentCard title="Warning Alert">
<div className="space-y-4">
<Alert
variant="warning"
title="Warning Message"
message="Be cautious when performing this action."
showLink={true}
linkHref="/"
linkText="Learn more"
/>
<Alert
variant="warning"
title="Warning Message"
message="This action cannot be undone."
showLink={false}
/>
</div>
</ComponentCard>
<ComponentCard title="Error Alert">
<div className="space-y-4">
<Alert
variant="error"
title="Error Message"
message="Something went wrong. Please try again."
showLink={true}
linkHref="/"
linkText="Learn more"
/>
<Alert
variant="error"
title="Error Message"
message="Failed to save changes. Please check your connection."
showLink={false}
/>
</div>
</ComponentCard>
<ComponentCard title="Info Alert">
<div className="space-y-4">
<Alert
variant="info"
title="Info Message"
message="Here's some useful information for you."
showLink={true}
linkHref="/"
linkText="Learn more"
/>
<Alert
variant="info"
title="Info Message"
message="New features are available. Check them out!"
showLink={false}
/>
</div>
</ComponentCard>
</div>
</>
);
}

View File

@@ -0,0 +1,121 @@
import ComponentCard from "../../../components/common/ComponentCard";
import Avatar from "../../../components/ui/avatar/Avatar";
import PageMeta from "../../../components/common/PageMeta";
export default function Avatars() {
return (
<>
<PageMeta
title="React.js Avatars Dashboard | TailAdmin - React.js Admin Dashboard Template"
description="This is React.js Avatars Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
/>
<div className="space-y-5 sm:space-y-6">
<ComponentCard title="Default Avatar">
{/* Default Avatar (No Status) */}
<div className="flex flex-col items-center justify-center gap-5 sm:flex-row">
<Avatar src="/images/user/user-01.jpg" size="xsmall" />
<Avatar src="/images/user/user-01.jpg" size="small" />
<Avatar src="/images/user/user-01.jpg" size="medium" />
<Avatar src="/images/user/user-01.jpg" size="large" />
<Avatar src="/images/user/user-01.jpg" size="xlarge" />
<Avatar src="/images/user/user-01.jpg" size="xxlarge" />
</div>
</ComponentCard>
<ComponentCard title="Avatar with online indicator">
<div className="flex flex-col items-center justify-center gap-5 sm:flex-row">
<Avatar
src="/images/user/user-01.jpg"
size="xsmall"
status="online"
/>
<Avatar
src="/images/user/user-01.jpg"
size="small"
status="online"
/>
<Avatar
src="/images/user/user-01.jpg"
size="medium"
status="online"
/>
<Avatar
src="/images/user/user-01.jpg"
size="large"
status="online"
/>
<Avatar
src="/images/user/user-01.jpg"
size="xlarge"
status="online"
/>
<Avatar
src="/images/user/user-01.jpg"
size="xxlarge"
status="online"
/>
</div>
</ComponentCard>
<ComponentCard title="Avatar with Offline indicator">
<div className="flex flex-col items-center justify-center gap-5 sm:flex-row">
<Avatar
src="/images/user/user-01.jpg"
size="xsmall"
status="offline"
/>
<Avatar
src="/images/user/user-01.jpg"
size="small"
status="offline"
/>
<Avatar
src="/images/user/user-01.jpg"
size="medium"
status="offline"
/>
<Avatar
src="/images/user/user-01.jpg"
size="large"
status="offline"
/>
<Avatar
src="/images/user/user-01.jpg"
size="xlarge"
status="offline"
/>
<Avatar
src="/images/user/user-01.jpg"
size="xxlarge"
status="offline"
/>
</div>
</ComponentCard>{" "}
<ComponentCard title="Avatar with busy indicator">
<div className="flex flex-col items-center justify-center gap-5 sm:flex-row">
<Avatar
src="/images/user/user-01.jpg"
size="xsmall"
status="busy"
/>
<Avatar src="/images/user/user-01.jpg" size="small" status="busy" />
<Avatar
src="/images/user/user-01.jpg"
size="medium"
status="busy"
/>
<Avatar src="/images/user/user-01.jpg" size="large" status="busy" />
<Avatar
src="/images/user/user-01.jpg"
size="xlarge"
status="busy"
/>
<Avatar
src="/images/user/user-01.jpg"
size="xxlarge"
status="busy"
/>
</div>
</ComponentCard>
</div>
</>
);
}

View File

@@ -0,0 +1,169 @@
import Badge from "../../../components/ui/badge/Badge";
import { PlusIcon } from "../../../icons";
import PageMeta from "../../../components/common/PageMeta";
import ComponentCard from "../../../components/common/ComponentCard";
export default function Badges() {
return (
<div>
<PageMeta
title="React.js Badges Dashboard | TailAdmin - React.js Admin Dashboard Template"
description="This is React.js Badges Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
/>
<div className="space-y-5 sm:space-y-6">
<ComponentCard title="With Light Background">
<div className="flex flex-wrap gap-4 sm:items-center sm:justify-center">
{/* Light Variant */}
<Badge variant="light" color="primary">
Primary
</Badge>
<Badge variant="light" color="success">
Success
</Badge>{" "}
<Badge variant="light" color="error">
Error
</Badge>{" "}
<Badge variant="light" color="warning">
Warning
</Badge>{" "}
<Badge variant="light" color="info">
Info
</Badge>
<Badge variant="light" color="light">
Light
</Badge>
<Badge variant="light" color="dark">
Dark
</Badge>
</div>
</ComponentCard>
<ComponentCard title="With Solid Background">
<div className="flex flex-wrap gap-4 sm:items-center sm:justify-center">
{/* Light Variant */}
<Badge variant="solid" color="primary">
Primary
</Badge>
<Badge variant="solid" color="success">
Success
</Badge>{" "}
<Badge variant="solid" color="error">
Error
</Badge>{" "}
<Badge variant="solid" color="warning">
Warning
</Badge>{" "}
<Badge variant="solid" color="info">
Info
</Badge>
<Badge variant="solid" color="light">
Light
</Badge>
<Badge variant="solid" color="dark">
Dark
</Badge>
</div>
</ComponentCard>
<ComponentCard title="Light Background with Left Icon">
<div className="flex flex-wrap gap-4 sm:items-center sm:justify-center">
<Badge variant="light" color="primary" startIcon={<PlusIcon />}>
Primary
</Badge>
<Badge variant="light" color="success" startIcon={<PlusIcon />}>
Success
</Badge>{" "}
<Badge variant="light" color="error" startIcon={<PlusIcon />}>
Error
</Badge>{" "}
<Badge variant="light" color="warning" startIcon={<PlusIcon />}>
Warning
</Badge>{" "}
<Badge variant="light" color="info" startIcon={<PlusIcon />}>
Info
</Badge>
<Badge variant="light" color="light" startIcon={<PlusIcon />}>
Light
</Badge>
<Badge variant="light" color="dark" startIcon={<PlusIcon />}>
Dark
</Badge>
</div>
</ComponentCard>
<ComponentCard title="Solid Background with Left Icon">
<div className="flex flex-wrap gap-4 sm:items-center sm:justify-center">
<Badge variant="solid" color="primary" startIcon={<PlusIcon />}>
Primary
</Badge>
<Badge variant="solid" color="success" startIcon={<PlusIcon />}>
Success
</Badge>{" "}
<Badge variant="solid" color="error" startIcon={<PlusIcon />}>
Error
</Badge>{" "}
<Badge variant="solid" color="warning" startIcon={<PlusIcon />}>
Warning
</Badge>{" "}
<Badge variant="solid" color="info" startIcon={<PlusIcon />}>
Info
</Badge>
<Badge variant="solid" color="light" startIcon={<PlusIcon />}>
Light
</Badge>
<Badge variant="solid" color="dark" startIcon={<PlusIcon />}>
Dark
</Badge>
</div>
</ComponentCard>
<ComponentCard title="Light Background with Right Icon">
<div className="flex flex-wrap gap-4 sm:items-center sm:justify-center">
<Badge variant="light" color="primary" endIcon={<PlusIcon />}>
Primary
</Badge>
<Badge variant="light" color="success" endIcon={<PlusIcon />}>
Success
</Badge>{" "}
<Badge variant="light" color="error" endIcon={<PlusIcon />}>
Error
</Badge>{" "}
<Badge variant="light" color="warning" endIcon={<PlusIcon />}>
Warning
</Badge>{" "}
<Badge variant="light" color="info" endIcon={<PlusIcon />}>
Info
</Badge>
<Badge variant="light" color="light" endIcon={<PlusIcon />}>
Light
</Badge>
<Badge variant="light" color="dark" endIcon={<PlusIcon />}>
Dark
</Badge>
</div>
</ComponentCard>
<ComponentCard title="Solid Background with Right Icon">
<div className="flex flex-wrap gap-4 sm:items-center sm:justify-center">
<Badge variant="solid" color="primary" endIcon={<PlusIcon />}>
Primary
</Badge>
<Badge variant="solid" color="success" endIcon={<PlusIcon />}>
Success
</Badge>{" "}
<Badge variant="solid" color="error" endIcon={<PlusIcon />}>
Error
</Badge>{" "}
<Badge variant="solid" color="warning" endIcon={<PlusIcon />}>
Warning
</Badge>{" "}
<Badge variant="solid" color="info" endIcon={<PlusIcon />}>
Info
</Badge>
<Badge variant="solid" color="light" endIcon={<PlusIcon />}>
Light
</Badge>
<Badge variant="solid" color="dark" endIcon={<PlusIcon />}>
Dark
</Badge>
</div>
</ComponentCard>
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import ComponentCard from "../../../components/common/ComponentCard";
import PageMeta from "../../../components/common/PageMeta";
import { Breadcrumb } from "../../../components/ui/breadcrumb";
export default function BreadcrumbPage() {
return (
<>
<PageMeta
title="React.js Breadcrumb Dashboard | TailAdmin - React.js Admin Dashboard Template"
description="This is React.js Breadcrumb Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
/>
<div className="space-y-5 sm:space-y-6">
<ComponentCard title="Default Breadcrumb">
<Breadcrumb
items={[
{ label: "Home", path: "/" },
{ label: "UI Elements", path: "/ui-elements" },
{ label: "Breadcrumb" },
]}
/>
</ComponentCard>
<ComponentCard title="Breadcrumb with Icon">
<Breadcrumb
items={[
{
label: "Home",
path: "/",
icon: (
<svg
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" />
</svg>
),
},
{ label: "UI Elements", path: "/ui-elements" },
{ label: "Breadcrumb" },
]}
/>
</ComponentCard>
</div>
</>
);
}

View File

@@ -0,0 +1,116 @@
import ComponentCard from "../../../components/common/ComponentCard";
import PageMeta from "../../../components/common/PageMeta";
import Button from "../../../components/ui/button/Button";
import { BoxIcon } from "../../../icons";
export default function Buttons() {
return (
<div>
<PageMeta
title="React.js Buttons Dashboard | TailAdmin - React.js Admin Dashboard Template"
description="This is React.js Buttons Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
/>
<div className="space-y-5 sm:space-y-6">
{/* Primary Button */}
<ComponentCard title="Primary Button">
<div className="flex items-center gap-5">
<Button size="sm" variant="primary">
Button Text
</Button>
<Button size="md" variant="primary">
Button Text
</Button>
</div>
</ComponentCard>
{/* Primary Button with Start Icon */}
<ComponentCard title="Primary Button with Left Icon">
<div className="flex items-center gap-5">
<Button
size="sm"
variant="primary"
startIcon={<BoxIcon className="size-5" />}
>
Button Text
</Button>
<Button
size="md"
variant="primary"
startIcon={<BoxIcon className="size-5" />}
>
Button Text
</Button>
</div>
</ComponentCard>
{/* Primary Button with Start Icon */}
<ComponentCard title="Primary Button with Right Icon">
<div className="flex items-center gap-5">
<Button
size="sm"
variant="primary"
endIcon={<BoxIcon className="size-5" />}
>
Button Text
</Button>
<Button
size="md"
variant="primary"
endIcon={<BoxIcon className="size-5" />}
>
Button Text
</Button>
</div>
</ComponentCard>
{/* Outline Button */}
<ComponentCard title="Secondary Button">
<div className="flex items-center gap-5">
{/* Outline Button */}
<Button size="sm" variant="outline">
Button Text
</Button>
<Button size="md" variant="outline">
Button Text
</Button>
</div>
</ComponentCard>
{/* Outline Button with Start Icon */}
<ComponentCard title="Outline Button with Left Icon">
<div className="flex items-center gap-5">
<Button
size="sm"
variant="outline"
startIcon={<BoxIcon className="size-5" />}
>
Button Text
</Button>
<Button
size="md"
variant="outline"
startIcon={<BoxIcon className="size-5" />}
>
Button Text
</Button>
</div>
</ComponentCard>{" "}
{/* Outline Button with Start Icon */}
<ComponentCard title="Outline Button with Right Icon">
<div className="flex items-center gap-5">
<Button
size="sm"
variant="outline"
endIcon={<BoxIcon className="size-5" />}
>
Button Text
</Button>
<Button
size="md"
variant="outline"
endIcon={<BoxIcon className="size-5" />}
>
Button Text
</Button>
</div>
</ComponentCard>
</div>
</div>
);
}

View File

@@ -0,0 +1,89 @@
import { useState } from "react";
import ComponentCard from "../../../components/common/ComponentCard";
import PageMeta from "../../../components/common/PageMeta";
import { ButtonGroup, ButtonGroupItem } from "../../../components/ui/button-group";
export default function ButtonsGroup() {
const [activeGroup, setActiveGroup] = useState("left");
return (
<>
<PageMeta
title="React.js Button Groups Dashboard | TailAdmin - React.js Admin Dashboard Template"
description="This is React.js Button Groups Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
/>
<div className="space-y-5 sm:space-y-6">
<ComponentCard title="Default Button Group">
<ButtonGroup>
<ButtonGroupItem
isActive={activeGroup === "left"}
onClick={() => setActiveGroup("left")}
className="rounded-l-lg border-l-0"
>
Left
</ButtonGroupItem>
<ButtonGroupItem
isActive={activeGroup === "center"}
onClick={() => setActiveGroup("center")}
className="border-l border-r border-gray-300 dark:border-gray-700"
>
Center
</ButtonGroupItem>
<ButtonGroupItem
isActive={activeGroup === "right"}
onClick={() => setActiveGroup("right")}
className="rounded-r-lg border-r-0"
>
Right
</ButtonGroupItem>
</ButtonGroup>
</ComponentCard>
<ComponentCard title="Icon Button Group">
<ButtonGroup>
<ButtonGroupItem className="rounded-l-lg border-l-0">
<svg
className="w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
clipRule="evenodd"
/>
</svg>
</ButtonGroupItem>
<ButtonGroupItem className="border-l border-r border-gray-300 dark:border-gray-700">
<svg
className="w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 3a1 1 0 011 1v12a1 1 0 11-2 0V4a1 1 0 011-1z"
clipRule="evenodd"
/>
</svg>
</ButtonGroupItem>
<ButtonGroupItem className="rounded-r-lg border-r-0">
<svg
className="w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M3 10a1 1 0 011 1h12a1 1 0 110-2H4a1 1 0 01-1-1z"
clipRule="evenodd"
/>
</svg>
</ButtonGroupItem>
</ButtonGroup>
</ComponentCard>
</div>
</>
);
}

View File

@@ -0,0 +1,67 @@
import ComponentCard from "../../../components/common/ComponentCard";
import PageMeta from "../../../components/common/PageMeta";
import {
Card,
CardTitle,
CardDescription,
CardAction,
CardIcon,
} from "../../../components/ui/card/Card";
export default function Cards() {
return (
<>
<PageMeta
title="React.js Cards Dashboard | TailAdmin - React.js Admin Dashboard Template"
description="This is React.js Cards Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
/>
<div className="space-y-5 sm:space-y-6">
<ComponentCard title="Basic Card">
<Card>
<CardTitle>Card Title</CardTitle>
<CardDescription>
This is a basic card with title and description.
</CardDescription>
</Card>
</ComponentCard>
<ComponentCard title="Card with Icon">
<Card>
<CardIcon>
<svg
className="w-6 h-6"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" />
<path
fillRule="evenodd"
d="M4 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v11a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm3 4a1 1 0 000 2h.01a1 1 0 100-2H7zm3 0a1 1 0 000 2h3a1 1 0 100-2h-3zm-3 4a1 1 0 100 2h.01a1 1 0 100-2H7zm3 0a1 1 0 100 2h3a1 1 0 100-2h-3z"
clipRule="evenodd"
/>
</svg>
</CardIcon>
<CardTitle>Card with Icon</CardTitle>
<CardDescription>This card includes an icon at the top.</CardDescription>
<CardAction>Learn More</CardAction>
</Card>
</ComponentCard>
<ComponentCard title="Card with Image">
<Card>
<img
src="https://via.placeholder.com/400x200"
alt="Card"
className="w-full h-48 object-cover rounded-t-xl"
/>
<CardTitle>Card with Image</CardTitle>
<CardDescription>
This card includes an image at the top.
</CardDescription>
</Card>
</ComponentCard>
</div>
</>
);
}

View File

@@ -0,0 +1,21 @@
import ComponentCard from "../../../components/common/ComponentCard";
import PageMeta from "../../../components/common/PageMeta";
export default function Carousel() {
return (
<>
<PageMeta
title="React.js Carousel Dashboard | TailAdmin - React.js Admin Dashboard Template"
description="This is React.js Carousel Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
/>
<div className="space-y-5 sm:space-y-6">
<ComponentCard title="Carousel">
<p className="text-sm text-gray-500 dark:text-gray-400">
Carousel component will be implemented here.
</p>
</ComponentCard>
</div>
</>
);
}

View File

@@ -0,0 +1,132 @@
import { useState } from "react";
import ComponentCard from "../../../components/common/ComponentCard";
import PageMeta from "../../../components/common/PageMeta";
import { Dropdown } from "../../../components/ui/dropdown/Dropdown";
import { DropdownItem } from "../../../components/ui/dropdown/DropdownItem";
import Button from "../../../components/ui/button/Button";
export default function Dropdowns() {
const [dropdown1, setDropdown1] = useState(false);
const [dropdown2, setDropdown2] = useState(false);
const [dropdown3, setDropdown3] = useState(false);
return (
<>
<PageMeta
title="React.js Dropdowns Dashboard | TailAdmin - React.js Admin Dashboard Template"
description="This is React.js Dropdowns Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
/>
<div className="space-y-5 sm:space-y-6">
<ComponentCard title="Default Dropdown">
<div className="relative inline-block">
<Button onClick={() => setDropdown1(!dropdown1)}>
Dropdown Default
</Button>
<Dropdown
isOpen={dropdown1}
onClose={() => setDropdown1(false)}
className="w-48 p-2 mt-2"
>
<DropdownItem
onItemClick={() => setDropdown1(false)}
className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg text-theme-sm hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
>
Edit
</DropdownItem>
<DropdownItem
onItemClick={() => setDropdown1(false)}
className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg text-theme-sm hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
>
Delete
</DropdownItem>
</Dropdown>
</div>
</ComponentCard>
<ComponentCard title="Dropdown with Divider">
<div className="relative inline-block">
<Button onClick={() => setDropdown2(!dropdown2)}>
Dropdown with Divider
</Button>
<Dropdown
isOpen={dropdown2}
onClose={() => setDropdown2(false)}
className="w-48 p-2 mt-2"
>
<DropdownItem
onItemClick={() => setDropdown2(false)}
className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg text-theme-sm hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
>
Edit
</DropdownItem>
<DropdownItem
onItemClick={() => setDropdown2(false)}
className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg text-theme-sm hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
>
View
</DropdownItem>
<div className="my-2 border-t border-gray-200 dark:border-gray-800"></div>
<DropdownItem
onItemClick={() => setDropdown2(false)}
className="flex items-center gap-3 px-3 py-2 font-medium text-red-600 rounded-lg text-theme-sm hover:bg-red-50 hover:text-red-700 dark:text-red-400 dark:hover:bg-red-900/20 dark:hover:text-red-300"
>
Delete
</DropdownItem>
</Dropdown>
</div>
</ComponentCard>
<ComponentCard title="Dropdown with Icon">
<div className="relative inline-block">
<Button onClick={() => setDropdown3(!dropdown3)}>
Dropdown with Icon
</Button>
<Dropdown
isOpen={dropdown3}
onClose={() => setDropdown3(false)}
className="w-48 p-2 mt-2"
>
<DropdownItem
onItemClick={() => setDropdown3(false)}
className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg text-theme-sm hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
Edit
</DropdownItem>
<DropdownItem
onItemClick={() => setDropdown3(false)}
className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg text-theme-sm hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
View
</DropdownItem>
<div className="my-2 border-t border-gray-200 dark:border-gray-800"></div>
<DropdownItem
onItemClick={() => setDropdown3(false)}
className="flex items-center gap-3 px-3 py-2 font-medium text-red-600 rounded-lg text-theme-sm hover:bg-red-50 hover:text-red-700 dark:text-red-400 dark:hover:bg-red-900/20 dark:hover:text-red-300"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
Delete
</DropdownItem>
</Dropdown>
</div>
</ComponentCard>
</div>
</>
);
}

View File

@@ -0,0 +1,27 @@
import ResponsiveImage from "../../../components/ui/images/ResponsiveImage";
import TwoColumnImageGrid from "../../../components/ui/images/TwoColumnImageGrid";
import ThreeColumnImageGrid from "../../../components/ui/images/ThreeColumnImageGrid";
import ComponentCard from "../../../components/common/ComponentCard";
import PageMeta from "../../../components/common/PageMeta";
export default function Images() {
return (
<>
<PageMeta
title="React.js Images Dashboard | TailAdmin - React.js Admin Dashboard Template"
description="This is React.js Images page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
/>
<div className="space-y-5 sm:space-y-6">
<ComponentCard title="Responsive image">
<ResponsiveImage />
</ComponentCard>
<ComponentCard title="Image in 2 Grid">
<TwoColumnImageGrid />
</ComponentCard>
<ComponentCard title="Image in 3 Grid">
<ThreeColumnImageGrid />
</ComponentCard>
</div>
</>
);
}

View File

@@ -0,0 +1,36 @@
import ComponentCard from "../../../components/common/ComponentCard";
import PageMeta from "../../../components/common/PageMeta";
export default function Links() {
return (
<>
<PageMeta
title="React.js Links Dashboard | TailAdmin - React.js Admin Dashboard Template"
description="This is React.js Links Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
/>
<div className="space-y-5 sm:space-y-6">
<ComponentCard title="Links">
<div className="space-y-4">
<div>
<a
href="#"
className="text-brand-500 hover:text-brand-600 underline"
>
Primary Link
</a>
</div>
<div>
<a
href="#"
className="text-gray-700 dark:text-gray-300 hover:text-brand-500 underline"
>
Default Link
</a>
</div>
</div>
</ComponentCard>
</div>
</>
);
}

View File

@@ -0,0 +1,46 @@
import ComponentCard from "../../../components/common/ComponentCard";
import PageMeta from "../../../components/common/PageMeta";
import { List, ListItem } from "../../../components/ui/list";
export default function ListPage() {
return (
<>
<PageMeta
title="React.js List Dashboard | TailAdmin - React.js Admin Dashboard Template"
description="This is React.js List Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
/>
<div className="space-y-5 sm:space-y-6">
<ComponentCard title="Unordered List">
<List variant="unordered">
<ListItem>Item 1</ListItem>
<ListItem>Item 2</ListItem>
<ListItem>Item 3</ListItem>
</List>
</ComponentCard>
<ComponentCard title="Ordered List">
<List variant="ordered">
<ListItem>First Item</ListItem>
<ListItem>Second Item</ListItem>
<ListItem>Third Item</ListItem>
</List>
</ComponentCard>
<ComponentCard title="Button List">
<List variant="button">
<ListItem variant="button" onClick={() => alert("Clicked Item 1")}>
Button Item 1
</ListItem>
<ListItem variant="button" onClick={() => alert("Clicked Item 2")}>
Button Item 2
</ListItem>
<ListItem variant="button" onClick={() => alert("Clicked Item 3")}>
Button Item 3
</ListItem>
</List>
</ComponentCard>
</div>
</>
);
}

View File

@@ -0,0 +1,177 @@
import { useState } from "react";
import ComponentCard from "../../../components/common/ComponentCard";
import PageMeta from "../../../components/common/PageMeta";
import { Modal } from "../../../components/ui/modal";
import Button from "../../../components/ui/button/Button";
import ConfirmDialog from "../../../components/common/ConfirmDialog";
import AlertModal from "../../../components/ui/alert/AlertModal";
export default function Modals() {
const [isDefaultModalOpen, setIsDefaultModalOpen] = useState(false);
const [isCenteredModalOpen, setIsCenteredModalOpen] = useState(false);
const [isFormModalOpen, setIsFormModalOpen] = useState(false);
const [isFullScreenModalOpen, setIsFullScreenModalOpen] = useState(false);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
const [isSuccessAlertOpen, setIsSuccessAlertOpen] = useState(false);
const [isInfoAlertOpen, setIsInfoAlertOpen] = useState(false);
const [isWarningAlertOpen, setIsWarningAlertOpen] = useState(false);
const [isDangerAlertOpen, setIsDangerAlertOpen] = useState(false);
return (
<>
<PageMeta
title="React.js Modals Dashboard | TailAdmin - React.js Admin Dashboard Template"
description="This is React.js Modals Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
/>
<div className="space-y-5 sm:space-y-6">
<ComponentCard title="Default Modal">
<Button onClick={() => setIsDefaultModalOpen(true)}>
Open Default Modal
</Button>
<Modal
isOpen={isDefaultModalOpen}
onClose={() => setIsDefaultModalOpen(false)}
className="max-w-lg"
>
<div className="p-6">
<h2 className="text-xl font-bold mb-4">Default Modal Title</h2>
<p>This is a default modal. It can contain any content.</p>
<div className="flex justify-end gap-4 mt-6">
<Button
variant="outline"
onClick={() => setIsDefaultModalOpen(false)}
>
Close
</Button>
<Button variant="primary">Save Changes</Button>
</div>
</div>
</Modal>
</ComponentCard>
<ComponentCard title="Centered Modal">
<Button onClick={() => setIsCenteredModalOpen(true)}>
Open Centered Modal
</Button>
<Modal
isOpen={isCenteredModalOpen}
onClose={() => setIsCenteredModalOpen(false)}
className="max-w-md"
>
<div className="p-6 text-center">
<h2 className="text-xl font-bold mb-4">Centered Modal Title</h2>
<p>This modal is vertically and horizontally centered.</p>
<Button
onClick={() => setIsCenteredModalOpen(false)}
className="mt-6"
>
Close
</Button>
</div>
</Modal>
</ComponentCard>
<ComponentCard title="Full Screen Modal">
<Button onClick={() => setIsFullScreenModalOpen(true)}>
Open Full Screen Modal
</Button>
<Modal
isOpen={isFullScreenModalOpen}
onClose={() => setIsFullScreenModalOpen(false)}
isFullscreen={true}
>
<div className="p-6 bg-white dark:bg-gray-900 w-full h-full flex flex-col">
<h2 className="text-2xl font-bold mb-4">Full Screen Modal</h2>
<p className="flex-grow">
This modal takes up the entire screen. Useful for complex forms
or detailed views.
</p>
<Button
onClick={() => setIsFullScreenModalOpen(false)}
className="mt-6 self-end"
>
Close Full Screen
</Button>
</div>
</Modal>
</ComponentCard>
<ComponentCard title="Confirmation Dialog">
<Button
onClick={() => setIsConfirmDialogOpen(true)}
variant="danger"
>
Open Confirmation Dialog
</Button>
<ConfirmDialog
isOpen={isConfirmDialogOpen}
onClose={() => setIsConfirmDialogOpen(false)}
onConfirm={() => {
alert("Action Confirmed!");
setIsConfirmDialogOpen(false);
}}
title="Confirm Action"
message="Are you sure you want to proceed with this action? It cannot be undone."
confirmText="Proceed"
variant="danger"
/>
</ComponentCard>
<ComponentCard title="Alert Modals">
<div className="flex flex-wrap gap-3">
<Button
onClick={() => setIsSuccessAlertOpen(true)}
variant="success"
>
Success Alert
</Button>
<Button onClick={() => setIsInfoAlertOpen(true)} variant="info">
Info Alert
</Button>
<Button
onClick={() => setIsWarningAlertOpen(true)}
variant="warning"
>
Warning Alert
</Button>
<Button
onClick={() => setIsDangerAlertOpen(true)}
variant="danger"
>
Danger Alert
</Button>
</div>
<AlertModal
isOpen={isSuccessAlertOpen}
onClose={() => setIsSuccessAlertOpen(false)}
title="Success!"
message="Your operation was completed successfully."
variant="success"
/>
<AlertModal
isOpen={isInfoAlertOpen}
onClose={() => setIsInfoAlertOpen(false)}
title="Information"
message="This is an informational message for the user."
variant="info"
/>
<AlertModal
isOpen={isWarningAlertOpen}
onClose={() => setIsWarningAlertOpen(false)}
title="Warning!"
message="Please be careful, this action has consequences."
variant="warning"
/>
<AlertModal
isOpen={isDangerAlertOpen}
onClose={() => setIsDangerAlertOpen(false)}
title="Danger!"
message="This is a critical alert. Proceed with caution."
variant="danger"
/>
</ComponentCard>
</div>
</>
);
}

View File

@@ -0,0 +1,278 @@
import React, { useState } from 'react';
import Alert from '../../../components/ui/alert/Alert';
import { useToast } from '../../../components/ui/toast/ToastContainer';
import PageMeta from '../../../components/common/PageMeta';
export default function Notifications() {
const toast = useToast();
// State for inline notifications (for demo purposes)
const [showSuccess, setShowSuccess] = useState(true);
const [showInfo, setShowInfo] = useState(true);
const [showWarning, setShowWarning] = useState(true);
const [showError, setShowError] = useState(true);
return (
<>
<PageMeta
title="React.js Notifications Dashboard | TailAdmin - React.js Admin Dashboard Template"
description="This is React.js Notifications Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
/>
<div className="space-y-5 sm:space-y-6">
{/* Components Grid */}
<div className="grid grid-cols-1 gap-5 xl:grid-cols-2 xl:gap-6">
{/* Announcement Bar Card */}
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03] xl:col-span-2">
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
Announcement Bar
</h3>
</div>
<div className="p-4 border-t border-gray-100 dark:border-gray-800 sm:p-6">
<div className="flex items-center justify-between gap-4 p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
{/* Lightning bolt icon */}
<div className="flex-shrink-0 w-10 h-10 bg-blue-light-100 dark:bg-blue-light-500/20 rounded-lg flex items-center justify-center">
<svg
className="w-5 h-5 text-blue-light-500"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z"
clipRule="evenodd"
/>
</svg>
</div>
<div>
<p className="font-semibold text-gray-800 dark:text-white">
New update! Available
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
Enjoy improved functionality and enhancements.
</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 transition-colors">
Later
</button>
<button className="px-4 py-2 text-sm font-medium text-white bg-brand-500 rounded-lg hover:bg-brand-600 transition-colors">
Update Now
</button>
</div>
</div>
</div>
</div>
{/* Toast Notification Card */}
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03] xl:col-span-2">
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
Toast Notification
</h3>
</div>
<div className="p-4 border-t border-gray-100 dark:border-gray-800 sm:p-6">
<div className="space-y-6">
<div className="flex flex-wrap items-center gap-3">
<button
onClick={() => toast.success('Success! Action Completed!', 'Your action has been completed successfully.')}
className="px-4 py-2 text-sm font-medium text-white rounded-lg bg-success-500 hover:bg-success-600 transition-colors"
>
Success Toast
</button>
<button
onClick={() => toast.info('Heads Up! New Information', 'This is an informational message.')}
className="px-4 py-2 text-sm font-medium text-white rounded-lg bg-blue-light-500 hover:bg-blue-light-600 transition-colors"
>
Info Toast
</button>
<button
onClick={() => toast.warning('Alert: Double Check Required', 'Please review this action carefully.')}
className="px-4 py-2 text-sm font-medium text-white rounded-lg bg-warning-500 hover:bg-warning-600 transition-colors"
>
Warning Toast
</button>
<button
onClick={() => toast.error('Something Went Wrong', 'An error occurred. Please try again.')}
className="px-4 py-2 text-sm font-medium text-white rounded-lg bg-error-500 hover:bg-error-600 transition-colors"
>
Error Toast
</button>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400">
Toast notifications appear in the top right corner with margin from top. They have a thin light gray border around the entire perimeter.
</p>
</div>
</div>
</div>
{/* Success Notification Card */}
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
Success Notification
</h3>
</div>
<div className="p-4 border-t border-gray-100 dark:border-gray-800 sm:p-6">
{showSuccess && (
<div className="relative">
<Alert
variant="success"
title="Success! Action Completed!"
message="Your action has been completed successfully."
/>
<button
onClick={() => setShowSuccess(false)}
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
aria-label="Close"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
)}
{!showSuccess && (
<button
onClick={() => setShowSuccess(true)}
className="px-4 py-2 text-sm font-medium text-white rounded-lg bg-success-500 hover:bg-success-600 transition-colors"
>
Show Success Notification
</button>
)}
</div>
</div>
{/* Info Notification Card */}
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
Info Notification
</h3>
</div>
<div className="p-4 border-t border-gray-100 dark:border-gray-800 sm:p-6">
{showInfo && (
<div className="relative">
<Alert
variant="info"
title="Heads Up! New Information"
message="This is an informational message for your attention."
/>
<button
onClick={() => setShowInfo(false)}
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
aria-label="Close"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
)}
{!showInfo && (
<button
onClick={() => setShowInfo(true)}
className="px-4 py-2 text-sm font-medium text-white rounded-lg bg-blue-light-500 hover:bg-blue-light-600 transition-colors"
>
Show Info Notification
</button>
)}
</div>
</div>
{/* Warning Notification Card */}
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
Warning Notification
</h3>
</div>
<div className="p-4 border-t border-gray-100 dark:border-gray-800 sm:p-6">
{showWarning && (
<div className="relative">
<Alert
variant="warning"
title="Alert: Double Check Required"
message="Please review this action carefully before proceeding."
/>
<button
onClick={() => setShowWarning(false)}
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
aria-label="Close"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
)}
{!showWarning && (
<button
onClick={() => setShowWarning(true)}
className="px-4 py-2 text-sm font-medium text-white rounded-lg bg-warning-500 hover:bg-warning-600 transition-colors"
>
Show Warning Notification
</button>
)}
</div>
</div>
{/* Error Notification Card */}
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-white/[0.03]">
<div className="px-6 py-5">
<h3 className="text-base font-medium text-gray-800 dark:text-white/90">
Error Notification
</h3>
</div>
<div className="p-4 border-t border-gray-100 dark:border-gray-800 sm:p-6">
{showError && (
<div className="relative">
<Alert
variant="error"
title="Something Went Wrong"
message="An error occurred. Please try again or contact support."
/>
<button
onClick={() => setShowError(false)}
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
aria-label="Close"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
)}
{!showError && (
<button
onClick={() => setShowError(true)}
className="px-4 py-2 text-sm font-medium text-white rounded-lg bg-error-500 hover:bg-error-600 transition-colors"
>
Show Error Notification
</button>
)}
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,48 @@
import { useState } from "react";
import ComponentCard from "../../../components/common/ComponentCard";
import PageMeta from "../../../components/common/PageMeta";
import { Pagination } from "../../../components/ui/pagination/Pagination";
export default function PaginationPage() {
const [page1, setPage1] = useState(1);
const [page2, setPage2] = useState(1);
const [page3, setPage3] = useState(1);
return (
<>
<PageMeta
title="React.js Pagination Dashboard | TailAdmin - React.js Admin Dashboard Template"
description="This is React.js Pagination Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
/>
<div className="space-y-5 sm:space-y-6">
<ComponentCard title="Pagination with Text">
<Pagination
currentPage={page1}
totalPages={10}
onPageChange={setPage1}
variant="text"
/>
</ComponentCard>
<ComponentCard title="Pagination with Text and Icon">
<Pagination
currentPage={page2}
totalPages={10}
onPageChange={setPage2}
variant="text-icon"
/>
</ComponentCard>
<ComponentCard title="Pagination with Icon">
<Pagination
currentPage={page3}
totalPages={10}
onPageChange={setPage3}
variant="icon"
/>
</ComponentCard>
</div>
</>
);
}

View File

@@ -0,0 +1,21 @@
import ComponentCard from "../../../components/common/ComponentCard";
import PageMeta from "../../../components/common/PageMeta";
export default function Popovers() {
return (
<>
<PageMeta
title="React.js Popovers Dashboard | TailAdmin - React.js Admin Dashboard Template"
description="This is React.js Popovers Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
/>
<div className="space-y-5 sm:space-y-6">
<ComponentCard title="Popovers">
<p className="text-sm text-gray-500 dark:text-gray-400">
Popover component will be implemented here.
</p>
</ComponentCard>
</div>
</>
);
}

View File

@@ -0,0 +1,237 @@
import ComponentCard from "../../../components/common/ComponentCard";
import PageMeta from "../../../components/common/PageMeta";
import { PricingTable, PricingPlan } from "../../../components/ui/pricing-table";
// Sample icons for variant 2
const PersonIcon = () => (
<svg className="fill-current" width="29" height="28" viewBox="0 0 29 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M11.4072 8.64984C11.4072 6.77971 12.9232 5.26367 14.7934 5.26367C16.6635 5.26367 18.1795 6.77971 18.1795 8.64984C18.1795 10.52 16.6635 12.036 14.7934 12.036C12.9232 12.036 11.4072 10.52 11.4072 8.64984ZM14.7934 3.48633C11.9416 3.48633 9.62986 5.79811 9.62986 8.64984C9.62986 11.5016 11.9416 13.8133 14.7934 13.8133C17.6451 13.8133 19.9569 11.5016 19.9569 8.64984C19.9569 5.79811 17.6451 3.48633 14.7934 3.48633ZM12.8251 15.6037C8.49586 15.6037 4.98632 19.1133 4.98632 23.4425V23.847C4.98632 24.3378 5.38419 24.7357 5.87499 24.7357C6.36579 24.7357 6.76366 24.3378 6.76366 23.847V23.4425C6.76366 20.0949 9.47746 17.3811 12.8251 17.3811H16.7635C20.1111 17.3811 22.8249 20.0949 22.8249 23.4425V23.847C22.8249 24.3378 23.2228 24.7357 23.7136 24.7357C24.2044 24.7357 24.6023 24.3378 24.6023 23.847V23.4425C24.6023 19.1133 21.0927 15.6037 16.7635 15.6037H12.8251Z" fill=""></path>
</svg>
);
const BriefcaseIcon = () => (
<svg className="fill-current" width="29" height="28" viewBox="0 0 29 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M12.2969 3.55469C10.8245 3.55469 9.6309 4.7483 9.6309 6.2207V7.10938H6.29462C4.82222 7.10938 3.6286 8.30299 3.6286 9.77539V20.4395C3.6286 21.9119 4.82222 23.1055 6.29462 23.1055H23.4758C24.9482 23.1055 26.1419 21.9119 26.1419 20.4395V9.77539C26.1419 8.30299 24.9482 7.10938 23.4758 7.10938H19.7025V6.2207C19.7025 4.7483 18.5089 3.55469 17.0365 3.55469H12.2969ZM18.8148 8.88672C18.8145 8.88672 18.8142 8.88672 18.8138 8.88672H10.5196C10.5193 8.88672 10.5189 8.88672 10.5186 8.88672H6.29462C5.80382 8.88672 5.40595 9.28459 5.40595 9.77539V10.9666L14.5355 14.8792C14.759 14.975 15.012 14.975 15.2356 14.8792L24.3645 10.9669V9.77539C24.3645 9.28459 23.9666 8.88672 23.4758 8.88672H18.8148ZM17.9252 7.10938V6.2207C17.9252 5.7299 17.5273 5.33203 17.0365 5.33203H12.2969C11.8061 5.33203 11.4082 5.7299 11.4082 6.2207V7.10938H17.9252ZM5.40595 20.4395V12.9003L13.8353 16.5129C14.506 16.8003 15.2651 16.8003 15.9357 16.5129L24.3645 12.9006V20.4395C24.3645 20.9303 23.9666 21.3281 23.4758 21.3281H6.29462C5.80382 21.3281 5.40595 20.9303 5.40595 20.4395Z" fill=""></path>
</svg>
);
const StarIcon = () => (
<svg className="fill-current" width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M23.7507 1.28757C24.0978 0.940553 24.6605 0.940611 25.0075 1.28769C25.3545 1.63478 25.3544 2.19745 25.0074 2.54447L19.8787 7.67208C19.5316 8.0191 18.9689 8.01904 18.6219 7.67195C18.2749 7.32487 18.275 6.76219 18.622 6.41518L23.7507 1.28757ZM19.4452 3.1553C19.7922 2.80822 19.7921 2.24554 19.4451 1.89853C19.098 1.55151 18.5353 1.55157 18.1883 1.89866L16.4386 3.64866C16.0916 3.99574 16.0917 4.55842 16.4388 4.90543C16.7859 5.25244 17.3485 5.25238 17.6955 4.9053L19.4452 3.1553ZM13.8188 4.02442C13.6691 3.72109 13.3602 3.52905 13.0219 3.52905C12.6837 3.52905 12.3747 3.72109 12.225 4.02442L9.39921 9.75015L3.08049 10.6683C2.74574 10.717 2.46763 10.9514 2.3631 11.2731C2.25857 11.5948 2.34575 11.948 2.58797 12.1841L7.16024 16.641L6.08087 22.9342C6.02369 23.2676 6.16075 23.6045 6.43441 23.8033C6.70807 24.0022 7.07088 24.0284 7.37029 23.871L13.0219 20.8997L18.6736 23.871C18.973 24.0284 19.3358 24.0022 19.6094 23.8033C19.8831 23.6045 20.0202 23.2676 19.963 22.9342L18.8836 16.641L23.4559 12.1841C23.6981 11.948 23.7853 11.5948 23.6807 11.2731C23.5762 10.9514 23.2981 10.717 22.9634 10.6683L16.6446 9.75015L13.8188 4.02442ZM10.7862 10.9557L13.0219 6.42572L15.2576 10.9557C15.387 11.218 15.6373 11.3998 15.9267 11.4418L20.9258 12.1683L17.3084 15.6944C17.099 15.8985 17.0034 16.1927 17.0529 16.4809L17.9068 21.4599L13.4355 19.1091C13.1766 18.973 12.8673 18.973 12.6084 19.1091L8.13703 21.4599L8.99098 16.4809C9.04043 16.1927 8.94485 15.8985 8.7354 15.6944L5.118 12.1683L10.1171 11.4418C10.4066 11.3998 10.6568 11.218 10.7862 10.9557ZM25.2694 5.97276C25.6165 6.31978 25.6166 6.88245 25.2696 7.22954L23.5199 8.97954C23.1729 9.32662 22.6102 9.32668 22.2632 8.97967C21.9161 8.63265 21.916 8.06998 22.263 7.72289L24.0127 5.97289C24.3597 5.62581 24.9224 5.62575 25.2694 5.97276Z" fill=""></path>
</svg>
);
export default function PricingTablePage() {
// Sample plans for variant 1
const plans1: PricingPlan[] = [
{
id: 1,
name: 'Starter',
price: 5.00,
originalPrice: 12.00,
period: '/month',
description: 'For solo designers & freelancers',
features: [
'5 website',
'500 MB Storage',
'Unlimited Sub-Domain',
'3 Custom Domain',
'Free SSL Certificate',
'Unlimited Traffic',
],
buttonText: 'Choose Starter',
},
{
id: 2,
name: 'Medium',
price: 10.99,
originalPrice: 30.00,
period: '/month',
description: 'For working on commercial projects',
features: [
'10 website',
'1 GB Storage',
'Unlimited Sub-Domain',
'5 Custom Domain',
'Free SSL Certificate',
'Unlimited Traffic',
],
buttonText: 'Choose Starter',
highlighted: true,
},
{
id: 3,
name: 'Large',
price: 15.00,
originalPrice: 59.00,
period: '/month',
description: 'For teams larger than 5 members',
features: [
'15 website',
'10 GB Storage',
'Unlimited Sub-Domain',
'10 Custom Domain',
'Free SSL Certificate',
'Unlimited Traffic',
],
buttonText: 'Choose Starter',
},
];
// Sample plans for variant 2
const plans2: PricingPlan[] = [
{
id: 1,
name: 'Personal',
price: 59.00,
period: ' / Lifetime',
description: 'For solo designers & freelancers',
features: [
'5 website',
'500 MB Storage',
'Unlimited Sub-Domain',
'3 Custom Domain',
'!Free SSL Certificate',
'!Unlimited Traffic',
],
buttonText: 'Choose Starter',
icon: <PersonIcon />,
},
{
id: 2,
name: 'Professional',
price: 199.00,
period: ' / Lifetime',
description: 'For working on commercial projects',
features: [
'10 website',
'1GB Storage',
'Unlimited Sub-Domain',
'5 Custom Domain',
'Free SSL Certificate',
'!Unlimited Traffic',
],
buttonText: 'Choose This Plan',
icon: <BriefcaseIcon />,
highlighted: true,
},
{
id: 3,
name: 'Enterprise',
price: 599.00,
period: ' / Lifetime',
description: 'For teams larger than 5 members',
features: [
'15 website',
'10GB Storage',
'Unlimited Sub-Domain',
'10 Custom Domain',
'Free SSL Certificate',
'Unlimited Traffic',
],
buttonText: 'Choose This Plan',
icon: <StarIcon />,
},
];
// Sample plans for variant 3
const plans3: PricingPlan[] = [
{
id: 1,
name: 'Personal',
price: 'Free',
period: 'For a Lifetime',
description: 'Perfect plan for Starters',
features: [
'Unlimited Projects',
'Share with 5 team members',
'Sync across devices',
],
buttonText: 'Current Plan',
disabled: true,
},
{
id: 2,
name: 'Professional',
price: 99.00,
period: '/year',
description: 'For users who want to do more',
features: [
'Unlimited Projects',
'Share with 5 team members',
'Sync across devices',
'30 days version history',
],
buttonText: 'Try for Free',
},
{
id: 3,
name: 'Team',
price: 299,
period: ' /year',
description: 'Your entire team in one place',
features: [
'Unlimited Projects',
'Share with 5 team members',
'Sync across devices',
'Sharing permissions',
'Admin tools',
],
buttonText: 'Try for Free',
recommended: true,
},
{
id: 4,
name: 'Enterprise',
price: 'Custom',
period: 'Reach out for a quote',
description: 'Run your company on your terms',
features: [
'Unlimited Projects',
'Share with 5 team members',
'Sync across devices',
'Sharing permissions',
'User provisioning (SCIM)',
'Advanced security',
],
buttonText: 'Try for Free',
},
];
return (
<>
<PageMeta
title="React.js Pricing Tables | TailAdmin - React.js Admin Dashboard Template"
description="This is React.js Pricing Tables page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
/>
<div className="space-y-5 sm:space-y-6">
<ComponentCard title="Pricing Table 1">
<PricingTable
variant="1"
title="Flexible Plans Tailored to Fit Your Unique Needs!"
plans={plans1}
showToggle={true}
onPlanSelect={(plan) => console.log('Selected plan:', plan)}
/>
</ComponentCard>
<ComponentCard title="Pricing Table 2">
<PricingTable
variant="2"
plans={plans2}
onPlanSelect={(plan) => console.log('Selected plan:', plan)}
/>
</ComponentCard>
<ComponentCard title="Pricing Table 3">
<PricingTable
variant="3"
plans={plans3}
onPlanSelect={(plan) => console.log('Selected plan:', plan)}
/>
</ComponentCard>
</div>
</>
);
}

View File

@@ -0,0 +1,66 @@
import ComponentCard from "../../../components/common/ComponentCard";
import PageMeta from "../../../components/common/PageMeta";
import { ProgressBar } from "../../../components/ui/progress";
export default function Progressbar() {
return (
<>
<PageMeta
title="React.js Progressbar Dashboard | TailAdmin - React.js Admin Dashboard Template"
description="This is React.js Progressbar Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
/>
<div className="space-y-5 sm:space-y-6">
<ComponentCard title="Progress Bar Sizes">
<div className="space-y-6">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
Small
</p>
<ProgressBar value={75} size="sm" />
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
Medium
</p>
<ProgressBar value={75} size="md" />
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
Large
</p>
<ProgressBar value={75} size="lg" />
</div>
</div>
</ComponentCard>
<ComponentCard title="Progress Bar Colors">
<div className="space-y-6">
<ProgressBar value={60} color="primary" showLabel />
<ProgressBar value={75} color="success" showLabel />
<ProgressBar value={45} color="error" showLabel />
<ProgressBar value={80} color="warning" showLabel />
<ProgressBar value={65} color="info" showLabel />
</div>
</ComponentCard>
<ComponentCard title="Progress Bar with Label">
<div className="space-y-6">
<ProgressBar
value={50}
color="primary"
showLabel
label="Upload Progress"
/>
<ProgressBar
value={75}
color="success"
showLabel
label="Download Progress"
/>
</div>
</ComponentCard>
</div>
</>
);
}

View File

@@ -0,0 +1,69 @@
import ComponentCard from "../../../components/common/ComponentCard";
import PageMeta from "../../../components/common/PageMeta";
import { Ribbon } from "../../../components/ui/ribbon";
export default function Ribbons() {
return (
<>
<PageMeta
title="React.js Ribbons Dashboard | TailAdmin - React.js Admin Dashboard Template"
description="This is React.js Ribbons Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
/>
<div className="grid grid-cols-1 gap-5 sm:gap-6 lg:grid-cols-2">
<ComponentCard title="Rounded Ribbon">
<Ribbon text="Popular" variant="rounded" color="primary">
<div className="rounded-xl border border-gray-200 dark:border-gray-800 dark:bg-white/[0.03]">
<div className="p-5 pt-16">
<p className="text-sm text-gray-500 dark:text-gray-400">
Lorem ipsum dolor sit amet consectetur. Eget nulla suscipit
arcu rutrum amet vel nec fringilla vulputate. Sed aliquam
fringilla vulputate imperdiet arcu natoque purus ac nec
ultricies nulla ultrices.
</p>
</div>
</div>
</Ribbon>
</ComponentCard>
<ComponentCard title="Filled Ribbon">
<Ribbon text="New" variant="filled" color="primary">
<div className="rounded-xl border border-gray-200 dark:border-gray-800 dark:bg-white/[0.03]">
<div className="p-5 pt-16">
<p className="text-sm text-gray-500 dark:text-gray-400">
Lorem ipsum dolor sit amet consectetur. Eget nulla suscipit
arcu rutrum amet vel nec fringilla vulputate. Sed aliquam
fringilla vulputate imperdiet arcu natoque purus ac nec
ultricies nulla ultrices.
</p>
</div>
</div>
</Ribbon>
</ComponentCard>
<ComponentCard title="Ribbon with Different Colors">
<div className="space-y-4">
<Ribbon text="Success" variant="rounded" color="success">
<div className="rounded-xl border border-gray-200 dark:border-gray-800 dark:bg-white/[0.03]">
<div className="p-5 pt-16">
<p className="text-sm text-gray-500 dark:text-gray-400">
Success ribbon example.
</p>
</div>
</div>
</Ribbon>
<Ribbon text="Warning" variant="rounded" color="warning">
<div className="rounded-xl border border-gray-200 dark:border-gray-800 dark:bg-white/[0.03]">
<div className="p-5 pt-16">
<p className="text-sm text-gray-500 dark:text-gray-400">
Warning ribbon example.
</p>
</div>
</div>
</Ribbon>
</div>
</ComponentCard>
</div>
</>
);
}

View File

@@ -0,0 +1,74 @@
import ComponentCard from "../../../components/common/ComponentCard";
import PageMeta from "../../../components/common/PageMeta";
import { Spinner } from "../../../components/ui/spinner";
export default function Spinners() {
return (
<>
<PageMeta
title="React.js Spinners Dashboard | TailAdmin - React.js Admin Dashboard Template"
description="This is React.js Spinners Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
/>
<div className="space-y-5 sm:space-y-6">
<ComponentCard title="Size Variants">
<div className="flex flex-wrap items-center gap-6">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
Small
</p>
<Spinner size="sm" />
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
Medium
</p>
<Spinner size="md" />
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
Large
</p>
<Spinner size="lg" />
</div>
</div>
</ComponentCard>
<ComponentCard title="Color Variants">
<div className="flex flex-wrap items-center gap-6">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
Primary
</p>
<Spinner color="primary" />
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
Success
</p>
<Spinner color="success" />
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
Error
</p>
<Spinner color="error" />
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
Warning
</p>
<Spinner color="warning" />
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
Info
</p>
<Spinner color="info" />
</div>
</div>
</ComponentCard>
</div>
</>
);
}

View File

@@ -0,0 +1,64 @@
import { useState } from "react";
import ComponentCard from "../../../components/common/ComponentCard";
import PageMeta from "../../../components/common/PageMeta";
import { Tabs, TabList, Tab, TabPanel } from "../../../components/ui/tabs";
export default function TabsPage() {
const [activeTab, setActiveTab] = useState("tab1");
return (
<>
<PageMeta
title="React.js Tabs Dashboard | TailAdmin - React.js Admin Dashboard Template"
description="This is React.js Tabs Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
/>
<div className="space-y-5 sm:space-y-6">
<ComponentCard title="Default Tabs">
<Tabs defaultTab="tab1" onChange={setActiveTab}>
<TabList>
<Tab
tabId="tab1"
isActive={activeTab === "tab1"}
onClick={() => setActiveTab("tab1")}
>
Tab 1
</Tab>
<Tab
tabId="tab2"
isActive={activeTab === "tab2"}
onClick={() => setActiveTab("tab2")}
>
Tab 2
</Tab>
<Tab
tabId="tab3"
isActive={activeTab === "tab3"}
onClick={() => setActiveTab("tab3")}
>
Tab 3
</Tab>
</TabList>
<div className="mt-4">
<TabPanel tabId="tab1" isActive={activeTab === "tab1"}>
<p className="text-sm text-gray-600 dark:text-gray-400">
Content for Tab 1
</p>
</TabPanel>
<TabPanel tabId="tab2" isActive={activeTab === "tab2"}>
<p className="text-sm text-gray-600 dark:text-gray-400">
Content for Tab 2
</p>
</TabPanel>
<TabPanel tabId="tab3" isActive={activeTab === "tab3"}>
<p className="text-sm text-gray-600 dark:text-gray-400">
Content for Tab 3
</p>
</TabPanel>
</div>
</Tabs>
</ComponentCard>
</div>
</>
);
}

View File

@@ -0,0 +1,34 @@
import ComponentCard from "../../../components/common/ComponentCard";
import PageMeta from "../../../components/common/PageMeta";
import { Tooltip } from "../../../components/ui/tooltip";
import Button from "../../../components/ui/button/Button";
export default function Tooltips() {
return (
<>
<PageMeta
title="React.js Tooltips Dashboard | TailAdmin - React.js Admin Dashboard Template"
description="This is React.js Tooltips Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
/>
<div className="space-y-5 sm:space-y-6">
<ComponentCard title="Tooltip Placements">
<div className="flex flex-wrap items-center gap-6">
<Tooltip text="Tooltip Top" placement="top">
<Button>Tooltip Top</Button>
</Tooltip>
<Tooltip text="Tooltip Right" placement="right">
<Button>Tooltip Right</Button>
</Tooltip>
<Tooltip text="Tooltip Bottom" placement="bottom">
<Button>Tooltip Bottom</Button>
</Tooltip>
<Tooltip text="Tooltip Left" placement="left">
<Button>Tooltip Left</Button>
</Tooltip>
</div>
</ComponentCard>
</div>
</>
);
}

View File

@@ -0,0 +1,35 @@
import ComponentCard from "../../../components/common/ComponentCard";
import PageMeta from "../../../components/common/PageMeta";
import FourIsToThree from "../../../components/ui/videos/FourIsToThree";
import OneIsToOne from "../../../components/ui/videos/OneIsToOne";
import SixteenIsToNine from "../../../components/ui/videos/SixteenIsToNine";
import TwentyOneIsToNine from "../../../components/ui/videos/TwentyOneIsToNine";
export default function Videos() {
return (
<>
<PageMeta
title="React.js Videos Tabs | TailAdmin - React.js Admin Dashboard Template"
description="This is React.js Videos page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
/>
<div className="grid grid-cols-1 gap-5 sm:gap-6 xl:grid-cols-2">
<div className="space-y-5 sm:space-y-6">
<ComponentCard title="Video Ratio 16:9">
<SixteenIsToNine />
</ComponentCard>
<ComponentCard title="Video Ratio 4:3">
<FourIsToThree />
</ComponentCard>
</div>
<div className="space-y-5 sm:space-y-6">
<ComponentCard title="Video Ratio 21:9">
<TwentyOneIsToNine />
</ComponentCard>
<ComponentCard title="Video Ratio 1:1">
<OneIsToOne />
</ComponentCard>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,84 @@
import { useState, useEffect } from 'react';
import PageMeta from '../../components/common/PageMeta';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { fetchAPI } from '../../services/api';
import { Card } from '../../components/ui/card';
import Badge from '../../components/ui/badge/Badge';
interface User {
id: number;
email: string;
username: string;
role: string;
is_active: boolean;
}
export default function Users() {
const toast = useToast();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadUsers();
}, []);
const loadUsers = async () => {
try {
setLoading(true);
const response = await fetchAPI('/v1/auth/users/');
setUsers(response.results || []);
} catch (error: any) {
toast.error(`Failed to load users: ${error.message}`);
} finally {
setLoading(false);
}
};
return (
<div className="p-6">
<PageMeta title="Users" />
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Users</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">Manage account users and permissions</p>
</div>
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading...</div>
</div>
) : (
<Card className="p-6">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700">
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Email</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Username</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Role</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 dark:text-gray-300">Status</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-b border-gray-100 dark:border-gray-800">
<td className="py-3 px-4 text-sm text-gray-900 dark:text-white">{user.email}</td>
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">{user.username}</td>
<td className="py-3 px-4">
<Badge variant="light" color="primary">{user.role}</Badge>
</td>
<td className="py-3 px-4">
<Badge variant="light" color={user.is_active ? 'success' : 'dark'}>
{user.is_active ? 'Active' : 'Inactive'}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,19 @@
import ComponentCard from "../../components/common/ComponentCard";
import PageMeta from "../../components/common/PageMeta";
import BasicTableOne from "../../components/tables/BasicTables/BasicTableOne";
export default function BasicTables() {
return (
<>
<PageMeta
title="React.js Basic Tables Dashboard | TailAdmin - Next.js Admin Dashboard Template"
description="This is React.js Basic Tables Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
/>
<div className="space-y-6">
<ComponentCard title="Basic Table 1">
<BasicTableOne />
</ComponentCard>
</div>
</>
);
}

View File

@@ -0,0 +1,164 @@
import { useState, useEffect } from 'react';
import PageMeta from '../../components/common/PageMeta';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { fetchAuthorProfiles, createAuthorProfile, updateAuthorProfile, deleteAuthorProfile, AuthorProfile } from '../../services/api';
import { Card } from '../../components/ui/card';
import Button from '../../components/ui/button/Button';
import FormModal, { FormField } from '../../components/common/FormModal';
import Badge from '../../components/ui/badge/Badge';
import { PlusIcon } from '../../icons';
export default function AuthorProfiles() {
const toast = useToast();
const [profiles, setProfiles] = useState<AuthorProfile[]>([]);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingProfile, setEditingProfile] = useState<AuthorProfile | null>(null);
const [formData, setFormData] = useState({
name: '',
description: '',
tone: '',
language: 'en',
is_active: true,
});
useEffect(() => {
loadProfiles();
}, []);
const loadProfiles = async () => {
try {
setLoading(true);
const response = await fetchAuthorProfiles();
setProfiles(response.results || []);
} catch (error: any) {
toast.error(`Failed to load author profiles: ${error.message}`);
} finally {
setLoading(false);
}
};
const handleCreate = () => {
setEditingProfile(null);
setFormData({
name: '',
description: '',
tone: '',
language: 'en',
is_active: true,
});
setIsModalOpen(true);
};
const handleEdit = (profile: AuthorProfile) => {
setEditingProfile(profile);
setFormData({
name: profile.name,
description: profile.description,
tone: profile.tone,
language: profile.language,
is_active: profile.is_active,
});
setIsModalOpen(true);
};
const handleSave = async () => {
try {
if (editingProfile) {
await updateAuthorProfile(editingProfile.id, formData);
toast.success('Author profile updated successfully');
} else {
await createAuthorProfile(formData);
toast.success('Author profile created successfully');
}
setIsModalOpen(false);
loadProfiles();
} catch (error: any) {
toast.error(`Failed to save: ${error.message}`);
}
};
const handleDelete = async (id: number) => {
if (!confirm('Are you sure you want to delete this author profile?')) return;
try {
await deleteAuthorProfile(id);
toast.success('Author profile deleted successfully');
loadProfiles();
} catch (error: any) {
toast.error(`Failed to delete: ${error.message}`);
}
};
const formFields: FormField[] = [
{ name: 'name', label: 'Name', type: 'text', required: true },
{ name: 'description', label: 'Description', type: 'textarea', required: false },
{ name: 'tone', label: 'Tone', type: 'text', required: true },
{ name: 'language', label: 'Language', type: 'text', required: true },
{ name: 'is_active', label: 'Active', type: 'checkbox', required: false },
];
return (
<div className="p-6">
<PageMeta title="Author Profiles" />
<div className="mb-6 flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Author Profiles</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">Manage writing style profiles</p>
</div>
<Button onClick={handleCreate} variant="primary">
<PlusIcon className="w-4 h-4 mr-2" />
Create Profile
</Button>
</div>
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading...</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{profiles.map((profile) => (
<Card key={profile.id} className="p-6">
<div className="flex justify-between items-start mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{profile.name}</h3>
<Badge variant="light" color={profile.is_active ? 'success' : 'dark'}>
{profile.is_active ? 'Active' : 'Inactive'}
</Badge>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">{profile.description}</p>
<div className="space-y-2 mb-4">
<div className="text-sm">
<span className="text-gray-500 dark:text-gray-400">Tone:</span>{' '}
<span className="text-gray-900 dark:text-white">{profile.tone}</span>
</div>
<div className="text-sm">
<span className="text-gray-500 dark:text-gray-400">Language:</span>{' '}
<span className="text-gray-900 dark:text-white">{profile.language}</span>
</div>
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => handleEdit(profile)}>
Edit
</Button>
<Button variant="danger" size="sm" onClick={() => handleDelete(profile.id)}>
Delete
</Button>
</div>
</Card>
))}
</div>
)}
<FormModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSave={handleSave}
title={editingProfile ? 'Edit Author Profile' : 'Create Author Profile'}
fields={formFields}
data={formData}
onChange={setFormData}
/>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import PageMeta from "../../components/common/PageMeta";
import ComponentCard from "../../components/common/ComponentCard";
export default function ThinkerDashboard() {
return (
<>
<PageMeta title="Thinker Dashboard - IGNY8" description="AI thinker overview" />
<ComponentCard title="Coming Soon" desc="AI thinker overview">
<div className="text-center py-8">
<p className="text-gray-600 dark:text-gray-400">
Thinker Dashboard - Coming Soon
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Overview of AI tools and strategies will be displayed here
</p>
</div>
</ComponentCard>
</>
);
}

View File

@@ -0,0 +1,22 @@
import PageMeta from "../../components/common/PageMeta";
import ComponentCard from "../../components/common/ComponentCard";
export default function ImageTesting() {
return (
<>
<PageMeta title="Image Testing - IGNY8" description="AI image testing" />
<ComponentCard title="Coming Soon" desc="AI image testing">
<div className="text-center py-8">
<p className="text-gray-600 dark:text-gray-400">
Image Testing - Coming Soon
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Test and configure AI image generation capabilities
</p>
</div>
</ComponentCard>
</>
);
}

View File

@@ -0,0 +1,22 @@
import PageMeta from "../../components/common/PageMeta";
import ComponentCard from "../../components/common/ComponentCard";
export default function Profile() {
return (
<>
<PageMeta title="AI Profile - IGNY8" description="AI profile settings" />
<ComponentCard title="Coming Soon" desc="AI profile settings">
<div className="text-center py-8">
<p className="text-gray-600 dark:text-gray-400">
AI Profile Settings - Coming Soon
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Configure AI personality and writing style
</p>
</div>
</ComponentCard>
</>
);
}

View File

@@ -0,0 +1,432 @@
import { useState, useEffect } from 'react';
import PageMeta from '../../components/common/PageMeta';
import Button from '../../components/ui/button/Button';
import TextArea from '../../components/form/input/TextArea';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { BoltIcon } from '../../icons';
import { fetchAPI } from '../../services/api';
interface PromptData {
prompt_type: string;
prompt_type_display: string;
prompt_value: string;
default_prompt: string;
is_active: boolean;
}
const PROMPT_TYPES = [
{
key: 'clustering',
label: 'Clustering Prompt',
description: 'Group keywords into topic clusters. Use [IGNY8_KEYWORDS] to inject keyword data.',
icon: '🌐',
color: 'green',
},
{
key: 'ideas',
label: 'Ideas Generation Prompt',
description: 'Generate content ideas from clusters. Use [IGNY8_CLUSTERS] and [IGNY8_CLUSTER_KEYWORDS] to inject data.',
icon: '💡',
color: 'amber',
},
{
key: 'content_generation',
label: 'Content Generation Prompt',
description: 'Generate content from ideas. Use [IGNY8_IDEA], [IGNY8_CLUSTER], and [IGNY8_KEYWORDS] to inject data.',
icon: '📝',
color: 'blue',
},
{
key: 'image_prompt_extraction',
label: 'Image Prompt Extraction',
description: 'Extract image prompts from article content. Use {title}, {content}, {max_images} placeholders.',
icon: '🔍',
color: 'indigo',
},
{
key: 'image_prompt_template',
label: 'Image Prompt Template',
description: 'Template for generating image prompts. Use {post_title}, {image_prompt}, {image_type} placeholders.',
icon: '🖼️',
color: 'purple',
},
{
key: 'negative_prompt',
label: 'Negative Prompt',
description: 'Specify elements to avoid in generated images (text, watermarks, logos, etc.).',
icon: '🚫',
color: 'red',
},
];
export default function Prompts() {
const toast = useToast();
const [prompts, setPrompts] = useState<Record<string, PromptData>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState<Record<string, boolean>>({});
// Load all prompts
useEffect(() => {
loadPrompts();
}, []);
const loadPrompts = async () => {
setLoading(true);
try {
const promises = PROMPT_TYPES.map(async (type) => {
try {
const data = await fetchAPI(`/v1/system/prompts/by_type/${type.key}/`);
return { key: type.key, data };
} catch (error) {
console.error(`Error loading prompt ${type.key}:`, error);
return { key: type.key, data: null };
}
});
const results = await Promise.all(promises);
const promptsMap: Record<string, PromptData> = {};
results.forEach(({ key, data }) => {
if (data) {
promptsMap[key] = data;
} else {
// Use default if not found
promptsMap[key] = {
prompt_type: key,
prompt_type_display: PROMPT_TYPES.find(t => t.key === key)?.label || key,
prompt_value: '',
default_prompt: '',
is_active: true,
};
}
});
setPrompts(promptsMap);
} catch (error: any) {
console.error('Error loading prompts:', error);
toast.error('Failed to load prompts');
} finally {
setLoading(false);
}
};
const handleSave = async (promptType: string) => {
const prompt = prompts[promptType];
if (!prompt) return;
setSaving({ ...saving, [promptType]: true });
try {
const data = await fetchAPI('/v1/system/prompts/save/', {
method: 'POST',
body: JSON.stringify({
prompt_type: promptType,
prompt_value: prompt.prompt_value,
}),
});
if (data.success) {
toast.success(data.message || 'Prompt saved successfully');
await loadPrompts(); // Reload to get updated data
} else {
throw new Error(data.error || 'Failed to save prompt');
}
} catch (error: any) {
console.error('Error saving prompt:', error);
toast.error(`Failed to save prompt: ${error.message}`);
} finally {
setSaving({ ...saving, [promptType]: false });
}
};
const handleReset = async (promptType: string) => {
if (!confirm('Are you sure you want to reset this prompt to default? This will overwrite any custom changes.')) {
return;
}
setSaving({ ...saving, [promptType]: true });
try {
const data = await fetchAPI('/v1/system/prompts/reset/', {
method: 'POST',
body: JSON.stringify({
prompt_type: promptType,
}),
});
if (data.success) {
toast.success(data.message || 'Prompt reset to default');
await loadPrompts(); // Reload to get default value
} else {
throw new Error(data.error || 'Failed to reset prompt');
}
} catch (error: any) {
console.error('Error resetting prompt:', error);
toast.error(`Failed to reset prompt: ${error.message}`);
} finally {
setSaving({ ...saving, [promptType]: false });
}
};
const handlePromptChange = (promptType: string, value: string) => {
setPrompts({
...prompts,
[promptType]: {
...prompts[promptType],
prompt_value: value,
},
});
};
if (loading) {
return (
<>
<PageMeta title="Prompts - IGNY8" description="AI prompts management" />
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-500 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading prompts...</p>
</div>
</div>
</>
);
}
return (
<>
<PageMeta title="Prompts - IGNY8" description="AI prompts management" />
<div className="p-6">
{/* Page Header */}
<div className="mb-6">
<div className="flex items-center gap-3 mb-2">
<BoltIcon className="text-primary-500 size-6" />
<h1 className="text-2xl font-semibold text-gray-800 dark:text-white">
AI Prompts Management
</h1>
</div>
<p className="text-gray-600 dark:text-gray-400">
Configure AI prompt templates for clustering, idea generation, content writing, and image generation
</p>
</div>
{/* Planner Prompts Section */}
<div className="mb-8">
<div className="mb-4">
<h2 className="text-xl font-semibold text-gray-800 dark:text-white mb-1">
Planner Prompts
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
Configure AI prompt templates for clustering and idea generation
</p>
</div>
{/* 2-Column Grid for Planner Prompts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Clustering Prompt */}
{PROMPT_TYPES.filter(t => ['clustering', 'ideas'].includes(t.key)).map((type) => {
const prompt = prompts[type.key] || {
prompt_type: type.key,
prompt_type_display: type.label,
prompt_value: '',
default_prompt: '',
is_active: true,
};
return (
<div key={type.key} className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
<div className="p-5 border-b border-gray-200 dark:border-gray-800">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-2xl">{type.icon}</span>
<div>
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">
{type.label}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{type.description}
</p>
</div>
</div>
</div>
</div>
<div className="p-5">
<TextArea
value={prompt.prompt_value || ''}
onChange={(value) => handlePromptChange(type.key, value)}
rows={12}
placeholder="Enter prompt template..."
className="font-mono-custom text-sm"
/>
<div className="flex gap-3 mt-4">
<Button
onClick={() => handleSave(type.key)}
disabled={saving[type.key]}
className="flex-1"
variant="solid"
color="primary"
>
{saving[type.key] ? 'Saving...' : 'Save Prompt'}
</Button>
<Button
onClick={() => handleReset(type.key)}
disabled={saving[type.key]}
variant="outline"
>
Reset to Default
</Button>
</div>
</div>
</div>
);
})}
</div>
</div>
{/* Writer Prompts Section */}
<div className="mb-8">
<div className="mb-4">
<h2 className="text-xl font-semibold text-gray-800 dark:text-white mb-1">
Writer Prompts
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
Configure AI prompt templates for content writing
</p>
</div>
{/* Content Generation Prompt */}
<div className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
{PROMPT_TYPES.filter(t => t.key === 'content_generation').map((type) => {
const prompt = prompts[type.key] || {
prompt_type: type.key,
prompt_type_display: type.label,
prompt_value: '',
default_prompt: '',
is_active: true,
};
return (
<div key={type.key}>
<div className="p-5 border-b border-gray-200 dark:border-gray-800">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-2xl">{type.icon}</span>
<div>
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">
{type.label}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{type.description}
</p>
</div>
</div>
</div>
</div>
<div className="p-5">
<TextArea
value={prompt.prompt_value || ''}
onChange={(value) => handlePromptChange(type.key, value)}
rows={15}
placeholder="Enter prompt template..."
className="font-mono-custom text-sm"
/>
<div className="flex gap-3 mt-4">
<Button
onClick={() => handleSave(type.key)}
disabled={saving[type.key]}
className="flex-1"
variant="solid"
color="primary"
>
{saving[type.key] ? 'Saving...' : 'Save Prompt'}
</Button>
<Button
onClick={() => handleReset(type.key)}
disabled={saving[type.key]}
variant="outline"
>
Reset to Default
</Button>
</div>
</div>
</div>
);
})}
</div>
</div>
{/* Image Generation Section */}
<div className="mb-8">
<div className="mb-4">
<h2 className="text-xl font-semibold text-gray-800 dark:text-white mb-1">
Image Generation
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
Configure AI image generation prompts
</p>
</div>
{/* 2-Column Grid for Image Prompts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{PROMPT_TYPES.filter(t => ['image_prompt_extraction', 'image_prompt_template', 'negative_prompt'].includes(t.key)).map((type) => {
const prompt = prompts[type.key] || {
prompt_type: type.key,
prompt_type_display: type.label,
prompt_value: '',
default_prompt: '',
is_active: true,
};
return (
<div key={type.key} className="rounded-2xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
<div className="p-5 border-b border-gray-200 dark:border-gray-800">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-2xl">{type.icon}</span>
<div>
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">
{type.label}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{type.description}
</p>
</div>
</div>
</div>
</div>
<div className="p-5">
<TextArea
value={prompt.prompt_value || ''}
onChange={(value) => handlePromptChange(type.key, value)}
rows={type.key === 'negative_prompt' ? 4 : 8}
placeholder="Enter prompt template..."
className="font-mono-custom text-sm"
/>
<div className="flex gap-3 mt-4">
<Button
onClick={() => handleSave(type.key)}
disabled={saving[type.key]}
className="flex-1"
variant="solid"
color="primary"
>
{saving[type.key] ? 'Saving...' : 'Save Prompt'}
</Button>
{type.key === 'image_prompt_template' && (
<Button
onClick={() => handleReset(type.key)}
disabled={saving[type.key]}
variant="outline"
>
Reset to Default
</Button>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,22 @@
import PageMeta from "../../components/common/PageMeta";
import ComponentCard from "../../components/common/ComponentCard";
export default function Strategies() {
return (
<>
<PageMeta title="Strategies - IGNY8" description="Content strategies" />
<ComponentCard title="Coming Soon" desc="Content strategies">
<div className="text-center py-8">
<p className="text-gray-600 dark:text-gray-400">
Content Strategies - Coming Soon
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Plan and manage content strategies and approaches
</p>
</div>
</ComponentCard>
</>
);
}

View File

@@ -0,0 +1,25 @@
import UserMetaCard from "../components/UserProfile/UserMetaCard";
import UserInfoCard from "../components/UserProfile/UserInfoCard";
import UserAddressCard from "../components/UserProfile/UserAddressCard";
import PageMeta from "../components/common/PageMeta";
export default function UserProfiles() {
return (
<>
<PageMeta
title="React.js Profile Dashboard | TailAdmin - Next.js Admin Dashboard Template"
description="This is React.js Profile Dashboard page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
/>
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">
<h3 className="mb-5 text-lg font-semibold text-gray-800 dark:text-white/90 lg:mb-7">
Profile
</h3>
<div className="space-y-6">
<UserMetaCard />
<UserInfoCard />
<UserAddressCard />
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,69 @@
import { useState, useEffect } from 'react';
import PageMeta from '../../components/common/PageMeta';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { fetchContent, Content as ContentType } from '../../services/api';
import { Card } from '../../components/ui/card';
export default function Content() {
const toast = useToast();
const [content, setContent] = useState<ContentType[]>([]);
const [loading, setLoading] = useState(true);
const [selectedContent, setSelectedContent] = useState<ContentType | null>(null);
useEffect(() => {
loadContent();
}, []);
const loadContent = async () => {
try {
setLoading(true);
const response = await fetchContent();
setContent(response.results || []);
} catch (error: any) {
toast.error(`Failed to load content: ${error.message}`);
} finally {
setLoading(false);
}
};
return (
<div className="p-6">
<PageMeta title="Content" />
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Content</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">View all generated content</p>
</div>
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading...</div>
</div>
) : (
<div className="space-y-4">
{content.map((item: ContentType) => (
<Card key={item.id} className="p-6">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Task #{item.task}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Generated: {new Date(item.generated_at).toLocaleString()}
</p>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{item.word_count} words
</div>
</div>
<div
className="prose dark:prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: item.html_content }}
/>
</Card>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,42 @@
import PageMeta from "../../components/common/PageMeta";
import ComponentCard from "../../components/common/ComponentCard";
export default function WriterDashboard() {
return (
<>
<PageMeta title="Writer Dashboard - IGNY8" description="Content creation overview" />
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 md:gap-6 mb-6">
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
<span className="text-sm text-gray-500 dark:text-gray-400">Tasks</span>
<h4 className="mt-2 font-bold text-gray-800 text-title-sm dark:text-white/90">-</h4>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Queued tasks</p>
</div>
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
<span className="text-sm text-gray-500 dark:text-gray-400">Drafts</span>
<h4 className="mt-2 font-bold text-gray-800 text-title-sm dark:text-white/90">-</h4>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Draft content</p>
</div>
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
<span className="text-sm text-gray-500 dark:text-gray-400">Published</span>
<h4 className="mt-2 font-bold text-gray-800 text-title-sm dark:text-white/90">-</h4>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Published content</p>
</div>
</div>
<ComponentCard title="Coming Soon" desc="Content creation overview">
<div className="text-center py-8">
<p className="text-gray-600 dark:text-gray-400">
Writer Dashboard - Coming Soon
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Overview of content tasks and workflow will be displayed here
</p>
</div>
</ComponentCard>
</>
);
}

View File

@@ -0,0 +1,13 @@
/**
* Drafts Page - Filtered Tasks with status='draft'
* Consistent with Keywords page layout, structure and design
*/
import Tasks from './Tasks';
export default function Drafts() {
// Drafts is just Tasks with status='draft' filter applied
// For now, we'll use the Tasks component but could enhance it later
// to show only draft status tasks by default
return <Tasks />;
}

View File

@@ -0,0 +1,231 @@
/**
* Images Page - Built with TablePageTemplate
* Consistent with Keywords page layout, structure and design
*/
import { useState, useEffect, useMemo, useCallback } from 'react';
import TablePageTemplate from '../../templates/TablePageTemplate';
import {
fetchTaskImages,
deleteTaskImage,
bulkDeleteTaskImages,
autoGenerateImages,
TaskImage,
TaskImageFilters,
} from '../../services/api';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { FileIcon, DownloadIcon } from '../../icons';
import { createImagesPageConfig } from '../../config/pages/images.config';
export default function Images() {
const toast = useToast();
// Data state
const [images, setImages] = useState<TaskImage[]>([]);
const [loading, setLoading] = useState(true);
// Filter state
const [searchTerm, setSearchTerm] = useState('');
const [imageTypeFilter, setImageTypeFilter] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalCount, setTotalCount] = useState(0);
// Sorting state
const [sortBy, setSortBy] = useState<string>('created_at');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [showContent, setShowContent] = useState(false);
// Load images - wrapped in useCallback
const loadImages = useCallback(async () => {
setLoading(true);
setShowContent(false);
try {
const ordering = sortBy ? `${sortDirection === 'desc' ? '-' : ''}${sortBy}` : '-created_at';
const filters: TaskImageFilters = {
...(imageTypeFilter && { image_type: imageTypeFilter }),
...(statusFilter && { status: statusFilter }),
page: currentPage,
ordering,
};
// Note: TaskImages API doesn't support search by task title yet
// We'll filter client-side for now
const data = await fetchTaskImages(filters);
let filteredResults = data.results || [];
// Client-side search filter
if (searchTerm) {
filteredResults = filteredResults.filter(img =>
img.task_title?.toLowerCase().includes(searchTerm.toLowerCase())
);
}
setImages(filteredResults);
setTotalCount(filteredResults.length);
setTotalPages(Math.ceil(filteredResults.length / 10));
setTimeout(() => {
setShowContent(true);
setLoading(false);
}, 100);
} catch (error: any) {
console.error('Error loading images:', error);
toast.error(`Failed to load images: ${error.message}`);
setShowContent(true);
setLoading(false);
}
}, [currentPage, imageTypeFilter, statusFilter, sortBy, sortDirection, searchTerm]);
useEffect(() => {
loadImages();
}, [loadImages]);
// Debounced search
useEffect(() => {
const timer = setTimeout(() => {
if (currentPage === 1) {
loadImages();
} else {
setCurrentPage(1);
}
}, 500);
return () => clearTimeout(timer);
}, [searchTerm, currentPage, loadImages]);
// Handle sorting
const handleSort = (field: string, direction: 'asc' | 'desc') => {
setSortBy(field || 'created_at');
setSortDirection(direction);
setCurrentPage(1);
};
// Bulk export handler
const handleBulkExport = useCallback(async (ids: string[]) => {
try {
if (!ids || ids.length === 0) {
throw new Error('No records selected for export');
}
toast.info('Export functionality coming soon');
} catch (error: any) {
throw error;
}
}, []);
// Bulk action handler
const handleBulkAction = useCallback(async (action: string, ids: string[]) => {
if (action === 'generate_images') {
try {
const numIds = ids.map(id => parseInt(id));
// Note: autoGenerateImages expects task_ids, not image_ids
// This would need to be adjusted based on API design
toast.info(`Generate images for ${ids.length} items`);
// await autoGenerateImages(numIds);
} catch (error: any) {
toast.error(`Failed to generate images: ${error.message}`);
}
} else {
toast.info(`Bulk action "${action}" for ${ids.length} items`);
}
}, []);
// Create page config
const pageConfig = useMemo(() => {
return createImagesPageConfig({
searchTerm,
setSearchTerm,
imageTypeFilter,
setImageTypeFilter,
statusFilter,
setStatusFilter,
setCurrentPage,
});
}, [searchTerm, imageTypeFilter, statusFilter]);
// Calculate header metrics
const headerMetrics = useMemo(() => {
if (!pageConfig?.headerMetrics) return [];
return pageConfig.headerMetrics.map((metric) => ({
label: metric.label,
value: metric.calculate({ images, totalCount }),
accentColor: metric.accentColor,
}));
}, [pageConfig?.headerMetrics, images, totalCount]);
return (
<TablePageTemplate
title="Task Images"
titleIcon={<FileIcon className="text-purple-500 size-5" />}
subtitle="Manage images for content tasks"
columns={pageConfig.columns}
data={images}
loading={loading}
showContent={showContent}
filters={pageConfig.filters}
filterValues={{
search: searchTerm,
image_type: imageTypeFilter,
status: statusFilter,
}}
onFilterChange={(key, value) => {
const stringValue = value === null || value === undefined ? '' : String(value);
if (key === 'search') {
setSearchTerm(stringValue);
} else if (key === 'image_type') {
setImageTypeFilter(stringValue);
} else if (key === 'status') {
setStatusFilter(stringValue);
}
setCurrentPage(1);
}}
onDelete={async (id: number) => {
await deleteTaskImage(id);
loadImages();
}}
onBulkDelete={async (ids: number[]) => {
// Note: bulkDeleteTaskImages doesn't exist yet, using individual deletes
for (const id of ids) {
await deleteTaskImage(id);
}
loadImages();
return { deleted_count: ids.length };
}}
onBulkExport={handleBulkExport}
onBulkAction={handleBulkAction}
getItemDisplayName={(row: TaskImage) => row.task_title || `Image ${row.id}`}
onExport={async () => {
toast.info('Export functionality coming soon');
}}
onExportIcon={<DownloadIcon />}
selectionLabel="image"
pagination={{
currentPage,
totalPages,
totalCount,
onPageChange: setCurrentPage,
}}
selection={{
selectedIds,
onSelectionChange: setSelectedIds,
}}
sorting={{
sortBy,
sortDirection,
onSort: handleSort,
}}
headerMetrics={headerMetrics}
onFilterReset={() => {
setSearchTerm('');
setImageTypeFilter('');
setStatusFilter('');
setCurrentPage(1);
}}
/>
);
}

View File

@@ -0,0 +1,13 @@
/**
* Published Page - Filtered Tasks with status='published'
* Consistent with Keywords page layout, structure and design
*/
import Tasks from './Tasks';
export default function Published() {
// Published is just Tasks with status='published' filter applied
// For now, we'll use the Tasks component but could enhance it later
// to show only published status tasks by default
return <Tasks />;
}

View File

@@ -0,0 +1,465 @@
/**
* Tasks Page - Built with TablePageTemplate
* Consistent with Keywords page layout, structure and design
*/
import { useState, useEffect, useMemo, useCallback } from 'react';
import TablePageTemplate from '../../templates/TablePageTemplate';
import {
fetchTasks,
createTask,
updateTask,
deleteTask,
bulkDeleteTasks,
bulkUpdateTasksStatus,
autoGenerateContent,
autoGenerateImages,
Task,
TasksFilters,
TaskCreateData,
fetchClusters,
Cluster,
} from '../../services/api';
import FormModal from '../../components/common/FormModal';
import ProgressModal from '../../components/common/ProgressModal';
import { useProgressModal } from '../../hooks/useProgressModal';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { TaskIcon, PlusIcon, DownloadIcon } from '../../icons';
import { createTasksPageConfig } from '../../config/pages/tasks.config';
import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
export default function Tasks() {
const toast = useToast();
const { activeSector } = useSectorStore();
const { pageSize } = usePageSizeStore();
// Data state
const [tasks, setTasks] = useState<Task[]>([]);
const [clusters, setClusters] = useState<Cluster[]>([]);
const [loading, setLoading] = useState(true);
// Filter state
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [clusterFilter, setClusterFilter] = useState('');
const [structureFilter, setStructureFilter] = useState('');
const [typeFilter, setTypeFilter] = useState('');
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalCount, setTotalCount] = useState(0);
// Sorting state
const [sortBy, setSortBy] = useState<string>('created_at');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [showContent, setShowContent] = useState(false);
// Modal state
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [editingTask, setEditingTask] = useState<Task | null>(null);
const [formData, setFormData] = useState<TaskCreateData>({
title: '',
description: '',
keywords: '',
cluster_id: null,
content_structure: 'blog_post',
content_type: 'blog_post',
status: 'queued',
word_count: 0,
});
// Progress modal for AI functions
const progressModal = useProgressModal();
// Load clusters for filter dropdown
useEffect(() => {
const loadClusters = async () => {
try {
const data = await fetchClusters({ ordering: 'name' });
setClusters(data.results || []);
} catch (error) {
console.error('Error fetching clusters:', error);
}
};
loadClusters();
}, []);
// Load tasks - wrapped in useCallback
const loadTasks = useCallback(async () => {
setLoading(true);
setShowContent(false);
try {
const ordering = sortBy ? `${sortDirection === 'desc' ? '-' : ''}${sortBy}` : '-created_at';
const filters: TasksFilters = {
...(searchTerm && { search: searchTerm }),
...(statusFilter && { status: statusFilter }),
...(clusterFilter && { cluster_id: clusterFilter }),
...(structureFilter && { content_structure: structureFilter }),
...(typeFilter && { content_type: typeFilter }),
page: currentPage,
page_size: pageSize,
ordering,
};
const data = await fetchTasks(filters);
setTasks(data.results || []);
setTotalCount(data.count || 0);
setTotalPages(Math.ceil((data.count || 0) / pageSize));
setTimeout(() => {
setShowContent(true);
setLoading(false);
}, 100);
} catch (error: any) {
console.error('Error loading tasks:', error);
toast.error(`Failed to load tasks: ${error.message}`);
setShowContent(true);
setLoading(false);
}
}, [currentPage, statusFilter, clusterFilter, structureFilter, typeFilter, sortBy, sortDirection, searchTerm, activeSector, pageSize]);
useEffect(() => {
loadTasks();
}, [loadTasks]);
// Listen for site and sector changes and refresh data
useEffect(() => {
const handleSiteChange = () => {
loadTasks();
};
const handleSectorChange = () => {
loadTasks();
};
window.addEventListener('siteChanged', handleSiteChange);
window.addEventListener('sectorChanged', handleSectorChange);
return () => {
window.removeEventListener('siteChanged', handleSiteChange);
window.removeEventListener('sectorChanged', handleSectorChange);
};
}, [loadTasks]);
// Reset to page 1 when pageSize changes
useEffect(() => {
setCurrentPage(1);
}, [pageSize]);
// Debounced search
useEffect(() => {
const timer = setTimeout(() => {
if (currentPage === 1) {
loadTasks();
} else {
setCurrentPage(1);
}
}, 500);
return () => clearTimeout(timer);
}, [searchTerm, currentPage, loadTasks]);
// Handle sorting
const handleSort = (field: string, direction: 'asc' | 'desc') => {
setSortBy(field || 'created_at');
setSortDirection(direction);
setCurrentPage(1);
};
// Bulk status update handler
const handleBulkUpdateStatus = useCallback(async (ids: string[], status: string) => {
try {
const numIds = ids.map(id => parseInt(id));
await bulkUpdateTasksStatus(numIds, status);
await loadTasks();
} catch (error: any) {
throw error;
}
}, [loadTasks]);
// Bulk export handler
const handleBulkExport = useCallback(async (ids: string[]) => {
try {
if (!ids || ids.length === 0) {
throw new Error('No records selected for export');
}
toast.info('Export functionality coming soon');
} catch (error: any) {
throw error;
}
}, []);
// Row action handler for single task actions
const handleRowAction = useCallback(async (action: string, row: Task) => {
if (action === 'generate_content') {
// Validate task has required data
if (!row.title) {
toast.error('Task must have a title to generate content');
return;
}
// Optional: Validate task status (can generate for any status)
// if (row.status !== 'queued') {
// toast.error(`Only tasks with status "queued" can generate content. Current status: ${row.status}`);
// return;
// }
try {
const result = await autoGenerateContent([row.id]);
if (result.success) {
if (result.task_id) {
// Async task - show progress modal
progressModal.openModal(result.task_id, 'Generating Content');
toast.success('Content generation started');
} else {
// Synchronous completion
toast.success(`Content generated successfully: ${result.tasks_updated || 0} article generated`);
await loadTasks();
}
} else {
toast.error(result.error || 'Failed to generate content');
}
} catch (error: any) {
toast.error(`Failed to generate content: ${error.message}`);
}
}
}, [toast, loadTasks, progressModal]);
// Bulk action handler
const handleBulkAction = useCallback(async (action: string, ids: string[]) => {
// generate_content removed from bulk actions - only available as row action
if (action === 'generate_images') {
if (ids.length === 0) {
toast.error('Please select at least one task to generate images');
return;
}
if (ids.length > 10) {
toast.error('Maximum 10 tasks allowed for image generation');
return;
}
try {
const numIds = ids.map(id => parseInt(id));
const result = await autoGenerateImages(numIds);
if (result.success) {
if (result.task_id) {
// Async task - show progress modal
progressModal.openModal(result.task_id, 'Generating Images');
toast.success('Image generation started');
} else {
// Synchronous completion
toast.success(`Image generation complete: ${result.images_created || 0} images generated`);
await loadTasks();
}
} else {
toast.error(result.error || 'Failed to generate images');
}
} catch (error: any) {
toast.error(`Failed to generate images: ${error.message}`);
}
} else {
toast.info(`Bulk action "${action}" for ${ids.length} items`);
}
}, [toast, loadTasks, progressModal]);
// Create page config
const pageConfig = useMemo(() => {
return createTasksPageConfig({
clusters,
activeSector,
formData,
setFormData,
searchTerm,
setSearchTerm,
statusFilter,
setStatusFilter,
clusterFilter,
setClusterFilter,
structureFilter,
setStructureFilter,
typeFilter,
setTypeFilter,
setCurrentPage,
});
}, [clusters, activeSector, formData, searchTerm, statusFilter, clusterFilter, structureFilter, typeFilter]);
// Calculate header metrics
const headerMetrics = useMemo(() => {
if (!pageConfig?.headerMetrics) return [];
return pageConfig.headerMetrics.map((metric) => ({
label: metric.label,
value: metric.calculate({ tasks, totalCount }),
accentColor: metric.accentColor,
}));
}, [pageConfig?.headerMetrics, tasks, totalCount]);
const resetForm = useCallback(() => {
setFormData({
title: '',
description: '',
keywords: '',
cluster_id: null,
content_structure: 'blog_post',
content_type: 'blog_post',
status: 'queued',
word_count: 0,
});
setIsEditMode(false);
setEditingTask(null);
}, []);
// Handle create/edit
const handleSave = async () => {
try {
if (isEditMode && editingTask) {
await updateTask(editingTask.id, formData);
toast.success('Task updated successfully');
} else {
await createTask(formData);
toast.success('Task created successfully');
}
setIsModalOpen(false);
resetForm();
loadTasks();
} catch (error: any) {
toast.error(`Failed to save: ${error.message}`);
}
};
return (
<>
<TablePageTemplate
title="Content Tasks"
titleIcon={<TaskIcon className="text-brand-500 size-5" />}
subtitle="Manage content generation queue and tasks"
columns={pageConfig.columns}
data={tasks}
loading={loading}
showContent={showContent}
filters={pageConfig.filters}
filterValues={{
search: searchTerm,
status: statusFilter,
cluster_id: clusterFilter,
content_structure: structureFilter,
content_type: typeFilter,
}}
onFilterChange={(key, value) => {
const stringValue = value === null || value === undefined ? '' : String(value);
if (key === 'search') {
setSearchTerm(stringValue);
} else if (key === 'status') {
setStatusFilter(stringValue);
} else if (key === 'cluster_id') {
setClusterFilter(stringValue);
} else if (key === 'content_structure') {
setStructureFilter(stringValue);
} else if (key === 'content_type') {
setTypeFilter(stringValue);
}
setCurrentPage(1);
}}
onEdit={(row) => {
setEditingTask(row);
setFormData({
title: row.title || '',
description: row.description || '',
keywords: row.keywords || '',
cluster_id: row.cluster_id || null,
content_structure: row.content_structure || 'blog_post',
content_type: row.content_type || 'blog_post',
status: row.status || 'queued',
word_count: row.word_count || 0,
});
setIsEditMode(true);
setIsModalOpen(true);
}}
onCreate={() => {
resetForm();
setIsModalOpen(true);
}}
createLabel="Add Task"
onCreateIcon={<PlusIcon />}
onDelete={async (id: number) => {
await deleteTask(id);
loadTasks();
}}
onBulkDelete={async (ids: number[]) => {
const result = await bulkDeleteTasks(ids);
loadTasks();
return result;
}}
onBulkExport={handleBulkExport}
onBulkUpdateStatus={handleBulkUpdateStatus}
onBulkAction={handleBulkAction}
onRowAction={handleRowAction}
getItemDisplayName={(row: Task) => row.title}
onExport={async () => {
toast.info('Export functionality coming soon');
}}
onExportIcon={<DownloadIcon />}
selectionLabel="task"
pagination={{
currentPage,
totalPages,
totalCount,
onPageChange: setCurrentPage,
}}
selection={{
selectedIds,
onSelectionChange: setSelectedIds,
}}
sorting={{
sortBy,
sortDirection,
onSort: handleSort,
}}
headerMetrics={headerMetrics}
onFilterReset={() => {
setSearchTerm('');
setStatusFilter('');
setClusterFilter('');
setStructureFilter('');
setTypeFilter('');
setCurrentPage(1);
}}
/>
{/* Progress Modal for AI Functions */}
<ProgressModal
isOpen={progressModal.isOpen}
title={progressModal.title}
percentage={progressModal.progress.percentage}
status={progressModal.progress.status}
message={progressModal.progress.message}
details={progressModal.progress.details}
taskId={progressModal.taskId || undefined}
onClose={() => {
const wasCompleted = progressModal.progress.status === 'completed';
progressModal.closeModal();
// Reload data after modal closes (if completed)
if (wasCompleted) {
loadTasks();
}
}}
/>
{/* Create/Edit Modal */}
<FormModal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false);
resetForm();
}}
onSubmit={handleSave}
title={isEditMode ? 'Edit Task' : 'Add Task'}
submitLabel={isEditMode ? 'Update' : 'Create'}
fields={pageConfig.formFields(clusters)}
/>
</>
);
}