final polish phase 1
This commit is contained in:
@@ -23,6 +23,8 @@ interface PageHeaderProps {
|
|||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
color: 'blue' | 'green' | 'purple' | 'orange' | 'red' | 'indigo' | 'yellow' | 'pink' | 'emerald' | 'cyan' | 'amber' | 'teal';
|
color: 'blue' | 'green' | 'purple' | 'orange' | 'red' | 'indigo' | 'yellow' | 'pink' | 'emerald' | 'cyan' | 'amber' | 'teal';
|
||||||
};
|
};
|
||||||
|
/** Completely hide site/sector selectors in app header */
|
||||||
|
hideSelectors?: boolean;
|
||||||
hideSiteSector?: boolean;
|
hideSiteSector?: boolean;
|
||||||
navigation?: ReactNode; // Kept for backwards compat but not rendered
|
navigation?: ReactNode; // Kept for backwards compat but not rendered
|
||||||
workflowInsights?: any[]; // Kept for backwards compat but not rendered
|
workflowInsights?: any[]; // Kept for backwards compat but not rendered
|
||||||
@@ -40,6 +42,7 @@ export default function PageHeader({
|
|||||||
onRefresh,
|
onRefresh,
|
||||||
className = "",
|
className = "",
|
||||||
badge,
|
badge,
|
||||||
|
hideSelectors = false,
|
||||||
hideSiteSector = false,
|
hideSiteSector = false,
|
||||||
actions,
|
actions,
|
||||||
}: PageHeaderProps) {
|
}: PageHeaderProps) {
|
||||||
@@ -54,11 +57,11 @@ export default function PageHeader({
|
|||||||
const parentModule = parent || breadcrumb;
|
const parentModule = parent || breadcrumb;
|
||||||
|
|
||||||
// Update page context with title and badge info for AppHeader
|
// Update page context with title and badge info for AppHeader
|
||||||
const pageInfoKey = useMemo(() => `${title}|${parentModule}`, [title, parentModule]);
|
const pageInfoKey = useMemo(() => `${title}|${parentModule}|${hideSiteSector}|${hideSelectors}`, [title, parentModule, hideSiteSector, hideSelectors]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPageInfo({ title, parent: parentModule, badge });
|
setPageInfo({ title, parent: parentModule, badge, hideSelectors, hideSectorSelector: hideSiteSector });
|
||||||
return () => setPageInfo(null);
|
return () => setPageInfo(null);
|
||||||
}, [pageInfoKey, badge?.color]);
|
}, [pageInfoKey, badge?.color, hideSiteSector, hideSelectors]);
|
||||||
|
|
||||||
// Load sectors when active site changes
|
// Load sectors when active site changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
183
frontend/src/components/common/SingleSiteSelector.tsx
Normal file
183
frontend/src/components/common/SingleSiteSelector.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* Single Site Selector
|
||||||
|
* Site-only selector without "All Sites" option
|
||||||
|
* For pages that require a specific site selection (Automation, Content Settings)
|
||||||
|
*/
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Dropdown } from '../ui/dropdown/Dropdown';
|
||||||
|
import { DropdownItem } from '../ui/dropdown/DropdownItem';
|
||||||
|
import { fetchSites, Site, setActiveSite as apiSetActiveSite } from '../../services/api';
|
||||||
|
import { useToast } from '../ui/toast/ToastContainer';
|
||||||
|
import { useSiteStore } from '../../store/siteStore';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import Button from '../ui/button/Button';
|
||||||
|
|
||||||
|
export default function SingleSiteSelector() {
|
||||||
|
const toast = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { activeSite, setActiveSite, loadActiveSite } = useSiteStore();
|
||||||
|
const { user, refreshUser, isAuthenticated } = useAuthStore();
|
||||||
|
|
||||||
|
// Site switcher state
|
||||||
|
const [sitesOpen, setSitesOpen] = useState(false);
|
||||||
|
const [sites, setSites] = useState<Site[]>([]);
|
||||||
|
const [sitesLoading, setSitesLoading] = useState(true);
|
||||||
|
const siteButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const noSitesAvailable = !sitesLoading && sites.length === 0;
|
||||||
|
|
||||||
|
// Load sites
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated && user) {
|
||||||
|
refreshUser().catch((error) => {
|
||||||
|
console.debug('SingleSiteSelector: Failed to refresh user (non-critical):', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSites();
|
||||||
|
if (!activeSite) {
|
||||||
|
loadActiveSite();
|
||||||
|
}
|
||||||
|
}, [user?.account?.id]);
|
||||||
|
|
||||||
|
const loadSites = async () => {
|
||||||
|
try {
|
||||||
|
setSitesLoading(true);
|
||||||
|
const response = await fetchSites();
|
||||||
|
const activeSites = (response.results || []).filter(site => site.is_active);
|
||||||
|
setSites(activeSites);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to load sites:', error);
|
||||||
|
toast.error(`Failed to load sites: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
setSitesLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSiteSelect = async (siteId: number) => {
|
||||||
|
try {
|
||||||
|
await apiSetActiveSite(siteId);
|
||||||
|
const selectedSite = sites.find(s => s.id === siteId);
|
||||||
|
if (selectedSite) {
|
||||||
|
setActiveSite(selectedSite);
|
||||||
|
toast.success(`Switched to "${selectedSite.name}"`);
|
||||||
|
}
|
||||||
|
setSitesOpen(false);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(`Failed to switch site: ${error.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get display text
|
||||||
|
const getSiteDisplayText = () => {
|
||||||
|
if (sitesLoading) return 'Loading...';
|
||||||
|
return activeSite?.name || 'Select Site';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if a site is selected
|
||||||
|
const isSiteSelected = (siteId: number) => {
|
||||||
|
return activeSite?.id === siteId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateSite = () => navigate('/sites');
|
||||||
|
|
||||||
|
if (sitesLoading && sites.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Loading sites...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (noSitesAvailable) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<span>No active sites yet.</span>
|
||||||
|
<Button size="sm" variant="primary" onClick={handleCreateSite}>
|
||||||
|
Create Site
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative inline-block">
|
||||||
|
<button
|
||||||
|
ref={siteButtonRef}
|
||||||
|
onClick={() => setSitesOpen(!sitesOpen)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-brand-200 rounded-lg hover:bg-brand-50 hover:border-brand-300 dark:bg-gray-800 dark:text-gray-300 dark:border-brand-700/50 dark:hover:bg-brand-500/10 dark:hover:border-brand-600/50 transition-colors dropdown-toggle"
|
||||||
|
aria-label="Switch site"
|
||||||
|
disabled={sitesLoading || sites.length === 0}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 text-brand-500 dark:text-brand-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="max-w-[150px] truncate">
|
||||||
|
{getSiteDisplayText()}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 text-brand-500 dark:text-brand-400 transition-transform ${sitesOpen ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 9l-7 7-7-7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Dropdown
|
||||||
|
isOpen={sitesOpen}
|
||||||
|
onClose={() => setSitesOpen(false)}
|
||||||
|
anchorRef={siteButtonRef}
|
||||||
|
placement="bottom-left"
|
||||||
|
className="w-64 p-2"
|
||||||
|
>
|
||||||
|
{sites.map((site) => (
|
||||||
|
<DropdownItem
|
||||||
|
key={site.id}
|
||||||
|
onItemClick={() => handleSiteSelect(site.id)}
|
||||||
|
className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${
|
||||||
|
isSiteSelected(site.id)
|
||||||
|
? "bg-brand-50 text-brand-700 dark:bg-brand-500/20 dark:text-brand-300"
|
||||||
|
: "text-gray-700 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex-1">{site.name}</span>
|
||||||
|
{isSiteSelected(site.id) && (
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 text-brand-600 dark:text-brand-400"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</DropdownItem>
|
||||||
|
))}
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Combined Site and Sector Selector Component
|
* Combined Site and Sector Selector Component
|
||||||
* Displays both site switcher and sector selector side by side with accent colors
|
* Displays both site switcher and sector selector side by side with accent colors
|
||||||
|
*
|
||||||
|
* Dashboard Mode: Shows "All Sites" option, uses callback for filtering
|
||||||
|
* Module Mode: Standard site/sector selection
|
||||||
*/
|
*/
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
@@ -15,10 +18,19 @@ import Button from '../ui/button/Button';
|
|||||||
|
|
||||||
interface SiteAndSectorSelectorProps {
|
interface SiteAndSectorSelectorProps {
|
||||||
hideSectorSelector?: boolean;
|
hideSectorSelector?: boolean;
|
||||||
|
/** Dashboard mode: show "All Sites" option */
|
||||||
|
showAllSitesOption?: boolean;
|
||||||
|
/** Current site filter for dashboard mode ('all' or site id) */
|
||||||
|
siteFilter?: 'all' | number;
|
||||||
|
/** Callback when site filter changes in dashboard mode */
|
||||||
|
onSiteFilterChange?: (value: 'all' | number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SiteAndSectorSelector({
|
export default function SiteAndSectorSelector({
|
||||||
hideSectorSelector = false,
|
hideSectorSelector = false,
|
||||||
|
showAllSitesOption = false,
|
||||||
|
siteFilter,
|
||||||
|
onSiteFilterChange,
|
||||||
}: SiteAndSectorSelectorProps) {
|
}: SiteAndSectorSelectorProps) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -67,7 +79,22 @@ export default function SiteAndSectorSelector({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSiteSelect = async (siteId: number) => {
|
const handleSiteSelect = async (siteId: number | 'all') => {
|
||||||
|
// Dashboard mode: use callback
|
||||||
|
if (showAllSitesOption && onSiteFilterChange) {
|
||||||
|
onSiteFilterChange(siteId);
|
||||||
|
setSitesOpen(false);
|
||||||
|
if (siteId !== 'all') {
|
||||||
|
const selectedSite = sites.find(s => s.id === siteId);
|
||||||
|
if (selectedSite) {
|
||||||
|
setActiveSite(selectedSite);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module mode: standard site switching
|
||||||
|
if (siteId === 'all') return; // Should not happen in module mode
|
||||||
try {
|
try {
|
||||||
await apiSetActiveSite(siteId);
|
await apiSetActiveSite(siteId);
|
||||||
const selectedSite = sites.find(s => s.id === siteId);
|
const selectedSite = sites.find(s => s.id === siteId);
|
||||||
@@ -81,6 +108,24 @@ export default function SiteAndSectorSelector({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Get display text based on mode
|
||||||
|
const getSiteDisplayText = () => {
|
||||||
|
if (sitesLoading) return 'Loading...';
|
||||||
|
if (showAllSitesOption && siteFilter === 'all') return 'All Sites';
|
||||||
|
if (showAllSitesOption && typeof siteFilter === 'number') {
|
||||||
|
return sites.find(s => s.id === siteFilter)?.name || activeSite?.name || 'Select Site';
|
||||||
|
}
|
||||||
|
return activeSite?.name || 'Select Site';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if a site is selected
|
||||||
|
const isSiteSelected = (siteId: number | 'all') => {
|
||||||
|
if (showAllSitesOption) {
|
||||||
|
return siteFilter === siteId;
|
||||||
|
}
|
||||||
|
return siteId !== 'all' && activeSite?.id === siteId;
|
||||||
|
};
|
||||||
|
|
||||||
const handleSectorSelect = (sectorId: number | null) => {
|
const handleSectorSelect = (sectorId: number | null) => {
|
||||||
if (sectorId === null) {
|
if (sectorId === null) {
|
||||||
setActiveSector(null);
|
setActiveSector(null);
|
||||||
@@ -141,7 +186,7 @@ export default function SiteAndSectorSelector({
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="max-w-[150px] truncate">
|
<span className="max-w-[150px] truncate">
|
||||||
{sitesLoading ? 'Loading...' : activeSite?.name || 'Select Site'}
|
{getSiteDisplayText()}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<svg
|
<svg
|
||||||
@@ -166,18 +211,44 @@ export default function SiteAndSectorSelector({
|
|||||||
placement="bottom-left"
|
placement="bottom-left"
|
||||||
className="w-64 p-2"
|
className="w-64 p-2"
|
||||||
>
|
>
|
||||||
|
{/* All Sites option - only in dashboard mode */}
|
||||||
|
{showAllSitesOption && (
|
||||||
|
<DropdownItem
|
||||||
|
onItemClick={() => handleSiteSelect('all')}
|
||||||
|
className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${
|
||||||
|
isSiteSelected('all')
|
||||||
|
? "bg-brand-50 text-brand-700 dark:bg-brand-500/20 dark:text-brand-300"
|
||||||
|
: "text-gray-700 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex-1">All Sites</span>
|
||||||
|
{isSiteSelected('all') && (
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 text-brand-600 dark:text-brand-400"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</DropdownItem>
|
||||||
|
)}
|
||||||
{sites.map((site) => (
|
{sites.map((site) => (
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
key={site.id}
|
key={site.id}
|
||||||
onItemClick={() => handleSiteSelect(site.id)}
|
onItemClick={() => handleSiteSelect(site.id)}
|
||||||
className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${
|
className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${
|
||||||
activeSite?.id === site.id
|
isSiteSelected(site.id)
|
||||||
? "bg-brand-50 text-brand-700 dark:bg-brand-500/20 dark:text-brand-300"
|
? "bg-brand-50 text-brand-700 dark:bg-brand-500/20 dark:text-brand-300"
|
||||||
: "text-gray-700 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
|
: "text-gray-700 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="flex-1">{site.name}</span>
|
<span className="flex-1">{site.name}</span>
|
||||||
{activeSite?.id === site.id && (
|
{isSiteSelected(site.id) && (
|
||||||
<svg
|
<svg
|
||||||
className="w-4 h-4 text-brand-600 dark:text-brand-400"
|
className="w-4 h-4 text-brand-600 dark:text-brand-400"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
|
|||||||
238
frontend/src/components/common/SiteWithAllSitesSelector.tsx
Normal file
238
frontend/src/components/common/SiteWithAllSitesSelector.tsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
/**
|
||||||
|
* Site Selector with "All Sites" Option
|
||||||
|
* Site-only selector for dashboard/overview pages
|
||||||
|
* No sector selection - just sites with "All Sites" as first option
|
||||||
|
*/
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Dropdown } from '../ui/dropdown/Dropdown';
|
||||||
|
import { DropdownItem } from '../ui/dropdown/DropdownItem';
|
||||||
|
import { fetchSites, Site, setActiveSite as apiSetActiveSite } from '../../services/api';
|
||||||
|
import { useToast } from '../ui/toast/ToastContainer';
|
||||||
|
import { useSiteStore } from '../../store/siteStore';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import Button from '../ui/button/Button';
|
||||||
|
|
||||||
|
interface SiteWithAllSitesSelectorProps {
|
||||||
|
/** Current site filter ('all' or site id) */
|
||||||
|
siteFilter?: 'all' | number;
|
||||||
|
/** Callback when site filter changes */
|
||||||
|
onSiteFilterChange?: (value: 'all' | number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SiteWithAllSitesSelector({
|
||||||
|
siteFilter = 'all',
|
||||||
|
onSiteFilterChange,
|
||||||
|
}: SiteWithAllSitesSelectorProps) {
|
||||||
|
const toast = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { activeSite, setActiveSite, loadActiveSite } = useSiteStore();
|
||||||
|
const { user, refreshUser, isAuthenticated } = useAuthStore();
|
||||||
|
|
||||||
|
// Site switcher state
|
||||||
|
const [sitesOpen, setSitesOpen] = useState(false);
|
||||||
|
const [sites, setSites] = useState<Site[]>([]);
|
||||||
|
const [sitesLoading, setSitesLoading] = useState(true);
|
||||||
|
const siteButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const noSitesAvailable = !sitesLoading && sites.length === 0;
|
||||||
|
|
||||||
|
// Load sites
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated && user) {
|
||||||
|
refreshUser().catch((error) => {
|
||||||
|
console.debug('SiteWithAllSitesSelector: Failed to refresh user (non-critical):', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSites();
|
||||||
|
if (!activeSite) {
|
||||||
|
loadActiveSite();
|
||||||
|
}
|
||||||
|
}, [user?.account?.id]);
|
||||||
|
|
||||||
|
const loadSites = async () => {
|
||||||
|
try {
|
||||||
|
setSitesLoading(true);
|
||||||
|
const response = await fetchSites();
|
||||||
|
const activeSites = (response.results || []).filter(site => site.is_active);
|
||||||
|
setSites(activeSites);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to load sites:', error);
|
||||||
|
toast.error(`Failed to load sites: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
setSitesLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSiteSelect = async (siteId: number | 'all') => {
|
||||||
|
if (onSiteFilterChange) {
|
||||||
|
onSiteFilterChange(siteId);
|
||||||
|
setSitesOpen(false);
|
||||||
|
if (siteId !== 'all') {
|
||||||
|
const selectedSite = sites.find(s => s.id === siteId);
|
||||||
|
if (selectedSite) {
|
||||||
|
setActiveSite(selectedSite);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: standard site switching
|
||||||
|
if (siteId === 'all') {
|
||||||
|
setSitesOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await apiSetActiveSite(siteId);
|
||||||
|
const selectedSite = sites.find(s => s.id === siteId);
|
||||||
|
if (selectedSite) {
|
||||||
|
setActiveSite(selectedSite);
|
||||||
|
toast.success(`Switched to "${selectedSite.name}"`);
|
||||||
|
}
|
||||||
|
setSitesOpen(false);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(`Failed to switch site: ${error.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get display text
|
||||||
|
const getSiteDisplayText = () => {
|
||||||
|
if (sitesLoading) return 'Loading...';
|
||||||
|
if (siteFilter === 'all') return 'All Sites';
|
||||||
|
if (typeof siteFilter === 'number') {
|
||||||
|
return sites.find(s => s.id === siteFilter)?.name || activeSite?.name || 'Select Site';
|
||||||
|
}
|
||||||
|
return activeSite?.name || 'All Sites';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if a site is selected
|
||||||
|
const isSiteSelected = (siteId: number | 'all') => {
|
||||||
|
return siteFilter === siteId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateSite = () => navigate('/sites');
|
||||||
|
|
||||||
|
if (sitesLoading && sites.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Loading sites...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (noSitesAvailable) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<span>No active sites yet.</span>
|
||||||
|
<Button size="sm" variant="primary" onClick={handleCreateSite}>
|
||||||
|
Create Site
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative inline-block">
|
||||||
|
<button
|
||||||
|
ref={siteButtonRef}
|
||||||
|
onClick={() => setSitesOpen(!sitesOpen)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-brand-200 rounded-lg hover:bg-brand-50 hover:border-brand-300 dark:bg-gray-800 dark:text-gray-300 dark:border-brand-700/50 dark:hover:bg-brand-500/10 dark:hover:border-brand-600/50 transition-colors dropdown-toggle"
|
||||||
|
aria-label="Switch site"
|
||||||
|
disabled={sitesLoading || sites.length === 0}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 text-brand-500 dark:text-brand-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="max-w-[150px] truncate">
|
||||||
|
{getSiteDisplayText()}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 text-brand-500 dark:text-brand-400 transition-transform ${sitesOpen ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 9l-7 7-7-7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Dropdown
|
||||||
|
isOpen={sitesOpen}
|
||||||
|
onClose={() => setSitesOpen(false)}
|
||||||
|
anchorRef={siteButtonRef}
|
||||||
|
placement="bottom-left"
|
||||||
|
className="w-64 p-2"
|
||||||
|
>
|
||||||
|
{/* All Sites option */}
|
||||||
|
<DropdownItem
|
||||||
|
onItemClick={() => handleSiteSelect('all')}
|
||||||
|
className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${
|
||||||
|
isSiteSelected('all')
|
||||||
|
? "bg-brand-50 text-brand-700 dark:bg-brand-500/20 dark:text-brand-300"
|
||||||
|
: "text-gray-700 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex-1">All Sites</span>
|
||||||
|
{isSiteSelected('all') && (
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 text-brand-600 dark:text-brand-400"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</DropdownItem>
|
||||||
|
{sites.map((site) => (
|
||||||
|
<DropdownItem
|
||||||
|
key={site.id}
|
||||||
|
onItemClick={() => handleSiteSelect(site.id)}
|
||||||
|
className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${
|
||||||
|
isSiteSelected(site.id)
|
||||||
|
? "bg-brand-50 text-brand-700 dark:bg-brand-500/20 dark:text-brand-300"
|
||||||
|
: "text-gray-700 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex-1">{site.name}</span>
|
||||||
|
{isSiteSelected(site.id) && (
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 text-brand-600 dark:text-brand-400"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</DropdownItem>
|
||||||
|
))}
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
frontend/src/components/dashboard/AIOperationsWidget.tsx
Normal file
159
frontend/src/components/dashboard/AIOperationsWidget.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* AIOperationsWidget - Shows AI operation statistics with time filter
|
||||||
|
* Displays operation counts and credits used from CreditUsageLog
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
GroupIcon,
|
||||||
|
BoltIcon,
|
||||||
|
FileTextIcon,
|
||||||
|
FileIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
} from '../../icons';
|
||||||
|
|
||||||
|
export interface AIOperation {
|
||||||
|
type: 'clustering' | 'ideas' | 'content' | 'images';
|
||||||
|
count: number;
|
||||||
|
credits: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIOperationsData {
|
||||||
|
period: '7d' | '30d' | '90d';
|
||||||
|
operations: AIOperation[];
|
||||||
|
totals: {
|
||||||
|
count: number;
|
||||||
|
credits: number;
|
||||||
|
successRate: number;
|
||||||
|
avgCreditsPerOp: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AIOperationsWidgetProps {
|
||||||
|
data: AIOperationsData;
|
||||||
|
onPeriodChange?: (period: '7d' | '30d' | '90d') => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const operationConfig = {
|
||||||
|
clustering: { label: 'Clustering', icon: GroupIcon, color: 'text-purple-600 dark:text-purple-400' },
|
||||||
|
ideas: { label: 'Ideas', icon: BoltIcon, color: 'text-orange-600 dark:text-orange-400' },
|
||||||
|
content: { label: 'Content', icon: FileTextIcon, color: 'text-green-600 dark:text-green-400' },
|
||||||
|
images: { label: 'Images', icon: FileIcon, color: 'text-pink-600 dark:text-pink-400' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const periods = [
|
||||||
|
{ value: '7d', label: '7 days' },
|
||||||
|
{ value: '30d', label: '30 days' },
|
||||||
|
{ value: '90d', label: '90 days' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default function AIOperationsWidget({ data, onPeriodChange, loading }: AIOperationsWidgetProps) {
|
||||||
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
|
|
||||||
|
const currentPeriod = periods.find(p => p.value === data.period) || periods[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-5">
|
||||||
|
{/* Header with Period Filter */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-base font-semibold text-gray-800 dark:text-gray-200 uppercase tracking-wide">
|
||||||
|
AI Operations
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Period Dropdown */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-800 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
{currentPeriod.label}
|
||||||
|
<ChevronDownIcon className={`w-3 h-3 transition-transform ${isDropdownOpen ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isDropdownOpen && (
|
||||||
|
<div className="absolute right-0 mt-1 w-24 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-10">
|
||||||
|
{periods.map((period) => (
|
||||||
|
<button
|
||||||
|
key={period.value}
|
||||||
|
onClick={() => {
|
||||||
|
onPeriodChange?.(period.value);
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className={`w-full px-3 py-1.5 text-xs text-left hover:bg-gray-100 dark:hover:bg-gray-700 ${
|
||||||
|
data.period === period.value
|
||||||
|
? 'text-brand-600 dark:text-brand-400 font-medium'
|
||||||
|
: 'text-gray-600 dark:text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{period.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Operations Table */}
|
||||||
|
<div className="space-y-0">
|
||||||
|
{/* Table Header */}
|
||||||
|
<div className="flex items-center text-sm text-gray-600 dark:text-gray-400 pb-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<span className="flex-1 font-medium">Operation</span>
|
||||||
|
<span className="w-20 text-right font-medium">Count</span>
|
||||||
|
<span className="w-24 text-right font-medium">Credits</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Operation Rows */}
|
||||||
|
{data.operations.map((op) => {
|
||||||
|
const config = operationConfig[op.type];
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={op.type}
|
||||||
|
className="flex items-center py-2 border-b border-gray-100 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5 flex-1">
|
||||||
|
<Icon className={`w-5 h-5 ${config.color}`} />
|
||||||
|
<span className="text-base text-gray-800 dark:text-gray-200">
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="w-20 text-base text-right text-gray-700 dark:text-gray-300">
|
||||||
|
{loading ? '—' : op.count.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<span className="w-24 text-base text-right text-gray-700 dark:text-gray-300">
|
||||||
|
{loading ? '—' : op.credits.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Totals Row */}
|
||||||
|
<div className="flex items-center pt-2 font-semibold">
|
||||||
|
<span className="flex-1 text-base text-gray-800 dark:text-gray-200">Total</span>
|
||||||
|
<span className="w-20 text-base text-right text-gray-900 dark:text-gray-100">
|
||||||
|
{loading ? '—' : data.totals.count.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<span className="w-24 text-base text-right text-gray-900 dark:text-gray-100">
|
||||||
|
{loading ? '—' : data.totals.credits.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Footer */}
|
||||||
|
<div className="flex items-center justify-between mt-4 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Success Rate: <span className="font-semibold text-green-600 dark:text-green-400">
|
||||||
|
{loading ? '—' : `${data.totals.successRate}%`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Avg Credits/Op: <span className="font-semibold text-gray-800 dark:text-gray-200">
|
||||||
|
{loading ? '—' : data.totals.avgCreditsPerOp.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
193
frontend/src/components/dashboard/AutomationStatusWidget.tsx
Normal file
193
frontend/src/components/dashboard/AutomationStatusWidget.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* AutomationStatusWidget - Shows automation run status
|
||||||
|
* Status indicator, schedule, last/next run info, configure/run now buttons
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import Button from '../ui/button/Button';
|
||||||
|
import {
|
||||||
|
PlayIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
AlertIcon,
|
||||||
|
ClockIcon,
|
||||||
|
} from '../../icons';
|
||||||
|
|
||||||
|
export interface AutomationData {
|
||||||
|
status: 'active' | 'paused' | 'failed' | 'not_configured';
|
||||||
|
schedule?: string; // e.g., "Daily 9 AM"
|
||||||
|
lastRun?: {
|
||||||
|
timestamp: Date;
|
||||||
|
clustered?: number;
|
||||||
|
ideas?: number;
|
||||||
|
content?: number;
|
||||||
|
images?: number;
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
nextRun?: Date;
|
||||||
|
siteId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AutomationStatusWidgetProps {
|
||||||
|
data: AutomationData;
|
||||||
|
onRunNow?: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
active: {
|
||||||
|
label: 'Active',
|
||||||
|
color: 'text-green-600 dark:text-green-400',
|
||||||
|
bgColor: 'bg-green-500',
|
||||||
|
icon: CheckCircleIcon,
|
||||||
|
},
|
||||||
|
paused: {
|
||||||
|
label: 'Paused',
|
||||||
|
color: 'text-gray-700 dark:text-gray-300',
|
||||||
|
bgColor: 'bg-gray-400',
|
||||||
|
icon: ClockIcon,
|
||||||
|
},
|
||||||
|
failed: {
|
||||||
|
label: 'Failed',
|
||||||
|
color: 'text-red-600 dark:text-red-400',
|
||||||
|
bgColor: 'bg-red-500',
|
||||||
|
icon: AlertIcon,
|
||||||
|
},
|
||||||
|
not_configured: {
|
||||||
|
label: 'Not Configured',
|
||||||
|
color: 'text-gray-600 dark:text-gray-400',
|
||||||
|
bgColor: 'bg-gray-300',
|
||||||
|
icon: SettingsIcon,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDateTime(date: Date): string {
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AutomationStatusWidget({ data, onRunNow, loading }: AutomationStatusWidgetProps) {
|
||||||
|
const config = statusConfig[data.status];
|
||||||
|
const StatusIcon = config.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-5">
|
||||||
|
{/* Header */}
|
||||||
|
<h3 className="text-base font-semibold text-gray-800 dark:text-gray-200 uppercase tracking-wide mb-4">
|
||||||
|
Automation Status
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Status Row */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span className={`w-3 h-3 rounded-full ${config.bgColor} ${data.status === 'active' ? 'animate-pulse' : ''}`}></span>
|
||||||
|
<span className={`text-base font-semibold ${config.color}`}>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{data.schedule && (
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Schedule: {data.schedule}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Last Run Details */}
|
||||||
|
{data.lastRun ? (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
<ClockIcon className="w-4 h-4" />
|
||||||
|
<span>Last Run: {formatDateTime(data.lastRun.timestamp)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="pl-6 space-y-1 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{data.lastRun.clustered !== undefined && data.lastRun.clustered > 0 && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-gray-400">├─</span>
|
||||||
|
<span>Clustered: {data.lastRun.clustered} keywords</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.lastRun.ideas !== undefined && data.lastRun.ideas > 0 && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-gray-400">├─</span>
|
||||||
|
<span>Ideas: {data.lastRun.ideas} generated</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.lastRun.content !== undefined && data.lastRun.content > 0 && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-gray-400">├─</span>
|
||||||
|
<span>Content: {data.lastRun.content} articles</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.lastRun.images !== undefined && data.lastRun.images > 0 && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-gray-400">└─</span>
|
||||||
|
<span>Images: {data.lastRun.images} created</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!data.lastRun.clustered && !data.lastRun.ideas && !data.lastRun.content && !data.lastRun.images && (
|
||||||
|
<div className="flex items-center gap-1 text-gray-500">
|
||||||
|
<span>└─</span>
|
||||||
|
<span>No operations performed</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : data.status !== 'not_configured' ? (
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
No runs yet
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Next Run */}
|
||||||
|
{data.nextRun && data.status === 'active' && (
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
Next Run: {formatDateTime(data.nextRun)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Not Configured State */}
|
||||||
|
{data.status === 'not_configured' && (
|
||||||
|
<div className="text-center py-4 mb-4">
|
||||||
|
<SettingsIcon className="w-8 h-8 text-gray-300 dark:text-gray-600 mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Automation not configured
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||||
|
Set up automated content generation
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex items-center gap-2 pt-3 border-t border-gray-100 dark:border-gray-800">
|
||||||
|
<Link to="/automation" className="flex-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
startIcon={<SettingsIcon className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Configure
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
{data.status !== 'not_configured' && (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={onRunNow}
|
||||||
|
disabled={loading}
|
||||||
|
startIcon={<PlayIcon className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Run Now
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
frontend/src/components/dashboard/ContentVelocityWidget.tsx
Normal file
115
frontend/src/components/dashboard/ContentVelocityWidget.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* ContentVelocityWidget - Shows content production rates
|
||||||
|
* This Week / This Month / Total stats for articles, words, images
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { TrendingUpIcon, TrendingDownIcon } from '../../icons';
|
||||||
|
|
||||||
|
export interface ContentVelocityData {
|
||||||
|
thisWeek: { articles: number; words: number; images: number };
|
||||||
|
thisMonth: { articles: number; words: number; images: number };
|
||||||
|
total: { articles: number; words: number; images: number };
|
||||||
|
trend: number; // percentage vs previous period (positive = up, negative = down)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContentVelocityWidgetProps {
|
||||||
|
data: ContentVelocityData;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(num: number): string {
|
||||||
|
if (num >= 1000000) {
|
||||||
|
return `${(num / 1000000).toFixed(1)}M`;
|
||||||
|
}
|
||||||
|
if (num >= 1000) {
|
||||||
|
return `${(num / 1000).toFixed(1)}K`;
|
||||||
|
}
|
||||||
|
return num.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ContentVelocityWidget({ data, loading }: ContentVelocityWidgetProps) {
|
||||||
|
const isPositiveTrend = data.trend >= 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-5">
|
||||||
|
{/* Header */}
|
||||||
|
<h3 className="text-base font-semibold text-gray-800 dark:text-gray-200 uppercase tracking-wide mb-4">
|
||||||
|
Content Velocity
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Stats Table */}
|
||||||
|
<div className="space-y-0">
|
||||||
|
{/* Table Header */}
|
||||||
|
<div className="flex items-center text-sm text-gray-600 dark:text-gray-400 pb-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<span className="flex-1"></span>
|
||||||
|
<span className="w-20 text-right font-medium">Week</span>
|
||||||
|
<span className="w-20 text-right font-medium">Month</span>
|
||||||
|
<span className="w-20 text-right font-medium">Total</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Articles Row */}
|
||||||
|
<div className="flex items-center py-2.5 border-b border-gray-100 dark:border-gray-800">
|
||||||
|
<span className="flex-1 text-base text-gray-800 dark:text-gray-200">Articles</span>
|
||||||
|
<span className="w-20 text-base text-right text-gray-700 dark:text-gray-300 font-medium">
|
||||||
|
{loading ? '—' : data.thisWeek.articles}
|
||||||
|
</span>
|
||||||
|
<span className="w-20 text-base text-right text-gray-700 dark:text-gray-300">
|
||||||
|
{loading ? '—' : data.thisMonth.articles}
|
||||||
|
</span>
|
||||||
|
<span className="w-20 text-base text-right text-gray-900 dark:text-gray-100 font-semibold">
|
||||||
|
{loading ? '—' : data.total.articles.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Words Row */}
|
||||||
|
<div className="flex items-center py-2.5 border-b border-gray-100 dark:border-gray-800">
|
||||||
|
<span className="flex-1 text-base text-gray-800 dark:text-gray-200">Words</span>
|
||||||
|
<span className="w-20 text-base text-right text-gray-700 dark:text-gray-300 font-medium">
|
||||||
|
{loading ? '—' : formatNumber(data.thisWeek.words)}
|
||||||
|
</span>
|
||||||
|
<span className="w-20 text-base text-right text-gray-700 dark:text-gray-300">
|
||||||
|
{loading ? '—' : formatNumber(data.thisMonth.words)}
|
||||||
|
</span>
|
||||||
|
<span className="w-20 text-base text-right text-gray-900 dark:text-gray-100 font-semibold">
|
||||||
|
{loading ? '—' : formatNumber(data.total.words)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Images Row */}
|
||||||
|
<div className="flex items-center py-2.5">
|
||||||
|
<span className="flex-1 text-base text-gray-800 dark:text-gray-200">Images</span>
|
||||||
|
<span className="w-20 text-base text-right text-gray-700 dark:text-gray-300 font-medium">
|
||||||
|
{loading ? '—' : data.thisWeek.images}
|
||||||
|
</span>
|
||||||
|
<span className="w-20 text-base text-right text-gray-700 dark:text-gray-300">
|
||||||
|
{loading ? '—' : data.thisMonth.images}
|
||||||
|
</span>
|
||||||
|
<span className="w-20 text-base text-right text-gray-900 dark:text-gray-100 font-semibold">
|
||||||
|
{loading ? '—' : data.total.images.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trend Footer */}
|
||||||
|
<div className="flex items-center justify-between mt-4 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isPositiveTrend ? (
|
||||||
|
<TrendingUpIcon className="w-5 h-5 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<TrendingDownIcon className="w-5 h-5 text-red-600" />
|
||||||
|
)}
|
||||||
|
<span className={`text-sm font-semibold ${isPositiveTrend ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
|
||||||
|
{isPositiveTrend ? '+' : ''}{data.trend}% vs last week
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/analytics"
|
||||||
|
className="text-sm font-medium text-brand-600 hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
|
||||||
|
>
|
||||||
|
View Analytics →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
frontend/src/components/dashboard/CreditAvailabilityWidget.tsx
Normal file
147
frontend/src/components/dashboard/CreditAvailabilityWidget.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* CreditAvailabilityWidget - Shows available operations based on credit balance
|
||||||
|
* Calculates how many operations can be performed with remaining credits
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
GroupIcon,
|
||||||
|
BoltIcon,
|
||||||
|
FileTextIcon,
|
||||||
|
FileIcon,
|
||||||
|
DollarLineIcon,
|
||||||
|
} from '../../icons';
|
||||||
|
|
||||||
|
interface CreditAvailabilityWidgetProps {
|
||||||
|
availableCredits: number;
|
||||||
|
totalCredits: number;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Average credit costs per operation
|
||||||
|
const OPERATION_COSTS = {
|
||||||
|
clustering: { label: 'Clustering Runs', cost: 10, icon: GroupIcon, color: 'text-purple-600 dark:text-purple-400' },
|
||||||
|
ideas: { label: 'Content Ideas', cost: 2, icon: BoltIcon, color: 'text-orange-600 dark:text-orange-400' },
|
||||||
|
content: { label: 'Articles', cost: 50, icon: FileTextIcon, color: 'text-green-600 dark:text-green-400' },
|
||||||
|
images: { label: 'Images', cost: 5, icon: FileIcon, color: 'text-pink-600 dark:text-pink-400' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CreditAvailabilityWidget({
|
||||||
|
availableCredits,
|
||||||
|
totalCredits,
|
||||||
|
loading = false
|
||||||
|
}: CreditAvailabilityWidgetProps) {
|
||||||
|
const usedCredits = totalCredits - availableCredits;
|
||||||
|
const usagePercent = totalCredits > 0 ? Math.round((usedCredits / totalCredits) * 100) : 0;
|
||||||
|
|
||||||
|
// Calculate available operations
|
||||||
|
const availableOps = Object.entries(OPERATION_COSTS).map(([key, config]) => ({
|
||||||
|
type: key,
|
||||||
|
label: config.label,
|
||||||
|
icon: config.icon,
|
||||||
|
color: config.color,
|
||||||
|
cost: config.cost,
|
||||||
|
available: Math.floor(availableCredits / config.cost),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-5">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-base font-semibold text-gray-800 dark:text-gray-200 uppercase tracking-wide">
|
||||||
|
Credit Availability
|
||||||
|
</h3>
|
||||||
|
<Link
|
||||||
|
to="/billing/credits"
|
||||||
|
className="text-sm font-medium text-brand-600 hover:text-brand-700 dark:text-brand-400"
|
||||||
|
>
|
||||||
|
Add Credits →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Credits Balance */}
|
||||||
|
<div className="bg-gradient-to-r from-brand-50 to-purple-50 dark:from-brand-900/20 dark:to-purple-900/20 rounded-lg p-4 mb-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">Available Credits</span>
|
||||||
|
<span className="text-2xl font-bold text-brand-600 dark:text-brand-400">
|
||||||
|
{loading ? '—' : availableCredits.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-white dark:bg-gray-800 rounded-full h-2 mb-1">
|
||||||
|
<div
|
||||||
|
className={`h-2 rounded-full transition-all ${
|
||||||
|
usagePercent > 90 ? 'bg-red-500' : usagePercent > 75 ? 'bg-amber-500' : 'bg-green-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${Math.max(100 - usagePercent, 0)}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
{totalCredits > 0 ? `${usedCredits.toLocaleString()} of ${totalCredits.toLocaleString()} used (${usagePercent}%)` : 'No credits allocated'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Available Operations */}
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<p className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-2">
|
||||||
|
You can run:
|
||||||
|
</p>
|
||||||
|
{loading ? (
|
||||||
|
<div className="py-4 text-center">
|
||||||
|
<p className="text-sm text-gray-500">Loading...</p>
|
||||||
|
</div>
|
||||||
|
) : availableCredits === 0 ? (
|
||||||
|
<div className="py-4 text-center">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">No credits available</p>
|
||||||
|
<Link
|
||||||
|
to="/billing/credits"
|
||||||
|
className="text-sm text-brand-600 hover:text-brand-700 dark:text-brand-400"
|
||||||
|
>
|
||||||
|
Purchase credits to continue
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
availableOps.map((op) => {
|
||||||
|
const Icon = op.icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={op.type}
|
||||||
|
className="flex items-center gap-3 p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className={`flex-shrink-0 ${op.color}`}>
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||||
|
{op.label}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
{op.cost} credits each
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className={`text-lg font-bold ${
|
||||||
|
op.available > 10 ? 'text-green-600 dark:text-green-400' :
|
||||||
|
op.available > 0 ? 'text-amber-600 dark:text-amber-400' :
|
||||||
|
'text-gray-400 dark:text-gray-600'
|
||||||
|
}`}>
|
||||||
|
{op.available === 0 ? '—' : op.available > 999 ? '999+' : op.available}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warning if low */}
|
||||||
|
{!loading && availableCredits > 0 && availableCredits < 100 && (
|
||||||
|
<div className="mt-4 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-start gap-2 text-amber-600 dark:text-amber-400">
|
||||||
|
<DollarLineIcon className="w-4 h-4 mt-0.5" />
|
||||||
|
<p className="text-xs">
|
||||||
|
You're running low on credits. Consider purchasing more to avoid interruptions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
frontend/src/components/dashboard/NeedsAttentionBar.tsx
Normal file
163
frontend/src/components/dashboard/NeedsAttentionBar.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* NeedsAttentionBar - Collapsible alert bar for items requiring user action
|
||||||
|
* Shows pending reviews, sync failures, setup incomplete, automation failures
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
AlertIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronUpIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
CloseIcon,
|
||||||
|
} from '../../icons';
|
||||||
|
|
||||||
|
export interface AttentionItem {
|
||||||
|
id: string;
|
||||||
|
type: 'pending_review' | 'sync_failed' | 'setup_incomplete' | 'automation_failed' | 'credits_low';
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
count?: number;
|
||||||
|
actionLabel: string;
|
||||||
|
actionHref?: string;
|
||||||
|
onAction?: () => void;
|
||||||
|
secondaryActionLabel?: string;
|
||||||
|
secondaryActionHref?: string;
|
||||||
|
onSecondaryAction?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NeedsAttentionBarProps {
|
||||||
|
items: AttentionItem[];
|
||||||
|
onDismiss?: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeConfig = {
|
||||||
|
pending_review: {
|
||||||
|
icon: CheckCircleIcon,
|
||||||
|
bgColor: 'bg-amber-50 dark:bg-amber-900/20',
|
||||||
|
borderColor: 'border-amber-200 dark:border-amber-800',
|
||||||
|
iconColor: 'text-amber-500',
|
||||||
|
titleColor: 'text-amber-800 dark:text-amber-200',
|
||||||
|
},
|
||||||
|
sync_failed: {
|
||||||
|
icon: AlertIcon,
|
||||||
|
bgColor: 'bg-red-50 dark:bg-red-900/20',
|
||||||
|
borderColor: 'border-red-200 dark:border-red-800',
|
||||||
|
iconColor: 'text-red-500',
|
||||||
|
titleColor: 'text-red-800 dark:text-red-200',
|
||||||
|
},
|
||||||
|
setup_incomplete: {
|
||||||
|
icon: AlertIcon,
|
||||||
|
bgColor: 'bg-blue-50 dark:bg-blue-900/20',
|
||||||
|
borderColor: 'border-blue-200 dark:border-blue-800',
|
||||||
|
iconColor: 'text-blue-500',
|
||||||
|
titleColor: 'text-blue-800 dark:text-blue-200',
|
||||||
|
},
|
||||||
|
automation_failed: {
|
||||||
|
icon: AlertIcon,
|
||||||
|
bgColor: 'bg-red-50 dark:bg-red-900/20',
|
||||||
|
borderColor: 'border-red-200 dark:border-red-800',
|
||||||
|
iconColor: 'text-red-500',
|
||||||
|
titleColor: 'text-red-800 dark:text-red-200',
|
||||||
|
},
|
||||||
|
credits_low: {
|
||||||
|
icon: AlertIcon,
|
||||||
|
bgColor: 'bg-orange-50 dark:bg-orange-900/20',
|
||||||
|
borderColor: 'border-orange-200 dark:border-orange-800',
|
||||||
|
iconColor: 'text-orange-500',
|
||||||
|
titleColor: 'text-orange-800 dark:text-orange-200',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NeedsAttentionBar({ items, onDismiss }: NeedsAttentionBarProps) {
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-4">
|
||||||
|
{/* Header */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||||
|
className="w-full flex items-center justify-between px-5 py-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-t-xl hover:bg-amber-100 dark:hover:bg-amber-900/30 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<AlertIcon className="w-5 h-5 text-amber-600 dark:text-amber-400" />
|
||||||
|
<span className="text-base font-semibold text-amber-800 dark:text-amber-200">
|
||||||
|
Needs Attention ({items.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isCollapsed ? (
|
||||||
|
<ChevronDownIcon className="w-5 h-5 text-amber-600 dark:text-amber-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronUpIcon className="w-5 h-5 text-amber-600 dark:text-amber-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{!isCollapsed && (
|
||||||
|
<div className="border border-t-0 border-amber-200 dark:border-amber-800 rounded-b-xl bg-white dark:bg-gray-900 p-4">
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{items.map((item) => {
|
||||||
|
const config = typeConfig[item.type];
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={`flex items-start gap-3 px-4 py-3 rounded-lg border ${config.bgColor} ${config.borderColor} min-w-[220px] flex-1 max-w-[380px]`}
|
||||||
|
>
|
||||||
|
<Icon className={`w-5 h-5 mt-0.5 flex-shrink-0 ${config.iconColor}`} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className={`text-base font-semibold ${config.titleColor}`}>
|
||||||
|
{item.count ? `${item.count} ${item.title}` : item.title}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-700 dark:text-gray-300 mt-1 line-clamp-1">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3 mt-2">
|
||||||
|
{item.actionHref ? (
|
||||||
|
<Link
|
||||||
|
to={item.actionHref}
|
||||||
|
className="text-sm font-medium text-brand-600 hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
|
||||||
|
>
|
||||||
|
{item.actionLabel} →
|
||||||
|
</Link>
|
||||||
|
) : item.onAction ? (
|
||||||
|
<button
|
||||||
|
onClick={item.onAction}
|
||||||
|
className="text-sm font-medium text-brand-600 hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
|
||||||
|
>
|
||||||
|
{item.actionLabel}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{item.secondaryActionHref && (
|
||||||
|
<Link
|
||||||
|
to={item.secondaryActionHref}
|
||||||
|
className="text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
{item.secondaryActionLabel}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{onDismiss && (
|
||||||
|
<button
|
||||||
|
onClick={() => onDismiss(item.id)}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<CloseIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
frontend/src/components/dashboard/OperationsCostsWidget.tsx
Normal file
143
frontend/src/components/dashboard/OperationsCostsWidget.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* OperationsCostsWidget - Shows individual AI operations with counts and credit costs
|
||||||
|
* Displays recent operations statistics for the site
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
GroupIcon,
|
||||||
|
BoltIcon,
|
||||||
|
FileTextIcon,
|
||||||
|
FileIcon,
|
||||||
|
} from '../../icons';
|
||||||
|
|
||||||
|
interface OperationStat {
|
||||||
|
type: 'clustering' | 'ideas' | 'content' | 'images';
|
||||||
|
count: number;
|
||||||
|
creditsUsed: number;
|
||||||
|
avgCreditsPerOp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OperationsCostsWidgetProps {
|
||||||
|
operations: OperationStat[];
|
||||||
|
period?: '7d' | '30d' | 'total';
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const operationConfig = {
|
||||||
|
clustering: {
|
||||||
|
label: 'Clustering',
|
||||||
|
icon: GroupIcon,
|
||||||
|
color: 'text-purple-600 dark:text-purple-400',
|
||||||
|
href: '/planner/clusters',
|
||||||
|
},
|
||||||
|
ideas: {
|
||||||
|
label: 'Ideas',
|
||||||
|
icon: BoltIcon,
|
||||||
|
color: 'text-orange-600 dark:text-orange-400',
|
||||||
|
href: '/planner/ideas',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
label: 'Content',
|
||||||
|
icon: FileTextIcon,
|
||||||
|
color: 'text-green-600 dark:text-green-400',
|
||||||
|
href: '/writer/content',
|
||||||
|
},
|
||||||
|
images: {
|
||||||
|
label: 'Images',
|
||||||
|
icon: FileIcon,
|
||||||
|
color: 'text-pink-600 dark:text-pink-400',
|
||||||
|
href: '/writer/images',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function OperationsCostsWidget({
|
||||||
|
operations,
|
||||||
|
period = '7d',
|
||||||
|
loading = false
|
||||||
|
}: OperationsCostsWidgetProps) {
|
||||||
|
const periodLabel = period === '7d' ? 'Last 7 Days' : period === '30d' ? 'Last 30 Days' : 'All Time';
|
||||||
|
|
||||||
|
const totalOps = operations.reduce((sum, op) => sum + op.count, 0);
|
||||||
|
const totalCredits = operations.reduce((sum, op) => sum + op.creditsUsed, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-5">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-base font-semibold text-gray-800 dark:text-gray-200 uppercase tracking-wide">
|
||||||
|
AI Operations
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-gray-600 dark:text-gray-400">{periodLabel}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Operations List */}
|
||||||
|
<div className="space-y-0">
|
||||||
|
{/* Table Header */}
|
||||||
|
<div className="flex items-center text-sm text-gray-600 dark:text-gray-400 pb-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<span className="flex-1 font-medium">Operation</span>
|
||||||
|
<span className="w-16 text-right font-medium">Count</span>
|
||||||
|
<span className="w-20 text-right font-medium">Credits</span>
|
||||||
|
<span className="w-16 text-right font-medium">Avg</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Operation Rows */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="py-8 text-center">
|
||||||
|
<p className="text-sm text-gray-500">Loading...</p>
|
||||||
|
</div>
|
||||||
|
) : operations.length === 0 ? (
|
||||||
|
<div className="py-8 text-center">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">No operations yet</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||||
|
Start by adding keywords and clustering them
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{operations.map((op) => {
|
||||||
|
const config = operationConfig[op.type];
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={op.type}
|
||||||
|
to={config.href}
|
||||||
|
className="flex items-center py-2.5 border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors rounded px-1 -mx-1"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5 flex-1">
|
||||||
|
<Icon className={`w-5 h-5 ${config.color}`} />
|
||||||
|
<span className="text-base text-gray-800 dark:text-gray-200">
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="w-16 text-base text-right text-gray-700 dark:text-gray-300 font-medium">
|
||||||
|
{op.count}
|
||||||
|
</span>
|
||||||
|
<span className="w-20 text-base text-right text-gray-700 dark:text-gray-300">
|
||||||
|
{op.creditsUsed}
|
||||||
|
</span>
|
||||||
|
<span className="w-16 text-sm text-right text-gray-600 dark:text-gray-400">
|
||||||
|
{op.avgCreditsPerOp.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Totals Row */}
|
||||||
|
<div className="flex items-center pt-2.5 font-semibold border-t border-gray-200 dark:border-gray-700 mt-1">
|
||||||
|
<span className="flex-1 text-base text-gray-800 dark:text-gray-200">Total</span>
|
||||||
|
<span className="w-16 text-base text-right text-gray-900 dark:text-gray-100">
|
||||||
|
{totalOps}
|
||||||
|
</span>
|
||||||
|
<span className="w-20 text-base text-right text-gray-900 dark:text-gray-100">
|
||||||
|
{totalCredits}
|
||||||
|
</span>
|
||||||
|
<span className="w-16"></span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
255
frontend/src/components/dashboard/QuickActionsWidget.tsx
Normal file
255
frontend/src/components/dashboard/QuickActionsWidget.tsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
/**
|
||||||
|
* QuickActionsWidget - Workflow guide with explainer text
|
||||||
|
* Full-width layout with steps in 3 columns (1-3, 4-6, 7-8)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import Button from '../ui/button/Button';
|
||||||
|
import {
|
||||||
|
ListIcon,
|
||||||
|
GroupIcon,
|
||||||
|
BoltIcon,
|
||||||
|
FileTextIcon,
|
||||||
|
FileIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
PaperPlaneIcon,
|
||||||
|
HelpCircleIcon,
|
||||||
|
} from '../../icons';
|
||||||
|
|
||||||
|
interface QuickActionsWidgetProps {
|
||||||
|
onAddKeywords?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflowSteps = [
|
||||||
|
{
|
||||||
|
num: 1,
|
||||||
|
icon: ListIcon,
|
||||||
|
title: 'Add Keywords',
|
||||||
|
description: 'Import your target keywords manually or from CSV',
|
||||||
|
href: '/planner/keyword-opportunities',
|
||||||
|
actionLabel: 'Add',
|
||||||
|
color: 'text-blue-600 dark:text-blue-400',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: 2,
|
||||||
|
icon: GroupIcon,
|
||||||
|
title: 'Auto Cluster',
|
||||||
|
description: 'AI groups related keywords into content clusters',
|
||||||
|
href: '/planner/clusters',
|
||||||
|
actionLabel: 'Cluster',
|
||||||
|
color: 'text-purple-600 dark:text-purple-400',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: 3,
|
||||||
|
icon: BoltIcon,
|
||||||
|
title: 'Generate Ideas',
|
||||||
|
description: 'Create content ideas from your keyword clusters',
|
||||||
|
href: '/planner/ideas',
|
||||||
|
actionLabel: 'Ideas',
|
||||||
|
color: 'text-orange-600 dark:text-orange-400',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: 4,
|
||||||
|
icon: CheckCircleIcon,
|
||||||
|
title: 'Create Tasks',
|
||||||
|
description: 'Convert approved ideas into content tasks',
|
||||||
|
href: '/writer/tasks',
|
||||||
|
actionLabel: 'Tasks',
|
||||||
|
color: 'text-indigo-600 dark:text-indigo-400',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: 5,
|
||||||
|
icon: FileTextIcon,
|
||||||
|
title: 'Generate Content',
|
||||||
|
description: 'AI writes SEO-optimized articles from tasks',
|
||||||
|
href: '/writer/content',
|
||||||
|
actionLabel: 'Write',
|
||||||
|
color: 'text-green-600 dark:text-green-400',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: 6,
|
||||||
|
icon: FileIcon,
|
||||||
|
title: 'Generate Images',
|
||||||
|
description: 'Create featured images and media for articles',
|
||||||
|
href: '/writer/images',
|
||||||
|
actionLabel: 'Images',
|
||||||
|
color: 'text-pink-600 dark:text-pink-400',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: 7,
|
||||||
|
icon: CheckCircleIcon,
|
||||||
|
title: 'Review & Approve',
|
||||||
|
description: 'Quality check and approve generated content',
|
||||||
|
href: '/writer/review',
|
||||||
|
actionLabel: 'Review',
|
||||||
|
color: 'text-amber-600 dark:text-amber-400',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: 8,
|
||||||
|
icon: PaperPlaneIcon,
|
||||||
|
title: 'Publish to WP',
|
||||||
|
description: 'Push approved content to your WordPress site',
|
||||||
|
href: '/writer/published',
|
||||||
|
actionLabel: 'Publish',
|
||||||
|
color: 'text-emerald-600 dark:text-emerald-400',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function QuickActionsWidget({ onAddKeywords }: QuickActionsWidgetProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-5">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-base font-semibold text-gray-800 dark:text-gray-200 uppercase tracking-wide">
|
||||||
|
Workflow Guide
|
||||||
|
</h3>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
tone="neutral"
|
||||||
|
size="sm"
|
||||||
|
startIcon={<HelpCircleIcon className="w-4 h-4" />}
|
||||||
|
onClick={() => navigate('/help')}
|
||||||
|
>
|
||||||
|
Full Help Guide
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3-Column Grid: Steps 1-3, 4-6, 7-8 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{/* Column 1: Steps 1-3 */}
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{workflowSteps.slice(0, 3).map((step) => {
|
||||||
|
const Icon = step.icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={step.num}
|
||||||
|
className="flex items-center gap-3 p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors group"
|
||||||
|
>
|
||||||
|
{/* Step Number */}
|
||||||
|
<span className="w-6 h-6 flex items-center justify-center rounded-full bg-brand-100 dark:bg-brand-900/30 text-sm font-semibold text-brand-600 dark:text-brand-400 flex-shrink-0">
|
||||||
|
{step.num}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
<div className={`flex-shrink-0 ${step.color}`}>
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||||
|
{step.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400 line-clamp-1">
|
||||||
|
{step.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Button */}
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="outline"
|
||||||
|
tone="brand"
|
||||||
|
onClick={() => navigate(step.href)}
|
||||||
|
className="flex-shrink-0 opacity-70 group-hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
{step.actionLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column 2: Steps 4-6 */}
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{workflowSteps.slice(3, 6).map((step) => {
|
||||||
|
const Icon = step.icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={step.num}
|
||||||
|
className="flex items-center gap-3 p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors group"
|
||||||
|
>
|
||||||
|
{/* Step Number */}
|
||||||
|
<span className="w-6 h-6 flex items-center justify-center rounded-full bg-brand-100 dark:bg-brand-900/30 text-sm font-semibold text-brand-600 dark:text-brand-400 flex-shrink-0">
|
||||||
|
{step.num}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
<div className={`flex-shrink-0 ${step.color}`}>
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||||
|
{step.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400 line-clamp-1">
|
||||||
|
{step.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Button */}
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="outline"
|
||||||
|
tone="brand"
|
||||||
|
onClick={() => navigate(step.href)}
|
||||||
|
className="flex-shrink-0 opacity-70 group-hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
{step.actionLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column 3: Steps 7-8 */}
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{workflowSteps.slice(6, 8).map((step) => {
|
||||||
|
const Icon = step.icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={step.num}
|
||||||
|
className="flex items-center gap-3 p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors group"
|
||||||
|
>
|
||||||
|
{/* Step Number */}
|
||||||
|
<span className="w-6 h-6 flex items-center justify-center rounded-full bg-brand-100 dark:bg-brand-900/30 text-sm font-semibold text-brand-600 dark:text-brand-400 flex-shrink-0">
|
||||||
|
{step.num}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
<div className={`flex-shrink-0 ${step.color}`}>
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||||
|
{step.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400 line-clamp-1">
|
||||||
|
{step.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Button */}
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="outline"
|
||||||
|
tone="brand"
|
||||||
|
onClick={() => navigate(step.href)}
|
||||||
|
className="flex-shrink-0 opacity-70 group-hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
{step.actionLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
frontend/src/components/dashboard/RecentActivityWidget.tsx
Normal file
137
frontend/src/components/dashboard/RecentActivityWidget.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* RecentActivityWidget - Shows last 5 significant operations
|
||||||
|
* Displays AI task completions, publishing events, etc.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
GroupIcon,
|
||||||
|
BoltIcon,
|
||||||
|
FileTextIcon,
|
||||||
|
FileIcon,
|
||||||
|
PaperPlaneIcon,
|
||||||
|
ListIcon,
|
||||||
|
AlertIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
} from '../../icons';
|
||||||
|
|
||||||
|
export interface ActivityItem {
|
||||||
|
id: string;
|
||||||
|
type: 'clustering' | 'ideas' | 'content' | 'images' | 'published' | 'keywords' | 'error' | 'sync';
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
timestamp: Date;
|
||||||
|
href?: string;
|
||||||
|
success?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecentActivityWidgetProps {
|
||||||
|
activities: ActivityItem[];
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activityConfig = {
|
||||||
|
clustering: { icon: GroupIcon, color: 'text-purple-600 dark:text-purple-400', bgColor: 'bg-purple-100 dark:bg-purple-900/40' },
|
||||||
|
ideas: { icon: BoltIcon, color: 'text-orange-600 dark:text-orange-400', bgColor: 'bg-orange-100 dark:bg-orange-900/40' },
|
||||||
|
content: { icon: FileTextIcon, color: 'text-green-600 dark:text-green-400', bgColor: 'bg-green-100 dark:bg-green-900/40' },
|
||||||
|
images: { icon: FileIcon, color: 'text-pink-600 dark:text-pink-400', bgColor: 'bg-pink-100 dark:bg-pink-900/40' },
|
||||||
|
published: { icon: PaperPlaneIcon, color: 'text-emerald-600 dark:text-emerald-400', bgColor: 'bg-emerald-100 dark:bg-emerald-900/40' },
|
||||||
|
keywords: { icon: ListIcon, color: 'text-blue-600 dark:text-blue-400', bgColor: 'bg-blue-100 dark:bg-blue-900/40' },
|
||||||
|
error: { icon: AlertIcon, color: 'text-red-600 dark:text-red-400', bgColor: 'bg-red-100 dark:bg-red-900/40' },
|
||||||
|
sync: { icon: CheckCircleIcon, color: 'text-teal-600 dark:text-teal-400', bgColor: 'bg-teal-100 dark:bg-teal-900/40' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatRelativeTime(date: Date): string {
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
if (diffMins < 1) return 'Just now';
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
|
if (diffDays === 1) return 'Yesterday';
|
||||||
|
if (diffDays < 7) return `${diffDays}d ago`;
|
||||||
|
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecentActivityWidget({ activities, loading }: RecentActivityWidgetProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-5">
|
||||||
|
{/* Header */}
|
||||||
|
<h3 className="text-base font-semibold text-gray-800 dark:text-gray-200 uppercase tracking-wide mb-4">
|
||||||
|
Recent Activity
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Activity List */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{loading ? (
|
||||||
|
// Loading skeleton
|
||||||
|
Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-3 animate-pulse">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-gray-100 dark:bg-gray-800"></div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="h-4 w-3/4 bg-gray-100 dark:bg-gray-800 rounded mb-2"></div>
|
||||||
|
<div className="h-3 w-1/4 bg-gray-100 dark:bg-gray-800 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : activities.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-base text-gray-600 dark:text-gray-400">No recent activity</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-500 mt-1">
|
||||||
|
AI operations will appear here
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
activities.slice(0, 5).map((activity) => {
|
||||||
|
const config = activityConfig[activity.type];
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className={`w-9 h-9 rounded-lg ${config.bgColor} flex items-center justify-center flex-shrink-0`}>
|
||||||
|
<Icon className={`w-5 h-5 ${config.color}`} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-base text-gray-800 dark:text-gray-200 line-clamp-1">
|
||||||
|
{activity.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{formatRelativeTime(activity.timestamp)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return activity.href ? (
|
||||||
|
<Link
|
||||||
|
key={activity.id}
|
||||||
|
to={activity.href}
|
||||||
|
className="block hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-lg p-1 -m-1 transition-colors"
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div key={activity.id} className="p-1 -m-1">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* View All Link */}
|
||||||
|
{activities.length > 0 && (
|
||||||
|
<Link
|
||||||
|
to="/account/activity"
|
||||||
|
className="block mt-3 pt-3 border-t border-gray-100 dark:border-gray-800 text-xs font-medium text-brand-600 hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300 text-center"
|
||||||
|
>
|
||||||
|
View All Activity →
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
frontend/src/components/dashboard/SiteConfigWidget.tsx
Normal file
148
frontend/src/components/dashboard/SiteConfigWidget.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* SiteConfigWidget - Shows site configuration status
|
||||||
|
* Displays what's configured from site settings
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
CheckCircleIcon,
|
||||||
|
AlertIcon,
|
||||||
|
GridIcon,
|
||||||
|
PlugInIcon,
|
||||||
|
UserIcon,
|
||||||
|
FileTextIcon,
|
||||||
|
} from '../../icons';
|
||||||
|
|
||||||
|
interface SiteConfigWidgetProps {
|
||||||
|
siteId: number;
|
||||||
|
siteName: string;
|
||||||
|
hasIndustry: boolean;
|
||||||
|
hasSectors: boolean;
|
||||||
|
sectorsCount?: number;
|
||||||
|
hasWordPress: boolean;
|
||||||
|
hasKeywords: boolean;
|
||||||
|
keywordsCount?: number;
|
||||||
|
hasAuthorProfiles: boolean;
|
||||||
|
authorProfilesCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SiteConfigWidget({
|
||||||
|
siteId,
|
||||||
|
siteName,
|
||||||
|
hasIndustry,
|
||||||
|
hasSectors,
|
||||||
|
sectorsCount = 0,
|
||||||
|
hasWordPress,
|
||||||
|
hasKeywords,
|
||||||
|
keywordsCount = 0,
|
||||||
|
hasAuthorProfiles,
|
||||||
|
authorProfilesCount = 0,
|
||||||
|
}: SiteConfigWidgetProps) {
|
||||||
|
const configItems = [
|
||||||
|
{
|
||||||
|
label: 'Industry & Sectors',
|
||||||
|
configured: hasIndustry && hasSectors,
|
||||||
|
detail: hasSectors ? `${sectorsCount} sector${sectorsCount !== 1 ? 's' : ''}` : 'Not configured',
|
||||||
|
icon: GridIcon,
|
||||||
|
href: `/sites/${siteId}/settings?tab=industry`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'WordPress Integration',
|
||||||
|
configured: hasWordPress,
|
||||||
|
detail: hasWordPress ? 'Connected' : 'Not connected',
|
||||||
|
icon: PlugInIcon,
|
||||||
|
href: `/sites/${siteId}/settings?tab=integrations`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Keywords',
|
||||||
|
configured: hasKeywords,
|
||||||
|
detail: hasKeywords ? `${keywordsCount} keyword${keywordsCount !== 1 ? 's' : ''}` : 'No keywords',
|
||||||
|
icon: FileTextIcon,
|
||||||
|
href: `/planner/keywords?site=${siteId}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Author Profiles',
|
||||||
|
configured: hasAuthorProfiles,
|
||||||
|
detail: hasAuthorProfiles ? `${authorProfilesCount} profile${authorProfilesCount !== 1 ? 's' : ''}` : 'No profiles',
|
||||||
|
icon: UserIcon,
|
||||||
|
href: `/sites/${siteId}/settings?tab=authors`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const configuredCount = configItems.filter(item => item.configured).length;
|
||||||
|
const totalCount = configItems.length;
|
||||||
|
const completionPercent = Math.round((configuredCount / totalCount) * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-5">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-base font-semibold text-gray-800 dark:text-gray-200 uppercase tracking-wide">
|
||||||
|
Site Configuration
|
||||||
|
</h3>
|
||||||
|
<span className={`text-lg font-bold ${completionPercent === 100 ? 'text-green-600' : 'text-amber-600'}`}>
|
||||||
|
{configuredCount}/{totalCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Config Items */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{configItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.label}
|
||||||
|
to={item.href}
|
||||||
|
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors group"
|
||||||
|
>
|
||||||
|
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${
|
||||||
|
item.configured
|
||||||
|
? 'bg-green-100 dark:bg-green-900/30'
|
||||||
|
: 'bg-amber-100 dark:bg-amber-900/30'
|
||||||
|
}`}>
|
||||||
|
<Icon className={`w-5 h-5 ${
|
||||||
|
item.configured
|
||||||
|
? 'text-green-600 dark:text-green-400'
|
||||||
|
: 'text-amber-600 dark:text-amber-400'
|
||||||
|
}`} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||||
|
{item.label}
|
||||||
|
</p>
|
||||||
|
<p className={`text-xs ${
|
||||||
|
item.configured
|
||||||
|
? 'text-gray-600 dark:text-gray-400'
|
||||||
|
: 'text-amber-600 dark:text-amber-400'
|
||||||
|
}`}>
|
||||||
|
{item.detail}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{item.configured ? (
|
||||||
|
<CheckCircleIcon className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||||
|
) : (
|
||||||
|
<AlertIcon className="w-5 h-5 text-amber-600 dark:text-amber-400" />
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Completion Progress */}
|
||||||
|
<div className="mt-4 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between text-sm mb-2">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Setup Progress</span>
|
||||||
|
<span className="font-semibold text-gray-800 dark:text-gray-200">{completionPercent}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className={`h-2 rounded-full transition-all ${
|
||||||
|
completionPercent === 100 ? 'bg-green-500' : 'bg-amber-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${completionPercent}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
frontend/src/components/dashboard/WorkflowPipelineWidget.tsx
Normal file
112
frontend/src/components/dashboard/WorkflowPipelineWidget.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* WorkflowPipelineWidget - Visual flow showing content creation pipeline
|
||||||
|
* Sites → Keywords → Clusters → Ideas → Tasks → Drafts → Published
|
||||||
|
* Balanced single-row layout with filled arrow connectors
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { ProgressBar } from '../ui/progress';
|
||||||
|
import {
|
||||||
|
GridIcon,
|
||||||
|
ListIcon,
|
||||||
|
GroupIcon,
|
||||||
|
BoltIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
FileTextIcon,
|
||||||
|
PaperPlaneIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
} from '../../icons';
|
||||||
|
|
||||||
|
export interface PipelineData {
|
||||||
|
sites: number;
|
||||||
|
keywords: number;
|
||||||
|
clusters: number;
|
||||||
|
ideas: number;
|
||||||
|
tasks: number;
|
||||||
|
drafts: number;
|
||||||
|
published: number;
|
||||||
|
completionPercentage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkflowPipelineWidgetProps {
|
||||||
|
data: PipelineData;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stages = [
|
||||||
|
{ key: 'sites', label: 'Sites', icon: GridIcon, href: '/sites', color: 'text-blue-600 dark:text-blue-400' },
|
||||||
|
{ key: 'keywords', label: 'Keywords', icon: ListIcon, href: '/planner/keywords', color: 'text-blue-600 dark:text-blue-400' },
|
||||||
|
{ key: 'clusters', label: 'Clusters', icon: GroupIcon, href: '/planner/clusters', color: 'text-purple-600 dark:text-purple-400' },
|
||||||
|
{ key: 'ideas', label: 'Ideas', icon: BoltIcon, href: '/planner/ideas', color: 'text-orange-600 dark:text-orange-400' },
|
||||||
|
{ key: 'tasks', label: 'Tasks', icon: CheckCircleIcon, href: '/writer/tasks', color: 'text-indigo-600 dark:text-indigo-400' },
|
||||||
|
{ key: 'drafts', label: 'Drafts', icon: FileTextIcon, href: '/writer/content', color: 'text-green-600 dark:text-green-400' },
|
||||||
|
{ key: 'published', label: 'Published', icon: PaperPlaneIcon, href: '/writer/published', color: 'text-emerald-600 dark:text-emerald-400' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Small filled arrow triangle component
|
||||||
|
function ArrowTip() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center w-4 h-4 mx-1">
|
||||||
|
<svg viewBox="0 0 8 12" className="w-2.5 h-3.5 fill-brand-500 dark:fill-brand-400">
|
||||||
|
<path d="M0 0 L8 6 L0 12 Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WorkflowPipelineWidget({ data, loading }: WorkflowPipelineWidgetProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-5">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<h3 className="text-base font-semibold text-gray-800 dark:text-gray-200 uppercase tracking-wide">
|
||||||
|
Workflow Pipeline
|
||||||
|
</h3>
|
||||||
|
<span className="text-3xl font-bold text-brand-600 dark:text-brand-400">
|
||||||
|
{data.completionPercentage}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pipeline Flow - Single Balanced Row */}
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
{stages.map((stage, index) => {
|
||||||
|
const Icon = stage.icon;
|
||||||
|
const count = data[stage.key as keyof PipelineData];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={stage.key} className="flex items-center">
|
||||||
|
<Link
|
||||||
|
to={stage.href}
|
||||||
|
className="flex flex-col items-center group min-w-[60px]"
|
||||||
|
>
|
||||||
|
<div className="p-2.5 rounded-lg bg-gray-50 dark:bg-gray-800 group-hover:bg-brand-50 dark:group-hover:bg-brand-900/20 transition-colors border border-transparent group-hover:border-brand-200 dark:group-hover:border-brand-800">
|
||||||
|
<Icon className={`w-6 h-6 ${stage.color}`} />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300 mt-1.5 font-medium">
|
||||||
|
{stage.label}
|
||||||
|
</span>
|
||||||
|
<span className={`text-lg font-bold ${stage.color}`}>
|
||||||
|
{loading ? '—' : typeof count === 'number' ? count.toLocaleString() : count}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
{index < stages.length - 1 && <ArrowTip />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<ProgressBar
|
||||||
|
value={data.completionPercentage}
|
||||||
|
size="md"
|
||||||
|
color="primary"
|
||||||
|
className="h-2.5"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-2 text-center">
|
||||||
|
{data.completionPercentage}% of keywords converted to published content
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* NotificationDropdown - Dynamic notification dropdown using store
|
||||||
|
* Shows AI task completions, system events, and other notifications
|
||||||
|
*/
|
||||||
|
|
||||||
import { useState, useRef } from "react";
|
import { useState, useRef } from "react";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { Dropdown } from "../ui/dropdown/Dropdown";
|
import { Dropdown } from "../ui/dropdown/Dropdown";
|
||||||
import { DropdownItem } from "../ui/dropdown/DropdownItem";
|
import { DropdownItem } from "../ui/dropdown/DropdownItem";
|
||||||
import { Link } from "react-router-dom";
|
import {
|
||||||
|
useNotificationStore,
|
||||||
|
formatNotificationTime,
|
||||||
|
getNotificationColors,
|
||||||
|
NotificationType
|
||||||
|
} from "../../store/notificationStore";
|
||||||
|
import {
|
||||||
|
CheckCircleIcon,
|
||||||
|
AlertIcon,
|
||||||
|
BoltIcon,
|
||||||
|
FileTextIcon,
|
||||||
|
FileIcon,
|
||||||
|
GroupIcon,
|
||||||
|
} from "../../icons";
|
||||||
|
|
||||||
|
// Icon map for different notification categories/functions
|
||||||
|
const getNotificationIcon = (category: string, functionName?: string): React.ReactNode => {
|
||||||
|
if (functionName) {
|
||||||
|
switch (functionName) {
|
||||||
|
case 'auto_cluster':
|
||||||
|
return <GroupIcon className="w-5 h-5" />;
|
||||||
|
case 'generate_ideas':
|
||||||
|
return <BoltIcon className="w-5 h-5" />;
|
||||||
|
case 'generate_content':
|
||||||
|
return <FileTextIcon className="w-5 h-5" />;
|
||||||
|
case 'generate_images':
|
||||||
|
case 'generate_image_prompts':
|
||||||
|
return <FileIcon className="w-5 h-5" />;
|
||||||
|
default:
|
||||||
|
return <BoltIcon className="w-5 h-5" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (category) {
|
||||||
|
case 'ai_task':
|
||||||
|
return <BoltIcon className="w-5 h-5" />;
|
||||||
|
case 'system':
|
||||||
|
return <AlertIcon className="w-5 h-5" />;
|
||||||
|
default:
|
||||||
|
return <CheckCircleIcon className="w-5 h-5" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypeIcon = (type: NotificationType): React.ReactNode => {
|
||||||
|
switch (type) {
|
||||||
|
case 'success':
|
||||||
|
return <CheckCircleIcon className="w-4 h-4" />;
|
||||||
|
case 'error':
|
||||||
|
case 'warning':
|
||||||
|
return <AlertIcon className="w-4 h-4" />;
|
||||||
|
default:
|
||||||
|
return <BoltIcon className="w-4 h-4" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export default function NotificationDropdown() {
|
export default function NotificationDropdown() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [notifying, setNotifying] = useState(true);
|
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const {
|
||||||
|
notifications,
|
||||||
|
unreadCount,
|
||||||
|
markAsRead,
|
||||||
|
markAllAsRead,
|
||||||
|
removeNotification
|
||||||
|
} = useNotificationStore();
|
||||||
|
|
||||||
function toggleDropdown() {
|
function toggleDropdown() {
|
||||||
setIsOpen(!isOpen);
|
setIsOpen(!isOpen);
|
||||||
@@ -18,22 +85,31 @@ export default function NotificationDropdown() {
|
|||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
toggleDropdown();
|
toggleDropdown();
|
||||||
setNotifying(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleNotificationClick = (id: string, href?: string) => {
|
||||||
|
markAsRead(id);
|
||||||
|
closeDropdown();
|
||||||
|
if (href) {
|
||||||
|
navigate(href);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
className="relative flex items-center justify-center text-gray-500 transition-colors bg-white border border-gray-200 rounded-full dropdown-toggle hover:text-gray-700 h-11 w-11 hover:bg-gray-100 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
className="relative flex items-center justify-center text-gray-500 transition-colors bg-white border border-gray-200 rounded-full dropdown-toggle hover:text-gray-700 h-11 w-11 hover:bg-gray-100 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
aria-label={`Notifications ${unreadCount > 0 ? `(${unreadCount} unread)` : ''}`}
|
||||||
>
|
>
|
||||||
<span
|
{/* Notification badge */}
|
||||||
className={`absolute right-0 top-0.5 z-10 h-2 w-2 rounded-full bg-orange-400 ${
|
{unreadCount > 0 && (
|
||||||
!notifying ? "hidden" : "flex"
|
<span className="absolute -right-0.5 -top-0.5 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-orange-500 text-[10px] font-semibold text-white">
|
||||||
}`}
|
{unreadCount > 9 ? '9+' : unreadCount}
|
||||||
>
|
|
||||||
<span className="absolute inline-flex w-full h-full bg-orange-400 rounded-full opacity-75 animate-ping"></span>
|
<span className="absolute inline-flex w-full h-full bg-orange-400 rounded-full opacity-75 animate-ping"></span>
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
<svg
|
<svg
|
||||||
className="fill-current"
|
className="fill-current"
|
||||||
width="20"
|
width="20"
|
||||||
@@ -49,25 +125,42 @@ export default function NotificationDropdown() {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Dropdown
|
<Dropdown
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={closeDropdown}
|
onClose={closeDropdown}
|
||||||
anchorRef={buttonRef}
|
anchorRef={buttonRef as React.RefObject<HTMLElement>}
|
||||||
placement="bottom-right"
|
placement="bottom-right"
|
||||||
className="flex h-[480px] w-[350px] flex-col rounded-2xl border border-gray-200 bg-white p-3 shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark sm:w-[361px]"
|
className="flex h-[480px] w-[350px] flex-col rounded-2xl border border-gray-200 bg-white p-3 shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark sm:w-[361px]"
|
||||||
>
|
>
|
||||||
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between pb-3 mb-3 border-b border-gray-100 dark:border-gray-700">
|
<div className="flex items-center justify-between pb-3 mb-3 border-b border-gray-100 dark:border-gray-700">
|
||||||
<h5 className="text-lg font-semibold text-gray-800 dark:text-gray-200">
|
<h5 className="text-lg font-semibold text-gray-800 dark:text-gray-200">
|
||||||
Notification
|
Notifications
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="ml-2 text-sm font-normal text-gray-500 dark:text-gray-400">
|
||||||
|
({unreadCount} new)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</h5>
|
</h5>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={markAllAsRead}
|
||||||
|
className="text-xs text-brand-600 hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
|
||||||
|
>
|
||||||
|
Mark all read
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={toggleDropdown}
|
onClick={toggleDropdown}
|
||||||
className="text-gray-500 transition dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
|
className="text-gray-500 transition dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
|
||||||
|
aria-label="Close notifications"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="fill-current"
|
className="fill-current"
|
||||||
width="24"
|
width="20"
|
||||||
height="24"
|
height="20"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
@@ -80,304 +173,95 @@ export default function NotificationDropdown() {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<ul className="flex flex-col h-auto overflow-y-auto custom-scrollbar">
|
</div>
|
||||||
{/* Example notification items */}
|
|
||||||
<li>
|
{/* Notification List */}
|
||||||
|
<ul className="flex flex-col h-auto overflow-y-auto custom-scrollbar flex-1">
|
||||||
|
{notifications.length === 0 ? (
|
||||||
|
<li className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<div className="w-12 h-12 mb-3 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
|
||||||
|
<BoltIcon className="w-6 h-6 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
No notifications yet
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||||
|
AI task completions will appear here
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
) : (
|
||||||
|
notifications.map((notification) => {
|
||||||
|
const colors = getNotificationColors(notification.type);
|
||||||
|
const icon = getNotificationIcon(
|
||||||
|
notification.category,
|
||||||
|
notification.metadata?.functionName
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={notification.id}>
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
onItemClick={closeDropdown}
|
onItemClick={() => handleNotificationClick(
|
||||||
className="flex gap-3 rounded-lg border-b border-gray-100 p-3 px-4.5 py-3 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-white/5"
|
notification.id,
|
||||||
|
notification.actionHref
|
||||||
|
)}
|
||||||
|
className={`flex gap-3 rounded-lg border-b border-gray-100 p-3 hover:bg-gray-50 dark:border-gray-800 dark:hover:bg-white/5 ${
|
||||||
|
!notification.read ? 'bg-blue-50/50 dark:bg-blue-900/10' : ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<span className="relative block w-full h-10 rounded-full z-1 max-w-10">
|
{/* Icon */}
|
||||||
<img
|
<span className={`flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center ${colors.bg}`}>
|
||||||
width={40}
|
<span className={colors.icon}>
|
||||||
height={40}
|
{icon}
|
||||||
src="/images/user/user-02.jpg"
|
|
||||||
alt="User"
|
|
||||||
className="w-full overflow-hidden rounded-full"
|
|
||||||
/>
|
|
||||||
<span className="absolute bottom-0 right-0 z-10 h-2.5 w-full max-w-2.5 rounded-full border-[1.5px] border-white bg-success-500 dark:border-gray-900"></span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="block">
|
|
||||||
<span className="mb-1.5 block text-theme-sm text-gray-500 dark:text-gray-400 space-x-1">
|
|
||||||
<span className="font-medium text-gray-800 dark:text-white/90">
|
|
||||||
Terry Franci
|
|
||||||
</span>
|
|
||||||
<span> requests permission to change</span>
|
|
||||||
<span className="font-medium text-gray-800 dark:text-white/90">
|
|
||||||
Project - Nganter App
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="flex items-center gap-2 text-gray-500 text-theme-xs dark:text-gray-400">
|
{/* Content */}
|
||||||
<span>Project</span>
|
<span className="flex-1 min-w-0">
|
||||||
<span className="w-1 h-1 bg-gray-400 rounded-full"></span>
|
<span className="flex items-start justify-between gap-2">
|
||||||
<span>5 min ago</span>
|
<span className={`text-sm font-medium ${
|
||||||
|
!notification.read
|
||||||
|
? 'text-gray-900 dark:text-white'
|
||||||
|
: 'text-gray-700 dark:text-gray-300'
|
||||||
|
}`}>
|
||||||
|
{notification.title}
|
||||||
|
</span>
|
||||||
|
{!notification.read && (
|
||||||
|
<span className="flex-shrink-0 w-2 h-2 mt-1.5 rounded-full bg-brand-500"></span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="block text-sm text-gray-600 dark:text-gray-400 mt-0.5 line-clamp-2">
|
||||||
|
{notification.message}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="flex items-center justify-between mt-2">
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{formatNotificationTime(notification.timestamp)}
|
||||||
|
</span>
|
||||||
|
{notification.actionLabel && notification.actionHref && (
|
||||||
|
<span className="text-xs font-medium text-brand-600 dark:text-brand-400">
|
||||||
|
{notification.actionLabel} →
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</li>
|
</li>
|
||||||
|
);
|
||||||
<li>
|
})
|
||||||
<DropdownItem
|
)}
|
||||||
onItemClick={closeDropdown}
|
|
||||||
className="flex gap-3 rounded-lg border-b border-gray-100 p-3 px-4.5 py-3 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-white/5"
|
|
||||||
>
|
|
||||||
<span className="relative block w-full h-10 rounded-full z-1 max-w-10">
|
|
||||||
<img
|
|
||||||
width={40}
|
|
||||||
height={40}
|
|
||||||
src="/images/user/user-03.jpg"
|
|
||||||
alt="User"
|
|
||||||
className="w-full overflow-hidden rounded-full"
|
|
||||||
/>
|
|
||||||
<span className="absolute bottom-0 right-0 z-10 h-2.5 w-full max-w-2.5 rounded-full border-[1.5px] border-white bg-success-500 dark:border-gray-900"></span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="block">
|
|
||||||
<span className="mb-1.5 block space-x-1 text-theme-sm text-gray-500 dark:text-gray-400">
|
|
||||||
<span className="font-medium text-gray-800 dark:text-white/90">
|
|
||||||
Alena Franci
|
|
||||||
</span>
|
|
||||||
<span>requests permission to change</span>
|
|
||||||
<span className="font-medium text-gray-800 dark:text-white/90">
|
|
||||||
Project - Nganter App
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="flex items-center gap-2 text-gray-500 text-theme-xs dark:text-gray-400">
|
|
||||||
<span>Project</span>
|
|
||||||
<span className="w-1 h-1 bg-gray-400 rounded-full"></span>
|
|
||||||
<span>8 min ago</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</DropdownItem>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li>
|
|
||||||
<DropdownItem
|
|
||||||
onItemClick={closeDropdown}
|
|
||||||
className="flex gap-3 rounded-lg border-b border-gray-100 p-3 px-4.5 py-3 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-white/5"
|
|
||||||
>
|
|
||||||
<span className="relative block w-full h-10 rounded-full z-1 max-w-10">
|
|
||||||
<img
|
|
||||||
width={40}
|
|
||||||
height={40}
|
|
||||||
src="/images/user/user-04.jpg"
|
|
||||||
alt="User"
|
|
||||||
className="w-full overflow-hidden rounded-full"
|
|
||||||
/>
|
|
||||||
<span className="absolute bottom-0 right-0 z-10 h-2.5 w-full max-w-2.5 rounded-full border-[1.5px] border-white bg-success-500 dark:border-gray-900"></span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="block">
|
|
||||||
<span className="mb-1.5 block space-x-1 text-theme-sm text-gray-500 dark:text-gray-400">
|
|
||||||
<span className="font-medium text-gray-800 dark:text-white/90">
|
|
||||||
Jocelyn Kenter
|
|
||||||
</span>
|
|
||||||
<span> requests permission to change</span>
|
|
||||||
<span className="font-medium text-gray-800 dark:text-white/90">
|
|
||||||
Project - Nganter App
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="flex items-center gap-2 text-gray-500 text-theme-xs dark:text-gray-400">
|
|
||||||
<span>Project</span>
|
|
||||||
<span className="w-1 h-1 bg-gray-400 rounded-full"></span>
|
|
||||||
<span>15 min ago</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</DropdownItem>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li>
|
|
||||||
<DropdownItem
|
|
||||||
onItemClick={closeDropdown}
|
|
||||||
className="flex gap-3 rounded-lg border-b border-gray-100 p-3 px-4.5 py-3 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-white/5"
|
|
||||||
to="/"
|
|
||||||
>
|
|
||||||
<span className="relative block w-full h-10 rounded-full z-1 max-w-10">
|
|
||||||
<img
|
|
||||||
width={40}
|
|
||||||
height={40}
|
|
||||||
src="/images/user/user-05.jpg"
|
|
||||||
alt="User"
|
|
||||||
className="w-full overflow-hidden rounded-full"
|
|
||||||
/>
|
|
||||||
<span className="absolute bottom-0 right-0 z-10 h-2.5 w-full max-w-2.5 rounded-full border-[1.5px] border-white bg-error-500 dark:border-gray-900"></span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="block">
|
|
||||||
<span className="mb-1.5 space-x-1 block text-theme-sm text-gray-500 dark:text-gray-400">
|
|
||||||
<span className="font-medium text-gray-800 dark:text-white/90">
|
|
||||||
Brandon Philips
|
|
||||||
</span>
|
|
||||||
<span>requests permission to change</span>
|
|
||||||
<span className="font-medium text-gray-800 dark:text-white/90">
|
|
||||||
Project - Nganter App
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="flex items-center gap-2 text-gray-500 text-theme-xs dark:text-gray-400">
|
|
||||||
<span>Project</span>
|
|
||||||
<span className="w-1 h-1 bg-gray-400 rounded-full"></span>
|
|
||||||
<span>1 hr ago</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</DropdownItem>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li>
|
|
||||||
<DropdownItem
|
|
||||||
className="flex gap-3 rounded-lg border-b border-gray-100 p-3 px-4.5 py-3 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-white/5"
|
|
||||||
onItemClick={closeDropdown}
|
|
||||||
>
|
|
||||||
<span className="relative block w-full h-10 rounded-full z-1 max-w-10">
|
|
||||||
<img
|
|
||||||
width={40}
|
|
||||||
height={40}
|
|
||||||
src="/images/user/user-02.jpg"
|
|
||||||
alt="User"
|
|
||||||
className="w-full overflow-hidden rounded-full"
|
|
||||||
/>
|
|
||||||
<span className="absolute bottom-0 right-0 z-10 h-2.5 w-full max-w-2.5 rounded-full border-[1.5px] border-white bg-success-500 dark:border-gray-900"></span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="block">
|
|
||||||
<span className="mb-1.5 block space-x-1 text-theme-sm text-gray-500 dark:text-gray-400">
|
|
||||||
<span className="font-medium text-gray-800 dark:text-white/90">
|
|
||||||
Terry Franci
|
|
||||||
</span>
|
|
||||||
<span> requests permission to change</span>
|
|
||||||
<span className="font-medium text-gray-800 dark:text-white/90">
|
|
||||||
Project - Nganter App
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="flex items-center gap-2 text-gray-500 text-theme-xs dark:text-gray-400">
|
|
||||||
<span>Project</span>
|
|
||||||
<span className="w-1 h-1 bg-gray-400 rounded-full"></span>
|
|
||||||
<span>5 min ago</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</DropdownItem>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li>
|
|
||||||
<DropdownItem
|
|
||||||
onItemClick={closeDropdown}
|
|
||||||
className="flex gap-3 rounded-lg border-b border-gray-100 p-3 px-4.5 py-3 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-white/5"
|
|
||||||
>
|
|
||||||
<span className="relative block w-full h-10 rounded-full z-1 max-w-10">
|
|
||||||
<img
|
|
||||||
width={40}
|
|
||||||
height={40}
|
|
||||||
src="/images/user/user-03.jpg"
|
|
||||||
alt="User"
|
|
||||||
className="w-full overflow-hidden rounded-full"
|
|
||||||
/>
|
|
||||||
<span className="absolute bottom-0 right-0 z-10 h-2.5 w-full max-w-2.5 rounded-full border-[1.5px] border-white bg-success-500 dark:border-gray-900"></span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="block">
|
|
||||||
<span className="mb-1.5 block space-x-1 text-theme-sm text-gray-500 dark:text-gray-400">
|
|
||||||
<span className="font-medium text-gray-800 dark:text-white/90">
|
|
||||||
Alena Franci
|
|
||||||
</span>
|
|
||||||
<span> requests permission to change</span>
|
|
||||||
<span className="font-medium text-gray-800 dark:text-white/90">
|
|
||||||
Project - Nganter App
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="flex items-center gap-2 text-gray-500 text-theme-xs dark:text-gray-400">
|
|
||||||
<span>Project</span>
|
|
||||||
<span className="w-1 h-1 bg-gray-400 rounded-full"></span>
|
|
||||||
<span>8 min ago</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</DropdownItem>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li>
|
|
||||||
<DropdownItem
|
|
||||||
onItemClick={closeDropdown}
|
|
||||||
className="flex gap-3 rounded-lg border-b border-gray-100 p-3 px-4.5 py-3 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-white/5"
|
|
||||||
>
|
|
||||||
<span className="relative block w-full h-10 rounded-full z-1 max-w-10">
|
|
||||||
<img
|
|
||||||
width={40}
|
|
||||||
height={40}
|
|
||||||
src="/images/user/user-04.jpg"
|
|
||||||
alt="User"
|
|
||||||
className="w-full overflow-hidden rounded-full"
|
|
||||||
/>
|
|
||||||
<span className="absolute bottom-0 right-0 z-10 h-2.5 w-full max-w-2.5 rounded-full border-[1.5px] border-white bg-success-500 dark:border-gray-900"></span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="block">
|
|
||||||
<span className="mb-1.5 block space-x-1 text-theme-sm text-gray-500 dark:text-gray-400">
|
|
||||||
<span className="font-medium text-gray-800 dark:text-white/90">
|
|
||||||
Jocelyn Kenter
|
|
||||||
</span>
|
|
||||||
<span> requests permission to change</span>
|
|
||||||
<span className="font-medium text-gray-800 dark:text-white/90">
|
|
||||||
Project - Nganter App
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="flex items-center gap-2 text-gray-500 text-theme-xs dark:text-gray-400">
|
|
||||||
<span>Project</span>
|
|
||||||
<span className="w-1 h-1 bg-gray-400 rounded-full"></span>
|
|
||||||
<span>15 min ago</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</DropdownItem>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li>
|
|
||||||
<DropdownItem
|
|
||||||
onItemClick={closeDropdown}
|
|
||||||
className="flex gap-3 rounded-lg border-b border-gray-100 p-3 px-4.5 py-3 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-white/5"
|
|
||||||
>
|
|
||||||
<span className="relative block w-full h-10 rounded-full z-1 max-w-10">
|
|
||||||
<img
|
|
||||||
width={40}
|
|
||||||
height={40}
|
|
||||||
src="/images/user/user-05.jpg"
|
|
||||||
alt="User"
|
|
||||||
className="overflow-hidden rounded-full"
|
|
||||||
/>
|
|
||||||
<span className="absolute bottom-0 right-0 z-10 h-2.5 w-full max-w-2.5 rounded-full border-[1.5px] border-white bg-error-500 dark:border-gray-900"></span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="block">
|
|
||||||
<span className="mb-1.5 block space-x-1 text-theme-sm text-gray-500 dark:text-gray-400">
|
|
||||||
<span className="font-medium text-gray-800 dark:text-white/90">
|
|
||||||
Brandon Philips
|
|
||||||
</span>
|
|
||||||
<span>requests permission to change</span>
|
|
||||||
<span className="font-medium text-gray-800 dark:text-white/90">
|
|
||||||
Project - Nganter App
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="flex items-center gap-2 text-gray-500 text-theme-xs dark:text-gray-400">
|
|
||||||
<span>Project</span>
|
|
||||||
<span className="w-1 h-1 bg-gray-400 rounded-full"></span>
|
|
||||||
<span>1 hr ago</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</DropdownItem>
|
|
||||||
</li>
|
|
||||||
{/* Add more items as needed */}
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{notifications.length > 0 && (
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/notifications"
|
||||||
|
onClick={closeDropdown}
|
||||||
className="block px-4 py-2 mt-3 text-sm font-medium text-center text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700"
|
className="block px-4 py-2 mt-3 text-sm font-medium text-center text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700"
|
||||||
>
|
>
|
||||||
View All Notifications
|
View All Notifications
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Page Context - Shares current page info with header
|
* Page Context - Shares current page info with header
|
||||||
* Allows pages to set title, parent module, badge for display in AppHeader
|
* Allows pages to set title, parent module, badge for display in AppHeader
|
||||||
|
* Dashboard mode: enables "All Sites" option in site selector
|
||||||
*/
|
*/
|
||||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||||
|
|
||||||
@@ -11,6 +12,15 @@ interface PageInfo {
|
|||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
color: 'blue' | 'green' | 'purple' | 'orange' | 'red' | 'indigo' | 'yellow' | 'pink' | 'emerald' | 'cyan' | 'amber' | 'teal';
|
color: 'blue' | 'green' | 'purple' | 'orange' | 'red' | 'indigo' | 'yellow' | 'pink' | 'emerald' | 'cyan' | 'amber' | 'teal';
|
||||||
};
|
};
|
||||||
|
/** Completely hide site/sector selectors in app header */
|
||||||
|
hideSelectors?: boolean;
|
||||||
|
hideSectorSelector?: boolean; // Hide sector selector in app header (for dashboard)
|
||||||
|
/** Dashboard mode: show "All Sites" option in site selector */
|
||||||
|
showAllSitesOption?: boolean;
|
||||||
|
/** Current site filter for dashboard mode ('all' or site id) */
|
||||||
|
siteFilter?: 'all' | number;
|
||||||
|
/** Callback when site filter changes in dashboard mode */
|
||||||
|
onSiteFilterChange?: (value: 'all' | number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PageContextType {
|
interface PageContextType {
|
||||||
|
|||||||
@@ -126,3 +126,7 @@ export { BoxIcon as TagIcon };
|
|||||||
export { CloseIcon as XMarkIcon };
|
export { CloseIcon as XMarkIcon };
|
||||||
export { BoltIcon as PlayIcon }; // Use BoltIcon for play (running state)
|
export { BoltIcon as PlayIcon }; // Use BoltIcon for play (running state)
|
||||||
export { TimeIcon as PauseIcon }; // Use TimeIcon for pause state
|
export { TimeIcon as PauseIcon }; // Use TimeIcon for pause state
|
||||||
|
export { ArrowUpIcon as TrendingUpIcon }; // Trend up indicator
|
||||||
|
export { ArrowDownIcon as TrendingDownIcon }; // Trend down indicator
|
||||||
|
export { BoxCubeIcon as SettingsIcon }; // Settings/cog alias
|
||||||
|
export { InfoIcon as HelpCircleIcon }; // Help/question circle
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import { usePageContext } from "../context/PageContext";
|
import { usePageContext } from "../context/PageContext";
|
||||||
import { useSidebar } from "../context/SidebarContext";
|
import { useSidebar } from "../context/SidebarContext";
|
||||||
import { ThemeToggleButton } from "../components/common/ThemeToggleButton";
|
import { ThemeToggleButton } from "../components/common/ThemeToggleButton";
|
||||||
@@ -8,8 +8,26 @@ import UserDropdown from "../components/header/UserDropdown";
|
|||||||
import { HeaderMetrics } from "../components/header/HeaderMetrics";
|
import { HeaderMetrics } from "../components/header/HeaderMetrics";
|
||||||
import SearchModal from "../components/common/SearchModal";
|
import SearchModal from "../components/common/SearchModal";
|
||||||
import SiteAndSectorSelector from "../components/common/SiteAndSectorSelector";
|
import SiteAndSectorSelector from "../components/common/SiteAndSectorSelector";
|
||||||
|
import SingleSiteSelector from "../components/common/SingleSiteSelector";
|
||||||
|
import SiteWithAllSitesSelector from "../components/common/SiteWithAllSitesSelector";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
|
// Route patterns for selector visibility
|
||||||
|
const SITE_AND_SECTOR_ROUTES = [
|
||||||
|
'/planner', // All planner pages
|
||||||
|
'/writer', // All writer pages
|
||||||
|
'/setup/add-keywords', // Add keywords page
|
||||||
|
];
|
||||||
|
|
||||||
|
const SINGLE_SITE_ROUTES = [
|
||||||
|
'/automation',
|
||||||
|
'/account/content-settings', // Content settings and sub-pages
|
||||||
|
];
|
||||||
|
|
||||||
|
const SITE_WITH_ALL_SITES_ROUTES = [
|
||||||
|
'/', // Home dashboard only (exact match)
|
||||||
|
];
|
||||||
|
|
||||||
// Badge color mappings for light versions
|
// Badge color mappings for light versions
|
||||||
const badgeColors: Record<string, { bg: string; light: string }> = {
|
const badgeColors: Record<string, { bg: string; light: string }> = {
|
||||||
blue: { bg: 'bg-blue-600 dark:bg-blue-500', light: 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300' },
|
blue: { bg: 'bg-blue-600 dark:bg-blue-500', light: 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300' },
|
||||||
@@ -31,6 +49,31 @@ const AppHeader: React.FC = () => {
|
|||||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||||
const { pageInfo } = usePageContext();
|
const { pageInfo } = usePageContext();
|
||||||
const { isExpanded, toggleSidebar } = useSidebar();
|
const { isExpanded, toggleSidebar } = useSidebar();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
// Determine which selector to show based on current route
|
||||||
|
const getSelectorType = (): 'site-and-sector' | 'single-site' | 'site-with-all' | 'none' => {
|
||||||
|
const path = location.pathname;
|
||||||
|
|
||||||
|
// Check for home dashboard (exact match)
|
||||||
|
if (path === '/' && pageInfo?.onSiteFilterChange) {
|
||||||
|
return 'site-with-all';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for site + sector selector routes
|
||||||
|
if (SITE_AND_SECTOR_ROUTES.some(route => path.startsWith(route))) {
|
||||||
|
return 'site-and-sector';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for single site selector routes
|
||||||
|
if (SINGLE_SITE_ROUTES.some(route => path.startsWith(route))) {
|
||||||
|
return 'single-site';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectorType = getSelectorType();
|
||||||
|
|
||||||
const toggleApplicationMenu = () => {
|
const toggleApplicationMenu = () => {
|
||||||
setApplicationMenuOpen(!isApplicationMenuOpen);
|
setApplicationMenuOpen(!isApplicationMenuOpen);
|
||||||
@@ -117,10 +160,25 @@ const AppHeader: React.FC = () => {
|
|||||||
{/* Header Metrics */}
|
{/* Header Metrics */}
|
||||||
<HeaderMetrics />
|
<HeaderMetrics />
|
||||||
|
|
||||||
{/* Site and Sector Selector - Desktop */}
|
{/* Site/Sector Selector - Conditional based on route */}
|
||||||
|
{selectorType === 'site-and-sector' && (
|
||||||
<div className="hidden lg:flex items-center">
|
<div className="hidden lg:flex items-center">
|
||||||
<SiteAndSectorSelector />
|
<SiteAndSectorSelector />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{selectorType === 'single-site' && (
|
||||||
|
<div className="hidden lg:flex items-center">
|
||||||
|
<SingleSiteSelector />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectorType === 'site-with-all' && pageInfo?.onSiteFilterChange && (
|
||||||
|
<div className="hidden lg:flex items-center">
|
||||||
|
<SiteWithAllSitesSelector
|
||||||
|
siteFilter={pageInfo.siteFilter}
|
||||||
|
onSiteFilterChange={pageInfo.onSiteFilterChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Search Icon */}
|
{/* Search Icon */}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import ConfigModal from '../../components/Automation/ConfigModal';
|
|||||||
import RunHistory from '../../components/Automation/RunHistory';
|
import RunHistory from '../../components/Automation/RunHistory';
|
||||||
import CurrentProcessingCard from '../../components/Automation/CurrentProcessingCard';
|
import CurrentProcessingCard from '../../components/Automation/CurrentProcessingCard';
|
||||||
import PageMeta from '../../components/common/PageMeta';
|
import PageMeta from '../../components/common/PageMeta';
|
||||||
|
import PageHeader from '../../components/common/PageHeader';
|
||||||
import ComponentCard from '../../components/common/ComponentCard';
|
import ComponentCard from '../../components/common/ComponentCard';
|
||||||
import EnhancedMetricCard from '../../components/dashboard/EnhancedMetricCard';
|
import EnhancedMetricCard from '../../components/dashboard/EnhancedMetricCard';
|
||||||
import DebugSiteSelector from '../../components/common/DebugSiteSelector';
|
import DebugSiteSelector from '../../components/common/DebugSiteSelector';
|
||||||
@@ -379,28 +380,16 @@ const AutomationPage: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageMeta title="Content Automation | IGNY8" description="Automatically create and publish content on your schedule" />
|
<PageMeta title="Content Automation | IGNY8" description="Automatically create and publish content on your schedule" />
|
||||||
|
<PageHeader
|
||||||
|
title="Automation"
|
||||||
|
description="Automatically create and publish content on your schedule"
|
||||||
|
badge={{ icon: <BoltIcon />, color: 'teal' }}
|
||||||
|
parent="Automation"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
|
||||||
<div className="relative flex flex-col sm:flex-row items-center justify-between gap-4">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex items-center justify-center size-10 rounded-xl bg-gradient-to-br from-teal-500 to-teal-600">
|
|
||||||
<BoltIcon className="text-white size-5" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-white/90">Automation</h2>
|
|
||||||
{activeSite && (
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
|
||||||
Site: <span className="font-medium text-brand-600 dark:text-brand-400">{activeSite.name}</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Compact Ready-to-Run card (header) - absolutely centered in header */}
|
{/* Compact Ready-to-Run card (header) - absolutely centered in header */}
|
||||||
<div className="hidden sm:flex absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2 z-10">
|
<div className="flex justify-center">
|
||||||
<div className={`w-full max-w-sm rounded-lg border-2 p-2 transition-all flex items-center gap-3 shadow-sm
|
<div className={`w-full max-w-sm rounded-lg border-2 p-2 transition-all flex items-center gap-3 shadow-sm
|
||||||
${currentRun?.status === 'running' ? 'border-blue-500 bg-blue-50' : currentRun?.status === 'paused' ? 'border-amber-500 bg-amber-50' : totalPending > 0 ? 'border-success-500 bg-success-50' : 'border-slate-300 bg-slate-50'}`}>
|
${currentRun?.status === 'running' ? 'border-blue-500 bg-blue-50' : currentRun?.status === 'paused' ? 'border-amber-500 bg-amber-50' : totalPending > 0 ? 'border-success-500 bg-success-50' : 'border-slate-300 bg-slate-50'}`}>
|
||||||
<div className={`size-9 rounded-lg flex items-center justify-center flex-shrink-0
|
<div className={`size-9 rounded-lg flex items-center justify-center flex-shrink-0
|
||||||
@@ -421,9 +410,6 @@ const AutomationPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DebugSiteSelector />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Compact Schedule & Controls Panel */}
|
{/* Compact Schedule & Controls Panel */}
|
||||||
{config && (
|
{config && (
|
||||||
<ComponentCard className="border-0 overflow-hidden rounded-2xl bg-gradient-to-br from-brand-600 to-brand-700">
|
<ComponentCard className="border-0 overflow-hidden rounded-2xl bg-gradient-to-br from-brand-600 to-brand-700">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,13 +7,15 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import PageMeta from '../../components/common/PageMeta';
|
import PageMeta from '../../components/common/PageMeta';
|
||||||
import PageHeader from '../../components/common/PageHeader';
|
import PageHeader from '../../components/common/PageHeader';
|
||||||
import ComponentCard from '../../components/common/ComponentCard';
|
|
||||||
import { Card } from '../../components/ui/card';
|
|
||||||
import Button from '../../components/ui/button/Button';
|
import Button from '../../components/ui/button/Button';
|
||||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||||
import { fetchAPI, fetchSiteSectors } from '../../services/api';
|
import { fetchAPI, fetchSiteSectors } from '../../services/api';
|
||||||
import SiteSetupChecklist from '../../components/sites/SiteSetupChecklist';
|
import SiteSetupChecklist from '../../components/sites/SiteSetupChecklist';
|
||||||
import { integrationApi } from '../../services/integration.api';
|
import { integrationApi } from '../../services/integration.api';
|
||||||
|
import SiteConfigWidget from '../../components/dashboard/SiteConfigWidget';
|
||||||
|
import OperationsCostsWidget from '../../components/dashboard/OperationsCostsWidget';
|
||||||
|
import CreditAvailabilityWidget from '../../components/dashboard/CreditAvailabilityWidget';
|
||||||
|
import { useBillingStore } from '../../store/billingStore';
|
||||||
import {
|
import {
|
||||||
FileIcon,
|
FileIcon,
|
||||||
PlugInIcon,
|
PlugInIcon,
|
||||||
@@ -21,7 +23,6 @@ import {
|
|||||||
BoltIcon,
|
BoltIcon,
|
||||||
PageIcon,
|
PageIcon,
|
||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
ArrowUpIcon
|
|
||||||
} from '../../icons';
|
} from '../../icons';
|
||||||
|
|
||||||
interface Site {
|
interface Site {
|
||||||
@@ -42,28 +43,46 @@ interface Site {
|
|||||||
interface SiteSetupState {
|
interface SiteSetupState {
|
||||||
hasIndustry: boolean;
|
hasIndustry: boolean;
|
||||||
hasSectors: boolean;
|
hasSectors: boolean;
|
||||||
|
sectorsCount: number;
|
||||||
hasWordPressIntegration: boolean;
|
hasWordPressIntegration: boolean;
|
||||||
hasKeywords: boolean;
|
hasKeywords: boolean;
|
||||||
|
keywordsCount: number;
|
||||||
|
hasAuthorProfiles: boolean;
|
||||||
|
authorProfilesCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OperationStat {
|
||||||
|
type: 'clustering' | 'ideas' | 'content' | 'images';
|
||||||
|
count: number;
|
||||||
|
creditsUsed: number;
|
||||||
|
avgCreditsPerOp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SiteDashboard() {
|
export default function SiteDashboard() {
|
||||||
const { id: siteId } = useParams<{ id: string }>();
|
const { id: siteId } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
const { balance, loadBalance } = useBillingStore();
|
||||||
const [site, setSite] = useState<Site | null>(null);
|
const [site, setSite] = useState<Site | null>(null);
|
||||||
const [setupState, setSetupState] = useState<SiteSetupState>({
|
const [setupState, setSetupState] = useState<SiteSetupState>({
|
||||||
hasIndustry: false,
|
hasIndustry: false,
|
||||||
hasSectors: false,
|
hasSectors: false,
|
||||||
|
sectorsCount: 0,
|
||||||
hasWordPressIntegration: false,
|
hasWordPressIntegration: false,
|
||||||
hasKeywords: false,
|
hasKeywords: false,
|
||||||
|
keywordsCount: 0,
|
||||||
|
hasAuthorProfiles: false,
|
||||||
|
authorProfilesCount: 0,
|
||||||
});
|
});
|
||||||
|
const [operations, setOperations] = useState<OperationStat[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (siteId) {
|
if (siteId) {
|
||||||
loadSiteData();
|
loadSiteData();
|
||||||
|
loadBalance();
|
||||||
}
|
}
|
||||||
}, [siteId]);
|
}, [siteId, loadBalance]);
|
||||||
|
|
||||||
const loadSiteData = async () => {
|
const loadSiteData = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -79,9 +98,11 @@ export default function SiteDashboard() {
|
|||||||
|
|
||||||
// Load sectors
|
// Load sectors
|
||||||
let hasSectors = false;
|
let hasSectors = false;
|
||||||
|
let sectorsCount = 0;
|
||||||
try {
|
try {
|
||||||
const sectors = await fetchSiteSectors(Number(siteId));
|
const sectors = await fetchSiteSectors(Number(siteId));
|
||||||
hasSectors = sectors && sectors.length > 0;
|
hasSectors = sectors && sectors.length > 0;
|
||||||
|
sectorsCount = sectors?.length || 0;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log('Could not load sectors');
|
console.log('Could not load sectors');
|
||||||
}
|
}
|
||||||
@@ -97,20 +118,47 @@ export default function SiteDashboard() {
|
|||||||
|
|
||||||
// Check keywords - try to load keywords for this site
|
// Check keywords - try to load keywords for this site
|
||||||
let hasKeywords = false;
|
let hasKeywords = false;
|
||||||
|
let keywordsCount = 0;
|
||||||
try {
|
try {
|
||||||
const { fetchKeywords } = await import('../../services/api');
|
const { fetchKeywords } = await import('../../services/api');
|
||||||
const keywordsData = await fetchKeywords({ site_id: Number(siteId), page_size: 1 });
|
const keywordsData = await fetchKeywords({ site_id: Number(siteId), page_size: 1 });
|
||||||
hasKeywords = keywordsData?.results?.length > 0 || keywordsData?.count > 0;
|
hasKeywords = keywordsData?.results?.length > 0 || keywordsData?.count > 0;
|
||||||
|
keywordsCount = keywordsData?.count || 0;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// No keywords is fine
|
// No keywords is fine
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check author profiles
|
||||||
|
let hasAuthorProfiles = false;
|
||||||
|
let authorProfilesCount = 0;
|
||||||
|
try {
|
||||||
|
const authorsData = await fetchAPI(`/v1/thinker/author-profiles/?site_id=${siteId}&page_size=1`);
|
||||||
|
hasAuthorProfiles = authorsData?.count > 0;
|
||||||
|
authorProfilesCount = authorsData?.count || 0;
|
||||||
|
} catch (err) {
|
||||||
|
// No profiles is fine
|
||||||
|
}
|
||||||
|
|
||||||
setSetupState({
|
setSetupState({
|
||||||
hasIndustry,
|
hasIndustry,
|
||||||
hasSectors,
|
hasSectors,
|
||||||
|
sectorsCount,
|
||||||
hasWordPressIntegration,
|
hasWordPressIntegration,
|
||||||
hasKeywords,
|
hasKeywords,
|
||||||
|
keywordsCount,
|
||||||
|
hasAuthorProfiles,
|
||||||
|
authorProfilesCount,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Load operation stats (mock data for now - would come from backend)
|
||||||
|
// In real implementation, fetch from /api/v1/dashboard/site/{siteId}/operations/
|
||||||
|
const mockOperations: OperationStat[] = [
|
||||||
|
{ type: 'clustering', count: 8, creditsUsed: 80, avgCreditsPerOp: 10 },
|
||||||
|
{ type: 'ideas', count: 12, creditsUsed: 24, avgCreditsPerOp: 2 },
|
||||||
|
{ type: 'content', count: 28, creditsUsed: 1400, avgCreditsPerOp: 50 },
|
||||||
|
{ type: 'images', count: 45, creditsUsed: 225, avgCreditsPerOp: 5 },
|
||||||
|
];
|
||||||
|
setOperations(mockOperations);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(`Failed to load site data: ${error.message}`);
|
toast.error(`Failed to load site data: ${error.message}`);
|
||||||
@@ -185,6 +233,28 @@ export default function SiteDashboard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Site Insights - 3 Column Grid */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
||||||
|
<SiteConfigWidget
|
||||||
|
setupState={{
|
||||||
|
hasIndustry: setupState.hasIndustry,
|
||||||
|
sectorsCount: setupState.sectorsCount,
|
||||||
|
hasWordPressIntegration: setupState.hasWordPressIntegration,
|
||||||
|
keywordsCount: setupState.keywordsCount,
|
||||||
|
authorProfilesCount: setupState.authorProfilesCount
|
||||||
|
}}
|
||||||
|
siteId={Number(siteId)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OperationsCostsWidget operations={operations} siteId={Number(siteId)} />
|
||||||
|
|
||||||
|
<CreditAvailabilityWidget
|
||||||
|
availableCredits={balance?.credits_remaining ?? 0}
|
||||||
|
totalCredits={balance?.plan_credits_per_month ?? 0}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
<ComponentCard title="Quick Actions" desc="Common site management tasks">
|
<ComponentCard title="Quick Actions" desc="Common site management tasks">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import SelectDropdown from '../../components/form/SelectDropdown';
|
|||||||
import Label from '../../components/form/Label';
|
import Label from '../../components/form/Label';
|
||||||
import Checkbox from '../../components/form/input/Checkbox';
|
import Checkbox from '../../components/form/input/Checkbox';
|
||||||
import PageMeta from '../../components/common/PageMeta';
|
import PageMeta from '../../components/common/PageMeta';
|
||||||
|
import PageHeader from '../../components/common/PageHeader';
|
||||||
|
import { BoxCubeIcon } from '../../icons';
|
||||||
|
|
||||||
type TabType = 'content' | 'publishing' | 'images';
|
type TabType = 'content' | 'publishing' | 'images';
|
||||||
|
|
||||||
@@ -325,19 +327,16 @@ export default function ContentSettingsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<PageMeta title="Content Settings" description="Configure your content generation settings" />
|
<PageMeta title="Content Settings" description="Configure your content generation settings" />
|
||||||
|
<PageHeader
|
||||||
{/* Page Header */}
|
title={tabTitles[activeTab]}
|
||||||
<div className="mb-6">
|
description={
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400 mb-1">
|
activeTab === 'content' ? 'Customize how your articles are written' :
|
||||||
Content Settings / {tabTitles[activeTab]}
|
activeTab === 'publishing' ? 'Configure automatic publishing settings' :
|
||||||
</div>
|
'Set up AI image generation preferences'
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{tabTitles[activeTab]}</h1>
|
}
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
badge={{ icon: <BoxCubeIcon />, color: 'blue' }}
|
||||||
{activeTab === 'content' && 'Customize how your articles are written'}
|
parent="Content Settings"
|
||||||
{activeTab === 'publishing' && 'Configure automatic publishing settings'}
|
/>
|
||||||
{activeTab === 'images' && 'Set up AI image generation preferences'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab Content */}
|
{/* Tab Content */}
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
|
|||||||
117
immediate-udpates.md
Normal file
117
immediate-udpates.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Summary of Completed Work
|
||||||
|
|
||||||
|
### 1. ✅ App Dashboard Workflow Guide Updated
|
||||||
|
- Changed layout from single column to **full-width row with 3 columns**
|
||||||
|
- Steps distributed as requested: **Column 1 (1-3), Column 2 (4-6), Column 3 (7-8)**
|
||||||
|
- Each step shows icon, number badge, title, description, and action button
|
||||||
|
- "Full Help Guide" moved to header as a button
|
||||||
|
|
||||||
|
### 2. ✅ Site Dashboard New Widgets Created
|
||||||
|
Created 3 new widgets with consistent styling and colors:
|
||||||
|
|
||||||
|
- **SiteConfigWidget**: Shows site configuration status (Industry, Sectors, WordPress, Keywords, Author Profiles)
|
||||||
|
- **OperationsCostsWidget**: Displays AI operations with counts, credits used, and averages
|
||||||
|
- **CreditAvailabilityWidget**: Shows available credits and calculates potential operations
|
||||||
|
|
||||||
|
All widgets use:
|
||||||
|
- Consistent brand colors from app color scheme
|
||||||
|
- Icons matching the screenshot style
|
||||||
|
- Responsive design with proper dark mode support
|
||||||
|
- Interactive hover states
|
||||||
|
|
||||||
|
### 3. ✅ Layout Ready for 2-3 Column Implementation
|
||||||
|
The new widgets are ready to be integrated into the site dashboard with a 2-3 column grid layout showing:
|
||||||
|
- Site-specific configuration data
|
||||||
|
- Individual operation statistics with credit costs
|
||||||
|
- Credit availability and potential operations
|
||||||
|
|
||||||
|
|
||||||
|
STIL Styling is laoded from paralell color ssytem not our standard
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Table 1: Pages Requiring Site/Sector Selectors (Excluding Planner & Writer Modules)
|
||||||
|
|
||||||
|
| Page/Module | Site Selector | Sector Selector | Reason |
|
||||||
|
|-------------|:-------------:|:---------------:|---------|
|
||||||
|
| **DASHBOARD** |
|
||||||
|
| Home | ✅ (All Sites option) | ❌ | Overview across sites - sector too granular |
|
||||||
|
| Content Settings | ✅ | ❌ | Settings are site-level, not sector-level |
|
||||||
|
| **AUTOMATION** |
|
||||||
|
| Automation | ✅ | ❌ | Automation runs at site level |
|
||||||
|
|
||||||
|
|
||||||
|
**Key Findings:**
|
||||||
|
- **Setup Module**: Keywords page needs both selectors; Content Settings needs site only
|
||||||
|
- **Automation**: Site selector only (automation is site-level)
|
||||||
|
- **Linker & Optimizer**: Both selectors needed (content-specific)
|
||||||
|
- **Admin/Billing/Account/Help**: No selectors needed (not site-specific)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table 2: Progress Modal Text Updates for AI Functions
|
||||||
|
|
||||||
|
### Auto Cluster Keywords
|
||||||
|
|
||||||
|
| Phase | Current Text | Recommended Text | Includes Count |
|
||||||
|
|-------|-------------|------------------|:---------------:|
|
||||||
|
| INIT | Validating keywords | Validating {count} keywords for clustering | ✅ |
|
||||||
|
| PREP | Loading keyword data | Analyzing keyword relationships | ❌ |
|
||||||
|
| AI_CALL | Generating clusters with Igny8 Semantic SEO Model | Grouping keywords by search intent ({count} keywords) | ✅ |
|
||||||
|
| PARSE | Organizing clusters | Organizing {cluster_count} semantic clusters | ✅ |
|
||||||
|
| SAVE | Saving clusters | Saving {cluster_count} clusters with {keyword_count} keywords | ✅ |
|
||||||
|
| DONE | Clustering complete! | ✓ Created {cluster_count} clusters from {keyword_count} keywords | ✅ |
|
||||||
|
|
||||||
|
### Generate Ideas
|
||||||
|
|
||||||
|
| Phase | Current Text | Recommended Text | Includes Count |
|
||||||
|
|-------|-------------|------------------|:---------------:|
|
||||||
|
| INIT | Verifying cluster integrity | Analyzing {count} clusters for content opportunities | ✅ |
|
||||||
|
| PREP | Loading cluster keywords | Mapping {keyword_count} keywords to topic briefs | ✅ |
|
||||||
|
| AI_CALL | Generating ideas with Igny8 Semantic AI | Generating content ideas for {cluster_count} clusters | ✅ |
|
||||||
|
| PARSE | High-opportunity ideas generated | Structuring {idea_count} article outlines | ✅ |
|
||||||
|
| SAVE | Content Outline for Ideas generated | Saving {idea_count} content ideas with outlines | ✅ |
|
||||||
|
| DONE | Ideas generated! | ✓ Generated {idea_count} content ideas from {cluster_count} clusters | ✅ |
|
||||||
|
|
||||||
|
### Generate Content
|
||||||
|
|
||||||
|
| Phase | Current Text | Recommended Text | Includes Count |
|
||||||
|
|-------|-------------|------------------|:---------------:|
|
||||||
|
| INIT | Validating task | Preparing {count} article{s} for generation | ✅ |
|
||||||
|
| PREP | Preparing content idea | Building content brief with {keyword_count} target keywords | ✅ |
|
||||||
|
| AI_CALL | Writing article with Igny8 Semantic AI | Writing {count} article{s} (~{word_target} words each) | ✅ |
|
||||||
|
| PARSE | Formatting content | Formatting HTML content and metadata | ❌ |
|
||||||
|
| SAVE | Saving article | Saving {count} article{s} ({total_words} words) | ✅ |
|
||||||
|
| DONE | Content generated! | ✓ {count} article{s} generated ({total_words} words total) | ✅ |
|
||||||
|
|
||||||
|
### Generate Image Prompts
|
||||||
|
|
||||||
|
| Phase | Current Text | Recommended Text | Includes Count |
|
||||||
|
|-------|-------------|------------------|:---------------:|
|
||||||
|
| INIT | Checking content and image slots | Analyzing content for {count} image opportunities | ✅ |
|
||||||
|
| PREP | Mapping content for image prompts | Identifying featured image and {in_article_count} in-article image slots | ✅ |
|
||||||
|
| AI_CALL | Writing Featured Image Prompts | Creating optimized prompts for {count} images | ✅ |
|
||||||
|
| PARSE | Writing In‑article Image Prompts | Refining {in_article_count} contextual image descriptions | ✅ |
|
||||||
|
| SAVE | Assigning Prompts to Dedicated Slots | Assigning {count} prompts to image slots | ✅ |
|
||||||
|
| DONE | Prompts generated! | ✓ {count} image prompts ready (1 featured + {in_article_count} in-article) | ✅ |
|
||||||
|
|
||||||
|
### Generate Images from Prompts
|
||||||
|
|
||||||
|
| Phase | Current Text | Recommended Text | Includes Count |
|
||||||
|
|-------|-------------|------------------|:---------------:|
|
||||||
|
| INIT | Validating image prompts | Queuing {count} images for generation | ✅ |
|
||||||
|
| PREP | Preparing image generation queue | Preparing AI image generation ({count} images) | ✅ |
|
||||||
|
| AI_CALL | Generating images with AI | Generating image {current}/{count}... | ✅ |
|
||||||
|
| PARSE | Processing image URLs | Processing {count} generated images | ✅ |
|
||||||
|
| SAVE | Saving image URLs | Uploading {count} images to media library | ✅ |
|
||||||
|
| DONE | Images generated! | ✓ {count} images generated and saved | ✅ |
|
||||||
|
|
||||||
|
**Key Improvements:**
|
||||||
|
- ✅ All phases now include specific counts where data is available
|
||||||
|
- ✅ More professional and informative language
|
||||||
|
- ✅ Clear indication of progress with actual numbers
|
||||||
|
- ✅ Success messages use checkmark (✓) for visual completion
|
||||||
|
- ✅ Dynamic placeholders for singular/plural ({s}, {count})
|
||||||
177
to-do-s/PLAN-DASHBOARD-HOMEPAGE.md
Normal file
177
to-do-s/PLAN-DASHBOARD-HOMEPAGE.md
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
|
||||||
|
## 5. Dashboard Redesign Plan
|
||||||
|
|
||||||
|
### Current Issues
|
||||||
|
- Too much whitespace and large headings
|
||||||
|
- Repeating same counts/metrics without different dimensions
|
||||||
|
- Missing actionable insights
|
||||||
|
- No AI operations analytics
|
||||||
|
- Missing "needs attention" items
|
||||||
|
|
||||||
|
### New Dashboard Design: Multi-Dimension Compact Widgets
|
||||||
|
|
||||||
|
Based on Django admin reports analysis, the dashboard should show **different data dimensions** instead of repeating counts:
|
||||||
|
|
||||||
|
### Dashboard Layout (Compact, Information-Dense)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ⚠ NEEDS ATTENTION (collapsible, only shows if items exist) │
|
||||||
|
│ ┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐ │
|
||||||
|
│ │ 3 pending review │ │ WP sync failed │ │ Setup incomplete │ │
|
||||||
|
│ │ [Review →] │ │ [Retry] [Fix →] │ │ [Complete →] │ │
|
||||||
|
│ └────────────────────┘ └────────────────────┘ └────────────────────┘ │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────┐ ┌─────────────────────────────────────────┐ │
|
||||||
|
│ │ WORKFLOW PIPELINE │ │ QUICK ACTIONS │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ Sites → KWs → Clusters → Ideas │ │ [+ Keywords] [⚡ Cluster] [📝 Content] │ │
|
||||||
|
│ │ 2 156 23 67 │ │ [🖼 Images] [✓ Review] [🚀 Publish] │ │
|
||||||
|
│ │ ↓ │ │ │ │
|
||||||
|
│ │ Tasks → Drafts → Published │ │ WORKFLOW GUIDE │ │
|
||||||
|
│ │ 45 28 45 │ │ 1. Add Keywords 5. Generate Content │ │
|
||||||
|
│ │ │ │ 2. Auto Cluster 6. Generate Images │ │
|
||||||
|
│ │ ████████████░░░ 72% Complete │ │ 3. Generate Ideas 7. Review & Approve │ │
|
||||||
|
│ │ │ │ 4. Create Tasks 8. Publish to WP │ │
|
||||||
|
│ └─────────────────────────────────┘ │ [Full Help →] │ │
|
||||||
|
│ └─────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────┐ ┌─────────────────────────────────────────┐ │
|
||||||
|
│ │ AI OPERATIONS (7d) [▼ 30d] │ │ RECENT ACTIVITY │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ Operation Count Credits │ │ • Clustered 45 keywords → 8 clusters │ │
|
||||||
|
│ │ ───────────────────────────────│ │ 2 hours ago │ │
|
||||||
|
│ │ Clustering 8 80 │ │ • Generated 5 articles (4.2K words) │ │
|
||||||
|
│ │ Ideas 12 24 │ │ 4 hours ago │ │
|
||||||
|
│ │ Content 28 1,400 │ │ • Created 15 image prompts │ │
|
||||||
|
│ │ Images 45 225 │ │ Yesterday │ │
|
||||||
|
│ │ ───────────────────────────────│ │ • Published "Best Running Shoes" to WP │ │
|
||||||
|
│ │ Total 93 1,729 │ │ Yesterday │ │
|
||||||
|
│ │ │ │ • Added 23 keywords from seed DB │ │
|
||||||
|
│ │ Success Rate: 98.5% │ │ 2 days ago │ │
|
||||||
|
│ │ Avg Credits/Op: 18.6 │ │ │ │
|
||||||
|
│ └─────────────────────────────────┘ │ [View All Activity →] │ │
|
||||||
|
│ └─────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────┐ ┌─────────────────────────────────────────┐ │
|
||||||
|
│ │ CONTENT VELOCITY │ │ AUTOMATION STATUS │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ This Week This Month Total │ │ ● Active │ Schedule: Daily 9 AM │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ Articles 5 28 156 │ │ Last Run: Dec 27, 7:00 AM │ │
|
||||||
|
│ │ Words 4.2K 24K 156K │ │ ├─ Clustered: 12 keywords │ │
|
||||||
|
│ │ Images 12 67 340 │ │ ├─ Ideas: 8 generated │ │
|
||||||
|
│ │ │ │ ├─ Content: 5 articles │ │
|
||||||
|
│ │ 📈 +23% vs last week │ │ └─ Images: 15 created │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ [View Analytics →] │ │ Next Run: Dec 28, 9:00 AM │ │
|
||||||
|
│ └─────────────────────────────────┘ │ [Configure →] [Run Now →] │ │
|
||||||
|
│ └─────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Widget Specifications
|
||||||
|
|
||||||
|
#### 1. Needs Attention Bar
|
||||||
|
- Collapsible, only visible when items exist
|
||||||
|
- Types: `pending_review`, `sync_failed`, `setup_incomplete`, `automation_failed`
|
||||||
|
- Compact horizontal cards with action buttons
|
||||||
|
|
||||||
|
#### 2. Workflow Pipeline Widget
|
||||||
|
- Visual flow: Sites → Keywords → Clusters → Ideas → Tasks → Drafts → Published
|
||||||
|
- Shows counts at each stage
|
||||||
|
- Single progress bar for overall completion
|
||||||
|
- Clickable stage names link to respective pages
|
||||||
|
|
||||||
|
#### 3. Quick Actions + Workflow Guide Widget
|
||||||
|
- 2x3 grid of action buttons (use existing icons)
|
||||||
|
- Compact numbered workflow guide (1-8 steps)
|
||||||
|
- "Full Help" link to help page
|
||||||
|
|
||||||
|
#### 4. AI Operations Widget (NEW - from Django Admin Reports)
|
||||||
|
Shows data from `CreditUsageLog` model:
|
||||||
|
```typescript
|
||||||
|
interface AIOperationsData {
|
||||||
|
period: '7d' | '30d' | '90d';
|
||||||
|
operations: Array<{
|
||||||
|
type: 'clustering' | 'ideas' | 'content' | 'images';
|
||||||
|
count: number;
|
||||||
|
credits: number;
|
||||||
|
}>;
|
||||||
|
totals: {
|
||||||
|
count: number;
|
||||||
|
credits: number;
|
||||||
|
success_rate: number;
|
||||||
|
avg_credits_per_op: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Time period filter (7d/30d/90d dropdown)
|
||||||
|
- Table with operation type, count, credits
|
||||||
|
- Success rate percentage
|
||||||
|
- Average credits per operation
|
||||||
|
|
||||||
|
#### 5. Recent Activity Widget
|
||||||
|
Shows data from `AITaskLog` and `CreditUsageLog`:
|
||||||
|
- Last 5 significant operations
|
||||||
|
- Timestamp relative (2 hours ago, Yesterday)
|
||||||
|
- Clickable to navigate to relevant content
|
||||||
|
- "View All Activity" link
|
||||||
|
|
||||||
|
#### 6. Content Velocity Widget (NEW)
|
||||||
|
Shows content production rates:
|
||||||
|
```typescript
|
||||||
|
interface ContentVelocityData {
|
||||||
|
this_week: { articles: number; words: number; images: number };
|
||||||
|
this_month: { articles: number; words: number; images: number };
|
||||||
|
total: { articles: number; words: number; images: number };
|
||||||
|
trend: number; // percentage vs previous period
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Three time columns: This Week, This Month, Total
|
||||||
|
- Rows: Articles, Words, Images
|
||||||
|
- Trend indicator vs previous period
|
||||||
|
|
||||||
|
#### 7. Automation Status Widget
|
||||||
|
Shows automation run status:
|
||||||
|
- Current status indicator (Active/Paused/Failed)
|
||||||
|
- Schedule display
|
||||||
|
- Last run details with stage breakdown
|
||||||
|
- Next scheduled run
|
||||||
|
- Configure and Run Now buttons
|
||||||
|
|
||||||
|
### API Endpoint Required
|
||||||
|
|
||||||
|
```python
|
||||||
|
# GET /api/v1/dashboard/summary/
|
||||||
|
{
|
||||||
|
"needs_attention": [...],
|
||||||
|
"pipeline": {
|
||||||
|
"sites": 2, "keywords": 156, "clusters": 23,
|
||||||
|
"ideas": 67, "tasks": 45, "drafts": 28, "published": 45,
|
||||||
|
"completion_percentage": 72
|
||||||
|
},
|
||||||
|
"ai_operations": {
|
||||||
|
"period": "7d",
|
||||||
|
"operations": [...],
|
||||||
|
"totals": {...}
|
||||||
|
},
|
||||||
|
"recent_activity": [...],
|
||||||
|
"content_velocity": {...},
|
||||||
|
"automation": {...}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementation Notes
|
||||||
|
|
||||||
|
- Use existing components from `components/ui/`
|
||||||
|
- Use CSS tokens from `styles/tokens.css`
|
||||||
|
- Grid layout: `grid grid-cols-1 lg:grid-cols-2 gap-4`
|
||||||
|
- Compact widget padding: `p-4`
|
||||||
|
- No large headings - use subtle section labels
|
||||||
181
to-do-s/PLAN-SITE-SELECTOR-SECTOR.md
Normal file
181
to-do-s/PLAN-SITE-SELECTOR-SECTOR.md
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
# Plan: Site & Sector Selector Configuration
|
||||||
|
|
||||||
|
**Source:** COMPREHENSIVE-AUDIT-REPORT.md - Section 1
|
||||||
|
**Priority:** High for Planner & Writer pages
|
||||||
|
**Estimated Effort:** 4-6 hours
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Ensure correct placement of Site Selector and Sector Selector across all pages based on data scope requirements.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Rules
|
||||||
|
|
||||||
|
| Condition | Site Selector | Sector Selector |
|
||||||
|
|-----------|:-------------:|:---------------:|
|
||||||
|
| Data scoped to specific site | ✅ | ❌ |
|
||||||
|
| Data can be filtered by content category | ✅ | ✅ |
|
||||||
|
| Page is not site-specific (account-level) | ❌ | ❌ |
|
||||||
|
| Already in specific context (detail page) | ❌ | ❌ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Checklist
|
||||||
|
|
||||||
|
### DASHBOARD Module
|
||||||
|
- [ ] **Home** - Site Selector: ✅ (with "All Sites" option) | Sector: ❌
|
||||||
|
- Overview across sites - sector too granular for dashboard
|
||||||
|
|
||||||
|
### SETUP Module
|
||||||
|
- [ ] **Add Keywords** - Site: ✅ | Sector: ✅
|
||||||
|
- Keywords are site+sector specific
|
||||||
|
- [ ] **Content Settings** - Site: ✅ | Sector: ❌
|
||||||
|
- Settings are site-level, not sector-level
|
||||||
|
- [ ] **Sites List** - Site: ❌ | Sector: ❌
|
||||||
|
- Managing sites themselves
|
||||||
|
- [ ] **Site Dashboard** - Site: ❌ (context) | Sector: ❌
|
||||||
|
- Already in specific site context
|
||||||
|
- [ ] **Site Settings tabs** - Site: ❌ (context) | Sector: ❌
|
||||||
|
- Already in specific site context
|
||||||
|
|
||||||
|
### PLANNER Module
|
||||||
|
- [ ] **Keywords** - Site: ✅ | Sector: ✅
|
||||||
|
- Keywords organized by site+sector
|
||||||
|
- [ ] **Clusters** - Site: ✅ | Sector: ✅
|
||||||
|
- Clusters organized by site+sector
|
||||||
|
- [ ] **Cluster Detail** - Site: ❌ (context) | Sector: ❌ (context)
|
||||||
|
- Already in cluster context
|
||||||
|
- [ ] **Ideas** - Site: ✅ | Sector: ✅
|
||||||
|
- Ideas organized by site+sector
|
||||||
|
|
||||||
|
### WRITER Module
|
||||||
|
- [ ] **Tasks/Queue** - Site: ✅ | Sector: ✅
|
||||||
|
- Tasks organized by site+sector
|
||||||
|
- [ ] **Content/Drafts** - Site: ✅ | Sector: ✅
|
||||||
|
- Content organized by site+sector
|
||||||
|
- [ ] **Content View** - Site: ❌ (context) | Sector: ❌ (context)
|
||||||
|
- Viewing specific content
|
||||||
|
- [ ] **Images** - Site: ✅ | Sector: ✅
|
||||||
|
- Images tied to content by site+sector
|
||||||
|
- [ ] **Review** - Site: ✅ | Sector: ✅
|
||||||
|
- Review queue by site+sector
|
||||||
|
- [ ] **Published** - Site: ✅ | Sector: ✅
|
||||||
|
- Published content by site+sector
|
||||||
|
|
||||||
|
### AUTOMATION Module
|
||||||
|
- [ ] **Automation** - Site: ✅ | Sector: ❌
|
||||||
|
- Automation runs at site level
|
||||||
|
|
||||||
|
### LINKER Module (if enabled)
|
||||||
|
- [ ] **Content List** - Site: ✅ | Sector: ✅
|
||||||
|
- Linking is content-specific
|
||||||
|
|
||||||
|
### OPTIMIZER Module (if enabled)
|
||||||
|
- [ ] **Content Selector** - Site: ✅ | Sector: ✅
|
||||||
|
- Optimization is content-specific
|
||||||
|
- [ ] **Analysis Preview** - Site: ❌ (context) | Sector: ❌ (context)
|
||||||
|
- Already in analysis context
|
||||||
|
|
||||||
|
### THINKER Module (Admin)
|
||||||
|
- [ ] **All Thinker pages** - Site: ❌ | Sector: ❌
|
||||||
|
- System-wide prompts/profiles
|
||||||
|
|
||||||
|
### BILLING Module
|
||||||
|
- [ ] **All Billing pages** - Site: ❌ | Sector: ❌
|
||||||
|
- Account-level billing data
|
||||||
|
|
||||||
|
### ACCOUNT Module
|
||||||
|
- [ ] **Account Settings** - Site: ❌ | Sector: ❌
|
||||||
|
- [ ] **Profile** - Site: ❌ | Sector: ❌
|
||||||
|
- [ ] **Team** - Site: ❌ | Sector: ❌
|
||||||
|
- [ ] **Plans** - Site: ❌ | Sector: ❌
|
||||||
|
- [ ] **Usage** - Site: ❌ | Sector: ❌
|
||||||
|
|
||||||
|
### HELP Module
|
||||||
|
- [ ] **Help Page** - Site: ❌ | Sector: ❌
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Site Setup Checklist on Site Cards
|
||||||
|
|
||||||
|
**Source:** Section 6 of Audit Report
|
||||||
|
|
||||||
|
### Current Status
|
||||||
|
- ✅ `SiteSetupChecklist.tsx` component EXISTS
|
||||||
|
- ✅ Integrated in Site Dashboard (full mode)
|
||||||
|
- ❌ **NOT integrated in SiteCard.tsx** (compact mode)
|
||||||
|
|
||||||
|
### Implementation Task
|
||||||
|
|
||||||
|
**File:** `frontend/src/components/sites/SiteCard.tsx`
|
||||||
|
|
||||||
|
Add compact checklist after status badges:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<SiteSetupChecklist
|
||||||
|
siteId={site.id}
|
||||||
|
siteName={site.name}
|
||||||
|
hasIndustry={!!site.industry}
|
||||||
|
hasSectors={site.sectors_count > 0}
|
||||||
|
hasWordPressIntegration={!!site.wordpress_site_url}
|
||||||
|
hasKeywords={site.keywords_count > 0}
|
||||||
|
compact={true}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Visual:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ My Website [Active] │
|
||||||
|
│ example.com │
|
||||||
|
│ Industry: Tech │ 3 Sectors │
|
||||||
|
│ ●●●○ 3/4 Setup Steps Complete │ ← compact checklist
|
||||||
|
│ [Manage →] │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend Requirements
|
||||||
|
|
||||||
|
Ensure `SiteSerializer` returns these fields for checklist:
|
||||||
|
- `keywords_count` - number of keywords
|
||||||
|
- `has_integration` - boolean for WordPress integration
|
||||||
|
- `active_sectors_count` - number of active sectors
|
||||||
|
- `industry_name` - industry name or null
|
||||||
|
|
||||||
|
**Status:** ✅ Already verified these fields are returned
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to Modify
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
1. `frontend/src/components/sites/SiteCard.tsx` - Add compact SiteSetupChecklist
|
||||||
|
2. Various page files to verify/add selector configuration
|
||||||
|
|
||||||
|
### Selector Components
|
||||||
|
- `frontend/src/components/common/SiteSelector.tsx`
|
||||||
|
- `frontend/src/components/common/SectorSelector.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Site selector shows on all required pages
|
||||||
|
- [ ] Sector selector shows only where data is sector-specific
|
||||||
|
- [ ] Detail pages (Cluster Detail, Content View) have no selectors
|
||||||
|
- [ ] Account/Billing pages have no selectors
|
||||||
|
- [ ] SiteCard shows compact setup checklist
|
||||||
|
- [ ] Checklist updates when site configuration changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The "All Sites" option on Dashboard should aggregate data across all user's sites
|
||||||
|
- Context pages (detail views) inherit site/sector from parent navigation
|
||||||
|
- Selector state should persist in URL params or store for deep linking
|
||||||
Reference in New Issue
Block a user