Add SEO fields to Tasks model, improve content generation response handling, and enhance progress bar animation
- Added primary_keyword, secondary_keywords, tags, and categories fields to Tasks model - Updated generate_content function to handle full JSON response with all SEO fields - Improved progress bar animation: smooth 1% increments every 300ms - Enhanced step detection for content generation vs clustering vs ideas - Fixed progress modal to show correct messages for each function type - Added comprehensive logging to Keywords and Tasks pages for AI functions - Fixed error handling to show meaningful error messages instead of generic failures
This commit is contained in:
@@ -1,98 +1,103 @@
|
||||
import { Suspense, lazy } from "react";
|
||||
import { BrowserRouter as Router, Routes, Route } from "react-router";
|
||||
import SignIn from "./pages/AuthPages/SignIn";
|
||||
import SignUp from "./pages/AuthPages/SignUp";
|
||||
import NotFound from "./pages/OtherPage/NotFound";
|
||||
import AppLayout from "./layout/AppLayout";
|
||||
import { ScrollToTop } from "./components/common/ScrollToTop";
|
||||
import ProtectedRoute from "./components/auth/ProtectedRoute";
|
||||
import GlobalErrorDisplay from "./components/common/GlobalErrorDisplay";
|
||||
import LoadingStateMonitor from "./components/common/LoadingStateMonitor";
|
||||
import Home from "./pages/Dashboard/Home";
|
||||
|
||||
// Planner Module
|
||||
import PlannerDashboard from "./pages/Planner/Dashboard";
|
||||
import Keywords from "./pages/Planner/Keywords";
|
||||
import Clusters from "./pages/Planner/Clusters";
|
||||
import Ideas from "./pages/Planner/Ideas";
|
||||
// Auth pages - loaded immediately (needed for login)
|
||||
import SignIn from "./pages/AuthPages/SignIn";
|
||||
import SignUp from "./pages/AuthPages/SignUp";
|
||||
import NotFound from "./pages/OtherPage/NotFound";
|
||||
|
||||
// Writer Module
|
||||
import WriterDashboard from "./pages/Writer/Dashboard";
|
||||
import Tasks from "./pages/Writer/Tasks";
|
||||
import Content from "./pages/Writer/Content";
|
||||
import Drafts from "./pages/Writer/Drafts";
|
||||
import Images from "./pages/Writer/Images";
|
||||
import Published from "./pages/Writer/Published";
|
||||
// Lazy load all other pages - only loads when navigated to
|
||||
const Home = lazy(() => import("./pages/Dashboard/Home"));
|
||||
|
||||
// Thinker Module
|
||||
import ThinkerDashboard from "./pages/Thinker/Dashboard";
|
||||
import Prompts from "./pages/Thinker/Prompts";
|
||||
import AuthorProfiles from "./pages/Thinker/AuthorProfiles";
|
||||
import ThinkerProfile from "./pages/Thinker/Profile";
|
||||
import Strategies from "./pages/Thinker/Strategies";
|
||||
import ImageTesting from "./pages/Thinker/ImageTesting";
|
||||
// Planner Module - Lazy loaded
|
||||
const PlannerDashboard = lazy(() => import("./pages/Planner/Dashboard"));
|
||||
const Keywords = lazy(() => import("./pages/Planner/Keywords"));
|
||||
const Clusters = lazy(() => import("./pages/Planner/Clusters"));
|
||||
const Ideas = lazy(() => import("./pages/Planner/Ideas"));
|
||||
const KeywordOpportunities = lazy(() => import("./pages/Planner/KeywordOpportunities"));
|
||||
|
||||
// Billing Module
|
||||
import Credits from "./pages/Billing/Credits";
|
||||
import Transactions from "./pages/Billing/Transactions";
|
||||
import Usage from "./pages/Billing/Usage";
|
||||
// Writer Module - Lazy loaded
|
||||
const WriterDashboard = lazy(() => import("./pages/Writer/Dashboard"));
|
||||
const Tasks = lazy(() => import("./pages/Writer/Tasks"));
|
||||
const Content = lazy(() => import("./pages/Writer/Content"));
|
||||
const Drafts = lazy(() => import("./pages/Writer/Drafts"));
|
||||
const Images = lazy(() => import("./pages/Writer/Images"));
|
||||
const Published = lazy(() => import("./pages/Writer/Published"));
|
||||
|
||||
// Reference Data
|
||||
import SeedKeywords from "./pages/Reference/SeedKeywords";
|
||||
import KeywordOpportunities from "./pages/Planner/KeywordOpportunities";
|
||||
import ReferenceIndustries from "./pages/Reference/Industries";
|
||||
// Thinker Module - Lazy loaded
|
||||
const ThinkerDashboard = lazy(() => import("./pages/Thinker/Dashboard"));
|
||||
const Prompts = lazy(() => import("./pages/Thinker/Prompts"));
|
||||
const AuthorProfiles = lazy(() => import("./pages/Thinker/AuthorProfiles"));
|
||||
const ThinkerProfile = lazy(() => import("./pages/Thinker/Profile"));
|
||||
const Strategies = lazy(() => import("./pages/Thinker/Strategies"));
|
||||
const ImageTesting = lazy(() => import("./pages/Thinker/ImageTesting"));
|
||||
|
||||
// Other Pages
|
||||
import Analytics from "./pages/Analytics";
|
||||
import Schedules from "./pages/Schedules";
|
||||
// Billing Module - Lazy loaded
|
||||
const Credits = lazy(() => import("./pages/Billing/Credits"));
|
||||
const Transactions = lazy(() => import("./pages/Billing/Transactions"));
|
||||
const Usage = lazy(() => import("./pages/Billing/Usage"));
|
||||
|
||||
// Settings
|
||||
import GeneralSettings from "./pages/Settings/General";
|
||||
import Users from "./pages/Settings/Users";
|
||||
import Subscriptions from "./pages/Settings/Subscriptions";
|
||||
import SystemSettings from "./pages/Settings/System";
|
||||
import AccountSettings from "./pages/Settings/Account";
|
||||
import ModuleSettings from "./pages/Settings/Modules";
|
||||
import AISettings from "./pages/Settings/AI";
|
||||
import Plans from "./pages/Settings/Plans";
|
||||
import Industries from "./pages/Settings/Industries";
|
||||
import Status from "./pages/Settings/Status";
|
||||
import Integration from "./pages/Settings/Integration";
|
||||
import Sites from "./pages/Settings/Sites";
|
||||
import ImportExport from "./pages/Settings/ImportExport";
|
||||
// Reference Data - Lazy loaded
|
||||
const SeedKeywords = lazy(() => import("./pages/Reference/SeedKeywords"));
|
||||
const ReferenceIndustries = lazy(() => import("./pages/Reference/Industries"));
|
||||
|
||||
// Help
|
||||
import Help from "./pages/Help/Help";
|
||||
import Docs from "./pages/Help/Docs";
|
||||
import SystemTesting from "./pages/Help/SystemTesting";
|
||||
import FunctionTesting from "./pages/Help/FunctionTesting";
|
||||
// Other Pages - Lazy loaded
|
||||
const Analytics = lazy(() => import("./pages/Analytics"));
|
||||
const Schedules = lazy(() => import("./pages/Schedules"));
|
||||
|
||||
// Components
|
||||
import Components from "./pages/Components";
|
||||
// Settings - Lazy loaded
|
||||
const GeneralSettings = lazy(() => import("./pages/Settings/General"));
|
||||
const Users = lazy(() => import("./pages/Settings/Users"));
|
||||
const Subscriptions = lazy(() => import("./pages/Settings/Subscriptions"));
|
||||
const SystemSettings = lazy(() => import("./pages/Settings/System"));
|
||||
const AccountSettings = lazy(() => import("./pages/Settings/Account"));
|
||||
const ModuleSettings = lazy(() => import("./pages/Settings/Modules"));
|
||||
const AISettings = lazy(() => import("./pages/Settings/AI"));
|
||||
const Plans = lazy(() => import("./pages/Settings/Plans"));
|
||||
const Industries = lazy(() => import("./pages/Settings/Industries"));
|
||||
const Status = lazy(() => import("./pages/Settings/Status"));
|
||||
const Integration = lazy(() => import("./pages/Settings/Integration"));
|
||||
const Sites = lazy(() => import("./pages/Settings/Sites"));
|
||||
const ImportExport = lazy(() => import("./pages/Settings/ImportExport"));
|
||||
|
||||
// UI Elements
|
||||
import Alerts from "./pages/Settings/UiElements/Alerts";
|
||||
import Avatars from "./pages/Settings/UiElements/Avatars";
|
||||
import Badges from "./pages/Settings/UiElements/Badges";
|
||||
import Breadcrumb from "./pages/Settings/UiElements/Breadcrumb";
|
||||
import Buttons from "./pages/Settings/UiElements/Buttons";
|
||||
import ButtonsGroup from "./pages/Settings/UiElements/ButtonsGroup";
|
||||
import Cards from "./pages/Settings/UiElements/Cards";
|
||||
import Carousel from "./pages/Settings/UiElements/Carousel";
|
||||
import Dropdowns from "./pages/Settings/UiElements/Dropdowns";
|
||||
import ImagesUI from "./pages/Settings/UiElements/Images";
|
||||
import Links from "./pages/Settings/UiElements/Links";
|
||||
import List from "./pages/Settings/UiElements/List";
|
||||
import Modals from "./pages/Settings/UiElements/Modals";
|
||||
import Notifications from "./pages/Settings/UiElements/Notifications";
|
||||
import Pagination from "./pages/Settings/UiElements/Pagination";
|
||||
import Popovers from "./pages/Settings/UiElements/Popovers";
|
||||
import PricingTable from "./pages/Settings/UiElements/PricingTable";
|
||||
import Progressbar from "./pages/Settings/UiElements/Progressbar";
|
||||
import Ribbons from "./pages/Settings/UiElements/Ribbons";
|
||||
import Spinners from "./pages/Settings/UiElements/Spinners";
|
||||
import Tabs from "./pages/Settings/UiElements/Tabs";
|
||||
import Tooltips from "./pages/Settings/UiElements/Tooltips";
|
||||
import Videos from "./pages/Settings/UiElements/Videos";
|
||||
// Help - Lazy loaded
|
||||
const Help = lazy(() => import("./pages/Help/Help"));
|
||||
const Docs = lazy(() => import("./pages/Help/Docs"));
|
||||
const SystemTesting = lazy(() => import("./pages/Help/SystemTesting"));
|
||||
const FunctionTesting = lazy(() => import("./pages/Help/FunctionTesting"));
|
||||
|
||||
// Components - Lazy loaded
|
||||
const Components = lazy(() => import("./pages/Components"));
|
||||
|
||||
// UI Elements - Lazy loaded (rarely used)
|
||||
const Alerts = lazy(() => import("./pages/Settings/UiElements/Alerts"));
|
||||
const Avatars = lazy(() => import("./pages/Settings/UiElements/Avatars"));
|
||||
const Badges = lazy(() => import("./pages/Settings/UiElements/Badges"));
|
||||
const Breadcrumb = lazy(() => import("./pages/Settings/UiElements/Breadcrumb"));
|
||||
const Buttons = lazy(() => import("./pages/Settings/UiElements/Buttons"));
|
||||
const ButtonsGroup = lazy(() => import("./pages/Settings/UiElements/ButtonsGroup"));
|
||||
const Cards = lazy(() => import("./pages/Settings/UiElements/Cards"));
|
||||
const Carousel = lazy(() => import("./pages/Settings/UiElements/Carousel"));
|
||||
const Dropdowns = lazy(() => import("./pages/Settings/UiElements/Dropdowns"));
|
||||
const ImagesUI = lazy(() => import("./pages/Settings/UiElements/Images"));
|
||||
const Links = lazy(() => import("./pages/Settings/UiElements/Links"));
|
||||
const List = lazy(() => import("./pages/Settings/UiElements/List"));
|
||||
const Modals = lazy(() => import("./pages/Settings/UiElements/Modals"));
|
||||
const Notifications = lazy(() => import("./pages/Settings/UiElements/Notifications"));
|
||||
const Pagination = lazy(() => import("./pages/Settings/UiElements/Pagination"));
|
||||
const Popovers = lazy(() => import("./pages/Settings/UiElements/Popovers"));
|
||||
const PricingTable = lazy(() => import("./pages/Settings/UiElements/PricingTable"));
|
||||
const Progressbar = lazy(() => import("./pages/Settings/UiElements/Progressbar"));
|
||||
const Ribbons = lazy(() => import("./pages/Settings/UiElements/Ribbons"));
|
||||
const Spinners = lazy(() => import("./pages/Settings/UiElements/Spinners"));
|
||||
const Tabs = lazy(() => import("./pages/Settings/UiElements/Tabs"));
|
||||
const Tooltips = lazy(() => import("./pages/Settings/UiElements/Tooltips"));
|
||||
const Videos = lazy(() => import("./pages/Settings/UiElements/Videos"));
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -115,95 +120,363 @@ export default function App() {
|
||||
}
|
||||
>
|
||||
{/* Dashboard */}
|
||||
<Route index path="/" element={<Home />} />
|
||||
<Route index path="/" element={
|
||||
<Suspense fallback={null}>
|
||||
<Home />
|
||||
</Suspense>
|
||||
} />
|
||||
|
||||
{/* Planner Module */}
|
||||
<Route path="/planner" element={<PlannerDashboard />} />
|
||||
<Route path="/planner/keywords" element={<Keywords />} />
|
||||
<Route path="/planner/clusters" element={<Clusters />} />
|
||||
<Route path="/planner/ideas" element={<Ideas />} />
|
||||
<Route path="/planner" element={
|
||||
<Suspense fallback={null}>
|
||||
<PlannerDashboard />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/planner/keywords" element={
|
||||
<Suspense fallback={null}>
|
||||
<Keywords />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/planner/clusters" element={
|
||||
<Suspense fallback={null}>
|
||||
<Clusters />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/planner/ideas" element={
|
||||
<Suspense fallback={null}>
|
||||
<Ideas />
|
||||
</Suspense>
|
||||
} />
|
||||
|
||||
{/* Writer Module */}
|
||||
<Route path="/writer" element={<WriterDashboard />} />
|
||||
<Route path="/writer/tasks" element={<Tasks />} />
|
||||
<Route path="/writer/content" element={<Content />} />
|
||||
<Route path="/writer/drafts" element={<Drafts />} />
|
||||
<Route path="/writer/images" element={<Images />} />
|
||||
<Route path="/writer/published" element={<Published />} />
|
||||
<Route path="/writer" element={
|
||||
<Suspense fallback={null}>
|
||||
<WriterDashboard />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/writer/tasks" element={
|
||||
<Suspense fallback={null}>
|
||||
<Tasks />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/writer/content" element={
|
||||
<Suspense fallback={null}>
|
||||
<Content />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/writer/drafts" element={
|
||||
<Suspense fallback={null}>
|
||||
<Drafts />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/writer/images" element={
|
||||
<Suspense fallback={null}>
|
||||
<Images />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/writer/published" element={
|
||||
<Suspense fallback={null}>
|
||||
<Published />
|
||||
</Suspense>
|
||||
} />
|
||||
|
||||
{/* Thinker Module */}
|
||||
<Route path="/thinker" element={<ThinkerDashboard />} />
|
||||
<Route path="/thinker/prompts" element={<Prompts />} />
|
||||
<Route path="/thinker/author-profiles" element={<AuthorProfiles />} />
|
||||
<Route path="/thinker/profile" element={<ThinkerProfile />} />
|
||||
<Route path="/thinker/strategies" element={<Strategies />} />
|
||||
<Route path="/thinker/image-testing" element={<ImageTesting />} />
|
||||
<Route path="/thinker" element={
|
||||
<Suspense fallback={null}>
|
||||
<ThinkerDashboard />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/thinker/prompts" element={
|
||||
<Suspense fallback={null}>
|
||||
<Prompts />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/thinker/author-profiles" element={
|
||||
<Suspense fallback={null}>
|
||||
<AuthorProfiles />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/thinker/profile" element={
|
||||
<Suspense fallback={null}>
|
||||
<ThinkerProfile />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/thinker/strategies" element={
|
||||
<Suspense fallback={null}>
|
||||
<Strategies />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/thinker/image-testing" element={
|
||||
<Suspense fallback={null}>
|
||||
<ImageTesting />
|
||||
</Suspense>
|
||||
} />
|
||||
|
||||
{/* Billing Module */}
|
||||
<Route path="/billing/credits" element={<Credits />} />
|
||||
<Route path="/billing/transactions" element={<Transactions />} />
|
||||
<Route path="/billing/usage" element={<Usage />} />
|
||||
<Route path="/billing/credits" element={
|
||||
<Suspense fallback={null}>
|
||||
<Credits />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/billing/transactions" element={
|
||||
<Suspense fallback={null}>
|
||||
<Transactions />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/billing/usage" element={
|
||||
<Suspense fallback={null}>
|
||||
<Usage />
|
||||
</Suspense>
|
||||
} />
|
||||
|
||||
{/* Reference Data */}
|
||||
<Route path="/reference/seed-keywords" element={<SeedKeywords />} />
|
||||
<Route path="/planner/keyword-opportunities" element={<KeywordOpportunities />} />
|
||||
<Route path="/reference/industries" element={<ReferenceIndustries />} />
|
||||
<Route path="/reference/seed-keywords" element={
|
||||
<Suspense fallback={null}>
|
||||
<SeedKeywords />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/planner/keyword-opportunities" element={
|
||||
<Suspense fallback={null}>
|
||||
<KeywordOpportunities />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/reference/industries" element={
|
||||
<Suspense fallback={null}>
|
||||
<ReferenceIndustries />
|
||||
</Suspense>
|
||||
} />
|
||||
|
||||
{/* Other Pages */}
|
||||
<Route path="/analytics" element={<Analytics />} />
|
||||
<Route path="/schedules" element={<Schedules />} />
|
||||
<Route path="/analytics" element={
|
||||
<Suspense fallback={null}>
|
||||
<Analytics />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/schedules" element={
|
||||
<Suspense fallback={null}>
|
||||
<Schedules />
|
||||
</Suspense>
|
||||
} />
|
||||
|
||||
{/* Settings */}
|
||||
<Route path="/settings" element={<GeneralSettings />} />
|
||||
<Route path="/settings/users" element={<Users />} />
|
||||
<Route path="/settings/subscriptions" element={<Subscriptions />} />
|
||||
<Route path="/settings/system" element={<SystemSettings />} />
|
||||
<Route path="/settings/account" element={<AccountSettings />} />
|
||||
<Route path="/settings/modules" element={<ModuleSettings />} />
|
||||
<Route path="/settings/ai" element={<AISettings />} />
|
||||
<Route path="/settings/plans" element={<Plans />} />
|
||||
<Route path="/settings/industries" element={<Industries />} />
|
||||
<Route path="/settings/status" element={<Status />} />
|
||||
<Route path="/settings/integration" element={<Integration />} />
|
||||
<Route path="/settings/sites" element={<Sites />} />
|
||||
<Route path="/settings/import-export" element={<ImportExport />} />
|
||||
<Route path="/settings" element={
|
||||
<Suspense fallback={null}>
|
||||
<GeneralSettings />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/settings/users" element={
|
||||
<Suspense fallback={null}>
|
||||
<Users />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/settings/subscriptions" element={
|
||||
<Suspense fallback={null}>
|
||||
<Subscriptions />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/settings/system" element={
|
||||
<Suspense fallback={null}>
|
||||
<SystemSettings />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/settings/account" element={
|
||||
<Suspense fallback={null}>
|
||||
<AccountSettings />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/settings/modules" element={
|
||||
<Suspense fallback={null}>
|
||||
<ModuleSettings />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/settings/ai" element={
|
||||
<Suspense fallback={null}>
|
||||
<AISettings />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/settings/plans" element={
|
||||
<Suspense fallback={null}>
|
||||
<Plans />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/settings/industries" element={
|
||||
<Suspense fallback={null}>
|
||||
<Industries />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/settings/status" element={
|
||||
<Suspense fallback={null}>
|
||||
<Status />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/settings/integration" element={
|
||||
<Suspense fallback={null}>
|
||||
<Integration />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/settings/sites" element={
|
||||
<Suspense fallback={null}>
|
||||
<Sites />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/settings/import-export" element={
|
||||
<Suspense fallback={null}>
|
||||
<ImportExport />
|
||||
</Suspense>
|
||||
} />
|
||||
|
||||
{/* Help */}
|
||||
<Route path="/help" element={<Help />} />
|
||||
<Route path="/help/docs" element={<Docs />} />
|
||||
<Route path="/help/system-testing" element={<SystemTesting />} />
|
||||
<Route path="/help/function-testing" element={<FunctionTesting />} />
|
||||
<Route path="/help" element={
|
||||
<Suspense fallback={null}>
|
||||
<Help />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/help/docs" element={
|
||||
<Suspense fallback={null}>
|
||||
<Docs />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/help/system-testing" element={
|
||||
<Suspense fallback={null}>
|
||||
<SystemTesting />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/help/function-testing" element={
|
||||
<Suspense fallback={null}>
|
||||
<FunctionTesting />
|
||||
</Suspense>
|
||||
} />
|
||||
|
||||
{/* UI Elements */}
|
||||
<Route path="/ui-elements/alerts" element={<Alerts />} />
|
||||
<Route path="/ui-elements/avatars" element={<Avatars />} />
|
||||
<Route path="/ui-elements/badges" element={<Badges />} />
|
||||
<Route path="/ui-elements/breadcrumb" element={<Breadcrumb />} />
|
||||
<Route path="/ui-elements/buttons" element={<Buttons />} />
|
||||
<Route path="/ui-elements/buttons-group" element={<ButtonsGroup />} />
|
||||
<Route path="/ui-elements/cards" element={<Cards />} />
|
||||
<Route path="/ui-elements/carousel" element={<Carousel />} />
|
||||
<Route path="/ui-elements/dropdowns" element={<Dropdowns />} />
|
||||
<Route path="/ui-elements/images" element={<ImagesUI />} />
|
||||
<Route path="/ui-elements/links" element={<Links />} />
|
||||
<Route path="/ui-elements/list" element={<List />} />
|
||||
<Route path="/ui-elements/modals" element={<Modals />} />
|
||||
<Route path="/ui-elements/notifications" element={<Notifications />} />
|
||||
<Route path="/ui-elements/pagination" element={<Pagination />} />
|
||||
<Route path="/ui-elements/popovers" element={<Popovers />} />
|
||||
<Route path="/ui-elements/pricing-table" element={<PricingTable />} />
|
||||
<Route path="/ui-elements/progressbar" element={<Progressbar />} />
|
||||
<Route path="/ui-elements/ribbons" element={<Ribbons />} />
|
||||
<Route path="/ui-elements/spinners" element={<Spinners />} />
|
||||
<Route path="/ui-elements/tabs" element={<Tabs />} />
|
||||
<Route path="/ui-elements/tooltips" element={<Tooltips />} />
|
||||
<Route path="/ui-elements/videos" element={<Videos />} />
|
||||
<Route path="/ui-elements/alerts" element={
|
||||
<Suspense fallback={null}>
|
||||
<Alerts />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/ui-elements/avatars" element={
|
||||
<Suspense fallback={null}>
|
||||
<Avatars />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/ui-elements/badges" element={
|
||||
<Suspense fallback={null}>
|
||||
<Badges />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/ui-elements/breadcrumb" element={
|
||||
<Suspense fallback={null}>
|
||||
<Breadcrumb />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/ui-elements/buttons" element={
|
||||
<Suspense fallback={null}>
|
||||
<Buttons />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/ui-elements/buttons-group" element={
|
||||
<Suspense fallback={null}>
|
||||
<ButtonsGroup />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/ui-elements/cards" element={
|
||||
<Suspense fallback={null}>
|
||||
<Cards />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/ui-elements/carousel" element={
|
||||
<Suspense fallback={null}>
|
||||
<Carousel />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/ui-elements/dropdowns" element={
|
||||
<Suspense fallback={null}>
|
||||
<Dropdowns />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/ui-elements/images" element={
|
||||
<Suspense fallback={null}>
|
||||
<ImagesUI />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/ui-elements/links" element={
|
||||
<Suspense fallback={null}>
|
||||
<Links />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/ui-elements/list" element={
|
||||
<Suspense fallback={null}>
|
||||
<List />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/ui-elements/modals" element={
|
||||
<Suspense fallback={null}>
|
||||
<Modals />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/ui-elements/notifications" element={
|
||||
<Suspense fallback={null}>
|
||||
<Notifications />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/ui-elements/pagination" element={
|
||||
<Suspense fallback={null}>
|
||||
<Pagination />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/ui-elements/popovers" element={
|
||||
<Suspense fallback={null}>
|
||||
<Popovers />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/ui-elements/pricing-table" element={
|
||||
<Suspense fallback={null}>
|
||||
<PricingTable />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/ui-elements/progressbar" element={
|
||||
<Suspense fallback={null}>
|
||||
<Progressbar />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/ui-elements/ribbons" element={
|
||||
<Suspense fallback={null}>
|
||||
<Ribbons />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/ui-elements/spinners" element={
|
||||
<Suspense fallback={null}>
|
||||
<Spinners />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/ui-elements/tabs" element={
|
||||
<Suspense fallback={null}>
|
||||
<Tabs />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/ui-elements/tooltips" element={
|
||||
<Suspense fallback={null}>
|
||||
<Tooltips />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/ui-elements/videos" element={
|
||||
<Suspense fallback={null}>
|
||||
<Videos />
|
||||
</Suspense>
|
||||
} />
|
||||
|
||||
{/* Components (Showcase Page) */}
|
||||
<Route path="/components" element={<Components />} />
|
||||
<Route path="/components" element={
|
||||
<Suspense fallback={null}>
|
||||
<Components />
|
||||
</Suspense>
|
||||
} />
|
||||
|
||||
{/* Redirect old notification route */}
|
||||
<Route path="/notifications" element={<Notifications />} />
|
||||
<Route path="/notifications" element={
|
||||
<Suspense fallback={null}>
|
||||
<Notifications />
|
||||
</Suspense>
|
||||
} />
|
||||
</Route>
|
||||
|
||||
{/* Fallback Route */}
|
||||
|
||||
76
frontend/src/components/common/PageTransition.tsx
Normal file
76
frontend/src/components/common/PageTransition.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { ReactNode, useEffect, useState } from 'react';
|
||||
import { useLocation } from 'react-router';
|
||||
|
||||
interface PageTransitionProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Smooth page transition wrapper with modern loading indicator
|
||||
* Provides seamless transitions between pages without feeling like a page load
|
||||
* Uses subtle fade effects and minimal loading indicator
|
||||
*/
|
||||
export default function PageTransition({ children }: PageTransitionProps) {
|
||||
const location = useLocation();
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
const [displayChildren, setDisplayChildren] = useState(children);
|
||||
const [currentPath, setCurrentPath] = useState(location.pathname);
|
||||
|
||||
useEffect(() => {
|
||||
// Only show transition if pathname actually changed
|
||||
if (location.pathname === currentPath) {
|
||||
setDisplayChildren(children);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start transition with minimal delay
|
||||
setIsTransitioning(true);
|
||||
setCurrentPath(location.pathname);
|
||||
|
||||
// Quick fade-out, then swap content
|
||||
const fadeOutTimer = setTimeout(() => {
|
||||
setDisplayChildren(children);
|
||||
}, 100);
|
||||
|
||||
// Complete transition quickly for smooth feel
|
||||
const fadeInTimer = setTimeout(() => {
|
||||
setIsTransitioning(false);
|
||||
}, 200);
|
||||
|
||||
return () => {
|
||||
clearTimeout(fadeOutTimer);
|
||||
clearTimeout(fadeInTimer);
|
||||
};
|
||||
}, [location.pathname, children, currentPath]);
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen">
|
||||
{/* Subtle fade overlay - very light */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-white/50 dark:bg-gray-900/50 backdrop-blur-sm transition-opacity duration-200 z-40 pointer-events-none ${
|
||||
isTransitioning ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Minimal loading indicator - only shows briefly */}
|
||||
{isTransitioning && (
|
||||
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50 pointer-events-none">
|
||||
<div className="relative w-10 h-10">
|
||||
<div className="absolute inset-0 border-2 border-gray-200/50 dark:border-gray-700/50 rounded-full"></div>
|
||||
<div className="absolute inset-0 border-2 border-transparent border-t-brand-500 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Page content with smooth fade */}
|
||||
<div
|
||||
className={`transition-opacity duration-200 ease-in-out ${
|
||||
isTransitioning ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
>
|
||||
{displayChildren}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Modal } from '../ui/modal';
|
||||
import { ProgressBar } from '../ui/progress';
|
||||
import Button from '../ui/button/Button';
|
||||
@@ -34,13 +34,19 @@ export default function ProgressModal({
|
||||
}: ProgressModalProps) {
|
||||
// Auto-close on completion after 2 seconds
|
||||
// Don't auto-close on error - let user manually close to see error details
|
||||
const hasAutoClosedRef = React.useRef(false);
|
||||
useEffect(() => {
|
||||
if (status === 'completed' && onClose) {
|
||||
if (status === 'completed' && onClose && !hasAutoClosedRef.current) {
|
||||
hasAutoClosedRef.current = true;
|
||||
const timer = setTimeout(() => {
|
||||
onClose();
|
||||
}, 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
// Reset when status changes away from completed
|
||||
if (status !== 'completed') {
|
||||
hasAutoClosedRef.current = false;
|
||||
}
|
||||
// Don't auto-close on error - user should manually dismiss
|
||||
}, [status, onClose]);
|
||||
|
||||
|
||||
@@ -93,8 +93,9 @@ export default function ResourceDebugOverlay({ enabled }: ResourceDebugOverlayPr
|
||||
const requestId = response.headers.get('X-Resource-Tracking-ID');
|
||||
if (requestId) {
|
||||
requestIdRef.current = requestId;
|
||||
// Fetch metrics after a short delay to ensure backend has stored them
|
||||
setTimeout(() => fetchRequestMetrics(requestId), 200);
|
||||
// Fetch metrics after a delay to ensure backend has stored them
|
||||
// Use a slightly longer delay to avoid race conditions
|
||||
setTimeout(() => fetchRequestMetrics(requestId), 300);
|
||||
}
|
||||
|
||||
return response;
|
||||
@@ -111,7 +112,7 @@ export default function ResourceDebugOverlay({ enabled }: ResourceDebugOverlayPr
|
||||
}, [enabled, isAdminOrDeveloper]);
|
||||
|
||||
// Fetch metrics for a request - use fetchAPI to get proper authentication handling
|
||||
const fetchRequestMetrics = async (requestId: string) => {
|
||||
const fetchRequestMetrics = async (requestId: string, retryCount = 0) => {
|
||||
try {
|
||||
// Use fetchAPI which handles token refresh and authentication properly
|
||||
// But we need to use native fetch to avoid interception loop
|
||||
@@ -135,7 +136,10 @@ export default function ResourceDebugOverlay({ enabled }: ResourceDebugOverlayPr
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log('Fetched metrics for request:', requestId, data); // Debug log
|
||||
// Only log in debug mode to reduce console noise
|
||||
if (import.meta.env.DEV) {
|
||||
console.debug('Fetched metrics for request:', requestId, data);
|
||||
}
|
||||
metricsRef.current = [...metricsRef.current, data];
|
||||
setMetrics([...metricsRef.current]);
|
||||
} else if (response.status === 401) {
|
||||
@@ -166,11 +170,19 @@ export default function ResourceDebugOverlay({ enabled }: ResourceDebugOverlayPr
|
||||
}
|
||||
// Silently ignore 401 errors - user might not be authenticated
|
||||
} else if (response.status === 404) {
|
||||
// Metrics not found or expired - this is expected, silently ignore
|
||||
// Metrics expire after 5 minutes, so 404 is normal for older requests
|
||||
// Metrics not found - could be race condition, retry once after short delay
|
||||
if (retryCount === 0) {
|
||||
// First attempt failed, retry once after 200ms (middleware might still be storing)
|
||||
setTimeout(() => fetchRequestMetrics(requestId, 1), 200);
|
||||
return;
|
||||
}
|
||||
// Second attempt also failed - metrics truly not available
|
||||
// This is expected: metrics expired (5min TTL), request wasn't tracked, or middleware error
|
||||
// Silently ignore - no need to log or show error
|
||||
return;
|
||||
} else {
|
||||
console.warn('Failed to fetch metrics:', response.status, response.statusText);
|
||||
// Only log non-404/401 errors (500, 403, etc.)
|
||||
console.warn('Failed to fetch metrics:', response.status, response.statusText, 'for request:', requestId);
|
||||
}
|
||||
} catch (error) {
|
||||
// Only log non-network errors
|
||||
|
||||
@@ -44,6 +44,7 @@ export function useProgressModal(): UseProgressModalReturn {
|
||||
// Step mapping with user-friendly messages and percentages
|
||||
const getStepInfo = (stepName: string, message: string = '', allSteps: any[] = []): { percentage: number; friendlyMessage: string } => {
|
||||
const stepUpper = stepName?.toUpperCase() || '';
|
||||
const messageLower = message.toLowerCase();
|
||||
|
||||
// Extract values from message and step messages
|
||||
const extractNumber = (pattern: RegExp, text: string): string => {
|
||||
@@ -54,11 +55,13 @@ export function useProgressModal(): UseProgressModalReturn {
|
||||
// Check message first, then all step messages
|
||||
let keywordCount = extractNumber(/(\d+)\s+keyword/i, message);
|
||||
let clusterCount = extractNumber(/(\d+)\s+cluster/i, message);
|
||||
let taskCount = extractNumber(/(\d+)\s+task/i, message);
|
||||
let itemCount = extractNumber(/(\d+)\s+item/i, message);
|
||||
|
||||
// Also check for "Loaded X items" or "Created X clusters" patterns
|
||||
if (!keywordCount) {
|
||||
if (!keywordCount && !taskCount && !itemCount) {
|
||||
const loadedMatch = extractNumber(/loaded\s+(\d+)\s+items?/i, message);
|
||||
if (loadedMatch) keywordCount = loadedMatch;
|
||||
if (loadedMatch) itemCount = loadedMatch;
|
||||
}
|
||||
if (!clusterCount) {
|
||||
const createdMatch = extractNumber(/created\s+(\d+)\s+clusters?/i, message);
|
||||
@@ -66,50 +69,106 @@ export function useProgressModal(): UseProgressModalReturn {
|
||||
}
|
||||
|
||||
// Check all steps if not found in message
|
||||
if (!keywordCount || !clusterCount) {
|
||||
if (!keywordCount && !taskCount && !itemCount) {
|
||||
for (const step of allSteps) {
|
||||
const stepMsg = step.message || '';
|
||||
if (!keywordCount) {
|
||||
keywordCount = extractNumber(/(\d+)\s+keyword/i, stepMsg) || extractNumber(/loaded\s+(\d+)\s+items?/i, stepMsg);
|
||||
keywordCount = extractNumber(/(\d+)\s+keyword/i, stepMsg);
|
||||
}
|
||||
if (!taskCount) {
|
||||
taskCount = extractNumber(/(\d+)\s+task/i, stepMsg);
|
||||
}
|
||||
if (!itemCount) {
|
||||
itemCount = extractNumber(/loaded\s+(\d+)\s+items?/i, stepMsg);
|
||||
}
|
||||
if (!clusterCount) {
|
||||
clusterCount = extractNumber(/(\d+)\s+cluster/i, stepMsg) || extractNumber(/created\s+(\d+)\s+clusters?/i, stepMsg);
|
||||
}
|
||||
if (keywordCount && clusterCount) break;
|
||||
if ((keywordCount || taskCount || itemCount) && clusterCount) break;
|
||||
}
|
||||
}
|
||||
|
||||
const finalKeywordCount = keywordCount;
|
||||
const finalClusterCount = clusterCount;
|
||||
const finalTaskCount = taskCount || itemCount;
|
||||
|
||||
// Determine function type from message/title context
|
||||
const isContentGeneration = messageLower.includes('content') || messageLower.includes('generating content') || messageLower.includes('article');
|
||||
const isClustering = messageLower.includes('cluster') && !messageLower.includes('content');
|
||||
const isIdeas = messageLower.includes('idea');
|
||||
|
||||
// Map steps to percentages and user-friendly messages
|
||||
if (stepUpper.includes('INIT') || stepUpper.includes('INITIALIZ')) {
|
||||
return { percentage: 0, friendlyMessage: 'Getting started...' };
|
||||
}
|
||||
if (stepUpper.includes('PREP') || stepUpper.includes('PREPAR')) {
|
||||
const msg = finalKeywordCount ? `Preparing ${finalKeywordCount} keyword${finalKeywordCount !== '1' ? 's' : ''}...` : 'Preparing your keywords...';
|
||||
return { percentage: 16, friendlyMessage: msg };
|
||||
if (isContentGeneration) {
|
||||
const msg = finalTaskCount ? `Preparing ${finalTaskCount} task${finalTaskCount !== '1' ? 's' : ''}...` : 'Preparing content generation...';
|
||||
return { percentage: 10, friendlyMessage: msg };
|
||||
} else if (isClustering) {
|
||||
const msg = finalKeywordCount ? `Preparing ${finalKeywordCount} keyword${finalKeywordCount !== '1' ? 's' : ''}...` : 'Preparing your keywords...';
|
||||
return { percentage: 16, friendlyMessage: msg };
|
||||
} else if (isIdeas) {
|
||||
const msg = finalClusterCount ? `Preparing ${finalClusterCount} cluster${finalClusterCount !== '1' ? 's' : ''}...` : 'Preparing clusters...';
|
||||
return { percentage: 10, friendlyMessage: msg };
|
||||
}
|
||||
return { percentage: 10, friendlyMessage: 'Preparing...' };
|
||||
}
|
||||
if (stepUpper.includes('AI_CALL') || stepUpper.includes('CALLING')) {
|
||||
return { percentage: 50, friendlyMessage: 'Finding related keywords...' };
|
||||
if (isContentGeneration) {
|
||||
return { percentage: 50, friendlyMessage: 'Generating content with AI...' };
|
||||
} else if (isClustering) {
|
||||
return { percentage: 50, friendlyMessage: 'Finding related keywords...' };
|
||||
} else if (isIdeas) {
|
||||
return { percentage: 50, friendlyMessage: 'Generating ideas...' };
|
||||
}
|
||||
return { percentage: 50, friendlyMessage: 'Processing with AI...' };
|
||||
}
|
||||
if (stepUpper.includes('PARSE') || stepUpper.includes('PARSING')) {
|
||||
return { percentage: 70, friendlyMessage: 'Organizing results...' };
|
||||
if (isContentGeneration) {
|
||||
return { percentage: 70, friendlyMessage: 'Processing content...' };
|
||||
} else if (isClustering) {
|
||||
return { percentage: 70, friendlyMessage: 'Organizing results...' };
|
||||
} else if (isIdeas) {
|
||||
return { percentage: 70, friendlyMessage: 'Processing ideas...' };
|
||||
}
|
||||
return { percentage: 70, friendlyMessage: 'Processing results...' };
|
||||
}
|
||||
if (stepUpper.includes('SAVE') || stepUpper.includes('SAVING') || stepUpper.includes('CREAT')) {
|
||||
const msg = finalClusterCount ? `Saving ${finalClusterCount} cluster${finalClusterCount !== '1' ? 's' : ''}...` : 'Saving clusters...';
|
||||
return { percentage: 85, friendlyMessage: msg };
|
||||
if (stepUpper.includes('SAVE') || stepUpper.includes('SAVING') || (stepUpper.includes('CREAT') && !stepUpper.includes('CONTENT'))) {
|
||||
if (isContentGeneration) {
|
||||
const msg = finalTaskCount ? `Saving content for ${finalTaskCount} task${finalTaskCount !== '1' ? 's' : ''}...` : 'Saving content...';
|
||||
return { percentage: 85, friendlyMessage: msg };
|
||||
} else if (isClustering) {
|
||||
const msg = finalClusterCount ? `Saving ${finalClusterCount} cluster${finalClusterCount !== '1' ? 's' : ''}...` : 'Saving clusters...';
|
||||
return { percentage: 85, friendlyMessage: msg };
|
||||
} else if (isIdeas) {
|
||||
const msg = finalTaskCount ? `Saving ${finalTaskCount} idea${finalTaskCount !== '1' ? 's' : ''}...` : 'Saving ideas...';
|
||||
return { percentage: 85, friendlyMessage: msg };
|
||||
}
|
||||
return { percentage: 85, friendlyMessage: 'Saving results...' };
|
||||
}
|
||||
if (stepUpper.includes('DONE') || stepUpper.includes('COMPLETE')) {
|
||||
// Use extracted counts for completion message
|
||||
const finalMsg = finalKeywordCount && finalClusterCount
|
||||
? `Done! Created ${finalClusterCount} cluster${finalClusterCount !== '1' ? 's' : ''} from ${finalKeywordCount} keyword${finalKeywordCount !== '1' ? 's' : ''}`
|
||||
: finalKeywordCount
|
||||
? `Done! Processed ${finalKeywordCount} keyword${finalKeywordCount !== '1' ? 's' : ''}`
|
||||
: finalClusterCount
|
||||
? `Done! Created ${finalClusterCount} cluster${finalClusterCount !== '1' ? 's' : ''}`
|
||||
: 'Done! Clustering complete';
|
||||
return { percentage: 100, friendlyMessage: finalMsg };
|
||||
if (isContentGeneration) {
|
||||
const finalMsg = finalTaskCount
|
||||
? `Done! Generated content for ${finalTaskCount} task${finalTaskCount !== '1' ? 's' : ''}`
|
||||
: 'Done! Content generation complete';
|
||||
return { percentage: 100, friendlyMessage: finalMsg };
|
||||
} else if (isClustering) {
|
||||
const finalMsg = finalKeywordCount && finalClusterCount
|
||||
? `Done! Created ${finalClusterCount} cluster${finalClusterCount !== '1' ? 's' : ''} from ${finalKeywordCount} keyword${finalKeywordCount !== '1' ? 's' : ''}`
|
||||
: finalKeywordCount
|
||||
? `Done! Processed ${finalKeywordCount} keyword${finalKeywordCount !== '1' ? 's' : ''}`
|
||||
: finalClusterCount
|
||||
? `Done! Created ${finalClusterCount} cluster${finalClusterCount !== '1' ? 's' : ''}`
|
||||
: 'Done! Clustering complete';
|
||||
return { percentage: 100, friendlyMessage: finalMsg };
|
||||
} else if (isIdeas) {
|
||||
const finalMsg = finalTaskCount
|
||||
? `Done! Generated ${finalTaskCount} idea${finalTaskCount !== '1' ? 's' : ''}`
|
||||
: 'Done! Ideas generation complete';
|
||||
return { percentage: 100, friendlyMessage: finalMsg };
|
||||
}
|
||||
return { percentage: 100, friendlyMessage: 'Done! Task complete' };
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
@@ -200,7 +259,7 @@ export function useProgressModal(): UseProgressModalReturn {
|
||||
currentStep = 'AI_CALL';
|
||||
} else if (currentPhase.includes('pars') || currentMessage.includes('pars') || currentMessage.includes('organizing')) {
|
||||
currentStep = 'PARSE';
|
||||
} else if (currentPhase.includes('sav') || currentPhase.includes('creat') || currentMessage.includes('sav') || currentMessage.includes('creat') || currentMessage.includes('cluster')) {
|
||||
} else if (currentPhase.includes('sav') || currentPhase.includes('creat') || currentMessage.includes('sav') || currentMessage.includes('creat') || (currentMessage.includes('cluster') && !currentMessage.includes('content'))) {
|
||||
currentStep = 'SAVE';
|
||||
} else if (currentPhase.includes('done') || currentPhase.includes('complet') || currentMessage.includes('done') || currentMessage.includes('complet')) {
|
||||
currentStep = 'DONE';
|
||||
@@ -209,39 +268,64 @@ export function useProgressModal(): UseProgressModalReturn {
|
||||
|
||||
// Get step info with user-friendly message (use original message and all steps for value extraction)
|
||||
const originalMessage = meta.message || '';
|
||||
const stepInfo = getStepInfo(currentStep || '', originalMessage, allSteps);
|
||||
// Include title in message for better function type detection
|
||||
const messageWithContext = `${title} ${originalMessage}`;
|
||||
const stepInfo = getStepInfo(currentStep || '', messageWithContext, allSteps);
|
||||
const targetPercentage = stepInfo.percentage;
|
||||
const friendlyMessage = stepInfo.friendlyMessage;
|
||||
|
||||
// Check if we're transitioning to a new step
|
||||
const isNewStep = currentStepRef.current !== currentStep;
|
||||
const currentDisplayedPercentage = displayedPercentageRef.current;
|
||||
|
||||
// Clear any existing transition timeout
|
||||
// Clear any existing transition timeout or animation interval
|
||||
if (stepTransitionTimeoutRef.current) {
|
||||
clearTimeout(stepTransitionTimeoutRef.current);
|
||||
stepTransitionTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// If it's a new step, add 500ms delay before updating
|
||||
if (isNewStep && currentStepRef.current !== null) {
|
||||
stepTransitionTimeoutRef.current = setTimeout(() => {
|
||||
// Smooth progress animation: increment by 1% every 300ms until reaching target
|
||||
if (targetPercentage > currentDisplayedPercentage) {
|
||||
// Start smooth animation
|
||||
let animatedPercentage = currentDisplayedPercentage;
|
||||
const animateProgress = () => {
|
||||
if (animatedPercentage < targetPercentage) {
|
||||
animatedPercentage = Math.min(animatedPercentage + 1, targetPercentage);
|
||||
displayedPercentageRef.current = animatedPercentage;
|
||||
setProgress({
|
||||
percentage: animatedPercentage,
|
||||
message: friendlyMessage,
|
||||
status: 'processing',
|
||||
details: {
|
||||
current: meta.current || 0,
|
||||
total: meta.total || 0,
|
||||
completed: meta.completed || 0,
|
||||
currentItem: meta.current_item,
|
||||
phase: meta.phase,
|
||||
},
|
||||
});
|
||||
|
||||
if (animatedPercentage < targetPercentage) {
|
||||
stepTransitionTimeoutRef.current = setTimeout(animateProgress, 300);
|
||||
} else {
|
||||
stepTransitionTimeoutRef.current = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// If it's a new step, add 500ms delay before starting animation
|
||||
if (isNewStep && currentStepRef.current !== null) {
|
||||
stepTransitionTimeoutRef.current = setTimeout(() => {
|
||||
currentStepRef.current = currentStep;
|
||||
animateProgress();
|
||||
}, 500);
|
||||
} else {
|
||||
// Same step or first step - start animation immediately
|
||||
currentStepRef.current = currentStep;
|
||||
displayedPercentageRef.current = targetPercentage;
|
||||
setProgress({
|
||||
percentage: targetPercentage,
|
||||
message: friendlyMessage,
|
||||
status: 'processing',
|
||||
details: {
|
||||
current: meta.current || 0,
|
||||
total: meta.total || 0,
|
||||
completed: meta.completed || 0,
|
||||
currentItem: meta.current_item,
|
||||
phase: meta.phase,
|
||||
},
|
||||
});
|
||||
}, 500);
|
||||
animateProgress();
|
||||
}
|
||||
} else {
|
||||
// Same step or first step - update immediately
|
||||
// Percentage decreased or same - update immediately (shouldn't happen normally)
|
||||
currentStepRef.current = currentStep;
|
||||
displayedPercentageRef.current = targetPercentage;
|
||||
setProgress({
|
||||
@@ -434,11 +518,14 @@ export function useProgressModal(): UseProgressModalReturn {
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
}
|
||||
// Clear step transition timeout on cleanup
|
||||
// Clear step transition timeout and animation on cleanup
|
||||
if (stepTransitionTimeoutRef.current) {
|
||||
clearTimeout(stepTransitionTimeoutRef.current);
|
||||
stepTransitionTimeoutRef.current = null;
|
||||
}
|
||||
// Reset displayed percentage
|
||||
displayedPercentageRef.current = 0;
|
||||
currentStepRef.current = null;
|
||||
};
|
||||
}, [taskId, isOpen]);
|
||||
|
||||
|
||||
102
frontend/src/icons/lazy.ts
Normal file
102
frontend/src/icons/lazy.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Lazy Icon Loader
|
||||
*
|
||||
* This module provides lazy-loaded icons to reduce initial bundle size.
|
||||
* Icons are only loaded when actually used, not on initial page load.
|
||||
*
|
||||
* Usage:
|
||||
* import { lazyIcon } from '@/icons/lazy';
|
||||
* const PlusIcon = lazyIcon('plus');
|
||||
*/
|
||||
|
||||
import React, { lazy, ComponentType } from 'react';
|
||||
|
||||
// Icon name to component mapping
|
||||
const iconMap: Record<string, () => Promise<{ default: ComponentType<any> }>> = {
|
||||
'plus': () => import('./plus.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'close': () => import('./close.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'box': () => import('./box.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'check-circle': () => import('./check-circle.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'alert': () => import('./alert.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'info': () => import('./info.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'error': () => import('./info-error.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'bolt': () => import('./bolt.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'arrow-up': () => import('./arrow-up.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'arrow-down': () => import('./arrow-down.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'folder': () => import('./folder.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'videos': () => import('./videos.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'audio': () => import('./audio.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'grid': () => import('./grid.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'file': () => import('./file.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'download': () => import('./download.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'arrow-right': () => import('./arrow-right.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'group': () => import('./group.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'box-line': () => import('./box-line.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'shooting-star': () => import('./shooting-star.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'dollar-line': () => import('./dollar-line.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'trash': () => import('./trash.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'angle-up': () => import('./angle-up.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'angle-down': () => import('./angle-down.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'angle-left': () => import('./angle-left.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'angle-right': () => import('./angle-right.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'pencil': () => import('./pencil.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'check-line': () => import('./check-line.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'close-line': () => import('./close-line.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'chevron-down': () => import('./chevron-down.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'chevron-up': () => import('./chevron-up.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'paper-plane': () => import('./paper-plane.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'lock': () => import('./lock.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'envelope': () => import('./envelope.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'user-line': () => import('./user-line.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'calender-line': () => import('./calender-line.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'eye': () => import('./eye.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'eye-close': () => import('./eye-close.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'time': () => import('./time.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'copy': () => import('./copy.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'chevron-left': () => import('./chevron-left.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'user-circle': () => import('./user-circle.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'task-icon': () => import('./task-icon.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'list': () => import('./list.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'table': () => import('./table.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'page': () => import('./page.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'pie-chart': () => import('./pie-chart.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'box-cube': () => import('./box-cube.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'plug-in': () => import('./plug-in.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'docs': () => import('./docs.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'mail-line': () => import('./mail-line.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'horizontal-dots': () => import('./horizontal-dots.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'chat': () => import('./chat.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'moredot': () => import('./moredot.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'alert-hexa': () => import('./alert-hexa.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
'info-hexa': () => import('./info-hexa.svg?react').then(m => ({ default: m.ReactComponent })),
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a lazy-loaded icon component
|
||||
* @param iconName - Name of the icon (without .svg extension)
|
||||
* @returns Lazy-loaded React component
|
||||
*/
|
||||
export function lazyIcon(iconName: string): ComponentType<any> {
|
||||
const loader = iconMap[iconName];
|
||||
if (!loader) {
|
||||
console.warn(`Icon "${iconName}" not found. Available icons: ${Object.keys(iconMap).join(', ')}`);
|
||||
// Return a placeholder component
|
||||
return () => React.createElement('span', null, '?');
|
||||
}
|
||||
return lazy(loader);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload commonly used icons (optional optimization)
|
||||
* Call this early in your app to preload icons that are used on every page
|
||||
*/
|
||||
export function preloadCommonIcons() {
|
||||
const commonIcons = ['plus', 'close', 'chevron-down', 'chevron-up', 'chevron-left', 'chevron-right'];
|
||||
commonIcons.forEach(iconName => {
|
||||
const loader = iconMap[iconName];
|
||||
if (loader) {
|
||||
loader(); // Start loading but don't await
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -32,14 +32,15 @@ const LayoutContent: React.FC = () => {
|
||||
trackLoading('site-loading', true);
|
||||
|
||||
// Add timeout to prevent infinite loading
|
||||
// Match API timeout (30s) + buffer for network delays
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (isLoadingSite.current) {
|
||||
console.error('AppLayout: Site loading timeout after 10 seconds');
|
||||
console.error('AppLayout: Site loading timeout after 35 seconds');
|
||||
trackLoading('site-loading', false);
|
||||
isLoadingSite.current = false;
|
||||
addError(new Error('Site loading timeout - check network connection'), 'AppLayout.loadActiveSite');
|
||||
}
|
||||
}, 10000);
|
||||
}, 35000); // 35 seconds to match API timeout (30s) + buffer
|
||||
|
||||
loadActiveSite()
|
||||
.catch((error) => {
|
||||
@@ -69,14 +70,15 @@ const LayoutContent: React.FC = () => {
|
||||
trackLoading('sector-loading', true);
|
||||
|
||||
// Add timeout to prevent infinite loading
|
||||
// Match API timeout (30s) + buffer for network delays
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (isLoadingSector.current) {
|
||||
console.error('AppLayout: Sector loading timeout after 10 seconds');
|
||||
console.error('AppLayout: Sector loading timeout after 35 seconds');
|
||||
trackLoading('sector-loading', false);
|
||||
isLoadingSector.current = false;
|
||||
addError(new Error('Sector loading timeout - check network connection'), 'AppLayout.loadSectorsForSite');
|
||||
}
|
||||
}, 10000);
|
||||
}, 35000); // 35 seconds to match API timeout (30s) + buffer
|
||||
|
||||
loadSectorsForSite(currentSiteId)
|
||||
.catch((error) => {
|
||||
@@ -99,27 +101,42 @@ const LayoutContent: React.FC = () => {
|
||||
}
|
||||
}, [activeSite?.id, activeSite?.is_active]); // Depend on both ID and is_active
|
||||
|
||||
// Refresh user data on mount and periodically to get latest account/plan changes
|
||||
// Refresh user data on mount and when app version changes (after code updates)
|
||||
// This ensures changes are reflected immediately without requiring re-login
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
const refreshUserData = async () => {
|
||||
const APP_VERSION = import.meta.env.VITE_APP_VERSION || '2.0.2';
|
||||
const VERSION_STORAGE_KEY = 'igny8-app-version';
|
||||
|
||||
const refreshUserData = async (force = false) => {
|
||||
const now = Date.now();
|
||||
// Throttle: only refresh if last refresh was more than 30 seconds ago
|
||||
if (now - lastUserRefresh.current < 30000) return;
|
||||
// Throttle: only refresh if last refresh was more than 30 seconds ago (unless forced)
|
||||
if (!force && now - lastUserRefresh.current < 30000) return;
|
||||
|
||||
try {
|
||||
lastUserRefresh.current = now;
|
||||
await refreshUser();
|
||||
|
||||
// Store current version after successful refresh
|
||||
if (force) {
|
||||
localStorage.setItem(VERSION_STORAGE_KEY, APP_VERSION);
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently fail - user might still be authenticated
|
||||
console.debug('User data refresh failed (non-critical):', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Refresh on mount
|
||||
refreshUserData();
|
||||
// Check if app version changed (indicates code update)
|
||||
const storedVersion = localStorage.getItem(VERSION_STORAGE_KEY);
|
||||
if (storedVersion !== APP_VERSION) {
|
||||
// Force refresh on version change
|
||||
refreshUserData(true);
|
||||
} else {
|
||||
// Normal refresh on mount
|
||||
refreshUserData();
|
||||
}
|
||||
|
||||
// Refresh when window becomes visible (user switches back to tab)
|
||||
const handleVisibilityChange = () => {
|
||||
@@ -134,7 +151,7 @@ const LayoutContent: React.FC = () => {
|
||||
};
|
||||
|
||||
// Periodic refresh every 2 minutes
|
||||
const intervalId = setInterval(refreshUserData, 120000);
|
||||
const intervalId = setInterval(() => refreshUserData(), 120000);
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.addEventListener('focus', handleFocus);
|
||||
|
||||
@@ -11,17 +11,16 @@ export default function Usage() {
|
||||
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 });
|
||||
const response = await fetchCreditUsage({ page: 1 });
|
||||
setUsageLogs(response.results || []);
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to load usage logs: ${error.message}`);
|
||||
@@ -48,23 +47,6 @@ export default function Usage() {
|
||||
}
|
||||
};
|
||||
|
||||
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'),
|
||||
@@ -87,14 +69,14 @@ export default function Usage() {
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageMeta title="Usage" />
|
||||
<PageMeta title="Usage" description="Monitor your plan limits and usage statistics" />
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Acoount Limit Usage Attemp 6</h1>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Acoount Limits & 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' && (
|
||||
{import.meta.env.DEV && (
|
||||
<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},
|
||||
|
||||
@@ -54,6 +54,21 @@ export default function Clusters() {
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
// AI Function logging state
|
||||
const [aiLogs, setAiLogs] = useState<Array<{
|
||||
timestamp: string;
|
||||
type: 'request' | 'success' | 'error' | 'step';
|
||||
action: string;
|
||||
data: any;
|
||||
stepName?: string;
|
||||
percentage?: number;
|
||||
}>>([]);
|
||||
|
||||
// Track last logged step to avoid duplicates
|
||||
const lastLoggedStepRef = useRef<string | null>(null);
|
||||
const lastLoggedPercentageRef = useRef<number>(-1);
|
||||
const hasReloadedRef = useRef<boolean>(false);
|
||||
|
||||
// Sorting state
|
||||
const [sortBy, setSortBy] = useState<string>('name');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||
@@ -201,20 +216,62 @@ export default function Clusters() {
|
||||
// Row action handler
|
||||
const handleRowAction = useCallback(async (action: string, row: Cluster) => {
|
||||
if (action === 'generate_ideas') {
|
||||
const requestData = {
|
||||
ids: [row.id],
|
||||
cluster_name: row.name,
|
||||
cluster_id: row.id,
|
||||
};
|
||||
|
||||
// Log request
|
||||
setAiLogs(prev => [...prev, {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'request',
|
||||
action: 'generate_ideas (Row Action)',
|
||||
data: requestData,
|
||||
}]);
|
||||
|
||||
try {
|
||||
const result = await autoGenerateIdeas([row.id]);
|
||||
|
||||
if (result.success && result.task_id) {
|
||||
// Log success with task_id
|
||||
setAiLogs(prev => [...prev, {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'success',
|
||||
action: 'generate_ideas (Row Action)',
|
||||
data: { task_id: result.task_id, message: result.message },
|
||||
}]);
|
||||
// Async task - show progress modal
|
||||
progressModal.openModal(result.task_id, 'Generating Ideas');
|
||||
} else if (result.success && result.ideas_created) {
|
||||
// Log success with ideas_created
|
||||
setAiLogs(prev => [...prev, {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'success',
|
||||
action: 'generate_ideas (Row Action)',
|
||||
data: { ideas_created: result.ideas_created, message: result.message },
|
||||
}]);
|
||||
// Synchronous completion
|
||||
toast.success(result.message || 'Ideas generated successfully');
|
||||
await loadClusters();
|
||||
} else {
|
||||
// Log error
|
||||
setAiLogs(prev => [...prev, {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'error',
|
||||
action: 'generate_ideas (Row Action)',
|
||||
data: { error: result.error || 'Failed to generate ideas' },
|
||||
}]);
|
||||
toast.error(result.error || 'Failed to generate ideas');
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Log error
|
||||
setAiLogs(prev => [...prev, {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'error',
|
||||
action: 'generate_ideas (Row Action)',
|
||||
data: { error: error.message || 'Unknown error occurred' },
|
||||
}]);
|
||||
toast.error(`Failed to generate ideas: ${error.message}`);
|
||||
}
|
||||
}
|
||||
@@ -227,33 +284,192 @@ export default function Clusters() {
|
||||
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');
|
||||
if (ids.length > 10) {
|
||||
toast.error('Maximum 10 clusters allowed for idea generation');
|
||||
return;
|
||||
}
|
||||
|
||||
const numIds = ids.map(id => parseInt(id));
|
||||
const selectedClusters = clusters.filter(c => numIds.includes(c.id));
|
||||
const requestData = {
|
||||
ids: numIds,
|
||||
cluster_count: numIds.length,
|
||||
cluster_names: selectedClusters.map(c => c.name),
|
||||
};
|
||||
|
||||
// Log request
|
||||
setAiLogs(prev => [...prev, {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'request',
|
||||
action: 'auto_generate_ideas (Bulk Action)',
|
||||
data: requestData,
|
||||
}]);
|
||||
|
||||
try {
|
||||
const numIds = ids.map(id => parseInt(id));
|
||||
const result = await autoGenerateIdeas(numIds);
|
||||
if (result.success) {
|
||||
if (result.task_id) {
|
||||
// Log success with task_id
|
||||
setAiLogs(prev => [...prev, {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'success',
|
||||
action: 'auto_generate_ideas (Bulk Action)',
|
||||
data: { task_id: result.task_id, message: result.message, cluster_count: numIds.length },
|
||||
}]);
|
||||
// Async task - show progress modal
|
||||
progressModal.openModal(result.task_id, 'Generating Content Ideas');
|
||||
// Don't show toast - progress modal will show status
|
||||
} else {
|
||||
// Log success with ideas_created
|
||||
setAiLogs(prev => [...prev, {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'success',
|
||||
action: 'auto_generate_ideas (Bulk Action)',
|
||||
data: { ideas_created: result.ideas_created || 0, message: result.message, cluster_count: numIds.length },
|
||||
}]);
|
||||
// Synchronous completion
|
||||
toast.success(`Ideas generation complete: ${result.ideas_created || 0} ideas created`);
|
||||
await loadClusters();
|
||||
}
|
||||
} else {
|
||||
// Log error
|
||||
setAiLogs(prev => [...prev, {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'error',
|
||||
action: 'auto_generate_ideas (Bulk Action)',
|
||||
data: { error: result.error || 'Failed to generate ideas', cluster_count: numIds.length },
|
||||
}]);
|
||||
toast.error(result.error || 'Failed to generate ideas');
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Log error
|
||||
setAiLogs(prev => [...prev, {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'error',
|
||||
action: 'auto_generate_ideas (Bulk Action)',
|
||||
data: { error: error.message || 'Unknown error occurred', cluster_count: numIds.length },
|
||||
}]);
|
||||
toast.error(`Failed to generate ideas: ${error.message}`);
|
||||
}
|
||||
} else {
|
||||
toast.info(`Bulk action "${action}" for ${ids.length} items`);
|
||||
}
|
||||
}, [toast, loadClusters, progressModal]);
|
||||
}, [toast, loadClusters, progressModal, clusters]);
|
||||
|
||||
// Log AI function progress steps
|
||||
useEffect(() => {
|
||||
if (!progressModal.taskId || !progressModal.isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const progress = progressModal.progress;
|
||||
const currentStep = progress.details?.phase || '';
|
||||
const currentPercentage = progress.percentage;
|
||||
const currentMessage = progress.message;
|
||||
const currentStatus = progress.status;
|
||||
|
||||
// Log step changes
|
||||
if (currentStep && currentStep !== lastLoggedStepRef.current) {
|
||||
const stepType = currentStatus === 'error' ? 'error' :
|
||||
currentStatus === 'completed' ? 'success' : 'step';
|
||||
|
||||
setAiLogs(prev => [...prev, {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: stepType,
|
||||
action: progressModal.title || 'AI Function',
|
||||
stepName: currentStep,
|
||||
percentage: currentPercentage,
|
||||
data: {
|
||||
step: currentStep,
|
||||
message: currentMessage,
|
||||
percentage: currentPercentage,
|
||||
status: currentStatus,
|
||||
details: progress.details,
|
||||
},
|
||||
}]);
|
||||
|
||||
lastLoggedStepRef.current = currentStep;
|
||||
lastLoggedPercentageRef.current = currentPercentage;
|
||||
}
|
||||
// Log percentage changes for same step (if significant change)
|
||||
else if (currentStep && Math.abs(currentPercentage - lastLoggedPercentageRef.current) >= 10) {
|
||||
const stepType = currentStatus === 'error' ? 'error' :
|
||||
currentStatus === 'completed' ? 'success' : 'step';
|
||||
|
||||
setAiLogs(prev => [...prev, {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: stepType,
|
||||
action: progressModal.title || 'AI Function',
|
||||
stepName: currentStep,
|
||||
percentage: currentPercentage,
|
||||
data: {
|
||||
step: currentStep,
|
||||
message: currentMessage,
|
||||
percentage: currentPercentage,
|
||||
status: currentStatus,
|
||||
details: progress.details,
|
||||
},
|
||||
}]);
|
||||
|
||||
lastLoggedPercentageRef.current = currentPercentage;
|
||||
}
|
||||
// Log status changes (error, completed)
|
||||
else if (currentStatus === 'error' || currentStatus === 'completed') {
|
||||
// Only log if we haven't already logged this status for this step
|
||||
if (currentStep !== lastLoggedStepRef.current ||
|
||||
(currentStatus === 'error' && lastLoggedStepRef.current !== 'error') ||
|
||||
(currentStatus === 'completed' && lastLoggedStepRef.current !== 'completed')) {
|
||||
const stepType = currentStatus === 'error' ? 'error' : 'success';
|
||||
|
||||
setAiLogs(prev => [...prev, {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: stepType,
|
||||
action: progressModal.title || 'AI Function',
|
||||
stepName: currentStep || 'Final',
|
||||
percentage: currentPercentage,
|
||||
data: {
|
||||
step: currentStep || 'Final',
|
||||
message: currentMessage,
|
||||
percentage: currentPercentage,
|
||||
status: currentStatus,
|
||||
details: progress.details,
|
||||
},
|
||||
}]);
|
||||
|
||||
lastLoggedStepRef.current = currentStep || currentStatus;
|
||||
}
|
||||
}
|
||||
}, [progressModal.progress, progressModal.taskId, progressModal.isOpen, progressModal.title]);
|
||||
|
||||
// Reset step tracking when modal closes or opens
|
||||
useEffect(() => {
|
||||
if (!progressModal.isOpen) {
|
||||
lastLoggedStepRef.current = null;
|
||||
lastLoggedPercentageRef.current = -1;
|
||||
hasReloadedRef.current = false; // Reset reload flag when modal closes
|
||||
} else {
|
||||
// Reset reload flag when modal opens for a new task
|
||||
hasReloadedRef.current = false;
|
||||
}
|
||||
}, [progressModal.isOpen, progressModal.taskId]);
|
||||
|
||||
// Handle modal close - memoized to prevent repeated calls
|
||||
const handleProgressModalClose = useCallback(() => {
|
||||
const wasCompleted = progressModal.progress.status === 'completed';
|
||||
progressModal.closeModal();
|
||||
// Reload data after modal closes (if completed) - only once
|
||||
if (wasCompleted && !hasReloadedRef.current) {
|
||||
hasReloadedRef.current = true;
|
||||
// Use setTimeout to ensure modal is fully closed before reloading
|
||||
setTimeout(() => {
|
||||
loadClusters();
|
||||
// Reset the flag after a delay to allow for future reloads
|
||||
setTimeout(() => {
|
||||
hasReloadedRef.current = false;
|
||||
}, 1000);
|
||||
}, 100);
|
||||
}
|
||||
}, [progressModal.progress.status, progressModal.closeModal, loadClusters]);
|
||||
|
||||
// Close volume dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
@@ -455,16 +671,77 @@ export default function Clusters() {
|
||||
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();
|
||||
}
|
||||
}}
|
||||
onClose={handleProgressModalClose}
|
||||
/>
|
||||
|
||||
{/* AI Function Logs - Display below table */}
|
||||
{aiLogs.length > 0 && (
|
||||
<div className="mt-6 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
AI Function Logs
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setAiLogs([])}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
Clear Logs
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{aiLogs.slice().reverse().map((log, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`p-3 rounded border text-xs font-mono ${
|
||||
log.type === 'request'
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
|
||||
: log.type === 'success'
|
||||
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
|
||||
: log.type === 'error'
|
||||
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800'
|
||||
: 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`font-semibold ${
|
||||
log.type === 'request'
|
||||
? 'text-blue-700 dark:text-blue-300'
|
||||
: log.type === 'success'
|
||||
? 'text-green-700 dark:text-green-300'
|
||||
: log.type === 'error'
|
||||
? 'text-red-700 dark:text-red-300'
|
||||
: 'text-purple-700 dark:text-purple-300'
|
||||
}`}>
|
||||
[{log.type.toUpperCase()}]
|
||||
</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
{log.action}
|
||||
</span>
|
||||
{log.stepName && (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
|
||||
{log.stepName}
|
||||
</span>
|
||||
)}
|
||||
{log.percentage !== undefined && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{log.percentage}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
{new Date(log.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
<pre className="text-xs text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-words">
|
||||
{JSON.stringify(log.data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<FormModal
|
||||
isOpen={isModalOpen}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Link } from "react-router";
|
||||
import { Link, useNavigate } 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() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Mock data - will be replaced with API calls
|
||||
const stats = {
|
||||
keywords: 245,
|
||||
@@ -175,13 +177,17 @@ export default function PlannerDashboard() {
|
||||
</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()}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
navigate(step.path);
|
||||
}}
|
||||
className="mt-3 inline-block text-xs font-medium text-brand-500 hover:text-brand-600 cursor-pointer"
|
||||
>
|
||||
Start Now →
|
||||
</Link>
|
||||
</button>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@@ -89,6 +89,20 @@ export default function Keywords() {
|
||||
const progressModal = useProgressModal();
|
||||
const hasReloadedRef = useRef(false);
|
||||
|
||||
// AI Function Logs state
|
||||
const [aiLogs, setAiLogs] = useState<Array<{
|
||||
timestamp: string;
|
||||
type: 'request' | 'success' | 'error' | 'step';
|
||||
action: string;
|
||||
data: any;
|
||||
stepName?: string;
|
||||
percentage?: number;
|
||||
}>>([]);
|
||||
|
||||
// Track last logged step to avoid duplicates
|
||||
const lastLoggedStepRef = useRef<string | null>(null);
|
||||
const lastLoggedPercentageRef = useRef<number>(-1);
|
||||
|
||||
// Load sectors for active site using sector store
|
||||
useEffect(() => {
|
||||
if (activeSite) {
|
||||
@@ -334,26 +348,68 @@ export default function Keywords() {
|
||||
return;
|
||||
}
|
||||
|
||||
const numIds = ids.map(id => parseInt(id));
|
||||
const sectorId = activeSector?.id;
|
||||
const selectedKeywords = keywords.filter(k => numIds.includes(k.id));
|
||||
const requestData = {
|
||||
ids: numIds,
|
||||
keyword_count: numIds.length,
|
||||
keyword_names: selectedKeywords.map(k => k.keyword),
|
||||
sector_id: sectorId,
|
||||
};
|
||||
|
||||
// Log request
|
||||
setAiLogs(prev => [...prev, {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'request',
|
||||
action: 'auto_cluster (Bulk Action)',
|
||||
data: requestData,
|
||||
}]);
|
||||
|
||||
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';
|
||||
// Log error
|
||||
setAiLogs(prev => [...prev, {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'error',
|
||||
action: 'auto_cluster (Bulk Action)',
|
||||
data: { error: errorMsg, keyword_count: numIds.length },
|
||||
}]);
|
||||
toast.error(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result && result.success) {
|
||||
if (result.task_id) {
|
||||
// Log success with task_id
|
||||
setAiLogs(prev => [...prev, {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'success',
|
||||
action: 'auto_cluster (Bulk Action)',
|
||||
data: { task_id: result.task_id, message: result.message, keyword_count: numIds.length },
|
||||
}]);
|
||||
// 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 {
|
||||
// Log success with results
|
||||
setAiLogs(prev => [...prev, {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'success',
|
||||
action: 'auto_cluster (Bulk Action)',
|
||||
data: {
|
||||
clusters_created: result.clusters_created || 0,
|
||||
keywords_updated: result.keywords_updated || 0,
|
||||
keyword_count: numIds.length,
|
||||
message: result.message,
|
||||
},
|
||||
}]);
|
||||
// Synchronous completion
|
||||
toast.success(`Clustering complete: ${result.clusters_created || 0} clusters created, ${result.keywords_updated || 0} keywords updated`);
|
||||
if (!hasReloadedRef.current) {
|
||||
@@ -364,6 +420,13 @@ export default function Keywords() {
|
||||
} else {
|
||||
// Unexpected response format - show error
|
||||
const errorMsg = result?.error || 'Unexpected response format';
|
||||
// Log error
|
||||
setAiLogs(prev => [...prev, {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'error',
|
||||
action: 'auto_cluster (Bulk Action)',
|
||||
data: { error: errorMsg, keyword_count: numIds.length },
|
||||
}]);
|
||||
toast.error(errorMsg);
|
||||
}
|
||||
} catch (error: any) {
|
||||
@@ -376,12 +439,116 @@ export default function Keywords() {
|
||||
errorMsg = error.message;
|
||||
}
|
||||
}
|
||||
// Log error
|
||||
setAiLogs(prev => [...prev, {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'error',
|
||||
action: 'auto_cluster (Bulk Action)',
|
||||
data: { error: errorMsg, keyword_count: numIds.length },
|
||||
}]);
|
||||
toast.error(errorMsg);
|
||||
}
|
||||
} else {
|
||||
toast.info(`Bulk action "${action}" for ${ids.length} items`);
|
||||
}
|
||||
}, [toast, activeSector, loadKeywords, progressModal]);
|
||||
}, [toast, activeSector, loadKeywords, progressModal, keywords]);
|
||||
|
||||
// Log AI function progress steps
|
||||
useEffect(() => {
|
||||
if (!progressModal.taskId || !progressModal.isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const progress = progressModal.progress;
|
||||
const currentStep = progress.details?.phase || '';
|
||||
const currentPercentage = progress.percentage;
|
||||
const currentMessage = progress.message;
|
||||
const currentStatus = progress.status;
|
||||
|
||||
// Log step changes
|
||||
if (currentStep && currentStep !== lastLoggedStepRef.current) {
|
||||
const stepType = currentStatus === 'error' ? 'error' :
|
||||
currentStatus === 'completed' ? 'success' : 'step';
|
||||
|
||||
setAiLogs(prev => [...prev, {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: stepType,
|
||||
action: progressModal.title || 'AI Function',
|
||||
stepName: currentStep,
|
||||
percentage: currentPercentage,
|
||||
data: {
|
||||
step: currentStep,
|
||||
message: currentMessage,
|
||||
percentage: currentPercentage,
|
||||
status: currentStatus,
|
||||
details: progress.details,
|
||||
},
|
||||
}]);
|
||||
|
||||
lastLoggedStepRef.current = currentStep;
|
||||
lastLoggedPercentageRef.current = currentPercentage;
|
||||
}
|
||||
// Log percentage changes for same step (if significant change)
|
||||
else if (currentStep && Math.abs(currentPercentage - lastLoggedPercentageRef.current) >= 10) {
|
||||
const stepType = currentStatus === 'error' ? 'error' :
|
||||
currentStatus === 'completed' ? 'success' : 'step';
|
||||
|
||||
setAiLogs(prev => [...prev, {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: stepType,
|
||||
action: progressModal.title || 'AI Function',
|
||||
stepName: currentStep,
|
||||
percentage: currentPercentage,
|
||||
data: {
|
||||
step: currentStep,
|
||||
message: currentMessage,
|
||||
percentage: currentPercentage,
|
||||
status: currentStatus,
|
||||
details: progress.details,
|
||||
},
|
||||
}]);
|
||||
|
||||
lastLoggedPercentageRef.current = currentPercentage;
|
||||
}
|
||||
// Log status changes (error, completed)
|
||||
else if (currentStatus === 'error' || currentStatus === 'completed') {
|
||||
// Only log if we haven't already logged this status for this step
|
||||
if (currentStep !== lastLoggedStepRef.current ||
|
||||
(currentStatus === 'error' && lastLoggedStepRef.current !== 'error') ||
|
||||
(currentStatus === 'completed' && lastLoggedStepRef.current !== 'completed')) {
|
||||
const stepType = currentStatus === 'error' ? 'error' : 'success';
|
||||
|
||||
setAiLogs(prev => [...prev, {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: stepType,
|
||||
action: progressModal.title || 'AI Function',
|
||||
stepName: currentStep || 'Final',
|
||||
percentage: currentPercentage,
|
||||
data: {
|
||||
step: currentStep || 'Final',
|
||||
message: currentMessage,
|
||||
percentage: currentPercentage,
|
||||
status: currentStatus,
|
||||
details: progress.details,
|
||||
},
|
||||
}]);
|
||||
|
||||
lastLoggedStepRef.current = currentStep || currentStatus;
|
||||
}
|
||||
}
|
||||
}, [progressModal.progress, progressModal.taskId, progressModal.isOpen, progressModal.title]);
|
||||
|
||||
// Reset step tracking when modal closes or opens
|
||||
useEffect(() => {
|
||||
if (!progressModal.isOpen) {
|
||||
lastLoggedStepRef.current = null;
|
||||
lastLoggedPercentageRef.current = -1;
|
||||
hasReloadedRef.current = false; // Reset reload flag when modal closes
|
||||
} else {
|
||||
// Reset reload flag when modal opens for a new task
|
||||
hasReloadedRef.current = false;
|
||||
}
|
||||
}, [progressModal.isOpen, progressModal.taskId]);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setFormData({
|
||||
@@ -712,6 +879,74 @@ export default function Keywords() {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* AI Function Logs - Display below table */}
|
||||
{aiLogs.length > 0 && (
|
||||
<div className="mt-6 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
AI Function Logs
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setAiLogs([])}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
Clear Logs
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{aiLogs.slice().reverse().map((log, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`p-3 rounded border text-xs font-mono ${
|
||||
log.type === 'request'
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
|
||||
: log.type === 'success'
|
||||
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
|
||||
: log.type === 'error'
|
||||
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800'
|
||||
: 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`font-semibold ${
|
||||
log.type === 'request'
|
||||
? 'text-blue-700 dark:text-blue-300'
|
||||
: log.type === 'success'
|
||||
? 'text-green-700 dark:text-green-300'
|
||||
: log.type === 'error'
|
||||
? 'text-red-700 dark:text-red-300'
|
||||
: 'text-purple-700 dark:text-purple-300'
|
||||
}`}>
|
||||
[{log.type.toUpperCase()}]
|
||||
</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
{log.action}
|
||||
</span>
|
||||
{log.stepName && (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
|
||||
{log.stepName}
|
||||
</span>
|
||||
)}
|
||||
{log.percentage !== undefined && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{log.percentage}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
{new Date(log.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
<pre className="text-xs text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-words">
|
||||
{JSON.stringify(log.data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Consistent with Keywords page layout, structure and design
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import TablePageTemplate from '../../templates/TablePageTemplate';
|
||||
import {
|
||||
fetchTasks,
|
||||
@@ -75,6 +75,21 @@ export default function Tasks() {
|
||||
// Progress modal for AI functions
|
||||
const progressModal = useProgressModal();
|
||||
|
||||
// AI Function Logs state
|
||||
const [aiLogs, setAiLogs] = useState<Array<{
|
||||
timestamp: string;
|
||||
type: 'request' | 'success' | 'error' | 'step';
|
||||
action: string;
|
||||
data: any;
|
||||
stepName?: string;
|
||||
percentage?: number;
|
||||
}>>([]);
|
||||
|
||||
// Track last logged step to avoid duplicates
|
||||
const lastLoggedStepRef = useRef<string | null>(null);
|
||||
const lastLoggedPercentageRef = useRef<number>(-1);
|
||||
const hasReloadedRef = useRef<boolean>(false);
|
||||
|
||||
// Load clusters for filter dropdown
|
||||
useEffect(() => {
|
||||
const loadClusters = async () => {
|
||||
@@ -208,23 +223,65 @@ export default function Tasks() {
|
||||
// return;
|
||||
// }
|
||||
|
||||
const requestData = {
|
||||
ids: [row.id],
|
||||
task_title: row.title,
|
||||
task_id: row.id,
|
||||
};
|
||||
|
||||
// Log request
|
||||
setAiLogs(prev => [...prev, {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'request',
|
||||
action: 'generate_content (Row Action)',
|
||||
data: requestData,
|
||||
}]);
|
||||
|
||||
try {
|
||||
const result = await autoGenerateContent([row.id]);
|
||||
|
||||
if (result.success) {
|
||||
if (result.task_id) {
|
||||
// Log success with task_id
|
||||
setAiLogs(prev => [...prev, {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'success',
|
||||
action: 'generate_content (Row Action)',
|
||||
data: { task_id: result.task_id, message: result.message },
|
||||
}]);
|
||||
// Async task - show progress modal
|
||||
progressModal.openModal(result.task_id, 'Generating Content');
|
||||
toast.success('Content generation started');
|
||||
} else {
|
||||
// Log success with results
|
||||
setAiLogs(prev => [...prev, {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'success',
|
||||
action: 'generate_content (Row Action)',
|
||||
data: { tasks_updated: result.tasks_updated || 0, message: result.message },
|
||||
}]);
|
||||
// Synchronous completion
|
||||
toast.success(`Content generated successfully: ${result.tasks_updated || 0} article generated`);
|
||||
await loadTasks();
|
||||
}
|
||||
} else {
|
||||
// Log error
|
||||
setAiLogs(prev => [...prev, {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'error',
|
||||
action: 'generate_content (Row Action)',
|
||||
data: { error: result.error || 'Failed to generate content' },
|
||||
}]);
|
||||
toast.error(result.error || 'Failed to generate content');
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Log error
|
||||
setAiLogs(prev => [...prev, {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'error',
|
||||
action: 'generate_content (Row Action)',
|
||||
data: { error: error.message || 'Unknown error occurred' },
|
||||
}]);
|
||||
toast.error(`Failed to generate content: ${error.message}`);
|
||||
}
|
||||
}
|
||||
@@ -242,29 +299,169 @@ export default function Tasks() {
|
||||
toast.error('Maximum 10 tasks allowed for image generation');
|
||||
return;
|
||||
}
|
||||
const numIds = ids.map(id => parseInt(id));
|
||||
const selectedTasks = tasks.filter(t => numIds.includes(t.id));
|
||||
const requestData = {
|
||||
ids: numIds,
|
||||
task_count: numIds.length,
|
||||
task_titles: selectedTasks.map(t => t.title),
|
||||
};
|
||||
|
||||
// Log request
|
||||
setAiLogs(prev => [...prev, {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'request',
|
||||
action: 'generate_images (Bulk Action)',
|
||||
data: requestData,
|
||||
}]);
|
||||
|
||||
try {
|
||||
const numIds = ids.map(id => parseInt(id));
|
||||
const result = await autoGenerateImages(numIds);
|
||||
if (result.success) {
|
||||
if (result.task_id) {
|
||||
// Log success with task_id
|
||||
setAiLogs(prev => [...prev, {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'success',
|
||||
action: 'generate_images (Bulk Action)',
|
||||
data: { task_id: result.task_id, message: result.message, task_count: numIds.length },
|
||||
}]);
|
||||
// Async task - show progress modal
|
||||
progressModal.openModal(result.task_id, 'Generating Images');
|
||||
toast.success('Image generation started');
|
||||
} else {
|
||||
// Log success with results
|
||||
setAiLogs(prev => [...prev, {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'success',
|
||||
action: 'generate_images (Bulk Action)',
|
||||
data: { images_created: result.images_created || 0, message: result.message, task_count: numIds.length },
|
||||
}]);
|
||||
// Synchronous completion
|
||||
toast.success(`Image generation complete: ${result.images_created || 0} images generated`);
|
||||
await loadTasks();
|
||||
}
|
||||
} else {
|
||||
// Log error
|
||||
setAiLogs(prev => [...prev, {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'error',
|
||||
action: 'generate_images (Bulk Action)',
|
||||
data: { error: result.error || 'Failed to generate images', task_count: numIds.length },
|
||||
}]);
|
||||
toast.error(result.error || 'Failed to generate images');
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Log error
|
||||
setAiLogs(prev => [...prev, {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'error',
|
||||
action: 'generate_images (Bulk Action)',
|
||||
data: { error: error.message || 'Unknown error occurred', task_count: numIds.length },
|
||||
}]);
|
||||
toast.error(`Failed to generate images: ${error.message}`);
|
||||
}
|
||||
} else {
|
||||
toast.info(`Bulk action "${action}" for ${ids.length} items`);
|
||||
}
|
||||
}, [toast, loadTasks, progressModal]);
|
||||
}, [toast, loadTasks, progressModal, tasks]);
|
||||
|
||||
// Log AI function progress steps
|
||||
useEffect(() => {
|
||||
if (!progressModal.taskId || !progressModal.isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const progress = progressModal.progress;
|
||||
const currentStep = progress.details?.phase || '';
|
||||
const currentPercentage = progress.percentage;
|
||||
const currentMessage = progress.message;
|
||||
const currentStatus = progress.status;
|
||||
|
||||
// Log step changes
|
||||
if (currentStep && currentStep !== lastLoggedStepRef.current) {
|
||||
const stepType = currentStatus === 'error' ? 'error' :
|
||||
currentStatus === 'completed' ? 'success' : 'step';
|
||||
|
||||
setAiLogs(prev => [...prev, {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: stepType,
|
||||
action: progressModal.title || 'AI Function',
|
||||
stepName: currentStep,
|
||||
percentage: currentPercentage,
|
||||
data: {
|
||||
step: currentStep,
|
||||
message: currentMessage,
|
||||
percentage: currentPercentage,
|
||||
status: currentStatus,
|
||||
details: progress.details,
|
||||
},
|
||||
}]);
|
||||
|
||||
lastLoggedStepRef.current = currentStep;
|
||||
lastLoggedPercentageRef.current = currentPercentage;
|
||||
}
|
||||
// Log percentage changes for same step (if significant change)
|
||||
else if (currentStep && Math.abs(currentPercentage - lastLoggedPercentageRef.current) >= 10) {
|
||||
const stepType = currentStatus === 'error' ? 'error' :
|
||||
currentStatus === 'completed' ? 'success' : 'step';
|
||||
|
||||
setAiLogs(prev => [...prev, {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: stepType,
|
||||
action: progressModal.title || 'AI Function',
|
||||
stepName: currentStep,
|
||||
percentage: currentPercentage,
|
||||
data: {
|
||||
step: currentStep,
|
||||
message: currentMessage,
|
||||
percentage: currentPercentage,
|
||||
status: currentStatus,
|
||||
details: progress.details,
|
||||
},
|
||||
}]);
|
||||
|
||||
lastLoggedPercentageRef.current = currentPercentage;
|
||||
}
|
||||
// Log status changes (error, completed)
|
||||
else if (currentStatus === 'error' || currentStatus === 'completed') {
|
||||
// Only log if we haven't already logged this status for this step
|
||||
if (currentStep !== lastLoggedStepRef.current ||
|
||||
(currentStatus === 'error' && lastLoggedStepRef.current !== 'error') ||
|
||||
(currentStatus === 'completed' && lastLoggedStepRef.current !== 'completed')) {
|
||||
const stepType = currentStatus === 'error' ? 'error' : 'success';
|
||||
|
||||
setAiLogs(prev => [...prev, {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: stepType,
|
||||
action: progressModal.title || 'AI Function',
|
||||
stepName: currentStep || 'Final',
|
||||
percentage: currentPercentage,
|
||||
data: {
|
||||
step: currentStep || 'Final',
|
||||
message: currentMessage,
|
||||
percentage: currentPercentage,
|
||||
status: currentStatus,
|
||||
details: progress.details,
|
||||
},
|
||||
}]);
|
||||
|
||||
lastLoggedStepRef.current = currentStep || currentStatus;
|
||||
}
|
||||
}
|
||||
}, [progressModal.progress, progressModal.taskId, progressModal.isOpen, progressModal.title]);
|
||||
|
||||
// Reset step tracking when modal closes or opens
|
||||
useEffect(() => {
|
||||
if (!progressModal.isOpen) {
|
||||
lastLoggedStepRef.current = null;
|
||||
lastLoggedPercentageRef.current = -1;
|
||||
hasReloadedRef.current = false; // Reset reload flag when modal closes
|
||||
} else {
|
||||
// Reset reload flag when modal opens for a new task
|
||||
hasReloadedRef.current = false;
|
||||
}
|
||||
}, [progressModal.isOpen, progressModal.taskId]);
|
||||
|
||||
// Create page config
|
||||
const pageConfig = useMemo(() => {
|
||||
@@ -442,12 +639,84 @@ export default function Tasks() {
|
||||
const wasCompleted = progressModal.progress.status === 'completed';
|
||||
progressModal.closeModal();
|
||||
// Reload data after modal closes (if completed)
|
||||
if (wasCompleted) {
|
||||
if (wasCompleted && !hasReloadedRef.current) {
|
||||
hasReloadedRef.current = true;
|
||||
loadTasks();
|
||||
setTimeout(() => {
|
||||
hasReloadedRef.current = false;
|
||||
}, 1000);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* AI Function Logs - Display below table */}
|
||||
{aiLogs.length > 0 && (
|
||||
<div className="mt-6 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
AI Function Logs
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setAiLogs([])}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
Clear Logs
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{aiLogs.slice().reverse().map((log, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`p-3 rounded border text-xs font-mono ${
|
||||
log.type === 'request'
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
|
||||
: log.type === 'success'
|
||||
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
|
||||
: log.type === 'error'
|
||||
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800'
|
||||
: 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`font-semibold ${
|
||||
log.type === 'request'
|
||||
? 'text-blue-700 dark:text-blue-300'
|
||||
: log.type === 'success'
|
||||
? 'text-green-700 dark:text-green-300'
|
||||
: log.type === 'error'
|
||||
? 'text-red-700 dark:text-red-300'
|
||||
: 'text-purple-700 dark:text-purple-300'
|
||||
}`}>
|
||||
[{log.type.toUpperCase()}]
|
||||
</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
{log.action}
|
||||
</span>
|
||||
{log.stepName && (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
|
||||
{log.stepName}
|
||||
</span>
|
||||
)}
|
||||
{log.percentage !== undefined && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{log.percentage}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
{new Date(log.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
<pre className="text-xs text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-words">
|
||||
{JSON.stringify(log.data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<FormModal
|
||||
isOpen={isModalOpen}
|
||||
|
||||
@@ -28,12 +28,10 @@ import Input from '../components/form/input/InputField';
|
||||
import SelectDropdown from '../components/form/SelectDropdown';
|
||||
import { Dropdown } from '../components/ui/dropdown/Dropdown';
|
||||
import { DropdownItem } from '../components/ui/dropdown/DropdownItem';
|
||||
import Alert from '../components/ui/alert/Alert';
|
||||
import AlertModal from '../components/ui/alert/AlertModal';
|
||||
import { ChevronDownIcon, MoreDotIcon, PlusIcon } from '../icons';
|
||||
import { useHeaderMetrics } from '../context/HeaderMetricsContext';
|
||||
import { useToast } from '../components/ui/toast/ToastContainer';
|
||||
import { pageNotifications } from '../config/pages/notifications.config';
|
||||
import { getDeleteModalConfig } from '../config/pages/delete-modal.config';
|
||||
import { getBulkActionModalConfig } from '../config/pages/bulk-action-modal.config';
|
||||
import { getTableActionsConfig } from '../config/pages/table-actions.config';
|
||||
@@ -42,7 +40,8 @@ import BulkStatusUpdateModal from '../components/common/BulkStatusUpdateModal';
|
||||
import { CompactPagination } from '../components/ui/pagination';
|
||||
import SectorSelector from '../components/common/SectorSelector';
|
||||
import { usePageSizeStore } from '../store/pageSizeStore';
|
||||
import { useAIRequestLogsStore } from '../store/aiRequestLogsStore';
|
||||
// DEPRECATED: Frontend debug logging removed - now using backend console logging
|
||||
// import { useAIRequestLogsStore } from '../store/aiRequestLogsStore';
|
||||
import ToggleTableRow, { ToggleButton } from '../components/common/ToggleTableRow';
|
||||
|
||||
interface ColumnConfig {
|
||||
@@ -101,7 +100,7 @@ interface TablePageTemplateProps {
|
||||
onCreate?: () => void;
|
||||
createLabel?: string;
|
||||
onCreateIcon?: ReactNode; // Icon for create button
|
||||
onExport?: () => void;
|
||||
onExportCSV?: () => void; // CSV export button handler
|
||||
onExportIcon?: ReactNode; // Icon for export button
|
||||
onImport?: () => void;
|
||||
onImportIcon?: ReactNode; // Icon for import button
|
||||
@@ -145,7 +144,7 @@ export default function TablePageTemplate({
|
||||
subtitle,
|
||||
columns,
|
||||
data,
|
||||
loading = false,
|
||||
loading: _loading = false, // Unused - component uses showContent for loading state
|
||||
showContent = true,
|
||||
filters = [],
|
||||
filterValues = {},
|
||||
@@ -156,7 +155,7 @@ export default function TablePageTemplate({
|
||||
onCreate,
|
||||
createLabel = '+ Add',
|
||||
onCreateIcon,
|
||||
onExport,
|
||||
onExportCSV,
|
||||
onExportIcon,
|
||||
onImport,
|
||||
onImportIcon,
|
||||
@@ -171,17 +170,17 @@ export default function TablePageTemplate({
|
||||
onBulkUpdateStatus,
|
||||
onBulkAction,
|
||||
onRowAction,
|
||||
onExport,
|
||||
getItemDisplayName = (row: any) => row.name || row.keyword || row.title || String(row.id),
|
||||
className = '',
|
||||
}: TablePageTemplateProps) {
|
||||
const location = useLocation();
|
||||
const [isBulkActionsDropdownOpen, setIsBulkActionsDropdownOpen] = useState(false);
|
||||
const [openRowActions, setOpenRowActions] = useState<Map<string | number, boolean>>(new Map());
|
||||
const rowActionButtonRefs = React.useRef<Map<string | number, React.RefObject<HTMLButtonElement>>>(new Map());
|
||||
const rowActionButtonRefs = React.useRef<Map<string | number, React.RefObject<HTMLButtonElement | null>>>(new Map());
|
||||
const bulkActionsButtonRef = React.useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Get notification config for current page
|
||||
const notificationConfig = pageNotifications[location.pathname];
|
||||
const deleteModalConfig = getDeleteModalConfig(location.pathname);
|
||||
const bulkActionModalConfig = getBulkActionModalConfig(location.pathname);
|
||||
const tableActionsConfig = getTableActionsConfig(location.pathname);
|
||||
@@ -302,7 +301,7 @@ export default function TablePageTemplate({
|
||||
} else if (actionKey === 'delete' && onDelete && deleteModalConfig) {
|
||||
handleDeleteClick(row);
|
||||
} else if (actionKey === 'export' && onExport) {
|
||||
onExport(row);
|
||||
await onExport(row);
|
||||
} else if (onRowAction) {
|
||||
// For custom row actions, use onRowAction with full row object
|
||||
onRowAction(actionKey, row).catch((error: any) => {
|
||||
@@ -648,7 +647,7 @@ export default function TablePageTemplate({
|
||||
<Dropdown
|
||||
isOpen={isBulkActionsDropdownOpen && selectedIds.length > 0}
|
||||
onClose={() => setIsBulkActionsDropdownOpen(false)}
|
||||
anchorRef={bulkActionsButtonRef}
|
||||
anchorRef={bulkActionsButtonRef as React.RefObject<HTMLElement>}
|
||||
placement="bottom-left"
|
||||
className="w-48 p-2"
|
||||
>
|
||||
@@ -682,12 +681,12 @@ export default function TablePageTemplate({
|
||||
|
||||
{/* Action Buttons - Right aligned */}
|
||||
<div className="flex gap-2">
|
||||
{onExport && (
|
||||
{onExportCSV && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
endIcon={onExportIcon}
|
||||
onClick={onExport}
|
||||
onClick={onExportCSV}
|
||||
>
|
||||
Export CSV
|
||||
</Button>
|
||||
@@ -746,16 +745,24 @@ export default function TablePageTemplate({
|
||||
key={column.key}
|
||||
isHeader
|
||||
className={`px-5 py-3 font-medium text-gray-500 text-${column.align || 'start'} text-theme-xs dark:text-gray-400 ${column.sortable ? 'cursor-pointer hover:text-gray-700 dark:hover:text-gray-300' : ''} ${isLastColumn && rowActions.length > 0 ? 'pr-16' : ''}`}
|
||||
onClick={() => column.sortable && handleSort(column)}
|
||||
>
|
||||
{column.label}
|
||||
{getSortIcon(column)}
|
||||
{column.sortable ? (
|
||||
<div onClick={() => handleSort(column)} className="flex items-center">
|
||||
{column.label}
|
||||
{getSortIcon(column)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{column.label}
|
||||
{getSortIcon(column)}
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="divide-y divide-gray-100 dark:divide-white/[0.05] igny8-table-body" style={{ position: 'relative' }}>
|
||||
<TableBody className="divide-y divide-gray-100 dark:divide-white/[0.05] igny8-table-body">
|
||||
{!showContent ? (
|
||||
// Loading Skeleton Rows - Always show 10 rows to match page size
|
||||
Array.from({ length: 10 }).map((_, index) => (
|
||||
@@ -768,7 +775,6 @@ export default function TablePageTemplate({
|
||||
))
|
||||
) : data.length === 0 ? null : (
|
||||
data.map((row, index) => {
|
||||
const rowId = row.id?.toString() || index.toString();
|
||||
// Use consistent key for expandedRows (number or index)
|
||||
const rowKey = row.id || index;
|
||||
const isRowExpanded = expandedRows.has(rowKey);
|
||||
@@ -809,8 +815,7 @@ export default function TablePageTemplate({
|
||||
return (
|
||||
<React.Fragment key={row.id || index}>
|
||||
<TableRow
|
||||
className={`igny8-data-row ${isRowAdded ? 'bg-blue-50 dark:bg-blue-500/10' : ''}`}
|
||||
style={{ animationDelay: `${index * 0.03}s` }}
|
||||
className={`igny8-data-row ${isRowAdded ? 'bg-blue-50 dark:bg-blue-500/10' : ''}`}
|
||||
>
|
||||
{selection && (
|
||||
<TableCell className="px-5 py-4 text-start">
|
||||
@@ -829,7 +834,8 @@ export default function TablePageTemplate({
|
||||
// Get or create ref for this row's actions button
|
||||
if (isLastColumn && rowActions.length > 0) {
|
||||
if (!rowActionButtonRefs.current.has(rowId)) {
|
||||
rowActionButtonRefs.current.set(rowId, React.createRef<HTMLButtonElement>());
|
||||
const ref = React.createRef<HTMLButtonElement>();
|
||||
rowActionButtonRefs.current.set(rowId, ref);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -837,7 +843,6 @@ export default function TablePageTemplate({
|
||||
<TableCell
|
||||
key={column.key}
|
||||
className={`px-5 py-4 text-${column.align || 'start'} text-gray-800 dark:text-white/90 ${isLastColumn && rowActions.length > 0 ? 'relative pr-16' : ''}`}
|
||||
style={isLastColumn && rowActions.length > 0 ? { position: 'relative', overflow: 'visible' } : undefined}
|
||||
>
|
||||
<div className={`flex items-center ${column.toggleable && hasToggleContent ? 'justify-between w-full' : ''} gap-2`}>
|
||||
<div className="flex-1">
|
||||
@@ -848,14 +853,15 @@ export default function TablePageTemplate({
|
||||
)}
|
||||
</div>
|
||||
{column.toggleable && hasToggleContent && (
|
||||
<ToggleButton
|
||||
isExpanded={isRowExpanded}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggle(!isRowExpanded, rowKey);
|
||||
}}
|
||||
hasContent={hasToggleContent}
|
||||
/>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ToggleButton
|
||||
isExpanded={isRowExpanded}
|
||||
onClick={() => {
|
||||
handleToggle(!isRowExpanded, rowKey);
|
||||
}}
|
||||
hasContent={hasToggleContent}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -900,7 +906,8 @@ export default function TablePageTemplate({
|
||||
}
|
||||
|
||||
// Multiple actions - use dropdown
|
||||
const buttonRef = rowActionButtonRefs.current.get(rowId)!;
|
||||
const buttonRef = rowActionButtonRefs.current.get(rowId);
|
||||
if (!buttonRef) return null;
|
||||
const isOpen = openRowActions.get(rowId) || false;
|
||||
|
||||
return (
|
||||
@@ -935,11 +942,11 @@ export default function TablePageTemplate({
|
||||
return newMap;
|
||||
});
|
||||
}}
|
||||
anchorRef={buttonRef}
|
||||
anchorRef={buttonRef as React.RefObject<HTMLElement>}
|
||||
placement="right"
|
||||
className="w-48 p-2"
|
||||
>
|
||||
{rowActions.map((action, actionIndex) => {
|
||||
{rowActions.map((action) => {
|
||||
const isEdit = action.key === 'edit';
|
||||
const isDelete = action.key === 'delete';
|
||||
const isExport = action.key === 'export';
|
||||
@@ -948,21 +955,21 @@ export default function TablePageTemplate({
|
||||
const getIconWithColor = () => {
|
||||
if (!action.icon) return null;
|
||||
const iconElement = action.icon as React.ReactElement;
|
||||
const existingClassName = iconElement.props?.className || "";
|
||||
const existingClassName = (iconElement.props as any)?.className || "";
|
||||
const baseSize = existingClassName.includes("w-") ? "" : "w-5 h-5 ";
|
||||
|
||||
if (isEdit) {
|
||||
return React.cloneElement(iconElement, {
|
||||
className: `${baseSize}text-blue-light-500 ${existingClassName}`.trim()
|
||||
});
|
||||
} as any);
|
||||
} else if (isDelete) {
|
||||
return React.cloneElement(iconElement, {
|
||||
className: `${baseSize}text-error-500 ${existingClassName}`.trim()
|
||||
});
|
||||
} as any);
|
||||
} else if (isExport) {
|
||||
return React.cloneElement(iconElement, {
|
||||
className: `${baseSize}text-gray-600 dark:text-gray-400 ${existingClassName}`.trim()
|
||||
});
|
||||
} as any);
|
||||
}
|
||||
return action.icon;
|
||||
};
|
||||
@@ -1089,274 +1096,6 @@ export default function TablePageTemplate({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Request Logs Section */}
|
||||
<AIRequestLogsSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// AI Request Logs Component
|
||||
function AIRequestLogsSection() {
|
||||
const { logs, clearLogs } = useAIRequestLogsStore();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
if (logs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'text-green-600 dark:text-green-400';
|
||||
case 'error':
|
||||
return 'text-red-600 dark:text-red-400';
|
||||
case 'pending':
|
||||
return 'text-yellow-600 dark:text-yellow-400';
|
||||
default:
|
||||
return 'text-gray-600 dark:text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400';
|
||||
case 'error':
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-6 border-t border-gray-200 dark:border-gray-800 pt-6">
|
||||
{isExpanded && (
|
||||
<div className="flex justify-center">
|
||||
<div className="w-1/2 border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800/50">
|
||||
{/* Header inside panel */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
AI Debug Logs
|
||||
</h3>
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400">
|
||||
{logs.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="text-xs"
|
||||
>
|
||||
{isExpanded ? 'Collapse' : 'Expand'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={clearLogs}
|
||||
className="text-xs text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="p-4 max-h-96 overflow-y-auto">
|
||||
<div className="space-y-4">
|
||||
{logs.map((log) => (
|
||||
<React.Fragment key={log.id}>
|
||||
<div
|
||||
className="p-4 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-gray-700 dark:text-gray-300 font-mono-custom">
|
||||
{log.function}
|
||||
</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${getStatusBadge(log.status)}`}>
|
||||
{log.status.toUpperCase()}
|
||||
</span>
|
||||
{log.duration && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{log.duration}ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatTime(log.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mb-3 font-mono-custom">
|
||||
{log.endpoint}
|
||||
</div>
|
||||
|
||||
{/* Request/Response Details (Collapsible) */}
|
||||
<details className="mt-3">
|
||||
<summary className="text-xs text-gray-500 dark:text-gray-400 cursor-pointer hover:text-gray-700 dark:hover:text-gray-300">
|
||||
Request/Response Details
|
||||
</summary>
|
||||
<div className="mt-2 space-y-2">
|
||||
<details className="text-xs">
|
||||
<summary className="text-gray-500 dark:text-gray-400 cursor-pointer">Request</summary>
|
||||
<div className="mt-1 p-2 bg-gray-50 dark:bg-gray-800 rounded text-xs overflow-x-auto font-mono-custom">
|
||||
<pre className="whitespace-pre-wrap">
|
||||
{JSON.stringify(log.request.body || log.request.params || {}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</details>
|
||||
{log.response && (
|
||||
<details className="text-xs">
|
||||
<summary className="text-gray-500 dark:text-gray-400 cursor-pointer">
|
||||
Response ({log.response.status})
|
||||
{log.response.errorType && (
|
||||
<span className="ml-2 px-1.5 py-0.5 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 text-[10px] font-semibold">
|
||||
{log.response.errorType}
|
||||
</span>
|
||||
)}
|
||||
</summary>
|
||||
<div className="mt-1 p-2 bg-gray-50 dark:bg-gray-800 rounded text-xs overflow-x-auto font-mono-custom">
|
||||
{log.response.error ? (
|
||||
<div className="text-red-600 dark:text-red-400">
|
||||
{log.response.error}
|
||||
</div>
|
||||
) : log.response.data ? (
|
||||
<pre className="whitespace-pre-wrap">
|
||||
{JSON.stringify(log.response.data, null, 2)}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{/* AI Debug Steps */}
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-white dark:bg-gray-900">
|
||||
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||
AI Debug Steps ({log.requestSteps.length + log.responseSteps.length} total steps)
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{(() => {
|
||||
// Combine and sort all steps by stepNumber
|
||||
const allSteps = [
|
||||
...log.requestSteps.map((step, idx) => ({ ...step, type: 'request', originalIdx: idx })),
|
||||
...log.responseSteps.map((step, idx) => ({ ...step, type: 'response', originalIdx: idx }))
|
||||
].sort((a, b) => (a.stepNumber || 0) - (b.stepNumber || 0));
|
||||
|
||||
return allSteps.length > 0 ? (
|
||||
allSteps.map((step, idx) => (
|
||||
<div
|
||||
key={`${step.type}-${step.originalIdx}-${idx}`}
|
||||
className="p-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs font-mono text-gray-500 dark:text-gray-400">
|
||||
{step.stepNumber}.
|
||||
</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${getStatusBadge(step.status)}`}>
|
||||
{step.status.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{step.type === 'request' ? 'Request' : 'Response'}
|
||||
</span>
|
||||
{step.duration && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{step.duration}ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1">
|
||||
{step.stepName}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 font-mono mb-1">
|
||||
{step.functionName}
|
||||
</div>
|
||||
{step.message && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
{step.message}
|
||||
</div>
|
||||
)}
|
||||
{step.error && (
|
||||
<div className="text-xs text-red-600 dark:text-red-400 mt-2">
|
||||
Error: {step.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 italic">
|
||||
No steps logged yet
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isExpanded && logs.length > 0 && (
|
||||
<div className="flex justify-center">
|
||||
<div className="w-1/2 border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800/50">
|
||||
{/* Header when collapsed */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
AI Debug Logs
|
||||
</h3>
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400">
|
||||
{logs.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="text-xs"
|
||||
>
|
||||
{isExpanded ? 'Collapse' : 'Expand'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={clearLogs}
|
||||
className="text-xs text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Click "Expand" to view {logs.length} AI request log{logs.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user