- Replaces the prior Rocket scaffold (saved on pre-bb-clone-rollback branch) - Domain: broadcastbeat.com -> avbeat.com - Brand: Broadcast Beat -> AV Beat - Slug: broadcastbeat -> avbeat (package, identifiers) - Schema fallback: bb -> av (env var name unchanged) - Placeholder AV BEAT logo (gold->red gradient) at /assets/logos/av.svg - All BB/RMP logo references repointed Outstanding before public DNS swap: - Supabase av schema bootstrap (mirror of bb tables) - WordPress archive import (avbeat-com -> av.articles) - Coolify env vars - dev-avbeat.onsethost.com staging deploy + visual review
463 lines
21 KiB
TypeScript
463 lines
21 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect, useCallback } from 'react';
|
|
import Link from 'next/link';
|
|
import { useAuth } from '@/contexts/AuthContext';
|
|
import { useRouter } from 'next/navigation';
|
|
import AppImage from '@/components/ui/AppImage';
|
|
|
|
interface ImportedPost {
|
|
id: string;
|
|
wp_id: number;
|
|
wp_slug: string;
|
|
title: string;
|
|
author_name: string;
|
|
category: string | null;
|
|
featured_image: string | null;
|
|
featured_image_alt: string | null;
|
|
wp_published_at: string | null;
|
|
imported_at: string;
|
|
status: string;
|
|
}
|
|
|
|
interface ImportLog {
|
|
id: string;
|
|
started_at: string;
|
|
completed_at: string | null;
|
|
total_fetched: number;
|
|
total_imported: number;
|
|
total_skipped: number;
|
|
total_errors: number;
|
|
status: string;
|
|
error_message: string | null;
|
|
}
|
|
|
|
interface ImportResult {
|
|
success?: boolean;
|
|
error?: string;
|
|
totalFetched?: number;
|
|
totalImported?: number;
|
|
totalErrors?: number;
|
|
pagesProcessed?: number;
|
|
}
|
|
|
|
export default function WPImportPage() {
|
|
const { user, loading } = useAuth();
|
|
const router = useRouter();
|
|
|
|
const [posts, setPosts] = useState<ImportedPost[]>([]);
|
|
const [logs, setLogs] = useState<ImportLog[]>([]);
|
|
const [isImporting, setIsImporting] = useState(false);
|
|
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
|
const [loadingData, setLoadingData] = useState(true);
|
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
|
const [maxPages, setMaxPages] = useState(5);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [activeTab, setActiveTab] = useState<'posts' | 'logs'>('posts');
|
|
|
|
useEffect(() => {
|
|
if (!loading && !user) {
|
|
router.replace('/login?next=' + encodeURIComponent(window.location.pathname));
|
|
}
|
|
}, [user, loading, router]);
|
|
|
|
const fetchData = useCallback(async () => {
|
|
try {
|
|
const res = await fetch('/api/wp-import');
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setPosts(data?.posts || []);
|
|
setLogs(data?.logs || []);
|
|
}
|
|
} catch {
|
|
// silent
|
|
} finally {
|
|
setLoadingData(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (user) fetchData();
|
|
}, [user, fetchData]);
|
|
|
|
const handleImport = async () => {
|
|
setIsImporting(true);
|
|
setImportResult(null);
|
|
try {
|
|
const res = await fetch('/api/wp-import', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ maxPages }),
|
|
});
|
|
const data = await res.json();
|
|
setImportResult(data);
|
|
if (data?.success) {
|
|
await fetchData();
|
|
}
|
|
} catch (err: any) {
|
|
setImportResult({ error: err?.message || 'Import failed' });
|
|
} finally {
|
|
setIsImporting(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id: string) => {
|
|
setDeletingId(id);
|
|
try {
|
|
const res = await fetch('/api/wp-import', {
|
|
method: 'DELETE',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id }),
|
|
});
|
|
if (res.ok) {
|
|
setPosts((prev) => prev.filter((p) => p.id !== id));
|
|
}
|
|
} catch {
|
|
// silent
|
|
} finally {
|
|
setDeletingId(null);
|
|
}
|
|
};
|
|
|
|
const filteredPosts = posts.filter((p) =>
|
|
!searchQuery ||
|
|
p.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
p.author_name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
p.category?.toLowerCase().includes(searchQuery.toLowerCase())
|
|
);
|
|
|
|
const formatDate = (dateStr: string | null) => {
|
|
if (!dateStr) return '—';
|
|
return new Date(dateStr).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
|
};
|
|
|
|
if (loading || (!user && !loading)) {
|
|
return (
|
|
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
|
<div className="w-6 h-6 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-[#0a0a0a] text-[#cccccc]">
|
|
{/* Page Header */}
|
|
<div className="bg-[#111111] border-b border-[#252525]">
|
|
<div className="max-w-6xl mx-auto px-4 py-5">
|
|
<div className="flex items-center gap-3 mb-1">
|
|
<Link href="/home-page" className="text-[#555] hover:text-[#3b82f6] transition-colors text-sm">
|
|
Home
|
|
</Link>
|
|
<span className="text-[#333]">/</span>
|
|
<span className="text-[#888] text-sm">Admin</span>
|
|
<span className="text-[#333]">/</span>
|
|
<span className="text-[#cccccc] text-sm">WP Import</span>
|
|
</div>
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mt-3">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-white font-heading">WordPress Post Importer</h1>
|
|
<p className="text-[#666] text-sm mt-1">
|
|
Import posts from{' '}
|
|
<a href="https://www.avbeat.com" target="_blank" rel="noopener noreferrer" className="text-[#3b82f6] hover:underline">
|
|
avbeat.com
|
|
</a>{' '}
|
|
via WordPress REST API
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-xs text-[#555] bg-[#1a1a1a] border border-[#252525] rounded px-3 py-2">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-[#3b82f6]">
|
|
<circle cx="12" cy="12" r="10" /><path d="M12 8v4l3 3" />
|
|
</svg>
|
|
{posts.length} posts imported
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="max-w-6xl mx-auto px-4 py-6 space-y-6">
|
|
{/* Import Controls */}
|
|
<div className="bg-[#111111] border border-[#252525] rounded-lg p-5">
|
|
<h2 className="text-base font-bold text-white font-heading mb-4 flex items-center gap-2">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-[#3b82f6]">
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" />
|
|
</svg>
|
|
Run Import
|
|
</h2>
|
|
|
|
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-end">
|
|
<div>
|
|
<label className="block text-xs text-[#666] mb-1.5 uppercase tracking-wider">Pages to fetch</label>
|
|
<select
|
|
value={maxPages}
|
|
onChange={(e) => setMaxPages(Number(e.target.value))}
|
|
disabled={isImporting}
|
|
className="bg-[#1a1a1a] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-2 focus:outline-none focus:border-[#3b82f6] disabled:opacity-50">
|
|
<option value={1}>1 page (~20 posts)</option>
|
|
<option value={3}>3 pages (~60 posts)</option>
|
|
<option value={5}>5 pages (~100 posts)</option>
|
|
<option value={10}>10 pages (~200 posts)</option>
|
|
<option value={20}>20 pages (~400 posts)</option>
|
|
<option value={50}>All pages (full import)</option>
|
|
</select>
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleImport}
|
|
disabled={isImporting}
|
|
className="flex items-center gap-2 bg-[#3b82f6] hover:bg-[#2563eb] disabled:bg-[#1e3a5f] disabled:cursor-not-allowed text-white text-sm font-bold px-5 py-2 rounded transition-colors">
|
|
{isImporting ? (
|
|
<>
|
|
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
|
Importing...
|
|
</>
|
|
) : (
|
|
<>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" />
|
|
</svg>
|
|
Start Import
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Import Progress / Result */}
|
|
{isImporting && (
|
|
<div className="mt-4 bg-[#0d1a2e] border border-[#1e3a5f] rounded p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-5 h-5 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin flex-shrink-0" />
|
|
<div>
|
|
<p className="text-[#3b82f6] text-sm font-bold">Import in progress</p>
|
|
<p className="text-[#666] text-xs mt-0.5">Fetching posts from avbeat.com and saving to database...</p>
|
|
</div>
|
|
</div>
|
|
<div className="mt-3 h-1.5 bg-[#1a2535] rounded-full overflow-hidden">
|
|
<div className="h-full bg-[#3b82f6] rounded-full animate-pulse w-2/3" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{importResult && !isImporting && (
|
|
<div className={`mt-4 rounded p-4 border ${importResult.error ? 'bg-[#1a0a0a] border-[#5f1e1e]' : 'bg-[#0a1a0a] border-[#1e5f1e]'}`}>
|
|
{importResult.error ? (
|
|
<div className="flex items-start gap-2">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-red-400 flex-shrink-0 mt-0.5">
|
|
<circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" />
|
|
</svg>
|
|
<div>
|
|
<p className="text-red-400 text-sm font-bold">Import failed</p>
|
|
<p className="text-[#888] text-xs mt-0.5">{importResult.error}</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-green-400">
|
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" />
|
|
</svg>
|
|
<p className="text-green-400 text-sm font-bold">Import completed successfully</p>
|
|
</div>
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
|
{[
|
|
{ label: 'Fetched', value: importResult.totalFetched, color: 'text-[#cccccc]' },
|
|
{ label: 'Imported', value: importResult.totalImported, color: 'text-green-400' },
|
|
{ label: 'Pages', value: importResult.pagesProcessed, color: 'text-[#3b82f6]' },
|
|
{ label: 'Errors', value: importResult.totalErrors, color: importResult.totalErrors ? 'text-red-400' : 'text-[#555]' },
|
|
].map((stat) => (
|
|
<div key={stat.label} className="bg-[#111] rounded p-2.5 text-center">
|
|
<p className={`text-xl font-bold ${stat.color}`}>{stat.value ?? 0}</p>
|
|
<p className="text-[#555] text-xs mt-0.5">{stat.label}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex gap-0 border-b border-[#252525]">
|
|
{(['posts', 'logs'] as const).map((tab) => (
|
|
<button
|
|
key={tab}
|
|
onClick={() => setActiveTab(tab)}
|
|
className={`px-5 py-2.5 text-sm font-bold uppercase tracking-wider border-b-2 transition-colors ${
|
|
activeTab === tab
|
|
? 'border-[#3b82f6] text-[#3b82f6]'
|
|
: 'border-transparent text-[#666] hover:text-[#999]'
|
|
}`}>
|
|
{tab === 'posts' ? `Imported Posts (${posts.length})` : `Import History (${logs.length})`}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Posts Tab */}
|
|
{activeTab === 'posts' && (
|
|
<div>
|
|
{/* Search */}
|
|
<div className="mb-4">
|
|
<div className="relative max-w-sm">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="absolute left-3 top-1/2 -translate-y-1/2 text-[#555]">
|
|
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
</svg>
|
|
<input
|
|
type="text"
|
|
placeholder="Search posts..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="w-full bg-[#111] border border-[#252525] text-[#cccccc] text-sm rounded pl-9 pr-4 py-2 focus:outline-none focus:border-[#3b82f6] placeholder-[#444]"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{loadingData ? (
|
|
<div className="flex items-center justify-center py-16">
|
|
<div className="w-6 h-6 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
|
</div>
|
|
) : filteredPosts.length === 0 ? (
|
|
<div className="text-center py-16 bg-[#111] border border-[#252525] rounded-lg">
|
|
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-[#333] mx-auto mb-3">
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14 2 14 8 20 8" /><line x1="16" y1="13" x2="8" y2="13" /><line x1="16" y1="17" x2="8" y2="17" /><polyline points="10 9 9 9 8 9" />
|
|
</svg>
|
|
<p className="text-[#555] text-sm">
|
|
{searchQuery ? 'No posts match your search' : 'No posts imported yet. Run an import to get started.'}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{filteredPosts.map((post) => (
|
|
<div key={post.id} className="bg-[#111] border border-[#252525] rounded-lg p-4 flex items-start gap-4 hover:border-[#333] transition-colors">
|
|
{/* Thumbnail */}
|
|
{post.featured_image ? (
|
|
<div className="flex-shrink-0 w-16 h-12 rounded overflow-hidden bg-[#1a1a1a]">
|
|
<AppImage
|
|
src={post.featured_image}
|
|
alt={post.featured_image_alt || post.title}
|
|
width={64}
|
|
height={48}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="flex-shrink-0 w-16 h-12 rounded bg-[#1a1a1a] flex items-center justify-center">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-[#333]">
|
|
<rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="8.5" cy="8.5" r="1.5" /><polyline points="21 15 16 10 5 21" />
|
|
</svg>
|
|
</div>
|
|
)}
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<h3 className="text-sm font-bold text-[#cccccc] leading-snug line-clamp-1">{post.title}</h3>
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1">
|
|
{post.category && (
|
|
<span className="text-[10px] font-bold uppercase tracking-wider text-[#3b82f6] bg-[#0d1a2e] px-1.5 py-0.5 rounded">
|
|
{post.category}
|
|
</span>
|
|
)}
|
|
<span className="text-[11px] text-[#555]">{post.author_name}</span>
|
|
<span className="text-[11px] text-[#444]">Published {formatDate(post.wp_published_at)}</span>
|
|
<span className="text-[11px] text-[#333]">Imported {formatDate(post.imported_at)}</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
<a
|
|
href={`https://www.avbeat.com/${post.wp_slug}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-[#555] hover:text-[#3b82f6] transition-colors p-1"
|
|
title="View on avbeat.com">
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /><polyline points="15 3 21 3 21 9" /><line x1="10" y1="14" x2="21" y2="3" />
|
|
</svg>
|
|
</a>
|
|
<button
|
|
onClick={() => handleDelete(post.id)}
|
|
disabled={deletingId === post.id}
|
|
className="text-[#555] hover:text-red-400 transition-colors p-1 disabled:opacity-50"
|
|
title="Delete post">
|
|
{deletingId === post.id ? (
|
|
<div className="w-3.5 h-3.5 border border-red-400 border-t-transparent rounded-full animate-spin" />
|
|
) : (
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" /><path d="M10 11v6" /><path d="M14 11v6" /><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Logs Tab */}
|
|
{activeTab === 'logs' && (
|
|
<div>
|
|
{loadingData ? (
|
|
<div className="flex items-center justify-center py-16">
|
|
<div className="w-6 h-6 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
|
</div>
|
|
) : logs.length === 0 ? (
|
|
<div className="text-center py-16 bg-[#111] border border-[#252525] rounded-lg">
|
|
<p className="text-[#555] text-sm">No import history yet.</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{logs.map((log) => (
|
|
<div key={log.id} className="bg-[#111] border border-[#252525] rounded-lg p-4">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex items-center gap-2">
|
|
{log.status === 'completed' ? (
|
|
<span className="w-2 h-2 rounded-full bg-green-400 flex-shrink-0" />
|
|
) : log.status === 'failed' ? (
|
|
<span className="w-2 h-2 rounded-full bg-red-400 flex-shrink-0" />
|
|
) : (
|
|
<span className="w-2 h-2 rounded-full bg-yellow-400 flex-shrink-0 animate-pulse" />
|
|
)}
|
|
<span className={`text-xs font-bold uppercase tracking-wider ${
|
|
log.status === 'completed' ? 'text-green-400' :
|
|
log.status === 'failed' ? 'text-red-400' : 'text-yellow-400'
|
|
}`}>
|
|
{log.status}
|
|
</span>
|
|
</div>
|
|
<span className="text-[11px] text-[#444]">{formatDate(log.started_at)}</span>
|
|
</div>
|
|
|
|
{log.status !== 'running' && (
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 mt-3">
|
|
{[
|
|
{ label: 'Fetched', value: log.total_fetched },
|
|
{ label: 'Imported', value: log.total_imported },
|
|
{ label: 'Skipped', value: log.total_skipped },
|
|
{ label: 'Errors', value: log.total_errors },
|
|
].map((stat) => (
|
|
<div key={stat.label} className="bg-[#0d0d0d] rounded p-2 text-center">
|
|
<p className="text-base font-bold text-[#cccccc]">{stat.value}</p>
|
|
<p className="text-[10px] text-[#444] uppercase tracking-wider">{stat.label}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{log.error_message && (
|
|
<p className="mt-2 text-xs text-red-400 bg-[#1a0a0a] rounded px-2 py-1.5">{log.error_message}</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|