final polish phase 1
This commit is contained in:
@@ -23,6 +23,8 @@ interface PageHeaderProps {
|
||||
icon: ReactNode;
|
||||
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;
|
||||
navigation?: ReactNode; // 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,
|
||||
className = "",
|
||||
badge,
|
||||
hideSelectors = false,
|
||||
hideSiteSector = false,
|
||||
actions,
|
||||
}: PageHeaderProps) {
|
||||
@@ -54,11 +57,11 @@ export default function PageHeader({
|
||||
const parentModule = parent || breadcrumb;
|
||||
|
||||
// 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(() => {
|
||||
setPageInfo({ title, parent: parentModule, badge });
|
||||
setPageInfo({ title, parent: parentModule, badge, hideSelectors, hideSectorSelector: hideSiteSector });
|
||||
return () => setPageInfo(null);
|
||||
}, [pageInfoKey, badge?.color]);
|
||||
}, [pageInfoKey, badge?.color, hideSiteSector, hideSelectors]);
|
||||
|
||||
// Load sectors when active site changes
|
||||
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
|
||||
* 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 { useNavigate } from 'react-router-dom';
|
||||
@@ -15,10 +18,19 @@ import Button from '../ui/button/Button';
|
||||
|
||||
interface SiteAndSectorSelectorProps {
|
||||
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({
|
||||
hideSectorSelector = false,
|
||||
showAllSitesOption = false,
|
||||
siteFilter,
|
||||
onSiteFilterChange,
|
||||
}: SiteAndSectorSelectorProps) {
|
||||
const toast = useToast();
|
||||
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 {
|
||||
await apiSetActiveSite(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) => {
|
||||
if (sectorId === null) {
|
||||
setActiveSector(null);
|
||||
@@ -141,7 +186,7 @@ export default function SiteAndSectorSelector({
|
||||
/>
|
||||
</svg>
|
||||
<span className="max-w-[150px] truncate">
|
||||
{sitesLoading ? 'Loading...' : activeSite?.name || 'Select Site'}
|
||||
{getSiteDisplayText()}
|
||||
</span>
|
||||
</span>
|
||||
<svg
|
||||
@@ -166,18 +211,44 @@ export default function SiteAndSectorSelector({
|
||||
placement="bottom-left"
|
||||
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) => (
|
||||
<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 ${
|
||||
activeSite?.id === site.id
|
||||
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>
|
||||
{activeSite?.id === site.id && (
|
||||
{isSiteSelected(site.id) && (
|
||||
<svg
|
||||
className="w-4 h-4 text-brand-600 dark:text-brand-400"
|
||||
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 { Link, useNavigate } from "react-router-dom";
|
||||
import { Dropdown } from "../ui/dropdown/Dropdown";
|
||||
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() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [notifying, setNotifying] = useState(true);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
notifications,
|
||||
unreadCount,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
removeNotification
|
||||
} = useNotificationStore();
|
||||
|
||||
function toggleDropdown() {
|
||||
setIsOpen(!isOpen);
|
||||
@@ -18,22 +85,31 @@ export default function NotificationDropdown() {
|
||||
|
||||
const handleClick = () => {
|
||||
toggleDropdown();
|
||||
setNotifying(false);
|
||||
};
|
||||
|
||||
const handleNotificationClick = (id: string, href?: string) => {
|
||||
markAsRead(id);
|
||||
closeDropdown();
|
||||
if (href) {
|
||||
navigate(href);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
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"
|
||||
onClick={handleClick}
|
||||
aria-label={`Notifications ${unreadCount > 0 ? `(${unreadCount} unread)` : ''}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute right-0 top-0.5 z-10 h-2 w-2 rounded-full bg-orange-400 ${
|
||||
!notifying ? "hidden" : "flex"
|
||||
}`}
|
||||
>
|
||||
<span className="absolute inline-flex w-full h-full bg-orange-400 rounded-full opacity-75 animate-ping"></span>
|
||||
</span>
|
||||
{/* Notification badge */}
|
||||
{unreadCount > 0 && (
|
||||
<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>
|
||||
)}
|
||||
<svg
|
||||
className="fill-current"
|
||||
width="20"
|
||||
@@ -49,335 +125,143 @@ export default function NotificationDropdown() {
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<Dropdown
|
||||
isOpen={isOpen}
|
||||
onClose={closeDropdown}
|
||||
anchorRef={buttonRef}
|
||||
anchorRef={buttonRef as React.RefObject<HTMLElement>}
|
||||
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]"
|
||||
>
|
||||
{/* Header */}
|
||||
<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">
|
||||
Notification
|
||||
Notifications
|
||||
{unreadCount > 0 && (
|
||||
<span className="ml-2 text-sm font-normal text-gray-500 dark:text-gray-400">
|
||||
({unreadCount} new)
|
||||
</span>
|
||||
)}
|
||||
</h5>
|
||||
<button
|
||||
onClick={toggleDropdown}
|
||||
className="text-gray-500 transition dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
|
||||
>
|
||||
<svg
|
||||
className="fill-current"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
<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
|
||||
onClick={toggleDropdown}
|
||||
className="text-gray-500 transition dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
|
||||
aria-label="Close notifications"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.21967 7.28131C5.92678 6.98841 5.92678 6.51354 6.21967 6.22065C6.51256 5.92775 6.98744 5.92775 7.28033 6.22065L11.999 10.9393L16.7176 6.22078C17.0105 5.92789 17.4854 5.92788 17.7782 6.22078C18.0711 6.51367 18.0711 6.98855 17.7782 7.28144L13.0597 12L17.7782 16.7186C18.0711 17.0115 18.0711 17.4863 17.7782 17.7792C17.4854 18.0721 17.0105 18.0721 16.7176 17.7792L11.999 13.0607L7.28033 17.7794C6.98744 18.0722 6.51256 18.0722 6.21967 17.7794C5.92678 17.4865 5.92678 17.0116 6.21967 16.7187L10.9384 12L6.21967 7.28131Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<svg
|
||||
className="fill-current"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.21967 7.28131C5.92678 6.98841 5.92678 6.51354 6.21967 6.22065C6.51256 5.92775 6.98744 5.92775 7.28033 6.22065L11.999 10.9393L16.7176 6.22078C17.0105 5.92789 17.4854 5.92788 17.7782 6.22078C18.0711 6.51367 18.0711 6.98855 17.7782 7.28144L13.0597 12L17.7782 16.7186C18.0711 17.0115 18.0711 17.4863 17.7782 17.7792C17.4854 18.0721 17.0105 18.0721 16.7176 17.7792L11.999 13.0607L7.28033 17.7794C6.98744 18.0722 6.51256 18.0722 6.21967 17.7794C5.92678 17.4865 5.92678 17.0116 6.21967 16.7187L10.9384 12L6.21967 7.28131Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="flex flex-col h-auto overflow-y-auto custom-scrollbar">
|
||||
{/* Example notification items */}
|
||||
<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-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>
|
||||
|
||||
{/* 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
|
||||
onItemClick={() => handleNotificationClick(
|
||||
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' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Icon */}
|
||||
<span className={`flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center ${colors.bg}`}>
|
||||
<span className={colors.icon}>
|
||||
{icon}
|
||||
</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>
|
||||
{/* Content */}
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="flex items-start justify-between gap-2">
|
||||
<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 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"
|
||||
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 */}
|
||||
<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>
|
||||
</DropdownItem>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ul>
|
||||
<Link
|
||||
to="/"
|
||||
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
|
||||
</Link>
|
||||
|
||||
{/* Footer */}
|
||||
{notifications.length > 0 && (
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
View All Notifications
|
||||
</Link>
|
||||
)}
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user