CI/CD: - Fix build_args format in woodpecker CI (comma-separated, not YAML list) - This fixes "unknown" SHA/version showing on remote deployments Backend schema-tolerant fixes (graceful fallbacks when tables missing): - users.ts: Check which columns exist before querying - worker-registry.ts: Return empty result if table doesn't exist - task-service.ts: Add tableExists() helper, handle missing tables/views - proxies.ts: Return totalProxies in test-all response Frontend fixes: - Proxies: Use total from response for accurate progress display - SEO PagesTab: Dim Generate button when no AI provider active 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
241 lines
10 KiB
TypeScript
241 lines
10 KiB
TypeScript
/**
|
|
* PagesTab - SEO Pages List
|
|
*
|
|
* Lists all SEO pages with filtering and state metrics.
|
|
* Token-safe: Component is small and focused.
|
|
*/
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { api } from '../../../lib/api';
|
|
import { Building2, Tag, Globe, Target, FileText, RefreshCw, Sparkles, Loader2, AlertCircle } from 'lucide-react';
|
|
|
|
interface SeoPage {
|
|
id: number;
|
|
type: string;
|
|
slug: string;
|
|
pageKey: string;
|
|
primaryKeyword: string | null;
|
|
status: string;
|
|
lastGeneratedAt: string | null;
|
|
lastReviewedAt: string | null;
|
|
metrics?: {
|
|
dispensaryCount: number;
|
|
productCount: number;
|
|
brandCount: number;
|
|
};
|
|
}
|
|
|
|
const TYPE_ICONS: Record<string, React.ReactNode> = {
|
|
state: <Globe className="w-4 h-4" />,
|
|
brand: <Tag className="w-4 h-4" />,
|
|
competitor_alternative: <Target className="w-4 h-4" />,
|
|
high_intent: <FileText className="w-4 h-4" />,
|
|
insight_post: <FileText className="w-4 h-4" />
|
|
};
|
|
|
|
const STATUS_COLORS: Record<string, string> = {
|
|
live: 'bg-green-100 text-green-800',
|
|
pending_generation: 'bg-yellow-100 text-yellow-800',
|
|
draft: 'bg-gray-100 text-gray-800',
|
|
stale: 'bg-red-100 text-red-800'
|
|
};
|
|
|
|
export function PagesTab() {
|
|
const [pages, setPages] = useState<SeoPage[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [typeFilter, setTypeFilter] = useState('all');
|
|
const [search, setSearch] = useState('');
|
|
const [syncing, setSyncing] = useState(false);
|
|
const [generatingId, setGeneratingId] = useState<number | null>(null);
|
|
const [hasActiveAiProvider, setHasActiveAiProvider] = useState<boolean | null>(null);
|
|
|
|
useEffect(() => {
|
|
loadPages();
|
|
checkAiProvider();
|
|
}, [typeFilter, search]);
|
|
|
|
async function checkAiProvider() {
|
|
try {
|
|
const data = await api.getSettings();
|
|
const settings = data.settings || [];
|
|
// Check if either Anthropic or OpenAI is configured with an API key AND enabled
|
|
const anthropicKey = settings.find((s: any) => s.key === 'anthropic_api_key')?.value;
|
|
const anthropicEnabled = settings.find((s: any) => s.key === 'anthropic_enabled')?.value === 'true';
|
|
const openaiKey = settings.find((s: any) => s.key === 'openai_api_key')?.value;
|
|
const openaiEnabled = settings.find((s: any) => s.key === 'openai_enabled')?.value === 'true';
|
|
|
|
const hasProvider = (anthropicKey && anthropicEnabled) || (openaiKey && openaiEnabled);
|
|
setHasActiveAiProvider(!!hasProvider);
|
|
} catch (error) {
|
|
console.error('Failed to check AI provider:', error);
|
|
setHasActiveAiProvider(false);
|
|
}
|
|
}
|
|
|
|
async function loadPages() {
|
|
setLoading(true);
|
|
try {
|
|
const result = await api.getSeoPages({
|
|
type: typeFilter !== 'all' ? typeFilter : undefined,
|
|
search: search || undefined
|
|
});
|
|
setPages(result.pages);
|
|
} catch (error) {
|
|
console.error('Failed to load SEO pages:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleSync() {
|
|
setSyncing(true);
|
|
try {
|
|
await api.syncStatePages();
|
|
await loadPages();
|
|
} catch (error) {
|
|
console.error('Failed to sync state pages:', error);
|
|
} finally {
|
|
setSyncing(false);
|
|
}
|
|
}
|
|
|
|
async function handleGenerate(pageId: number) {
|
|
setGeneratingId(pageId);
|
|
try {
|
|
await api.generateSeoPage(pageId);
|
|
await loadPages();
|
|
} catch (error) {
|
|
console.error('Failed to generate SEO page:', error);
|
|
} finally {
|
|
setGeneratingId(null);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Filters - responsive wrap */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<select
|
|
value={typeFilter}
|
|
onChange={(e) => setTypeFilter(e.target.value)}
|
|
className="px-3 py-2 border rounded-lg text-sm"
|
|
>
|
|
<option value="all">All Types</option>
|
|
<option value="state">State</option>
|
|
<option value="brand">Brand</option>
|
|
<option value="competitor_alternative">Competitor</option>
|
|
<option value="high_intent">High Intent</option>
|
|
<option value="insight_post">Insight</option>
|
|
</select>
|
|
<input
|
|
type="text"
|
|
placeholder="Search..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="px-3 py-2 border rounded-lg text-sm w-full sm:w-48 lg:w-64"
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={handleSync}
|
|
disabled={syncing}
|
|
className="flex items-center justify-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg text-sm hover:bg-emerald-700 disabled:opacity-50 whitespace-nowrap"
|
|
>
|
|
<RefreshCw className={`w-4 h-4 ${syncing ? 'animate-spin' : ''}`} />
|
|
<span className="hidden sm:inline">Sync State Pages</span>
|
|
<span className="sm:hidden">Sync</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Table - responsive with horizontal scroll */}
|
|
<div className="bg-white rounded-lg border overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full divide-y divide-gray-200">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th className="px-3 sm:px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
|
|
<th className="px-3 sm:px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Slug</th>
|
|
<th className="hidden md:table-cell px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Keyword</th>
|
|
<th className="px-3 sm:px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
|
<th className="hidden lg:table-cell px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Metrics</th>
|
|
<th className="hidden sm:table-cell px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Generated</th>
|
|
<th className="px-3 sm:px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-200">
|
|
{loading ? (
|
|
<tr>
|
|
<td colSpan={7} className="px-4 py-8 text-center text-gray-500">Loading...</td>
|
|
</tr>
|
|
) : pages.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={7} className="px-4 py-8 text-center text-gray-500">
|
|
No pages found. Click "Sync State Pages" to create state pages.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
pages.map((page) => (
|
|
<tr key={page.id} className="hover:bg-gray-50">
|
|
<td className="px-3 sm:px-4 py-3">
|
|
<div className="flex items-center gap-1.5 sm:gap-2">
|
|
{TYPE_ICONS[page.type] || <FileText className="w-4 h-4" />}
|
|
<span className="text-xs sm:text-sm capitalize whitespace-nowrap">{page.type.replace('_', ' ')}</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-3 sm:px-4 py-3">
|
|
<code className="text-xs sm:text-sm text-gray-700 break-all">{page.slug}</code>
|
|
</td>
|
|
<td className="hidden md:table-cell px-4 py-3 text-sm text-gray-600">
|
|
{page.primaryKeyword || '-'}
|
|
</td>
|
|
<td className="px-3 sm:px-4 py-3">
|
|
<span className={`px-2 py-1 rounded-full text-xs whitespace-nowrap ${STATUS_COLORS[page.status] || 'bg-gray-100'}`}>
|
|
{page.status.replace('_', ' ')}
|
|
</span>
|
|
</td>
|
|
<td className="hidden lg:table-cell px-4 py-3 text-sm">
|
|
{page.metrics ? (
|
|
<div className="flex items-center gap-3 text-gray-600 text-xs">
|
|
<span title="Dispensaries"><Building2 className="w-3 h-3 inline" /> {page.metrics.dispensaryCount}</span>
|
|
<span title="Products">{page.metrics.productCount.toLocaleString()}</span>
|
|
<span title="Brands">{page.metrics.brandCount}</span>
|
|
</div>
|
|
) : '-'}
|
|
</td>
|
|
<td className="hidden sm:table-cell px-4 py-3 text-sm text-gray-500 whitespace-nowrap">
|
|
{page.lastGeneratedAt ? new Date(page.lastGeneratedAt).toLocaleDateString() : 'Never'}
|
|
</td>
|
|
<td className="px-3 sm:px-4 py-3">
|
|
<button
|
|
onClick={() => handleGenerate(page.id)}
|
|
disabled={generatingId === page.id || hasActiveAiProvider === false}
|
|
className={`flex items-center gap-1 px-2 sm:px-3 py-1.5 text-xs font-medium rounded-lg disabled:cursor-not-allowed ${
|
|
hasActiveAiProvider === false
|
|
? 'bg-gray-100 text-gray-400'
|
|
: 'bg-purple-50 text-purple-700 hover:bg-purple-100 disabled:opacity-50'
|
|
}`}
|
|
title={hasActiveAiProvider === false ? 'No Active AI Provider' : 'Generate content'}
|
|
>
|
|
{generatingId === page.id ? (
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
) : hasActiveAiProvider === false ? (
|
|
<AlertCircle className="w-3.5 h-3.5" />
|
|
) : (
|
|
<Sparkles className="w-3.5 h-3.5" />
|
|
)}
|
|
<span className="hidden sm:inline">{generatingId === page.id ? 'Generating...' : 'Generate'}</span>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default PagesTab;
|