Compare commits
80 Commits
archive/in
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9c761b9a2 | ||
| 03134cdc69 | |||
| aa0d9540e8 | |||
| 7a84c94c3b | |||
| 4730439e38 | |||
| eb05918960 | |||
| 4acc068732 | |||
| ab6f3041fc | |||
| ceb5333077 | |||
| 7d41eebf62 | |||
| 9fcf10817f | |||
| 545c782d37 | |||
| 3f00851908 | |||
| 3f23447af7 | |||
| 64600c4f28 | |||
| ce68699839 | |||
| 423ec363d5 | |||
| b1baf2fd10 | |||
|
|
8719b62808 | ||
| 2623fbe4ac | |||
| b9621ede1a | |||
| f1e582610b | |||
| c9013249eb | |||
| c3b2c284b5 | |||
| 36dc2577bd | |||
| 91e60caa60 | |||
| a63a5db6d3 | |||
| 8d548bdc9d | |||
| 45cf96a203 | |||
| a8dc457742 | |||
| 761ebf0cdc | |||
| d8ca8ff3ec | |||
| 3a65e776ac | |||
| ca317f26a9 | |||
| f44add70c7 | |||
| 0b2ed8cd7f | |||
| abc4f6fc8e | |||
| bf484c14c3 | |||
|
|
e43d8737cf | ||
|
|
6b1a5c281a | ||
|
|
aa5bee95e2 | ||
|
|
e84f69f241 | ||
|
|
bc49c676c3 | ||
|
|
c35ed117b2 | ||
|
|
c959a5f8f7 | ||
|
|
9fa69ef74e | ||
|
|
d832e624cc | ||
|
|
e521820a2c | ||
| 6e9a163cfd | |||
| 2826f0b566 | |||
| a5d4c50186 | |||
| 93db296380 | |||
| 43d2bdd106 | |||
| 334115068b | |||
| 1a6b9d4333 | |||
| ecebc9b3b4 | |||
| d5b54d077b | |||
| 802fa05631 | |||
| aeff448448 | |||
| f1ecfe3176 | |||
| 25abc55da1 | |||
| 190d374bb1 | |||
| 0e9d3c570e | |||
| 1977e077f8 | |||
| a1148e213b | |||
| 61363f81be | |||
| de82125999 | |||
| 8042024c4a | |||
| 39d2bf9073 | |||
| 4bcd841c53 | |||
| 2876ac6ecd | |||
| 01c1abf99d | |||
| 10ecf723dc | |||
| 54117c34d3 | |||
| 8695b338d2 | |||
| 1cb6e3ec01 | |||
| 3dbe7545d8 | |||
| b96c424476 | |||
| 9bb04fb373 | |||
| 4fd6a88158 |
2
.gitignore
vendored
@@ -40,3 +40,5 @@ jest-test-results.json
|
|||||||
|
|
||||||
# Optional: if using TurboRepo or monorepo
|
# Optional: if using TurboRepo or monorepo
|
||||||
turbo/
|
turbo/
|
||||||
|
# Legacy WP uploads served from Supabase/CDN, not git
|
||||||
|
public/wp-content/uploads/
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
|
|||||||
ENV NEXT_PUBLIC_GA_MEASUREMENT_ID=$NEXT_PUBLIC_GA_MEASUREMENT_ID
|
ENV NEXT_PUBLIC_GA_MEASUREMENT_ID=$NEXT_PUBLIC_GA_MEASUREMENT_ID
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install
|
RUN npm install --include=dev
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|||||||
@@ -31,4 +31,16 @@ export const imageHosts = [
|
|||||||
protocol: 'https',
|
protocol: 'https',
|
||||||
hostname: 'www.avbeat.com',
|
hostname: 'www.avbeat.com',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'broadcastbeat.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'www.broadcastbeat.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'advertising.relevantmediaproperties.com',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -49,6 +49,23 @@ const nextConfig = {
|
|||||||
{ key: 'Cache-Control', value: 'public, max-age=86400, stale-while-revalidate=604800' },
|
{ key: 'Cache-Control', value: 'public, max-age=86400, stale-while-revalidate=604800' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Homepage and news routes must never be cached at edge — the hero
|
||||||
|
// and feed are DB-driven via force-dynamic, but Next.js sets a
|
||||||
|
// long s-maxage on prerendered HTML which CF then caches for a year.
|
||||||
|
source: '/',
|
||||||
|
headers: [
|
||||||
|
{ key: 'Cache-Control', value: 'no-store, must-revalidate' },
|
||||||
|
{ key: 'CDN-Cache-Control', value: 'no-store' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/news/:path*',
|
||||||
|
headers: [
|
||||||
|
{ key: 'Cache-Control', value: 'no-store, must-revalidate' },
|
||||||
|
{ key: 'CDN-Cache-Control', value: 'no-store' },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
42
public/assets/images/article-placeholder-v2.svg
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" preserveAspectRatio="xMidYMid slice">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#0F172A"/>
|
||||||
|
<stop offset="50%" stop-color="#1E3A8A"/>
|
||||||
|
<stop offset="100%" stop-color="#0F172A"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="glow" cx="50%" cy="50%" r="60%">
|
||||||
|
<stop offset="0%" stop-color="#1D4ED8" stop-opacity="0.18"/>
|
||||||
|
<stop offset="100%" stop-color="#1D4ED8" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="tile" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#2563EB"/>
|
||||||
|
<stop offset="100%" stop-color="#1E3A8A"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Background gradient -->
|
||||||
|
<rect width="1200" height="630" fill="url(#bg)"/>
|
||||||
|
<rect width="1200" height="630" fill="url(#glow)"/>
|
||||||
|
|
||||||
|
<!-- Brand-mark tile + A monogram, centered. Same geometry as AnimatedLogo. -->
|
||||||
|
<g transform="translate(420 175)">
|
||||||
|
<!-- Rounded tile (280x280, rx 62) with the brand gradient -->
|
||||||
|
<rect x="0" y="0" width="280" height="280" rx="62" ry="62" fill="url(#tile)"/>
|
||||||
|
<!-- Faint inner ring so the tile reads on dark bg -->
|
||||||
|
<rect x="3" y="3" width="274" height="274" rx="59" ry="59" fill="none" stroke="rgba(255,255,255,0.20)" stroke-width="3"/>
|
||||||
|
<!-- A — two strokes: left+right legs and crossbar. Same path as AnimatedLogo at 4.4x scale. -->
|
||||||
|
<path d="M 62 220 L 140 60 L 218 220"
|
||||||
|
fill="none" stroke="#FFFFFF" stroke-width="22"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M 92 158 L 188 158"
|
||||||
|
fill="none" stroke="#FFFFFF" stroke-width="18"
|
||||||
|
stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Wordmark + tagline -->
|
||||||
|
<g transform="translate(600 525)" text-anchor="middle" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">
|
||||||
|
<text y="0" font-size="56" font-weight="700" fill="#FFFFFF" letter-spacing="-0.012em">AV BEAT</text>
|
||||||
|
<text y="34" font-size="14" font-weight="500" fill="#CBD5E1" letter-spacing="0.22em">INSIDE THE FUTURE OF PRO AV</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -1,20 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" preserveAspectRatio="xMidYMid slice">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stop-color="#1a1f2e"/>
|
|
||||||
<stop offset="50%" stop-color="#0d1018"/>
|
|
||||||
<stop offset="100%" stop-color="#080a10"/>
|
|
||||||
</linearGradient>
|
|
||||||
<radialGradient id="r" cx="50%" cy="50%" r="60%">
|
|
||||||
<stop offset="0%" stop-color="#3b82f6" stop-opacity="0.10"/>
|
|
||||||
<stop offset="100%" stop-color="#3b82f6" stop-opacity="0"/>
|
|
||||||
</radialGradient>
|
|
||||||
</defs>
|
|
||||||
<rect width="1200" height="630" fill="url(#g)"/>
|
|
||||||
<rect width="1200" height="630" fill="url(#r)"/>
|
|
||||||
<g transform="translate(600 315)" text-anchor="middle" font-family="system-ui, sans-serif">
|
|
||||||
<text y="0" font-size="48" font-weight="700" fill="#3b82f6" letter-spacing="0.05em">BROADCAST</text>
|
|
||||||
<text y="56" font-size="48" font-weight="700" fill="#cbd5e1" letter-spacing="0.05em">BEAT</text>
|
|
||||||
<text y="116" font-size="16" font-weight="500" fill="#64748b" letter-spacing="0.25em" text-transform="uppercase">NEWS · INTELLIGENCE · TECHNOLOGY</text>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB |
51
public/assets/images/logo.svg
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 320" width="1200" height="320" role="img" aria-label="AV Beat — Pro AV, Live Production, and Display Tech">
|
||||||
|
<defs>
|
||||||
|
<filter id="haloGold" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="18"/></filter>
|
||||||
|
<filter id="haloRed" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="18"/></filter>
|
||||||
|
<filter id="glowGold" x="-30%" y="-30%" width="160%" height="160%"><feGaussianBlur stdDeviation="3.5"/></filter>
|
||||||
|
<filter id="glowRed" x="-30%" y="-30%" width="160%" height="160%"><feGaussianBlur stdDeviation="3.5"/></filter>
|
||||||
|
<filter id="glowSoft" x="-20%" y="-50%" width="140%" height="200%"><feGaussianBlur stdDeviation="1.6"/></filter>
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@800;900&family=Inter:wght@500;600&display=swap');
|
||||||
|
.word { font-family:'Barlow Condensed','Oswald','Nimbus Sans Narrow','Inter','Helvetica Neue',Arial,sans-serif; font-weight:900; font-size:156px; letter-spacing:-0.012em; }
|
||||||
|
.tm { font-family:'Inter','Nimbus Sans Narrow','Helvetica Neue',Arial,sans-serif; font-weight:600; font-size:22px; }
|
||||||
|
.tag { font-family:'Inter','Nimbus Sans Narrow','Helvetica Neue',Arial,sans-serif; font-weight:500; font-size:20px; letter-spacing:0.34em; }
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- NO background fill — transparent SVG so the header bg shows through -->
|
||||||
|
|
||||||
|
<!-- ─── AV (gold neon) ────────────────────────────────────────── -->
|
||||||
|
<g text-anchor="end">
|
||||||
|
<text class="word" x="500" y="195" fill="#ffb800" opacity="0.85" filter="url(#haloGold)">AV</text>
|
||||||
|
<text class="word" x="500" y="195" fill="#ffd25a" opacity="0.95" filter="url(#glowGold)">AV</text>
|
||||||
|
<text class="word" x="500" y="195" fill="#ffffff">AV</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- ─── BEAT (red neon) ──────────────────────────────────────── -->
|
||||||
|
<g text-anchor="start">
|
||||||
|
<text class="word" x="530" y="195" fill="#d60701" opacity="0.85" filter="url(#haloRed)">BEAT</text>
|
||||||
|
<text class="word" x="530" y="195" fill="#ff4035" opacity="0.95" filter="url(#glowRed)">BEAT</text>
|
||||||
|
<text class="word" x="530" y="195" fill="#ffffff">BEAT</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- ™ mark -->
|
||||||
|
<g>
|
||||||
|
<text class="tm" x="935" y="92" fill="#ffd45e" filter="url(#glowGold)">™</text>
|
||||||
|
<text class="tm" x="935" y="92" fill="#ffffff">™</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Waveform -->
|
||||||
|
<g>
|
||||||
|
<path d="M 70 230 L 440 230 L 460 230 L 480 210 L 500 252 L 520 164 L 540 286 L 560 210 L 580 238 L 600 230 L 1130 230" fill="none" stroke="#ffb800" stroke-width="6" stroke-linejoin="round" stroke-linecap="round" filter="url(#haloGold)" opacity="0.75"/>
|
||||||
|
<path d="M 70 230 L 440 230 L 460 230 L 480 210 L 500 252 L 520 164 L 540 286 L 560 210 L 580 238 L 600 230 L 1130 230" fill="none" stroke="#ffb800" stroke-width="3" stroke-linejoin="round" stroke-linecap="round" filter="url(#glowGold)"/>
|
||||||
|
<path d="M 70 230 L 440 230 L 460 230 L 480 210 L 500 252 L 520 164 L 540 286 L 560 210 L 580 238 L 600 230 L 1130 230" fill="none" stroke="#ffffff" stroke-width="1.2" stroke-linejoin="round" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Tagline -->
|
||||||
|
<g text-anchor="middle">
|
||||||
|
<text class="tag" x="600" y="294" fill="#ffffff" opacity="0.85" filter="url(#glowSoft)">PRO AV · LIVE PRODUCTION · DISPLAY TECH</text>
|
||||||
|
<text class="tag" x="600" y="294" fill="#ffffff">PRO AV · LIVE PRODUCTION · DISPLAY TECH</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -1,103 +1,51 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 320" width="1200" height="320" role="img" aria-label="Broadcast Beat — The Pulse of Production Intelligence">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 320" width="1200" height="320" role="img" aria-label="AV Beat — Pro AV, Live Production, and Display Tech">
|
||||||
<defs>
|
<defs>
|
||||||
<!-- Background gradient: deep navy → near-black -->
|
<filter id="haloGold" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="18"/></filter>
|
||||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
<filter id="haloRed" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="18"/></filter>
|
||||||
<stop offset="0%" stop-color="#050B1E"/>
|
<filter id="glowGold" x="-30%" y="-30%" width="160%" height="160%"><feGaussianBlur stdDeviation="3.5"/></filter>
|
||||||
<stop offset="100%" stop-color="#020613"/>
|
<filter id="glowRed" x="-30%" y="-30%" width="160%" height="160%"><feGaussianBlur stdDeviation="3.5"/></filter>
|
||||||
</linearGradient>
|
<filter id="glowSoft" x="-20%" y="-50%" width="140%" height="200%"><feGaussianBlur stdDeviation="1.6"/></filter>
|
||||||
|
|
||||||
<!-- Wide soft halo (outer bloom) -->
|
|
||||||
<filter id="haloPink" x="-50%" y="-50%" width="200%" height="200%">
|
|
||||||
<feGaussianBlur stdDeviation="18"/>
|
|
||||||
</filter>
|
|
||||||
<filter id="haloCyan" x="-50%" y="-50%" width="200%" height="200%">
|
|
||||||
<feGaussianBlur stdDeviation="18"/>
|
|
||||||
</filter>
|
|
||||||
|
|
||||||
<!-- Tighter glow (inner neon) -->
|
|
||||||
<filter id="glowPink" x="-30%" y="-30%" width="160%" height="160%">
|
|
||||||
<feGaussianBlur stdDeviation="3.5"/>
|
|
||||||
</filter>
|
|
||||||
<filter id="glowCyan" x="-30%" y="-30%" width="160%" height="160%">
|
|
||||||
<feGaussianBlur stdDeviation="3.5"/>
|
|
||||||
</filter>
|
|
||||||
|
|
||||||
<!-- Tagline subtle glow -->
|
|
||||||
<filter id="glowSoft" x="-20%" y="-50%" width="140%" height="200%">
|
|
||||||
<feGaussianBlur stdDeviation="1.6"/>
|
|
||||||
</filter>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@800;900&family=Inter:wght@500;600&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@800;900&family=Inter:wght@500;600&display=swap');
|
||||||
.word {
|
.word { font-family:'Barlow Condensed','Oswald','Nimbus Sans Narrow','Inter','Helvetica Neue',Arial,sans-serif; font-weight:900; font-size:156px; letter-spacing:-0.012em; }
|
||||||
font-family: 'Barlow Condensed', 'Oswald', 'Nimbus Sans Narrow', 'Inter', 'Helvetica Neue', Arial, sans-serif;
|
.tm { font-family:'Inter','Nimbus Sans Narrow','Helvetica Neue',Arial,sans-serif; font-weight:600; font-size:22px; }
|
||||||
font-weight: 900;
|
.tag { font-family:'Inter','Nimbus Sans Narrow','Helvetica Neue',Arial,sans-serif; font-weight:500; font-size:20px; letter-spacing:0.34em; }
|
||||||
font-size: 118px;
|
|
||||||
letter-spacing: -0.012em;
|
|
||||||
}
|
|
||||||
.tm {
|
|
||||||
font-family: 'Inter', 'Nimbus Sans Narrow', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 22px;
|
|
||||||
}
|
|
||||||
.tag {
|
|
||||||
font-family: 'Inter', 'Nimbus Sans Narrow', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 20px;
|
|
||||||
letter-spacing: 0.34em;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
<!-- Dark background -->
|
<!-- NO background fill — transparent SVG so the header bg shows through -->
|
||||||
<rect width="1200" height="320" fill="url(#bg)"/>
|
|
||||||
|
|
||||||
<!-- Faint vignette + scanline accents (optional broadcast vibe) -->
|
<!-- ─── AV (gold neon) ────────────────────────────────────────── -->
|
||||||
<rect width="1200" height="1" y="0" fill="#00d4ff" opacity="0.10"/>
|
|
||||||
<rect width="1200" height="1" y="319" fill="#ff2188" opacity="0.10"/>
|
|
||||||
|
|
||||||
<!-- ─── BROADCAST (pink/magenta neon) ─────────────────────────── -->
|
|
||||||
<g text-anchor="end">
|
<g text-anchor="end">
|
||||||
<!-- outer halo -->
|
<text class="word" x="500" y="195" fill="#ffb800" opacity="0.85" filter="url(#haloGold)">AV</text>
|
||||||
<text class="word" x="595" y="180" fill="#ff2188" opacity="0.85" filter="url(#haloPink)">BROADCAST</text>
|
<text class="word" x="500" y="195" fill="#ffd25a" opacity="0.95" filter="url(#glowGold)">AV</text>
|
||||||
<!-- mid glow -->
|
<text class="word" x="500" y="195" fill="#ffffff">AV</text>
|
||||||
<text class="word" x="595" y="180" fill="#ff58a8" opacity="0.95" filter="url(#glowPink)">BROADCAST</text>
|
|
||||||
<!-- white core -->
|
|
||||||
<text class="word" x="595" y="180" fill="#ffffff">BROADCAST</text>
|
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- ─── BEAT (cyan neon) ──────────────────────────────────────── -->
|
<!-- ─── BEAT (red neon) ──────────────────────────────────────── -->
|
||||||
<g text-anchor="start">
|
<g text-anchor="start">
|
||||||
<text class="word" x="615" y="180" fill="#00d4ff" opacity="0.85" filter="url(#haloCyan)">BEAT</text>
|
<text class="word" x="530" y="195" fill="#d60701" opacity="0.85" filter="url(#haloRed)">BEAT</text>
|
||||||
<text class="word" x="615" y="180" fill="#5ee5ff" opacity="0.95" filter="url(#glowCyan)">BEAT</text>
|
<text class="word" x="530" y="195" fill="#ff4035" opacity="0.95" filter="url(#glowRed)">BEAT</text>
|
||||||
<text class="word" x="615" y="180" fill="#ffffff">BEAT</text>
|
<text class="word" x="530" y="195" fill="#ffffff">BEAT</text>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- ™ mark near end of BEAT, upper right -->
|
<!-- ™ mark -->
|
||||||
<g>
|
<g>
|
||||||
<text class="tm" x="918" y="90" fill="#7feaff" filter="url(#glowCyan)">™</text>
|
<text class="tm" x="935" y="92" fill="#ffd45e" filter="url(#glowGold)">™</text>
|
||||||
<text class="tm" x="918" y="90" fill="#ffffff">™</text>
|
<text class="tm" x="935" y="92" fill="#ffffff">™</text>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- ─── Waveform / heartbeat pulse ────────────────────────────── -->
|
<!-- Waveform -->
|
||||||
<g>
|
<g>
|
||||||
<!-- Halo pass -->
|
<path d="M 70 230 L 440 230 L 460 230 L 480 210 L 500 252 L 520 164 L 540 286 L 560 210 L 580 238 L 600 230 L 1130 230" fill="none" stroke="#ffb800" stroke-width="6" stroke-linejoin="round" stroke-linecap="round" filter="url(#haloGold)" opacity="0.75"/>
|
||||||
<path d="M 70 218 L 460 218 L 480 218 L 500 198 L 520 240 L 540 152 L 560 274 L 580 198 L 600 226 L 620 218 L 1130 218"
|
<path d="M 70 230 L 440 230 L 460 230 L 480 210 L 500 252 L 520 164 L 540 286 L 560 210 L 580 238 L 600 230 L 1130 230" fill="none" stroke="#ffb800" stroke-width="3" stroke-linejoin="round" stroke-linecap="round" filter="url(#glowGold)"/>
|
||||||
fill="none" stroke="#00d4ff" stroke-width="6" stroke-linejoin="round" stroke-linecap="round"
|
<path d="M 70 230 L 440 230 L 460 230 L 480 210 L 500 252 L 520 164 L 540 286 L 560 210 L 580 238 L 600 230 L 1130 230" fill="none" stroke="#ffffff" stroke-width="1.2" stroke-linejoin="round" stroke-linecap="round"/>
|
||||||
filter="url(#haloCyan)" opacity="0.75"/>
|
|
||||||
<!-- Mid stroke -->
|
|
||||||
<path d="M 70 218 L 460 218 L 480 218 L 500 198 L 520 240 L 540 152 L 560 274 L 580 198 L 600 226 L 620 218 L 1130 218"
|
|
||||||
fill="none" stroke="#00d4ff" stroke-width="3" stroke-linejoin="round" stroke-linecap="round"
|
|
||||||
filter="url(#glowCyan)"/>
|
|
||||||
<!-- Crisp white core -->
|
|
||||||
<path d="M 70 218 L 460 218 L 480 218 L 500 198 L 520 240 L 540 152 L 560 274 L 580 198 L 600 226 L 620 218 L 1130 218"
|
|
||||||
fill="none" stroke="#ffffff" stroke-width="1.2" stroke-linejoin="round" stroke-linecap="round"/>
|
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- ─── Tagline ───────────────────────────────────────────────── -->
|
<!-- Tagline -->
|
||||||
<g text-anchor="middle">
|
<g text-anchor="middle">
|
||||||
<text class="tag" x="600" y="282" fill="#ffffff" opacity="0.85" filter="url(#glowSoft)">THE PULSE OF PRODUCTION INTELLIGENCE</text>
|
<text class="tag" x="600" y="294" fill="#ffffff" opacity="0.85" filter="url(#glowSoft)">PRO AV · LIVE PRODUCTION · DISPLAY TECH</text>
|
||||||
<text class="tag" x="600" y="282" fill="#ffffff">THE PULSE OF PRODUCTION INTELLIGENCE</text>
|
<text class="tag" x="600" y="294" fill="#ffffff">PRO AV · LIVE PRODUCTION · DISPLAY TECH</text>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 3.6 KiB |
BIN
public/img/partners/blackmagic-design.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/img/partners/magewell.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
@@ -8,7 +8,7 @@ const CONTACTS = [
|
|||||||
{
|
{
|
||||||
title: "Editorial",
|
title: "Editorial",
|
||||||
body: (
|
body: (
|
||||||
<a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="mailto:editor@avbeat.com">editor@avbeat.com</a>
|
<a className="text-[#7DD3FC] hover:underline" href="mailto:editor@relevantmediaproperties.com">editor@relevantmediaproperties.com</a>
|
||||||
),
|
),
|
||||||
hint: "Story tips, corrections, interview requests.",
|
hint: "Story tips, corrections, interview requests.",
|
||||||
},
|
},
|
||||||
@@ -17,7 +17,7 @@ const CONTACTS = [
|
|||||||
body: (
|
body: (
|
||||||
<>
|
<>
|
||||||
Route PR-firm submissions through{" "}
|
Route PR-firm submissions through{" "}
|
||||||
<a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="https://distribution.relevantmediaproperties.com">distribution.relevantmediaproperties.com</a>.
|
<a className="text-[#7DD3FC] hover:underline" href="https://distribution.relevantmediaproperties.com">distribution.relevantmediaproperties.com</a>.
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
hint: "Routed submissions land in our editorial queue with provenance.",
|
hint: "Routed submissions land in our editorial queue with provenance.",
|
||||||
@@ -26,8 +26,8 @@ const CONTACTS = [
|
|||||||
title: "Advertising",
|
title: "Advertising",
|
||||||
body: (
|
body: (
|
||||||
<>
|
<>
|
||||||
<a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="mailto:advertising@avbeat.com">advertising@avbeat.com</a> — see the{" "}
|
<a className="text-[#7DD3FC] hover:underline" href="mailto:advertising@relevantmediaproperties.com">advertising@relevantmediaproperties.com</a> — see the{" "}
|
||||||
<a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="/about/advertise">media kit</a>.
|
<a className="text-[#7DD3FC] hover:underline" href="https://relevantmediaproperties.com/av-beat---media-kit">media kit</a>.
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
hint: "728×90, 300×600, 300×250 inventory; CTR + impression reports monthly.",
|
hint: "728×90, 300×600, 300×250 inventory; CTR + impression reports monthly.",
|
||||||
@@ -37,7 +37,7 @@ const CONTACTS = [
|
|||||||
body: (
|
body: (
|
||||||
<>
|
<>
|
||||||
Ryan Salazar ·{" "}
|
Ryan Salazar ·{" "}
|
||||||
<a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="mailto:ryan.salazar@relevantmediaproperties.com">ryan.salazar@relevantmediaproperties.com</a>
|
<a className="text-[#7DD3FC] hover:underline" href="mailto:ryan.salazar@relevantmediaproperties.com">ryan.salazar@relevantmediaproperties.com</a>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
hint: "Partnerships, M&A, syndication, network membership.",
|
hint: "Partnerships, M&A, syndication, network membership.",
|
||||||
@@ -48,27 +48,31 @@ export default function ContactPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<Header />
|
<Header />
|
||||||
<div className="max-w-container mx-auto px-4 py-12 text-[#e5e7eb]">
|
<div className="max-w-container mx-auto px-4 py-12 text-[#0F172A]">
|
||||||
<div className="lg:grid lg:grid-cols-[1fr,300px] lg:gap-10">
|
<div className="lg:grid lg:grid-cols-[1fr,300px] lg:gap-10">
|
||||||
<main>
|
<main>
|
||||||
<header className="mb-8 border-b border-[#252525] pb-6">
|
<header className="mb-8 border-b border-[#DCE6F2] pb-6">
|
||||||
<div className="text-[10px] font-mono uppercase tracking-[0.22em] text-[var(--color-text-info,#60a5fa)] mb-2">
|
<div className="text-[10px] font-mono uppercase tracking-[0.22em] text-[#1D4ED8] mb-2">
|
||||||
AV Beat
|
AV Beat
|
||||||
</div>
|
</div>
|
||||||
<h1 className="font-serif text-4xl md:text-5xl font-bold tracking-tight">Contact</h1>
|
<h1 className="font-display text-4xl md:text-5xl font-bold tracking-tight text-[#0F172A]">Contact</h1>
|
||||||
<p className="mt-3 text-[#9ca3af] text-base">
|
<p className="mt-3 text-[#475569] text-base">
|
||||||
Pitches, press, partnerships — pick the right desk and we'll get back fast.
|
Pitches, press, partnerships — pick the right desk and we'll get back fast.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<ul className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<ul className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{CONTACTS.map((c) => (
|
{CONTACTS.map((c) => (
|
||||||
<li key={c.title} className="rounded border border-[#252525] bg-[#0b0f17] p-5 hover:border-[var(--color-text-info,#60a5fa)]/60 transition-colors">
|
// Dark contact card with a brand-gradient top accent (animated
|
||||||
<div className="text-[10px] font-mono uppercase tracking-[0.18em] text-[var(--color-text-info,#60a5fa)] mb-2">
|
// sheen). Card body text is explicitly light because the parent
|
||||||
|
// wrapper is now dark-on-light at the page level.
|
||||||
|
<li key={c.title} className="relative overflow-hidden rounded border border-[#1a1f2a] bg-[#0b0f17] p-5 hover:border-[#1D4ED8]/60 transition-colors">
|
||||||
|
<span className="brand-gradient absolute top-0 left-0 right-0 h-1" aria-hidden />
|
||||||
|
<div className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#93C5FD] mb-2">
|
||||||
{c.title}
|
{c.title}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[15px] leading-relaxed">{c.body}</div>
|
<div className="text-[15px] leading-relaxed text-[#e5e7eb]">{c.body}</div>
|
||||||
<div className="mt-3 text-xs text-[#6b7280]">{c.hint}</div>
|
<div className="mt-3 text-xs text-[#94a3b8]">{c.hint}</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -4,23 +4,23 @@ import SidebarAdStack from "@/components/SidebarAdStack";
|
|||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "About AV Beat",
|
title: "About AV Beat",
|
||||||
description: "AV Beat is an independent industry publication covering broadcast, production, and post-production technology.",
|
description: "AV Beat is an independent industry publication covering pro AV, live production, and display tech.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AboutPage() {
|
export default function AboutPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<Header />
|
<Header />
|
||||||
<div className="max-w-container mx-auto px-4 py-12 text-[#e5e7eb]">
|
<div className="max-w-container mx-auto px-4 py-12 text-[#1e293b]">
|
||||||
<div className="lg:grid lg:grid-cols-[1fr,300px] lg:gap-10">
|
<div className="lg:grid lg:grid-cols-[1fr,300px] lg:gap-10">
|
||||||
<main>
|
<main>
|
||||||
<header className="mb-8 border-b border-[#252525] pb-6">
|
<header className="mb-8 border-b border-[#DCE6F2] pb-6">
|
||||||
<div className="text-[10px] font-mono uppercase tracking-[0.22em] text-[var(--color-text-info,#60a5fa)] mb-2">
|
<div className="text-[10px] font-mono uppercase tracking-[0.22em] text-[var(--color-text-info,#60a5fa)] mb-2">
|
||||||
AV Beat
|
AV Beat
|
||||||
</div>
|
</div>
|
||||||
<h1 className="font-serif text-4xl md:text-5xl font-bold tracking-tight">About</h1>
|
<h1 className="font-display text-4xl md:text-5xl font-bold tracking-tight">About</h1>
|
||||||
<p className="mt-3 text-[#9ca3af] text-base max-w-2xl">
|
<p className="mt-3 text-[#475569] text-base max-w-2xl">
|
||||||
An independent trade publication covering broadcast, live production, streaming, and post-production technology.
|
An independent trade publication covering pro AV, live production, display tech, and signal-flow technology.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ export default function AboutPage() {
|
|||||||
<Stat label="Stories archived" value="29k+" />
|
<Stat label="Stories archived" value="29k+" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="space-y-6 text-[#d1d5db] text-[15px] leading-relaxed">
|
<section className="space-y-6 text-[#1e293b] text-[15px] leading-relaxed">
|
||||||
<Block title="What we cover">
|
<Block title="What we cover">
|
||||||
AV Beat reports on the equipment, services, and people
|
AV Beat reports on the equipment, services, and people
|
||||||
that move the professional video industry forward. We follow
|
that move the professional video industry forward. We follow
|
||||||
@@ -41,7 +41,7 @@ export default function AboutPage() {
|
|||||||
|
|
||||||
<Block title="Where we go">
|
<Block title="Where we go">
|
||||||
We cover the major industry shows in person and remotely — NAB
|
We cover the major industry shows in person and remotely — NAB
|
||||||
Show, IBC, BroadcastAsia, MPTS, InfoComm, and more — track
|
Show — InfoComm, ISE, NAB Show, IBC, and more — track
|
||||||
product launches and acquisitions year-round, and publish
|
product launches and acquisitions year-round, and publish
|
||||||
interviews with the engineers, post supervisors, and executives
|
interviews with the engineers, post supervisors, and executives
|
||||||
building the next generation of workflows.
|
building the next generation of workflows.
|
||||||
@@ -51,14 +51,13 @@ export default function AboutPage() {
|
|||||||
Editorial decisions are made independently of advertising. We
|
Editorial decisions are made independently of advertising. We
|
||||||
do not accept payment for editorial coverage. Vendor-supplied
|
do not accept payment for editorial coverage. Vendor-supplied
|
||||||
press releases routed through our distribution platform are
|
press releases routed through our distribution platform are
|
||||||
rewritten by our staff or AI assistants according to our
|
rewritten by our editorial team to our house style guide;
|
||||||
editorial style guide; the original submission is preserved on
|
the original submission is preserved on file for provenance.
|
||||||
file. Our AI personas are clearly labeled wherever they appear.
|
|
||||||
</Block>
|
</Block>
|
||||||
|
|
||||||
<Block title="Get involved">
|
<Block title="Get involved">
|
||||||
For editorial pitches: <a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="mailto:editor@avbeat.com">editor@avbeat.com</a>.
|
For editorial pitches: <a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="mailto:editor@avbeat.com">editor@avbeat.com</a>.
|
||||||
{" "}For advertising: <a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="/about/advertise">see the media kit</a>.
|
{" "}For advertising: <a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="https://relevantmediaproperties.com/av-beat---media-kit" target="_blank" rel="noopener noreferrer">see the media kit</a>.
|
||||||
{" "}For our team: <a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="/about/team">meet the editorial team</a>.
|
{" "}For our team: <a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="/about/team">meet the editorial team</a>.
|
||||||
</Block>
|
</Block>
|
||||||
</section>
|
</section>
|
||||||
@@ -78,10 +77,14 @@ export default function AboutPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Stat({ label, value }: { label: string; value: string }) {
|
function Stat({ label, value }: { label: string; value: string }) {
|
||||||
|
// Brand-gradient top accent (animated sheen) gives the stat tile its
|
||||||
|
// on-brand identity without redoing the layout. The tile itself stays
|
||||||
|
// white-on-light-border for legibility.
|
||||||
return (
|
return (
|
||||||
<div className="rounded border border-[#252525] bg-[#0b0f17] p-4">
|
<div className="relative overflow-hidden rounded border border-[#DCE6F2] bg-[#FFFFFF] p-4">
|
||||||
<div className="font-serif text-3xl text-white">{value}</div>
|
<span className="brand-gradient absolute top-0 left-0 right-0 h-1" aria-hidden />
|
||||||
<div className="mt-1 text-[10px] font-mono uppercase tracking-[0.16em] text-[#6b7280]">
|
<div className="font-display text-3xl font-bold text-[#0F172A]">{value}</div>
|
||||||
|
<div className="mt-1 text-[10px] font-mono uppercase tracking-[0.16em] text-[#475569]">
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,7 +93,7 @@ function Stat({ label, value }: { label: string; value: string }) {
|
|||||||
|
|
||||||
function Block({ title, children }: { title: string; children: React.ReactNode }) {
|
function Block({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded border border-[#1e1e1e] bg-[#0b0f17]/70 p-5">
|
<div className="rounded border border-[#DCE6F2] bg-[#FFFFFF] p-5">
|
||||||
<h2 className="font-mono text-xs uppercase tracking-[0.18em] text-[var(--color-text-info,#60a5fa)] mb-2">
|
<h2 className="font-mono text-xs uppercase tracking-[0.18em] text-[var(--color-text-info,#60a5fa)] mb-2">
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export const revalidate = 600;
|
|||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Our team — AV Beat",
|
title: "Our team — AV Beat",
|
||||||
description: "Meet the AV Beat team — editorial staff plus accounts and support, covering broadcast, streaming, and post-production.",
|
description: "Meet the AV Beat team — editorial staff plus accounts and support, covering pro AV, live production, and display tech.",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Persona {
|
interface Persona {
|
||||||
@@ -44,7 +44,6 @@ export default async function TeamPage() {
|
|||||||
.select("slug, name, bio, beat, avatar_url")
|
.select("slug, name, bio, beat, avatar_url")
|
||||||
.eq("active", true);
|
.eq("active", true);
|
||||||
const raw = (data || []) as Persona[];
|
const raw = (data || []) as Persona[];
|
||||||
// Ryan Salazar pinned first as Founder / Editor-in-Chief; rest alphabetical.
|
|
||||||
const team = [
|
const team = [
|
||||||
...raw.filter((p) => p.slug === "ryan-salazar"),
|
...raw.filter((p) => p.slug === "ryan-salazar"),
|
||||||
...raw
|
...raw
|
||||||
@@ -52,8 +51,6 @@ export default async function TeamPage() {
|
|||||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Operations staff — pulsedesk personas (accounts receivable, support).
|
|
||||||
// Only the company-wide RMP roles, not property-specific auto-attendants.
|
|
||||||
const sbOps = createClient(
|
const sbOps = createClient(
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
@@ -67,18 +64,15 @@ export default async function TeamPage() {
|
|||||||
const ops = (opsData || []) as OpsPersona[];
|
const ops = (opsData || []) as OpsPersona[];
|
||||||
|
|
||||||
const displayBeat = (p: Persona) =>
|
const displayBeat = (p: Persona) =>
|
||||||
p.slug === "ryan-salazar" ? "FOUNDER / EDITOR-IN-CHIEF" : p.beat?.replace(/-/g, " ");
|
p.slug === "ryan-salazar" ? "Founder / Editor-in-Chief" : p.beat?.replace(/-/g, " ") ?? "";
|
||||||
|
|
||||||
const initials = (name: string) =>
|
const formatPhone = (raw: string): string => {
|
||||||
name
|
const digits = raw.replace(/[^0-9]/g, "");
|
||||||
.split(" ")
|
const local = digits.startsWith("1") && digits.length === 11 ? digits.slice(1) : digits;
|
||||||
.filter(Boolean)
|
if (local.length !== 10) return raw;
|
||||||
.slice(0, 2)
|
return `(${local.slice(0, 3)}) ${local.slice(3, 6)}-${local.slice(6)}`;
|
||||||
.map((w) => w.charAt(0).toUpperCase())
|
};
|
||||||
.join("");
|
|
||||||
|
|
||||||
// Derive @avbeat.com email from the persona slug. Real mailboxes
|
|
||||||
// are set up centrally; this is the authoring address surfaced to readers.
|
|
||||||
const editorialEmail = (p: Persona): { user: string; domain: string } | null => {
|
const editorialEmail = (p: Persona): { user: string; domain: string } | null => {
|
||||||
if (!p.slug) return null;
|
if (!p.slug) return null;
|
||||||
return { user: p.slug.replace(/-/g, "."), domain: "avbeat.com" };
|
return { user: p.slug.replace(/-/g, "."), domain: "avbeat.com" };
|
||||||
@@ -90,167 +84,178 @@ export default async function TeamPage() {
|
|||||||
return { user: email.slice(0, at), domain: email.slice(at + 1) };
|
return { user: email.slice(0, at), domain: email.slice(at + 1) };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const tableWrap = "overflow-x-auto rounded-xl border border-[#1a1f2a] bg-[#0b0f17]";
|
||||||
|
const tableClass = "w-full min-w-[640px] text-sm text-left border-collapse";
|
||||||
|
const thClass =
|
||||||
|
"px-4 py-3 text-[10px] font-mono uppercase tracking-[0.14em] text-[#93C5FD] bg-[#141a25] border-b border-[#1a1f2a] whitespace-nowrap";
|
||||||
|
const tdClass = "px-4 py-4 align-top border-b border-[#1a1f2a] text-[#aab1bd]";
|
||||||
|
const trHover = "hover:bg-[#111827]/60 transition-colors";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<Header />
|
<Header />
|
||||||
<div className="max-w-container mx-auto px-4 py-12 text-[#e5e7eb]">
|
<div className="max-w-container mx-auto px-4 py-12 text-[#0F172A]">
|
||||||
<div className="lg:grid lg:grid-cols-[1fr,300px] lg:gap-10">
|
<div className="lg:grid lg:grid-cols-[1fr,300px] lg:gap-10">
|
||||||
<main>
|
<main>
|
||||||
{/* Hero */}
|
<header className="brand-gradient rounded-xl mb-10">
|
||||||
<header className="relative overflow-hidden rounded-xl mb-10 border border-[#1a1f2a] bg-gradient-to-br from-[#0d1118] via-[#0b0f17] to-[#0d1118]">
|
<div className="px-6 py-10 sm:px-10 sm:py-14">
|
||||||
<div className="absolute inset-0 opacity-[0.04] bg-[radial-gradient(ellipse_at_top_right,#d60701,transparent_60%)]" aria-hidden />
|
<div className="inline-flex items-center gap-2 text-[10px] font-mono uppercase tracking-[0.22em] text-white/85 mb-3">
|
||||||
<div className="relative px-6 py-10 sm:px-10 sm:py-14">
|
<span className="w-6 h-px bg-white/60" />
|
||||||
<div className="inline-flex items-center gap-2 text-[10px] font-mono uppercase tracking-[0.22em] text-[#ff5a55] mb-3">
|
|
||||||
<span className="w-6 h-px bg-[#d60701]" />
|
|
||||||
AV Beat · editorial
|
AV Beat · editorial
|
||||||
</div>
|
</div>
|
||||||
<h1 className="font-serif text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight leading-tight">
|
<h1 className="font-display text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight leading-tight text-white">
|
||||||
The team behind every byline
|
The team behind every byline
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-4 text-[#9ca3af] text-base md:text-lg max-w-2xl leading-relaxed">
|
<p className="mt-4 text-white/80 text-base md:text-lg max-w-2xl leading-relaxed">
|
||||||
Beats covered by trained AI personas working under house style, plus the
|
The writers, editors, and operators behind the AV Beat newsroom —
|
||||||
business and support people who keep things moving.
|
covering pro AV, live production, and display tech every day.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Editorial team — three-col grid on desktop, larger portraits */}
|
|
||||||
<section className="mb-14">
|
<section className="mb-14">
|
||||||
<div className="flex items-baseline justify-between mb-5">
|
<div className="flex items-baseline justify-between mb-5">
|
||||||
<h2 className="font-serif text-2xl md:text-3xl font-bold tracking-tight">Editorial</h2>
|
<h2 className="font-display text-2xl md:text-3xl font-bold tracking-tight">Editorial</h2>
|
||||||
<span className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#6c7686]">
|
<span className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#6c7686]">
|
||||||
{team.length} {team.length === 1 ? "writer" : "writers"}
|
{team.length} {team.length === 1 ? "writer" : "writers"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
|
<div className={tableWrap}>
|
||||||
|
<table className={tableClass}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className={thClass}>Name</th>
|
||||||
|
<th className={thClass}>Role</th>
|
||||||
|
<th className={thClass}>Focus</th>
|
||||||
|
<th className={thClass}>Contact</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
{team.map((p) => {
|
{team.map((p) => {
|
||||||
const e = editorialEmail(p);
|
const e = editorialEmail(p);
|
||||||
return (
|
return (
|
||||||
<li
|
<tr key={p.slug} className={trHover}>
|
||||||
key={p.slug}
|
<td className={tdClass}>
|
||||||
className="group relative rounded-xl border border-[#1a1f2a] bg-[#0b0f17] p-5 hover:border-[#d60701]/50 hover:shadow-[0_0_0_1px_rgba(214,7,1,0.15)] transition-all"
|
<a
|
||||||
|
href={`/authors/${p.slug}`}
|
||||||
|
className="font-display text-base font-bold text-white hover:text-[#7DD3FC] hover:underline"
|
||||||
>
|
>
|
||||||
{/* Top accent */}
|
{p.name}
|
||||||
<div className="absolute top-0 left-5 right-5 h-px bg-gradient-to-r from-transparent via-[#d60701]/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" aria-hidden />
|
|
||||||
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
{p.avatar_url ? (
|
|
||||||
<a href={`/authors/${p.slug}`} className="flex-shrink-0">
|
|
||||||
<img
|
|
||||||
src={p.avatar_url}
|
|
||||||
alt={p.name}
|
|
||||||
width={96}
|
|
||||||
height={96}
|
|
||||||
className="w-24 h-24 rounded-full border-2 border-[#1a1f2a] group-hover:border-[#d60701]/40 transition-colors object-cover"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<div className="w-24 h-24 rounded-full border-2 border-[#1a1f2a] bg-[#141a25] flex items-center justify-center font-serif font-bold text-2xl text-[#6c7686] flex-shrink-0">
|
|
||||||
{initials(p.name)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<a href={`/authors/${p.slug}`} className="block hover:underline">
|
|
||||||
<h3 className="font-serif text-xl font-bold leading-tight">{p.name}</h3>
|
|
||||||
</a>
|
</a>
|
||||||
|
</td>
|
||||||
|
<td className={tdClass}>
|
||||||
{(p.beat || p.slug === "ryan-salazar") && (
|
{(p.beat || p.slug === "ryan-salazar") && (
|
||||||
<div className="inline-block mt-1.5 px-2 py-0.5 rounded-sm bg-[#d60701]/10 border border-[#d60701]/25 text-[10px] font-mono uppercase tracking-[0.12em] text-[#ff6b66]">
|
<span className="inline-block px-2 py-0.5 rounded-sm bg-[#1D4ED8]/15 border border-[#1D4ED8]/35 text-[10px] font-mono uppercase tracking-[0.12em] text-[#93C5FD]">
|
||||||
{displayBeat(p)}
|
{displayBeat(p)}
|
||||||
</div>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</td>
|
||||||
</div>
|
<td className={`${tdClass} max-w-md`}>
|
||||||
|
{p.bio ? (
|
||||||
{p.bio && (
|
<p className="leading-relaxed">{p.bio}</p>
|
||||||
<p className="text-sm text-[#aab1bd] mt-4 leading-relaxed line-clamp-4">
|
) : (
|
||||||
{p.bio}
|
<span className="text-[#6c7686]">—</span>
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
</td>
|
||||||
|
<td className={tdClass}>
|
||||||
|
<div className="space-y-2">
|
||||||
{e && (
|
{e && (
|
||||||
<div className="mt-4 pt-4 border-t border-[#1a1f2a] flex items-center justify-between text-xs">
|
|
||||||
<ObfuscatedEmail
|
<ObfuscatedEmail
|
||||||
user={e.user}
|
user={e.user}
|
||||||
domain={e.domain}
|
domain={e.domain}
|
||||||
className="text-[#aab1bd] hover:text-[#ff6b66] font-mono"
|
className="text-[#aab1bd] hover:text-[#7DD3FC] font-mono block"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<a
|
<a
|
||||||
href={`/authors/${p.slug}`}
|
href={`/authors/${p.slug}`}
|
||||||
className="text-[10px] font-mono uppercase tracking-[0.12em] text-[#6c7686] hover:text-[#ff6b66]"
|
className="inline-block text-[10px] font-mono uppercase tracking-[0.12em] text-[#6c7686] hover:text-[#7DD3FC]"
|
||||||
>
|
>
|
||||||
View →
|
Profile →
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</td>
|
||||||
</li>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Operations — denser layout */}
|
|
||||||
{ops.length > 0 && (
|
{ops.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<div className="flex items-baseline justify-between mb-5">
|
<div className="flex items-baseline justify-between mb-5">
|
||||||
<h2 className="font-serif text-2xl md:text-3xl font-bold tracking-tight">
|
<h2 className="font-display text-2xl md:text-3xl font-bold tracking-tight">
|
||||||
Accounts & support
|
Accounts & support
|
||||||
</h2>
|
</h2>
|
||||||
<span className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#6c7686]">
|
<span className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#6c7686]">
|
||||||
{ops.length} {ops.length === 1 ? "contact" : "contacts"}
|
{ops.length} {ops.length === 1 ? "contact" : "contacts"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[#9ca3af] text-sm mb-5 max-w-2xl">
|
<p className="text-[#475569] text-sm mb-5 max-w-2xl">
|
||||||
For billing, accounts receivable, vendor onboarding, sales, and
|
For billing, accounts receivable, vendor onboarding, sales, and
|
||||||
general support questions — these are the people to reach.
|
general support questions — these are the people to reach.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ul className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
<div className={tableWrap}>
|
||||||
|
<table className={tableClass}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className={thClass}>Name</th>
|
||||||
|
<th className={thClass}>Role</th>
|
||||||
|
<th className={thClass}>Email</th>
|
||||||
|
<th className={thClass}>Phone</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
{ops.map((p) => (
|
{ops.map((p) => (
|
||||||
<li
|
<tr key={p.id} className={trHover}>
|
||||||
key={p.id}
|
<td className={tdClass}>
|
||||||
className="rounded-xl border border-[#1a1f2a] bg-[#0b0f17] p-5 flex gap-4 hover:border-[#d60701]/40 transition-colors"
|
<span className="font-display text-base font-bold text-white">
|
||||||
>
|
{p.display_name}
|
||||||
{p.avatar_url ? (
|
</span>
|
||||||
<img
|
</td>
|
||||||
src={p.avatar_url}
|
<td className={tdClass}>
|
||||||
alt={p.display_name}
|
<span className="inline-block px-2 py-0.5 rounded-sm bg-[#1D4ED8]/15 border border-[#1D4ED8]/35 text-[10px] font-mono uppercase tracking-[0.12em] text-[#93C5FD]">
|
||||||
width={80}
|
|
||||||
height={80}
|
|
||||||
className="w-20 h-20 rounded-full border-2 border-[#1a1f2a] flex-shrink-0 object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="w-20 h-20 rounded-full border-2 border-[#1a1f2a] flex-shrink-0 bg-[#141a25] flex items-center justify-center font-serif font-bold text-xl text-[#6c7686]">
|
|
||||||
{initials(p.display_name)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="font-serif text-lg font-bold leading-tight">{p.display_name}</h3>
|
|
||||||
<div className="inline-block mt-1 px-2 py-0.5 rounded-sm bg-[#1a2535] border border-[#3b82f6]/30 text-[10px] font-mono uppercase tracking-[0.12em] text-[#7ba0ff]">
|
|
||||||
{p.role}
|
{p.role}
|
||||||
</div>
|
</span>
|
||||||
<div className="text-xs text-[#aab1bd] mt-3 space-y-1">
|
</td>
|
||||||
{p.email && (() => {
|
<td className={tdClass}>
|
||||||
const e = splitEmail(p.email);
|
{p.email ? (
|
||||||
|
(() => {
|
||||||
|
const e = splitEmail(p.email!);
|
||||||
if (!e) return null;
|
if (!e) return null;
|
||||||
return (
|
return (
|
||||||
<div className="font-mono">
|
<ObfuscatedEmail
|
||||||
<ObfuscatedEmail user={e.user} domain={e.domain} className="hover:text-[#ff6b66]" />
|
user={e.user}
|
||||||
</div>
|
domain={e.domain}
|
||||||
|
className="text-[#aab1bd] hover:text-[#7DD3FC] font-mono block"
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
})()}
|
})()
|
||||||
{p.phone && (
|
) : (
|
||||||
<div>
|
<span className="text-[#6c7686]">—</span>
|
||||||
<a href={`tel:${p.phone.replace(/[^\d+]/g, '')}`} className="hover:text-[#ff6b66] font-mono">
|
|
||||||
{p.phone}{p.extension ? ` ext. ${p.extension}` : ''}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</td>
|
||||||
</div>
|
<td className={tdClass}>
|
||||||
</li>
|
{p.phone ? (
|
||||||
|
<a
|
||||||
|
href={`tel:${p.phone.replace(/[^\d+]/g, "")}${p.extension ? "," + p.extension : ""}`}
|
||||||
|
className="hover:text-[#7DD3FC] font-mono text-[#aab1bd]"
|
||||||
|
>
|
||||||
|
{formatPhone(p.phone)}
|
||||||
|
{p.extension ? `, Ext. ${p.extension}` : ""}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-[#6c7686]">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ export default function AccountPage() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#111111]">
|
<div className="min-h-screen bg-[#F8FAFC]">
|
||||||
<Header />
|
<Header />
|
||||||
<div className="max-w-container mx-auto px-4 py-12">
|
<div className="max-w-container mx-auto px-4 py-12">
|
||||||
<div className="max-w-3xl mx-auto space-y-4">
|
<div className="max-w-3xl mx-auto space-y-4">
|
||||||
@@ -298,21 +298,21 @@ export default function AccountPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#111111]">
|
<div className="min-h-screen bg-[#F8FAFC]">
|
||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
{/* Toast */}
|
{/* Toast */}
|
||||||
{toast && (
|
{toast && (
|
||||||
<div
|
<div
|
||||||
className={`fixed bottom-6 left-1/2 -translate-x-1/2 z-50 border font-body text-sm px-5 py-3 rounded-sm shadow-lg flex items-center gap-2 animate-fade-in ${
|
className={`fixed bottom-6 left-1/2 -translate-x-1/2 z-50 border font-body text-sm px-5 py-3 rounded-sm shadow-lg flex items-center gap-2 animate-fade-in ${
|
||||||
toast.type === "success" ?"bg-[#1a2535] border-[#3b82f6] text-[#e0e0e0]" :"bg-[#1a0a0a] border-[#cc0000] text-[#e0e0e0]"
|
toast.type === "success" ?"bg-[#FFFFFF] border-[#1D4ED8] text-[#e0e0e0]" :"bg-[#1a0a0a] border-[#1D4ED8] text-[#e0e0e0]"
|
||||||
}`}>
|
}`}>
|
||||||
{toast.type === "success" ? (
|
{toast.type === "success" ? (
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" strokeWidth="2.5" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#1D4ED8" strokeWidth="2.5" aria-hidden="true">
|
||||||
<polyline points="20 6 9 17 4 12" />
|
<polyline points="20 6 9 17 4 12" />
|
||||||
</svg>
|
</svg>
|
||||||
) : (
|
) : (
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#cc0000" strokeWidth="2.5" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#1D4ED8" strokeWidth="2.5" aria-hidden="true">
|
||||||
<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" />
|
<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>
|
</svg>
|
||||||
)}
|
)}
|
||||||
@@ -321,10 +321,10 @@ export default function AccountPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
<div className="bg-[#0d0d0d] border-b border-[#1e1e1e]">
|
<div className="bg-[#F8FAFC] border-b border-[#1e1e1e]">
|
||||||
<div className="max-w-container mx-auto px-4 py-2.5">
|
<div className="max-w-container mx-auto px-4 py-2.5">
|
||||||
<nav aria-label="Breadcrumb" className="flex items-center gap-2 text-xs font-body text-[#555]">
|
<nav aria-label="Breadcrumb" className="flex items-center gap-2 text-xs font-body text-[#555]">
|
||||||
<Link href="/home-page" className="hover:text-[#3b82f6] transition-colors">Home</Link>
|
<Link href="/home-page" className="hover:text-[#1D4ED8] transition-colors">Home</Link>
|
||||||
<span aria-hidden="true">/</span>
|
<span aria-hidden="true">/</span>
|
||||||
<span className="text-[#888]">Account</span>
|
<span className="text-[#888]">Account</span>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -336,25 +336,25 @@ export default function AccountPage() {
|
|||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-heading text-2xl font-bold text-[#e8e8e8]">Account Settings</h1>
|
<h1 className="font-heading text-2xl font-bold text-[#0F172A]">Account Settings</h1>
|
||||||
<p className="font-body text-sm text-[#666] mt-1">{user?.email}</p>
|
<p className="font-body text-sm text-[#666] mt-1">{user?.email}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleSignOut}
|
onClick={handleSignOut}
|
||||||
className="font-body text-xs font-bold uppercase tracking-wide text-[#888] hover:text-[#cc0000] border border-[#2a2a2a] hover:border-[#cc0000] px-4 py-2 rounded-sm transition-colors">
|
className="font-body text-xs font-bold uppercase tracking-wide text-[#888] hover:text-[#1D4ED8] border border-[#DCE6F2] hover:border-[#1D4ED8] px-4 py-2 rounded-sm transition-colors">
|
||||||
Sign Out
|
Sign Out
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex border-b border-[#2a2a2a] mb-6 overflow-x-auto scrollbar-none">
|
<div className="flex border-b border-[#DCE6F2] mb-6 overflow-x-auto scrollbar-none">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={`font-body text-sm font-bold uppercase tracking-wider px-4 py-3 border-b-2 transition-colors whitespace-nowrap ${
|
className={`font-body text-sm font-bold uppercase tracking-wider px-4 py-3 border-b-2 transition-colors whitespace-nowrap ${
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? "border-[#3b82f6] text-[#3b82f6]"
|
? "border-[#1D4ED8] text-[#1D4ED8]"
|
||||||
: "border-transparent text-[#666] hover:text-[#aaa]"
|
: "border-transparent text-[#666] hover:text-[#aaa]"
|
||||||
}`}>
|
}`}>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
@@ -365,8 +365,8 @@ export default function AccountPage() {
|
|||||||
{/* Profile Tab */}
|
{/* Profile Tab */}
|
||||||
{activeTab === "profile" && (
|
{activeTab === "profile" && (
|
||||||
<form onSubmit={handleSaveProfile} className="space-y-5">
|
<form onSubmit={handleSaveProfile} className="space-y-5">
|
||||||
<div className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm p-6">
|
<div className="bg-[#FFFFFF] border border-[#DCE6F2] rounded-sm p-6">
|
||||||
<h2 className="font-heading text-base font-bold text-[#e0e0e0] mb-5 pb-3 border-b border-[#2a2a2a]">
|
<h2 className="font-heading text-base font-bold text-[#e0e0e0] mb-5 pb-3 border-b border-[#DCE6F2]">
|
||||||
Public Profile
|
Public Profile
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -379,7 +379,7 @@ export default function AccountPage() {
|
|||||||
value={fullName}
|
value={fullName}
|
||||||
onChange={(e) => setFullName(e.target.value)}
|
onChange={(e) => setFullName(e.target.value)}
|
||||||
placeholder="Your full name"
|
placeholder="Your full name"
|
||||||
className="w-full bg-[#111111] border border-[#2a2a2a] text-[#e0e0e0] font-body text-sm px-3 py-2.5 rounded-sm focus:outline-none focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6] transition-colors placeholder-[#444]"
|
className="w-full bg-[#F8FAFC] border border-[#DCE6F2] text-[#e0e0e0] font-body text-sm px-3 py-2.5 rounded-sm focus:outline-none focus:border-[#1D4ED8] focus:ring-1 focus:ring-[#1D4ED8] transition-colors placeholder-[#444]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -391,7 +391,7 @@ export default function AccountPage() {
|
|||||||
onChange={(e) => setBio(e.target.value)}
|
onChange={(e) => setBio(e.target.value)}
|
||||||
placeholder="Tell us a bit about yourself..."
|
placeholder="Tell us a bit about yourself..."
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full bg-[#111111] border border-[#2a2a2a] text-[#e0e0e0] font-body text-sm px-3 py-2.5 rounded-sm focus:outline-none focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6] transition-colors placeholder-[#444] resize-none"
|
className="w-full bg-[#F8FAFC] border border-[#DCE6F2] text-[#e0e0e0] font-body text-sm px-3 py-2.5 rounded-sm focus:outline-none focus:border-[#1D4ED8] focus:ring-1 focus:ring-[#1D4ED8] transition-colors placeholder-[#444] resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
@@ -404,7 +404,7 @@ export default function AccountPage() {
|
|||||||
value={website}
|
value={website}
|
||||||
onChange={(e) => setWebsite(e.target.value)}
|
onChange={(e) => setWebsite(e.target.value)}
|
||||||
placeholder="https://yoursite.com"
|
placeholder="https://yoursite.com"
|
||||||
className="w-full bg-[#111111] border border-[#2a2a2a] text-[#e0e0e0] font-body text-sm px-3 py-2.5 rounded-sm focus:outline-none focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6] transition-colors placeholder-[#444]"
|
className="w-full bg-[#F8FAFC] border border-[#DCE6F2] text-[#e0e0e0] font-body text-sm px-3 py-2.5 rounded-sm focus:outline-none focus:border-[#1D4ED8] focus:ring-1 focus:ring-[#1D4ED8] transition-colors placeholder-[#444]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -416,15 +416,15 @@ export default function AccountPage() {
|
|||||||
value={location}
|
value={location}
|
||||||
onChange={(e) => setLocation(e.target.value)}
|
onChange={(e) => setLocation(e.target.value)}
|
||||||
placeholder="City, Country"
|
placeholder="City, Country"
|
||||||
className="w-full bg-[#111111] border border-[#2a2a2a] text-[#e0e0e0] font-body text-sm px-3 py-2.5 rounded-sm focus:outline-none focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6] transition-colors placeholder-[#444]"
|
className="w-full bg-[#F8FAFC] border border-[#DCE6F2] text-[#e0e0e0] font-body text-sm px-3 py-2.5 rounded-sm focus:outline-none focus:border-[#1D4ED8] focus:ring-1 focus:ring-[#1D4ED8] transition-colors placeholder-[#444]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm p-6">
|
<div className="bg-[#FFFFFF] border border-[#DCE6F2] rounded-sm p-6">
|
||||||
<h2 className="font-heading text-base font-bold text-[#e0e0e0] mb-3 pb-3 border-b border-[#2a2a2a]">
|
<h2 className="font-heading text-base font-bold text-[#e0e0e0] mb-3 pb-3 border-b border-[#DCE6F2]">
|
||||||
Account Info
|
Account Info
|
||||||
</h2>
|
</h2>
|
||||||
<div>
|
<div>
|
||||||
@@ -435,7 +435,7 @@ export default function AccountPage() {
|
|||||||
type="email"
|
type="email"
|
||||||
value={user?.email || ""}
|
value={user?.email || ""}
|
||||||
disabled
|
disabled
|
||||||
className="w-full bg-[#0d0d0d] border border-[#222] text-[#555] font-body text-sm px-3 py-2.5 rounded-sm cursor-not-allowed"
|
className="w-full bg-[#F8FAFC] border border-[#222] text-[#555] font-body text-sm px-3 py-2.5 rounded-sm cursor-not-allowed"
|
||||||
/>
|
/>
|
||||||
<p className="font-body text-xs text-[#555] mt-1.5">Email cannot be changed here.</p>
|
<p className="font-body text-xs text-[#555] mt-1.5">Email cannot be changed here.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -445,7 +445,7 @@ export default function AccountPage() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="bg-[#3b82f6] hover:bg-[#2563eb] text-white font-body text-xs font-bold uppercase tracking-wide px-6 py-2.5 rounded-sm transition-colors disabled:opacity-60 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6]">
|
className="bg-[#1D4ED8] hover:bg-[#1E3A8A] text-white font-body text-xs font-bold uppercase tracking-wide px-6 py-2.5 rounded-sm transition-colors disabled:opacity-60 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#1D4ED8]">
|
||||||
{saving ? "Saving..." : "Save Profile"}
|
{saving ? "Saving..." : "Save Profile"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -456,8 +456,8 @@ export default function AccountPage() {
|
|||||||
{activeTab === "security" && (
|
{activeTab === "security" && (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<form onSubmit={handleChangePassword}>
|
<form onSubmit={handleChangePassword}>
|
||||||
<div className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm p-6">
|
<div className="bg-[#FFFFFF] border border-[#DCE6F2] rounded-sm p-6">
|
||||||
<h2 className="font-heading text-base font-bold text-[#e0e0e0] mb-5 pb-3 border-b border-[#2a2a2a]">
|
<h2 className="font-heading text-base font-bold text-[#e0e0e0] mb-5 pb-3 border-b border-[#DCE6F2]">
|
||||||
Change Password
|
Change Password
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -471,7 +471,7 @@ export default function AccountPage() {
|
|||||||
onChange={(e) => setNewPassword(e.target.value)}
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
placeholder="Enter new password"
|
placeholder="Enter new password"
|
||||||
minLength={6}
|
minLength={6}
|
||||||
className="w-full bg-[#111111] border border-[#2a2a2a] text-[#e0e0e0] font-body text-sm px-3 py-2.5 rounded-sm focus:outline-none focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6] transition-colors placeholder-[#444]"
|
className="w-full bg-[#F8FAFC] border border-[#DCE6F2] text-[#e0e0e0] font-body text-sm px-3 py-2.5 rounded-sm focus:outline-none focus:border-[#1D4ED8] focus:ring-1 focus:ring-[#1D4ED8] transition-colors placeholder-[#444]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -484,7 +484,7 @@ export default function AccountPage() {
|
|||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
placeholder="Confirm new password"
|
placeholder="Confirm new password"
|
||||||
minLength={6}
|
minLength={6}
|
||||||
className="w-full bg-[#111111] border border-[#2a2a2a] text-[#e0e0e0] font-body text-sm px-3 py-2.5 rounded-sm focus:outline-none focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6] transition-colors placeholder-[#444]"
|
className="w-full bg-[#F8FAFC] border border-[#DCE6F2] text-[#e0e0e0] font-body text-sm px-3 py-2.5 rounded-sm focus:outline-none focus:border-[#1D4ED8] focus:ring-1 focus:ring-[#1D4ED8] transition-colors placeholder-[#444]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -492,7 +492,7 @@ export default function AccountPage() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={changingPassword || !newPassword || !confirmPassword}
|
disabled={changingPassword || !newPassword || !confirmPassword}
|
||||||
className="bg-[#3b82f6] hover:bg-[#2563eb] text-white font-body text-xs font-bold uppercase tracking-wide px-6 py-2.5 rounded-sm transition-colors disabled:opacity-60 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6]">
|
className="bg-[#1D4ED8] hover:bg-[#1E3A8A] text-white font-body text-xs font-bold uppercase tracking-wide px-6 py-2.5 rounded-sm transition-colors disabled:opacity-60 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#1D4ED8]">
|
||||||
{changingPassword ? "Updating..." : "Update Password"}
|
{changingPassword ? "Updating..." : "Update Password"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -506,7 +506,7 @@ export default function AccountPage() {
|
|||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleSignOut}
|
onClick={handleSignOut}
|
||||||
className="font-body text-xs font-bold uppercase tracking-wide text-[#cc0000] border border-[#cc0000]/40 hover:border-[#cc0000] hover:bg-[#cc0000]/10 px-5 py-2 rounded-sm transition-colors">
|
className="font-body text-xs font-bold uppercase tracking-wide text-[#1D4ED8] border border-[#1D4ED8]/40 hover:border-[#1D4ED8] hover:bg-[#1D4ED8]/10 px-5 py-2 rounded-sm transition-colors">
|
||||||
Sign Out of All Sessions
|
Sign Out of All Sessions
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -520,12 +520,12 @@ export default function AccountPage() {
|
|||||||
<h2 className="font-heading text-base font-bold text-[#e0e0e0]">
|
<h2 className="font-heading text-base font-bold text-[#e0e0e0]">
|
||||||
Saved Articles
|
Saved Articles
|
||||||
{bookmarks.length > 0 && (
|
{bookmarks.length > 0 && (
|
||||||
<span className="ml-2 font-body text-xs text-[#666] bg-[#1a1a1a] border border-[#2a2a2a] px-2 py-0.5 rounded-sm">
|
<span className="ml-2 font-body text-xs text-[#666] bg-[#FFFFFF] border border-[#DCE6F2] px-2 py-0.5 rounded-sm">
|
||||||
{bookmarks.length}
|
{bookmarks.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
<Link href="/reading-list" className="font-body text-xs font-bold uppercase tracking-wide text-[#3b82f6] hover:text-[#2563eb] transition-colors">
|
<Link href="/reading-list" className="font-body text-xs font-bold uppercase tracking-wide text-[#1D4ED8] hover:text-[#1E3A8A] transition-colors">
|
||||||
Full List →
|
Full List →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -533,37 +533,37 @@ export default function AccountPage() {
|
|||||||
{bookmarksLoading ? (
|
{bookmarksLoading ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{[1, 2, 3].map((i) => (
|
{[1, 2, 3].map((i) => (
|
||||||
<div key={i} className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm p-4 animate-pulse">
|
<div key={i} className="bg-[#FFFFFF] border border-[#DCE6F2] rounded-sm p-4 animate-pulse">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<div className="w-20 h-14 bg-[#252525] rounded-sm flex-shrink-0" />
|
<div className="w-20 h-14 bg-[#DCE6F2] rounded-sm flex-shrink-0" />
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<div className="h-3 bg-[#252525] rounded w-1/4" />
|
<div className="h-3 bg-[#DCE6F2] rounded w-1/4" />
|
||||||
<div className="h-4 bg-[#252525] rounded w-3/4" />
|
<div className="h-4 bg-[#DCE6F2] rounded w-3/4" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : bookmarks.length === 0 ? (
|
) : bookmarks.length === 0 ? (
|
||||||
<div className="text-center py-12 bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm">
|
<div className="text-center py-12 bg-[#FFFFFF] border border-[#DCE6F2] rounded-sm">
|
||||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#444" strokeWidth="1.5" className="mx-auto mb-3" aria-hidden="true">
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#444" strokeWidth="1.5" className="mx-auto mb-3" aria-hidden="true">
|
||||||
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
<p className="font-body text-sm text-[#666]">No saved articles yet.</p>
|
<p className="font-body text-sm text-[#666]">No saved articles yet.</p>
|
||||||
<Link href="/news" className="inline-block mt-3 font-body text-xs font-bold uppercase tracking-wide text-[#3b82f6] hover:text-[#2563eb] transition-colors">
|
<Link href="/news" className="inline-block mt-3 font-body text-xs font-bold uppercase tracking-wide text-[#1D4ED8] hover:text-[#1E3A8A] transition-colors">
|
||||||
Browse News →
|
Browse News →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{bookmarks.map((item) => (
|
{bookmarks.map((item) => (
|
||||||
<div key={item.id} className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm hover:border-[#3a3a3a] transition-colors group">
|
<div key={item.id} className="bg-[#FFFFFF] border border-[#DCE6F2] rounded-sm hover:border-[#3a3a3a] transition-colors group">
|
||||||
<div className="flex gap-3 p-4">
|
<div className="flex gap-3 p-4">
|
||||||
<Link href={`/articles/${item.article_slug}`} className="flex-shrink-0 w-20 h-14 overflow-hidden rounded-sm">
|
<Link href={`/articles/${item.article_slug}`} className="flex-shrink-0 w-20 h-14 overflow-hidden rounded-sm">
|
||||||
{item.article_image ? (
|
{item.article_image ? (
|
||||||
<AppImage src={item.article_image} alt={item.article_image_alt || item.article_title} width={80} height={56} className="w-full h-full object-cover" />
|
<AppImage src={item.article_image} alt={item.article_image_alt || item.article_title} width={80} height={56} className="w-full h-full object-cover" />
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full bg-[#252525] flex items-center justify-center">
|
<div className="w-full h-full bg-[#DCE6F2] flex items-center justify-center">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#444" strokeWidth="1.5" aria-hidden="true">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#444" strokeWidth="1.5" aria-hidden="true">
|
||||||
<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" />
|
<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>
|
</svg>
|
||||||
@@ -572,10 +572,10 @@ export default function AccountPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{item.article_category && (
|
{item.article_category && (
|
||||||
<span className="font-body text-[9px] font-bold uppercase tracking-widest text-[#3b82f6]">{item.article_category}</span>
|
<span className="font-body text-[9px] font-bold uppercase tracking-widest text-[#1D4ED8]">{item.article_category}</span>
|
||||||
)}
|
)}
|
||||||
<Link href={`/articles/${item.article_slug}`}>
|
<Link href={`/articles/${item.article_slug}`}>
|
||||||
<h3 className="font-heading text-sm font-bold text-[#e0e0e0] group-hover:text-[#3b82f6] transition-colors line-clamp-2 leading-snug mt-0.5">
|
<h3 className="font-heading text-sm font-bold text-[#e0e0e0] group-hover:text-[#1D4ED8] transition-colors line-clamp-2 leading-snug mt-0.5">
|
||||||
{item.article_title}
|
{item.article_title}
|
||||||
</h3>
|
</h3>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -605,7 +605,7 @@ export default function AccountPage() {
|
|||||||
<h2 className="font-heading text-base font-bold text-[#e0e0e0]">
|
<h2 className="font-heading text-base font-bold text-[#e0e0e0]">
|
||||||
Reading History
|
Reading History
|
||||||
{history.length > 0 && (
|
{history.length > 0 && (
|
||||||
<span className="ml-2 font-body text-xs text-[#666] bg-[#1a1a1a] border border-[#2a2a2a] px-2 py-0.5 rounded-sm">
|
<span className="ml-2 font-body text-xs text-[#666] bg-[#FFFFFF] border border-[#DCE6F2] px-2 py-0.5 rounded-sm">
|
||||||
{history.length}
|
{history.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -622,37 +622,37 @@ export default function AccountPage() {
|
|||||||
{historyLoading ? (
|
{historyLoading ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{[1, 2, 3].map((i) => (
|
{[1, 2, 3].map((i) => (
|
||||||
<div key={i} className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm p-4 animate-pulse">
|
<div key={i} className="bg-[#FFFFFF] border border-[#DCE6F2] rounded-sm p-4 animate-pulse">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<div className="w-20 h-14 bg-[#252525] rounded-sm flex-shrink-0" />
|
<div className="w-20 h-14 bg-[#DCE6F2] rounded-sm flex-shrink-0" />
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<div className="h-3 bg-[#252525] rounded w-1/4" />
|
<div className="h-3 bg-[#DCE6F2] rounded w-1/4" />
|
||||||
<div className="h-4 bg-[#252525] rounded w-3/4" />
|
<div className="h-4 bg-[#DCE6F2] rounded w-3/4" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : history.length === 0 ? (
|
) : history.length === 0 ? (
|
||||||
<div className="text-center py-12 bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm">
|
<div className="text-center py-12 bg-[#FFFFFF] border border-[#DCE6F2] rounded-sm">
|
||||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#444" strokeWidth="1.5" className="mx-auto mb-3" aria-hidden="true">
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#444" strokeWidth="1.5" className="mx-auto mb-3" aria-hidden="true">
|
||||||
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
|
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
|
||||||
</svg>
|
</svg>
|
||||||
<p className="font-body text-sm text-[#666]">No reading history yet.</p>
|
<p className="font-body text-sm text-[#666]">No reading history yet.</p>
|
||||||
<Link href="/news" className="inline-block mt-3 font-body text-xs font-bold uppercase tracking-wide text-[#3b82f6] hover:text-[#2563eb] transition-colors">
|
<Link href="/news" className="inline-block mt-3 font-body text-xs font-bold uppercase tracking-wide text-[#1D4ED8] hover:text-[#1E3A8A] transition-colors">
|
||||||
Browse News →
|
Browse News →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{history.map((item) => (
|
{history.map((item) => (
|
||||||
<div key={item.id} className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm hover:border-[#3a3a3a] transition-colors group">
|
<div key={item.id} className="bg-[#FFFFFF] border border-[#DCE6F2] rounded-sm hover:border-[#3a3a3a] transition-colors group">
|
||||||
<div className="flex gap-3 p-4">
|
<div className="flex gap-3 p-4">
|
||||||
<Link href={`/articles/${item.article_slug}`} className="flex-shrink-0 w-20 h-14 overflow-hidden rounded-sm">
|
<Link href={`/articles/${item.article_slug}`} className="flex-shrink-0 w-20 h-14 overflow-hidden rounded-sm">
|
||||||
{item.article_image ? (
|
{item.article_image ? (
|
||||||
<AppImage src={item.article_image} alt={item.article_image_alt || item.article_title} width={80} height={56} className="w-full h-full object-cover" />
|
<AppImage src={item.article_image} alt={item.article_image_alt || item.article_title} width={80} height={56} className="w-full h-full object-cover" />
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full bg-[#252525] flex items-center justify-center">
|
<div className="w-full h-full bg-[#DCE6F2] flex items-center justify-center">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#444" strokeWidth="1.5" aria-hidden="true">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#444" strokeWidth="1.5" aria-hidden="true">
|
||||||
<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" />
|
<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>
|
</svg>
|
||||||
@@ -661,10 +661,10 @@ export default function AccountPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{item.article_category && (
|
{item.article_category && (
|
||||||
<span className="font-body text-[9px] font-bold uppercase tracking-widest text-[#3b82f6]">{item.article_category}</span>
|
<span className="font-body text-[9px] font-bold uppercase tracking-widest text-[#1D4ED8]">{item.article_category}</span>
|
||||||
)}
|
)}
|
||||||
<Link href={`/articles/${item.article_slug}`}>
|
<Link href={`/articles/${item.article_slug}`}>
|
||||||
<h3 className="font-heading text-sm font-bold text-[#e0e0e0] group-hover:text-[#3b82f6] transition-colors line-clamp-2 leading-snug mt-0.5">
|
<h3 className="font-heading text-sm font-bold text-[#e0e0e0] group-hover:text-[#1D4ED8] transition-colors line-clamp-2 leading-snug mt-0.5">
|
||||||
{item.article_title}
|
{item.article_title}
|
||||||
</h3>
|
</h3>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -693,17 +693,17 @@ export default function AccountPage() {
|
|||||||
{topicsLoading ? (
|
{topicsLoading ? (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||||
<div key={i} className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm p-4 animate-pulse h-16" />
|
<div key={i} className="bg-[#FFFFFF] border border-[#DCE6F2] rounded-sm p-4 animate-pulse h-16" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : topics.length === 0 ? (
|
) : topics.length === 0 ? (
|
||||||
<div className="text-center py-12 bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm">
|
<div className="text-center py-12 bg-[#FFFFFF] border border-[#DCE6F2] rounded-sm">
|
||||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#444" strokeWidth="1.5" className="mx-auto mb-3" aria-hidden="true">
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#444" strokeWidth="1.5" className="mx-auto mb-3" aria-hidden="true">
|
||||||
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" /><line x1="7" y1="7" x2="7.01" y2="7" />
|
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" /><line x1="7" y1="7" x2="7.01" y2="7" />
|
||||||
</svg>
|
</svg>
|
||||||
<p className="font-body text-sm text-[#666]">No topic preferences tracked yet.</p>
|
<p className="font-body text-sm text-[#666]">No topic preferences tracked yet.</p>
|
||||||
<p className="font-body text-xs text-[#555] mt-1">Read articles to build your interest profile.</p>
|
<p className="font-body text-xs text-[#555] mt-1">Read articles to build your interest profile.</p>
|
||||||
<Link href="/news" className="inline-block mt-3 font-body text-xs font-bold uppercase tracking-wide text-[#3b82f6] hover:text-[#2563eb] transition-colors">
|
<Link href="/news" className="inline-block mt-3 font-body text-xs font-bold uppercase tracking-wide text-[#1D4ED8] hover:text-[#1E3A8A] transition-colors">
|
||||||
Browse News →
|
Browse News →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -714,18 +714,18 @@ export default function AccountPage() {
|
|||||||
const maxCount = topics[0]?.view_count || 1;
|
const maxCount = topics[0]?.view_count || 1;
|
||||||
const pct = Math.round((t.view_count / maxCount) * 100);
|
const pct = Math.round((t.view_count / maxCount) * 100);
|
||||||
return (
|
return (
|
||||||
<div key={t.id} className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm p-4 hover:border-[#3a3a3a] transition-colors">
|
<div key={t.id} className="bg-[#FFFFFF] border border-[#DCE6F2] rounded-sm p-4 hover:border-[#3a3a3a] transition-colors">
|
||||||
<div className="flex items-start justify-between mb-2">
|
<div className="flex items-start justify-between mb-2">
|
||||||
<span className="font-body text-xs font-bold uppercase tracking-wider text-[#e0e0e0] capitalize leading-tight">
|
<span className="font-body text-xs font-bold uppercase tracking-wider text-[#e0e0e0] capitalize leading-tight">
|
||||||
{t.topic}
|
{t.topic}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-body text-[10px] text-[#3b82f6] font-bold ml-2 flex-shrink-0">
|
<span className="font-body text-[10px] text-[#1D4ED8] font-bold ml-2 flex-shrink-0">
|
||||||
{t.view_count}×
|
{t.view_count}×
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-[#252525] rounded-full h-1">
|
<div className="w-full bg-[#DCE6F2] rounded-full h-1">
|
||||||
<div
|
<div
|
||||||
className="bg-[#3b82f6] h-1 rounded-full transition-all"
|
className="bg-[#1D4ED8] h-1 rounded-full transition-all"
|
||||||
style={{ width: `${pct}%` }}
|
style={{ width: `${pct}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -744,13 +744,13 @@ export default function AccountPage() {
|
|||||||
{/* Preferences Tab */}
|
{/* Preferences Tab */}
|
||||||
{activeTab === "preferences" && (
|
{activeTab === "preferences" && (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm p-6">
|
<div className="bg-[#FFFFFF] border border-[#DCE6F2] rounded-sm p-6">
|
||||||
<h2 className="font-heading text-base font-bold text-[#e0e0e0] mb-5 pb-3 border-b border-[#2a2a2a]">
|
<h2 className="font-heading text-base font-bold text-[#e0e0e0] mb-5 pb-3 border-b border-[#DCE6F2]">
|
||||||
Forum Reputation
|
Forum Reputation
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex flex-col items-center justify-center bg-[#111] border border-[#2a2a2a] rounded-sm px-6 py-4 min-w-[100px]">
|
<div className="flex flex-col items-center justify-center bg-[#111] border border-[#DCE6F2] rounded-sm px-6 py-4 min-w-[100px]">
|
||||||
<span className="font-heading text-3xl font-bold text-[#3b82f6]">
|
<span className="font-heading text-3xl font-bold text-[#1D4ED8]">
|
||||||
{profile?.reputation ?? 0}
|
{profile?.reputation ?? 0}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-body text-xs text-[#666] mt-1 uppercase tracking-wider">Points</span>
|
<span className="font-body text-xs text-[#666] mt-1 uppercase tracking-wider">Points</span>
|
||||||
@@ -759,26 +759,26 @@ export default function AccountPage() {
|
|||||||
<p className="font-body text-sm text-[#aaa] leading-relaxed">
|
<p className="font-body text-sm text-[#aaa] leading-relaxed">
|
||||||
Earn reputation by posting helpful threads and replies in the community forum.
|
Earn reputation by posting helpful threads and replies in the community forum.
|
||||||
</p>
|
</p>
|
||||||
<Link href="/forum" className="inline-block mt-3 font-body text-xs font-bold uppercase tracking-wide text-[#3b82f6] hover:text-[#2563eb] transition-colors">
|
<Link href="/forum" className="inline-block mt-3 font-body text-xs font-bold uppercase tracking-wide text-[#1D4ED8] hover:text-[#1E3A8A] transition-colors">
|
||||||
Go to Forum →
|
Go to Forum →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm p-6">
|
<div className="bg-[#FFFFFF] border border-[#DCE6F2] rounded-sm p-6">
|
||||||
<h2 className="font-heading text-base font-bold text-[#e0e0e0] mb-5 pb-3 border-b border-[#2a2a2a]">
|
<h2 className="font-heading text-base font-bold text-[#e0e0e0] mb-5 pb-3 border-b border-[#DCE6F2]">
|
||||||
Reading Preferences
|
Reading Preferences
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between py-3 border-b border-[#222]">
|
<div className="flex items-center justify-between py-3 border-b border-[#222]">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-body text-sm font-bold text-[#e0e0e0]">Email Newsletter</p>
|
<p className="font-body text-sm font-bold text-[#e0e0e0]">Email Newsletter</p>
|
||||||
<p className="font-body text-xs text-[#666] mt-0.5">Receive weekly broadcast industry news</p>
|
<p className="font-body text-xs text-[#666] mt-0.5">Receive weekly pro AV industry news</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/home-page"
|
href="/home-page"
|
||||||
className="font-body text-xs font-bold uppercase tracking-wide text-[#3b82f6] hover:text-[#2563eb] transition-colors">
|
className="font-body text-xs font-bold uppercase tracking-wide text-[#1D4ED8] hover:text-[#1E3A8A] transition-colors">
|
||||||
Manage →
|
Manage →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -789,7 +789,7 @@ export default function AccountPage() {
|
|||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/reading-list"
|
href="/reading-list"
|
||||||
className="font-body text-xs font-bold uppercase tracking-wide text-[#3b82f6] hover:text-[#2563eb] transition-colors">
|
className="font-body text-xs font-bold uppercase tracking-wide text-[#1D4ED8] hover:text-[#1E3A8A] transition-colors">
|
||||||
View List →
|
View List →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -808,8 +808,8 @@ export default function AccountPage() {
|
|||||||
onClick={() => handleToggleDigest(!weeklyDigest)}
|
onClick={() => handleToggleDigest(!weeklyDigest)}
|
||||||
aria-pressed={weeklyDigest}
|
aria-pressed={weeklyDigest}
|
||||||
aria-label="Toggle weekly digest email"
|
aria-label="Toggle weekly digest email"
|
||||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6] disabled:opacity-50 ${
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-[#1D4ED8] disabled:opacity-50 ${
|
||||||
weeklyDigest ? "bg-[#3b82f6] border-[#3b82f6]" : "bg-[#2a2a2a] border-[#333]"
|
weeklyDigest ? "bg-[#1D4ED8] border-[#1D4ED8]" : "bg-[#DCE6F2] border-[#333]"
|
||||||
}`}>
|
}`}>
|
||||||
<span
|
<span
|
||||||
className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow transition duration-200 ease-in-out mt-0.5 ${
|
className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow transition duration-200 ease-in-out mt-0.5 ${
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export default function ClientsPage() {
|
|||||||
setForm({ company_name: '', contact_name: '', contact_email: '', contact_phone: '', billing_address: '', notes: '' });
|
setForm({ company_name: '', contact_name: '', contact_email: '', contact_phone: '', billing_address: '', notes: '' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /></div>;
|
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" /></div>;
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -102,7 +102,7 @@ export default function ClientsPage() {
|
|||||||
<h1 className="text-xl font-bold">Clients</h1>
|
<h1 className="text-xl font-bold">Clients</h1>
|
||||||
<Link href="/admin/accounting" className="text-xs text-[#555] hover:text-[#888]">← Accounting</Link>
|
<Link href="/admin/accounting" className="text-xs text-[#555] hover:text-[#888]">← Accounting</Link>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setShowForm(true)} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500 transition-colors">+ New Client</button>
|
<button onClick={() => setShowForm(true)} className="px-3 py-1.5 bg-[#1D4ED8] rounded text-xs font-medium hover:bg-blue-500 transition-colors">+ New Client</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Success Toast */}
|
{/* Success Toast */}
|
||||||
@@ -116,14 +116,14 @@ export default function ClientsPage() {
|
|||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{['active','inactive','all'].map(s => (
|
{['active','inactive','all'].map(s => (
|
||||||
<button key={s} onClick={() => setFilter(s)} className={`px-3 py-1 rounded text-xs capitalize ${filter === s ? 'bg-[#3b82f6] text-white' : 'bg-[#111] border border-[#252525] text-[#888] hover:text-white'}`}>{s}</button>
|
<button key={s} onClick={() => setFilter(s)} className={`px-3 py-1 rounded text-xs capitalize ${filter === s ? 'bg-[#1D4ED8] text-white' : 'bg-[#111] border border-[#DCE6F2] text-[#888] hover:text-white'}`}>{s}</button>
|
||||||
))}
|
))}
|
||||||
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search clients…" className="ml-auto px-3 py-1 bg-[#111] border border-[#252525] rounded text-xs text-white placeholder-[#555] focus:outline-none focus:border-[#3b82f6]" />
|
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search clients…" className="ml-auto px-3 py-1 bg-[#111] border border-[#DCE6F2] rounded text-xs text-white placeholder-[#555] focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* New Client Form */}
|
{/* New Client Form */}
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<form onSubmit={handleCreate} noValidate className="bg-[#111] border border-[#252525] rounded-lg p-4 space-y-3">
|
<form onSubmit={handleCreate} noValidate className="bg-[#111] border border-[#DCE6F2] rounded-lg p-4 space-y-3">
|
||||||
<h3 className="text-sm font-semibold">New Client</h3>
|
<h3 className="text-sm font-semibold">New Client</h3>
|
||||||
|
|
||||||
{errors._form && (
|
{errors._form && (
|
||||||
@@ -148,7 +148,7 @@ export default function ClientsPage() {
|
|||||||
type={f.key === 'contact_email' ? 'email' : 'text'}
|
type={f.key === 'contact_email' ? 'email' : 'text'}
|
||||||
value={(form as any)[f.key]}
|
value={(form as any)[f.key]}
|
||||||
onChange={e => handleChange(f.key, e.target.value)}
|
onChange={e => handleChange(f.key, e.target.value)}
|
||||||
className={`w-full px-2 py-1.5 bg-[#0a0a0a] border rounded text-xs text-white focus:outline-none focus:border-[#3b82f6] ${errors[f.key] ? 'border-red-500/60' : 'border-[#252525]'}`}
|
className={`w-full px-2 py-1.5 bg-[#0a0a0a] border rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8] ${errors[f.key] ? 'border-red-500/60' : 'border-[#DCE6F2]'}`}
|
||||||
/>
|
/>
|
||||||
{errors[f.key] && <p className="mt-1 text-xs text-red-400">{errors[f.key]}</p>}
|
{errors[f.key] && <p className="mt-1 text-xs text-red-400">{errors[f.key]}</p>}
|
||||||
</div>
|
</div>
|
||||||
@@ -156,26 +156,26 @@ export default function ClientsPage() {
|
|||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<label className="text-xs text-[#888] block mb-1">Billing Address</label>
|
<label className="text-xs text-[#888] block mb-1">Billing Address</label>
|
||||||
<input value={form.billing_address} onChange={e => handleChange('billing_address', e.target.value)}
|
<input value={form.billing_address} onChange={e => handleChange('billing_address', e.target.value)}
|
||||||
className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
|
className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<label className="text-xs text-[#888] block mb-1">Notes</label>
|
<label className="text-xs text-[#888] block mb-1">Notes</label>
|
||||||
<textarea value={form.notes} onChange={e => handleChange('notes', e.target.value)} rows={2}
|
<textarea value={form.notes} onChange={e => handleChange('notes', e.target.value)} rows={2}
|
||||||
className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
|
className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button type="submit" disabled={saving} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500 disabled:opacity-50">{saving ? 'Saving…' : 'Create Client'}</button>
|
<button type="submit" disabled={saving} className="px-3 py-1.5 bg-[#1D4ED8] rounded text-xs font-medium hover:bg-blue-500 disabled:opacity-50">{saving ? 'Saving…' : 'Create Client'}</button>
|
||||||
<button type="button" onClick={handleCancel} className="px-3 py-1.5 bg-[#1a1a1a] border border-[#252525] rounded text-xs text-[#888] hover:text-white">Cancel</button>
|
<button type="button" onClick={handleCancel} className="px-3 py-1.5 bg-[#FFFFFF] border border-[#DCE6F2] rounded text-xs text-[#888] hover:text-white">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[#1a1a1a]">
|
<tr className="border-b border-[#FFFFFF]">
|
||||||
{['Company','Contact','Email','Active Orders','Total Revenue','Status',''].map(h => (
|
{['Company','Contact','Email','Active Orders','Total Revenue','Status',''].map(h => (
|
||||||
<th key={h} className="px-4 py-2 text-left text-xs text-[#555] font-medium">{h}</th>
|
<th key={h} className="px-4 py-2 text-left text-xs text-[#555] font-medium">{h}</th>
|
||||||
))}
|
))}
|
||||||
@@ -187,17 +187,17 @@ export default function ClientsPage() {
|
|||||||
) : clients.length === 0 ? (
|
) : clients.length === 0 ? (
|
||||||
<tr><td colSpan={7} className="px-4 py-8 text-center text-[#555] text-xs">No clients found</td></tr>
|
<tr><td colSpan={7} className="px-4 py-8 text-center text-[#555] text-xs">No clients found</td></tr>
|
||||||
) : clients.map(c => (
|
) : clients.map(c => (
|
||||||
<tr key={c.id} className="border-b border-[#1a1a1a] hover:bg-[#161616]">
|
<tr key={c.id} className="border-b border-[#FFFFFF] hover:bg-[#FFFFFF]">
|
||||||
<td className="px-4 py-2 text-white font-medium">{c.company_name}</td>
|
<td className="px-4 py-2 text-white font-medium">{c.company_name}</td>
|
||||||
<td className="px-4 py-2 text-[#ccc]">{c.contact_name}</td>
|
<td className="px-4 py-2 text-[#ccc]">{c.contact_name}</td>
|
||||||
<td className="px-4 py-2 text-[#888]">{c.contact_email}</td>
|
<td className="px-4 py-2 text-[#888]">{c.contact_email}</td>
|
||||||
<td className="px-4 py-2 text-[#ccc]">{c.active_orders ?? 0}</td>
|
<td className="px-4 py-2 text-[#ccc]">{c.active_orders ?? 0}</td>
|
||||||
<td className="px-4 py-2 text-green-400">{fmt(c.total_revenue ?? 0)}</td>
|
<td className="px-4 py-2 text-green-400">{fmt(c.total_revenue ?? 0)}</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<span className={`px-2 py-0.5 rounded-full text-xs ${c.status === 'active' ? 'bg-green-400/10 text-green-400' : 'bg-[#1a1a1a] text-[#555]'}`}>{c.status}</span>
|
<span className={`px-2 py-0.5 rounded-full text-xs ${c.status === 'active' ? 'bg-green-400/10 text-green-400' : 'bg-[#FFFFFF] text-[#555]'}`}>{c.status}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<Link href={`/admin/accounting/clients/${c.id}`} className="text-xs text-[#3b82f6] hover:underline">View</Link>
|
<Link href={`/admin/accounting/clients/${c.id}`} className="text-xs text-[#1D4ED8] hover:underline">View</Link>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export default function CommissionsPage() {
|
|||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /></div>;
|
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" /></div>;
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -53,10 +53,10 @@ export default function CommissionsPage() {
|
|||||||
<Link href="/admin/accounting" className="text-xs text-[#555] hover:text-[#888]">← Accounting</Link>
|
<Link href="/admin/accounting" className="text-xs text-[#555] hover:text-[#888]">← Accounting</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<select value={year} onChange={e => setYear(Number(e.target.value))} className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-[#888] focus:outline-none">
|
<select value={year} onChange={e => setYear(Number(e.target.value))} className="px-2 py-1 bg-[#111] border border-[#DCE6F2] rounded text-xs text-[#888] focus:outline-none">
|
||||||
{[2024, 2025, 2026, 2027].map(y => <option key={y} value={y}>{y}</option>)}
|
{[2024, 2025, 2026, 2027].map(y => <option key={y} value={y}>{y}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<select value={filterStaff} onChange={e => setFilterStaff(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-[#888] focus:outline-none">
|
<select value={filterStaff} onChange={e => setFilterStaff(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#DCE6F2] rounded text-xs text-[#888] focus:outline-none">
|
||||||
<option value="">All Staff</option>
|
<option value="">All Staff</option>
|
||||||
{staff.map((s: any) => <option key={s.id} value={s.id}>{s.full_name}</option>)}
|
{staff.map((s: any) => <option key={s.id} value={s.id}>{s.full_name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
@@ -68,7 +68,7 @@ export default function CommissionsPage() {
|
|||||||
{summary.map((s: any) => {
|
{summary.map((s: any) => {
|
||||||
const pct = s.next_threshold_cents ? Math.min(100, Math.round((s.ytd_sales_cents / s.next_threshold_cents) * 100)) : 100;
|
const pct = s.next_threshold_cents ? Math.min(100, Math.round((s.ytd_sales_cents / s.next_threshold_cents) * 100)) : 100;
|
||||||
return (
|
return (
|
||||||
<div key={s.staff_id} className="bg-[#111] border border-[#252525] rounded-lg p-4 space-y-2">
|
<div key={s.staff_id} className="bg-[#111] border border-[#DCE6F2] rounded-lg p-4 space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm font-semibold text-white">{s.full_name}</p>
|
<p className="text-sm font-semibold text-white">{s.full_name}</p>
|
||||||
<span className="text-xs text-[#888]">{s.current_tier}</span>
|
<span className="text-xs text-[#888]">{s.current_tier}</span>
|
||||||
@@ -83,8 +83,8 @@ export default function CommissionsPage() {
|
|||||||
<div className="flex justify-between text-xs text-[#555] mb-1">
|
<div className="flex justify-between text-xs text-[#555] mb-1">
|
||||||
<span>Tier progress</span><span>{pct}%</span>
|
<span>Tier progress</span><span>{pct}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1.5 bg-[#1a1a1a] rounded-full overflow-hidden">
|
<div className="h-1.5 bg-[#FFFFFF] rounded-full overflow-hidden">
|
||||||
<div className="h-full bg-[#3b82f6] rounded-full transition-all" style={{ width: `${pct}%` }} />
|
<div className="h-full bg-[#1D4ED8] rounded-full transition-all" style={{ width: `${pct}%` }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -94,10 +94,10 @@ export default function CommissionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Detail table */}
|
{/* Detail table */}
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-x-auto">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[#1a1a1a]">
|
<tr className="border-b border-[#FFFFFF]">
|
||||||
{['Staff','Invoice','Sale Amount','Rate','Commission','YTD at Time','Paid',''].map(h => (
|
{['Staff','Invoice','Sale Amount','Rate','Commission','YTD at Time','Paid',''].map(h => (
|
||||||
<th key={h} className="px-3 py-2 text-left text-xs text-[#555] font-medium">{h}</th>
|
<th key={h} className="px-3 py-2 text-left text-xs text-[#555] font-medium">{h}</th>
|
||||||
))}
|
))}
|
||||||
@@ -109,9 +109,9 @@ export default function CommissionsPage() {
|
|||||||
) : commissions.length === 0 ? (
|
) : commissions.length === 0 ? (
|
||||||
<tr><td colSpan={8} className="px-4 py-8 text-center text-[#555] text-xs">No commission records</td></tr>
|
<tr><td colSpan={8} className="px-4 py-8 text-center text-[#555] text-xs">No commission records</td></tr>
|
||||||
) : commissions.map((c: any) => (
|
) : commissions.map((c: any) => (
|
||||||
<tr key={c.id} className="border-b border-[#1a1a1a] hover:bg-[#161616]">
|
<tr key={c.id} className="border-b border-[#FFFFFF] hover:bg-[#FFFFFF]">
|
||||||
<td className="px-3 py-2 text-white text-xs">{c.rmp_sales_staff?.full_name ?? '—'}</td>
|
<td className="px-3 py-2 text-white text-xs">{c.rmp_sales_staff?.full_name ?? '—'}</td>
|
||||||
<td className="px-3 py-2 text-[#3b82f6] text-xs font-mono">{c.rmp_invoices?.invoice_number ?? '—'}</td>
|
<td className="px-3 py-2 text-[#1D4ED8] text-xs font-mono">{c.rmp_invoices?.invoice_number ?? '—'}</td>
|
||||||
<td className="px-3 py-2 text-[#ccc] text-xs">{fmt(c.sale_amount_cents ?? 0)}</td>
|
<td className="px-3 py-2 text-[#ccc] text-xs">{fmt(c.sale_amount_cents ?? 0)}</td>
|
||||||
<td className="px-3 py-2 text-[#888] text-xs">{((c.commission_rate ?? 0) * 100).toFixed(1)}%</td>
|
<td className="px-3 py-2 text-[#888] text-xs">{((c.commission_rate ?? 0) * 100).toFixed(1)}%</td>
|
||||||
<td className="px-3 py-2 text-green-400 text-xs">{fmt(c.commission_amount_cents ?? 0)}</td>
|
<td className="px-3 py-2 text-green-400 text-xs">{fmt(c.commission_amount_cents ?? 0)}</td>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default function DocumentsPage() {
|
|||||||
|
|
||||||
const years = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i);
|
const years = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i);
|
||||||
|
|
||||||
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /></div>;
|
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" /></div>;
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -54,70 +54,70 @@ export default function DocumentsPage() {
|
|||||||
<h1 className="text-xl font-bold">Document Vault</h1>
|
<h1 className="text-xl font-bold">Document Vault</h1>
|
||||||
<Link href="/admin/accounting" className="text-xs text-[#555] hover:text-[#888]">← Accounting</Link>
|
<Link href="/admin/accounting" className="text-xs text-[#555] hover:text-[#888]">← Accounting</Link>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setShowForm(true)} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500">+ Upload Document</button>
|
<button onClick={() => setShowForm(true)} className="px-3 py-1.5 bg-[#1D4ED8] rounded text-xs font-medium hover:bg-blue-500">+ Upload Document</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Year tabs */}
|
{/* Year tabs */}
|
||||||
<div className="flex gap-1 flex-wrap">
|
<div className="flex gap-1 flex-wrap">
|
||||||
<button onClick={() => setFilterYear('')} className={`px-3 py-1 rounded text-xs ${!filterYear ? 'bg-[#3b82f6] text-white' : 'bg-[#111] border border-[#252525] text-[#888]'}`}>All Years</button>
|
<button onClick={() => setFilterYear('')} className={`px-3 py-1 rounded text-xs ${!filterYear ? 'bg-[#1D4ED8] text-white' : 'bg-[#111] border border-[#DCE6F2] text-[#888]'}`}>All Years</button>
|
||||||
{years.map(y => (
|
{years.map(y => (
|
||||||
<button key={y} onClick={() => setFilterYear(String(y))} className={`px-3 py-1 rounded text-xs ${filterYear === String(y) ? 'bg-[#3b82f6] text-white' : 'bg-[#111] border border-[#252525] text-[#888]'}`}>{y}</button>
|
<button key={y} onClick={() => setFilterYear(String(y))} className={`px-3 py-1 rounded text-xs ${filterYear === String(y) ? 'bg-[#1D4ED8] text-white' : 'bg-[#111] border border-[#DCE6F2] text-[#888]'}`}>{y}</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<select value={filterType} onChange={e => setFilterType(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-[#888] focus:outline-none">
|
<select value={filterType} onChange={e => setFilterType(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#DCE6F2] rounded text-xs text-[#888] focus:outline-none">
|
||||||
<option value="">All Types</option>
|
<option value="">All Types</option>
|
||||||
{DOC_TYPES.map(t => <option key={t} value={t}>{t.replace(/_/g, ' ')}</option>)}
|
{DOC_TYPES.map(t => <option key={t} value={t}>{t.replace(/_/g, ' ')}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<form onSubmit={handleCreate} className="bg-[#111] border border-[#252525] rounded-lg p-4 space-y-3">
|
<form onSubmit={handleCreate} className="bg-[#111] border border-[#DCE6F2] rounded-lg p-4 space-y-3">
|
||||||
<h3 className="text-sm font-semibold">Upload Document</h3>
|
<h3 className="text-sm font-semibold">Upload Document</h3>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Type</label>
|
<label className="text-xs text-[#888] block mb-1">Type</label>
|
||||||
<select value={form.document_type} onChange={e => setForm(p => ({ ...p, document_type: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none">
|
<select value={form.document_type} onChange={e => setForm(p => ({ ...p, document_type: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none">
|
||||||
{DOC_TYPES.map(t => <option key={t} value={t}>{t.replace(/_/g, ' ')}</option>)}
|
{DOC_TYPES.map(t => <option key={t} value={t}>{t.replace(/_/g, ' ')}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Title *</label>
|
<label className="text-xs text-[#888] block mb-1">Title *</label>
|
||||||
<input required value={form.title} onChange={e => setForm(p => ({ ...p, title: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
|
<input required value={form.title} onChange={e => setForm(p => ({ ...p, title: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
{form.document_type === 'sales_agreement' && (
|
{form.document_type === 'sales_agreement' && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Person *</label>
|
<label className="text-xs text-[#888] block mb-1">Person *</label>
|
||||||
<input required value={form.person} onChange={e => setForm(p => ({ ...p, person: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
|
<input required value={form.person} onChange={e => setForm(p => ({ ...p, person: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Year *</label>
|
<label className="text-xs text-[#888] block mb-1">Year *</label>
|
||||||
<input type="number" required value={form.year} onChange={e => setForm(p => ({ ...p, year: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
|
<input type="number" required value={form.year} onChange={e => setForm(p => ({ ...p, year: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<label className="text-xs text-[#888] block mb-1">File URL</label>
|
<label className="text-xs text-[#888] block mb-1">File URL</label>
|
||||||
<input value={form.file_url} onChange={e => setForm(p => ({ ...p, file_url: e.target.value }))} placeholder="https://…" className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
|
<input value={form.file_url} onChange={e => setForm(p => ({ ...p, file_url: e.target.value }))} placeholder="https://…" className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<label className="text-xs text-[#888] block mb-1">Notes</label>
|
<label className="text-xs text-[#888] block mb-1">Notes</label>
|
||||||
<textarea value={form.notes} onChange={e => setForm(p => ({ ...p, notes: e.target.value }))} rows={2} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
|
<textarea value={form.notes} onChange={e => setForm(p => ({ ...p, notes: e.target.value }))} rows={2} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button type="submit" disabled={saving} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500 disabled:opacity-50">{saving ? 'Saving…' : 'Save Document'}</button>
|
<button type="submit" disabled={saving} className="px-3 py-1.5 bg-[#1D4ED8] rounded text-xs font-medium hover:bg-blue-500 disabled:opacity-50">{saving ? 'Saving…' : 'Save Document'}</button>
|
||||||
<button type="button" onClick={() => setShowForm(false)} className="px-3 py-1.5 bg-[#1a1a1a] border border-[#252525] rounded text-xs text-[#888] hover:text-white">Cancel</button>
|
<button type="button" onClick={() => setShowForm(false)} className="px-3 py-1.5 bg-[#FFFFFF] border border-[#DCE6F2] rounded text-xs text-[#888] hover:text-white">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[#1a1a1a]">
|
<tr className="border-b border-[#FFFFFF]">
|
||||||
{['Type','Title','Person','Year','Date',''].map(h => (
|
{['Type','Title','Person','Year','Date',''].map(h => (
|
||||||
<th key={h} className="px-4 py-2 text-left text-xs text-[#555] font-medium">{h}</th>
|
<th key={h} className="px-4 py-2 text-left text-xs text-[#555] font-medium">{h}</th>
|
||||||
))}
|
))}
|
||||||
@@ -129,14 +129,14 @@ export default function DocumentsPage() {
|
|||||||
) : docs.length === 0 ? (
|
) : docs.length === 0 ? (
|
||||||
<tr><td colSpan={6} className="px-4 py-8 text-center text-[#555] text-xs">No documents found</td></tr>
|
<tr><td colSpan={6} className="px-4 py-8 text-center text-[#555] text-xs">No documents found</td></tr>
|
||||||
) : docs.map((d: any) => (
|
) : docs.map((d: any) => (
|
||||||
<tr key={d.id} className="border-b border-[#1a1a1a] hover:bg-[#161616]">
|
<tr key={d.id} className="border-b border-[#FFFFFF] hover:bg-[#FFFFFF]">
|
||||||
<td className="px-4 py-2 text-[#888] text-xs capitalize">{d.document_type?.replace(/_/g, ' ')}</td>
|
<td className="px-4 py-2 text-[#888] text-xs capitalize">{d.document_type?.replace(/_/g, ' ')}</td>
|
||||||
<td className="px-4 py-2 text-white text-xs">{d.title}</td>
|
<td className="px-4 py-2 text-white text-xs">{d.title}</td>
|
||||||
<td className="px-4 py-2 text-[#ccc] text-xs">{d.person ?? '—'}</td>
|
<td className="px-4 py-2 text-[#ccc] text-xs">{d.person ?? '—'}</td>
|
||||||
<td className="px-4 py-2 text-[#888] text-xs">{d.year ?? '—'}</td>
|
<td className="px-4 py-2 text-[#888] text-xs">{d.year ?? '—'}</td>
|
||||||
<td className="px-4 py-2 text-[#555] text-xs">{d.uploaded_at ? new Date(d.uploaded_at).toLocaleDateString() : '—'}</td>
|
<td className="px-4 py-2 text-[#555] text-xs">{d.uploaded_at ? new Date(d.uploaded_at).toLocaleDateString() : '—'}</td>
|
||||||
<td className="px-4 py-2 flex gap-2">
|
<td className="px-4 py-2 flex gap-2">
|
||||||
{d.file_url && <a href={d.file_url} target="_blank" rel="noopener noreferrer" className="text-xs text-[#3b82f6] hover:underline">Download</a>}
|
{d.file_url && <a href={d.file_url} target="_blank" rel="noopener noreferrer" className="text-xs text-[#1D4ED8] hover:underline">Download</a>}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export default function ExpensesPage() {
|
|||||||
setForm({ vendor: '', amount: '', category: 'other', description: '', expense_date: new Date().toISOString().split('T')[0], receipt_source: 'manual' });
|
setForm({ vendor: '', amount: '', category: 'other', description: '', expense_date: new Date().toISOString().split('T')[0], receipt_source: 'manual' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /></div>;
|
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" /></div>;
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -95,7 +95,7 @@ export default function ExpensesPage() {
|
|||||||
<h1 className="text-xl font-bold">Expenses</h1>
|
<h1 className="text-xl font-bold">Expenses</h1>
|
||||||
<Link href="/admin/accounting" className="text-xs text-[#555] hover:text-[#888]">← Accounting</Link>
|
<Link href="/admin/accounting" className="text-xs text-[#555] hover:text-[#888]">← Accounting</Link>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setShowForm(true)} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500">+ Log Expense</button>
|
<button onClick={() => setShowForm(true)} className="px-3 py-1.5 bg-[#1D4ED8] rounded text-xs font-medium hover:bg-blue-500">+ Log Expense</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Success Toast */}
|
{/* Success Toast */}
|
||||||
@@ -107,11 +107,11 @@ export default function ExpensesPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<select value={filterCat} onChange={e => setFilterCat(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-[#888] focus:outline-none">
|
<select value={filterCat} onChange={e => setFilterCat(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#DCE6F2] rounded text-xs text-[#888] focus:outline-none">
|
||||||
<option value="">All Categories</option>
|
<option value="">All Categories</option>
|
||||||
{CATEGORIES.map(c => <option key={c} value={c}>{c.replace(/_/g, ' ')}</option>)}
|
{CATEGORIES.map(c => <option key={c} value={c}>{c.replace(/_/g, ' ')}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<select value={filterReconciled} onChange={e => setFilterReconciled(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-[#888] focus:outline-none">
|
<select value={filterReconciled} onChange={e => setFilterReconciled(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#DCE6F2] rounded text-xs text-[#888] focus:outline-none">
|
||||||
<option value="">All</option>
|
<option value="">All</option>
|
||||||
<option value="true">Reconciled</option>
|
<option value="true">Reconciled</option>
|
||||||
<option value="false">Unreconciled</option>
|
<option value="false">Unreconciled</option>
|
||||||
@@ -119,7 +119,7 @@ export default function ExpensesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<form onSubmit={handleCreate} noValidate className="bg-[#111] border border-[#252525] rounded-lg p-4 space-y-3">
|
<form onSubmit={handleCreate} noValidate className="bg-[#111] border border-[#DCE6F2] rounded-lg p-4 space-y-3">
|
||||||
<h3 className="text-sm font-semibold">Log Expense</h3>
|
<h3 className="text-sm font-semibold">Log Expense</h3>
|
||||||
|
|
||||||
{errors._form && (
|
{errors._form && (
|
||||||
@@ -132,40 +132,40 @@ export default function ExpensesPage() {
|
|||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Vendor</label>
|
<label className="text-xs text-[#888] block mb-1">Vendor</label>
|
||||||
<input type="text" value={form.vendor} onChange={e => handleChange('vendor', e.target.value)} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
|
<input type="text" value={form.vendor} onChange={e => handleChange('vendor', e.target.value)} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Amount ($) <span className="text-red-400">*</span></label>
|
<label className="text-xs text-[#888] block mb-1">Amount ($) <span className="text-red-400">*</span></label>
|
||||||
<input type="number" step="0.01" value={form.amount} onChange={e => handleChange('amount', e.target.value)} className={`w-full px-2 py-1.5 bg-[#0a0a0a] border rounded text-xs text-white focus:outline-none focus:border-[#3b82f6] ${errors.amount ? 'border-red-500/60' : 'border-[#252525]'}`} />
|
<input type="number" step="0.01" value={form.amount} onChange={e => handleChange('amount', e.target.value)} className={`w-full px-2 py-1.5 bg-[#0a0a0a] border rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8] ${errors.amount ? 'border-red-500/60' : 'border-[#DCE6F2]'}`} />
|
||||||
{errors.amount && <p className="mt-1 text-xs text-red-400">{errors.amount}</p>}
|
{errors.amount && <p className="mt-1 text-xs text-red-400">{errors.amount}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Date <span className="text-red-400">*</span></label>
|
<label className="text-xs text-[#888] block mb-1">Date <span className="text-red-400">*</span></label>
|
||||||
<input type="date" value={form.expense_date} onChange={e => handleChange('expense_date', e.target.value)} className={`w-full px-2 py-1.5 bg-[#0a0a0a] border rounded text-xs text-white focus:outline-none focus:border-[#3b82f6] ${errors.expense_date ? 'border-red-500/60' : 'border-[#252525]'}`} />
|
<input type="date" value={form.expense_date} onChange={e => handleChange('expense_date', e.target.value)} className={`w-full px-2 py-1.5 bg-[#0a0a0a] border rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8] ${errors.expense_date ? 'border-red-500/60' : 'border-[#DCE6F2]'}`} />
|
||||||
{errors.expense_date && <p className="mt-1 text-xs text-red-400">{errors.expense_date}</p>}
|
{errors.expense_date && <p className="mt-1 text-xs text-red-400">{errors.expense_date}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Category</label>
|
<label className="text-xs text-[#888] block mb-1">Category</label>
|
||||||
<select value={form.category} onChange={e => handleChange('category', e.target.value)} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none">
|
<select value={form.category} onChange={e => handleChange('category', e.target.value)} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none">
|
||||||
{CATEGORIES.map(c => <option key={c} value={c}>{c.replace(/_/g, ' ')}</option>)}
|
{CATEGORIES.map(c => <option key={c} value={c}>{c.replace(/_/g, ' ')}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<label className="text-xs text-[#888] block mb-1">Description</label>
|
<label className="text-xs text-[#888] block mb-1">Description</label>
|
||||||
<input value={form.description} onChange={e => handleChange('description', e.target.value)} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
|
<input value={form.description} onChange={e => handleChange('description', e.target.value)} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button type="submit" disabled={saving} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500 disabled:opacity-50">{saving ? 'Saving…' : 'Log Expense'}</button>
|
<button type="submit" disabled={saving} className="px-3 py-1.5 bg-[#1D4ED8] rounded text-xs font-medium hover:bg-blue-500 disabled:opacity-50">{saving ? 'Saving…' : 'Log Expense'}</button>
|
||||||
<button type="button" onClick={handleCancel} className="px-3 py-1.5 bg-[#1a1a1a] border border-[#252525] rounded text-xs text-[#888] hover:text-white">Cancel</button>
|
<button type="button" onClick={handleCancel} className="px-3 py-1.5 bg-[#FFFFFF] border border-[#DCE6F2] rounded text-xs text-[#888] hover:text-white">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-x-auto">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[#1a1a1a]">
|
<tr className="border-b border-[#FFFFFF]">
|
||||||
{['Date','Vendor','Amount','Category','Source','Reconciled','Description'].map(h => (
|
{['Date','Vendor','Amount','Category','Source','Reconciled','Description'].map(h => (
|
||||||
<th key={h} className="px-3 py-2 text-left text-xs text-[#555] font-medium">{h}</th>
|
<th key={h} className="px-3 py-2 text-left text-xs text-[#555] font-medium">{h}</th>
|
||||||
))}
|
))}
|
||||||
@@ -177,7 +177,7 @@ export default function ExpensesPage() {
|
|||||||
) : expenses.length === 0 ? (
|
) : expenses.length === 0 ? (
|
||||||
<tr><td colSpan={7} className="px-4 py-8 text-center text-[#555] text-xs">No expenses found</td></tr>
|
<tr><td colSpan={7} className="px-4 py-8 text-center text-[#555] text-xs">No expenses found</td></tr>
|
||||||
) : expenses.map((ex: any) => (
|
) : expenses.map((ex: any) => (
|
||||||
<tr key={ex.id} className="border-b border-[#1a1a1a] hover:bg-[#161616]">
|
<tr key={ex.id} className="border-b border-[#FFFFFF] hover:bg-[#FFFFFF]">
|
||||||
<td className="px-3 py-2 text-[#888] text-xs">{ex.expense_date}</td>
|
<td className="px-3 py-2 text-[#888] text-xs">{ex.expense_date}</td>
|
||||||
<td className="px-3 py-2 text-white text-xs">{ex.vendor ?? '—'}</td>
|
<td className="px-3 py-2 text-white text-xs">{ex.vendor ?? '—'}</td>
|
||||||
<td className="px-3 py-2 text-red-400 text-xs">{ex.amount_cents ? fmt(ex.amount_cents) : '—'}</td>
|
<td className="px-3 py-2 text-red-400 text-xs">{ex.amount_cents ? fmt(ex.amount_cents) : '—'}</td>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const STATUS_COLORS: Record<string, string> = {
|
|||||||
paid: 'bg-green-400/10 text-green-400',
|
paid: 'bg-green-400/10 text-green-400',
|
||||||
pending: 'bg-yellow-400/10 text-yellow-400',
|
pending: 'bg-yellow-400/10 text-yellow-400',
|
||||||
overdue: 'bg-red-400/10 text-red-400',
|
overdue: 'bg-red-400/10 text-red-400',
|
||||||
cancelled: 'bg-[#1a1a1a] text-[#555]',
|
cancelled: 'bg-[#FFFFFF] text-[#555]',
|
||||||
};
|
};
|
||||||
|
|
||||||
function InvoicesContent() {
|
function InvoicesContent() {
|
||||||
@@ -83,7 +83,7 @@ function InvoicesContent() {
|
|||||||
setPayForm({ method: 'check', paid_date: new Date().toISOString().split('T')[0], wire_reference: '' });
|
setPayForm({ method: 'check', paid_date: new Date().toISOString().split('T')[0], wire_reference: '' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /></div>;
|
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" /></div>;
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -94,7 +94,7 @@ function InvoicesContent() {
|
|||||||
<h1 className="text-xl font-bold">Invoices</h1>
|
<h1 className="text-xl font-bold">Invoices</h1>
|
||||||
<Link href="/admin/accounting" className="text-xs text-[#555] hover:text-[#888]">← Accounting</Link>
|
<Link href="/admin/accounting" className="text-xs text-[#555] hover:text-[#888]">← Accounting</Link>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/admin/accounting/orders" className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500">+ New Invoice (via Order)</Link>
|
<Link href="/admin/accounting/orders" className="px-3 py-1.5 bg-[#1D4ED8] rounded text-xs font-medium hover:bg-blue-500">+ New Invoice (via Order)</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Success Toast */}
|
{/* Success Toast */}
|
||||||
@@ -108,14 +108,14 @@ function InvoicesContent() {
|
|||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{['','pending','paid','overdue','cancelled'].map(s => (
|
{['','pending','paid','overdue','cancelled'].map(s => (
|
||||||
<button key={s} onClick={() => setFilterStatus(s)} className={`px-3 py-1 rounded text-xs capitalize ${filterStatus === s ? 'bg-[#3b82f6] text-white' : 'bg-[#111] border border-[#252525] text-[#888] hover:text-white'}`}>{s || 'All'}</button>
|
<button key={s} onClick={() => setFilterStatus(s)} className={`px-3 py-1 rounded text-xs capitalize ${filterStatus === s ? 'bg-[#1D4ED8] text-white' : 'bg-[#111] border border-[#DCE6F2] text-[#888] hover:text-white'}`}>{s || 'All'}</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mark Paid Modal */}
|
{/* Mark Paid Modal */}
|
||||||
{markPaidId && (
|
{markPaidId && (
|
||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||||
<form onSubmit={handleMarkPaid} noValidate className="bg-[#111] border border-[#252525] rounded-lg p-5 w-80 space-y-3">
|
<form onSubmit={handleMarkPaid} noValidate className="bg-[#111] border border-[#DCE6F2] rounded-lg p-5 w-80 space-y-3">
|
||||||
<h3 className="text-sm font-semibold">Mark Invoice as Paid</h3>
|
<h3 className="text-sm font-semibold">Mark Invoice as Paid</h3>
|
||||||
|
|
||||||
{payErrors._form && (
|
{payErrors._form && (
|
||||||
@@ -127,35 +127,35 @@ function InvoicesContent() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Payment Method</label>
|
<label className="text-xs text-[#888] block mb-1">Payment Method</label>
|
||||||
<select value={payForm.method} onChange={e => handlePayChange('method', e.target.value)} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none">
|
<select value={payForm.method} onChange={e => handlePayChange('method', e.target.value)} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none">
|
||||||
{['stripe','wire','check','other'].map(m => <option key={m} value={m}>{m}</option>)}
|
{['stripe','wire','check','other'].map(m => <option key={m} value={m}>{m}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Paid Date <span className="text-red-400">*</span></label>
|
<label className="text-xs text-[#888] block mb-1">Paid Date <span className="text-red-400">*</span></label>
|
||||||
<input type="date" value={payForm.paid_date} onChange={e => handlePayChange('paid_date', e.target.value)} className={`w-full px-2 py-1.5 bg-[#0a0a0a] border rounded text-xs text-white focus:outline-none ${payErrors.paid_date ? 'border-red-500/60' : 'border-[#252525]'}`} />
|
<input type="date" value={payForm.paid_date} onChange={e => handlePayChange('paid_date', e.target.value)} className={`w-full px-2 py-1.5 bg-[#0a0a0a] border rounded text-xs text-white focus:outline-none ${payErrors.paid_date ? 'border-red-500/60' : 'border-[#DCE6F2]'}`} />
|
||||||
{payErrors.paid_date && <p className="mt-1 text-xs text-red-400">{payErrors.paid_date}</p>}
|
{payErrors.paid_date && <p className="mt-1 text-xs text-red-400">{payErrors.paid_date}</p>}
|
||||||
</div>
|
</div>
|
||||||
{payForm.method === 'wire' && (
|
{payForm.method === 'wire' && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Wire Reference <span className="text-red-400">*</span></label>
|
<label className="text-xs text-[#888] block mb-1">Wire Reference <span className="text-red-400">*</span></label>
|
||||||
<input value={payForm.wire_reference} onChange={e => handlePayChange('wire_reference', e.target.value)} className={`w-full px-2 py-1.5 bg-[#0a0a0a] border rounded text-xs text-white focus:outline-none ${payErrors.wire_reference ? 'border-red-500/60' : 'border-[#252525]'}`} />
|
<input value={payForm.wire_reference} onChange={e => handlePayChange('wire_reference', e.target.value)} className={`w-full px-2 py-1.5 bg-[#0a0a0a] border rounded text-xs text-white focus:outline-none ${payErrors.wire_reference ? 'border-red-500/60' : 'border-[#DCE6F2]'}`} />
|
||||||
{payErrors.wire_reference && <p className="mt-1 text-xs text-red-400">{payErrors.wire_reference}</p>}
|
{payErrors.wire_reference && <p className="mt-1 text-xs text-red-400">{payErrors.wire_reference}</p>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button type="submit" disabled={saving} className="px-3 py-1.5 bg-green-600 rounded text-xs font-medium hover:bg-green-500 disabled:opacity-50">{saving ? 'Saving…' : 'Mark Paid'}</button>
|
<button type="submit" disabled={saving} className="px-3 py-1.5 bg-green-600 rounded text-xs font-medium hover:bg-green-500 disabled:opacity-50">{saving ? 'Saving…' : 'Mark Paid'}</button>
|
||||||
<button type="button" onClick={handleCloseModal} className="px-3 py-1.5 bg-[#1a1a1a] border border-[#252525] rounded text-xs text-[#888] hover:text-white">Cancel</button>
|
<button type="button" onClick={handleCloseModal} className="px-3 py-1.5 bg-[#FFFFFF] border border-[#DCE6F2] rounded text-xs text-[#888] hover:text-white">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-x-auto">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[#1a1a1a]">
|
<tr className="border-b border-[#FFFFFF]">
|
||||||
{['Invoice#','Client','Site','Order#','Amount','Status','Due Date','Salesperson',''].map(h => (
|
{['Invoice#','Client','Site','Order#','Amount','Status','Due Date','Salesperson',''].map(h => (
|
||||||
<th key={h} className="px-3 py-2 text-left text-xs text-[#555] font-medium whitespace-nowrap">{h}</th>
|
<th key={h} className="px-3 py-2 text-left text-xs text-[#555] font-medium whitespace-nowrap">{h}</th>
|
||||||
))}
|
))}
|
||||||
@@ -167,14 +167,14 @@ function InvoicesContent() {
|
|||||||
) : invoices.length === 0 ? (
|
) : invoices.length === 0 ? (
|
||||||
<tr><td colSpan={9} className="px-4 py-8 text-center text-[#555] text-xs">No invoices found</td></tr>
|
<tr><td colSpan={9} className="px-4 py-8 text-center text-[#555] text-xs">No invoices found</td></tr>
|
||||||
) : invoices.map((inv: any) => (
|
) : invoices.map((inv: any) => (
|
||||||
<tr key={inv.id} className="border-b border-[#1a1a1a] hover:bg-[#161616]">
|
<tr key={inv.id} className="border-b border-[#FFFFFF] hover:bg-[#FFFFFF]">
|
||||||
<td className="px-3 py-2 text-[#3b82f6] font-mono text-xs">{inv.invoice_number}</td>
|
<td className="px-3 py-2 text-[#1D4ED8] font-mono text-xs">{inv.invoice_number}</td>
|
||||||
<td className="px-3 py-2 text-white text-xs">{inv.rmp_clients?.company_name ?? '—'}</td>
|
<td className="px-3 py-2 text-white text-xs">{inv.rmp_clients?.company_name ?? '—'}</td>
|
||||||
<td className="px-3 py-2 text-[#888] text-xs capitalize">{inv.site}</td>
|
<td className="px-3 py-2 text-[#888] text-xs capitalize">{inv.site}</td>
|
||||||
<td className="px-3 py-2 text-[#888] text-xs font-mono">{inv.rmp_orders?.internal_order_number ?? '—'}</td>
|
<td className="px-3 py-2 text-[#888] text-xs font-mono">{inv.rmp_orders?.internal_order_number ?? '—'}</td>
|
||||||
<td className="px-3 py-2 text-green-400 text-xs">{inv.amount_cents ? fmt(inv.amount_cents) : '—'}</td>
|
<td className="px-3 py-2 text-green-400 text-xs">{inv.amount_cents ? fmt(inv.amount_cents) : '—'}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<span className={`px-2 py-0.5 rounded-full text-xs ${STATUS_COLORS[inv.status] ?? 'bg-[#1a1a1a] text-[#555]'}`}>{inv.status}</span>
|
<span className={`px-2 py-0.5 rounded-full text-xs ${STATUS_COLORS[inv.status] ?? 'bg-[#FFFFFF] text-[#555]'}`}>{inv.status}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-[#888] text-xs">{inv.due_date ?? '—'}</td>
|
<td className="px-3 py-2 text-[#888] text-xs">{inv.due_date ?? '—'}</td>
|
||||||
<td className="px-3 py-2 text-[#ccc] text-xs">{inv.rmp_sales_staff?.full_name ?? '—'}</td>
|
<td className="px-3 py-2 text-[#ccc] text-xs">{inv.rmp_sales_staff?.full_name ?? '—'}</td>
|
||||||
@@ -195,7 +195,7 @@ function InvoicesContent() {
|
|||||||
|
|
||||||
export default function InvoicesPage() {
|
export default function InvoicesPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /></div>}>
|
<Suspense fallback={<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" /></div>}>
|
||||||
<InvoicesContent />
|
<InvoicesContent />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ function OrdersContent() {
|
|||||||
setForm({ client_id: '', site: '', product_id: '', ad_unit: '', description: '', start_date: '', end_date: '', total_amount_cents: '', currency: 'USD', invoicing_schedule: 'single', next_invoice_date: '', staff_id: '', po_number: '', notes: '' });
|
setForm({ client_id: '', site: '', product_id: '', ad_unit: '', description: '', start_date: '', end_date: '', total_amount_cents: '', currency: 'USD', invoicing_schedule: 'single', next_invoice_date: '', staff_id: '', po_number: '', notes: '' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /></div>;
|
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" /></div>;
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -115,7 +115,7 @@ function OrdersContent() {
|
|||||||
<h1 className="text-xl font-bold">Orders</h1>
|
<h1 className="text-xl font-bold">Orders</h1>
|
||||||
<Link href="/admin/accounting" className="text-xs text-[#555] hover:text-[#888]">← Accounting</Link>
|
<Link href="/admin/accounting" className="text-xs text-[#555] hover:text-[#888]">← Accounting</Link>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setShowForm(true)} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500">+ New Order</button>
|
<button onClick={() => setShowForm(true)} className="px-3 py-1.5 bg-[#1D4ED8] rounded text-xs font-medium hover:bg-blue-500">+ New Order</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Success Toast */}
|
{/* Success Toast */}
|
||||||
@@ -128,20 +128,20 @@ function OrdersContent() {
|
|||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex gap-2 flex-wrap items-center">
|
<div className="flex gap-2 flex-wrap items-center">
|
||||||
<select value={filterSite} onChange={e => setFilterSite(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-[#888] focus:outline-none">
|
<select value={filterSite} onChange={e => setFilterSite(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#DCE6F2] rounded text-xs text-[#888] focus:outline-none">
|
||||||
<option value="">All Sites</option>
|
<option value="">All Sites</option>
|
||||||
{SITES.map(s => <option key={s} value={s}>{s}</option>)}
|
{SITES.map(s => <option key={s} value={s}>{s}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<select value={filterStatus} onChange={e => setFilterStatus(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-[#888] focus:outline-none">
|
<select value={filterStatus} onChange={e => setFilterStatus(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#DCE6F2] rounded text-xs text-[#888] focus:outline-none">
|
||||||
<option value="">All Statuses</option>
|
<option value="">All Statuses</option>
|
||||||
{STATUSES.map(s => <option key={s} value={s}>{s}</option>)}
|
{STATUSES.map(s => <option key={s} value={s}>{s}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<input value={filterClient} onChange={e => setFilterClient(e.target.value)} placeholder="Filter by client…" className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-white placeholder-[#555] focus:outline-none focus:border-[#3b82f6]" />
|
<input value={filterClient} onChange={e => setFilterClient(e.target.value)} placeholder="Filter by client…" className="px-2 py-1 bg-[#111] border border-[#DCE6F2] rounded text-xs text-white placeholder-[#555] focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* New Order Form */}
|
{/* New Order Form */}
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<form onSubmit={handleCreate} noValidate className="bg-[#111] border border-[#252525] rounded-lg p-4 space-y-3">
|
<form onSubmit={handleCreate} noValidate className="bg-[#111] border border-[#DCE6F2] rounded-lg p-4 space-y-3">
|
||||||
<h3 className="text-sm font-semibold">New Order</h3>
|
<h3 className="text-sm font-semibold">New Order</h3>
|
||||||
|
|
||||||
{errors._form && (
|
{errors._form && (
|
||||||
@@ -154,7 +154,7 @@ function OrdersContent() {
|
|||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Client <span className="text-red-400">*</span></label>
|
<label className="text-xs text-[#888] block mb-1">Client <span className="text-red-400">*</span></label>
|
||||||
<select value={form.client_id} onChange={e => handleChange('client_id', e.target.value)} className={`w-full px-2 py-1.5 bg-[#0a0a0a] border rounded text-xs text-white focus:outline-none focus:border-[#3b82f6] ${errors.client_id ? 'border-red-500/60' : 'border-[#252525]'}`}>
|
<select value={form.client_id} onChange={e => handleChange('client_id', e.target.value)} className={`w-full px-2 py-1.5 bg-[#0a0a0a] border rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8] ${errors.client_id ? 'border-red-500/60' : 'border-[#DCE6F2]'}`}>
|
||||||
<option value="">Select client…</option>
|
<option value="">Select client…</option>
|
||||||
{clients.map((c: any) => <option key={c.id} value={c.id}>{c.company_name}</option>)}
|
{clients.map((c: any) => <option key={c.id} value={c.id}>{c.company_name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
@@ -162,7 +162,7 @@ function OrdersContent() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Site <span className="text-red-400">*</span></label>
|
<label className="text-xs text-[#888] block mb-1">Site <span className="text-red-400">*</span></label>
|
||||||
<select value={form.site} onChange={e => handleChange('site', e.target.value)} className={`w-full px-2 py-1.5 bg-[#0a0a0a] border rounded text-xs text-white focus:outline-none focus:border-[#3b82f6] ${errors.site ? 'border-red-500/60' : 'border-[#252525]'}`}>
|
<select value={form.site} onChange={e => handleChange('site', e.target.value)} className={`w-full px-2 py-1.5 bg-[#0a0a0a] border rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8] ${errors.site ? 'border-red-500/60' : 'border-[#DCE6F2]'}`}>
|
||||||
<option value="">Select site…</option>
|
<option value="">Select site…</option>
|
||||||
{SITES.map(s => <option key={s} value={s}>{s}</option>)}
|
{SITES.map(s => <option key={s} value={s}>{s}</option>)}
|
||||||
</select>
|
</select>
|
||||||
@@ -170,67 +170,67 @@ function OrdersContent() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Product</label>
|
<label className="text-xs text-[#888] block mb-1">Product</label>
|
||||||
<select value={form.product_id} onChange={e => handleChange('product_id', e.target.value)} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]">
|
<select value={form.product_id} onChange={e => handleChange('product_id', e.target.value)} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]">
|
||||||
<option value="">Select product…</option>
|
<option value="">Select product…</option>
|
||||||
{products.map((p: any) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
{products.map((p: any) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Ad Unit</label>
|
<label className="text-xs text-[#888] block mb-1">Ad Unit</label>
|
||||||
<input value={form.ad_unit} onChange={e => handleChange('ad_unit', e.target.value)} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
|
<input value={form.ad_unit} onChange={e => handleChange('ad_unit', e.target.value)} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Start Date</label>
|
<label className="text-xs text-[#888] block mb-1">Start Date</label>
|
||||||
<input type="date" value={form.start_date} onChange={e => handleChange('start_date', e.target.value)} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
|
<input type="date" value={form.start_date} onChange={e => handleChange('start_date', e.target.value)} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">End Date</label>
|
<label className="text-xs text-[#888] block mb-1">End Date</label>
|
||||||
<input type="date" value={form.end_date} onChange={e => handleChange('end_date', e.target.value)} className={`w-full px-2 py-1.5 bg-[#0a0a0a] border rounded text-xs text-white focus:outline-none focus:border-[#3b82f6] ${errors.end_date ? 'border-red-500/60' : 'border-[#252525]'}`} />
|
<input type="date" value={form.end_date} onChange={e => handleChange('end_date', e.target.value)} className={`w-full px-2 py-1.5 bg-[#0a0a0a] border rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8] ${errors.end_date ? 'border-red-500/60' : 'border-[#DCE6F2]'}`} />
|
||||||
{errors.end_date && <p className="mt-1 text-xs text-red-400">{errors.end_date}</p>}
|
{errors.end_date && <p className="mt-1 text-xs text-red-400">{errors.end_date}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Total Amount ($)</label>
|
<label className="text-xs text-[#888] block mb-1">Total Amount ($)</label>
|
||||||
<input type="number" step="0.01" value={form.total_amount_cents} onChange={e => handleChange('total_amount_cents', e.target.value)} className={`w-full px-2 py-1.5 bg-[#0a0a0a] border rounded text-xs text-white focus:outline-none focus:border-[#3b82f6] ${errors.total_amount_cents ? 'border-red-500/60' : 'border-[#252525]'}`} />
|
<input type="number" step="0.01" value={form.total_amount_cents} onChange={e => handleChange('total_amount_cents', e.target.value)} className={`w-full px-2 py-1.5 bg-[#0a0a0a] border rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8] ${errors.total_amount_cents ? 'border-red-500/60' : 'border-[#DCE6F2]'}`} />
|
||||||
{errors.total_amount_cents && <p className="mt-1 text-xs text-red-400">{errors.total_amount_cents}</p>}
|
{errors.total_amount_cents && <p className="mt-1 text-xs text-red-400">{errors.total_amount_cents}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Invoicing Schedule</label>
|
<label className="text-xs text-[#888] block mb-1">Invoicing Schedule</label>
|
||||||
<select value={form.invoicing_schedule} onChange={e => handleChange('invoicing_schedule', e.target.value)} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]">
|
<select value={form.invoicing_schedule} onChange={e => handleChange('invoicing_schedule', e.target.value)} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]">
|
||||||
{SCHEDULES.map(s => <option key={s} value={s}>{s}</option>)}
|
{SCHEDULES.map(s => <option key={s} value={s}>{s}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Next Invoice Date</label>
|
<label className="text-xs text-[#888] block mb-1">Next Invoice Date</label>
|
||||||
<input type="date" value={form.next_invoice_date} onChange={e => handleChange('next_invoice_date', e.target.value)} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
|
<input type="date" value={form.next_invoice_date} onChange={e => handleChange('next_invoice_date', e.target.value)} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Salesperson</label>
|
<label className="text-xs text-[#888] block mb-1">Salesperson</label>
|
||||||
<select value={form.staff_id} onChange={e => handleChange('staff_id', e.target.value)} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]">
|
<select value={form.staff_id} onChange={e => handleChange('staff_id', e.target.value)} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]">
|
||||||
<option value="">None</option>
|
<option value="">None</option>
|
||||||
{staff.map((s: any) => <option key={s.id} value={s.id}>{s.full_name}</option>)}
|
{staff.map((s: any) => <option key={s.id} value={s.id}>{s.full_name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">PO Number</label>
|
<label className="text-xs text-[#888] block mb-1">PO Number</label>
|
||||||
<input value={form.po_number} onChange={e => handleChange('po_number', e.target.value)} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
|
<input value={form.po_number} onChange={e => handleChange('po_number', e.target.value)} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 md:col-span-3">
|
<div className="col-span-2 md:col-span-3">
|
||||||
<label className="text-xs text-[#888] block mb-1">Notes</label>
|
<label className="text-xs text-[#888] block mb-1">Notes</label>
|
||||||
<textarea value={form.notes} onChange={e => handleChange('notes', e.target.value)} rows={2} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
|
<textarea value={form.notes} onChange={e => handleChange('notes', e.target.value)} rows={2} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button type="submit" disabled={saving} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500 disabled:opacity-50">{saving ? 'Saving…' : 'Create Order'}</button>
|
<button type="submit" disabled={saving} className="px-3 py-1.5 bg-[#1D4ED8] rounded text-xs font-medium hover:bg-blue-500 disabled:opacity-50">{saving ? 'Saving…' : 'Create Order'}</button>
|
||||||
<button type="button" onClick={handleCancel} className="px-3 py-1.5 bg-[#1a1a1a] border border-[#252525] rounded text-xs text-[#888] hover:text-white">Cancel</button>
|
<button type="button" onClick={handleCancel} className="px-3 py-1.5 bg-[#FFFFFF] border border-[#DCE6F2] rounded text-xs text-[#888] hover:text-white">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-x-auto">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[#1a1a1a]">
|
<tr className="border-b border-[#FFFFFF]">
|
||||||
{['Client','PO#','Order#','Site','Product','Ad Unit','Dates','Amount','Status',''].map(h => (
|
{['Client','PO#','Order#','Site','Product','Ad Unit','Dates','Amount','Status',''].map(h => (
|
||||||
<th key={h} className="px-3 py-2 text-left text-xs text-[#555] font-medium whitespace-nowrap">{h}</th>
|
<th key={h} className="px-3 py-2 text-left text-xs text-[#555] font-medium whitespace-nowrap">{h}</th>
|
||||||
))}
|
))}
|
||||||
@@ -242,20 +242,20 @@ function OrdersContent() {
|
|||||||
) : orders.length === 0 ? (
|
) : orders.length === 0 ? (
|
||||||
<tr><td colSpan={10} className="px-4 py-8 text-center text-[#555] text-xs">No orders found</td></tr>
|
<tr><td colSpan={10} className="px-4 py-8 text-center text-[#555] text-xs">No orders found</td></tr>
|
||||||
) : orders.map((o: any) => (
|
) : orders.map((o: any) => (
|
||||||
<tr key={o.id} className="border-b border-[#1a1a1a] hover:bg-[#161616]">
|
<tr key={o.id} className="border-b border-[#FFFFFF] hover:bg-[#FFFFFF]">
|
||||||
<td className="px-3 py-2 text-white text-xs">{o.rmp_clients?.company_name ?? '—'}</td>
|
<td className="px-3 py-2 text-white text-xs">{o.rmp_clients?.company_name ?? '—'}</td>
|
||||||
<td className="px-3 py-2 text-[#888] text-xs">{o.po_number ?? '—'}</td>
|
<td className="px-3 py-2 text-[#888] text-xs">{o.po_number ?? '—'}</td>
|
||||||
<td className="px-3 py-2 text-[#3b82f6] text-xs font-mono">{o.internal_order_number}</td>
|
<td className="px-3 py-2 text-[#1D4ED8] text-xs font-mono">{o.internal_order_number}</td>
|
||||||
<td className="px-3 py-2 text-[#888] text-xs capitalize">{o.site}</td>
|
<td className="px-3 py-2 text-[#888] text-xs capitalize">{o.site}</td>
|
||||||
<td className="px-3 py-2 text-[#ccc] text-xs">{o.rmp_products?.name ?? o.ad_unit ?? '—'}</td>
|
<td className="px-3 py-2 text-[#ccc] text-xs">{o.rmp_products?.name ?? o.ad_unit ?? '—'}</td>
|
||||||
<td className="px-3 py-2 text-[#888] text-xs">{o.ad_unit ?? '—'}</td>
|
<td className="px-3 py-2 text-[#888] text-xs">{o.ad_unit ?? '—'}</td>
|
||||||
<td className="px-3 py-2 text-[#888] text-xs whitespace-nowrap">{o.start_date} – {o.end_date ?? '∞'}</td>
|
<td className="px-3 py-2 text-[#888] text-xs whitespace-nowrap">{o.start_date} – {o.end_date ?? '∞'}</td>
|
||||||
<td className="px-3 py-2 text-green-400 text-xs">{o.total_amount_cents ? fmt(o.total_amount_cents) : '—'}</td>
|
<td className="px-3 py-2 text-green-400 text-xs">{o.total_amount_cents ? fmt(o.total_amount_cents) : '—'}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<span className={`px-2 py-0.5 rounded-full text-xs ${o.status === 'active' ? 'bg-green-400/10 text-green-400' : o.status === 'cancelled' ? 'bg-red-400/10 text-red-400' : 'bg-[#1a1a1a] text-[#555]'}`}>{o.status}</span>
|
<span className={`px-2 py-0.5 rounded-full text-xs ${o.status === 'active' ? 'bg-green-400/10 text-green-400' : o.status === 'cancelled' ? 'bg-red-400/10 text-red-400' : 'bg-[#FFFFFF] text-[#555]'}`}>{o.status}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<Link href={`/admin/accounting/orders/${o.id}`} className="text-xs text-[#3b82f6] hover:underline">View</Link>
|
<Link href={`/admin/accounting/orders/${o.id}`} className="text-xs text-[#1D4ED8] hover:underline">View</Link>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -269,7 +269,7 @@ function OrdersContent() {
|
|||||||
|
|
||||||
export default function OrdersPage() {
|
export default function OrdersPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /></div>}>
|
<Suspense fallback={<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" /></div>}>
|
||||||
<OrdersContent />
|
<OrdersContent />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default function AccountingDashboard() {
|
|||||||
|
|
||||||
if (loading || loadingData) return (
|
if (loading || loadingData) return (
|
||||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
||||||
<div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
<div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
@@ -59,18 +59,18 @@ export default function AccountingDashboard() {
|
|||||||
<p className="text-[#888] text-sm mt-1">Relevant Media Properties, LLC</p>
|
<p className="text-[#888] text-sm mt-1">Relevant Media Properties, LLC</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<Link href="/admin/accounting/clients" className="px-3 py-1.5 bg-[#1a2535] border border-[#2a3a50] rounded text-xs text-[#3b82f6] hover:bg-[#0d1a2d] transition-colors">+ New Client</Link>
|
<Link href="/admin/accounting/clients" className="px-3 py-1.5 bg-[#FFFFFF] border border-[#2a3a50] rounded text-xs text-[#1D4ED8] hover:bg-[#0d1a2d] transition-colors">+ New Client</Link>
|
||||||
<Link href="/admin/accounting/orders" className="px-3 py-1.5 bg-[#1a2535] border border-[#2a3a50] rounded text-xs text-[#3b82f6] hover:bg-[#0d1a2d] transition-colors">+ New Order</Link>
|
<Link href="/admin/accounting/orders" className="px-3 py-1.5 bg-[#FFFFFF] border border-[#2a3a50] rounded text-xs text-[#1D4ED8] hover:bg-[#0d1a2d] transition-colors">+ New Order</Link>
|
||||||
<Link href="/admin/accounting/invoices" className="px-3 py-1.5 bg-[#1a2535] border border-[#2a3a50] rounded text-xs text-[#3b82f6] hover:bg-[#0d1a2d] transition-colors">+ New Invoice</Link>
|
<Link href="/admin/accounting/invoices" className="px-3 py-1.5 bg-[#FFFFFF] border border-[#2a3a50] rounded text-xs text-[#1D4ED8] hover:bg-[#0d1a2d] transition-colors">+ New Invoice</Link>
|
||||||
<Link href="/admin/accounting/expenses" className="px-3 py-1.5 bg-[#1a2535] border border-[#2a3a50] rounded text-xs text-[#3b82f6] hover:bg-[#0d1a2d] transition-colors">+ Log Expense</Link>
|
<Link href="/admin/accounting/expenses" className="px-3 py-1.5 bg-[#FFFFFF] border border-[#2a3a50] rounded text-xs text-[#1D4ED8] hover:bg-[#0d1a2d] transition-colors">+ Log Expense</Link>
|
||||||
<Link href="/admin/accounting/reconciliation" className="px-3 py-1.5 bg-[#1a2535] border border-[#2a3a50] rounded text-xs text-[#3b82f6] hover:bg-[#0d1a2d] transition-colors">Upload Bank CSV</Link>
|
<Link href="/admin/accounting/reconciliation" className="px-3 py-1.5 bg-[#FFFFFF] border border-[#2a3a50] rounded text-xs text-[#1D4ED8] hover:bg-[#0d1a2d] transition-colors">Upload Bank CSV</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stat Cards */}
|
{/* Stat Cards */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||||
{STAT_CARDS.map(c => (
|
{STAT_CARDS.map(c => (
|
||||||
<Link key={c.key} href={c.href} className="bg-[#111] border border-[#252525] rounded-lg p-4 hover:border-[#333] transition-colors">
|
<Link key={c.key} href={c.href} className="bg-[#111] border border-[#DCE6F2] rounded-lg p-4 hover:border-[#333] transition-colors">
|
||||||
<p className={`text-lg font-bold ${c.color}`}>{fmt(stats[c.key] ?? 0)}</p>
|
<p className={`text-lg font-bold ${c.color}`}>{fmt(stats[c.key] ?? 0)}</p>
|
||||||
<p className="text-xs text-[#888] mt-1">{c.label}</p>
|
<p className="text-xs text-[#888] mt-1">{c.label}</p>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -78,13 +78,13 @@ export default function AccountingDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Site Revenue Table */}
|
{/* Site Revenue Table */}
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-hidden">
|
||||||
<div className="px-4 py-3 border-b border-[#252525]">
|
<div className="px-4 py-3 border-b border-[#DCE6F2]">
|
||||||
<h2 className="text-sm font-semibold text-white">Revenue by Site</h2>
|
<h2 className="text-sm font-semibold text-white">Revenue by Site</h2>
|
||||||
</div>
|
</div>
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[#1a1a1a]">
|
<tr className="border-b border-[#FFFFFF]">
|
||||||
{['Site','Revenue MTD','Revenue YTD','Outstanding','Active Orders'].map(h => (
|
{['Site','Revenue MTD','Revenue YTD','Outstanding','Active Orders'].map(h => (
|
||||||
<th key={h} className="px-4 py-2 text-left text-xs text-[#555] font-medium">{h}</th>
|
<th key={h} className="px-4 py-2 text-left text-xs text-[#555] font-medium">{h}</th>
|
||||||
))}
|
))}
|
||||||
@@ -94,7 +94,7 @@ export default function AccountingDashboard() {
|
|||||||
{siteRevenue.length === 0 ? (
|
{siteRevenue.length === 0 ? (
|
||||||
<tr><td colSpan={5} className="px-4 py-6 text-center text-[#555] text-xs">No data yet</td></tr>
|
<tr><td colSpan={5} className="px-4 py-6 text-center text-[#555] text-xs">No data yet</td></tr>
|
||||||
) : siteRevenue.map((row: any) => (
|
) : siteRevenue.map((row: any) => (
|
||||||
<tr key={row.site} className="border-b border-[#1a1a1a] hover:bg-[#161616]">
|
<tr key={row.site} className="border-b border-[#FFFFFF] hover:bg-[#FFFFFF]">
|
||||||
<td className="px-4 py-2 text-white font-medium capitalize">{row.site}</td>
|
<td className="px-4 py-2 text-white font-medium capitalize">{row.site}</td>
|
||||||
<td className="px-4 py-2 text-green-400">{fmt(row.revenue_mtd ?? 0)}</td>
|
<td className="px-4 py-2 text-green-400">{fmt(row.revenue_mtd ?? 0)}</td>
|
||||||
<td className="px-4 py-2 text-green-400">{fmt(row.revenue_ytd ?? 0)}</td>
|
<td className="px-4 py-2 text-green-400">{fmt(row.revenue_ytd ?? 0)}</td>
|
||||||
@@ -107,13 +107,13 @@ export default function AccountingDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top Clients */}
|
{/* Top Clients */}
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-hidden">
|
||||||
<div className="px-4 py-3 border-b border-[#252525]">
|
<div className="px-4 py-3 border-b border-[#DCE6F2]">
|
||||||
<h2 className="text-sm font-semibold text-white">Top 5 Clients YTD</h2>
|
<h2 className="text-sm font-semibold text-white">Top 5 Clients YTD</h2>
|
||||||
</div>
|
</div>
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[#1a1a1a]">
|
<tr className="border-b border-[#FFFFFF]">
|
||||||
{['Client','Revenue YTD','Active Orders'].map(h => (
|
{['Client','Revenue YTD','Active Orders'].map(h => (
|
||||||
<th key={h} className="px-4 py-2 text-left text-xs text-[#555] font-medium">{h}</th>
|
<th key={h} className="px-4 py-2 text-left text-xs text-[#555] font-medium">{h}</th>
|
||||||
))}
|
))}
|
||||||
@@ -123,7 +123,7 @@ export default function AccountingDashboard() {
|
|||||||
{topClients.length === 0 ? (
|
{topClients.length === 0 ? (
|
||||||
<tr><td colSpan={3} className="px-4 py-6 text-center text-[#555] text-xs">No data yet</td></tr>
|
<tr><td colSpan={3} className="px-4 py-6 text-center text-[#555] text-xs">No data yet</td></tr>
|
||||||
) : topClients.map((c: any) => (
|
) : topClients.map((c: any) => (
|
||||||
<tr key={c.id} className="border-b border-[#1a1a1a] hover:bg-[#161616]">
|
<tr key={c.id} className="border-b border-[#FFFFFF] hover:bg-[#FFFFFF]">
|
||||||
<td className="px-4 py-2 text-white">{c.company_name}</td>
|
<td className="px-4 py-2 text-white">{c.company_name}</td>
|
||||||
<td className="px-4 py-2 text-green-400">{fmt(c.revenue_ytd ?? 0)}</td>
|
<td className="px-4 py-2 text-green-400">{fmt(c.revenue_ytd ?? 0)}</td>
|
||||||
<td className="px-4 py-2 text-[#ccc]">{c.active_orders ?? 0}</td>
|
<td className="px-4 py-2 text-[#ccc]">{c.active_orders ?? 0}</td>
|
||||||
@@ -146,7 +146,7 @@ export default function AccountingDashboard() {
|
|||||||
{ label: 'Reports', href: '/admin/accounting/reports' },
|
{ label: 'Reports', href: '/admin/accounting/reports' },
|
||||||
{ label: 'Documents', href: '/admin/accounting/documents' },
|
{ label: 'Documents', href: '/admin/accounting/documents' },
|
||||||
].map(l => (
|
].map(l => (
|
||||||
<Link key={l.href} href={l.href} className="bg-[#111] border border-[#252525] rounded-lg p-3 text-center text-sm text-[#888] hover:text-white hover:border-[#3b82f6] transition-colors">
|
<Link key={l.href} href={l.href} className="bg-[#111] border border-[#DCE6F2] rounded-lg p-3 text-center text-sm text-[#888] hover:text-white hover:border-[#1D4ED8] transition-colors">
|
||||||
{l.label}
|
{l.label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export default function ReconciliationPage() {
|
|||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /></div>;
|
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" /></div>;
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -59,7 +59,7 @@ export default function ReconciliationPage() {
|
|||||||
<h1 className="text-xl font-bold">Bank Reconciliation</h1>
|
<h1 className="text-xl font-bold">Bank Reconciliation</h1>
|
||||||
<Link href="/admin/accounting" className="text-xs text-[#555] hover:text-[#888]">← Accounting</Link>
|
<Link href="/admin/accounting" className="text-xs text-[#555] hover:text-[#888]">← Accounting</Link>
|
||||||
</div>
|
</div>
|
||||||
<label className={`px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500 cursor-pointer ${uploading ? 'opacity-50' : ''}`}>
|
<label className={`px-3 py-1.5 bg-[#1D4ED8] rounded text-xs font-medium hover:bg-blue-500 cursor-pointer ${uploading ? 'opacity-50' : ''}`}>
|
||||||
{uploading ? 'Uploading…' : 'Upload Bank CSV'}
|
{uploading ? 'Uploading…' : 'Upload Bank CSV'}
|
||||||
<input type="file" accept=".csv" className="hidden" onChange={handleUpload} disabled={uploading} />
|
<input type="file" accept=".csv" className="hidden" onChange={handleUpload} disabled={uploading} />
|
||||||
</label>
|
</label>
|
||||||
@@ -78,10 +78,10 @@ export default function ReconciliationPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-x-auto">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[#1a1a1a]">
|
<tr className="border-b border-[#FFFFFF]">
|
||||||
{['Date','Description','Amount','Type','Matched To','Reconciled','Action'].map(h => (
|
{['Date','Description','Amount','Type','Matched To','Reconciled','Action'].map(h => (
|
||||||
<th key={h} className="px-3 py-2 text-left text-xs text-[#555] font-medium">{h}</th>
|
<th key={h} className="px-3 py-2 text-left text-xs text-[#555] font-medium">{h}</th>
|
||||||
))}
|
))}
|
||||||
@@ -93,7 +93,7 @@ export default function ReconciliationPage() {
|
|||||||
) : transactions.length === 0 ? (
|
) : transactions.length === 0 ? (
|
||||||
<tr><td colSpan={7} className="px-4 py-8 text-center text-[#555] text-xs">No transactions. Upload a CSV to begin.</td></tr>
|
<tr><td colSpan={7} className="px-4 py-8 text-center text-[#555] text-xs">No transactions. Upload a CSV to begin.</td></tr>
|
||||||
) : transactions.map((tx: any) => (
|
) : transactions.map((tx: any) => (
|
||||||
<tr key={tx.id} className="border-b border-[#1a1a1a] hover:bg-[#161616]">
|
<tr key={tx.id} className="border-b border-[#FFFFFF] hover:bg-[#FFFFFF]">
|
||||||
<td className="px-3 py-2 text-[#888] text-xs">{tx.transaction_date}</td>
|
<td className="px-3 py-2 text-[#888] text-xs">{tx.transaction_date}</td>
|
||||||
<td className="px-3 py-2 text-white text-xs max-w-xs truncate">{tx.description}</td>
|
<td className="px-3 py-2 text-white text-xs max-w-xs truncate">{tx.description}</td>
|
||||||
<td className={`px-3 py-2 text-xs font-medium ${tx.type === 'credit' ? 'text-green-400' : 'text-red-400'}`}>{fmt(Math.abs(tx.amount_cents ?? 0))}</td>
|
<td className={`px-3 py-2 text-xs font-medium ${tx.type === 'credit' ? 'text-green-400' : 'text-red-400'}`}>{fmt(Math.abs(tx.amount_cents ?? 0))}</td>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default function ReportsPage() {
|
|||||||
|
|
||||||
useEffect(() => { if (user) loadReport(); }, [user]);
|
useEffect(() => { if (user) loadReport(); }, [user]);
|
||||||
|
|
||||||
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /></div>;
|
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" /></div>;
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -40,41 +40,41 @@ export default function ReportsPage() {
|
|||||||
<Link href="/admin/accounting" className="text-xs text-[#555] hover:text-[#888]">← Accounting</Link>
|
<Link href="/admin/accounting" className="text-xs text-[#555] hover:text-[#888]">← Accounting</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-white focus:outline-none" />
|
<input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none" />
|
||||||
<span className="text-[#555] text-xs">to</span>
|
<span className="text-[#555] text-xs">to</span>
|
||||||
<input type="date" value={endDate} onChange={e => setEndDate(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-white focus:outline-none" />
|
<input type="date" value={endDate} onChange={e => setEndDate(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none" />
|
||||||
<button onClick={loadReport} disabled={loadingData} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500 disabled:opacity-50">Run Report</button>
|
<button onClick={loadReport} disabled={loadingData} className="px-3 py-1.5 bg-[#1D4ED8] rounded text-xs font-medium hover:bg-blue-500 disabled:opacity-50">Run Report</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loadingData ? (
|
{loadingData ? (
|
||||||
<div className="flex items-center justify-center py-20"><div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /></div>
|
<div className="flex items-center justify-center py-20"><div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" /></div>
|
||||||
) : report ? (
|
) : report ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* P&L Summary */}
|
{/* P&L Summary */}
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-4">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg p-4">
|
||||||
<p className="text-xs text-[#555]">Total Revenue</p>
|
<p className="text-xs text-[#555]">Total Revenue</p>
|
||||||
<p className="text-2xl font-bold text-green-400 mt-1">{fmt(report.total_revenue_cents ?? 0)}</p>
|
<p className="text-2xl font-bold text-green-400 mt-1">{fmt(report.total_revenue_cents ?? 0)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-4">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg p-4">
|
||||||
<p className="text-xs text-[#555]">Total Expenses</p>
|
<p className="text-xs text-[#555]">Total Expenses</p>
|
||||||
<p className="text-2xl font-bold text-red-400 mt-1">{fmt(report.total_expenses_cents ?? 0)}</p>
|
<p className="text-2xl font-bold text-red-400 mt-1">{fmt(report.total_expenses_cents ?? 0)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-4">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg p-4">
|
||||||
<p className="text-xs text-[#555]">Net Profit</p>
|
<p className="text-xs text-[#555]">Net Profit</p>
|
||||||
<p className={`text-2xl font-bold mt-1 ${(report.net_profit_cents ?? 0) >= 0 ? 'text-blue-400' : 'text-red-400'}`}>{fmt(report.net_profit_cents ?? 0)}</p>
|
<p className={`text-2xl font-bold mt-1 ${(report.net_profit_cents ?? 0) >= 0 ? 'text-blue-400' : 'text-red-400'}`}>{fmt(report.net_profit_cents ?? 0)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Revenue by Site */}
|
{/* Revenue by Site */}
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-hidden">
|
||||||
<div className="px-4 py-3 border-b border-[#252525]"><h2 className="text-sm font-semibold">Revenue by Site</h2></div>
|
<div className="px-4 py-3 border-b border-[#DCE6F2]"><h2 className="text-sm font-semibold">Revenue by Site</h2></div>
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead><tr className="border-b border-[#1a1a1a]">{['Site','Revenue'].map(h => <th key={h} className="px-4 py-2 text-left text-xs text-[#555]">{h}</th>)}</tr></thead>
|
<thead><tr className="border-b border-[#FFFFFF]">{['Site','Revenue'].map(h => <th key={h} className="px-4 py-2 text-left text-xs text-[#555]">{h}</th>)}</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{(report.by_site ?? []).map((r: any) => (
|
{(report.by_site ?? []).map((r: any) => (
|
||||||
<tr key={r.site} className="border-b border-[#1a1a1a]">
|
<tr key={r.site} className="border-b border-[#FFFFFF]">
|
||||||
<td className="px-4 py-2 text-white text-xs capitalize">{r.site}</td>
|
<td className="px-4 py-2 text-white text-xs capitalize">{r.site}</td>
|
||||||
<td className="px-4 py-2 text-green-400 text-xs">{fmt(r.revenue_cents ?? 0)}</td>
|
<td className="px-4 py-2 text-green-400 text-xs">{fmt(r.revenue_cents ?? 0)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -84,13 +84,13 @@ export default function ReportsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top Clients */}
|
{/* Top Clients */}
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-hidden">
|
||||||
<div className="px-4 py-3 border-b border-[#252525]"><h2 className="text-sm font-semibold">Top 10 Clients</h2></div>
|
<div className="px-4 py-3 border-b border-[#DCE6F2]"><h2 className="text-sm font-semibold">Top 10 Clients</h2></div>
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead><tr className="border-b border-[#1a1a1a]">{['Client','Revenue'].map(h => <th key={h} className="px-4 py-2 text-left text-xs text-[#555]">{h}</th>)}</tr></thead>
|
<thead><tr className="border-b border-[#FFFFFF]">{['Client','Revenue'].map(h => <th key={h} className="px-4 py-2 text-left text-xs text-[#555]">{h}</th>)}</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{(report.top_clients ?? []).map((c: any) => (
|
{(report.top_clients ?? []).map((c: any) => (
|
||||||
<tr key={c.id} className="border-b border-[#1a1a1a]">
|
<tr key={c.id} className="border-b border-[#FFFFFF]">
|
||||||
<td className="px-4 py-2 text-white text-xs">{c.company_name}</td>
|
<td className="px-4 py-2 text-white text-xs">{c.company_name}</td>
|
||||||
<td className="px-4 py-2 text-green-400 text-xs">{fmt(c.revenue_cents ?? 0)}</td>
|
<td className="px-4 py-2 text-green-400 text-xs">{fmt(c.revenue_cents ?? 0)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -100,13 +100,13 @@ export default function ReportsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expenses by Category */}
|
{/* Expenses by Category */}
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-hidden">
|
||||||
<div className="px-4 py-3 border-b border-[#252525]"><h2 className="text-sm font-semibold">Expenses by Category</h2></div>
|
<div className="px-4 py-3 border-b border-[#DCE6F2]"><h2 className="text-sm font-semibold">Expenses by Category</h2></div>
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead><tr className="border-b border-[#1a1a1a]">{['Category','Amount'].map(h => <th key={h} className="px-4 py-2 text-left text-xs text-[#555]">{h}</th>)}</tr></thead>
|
<thead><tr className="border-b border-[#FFFFFF]">{['Category','Amount'].map(h => <th key={h} className="px-4 py-2 text-left text-xs text-[#555]">{h}</th>)}</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{(report.by_category ?? []).map((c: any) => (
|
{(report.by_category ?? []).map((c: any) => (
|
||||||
<tr key={c.category} className="border-b border-[#1a1a1a]">
|
<tr key={c.category} className="border-b border-[#FFFFFF]">
|
||||||
<td className="px-4 py-2 text-white text-xs">{c.category?.replace(/_/g, ' ')}</td>
|
<td className="px-4 py-2 text-white text-xs">{c.category?.replace(/_/g, ' ')}</td>
|
||||||
<td className="px-4 py-2 text-red-400 text-xs">{fmt(c.amount_cents ?? 0)}</td>
|
<td className="px-4 py-2 text-red-400 text-xs">{fmt(c.amount_cents ?? 0)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export default function StaffPage() {
|
|||||||
if (errors[errKey]) setErrors(p => { const n = { ...p }; delete n[errKey]; return n; });
|
if (errors[errKey]) setErrors(p => { const n = { ...p }; delete n[errKey]; return n; });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /></div>;
|
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" /></div>;
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -108,7 +108,7 @@ export default function StaffPage() {
|
|||||||
<h1 className="text-xl font-bold">Sales Staff</h1>
|
<h1 className="text-xl font-bold">Sales Staff</h1>
|
||||||
<Link href="/admin/accounting" className="text-xs text-[#555] hover:text-[#888]">← Accounting</Link>
|
<Link href="/admin/accounting" className="text-xs text-[#555] hover:text-[#888]">← Accounting</Link>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setShowForm(true)} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500">+ Add Salesperson</button>
|
<button onClick={() => setShowForm(true)} className="px-3 py-1.5 bg-[#1D4ED8] rounded text-xs font-medium hover:bg-blue-500">+ Add Salesperson</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Success Toast */}
|
{/* Success Toast */}
|
||||||
@@ -120,7 +120,7 @@ export default function StaffPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<form onSubmit={handleCreate} noValidate className="bg-[#111] border border-[#252525] rounded-lg p-4 space-y-3">
|
<form onSubmit={handleCreate} noValidate className="bg-[#111] border border-[#DCE6F2] rounded-lg p-4 space-y-3">
|
||||||
<h3 className="text-sm font-semibold">New Salesperson</h3>
|
<h3 className="text-sm font-semibold">New Salesperson</h3>
|
||||||
|
|
||||||
{errors._form && (
|
{errors._form && (
|
||||||
@@ -140,7 +140,7 @@ export default function StaffPage() {
|
|||||||
type={f.key === 'email' ? 'email' : 'text'}
|
type={f.key === 'email' ? 'email' : 'text'}
|
||||||
value={(form as any)[f.key]}
|
value={(form as any)[f.key]}
|
||||||
onChange={e => handleChange(f.key, e.target.value)}
|
onChange={e => handleChange(f.key, e.target.value)}
|
||||||
className={`w-full px-2 py-1.5 bg-[#0a0a0a] border rounded text-xs text-white focus:outline-none focus:border-[#3b82f6] ${errors[f.key] ? 'border-red-500/60' : 'border-[#252525]'}`}
|
className={`w-full px-2 py-1.5 bg-[#0a0a0a] border rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8] ${errors[f.key] ? 'border-red-500/60' : 'border-[#DCE6F2]'}`}
|
||||||
/>
|
/>
|
||||||
{errors[f.key] && <p className="mt-1 text-xs text-red-400">{errors[f.key]}</p>}
|
{errors[f.key] && <p className="mt-1 text-xs text-red-400">{errors[f.key]}</p>}
|
||||||
</div>
|
</div>
|
||||||
@@ -149,34 +149,34 @@ export default function StaffPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<label className="text-xs text-[#888]">Commission Tiers ({new Date().getFullYear()})</label>
|
<label className="text-xs text-[#888]">Commission Tiers ({new Date().getFullYear()})</label>
|
||||||
<button type="button" onClick={() => setTiers(t => [...t, { tier_order: t.length + 1, threshold_cents: '', rate: '' }])} className="text-xs text-[#3b82f6] hover:underline">+ Add Tier</button>
|
<button type="button" onClick={() => setTiers(t => [...t, { tier_order: t.length + 1, threshold_cents: '', rate: '' }])} className="text-xs text-[#1D4ED8] hover:underline">+ Add Tier</button>
|
||||||
</div>
|
</div>
|
||||||
{tiers.map((tier, i) => (
|
{tiers.map((tier, i) => (
|
||||||
<div key={i} className="flex gap-2 mb-2 items-start">
|
<div key={i} className="flex gap-2 mb-2 items-start">
|
||||||
<span className="text-xs text-[#555] w-12 pt-5">Tier {tier.tier_order}</span>
|
<span className="text-xs text-[#555] w-12 pt-5">Tier {tier.tier_order}</span>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<label className="text-xs text-[#555] block mb-0.5">Threshold ($, blank = no cap)</label>
|
<label className="text-xs text-[#555] block mb-0.5">Threshold ($, blank = no cap)</label>
|
||||||
<input type="number" step="0.01" value={tier.threshold_cents} onChange={e => handleTierChange(i, 'threshold_cents', e.target.value)} placeholder="100000" className="w-full px-2 py-1 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none" />
|
<input type="number" step="0.01" value={tier.threshold_cents} onChange={e => handleTierChange(i, 'threshold_cents', e.target.value)} placeholder="100000" className="w-full px-2 py-1 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<label className="text-xs text-[#555] block mb-0.5">Rate (%) <span className="text-red-400">*</span></label>
|
<label className="text-xs text-[#555] block mb-0.5">Rate (%) <span className="text-red-400">*</span></label>
|
||||||
<input type="number" step="0.1" value={tier.rate} onChange={e => handleTierChange(i, 'rate', e.target.value)} placeholder="25" className={`w-full px-2 py-1 bg-[#0a0a0a] border rounded text-xs text-white focus:outline-none ${errors[`tier_rate_${i}`] ? 'border-red-500/60' : 'border-[#252525]'}`} />
|
<input type="number" step="0.1" value={tier.rate} onChange={e => handleTierChange(i, 'rate', e.target.value)} placeholder="25" className={`w-full px-2 py-1 bg-[#0a0a0a] border rounded text-xs text-white focus:outline-none ${errors[`tier_rate_${i}`] ? 'border-red-500/60' : 'border-[#DCE6F2]'}`} />
|
||||||
{errors[`tier_rate_${i}`] && <p className="mt-1 text-xs text-red-400">{errors[`tier_rate_${i}`]}</p>}
|
{errors[`tier_rate_${i}`] && <p className="mt-1 text-xs text-red-400">{errors[`tier_rate_${i}`]}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button type="submit" disabled={saving} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500 disabled:opacity-50">{saving ? 'Saving…' : 'Create'}</button>
|
<button type="submit" disabled={saving} className="px-3 py-1.5 bg-[#1D4ED8] rounded text-xs font-medium hover:bg-blue-500 disabled:opacity-50">{saving ? 'Saving…' : 'Create'}</button>
|
||||||
<button type="button" onClick={handleCancel} className="px-3 py-1.5 bg-[#1a1a1a] border border-[#252525] rounded text-xs text-[#888] hover:text-white">Cancel</button>
|
<button type="button" onClick={handleCancel} className="px-3 py-1.5 bg-[#FFFFFF] border border-[#DCE6F2] rounded text-xs text-[#888] hover:text-white">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[#1a1a1a]">
|
<tr className="border-b border-[#FFFFFF]">
|
||||||
{['Name','Email','Status','Commission YTD','Outstanding',''].map(h => (
|
{['Name','Email','Status','Commission YTD','Outstanding',''].map(h => (
|
||||||
<th key={h} className="px-4 py-2 text-left text-xs text-[#555] font-medium">{h}</th>
|
<th key={h} className="px-4 py-2 text-left text-xs text-[#555] font-medium">{h}</th>
|
||||||
))}
|
))}
|
||||||
@@ -188,16 +188,16 @@ export default function StaffPage() {
|
|||||||
) : staff.length === 0 ? (
|
) : staff.length === 0 ? (
|
||||||
<tr><td colSpan={6} className="px-4 py-8 text-center text-[#555] text-xs">No staff found</td></tr>
|
<tr><td colSpan={6} className="px-4 py-8 text-center text-[#555] text-xs">No staff found</td></tr>
|
||||||
) : staff.map((s: any) => (
|
) : staff.map((s: any) => (
|
||||||
<tr key={s.id} className={`border-b border-[#1a1a1a] hover:bg-[#161616] ${s.status === 'terminated' ? 'opacity-50' : ''}`}>
|
<tr key={s.id} className={`border-b border-[#FFFFFF] hover:bg-[#FFFFFF] ${s.status === 'terminated' ? 'opacity-50' : ''}`}>
|
||||||
<td className="px-4 py-2 text-white">{s.full_name}</td>
|
<td className="px-4 py-2 text-white">{s.full_name}</td>
|
||||||
<td className="px-4 py-2 text-[#888] text-xs">{s.email}</td>
|
<td className="px-4 py-2 text-[#888] text-xs">{s.email}</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<span className={`px-2 py-0.5 rounded-full text-xs ${s.status === 'active' ? 'bg-green-400/10 text-green-400' : s.status === 'terminated' ? 'bg-red-400/10 text-red-400' : 'bg-[#1a1a1a] text-[#555]'}`}>{s.status}</span>
|
<span className={`px-2 py-0.5 rounded-full text-xs ${s.status === 'active' ? 'bg-green-400/10 text-green-400' : s.status === 'terminated' ? 'bg-red-400/10 text-red-400' : 'bg-[#FFFFFF] text-[#555]'}`}>{s.status}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-green-400 text-xs">{fmt(s.commission_ytd ?? 0)}</td>
|
<td className="px-4 py-2 text-green-400 text-xs">{fmt(s.commission_ytd ?? 0)}</td>
|
||||||
<td className="px-4 py-2 text-yellow-400 text-xs">{fmt(s.commission_outstanding ?? 0)}</td>
|
<td className="px-4 py-2 text-yellow-400 text-xs">{fmt(s.commission_outstanding ?? 0)}</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<Link href={`/admin/accounting/staff/${s.id}`} className="text-xs text-[#3b82f6] hover:underline">View</Link>
|
<Link href={`/admin/accounting/staff/${s.id}`} className="text-xs text-[#1D4ED8] hover:underline">View</Link>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const FLIGHT_COLORS: Record<string, string> = {
|
|||||||
scheduled: 'bg-blue-400/10 text-blue-400',
|
scheduled: 'bg-blue-400/10 text-blue-400',
|
||||||
paused: 'bg-gray-600/20 text-gray-400',
|
paused: 'bg-gray-600/20 text-gray-400',
|
||||||
expired: 'bg-red-400/10 text-red-400',
|
expired: 'bg-red-400/10 text-red-400',
|
||||||
cancelled: 'bg-[#1a1a1a] text-[#555]',
|
cancelled: 'bg-[#FFFFFF] text-[#555]',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AdOpsIOsPage() {
|
export default function AdOpsIOsPage() {
|
||||||
@@ -73,7 +73,7 @@ export default function AdOpsIOsPage() {
|
|||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /></div>;
|
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" /></div>;
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
const selectedProduct = products.find((p: any) => p.id === form.product_id);
|
const selectedProduct = products.find((p: any) => p.id === form.product_id);
|
||||||
@@ -88,63 +88,63 @@ export default function AdOpsIOsPage() {
|
|||||||
<h1 className="text-xl font-bold">Insertion Orders</h1>
|
<h1 className="text-xl font-bold">Insertion Orders</h1>
|
||||||
<Link href="/admin/adops" className="text-xs text-[#555] hover:text-[#888]">← Ad Operations</Link>
|
<Link href="/admin/adops" className="text-xs text-[#555] hover:text-[#888]">← Ad Operations</Link>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setShowForm(true)} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500">+ New IO Extension</button>
|
<button onClick={() => setShowForm(true)} className="px-3 py-1.5 bg-[#1D4ED8] rounded text-xs font-medium hover:bg-blue-500">+ New IO Extension</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<select value={filterStatus} onChange={e => setFilterStatus(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-[#888] focus:outline-none">
|
<select value={filterStatus} onChange={e => setFilterStatus(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#DCE6F2] rounded text-xs text-[#888] focus:outline-none">
|
||||||
<option value="">All Statuses</option>
|
<option value="">All Statuses</option>
|
||||||
{['scheduled','active','paused','expired','cancelled'].map(s => <option key={s} value={s}>{s}</option>)}
|
{['scheduled','active','paused','expired','cancelled'].map(s => <option key={s} value={s}>{s}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<select value={filterSite} onChange={e => setFilterSite(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-[#888] focus:outline-none">
|
<select value={filterSite} onChange={e => setFilterSite(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#DCE6F2] rounded text-xs text-[#888] focus:outline-none">
|
||||||
<option value="">All Sites</option>
|
<option value="">All Sites</option>
|
||||||
{['avbeat','avbeat','backlotbeat','broadcastengineering'].map(s => <option key={s} value={s}>{s}</option>)}
|
{['avbeat','broadcastbeat','backlotbeat','broadcastengineering'].map(s => <option key={s} value={s}>{s}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<form onSubmit={handleCreate} className="bg-[#111] border border-[#252525] rounded-lg p-4 space-y-3">
|
<form onSubmit={handleCreate} className="bg-[#111] border border-[#DCE6F2] rounded-lg p-4 space-y-3">
|
||||||
<h3 className="text-sm font-semibold">New IO Extension</h3>
|
<h3 className="text-sm font-semibold">New IO Extension</h3>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Existing IO ID *</label>
|
<label className="text-xs text-[#888] block mb-1">Existing IO ID *</label>
|
||||||
<input required value={form.existing_io_id} onChange={e => setForm(p => ({ ...p, existing_io_id: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
|
<input required value={form.existing_io_id} onChange={e => setForm(p => ({ ...p, existing_io_id: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Client</label>
|
<label className="text-xs text-[#888] block mb-1">Client</label>
|
||||||
<select value={form.client_id} onChange={e => setForm(p => ({ ...p, client_id: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none">
|
<select value={form.client_id} onChange={e => setForm(p => ({ ...p, client_id: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none">
|
||||||
<option value="">Select…</option>
|
<option value="">Select…</option>
|
||||||
{clients.map((c: any) => <option key={c.id} value={c.id}>{c.company_name}</option>)}
|
{clients.map((c: any) => <option key={c.id} value={c.id}>{c.company_name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Salesperson</label>
|
<label className="text-xs text-[#888] block mb-1">Salesperson</label>
|
||||||
<select value={form.staff_id} onChange={e => setForm(p => ({ ...p, staff_id: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none">
|
<select value={form.staff_id} onChange={e => setForm(p => ({ ...p, staff_id: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none">
|
||||||
<option value="">None</option>
|
<option value="">None</option>
|
||||||
{staff.map((s: any) => <option key={s.id} value={s.id}>{s.full_name}</option>)}
|
{staff.map((s: any) => <option key={s.id} value={s.id}>{s.full_name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Start Date *</label>
|
<label className="text-xs text-[#888] block mb-1">Start Date *</label>
|
||||||
<input type="date" required value={form.start_date} onChange={e => setForm(p => ({ ...p, start_date: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none" />
|
<input type="date" required value={form.start_date} onChange={e => setForm(p => ({ ...p, start_date: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">End Date</label>
|
<label className="text-xs text-[#888] block mb-1">End Date</label>
|
||||||
<input type="date" value={form.end_date} onChange={e => setForm(p => ({ ...p, end_date: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none" />
|
<input type="date" value={form.end_date} onChange={e => setForm(p => ({ ...p, end_date: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Flight Status</label>
|
<label className="text-xs text-[#888] block mb-1">Flight Status</label>
|
||||||
<select value={form.flight_status} onChange={e => setForm(p => ({ ...p, flight_status: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none">
|
<select value={form.flight_status} onChange={e => setForm(p => ({ ...p, flight_status: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none">
|
||||||
{['scheduled','active','paused'].map(s => <option key={s} value={s}>{s}</option>)}
|
{['scheduled','active','paused'].map(s => <option key={s} value={s}>{s}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Banner Image URL</label>
|
<label className="text-xs text-[#888] block mb-1">Banner Image URL</label>
|
||||||
<input value={form.banner_image_url} onChange={e => setForm(p => ({ ...p, banner_image_url: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
|
<input value={form.banner_image_url} onChange={e => setForm(p => ({ ...p, banner_image_url: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Click-Through URL</label>
|
<label className="text-xs text-[#888] block mb-1">Click-Through URL</label>
|
||||||
<input value={form.click_through_url} onChange={e => setForm(p => ({ ...p, click_through_url: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
|
<input value={form.click_through_url} onChange={e => setForm(p => ({ ...p, click_through_url: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 pt-4">
|
<div className="flex items-center gap-3 pt-4">
|
||||||
<label className="flex items-center gap-2 text-xs text-[#888] cursor-pointer">
|
<label className="flex items-center gap-2 text-xs text-[#888] cursor-pointer">
|
||||||
@@ -159,47 +159,47 @@ export default function AdOpsIOsPage() {
|
|||||||
{form.is_comp && (
|
{form.is_comp && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Comp Reason</label>
|
<label className="text-xs text-[#888] block mb-1">Comp Reason</label>
|
||||||
<input value={form.comp_reason} onChange={e => setForm(p => ({ ...p, comp_reason: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
|
<input value={form.comp_reason} onChange={e => setForm(p => ({ ...p, comp_reason: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isEmailProduct && (
|
{isEmailProduct && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Email HTML URL</label>
|
<label className="text-xs text-[#888] block mb-1">Email HTML URL</label>
|
||||||
<input value={form.email_html_url} onChange={e => setForm(p => ({ ...p, email_html_url: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
|
<input value={form.email_html_url} onChange={e => setForm(p => ({ ...p, email_html_url: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Scheduled Send Date</label>
|
<label className="text-xs text-[#888] block mb-1">Scheduled Send Date</label>
|
||||||
<input type="date" value={form.email_scheduled_date} onChange={e => setForm(p => ({ ...p, email_scheduled_date: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none" />
|
<input type="date" value={form.email_scheduled_date} onChange={e => setForm(p => ({ ...p, email_scheduled_date: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">List Segment</label>
|
<label className="text-xs text-[#888] block mb-1">List Segment</label>
|
||||||
<input value={form.email_list_segment} onChange={e => setForm(p => ({ ...p, email_list_segment: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
|
<input value={form.email_list_segment} onChange={e => setForm(p => ({ ...p, email_list_segment: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{isAdvertorial && (
|
{isAdvertorial && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Page URL (permanent)</label>
|
<label className="text-xs text-[#888] block mb-1">Page URL (permanent)</label>
|
||||||
<input value={form.page_url} onChange={e => setForm(p => ({ ...p, page_url: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
|
<input value={form.page_url} onChange={e => setForm(p => ({ ...p, page_url: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="col-span-2 md:col-span-3">
|
<div className="col-span-2 md:col-span-3">
|
||||||
<label className="text-xs text-[#888] block mb-1">Notes</label>
|
<label className="text-xs text-[#888] block mb-1">Notes</label>
|
||||||
<textarea value={form.notes} onChange={e => setForm(p => ({ ...p, notes: e.target.value }))} rows={2} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
|
<textarea value={form.notes} onChange={e => setForm(p => ({ ...p, notes: e.target.value }))} rows={2} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button type="submit" disabled={saving} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500 disabled:opacity-50">{saving ? 'Saving…' : 'Create IO Extension'}</button>
|
<button type="submit" disabled={saving} className="px-3 py-1.5 bg-[#1D4ED8] rounded text-xs font-medium hover:bg-blue-500 disabled:opacity-50">{saving ? 'Saving…' : 'Create IO Extension'}</button>
|
||||||
<button type="button" onClick={() => setShowForm(false)} className="px-3 py-1.5 bg-[#1a1a1a] border border-[#252525] rounded text-xs text-[#888] hover:text-white">Cancel</button>
|
<button type="button" onClick={() => setShowForm(false)} className="px-3 py-1.5 bg-[#FFFFFF] border border-[#DCE6F2] rounded text-xs text-[#888] hover:text-white">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-x-auto">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[#1a1a1a]">
|
<tr className="border-b border-[#FFFFFF]">
|
||||||
{['IO ID','Client','Product','Start','End','Status','Comp','Paid','Actions'].map(h => (
|
{['IO ID','Client','Product','Start','End','Status','Comp','Paid','Actions'].map(h => (
|
||||||
<th key={h} className="px-3 py-2 text-left text-xs text-[#555] font-medium whitespace-nowrap">{h}</th>
|
<th key={h} className="px-3 py-2 text-left text-xs text-[#555] font-medium whitespace-nowrap">{h}</th>
|
||||||
))}
|
))}
|
||||||
@@ -211,8 +211,8 @@ export default function AdOpsIOsPage() {
|
|||||||
) : ios.length === 0 ? (
|
) : ios.length === 0 ? (
|
||||||
<tr><td colSpan={9} className="px-4 py-8 text-center text-[#555] text-xs">No IO extensions found</td></tr>
|
<tr><td colSpan={9} className="px-4 py-8 text-center text-[#555] text-xs">No IO extensions found</td></tr>
|
||||||
) : ios.map((io: any) => (
|
) : ios.map((io: any) => (
|
||||||
<tr key={io.id} className="border-b border-[#1a1a1a] hover:bg-[#161616]">
|
<tr key={io.id} className="border-b border-[#FFFFFF] hover:bg-[#FFFFFF]">
|
||||||
<td className="px-3 py-2 text-[#3b82f6] font-mono text-xs">{io.existing_io_id}</td>
|
<td className="px-3 py-2 text-[#1D4ED8] font-mono text-xs">{io.existing_io_id}</td>
|
||||||
<td className="px-3 py-2 text-white text-xs">{io.rmp_clients?.company_name ?? '—'}</td>
|
<td className="px-3 py-2 text-white text-xs">{io.rmp_clients?.company_name ?? '—'}</td>
|
||||||
<td className="px-3 py-2 text-[#ccc] text-xs">{io.rmp_products?.name ?? '—'}</td>
|
<td className="px-3 py-2 text-[#ccc] text-xs">{io.rmp_products?.name ?? '—'}</td>
|
||||||
<td className="px-3 py-2 text-[#888] text-xs">{io.start_date}</td>
|
<td className="px-3 py-2 text-[#888] text-xs">{io.start_date}</td>
|
||||||
@@ -227,7 +227,7 @@ export default function AdOpsIOsPage() {
|
|||||||
<span className={`px-2 py-0.5 rounded-full text-xs ${io.is_paid ? 'bg-green-400/10 text-green-400' : 'bg-yellow-400/10 text-yellow-400'}`}>{io.is_paid ? 'Paid' : 'Unpaid'}</span>
|
<span className={`px-2 py-0.5 rounded-full text-xs ${io.is_paid ? 'bg-green-400/10 text-green-400' : 'bg-yellow-400/10 text-yellow-400'}`}>{io.is_paid ? 'Paid' : 'Unpaid'}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 flex gap-2">
|
<td className="px-3 py-2 flex gap-2">
|
||||||
<Link href={`/admin/adops/ios/${io.id}`} className="text-xs text-[#3b82f6] hover:underline">View</Link>
|
<Link href={`/admin/adops/ios/${io.id}`} className="text-xs text-[#1D4ED8] hover:underline">View</Link>
|
||||||
{io.flight_status === 'expired' && (
|
{io.flight_status === 'expired' && (
|
||||||
<button onClick={() => { const r = prompt('Reactivation reason?'); if (r) updateStatus(io.id, 'active', r); }} className="text-xs text-green-400 hover:underline">Reactivate</button>
|
<button onClick={() => { const r = prompt('Reactivation reason?'); if (r) updateStatus(io.id, 'active', r); }} className="text-xs text-green-400 hover:underline">Reactivate</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default function AdOpsNotificationsPage() {
|
|||||||
|
|
||||||
useEffect(() => { if (user) load(); }, [user, load]);
|
useEffect(() => { if (user) load(); }, [user, load]);
|
||||||
|
|
||||||
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /></div>;
|
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" /></div>;
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -33,10 +33,10 @@ export default function AdOpsNotificationsPage() {
|
|||||||
<Link href="/admin/adops" className="text-xs text-[#555] hover:text-[#888]">← Ad Operations</Link>
|
<Link href="/admin/adops" className="text-xs text-[#555] hover:text-[#888]">← Ad Operations</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-x-auto">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[#1a1a1a]">
|
<tr className="border-b border-[#FFFFFF]">
|
||||||
{['Type','IO','Recipient','Via','Sent At','Status','Message'].map(h => (
|
{['Type','IO','Recipient','Via','Sent At','Status','Message'].map(h => (
|
||||||
<th key={h} className="px-3 py-2 text-left text-xs text-[#555] font-medium">{h}</th>
|
<th key={h} className="px-3 py-2 text-left text-xs text-[#555] font-medium">{h}</th>
|
||||||
))}
|
))}
|
||||||
@@ -48,9 +48,9 @@ export default function AdOpsNotificationsPage() {
|
|||||||
) : notifications.length === 0 ? (
|
) : notifications.length === 0 ? (
|
||||||
<tr><td colSpan={7} className="px-4 py-8 text-center text-[#555] text-xs">No notifications yet</td></tr>
|
<tr><td colSpan={7} className="px-4 py-8 text-center text-[#555] text-xs">No notifications yet</td></tr>
|
||||||
) : notifications.map((n: any) => (
|
) : notifications.map((n: any) => (
|
||||||
<tr key={n.id} className="border-b border-[#1a1a1a] hover:bg-[#161616]">
|
<tr key={n.id} className="border-b border-[#FFFFFF] hover:bg-[#FFFFFF]">
|
||||||
<td className="px-3 py-2 text-[#888] text-xs">{n.notification_type?.replace(/_/g, ' ')}</td>
|
<td className="px-3 py-2 text-[#888] text-xs">{n.notification_type?.replace(/_/g, ' ')}</td>
|
||||||
<td className="px-3 py-2 text-[#3b82f6] font-mono text-xs">{n.io_extension_id?.slice(0, 8) ?? '—'}</td>
|
<td className="px-3 py-2 text-[#1D4ED8] font-mono text-xs">{n.io_extension_id?.slice(0, 8) ?? '—'}</td>
|
||||||
<td className="px-3 py-2 text-[#ccc] text-xs">{n.recipient_email ?? n.recipient_phone ?? '—'}</td>
|
<td className="px-3 py-2 text-[#ccc] text-xs">{n.recipient_email ?? n.recipient_phone ?? '—'}</td>
|
||||||
<td className="px-3 py-2 text-[#555] text-xs">{n.sent_via}</td>
|
<td className="px-3 py-2 text-[#555] text-xs">{n.sent_via}</td>
|
||||||
<td className="px-3 py-2 text-[#555] text-xs">{n.sent_at ? new Date(n.sent_at).toLocaleString() : '—'}</td>
|
<td className="px-3 py-2 text-[#555] text-xs">{n.sent_at ? new Date(n.sent_at).toLocaleString() : '—'}</td>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const FLIGHT_COLORS: Record<string, string> = {
|
|||||||
scheduled: 'bg-blue-400/10 text-blue-400 border-blue-400/20',
|
scheduled: 'bg-blue-400/10 text-blue-400 border-blue-400/20',
|
||||||
paused: 'bg-gray-600/20 text-gray-400 border-gray-600/20',
|
paused: 'bg-gray-600/20 text-gray-400 border-gray-600/20',
|
||||||
expired: 'bg-red-400/10 text-red-400 border-red-400/20',
|
expired: 'bg-red-400/10 text-red-400 border-red-400/20',
|
||||||
cancelled: 'bg-[#1a1a1a] text-[#555] border-[#252525]',
|
cancelled: 'bg-[#FFFFFF] text-[#555] border-[#DCE6F2]',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AdOpsDashboardPage() {
|
export default function AdOpsDashboardPage() {
|
||||||
@@ -43,7 +43,7 @@ export default function AdOpsDashboardPage() {
|
|||||||
|
|
||||||
useEffect(() => { if (user) load(); }, [user, load]);
|
useEffect(() => { if (user) load(); }, [user, load]);
|
||||||
|
|
||||||
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /></div>;
|
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" /></div>;
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -64,7 +64,7 @@ export default function AdOpsDashboardPage() {
|
|||||||
{ label: 'Unpaid Active', key: 'unpaid_active', color: 'text-orange-400' },
|
{ label: 'Unpaid Active', key: 'unpaid_active', color: 'text-orange-400' },
|
||||||
{ label: 'Email Blasts Scheduled', key: 'email_scheduled', color: 'text-purple-400' },
|
{ label: 'Email Blasts Scheduled', key: 'email_scheduled', color: 'text-purple-400' },
|
||||||
].map(c => (
|
].map(c => (
|
||||||
<div key={c.key} className="bg-[#111] border border-[#252525] rounded-lg p-4">
|
<div key={c.key} className="bg-[#111] border border-[#DCE6F2] rounded-lg p-4">
|
||||||
<p className={`text-2xl font-bold ${c.color}`}>{stats?.[c.key] ?? 0}</p>
|
<p className={`text-2xl font-bold ${c.color}`}>{stats?.[c.key] ?? 0}</p>
|
||||||
<p className="text-xs text-[#888] mt-1">{c.label}</p>
|
<p className="text-xs text-[#888] mt-1">{c.label}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,25 +73,25 @@ export default function AdOpsDashboardPage() {
|
|||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<select value={filterSite} onChange={e => setFilterSite(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-[#888] focus:outline-none">
|
<select value={filterSite} onChange={e => setFilterSite(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#DCE6F2] rounded text-xs text-[#888] focus:outline-none">
|
||||||
<option value="">All Sites</option>
|
<option value="">All Sites</option>
|
||||||
{['avbeat','avbeat','backlotbeat','broadcastengineering'].map(s => <option key={s} value={s}>{s}</option>)}
|
{['avbeat','broadcastbeat','backlotbeat','broadcastengineering'].map(s => <option key={s} value={s}>{s}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<select value={filterStatus} onChange={e => setFilterStatus(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-[#888] focus:outline-none">
|
<select value={filterStatus} onChange={e => setFilterStatus(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#DCE6F2] rounded text-xs text-[#888] focus:outline-none">
|
||||||
<option value="">All Statuses</option>
|
<option value="">All Statuses</option>
|
||||||
{['active','scheduled','paused','expired','cancelled'].map(s => <option key={s} value={s}>{s}</option>)}
|
{['active','scheduled','paused','expired','cancelled'].map(s => <option key={s} value={s}>{s}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Active Flights Table */}
|
{/* Active Flights Table */}
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-x-auto">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-x-auto">
|
||||||
<div className="px-4 py-3 border-b border-[#252525] flex items-center justify-between">
|
<div className="px-4 py-3 border-b border-[#DCE6F2] flex items-center justify-between">
|
||||||
<h2 className="text-sm font-semibold">Active Flights</h2>
|
<h2 className="text-sm font-semibold">Active Flights</h2>
|
||||||
<Link href="/admin/adops/ios" className="text-xs text-[#3b82f6] hover:underline">View All IOs →</Link>
|
<Link href="/admin/adops/ios" className="text-xs text-[#1D4ED8] hover:underline">View All IOs →</Link>
|
||||||
</div>
|
</div>
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[#1a1a1a]">
|
<tr className="border-b border-[#FFFFFF]">
|
||||||
{['Client','Product','Site','Start','End','Status','Salesperson','Paid',''].map(h => (
|
{['Client','Product','Site','Start','End','Status','Salesperson','Paid',''].map(h => (
|
||||||
<th key={h} className="px-3 py-2 text-left text-xs text-[#555] font-medium whitespace-nowrap">{h}</th>
|
<th key={h} className="px-3 py-2 text-left text-xs text-[#555] font-medium whitespace-nowrap">{h}</th>
|
||||||
))}
|
))}
|
||||||
@@ -103,7 +103,7 @@ export default function AdOpsDashboardPage() {
|
|||||||
) : flights.length === 0 ? (
|
) : flights.length === 0 ? (
|
||||||
<tr><td colSpan={9} className="px-4 py-8 text-center text-[#555] text-xs">No flights found</td></tr>
|
<tr><td colSpan={9} className="px-4 py-8 text-center text-[#555] text-xs">No flights found</td></tr>
|
||||||
) : flights.map((f: any) => (
|
) : flights.map((f: any) => (
|
||||||
<tr key={f.id} className="border-b border-[#1a1a1a] hover:bg-[#161616]">
|
<tr key={f.id} className="border-b border-[#FFFFFF] hover:bg-[#FFFFFF]">
|
||||||
<td className="px-3 py-2 text-white text-xs">{f.rmp_clients?.company_name ?? '—'}</td>
|
<td className="px-3 py-2 text-white text-xs">{f.rmp_clients?.company_name ?? '—'}</td>
|
||||||
<td className="px-3 py-2 text-[#ccc] text-xs">{f.rmp_products?.name ?? '—'}</td>
|
<td className="px-3 py-2 text-[#ccc] text-xs">{f.rmp_products?.name ?? '—'}</td>
|
||||||
<td className="px-3 py-2 text-[#888] text-xs capitalize">{f.rmp_orders?.site ?? '—'}</td>
|
<td className="px-3 py-2 text-[#888] text-xs capitalize">{f.rmp_orders?.site ?? '—'}</td>
|
||||||
@@ -117,7 +117,7 @@ export default function AdOpsDashboardPage() {
|
|||||||
<span className={`px-2 py-0.5 rounded-full text-xs ${f.is_paid ? 'bg-green-400/10 text-green-400' : f.is_comp ? 'bg-blue-400/10 text-blue-400' : 'bg-yellow-400/10 text-yellow-400'}`}>{f.is_comp ? 'Comp' : f.is_paid ? 'Paid' : 'Unpaid'}</span>
|
<span className={`px-2 py-0.5 rounded-full text-xs ${f.is_paid ? 'bg-green-400/10 text-green-400' : f.is_comp ? 'bg-blue-400/10 text-blue-400' : 'bg-yellow-400/10 text-yellow-400'}`}>{f.is_comp ? 'Comp' : f.is_paid ? 'Paid' : 'Unpaid'}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<Link href={`/admin/adops/ios/${f.id}`} className="text-xs text-[#3b82f6] hover:underline">View</Link>
|
<Link href={`/admin/adops/ios/${f.id}`} className="text-xs text-[#1D4ED8] hover:underline">View</Link>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -126,13 +126,13 @@ export default function AdOpsDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email Schedule */}
|
{/* Email Schedule */}
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-hidden">
|
||||||
<div className="px-4 py-3 border-b border-[#252525]">
|
<div className="px-4 py-3 border-b border-[#DCE6F2]">
|
||||||
<h2 className="text-sm font-semibold">Email Distribution Schedule</h2>
|
<h2 className="text-sm font-semibold">Email Distribution Schedule</h2>
|
||||||
</div>
|
</div>
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[#1a1a1a]">
|
<tr className="border-b border-[#FFFFFF]">
|
||||||
{['Client','Type','Site','Scheduled Date','Status',''].map(h => (
|
{['Client','Type','Site','Scheduled Date','Status',''].map(h => (
|
||||||
<th key={h} className="px-3 py-2 text-left text-xs text-[#555] font-medium">{h}</th>
|
<th key={h} className="px-3 py-2 text-left text-xs text-[#555] font-medium">{h}</th>
|
||||||
))}
|
))}
|
||||||
@@ -142,7 +142,7 @@ export default function AdOpsDashboardPage() {
|
|||||||
{emailSchedule.length === 0 ? (
|
{emailSchedule.length === 0 ? (
|
||||||
<tr><td colSpan={6} className="px-4 py-6 text-center text-[#555] text-xs">No email blasts scheduled</td></tr>
|
<tr><td colSpan={6} className="px-4 py-6 text-center text-[#555] text-xs">No email blasts scheduled</td></tr>
|
||||||
) : emailSchedule.map((e: any) => (
|
) : emailSchedule.map((e: any) => (
|
||||||
<tr key={e.id} className="border-b border-[#1a1a1a] hover:bg-[#161616]">
|
<tr key={e.id} className="border-b border-[#FFFFFF] hover:bg-[#FFFFFF]">
|
||||||
<td className="px-3 py-2 text-white text-xs">{e.rmp_clients?.company_name ?? '—'}</td>
|
<td className="px-3 py-2 text-white text-xs">{e.rmp_clients?.company_name ?? '—'}</td>
|
||||||
<td className="px-3 py-2 text-[#888] text-xs">{e.rmp_products?.product_type?.replace(/_/g, ' ')}</td>
|
<td className="px-3 py-2 text-[#888] text-xs">{e.rmp_products?.product_type?.replace(/_/g, ' ')}</td>
|
||||||
<td className="px-3 py-2 text-[#888] text-xs capitalize">{e.rmp_orders?.site ?? '—'}</td>
|
<td className="px-3 py-2 text-[#888] text-xs capitalize">{e.rmp_orders?.site ?? '—'}</td>
|
||||||
@@ -151,7 +151,7 @@ export default function AdOpsDashboardPage() {
|
|||||||
<span className={`px-2 py-0.5 rounded-full text-xs ${e.email_sent_at ? 'bg-green-400/10 text-green-400' : 'bg-blue-400/10 text-blue-400'}`}>{e.email_sent_at ? 'Sent' : 'Pending'}</span>
|
<span className={`px-2 py-0.5 rounded-full text-xs ${e.email_sent_at ? 'bg-green-400/10 text-green-400' : 'bg-blue-400/10 text-blue-400'}`}>{e.email_sent_at ? 'Sent' : 'Pending'}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
{e.email_html_url && <a href={e.email_html_url} target="_blank" rel="noopener noreferrer" className="text-xs text-[#3b82f6] hover:underline">Preview</a>}
|
{e.email_html_url && <a href={e.email_html_url} target="_blank" rel="noopener noreferrer" className="text-xs text-[#1D4ED8] hover:underline">Preview</a>}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export default function AdOpsProductsPage() {
|
|||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /></div>;
|
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" /></div>;
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -60,62 +60,62 @@ export default function AdOpsProductsPage() {
|
|||||||
<h1 className="text-xl font-bold">Product Catalog</h1>
|
<h1 className="text-xl font-bold">Product Catalog</h1>
|
||||||
<Link href="/admin/adops" className="text-xs text-[#555] hover:text-[#888]">← Ad Operations</Link>
|
<Link href="/admin/adops" className="text-xs text-[#555] hover:text-[#888]">← Ad Operations</Link>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setShowForm(true)} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500">+ Add Product</button>
|
<button onClick={() => setShowForm(true)} className="px-3 py-1.5 bg-[#1D4ED8] rounded text-xs font-medium hover:bg-blue-500">+ Add Product</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<select value={filterSite} onChange={e => setFilterSite(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-[#888] focus:outline-none">
|
<select value={filterSite} onChange={e => setFilterSite(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#DCE6F2] rounded text-xs text-[#888] focus:outline-none">
|
||||||
<option value="">All Sites</option>
|
<option value="">All Sites</option>
|
||||||
{SITES.map(s => <option key={s} value={s}>{s}</option>)}
|
{SITES.map(s => <option key={s} value={s}>{s}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<form onSubmit={handleCreate} className="bg-[#111] border border-[#252525] rounded-lg p-4 space-y-3">
|
<form onSubmit={handleCreate} className="bg-[#111] border border-[#DCE6F2] rounded-lg p-4 space-y-3">
|
||||||
<h3 className="text-sm font-semibold">New Product</h3>
|
<h3 className="text-sm font-semibold">New Product</h3>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Site *</label>
|
<label className="text-xs text-[#888] block mb-1">Site *</label>
|
||||||
<select required value={form.site} onChange={e => setForm(p => ({ ...p, site: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none">
|
<select required value={form.site} onChange={e => setForm(p => ({ ...p, site: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none">
|
||||||
<option value="">Select…</option>
|
<option value="">Select…</option>
|
||||||
{SITES.map(s => <option key={s} value={s}>{s}</option>)}
|
{SITES.map(s => <option key={s} value={s}>{s}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Product Type *</label>
|
<label className="text-xs text-[#888] block mb-1">Product Type *</label>
|
||||||
<select required value={form.product_type} onChange={e => setForm(p => ({ ...p, product_type: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none">
|
<select required value={form.product_type} onChange={e => setForm(p => ({ ...p, product_type: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none">
|
||||||
<option value="">Select…</option>
|
<option value="">Select…</option>
|
||||||
{PRODUCT_TYPES.map(t => <option key={t} value={t}>{t.replace(/_/g, ' ')}</option>)}
|
{PRODUCT_TYPES.map(t => <option key={t} value={t}>{t.replace(/_/g, ' ')}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Name *</label>
|
<label className="text-xs text-[#888] block mb-1">Name *</label>
|
||||||
<input required value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
|
<input required value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Dimensions</label>
|
<label className="text-xs text-[#888] block mb-1">Dimensions</label>
|
||||||
<input value={form.dimensions} onChange={e => setForm(p => ({ ...p, dimensions: e.target.value }))} placeholder="e.g. 300x250" className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
|
<input value={form.dimensions} onChange={e => setForm(p => ({ ...p, dimensions: e.target.value }))} placeholder="e.g. 300x250" className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Duration (days)</label>
|
<label className="text-xs text-[#888] block mb-1">Duration (days)</label>
|
||||||
<input type="number" value={form.duration_days} onChange={e => setForm(p => ({ ...p, duration_days: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
|
<input type="number" value={form.duration_days} onChange={e => setForm(p => ({ ...p, duration_days: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 md:col-span-3">
|
<div className="col-span-2 md:col-span-3">
|
||||||
<label className="text-xs text-[#888] block mb-1">Description</label>
|
<label className="text-xs text-[#888] block mb-1">Description</label>
|
||||||
<input value={form.description} onChange={e => setForm(p => ({ ...p, description: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
|
<input value={form.description} onChange={e => setForm(p => ({ ...p, description: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button type="submit" disabled={saving} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500 disabled:opacity-50">{saving ? 'Saving…' : 'Add Product'}</button>
|
<button type="submit" disabled={saving} className="px-3 py-1.5 bg-[#1D4ED8] rounded text-xs font-medium hover:bg-blue-500 disabled:opacity-50">{saving ? 'Saving…' : 'Add Product'}</button>
|
||||||
<button type="button" onClick={() => setShowForm(false)} className="px-3 py-1.5 bg-[#1a1a1a] border border-[#252525] rounded text-xs text-[#888] hover:text-white">Cancel</button>
|
<button type="button" onClick={() => setShowForm(false)} className="px-3 py-1.5 bg-[#FFFFFF] border border-[#DCE6F2] rounded text-xs text-[#888] hover:text-white">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-x-auto">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[#1a1a1a]">
|
<tr className="border-b border-[#FFFFFF]">
|
||||||
{['Site','Type','Name','Dimensions','Duration','Active',''].map(h => (
|
{['Site','Type','Name','Dimensions','Duration','Active',''].map(h => (
|
||||||
<th key={h} className="px-3 py-2 text-left text-xs text-[#555] font-medium">{h}</th>
|
<th key={h} className="px-3 py-2 text-left text-xs text-[#555] font-medium">{h}</th>
|
||||||
))}
|
))}
|
||||||
@@ -127,17 +127,17 @@ export default function AdOpsProductsPage() {
|
|||||||
) : products.length === 0 ? (
|
) : products.length === 0 ? (
|
||||||
<tr><td colSpan={7} className="px-4 py-8 text-center text-[#555] text-xs">No products found</td></tr>
|
<tr><td colSpan={7} className="px-4 py-8 text-center text-[#555] text-xs">No products found</td></tr>
|
||||||
) : products.map((p: any) => (
|
) : products.map((p: any) => (
|
||||||
<tr key={p.id} className={`border-b border-[#1a1a1a] hover:bg-[#161616] ${!p.active ? 'opacity-50' : ''}`}>
|
<tr key={p.id} className={`border-b border-[#FFFFFF] hover:bg-[#FFFFFF] ${!p.active ? 'opacity-50' : ''}`}>
|
||||||
<td className="px-3 py-2 text-[#888] text-xs capitalize">{p.site}</td>
|
<td className="px-3 py-2 text-[#888] text-xs capitalize">{p.site}</td>
|
||||||
<td className="px-3 py-2 text-[#555] text-xs">{p.product_type?.replace(/_/g, ' ')}</td>
|
<td className="px-3 py-2 text-[#555] text-xs">{p.product_type?.replace(/_/g, ' ')}</td>
|
||||||
<td className="px-3 py-2 text-white text-xs">{p.name}</td>
|
<td className="px-3 py-2 text-white text-xs">{p.name}</td>
|
||||||
<td className="px-3 py-2 text-[#888] text-xs">{p.dimensions ?? '—'}</td>
|
<td className="px-3 py-2 text-[#888] text-xs">{p.dimensions ?? '—'}</td>
|
||||||
<td className="px-3 py-2 text-[#888] text-xs">{p.duration_days ? `${p.duration_days}d` : '—'}</td>
|
<td className="px-3 py-2 text-[#888] text-xs">{p.duration_days ? `${p.duration_days}d` : '—'}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<span className={`px-2 py-0.5 rounded-full text-xs ${p.active ? 'bg-green-400/10 text-green-400' : 'bg-[#1a1a1a] text-[#555]'}`}>{p.active ? 'Active' : 'Inactive'}</span>
|
<span className={`px-2 py-0.5 rounded-full text-xs ${p.active ? 'bg-green-400/10 text-green-400' : 'bg-[#FFFFFF] text-[#555]'}`}>{p.active ? 'Active' : 'Inactive'}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<button onClick={() => toggleActive(p.id, p.active)} className="text-xs text-[#3b82f6] hover:underline">{p.active ? 'Deactivate' : 'Activate'}</button>
|
<button onClick={() => toggleActive(p.id, p.active)} className="text-xs text-[#1D4ED8] hover:underline">{p.active ? 'Deactivate' : 'Activate'}</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ interface MonthlyTrend {
|
|||||||
// ─── Mock / derived data helpers ──────────────────────────────────────────────
|
// ─── Mock / derived data helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
const CATEGORY_COLORS = [
|
const CATEGORY_COLORS = [
|
||||||
'#3b82f6', '#f59e0b', '#10b981', '#8b5cf6',
|
'#1D4ED8', '#f59e0b', '#10b981', '#8b5cf6',
|
||||||
'#ef4444', '#06b6d4', '#f97316', '#84cc16',
|
'#ef4444', '#06b6d4', '#f97316', '#84cc16',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -78,9 +78,9 @@ interface StatCardProps {
|
|||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatCard({ label, value, sub, accent = '#3b82f6', icon }: StatCardProps) {
|
function StatCard({ label, value, sub, accent = '#1D4ED8', icon }: StatCardProps) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-5 flex items-start gap-4">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg p-5 flex items-start gap-4">
|
||||||
<div
|
<div
|
||||||
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
|
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||||
style={{ background: `${accent}18` }}
|
style={{ background: `${accent}18` }}
|
||||||
@@ -112,7 +112,7 @@ function SectionHeader({ title, subtitle }: { title: string; subtitle?: string }
|
|||||||
function ChartTooltip({ active, payload, label }: any) {
|
function ChartTooltip({ active, payload, label }: any) {
|
||||||
if (!active || !payload?.length) return null;
|
if (!active || !payload?.length) return null;
|
||||||
return (
|
return (
|
||||||
<div className="bg-[#1a1a1a] border border-[#333] rounded-lg px-3 py-2 text-xs font-body shadow-xl">
|
<div className="bg-[#FFFFFF] border border-[#333] rounded-lg px-3 py-2 text-xs font-body shadow-xl">
|
||||||
{label && <p className="text-[#888] mb-1">{label}</p>}
|
{label && <p className="text-[#888] mb-1">{label}</p>}
|
||||||
{payload.map((entry: any, i: number) => (
|
{payload.map((entry: any, i: number) => (
|
||||||
<p key={i} style={{ color: entry.color }} className="leading-5">
|
<p key={i} style={{ color: entry.color }} className="leading-5">
|
||||||
@@ -252,7 +252,7 @@ export default function AdminAnalyticsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
<div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
<p className="text-[#555] text-sm font-body">Loading analytics…</p>
|
<p className="text-[#555] text-sm font-body">Loading analytics…</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -266,7 +266,7 @@ export default function AdminAnalyticsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0a0a] text-white">
|
<div className="min-h-screen bg-[#0a0a0a] text-white">
|
||||||
{/* ── Page Header ─────────────────────────────────────────────────── */}
|
{/* ── Page Header ─────────────────────────────────────────────────── */}
|
||||||
<div className="bg-[#111] border-b border-[#252525]">
|
<div className="bg-[#111] border-b border-[#DCE6F2]">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-5 flex items-center justify-between gap-4">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-5 flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-white text-xl font-bold font-heading uppercase tracking-wider">Analytics</h1>
|
<h1 className="text-white text-xl font-bold font-heading uppercase tracking-wider">Analytics</h1>
|
||||||
@@ -275,20 +275,20 @@ export default function AdminAnalyticsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Link href="/admin/articles" className="text-[#555] hover:text-[#3b82f6] text-xs font-body uppercase tracking-wider transition-colors">
|
<Link href="/admin/articles" className="text-[#555] hover:text-[#1D4ED8] text-xs font-body uppercase tracking-wider transition-colors">
|
||||||
Articles
|
Articles
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-[#333]">·</span>
|
<span className="text-[#333]">·</span>
|
||||||
<Link href="/admin/users" className="text-[#555] hover:text-[#3b82f6] text-xs font-body uppercase tracking-wider transition-colors">
|
<Link href="/admin/users" className="text-[#555] hover:text-[#1D4ED8] text-xs font-body uppercase tracking-wider transition-colors">
|
||||||
Users
|
Users
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-[#333]">·</span>
|
<span className="text-[#333]">·</span>
|
||||||
<Link href="/admin/import" className="text-[#555] hover:text-[#3b82f6] text-xs font-body uppercase tracking-wider transition-colors">
|
<Link href="/admin/import" className="text-[#555] hover:text-[#1D4ED8] text-xs font-body uppercase tracking-wider transition-colors">
|
||||||
WP Import
|
WP Import
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={fetchAnalytics}
|
onClick={fetchAnalytics}
|
||||||
className="ml-2 px-3 py-1.5 bg-[#1a1a1a] border border-[#333] hover:border-[#3b82f6] text-[#888] hover:text-[#3b82f6] text-xs font-body rounded transition-colors"
|
className="ml-2 px-3 py-1.5 bg-[#FFFFFF] border border-[#333] hover:border-[#1D4ED8] text-[#888] hover:text-[#1D4ED8] text-xs font-body rounded transition-colors"
|
||||||
>
|
>
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
@@ -312,7 +312,7 @@ export default function AdminAnalyticsPage() {
|
|||||||
label="Total Articles"
|
label="Total Articles"
|
||||||
value={metrics?.totalArticles ?? 0}
|
value={metrics?.totalArticles ?? 0}
|
||||||
icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><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"/></svg>}
|
icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><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"/></svg>}
|
||||||
accent="#3b82f6"
|
accent="#1D4ED8"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Published"
|
label="Published"
|
||||||
@@ -361,7 +361,7 @@ export default function AdminAnalyticsPage() {
|
|||||||
value={`${publishRate}%`}
|
value={`${publishRate}%`}
|
||||||
sub="Published / total"
|
sub="Published / total"
|
||||||
icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>}
|
icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>}
|
||||||
accent="#3b82f6"
|
accent="#1D4ED8"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -369,7 +369,7 @@ export default function AdminAnalyticsPage() {
|
|||||||
{/* ── Monthly Trend ────────────────────────────────────────────── */}
|
{/* ── Monthly Trend ────────────────────────────────────────────── */}
|
||||||
<section>
|
<section>
|
||||||
<SectionHeader title="Monthly Trends" subtitle="Published, imported, and native articles over the last 6 months" />
|
<SectionHeader title="Monthly Trends" subtitle="Published, imported, and native articles over the last 6 months" />
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-5">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg p-5">
|
||||||
{monthlyTrends.length > 0 ? (
|
{monthlyTrends.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={240}>
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
<LineChart data={monthlyTrends} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
|
<LineChart data={monthlyTrends} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
|
||||||
@@ -395,7 +395,7 @@ export default function AdminAnalyticsPage() {
|
|||||||
{/* Author Contributions */}
|
{/* Author Contributions */}
|
||||||
<section>
|
<section>
|
||||||
<SectionHeader title="Author Contributions" subtitle="Top contributors by article count" />
|
<SectionHeader title="Author Contributions" subtitle="Top contributors by article count" />
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-5">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg p-5">
|
||||||
{authorStats.length > 0 ? (
|
{authorStats.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={280}>
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
<BarChart
|
<BarChart
|
||||||
@@ -429,7 +429,7 @@ export default function AdminAnalyticsPage() {
|
|||||||
{/* Top Categories */}
|
{/* Top Categories */}
|
||||||
<section>
|
<section>
|
||||||
<SectionHeader title="Top-Performing Categories" subtitle="Article distribution by category" />
|
<SectionHeader title="Top-Performing Categories" subtitle="Article distribution by category" />
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-5">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg p-5">
|
||||||
{categoryStats.length > 0 ? (
|
{categoryStats.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<ResponsiveContainer width="100%" height={200}>
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
@@ -481,7 +481,7 @@ export default function AdminAnalyticsPage() {
|
|||||||
<SectionHeader title="Import Success Rates" subtitle="Publishing success across content sources" />
|
<SectionHeader title="Import Success Rates" subtitle="Publishing success across content sources" />
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
{importStats.map((stat) => (
|
{importStats.map((stat) => (
|
||||||
<div key={stat.label} className="bg-[#111] border border-[#252525] rounded-lg p-5">
|
<div key={stat.label} className="bg-[#111] border border-[#DCE6F2] rounded-lg p-5">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<p className="text-white text-sm font-bold font-heading uppercase tracking-wider">{stat.label}</p>
|
<p className="text-white text-sm font-bold font-heading uppercase tracking-wider">{stat.label}</p>
|
||||||
<span
|
<span
|
||||||
@@ -526,7 +526,7 @@ export default function AdminAnalyticsPage() {
|
|||||||
{/* ── Author Detail Table ──────────────────────────────────────── */}
|
{/* ── Author Detail Table ──────────────────────────────────────── */}
|
||||||
<section>
|
<section>
|
||||||
<SectionHeader title="Author Detail Breakdown" subtitle="Per-author article status distribution" />
|
<SectionHeader title="Author Detail Breakdown" subtitle="Per-author article status distribution" />
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm font-body">
|
<table className="w-full text-sm font-body">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -550,7 +550,7 @@ export default function AdminAnalyticsPage() {
|
|||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={a.author_name}
|
key={a.author_name}
|
||||||
className={`border-b border-[#1a1a1a] hover:bg-[#151515] transition-colors ${i % 2 === 0 ? '' : 'bg-[#0d0d0d]'}`}
|
className={`border-b border-[#FFFFFF] hover:bg-[#151515] transition-colors ${i % 2 === 0 ? '' : 'bg-[#F8FAFC]'}`}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3 text-white font-medium">{a.author_name}</td>
|
<td className="px-4 py-3 text-white font-medium">{a.author_name}</td>
|
||||||
<td className="px-4 py-3 text-right text-[#888]">{a.total}</td>
|
<td className="px-4 py-3 text-right text-[#888]">{a.total}</td>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useAuth } from '@/contexts/AuthContext';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import AppImage from '@/components/ui/AppImage';
|
import AppImage from '@/components/ui/AppImage';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
import AdminShell from '@/components/admin/AdminShell';
|
||||||
|
|
||||||
const RichTextEditor = dynamic(() => import('@/components/RichTextEditor'), { ssr: false });
|
const RichTextEditor = dynamic(() => import('@/components/RichTextEditor'), { ssr: false });
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ const STATUS_COLORS: Record<string, string> = {
|
|||||||
published: 'text-green-400 bg-green-400/10',
|
published: 'text-green-400 bg-green-400/10',
|
||||||
draft: 'text-yellow-400 bg-yellow-400/10',
|
draft: 'text-yellow-400 bg-yellow-400/10',
|
||||||
pending: 'text-orange-400 bg-orange-400/10',
|
pending: 'text-orange-400 bg-orange-400/10',
|
||||||
archived: 'text-[#555] bg-[#1a1a1a]',
|
archived: 'text-[#555] bg-[#FFFFFF]',
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
@@ -384,12 +385,13 @@ export default function AdminArticlesPage() {
|
|||||||
if (loading || (!user && !loading)) {
|
if (loading || (!user && !loading)) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
<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 className="w-6 h-6 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<AdminShell title="Articles">
|
||||||
<div className="min-h-screen bg-[#0a0a0a] text-[#cccccc]">
|
<div className="min-h-screen bg-[#0a0a0a] text-[#cccccc]">
|
||||||
{/* Notification */}
|
{/* Notification */}
|
||||||
{notification && (
|
{notification && (
|
||||||
@@ -406,10 +408,10 @@ export default function AdminArticlesPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="bg-[#111111] border-b border-[#252525]">
|
<div className="bg-[#F8FAFC] border-b border-[#DCE6F2]">
|
||||||
<div className="max-w-7xl mx-auto px-4 py-5">
|
<div className="max-w-7xl mx-auto px-4 py-5">
|
||||||
<div className="flex items-center gap-3 mb-1">
|
<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>
|
<Link href="/home-page" className="text-[#555] hover:text-[#1D4ED8] transition-colors text-sm">Home</Link>
|
||||||
<span className="text-[#333]">/</span>
|
<span className="text-[#333]">/</span>
|
||||||
<span className="text-[#888] text-sm">Admin</span>
|
<span className="text-[#888] text-sm">Admin</span>
|
||||||
<span className="text-[#333]">/</span>
|
<span className="text-[#333]">/</span>
|
||||||
@@ -423,13 +425,13 @@ export default function AdminArticlesPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowNewArticle(true)}
|
onClick={() => setShowNewArticle(true)}
|
||||||
className="flex items-center gap-1.5 text-xs font-bold text-white bg-[#3b82f6] hover:bg-[#2563eb] rounded px-3 py-2 transition-colors">
|
className="flex items-center gap-1.5 text-xs font-bold text-white bg-[#1D4ED8] hover:bg-[#1E3A8A] rounded px-3 py-2 transition-colors">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg>
|
||||||
New Article
|
New Article
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
href="/admin/import"
|
href="/admin/import"
|
||||||
className="flex items-center gap-1.5 text-xs text-[#666] hover:text-[#3b82f6] border border-[#252525] hover:border-[#3b82f6] rounded px-3 py-2 transition-colors">
|
className="flex items-center gap-1.5 text-xs text-[#666] hover:text-[#1D4ED8] border border-[#DCE6F2] hover:border-[#1D4ED8] rounded px-3 py-2 transition-colors">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><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>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><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>
|
||||||
WP Import
|
WP Import
|
||||||
</Link>
|
</Link>
|
||||||
@@ -471,7 +473,7 @@ export default function AdminArticlesPage() {
|
|||||||
{ label: 'Drafts', value: counts.draft, color: 'text-yellow-400' },
|
{ label: 'Drafts', value: counts.draft, color: 'text-yellow-400' },
|
||||||
{ label: 'Archived', value: counts.archived, color: 'text-[#555]' },
|
{ label: 'Archived', value: counts.archived, color: 'text-[#555]' },
|
||||||
].map((s) => (
|
].map((s) => (
|
||||||
<div key={s.label} className="bg-[#111] border border-[#252525] rounded-lg p-4 text-center">
|
<div key={s.label} className="bg-[#111] border border-[#DCE6F2] rounded-lg p-4 text-center">
|
||||||
<p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
|
<p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
|
||||||
<p className="text-[#555] text-xs mt-0.5 uppercase tracking-wider">{s.label}</p>
|
<p className="text-[#555] text-xs mt-0.5 uppercase tracking-wider">{s.label}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -479,7 +481,7 @@ export default function AdminArticlesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Tabs */}
|
{/* Status Tabs */}
|
||||||
<div className="flex items-center gap-1 border-b border-[#252525] overflow-x-auto">
|
<div className="flex items-center gap-1 border-b border-[#DCE6F2] overflow-x-auto">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
@@ -492,7 +494,7 @@ export default function AdminArticlesPage() {
|
|||||||
{tab.label}
|
{tab.label}
|
||||||
{tab.key !== 'all' && (
|
{tab.key !== 'all' && (
|
||||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${
|
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${
|
||||||
activeTab === tab.key ? 'bg-current/10' : 'bg-[#1a1a1a]'
|
activeTab === tab.key ? 'bg-current/10' : 'bg-[#FFFFFF]'
|
||||||
}`}>
|
}`}>
|
||||||
{counts[tab.key]}
|
{counts[tab.key]}
|
||||||
</span>
|
</span>
|
||||||
@@ -512,13 +514,13 @@ export default function AdminArticlesPage() {
|
|||||||
placeholder="Search articles..."
|
placeholder="Search articles..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
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]"
|
className="w-full bg-[#111] border border-[#DCE6F2] text-[#cccccc] text-sm rounded pl-9 pr-4 py-2 focus:outline-none focus:border-[#1D4ED8] placeholder-[#444]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
value={sourceFilter}
|
value={sourceFilter}
|
||||||
onChange={(e) => setSourceFilter(e.target.value as SourceFilter)}
|
onChange={(e) => setSourceFilter(e.target.value as SourceFilter)}
|
||||||
className="bg-[#111] border border-[#252525] text-[#cccccc] text-sm rounded px-3 py-2 focus:outline-none focus:border-[#3b82f6]">
|
className="bg-[#111] border border-[#DCE6F2] text-[#cccccc] text-sm rounded px-3 py-2 focus:outline-none focus:border-[#1D4ED8]">
|
||||||
<option value="all">All Sources</option>
|
<option value="all">All Sources</option>
|
||||||
<option value="imported">Imported (WP)</option>
|
<option value="imported">Imported (WP)</option>
|
||||||
<option value="native">Native</option>
|
<option value="native">Native</option>
|
||||||
@@ -527,13 +529,13 @@ export default function AdminArticlesPage() {
|
|||||||
|
|
||||||
{/* Bulk Actions Bar */}
|
{/* Bulk Actions Bar */}
|
||||||
{selectedIds.size > 0 && (
|
{selectedIds.size > 0 && (
|
||||||
<div className="flex items-center gap-3 bg-[#0d1a2e] border border-[#1e3a5f] rounded-lg px-4 py-3">
|
<div className="flex items-center gap-3 bg-[#0d1a2e] border border-[#DCE6F2] rounded-lg px-4 py-3">
|
||||||
<span className="text-[#3b82f6] text-sm font-bold">{selectedIds.size} selected</span>
|
<span className="text-[#1D4ED8] text-sm font-bold">{selectedIds.size} selected</span>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<select
|
<select
|
||||||
value={bulkAction}
|
value={bulkAction}
|
||||||
onChange={(e) => setBulkAction(e.target.value as BulkAction | '')}
|
onChange={(e) => setBulkAction(e.target.value as BulkAction | '')}
|
||||||
className="bg-[#111] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-1.5 focus:outline-none focus:border-[#3b82f6]">
|
className="bg-[#111] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-1.5 focus:outline-none focus:border-[#1D4ED8]">
|
||||||
<option value="">Bulk Action…</option>
|
<option value="">Bulk Action…</option>
|
||||||
<option value="publish">Publish</option>
|
<option value="publish">Publish</option>
|
||||||
<option value="pending">Submit for Review</option>
|
<option value="pending">Submit for Review</option>
|
||||||
@@ -544,7 +546,7 @@ export default function AdminArticlesPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleBulkAction}
|
onClick={handleBulkAction}
|
||||||
disabled={!bulkAction || bulkProcessing}
|
disabled={!bulkAction || bulkProcessing}
|
||||||
className="flex items-center gap-1.5 bg-[#3b82f6] hover:bg-[#2563eb] disabled:bg-[#1e3a5f] disabled:cursor-not-allowed text-white text-sm font-bold px-4 py-1.5 rounded transition-colors">
|
className="flex items-center gap-1.5 bg-[#1D4ED8] hover:bg-[#1E3A8A] disabled:bg-[#DCE6F2] disabled:cursor-not-allowed text-white text-sm font-bold px-4 py-1.5 rounded transition-colors">
|
||||||
{bulkProcessing ? (
|
{bulkProcessing ? (
|
||||||
<div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
<div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -568,15 +570,15 @@ export default function AdminArticlesPage() {
|
|||||||
<span className="text-orange-400 text-sm font-bold">Approval Queue</span>
|
<span className="text-orange-400 text-sm font-bold">Approval Queue</span>
|
||||||
<span className="text-[#555] text-xs ml-1">— Review and approve or reject each submission</span>
|
<span className="text-[#555] text-xs ml-1">— Review and approve or reject each submission</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-[#1a1a1a]">
|
<div className="divide-y divide-[#FFFFFF]">
|
||||||
{pendingArticles.map((article) => (
|
{pendingArticles.map((article) => (
|
||||||
<div key={article.id} className={`flex items-start gap-4 px-4 py-4 hover:bg-[#141414] transition-colors ${processingIds.has(article.id) ? 'opacity-50 pointer-events-none' : ''}`}>
|
<div key={article.id} className={`flex items-start gap-4 px-4 py-4 hover:bg-[#141414] transition-colors ${processingIds.has(article.id) ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||||
{article.featured_image ? (
|
{article.featured_image ? (
|
||||||
<div className="flex-shrink-0 w-16 h-12 rounded overflow-hidden bg-[#1a1a1a]">
|
<div className="flex-shrink-0 w-16 h-12 rounded overflow-hidden bg-[#FFFFFF]">
|
||||||
<AppImage src={article.featured_image} alt={article.featured_image_alt || article.title} width={64} height={48} className="w-full h-full object-cover" />
|
<AppImage src={article.featured_image} alt={article.featured_image_alt || article.title} width={64} height={48} className="w-full h-full object-cover" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-shrink-0 w-16 h-12 rounded bg-[#1a1a1a] flex items-center justify-center">
|
<div className="flex-shrink-0 w-16 h-12 rounded bg-[#FFFFFF] 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]">
|
<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" />
|
<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>
|
</svg>
|
||||||
@@ -587,9 +589,9 @@ export default function AdminArticlesPage() {
|
|||||||
<div className="flex items-center gap-3 mt-1">
|
<div className="flex items-center gap-3 mt-1">
|
||||||
<span className="text-[11px] text-[#555]">{article.author_name || 'Unknown author'}</span>
|
<span className="text-[11px] text-[#555]">{article.author_name || 'Unknown author'}</span>
|
||||||
{article.category && (
|
{article.category && (
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider text-[#3b82f6] bg-[#0d1a2e] px-1.5 py-0.5 rounded">{article.category}</span>
|
<span className="text-[10px] font-bold uppercase tracking-wider text-[#1D4ED8] bg-[#0d1a2e] px-1.5 py-0.5 rounded">{article.category}</span>
|
||||||
)}
|
)}
|
||||||
<span className={`text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded ${article.source === 'imported' ? 'text-purple-400 bg-purple-400/10' : 'text-[#3b82f6] bg-[#3b82f6]/10'}`}>
|
<span className={`text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded ${article.source === 'imported' ? 'text-purple-400 bg-purple-400/10' : 'text-[#1D4ED8] bg-[#1D4ED8]/10'}`}>
|
||||||
{article.source === 'imported' ? 'WP' : 'Native'}
|
{article.source === 'imported' ? 'WP' : 'Native'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -606,13 +608,13 @@ export default function AdminArticlesPage() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleStatusChange(article, 'draft')}
|
onClick={() => handleStatusChange(article, 'draft')}
|
||||||
className="flex items-center gap-1.5 text-xs font-bold text-[#666] border border-[#333] hover:border-[#555] hover:bg-[#1a1a1a] px-3 py-1.5 rounded transition-colors">
|
className="flex items-center gap-1.5 text-xs font-bold text-[#666] border border-[#333] hover:border-[#555] hover:bg-[#FFFFFF] px-3 py-1.5 rounded transition-colors">
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
|
||||||
Reject
|
Reject
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => openEdit(article)}
|
onClick={() => openEdit(article)}
|
||||||
className="p-1.5 text-[#555] hover:text-[#3b82f6] transition-colors rounded hover:bg-[#1a1a1a]"
|
className="p-1.5 text-[#555] hover:text-[#1D4ED8] transition-colors rounded hover:bg-[#FFFFFF]"
|
||||||
title="Edit">
|
title="Edit">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
@@ -627,14 +629,14 @@ export default function AdminArticlesPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Articles Table */}
|
{/* Articles Table */}
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-hidden">
|
||||||
{/* Table Header */}
|
{/* Table Header */}
|
||||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-[#252525] bg-[#0d0d0d]">
|
<div className="flex items-center gap-3 px-4 py-3 border-b border-[#DCE6F2] bg-[#F8FAFC]">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={articles.length > 0 && selectedIds.size === articles.length}
|
checked={articles.length > 0 && selectedIds.size === articles.length}
|
||||||
onChange={toggleSelectAll}
|
onChange={toggleSelectAll}
|
||||||
className="w-4 h-4 rounded border-[#333] bg-[#1a1a1a] accent-[#3b82f6] cursor-pointer"
|
className="w-4 h-4 rounded border-[#333] bg-[#FFFFFF] accent-[#1D4ED8] cursor-pointer"
|
||||||
/>
|
/>
|
||||||
<span className="text-[10px] text-[#555] uppercase tracking-wider font-bold flex-1">Article</span>
|
<span className="text-[10px] text-[#555] uppercase tracking-wider font-bold flex-1">Article</span>
|
||||||
<span className="text-[10px] text-[#555] uppercase tracking-wider font-bold w-24 hidden md:block">Category</span>
|
<span className="text-[10px] text-[#555] uppercase tracking-wider font-bold w-24 hidden md:block">Category</span>
|
||||||
@@ -646,7 +648,7 @@ export default function AdminArticlesPage() {
|
|||||||
|
|
||||||
{loadingData ? (
|
{loadingData ? (
|
||||||
<div className="flex items-center justify-center py-16">
|
<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 className="w-6 h-6 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : articles.length === 0 ? (
|
) : articles.length === 0 ? (
|
||||||
<div className="text-center py-16">
|
<div className="text-center py-16">
|
||||||
@@ -656,7 +658,7 @@ export default function AdminArticlesPage() {
|
|||||||
<p className="text-[#555] text-sm">No articles found</p>
|
<p className="text-[#555] text-sm">No articles found</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-[#1a1a1a]">
|
<div className="divide-y divide-[#FFFFFF]">
|
||||||
{articles.map((article) => (
|
{articles.map((article) => (
|
||||||
<div
|
<div
|
||||||
key={article.id}
|
key={article.id}
|
||||||
@@ -668,13 +670,13 @@ export default function AdminArticlesPage() {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedIds.has(article.id)}
|
checked={selectedIds.has(article.id)}
|
||||||
onChange={() => toggleSelect(article.id)}
|
onChange={() => toggleSelect(article.id)}
|
||||||
className="w-4 h-4 rounded border-[#333] bg-[#1a1a1a] accent-[#3b82f6] cursor-pointer flex-shrink-0"
|
className="w-4 h-4 rounded border-[#333] bg-[#FFFFFF] accent-[#1D4ED8] cursor-pointer flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Thumbnail + Title */}
|
{/* Thumbnail + Title */}
|
||||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
{article.featured_image ? (
|
{article.featured_image ? (
|
||||||
<div className="flex-shrink-0 w-12 h-9 rounded overflow-hidden bg-[#1a1a1a]">
|
<div className="flex-shrink-0 w-12 h-9 rounded overflow-hidden bg-[#FFFFFF]">
|
||||||
<AppImage
|
<AppImage
|
||||||
src={article.featured_image}
|
src={article.featured_image}
|
||||||
alt={article.featured_image_alt || article.title}
|
alt={article.featured_image_alt || article.title}
|
||||||
@@ -684,7 +686,7 @@ export default function AdminArticlesPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-shrink-0 w-12 h-9 rounded bg-[#1a1a1a] flex items-center justify-center">
|
<div className="flex-shrink-0 w-12 h-9 rounded bg-[#FFFFFF] flex items-center justify-center">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-[#333]">
|
<svg width="14" height="14" 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" />
|
<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>
|
</svg>
|
||||||
@@ -699,7 +701,7 @@ export default function AdminArticlesPage() {
|
|||||||
{/* Category */}
|
{/* Category */}
|
||||||
<div className="w-24 hidden md:block">
|
<div className="w-24 hidden md:block">
|
||||||
{article.category ? (
|
{article.category ? (
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider text-[#3b82f6] bg-[#0d1a2e] px-1.5 py-0.5 rounded">
|
<span className="text-[10px] font-bold uppercase tracking-wider text-[#1D4ED8] bg-[#0d1a2e] px-1.5 py-0.5 rounded">
|
||||||
{article.category}
|
{article.category}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -710,7 +712,7 @@ export default function AdminArticlesPage() {
|
|||||||
{/* Source */}
|
{/* Source */}
|
||||||
<div className="w-20 hidden lg:block">
|
<div className="w-20 hidden lg:block">
|
||||||
<span className={`text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded ${
|
<span className={`text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded ${
|
||||||
article.source === 'imported' ? 'text-purple-400 bg-purple-400/10' : 'text-[#3b82f6] bg-[#3b82f6]/10'
|
article.source === 'imported' ? 'text-purple-400 bg-purple-400/10' : 'text-[#1D4ED8] bg-[#1D4ED8]/10'
|
||||||
}`}>
|
}`}>
|
||||||
{article.source === 'imported' ? 'WP' : 'Native'}
|
{article.source === 'imported' ? 'WP' : 'Native'}
|
||||||
</span>
|
</span>
|
||||||
@@ -730,7 +732,7 @@ export default function AdminArticlesPage() {
|
|||||||
|
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
<div className="w-28">
|
<div className="w-28">
|
||||||
<span className={`text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded ${STATUS_COLORS[article.status] || 'text-[#555] bg-[#1a1a1a]'}`}>
|
<span className={`text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded ${STATUS_COLORS[article.status] || 'text-[#555] bg-[#FFFFFF]'}`}>
|
||||||
{article.status === 'pending' ? 'Pending' : article.status}
|
{article.status === 'pending' ? 'Pending' : article.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -741,19 +743,32 @@ export default function AdminArticlesPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => openEdit(article)}
|
onClick={() => openEdit(article)}
|
||||||
title="Edit"
|
title="Edit"
|
||||||
className="p-1.5 text-[#555] hover:text-[#3b82f6] transition-colors rounded hover:bg-[#1a1a1a]">
|
className="p-1.5 text-[#555] hover:text-[#1D4ED8] transition-colors rounded hover:bg-[#FFFFFF]">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Promote — opens /admin/promote/<slug> share sheet */}
|
||||||
|
{article.status === 'published' && (article.slug || article.wp_slug) && (
|
||||||
|
<Link
|
||||||
|
href={`/admin/promote/${article.slug || article.wp_slug}`}
|
||||||
|
title="Promote on social"
|
||||||
|
className="p-1.5 text-[#555] hover:text-[#1D4ED8] transition-colors rounded hover:bg-[#FFFFFF]">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
|
||||||
|
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Submit for Review (draft → pending) */}
|
{/* Submit for Review (draft → pending) */}
|
||||||
{article.status === 'draft' && (
|
{article.status === 'draft' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleStatusChange(article, 'pending')}
|
onClick={() => handleStatusChange(article, 'pending')}
|
||||||
title="Submit for Review"
|
title="Submit for Review"
|
||||||
className="p-1.5 text-[#555] hover:text-orange-400 transition-colors rounded hover:bg-[#1a1a1a]">
|
className="p-1.5 text-[#555] hover:text-orange-400 transition-colors rounded hover:bg-[#FFFFFF]">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
|
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -765,7 +780,7 @@ export default function AdminArticlesPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleStatusChange(article, 'published')}
|
onClick={() => handleStatusChange(article, 'published')}
|
||||||
title="Approve & Publish"
|
title="Approve & Publish"
|
||||||
className="p-1.5 text-[#555] hover:text-green-400 transition-colors rounded hover:bg-[#1a1a1a]">
|
className="p-1.5 text-[#555] hover:text-green-400 transition-colors rounded hover:bg-[#FFFFFF]">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" />
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -777,7 +792,7 @@ export default function AdminArticlesPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleStatusChange(article, 'draft')}
|
onClick={() => handleStatusChange(article, 'draft')}
|
||||||
title="Reject (back to draft)"
|
title="Reject (back to draft)"
|
||||||
className="p-1.5 text-[#555] hover:text-red-400 transition-colors rounded hover:bg-[#1a1a1a]">
|
className="p-1.5 text-[#555] hover:text-red-400 transition-colors rounded hover:bg-[#FFFFFF]">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -789,7 +804,7 @@ export default function AdminArticlesPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleStatusChange(article, 'published')}
|
onClick={() => handleStatusChange(article, 'published')}
|
||||||
title="Publish"
|
title="Publish"
|
||||||
className="p-1.5 text-[#555] hover:text-green-400 transition-colors rounded hover:bg-[#1a1a1a]">
|
className="p-1.5 text-[#555] hover:text-green-400 transition-colors rounded hover:bg-[#FFFFFF]">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" />
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -801,7 +816,7 @@ export default function AdminArticlesPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleStatusChange(article, 'archived')}
|
onClick={() => handleStatusChange(article, 'archived')}
|
||||||
title="Archive"
|
title="Archive"
|
||||||
className="p-1.5 text-[#555] hover:text-yellow-400 transition-colors rounded hover:bg-[#1a1a1a]">
|
className="p-1.5 text-[#555] hover:text-yellow-400 transition-colors rounded hover:bg-[#FFFFFF]">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<polyline points="21 8 21 21 3 21 3 8" /><rect x="1" y="3" width="22" height="5" /><line x1="10" y1="12" x2="14" y2="12" />
|
<polyline points="21 8 21 21 3 21 3 8" /><rect x="1" y="3" width="22" height="5" /><line x1="10" y1="12" x2="14" y2="12" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -812,7 +827,7 @@ export default function AdminArticlesPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(article)}
|
onClick={() => handleDelete(article)}
|
||||||
title="Delete"
|
title="Delete"
|
||||||
className="p-1.5 text-[#555] hover:text-red-400 transition-colors rounded hover:bg-[#1a1a1a]">
|
className="p-1.5 text-[#555] hover:text-red-400 transition-colors rounded hover:bg-[#FFFFFF]">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<polyline points="3 6 5 6 21 6" />
|
<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="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||||
@@ -834,12 +849,12 @@ export default function AdminArticlesPage() {
|
|||||||
{/* New Article Modal */}
|
{/* New Article Modal */}
|
||||||
{showNewArticle && (
|
{showNewArticle && (
|
||||||
<div className="fixed inset-0 z-50 flex items-start justify-center p-4 bg-black/80 overflow-y-auto">
|
<div className="fixed inset-0 z-50 flex items-start justify-center p-4 bg-black/80 overflow-y-auto">
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-xl w-full max-w-4xl shadow-2xl my-8">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-xl w-full max-w-4xl shadow-2xl my-8">
|
||||||
{/* Modal Header */}
|
{/* Modal Header */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[#252525] sticky top-0 bg-[#111] z-10 rounded-t-xl">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-[#DCE6F2] sticky top-0 bg-[#111] z-10 rounded-t-xl">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h2 className="text-base font-bold text-white font-heading">New Article</h2>
|
<h2 className="text-base font-bold text-white font-heading">New Article</h2>
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider text-[#3b82f6] bg-[#3b82f6]/10 px-1.5 py-0.5 rounded">Native</span>
|
<span className="text-[10px] font-bold uppercase tracking-wider text-[#1D4ED8] bg-[#1D4ED8]/10 px-1.5 py-0.5 rounded">Native</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowNewArticle(false)}
|
onClick={() => setShowNewArticle(false)}
|
||||||
@@ -859,7 +874,7 @@ export default function AdminArticlesPage() {
|
|||||||
value={newTitle}
|
value={newTitle}
|
||||||
onChange={(e) => setNewTitle(e.target.value)}
|
onChange={(e) => setNewTitle(e.target.value)}
|
||||||
placeholder="Enter article title..."
|
placeholder="Enter article title..."
|
||||||
className="w-full bg-[#1a1a1a] border border-[#333] text-white text-lg font-bold rounded px-4 py-3 focus:outline-none focus:border-[#3b82f6] placeholder-[#444]"
|
className="w-full bg-[#FFFFFF] border border-[#333] text-white text-lg font-bold rounded px-4 py-3 focus:outline-none focus:border-[#1D4ED8] placeholder-[#444]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -871,7 +886,7 @@ export default function AdminArticlesPage() {
|
|||||||
onChange={(e) => setNewExcerpt(e.target.value)}
|
onChange={(e) => setNewExcerpt(e.target.value)}
|
||||||
placeholder="Brief summary shown in article listings..."
|
placeholder="Brief summary shown in article listings..."
|
||||||
rows={2}
|
rows={2}
|
||||||
className="w-full bg-[#1a1a1a] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-2.5 focus:outline-none focus:border-[#3b82f6] placeholder-[#444] resize-none"
|
className="w-full bg-[#FFFFFF] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-2.5 focus:outline-none focus:border-[#1D4ED8] placeholder-[#444] resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -894,7 +909,7 @@ export default function AdminArticlesPage() {
|
|||||||
value={newAuthor}
|
value={newAuthor}
|
||||||
onChange={(e) => setNewAuthor(e.target.value)}
|
onChange={(e) => setNewAuthor(e.target.value)}
|
||||||
placeholder="Author name"
|
placeholder="Author name"
|
||||||
className="w-full bg-[#1a1a1a] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-2 focus:outline-none focus:border-[#3b82f6] placeholder-[#444]"
|
className="w-full bg-[#FFFFFF] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-2 focus:outline-none focus:border-[#1D4ED8] placeholder-[#444]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -904,7 +919,7 @@ export default function AdminArticlesPage() {
|
|||||||
value={newCategory}
|
value={newCategory}
|
||||||
onChange={(e) => setNewCategory(e.target.value)}
|
onChange={(e) => setNewCategory(e.target.value)}
|
||||||
placeholder="e.g. Technology"
|
placeholder="e.g. Technology"
|
||||||
className="w-full bg-[#1a1a1a] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-2 focus:outline-none focus:border-[#3b82f6] placeholder-[#444]"
|
className="w-full bg-[#FFFFFF] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-2 focus:outline-none focus:border-[#1D4ED8] placeholder-[#444]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -912,7 +927,7 @@ export default function AdminArticlesPage() {
|
|||||||
<select
|
<select
|
||||||
value={newStatus}
|
value={newStatus}
|
||||||
onChange={(e) => setNewStatus(e.target.value as 'draft' | 'pending' | 'published')}
|
onChange={(e) => setNewStatus(e.target.value as 'draft' | 'pending' | 'published')}
|
||||||
className="w-full bg-[#1a1a1a] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-2 focus:outline-none focus:border-[#3b82f6]">
|
className="w-full bg-[#FFFFFF] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-2 focus:outline-none focus:border-[#1D4ED8]">
|
||||||
<option value="draft">Draft</option>
|
<option value="draft">Draft</option>
|
||||||
<option value="pending">Submit for Review</option>
|
<option value="pending">Submit for Review</option>
|
||||||
<option value="published">Publish Now</option>
|
<option value="published">Publish Now</option>
|
||||||
@@ -929,7 +944,7 @@ export default function AdminArticlesPage() {
|
|||||||
value={newFeaturedImage}
|
value={newFeaturedImage}
|
||||||
onChange={(e) => setNewFeaturedImage(e.target.value)}
|
onChange={(e) => setNewFeaturedImage(e.target.value)}
|
||||||
placeholder="https://..."
|
placeholder="https://..."
|
||||||
className="w-full bg-[#1a1a1a] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-2 focus:outline-none focus:border-[#3b82f6] placeholder-[#444]"
|
className="w-full bg-[#FFFFFF] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-2 focus:outline-none focus:border-[#1D4ED8] placeholder-[#444]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -939,21 +954,21 @@ export default function AdminArticlesPage() {
|
|||||||
value={newFeaturedImageAlt}
|
value={newFeaturedImageAlt}
|
||||||
onChange={(e) => setNewFeaturedImageAlt(e.target.value)}
|
onChange={(e) => setNewFeaturedImageAlt(e.target.value)}
|
||||||
placeholder="Describe the image..."
|
placeholder="Describe the image..."
|
||||||
className="w-full bg-[#1a1a1a] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-2 focus:outline-none focus:border-[#3b82f6] placeholder-[#444]"
|
className="w-full bg-[#FFFFFF] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-2 focus:outline-none focus:border-[#1D4ED8] placeholder-[#444]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Featured image preview */}
|
{/* Featured image preview */}
|
||||||
{newFeaturedImage && (
|
{newFeaturedImage && (
|
||||||
<div className="rounded-lg overflow-hidden border border-[#252525] max-h-48">
|
<div className="rounded-lg overflow-hidden border border-[#DCE6F2] max-h-48">
|
||||||
<img src={newFeaturedImage} alt={newFeaturedImageAlt || 'Featured image preview'} className="w-full h-48 object-cover" />
|
<img src={newFeaturedImage} alt={newFeaturedImageAlt || 'Featured image preview'} className="w-full h-48 object-cover" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modal Footer */}
|
{/* Modal Footer */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-t border-[#252525] bg-[#0d0d0d] rounded-b-xl">
|
<div className="flex items-center justify-between px-6 py-4 border-t border-[#DCE6F2] bg-[#F8FAFC] rounded-b-xl">
|
||||||
<p className="text-[11px] text-[#444]">
|
<p className="text-[11px] text-[#444]">
|
||||||
{newStatus === 'draft' ? 'Saved as draft — not publicly visible' : newStatus === 'pending' ? 'Submitted for editorial review' : 'Will be published immediately'}
|
{newStatus === 'draft' ? 'Saved as draft — not publicly visible' : newStatus === 'pending' ? 'Submitted for editorial review' : 'Will be published immediately'}
|
||||||
</p>
|
</p>
|
||||||
@@ -966,7 +981,7 @@ export default function AdminArticlesPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleNewArticleSave}
|
onClick={handleNewArticleSave}
|
||||||
disabled={newSaving || !newTitle.trim()}
|
disabled={newSaving || !newTitle.trim()}
|
||||||
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">
|
className="flex items-center gap-2 bg-[#1D4ED8] hover:bg-[#1E3A8A] disabled:bg-[#DCE6F2] disabled:cursor-not-allowed text-white text-sm font-bold px-5 py-2 rounded transition-colors">
|
||||||
{newSaving ? (
|
{newSaving ? (
|
||||||
<div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
<div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
@@ -983,8 +998,8 @@ export default function AdminArticlesPage() {
|
|||||||
{/* Edit Modal */}
|
{/* Edit Modal */}
|
||||||
{editingArticle && (
|
{editingArticle && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70">
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-xl w-full max-w-lg shadow-2xl">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-xl w-full max-w-lg shadow-2xl">
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-[#252525]">
|
<div className="flex items-center justify-between px-5 py-4 border-b border-[#DCE6F2]">
|
||||||
<h2 className="text-base font-bold text-white font-heading">Edit Article</h2>
|
<h2 className="text-base font-bold text-white font-heading">Edit Article</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingArticle(null)}
|
onClick={() => setEditingArticle(null)}
|
||||||
@@ -1002,7 +1017,7 @@ export default function AdminArticlesPage() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={editTitle}
|
value={editTitle}
|
||||||
onChange={(e) => setEditTitle(e.target.value)}
|
onChange={(e) => setEditTitle(e.target.value)}
|
||||||
className="w-full bg-[#1a1a1a] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-2 focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#FFFFFF] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-2 focus:outline-none focus:border-[#1D4ED8]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
@@ -1012,7 +1027,7 @@ export default function AdminArticlesPage() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={editAuthor}
|
value={editAuthor}
|
||||||
onChange={(e) => setEditAuthor(e.target.value)}
|
onChange={(e) => setEditAuthor(e.target.value)}
|
||||||
className="w-full bg-[#1a1a1a] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-2 focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#FFFFFF] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-2 focus:outline-none focus:border-[#1D4ED8]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -1021,7 +1036,7 @@ export default function AdminArticlesPage() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={editCategory}
|
value={editCategory}
|
||||||
onChange={(e) => setEditCategory(e.target.value)}
|
onChange={(e) => setEditCategory(e.target.value)}
|
||||||
className="w-full bg-[#1a1a1a] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-2 focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#FFFFFF] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-2 focus:outline-none focus:border-[#1D4ED8]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1030,7 +1045,7 @@ export default function AdminArticlesPage() {
|
|||||||
<select
|
<select
|
||||||
value={editStatus}
|
value={editStatus}
|
||||||
onChange={(e) => setEditStatus(e.target.value)}
|
onChange={(e) => setEditStatus(e.target.value)}
|
||||||
className="w-full bg-[#1a1a1a] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-2 focus:outline-none focus:border-[#3b82f6]">
|
className="w-full bg-[#FFFFFF] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-2 focus:outline-none focus:border-[#1D4ED8]">
|
||||||
<option value="draft">Draft</option>
|
<option value="draft">Draft</option>
|
||||||
<option value="pending">Pending Review</option>
|
<option value="pending">Pending Review</option>
|
||||||
<option value="published">Published</option>
|
<option value="published">Published</option>
|
||||||
@@ -1040,19 +1055,19 @@ export default function AdminArticlesPage() {
|
|||||||
|
|
||||||
{/* Scheduling — only shown for draft status */}
|
{/* Scheduling — only shown for draft status */}
|
||||||
{editStatus === 'draft' && (
|
{editStatus === 'draft' && (
|
||||||
<div className="bg-[#0d1a2e] border border-[#1e3a5f] rounded-lg p-4">
|
<div className="bg-[#0d1a2e] border border-[#DCE6F2] rounded-lg p-4">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-[#3b82f6]">
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-[#1D4ED8]">
|
||||||
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
|
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
|
||||||
</svg>
|
</svg>
|
||||||
<label className="text-xs text-[#3b82f6] font-bold uppercase tracking-wider">Schedule Publish</label>
|
<label className="text-xs text-[#1D4ED8] font-bold uppercase tracking-wider">Schedule Publish</label>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={editScheduledAt}
|
value={editScheduledAt}
|
||||||
onChange={(e) => setEditScheduledAt(e.target.value)}
|
onChange={(e) => setEditScheduledAt(e.target.value)}
|
||||||
min={new Date().toISOString().slice(0, 16)}
|
min={new Date().toISOString().slice(0, 16)}
|
||||||
className="w-full bg-[#111] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-2 focus:outline-none focus:border-[#3b82f6] [color-scheme:dark]"
|
className="w-full bg-[#111] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-2 focus:outline-none focus:border-[#1D4ED8] [color-scheme:dark]"
|
||||||
/>
|
/>
|
||||||
{editScheduledAt && (
|
{editScheduledAt && (
|
||||||
<div className="flex items-center justify-between mt-2">
|
<div className="flex items-center justify-between mt-2">
|
||||||
@@ -1074,14 +1089,14 @@ export default function AdminArticlesPage() {
|
|||||||
<div className="flex items-center gap-2 pt-1">
|
<div className="flex items-center gap-2 pt-1">
|
||||||
<span className="text-xs text-[#555]">Source:</span>
|
<span className="text-xs text-[#555]">Source:</span>
|
||||||
<span className={`text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded ${
|
<span className={`text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded ${
|
||||||
editingArticle.source === 'imported' ? 'text-purple-400 bg-purple-400/10' : 'text-[#3b82f6] bg-[#3b82f6]/10'
|
editingArticle.source === 'imported' ? 'text-purple-400 bg-purple-400/10' : 'text-[#1D4ED8] bg-[#1D4ED8]/10'
|
||||||
}`}>
|
}`}>
|
||||||
{editingArticle.source === 'imported' ? 'WordPress Import' : 'Native'}
|
{editingArticle.source === 'imported' ? 'WordPress Import' : 'Native'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-3 px-5 py-4 border-t border-[#252525]">
|
<div className="flex items-center justify-end gap-3 px-5 py-4 border-t border-[#DCE6F2]">
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingArticle(null)}
|
onClick={() => setEditingArticle(null)}
|
||||||
className="text-[#666] hover:text-[#999] text-sm transition-colors px-4 py-2">
|
className="text-[#666] hover:text-[#999] text-sm transition-colors px-4 py-2">
|
||||||
@@ -1090,7 +1105,7 @@ export default function AdminArticlesPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleEditSave}
|
onClick={handleEditSave}
|
||||||
disabled={editSaving || !editTitle.trim()}
|
disabled={editSaving || !editTitle.trim()}
|
||||||
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">
|
className="flex items-center gap-2 bg-[#1D4ED8] hover:bg-[#1E3A8A] disabled:bg-[#DCE6F2] disabled:cursor-not-allowed text-white text-sm font-bold px-5 py-2 rounded transition-colors">
|
||||||
{editSaving ? (
|
{editSaving ? (
|
||||||
<div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
<div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1102,5 +1117,6 @@ export default function AdminArticlesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</AdminShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,13 +50,13 @@ const ACTION_COLORS: Record<string, string> = {
|
|||||||
article_published: 'text-green-400 bg-green-400/10',
|
article_published: 'text-green-400 bg-green-400/10',
|
||||||
article_unpublished: 'text-yellow-400 bg-yellow-400/10',
|
article_unpublished: 'text-yellow-400 bg-yellow-400/10',
|
||||||
article_deleted: 'text-red-400 bg-red-400/10',
|
article_deleted: 'text-red-400 bg-red-400/10',
|
||||||
article_archived: 'text-[#888] bg-[#1a1a1a]',
|
article_archived: 'text-[#888] bg-[#FFFFFF]',
|
||||||
article_status_changed: 'text-purple-400 bg-purple-400/10',
|
article_status_changed: 'text-purple-400 bg-purple-400/10',
|
||||||
user_role_changed: 'text-orange-400 bg-orange-400/10',
|
user_role_changed: 'text-orange-400 bg-orange-400/10',
|
||||||
user_activated: 'text-green-400 bg-green-400/10',
|
user_activated: 'text-green-400 bg-green-400/10',
|
||||||
user_deactivated: 'text-red-400 bg-red-400/10',
|
user_deactivated: 'text-red-400 bg-red-400/10',
|
||||||
user_invited: 'text-blue-400 bg-blue-400/10',
|
user_invited: 'text-blue-400 bg-blue-400/10',
|
||||||
user_invitation_revoked: 'text-[#888] bg-[#1a1a1a]',
|
user_invitation_revoked: 'text-[#888] bg-[#FFFFFF]',
|
||||||
import_completed: 'text-teal-400 bg-teal-400/10',
|
import_completed: 'text-teal-400 bg-teal-400/10',
|
||||||
import_failed: 'text-red-400 bg-red-400/10',
|
import_failed: 'text-red-400 bg-red-400/10',
|
||||||
};
|
};
|
||||||
@@ -150,7 +150,7 @@ interface StatCardProps {
|
|||||||
|
|
||||||
function StatCard({ label, value, accent, icon }: StatCardProps) {
|
function StatCard({ label, value, accent, icon }: StatCardProps) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-4 flex items-center gap-4">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg p-4 flex items-center gap-4">
|
||||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${accent}`}>
|
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${accent}`}>
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
@@ -170,16 +170,16 @@ interface DetailModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DetailModal({ log, onClose }: DetailModalProps) {
|
function DetailModal({ log, onClose }: DetailModalProps) {
|
||||||
const actionColor = ACTION_COLORS[log.action] || 'text-[#888] bg-[#1a1a1a]';
|
const actionColor = ACTION_COLORS[log.action] || 'text-[#888] bg-[#FFFFFF]';
|
||||||
const actionLabel = ACTION_LABELS[log.action] || log.action;
|
const actionLabel = ACTION_LABELS[log.action] || log.action;
|
||||||
const performerName = log.user_profiles?.full_name || log.performed_by_email || 'System';
|
const performerName = log.user_profiles?.full_name || log.performed_by_email || 'System';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
<div className="absolute inset-0 bg-black/70" onClick={onClose} />
|
<div className="absolute inset-0 bg-black/70" onClick={onClose} />
|
||||||
<div className="relative bg-[#111] border border-[#252525] rounded-xl w-full max-w-lg shadow-2xl">
|
<div className="relative bg-[#111] border border-[#DCE6F2] rounded-xl w-full max-w-lg shadow-2xl">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-5 border-b border-[#252525]">
|
<div className="flex items-center justify-between p-5 border-b border-[#DCE6F2]">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${actionColor}`}>
|
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${actionColor}`}>
|
||||||
{ACTION_ICONS[log.action] || getDefaultIcon(log.action)}
|
{ACTION_ICONS[log.action] || getDefaultIcon(log.action)}
|
||||||
@@ -247,7 +247,7 @@ function DetailModal({ log, onClose }: DetailModalProps) {
|
|||||||
{log.metadata && Object.keys(log.metadata).length > 0 && (
|
{log.metadata && Object.keys(log.metadata).length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[#555] text-xs font-body uppercase tracking-wider mb-1">Metadata</p>
|
<p className="text-[#555] text-xs font-body uppercase tracking-wider mb-1">Metadata</p>
|
||||||
<div className="bg-[#0d0d0d] border border-[#1e1e1e] rounded-lg p-3">
|
<div className="bg-[#F8FAFC] border border-[#1e1e1e] rounded-lg p-3">
|
||||||
<pre className="text-[#aaa] text-xs font-mono whitespace-pre-wrap break-all">
|
<pre className="text-[#aaa] text-xs font-mono whitespace-pre-wrap break-all">
|
||||||
{JSON.stringify(log.metadata, null, 2)}
|
{JSON.stringify(log.metadata, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
@@ -340,7 +340,7 @@ export default function AuditLogPage() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
<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 className="w-6 h-6 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -368,7 +368,7 @@ export default function AuditLogPage() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={fetchLogs}
|
onClick={fetchLogs}
|
||||||
className="flex items-center gap-2 px-3 py-2 bg-[#111] border border-[#252525] rounded-lg text-[#888] hover:text-white hover:border-[#333] transition-all text-sm font-body"
|
className="flex items-center gap-2 px-3 py-2 bg-[#111] border border-[#DCE6F2] rounded-lg text-[#888] hover:text-white hover:border-[#333] transition-all text-sm font-body"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
@@ -382,7 +382,7 @@ export default function AuditLogPage() {
|
|||||||
<StatCard
|
<StatCard
|
||||||
label="Total Events"
|
label="Total Events"
|
||||||
value={logs.length}
|
value={logs.length}
|
||||||
accent="bg-[#3b82f6]/10 text-[#3b82f6]"
|
accent="bg-[#1D4ED8]/10 text-[#1D4ED8]"
|
||||||
icon={
|
icon={
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||||
@@ -433,19 +433,19 @@ export default function AuditLogPage() {
|
|||||||
placeholder="Search by user, item, or action…"
|
placeholder="Search by user, item, or action…"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="w-full bg-[#111] border border-[#252525] rounded-lg pl-9 pr-4 py-2.5 text-sm text-white placeholder-[#444] focus:outline-none focus:border-[#3b82f6] font-body"
|
className="w-full bg-[#111] border border-[#DCE6F2] rounded-lg pl-9 pr-4 py-2.5 text-sm text-white placeholder-[#444] focus:outline-none focus:border-[#1D4ED8] font-body"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Type Filter */}
|
{/* Action Type Filter */}
|
||||||
<div className="flex items-center gap-1 bg-[#111] border border-[#252525] rounded-lg p-1">
|
<div className="flex items-center gap-1 bg-[#111] border border-[#DCE6F2] rounded-lg p-1">
|
||||||
{(['all', 'article', 'user', 'import'] as ActionFilter[]).map((f) => (
|
{(['all', 'article', 'user', 'import'] as ActionFilter[]).map((f) => (
|
||||||
<button
|
<button
|
||||||
key={f}
|
key={f}
|
||||||
onClick={() => setActionFilter(f)}
|
onClick={() => setActionFilter(f)}
|
||||||
className={`px-3 py-1.5 rounded-md text-xs font-body font-semibold capitalize transition-all ${
|
className={`px-3 py-1.5 rounded-md text-xs font-body font-semibold capitalize transition-all ${
|
||||||
actionFilter === f
|
actionFilter === f
|
||||||
? 'bg-[#3b82f6] text-white'
|
? 'bg-[#1D4ED8] text-white'
|
||||||
: 'text-[#555] hover:text-[#888]'
|
: 'text-[#555] hover:text-[#888]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -466,9 +466,9 @@ export default function AuditLogPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-xl overflow-hidden">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-xl overflow-hidden">
|
||||||
{/* Table Header */}
|
{/* Table Header */}
|
||||||
<div className="hidden md:grid grid-cols-[2fr_1.5fr_1.5fr_1fr_auto] gap-4 px-5 py-3 border-b border-[#1e1e1e] bg-[#0d0d0d]">
|
<div className="hidden md:grid grid-cols-[2fr_1.5fr_1.5fr_1fr_auto] gap-4 px-5 py-3 border-b border-[#1e1e1e] bg-[#F8FAFC]">
|
||||||
<p className="text-[#444] text-xs font-body font-semibold uppercase tracking-wider">Action</p>
|
<p className="text-[#444] text-xs font-body font-semibold uppercase tracking-wider">Action</p>
|
||||||
<p className="text-[#444] text-xs font-body font-semibold uppercase tracking-wider">Performed By</p>
|
<p className="text-[#444] text-xs font-body font-semibold uppercase tracking-wider">Performed By</p>
|
||||||
<p className="text-[#444] text-xs font-body font-semibold uppercase tracking-wider">Affected Item</p>
|
<p className="text-[#444] text-xs font-body font-semibold uppercase tracking-wider">Affected Item</p>
|
||||||
@@ -480,7 +480,7 @@ export default function AuditLogPage() {
|
|||||||
{loadingData && (
|
{loadingData && (
|
||||||
<div className="flex items-center justify-center py-16">
|
<div className="flex items-center justify-center py-16">
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<div className="w-6 h-6 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
<div className="w-6 h-6 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
<p className="text-[#555] text-sm font-body">Loading audit logs…</p>
|
<p className="text-[#555] text-sm font-body">Loading audit logs…</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -489,14 +489,14 @@ export default function AuditLogPage() {
|
|||||||
{/* Empty */}
|
{/* Empty */}
|
||||||
{!loadingData && filteredLogs.length === 0 && (
|
{!loadingData && filteredLogs.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
||||||
<div className="w-12 h-12 rounded-full bg-[#1a1a1a] flex items-center justify-center">
|
<div className="w-12 h-12 rounded-full bg-[#FFFFFF] flex items-center justify-center">
|
||||||
<svg className="w-6 h-6 text-[#333]" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
<svg className="w-6 h-6 text-[#333]" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[#555] text-sm font-body">No audit log entries found</p>
|
<p className="text-[#555] text-sm font-body">No audit log entries found</p>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button onClick={() => setSearchQuery('')} className="text-[#3b82f6] text-xs font-body hover:underline">
|
<button onClick={() => setSearchQuery('')} className="text-[#1D4ED8] text-xs font-body hover:underline">
|
||||||
Clear search
|
Clear search
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -505,7 +505,7 @@ export default function AuditLogPage() {
|
|||||||
|
|
||||||
{/* Rows */}
|
{/* Rows */}
|
||||||
{!loadingData && filteredLogs.map((log, idx) => {
|
{!loadingData && filteredLogs.map((log, idx) => {
|
||||||
const actionColor = ACTION_COLORS[log.action] || 'text-[#888] bg-[#1a1a1a]';
|
const actionColor = ACTION_COLORS[log.action] || 'text-[#888] bg-[#FFFFFF]';
|
||||||
const actionLabel = ACTION_LABELS[log.action] || log.action;
|
const actionLabel = ACTION_LABELS[log.action] || log.action;
|
||||||
const icon = ACTION_ICONS[log.action] || getDefaultIcon(log.action);
|
const icon = ACTION_ICONS[log.action] || getDefaultIcon(log.action);
|
||||||
const performerName = log.user_profiles?.full_name || log.performed_by_email || 'System';
|
const performerName = log.user_profiles?.full_name || log.performed_by_email || 'System';
|
||||||
@@ -513,8 +513,8 @@ export default function AuditLogPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={log.id}
|
key={log.id}
|
||||||
className={`grid grid-cols-1 md:grid-cols-[2fr_1.5fr_1.5fr_1fr_auto] gap-3 md:gap-4 px-5 py-4 hover:bg-[#0d0d0d] transition-colors cursor-pointer ${
|
className={`grid grid-cols-1 md:grid-cols-[2fr_1.5fr_1.5fr_1fr_auto] gap-3 md:gap-4 px-5 py-4 hover:bg-[#F8FAFC] transition-colors cursor-pointer ${
|
||||||
idx !== filteredLogs.length - 1 ? 'border-b border-[#1a1a1a]' : ''
|
idx !== filteredLogs.length - 1 ? 'border-b border-[#FFFFFF]' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setSelectedLog(log)}
|
onClick={() => setSelectedLog(log)}
|
||||||
>
|
>
|
||||||
@@ -530,7 +530,7 @@ export default function AuditLogPage() {
|
|||||||
|
|
||||||
{/* Performed By */}
|
{/* Performed By */}
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<div className="w-6 h-6 rounded-full bg-[#1a1a1a] border border-[#252525] flex items-center justify-center flex-shrink-0">
|
<div className="w-6 h-6 rounded-full bg-[#FFFFFF] border border-[#DCE6F2] flex items-center justify-center flex-shrink-0">
|
||||||
<span className="text-[#888] text-xs font-body font-bold">
|
<span className="text-[#888] text-xs font-body font-bold">
|
||||||
{performerName.charAt(0).toUpperCase()}
|
{performerName.charAt(0).toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
@@ -569,7 +569,7 @@ export default function AuditLogPage() {
|
|||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setSelectedLog(log); }}
|
onClick={(e) => { e.stopPropagation(); setSelectedLog(log); }}
|
||||||
className="text-[#444] hover:text-[#3b82f6] transition-colors p-1"
|
className="text-[#444] hover:text-[#1D4ED8] transition-colors p-1"
|
||||||
aria-label="View details"
|
aria-label="View details"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
@@ -593,7 +593,7 @@ export default function AuditLogPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
disabled={page === 0}
|
disabled={page === 0}
|
||||||
className="px-3 py-1.5 bg-[#111] border border-[#252525] rounded-lg text-xs font-body text-[#888] hover:text-white hover:border-[#333] disabled:opacity-30 disabled:cursor-not-allowed transition-all"
|
className="px-3 py-1.5 bg-[#111] border border-[#DCE6F2] rounded-lg text-xs font-body text-[#888] hover:text-white hover:border-[#333] disabled:opacity-30 disabled:cursor-not-allowed transition-all"
|
||||||
>
|
>
|
||||||
Previous
|
Previous
|
||||||
</button>
|
</button>
|
||||||
@@ -601,7 +601,7 @@ export default function AuditLogPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setPage((p) => p + 1)}
|
onClick={() => setPage((p) => p + 1)}
|
||||||
disabled={filteredLogs.length < PAGE_SIZE}
|
disabled={filteredLogs.length < PAGE_SIZE}
|
||||||
className="px-3 py-1.5 bg-[#111] border border-[#252525] rounded-lg text-xs font-body text-[#888] hover:text-white hover:border-[#333] disabled:opacity-30 disabled:cursor-not-allowed transition-all"
|
className="px-3 py-1.5 bg-[#111] border border-[#DCE6F2] rounded-lg text-xs font-body text-[#888] hover:text-white hover:border-[#333] disabled:opacity-30 disabled:cursor-not-allowed transition-all"
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export default function BannedTermsPage() {
|
|||||||
if (loading || loadingData) {
|
if (loading || loadingData) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
||||||
<div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
<div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -190,17 +190,17 @@ export default function BannedTermsPage() {
|
|||||||
<p className="text-[#555] text-sm font-body mt-1">Manage restricted company names and keywords — visible to administrators only</p>
|
<p className="text-[#555] text-sm font-body mt-1">Manage restricted company names and keywords — visible to administrators only</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button onClick={exportCSV} className="flex items-center gap-2 px-3 py-2 bg-[#1a1a1a] border border-[#333] text-[#888] hover:text-white text-sm font-body rounded-lg transition-colors">
|
<button onClick={exportCSV} className="flex items-center gap-2 px-3 py-2 bg-[#FFFFFF] border border-[#333] text-[#888] hover:text-white text-sm font-body rounded-lg transition-colors">
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
|
||||||
Export CSV
|
Export CSV
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleRetroactiveScan} disabled={scanning}
|
<button onClick={handleRetroactiveScan} disabled={scanning}
|
||||||
className="flex items-center gap-2 px-3 py-2 bg-[#1a1a1a] border border-[#333] text-[#888] hover:text-white text-sm font-body rounded-lg transition-colors disabled:opacity-50">
|
className="flex items-center gap-2 px-3 py-2 bg-[#FFFFFF] border border-[#333] text-[#888] hover:text-white text-sm font-body rounded-lg transition-colors disabled:opacity-50">
|
||||||
{scanning ? <div className="w-4 h-4 border-2 border-[#888]/30 border-t-[#888] rounded-full animate-spin" /> : <svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>}
|
{scanning ? <div className="w-4 h-4 border-2 border-[#888]/30 border-t-[#888] rounded-full animate-spin" /> : <svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>}
|
||||||
Scan Published Posts
|
Scan Published Posts
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => { setForm(EMPTY_FORM); setEditingId(null); setShowAddForm(true); }}
|
<button onClick={() => { setForm(EMPTY_FORM); setEditingId(null); setShowAddForm(true); }}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-[#3b82f6] hover:bg-blue-500 text-white text-sm font-body font-semibold rounded-lg transition-colors">
|
className="flex items-center gap-2 px-4 py-2 bg-[#1D4ED8] hover:bg-blue-500 text-white text-sm font-body font-semibold rounded-lg transition-colors">
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" /></svg>
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" /></svg>
|
||||||
Add Term
|
Add Term
|
||||||
</button>
|
</button>
|
||||||
@@ -209,7 +209,7 @@ export default function BannedTermsPage() {
|
|||||||
|
|
||||||
{/* Retroactive scan results */}
|
{/* Retroactive scan results */}
|
||||||
{scanResults !== null && (
|
{scanResults !== null && (
|
||||||
<div className="mb-6 bg-[#111] border border-[#252525] rounded-lg p-5">
|
<div className="mb-6 bg-[#111] border border-[#DCE6F2] rounded-lg p-5">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-sm font-display font-bold text-white">Retroactive Scan Results — {scanResults.length} match{scanResults.length !== 1 ? 'es' : ''} found</h3>
|
<h3 className="text-sm font-display font-bold text-white">Retroactive Scan Results — {scanResults.length} match{scanResults.length !== 1 ? 'es' : ''} found</h3>
|
||||||
<button onClick={() => setScanResults(null)} className="text-[#555] hover:text-white text-xs font-body">Dismiss</button>
|
<button onClick={() => setScanResults(null)} className="text-[#555] hover:text-white text-xs font-body">Dismiss</button>
|
||||||
@@ -234,18 +234,18 @@ export default function BannedTermsPage() {
|
|||||||
|
|
||||||
{/* Add/Edit Form */}
|
{/* Add/Edit Form */}
|
||||||
{showAddForm && (
|
{showAddForm && (
|
||||||
<div className="mb-6 bg-[#111] border border-[#252525] rounded-lg p-6">
|
<div className="mb-6 bg-[#111] border border-[#DCE6F2] rounded-lg p-6">
|
||||||
<h3 className="text-sm font-display font-bold text-white mb-4">{editingId ? 'Edit Term' : 'Add Banned Term'}</h3>
|
<h3 className="text-sm font-display font-bold text-white mb-4">{editingId ? 'Edit Term' : 'Add Banned Term'}</h3>
|
||||||
<form onSubmit={handleSave} className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<form onSubmit={handleSave} className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label className="block text-xs font-body text-[#888] mb-1.5">Term / Company Name *</label>
|
<label className="block text-xs font-body text-[#888] mb-1.5">Term / Company Name *</label>
|
||||||
<input type="text" value={form.term} onChange={e => setForm(f => ({ ...f, term: e.target.value }))} placeholder="e.g. Sony, Acme Corp"
|
<input type="text" value={form.term} onChange={e => setForm(f => ({ ...f, term: e.target.value }))} placeholder="e.g. Sony, Acme Corp"
|
||||||
className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-white text-sm font-body focus:outline-none focus:border-[#3b82f6]" required />
|
className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-white text-sm font-body focus:outline-none focus:border-[#1D4ED8]" required />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-body text-[#888] mb-1.5">Ban Level</label>
|
<label className="block text-xs font-body text-[#888] mb-1.5">Ban Level</label>
|
||||||
<select value={form.ban_level} onChange={e => setForm(f => ({ ...f, ban_level: e.target.value as 'soft' | 'hard' }))}
|
<select value={form.ban_level} onChange={e => setForm(f => ({ ...f, ban_level: e.target.value as 'soft' | 'hard' }))}
|
||||||
className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-white text-sm font-body focus:outline-none focus:border-[#3b82f6]">
|
className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-white text-sm font-body focus:outline-none focus:border-[#1D4ED8]">
|
||||||
<option value="soft">Soft Ban — Flag for review (contributor doesn't know)</option>
|
<option value="soft">Soft Ban — Flag for review (contributor doesn't know)</option>
|
||||||
<option value="hard">Hard Ban — Block submission entirely</option>
|
<option value="hard">Hard Ban — Block submission entirely</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -253,7 +253,7 @@ export default function BannedTermsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-body text-[#888] mb-1.5">Site Scope</label>
|
<label className="block text-xs font-body text-[#888] mb-1.5">Site Scope</label>
|
||||||
<select value={form.site_scope} onChange={e => setForm(f => ({ ...f, site_scope: e.target.value as any }))}
|
<select value={form.site_scope} onChange={e => setForm(f => ({ ...f, site_scope: e.target.value as any }))}
|
||||||
className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-white text-sm font-body focus:outline-none focus:border-[#3b82f6]">
|
className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-white text-sm font-body focus:outline-none focus:border-[#1D4ED8]">
|
||||||
<option value="both">Both Sites</option>
|
<option value="both">Both Sites</option>
|
||||||
<option value="avbeat">AV Beat only</option>
|
<option value="avbeat">AV Beat only</option>
|
||||||
<option value="avbeat">AV Beat only</option>
|
<option value="avbeat">AV Beat only</option>
|
||||||
@@ -262,15 +262,15 @@ export default function BannedTermsPage() {
|
|||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label className="block text-xs font-body text-[#888] mb-1.5">Internal Notes (not shown to contributors)</label>
|
<label className="block text-xs font-body text-[#888] mb-1.5">Internal Notes (not shown to contributors)</label>
|
||||||
<textarea value={form.notes} onChange={e => setForm(f => ({ ...f, notes: e.target.value }))} rows={2} placeholder="Reason for ban, context..."
|
<textarea value={form.notes} onChange={e => setForm(f => ({ ...f, notes: e.target.value }))} rows={2} placeholder="Reason for ban, context..."
|
||||||
className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-white text-sm font-body focus:outline-none focus:border-[#3b82f6] resize-none" />
|
className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-white text-sm font-body focus:outline-none focus:border-[#1D4ED8] resize-none" />
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-2 flex gap-3">
|
<div className="md:col-span-2 flex gap-3">
|
||||||
<button type="submit" disabled={saving}
|
<button type="submit" disabled={saving}
|
||||||
className="px-5 py-2 bg-[#3b82f6] hover:bg-blue-500 text-white text-sm font-body font-semibold rounded-lg transition-colors disabled:opacity-50">
|
className="px-5 py-2 bg-[#1D4ED8] hover:bg-blue-500 text-white text-sm font-body font-semibold rounded-lg transition-colors disabled:opacity-50">
|
||||||
{saving ? 'Saving...' : editingId ? 'Update Term' : 'Add Term'}
|
{saving ? 'Saving...' : editingId ? 'Update Term' : 'Add Term'}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => { setShowAddForm(false); setEditingId(null); setForm(EMPTY_FORM); }}
|
<button type="button" onClick={() => { setShowAddForm(false); setEditingId(null); setForm(EMPTY_FORM); }}
|
||||||
className="px-4 py-2 bg-[#1a1a1a] border border-[#333] text-[#888] hover:text-white text-sm font-body rounded-lg transition-colors">
|
className="px-4 py-2 bg-[#FFFFFF] border border-[#333] text-[#888] hover:text-white text-sm font-body rounded-lg transition-colors">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -281,22 +281,22 @@ export default function BannedTermsPage() {
|
|||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="mb-4 flex flex-wrap gap-3">
|
<div className="mb-4 flex flex-wrap gap-3">
|
||||||
<input type="text" value={searchTerm} onChange={e => setSearchTerm(e.target.value)} placeholder="Search terms..."
|
<input type="text" value={searchTerm} onChange={e => setSearchTerm(e.target.value)} placeholder="Search terms..."
|
||||||
className="bg-[#111] border border-[#252525] rounded-lg px-3 py-2 text-white text-sm font-body focus:outline-none focus:border-[#3b82f6] w-48" />
|
className="bg-[#111] border border-[#DCE6F2] rounded-lg px-3 py-2 text-white text-sm font-body focus:outline-none focus:border-[#1D4ED8] w-48" />
|
||||||
<select value={filterBanLevel} onChange={e => setFilterBanLevel(e.target.value)}
|
<select value={filterBanLevel} onChange={e => setFilterBanLevel(e.target.value)}
|
||||||
className="bg-[#111] border border-[#252525] rounded-lg px-3 py-2 text-[#888] text-sm font-body focus:outline-none focus:border-[#3b82f6]">
|
className="bg-[#111] border border-[#DCE6F2] rounded-lg px-3 py-2 text-[#888] text-sm font-body focus:outline-none focus:border-[#1D4ED8]">
|
||||||
<option value="">All Ban Levels</option>
|
<option value="">All Ban Levels</option>
|
||||||
<option value="soft">Soft Ban</option>
|
<option value="soft">Soft Ban</option>
|
||||||
<option value="hard">Hard Ban</option>
|
<option value="hard">Hard Ban</option>
|
||||||
</select>
|
</select>
|
||||||
<select value={filterSiteScope} onChange={e => setFilterSiteScope(e.target.value)}
|
<select value={filterSiteScope} onChange={e => setFilterSiteScope(e.target.value)}
|
||||||
className="bg-[#111] border border-[#252525] rounded-lg px-3 py-2 text-[#888] text-sm font-body focus:outline-none focus:border-[#3b82f6]">
|
className="bg-[#111] border border-[#DCE6F2] rounded-lg px-3 py-2 text-[#888] text-sm font-body focus:outline-none focus:border-[#1D4ED8]">
|
||||||
<option value="">All Sites</option>
|
<option value="">All Sites</option>
|
||||||
<option value="avbeat">AV Beat</option>
|
<option value="avbeat">AV Beat</option>
|
||||||
<option value="avbeat">AV Beat</option>
|
<option value="avbeat">AV Beat</option>
|
||||||
<option value="both">Both</option>
|
<option value="both">Both</option>
|
||||||
</select>
|
</select>
|
||||||
<select value={filterActive} onChange={e => setFilterActive(e.target.value)}
|
<select value={filterActive} onChange={e => setFilterActive(e.target.value)}
|
||||||
className="bg-[#111] border border-[#252525] rounded-lg px-3 py-2 text-[#888] text-sm font-body focus:outline-none focus:border-[#3b82f6]">
|
className="bg-[#111] border border-[#DCE6F2] rounded-lg px-3 py-2 text-[#888] text-sm font-body focus:outline-none focus:border-[#1D4ED8]">
|
||||||
<option value="">All Status</option>
|
<option value="">All Status</option>
|
||||||
<option value="true">Active</option>
|
<option value="true">Active</option>
|
||||||
<option value="false">Inactive</option>
|
<option value="false">Inactive</option>
|
||||||
@@ -304,8 +304,8 @@ export default function BannedTermsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Terms Table */}
|
{/* Terms Table */}
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-hidden">
|
||||||
<div className="px-5 py-4 border-b border-[#1a1a1a] flex items-center justify-between">
|
<div className="px-5 py-4 border-b border-[#FFFFFF] flex items-center justify-between">
|
||||||
<h2 className="text-sm font-display font-bold text-white">Banned Terms ({terms.length})</h2>
|
<h2 className="text-sm font-display font-bold text-white">Banned Terms ({terms.length})</h2>
|
||||||
</div>
|
</div>
|
||||||
{terms.length === 0 ? (
|
{terms.length === 0 ? (
|
||||||
@@ -316,15 +316,15 @@ export default function BannedTermsPage() {
|
|||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[#1a1a1a]">
|
<tr className="border-b border-[#FFFFFF]">
|
||||||
{['Term', 'Ban Level', 'Site Scope', 'Status', 'Date Added', 'Added By', 'Notes', 'Actions'].map(h => (
|
{['Term', 'Ban Level', 'Site Scope', 'Status', 'Date Added', 'Added By', 'Notes', 'Actions'].map(h => (
|
||||||
<th key={h} className="px-4 py-3 text-left text-xs font-body font-bold text-[#555] uppercase tracking-wider">{h}</th>
|
<th key={h} className="px-4 py-3 text-left text-xs font-body font-bold text-[#555] uppercase tracking-wider">{h}</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[#1a1a1a]">
|
<tbody className="divide-y divide-[#FFFFFF]">
|
||||||
{terms.map(term => (
|
{terms.map(term => (
|
||||||
<tr key={term.id} className={`hover:bg-[#0d0d0d] transition-colors ${!term.is_active ? 'opacity-50' : ''}`}>
|
<tr key={term.id} className={`hover:bg-[#F8FAFC] transition-colors ${!term.is_active ? 'opacity-50' : ''}`}>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className="text-sm font-body font-semibold text-white">{term.term}</span>
|
<span className="text-sm font-body font-semibold text-white">{term.term}</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -339,7 +339,7 @@ export default function BannedTermsPage() {
|
|||||||
<span className="text-xs text-[#888] font-body capitalize">{term.site_scope === 'both' ? 'Both Sites' : term.site_scope === 'avbeat' ? 'AV Beat' : 'AV Beat'}</span>
|
<span className="text-xs text-[#888] font-body capitalize">{term.site_scope === 'both' ? 'Both Sites' : term.site_scope === 'avbeat' ? 'AV Beat' : 'AV Beat'}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className={`text-xs px-2 py-0.5 rounded-full font-body ${term.is_active ? 'bg-green-500/10 text-green-400' : 'bg-[#1a1a1a] text-[#555]'}`}>
|
<span className={`text-xs px-2 py-0.5 rounded-full font-body ${term.is_active ? 'bg-green-500/10 text-green-400' : 'bg-[#FFFFFF] text-[#555]'}`}>
|
||||||
{term.is_active ? 'Active' : 'Inactive'}
|
{term.is_active ? 'Active' : 'Inactive'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -348,7 +348,7 @@ export default function BannedTermsPage() {
|
|||||||
<td className="px-4 py-3 text-xs text-[#555] font-body max-w-xs truncate">{term.notes || '—'}</td>
|
<td className="px-4 py-3 text-xs text-[#555] font-body max-w-xs truncate">{term.notes || '—'}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button onClick={() => handleEdit(term)} className="text-xs text-[#3b82f6] hover:text-blue-300 font-body transition-colors">Edit</button>
|
<button onClick={() => handleEdit(term)} className="text-xs text-[#1D4ED8] hover:text-blue-300 font-body transition-colors">Edit</button>
|
||||||
<button onClick={() => handleDeactivate(term.id, term.is_active)} disabled={actionLoading === term.id}
|
<button onClick={() => handleDeactivate(term.id, term.is_active)} disabled={actionLoading === term.id}
|
||||||
className="text-xs text-[#888] hover:text-white font-body transition-colors disabled:opacity-50">
|
className="text-xs text-[#888] hover:text-white font-body transition-colors disabled:opacity-50">
|
||||||
{term.is_active ? 'Deactivate' : 'Reactivate'}
|
{term.is_active ? 'Deactivate' : 'Reactivate'}
|
||||||
@@ -366,7 +366,7 @@ export default function BannedTermsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
<div className="mt-6 bg-[#111] border border-[#252525] rounded-lg p-5">
|
<div className="mt-6 bg-[#111] border border-[#DCE6F2] rounded-lg p-5">
|
||||||
<h3 className="text-xs font-body font-bold text-[#555] uppercase tracking-widest mb-3">Ban Level Reference</h3>
|
<h3 className="text-xs font-body font-bold text-[#555] uppercase tracking-widest mb-3">Ban Level Reference</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ function StatBlock({ label, b }: { label: string; b: StatBucket }) {
|
|||||||
<span className="font-semibold">{fmtNum(b.clicks)}</span>
|
<span className="font-semibold">{fmtNum(b.clicks)}</span>
|
||||||
<span className="text-[#6b7280]"> clk</span>
|
<span className="text-[#6b7280]"> clk</span>
|
||||||
<span className="mx-1 text-[#374151]">·</span>
|
<span className="mx-1 text-[#374151]">·</span>
|
||||||
<span className="text-[#60a5fa]">{pct(b.clicks, b.impressions)}</span>
|
<span className="text-[#1D4ED8]">{pct(b.clicks, b.impressions)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -140,7 +140,7 @@ export default function BannerRows({
|
|||||||
<input
|
<input
|
||||||
value={current.name}
|
value={current.name}
|
||||||
onChange={(e) => field(b.id, "name", e.target.value)}
|
onChange={(e) => field(b.id, "name", e.target.value)}
|
||||||
className="rounded border border-[#1f2937] bg-[#070a10] p-2 text-[#e5e7eb] focus:border-[#3b82f6] focus:outline-none"
|
className="rounded border border-[#1f2937] bg-[#070a10] p-2 text-[#e5e7eb] focus:border-[#1D4ED8] focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@ export default function BannerRows({
|
|||||||
value={current.click_url || ""}
|
value={current.click_url || ""}
|
||||||
onChange={(e) => field(b.id, "click_url", e.target.value)}
|
onChange={(e) => field(b.id, "click_url", e.target.value)}
|
||||||
placeholder="https://…"
|
placeholder="https://…"
|
||||||
className="rounded border border-[#1f2937] bg-[#070a10] p-2 text-[#e5e7eb] focus:border-[#3b82f6] focus:outline-none"
|
className="rounded border border-[#1f2937] bg-[#070a10] p-2 text-[#e5e7eb] focus:border-[#1D4ED8] focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@ export default function BannerRows({
|
|||||||
type="date"
|
type="date"
|
||||||
value={fmtDate(current.start_date)}
|
value={fmtDate(current.start_date)}
|
||||||
onChange={(e) => field(b.id, "start_date", e.target.value ? new Date(e.target.value).toISOString() : null)}
|
onChange={(e) => field(b.id, "start_date", e.target.value ? new Date(e.target.value).toISOString() : null)}
|
||||||
className="rounded border border-[#1f2937] bg-[#070a10] p-2 text-[#e5e7eb] focus:border-[#3b82f6] focus:outline-none"
|
className="rounded border border-[#1f2937] bg-[#070a10] p-2 text-[#e5e7eb] focus:border-[#1D4ED8] focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -170,7 +170,7 @@ export default function BannerRows({
|
|||||||
type="date"
|
type="date"
|
||||||
value={fmtDate(current.end_date)}
|
value={fmtDate(current.end_date)}
|
||||||
onChange={(e) => field(b.id, "end_date", e.target.value ? new Date(e.target.value).toISOString() : null)}
|
onChange={(e) => field(b.id, "end_date", e.target.value ? new Date(e.target.value).toISOString() : null)}
|
||||||
className="rounded border border-[#1f2937] bg-[#070a10] p-2 text-[#e5e7eb] focus:border-[#3b82f6] focus:outline-none"
|
className="rounded border border-[#1f2937] bg-[#070a10] p-2 text-[#e5e7eb] focus:border-[#1D4ED8] focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -179,7 +179,7 @@ export default function BannerRows({
|
|||||||
<select
|
<select
|
||||||
value={current.status}
|
value={current.status}
|
||||||
onChange={(e) => field(b.id, "status", e.target.value)}
|
onChange={(e) => field(b.id, "status", e.target.value)}
|
||||||
className="rounded border border-[#1f2937] bg-[#070a10] p-2 text-[#e5e7eb] focus:border-[#3b82f6] focus:outline-none"
|
className="rounded border border-[#1f2937] bg-[#070a10] p-2 text-[#e5e7eb] focus:border-[#1D4ED8] focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="active">active</option>
|
<option value="active">active</option>
|
||||||
<option value="scheduled">scheduled</option>
|
<option value="scheduled">scheduled</option>
|
||||||
@@ -210,7 +210,7 @@ export default function BannerRows({
|
|||||||
<button
|
<button
|
||||||
onClick={() => save(b.id)}
|
onClick={() => save(b.id)}
|
||||||
disabled={!dirty || pending}
|
disabled={!dirty || pending}
|
||||||
className="rounded bg-[#1d4ed8] px-3 py-1.5 text-sm font-medium text-white hover:bg-[#2563eb] disabled:opacity-40 disabled:cursor-not-allowed"
|
className="rounded bg-[#1d4ed8] px-3 py-1.5 text-sm font-medium text-white hover:bg-[#1E3A8A] disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
Save changes
|
Save changes
|
||||||
</button>
|
</button>
|
||||||
@@ -219,7 +219,7 @@ export default function BannerRows({
|
|||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/banners/${b.id}/analytics`}
|
href={`/admin/banners/${b.id}/analytics`}
|
||||||
className="text-sm text-[#60a5fa] hover:underline"
|
className="text-sm text-[#1D4ED8] hover:underline"
|
||||||
>
|
>
|
||||||
View detailed analytics →
|
View detailed analytics →
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export default function AnalyticsCharts({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||||
<ChartCard title="Impressions per day" data={imp} stroke="#60a5fa" />
|
<ChartCard title="Impressions per day" data={imp} stroke="#1D4ED8" />
|
||||||
<ChartCard title="Clicks per day" data={clk} stroke="#34d399" />
|
<ChartCard title="Clicks per day" data={clk} stroke="#34d399" />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ function TopListCard({ title, rows }: { title: string; rows: TopRow[] }) {
|
|||||||
{rows.map((r) => (
|
{rows.map((r) => (
|
||||||
<tr key={r.label} className="border-b border-[#1f2937] last:border-0">
|
<tr key={r.label} className="border-b border-[#1f2937] last:border-0">
|
||||||
<td className="py-2 pr-3 text-[#e5e7eb] truncate max-w-[400px]" title={r.label}>{r.label}</td>
|
<td className="py-2 pr-3 text-[#e5e7eb] truncate max-w-[400px]" title={r.label}>{r.label}</td>
|
||||||
<td className="py-2 text-right text-[#60a5fa]">{r.count.toLocaleString()}</td>
|
<td className="py-2 text-right text-[#1D4ED8]">{r.count.toLocaleString()}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export default async function BannerAnalyticsPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-6xl px-6 py-10 text-[#e5e7eb]">
|
<main className="mx-auto max-w-6xl px-6 py-10 text-[#e5e7eb]">
|
||||||
<Link href="/admin/banners" className="text-sm text-[#60a5fa] hover:underline">
|
<Link href="/admin/banners" className="text-sm text-[#1D4ED8] hover:underline">
|
||||||
← Back to banners
|
← Back to banners
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ export default async function BannerAnalyticsPage({
|
|||||||
<p className="text-sm text-[#9ca3af]">
|
<p className="text-sm text-[#9ca3af]">
|
||||||
{b.size} · {b.status} ·{" "}
|
{b.size} · {b.status} ·{" "}
|
||||||
{b.click_url && (
|
{b.click_url && (
|
||||||
<a className="text-[#60a5fa] hover:underline" href={b.click_url} target="_blank" rel="noopener noreferrer">
|
<a className="text-[#1D4ED8] hover:underline" href={b.click_url} target="_blank" rel="noopener noreferrer">
|
||||||
{b.click_url.slice(0, 80)}{b.click_url.length > 80 ? "…" : ""}
|
{b.click_url.slice(0, 80)}{b.click_url.length > 80 ? "…" : ""}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export default async function AdminBannersPage() {
|
|||||||
after the next refresh.
|
after the next refresh.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/admin" className="text-sm text-[#60a5fa] hover:underline">
|
<Link href="/admin" className="text-sm text-[#1D4ED8] hover:underline">
|
||||||
← Admin home
|
← Admin home
|
||||||
</Link>
|
</Link>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ export default function CompanyCoveragePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
<div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
<p className="text-[#555] text-sm font-body">Loading company coverage…</p>
|
<p className="text-[#555] text-sm font-body">Loading company coverage…</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -218,7 +218,7 @@ export default function CompanyCoveragePage() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleScan}
|
onClick={handleScan}
|
||||||
disabled={scanning}
|
disabled={scanning}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-[#3b82f6] hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-body font-semibold rounded-lg transition-colors"
|
className="flex items-center gap-2 px-4 py-2 bg-[#1D4ED8] hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-body font-semibold rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
{scanning ? (
|
{scanning ? (
|
||||||
<>
|
<>
|
||||||
@@ -255,14 +255,14 @@ export default function CompanyCoveragePage() {
|
|||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-1 mb-4 bg-[#111] border border-[#252525] rounded-lg p-1 w-fit">
|
<div className="flex gap-1 mb-4 bg-[#111] border border-[#DCE6F2] rounded-lg p-1 w-fit">
|
||||||
{(['queued', 'published', 'dismissed'] as const).map((tab) => (
|
{(['queued', 'published', 'dismissed'] as const).map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
className={`px-4 py-1.5 rounded-md text-sm font-body font-semibold capitalize transition-colors ${
|
className={`px-4 py-1.5 rounded-md text-sm font-body font-semibold capitalize transition-colors ${
|
||||||
activeTab === tab
|
activeTab === tab
|
||||||
? 'bg-[#3b82f6] text-white'
|
? 'bg-[#1D4ED8] text-white'
|
||||||
: 'text-[#888] hover:text-white'
|
: 'text-[#888] hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -272,7 +272,7 @@ export default function CompanyCoveragePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Queue List */}
|
{/* Queue List */}
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-hidden">
|
||||||
{queue.length === 0 ? (
|
{queue.length === 0 ? (
|
||||||
<div className="px-5 py-16 text-center">
|
<div className="px-5 py-16 text-center">
|
||||||
<svg className="w-10 h-10 text-[#333] mx-auto mb-3" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
|
<svg className="w-10 h-10 text-[#333] mx-auto mb-3" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
|
||||||
@@ -282,13 +282,13 @@ export default function CompanyCoveragePage() {
|
|||||||
{activeTab === 'queued' ? 'No stories queued — run a scan to detect companies' : `No ${activeTab} stories`}
|
{activeTab === 'queued' ? 'No stories queued — run a scan to detect companies' : `No ${activeTab} stories`}
|
||||||
</p>
|
</p>
|
||||||
{activeTab === 'queued' && (
|
{activeTab === 'queued' && (
|
||||||
<button onClick={handleScan} disabled={scanning} className="mt-3 text-xs text-[#3b82f6] hover:text-blue-300 font-body underline">
|
<button onClick={handleScan} disabled={scanning} className="mt-3 text-xs text-[#1D4ED8] hover:text-blue-300 font-body underline">
|
||||||
Run scan now →
|
Run scan now →
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-[#1a1a1a]">
|
<div className="divide-y divide-[#FFFFFF]">
|
||||||
{queue.map((item) => (
|
{queue.map((item) => (
|
||||||
<div key={item.id} className="p-4">
|
<div key={item.id} className="p-4">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
@@ -332,7 +332,7 @@ export default function CompanyCoveragePage() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setExpandedItem(expandedItem === item.id ? null : item.id)}
|
onClick={() => setExpandedItem(expandedItem === item.id ? null : item.id)}
|
||||||
className="px-3 py-1.5 bg-[#1a1a1a] hover:bg-[#222] text-[#888] text-xs font-body rounded-md transition-colors"
|
className="px-3 py-1.5 bg-[#FFFFFF] hover:bg-[#222] text-[#888] text-xs font-body rounded-md transition-colors"
|
||||||
>
|
>
|
||||||
{expandedItem === item.id ? 'Hide' : 'Preview'}
|
{expandedItem === item.id ? 'Hide' : 'Preview'}
|
||||||
</button>
|
</button>
|
||||||
@@ -349,7 +349,7 @@ export default function CompanyCoveragePage() {
|
|||||||
|
|
||||||
{/* Expanded Draft Preview */}
|
{/* Expanded Draft Preview */}
|
||||||
{expandedItem === item.id && (
|
{expandedItem === item.id && (
|
||||||
<div className="mt-4 bg-[#0d0d0d] border border-[#1a1a1a] rounded-lg p-4">
|
<div className="mt-4 bg-[#F8FAFC] border border-[#FFFFFF] rounded-lg p-4">
|
||||||
<p className="text-xs font-body font-bold text-[#555] uppercase tracking-widest mb-3">AI Draft Preview</p>
|
<p className="text-xs font-body font-bold text-[#555] uppercase tracking-widest mb-3">AI Draft Preview</p>
|
||||||
<h3 className="text-base font-display font-bold text-white mb-2">{item.ai_draft_title}</h3>
|
<h3 className="text-base font-display font-bold text-white mb-2">{item.ai_draft_title}</h3>
|
||||||
<p className="text-sm text-[#888] font-body italic mb-3">{item.ai_draft_excerpt}</p>
|
<p className="text-sm text-[#888] font-body italic mb-3">{item.ai_draft_excerpt}</p>
|
||||||
@@ -370,11 +370,11 @@ export default function CompanyCoveragePage() {
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
|
||||||
{/* Auto-Pilot Toggle */}
|
{/* Auto-Pilot Toggle */}
|
||||||
<div className={`bg-[#111] border rounded-lg p-5 transition-colors ${settings.autopilot_enabled ? 'border-green-500/40' : 'border-[#252525]'}`}>
|
<div className={`bg-[#111] border rounded-lg p-5 transition-colors ${settings.autopilot_enabled ? 'border-green-500/40' : 'border-[#DCE6F2]'}`}>
|
||||||
<div className="flex items-start justify-between gap-3 mb-3">
|
<div className="flex items-start justify-between gap-3 mb-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<svg className="w-4 h-4 text-[#3b82f6]" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 text-[#1D4ED8]" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
</svg>
|
</svg>
|
||||||
<h3 className="text-sm font-display font-bold text-white">Auto-Pilot</h3>
|
<h3 className="text-sm font-display font-bold text-white">Auto-Pilot</h3>
|
||||||
@@ -405,7 +405,7 @@ export default function CompanyCoveragePage() {
|
|||||||
<p className="text-xs text-green-400 font-body font-semibold">Auto-Pilot is ON — stories publish automatically</p>
|
<p className="text-xs text-green-400 font-body font-semibold">Auto-Pilot is ON — stories publish automatically</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2 bg-[#1a1a1a] rounded-md px-3 py-2">
|
<div className="flex items-center gap-2 bg-[#FFFFFF] rounded-md px-3 py-2">
|
||||||
<div className="w-2 h-2 rounded-full bg-[#444] flex-shrink-0" />
|
<div className="w-2 h-2 rounded-full bg-[#444] flex-shrink-0" />
|
||||||
<p className="text-xs text-[#666] font-body">Manual review required before publishing</p>
|
<p className="text-xs text-[#666] font-body">Manual review required before publishing</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -413,7 +413,7 @@ export default function CompanyCoveragePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reminder Settings */}
|
{/* Reminder Settings */}
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-5">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg p-5">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<svg className="w-4 h-4 text-[#888]" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 text-[#888]" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||||
@@ -421,14 +421,14 @@ export default function CompanyCoveragePage() {
|
|||||||
<h3 className="text-sm font-display font-bold text-white">Reminder Email</h3>
|
<h3 className="text-sm font-display font-bold text-white">Reminder Email</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-[#555] font-body mb-2">New story suggestions are emailed to:</p>
|
<p className="text-xs text-[#555] font-body mb-2">New story suggestions are emailed to:</p>
|
||||||
<div className="bg-[#0d0d0d] border border-[#1a1a1a] rounded-md px-3 py-2">
|
<div className="bg-[#F8FAFC] border border-[#FFFFFF] rounded-md px-3 py-2">
|
||||||
<p className="text-xs text-[#3b82f6] font-body font-mono break-all">{settings.reminder_email}</p>
|
<p className="text-xs text-[#1D4ED8] font-body font-mono break-all">{settings.reminder_email}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-[#444] font-body mt-2">Reminders are sent when Auto-Pilot is off and new companies are detected.</p>
|
<p className="text-xs text-[#444] font-body mt-2">Reminders are sent when Auto-Pilot is off and new companies are detected.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Last Scan Info */}
|
{/* Last Scan Info */}
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-5">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg p-5">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<svg className="w-4 h-4 text-[#888]" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 text-[#888]" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
<circle cx="12" cy="12" r="10" /><path strokeLinecap="round" d="M12 6v6l4 2" />
|
<circle cx="12" cy="12" r="10" /><path strokeLinecap="round" d="M12 6v6l4 2" />
|
||||||
@@ -456,7 +456,7 @@ export default function CompanyCoveragePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* How It Works */}
|
{/* How It Works */}
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-5">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg p-5">
|
||||||
<h3 className="text-xs font-body font-bold text-[#555] uppercase tracking-widest mb-3">How It Works</h3>
|
<h3 className="text-xs font-body font-bold text-[#555] uppercase tracking-widest mb-3">How It Works</h3>
|
||||||
<ol className="space-y-2">
|
<ol className="space-y-2">
|
||||||
{[
|
{[
|
||||||
@@ -467,7 +467,7 @@ export default function CompanyCoveragePage() {
|
|||||||
'Email reminder sent to coverage contact',
|
'Email reminder sent to coverage contact',
|
||||||
].map((step, i) => (
|
].map((step, i) => (
|
||||||
<li key={i} className="flex items-start gap-2">
|
<li key={i} className="flex items-start gap-2">
|
||||||
<span className="flex-shrink-0 w-4 h-4 rounded-full bg-[#1a1a1a] text-[#555] text-xs font-body font-bold flex items-center justify-center mt-0.5">
|
<span className="flex-shrink-0 w-4 h-4 rounded-full bg-[#FFFFFF] text-[#555] text-xs font-body font-bold flex items-center justify-center mt-0.5">
|
||||||
{i + 1}
|
{i + 1}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-[#666] font-body leading-relaxed">{step}</span>
|
<span className="text-xs text-[#666] font-body leading-relaxed">{step}</span>
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ export default function CompanyTrackerPage() {
|
|||||||
if (loading || loadingData) {
|
if (loading || loadingData) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
||||||
<div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
<div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -279,7 +279,7 @@ export default function CompanyTrackerPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Auto-Pilot toggle */}
|
{/* Auto-Pilot toggle */}
|
||||||
<div className="flex items-center gap-3 bg-[#111] border border-[#252525] rounded-lg px-4 py-2.5">
|
<div className="flex items-center gap-3 bg-[#111] border border-[#DCE6F2] rounded-lg px-4 py-2.5">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-body font-bold text-white">Auto-Pilot</div>
|
<div className="text-xs font-body font-bold text-white">Auto-Pilot</div>
|
||||||
<div className="text-xs text-[#555] font-body">{settings.autopilot_enabled ? 'Stories auto-publish' : 'Manual review'}</div>
|
<div className="text-xs text-[#555] font-body">{settings.autopilot_enabled ? 'Stories auto-publish' : 'Manual review'}</div>
|
||||||
@@ -291,13 +291,13 @@ export default function CompanyTrackerPage() {
|
|||||||
await fetch('/api/admin/company-coverage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'update_settings', autopilot_enabled: newVal, reminder_email: settings.reminder_email }) });
|
await fetch('/api/admin/company-coverage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'update_settings', autopilot_enabled: newVal, reminder_email: settings.reminder_email }) });
|
||||||
showToast(newVal ? '🚀 Auto-Pilot enabled' : '⏸ Auto-Pilot disabled');
|
showToast(newVal ? '🚀 Auto-Pilot enabled' : '⏸ Auto-Pilot disabled');
|
||||||
}}
|
}}
|
||||||
className={`relative w-11 h-6 rounded-full transition-colors ${settings.autopilot_enabled ? 'bg-[#3b82f6]' : 'bg-[#333]'}`}
|
className={`relative w-11 h-6 rounded-full transition-colors ${settings.autopilot_enabled ? 'bg-[#1D4ED8]' : 'bg-[#333]'}`}
|
||||||
>
|
>
|
||||||
<span className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${settings.autopilot_enabled ? 'translate-x-5' : 'translate-x-0'}`} />
|
<span className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${settings.autopilot_enabled ? 'translate-x-5' : 'translate-x-0'}`} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={handleScan} disabled={scanning}
|
<button onClick={handleScan} disabled={scanning}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-[#3b82f6] hover:bg-blue-500 disabled:opacity-50 text-white text-sm font-body font-semibold rounded-lg transition-colors">
|
className="flex items-center gap-2 px-4 py-2 bg-[#1D4ED8] hover:bg-blue-500 disabled:opacity-50 text-white text-sm font-body font-semibold rounded-lg transition-colors">
|
||||||
{scanning ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>}
|
{scanning ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>}
|
||||||
{scanning ? 'Scanning…' : 'Scan Articles Now'}
|
{scanning ? 'Scanning…' : 'Scan Articles Now'}
|
||||||
</button>
|
</button>
|
||||||
@@ -313,7 +313,7 @@ export default function CompanyTrackerPage() {
|
|||||||
{ label: 'Companies Found', value: settings.last_scan_companies_found },
|
{ label: 'Companies Found', value: settings.last_scan_companies_found },
|
||||||
{ label: 'Tracked Companies', value: companies.length || '—' },
|
{ label: 'Tracked Companies', value: companies.length || '—' },
|
||||||
].map(stat => (
|
].map(stat => (
|
||||||
<div key={stat.label} className="bg-[#111] border border-[#252525] rounded-lg px-4 py-3">
|
<div key={stat.label} className="bg-[#111] border border-[#DCE6F2] rounded-lg px-4 py-3">
|
||||||
<div className="text-lg font-display font-bold text-white">{stat.value}</div>
|
<div className="text-lg font-display font-bold text-white">{stat.value}</div>
|
||||||
<div className="text-xs text-[#555] font-body">{stat.label}</div>
|
<div className="text-xs text-[#555] font-body">{stat.label}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -322,10 +322,10 @@ export default function CompanyTrackerPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-1 mb-6 bg-[#111] border border-[#252525] rounded-lg p-1 w-fit">
|
<div className="flex gap-1 mb-6 bg-[#111] border border-[#DCE6F2] rounded-lg p-1 w-fit">
|
||||||
{tabs.map(tab => (
|
{tabs.map(tab => (
|
||||||
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
|
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
|
||||||
className={`px-4 py-2 rounded-md text-sm font-body font-medium transition-colors ${activeTab === tab.id ? 'bg-[#1a1a1a] text-white' : 'text-[#555] hover:text-[#888]'}`}>
|
className={`px-4 py-2 rounded-md text-sm font-body font-medium transition-colors ${activeTab === tab.id ? 'bg-[#FFFFFF] text-white' : 'text-[#555] hover:text-[#888]'}`}>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -337,12 +337,12 @@ export default function CompanyTrackerPage() {
|
|||||||
<div className="flex flex-wrap gap-3 mb-4">
|
<div className="flex flex-wrap gap-3 mb-4">
|
||||||
{(['generated','pending','approved','published','rejected'] as QueueStatus[]).map(s => (
|
{(['generated','pending','approved','published','rejected'] as QueueStatus[]).map(s => (
|
||||||
<button key={s} onClick={() => setQueueStatus(s)}
|
<button key={s} onClick={() => setQueueStatus(s)}
|
||||||
className={`px-3 py-1.5 rounded-lg text-xs font-body font-medium transition-colors capitalize ${queueStatus === s ? 'bg-[#3b82f6] text-white' : 'bg-[#111] border border-[#252525] text-[#555] hover:text-white'}`}>
|
className={`px-3 py-1.5 rounded-lg text-xs font-body font-medium transition-colors capitalize ${queueStatus === s ? 'bg-[#1D4ED8] text-white' : 'bg-[#111] border border-[#DCE6F2] text-[#555] hover:text-white'}`}>
|
||||||
{s === 'generated' ? 'Pending Review' : s}
|
{s === 'generated' ? 'Pending Review' : s}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<select value={queueSiteFilter} onChange={e => setQueueSiteFilter(e.target.value)}
|
<select value={queueSiteFilter} onChange={e => setQueueSiteFilter(e.target.value)}
|
||||||
className="bg-[#111] border border-[#252525] rounded-lg px-3 py-1.5 text-[#555] text-xs font-body focus:outline-none">
|
className="bg-[#111] border border-[#DCE6F2] rounded-lg px-3 py-1.5 text-[#555] text-xs font-body focus:outline-none">
|
||||||
<option value="">All Sites</option>
|
<option value="">All Sites</option>
|
||||||
<option value="1">AV Beat</option>
|
<option value="1">AV Beat</option>
|
||||||
<option value="2">AV Beat</option>
|
<option value="2">AV Beat</option>
|
||||||
@@ -350,18 +350,18 @@ export default function CompanyTrackerPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{queue.length === 0 ? (
|
{queue.length === 0 ? (
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-12 text-center">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg p-12 text-center">
|
||||||
<p className="text-[#555] text-sm font-body">No stories in this queue. Run a scan to generate new story suggestions.</p>
|
<p className="text-[#555] text-sm font-body">No stories in this queue. Run a scan to generate new story suggestions.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{queue.map(item => (
|
{queue.map(item => (
|
||||||
<div key={item.id} className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
<div key={item.id} className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-hidden">
|
||||||
<div className="px-5 py-4 flex items-start gap-4">
|
<div className="px-5 py-4 flex items-start gap-4">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
<SiteBadge siteId={item.site_id} />
|
<SiteBadge siteId={item.site_id} />
|
||||||
<span className="text-xs px-2 py-0.5 rounded bg-[#1a1a1a] text-[#888] font-body capitalize">{item.story_type.replace(/_/g, ' ')}</span>
|
<span className="text-xs px-2 py-0.5 rounded bg-[#FFFFFF] text-[#888] font-body capitalize">{item.story_type.replace(/_/g, ' ')}</span>
|
||||||
{item.quality_confidence && (
|
{item.quality_confidence && (
|
||||||
<span className={`text-xs px-2 py-0.5 rounded font-body ${item.quality_confidence >= 0.85 ? 'bg-green-500/10 text-green-400' : 'bg-yellow-500/10 text-yellow-400'}`}>
|
<span className={`text-xs px-2 py-0.5 rounded font-body ${item.quality_confidence >= 0.85 ? 'bg-green-500/10 text-green-400' : 'bg-yellow-500/10 text-yellow-400'}`}>
|
||||||
{Math.round(item.quality_confidence * 100)}% confidence
|
{Math.round(item.quality_confidence * 100)}% confidence
|
||||||
@@ -398,7 +398,7 @@ export default function CompanyTrackerPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{expandedItem === item.id && item.generated_content && (
|
{expandedItem === item.id && item.generated_content && (
|
||||||
<div className="px-5 pb-5 border-t border-[#1a1a1a]">
|
<div className="px-5 pb-5 border-t border-[#FFFFFF]">
|
||||||
<div className="mt-4 bg-[#0a0a0a] rounded-lg p-4 text-sm text-[#aaa] font-body leading-relaxed max-h-64 overflow-y-auto"
|
<div className="mt-4 bg-[#0a0a0a] rounded-lg p-4 text-sm text-[#aaa] font-body leading-relaxed max-h-64 overflow-y-auto"
|
||||||
dangerouslySetInnerHTML={{ __html: item.generated_content }} />
|
dangerouslySetInnerHTML={{ __html: item.generated_content }} />
|
||||||
<div className="mt-3 flex items-center gap-3">
|
<div className="mt-3 flex items-center gap-3">
|
||||||
@@ -430,30 +430,30 @@ export default function CompanyTrackerPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<p className="text-sm text-[#555] font-body">{companies.length} companies tracked</p>
|
<p className="text-sm text-[#555] font-body">{companies.length} companies tracked</p>
|
||||||
<button onClick={() => setShowAddCompany(true)} className="flex items-center gap-2 px-4 py-2 bg-[#3b82f6] hover:bg-blue-500 text-white text-sm font-body font-semibold rounded-lg transition-colors">
|
<button onClick={() => setShowAddCompany(true)} className="flex items-center gap-2 px-4 py-2 bg-[#1D4ED8] hover:bg-blue-500 text-white text-sm font-body font-semibold rounded-lg transition-colors">
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" /></svg>
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" /></svg>
|
||||||
Add Company
|
Add Company
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showAddCompany && (
|
{showAddCompany && (
|
||||||
<div className="mb-4 bg-[#111] border border-[#252525] rounded-lg p-5">
|
<div className="mb-4 bg-[#111] border border-[#DCE6F2] rounded-lg p-5">
|
||||||
<h3 className="text-sm font-display font-bold text-white mb-4">Add Company Manually</h3>
|
<h3 className="text-sm font-display font-bold text-white mb-4">Add Company Manually</h3>
|
||||||
<form onSubmit={handleAddCompany} className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<form onSubmit={handleAddCompany} className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-body text-[#888] mb-1.5">Company Name *</label>
|
<label className="block text-xs font-body text-[#888] mb-1.5">Company Name *</label>
|
||||||
<input type="text" value={addCompanyForm.company_name} onChange={e => setAddCompanyForm(f => ({ ...f, company_name: e.target.value }))} required
|
<input type="text" value={addCompanyForm.company_name} onChange={e => setAddCompanyForm(f => ({ ...f, company_name: e.target.value }))} required
|
||||||
className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-white text-sm font-body focus:outline-none focus:border-[#3b82f6]" />
|
className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-white text-sm font-body focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-body text-[#888] mb-1.5">Website URL</label>
|
<label className="block text-xs font-body text-[#888] mb-1.5">Website URL</label>
|
||||||
<input type="text" value={addCompanyForm.company_website} onChange={e => setAddCompanyForm(f => ({ ...f, company_website: e.target.value }))} placeholder="example.com"
|
<input type="text" value={addCompanyForm.company_website} onChange={e => setAddCompanyForm(f => ({ ...f, company_website: e.target.value }))} placeholder="example.com"
|
||||||
className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-white text-sm font-body focus:outline-none focus:border-[#3b82f6]" />
|
className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-white text-sm font-body focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-body text-[#888] mb-1.5">Site Scope</label>
|
<label className="block text-xs font-body text-[#888] mb-1.5">Site Scope</label>
|
||||||
<select value={addCompanyForm.site_scope} onChange={e => setAddCompanyForm(f => ({ ...f, site_scope: e.target.value }))}
|
<select value={addCompanyForm.site_scope} onChange={e => setAddCompanyForm(f => ({ ...f, site_scope: e.target.value }))}
|
||||||
className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-white text-sm font-body focus:outline-none focus:border-[#3b82f6]">
|
className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-white text-sm font-body focus:outline-none focus:border-[#1D4ED8]">
|
||||||
<option value="both">Both Sites</option>
|
<option value="both">Both Sites</option>
|
||||||
<option value="avbeat">AV Beat only</option>
|
<option value="avbeat">AV Beat only</option>
|
||||||
<option value="avbeat">AV Beat only</option>
|
<option value="avbeat">AV Beat only</option>
|
||||||
@@ -462,51 +462,51 @@ export default function CompanyTrackerPage() {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-body text-[#888] mb-1.5">Crawl Priority</label>
|
<label className="block text-xs font-body text-[#888] mb-1.5">Crawl Priority</label>
|
||||||
<select value={addCompanyForm.crawl_priority} onChange={e => setAddCompanyForm(f => ({ ...f, crawl_priority: e.target.value }))}
|
<select value={addCompanyForm.crawl_priority} onChange={e => setAddCompanyForm(f => ({ ...f, crawl_priority: e.target.value }))}
|
||||||
className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-white text-sm font-body focus:outline-none focus:border-[#3b82f6]">
|
className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-white text-sm font-body focus:outline-none focus:border-[#1D4ED8]">
|
||||||
<option value="standard">Standard (weekly)</option>
|
<option value="standard">Standard (weekly)</option>
|
||||||
<option value="high">High (every 3 days)</option>
|
<option value="high">High (every 3 days)</option>
|
||||||
<option value="critical">Critical (daily)</option>
|
<option value="critical">Critical (daily)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-2 flex gap-3">
|
<div className="md:col-span-2 flex gap-3">
|
||||||
<button type="submit" className="px-5 py-2 bg-[#3b82f6] hover:bg-blue-500 text-white text-sm font-body font-semibold rounded-lg transition-colors">Add Company</button>
|
<button type="submit" className="px-5 py-2 bg-[#1D4ED8] hover:bg-blue-500 text-white text-sm font-body font-semibold rounded-lg transition-colors">Add Company</button>
|
||||||
<button type="button" onClick={() => setShowAddCompany(false)} className="px-4 py-2 bg-[#1a1a1a] border border-[#333] text-[#888] hover:text-white text-sm font-body rounded-lg transition-colors">Cancel</button>
|
<button type="button" onClick={() => setShowAddCompany(false)} className="px-4 py-2 bg-[#FFFFFF] border border-[#333] text-[#888] hover:text-white text-sm font-body rounded-lg transition-colors">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[#1a1a1a]">
|
<tr className="border-b border-[#FFFFFF]">
|
||||||
{['Company', 'Website', 'Mentions', 'Sites', 'Auto-Stories', 'Priority', 'Last Crawl', 'Actions'].map(h => (
|
{['Company', 'Website', 'Mentions', 'Sites', 'Auto-Stories', 'Priority', 'Last Crawl', 'Actions'].map(h => (
|
||||||
<th key={h} className="px-4 py-3 text-left text-xs font-body font-bold text-[#555] uppercase tracking-wider">{h}</th>
|
<th key={h} className="px-4 py-3 text-left text-xs font-body font-bold text-[#555] uppercase tracking-wider">{h}</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[#1a1a1a]">
|
<tbody className="divide-y divide-[#FFFFFF]">
|
||||||
{companies.length === 0 ? (
|
{companies.length === 0 ? (
|
||||||
<tr><td colSpan={8} className="px-4 py-12 text-center text-[#555] text-sm font-body">No companies tracked yet. Run a scan to auto-detect companies from published articles.</td></tr>
|
<tr><td colSpan={8} className="px-4 py-12 text-center text-[#555] text-sm font-body">No companies tracked yet. Run a scan to auto-detect companies from published articles.</td></tr>
|
||||||
) : companies.map(company => (
|
) : companies.map(company => (
|
||||||
<tr key={company.id} className="hover:bg-[#0d0d0d] transition-colors">
|
<tr key={company.id} className="hover:bg-[#F8FAFC] transition-colors">
|
||||||
<td className="px-4 py-3 text-sm font-body font-semibold text-white">{company.company_name}</td>
|
<td className="px-4 py-3 text-sm font-body font-semibold text-white">{company.company_name}</td>
|
||||||
<td className="px-4 py-3 text-xs text-[#555] font-body">{company.company_website ? <a href={`https://${company.company_website}`} target="_blank" rel="noopener noreferrer" className="text-[#3b82f6] hover:underline">{company.company_website}</a> : '—'}</td>
|
<td className="px-4 py-3 text-xs text-[#555] font-body">{company.company_website ? <a href={`https://${company.company_website}`} target="_blank" rel="noopener noreferrer" className="text-[#1D4ED8] hover:underline">{company.company_website}</a> : '—'}</td>
|
||||||
<td className="px-4 py-3 text-sm font-body text-white font-bold">{company.mention_count}</td>
|
<td className="px-4 py-3 text-sm font-body text-white font-bold">{company.mention_count}</td>
|
||||||
<td className="px-4 py-3 text-xs text-[#888] font-body capitalize">{company.site_scope === 'both' ? 'Both' : company.site_scope === 'avbeat' ? 'BB' : 'AV'}</td>
|
<td className="px-4 py-3 text-xs text-[#888] font-body capitalize">{company.site_scope === 'both' ? 'Both' : company.site_scope === 'avbeat' ? 'BB' : 'AV'}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<button onClick={() => handleToggleCompanyAutoStory(company.id, !company.auto_story_enabled)} disabled={actionLoading === company.id}
|
<button onClick={() => handleToggleCompanyAutoStory(company.id, !company.auto_story_enabled)} disabled={actionLoading === company.id}
|
||||||
className={`relative w-9 h-5 rounded-full transition-colors ${company.auto_story_enabled ? 'bg-[#3b82f6]' : 'bg-[#333]'}`}>
|
className={`relative w-9 h-5 rounded-full transition-colors ${company.auto_story_enabled ? 'bg-[#1D4ED8]' : 'bg-[#333]'}`}>
|
||||||
<span className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${company.auto_story_enabled ? 'translate-x-4' : 'translate-x-0'}`} />
|
<span className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${company.auto_story_enabled ? 'translate-x-4' : 'translate-x-0'}`} />
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3"><span className={`text-xs px-2 py-0.5 rounded font-body capitalize ${company.crawl_priority === 'critical' ? 'bg-red-500/10 text-red-400' : company.crawl_priority === 'high' ? 'bg-yellow-500/10 text-yellow-400' : 'bg-[#1a1a1a] text-[#555]'}`}>{company.crawl_priority}</span></td>
|
<td className="px-4 py-3"><span className={`text-xs px-2 py-0.5 rounded font-body capitalize ${company.crawl_priority === 'critical' ? 'bg-red-500/10 text-red-400' : company.crawl_priority === 'high' ? 'bg-yellow-500/10 text-yellow-400' : 'bg-[#FFFFFF] text-[#555]'}`}>{company.crawl_priority}</span></td>
|
||||||
<td className="px-4 py-3 text-xs text-[#555] font-body">{company.last_crawled ? timeAgo(company.last_crawled) : 'Never'}</td>
|
<td className="px-4 py-3 text-xs text-[#555] font-body">{company.last_crawled ? timeAgo(company.last_crawled) : 'Never'}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button onClick={async () => { await fetch('/api/admin/company-coverage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'force_crawl', company_id: company.id }) }); showToast('Crawl scheduled'); }}
|
<button onClick={async () => { await fetch('/api/admin/company-coverage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'force_crawl', company_id: company.id }) }); showToast('Crawl scheduled'); }}
|
||||||
className="text-xs text-[#3b82f6] hover:text-blue-300 font-body transition-colors">Force Crawl</button>
|
className="text-xs text-[#1D4ED8] hover:text-blue-300 font-body transition-colors">Force Crawl</button>
|
||||||
<button onClick={() => handleDeleteCompany(company.id)} disabled={actionLoading === company.id}
|
<button onClick={() => handleDeleteCompany(company.id)} disabled={actionLoading === company.id}
|
||||||
className="text-xs text-red-500 hover:text-red-400 font-body transition-colors disabled:opacity-50">Remove</button>
|
className="text-xs text-red-500 hover:text-red-400 font-body transition-colors disabled:opacity-50">Remove</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -522,21 +522,21 @@ export default function CompanyTrackerPage() {
|
|||||||
|
|
||||||
{/* ── Crawl Log Tab ── */}
|
{/* ── Crawl Log Tab ── */}
|
||||||
{activeTab === 'crawl-log' && (
|
{activeTab === 'crawl-log' && (
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[#1a1a1a]">
|
<tr className="border-b border-[#FFFFFF]">
|
||||||
{['Date', 'Company', 'Source URL', 'Items Found', 'Stories Queued', 'Status'].map(h => (
|
{['Date', 'Company', 'Source URL', 'Items Found', 'Stories Queued', 'Status'].map(h => (
|
||||||
<th key={h} className="px-4 py-3 text-left text-xs font-body font-bold text-[#555] uppercase tracking-wider">{h}</th>
|
<th key={h} className="px-4 py-3 text-left text-xs font-body font-bold text-[#555] uppercase tracking-wider">{h}</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[#1a1a1a]">
|
<tbody className="divide-y divide-[#FFFFFF]">
|
||||||
{crawlLogs.length === 0 ? (
|
{crawlLogs.length === 0 ? (
|
||||||
<tr><td colSpan={6} className="px-4 py-12 text-center text-[#555] text-sm font-body">No crawl history yet.</td></tr>
|
<tr><td colSpan={6} className="px-4 py-12 text-center text-[#555] text-sm font-body">No crawl history yet.</td></tr>
|
||||||
) : crawlLogs.map(log => (
|
) : crawlLogs.map(log => (
|
||||||
<tr key={log.id} className="hover:bg-[#0d0d0d] transition-colors">
|
<tr key={log.id} className="hover:bg-[#F8FAFC] transition-colors">
|
||||||
<td className="px-4 py-3 text-xs text-[#555] font-body">{new Date(log.crawled_at).toLocaleString()}</td>
|
<td className="px-4 py-3 text-xs text-[#555] font-body">{new Date(log.crawled_at).toLocaleString()}</td>
|
||||||
<td className="px-4 py-3 text-sm text-white font-body">{log.company_name}</td>
|
<td className="px-4 py-3 text-sm text-white font-body">{log.company_name}</td>
|
||||||
<td className="px-4 py-3 text-xs text-[#555] font-body truncate max-w-xs">{log.source_url || '—'}</td>
|
<td className="px-4 py-3 text-xs text-[#555] font-body truncate max-w-xs">{log.source_url || '—'}</td>
|
||||||
@@ -558,7 +558,7 @@ export default function CompanyTrackerPage() {
|
|||||||
{/* ── Settings Tab ── */}
|
{/* ── Settings Tab ── */}
|
||||||
{activeTab === 'settings' && (
|
{activeTab === 'settings' && (
|
||||||
<div className="max-w-2xl space-y-6">
|
<div className="max-w-2xl space-y-6">
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-6">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg p-6">
|
||||||
<h3 className="text-sm font-display font-bold text-white mb-4">Auto-Pilot & Reminders</h3>
|
<h3 className="text-sm font-display font-bold text-white mb-4">Auto-Pilot & Reminders</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -567,19 +567,19 @@ export default function CompanyTrackerPage() {
|
|||||||
<div className="text-xs text-[#555] font-body mt-0.5">When enabled, high-confidence stories publish automatically without review</div>
|
<div className="text-xs text-[#555] font-body mt-0.5">When enabled, high-confidence stories publish automatically without review</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setSettings(s => ({ ...s, autopilot_enabled: !s.autopilot_enabled }))}
|
<button onClick={() => setSettings(s => ({ ...s, autopilot_enabled: !s.autopilot_enabled }))}
|
||||||
className={`relative w-11 h-6 rounded-full transition-colors ${settings.autopilot_enabled ? 'bg-[#3b82f6]' : 'bg-[#333]'}`}>
|
className={`relative w-11 h-6 rounded-full transition-colors ${settings.autopilot_enabled ? 'bg-[#1D4ED8]' : 'bg-[#333]'}`}>
|
||||||
<span className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${settings.autopilot_enabled ? 'translate-x-5' : 'translate-x-0'}`} />
|
<span className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${settings.autopilot_enabled ? 'translate-x-5' : 'translate-x-0'}`} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-body text-[#888] mb-1.5">Reminder Email</label>
|
<label className="block text-xs font-body text-[#888] mb-1.5">Reminder Email</label>
|
||||||
<input type="email" value={settings.reminder_email} onChange={e => setSettings(s => ({ ...s, reminder_email: e.target.value }))}
|
<input type="email" value={settings.reminder_email} onChange={e => setSettings(s => ({ ...s, reminder_email: e.target.value }))}
|
||||||
className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-white text-sm font-body focus:outline-none focus:border-[#3b82f6]" />
|
className="w-full bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-white text-sm font-body focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-6">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg p-6">
|
||||||
<h3 className="text-sm font-display font-bold text-white mb-4">Story Generation Settings</h3>
|
<h3 className="text-sm font-display font-bold text-white mb-4">Story Generation Settings</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -588,7 +588,7 @@ export default function CompanyTrackerPage() {
|
|||||||
<div className="text-xs text-[#555] font-body mt-0.5">Master switch for all automated story generation</div>
|
<div className="text-xs text-[#555] font-body mt-0.5">Master switch for all automated story generation</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setTrackerSettings(s => ({ ...s, auto_story_global_enabled: !s.auto_story_global_enabled }))}
|
<button onClick={() => setTrackerSettings(s => ({ ...s, auto_story_global_enabled: !s.auto_story_global_enabled }))}
|
||||||
className={`relative w-11 h-6 rounded-full transition-colors ${trackerSettings.auto_story_global_enabled ? 'bg-[#3b82f6]' : 'bg-[#333]'}`}>
|
className={`relative w-11 h-6 rounded-full transition-colors ${trackerSettings.auto_story_global_enabled ? 'bg-[#1D4ED8]' : 'bg-[#333]'}`}>
|
||||||
<span className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${trackerSettings.auto_story_global_enabled ? 'translate-x-5' : 'translate-x-0'}`} />
|
<span className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${trackerSettings.auto_story_global_enabled ? 'translate-x-5' : 'translate-x-0'}`} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -598,25 +598,25 @@ export default function CompanyTrackerPage() {
|
|||||||
<div className="text-xs text-[#555] font-body mt-0.5">Publish stories with confidence ≥ {Math.round(trackerSettings.auto_publish_confidence_threshold * 100)}% automatically</div>
|
<div className="text-xs text-[#555] font-body mt-0.5">Publish stories with confidence ≥ {Math.round(trackerSettings.auto_publish_confidence_threshold * 100)}% automatically</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setTrackerSettings(s => ({ ...s, auto_publish_enabled: !s.auto_publish_enabled }))}
|
<button onClick={() => setTrackerSettings(s => ({ ...s, auto_publish_enabled: !s.auto_publish_enabled }))}
|
||||||
className={`relative w-11 h-6 rounded-full transition-colors ${trackerSettings.auto_publish_enabled ? 'bg-[#3b82f6]' : 'bg-[#333]'}`}>
|
className={`relative w-11 h-6 rounded-full transition-colors ${trackerSettings.auto_publish_enabled ? 'bg-[#1D4ED8]' : 'bg-[#333]'}`}>
|
||||||
<span className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${trackerSettings.auto_publish_enabled ? 'translate-x-5' : 'translate-x-0'}`} />
|
<span className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${trackerSettings.auto_publish_enabled ? 'translate-x-5' : 'translate-x-0'}`} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-body text-[#888] mb-1.5">Mention Threshold (min mentions to qualify for auto-stories)</label>
|
<label className="block text-xs font-body text-[#888] mb-1.5">Mention Threshold (min mentions to qualify for auto-stories)</label>
|
||||||
<input type="number" min={1} max={20} value={trackerSettings.mention_threshold} onChange={e => setTrackerSettings(s => ({ ...s, mention_threshold: parseInt(e.target.value) }))}
|
<input type="number" min={1} max={20} value={trackerSettings.mention_threshold} onChange={e => setTrackerSettings(s => ({ ...s, mention_threshold: parseInt(e.target.value) }))}
|
||||||
className="w-32 bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-white text-sm font-body focus:outline-none focus:border-[#3b82f6]" />
|
className="w-32 bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-white text-sm font-body focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-body text-[#888] mb-1.5">Max Stories Per Company Per Week</label>
|
<label className="block text-xs font-body text-[#888] mb-1.5">Max Stories Per Company Per Week</label>
|
||||||
<input type="number" min={1} max={20} value={trackerSettings.max_stories_per_company_per_week} onChange={e => setTrackerSettings(s => ({ ...s, max_stories_per_company_per_week: parseInt(e.target.value) }))}
|
<input type="number" min={1} max={20} value={trackerSettings.max_stories_per_company_per_week} onChange={e => setTrackerSettings(s => ({ ...s, max_stories_per_company_per_week: parseInt(e.target.value) }))}
|
||||||
className="w-32 bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-white text-sm font-body focus:outline-none focus:border-[#3b82f6]" />
|
className="w-32 bg-[#0a0a0a] border border-[#333] rounded-lg px-3 py-2.5 text-white text-sm font-body focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button onClick={handleSaveSettings} disabled={savingSettings}
|
<button onClick={handleSaveSettings} disabled={savingSettings}
|
||||||
className="px-6 py-2.5 bg-[#3b82f6] hover:bg-blue-500 text-white text-sm font-body font-semibold rounded-lg transition-colors disabled:opacity-50">
|
className="px-6 py-2.5 bg-[#1D4ED8] hover:bg-blue-500 text-white text-sm font-body font-semibold rounded-lg transition-colors disabled:opacity-50">
|
||||||
{savingSettings ? 'Saving...' : 'Save Settings'}
|
{savingSettings ? 'Saving...' : 'Save Settings'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ interface LaunchChecklist {
|
|||||||
// ─── Site labels ──────────────────────────────────────────────────────────────
|
// ─── Site labels ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const SITE_LABELS: Record<string, { label: string; color: string; target: string }> = {
|
const SITE_LABELS: Record<string, { label: string; color: string; target: string }> = {
|
||||||
'broadcast-beat': { label: 'AV Beat', color: 'text-blue-400', target: '15–25/day' },
|
'avbeat': { label: 'AV Beat', color: 'text-blue-400', target: '15–25/day' },
|
||||||
'av-beat': { label: 'AV Beat', color: 'text-purple-400', target: '10–15/day' },
|
'av-beat': { label: 'AV Beat', color: 'text-purple-400', target: '10–15/day' },
|
||||||
'backlot-beat': { label: 'Backlot Beat', color: 'text-amber-400', target: '10–15/day' },
|
'backlot-beat': { label: 'Backlot Beat', color: 'text-amber-400', target: '10–15/day' },
|
||||||
'broadcast-engineering': { label: 'BroadcastEngineering.com', color: 'text-green-400', target: '10–15/day' },
|
'broadcast-engineering': { label: 'BroadcastEngineering.com', color: 'text-green-400', target: '10–15/day' },
|
||||||
@@ -97,7 +97,7 @@ function StatCard({
|
|||||||
good?: boolean;
|
good?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-5 flex flex-col gap-2">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg p-5 flex flex-col gap-2">
|
||||||
<p className={`text-2xl font-display font-bold ${accent}`}>{value}</p>
|
<p className={`text-2xl font-display font-bold ${accent}`}>{value}</p>
|
||||||
<p className="text-sm text-[#888] font-body">{label}</p>
|
<p className="text-sm text-[#888] font-body">{label}</p>
|
||||||
{sub && <p className="text-xs text-[#555] font-body">{sub}</p>}
|
{sub && <p className="text-xs text-[#555] font-body">{sub}</p>}
|
||||||
@@ -124,7 +124,7 @@ function ChecklistItem({ label, done }: { label: string; done: boolean }) {
|
|||||||
type RMPSite = 'broadcast-beat' | 'av-beat' | 'backlot-beat' | 'broadcast-engineering';
|
type RMPSite = 'broadcast-beat' | 'av-beat' | 'backlot-beat' | 'broadcast-engineering';
|
||||||
|
|
||||||
function GeneratePanel({ onGenerated }: { onGenerated: () => void }) {
|
function GeneratePanel({ onGenerated }: { onGenerated: () => void }) {
|
||||||
const [site, setSite] = useState<RMPSite>('broadcast-beat');
|
const [site, setSite] = useState<RMPSite>('avbeat');
|
||||||
const [topic, setTopic] = useState('');
|
const [topic, setTopic] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [result, setResult] = useState<string | null>(null);
|
const [result, setResult] = useState<string | null>(null);
|
||||||
@@ -173,7 +173,7 @@ function GeneratePanel({ onGenerated }: { onGenerated: () => void }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-5 flex flex-col gap-4">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg p-5 flex flex-col gap-4">
|
||||||
<h3 className="text-sm font-display font-bold text-white">Manual Controls</h3>
|
<h3 className="text-sm font-display font-bold text-white">Manual Controls</h3>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -181,7 +181,7 @@ function GeneratePanel({ onGenerated }: { onGenerated: () => void }) {
|
|||||||
<select
|
<select
|
||||||
value={site}
|
value={site}
|
||||||
onChange={(e) => setSite(e.target.value as RMPSite)}
|
onChange={(e) => setSite(e.target.value as RMPSite)}
|
||||||
className="w-full bg-[#1a1a1a] border border-[#333] rounded-lg px-3 py-2 text-sm text-white font-body focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#FFFFFF] border border-[#333] rounded-lg px-3 py-2 text-sm text-white font-body focus:outline-none focus:border-[#1D4ED8]"
|
||||||
>
|
>
|
||||||
{Object.entries(SITE_LABELS).map(([key, val]) => (
|
{Object.entries(SITE_LABELS).map(([key, val]) => (
|
||||||
<option key={key} value={key}>{val.label}</option>
|
<option key={key} value={key}>{val.label}</option>
|
||||||
@@ -195,21 +195,21 @@ function GeneratePanel({ onGenerated }: { onGenerated: () => void }) {
|
|||||||
value={topic}
|
value={topic}
|
||||||
onChange={(e) => setTopic(e.target.value)}
|
onChange={(e) => setTopic(e.target.value)}
|
||||||
placeholder="e.g. ATSC 3.0 deployment update"
|
placeholder="e.g. ATSC 3.0 deployment update"
|
||||||
className="w-full bg-[#1a1a1a] border border-[#333] rounded-lg px-3 py-2 text-sm text-white font-body placeholder-[#444] focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#FFFFFF] border border-[#333] rounded-lg px-3 py-2 text-sm text-white font-body placeholder-[#444] focus:outline-none focus:border-[#1D4ED8]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleGenerate}
|
onClick={handleGenerate}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="flex-1 bg-[#3b82f6] hover:bg-[#2563eb] disabled:opacity-50 text-white text-sm font-body font-semibold py-2 px-4 rounded-lg transition-colors"
|
className="flex-1 bg-[#1D4ED8] hover:bg-[#1E3A8A] disabled:opacity-50 text-white text-sm font-body font-semibold py-2 px-4 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
{loading ? 'Generating…' : 'Generate Article'}
|
{loading ? 'Generating…' : 'Generate Article'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSEOBatch}
|
onClick={handleSEOBatch}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="flex-1 bg-[#1a1a1a] hover:bg-[#252525] disabled:opacity-50 border border-[#333] text-white text-sm font-body font-semibold py-2 px-4 rounded-lg transition-colors"
|
className="flex-1 bg-[#FFFFFF] hover:bg-[#DCE6F2] disabled:opacity-50 border border-[#333] text-white text-sm font-body font-semibold py-2 px-4 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
{loading ? 'Running…' : 'Batch SEO Meta'}
|
{loading ? 'Running…' : 'Batch SEO Meta'}
|
||||||
</button>
|
</button>
|
||||||
@@ -279,7 +279,7 @@ export default function ContentStatusPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
<div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
<p className="text-[#555] text-sm font-body">Loading content status…</p>
|
<p className="text-[#555] text-sm font-body">Loading content status…</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -296,7 +296,7 @@ export default function ContentStatusPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0a0a] text-white">
|
<div className="min-h-screen bg-[#0a0a0a] text-white">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="border-b border-[#1a1a1a] bg-[#0d0d0d] px-6 py-4">
|
<div className="border-b border-[#FFFFFF] bg-[#F8FAFC] px-6 py-4">
|
||||||
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link href="/admin" className="text-[#555] hover:text-white transition-colors text-sm font-body">
|
<Link href="/admin" className="text-[#555] hover:text-white transition-colors text-sm font-body">
|
||||||
@@ -327,7 +327,7 @@ export default function ContentStatusPage() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={fetchAll}
|
onClick={fetchAll}
|
||||||
className="text-xs font-body px-3 py-1.5 rounded-lg border border-[#333] text-white bg-[#111] hover:bg-[#1a1a1a] transition-colors"
|
className="text-xs font-body px-3 py-1.5 rounded-lg border border-[#333] text-white bg-[#111] hover:bg-[#FFFFFF] transition-colors"
|
||||||
>
|
>
|
||||||
Refresh Now
|
Refresh Now
|
||||||
</button>
|
</button>
|
||||||
@@ -395,7 +395,7 @@ export default function ContentStatusPage() {
|
|||||||
{Object.entries(SITE_LABELS).map(([key, cfg]) => {
|
{Object.entries(SITE_LABELS).map(([key, cfg]) => {
|
||||||
const count = status?.siteBreakdown?.[key] ?? 0;
|
const count = status?.siteBreakdown?.[key] ?? 0;
|
||||||
return (
|
return (
|
||||||
<div key={key} className="bg-[#111] border border-[#252525] rounded-lg p-4 flex flex-col gap-2">
|
<div key={key} className="bg-[#111] border border-[#DCE6F2] rounded-lg p-4 flex flex-col gap-2">
|
||||||
<p className={`text-xs font-body font-semibold ${cfg.color}`}>{cfg.label}</p>
|
<p className={`text-xs font-body font-semibold ${cfg.color}`}>{cfg.label}</p>
|
||||||
<p className="text-3xl font-display font-bold text-white">{count}</p>
|
<p className="text-3xl font-display font-bold text-white">{count}</p>
|
||||||
<p className="text-xs text-[#555] font-body">Target: {cfg.target}</p>
|
<p className="text-xs text-[#555] font-body">Target: {cfg.target}</p>
|
||||||
@@ -484,7 +484,7 @@ export default function ContentStatusPage() {
|
|||||||
{/* API Reference */}
|
{/* API Reference */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-display font-bold text-[#888] uppercase tracking-wider mb-4">API Endpoints</h2>
|
<h2 className="text-sm font-display font-bold text-[#888] uppercase tracking-wider mb-4">API Endpoints</h2>
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-5 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg p-5 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
{[
|
{[
|
||||||
{ method: 'GET', path: '/api/content/audit', desc: 'Full content audit report' },
|
{ method: 'GET', path: '/api/content/audit', desc: 'Full content audit report' },
|
||||||
{ method: 'GET', path: '/api/content/pipeline-status', desc: 'Live pipeline + node status' },
|
{ method: 'GET', path: '/api/content/pipeline-status', desc: 'Live pipeline + node status' },
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ function StatCard({
|
|||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
sub,
|
sub,
|
||||||
accent = '#3b82f6',
|
accent = '#1D4ED8',
|
||||||
icon,
|
icon,
|
||||||
trend,
|
trend,
|
||||||
}: {
|
}: {
|
||||||
@@ -102,7 +102,7 @@ function StatCard({
|
|||||||
trend?: { value: number; label: string };
|
trend?: { value: number; label: string };
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-xl p-5 flex flex-col gap-3">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-xl p-5 flex flex-col gap-3">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div
|
<div
|
||||||
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
|
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||||
@@ -134,7 +134,7 @@ function StatCard({
|
|||||||
function ChartTooltip({ active, payload, label }: any) {
|
function ChartTooltip({ active, payload, label }: any) {
|
||||||
if (!active || !payload?.length) return null;
|
if (!active || !payload?.length) return null;
|
||||||
return (
|
return (
|
||||||
<div className="bg-[#1a1a1a] border border-[#333] rounded-lg px-3 py-2 text-xs font-body shadow-xl">
|
<div className="bg-[#FFFFFF] border border-[#333] rounded-lg px-3 py-2 text-xs font-body shadow-xl">
|
||||||
{label && <p className="text-[#888] mb-1.5 font-semibold">{label}</p>}
|
{label && <p className="text-[#888] mb-1.5 font-semibold">{label}</p>}
|
||||||
{payload.map((entry: any, i: number) => (
|
{payload.map((entry: any, i: number) => (
|
||||||
<p key={i} style={{ color: entry.color }} className="leading-5">
|
<p key={i} style={{ color: entry.color }} className="leading-5">
|
||||||
@@ -252,7 +252,7 @@ export default function EmailAnalyticsDashboard() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
<div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
<p className="text-[#555] text-sm font-body">Loading analytics…</p>
|
<p className="text-[#555] text-sm font-body">Loading analytics…</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -262,7 +262,7 @@ export default function EmailAnalyticsDashboard() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0a0a] text-white">
|
<div className="min-h-screen bg-[#0a0a0a] text-white">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="border-b border-[#1a1a1a] bg-[#0d0d0d] sticky top-0 z-10">
|
<div className="border-b border-[#FFFFFF] bg-[#F8FAFC] sticky top-0 z-10">
|
||||||
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link href="/admin" className="text-[#555] hover:text-white transition-colors">
|
<Link href="/admin" className="text-[#555] hover:text-white transition-colors">
|
||||||
@@ -270,7 +270,7 @@ export default function EmailAnalyticsDashboard() {
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
|
||||||
</svg>
|
</svg>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="w-px h-5 bg-[#252525]" />
|
<div className="w-px h-5 bg-[#DCE6F2]" />
|
||||||
<span className="text-white text-sm font-body font-semibold">Email Analytics</span>
|
<span className="text-white text-sm font-body font-semibold">Email Analytics</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -280,17 +280,17 @@ export default function EmailAnalyticsDashboard() {
|
|||||||
onClick={() => setTimeRange(r)}
|
onClick={() => setTimeRange(r)}
|
||||||
className={`px-3 py-1.5 text-xs font-semibold font-body rounded-lg transition-colors ${
|
className={`px-3 py-1.5 text-xs font-semibold font-body rounded-lg transition-colors ${
|
||||||
timeRange === r
|
timeRange === r
|
||||||
? 'bg-[#3b82f6] text-white'
|
? 'bg-[#1D4ED8] text-white'
|
||||||
: 'text-[#555] hover:text-white border border-[#252525] hover:border-[#444]'
|
: 'text-[#555] hover:text-white border border-[#DCE6F2] hover:border-[#444]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{r === '3m' ? '3 Mo' : r === '6m' ? '6 Mo' : 'All'}
|
{r === '3m' ? '3 Mo' : r === '6m' ? '6 Mo' : 'All'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<div className="w-px h-6 bg-[#252525] mx-1" />
|
<div className="w-px h-6 bg-[#DCE6F2] mx-1" />
|
||||||
<Link
|
<Link
|
||||||
href="/admin/newsletter/analytics"
|
href="/admin/newsletter/analytics"
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-body font-semibold text-[#888] hover:text-white border border-[#252525] rounded-lg transition-colors"
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-body font-semibold text-[#888] hover:text-white border border-[#DCE6F2] rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 10h18M3 14h18M10 3v18M14 3v18" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 10h18M3 14h18M10 3v18M14 3v18" />
|
||||||
@@ -317,7 +317,7 @@ export default function EmailAnalyticsDashboard() {
|
|||||||
label="Active Subscribers"
|
label="Active Subscribers"
|
||||||
value={fmt(stats.activeSubscribers)}
|
value={fmt(stats.activeSubscribers)}
|
||||||
sub={`${fmt(stats.unsubscribed)} unsubscribed`}
|
sub={`${fmt(stats.unsubscribed)} unsubscribed`}
|
||||||
accent="#3b82f6"
|
accent="#1D4ED8"
|
||||||
trend={{ value: 12, label: 'vs last month' }}
|
trend={{ value: 12, label: 'vs last month' }}
|
||||||
icon={
|
icon={
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
@@ -365,7 +365,7 @@ export default function EmailAnalyticsDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Send Volume Chart */}
|
{/* Send Volume Chart */}
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-xl p-6">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-xl p-6">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Send Volume Over Time"
|
title="Send Volume Over Time"
|
||||||
subtitle="Total emails sent, opens, and clicks per month"
|
subtitle="Total emails sent, opens, and clicks per month"
|
||||||
@@ -378,7 +378,7 @@ export default function EmailAnalyticsDashboard() {
|
|||||||
<YAxis tick={{ fill: '#555', fontSize: 10 }} tickLine={false} axisLine={false} tickFormatter={(v) => fmt(v)} />
|
<YAxis tick={{ fill: '#555', fontSize: 10 }} tickLine={false} axisLine={false} tickFormatter={(v) => fmt(v)} />
|
||||||
<Tooltip content={<ChartTooltip />} />
|
<Tooltip content={<ChartTooltip />} />
|
||||||
<Legend wrapperStyle={{ fontSize: 11, color: '#888', paddingTop: 12 }} />
|
<Legend wrapperStyle={{ fontSize: 11, color: '#888', paddingTop: 12 }} />
|
||||||
<Bar dataKey="sent" name="Sent" fill="#3b82f620" stroke="#3b82f6" strokeWidth={1} radius={[3, 3, 0, 0]} />
|
<Bar dataKey="sent" name="Sent" fill="#3b82f620" stroke="#1D4ED8" strokeWidth={1} radius={[3, 3, 0, 0]} />
|
||||||
<Bar dataKey="opens" name="Opens" fill="#10b98120" stroke="#10b981" strokeWidth={1} radius={[3, 3, 0, 0]} />
|
<Bar dataKey="opens" name="Opens" fill="#10b98120" stroke="#10b981" strokeWidth={1} radius={[3, 3, 0, 0]} />
|
||||||
<Bar dataKey="clicks" name="Clicks" fill="#f59e0b20" stroke="#f59e0b" strokeWidth={1} radius={[3, 3, 0, 0]} />
|
<Bar dataKey="clicks" name="Clicks" fill="#f59e0b20" stroke="#f59e0b" strokeWidth={1} radius={[3, 3, 0, 0]} />
|
||||||
</ComposedChart>
|
</ComposedChart>
|
||||||
@@ -387,7 +387,7 @@ export default function EmailAnalyticsDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Open Rate & CTR Trend */}
|
{/* Open Rate & CTR Trend */}
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-xl p-6">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-xl p-6">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Open Rate & Click-Through Rate Trend"
|
title="Open Rate & Click-Through Rate Trend"
|
||||||
subtitle="Monthly engagement rates as a percentage of recipients"
|
subtitle="Monthly engagement rates as a percentage of recipients"
|
||||||
@@ -397,8 +397,8 @@ export default function EmailAnalyticsDashboard() {
|
|||||||
<AreaChart data={filteredRates} margin={{ top: 4, right: 8, left: -10, bottom: 0 }}>
|
<AreaChart data={filteredRates} margin={{ top: 4, right: 8, left: -10, bottom: 0 }}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="openGrad" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="openGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.2} />
|
<stop offset="5%" stopColor="#1D4ED8" stopOpacity={0.2} />
|
||||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
<stop offset="95%" stopColor="#1D4ED8" stopOpacity={0} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="clickGrad" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="clickGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.2} />
|
<stop offset="5%" stopColor="#10b981" stopOpacity={0.2} />
|
||||||
@@ -418,10 +418,10 @@ export default function EmailAnalyticsDashboard() {
|
|||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="Open Rate"
|
dataKey="Open Rate"
|
||||||
stroke="#3b82f6"
|
stroke="#1D4ED8"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
fill="url(#openGrad)"
|
fill="url(#openGrad)"
|
||||||
dot={{ r: 3, fill: '#3b82f6' }}
|
dot={{ r: 3, fill: '#1D4ED8' }}
|
||||||
activeDot={{ r: 5 }}
|
activeDot={{ r: 5 }}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
@@ -441,7 +441,7 @@ export default function EmailAnalyticsDashboard() {
|
|||||||
{/* Subscriber Growth */}
|
{/* Subscriber Growth */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Growth Over Time */}
|
{/* Growth Over Time */}
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-xl p-6">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-xl p-6">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Subscriber Growth"
|
title="Subscriber Growth"
|
||||||
subtitle="Total subscribers and monthly net change"
|
subtitle="Total subscribers and monthly net change"
|
||||||
@@ -463,14 +463,14 @@ export default function EmailAnalyticsDashboard() {
|
|||||||
fill="#8b5cf618"
|
fill="#8b5cf618"
|
||||||
dot={false}
|
dot={false}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="net" name="Net New" fill="#3b82f6" radius={[3, 3, 0, 0]} />
|
<Bar dataKey="net" name="Net New" fill="#1D4ED8" radius={[3, 3, 0, 0]} />
|
||||||
</ComposedChart>
|
</ComposedChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* New vs Unsubscribed */}
|
{/* New vs Unsubscribed */}
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-xl p-6">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-xl p-6">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="New Subscribers vs Unsubscribes"
|
title="New Subscribers vs Unsubscribes"
|
||||||
subtitle="Monthly acquisition vs churn"
|
subtitle="Monthly acquisition vs churn"
|
||||||
@@ -492,7 +492,7 @@ export default function EmailAnalyticsDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Benchmark Comparison */}
|
{/* Benchmark Comparison */}
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-xl p-6">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-xl p-6">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Performance vs Industry Benchmarks"
|
title="Performance vs Industry Benchmarks"
|
||||||
subtitle="How your metrics compare to email marketing industry averages"
|
subtitle="How your metrics compare to email marketing industry averages"
|
||||||
@@ -503,7 +503,7 @@ export default function EmailAnalyticsDashboard() {
|
|||||||
label: 'Open Rate',
|
label: 'Open Rate',
|
||||||
yours: stats.avgOpenRate,
|
yours: stats.avgOpenRate,
|
||||||
benchmark: 21.5,
|
benchmark: 21.5,
|
||||||
color: '#3b82f6',
|
color: '#1D4ED8',
|
||||||
unit: '%',
|
unit: '%',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -584,7 +584,7 @@ export default function EmailAnalyticsDashboard() {
|
|||||||
<div className="flex flex-wrap gap-3 pb-4">
|
<div className="flex flex-wrap gap-3 pb-4">
|
||||||
<Link
|
<Link
|
||||||
href="/admin/newsletter"
|
href="/admin/newsletter"
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-[#111] border border-[#252525] rounded-lg text-sm font-body text-[#888] hover:text-white hover:border-[#444] transition-colors"
|
className="flex items-center gap-2 px-4 py-2 bg-[#111] border border-[#DCE6F2] rounded-lg text-sm font-body text-[#888] hover:text-white hover:border-[#444] transition-colors"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
||||||
@@ -593,7 +593,7 @@ export default function EmailAnalyticsDashboard() {
|
|||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/admin/newsletter/analytics"
|
href="/admin/newsletter/analytics"
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-[#111] border border-[#252525] rounded-lg text-sm font-body text-[#888] hover:text-white hover:border-[#444] transition-colors"
|
className="flex items-center gap-2 px-4 py-2 bg-[#111] border border-[#DCE6F2] rounded-lg text-sm font-body text-[#888] hover:text-white hover:border-[#444] transition-colors"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
|
||||||
@@ -602,7 +602,7 @@ export default function EmailAnalyticsDashboard() {
|
|||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/admin/newsletter/templates"
|
href="/admin/newsletter/templates"
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-[#111] border border-[#252525] rounded-lg text-sm font-body text-[#888] hover:text-white hover:border-[#444] transition-colors"
|
className="flex items-center gap-2 px-4 py-2 bg-[#111] border border-[#DCE6F2] rounded-lg text-sm font-body text-[#888] hover:text-white hover:border-[#444] transition-colors"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ function DomainsContent() {
|
|||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /></div>;
|
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" /></div>;
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -86,41 +86,41 @@ function DomainsContent() {
|
|||||||
<h1 className="text-xl font-bold">Sending Domains</h1>
|
<h1 className="text-xl font-bold">Sending Domains</h1>
|
||||||
<Link href="/admin/email" className="text-xs text-[#555] hover:text-[#888]">← Email</Link>
|
<Link href="/admin/email" className="text-xs text-[#555] hover:text-[#888]">← Email</Link>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setShowAddForm(true)} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500">+ Add Domain</button>
|
<button onClick={() => setShowAddForm(true)} className="px-3 py-1.5 bg-[#1D4ED8] rounded text-xs font-medium hover:bg-blue-500">+ Add Domain</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<select value={filterSite} onChange={e => setFilterSite(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-[#888] focus:outline-none">
|
<select value={filterSite} onChange={e => setFilterSite(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#DCE6F2] rounded text-xs text-[#888] focus:outline-none">
|
||||||
<option value="">All Sites</option>
|
<option value="">All Sites</option>
|
||||||
{SITES.map(s => <option key={s} value={s}>{s}</option>)}
|
{SITES.map(s => <option key={s} value={s}>{s}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showAddForm && (
|
{showAddForm && (
|
||||||
<form onSubmit={handleAddDomain} className="bg-[#111] border border-[#252525] rounded-lg p-4 space-y-3">
|
<form onSubmit={handleAddDomain} className="bg-[#111] border border-[#DCE6F2] rounded-lg p-4 space-y-3">
|
||||||
<h3 className="text-sm font-semibold">Add Rotation Domain</h3>
|
<h3 className="text-sm font-semibold">Add Rotation Domain</h3>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Site *</label>
|
<label className="text-xs text-[#888] block mb-1">Site *</label>
|
||||||
<select required value={addForm.site} onChange={e => setAddForm(p => ({ ...p, site: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none">
|
<select required value={addForm.site} onChange={e => setAddForm(p => ({ ...p, site: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none">
|
||||||
<option value="">Select…</option>
|
<option value="">Select…</option>
|
||||||
{SITES.map(s => <option key={s} value={s}>{s}</option>)}
|
{SITES.map(s => <option key={s} value={s}>{s}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Domain *</label>
|
<label className="text-xs text-[#888] block mb-1">Domain *</label>
|
||||||
<input required value={addForm.domain} onChange={e => setAddForm(p => ({ ...p, domain: e.target.value }))} placeholder="mail.example.com" className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
|
<input required value={addForm.domain} onChange={e => setAddForm(p => ({ ...p, domain: e.target.value }))} placeholder="mail.example.com" className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Type</label>
|
<label className="text-xs text-[#888] block mb-1">Type</label>
|
||||||
<select value={addForm.domain_type} onChange={e => setAddForm(p => ({ ...p, domain_type: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none">
|
<select value={addForm.domain_type} onChange={e => setAddForm(p => ({ ...p, domain_type: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none">
|
||||||
{['subdomain','sister_domain','primary'].map(t => <option key={t} value={t}>{t}</option>)}
|
{['subdomain','sister_domain','primary'].map(t => <option key={t} value={t}>{t}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button type="submit" disabled={saving} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500 disabled:opacity-50">{saving ? 'Saving…' : 'Add Domain'}</button>
|
<button type="submit" disabled={saving} className="px-3 py-1.5 bg-[#1D4ED8] rounded text-xs font-medium hover:bg-blue-500 disabled:opacity-50">{saving ? 'Saving…' : 'Add Domain'}</button>
|
||||||
<button type="button" onClick={() => setShowAddForm(false)} className="px-3 py-1.5 bg-[#1a1a1a] border border-[#252525] rounded text-xs text-[#888] hover:text-white">Cancel</button>
|
<button type="button" onClick={() => setShowAddForm(false)} className="px-3 py-1.5 bg-[#FFFFFF] border border-[#DCE6F2] rounded text-xs text-[#888] hover:text-white">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
@@ -131,10 +131,10 @@ function DomainsContent() {
|
|||||||
<div className="bg-[#111] border border-yellow-400/30 rounded-lg p-5 w-80 space-y-3">
|
<div className="bg-[#111] border border-yellow-400/30 rounded-lg p-5 w-80 space-y-3">
|
||||||
<h3 className="text-sm font-semibold text-yellow-400">⚠ Override DNS Verification</h3>
|
<h3 className="text-sm font-semibold text-yellow-400">⚠ Override DNS Verification</h3>
|
||||||
<p className="text-xs text-[#888]">This allows sending before DNS is verified. Use with caution.</p>
|
<p className="text-xs text-[#888]">This allows sending before DNS is verified. Use with caution.</p>
|
||||||
<textarea value={bypassReason} onChange={e => setBypassReason(e.target.value)} placeholder="Reason for bypass…" rows={3} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none" />
|
<textarea value={bypassReason} onChange={e => setBypassReason(e.target.value)} placeholder="Reason for bypass…" rows={3} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none" />
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button onClick={() => setBypass(showBypass)} disabled={!bypassReason} className="px-3 py-1.5 bg-yellow-600 rounded text-xs font-medium hover:bg-yellow-500 disabled:opacity-50">Set Bypass</button>
|
<button onClick={() => setBypass(showBypass)} disabled={!bypassReason} className="px-3 py-1.5 bg-yellow-600 rounded text-xs font-medium hover:bg-yellow-500 disabled:opacity-50">Set Bypass</button>
|
||||||
<button onClick={() => setShowBypass(null)} className="px-3 py-1.5 bg-[#1a1a1a] border border-[#252525] rounded text-xs text-[#888] hover:text-white">Cancel</button>
|
<button onClick={() => setShowBypass(null)} className="px-3 py-1.5 bg-[#FFFFFF] border border-[#DCE6F2] rounded text-xs text-[#888] hover:text-white">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,12 +142,12 @@ function DomainsContent() {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{loadingData ? (
|
{loadingData ? (
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-8 text-center text-[#555] text-xs">Loading…</div>
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg p-8 text-center text-[#555] text-xs">Loading…</div>
|
||||||
) : domains.length === 0 ? (
|
) : domains.length === 0 ? (
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-8 text-center text-[#555] text-xs">No domains found</div>
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg p-8 text-center text-[#555] text-xs">No domains found</div>
|
||||||
) : domains.map((d: any) => (
|
) : domains.map((d: any) => (
|
||||||
<div key={d.id} className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
<div key={d.id} className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-hidden">
|
||||||
<div className="px-4 py-3 flex items-center gap-3 cursor-pointer hover:bg-[#161616]" onClick={() => setExpanded(expanded === d.id ? null : d.id)}>
|
<div className="px-4 py-3 flex items-center gap-3 cursor-pointer hover:bg-[#FFFFFF]" onClick={() => setExpanded(expanded === d.id ? null : d.id)}>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-sm font-medium text-white">{d.domain}</span>
|
<span className="text-sm font-medium text-white">{d.domain}</span>
|
||||||
@@ -156,7 +156,7 @@ function DomainsContent() {
|
|||||||
<span className={`px-1.5 py-0.5 rounded text-xs ${d.dns_fully_verified ? 'bg-green-400/10 text-green-400' : 'bg-yellow-400/10 text-yellow-400'}`}>{d.dns_fully_verified ? 'DNS OK' : 'DNS Pending'}</span>
|
<span className={`px-1.5 py-0.5 rounded text-xs ${d.dns_fully_verified ? 'bg-green-400/10 text-green-400' : 'bg-yellow-400/10 text-yellow-400'}`}>{d.dns_fully_verified ? 'DNS OK' : 'DNS Pending'}</span>
|
||||||
{d.blacklisted && <span className="px-1.5 py-0.5 bg-red-400/10 text-red-400 rounded text-xs">Blacklisted</span>}
|
{d.blacklisted && <span className="px-1.5 py-0.5 bg-red-400/10 text-red-400 rounded text-xs">Blacklisted</span>}
|
||||||
{d.bypass_verification && <span className="px-1.5 py-0.5 bg-yellow-400/10 text-yellow-400 rounded text-xs">Bypass Active</span>}
|
{d.bypass_verification && <span className="px-1.5 py-0.5 bg-yellow-400/10 text-yellow-400 rounded text-xs">Bypass Active</span>}
|
||||||
<span className={`px-1.5 py-0.5 rounded text-xs ${d.is_active ? 'bg-green-400/10 text-green-400' : 'bg-[#1a1a1a] text-[#555]'}`}>{d.is_active ? 'Active' : 'Inactive'}</span>
|
<span className={`px-1.5 py-0.5 rounded text-xs ${d.is_active ? 'bg-green-400/10 text-green-400' : 'bg-[#FFFFFF] text-[#555]'}`}>{d.is_active ? 'Active' : 'Inactive'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 mt-1 text-xs text-[#555]">
|
<div className="flex gap-3 mt-1 text-xs text-[#555]">
|
||||||
<span>Health: {d.health_score}/100</span>
|
<span>Health: {d.health_score}/100</span>
|
||||||
@@ -167,7 +167,7 @@ function DomainsContent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{expanded === d.id && (
|
{expanded === d.id && (
|
||||||
<div className="border-t border-[#1a1a1a] px-4 py-3 space-y-3">
|
<div className="border-t border-[#FFFFFF] px-4 py-3 space-y-3">
|
||||||
{/* DNS checks */}
|
{/* DNS checks */}
|
||||||
<div className="grid grid-cols-2 gap-x-6">
|
<div className="grid grid-cols-2 gap-x-6">
|
||||||
<DnsRow label="SPF" valid={d.spf_valid} checkedAt={d.spf_checked_at} />
|
<DnsRow label="SPF" valid={d.spf_valid} checkedAt={d.spf_checked_at} />
|
||||||
@@ -178,7 +178,7 @@ function DomainsContent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* DNS helper panel */}
|
{/* DNS helper panel */}
|
||||||
<div className="bg-[#0a0a0a] border border-[#1a1a1a] rounded p-3 space-y-2">
|
<div className="bg-[#0a0a0a] border border-[#FFFFFF] rounded p-3 space-y-2">
|
||||||
<p className="text-xs font-semibold text-[#888]">DNS Record Helper (GoDaddy)</p>
|
<p className="text-xs font-semibold text-[#888]">DNS Record Helper (GoDaddy)</p>
|
||||||
<div className="space-y-1 text-xs text-[#555] font-mono">
|
<div className="space-y-1 text-xs text-[#555] font-mono">
|
||||||
<p>SPF: <span className="text-[#888]">v=spf1 include:sendgrid.net ~all</span></p>
|
<p>SPF: <span className="text-[#888]">v=spf1 include:sendgrid.net ~all</span></p>
|
||||||
@@ -197,10 +197,10 @@ function DomainsContent() {
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<button onClick={() => runVerify(d.id)} disabled={verifying === d.id} className="px-3 py-1.5 bg-[#1a2535] border border-[#2a3a50] rounded text-xs text-[#3b82f6] hover:bg-[#0d1a2d] disabled:opacity-50">
|
<button onClick={() => runVerify(d.id)} disabled={verifying === d.id} className="px-3 py-1.5 bg-[#FFFFFF] border border-[#2a3a50] rounded text-xs text-[#1D4ED8] hover:bg-[#0d1a2d] disabled:opacity-50">
|
||||||
{verifying === d.id ? 'Verifying…' : 'Run Full Verification'}
|
{verifying === d.id ? 'Verifying…' : 'Run Full Verification'}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => toggleActive(d.id, d.is_active)} disabled={!d.dns_fully_verified && !d.bypass_verification && !d.is_active} className="px-3 py-1.5 bg-[#1a1a1a] border border-[#252525] rounded text-xs text-[#888] hover:text-white disabled:opacity-30">
|
<button onClick={() => toggleActive(d.id, d.is_active)} disabled={!d.dns_fully_verified && !d.bypass_verification && !d.is_active} className="px-3 py-1.5 bg-[#FFFFFF] border border-[#DCE6F2] rounded text-xs text-[#888] hover:text-white disabled:opacity-30">
|
||||||
{d.is_active ? 'Deactivate' : 'Activate'}
|
{d.is_active ? 'Deactivate' : 'Activate'}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setShowBypass(d.id)} className="px-3 py-1.5 bg-yellow-900/30 border border-yellow-400/20 rounded text-xs text-yellow-400 hover:bg-yellow-900/50">
|
<button onClick={() => setShowBypass(d.id)} className="px-3 py-1.5 bg-yellow-900/30 border border-yellow-400/20 rounded text-xs text-yellow-400 hover:bg-yellow-900/50">
|
||||||
@@ -219,7 +219,7 @@ function DomainsContent() {
|
|||||||
|
|
||||||
export default function EmailDomainsPage() {
|
export default function EmailDomainsPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /></div>}>
|
<Suspense fallback={<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" /></div>}>
|
||||||
<DomainsContent />
|
<DomainsContent />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const SITES = ['avbeat','avbeat','backlotbeat','broadcastengineering'];
|
|||||||
|
|
||||||
function DnsCheck({ label, valid }: { label: string; valid: boolean | null }) {
|
function DnsCheck({ label, valid }: { label: string; valid: boolean | null }) {
|
||||||
return (
|
return (
|
||||||
<span className={`inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded ${valid === true ? 'bg-green-400/10 text-green-400' : valid === false ? 'bg-red-400/10 text-red-400' : 'bg-[#1a1a1a] text-[#555]'}`}>
|
<span className={`inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded ${valid === true ? 'bg-green-400/10 text-green-400' : valid === false ? 'bg-red-400/10 text-red-400' : 'bg-[#FFFFFF] text-[#555]'}`}>
|
||||||
{valid === true ? '✓' : valid === false ? '✗' : '?'} {label}
|
{valid === true ? '✓' : valid === false ? '✗' : '?'} {label}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -42,7 +42,7 @@ export default function EmailDashboard() {
|
|||||||
domains: domains.filter((d: any) => d.site === site),
|
domains: domains.filter((d: any) => d.site === site),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /></div>;
|
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" /></div>;
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -61,7 +61,7 @@ export default function EmailDashboard() {
|
|||||||
{ label: 'Total Suppressions', value: stats?.total_suppressions ?? 0, color: 'text-red-400' },
|
{ label: 'Total Suppressions', value: stats?.total_suppressions ?? 0, color: 'text-red-400' },
|
||||||
{ label: 'Blacklisted Domains', value: stats?.blacklisted ?? 0, color: 'text-red-400' },
|
{ label: 'Blacklisted Domains', value: stats?.blacklisted ?? 0, color: 'text-red-400' },
|
||||||
].map(c => (
|
].map(c => (
|
||||||
<div key={c.label} className="bg-[#111] border border-[#252525] rounded-lg p-4">
|
<div key={c.label} className="bg-[#111] border border-[#DCE6F2] rounded-lg p-4">
|
||||||
<p className={`text-2xl font-bold ${c.color}`}>{c.value}</p>
|
<p className={`text-2xl font-bold ${c.color}`}>{c.value}</p>
|
||||||
<p className="text-xs text-[#888] mt-1">{c.label}</p>
|
<p className="text-xs text-[#888] mt-1">{c.label}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,10 +74,10 @@ export default function EmailDashboard() {
|
|||||||
const primary = siteDomains.find((d: any) => d.is_primary);
|
const primary = siteDomains.find((d: any) => d.is_primary);
|
||||||
const suppCount = stats?.suppressions_by_site?.[site] ?? 0;
|
const suppCount = stats?.suppressions_by_site?.[site] ?? 0;
|
||||||
return (
|
return (
|
||||||
<div key={site} className="bg-[#111] border border-[#252525] rounded-lg p-4 space-y-3">
|
<div key={site} className="bg-[#111] border border-[#DCE6F2] rounded-lg p-4 space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold capitalize">{site}</h3>
|
<h3 className="text-sm font-semibold capitalize">{site}</h3>
|
||||||
<Link href={`/admin/email/domains?site=${site}`} className="text-xs text-[#3b82f6] hover:underline">Manage →</Link>
|
<Link href={`/admin/email/domains?site=${site}`} className="text-xs text-[#1D4ED8] hover:underline">Manage →</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-[#888]">
|
<div className="text-xs text-[#888]">
|
||||||
<span className="text-white">{primary?.domain ?? 'No primary'}</span>
|
<span className="text-white">{primary?.domain ?? 'No primary'}</span>
|
||||||
@@ -113,7 +113,7 @@ export default function EmailDashboard() {
|
|||||||
{ label: 'Suppressions', href: '/admin/email/suppressions' },
|
{ label: 'Suppressions', href: '/admin/email/suppressions' },
|
||||||
{ label: 'Send Log', href: '/admin/email/send-log' },
|
{ label: 'Send Log', href: '/admin/email/send-log' },
|
||||||
].map(l => (
|
].map(l => (
|
||||||
<Link key={l.href} href={l.href} className="bg-[#111] border border-[#252525] rounded-lg p-3 text-center text-sm text-[#888] hover:text-white hover:border-[#3b82f6] transition-colors">
|
<Link key={l.href} href={l.href} className="bg-[#111] border border-[#DCE6F2] rounded-lg p-3 text-center text-sm text-[#888] hover:text-white hover:border-[#1D4ED8] transition-colors">
|
||||||
{l.label}
|
{l.label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default function SendLogPage() {
|
|||||||
|
|
||||||
useEffect(() => { if (user) load(); }, [user, load]);
|
useEffect(() => { if (user) load(); }, [user, load]);
|
||||||
|
|
||||||
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /></div>;
|
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" /></div>;
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -41,20 +41,20 @@ export default function SendLogPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<select value={filterSite} onChange={e => setFilterSite(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-[#888] focus:outline-none">
|
<select value={filterSite} onChange={e => setFilterSite(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#DCE6F2] rounded text-xs text-[#888] focus:outline-none">
|
||||||
<option value="">All Sites</option>
|
<option value="">All Sites</option>
|
||||||
{SITES.map(s => <option key={s} value={s}>{s}</option>)}
|
{SITES.map(s => <option key={s} value={s}>{s}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<select value={filterStatus} onChange={e => setFilterStatus(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-[#888] focus:outline-none">
|
<select value={filterStatus} onChange={e => setFilterStatus(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#DCE6F2] rounded text-xs text-[#888] focus:outline-none">
|
||||||
<option value="">All Statuses</option>
|
<option value="">All Statuses</option>
|
||||||
{['queued','sent','failed','suppressed'].map(s => <option key={s} value={s}>{s}</option>)}
|
{['queued','sent','failed','suppressed'].map(s => <option key={s} value={s}>{s}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-x-auto">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[#1a1a1a]">
|
<tr className="border-b border-[#FFFFFF]">
|
||||||
{['Site','Recipient','Subject','Type','Domain','Status','Sent At','Opens','Clicks'].map(h => (
|
{['Site','Recipient','Subject','Type','Domain','Status','Sent At','Opens','Clicks'].map(h => (
|
||||||
<th key={h} className="px-3 py-2 text-left text-xs text-[#555] font-medium whitespace-nowrap">{h}</th>
|
<th key={h} className="px-3 py-2 text-left text-xs text-[#555] font-medium whitespace-nowrap">{h}</th>
|
||||||
))}
|
))}
|
||||||
@@ -66,14 +66,14 @@ export default function SendLogPage() {
|
|||||||
) : logs.length === 0 ? (
|
) : logs.length === 0 ? (
|
||||||
<tr><td colSpan={9} className="px-4 py-8 text-center text-[#555] text-xs">No send log entries</td></tr>
|
<tr><td colSpan={9} className="px-4 py-8 text-center text-[#555] text-xs">No send log entries</td></tr>
|
||||||
) : logs.map((l: any) => (
|
) : logs.map((l: any) => (
|
||||||
<tr key={l.id} className="border-b border-[#1a1a1a] hover:bg-[#161616]">
|
<tr key={l.id} className="border-b border-[#FFFFFF] hover:bg-[#FFFFFF]">
|
||||||
<td className="px-3 py-2 text-[#888] text-xs capitalize">{l.site}</td>
|
<td className="px-3 py-2 text-[#888] text-xs capitalize">{l.site}</td>
|
||||||
<td className="px-3 py-2 text-white text-xs">{l.recipient_email}</td>
|
<td className="px-3 py-2 text-white text-xs">{l.recipient_email}</td>
|
||||||
<td className="px-3 py-2 text-[#ccc] text-xs max-w-xs truncate">{l.subject ?? '—'}</td>
|
<td className="px-3 py-2 text-[#ccc] text-xs max-w-xs truncate">{l.subject ?? '—'}</td>
|
||||||
<td className="px-3 py-2 text-[#555] text-xs">{l.send_type}</td>
|
<td className="px-3 py-2 text-[#555] text-xs">{l.send_type}</td>
|
||||||
<td className="px-3 py-2 text-[#555] text-xs">{l.rmp_email_sending_domains?.domain ?? '—'}</td>
|
<td className="px-3 py-2 text-[#555] text-xs">{l.rmp_email_sending_domains?.domain ?? '—'}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<span className={`px-2 py-0.5 rounded-full text-xs ${l.status === 'sent' ? 'bg-green-400/10 text-green-400' : l.status === 'failed' ? 'bg-red-400/10 text-red-400' : l.status === 'suppressed' ? 'bg-yellow-400/10 text-yellow-400' : 'bg-[#1a1a1a] text-[#555]'}`}>{l.status}</span>
|
<span className={`px-2 py-0.5 rounded-full text-xs ${l.status === 'sent' ? 'bg-green-400/10 text-green-400' : l.status === 'failed' ? 'bg-red-400/10 text-red-400' : l.status === 'suppressed' ? 'bg-yellow-400/10 text-yellow-400' : 'bg-[#FFFFFF] text-[#555]'}`}>{l.status}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-[#555] text-xs">{l.sent_at ? new Date(l.sent_at).toLocaleString() : '—'}</td>
|
<td className="px-3 py-2 text-[#555] text-xs">{l.sent_at ? new Date(l.sent_at).toLocaleString() : '—'}</td>
|
||||||
<td className="px-3 py-2 text-[#888] text-xs">{l.opens}</td>
|
<td className="px-3 py-2 text-[#888] text-xs">{l.opens}</td>
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export default function SuppressionsPage() {
|
|||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /></div>;
|
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" /></div>;
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -84,29 +84,29 @@ export default function SuppressionsPage() {
|
|||||||
<Link href="/admin/email" className="text-xs text-[#555] hover:text-[#888]">← Email</Link>
|
<Link href="/admin/email" className="text-xs text-[#555] hover:text-[#888]">← Email</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button onClick={() => setShowAddForm(true)} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500">+ Add Suppression</button>
|
<button onClick={() => setShowAddForm(true)} className="px-3 py-1.5 bg-[#1D4ED8] rounded text-xs font-medium hover:bg-blue-500">+ Add Suppression</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Site selector */}
|
{/* Site selector */}
|
||||||
<div className="flex gap-1 flex-wrap">
|
<div className="flex gap-1 flex-wrap">
|
||||||
{SITES.map(s => (
|
{SITES.map(s => (
|
||||||
<button key={s} onClick={() => setSite(s)} className={`px-3 py-1 rounded text-xs capitalize ${site === s ? 'bg-[#3b82f6] text-white' : 'bg-[#111] border border-[#252525] text-[#888]'}`}>{s}</button>
|
<button key={s} onClick={() => setSite(s)} className={`px-3 py-1 rounded text-xs capitalize ${site === s ? 'bg-[#1D4ED8] text-white' : 'bg-[#111] border border-[#DCE6F2] text-[#888]'}`}>{s}</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
{stats && (
|
{stats && (
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-3">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg p-3">
|
||||||
<p className="text-xl font-bold text-red-400">{stats.total ?? 0}</p>
|
<p className="text-xl font-bold text-red-400">{stats.total ?? 0}</p>
|
||||||
<p className="text-xs text-[#888] mt-0.5">Total Suppressed</p>
|
<p className="text-xs text-[#888] mt-0.5">Total Suppressed</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-3">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg p-3">
|
||||||
<p className="text-xl font-bold text-yellow-400">{stats.added_this_month ?? 0}</p>
|
<p className="text-xl font-bold text-yellow-400">{stats.added_this_month ?? 0}</p>
|
||||||
<p className="text-xs text-[#888] mt-0.5">Added This Month</p>
|
<p className="text-xs text-[#888] mt-0.5">Added This Month</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-3">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg p-3">
|
||||||
<p className="text-xl font-bold text-orange-400">{stats.hostile_replies ?? 0}</p>
|
<p className="text-xl font-bold text-orange-400">{stats.hostile_replies ?? 0}</p>
|
||||||
<p className="text-xs text-[#888] mt-0.5">Hostile Replies</p>
|
<p className="text-xs text-[#888] mt-0.5">Hostile Replies</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,44 +115,44 @@ export default function SuppressionsPage() {
|
|||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<select value={filterType} onChange={e => setFilterType(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-[#888] focus:outline-none">
|
<select value={filterType} onChange={e => setFilterType(e.target.value)} className="px-2 py-1 bg-[#111] border border-[#DCE6F2] rounded text-xs text-[#888] focus:outline-none">
|
||||||
<option value="">All Types</option>
|
<option value="">All Types</option>
|
||||||
{SUPP_TYPES.map(t => <option key={t} value={t}>{t.replace(/_/g, ' ')}</option>)}
|
{SUPP_TYPES.map(t => <option key={t} value={t}>{t.replace(/_/g, ' ')}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search email…" className="px-2 py-1 bg-[#111] border border-[#252525] rounded text-xs text-white placeholder-[#555] focus:outline-none focus:border-[#3b82f6]" />
|
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search email…" className="px-2 py-1 bg-[#111] border border-[#DCE6F2] rounded text-xs text-white placeholder-[#555] focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showAddForm && (
|
{showAddForm && (
|
||||||
<form onSubmit={handleAddSuppression} className="bg-[#111] border border-[#252525] rounded-lg p-4 space-y-3">
|
<form onSubmit={handleAddSuppression} className="bg-[#111] border border-[#DCE6F2] rounded-lg p-4 space-y-3">
|
||||||
<h3 className="text-sm font-semibold">Add Manual Suppression</h3>
|
<h3 className="text-sm font-semibold">Add Manual Suppression</h3>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Email *</label>
|
<label className="text-xs text-[#888] block mb-1">Email *</label>
|
||||||
<input required type="email" value={addForm.email} onChange={e => setAddForm(p => ({ ...p, email: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
|
<input required type="email" value={addForm.email} onChange={e => setAddForm(p => ({ ...p, email: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Type</label>
|
<label className="text-xs text-[#888] block mb-1">Type</label>
|
||||||
<select value={addForm.suppression_type} onChange={e => setAddForm(p => ({ ...p, suppression_type: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none">
|
<select value={addForm.suppression_type} onChange={e => setAddForm(p => ({ ...p, suppression_type: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none">
|
||||||
{SUPP_TYPES.map(t => <option key={t} value={t}>{t.replace(/_/g, ' ')}</option>)}
|
{SUPP_TYPES.map(t => <option key={t} value={t}>{t.replace(/_/g, ' ')}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<label className="text-xs text-[#888] block mb-1">Notes</label>
|
<label className="text-xs text-[#888] block mb-1">Notes</label>
|
||||||
<input value={addForm.notes} onChange={e => setAddForm(p => ({ ...p, notes: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
|
<input value={addForm.notes} onChange={e => setAddForm(p => ({ ...p, notes: e.target.value }))} className="w-full px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button type="submit" disabled={saving} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500 disabled:opacity-50">{saving ? 'Saving…' : 'Add'}</button>
|
<button type="submit" disabled={saving} className="px-3 py-1.5 bg-[#1D4ED8] rounded text-xs font-medium hover:bg-blue-500 disabled:opacity-50">{saving ? 'Saving…' : 'Add'}</button>
|
||||||
<button type="button" onClick={() => setShowAddForm(false)} className="px-3 py-1.5 bg-[#1a1a1a] border border-[#252525] rounded text-xs text-[#888] hover:text-white">Cancel</button>
|
<button type="button" onClick={() => setShowAddForm(false)} className="px-3 py-1.5 bg-[#FFFFFF] border border-[#DCE6F2] rounded text-xs text-[#888] hover:text-white">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Suppressions table */}
|
{/* Suppressions table */}
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-x-auto">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[#1a1a1a]">
|
<tr className="border-b border-[#FFFFFF]">
|
||||||
{['Email','Type','Source','Reply Text','Domain','Date',''].map(h => (
|
{['Email','Type','Source','Reply Text','Domain','Date',''].map(h => (
|
||||||
<th key={h} className="px-3 py-2 text-left text-xs text-[#555] font-medium">{h}</th>
|
<th key={h} className="px-3 py-2 text-left text-xs text-[#555] font-medium">{h}</th>
|
||||||
))}
|
))}
|
||||||
@@ -164,10 +164,10 @@ export default function SuppressionsPage() {
|
|||||||
) : suppressions.length === 0 ? (
|
) : suppressions.length === 0 ? (
|
||||||
<tr><td colSpan={7} className="px-4 py-8 text-center text-[#555] text-xs">No suppressions found</td></tr>
|
<tr><td colSpan={7} className="px-4 py-8 text-center text-[#555] text-xs">No suppressions found</td></tr>
|
||||||
) : suppressions.map((s: any) => (
|
) : suppressions.map((s: any) => (
|
||||||
<tr key={s.id} className="border-b border-[#1a1a1a] hover:bg-[#161616]">
|
<tr key={s.id} className="border-b border-[#FFFFFF] hover:bg-[#FFFFFF]">
|
||||||
<td className="px-3 py-2 text-white text-xs">{s.email}</td>
|
<td className="px-3 py-2 text-white text-xs">{s.email}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<span className={`px-2 py-0.5 rounded-full text-xs ${s.suppression_type === 'hostile_reply' ? 'bg-red-400/10 text-red-400' : s.suppression_type === 'unsubscribe' ? 'bg-yellow-400/10 text-yellow-400' : 'bg-[#1a1a1a] text-[#555]'}`}>{s.suppression_type?.replace(/_/g, ' ')}</span>
|
<span className={`px-2 py-0.5 rounded-full text-xs ${s.suppression_type === 'hostile_reply' ? 'bg-red-400/10 text-red-400' : s.suppression_type === 'unsubscribe' ? 'bg-yellow-400/10 text-yellow-400' : 'bg-[#FFFFFF] text-[#555]'}`}>{s.suppression_type?.replace(/_/g, ' ')}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-[#555] text-xs">{s.source}</td>
|
<td className="px-3 py-2 text-[#555] text-xs">{s.source}</td>
|
||||||
<td className="px-3 py-2 text-[#555] text-xs max-w-xs truncate">{s.reply_text ?? '—'}</td>
|
<td className="px-3 py-2 text-[#555] text-xs max-w-xs truncate">{s.reply_text ?? '—'}</td>
|
||||||
@@ -183,40 +183,40 @@ export default function SuppressionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Keyword Management */}
|
{/* Keyword Management */}
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-hidden">
|
||||||
<div className="px-4 py-3 border-b border-[#252525] flex items-center justify-between">
|
<div className="px-4 py-3 border-b border-[#DCE6F2] flex items-center justify-between">
|
||||||
<h2 className="text-sm font-semibold">Suppression Keywords</h2>
|
<h2 className="text-sm font-semibold">Suppression Keywords</h2>
|
||||||
<button onClick={() => setShowKeywordForm(true)} className="text-xs text-[#3b82f6] hover:underline">+ Add Keyword</button>
|
<button onClick={() => setShowKeywordForm(true)} className="text-xs text-[#1D4ED8] hover:underline">+ Add Keyword</button>
|
||||||
</div>
|
</div>
|
||||||
{showKeywordForm && (
|
{showKeywordForm && (
|
||||||
<form onSubmit={handleAddKeyword} className="px-4 py-3 border-b border-[#1a1a1a] flex gap-2 items-end">
|
<form onSubmit={handleAddKeyword} className="px-4 py-3 border-b border-[#FFFFFF] flex gap-2 items-end">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Keyword *</label>
|
<label className="text-xs text-[#888] block mb-1">Keyword *</label>
|
||||||
<input required value={keywordForm.keyword} onChange={e => setKeywordForm(p => ({ ...p, keyword: e.target.value }))} className="px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none focus:border-[#3b82f6]" />
|
<input required value={keywordForm.keyword} onChange={e => setKeywordForm(p => ({ ...p, keyword: e.target.value }))} className="px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none focus:border-[#1D4ED8]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-[#888] block mb-1">Type</label>
|
<label className="text-xs text-[#888] block mb-1">Type</label>
|
||||||
<select value={keywordForm.keyword_type} onChange={e => setKeywordForm(p => ({ ...p, keyword_type: e.target.value }))} className="px-2 py-1.5 bg-[#0a0a0a] border border-[#252525] rounded text-xs text-white focus:outline-none">
|
<select value={keywordForm.keyword_type} onChange={e => setKeywordForm(p => ({ ...p, keyword_type: e.target.value }))} className="px-2 py-1.5 bg-[#0a0a0a] border border-[#DCE6F2] rounded text-xs text-white focus:outline-none">
|
||||||
<option value="unsubscribe">unsubscribe</option>
|
<option value="unsubscribe">unsubscribe</option>
|
||||||
<option value="hostile">hostile</option>
|
<option value="hostile">hostile</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" disabled={saving} className="px-3 py-1.5 bg-[#3b82f6] rounded text-xs font-medium hover:bg-blue-500 disabled:opacity-50">Add</button>
|
<button type="submit" disabled={saving} className="px-3 py-1.5 bg-[#1D4ED8] rounded text-xs font-medium hover:bg-blue-500 disabled:opacity-50">Add</button>
|
||||||
<button type="button" onClick={() => setShowKeywordForm(false)} className="px-3 py-1.5 bg-[#1a1a1a] border border-[#252525] rounded text-xs text-[#888]">Cancel</button>
|
<button type="button" onClick={() => setShowKeywordForm(false)} className="px-3 py-1.5 bg-[#FFFFFF] border border-[#DCE6F2] rounded text-xs text-[#888]">Cancel</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead><tr className="border-b border-[#1a1a1a]">{['Keyword','Type','Active',''].map(h => <th key={h} className="px-4 py-2 text-left text-xs text-[#555]">{h}</th>)}</tr></thead>
|
<thead><tr className="border-b border-[#FFFFFF]">{['Keyword','Type','Active',''].map(h => <th key={h} className="px-4 py-2 text-left text-xs text-[#555]">{h}</th>)}</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{keywords.map((k: any) => (
|
{keywords.map((k: any) => (
|
||||||
<tr key={k.id} className="border-b border-[#1a1a1a] hover:bg-[#161616]">
|
<tr key={k.id} className="border-b border-[#FFFFFF] hover:bg-[#FFFFFF]">
|
||||||
<td className="px-4 py-2 text-white text-xs">{k.keyword}</td>
|
<td className="px-4 py-2 text-white text-xs">{k.keyword}</td>
|
||||||
<td className="px-4 py-2 text-[#888] text-xs">{k.keyword_type}</td>
|
<td className="px-4 py-2 text-[#888] text-xs">{k.keyword_type}</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<span className={`px-2 py-0.5 rounded-full text-xs ${k.active ? 'bg-green-400/10 text-green-400' : 'bg-[#1a1a1a] text-[#555]'}`}>{k.active ? 'Active' : 'Inactive'}</span>
|
<span className={`px-2 py-0.5 rounded-full text-xs ${k.active ? 'bg-green-400/10 text-green-400' : 'bg-[#FFFFFF] text-[#555]'}`}>{k.active ? 'Active' : 'Inactive'}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<button onClick={() => toggleKeyword(k.id, k.active)} className="text-xs text-[#3b82f6] hover:underline">{k.active ? 'Deactivate' : 'Activate'}</button>
|
<button onClick={() => toggleKeyword(k.id, k.active)} className="text-xs text-[#1D4ED8] hover:underline">{k.active ? 'Deactivate' : 'Activate'}</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ interface StatCardProps {
|
|||||||
|
|
||||||
function StatCard({ label, value, sub, accentColor, bgColor, icon, badge }: StatCardProps) {
|
function StatCard({ label, value, sub, accentColor, bgColor, icon, badge }: StatCardProps) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-5 flex flex-col gap-3 hover:border-[#333] transition-colors">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg p-5 flex flex-col gap-3 hover:border-[#333] transition-colors">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${bgColor}`}>
|
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${bgColor}`}>
|
||||||
<span className={accentColor}>{icon}</span>
|
<span className={accentColor}>{icon}</span>
|
||||||
@@ -116,7 +116,7 @@ function StatCard({ label, value, sub, accentColor, bgColor, icon, badge }: Stat
|
|||||||
function ChartTooltip({ active, payload, label }: any) {
|
function ChartTooltip({ active, payload, label }: any) {
|
||||||
if (!active || !payload?.length) return null;
|
if (!active || !payload?.length) return null;
|
||||||
return (
|
return (
|
||||||
<div className="bg-[#1a1a1a] border border-[#333] rounded-lg px-3 py-2 text-xs font-body shadow-xl">
|
<div className="bg-[#FFFFFF] border border-[#333] rounded-lg px-3 py-2 text-xs font-body shadow-xl">
|
||||||
{label && <p className="text-[#888] mb-1">{label}</p>}
|
{label && <p className="text-[#888] mb-1">{label}</p>}
|
||||||
{payload.map((entry: any, i: number) => (
|
{payload.map((entry: any, i: number) => (
|
||||||
<p key={i} style={{ color: entry.color }} className="leading-5">
|
<p key={i} style={{ color: entry.color }} className="leading-5">
|
||||||
@@ -167,7 +167,7 @@ const EMPTY_STATS: ForumStats = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const CATEGORY_COLORS = ['#3b82f6', '#f59e0b', '#10b981', '#8b5cf6', '#ef4444', '#06b6d4', '#f97316', '#84cc16'];
|
const CATEGORY_COLORS = ['#1D4ED8', '#f59e0b', '#10b981', '#8b5cf6', '#ef4444', '#06b6d4', '#f97316', '#84cc16'];
|
||||||
|
|
||||||
export default function ForumDashboardPage() {
|
export default function ForumDashboardPage() {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
@@ -204,7 +204,7 @@ export default function ForumDashboardPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
<div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
<p className="text-[#555] text-sm font-body">Loading forum dashboard…</p>
|
<p className="text-[#555] text-sm font-body">Loading forum dashboard…</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -236,7 +236,7 @@ export default function ForumDashboardPage() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={fetchStats}
|
onClick={fetchStats}
|
||||||
className="flex items-center gap-2 px-3 py-2 bg-[#111] border border-[#252525] rounded-lg text-xs text-[#888] hover:text-white hover:border-[#333] transition-all font-body"
|
className="flex items-center gap-2 px-3 py-2 bg-[#111] border border-[#DCE6F2] rounded-lg text-xs text-[#888] hover:text-white hover:border-[#333] transition-all font-body"
|
||||||
>
|
>
|
||||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
@@ -245,7 +245,7 @@ export default function ForumDashboardPage() {
|
|||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
href="/admin/forum-moderation"
|
href="/admin/forum-moderation"
|
||||||
className="flex items-center gap-2 px-3 py-2 bg-[#3b82f6] rounded-lg text-xs text-white hover:bg-blue-500 transition-colors font-body font-semibold"
|
className="flex items-center gap-2 px-3 py-2 bg-[#1D4ED8] rounded-lg text-xs text-white hover:bg-blue-500 transition-colors font-body font-semibold"
|
||||||
>
|
>
|
||||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
@@ -386,7 +386,7 @@ export default function ForumDashboardPage() {
|
|||||||
|
|
||||||
{/* Thread Growth Chart */}
|
{/* Thread Growth Chart */}
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-6">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg p-6">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Thread Growth"
|
title="Thread Growth"
|
||||||
subtitle="Weekly new threads and replies over the past 8 weeks"
|
subtitle="Weekly new threads and replies over the past 8 weeks"
|
||||||
@@ -398,7 +398,7 @@ export default function ForumDashboardPage() {
|
|||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={240}>
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
<LineChart data={weeklyGrowth} margin={{ top: 5, right: 10, left: -20, bottom: 5 }}>
|
<LineChart data={weeklyGrowth} margin={{ top: 5, right: 10, left: -20, bottom: 5 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#1a1a1a" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#FFFFFF" />
|
||||||
<XAxis dataKey="week" tick={{ fill: '#555', fontSize: 11 }} axisLine={false} tickLine={false} />
|
<XAxis dataKey="week" tick={{ fill: '#555', fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||||
<YAxis tick={{ fill: '#555', fontSize: 11 }} axisLine={false} tickLine={false} />
|
<YAxis tick={{ fill: '#555', fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||||
<Tooltip content={<ChartTooltip />} />
|
<Tooltip content={<ChartTooltip />} />
|
||||||
@@ -407,7 +407,7 @@ export default function ForumDashboardPage() {
|
|||||||
iconType="circle"
|
iconType="circle"
|
||||||
iconSize={8}
|
iconSize={8}
|
||||||
/>
|
/>
|
||||||
<Line type="monotone" dataKey="threads" stroke="#3b82f6" strokeWidth={2} dot={{ fill: '#3b82f6', r: 3 }} name="New Threads" />
|
<Line type="monotone" dataKey="threads" stroke="#1D4ED8" strokeWidth={2} dot={{ fill: '#1D4ED8', r: 3 }} name="New Threads" />
|
||||||
<Line type="monotone" dataKey="replies" stroke="#10b981" strokeWidth={2} dot={{ fill: '#10b981', r: 3 }} name="New Replies" />
|
<Line type="monotone" dataKey="replies" stroke="#10b981" strokeWidth={2} dot={{ fill: '#10b981', r: 3 }} name="New Replies" />
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -419,33 +419,33 @@ export default function ForumDashboardPage() {
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||||
|
|
||||||
{/* Top Categories */}
|
{/* Top Categories */}
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-hidden">
|
||||||
<div className="px-5 py-4 border-b border-[#1a1a1a] flex items-center justify-between">
|
<div className="px-5 py-4 border-b border-[#FFFFFF] flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-display font-bold text-white">Top Categories</h2>
|
<h2 className="text-sm font-display font-bold text-white">Top Categories</h2>
|
||||||
<p className="text-xs text-[#555] font-body mt-0.5">By thread count</p>
|
<p className="text-xs text-[#555] font-body mt-0.5">By thread count</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/forum" className="text-xs text-[#3b82f6] hover:text-blue-300 font-body transition-colors">View forum →</Link>
|
<Link href="/forum" className="text-xs text-[#1D4ED8] hover:text-blue-300 font-body transition-colors">View forum →</Link>
|
||||||
</div>
|
</div>
|
||||||
{topCategories.length === 0 ? (
|
{topCategories.length === 0 ? (
|
||||||
<div className="px-5 py-10 text-center">
|
<div className="px-5 py-10 text-center">
|
||||||
<p className="text-[#555] text-sm font-body">No categories found</p>
|
<p className="text-[#555] text-sm font-body">No categories found</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-[#1a1a1a]">
|
<div className="divide-y divide-[#FFFFFF]">
|
||||||
{topCategories.map((cat, idx) => {
|
{topCategories.map((cat, idx) => {
|
||||||
const maxThreads = topCategories[0]?.thread_count ?? 1;
|
const maxThreads = topCategories[0]?.thread_count ?? 1;
|
||||||
const pct = maxThreads > 0 ? Math.round((cat.thread_count / maxThreads) * 100) : 0;
|
const pct = maxThreads > 0 ? Math.round((cat.thread_count / maxThreads) * 100) : 0;
|
||||||
const color = CATEGORY_COLORS[idx % CATEGORY_COLORS.length];
|
const color = CATEGORY_COLORS[idx % CATEGORY_COLORS.length];
|
||||||
return (
|
return (
|
||||||
<div key={cat.id} className="px-5 py-3 hover:bg-[#0d0d0d] transition-colors">
|
<div key={cat.id} className="px-5 py-3 hover:bg-[#F8FAFC] transition-colors">
|
||||||
<div className="flex items-center gap-3 mb-1.5">
|
<div className="flex items-center gap-3 mb-1.5">
|
||||||
<span className="text-base leading-none">{cat.icon}</span>
|
<span className="text-base leading-none">{cat.icon}</span>
|
||||||
<span className="text-sm text-white font-body flex-1 truncate">{cat.name}</span>
|
<span className="text-sm text-white font-body flex-1 truncate">{cat.name}</span>
|
||||||
<span className="text-xs text-[#888] font-body font-semibold">{cat.thread_count} threads</span>
|
<span className="text-xs text-[#888] font-body font-semibold">{cat.thread_count} threads</span>
|
||||||
<span className="text-xs text-[#555] font-body">{cat.post_count} posts</span>
|
<span className="text-xs text-[#555] font-body">{cat.post_count} posts</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1.5 bg-[#1a1a1a] rounded-full overflow-hidden ml-7">
|
<div className="h-1.5 bg-[#FFFFFF] rounded-full overflow-hidden ml-7">
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full transition-all duration-500"
|
className="h-full rounded-full transition-all duration-500"
|
||||||
style={{ width: `${pct}%`, backgroundColor: color }}
|
style={{ width: `${pct}%`, backgroundColor: color }}
|
||||||
@@ -459,8 +459,8 @@ export default function ForumDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Active Users */}
|
{/* Active Users */}
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-hidden">
|
||||||
<div className="px-5 py-4 border-b border-[#1a1a1a]">
|
<div className="px-5 py-4 border-b border-[#FFFFFF]">
|
||||||
<h2 className="text-sm font-display font-bold text-white">Active Users</h2>
|
<h2 className="text-sm font-display font-bold text-white">Active Users</h2>
|
||||||
<p className="text-xs text-[#555] font-body mt-0.5">Most active in the last 30 days</p>
|
<p className="text-xs text-[#555] font-body mt-0.5">Most active in the last 30 days</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -469,13 +469,13 @@ export default function ForumDashboardPage() {
|
|||||||
<p className="text-[#555] text-sm font-body">No active users found</p>
|
<p className="text-[#555] text-sm font-body">No active users found</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-[#1a1a1a]">
|
<div className="divide-y divide-[#FFFFFF]">
|
||||||
{activeUsers.map((u, idx) => (
|
{activeUsers.map((u, idx) => (
|
||||||
<div key={u.author_id} className="px-5 py-3 flex items-center gap-3 hover:bg-[#0d0d0d] transition-colors">
|
<div key={u.author_id} className="px-5 py-3 flex items-center gap-3 hover:bg-[#F8FAFC] transition-colors">
|
||||||
<div className="w-6 h-6 rounded-full bg-[#1a1a1a] flex items-center justify-center flex-shrink-0 text-xs font-bold font-body text-[#555]">
|
<div className="w-6 h-6 rounded-full bg-[#FFFFFF] flex items-center justify-center flex-shrink-0 text-xs font-bold font-body text-[#555]">
|
||||||
{idx + 1}
|
{idx + 1}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-[#3b82f6] to-[#8b5cf6] flex items-center justify-center flex-shrink-0">
|
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-[#1D4ED8] to-[#8b5cf6] flex items-center justify-center flex-shrink-0">
|
||||||
<span className="text-xs font-bold text-white font-body">
|
<span className="text-xs font-bold text-white font-body">
|
||||||
{u.author_name.charAt(0).toUpperCase()}
|
{u.author_name.charAt(0).toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
@@ -498,14 +498,14 @@ export default function ForumDashboardPage() {
|
|||||||
{/* Category Bar Chart */}
|
{/* Category Bar Chart */}
|
||||||
{topCategories.length > 0 && (
|
{topCategories.length > 0 && (
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-6">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg p-6">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Category Activity"
|
title="Category Activity"
|
||||||
subtitle="Thread and post distribution across categories"
|
subtitle="Thread and post distribution across categories"
|
||||||
/>
|
/>
|
||||||
<ResponsiveContainer width="100%" height={220}>
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
<BarChart data={topCategories} margin={{ top: 5, right: 10, left: -20, bottom: 5 }}>
|
<BarChart data={topCategories} margin={{ top: 5, right: 10, left: -20, bottom: 5 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#1a1a1a" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#FFFFFF" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="name"
|
dataKey="name"
|
||||||
tick={{ fill: '#555', fontSize: 10 }}
|
tick={{ fill: '#555', fontSize: 10 }}
|
||||||
@@ -523,7 +523,7 @@ export default function ForumDashboardPage() {
|
|||||||
iconType="circle"
|
iconType="circle"
|
||||||
iconSize={8}
|
iconSize={8}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="thread_count" fill="#3b82f6" radius={[3, 3, 0, 0]} name="Threads" />
|
<Bar dataKey="thread_count" fill="#1D4ED8" radius={[3, 3, 0, 0]} name="Threads" />
|
||||||
<Bar dataKey="post_count" fill="#10b981" radius={[3, 3, 0, 0]} name="Posts" />
|
<Bar dataKey="post_count" fill="#10b981" radius={[3, 3, 0, 0]} name="Posts" />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -533,22 +533,22 @@ export default function ForumDashboardPage() {
|
|||||||
|
|
||||||
{/* Moderation Stats */}
|
{/* Moderation Stats */}
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-hidden">
|
||||||
<div className="px-5 py-4 border-b border-[#1a1a1a] flex items-center justify-between">
|
<div className="px-5 py-4 border-b border-[#FFFFFF] flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-display font-bold text-white">Moderation Stats</h2>
|
<h2 className="text-sm font-display font-bold text-white">Moderation Stats</h2>
|
||||||
<p className="text-xs text-[#555] font-body mt-0.5">Current moderation state across all forum content</p>
|
<p className="text-xs text-[#555] font-body mt-0.5">Current moderation state across all forum content</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/admin/forum-moderation" className="text-xs text-[#3b82f6] hover:text-blue-300 font-body transition-colors">Open moderation →</Link>
|
<Link href="/admin/forum-moderation" className="text-xs text-[#1D4ED8] hover:text-blue-300 font-body transition-colors">Open moderation →</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 divide-x divide-y sm:divide-y-0 divide-[#1a1a1a]">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 divide-x divide-y sm:divide-y-0 divide-[#FFFFFF]">
|
||||||
{[
|
{[
|
||||||
{ label: 'Flagged Threads', value: moderation.flaggedThreads, color: 'text-red-400', bg: 'bg-red-500/10' },
|
{ label: 'Flagged Threads', value: moderation.flaggedThreads, color: 'text-red-400', bg: 'bg-red-500/10' },
|
||||||
{ label: 'Flagged Replies', value: moderation.flaggedReplies, color: 'text-red-400', bg: 'bg-red-500/10' },
|
{ label: 'Flagged Replies', value: moderation.flaggedReplies, color: 'text-red-400', bg: 'bg-red-500/10' },
|
||||||
{ label: 'Locked Threads', value: moderation.lockedThreads, color: 'text-orange-400', bg: 'bg-orange-500/10' },
|
{ label: 'Locked Threads', value: moderation.lockedThreads, color: 'text-orange-400', bg: 'bg-orange-500/10' },
|
||||||
{ label: 'Pinned Threads', value: moderation.pinnedThreads, color: 'text-yellow-400', bg: 'bg-yellow-500/10' },
|
{ label: 'Pinned Threads', value: moderation.pinnedThreads, color: 'text-yellow-400', bg: 'bg-yellow-500/10' },
|
||||||
{ label: 'Featured Threads', value: moderation.featuredThreads, color: 'text-blue-400', bg: 'bg-blue-500/10' },
|
{ label: 'Featured Threads', value: moderation.featuredThreads, color: 'text-blue-400', bg: 'bg-blue-500/10' },
|
||||||
{ label: 'Hidden Replies', value: moderation.hiddenReplies, color: 'text-[#888]', bg: 'bg-[#1a1a1a]' },
|
{ label: 'Hidden Replies', value: moderation.hiddenReplies, color: 'text-[#888]', bg: 'bg-[#FFFFFF]' },
|
||||||
].map(({ label, value, color, bg }) => (
|
].map(({ label, value, color, bg }) => (
|
||||||
<div key={label} className="px-4 py-5 flex flex-col items-center gap-1 text-center">
|
<div key={label} className="px-4 py-5 flex flex-col items-center gap-1 text-center">
|
||||||
<p className={`text-2xl font-bold font-display ${color}`}>{value}</p>
|
<p className={`text-2xl font-bold font-display ${color}`}>{value}</p>
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ function FlagModal({ onConfirm, onCancel }: FlagModalProps) {
|
|||||||
const [reason, setReason] = useState('');
|
const [reason, setReason] = useState('');
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 px-4">
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 px-4">
|
||||||
<div className="bg-[#1a1a1a] border border-[#333] rounded-xl p-6 w-full max-w-md">
|
<div className="bg-[#FFFFFF] border border-[#333] rounded-xl p-6 w-full max-w-md">
|
||||||
<h3 className="text-white font-heading font-bold text-lg mb-2">Flag Content</h3>
|
<h3 className="text-white font-heading font-bold text-lg mb-2">Flag Content</h3>
|
||||||
<p className="text-[#888] font-body text-sm mb-4">Provide a reason for flagging this content (optional).</p>
|
<p className="text-[#888] font-body text-sm mb-4">Provide a reason for flagging this content (optional).</p>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -70,10 +70,10 @@ function FlagModal({ onConfirm, onCancel }: FlagModalProps) {
|
|||||||
onChange={e => setReason(e.target.value)}
|
onChange={e => setReason(e.target.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="e.g. Spam, harassment, off-topic..."
|
placeholder="e.g. Spam, harassment, off-topic..."
|
||||||
className="w-full bg-[#0d0d0d] border border-[#333] rounded-lg px-3 py-2 text-white font-body text-sm focus:outline-none focus:border-[#ef4444] resize-none mb-4"
|
className="w-full bg-[#F8FAFC] border border-[#333] rounded-lg px-3 py-2 text-white font-body text-sm focus:outline-none focus:border-[#ef4444] resize-none mb-4"
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-3 justify-end">
|
<div className="flex gap-3 justify-end">
|
||||||
<button onClick={onCancel} className="px-4 py-2 text-sm font-body text-[#888] hover:text-white bg-[#252525] hover:bg-[#333] rounded-lg transition-colors">
|
<button onClick={onCancel} className="px-4 py-2 text-sm font-body text-[#888] hover:text-white bg-[#DCE6F2] hover:bg-[#333] rounded-lg transition-colors">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => onConfirm(reason)} className="px-4 py-2 text-sm font-body font-semibold text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors">
|
<button onClick={() => onConfirm(reason)} className="px-4 py-2 text-sm font-body font-semibold text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors">
|
||||||
@@ -119,14 +119,14 @@ function ThreadRow({ thread, onAction, loadingId }: ThreadRowProps) {
|
|||||||
onCancel={() => setShowFlagModal(false)}
|
onCancel={() => setShowFlagModal(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className={`bg-[#111] border rounded-lg p-4 transition-colors ${isAiFlagged ? 'border-purple-500/50' : thread.is_flagged ? 'border-red-500/40' : 'border-[#252525]'}`}>
|
<div className={`bg-[#111] border rounded-lg p-4 transition-colors ${isAiFlagged ? 'border-purple-500/50' : thread.is_flagged ? 'border-red-500/40' : 'border-[#DCE6F2]'}`}>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{/* Title + badges */}
|
{/* Title + badges */}
|
||||||
<div className="flex flex-wrap items-center gap-2 mb-1">
|
<div className="flex flex-wrap items-center gap-2 mb-1">
|
||||||
<Link
|
<Link
|
||||||
href={`/forum/thread/${thread.id}`}
|
href={`/forum/thread/${thread.id}`}
|
||||||
className="text-white font-body font-semibold text-sm hover:text-[#3b82f6] transition-colors truncate max-w-xs"
|
className="text-white font-body font-semibold text-sm hover:text-[#1D4ED8] transition-colors truncate max-w-xs"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
{thread.title}
|
{thread.title}
|
||||||
@@ -159,7 +159,7 @@ function ThreadRow({ thread, onAction, loadingId }: ThreadRowProps) {
|
|||||||
onClick={() => onAction(thread.id, thread.is_pinned ? 'unpin' : 'pin')}
|
onClick={() => onAction(thread.id, thread.is_pinned ? 'unpin' : 'pin')}
|
||||||
className={`px-2.5 py-1 text-xs font-body font-semibold rounded-md transition-colors disabled:opacity-50 ${
|
className={`px-2.5 py-1 text-xs font-body font-semibold rounded-md transition-colors disabled:opacity-50 ${
|
||||||
thread.is_pinned
|
thread.is_pinned
|
||||||
? 'bg-amber-400/20 text-amber-400 hover:bg-amber-400/30' :'bg-[#1a1a1a] text-[#888] hover:bg-[#252525] hover:text-amber-400'
|
? 'bg-amber-400/20 text-amber-400 hover:bg-amber-400/30' :'bg-[#FFFFFF] text-[#888] hover:bg-[#DCE6F2] hover:text-amber-400'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{thread.is_pinned ? 'Unpin' : 'Pin'}
|
{thread.is_pinned ? 'Unpin' : 'Pin'}
|
||||||
@@ -169,7 +169,7 @@ function ThreadRow({ thread, onAction, loadingId }: ThreadRowProps) {
|
|||||||
onClick={() => onAction(thread.id, thread.is_featured ? 'unfeature' : 'feature')}
|
onClick={() => onAction(thread.id, thread.is_featured ? 'unfeature' : 'feature')}
|
||||||
className={`px-2.5 py-1 text-xs font-body font-semibold rounded-md transition-colors disabled:opacity-50 ${
|
className={`px-2.5 py-1 text-xs font-body font-semibold rounded-md transition-colors disabled:opacity-50 ${
|
||||||
thread.is_featured
|
thread.is_featured
|
||||||
? 'bg-yellow-400/20 text-yellow-400 hover:bg-yellow-400/30' :'bg-[#1a1a1a] text-[#888] hover:bg-[#252525] hover:text-yellow-400'
|
? 'bg-yellow-400/20 text-yellow-400 hover:bg-yellow-400/30' :'bg-[#FFFFFF] text-[#888] hover:bg-[#DCE6F2] hover:text-yellow-400'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{thread.is_featured ? 'Unfeature' : 'Feature'}
|
{thread.is_featured ? 'Unfeature' : 'Feature'}
|
||||||
@@ -179,7 +179,7 @@ function ThreadRow({ thread, onAction, loadingId }: ThreadRowProps) {
|
|||||||
onClick={() => onAction(thread.id, thread.is_locked ? 'unlock' : 'lock')}
|
onClick={() => onAction(thread.id, thread.is_locked ? 'unlock' : 'lock')}
|
||||||
className={`px-2.5 py-1 text-xs font-body font-semibold rounded-md transition-colors disabled:opacity-50 ${
|
className={`px-2.5 py-1 text-xs font-body font-semibold rounded-md transition-colors disabled:opacity-50 ${
|
||||||
thread.is_locked
|
thread.is_locked
|
||||||
? 'bg-orange-400/20 text-orange-400 hover:bg-orange-400/30' :'bg-[#1a1a1a] text-[#888] hover:bg-[#252525] hover:text-orange-400'
|
? 'bg-orange-400/20 text-orange-400 hover:bg-orange-400/30' :'bg-[#FFFFFF] text-[#888] hover:bg-[#DCE6F2] hover:text-orange-400'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{thread.is_locked ? 'Unlock' : 'Lock'}
|
{thread.is_locked ? 'Unlock' : 'Lock'}
|
||||||
@@ -199,7 +199,7 @@ function ThreadRow({ thread, onAction, loadingId }: ThreadRowProps) {
|
|||||||
<button
|
<button
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
onClick={() => setShowFlagModal(true)}
|
onClick={() => setShowFlagModal(true)}
|
||||||
className="px-2.5 py-1 text-xs font-body font-semibold rounded-md bg-[#1a1a1a] text-[#888] hover:bg-[#252525] hover:text-red-400 transition-colors disabled:opacity-50"
|
className="px-2.5 py-1 text-xs font-body font-semibold rounded-md bg-[#FFFFFF] text-[#888] hover:bg-[#DCE6F2] hover:text-red-400 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Flag
|
Flag
|
||||||
</button>
|
</button>
|
||||||
@@ -235,7 +235,7 @@ function ReplyRow({ reply, onAction, loadingId }: ReplyRowProps) {
|
|||||||
onCancel={() => setShowFlagModal(false)}
|
onCancel={() => setShowFlagModal(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className={`bg-[#111] border rounded-lg p-4 transition-colors ${isAiFlagged ? 'border-purple-500/50' : reply.is_flagged ? 'border-red-500/40' : reply.is_hidden ? 'border-[#333] opacity-60' : 'border-[#252525]'}`}>
|
<div className={`bg-[#111] border rounded-lg p-4 transition-colors ${isAiFlagged ? 'border-purple-500/50' : reply.is_flagged ? 'border-red-500/40' : reply.is_hidden ? 'border-[#333] opacity-60' : 'border-[#DCE6F2]'}`}>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{/* Thread link + badges */}
|
{/* Thread link + badges */}
|
||||||
@@ -243,13 +243,13 @@ function ReplyRow({ reply, onAction, loadingId }: ReplyRowProps) {
|
|||||||
{reply.forum_threads && (
|
{reply.forum_threads && (
|
||||||
<Link
|
<Link
|
||||||
href={`/forum/thread/${reply.forum_threads.id}`}
|
href={`/forum/thread/${reply.forum_threads.id}`}
|
||||||
className="text-[#3b82f6] font-body text-xs hover:underline truncate max-w-xs"
|
className="text-[#1D4ED8] font-body text-xs hover:underline truncate max-w-xs"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
↗ {reply.forum_threads.title}
|
↗ {reply.forum_threads.title}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{reply.is_hidden && <StatusBadge label="👁 Hidden" color="text-[#888] bg-[#252525]" />}
|
{reply.is_hidden && <StatusBadge label="👁 Hidden" color="text-[#888] bg-[#DCE6F2]" />}
|
||||||
{isAiFlagged && <StatusBadge label="🤖 AI Flagged" color="text-purple-400 bg-purple-400/10" />}
|
{isAiFlagged && <StatusBadge label="🤖 AI Flagged" color="text-purple-400 bg-purple-400/10" />}
|
||||||
{reply.is_flagged && !isAiFlagged && <StatusBadge label="🚩 Flagged" color="text-red-400 bg-red-400/10" />}
|
{reply.is_flagged && !isAiFlagged && <StatusBadge label="🚩 Flagged" color="text-red-400 bg-red-400/10" />}
|
||||||
</div>
|
</div>
|
||||||
@@ -273,8 +273,8 @@ function ReplyRow({ reply, onAction, loadingId }: ReplyRowProps) {
|
|||||||
onClick={() => onAction(reply.id, reply.is_hidden ? 'unhide' : 'hide')}
|
onClick={() => onAction(reply.id, reply.is_hidden ? 'unhide' : 'hide')}
|
||||||
className={`px-2.5 py-1 text-xs font-body font-semibold rounded-md transition-colors disabled:opacity-50 ${
|
className={`px-2.5 py-1 text-xs font-body font-semibold rounded-md transition-colors disabled:opacity-50 ${
|
||||||
reply.is_hidden
|
reply.is_hidden
|
||||||
? 'bg-[#252525] text-[#aaa] hover:bg-[#333]'
|
? 'bg-[#DCE6F2] text-[#aaa] hover:bg-[#333]'
|
||||||
: 'bg-[#1a1a1a] text-[#888] hover:bg-[#252525] hover:text-white'
|
: 'bg-[#FFFFFF] text-[#888] hover:bg-[#DCE6F2] hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{reply.is_hidden ? 'Unhide' : 'Hide'}
|
{reply.is_hidden ? 'Unhide' : 'Hide'}
|
||||||
@@ -294,7 +294,7 @@ function ReplyRow({ reply, onAction, loadingId }: ReplyRowProps) {
|
|||||||
<button
|
<button
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
onClick={() => setShowFlagModal(true)}
|
onClick={() => setShowFlagModal(true)}
|
||||||
className="px-2.5 py-1 text-xs font-body font-semibold rounded-md bg-[#1a1a1a] text-[#888] hover:bg-[#252525] hover:text-red-400 transition-colors disabled:opacity-50"
|
className="px-2.5 py-1 text-xs font-body font-semibold rounded-md bg-[#FFFFFF] text-[#888] hover:bg-[#DCE6F2] hover:text-red-400 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Flag
|
Flag
|
||||||
</button>
|
</button>
|
||||||
@@ -398,7 +398,7 @@ export default function ForumModerationPage() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
||||||
<div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
<div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -435,10 +435,10 @@ export default function ForumModerationPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="bg-[#1a2535] border-b border-[#2a3a50]">
|
<div className="bg-[#FFFFFF] border-b border-[#2a3a50]">
|
||||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||||
<nav className="text-sm font-body text-[#666] mb-2">
|
<nav className="text-sm font-body text-[#666] mb-2">
|
||||||
<Link href="/admin" className="hover:text-[#3b82f6] transition-colors">Admin</Link>
|
<Link href="/admin" className="hover:text-[#1D4ED8] transition-colors">Admin</Link>
|
||||||
<span className="mx-2">›</span>
|
<span className="mx-2">›</span>
|
||||||
<span className="text-[#aab4c4]">Forum Moderation</span>
|
<span className="text-[#aab4c4]">Forum Moderation</span>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -453,7 +453,7 @@ export default function ForumModerationPage() {
|
|||||||
<span className="text-xs text-purple-400 font-body bg-purple-400/10 border border-purple-400/20 px-3 py-1.5 rounded-lg">
|
<span className="text-xs text-purple-400 font-body bg-purple-400/10 border border-purple-400/20 px-3 py-1.5 rounded-lg">
|
||||||
🤖 Claude AI Auto-Moderation Active
|
🤖 Claude AI Auto-Moderation Active
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-[#555] font-body bg-[#1a1a1a] border border-[#252525] px-3 py-1.5 rounded-lg">
|
<span className="text-xs text-[#555] font-body bg-[#FFFFFF] border border-[#DCE6F2] px-3 py-1.5 rounded-lg">
|
||||||
🛡 Moderator View
|
🛡 Moderator View
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -463,14 +463,14 @@ export default function ForumModerationPage() {
|
|||||||
|
|
||||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-1 bg-[#111] border border-[#252525] rounded-lg p-1 w-fit mb-6">
|
<div className="flex gap-1 bg-[#111] border border-[#DCE6F2] rounded-lg p-1 w-fit mb-6">
|
||||||
{(['threads', 'replies'] as Tab[]).map(tab => (
|
{(['threads', 'replies'] as Tab[]).map(tab => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
onClick={() => { setActiveTab(tab); setPage(1); }}
|
onClick={() => { setActiveTab(tab); setPage(1); }}
|
||||||
className={`px-5 py-2 text-sm font-body font-semibold rounded-md transition-colors capitalize ${
|
className={`px-5 py-2 text-sm font-body font-semibold rounded-md transition-colors capitalize ${
|
||||||
activeTab === tab
|
activeTab === tab
|
||||||
? 'bg-[#3b82f6] text-white'
|
? 'bg-[#1D4ED8] text-white'
|
||||||
: 'text-[#888] hover:text-white'
|
: 'text-[#888] hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -488,8 +488,8 @@ export default function ForumModerationPage() {
|
|||||||
onClick={() => { setThreadFilter(f.value); setPage(1); }}
|
onClick={() => { setThreadFilter(f.value); setPage(1); }}
|
||||||
className={`px-3 py-1.5 text-xs font-body font-semibold rounded-lg transition-colors ${
|
className={`px-3 py-1.5 text-xs font-body font-semibold rounded-lg transition-colors ${
|
||||||
threadFilter === f.value
|
threadFilter === f.value
|
||||||
? 'bg-[#3b82f6] text-white'
|
? 'bg-[#1D4ED8] text-white'
|
||||||
: 'bg-[#1a1a1a] text-[#888] hover:bg-[#252525] hover:text-white border border-[#252525]'
|
: 'bg-[#FFFFFF] text-[#888] hover:bg-[#DCE6F2] hover:text-white border border-[#DCE6F2]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{f.label}
|
{f.label}
|
||||||
@@ -501,8 +501,8 @@ export default function ForumModerationPage() {
|
|||||||
onClick={() => { setReplyFilter(f.value); setPage(1); }}
|
onClick={() => { setReplyFilter(f.value); setPage(1); }}
|
||||||
className={`px-3 py-1.5 text-xs font-body font-semibold rounded-lg transition-colors ${
|
className={`px-3 py-1.5 text-xs font-body font-semibold rounded-lg transition-colors ${
|
||||||
replyFilter === f.value
|
replyFilter === f.value
|
||||||
? 'bg-[#3b82f6] text-white'
|
? 'bg-[#1D4ED8] text-white'
|
||||||
: 'bg-[#1a1a1a] text-[#888] hover:bg-[#252525] hover:text-white border border-[#252525]'
|
: 'bg-[#FFFFFF] text-[#888] hover:bg-[#DCE6F2] hover:text-white border border-[#DCE6F2]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{f.label}
|
{f.label}
|
||||||
@@ -515,7 +515,7 @@ export default function ForumModerationPage() {
|
|||||||
{/* Content */}
|
{/* Content */}
|
||||||
{loadingData ? (
|
{loadingData ? (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<div className="w-7 h-7 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
<div className="w-7 h-7 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : activeTab === 'threads' ? (
|
) : activeTab === 'threads' ? (
|
||||||
<>
|
<>
|
||||||
@@ -559,7 +559,7 @@ export default function ForumModerationPage() {
|
|||||||
<button
|
<button
|
||||||
disabled={page === 1}
|
disabled={page === 1}
|
||||||
onClick={() => setPage(p => p - 1)}
|
onClick={() => setPage(p => p - 1)}
|
||||||
className="px-4 py-2 text-sm font-body text-[#888] bg-[#1a1a1a] border border-[#252525] rounded-lg hover:bg-[#252525] disabled:opacity-40 transition-colors"
|
className="px-4 py-2 text-sm font-body text-[#888] bg-[#FFFFFF] border border-[#DCE6F2] rounded-lg hover:bg-[#DCE6F2] disabled:opacity-40 transition-colors"
|
||||||
>
|
>
|
||||||
← Prev
|
← Prev
|
||||||
</button>
|
</button>
|
||||||
@@ -567,7 +567,7 @@ export default function ForumModerationPage() {
|
|||||||
<button
|
<button
|
||||||
disabled={page === totalPages}
|
disabled={page === totalPages}
|
||||||
onClick={() => setPage(p => p + 1)}
|
onClick={() => setPage(p => p + 1)}
|
||||||
className="px-4 py-2 text-sm font-body text-[#888] bg-[#1a1a1a] border border-[#252525] rounded-lg hover:bg-[#252525] disabled:opacity-40 transition-colors"
|
className="px-4 py-2 text-sm font-body text-[#888] bg-[#FFFFFF] border border-[#DCE6F2] rounded-lg hover:bg-[#DCE6F2] disabled:opacity-40 transition-colors"
|
||||||
>
|
>
|
||||||
Next →
|
Next →
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -136,13 +136,13 @@ export default function AdminForumSeedPage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
<main className="min-h-screen bg-[#111111]">
|
<main className="min-h-screen bg-[#F8FAFC]">
|
||||||
<div className="bg-[#1a2535] border-b border-[#2a3a50]">
|
<div className="bg-[#FFFFFF] border-b border-[#2a3a50]">
|
||||||
<div className="max-w-container mx-auto px-4 py-6">
|
<div className="max-w-container mx-auto px-4 py-6">
|
||||||
<nav className="text-sm font-body text-[#666] mb-2">
|
<nav className="text-sm font-body text-[#666] mb-2">
|
||||||
<Link href="/admin" className="hover:text-[#3b82f6] transition-colors">Admin</Link>
|
<Link href="/admin" className="hover:text-[#1D4ED8] transition-colors">Admin</Link>
|
||||||
<span className="mx-2">›</span>
|
<span className="mx-2">›</span>
|
||||||
<Link href="/forum" className="hover:text-[#3b82f6] transition-colors">Forum</Link>
|
<Link href="/forum" className="hover:text-[#1D4ED8] transition-colors">Forum</Link>
|
||||||
<span className="mx-2">›</span>
|
<span className="mx-2">›</span>
|
||||||
<span className="text-[#aab4c4]">Seed Tool</span>
|
<span className="text-[#aab4c4]">Seed Tool</span>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -157,17 +157,17 @@ export default function AdminForumSeedPage() {
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
{/* Left: Editor */}
|
{/* Left: Editor */}
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<div className="bg-[#1a1a1a] border border-[#252525] rounded-lg overflow-hidden">
|
<div className="bg-[#FFFFFF] border border-[#DCE6F2] rounded-lg overflow-hidden">
|
||||||
<div className="flex border-b border-[#252525]">
|
<div className="flex border-b border-[#DCE6F2]">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('import')}
|
onClick={() => setActiveTab('import')}
|
||||||
className={`px-4 py-3 font-body text-sm font-semibold transition-colors ${activeTab === 'import' ? 'text-white border-b-2 border-[#3b82f6]' : 'text-[#666] hover:text-[#aaa]'}`}
|
className={`px-4 py-3 font-body text-sm font-semibold transition-colors ${activeTab === 'import' ? 'text-white border-b-2 border-[#1D4ED8]' : 'text-[#666] hover:text-[#aaa]'}`}
|
||||||
>
|
>
|
||||||
Import Editor
|
Import Editor
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('json')}
|
onClick={() => setActiveTab('json')}
|
||||||
className={`px-4 py-3 font-body text-sm font-semibold transition-colors ${activeTab === 'json' ? 'text-white border-b-2 border-[#3b82f6]' : 'text-[#666] hover:text-[#aaa]'}`}
|
className={`px-4 py-3 font-body text-sm font-semibold transition-colors ${activeTab === 'json' ? 'text-white border-b-2 border-[#1D4ED8]' : 'text-[#666] hover:text-[#aaa]'}`}
|
||||||
>
|
>
|
||||||
JSON Schema
|
JSON Schema
|
||||||
</button>
|
</button>
|
||||||
@@ -182,20 +182,20 @@ export default function AdminForumSeedPage() {
|
|||||||
value={seedData}
|
value={seedData}
|
||||||
onChange={e => setSeedData(e.target.value)}
|
onChange={e => setSeedData(e.target.value)}
|
||||||
rows={24}
|
rows={24}
|
||||||
className="w-full bg-[#0d0d0d] border border-[#333] rounded-lg px-3 py-3 text-[#d0d0d0] font-mono text-xs focus:outline-none focus:border-[#3b82f6] resize-none"
|
className="w-full bg-[#F8FAFC] border border-[#333] rounded-lg px-3 py-3 text-[#d0d0d0] font-mono text-xs focus:outline-none focus:border-[#1D4ED8] resize-none"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-3 mt-3">
|
<div className="flex gap-3 mt-3">
|
||||||
<button
|
<button
|
||||||
onClick={handleImport}
|
onClick={handleImport}
|
||||||
disabled={importing}
|
disabled={importing}
|
||||||
className="bg-[#3b82f6] hover:bg-[#2563eb] disabled:opacity-50 text-white font-body font-semibold text-sm px-5 py-2 rounded-lg transition-colors"
|
className="bg-[#1D4ED8] hover:bg-[#1E3A8A] disabled:opacity-50 text-white font-body font-semibold text-sm px-5 py-2 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
{importing ? 'Importing...' : 'Import Threads'}
|
{importing ? 'Importing...' : 'Import Threads'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSeedData(JSON.stringify(SEED_TEMPLATES, null, 2))}
|
onClick={() => setSeedData(JSON.stringify(SEED_TEMPLATES, null, 2))}
|
||||||
className="bg-[#252525] hover:bg-[#333] text-[#aaa] font-body text-sm px-4 py-2 rounded-lg transition-colors"
|
className="bg-[#DCE6F2] hover:bg-[#333] text-[#aaa] font-body text-sm px-4 py-2 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
Reset to Templates
|
Reset to Templates
|
||||||
</button>
|
</button>
|
||||||
@@ -206,7 +206,7 @@ export default function AdminForumSeedPage() {
|
|||||||
{activeTab === 'json' && (
|
{activeTab === 'json' && (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<p className="text-[#888] font-body text-xs mb-3">Required JSON structure for seed data:</p>
|
<p className="text-[#888] font-body text-xs mb-3">Required JSON structure for seed data:</p>
|
||||||
<pre className="bg-[#0d0d0d] border border-[#333] rounded-lg p-4 text-[#d0d0d0] font-mono text-xs overflow-auto">
|
<pre className="bg-[#F8FAFC] border border-[#333] rounded-lg p-4 text-[#d0d0d0] font-mono text-xs overflow-auto">
|
||||||
{`[
|
{`[
|
||||||
{
|
{
|
||||||
"category_slug": "live-production",
|
"category_slug": "live-production",
|
||||||
@@ -226,7 +226,7 @@ export default function AdminForumSeedPage() {
|
|||||||
<h4 className="text-white font-body font-semibold text-sm mb-2">Available Category Slugs</h4>
|
<h4 className="text-white font-body font-semibold text-sm mb-2">Available Category Slugs</h4>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{['live-production','ip-cloud','audio','cameras','storage-mam','streaming','ai-automation','post-production','nab-ibc','career-jobs','gear-reviews','general'].map(slug => (
|
{['live-production','ip-cloud','audio','cameras','storage-mam','streaming','ai-automation','post-production','nab-ibc','career-jobs','gear-reviews','general'].map(slug => (
|
||||||
<code key={slug} className="bg-[#0d0d0d] border border-[#333] text-[#3b82f6] font-mono text-xs px-2 py-1 rounded">{slug}</code>
|
<code key={slug} className="bg-[#F8FAFC] border border-[#333] text-[#1D4ED8] font-mono text-xs px-2 py-1 rounded">{slug}</code>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -239,7 +239,7 @@ export default function AdminForumSeedPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Import Results */}
|
{/* Import Results */}
|
||||||
{results.length > 0 && (
|
{results.length > 0 && (
|
||||||
<div className="bg-[#1a1a1a] border border-[#252525] rounded-lg p-4">
|
<div className="bg-[#FFFFFF] border border-[#DCE6F2] rounded-lg p-4">
|
||||||
<h3 className="text-white font-heading font-semibold mb-3">Import Results</h3>
|
<h3 className="text-white font-heading font-semibold mb-3">Import Results</h3>
|
||||||
<div className="flex gap-4 mb-3">
|
<div className="flex gap-4 mb-3">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -265,20 +265,20 @@ export default function AdminForumSeedPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Quick Links */}
|
{/* Quick Links */}
|
||||||
<div className="bg-[#1a1a1a] border border-[#252525] rounded-lg p-4">
|
<div className="bg-[#FFFFFF] border border-[#DCE6F2] rounded-lg p-4">
|
||||||
<h3 className="text-white font-heading font-semibold mb-3">Quick Links</h3>
|
<h3 className="text-white font-heading font-semibold mb-3">Quick Links</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Link href="/forum" className="flex items-center gap-2 text-[#3b82f6] hover:underline font-body text-sm">
|
<Link href="/forum" className="flex items-center gap-2 text-[#1D4ED8] hover:underline font-body text-sm">
|
||||||
<span>→</span> View Forum
|
<span>→</span> View Forum
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/admin" className="flex items-center gap-2 text-[#3b82f6] hover:underline font-body text-sm">
|
<Link href="/admin" className="flex items-center gap-2 text-[#1D4ED8] hover:underline font-body text-sm">
|
||||||
<span>→</span> Admin Dashboard
|
<span>→</span> Admin Dashboard
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tips */}
|
{/* Tips */}
|
||||||
<div className="bg-[#1a2535] border border-[#2a3a50] rounded-lg p-4">
|
<div className="bg-[#FFFFFF] border border-[#2a3a50] rounded-lg p-4">
|
||||||
<h3 className="text-white font-heading font-semibold mb-2 text-sm">Tips</h3>
|
<h3 className="text-white font-heading font-semibold mb-2 text-sm">Tips</h3>
|
||||||
<ul className="text-[#aab4c4] font-body text-xs space-y-1.5">
|
<ul className="text-[#aab4c4] font-body text-xs space-y-1.5">
|
||||||
<li>• Threads are imported as real posts — they appear immediately in the forum</li>
|
<li>• Threads are imported as real posts — they appear immediately in the forum</li>
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ export default function WPImportPage() {
|
|||||||
if (loading || (!user && !loading)) {
|
if (loading || (!user && !loading)) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
<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 className="w-6 h-6 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -142,10 +142,10 @@ export default function WPImportPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0a0a] text-[#cccccc]">
|
<div className="min-h-screen bg-[#0a0a0a] text-[#cccccc]">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="bg-[#111111] border-b border-[#252525]">
|
<div className="bg-[#F8FAFC] border-b border-[#DCE6F2]">
|
||||||
<div className="max-w-6xl mx-auto px-4 py-5">
|
<div className="max-w-6xl mx-auto px-4 py-5">
|
||||||
<div className="flex items-center gap-3 mb-1">
|
<div className="flex items-center gap-3 mb-1">
|
||||||
<Link href="/home-page" className="text-[#555] hover:text-[#3b82f6] transition-colors text-sm">
|
<Link href="/home-page" className="text-[#555] hover:text-[#1D4ED8] transition-colors text-sm">
|
||||||
Home
|
Home
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-[#333]">/</span>
|
<span className="text-[#333]">/</span>
|
||||||
@@ -158,14 +158,14 @@ export default function WPImportPage() {
|
|||||||
<h1 className="text-2xl font-bold text-white font-heading">WordPress Post Importer</h1>
|
<h1 className="text-2xl font-bold text-white font-heading">WordPress Post Importer</h1>
|
||||||
<p className="text-[#666] text-sm mt-1">
|
<p className="text-[#666] text-sm mt-1">
|
||||||
Import posts from{' '}
|
Import posts from{' '}
|
||||||
<a href="https://www.avbeat.com" target="_blank" rel="noopener noreferrer" className="text-[#3b82f6] hover:underline">
|
<a href="https://www.avbeat.com" target="_blank" rel="noopener noreferrer" className="text-[#1D4ED8] hover:underline">
|
||||||
avbeat.com
|
avbeat.com
|
||||||
</a>{' '}
|
</a>{' '}
|
||||||
via WordPress REST API
|
via WordPress REST API
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-xs text-[#555] bg-[#1a1a1a] border border-[#252525] rounded px-3 py-2">
|
<div className="flex items-center gap-2 text-xs text-[#555] bg-[#FFFFFF] border border-[#DCE6F2] rounded px-3 py-2">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-[#3b82f6]">
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-[#1D4ED8]">
|
||||||
<circle cx="12" cy="12" r="10" /><path d="M12 8v4l3 3" />
|
<circle cx="12" cy="12" r="10" /><path d="M12 8v4l3 3" />
|
||||||
</svg>
|
</svg>
|
||||||
{posts.length} posts imported
|
{posts.length} posts imported
|
||||||
@@ -176,9 +176,9 @@ export default function WPImportPage() {
|
|||||||
|
|
||||||
<div className="max-w-6xl mx-auto px-4 py-6 space-y-6">
|
<div className="max-w-6xl mx-auto px-4 py-6 space-y-6">
|
||||||
{/* Import Controls */}
|
{/* Import Controls */}
|
||||||
<div className="bg-[#111111] border border-[#252525] rounded-lg p-5">
|
<div className="bg-[#F8FAFC] border border-[#DCE6F2] rounded-lg p-5">
|
||||||
<h2 className="text-base font-bold text-white font-heading mb-4 flex items-center gap-2">
|
<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]">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-[#1D4ED8]">
|
||||||
<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" />
|
<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>
|
</svg>
|
||||||
Run Import
|
Run Import
|
||||||
@@ -191,7 +191,7 @@ export default function WPImportPage() {
|
|||||||
value={maxPages}
|
value={maxPages}
|
||||||
onChange={(e) => setMaxPages(Number(e.target.value))}
|
onChange={(e) => setMaxPages(Number(e.target.value))}
|
||||||
disabled={isImporting}
|
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">
|
className="bg-[#FFFFFF] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-2 focus:outline-none focus:border-[#1D4ED8] disabled:opacity-50">
|
||||||
<option value={1}>1 page (~20 posts)</option>
|
<option value={1}>1 page (~20 posts)</option>
|
||||||
<option value={3}>3 pages (~60 posts)</option>
|
<option value={3}>3 pages (~60 posts)</option>
|
||||||
<option value={5}>5 pages (~100 posts)</option>
|
<option value={5}>5 pages (~100 posts)</option>
|
||||||
@@ -204,7 +204,7 @@ export default function WPImportPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleImport}
|
onClick={handleImport}
|
||||||
disabled={isImporting}
|
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">
|
className="flex items-center gap-2 bg-[#1D4ED8] hover:bg-[#1E3A8A] disabled:bg-[#DCE6F2] disabled:cursor-not-allowed text-white text-sm font-bold px-5 py-2 rounded transition-colors">
|
||||||
{isImporting ? (
|
{isImporting ? (
|
||||||
<>
|
<>
|
||||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||||
@@ -223,16 +223,16 @@ export default function WPImportPage() {
|
|||||||
|
|
||||||
{/* Import Progress / Result */}
|
{/* Import Progress / Result */}
|
||||||
{isImporting && (
|
{isImporting && (
|
||||||
<div className="mt-4 bg-[#0d1a2e] border border-[#1e3a5f] rounded p-4">
|
<div className="mt-4 bg-[#0d1a2e] border border-[#DCE6F2] rounded p-4">
|
||||||
<div className="flex items-center gap-3">
|
<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 className="w-5 h-5 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[#3b82f6] text-sm font-bold">Import in progress</p>
|
<p className="text-[#1D4ED8] 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>
|
<p className="text-[#666] text-xs mt-0.5">Fetching posts from avbeat.com and saving to database...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 h-1.5 bg-[#1a2535] rounded-full overflow-hidden">
|
<div className="mt-3 h-1.5 bg-[#FFFFFF] rounded-full overflow-hidden">
|
||||||
<div className="h-full bg-[#3b82f6] rounded-full animate-pulse w-2/3" />
|
<div className="h-full bg-[#1D4ED8] rounded-full animate-pulse w-2/3" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -261,7 +261,7 @@ export default function WPImportPage() {
|
|||||||
{[
|
{[
|
||||||
{ label: 'Fetched', value: importResult.totalFetched, color: 'text-[#cccccc]' },
|
{ label: 'Fetched', value: importResult.totalFetched, color: 'text-[#cccccc]' },
|
||||||
{ label: 'Imported', value: importResult.totalImported, color: 'text-green-400' },
|
{ label: 'Imported', value: importResult.totalImported, color: 'text-green-400' },
|
||||||
{ label: 'Pages', value: importResult.pagesProcessed, color: 'text-[#3b82f6]' },
|
{ label: 'Pages', value: importResult.pagesProcessed, color: 'text-[#1D4ED8]' },
|
||||||
{ label: 'Errors', value: importResult.totalErrors, color: importResult.totalErrors ? 'text-red-400' : 'text-[#555]' },
|
{ label: 'Errors', value: importResult.totalErrors, color: importResult.totalErrors ? 'text-red-400' : 'text-[#555]' },
|
||||||
].map((stat) => (
|
].map((stat) => (
|
||||||
<div key={stat.label} className="bg-[#111] rounded p-2.5 text-center">
|
<div key={stat.label} className="bg-[#111] rounded p-2.5 text-center">
|
||||||
@@ -277,14 +277,14 @@ export default function WPImportPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-0 border-b border-[#252525]">
|
<div className="flex gap-0 border-b border-[#DCE6F2]">
|
||||||
{(['posts', 'logs'] as const).map((tab) => (
|
{(['posts', 'logs'] as const).map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
className={`px-5 py-2.5 text-sm font-bold uppercase tracking-wider border-b-2 transition-colors ${
|
className={`px-5 py-2.5 text-sm font-bold uppercase tracking-wider border-b-2 transition-colors ${
|
||||||
activeTab === tab
|
activeTab === tab
|
||||||
? 'border-[#3b82f6] text-[#3b82f6]'
|
? 'border-[#1D4ED8] text-[#1D4ED8]'
|
||||||
: 'border-transparent text-[#666] hover:text-[#999]'
|
: 'border-transparent text-[#666] hover:text-[#999]'
|
||||||
}`}>
|
}`}>
|
||||||
{tab === 'posts' ? `Imported Posts (${posts.length})` : `Import History (${logs.length})`}
|
{tab === 'posts' ? `Imported Posts (${posts.length})` : `Import History (${logs.length})`}
|
||||||
@@ -306,17 +306,17 @@ export default function WPImportPage() {
|
|||||||
placeholder="Search posts..."
|
placeholder="Search posts..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
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]"
|
className="w-full bg-[#111] border border-[#DCE6F2] text-[#cccccc] text-sm rounded pl-9 pr-4 py-2 focus:outline-none focus:border-[#1D4ED8] placeholder-[#444]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loadingData ? (
|
{loadingData ? (
|
||||||
<div className="flex items-center justify-center py-16">
|
<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 className="w-6 h-6 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : filteredPosts.length === 0 ? (
|
) : filteredPosts.length === 0 ? (
|
||||||
<div className="text-center py-16 bg-[#111] border border-[#252525] rounded-lg">
|
<div className="text-center py-16 bg-[#111] border border-[#DCE6F2] 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">
|
<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" />
|
<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>
|
</svg>
|
||||||
@@ -327,10 +327,10 @@ export default function WPImportPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{filteredPosts.map((post) => (
|
{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">
|
<div key={post.id} className="bg-[#111] border border-[#DCE6F2] rounded-lg p-4 flex items-start gap-4 hover:border-[#333] transition-colors">
|
||||||
{/* Thumbnail */}
|
{/* Thumbnail */}
|
||||||
{post.featured_image ? (
|
{post.featured_image ? (
|
||||||
<div className="flex-shrink-0 w-16 h-12 rounded overflow-hidden bg-[#1a1a1a]">
|
<div className="flex-shrink-0 w-16 h-12 rounded overflow-hidden bg-[#FFFFFF]">
|
||||||
<AppImage
|
<AppImage
|
||||||
src={post.featured_image}
|
src={post.featured_image}
|
||||||
alt={post.featured_image_alt || post.title}
|
alt={post.featured_image_alt || post.title}
|
||||||
@@ -340,7 +340,7 @@ export default function WPImportPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-shrink-0 w-16 h-12 rounded bg-[#1a1a1a] flex items-center justify-center">
|
<div className="flex-shrink-0 w-16 h-12 rounded bg-[#FFFFFF] 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]">
|
<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" />
|
<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>
|
</svg>
|
||||||
@@ -354,7 +354,7 @@ export default function WPImportPage() {
|
|||||||
<h3 className="text-sm font-bold text-[#cccccc] leading-snug line-clamp-1">{post.title}</h3>
|
<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">
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1">
|
||||||
{post.category && (
|
{post.category && (
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider text-[#3b82f6] bg-[#0d1a2e] px-1.5 py-0.5 rounded">
|
<span className="text-[10px] font-bold uppercase tracking-wider text-[#1D4ED8] bg-[#0d1a2e] px-1.5 py-0.5 rounded">
|
||||||
{post.category}
|
{post.category}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -368,7 +368,7 @@ export default function WPImportPage() {
|
|||||||
href={`https://www.avbeat.com/${post.wp_slug}`}
|
href={`https://www.avbeat.com/${post.wp_slug}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-[#555] hover:text-[#3b82f6] transition-colors p-1"
|
className="text-[#555] hover:text-[#1D4ED8] transition-colors p-1"
|
||||||
title="View on avbeat.com">
|
title="View on avbeat.com">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<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" />
|
<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" />
|
||||||
@@ -402,16 +402,16 @@ export default function WPImportPage() {
|
|||||||
<div>
|
<div>
|
||||||
{loadingData ? (
|
{loadingData ? (
|
||||||
<div className="flex items-center justify-center py-16">
|
<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 className="w-6 h-6 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : logs.length === 0 ? (
|
) : logs.length === 0 ? (
|
||||||
<div className="text-center py-16 bg-[#111] border border-[#252525] rounded-lg">
|
<div className="text-center py-16 bg-[#111] border border-[#DCE6F2] rounded-lg">
|
||||||
<p className="text-[#555] text-sm">No import history yet.</p>
|
<p className="text-[#555] text-sm">No import history yet.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{logs.map((log) => (
|
{logs.map((log) => (
|
||||||
<div key={log.id} className="bg-[#111] border border-[#252525] rounded-lg p-4">
|
<div key={log.id} className="bg-[#111] border border-[#DCE6F2] rounded-lg p-4">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{log.status === 'completed' ? (
|
{log.status === 'completed' ? (
|
||||||
@@ -439,7 +439,7 @@ export default function WPImportPage() {
|
|||||||
{ label: 'Skipped', value: log.total_skipped },
|
{ label: 'Skipped', value: log.total_skipped },
|
||||||
{ label: 'Errors', value: log.total_errors },
|
{ label: 'Errors', value: log.total_errors },
|
||||||
].map((stat) => (
|
].map((stat) => (
|
||||||
<div key={stat.label} className="bg-[#0d0d0d] rounded p-2 text-center">
|
<div key={stat.label} className="bg-[#F8FAFC] rounded p-2 text-center">
|
||||||
<p className="text-base font-bold text-[#cccccc]">{stat.value}</p>
|
<p className="text-base font-bold text-[#cccccc]">{stat.value}</p>
|
||||||
<p className="text-[10px] text-[#444] uppercase tracking-wider">{stat.label}</p>
|
<p className="text-[10px] text-[#444] uppercase tracking-wider">{stat.label}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -67,11 +67,11 @@ function fmtDate(dateStr: string): string {
|
|||||||
|
|
||||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function StatCard({ label, value, sub, accent = '#3b82f6', icon }: {
|
function StatCard({ label, value, sub, accent = '#1D4ED8', icon }: {
|
||||||
label: string; value: string; sub: string; accent?: string; icon: React.ReactNode;
|
label: string; value: string; sub: string; accent?: string; icon: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-5 flex items-start gap-4">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg p-5 flex items-start gap-4">
|
||||||
<div
|
<div
|
||||||
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
|
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||||
style={{ background: `${accent}18` }}
|
style={{ background: `${accent}18` }}
|
||||||
@@ -90,7 +90,7 @@ function StatCard({ label, value, sub, accent = '#3b82f6', icon }: {
|
|||||||
function ChartTooltip({ active, payload, label }: any) {
|
function ChartTooltip({ active, payload, label }: any) {
|
||||||
if (!active || !payload?.length) return null;
|
if (!active || !payload?.length) return null;
|
||||||
return (
|
return (
|
||||||
<div className="bg-[#1a1a1a] border border-[#333] rounded-lg px-3 py-2 text-xs font-body shadow-xl">
|
<div className="bg-[#FFFFFF] border border-[#333] rounded-lg px-3 py-2 text-xs font-body shadow-xl">
|
||||||
{label && <p className="text-[#888] mb-1">{label}</p>}
|
{label && <p className="text-[#888] mb-1">{label}</p>}
|
||||||
{payload.map((entry: any, i: number) => (
|
{payload.map((entry: any, i: number) => (
|
||||||
<p key={i} style={{ color: entry.color }} className="leading-5">
|
<p key={i} style={{ color: entry.color }} className="leading-5">
|
||||||
@@ -243,7 +243,7 @@ export default function NewsletterAnalyticsPage() {
|
|||||||
if (loading || loadingData) {
|
if (loading || loadingData) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
||||||
<div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
<div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -258,7 +258,7 @@ export default function NewsletterAnalyticsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0a0a] text-white">
|
<div className="min-h-screen bg-[#0a0a0a] text-white">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="border-b border-[#1a1a1a] bg-[#0d0d0d] sticky top-0 z-10">
|
<div className="border-b border-[#FFFFFF] bg-[#F8FAFC] sticky top-0 z-10">
|
||||||
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link href="/admin" className="text-[#555] hover:text-white transition-colors">
|
<Link href="/admin" className="text-[#555] hover:text-white transition-colors">
|
||||||
@@ -266,7 +266,7 @@ export default function NewsletterAnalyticsPage() {
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
|
||||||
</svg>
|
</svg>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="w-px h-5 bg-[#252525]" />
|
<div className="w-px h-5 bg-[#DCE6F2]" />
|
||||||
<Link href="/admin/newsletter" className="text-[#555] hover:text-white text-sm font-body transition-colors">
|
<Link href="/admin/newsletter" className="text-[#555] hover:text-white text-sm font-body transition-colors">
|
||||||
Newsletter
|
Newsletter
|
||||||
</Link>
|
</Link>
|
||||||
@@ -278,14 +278,14 @@ export default function NewsletterAnalyticsPage() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link
|
<Link
|
||||||
href="/admin/newsletter/templates"
|
href="/admin/newsletter/templates"
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-body font-semibold text-[#888] hover:text-white border border-[#252525] rounded-lg transition-colors"
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-body font-semibold text-[#888] hover:text-white border border-[#DCE6F2] rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||||
</svg>
|
</svg>
|
||||||
Templates
|
Templates
|
||||||
</Link>
|
</Link>
|
||||||
<div className="w-px h-8 bg-[#252525]" />
|
<div className="w-px h-8 bg-[#DCE6F2]" />
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-white text-sm font-bold font-heading">{fmt(totalSubscribers)}</p>
|
<p className="text-white text-sm font-bold font-heading">{fmt(totalSubscribers)}</p>
|
||||||
<p className="text-[#555] text-xs font-body">active subscribers</p>
|
<p className="text-[#555] text-xs font-body">active subscribers</p>
|
||||||
@@ -308,7 +308,7 @@ export default function NewsletterAnalyticsPage() {
|
|||||||
label="Avg Open Rate"
|
label="Avg Open Rate"
|
||||||
value={pct(avgOpenRate)}
|
value={pct(avgOpenRate)}
|
||||||
sub={`across ${digestData.length} digests`}
|
sub={`across ${digestData.length} digests`}
|
||||||
accent="#3b82f6"
|
accent="#1D4ED8"
|
||||||
icon={
|
icon={
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
||||||
@@ -352,7 +352,7 @@ export default function NewsletterAnalyticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Open & Click Rate Trend */}
|
{/* Open & Click Rate Trend */}
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-6">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg p-6">
|
||||||
<SectionHeader title="Open & Click Rate Trend" subtitle="Rate per digest over time" />
|
<SectionHeader title="Open & Click Rate Trend" subtitle="Rate per digest over time" />
|
||||||
<div className="h-64">
|
<div className="h-64">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
@@ -362,7 +362,7 @@ export default function NewsletterAnalyticsPage() {
|
|||||||
<YAxis tick={{ fill: '#555', fontSize: 10 }} tickLine={false} axisLine={false} tickFormatter={(v) => `${v}%`} />
|
<YAxis tick={{ fill: '#555', fontSize: 10 }} tickLine={false} axisLine={false} tickFormatter={(v) => `${v}%`} />
|
||||||
<Tooltip content={<ChartTooltip />} />
|
<Tooltip content={<ChartTooltip />} />
|
||||||
<Legend wrapperStyle={{ fontSize: 11, color: '#888' }} />
|
<Legend wrapperStyle={{ fontSize: 11, color: '#888' }} />
|
||||||
<Line type="monotone" dataKey="Open Rate" stroke="#3b82f6" strokeWidth={2} dot={{ r: 3, fill: '#3b82f6' }} activeDot={{ r: 5 }} />
|
<Line type="monotone" dataKey="Open Rate" stroke="#1D4ED8" strokeWidth={2} dot={{ r: 3, fill: '#1D4ED8' }} activeDot={{ r: 5 }} />
|
||||||
<Line type="monotone" dataKey="Click Rate" stroke="#10b981" strokeWidth={2} dot={{ r: 3, fill: '#10b981' }} activeDot={{ r: 5 }} />
|
<Line type="monotone" dataKey="Click Rate" stroke="#10b981" strokeWidth={2} dot={{ r: 3, fill: '#10b981' }} activeDot={{ r: 5 }} />
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -370,7 +370,7 @@ export default function NewsletterAnalyticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subscriber Growth Chart */}
|
{/* Subscriber Growth Chart */}
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-6">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg p-6">
|
||||||
<SectionHeader title="Subscriber Growth" subtitle="Monthly net new subscribers vs unsubscribes" />
|
<SectionHeader title="Subscriber Growth" subtitle="Monthly net new subscribers vs unsubscribes" />
|
||||||
<div className="h-64">
|
<div className="h-64">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
@@ -380,7 +380,7 @@ export default function NewsletterAnalyticsPage() {
|
|||||||
<YAxis tick={{ fill: '#555', fontSize: 11 }} tickLine={false} axisLine={false} />
|
<YAxis tick={{ fill: '#555', fontSize: 11 }} tickLine={false} axisLine={false} />
|
||||||
<Tooltip content={<ChartTooltip />} />
|
<Tooltip content={<ChartTooltip />} />
|
||||||
<Legend wrapperStyle={{ fontSize: 11, color: '#888' }} />
|
<Legend wrapperStyle={{ fontSize: 11, color: '#888' }} />
|
||||||
<Bar dataKey="subscribers" name="Total Subscribers" fill="#3b82f6" radius={[3, 3, 0, 0]} />
|
<Bar dataKey="subscribers" name="Total Subscribers" fill="#1D4ED8" radius={[3, 3, 0, 0]} />
|
||||||
<Bar dataKey="unsubscribes" name="Unsubscribes" fill="#ef4444" radius={[3, 3, 0, 0]} />
|
<Bar dataKey="unsubscribes" name="Unsubscribes" fill="#ef4444" radius={[3, 3, 0, 0]} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -388,7 +388,7 @@ export default function NewsletterAnalyticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Per-Digest Performance Table */}
|
{/* Per-Digest Performance Table */}
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-hidden">
|
||||||
<div className="px-6 py-5 border-b border-[#1e1e1e]">
|
<div className="px-6 py-5 border-b border-[#1e1e1e]">
|
||||||
<SectionHeader title="Per-Digest Performance" subtitle="Click column headers to sort" />
|
<SectionHeader title="Per-Digest Performance" subtitle="Click column headers to sort" />
|
||||||
</div>
|
</div>
|
||||||
@@ -421,7 +421,7 @@ export default function NewsletterAnalyticsPage() {
|
|||||||
{sortedDigests.map((digest, i) => (
|
{sortedDigests.map((digest, i) => (
|
||||||
<tr
|
<tr
|
||||||
key={digest.id}
|
key={digest.id}
|
||||||
className={`border-b border-[#1a1a1a] hover:bg-[#161616] transition-colors ${i % 2 === 0 ? '' : 'bg-[#0d0d0d]'}`}
|
className={`border-b border-[#FFFFFF] hover:bg-[#FFFFFF] transition-colors ${i % 2 === 0 ? '' : 'bg-[#F8FAFC]'}`}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3 text-white max-w-xs">
|
<td className="px-4 py-3 text-white max-w-xs">
|
||||||
<p className="truncate font-medium" title={digest.subject}>{digest.subject}</p>
|
<p className="truncate font-medium" title={digest.subject}>{digest.subject}</p>
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ export default function CampaignEditorPage() {
|
|||||||
article_blocks: payload.article_blocks,
|
article_blocks: payload.article_blocks,
|
||||||
layout: 'featured',
|
layout: 'featured',
|
||||||
style: 'dark',
|
style: 'dark',
|
||||||
accent_color: '#3b82f6',
|
accent_color: '#1D4ED8',
|
||||||
is_preset: false,
|
is_preset: false,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -296,7 +296,7 @@ export default function CampaignEditorPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Top Bar ── */}
|
{/* ── Top Bar ── */}
|
||||||
<div className="sticky top-0 z-40 bg-[#0a0a0a]/95 backdrop-blur border-b border-[#1a1a1a]">
|
<div className="sticky top-0 z-40 bg-[#0a0a0a]/95 backdrop-blur border-b border-[#FFFFFF]">
|
||||||
<div className="max-w-screen-xl mx-auto px-4 h-14 flex items-center justify-between gap-4">
|
<div className="max-w-screen-xl mx-auto px-4 h-14 flex items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<Link href="/admin/newsletter" className="text-[#555] hover:text-white transition-colors flex-shrink-0">
|
<Link href="/admin/newsletter" className="text-[#555] hover:text-white transition-colors flex-shrink-0">
|
||||||
@@ -313,7 +313,7 @@ export default function CampaignEditorPage() {
|
|||||||
className="bg-transparent text-sm font-medium text-white placeholder-[#444] outline-none min-w-0 w-48"
|
className="bg-transparent text-sm font-medium text-white placeholder-[#444] outline-none min-w-0 w-48"
|
||||||
/>
|
/>
|
||||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium flex-shrink-0 ${
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium flex-shrink-0 ${
|
||||||
campaign.status === 'draft' ? 'bg-[#1a1a1a] text-[#666]' :
|
campaign.status === 'draft' ? 'bg-[#FFFFFF] text-[#666]' :
|
||||||
campaign.status === 'scheduled'? 'bg-yellow-400/10 text-yellow-400' : 'bg-green-400/10 text-green-400'
|
campaign.status === 'scheduled'? 'bg-yellow-400/10 text-yellow-400' : 'bg-green-400/10 text-green-400'
|
||||||
}`}>
|
}`}>
|
||||||
{campaign.status}
|
{campaign.status}
|
||||||
@@ -339,7 +339,7 @@ export default function CampaignEditorPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleSave('draft')}
|
onClick={() => handleSave('draft')}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="px-3 py-1.5 text-xs font-medium bg-[#1a1a1a] hover:bg-[#222] border border-[#2a2a2a] text-[#aaa] rounded-lg transition-colors disabled:opacity-50"
|
className="px-3 py-1.5 text-xs font-medium bg-[#FFFFFF] hover:bg-[#222] border border-[#DCE6F2] text-[#aaa] rounded-lg transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{saving ? 'Saving…' : 'Save Draft'}
|
{saving ? 'Saving…' : 'Save Draft'}
|
||||||
</button>
|
</button>
|
||||||
@@ -393,9 +393,9 @@ export default function CampaignEditorPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{loadingArticles ? (
|
{loadingArticles ? (
|
||||||
Array.from({ length: 5 }).map((_, i) => (
|
Array.from({ length: 5 }).map((_, i) => (
|
||||||
<div key={i} className="bg-[#111] border border-[#1a1a1a] rounded-xl p-4 animate-pulse">
|
<div key={i} className="bg-[#111] border border-[#FFFFFF] rounded-xl p-4 animate-pulse">
|
||||||
<div className="h-3 bg-[#1e1e1e] rounded w-3/4 mb-2" />
|
<div className="h-3 bg-[#1e1e1e] rounded w-3/4 mb-2" />
|
||||||
<div className="h-2.5 bg-[#1a1a1a] rounded w-1/2" />
|
<div className="h-2.5 bg-[#FFFFFF] rounded w-1/2" />
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : articles.length === 0 ? (
|
) : articles.length === 0 ? (
|
||||||
@@ -409,7 +409,7 @@ export default function CampaignEditorPage() {
|
|||||||
onClick={() => toggleArticle(article)}
|
onClick={() => toggleArticle(article)}
|
||||||
className={`group relative bg-[#111] border rounded-xl p-4 cursor-pointer transition-all ${
|
className={`group relative bg-[#111] border rounded-xl p-4 cursor-pointer transition-all ${
|
||||||
selected
|
selected
|
||||||
? 'border-white/20 bg-white/5' :'border-[#1a1a1a] hover:border-[#2a2a2a]'
|
? 'border-white/20 bg-white/5' :'border-[#FFFFFF] hover:border-[#DCE6F2]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
@@ -427,7 +427,7 @@ export default function CampaignEditorPage() {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
{article.category && (
|
{article.category && (
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-[#555] bg-[#1a1a1a] px-1.5 py-0.5 rounded">
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-[#555] bg-[#FFFFFF] px-1.5 py-0.5 rounded">
|
||||||
{article.category}
|
{article.category}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -454,7 +454,7 @@ export default function CampaignEditorPage() {
|
|||||||
{(activePanel === 'settings' || activePanel === 'articles') && (
|
{(activePanel === 'settings' || activePanel === 'articles') && (
|
||||||
<>
|
<>
|
||||||
{/* Subject Line */}
|
{/* Subject Line */}
|
||||||
<div className="bg-[#111] border border-[#1a1a1a] rounded-xl p-4">
|
<div className="bg-[#111] border border-[#FFFFFF] rounded-xl p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-xs font-semibold text-white uppercase tracking-wider">Subject Line</h3>
|
<h3 className="text-xs font-semibold text-white uppercase tracking-wider">Subject Line</h3>
|
||||||
<span className={`text-[10px] font-mono ${subjectCharCount > 60 ? 'text-yellow-400' : 'text-[#444]'}`}>
|
<span className={`text-[10px] font-mono ${subjectCharCount > 60 ? 'text-yellow-400' : 'text-[#444]'}`}>
|
||||||
@@ -477,7 +477,7 @@ export default function CampaignEditorPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Preview Text */}
|
{/* Preview Text */}
|
||||||
<div className="bg-[#111] border border-[#1a1a1a] rounded-xl p-4">
|
<div className="bg-[#111] border border-[#FFFFFF] rounded-xl p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-xs font-semibold text-white uppercase tracking-wider">Preview Text</h3>
|
<h3 className="text-xs font-semibold text-white uppercase tracking-wider">Preview Text</h3>
|
||||||
<span className={`text-[10px] font-mono ${previewCharCount > 90 ? 'text-yellow-400' : 'text-[#444]'}`}>
|
<span className={`text-[10px] font-mono ${previewCharCount > 90 ? 'text-yellow-400' : 'text-[#444]'}`}>
|
||||||
@@ -497,7 +497,7 @@ export default function CampaignEditorPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recipient Segment */}
|
{/* Recipient Segment */}
|
||||||
<div className="bg-[#111] border border-[#1a1a1a] rounded-xl p-4">
|
<div className="bg-[#111] border border-[#FFFFFF] rounded-xl p-4">
|
||||||
<h3 className="text-xs font-semibold text-white uppercase tracking-wider mb-3">Recipient Segment</h3>
|
<h3 className="text-xs font-semibold text-white uppercase tracking-wider mb-3">Recipient Segment</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{SEGMENTS.map((seg) => {
|
{SEGMENTS.map((seg) => {
|
||||||
@@ -509,7 +509,7 @@ export default function CampaignEditorPage() {
|
|||||||
onClick={() => setCampaign((p) => ({ ...p, segment: seg.id }))}
|
onClick={() => setCampaign((p) => ({ ...p, segment: seg.id }))}
|
||||||
className={`w-full text-left px-3 py-2.5 rounded-lg border transition-all ${
|
className={`w-full text-left px-3 py-2.5 rounded-lg border transition-all ${
|
||||||
active
|
active
|
||||||
? 'bg-white/5 border-white/20 text-white' :'bg-[#0a0a0a] border-[#1a1a1a] text-[#888] hover:border-[#2a2a2a] hover:text-white'
|
? 'bg-white/5 border-white/20 text-white' :'bg-[#0a0a0a] border-[#FFFFFF] text-[#888] hover:border-[#DCE6F2] hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -529,7 +529,7 @@ export default function CampaignEditorPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{recipientCount > 0 && (
|
{recipientCount > 0 && (
|
||||||
<div className="mt-3 pt-3 border-t border-[#1a1a1a] flex items-center gap-2">
|
<div className="mt-3 pt-3 border-t border-[#FFFFFF] flex items-center gap-2">
|
||||||
<svg className="w-3.5 h-3.5 text-[#555]" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
<svg className="w-3.5 h-3.5 text-[#555]" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -543,7 +543,7 @@ export default function CampaignEditorPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Selected Articles ── */}
|
{/* ── Selected Articles ── */}
|
||||||
<div className="bg-[#111] border border-[#1a1a1a] rounded-xl p-4">
|
<div className="bg-[#111] border border-[#FFFFFF] rounded-xl p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-xs font-semibold text-white uppercase tracking-wider">Selected Articles</h3>
|
<h3 className="text-xs font-semibold text-white uppercase tracking-wider">Selected Articles</h3>
|
||||||
<span className="text-[10px] text-[#555]">{campaign.articles.length} article{campaign.articles.length !== 1 ? 's' : ''}</span>
|
<span className="text-[10px] text-[#555]">{campaign.articles.length} article{campaign.articles.length !== 1 ? 's' : ''}</span>
|
||||||
@@ -559,7 +559,7 @@ export default function CampaignEditorPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{campaign.articles.map((article, idx) => (
|
{campaign.articles.map((article, idx) => (
|
||||||
<div key={article.id} className="bg-[#0a0a0a] border border-[#1a1a1a] rounded-lg p-3">
|
<div key={article.id} className="bg-[#0a0a0a] border border-[#FFFFFF] rounded-lg p-3">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
{/* Order controls */}
|
{/* Order controls */}
|
||||||
<div className="flex flex-col gap-0.5 flex-shrink-0 mt-0.5">
|
<div className="flex flex-col gap-0.5 flex-shrink-0 mt-0.5">
|
||||||
@@ -618,16 +618,16 @@ export default function CampaignEditorPage() {
|
|||||||
|
|
||||||
{/* ── Preview Panel ── */}
|
{/* ── Preview Panel ── */}
|
||||||
{activePanel === 'preview' && (
|
{activePanel === 'preview' && (
|
||||||
<div className="bg-[#111] border border-[#1a1a1a] rounded-xl overflow-hidden">
|
<div className="bg-[#111] border border-[#FFFFFF] rounded-xl overflow-hidden">
|
||||||
<div className="px-4 py-3 border-b border-[#1a1a1a] flex items-center justify-between">
|
<div className="px-4 py-3 border-b border-[#FFFFFF] flex items-center justify-between">
|
||||||
<h3 className="text-xs font-semibold text-white uppercase tracking-wider">Email Preview</h3>
|
<h3 className="text-xs font-semibold text-white uppercase tracking-wider">Email Preview</h3>
|
||||||
<span className="text-[10px] text-[#555]">Inbox view</span>
|
<span className="text-[10px] text-[#555]">Inbox view</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Inbox row preview */}
|
{/* Inbox row preview */}
|
||||||
<div className="px-4 py-3 border-b border-[#1a1a1a] bg-[#0d0d0d]">
|
<div className="px-4 py-3 border-b border-[#FFFFFF] bg-[#F8FAFC]">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<div className="w-6 h-6 rounded-full bg-[#1a1a1a] flex items-center justify-center flex-shrink-0">
|
<div className="w-6 h-6 rounded-full bg-[#FFFFFF] flex items-center justify-center flex-shrink-0">
|
||||||
<span className="text-[8px] font-bold text-[#666]">BB</span>
|
<span className="text-[8px] font-bold text-[#666]">BB</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -644,7 +644,7 @@ export default function CampaignEditorPage() {
|
|||||||
{/* Email body preview */}
|
{/* Email body preview */}
|
||||||
<div className="p-4 space-y-3 max-h-[400px] overflow-y-auto">
|
<div className="p-4 space-y-3 max-h-[400px] overflow-y-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-[#0a0a0a] rounded-lg p-3 text-center border border-[#1a1a1a]">
|
<div className="bg-[#0a0a0a] rounded-lg p-3 text-center border border-[#FFFFFF]">
|
||||||
<p className="text-xs font-bold text-white tracking-widest uppercase">AV Beat</p>
|
<p className="text-xs font-bold text-white tracking-widest uppercase">AV Beat</p>
|
||||||
{campaign.previewText && (
|
{campaign.previewText && (
|
||||||
<p className="text-[10px] text-[#555] mt-1">{campaign.previewText}</p>
|
<p className="text-[10px] text-[#555] mt-1">{campaign.previewText}</p>
|
||||||
@@ -657,7 +657,7 @@ export default function CampaignEditorPage() {
|
|||||||
) : (
|
) : (
|
||||||
campaign.articles.map((article, idx) => (
|
campaign.articles.map((article, idx) => (
|
||||||
<div key={article.id} className={`rounded-lg border p-3 ${
|
<div key={article.id} className={`rounded-lg border p-3 ${
|
||||||
article.featured ? 'border-white/10 bg-white/3' : 'border-[#1a1a1a] bg-[#0a0a0a]'
|
article.featured ? 'border-white/10 bg-white/3' : 'border-[#FFFFFF] bg-[#0a0a0a]'
|
||||||
}`}>
|
}`}>
|
||||||
{article.featured && (
|
{article.featured && (
|
||||||
<span className="text-[9px] font-semibold uppercase tracking-wider text-yellow-400 mb-1 block">Featured Story</span>
|
<span className="text-[9px] font-semibold uppercase tracking-wider text-yellow-400 mb-1 block">Featured Story</span>
|
||||||
@@ -679,7 +679,7 @@ export default function CampaignEditorPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="text-center pt-2 border-t border-[#1a1a1a]">
|
<div className="text-center pt-2 border-t border-[#FFFFFF]">
|
||||||
<p className="text-[9px] text-[#333]">AV Beat · Unsubscribe · View in browser</p>
|
<p className="text-[9px] text-[#333]">AV Beat · Unsubscribe · View in browser</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -687,7 +687,7 @@ export default function CampaignEditorPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Publish Summary ── */}
|
{/* ── Publish Summary ── */}
|
||||||
<div className="bg-[#111] border border-[#1a1a1a] rounded-xl p-4">
|
<div className="bg-[#111] border border-[#FFFFFF] rounded-xl p-4">
|
||||||
<h3 className="text-xs font-semibold text-white uppercase tracking-wider mb-3">Campaign Summary</h3>
|
<h3 className="text-xs font-semibold text-white uppercase tracking-wider mb-3">Campaign Summary</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{[
|
{[
|
||||||
@@ -710,7 +710,7 @@ export default function CampaignEditorPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleSave('draft')}
|
onClick={() => handleSave('draft')}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="flex-1 py-2 text-xs font-medium bg-[#1a1a1a] hover:bg-[#222] border border-[#2a2a2a] text-[#aaa] rounded-lg transition-colors disabled:opacity-50"
|
className="flex-1 py-2 text-xs font-medium bg-[#FFFFFF] hover:bg-[#222] border border-[#DCE6F2] text-[#aaa] rounded-lg transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{saving ? 'Saving…' : 'Save Draft'}
|
{saving ? 'Saving…' : 'Save Draft'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -356,7 +356,7 @@ export default function AdminNewsletterPage() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
||||||
<div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
<div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -373,7 +373,7 @@ export default function AdminNewsletterPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-[#111] border-b border-[#252525] px-6 py-4">
|
<div className="bg-[#111] border-b border-[#DCE6F2] px-6 py-4">
|
||||||
<div className="max-w-6xl mx-auto flex items-center justify-between">
|
<div className="max-w-6xl mx-auto flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link href="/admin" className="text-[#555] hover:text-[#888] transition-colors">
|
<Link href="/admin" className="text-[#555] hover:text-[#888] transition-colors">
|
||||||
@@ -389,7 +389,7 @@ export default function AdminNewsletterPage() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link
|
<Link
|
||||||
href="/admin/newsletter/templates"
|
href="/admin/newsletter/templates"
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-body font-semibold text-[#888] hover:text-white border border-[#252525] rounded-lg transition-colors">
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-body font-semibold text-[#888] hover:text-white border border-[#DCE6F2] rounded-lg transition-colors">
|
||||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -397,7 +397,7 @@ export default function AdminNewsletterPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/admin/newsletter/campaign-editor"
|
href="/admin/newsletter/campaign-editor"
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-body font-semibold text-white bg-[#3b82f6] hover:bg-[#2563eb] rounded-lg transition-colors">
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-body font-semibold text-white bg-[#1D4ED8] hover:bg-[#1E3A8A] rounded-lg transition-colors">
|
||||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -405,18 +405,18 @@ export default function AdminNewsletterPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/admin/newsletter/analytics"
|
href="/admin/newsletter/analytics"
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-body font-semibold text-[#888] hover:text-white border border-[#252525] rounded-lg transition-colors">
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-body font-semibold text-[#888] hover:text-white border border-[#DCE6F2] rounded-lg transition-colors">
|
||||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
|
||||||
</svg>
|
</svg>
|
||||||
Analytics
|
Analytics
|
||||||
</Link>
|
</Link>
|
||||||
<div className="w-px h-8 bg-[#252525]" />
|
<div className="w-px h-8 bg-[#DCE6F2]" />
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-white font-display font-bold text-xl">{activeCount}</p>
|
<p className="text-white font-display font-bold text-xl">{activeCount}</p>
|
||||||
<p className="text-[#555] text-xs font-body">active subscribers</p>
|
<p className="text-[#555] text-xs font-body">active subscribers</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-px h-8 bg-[#252525]" />
|
<div className="w-px h-8 bg-[#DCE6F2]" />
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-white font-display font-bold text-xl">{totalCount}</p>
|
<p className="text-white font-display font-bold text-xl">{totalCount}</p>
|
||||||
<p className="text-[#555] text-xs font-body">total</p>
|
<p className="text-[#555] text-xs font-body">total</p>
|
||||||
@@ -427,15 +427,15 @@ export default function AdminNewsletterPage() {
|
|||||||
|
|
||||||
<div className="max-w-6xl mx-auto px-6 py-6">
|
<div className="max-w-6xl mx-auto px-6 py-6">
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-1 mb-6 bg-[#111] border border-[#252525] rounded-lg p-1 w-fit">
|
<div className="flex gap-1 mb-6 bg-[#111] border border-[#DCE6F2] rounded-lg p-1 w-fit">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('subscribers')}
|
onClick={() => setActiveTab('subscribers')}
|
||||||
className={`px-4 py-2 rounded-md text-sm font-body font-semibold transition-colors ${activeTab === 'subscribers' ? 'bg-[#3b82f6] text-white' : 'text-[#888] hover:text-white'}`}>
|
className={`px-4 py-2 rounded-md text-sm font-body font-semibold transition-colors ${activeTab === 'subscribers' ? 'bg-[#1D4ED8] text-white' : 'text-[#888] hover:text-white'}`}>
|
||||||
Subscribers
|
Subscribers
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('send-digest')}
|
onClick={() => setActiveTab('send-digest')}
|
||||||
className={`px-4 py-2 rounded-md text-sm font-body font-semibold transition-colors ${activeTab === 'send-digest' ? 'bg-[#3b82f6] text-white' : 'text-[#888] hover:text-white'}`}>
|
className={`px-4 py-2 rounded-md text-sm font-body font-semibold transition-colors ${activeTab === 'send-digest' ? 'bg-[#1D4ED8] text-white' : 'text-[#888] hover:text-white'}`}>
|
||||||
Send Digest
|
Send Digest
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -452,12 +452,12 @@ export default function AdminNewsletterPage() {
|
|||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="search-input flex-1 py-2 px-3 text-sm"
|
className="search-input flex-1 py-2 px-3 text-sm"
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-1 bg-[#111] border border-[#252525] rounded-lg p-1">
|
<div className="flex gap-1 bg-[#111] border border-[#DCE6F2] rounded-lg p-1">
|
||||||
{(['all', 'active', 'unsubscribed'] as const).map((s) => (
|
{(['all', 'active', 'unsubscribed'] as const).map((s) => (
|
||||||
<button
|
<button
|
||||||
key={s}
|
key={s}
|
||||||
onClick={() => setStatusFilter(s)}
|
onClick={() => setStatusFilter(s)}
|
||||||
className={`px-3 py-1.5 rounded-md text-xs font-body font-semibold capitalize transition-colors ${statusFilter === s ? 'bg-[#3b82f6] text-white' : 'text-[#888] hover:text-white'}`}
|
className={`px-3 py-1.5 rounded-md text-xs font-body font-semibold capitalize transition-colors ${statusFilter === s ? 'bg-[#1D4ED8] text-white' : 'text-[#888] hover:text-white'}`}
|
||||||
>
|
>
|
||||||
{s}
|
{s}
|
||||||
</button>
|
</button>
|
||||||
@@ -467,7 +467,7 @@ export default function AdminNewsletterPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleExportCSV}
|
onClick={handleExportCSV}
|
||||||
disabled={subscribers.length === 0}
|
disabled={subscribers.length === 0}
|
||||||
className="flex items-center gap-1.5 px-3 py-2 text-xs font-body font-semibold text-[#888] hover:text-white border border-[#252525] rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap"
|
className="flex items-center gap-1.5 px-3 py-2 text-xs font-body font-semibold text-[#888] hover:text-white border border-[#DCE6F2] rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 015.25 21h13.5A2.25 2.25 0 0121 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 015.25 21h13.5A2.25 2.25 0 0121 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||||
@@ -478,9 +478,9 @@ export default function AdminNewsletterPage() {
|
|||||||
|
|
||||||
{/* Bulk Actions Bar */}
|
{/* Bulk Actions Bar */}
|
||||||
{someSelected && (
|
{someSelected && (
|
||||||
<div className="flex items-center justify-between bg-[#1a2535] border border-[#1e3a5f] rounded-lg px-4 py-3 mb-4">
|
<div className="flex items-center justify-between bg-[#FFFFFF] border border-[#DCE6F2] rounded-lg px-4 py-3 mb-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-sm font-body font-semibold text-[#3b82f6]">
|
<span className="text-sm font-body font-semibold text-[#1D4ED8]">
|
||||||
{selectedIds.size} selected
|
{selectedIds.size} selected
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -512,17 +512,17 @@ export default function AdminNewsletterPage() {
|
|||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
{bulkLoading && (
|
{bulkLoading && (
|
||||||
<div className="w-4 h-4 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
<div className="w-4 h-4 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-hidden">
|
||||||
{loadingData ? (
|
{loadingData ? (
|
||||||
<div className="flex items-center justify-center py-16">
|
<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 className="w-6 h-6 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : subscribers.length === 0 ? (
|
) : subscribers.length === 0 ? (
|
||||||
<div className="text-center py-16">
|
<div className="text-center py-16">
|
||||||
@@ -534,13 +534,13 @@ export default function AdminNewsletterPage() {
|
|||||||
) : (
|
) : (
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[#252525]">
|
<tr className="border-b border-[#DCE6F2]">
|
||||||
<th className="px-4 py-3 w-10">
|
<th className="px-4 py-3 w-10">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={allSelected}
|
checked={allSelected}
|
||||||
onChange={toggleSelectAll}
|
onChange={toggleSelectAll}
|
||||||
className="w-4 h-4 rounded border-[#444] bg-[#1a1a1a] accent-[#3b82f6] cursor-pointer"
|
className="w-4 h-4 rounded border-[#444] bg-[#FFFFFF] accent-[#1D4ED8] cursor-pointer"
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
<th className="text-left px-4 py-3 text-xs font-body font-bold text-[#555] uppercase tracking-wider">Email</th>
|
<th className="text-left px-4 py-3 text-xs font-body font-bold text-[#555] uppercase tracking-wider">Email</th>
|
||||||
@@ -554,21 +554,21 @@ export default function AdminNewsletterPage() {
|
|||||||
{subscribers.map((sub) => (
|
{subscribers.map((sub) => (
|
||||||
<tr
|
<tr
|
||||||
key={sub.id}
|
key={sub.id}
|
||||||
className={`border-b border-[#1a1a1a] transition-colors ${selectedIds.has(sub.id) ? 'bg-[#0d1a2d]' : 'hover:bg-[#0d0d0d]'}`}
|
className={`border-b border-[#FFFFFF] transition-colors ${selectedIds.has(sub.id) ? 'bg-[#0d1a2d]' : 'hover:bg-[#F8FAFC]'}`}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3 w-10">
|
<td className="px-4 py-3 w-10">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedIds.has(sub.id)}
|
checked={selectedIds.has(sub.id)}
|
||||||
onChange={() => toggleSelect(sub.id)}
|
onChange={() => toggleSelect(sub.id)}
|
||||||
className="w-4 h-4 rounded border-[#444] bg-[#1a1a1a] accent-[#3b82f6] cursor-pointer"
|
className="w-4 h-4 rounded border-[#444] bg-[#FFFFFF] accent-[#1D4ED8] cursor-pointer"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm font-body text-[#e0e0e0]">{sub.email}</td>
|
<td className="px-4 py-3 text-sm font-body text-[#e0e0e0]">{sub.email}</td>
|
||||||
<td className="px-4 py-3 hidden md:table-cell">
|
<td className="px-4 py-3 hidden md:table-cell">
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{sub.topics?.slice(0, 3).map((t) => (
|
{sub.topics?.slice(0, 3).map((t) => (
|
||||||
<span key={t} className="text-xs bg-[#1a2535] text-[#3b82f6] px-2 py-0.5 rounded-full font-body">{t}</span>
|
<span key={t} className="text-xs bg-[#FFFFFF] text-[#1D4ED8] px-2 py-0.5 rounded-full font-body">{t}</span>
|
||||||
))}
|
))}
|
||||||
{sub.topics?.length > 3 && (
|
{sub.topics?.length > 3 && (
|
||||||
<span className="text-xs text-[#555] font-body">+{sub.topics.length - 3}</span>
|
<span className="text-xs text-[#555] font-body">+{sub.topics.length - 3}</span>
|
||||||
@@ -576,7 +576,7 @@ export default function AdminNewsletterPage() {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className={`text-xs font-body font-bold px-2 py-1 rounded-full ${sub.status === 'active' ? 'text-green-400 bg-green-400/10' : 'text-[#555] bg-[#1a1a1a]'}`}>
|
<span className={`text-xs font-body font-bold px-2 py-1 rounded-full ${sub.status === 'active' ? 'text-green-400 bg-green-400/10' : 'text-[#555] bg-[#FFFFFF]'}`}>
|
||||||
{sub.status}
|
{sub.status}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -616,21 +616,21 @@ export default function AdminNewsletterPage() {
|
|||||||
{activeTab === 'send-digest' && (
|
{activeTab === 'send-digest' && (
|
||||||
<div className="max-w-2xl">
|
<div className="max-w-2xl">
|
||||||
{/* SMTP warning if not configured */}
|
{/* SMTP warning if not configured */}
|
||||||
<div className="bg-[#1a2535] border border-[#1e3a5f] rounded-lg p-4 mb-6 flex gap-3">
|
<div className="bg-[#FFFFFF] border border-[#DCE6F2] rounded-lg p-4 mb-6 flex gap-3">
|
||||||
<svg className="w-5 h-5 text-[#3b82f6] flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 text-[#1D4ED8] flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[#e0e0e0] text-sm font-body font-semibold">SMTP Required</p>
|
<p className="text-[#e0e0e0] text-sm font-body font-semibold">SMTP Required</p>
|
||||||
<p className="text-[#777] text-xs font-body mt-0.5">
|
<p className="text-[#777] text-xs font-body mt-0.5">
|
||||||
Set <code className="text-[#3b82f6]">SMTP_HOST</code>, <code className="text-[#3b82f6]">SMTP_USER</code>, <code className="text-[#3b82f6]">SMTP_PASS</code>, and <code className="text-[#3b82f6]">SMTP_FROM_EMAIL</code> in your environment variables to enable sending.
|
Set <code className="text-[#1D4ED8]">SMTP_HOST</code>, <code className="text-[#1D4ED8]">SMTP_USER</code>, <code className="text-[#1D4ED8]">SMTP_PASS</code>, and <code className="text-[#1D4ED8]">SMTP_FROM_EMAIL</code> in your environment variables to enable sending.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Applied template badge */}
|
{/* Applied template badge */}
|
||||||
{appliedTemplate && (
|
{appliedTemplate && (
|
||||||
<div className="flex items-center justify-between bg-[#1a2535] border border-[#1e3a5f] rounded-lg px-4 py-3 mb-4">
|
<div className="flex items-center justify-between bg-[#FFFFFF] border border-[#DCE6F2] rounded-lg px-4 py-3 mb-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5" style={{ backgroundColor: appliedTemplate.accent_color + '20' }}>
|
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5" style={{ backgroundColor: appliedTemplate.accent_color + '20' }}>
|
||||||
<span style={{ color: appliedTemplate.accent_color }}>
|
<span style={{ color: appliedTemplate.accent_color }}>
|
||||||
@@ -662,7 +662,7 @@ export default function AdminNewsletterPage() {
|
|||||||
<p className="text-xs font-body text-[#555]">Fill in the fields below or start from a template</p>
|
<p className="text-xs font-body text-[#555]">Fill in the fields below or start from a template</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => { fetchTemplates(); setShowTemplateDrawer(true); }}
|
onClick={() => { fetchTemplates(); setShowTemplateDrawer(true); }}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-body font-semibold text-[#3b82f6] border border-[#1e3a5f] rounded-lg hover:bg-[#1a2535] transition-colors"
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-body font-semibold text-[#1D4ED8] border border-[#DCE6F2] rounded-lg hover:bg-[#FFFFFF] transition-colors"
|
||||||
>
|
>
|
||||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||||
@@ -725,14 +725,14 @@ export default function AdminNewsletterPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={addArticle}
|
onClick={addArticle}
|
||||||
className="text-xs text-[#3b82f6] hover:text-blue-400 font-body font-semibold transition-colors"
|
className="text-xs text-[#1D4ED8] hover:text-blue-400 font-body font-semibold transition-colors"
|
||||||
>
|
>
|
||||||
+ Add Article
|
+ Add Article
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{digestArticles.map((article, index) => (
|
{digestArticles.map((article, index) => (
|
||||||
<div key={index} className="bg-[#111] border border-[#252525] rounded-lg p-4">
|
<div key={index} className="bg-[#111] border border-[#DCE6F2] rounded-lg p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="text-xs font-body text-[#555]">Article {index + 1}</span>
|
<span className="text-xs font-body text-[#555]">Article {index + 1}</span>
|
||||||
{digestArticles.length > 1 && (
|
{digestArticles.length > 1 && (
|
||||||
@@ -803,11 +803,11 @@ export default function AdminNewsletterPage() {
|
|||||||
{/* Template Picker Drawer */}
|
{/* Template Picker Drawer */}
|
||||||
{showTemplateDrawer && (
|
{showTemplateDrawer && (
|
||||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-end sm:items-center justify-center p-0 sm:p-4" onClick={() => setShowTemplateDrawer(false)}>
|
<div className="fixed inset-0 z-50 bg-black/70 flex items-end sm:items-center justify-center p-0 sm:p-4" onClick={() => setShowTemplateDrawer(false)}>
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-t-2xl sm:rounded-xl w-full sm:max-w-2xl max-h-[80vh] overflow-hidden flex flex-col" onClick={(e) => e.stopPropagation()}>
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-t-2xl sm:rounded-xl w-full sm:max-w-2xl max-h-[80vh] overflow-hidden flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[#252525] flex-shrink-0">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-[#DCE6F2] flex-shrink-0">
|
||||||
<h2 className="font-display font-bold text-white text-base">Choose a Template</h2>
|
<h2 className="font-display font-bold text-white text-base">Choose a Template</h2>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link href="/admin/newsletter/templates" className="text-xs text-[#3b82f6] font-body hover:text-blue-400 transition-colors">
|
<Link href="/admin/newsletter/templates" className="text-xs text-[#1D4ED8] font-body hover:text-blue-400 transition-colors">
|
||||||
Manage templates →
|
Manage templates →
|
||||||
</Link>
|
</Link>
|
||||||
<button onClick={() => setShowTemplateDrawer(false)} className="text-[#555] hover:text-white transition-colors">
|
<button onClick={() => setShowTemplateDrawer(false)} className="text-[#555] hover:text-white transition-colors">
|
||||||
@@ -820,12 +820,12 @@ export default function AdminNewsletterPage() {
|
|||||||
<div className="overflow-y-auto flex-1 p-4">
|
<div className="overflow-y-auto flex-1 p-4">
|
||||||
{loadingTemplates ? (
|
{loadingTemplates ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<div className="w-6 h-6 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
<div className="w-6 h-6 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : templates.length === 0 ? (
|
) : templates.length === 0 ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-[#555] font-body text-sm">No templates found.</p>
|
<p className="text-[#555] font-body text-sm">No templates found.</p>
|
||||||
<Link href="/admin/newsletter/templates" className="text-[#3b82f6] text-sm font-body hover:text-blue-400 mt-2 inline-block">
|
<Link href="/admin/newsletter/templates" className="text-[#1D4ED8] text-sm font-body hover:text-blue-400 mt-2 inline-block">
|
||||||
Create your first template →
|
Create your first template →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -835,7 +835,7 @@ export default function AdminNewsletterPage() {
|
|||||||
<button
|
<button
|
||||||
key={tpl.id}
|
key={tpl.id}
|
||||||
onClick={() => applyTemplate(tpl)}
|
onClick={() => applyTemplate(tpl)}
|
||||||
className="flex items-start gap-3 p-4 bg-[#0d0d0d] border border-[#252525] rounded-xl text-left hover:border-[#3b82f6] hover:bg-[#1a2535] transition-colors group"
|
className="flex items-start gap-3 p-4 bg-[#F8FAFC] border border-[#DCE6F2] rounded-xl text-left hover:border-[#1D4ED8] hover:bg-[#FFFFFF] transition-colors group"
|
||||||
>
|
>
|
||||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5" style={{ backgroundColor: tpl.accent_color + '20' }}>
|
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5" style={{ backgroundColor: tpl.accent_color + '20' }}>
|
||||||
<span style={{ color: tpl.accent_color }}>
|
<span style={{ color: tpl.accent_color }}>
|
||||||
@@ -851,8 +851,8 @@ export default function AdminNewsletterPage() {
|
|||||||
</div>
|
</div>
|
||||||
{tpl.description && <p className="text-xs text-[#777] font-body line-clamp-1">{tpl.description}</p>}
|
{tpl.description && <p className="text-xs text-[#777] font-body line-clamp-1">{tpl.description}</p>}
|
||||||
<div className="flex gap-1.5 mt-1.5">
|
<div className="flex gap-1.5 mt-1.5">
|
||||||
<span className="text-xs bg-[#1a1a1a] text-[#888] px-1.5 py-0.5 rounded font-body capitalize">{tpl.layout}</span>
|
<span className="text-xs bg-[#FFFFFF] text-[#888] px-1.5 py-0.5 rounded font-body capitalize">{tpl.layout}</span>
|
||||||
<span className="text-xs bg-[#1a1a1a] text-[#888] px-1.5 py-0.5 rounded font-body">{tpl.article_blocks?.length || 0} slots</span>
|
<span className="text-xs bg-[#FFFFFF] text-[#888] px-1.5 py-0.5 rounded font-body">{tpl.article_blocks?.length || 0} slots</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const STYLE_OPTIONS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const ACCENT_COLORS = [
|
const ACCENT_COLORS = [
|
||||||
'#3b82f6', '#ef4444', '#10b981', '#8b5cf6', '#f59e0b', '#ec4899', '#06b6d4', '#f97316',
|
'#1D4ED8', '#ef4444', '#10b981', '#8b5cf6', '#f59e0b', '#ec4899', '#06b6d4', '#f97316',
|
||||||
];
|
];
|
||||||
|
|
||||||
const LAYOUT_ICONS: Record<string, React.ReactNode> = {
|
const LAYOUT_ICONS: Record<string, React.ReactNode> = {
|
||||||
@@ -83,7 +83,7 @@ const emptyForm = (): Omit<NewsletterTemplate, 'id' | 'is_preset' | 'created_at'
|
|||||||
subject_prefix: '',
|
subject_prefix: '',
|
||||||
header_text: '',
|
header_text: '',
|
||||||
footer_text: '',
|
footer_text: '',
|
||||||
accent_color: '#3b82f6',
|
accent_color: '#1D4ED8',
|
||||||
article_blocks: [{ title: '', excerpt: '', url: '', category: '' }],
|
article_blocks: [{ title: '', excerpt: '', url: '', category: '' }],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -226,7 +226,7 @@ export default function NewsletterTemplatesPage() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
||||||
<div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
<div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -242,7 +242,7 @@ export default function NewsletterTemplatesPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-[#111] border-b border-[#252525] px-6 py-4">
|
<div className="bg-[#111] border-b border-[#DCE6F2] px-6 py-4">
|
||||||
<div className="max-w-6xl mx-auto flex items-center justify-between">
|
<div className="max-w-6xl mx-auto flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link href="/admin/newsletter" className="text-[#555] hover:text-[#888] transition-colors">
|
<Link href="/admin/newsletter" className="text-[#555] hover:text-[#888] transition-colors">
|
||||||
@@ -272,11 +272,11 @@ export default function NewsletterTemplatesPage() {
|
|||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<h2 className="font-display font-bold text-white text-base">Preset Templates</h2>
|
<h2 className="font-display font-bold text-white text-base">Preset Templates</h2>
|
||||||
<span className="text-xs bg-[#1a2535] text-[#3b82f6] px-2 py-0.5 rounded-full font-body">{presets.length}</span>
|
<span className="text-xs bg-[#FFFFFF] text-[#1D4ED8] px-2 py-0.5 rounded-full font-body">{presets.length}</span>
|
||||||
</div>
|
</div>
|
||||||
{loadingData ? (
|
{loadingData ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<div className="w-6 h-6 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
<div className="w-6 h-6 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
@@ -298,15 +298,15 @@ export default function NewsletterTemplatesPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<h2 className="font-display font-bold text-white text-base">Custom Templates</h2>
|
<h2 className="font-display font-bold text-white text-base">Custom Templates</h2>
|
||||||
<span className="text-xs bg-[#1a2535] text-[#3b82f6] px-2 py-0.5 rounded-full font-body">{custom.length}</span>
|
<span className="text-xs bg-[#FFFFFF] text-[#1D4ED8] px-2 py-0.5 rounded-full font-body">{custom.length}</span>
|
||||||
</div>
|
</div>
|
||||||
{!loadingData && custom.length === 0 ? (
|
{!loadingData && custom.length === 0 ? (
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-10 text-center">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg p-10 text-center">
|
||||||
<svg className="w-10 h-10 text-[#333] mx-auto mb-3" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
|
<svg className="w-10 h-10 text-[#333] mx-auto mb-3" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||||
</svg>
|
</svg>
|
||||||
<p className="text-[#555] font-body text-sm mb-3">No custom templates yet</p>
|
<p className="text-[#555] font-body text-sm mb-3">No custom templates yet</p>
|
||||||
<button onClick={openNew} className="text-[#3b82f6] text-sm font-body font-semibold hover:text-blue-400 transition-colors">
|
<button onClick={openNew} className="text-[#1D4ED8] text-sm font-body font-semibold hover:text-blue-400 transition-colors">
|
||||||
Create your first template →
|
Create your first template →
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -330,8 +330,8 @@ export default function NewsletterTemplatesPage() {
|
|||||||
{/* Template Form Modal */}
|
{/* Template Form Modal */}
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-start justify-center overflow-y-auto py-8 px-4">
|
<div className="fixed inset-0 z-50 bg-black/70 flex items-start justify-center overflow-y-auto py-8 px-4">
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-xl w-full max-w-2xl">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-xl w-full max-w-2xl">
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[#252525]">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-[#DCE6F2]">
|
||||||
<h2 className="font-display font-bold text-white text-base">
|
<h2 className="font-display font-bold text-white text-base">
|
||||||
{editingId ? 'Edit Template' : 'New Template'}
|
{editingId ? 'Edit Template' : 'New Template'}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -376,8 +376,8 @@ export default function NewsletterTemplatesPage() {
|
|||||||
key={opt.value}
|
key={opt.value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setForm((f) => ({ ...f, layout: opt.value as NewsletterTemplate['layout'] }))}
|
onClick={() => setForm((f) => ({ ...f, layout: opt.value as NewsletterTemplate['layout'] }))}
|
||||||
className={`flex items-start gap-3 p-3 rounded-lg border text-left transition-colors ${form.layout === opt.value ? 'border-[#3b82f6] bg-[#1a2535]' : 'border-[#252525] bg-[#0d0d0d] hover:border-[#333]'}`}>
|
className={`flex items-start gap-3 p-3 rounded-lg border text-left transition-colors ${form.layout === opt.value ? 'border-[#1D4ED8] bg-[#FFFFFF]' : 'border-[#DCE6F2] bg-[#F8FAFC] hover:border-[#333]'}`}>
|
||||||
<span className={`mt-0.5 ${form.layout === opt.value ? 'text-[#3b82f6]' : 'text-[#555]'}`}>
|
<span className={`mt-0.5 ${form.layout === opt.value ? 'text-[#1D4ED8]' : 'text-[#555]'}`}>
|
||||||
{LAYOUT_ICONS[opt.value]}
|
{LAYOUT_ICONS[opt.value]}
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
@@ -398,7 +398,7 @@ export default function NewsletterTemplatesPage() {
|
|||||||
key={opt.value}
|
key={opt.value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setForm((f) => ({ ...f, style: opt.value as NewsletterTemplate['style'] }))}
|
onClick={() => setForm((f) => ({ ...f, style: opt.value as NewsletterTemplate['style'] }))}
|
||||||
className={`flex-1 py-2.5 px-3 rounded-lg border text-sm font-body font-semibold transition-colors ${form.style === opt.value ? 'border-[#3b82f6] bg-[#1a2535] text-white' : 'border-[#252525] bg-[#0d0d0d] text-[#888] hover:border-[#333]'}`}>
|
className={`flex-1 py-2.5 px-3 rounded-lg border text-sm font-body font-semibold transition-colors ${form.style === opt.value ? 'border-[#1D4ED8] bg-[#FFFFFF] text-white' : 'border-[#DCE6F2] bg-[#F8FAFC] text-[#888] hover:border-[#333]'}`}>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -469,14 +469,14 @@ export default function NewsletterTemplatesPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<label className="text-xs font-body font-bold text-[#888] uppercase tracking-wider">Article Block Slots</label>
|
<label className="text-xs font-body font-bold text-[#888] uppercase tracking-wider">Article Block Slots</label>
|
||||||
<button type="button" onClick={addBlock} className="text-xs text-[#3b82f6] hover:text-blue-400 font-body font-semibold transition-colors">
|
<button type="button" onClick={addBlock} className="text-xs text-[#1D4ED8] hover:text-blue-400 font-body font-semibold transition-colors">
|
||||||
+ Add Slot
|
+ Add Slot
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-[#555] font-body mb-3">Define placeholder slots for articles. These will be pre-filled when using the template in Send Digest.</p>
|
<p className="text-xs text-[#555] font-body mb-3">Define placeholder slots for articles. These will be pre-filled when using the template in Send Digest.</p>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{form.article_blocks.map((block, i) => (
|
{form.article_blocks.map((block, i) => (
|
||||||
<div key={i} className="bg-[#0d0d0d] border border-[#252525] rounded-lg p-3">
|
<div key={i} className="bg-[#F8FAFC] border border-[#DCE6F2] rounded-lg p-3">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-xs font-body text-[#555]">Slot {i + 1}</span>
|
<span className="text-xs font-body text-[#555]">Slot {i + 1}</span>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -486,7 +486,7 @@ export default function NewsletterTemplatesPage() {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={!!block.featured}
|
checked={!!block.featured}
|
||||||
onChange={(e) => updateBlock(i, 'featured', e.target.checked)}
|
onChange={(e) => updateBlock(i, 'featured', e.target.checked)}
|
||||||
className="w-3 h-3 accent-[#3b82f6]"
|
className="w-3 h-3 accent-[#1D4ED8]"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs font-body text-[#888]">Featured</span>
|
<span className="text-xs font-body text-[#888]">Featured</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -527,7 +527,7 @@ export default function NewsletterTemplatesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-[#252525]">
|
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-[#DCE6F2]">
|
||||||
<button onClick={() => setShowForm(false)} className="px-4 py-2 text-sm font-body text-[#888] hover:text-white transition-colors">
|
<button onClick={() => setShowForm(false)} className="px-4 py-2 text-sm font-body text-[#888] hover:text-white transition-colors">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -545,8 +545,8 @@ export default function NewsletterTemplatesPage() {
|
|||||||
{/* Preview Modal */}
|
{/* Preview Modal */}
|
||||||
{previewTemplate && (
|
{previewTemplate && (
|
||||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4" onClick={() => setPreviewTemplate(null)}>
|
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4" onClick={() => setPreviewTemplate(null)}>
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-xl w-full max-w-lg" onClick={(e) => e.stopPropagation()}>
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-xl w-full max-w-lg" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[#252525]">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-[#DCE6F2]">
|
||||||
<h2 className="font-display font-bold text-white text-base">Template Preview</h2>
|
<h2 className="font-display font-bold text-white text-base">Template Preview</h2>
|
||||||
<button onClick={() => setPreviewTemplate(null)} className="text-[#555] hover:text-white transition-colors">
|
<button onClick={() => setPreviewTemplate(null)} className="text-[#555] hover:text-white transition-colors">
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
@@ -565,27 +565,27 @@ export default function NewsletterTemplatesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-3 text-center">
|
<div className="grid grid-cols-3 gap-3 text-center">
|
||||||
<div className="bg-[#0d0d0d] rounded-lg p-3">
|
<div className="bg-[#F8FAFC] rounded-lg p-3">
|
||||||
<p className="text-xs text-[#555] font-body mb-1">Layout</p>
|
<p className="text-xs text-[#555] font-body mb-1">Layout</p>
|
||||||
<p className="text-sm font-body font-semibold text-white capitalize">{previewTemplate.layout}</p>
|
<p className="text-sm font-body font-semibold text-white capitalize">{previewTemplate.layout}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-[#0d0d0d] rounded-lg p-3">
|
<div className="bg-[#F8FAFC] rounded-lg p-3">
|
||||||
<p className="text-xs text-[#555] font-body mb-1">Style</p>
|
<p className="text-xs text-[#555] font-body mb-1">Style</p>
|
||||||
<p className="text-sm font-body font-semibold text-white capitalize">{previewTemplate.style}</p>
|
<p className="text-sm font-body font-semibold text-white capitalize">{previewTemplate.style}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-[#0d0d0d] rounded-lg p-3">
|
<div className="bg-[#F8FAFC] rounded-lg p-3">
|
||||||
<p className="text-xs text-[#555] font-body mb-1">Slots</p>
|
<p className="text-xs text-[#555] font-body mb-1">Slots</p>
|
||||||
<p className="text-sm font-body font-semibold text-white">{previewTemplate.article_blocks?.length || 0}</p>
|
<p className="text-sm font-body font-semibold text-white">{previewTemplate.article_blocks?.length || 0}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{previewTemplate.subject_prefix && (
|
{previewTemplate.subject_prefix && (
|
||||||
<div className="bg-[#0d0d0d] rounded-lg p-3">
|
<div className="bg-[#F8FAFC] rounded-lg p-3">
|
||||||
<p className="text-xs text-[#555] font-body mb-1">Subject Prefix</p>
|
<p className="text-xs text-[#555] font-body mb-1">Subject Prefix</p>
|
||||||
<p className="text-sm font-body text-[#e0e0e0]">{previewTemplate.subject_prefix}</p>
|
<p className="text-sm font-body text-[#e0e0e0]">{previewTemplate.subject_prefix}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{previewTemplate.header_text && (
|
{previewTemplate.header_text && (
|
||||||
<div className="bg-[#0d0d0d] rounded-lg p-3">
|
<div className="bg-[#F8FAFC] rounded-lg p-3">
|
||||||
<p className="text-xs text-[#555] font-body mb-1">Header Text</p>
|
<p className="text-xs text-[#555] font-body mb-1">Header Text</p>
|
||||||
<p className="text-sm font-body text-[#e0e0e0]">{previewTemplate.header_text}</p>
|
<p className="text-sm font-body text-[#e0e0e0]">{previewTemplate.header_text}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -595,9 +595,9 @@ export default function NewsletterTemplatesPage() {
|
|||||||
<p className="text-xs text-[#555] font-body mb-2">Article Slots ({previewTemplate.article_blocks.length})</p>
|
<p className="text-xs text-[#555] font-body mb-2">Article Slots ({previewTemplate.article_blocks.length})</p>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{previewTemplate.article_blocks.map((b, i) => (
|
{previewTemplate.article_blocks.map((b, i) => (
|
||||||
<div key={i} className="flex items-center gap-2 bg-[#0d0d0d] rounded px-3 py-2">
|
<div key={i} className="flex items-center gap-2 bg-[#F8FAFC] rounded px-3 py-2">
|
||||||
<span className="text-xs text-[#555] font-body w-12 flex-shrink-0">Slot {i + 1}</span>
|
<span className="text-xs text-[#555] font-body w-12 flex-shrink-0">Slot {i + 1}</span>
|
||||||
{b.featured && <span className="text-xs text-[#3b82f6] font-body bg-[#1a2535] px-1.5 py-0.5 rounded">Featured</span>}
|
{b.featured && <span className="text-xs text-[#1D4ED8] font-body bg-[#FFFFFF] px-1.5 py-0.5 rounded">Featured</span>}
|
||||||
{b.category && <span className="text-xs text-[#888] font-body">{b.category}</span>}
|
{b.category && <span className="text-xs text-[#888] font-body">{b.category}</span>}
|
||||||
{b.title && <span className="text-xs text-[#e0e0e0] font-body truncate">{b.title}</span>}
|
{b.title && <span className="text-xs text-[#e0e0e0] font-body truncate">{b.title}</span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -614,7 +614,7 @@ export default function NewsletterTemplatesPage() {
|
|||||||
{!previewTemplate.is_preset && (
|
{!previewTemplate.is_preset && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setPreviewTemplate(null); openEdit(previewTemplate); }}
|
onClick={() => { setPreviewTemplate(null); openEdit(previewTemplate); }}
|
||||||
className="px-4 py-2 text-sm font-body text-[#888] hover:text-white border border-[#252525] rounded-lg transition-colors">
|
className="px-4 py-2 text-sm font-body text-[#888] hover:text-white border border-[#DCE6F2] rounded-lg transition-colors">
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -637,7 +637,7 @@ interface TemplateCardProps {
|
|||||||
|
|
||||||
function TemplateCard({ template, onEdit, onDelete, onPreview, deletingId }: TemplateCardProps) {
|
function TemplateCard({ template, onEdit, onDelete, onPreview, deletingId }: TemplateCardProps) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-xl overflow-hidden hover:border-[#333] transition-colors group">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-xl overflow-hidden hover:border-[#333] transition-colors group">
|
||||||
{/* Color bar */}
|
{/* Color bar */}
|
||||||
<div className="h-1" style={{ backgroundColor: template.accent_color }} />
|
<div className="h-1" style={{ backgroundColor: template.accent_color }} />
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
@@ -660,27 +660,27 @@ function TemplateCard({ template, onEdit, onDelete, onPreview, deletingId }: Tem
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1.5 mb-4">
|
<div className="flex flex-wrap gap-1.5 mb-4">
|
||||||
<span className="text-xs bg-[#1a1a1a] text-[#888] px-2 py-0.5 rounded-full font-body capitalize">{template.layout}</span>
|
<span className="text-xs bg-[#FFFFFF] text-[#888] px-2 py-0.5 rounded-full font-body capitalize">{template.layout}</span>
|
||||||
<span className="text-xs bg-[#1a1a1a] text-[#888] px-2 py-0.5 rounded-full font-body capitalize">{template.style}</span>
|
<span className="text-xs bg-[#FFFFFF] text-[#888] px-2 py-0.5 rounded-full font-body capitalize">{template.style}</span>
|
||||||
<span className="text-xs bg-[#1a1a1a] text-[#888] px-2 py-0.5 rounded-full font-body">{template.article_blocks?.length || 0} slots</span>
|
<span className="text-xs bg-[#FFFFFF] text-[#888] px-2 py-0.5 rounded-full font-body">{template.article_blocks?.length || 0} slots</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => onPreview(template)}
|
onClick={() => onPreview(template)}
|
||||||
className="flex-1 py-1.5 text-xs font-body font-semibold text-[#3b82f6] border border-[#1e3a5f] rounded-lg hover:bg-[#1a2535] transition-colors">
|
className="flex-1 py-1.5 text-xs font-body font-semibold text-[#1D4ED8] border border-[#DCE6F2] rounded-lg hover:bg-[#FFFFFF] transition-colors">
|
||||||
Preview
|
Preview
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/newsletter?template=${template.id}`}
|
href={`/admin/newsletter?template=${template.id}`}
|
||||||
className="flex-1 py-1.5 text-xs font-body font-semibold text-center text-white bg-[#1a2535] border border-[#1e3a5f] rounded-lg hover:bg-[#1e3a5f] transition-colors">
|
className="flex-1 py-1.5 text-xs font-body font-semibold text-center text-white bg-[#FFFFFF] border border-[#DCE6F2] rounded-lg hover:bg-[#DCE6F2] transition-colors">
|
||||||
Use
|
Use
|
||||||
</Link>
|
</Link>
|
||||||
{!template.is_preset && (
|
{!template.is_preset && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => onEdit(template)}
|
onClick={() => onEdit(template)}
|
||||||
className="p-1.5 text-[#555] hover:text-white border border-[#252525] rounded-lg transition-colors">
|
className="p-1.5 text-[#555] hover:text-white border border-[#DCE6F2] rounded-lg transition-colors">
|
||||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -688,7 +688,7 @@ function TemplateCard({ template, onEdit, onDelete, onPreview, deletingId }: Tem
|
|||||||
<button
|
<button
|
||||||
onClick={() => onDelete(template.id)}
|
onClick={() => onDelete(template.id)}
|
||||||
disabled={deletingId === template.id}
|
disabled={deletingId === template.id}
|
||||||
className="p-1.5 text-[#555] hover:text-red-400 border border-[#252525] rounded-lg transition-colors disabled:opacity-50">
|
className="p-1.5 text-[#555] hover:text-red-400 border border-[#DCE6F2] rounded-lg transition-colors disabled:opacity-50">
|
||||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ function StatCard({ label, value, icon, color, active, onClick }: StatCardProps)
|
|||||||
className={`w-full text-left p-4 rounded-lg border transition-all ${
|
className={`w-full text-left p-4 rounded-lg border transition-all ${
|
||||||
active
|
active
|
||||||
? `${color} border-current/30`
|
? `${color} border-current/30`
|
||||||
: 'bg-[#111] border-[#252525] hover:border-[#333]'
|
: 'bg-[#111] border-[#DCE6F2] hover:border-[#333]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
@@ -191,7 +191,7 @@ function NotificationRow({ notification: n, onMarkRead, onDismiss }: Notificatio
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`relative p-4 rounded-lg border transition-all ${
|
className={`relative p-4 rounded-lg border transition-all ${
|
||||||
n.read ? 'bg-[#0d0d0d] border-[#1e1e1e]' : `${cfg.bg} ${cfg.border}`
|
n.read ? 'bg-[#F8FAFC] border-[#1e1e1e]' : `${cfg.bg} ${cfg.border}`
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Unread dot */}
|
{/* Unread dot */}
|
||||||
@@ -202,7 +202,7 @@ function NotificationRow({ notification: n, onMarkRead, onDismiss }: Notificatio
|
|||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<div className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center ${
|
<div className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center ${
|
||||||
n.read ? 'bg-[#1a1a1a]' : cfg.badge
|
n.read ? 'bg-[#FFFFFF]' : cfg.badge
|
||||||
}`}>
|
}`}>
|
||||||
<TypeIcon type={n.type} />
|
<TypeIcon type={n.type} />
|
||||||
</div>
|
</div>
|
||||||
@@ -244,7 +244,7 @@ function NotificationRow({ notification: n, onMarkRead, onDismiss }: Notificatio
|
|||||||
{n.actionUrl && (
|
{n.actionUrl && (
|
||||||
<Link
|
<Link
|
||||||
href={n.actionUrl}
|
href={n.actionUrl}
|
||||||
className="text-xs font-body font-bold text-[#3b82f6] hover:text-blue-300 transition-colors"
|
className="text-xs font-body font-bold text-[#1D4ED8] hover:text-blue-300 transition-colors"
|
||||||
>
|
>
|
||||||
{n.actionLabel || 'View →'}
|
{n.actionLabel || 'View →'}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -474,7 +474,7 @@ export default function NotificationsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin mx-auto mb-3" />
|
<div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin mx-auto mb-3" />
|
||||||
<p className="text-[#555] text-sm font-body">Loading notifications…</p>
|
<p className="text-[#555] text-sm font-body">Loading notifications…</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -512,7 +512,7 @@ export default function NotificationsPage() {
|
|||||||
{stats.unread > 0 && (
|
{stats.unread > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={handleMarkAllRead}
|
onClick={handleMarkAllRead}
|
||||||
className="px-3 py-1.5 text-xs font-body font-bold text-[#3b82f6] border border-[#3b82f6]/30 rounded hover:bg-[#3b82f6]/10 transition-colors"
|
className="px-3 py-1.5 text-xs font-body font-bold text-[#1D4ED8] border border-[#1D4ED8]/30 rounded hover:bg-[#1D4ED8]/10 transition-colors"
|
||||||
>
|
>
|
||||||
Mark all read
|
Mark all read
|
||||||
</button>
|
</button>
|
||||||
@@ -520,7 +520,7 @@ export default function NotificationsPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
disabled={refreshing}
|
disabled={refreshing}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-body text-[#888] border border-[#252525] rounded hover:border-[#333] hover:text-white transition-colors disabled:opacity-50"
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-body text-[#888] border border-[#DCE6F2] rounded hover:border-[#333] hover:text-white transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<RefreshIcon className={`w-3.5 h-3.5 ${refreshing ? 'animate-spin' : ''}`} />
|
<RefreshIcon className={`w-3.5 h-3.5 ${refreshing ? 'animate-spin' : ''}`} />
|
||||||
Refresh
|
Refresh
|
||||||
@@ -558,7 +558,7 @@ export default function NotificationsPage() {
|
|||||||
label="Unread"
|
label="Unread"
|
||||||
value={stats.unread}
|
value={stats.unread}
|
||||||
icon={<BellIcon className="w-4 h-4" />}
|
icon={<BellIcon className="w-4 h-4" />}
|
||||||
color="text-[#3b82f6] bg-[#3b82f6]/5 border-[#3b82f6]/30"
|
color="text-[#1D4ED8] bg-[#1D4ED8]/5 border-[#1D4ED8]/30"
|
||||||
active={filter === 'unread'}
|
active={filter === 'unread'}
|
||||||
onClick={() => setFilter(filter === 'unread' ? 'all' : 'unread')}
|
onClick={() => setFilter(filter === 'unread' ? 'all' : 'unread')}
|
||||||
/>
|
/>
|
||||||
@@ -580,7 +580,7 @@ export default function NotificationsPage() {
|
|||||||
onClick={() => setFilter(f)}
|
onClick={() => setFilter(f)}
|
||||||
className={`px-3 py-1.5 text-xs font-body font-bold rounded transition-colors ${
|
className={`px-3 py-1.5 text-xs font-body font-bold rounded transition-colors ${
|
||||||
filter === f
|
filter === f
|
||||||
? 'bg-[#3b82f6] text-white'
|
? 'bg-[#1D4ED8] text-white'
|
||||||
: 'text-[#555] hover:text-[#888] hover:bg-[#111]'
|
: 'text-[#555] hover:text-[#888] hover:bg-[#111]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React, { useState, useEffect, useCallback } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import AdminShell from '@/components/admin/AdminShell';
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -44,8 +45,8 @@ function timeAgo(dateStr: string): string {
|
|||||||
const STATUS_COLORS: Record<string, string> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
published: 'text-green-400 bg-green-400/10',
|
published: 'text-green-400 bg-green-400/10',
|
||||||
pending: 'text-yellow-400 bg-yellow-400/10',
|
pending: 'text-yellow-400 bg-yellow-400/10',
|
||||||
draft: 'text-[#888] bg-[#1a1a1a]',
|
draft: 'text-[#888] bg-[#FFFFFF]',
|
||||||
archived: 'text-[#555] bg-[#1a1a1a]',
|
archived: 'text-[#555] bg-[#FFFFFF]',
|
||||||
imported: 'text-blue-400 bg-blue-400/10',
|
imported: 'text-blue-400 bg-blue-400/10',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -91,7 +92,7 @@ interface MetricCardProps {
|
|||||||
|
|
||||||
function MetricCard({ label, value, sub, accent, icon, href, badge }: MetricCardProps) {
|
function MetricCard({ label, value, sub, accent, icon, href, badge }: MetricCardProps) {
|
||||||
const content = (
|
const content = (
|
||||||
<div className={`bg-[#111] border border-[#252525] rounded-lg p-5 flex flex-col gap-3 hover:border-[#333] transition-colors ${href ? 'cursor-pointer' : ''}`}>
|
<div className={`bg-[#111] border border-[#DCE6F2] rounded-lg p-5 flex flex-col gap-3 hover:border-[#333] transition-colors ${href ? 'cursor-pointer' : ''}`}>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${accent}`}>
|
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${accent}`}>
|
||||||
{icon}
|
{icon}
|
||||||
@@ -125,15 +126,15 @@ interface QuickActionProps {
|
|||||||
|
|
||||||
function QuickAction({ label, description, href, icon, accent }: QuickActionProps) {
|
function QuickAction({ label, description, href, icon, accent }: QuickActionProps) {
|
||||||
return (
|
return (
|
||||||
<Link href={href} className="flex items-center gap-3 p-3 bg-[#111] border border-[#252525] rounded-lg hover:border-[#3b82f6] hover:bg-[#0d1a2d] transition-all group">
|
<Link href={href} className="flex items-center gap-3 p-3 bg-[#111] border border-[#DCE6F2] rounded-lg hover:border-[#1D4ED8] hover:bg-[#0d1a2d] transition-all group">
|
||||||
<div className={`w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0 ${accent} group-hover:scale-105 transition-transform`}>
|
<div className={`w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0 ${accent} group-hover:scale-105 transition-transform`}>
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-body font-semibold text-white group-hover:text-[#3b82f6] transition-colors">{label}</p>
|
<p className="text-sm font-body font-semibold text-white group-hover:text-[#1D4ED8] transition-colors">{label}</p>
|
||||||
<p className="text-xs text-[#555] font-body truncate">{description}</p>
|
<p className="text-xs text-[#555] font-body truncate">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
<svg className="w-4 h-4 text-[#444] group-hover:text-[#3b82f6] ml-auto flex-shrink-0 transition-colors" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 text-[#444] group-hover:text-[#1D4ED8] ml-auto flex-shrink-0 transition-colors" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -239,7 +240,7 @@ export default function AdminDashboardPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
<div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
<p className="text-[#555] text-sm font-body">Loading dashboard…</p>
|
<p className="text-[#555] text-sm font-body">Loading dashboard…</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -249,6 +250,7 @@ export default function AdminDashboardPage() {
|
|||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<AdminShell title="Dashboard">
|
||||||
<div className="min-h-screen bg-[#0a0a0a]">
|
<div className="min-h-screen bg-[#0a0a0a]">
|
||||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||||
|
|
||||||
@@ -256,7 +258,7 @@ export default function AdminDashboardPage() {
|
|||||||
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-display font-bold text-white">Admin Dashboard</h1>
|
<h1 className="text-2xl font-display font-bold text-white">Admin Dashboard</h1>
|
||||||
<p className="text-[#555] text-sm font-body mt-1">Overview of your broadcast platform</p>
|
<p className="text-[#555] text-sm font-body mt-1">Overview of your pro AV platform</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-xs text-[#555] font-body">
|
<div className="flex items-center gap-2 text-xs text-[#555] font-body">
|
||||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
@@ -345,7 +347,7 @@ export default function AdminDashboardPage() {
|
|||||||
<MetricCard
|
<MetricCard
|
||||||
label="Drafts"
|
label="Drafts"
|
||||||
value={metrics.draftArticles}
|
value={metrics.draftArticles}
|
||||||
accent="bg-[#1a1a1a]"
|
accent="bg-[#FFFFFF]"
|
||||||
href="/admin/articles"
|
href="/admin/articles"
|
||||||
icon={
|
icon={
|
||||||
<svg className="w-5 h-5 text-[#888]" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 text-[#888]" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
@@ -356,7 +358,7 @@ export default function AdminDashboardPage() {
|
|||||||
<MetricCard
|
<MetricCard
|
||||||
label="Archived"
|
label="Archived"
|
||||||
value={metrics.archivedArticles}
|
value={metrics.archivedArticles}
|
||||||
accent="bg-[#1a1a1a]"
|
accent="bg-[#FFFFFF]"
|
||||||
href="/admin/articles"
|
href="/admin/articles"
|
||||||
icon={
|
icon={
|
||||||
<svg className="w-5 h-5 text-[#555]" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 text-[#555]" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
@@ -372,20 +374,20 @@ export default function AdminDashboardPage() {
|
|||||||
|
|
||||||
{/* Recent Activity Feed */}
|
{/* Recent Activity Feed */}
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-hidden">
|
||||||
<div className="px-5 py-4 border-b border-[#1a1a1a] flex items-center justify-between">
|
<div className="px-5 py-4 border-b border-[#FFFFFF] flex items-center justify-between">
|
||||||
<h2 className="text-sm font-display font-bold text-white">Recent Activity</h2>
|
<h2 className="text-sm font-display font-bold text-white">Recent Activity</h2>
|
||||||
<Link href="/admin/articles" className="text-xs text-[#3b82f6] hover:text-blue-300 font-body transition-colors">View all →</Link>
|
<Link href="/admin/articles" className="text-xs text-[#1D4ED8] hover:text-blue-300 font-body transition-colors">View all →</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-[#1a1a1a]">
|
<div className="divide-y divide-[#FFFFFF]">
|
||||||
{recentActivity.length === 0 ? (
|
{recentActivity.length === 0 ? (
|
||||||
<div className="px-5 py-10 text-center">
|
<div className="px-5 py-10 text-center">
|
||||||
<p className="text-[#555] text-sm font-body">No recent activity found</p>
|
<p className="text-[#555] text-sm font-body">No recent activity found</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
recentActivity.map((item) => (
|
recentActivity.map((item) => (
|
||||||
<div key={`${item.source}-${item.id}`} className="px-5 py-3 flex items-start gap-3 hover:bg-[#0d0d0d] transition-colors">
|
<div key={`${item.source}-${item.id}`} className="px-5 py-3 flex items-start gap-3 hover:bg-[#F8FAFC] transition-colors">
|
||||||
<div className="w-6 h-6 rounded-full bg-[#1a1a1a] flex items-center justify-center flex-shrink-0 mt-0.5">
|
<div className="w-6 h-6 rounded-full bg-[#FFFFFF] flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
{ACTIVITY_ICONS[item.type] || ACTIVITY_ICONS['draft']}
|
{ACTIVITY_ICONS[item.type] || ACTIVITY_ICONS['draft']}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -412,8 +414,8 @@ export default function AdminDashboardPage() {
|
|||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-hidden">
|
||||||
<div className="px-5 py-4 border-b border-[#1a1a1a]">
|
<div className="px-5 py-4 border-b border-[#FFFFFF]">
|
||||||
<h2 className="text-sm font-display font-bold text-white">Quick Actions</h2>
|
<h2 className="text-sm font-display font-bold text-white">Quick Actions</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 flex flex-col gap-2">
|
<div className="p-4 flex flex-col gap-2">
|
||||||
@@ -587,21 +589,21 @@ export default function AdminDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Summary Mini-Card */}
|
{/* Status Summary Mini-Card */}
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg p-4">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg p-4">
|
||||||
<h3 className="text-xs font-body font-bold text-[#555] uppercase tracking-widest mb-3">Content Status</h3>
|
<h3 className="text-xs font-body font-bold text-[#555] uppercase tracking-widest mb-3">Content Status</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{[
|
{[
|
||||||
{ label: 'Published', value: metrics.publishedArticles, color: 'bg-green-400', total: metrics.totalArticles },
|
{ label: 'Published', value: metrics.publishedArticles, color: 'bg-green-400', total: metrics.totalArticles },
|
||||||
{ label: 'Pending', value: metrics.pendingApprovals, color: 'bg-yellow-400', total: metrics.totalArticles },
|
{ label: 'Pending', value: metrics.pendingApprovals, color: 'bg-yellow-400', total: metrics.totalArticles },
|
||||||
{ label: 'Draft', value: metrics.draftArticles, color: 'bg-[#444]', total: metrics.totalArticles },
|
{ label: 'Draft', value: metrics.draftArticles, color: 'bg-[#444]', total: metrics.totalArticles },
|
||||||
{ label: 'Archived', value: metrics.archivedArticles, color: 'bg-[#2a2a2a]', total: metrics.totalArticles },
|
{ label: 'Archived', value: metrics.archivedArticles, color: 'bg-[#DCE6F2]', total: metrics.totalArticles },
|
||||||
].map(({ label, value, color, total }) => (
|
].map(({ label, value, color, total }) => (
|
||||||
<div key={label}>
|
<div key={label}>
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<span className="text-xs text-[#888] font-body">{label}</span>
|
<span className="text-xs text-[#888] font-body">{label}</span>
|
||||||
<span className="text-xs text-white font-body font-semibold">{value}</span>
|
<span className="text-xs text-white font-body font-semibold">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1.5 bg-[#1a1a1a] rounded-full overflow-hidden">
|
<div className="h-1.5 bg-[#FFFFFF] rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={`h-full rounded-full ${color} transition-all duration-500`}
|
className={`h-full rounded-full ${color} transition-all duration-500`}
|
||||||
style={{ width: total > 0 ? `${Math.round((value / total) * 100)}%` : '0%' }}
|
style={{ width: total > 0 ? `${Math.round((value / total) * 100)}%` : '0%' }}
|
||||||
@@ -615,5 +617,6 @@ export default function AdminDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</AdminShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
269
src/app/admin/promote/[slug]/page.tsx
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /admin/promote/[slug]
|
||||||
|
*
|
||||||
|
* Per-article promote sheet. Opens a single screen with one button per
|
||||||
|
* social platform. Each button uses that platform's standard share URL
|
||||||
|
* (LinkedIn Sharing, X Web Intent, Facebook Sharer) — no OAuth, no API
|
||||||
|
* tokens. On iPhone the button opens the platform's native app via the
|
||||||
|
* universal link.
|
||||||
|
*
|
||||||
|
* Instagram has no URL share API, so we render a "Copy caption + open IG"
|
||||||
|
* affordance: caption goes to clipboard, IG opens via instagram:// (falls
|
||||||
|
* back to web), and you paste in the composer.
|
||||||
|
*
|
||||||
|
* This page is the MVP. The follow-up commits will:
|
||||||
|
* • Add a distribute.social_posts table to track what was promoted
|
||||||
|
* • Replace each share URL with a server-side API POST once OAuth is wired
|
||||||
|
* • Add caption auto-generation (LLM summary of the article)
|
||||||
|
*
|
||||||
|
* Until then this works tonight from your phone.
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
import AdminShell from "@/components/admin/AdminShell";
|
||||||
|
|
||||||
|
interface Article {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
excerpt: string | null;
|
||||||
|
url: string;
|
||||||
|
featured_image: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SITE = "https://avbeat.com";
|
||||||
|
|
||||||
|
function strip(html: string | null | undefined, max = 260): string {
|
||||||
|
if (!html) return "";
|
||||||
|
const text = html.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
|
||||||
|
return text.length > max ? text.slice(0, max - 1).trimEnd() + "…" : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shareUrls(a: Article) {
|
||||||
|
const url = a.url;
|
||||||
|
const text = `${a.title}`;
|
||||||
|
const hashtags = "AVBeat,proAV,InfoComm";
|
||||||
|
return {
|
||||||
|
linkedin: `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url)}`,
|
||||||
|
x: `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(url)}&hashtags=${encodeURIComponent(hashtags)}`,
|
||||||
|
facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`,
|
||||||
|
email: `mailto:?subject=${encodeURIComponent(text)}&body=${encodeURIComponent(strip(a.excerpt) + "\n\n" + url)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PromotePage() {
|
||||||
|
const params = useParams<{ slug: string }>();
|
||||||
|
const router = useRouter();
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
const [article, setArticle] = useState<Article | null>(null);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [copied, setCopied] = useState<string | null>(null);
|
||||||
|
const [caption, setCaption] = useState<string>("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && !user) {
|
||||||
|
router.push("/login?next=" + encodeURIComponent(window.location.pathname));
|
||||||
|
}
|
||||||
|
}, [user, loading, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!params?.slug) return;
|
||||||
|
// Hit the public posts API to get title/excerpt/image. Falls back to a
|
||||||
|
// stub if the article isn't surfaced there (e.g., draft, archived).
|
||||||
|
fetch(`/api/public/posts?slug=${encodeURIComponent(params.slug)}`)
|
||||||
|
.then((r) => r.ok ? r.json() : null)
|
||||||
|
.then((d) => {
|
||||||
|
const item = (d?.items || []).find((x: any) => x.slug === params.slug) || d?.item;
|
||||||
|
if (item) {
|
||||||
|
const url = `${SITE}/news/${item.slug}`;
|
||||||
|
const a: Article = {
|
||||||
|
slug: item.slug,
|
||||||
|
title: item.title || item.slug,
|
||||||
|
excerpt: item.excerpt || null,
|
||||||
|
url,
|
||||||
|
featured_image: item.image || item.featured_image || null,
|
||||||
|
};
|
||||||
|
setArticle(a);
|
||||||
|
setCaption(`${a.title}\n\n${strip(a.excerpt)}\n\n${a.url}`);
|
||||||
|
} else {
|
||||||
|
// Stub — minimum required to share by URL alone
|
||||||
|
const url = `${SITE}/news/${params.slug}`;
|
||||||
|
setArticle({ slug: params.slug, title: params.slug, excerpt: null, url, featured_image: null });
|
||||||
|
setCaption(`Check this out — ${url}`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => setErr(e.message || "Failed to load article"));
|
||||||
|
}, [params?.slug]);
|
||||||
|
|
||||||
|
if (!user || !article) {
|
||||||
|
return (
|
||||||
|
<AdminShell title="Promote">
|
||||||
|
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center px-4">
|
||||||
|
{err ? (
|
||||||
|
<p className="text-red-400 text-sm">{err}</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="w-6 h-6 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
|
<p className="text-[#555] text-sm font-body">Loading article…</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AdminShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const urls = shareUrls(article);
|
||||||
|
|
||||||
|
function copyToClipboard(text: string, label: string) {
|
||||||
|
navigator.clipboard?.writeText(text)
|
||||||
|
.then(() => {
|
||||||
|
setCopied(label);
|
||||||
|
setTimeout(() => setCopied(null), 1500);
|
||||||
|
})
|
||||||
|
.catch(() => setErr("Couldn't copy. Long-press the caption box to copy manually."));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminShell title="Promote">
|
||||||
|
<main className="min-h-screen bg-[#0a0a0a]">
|
||||||
|
<div className="max-w-2xl mx-auto px-4 py-6">
|
||||||
|
{/* Article preview */}
|
||||||
|
<section className="bg-[#FFFFFF] border border-[#1a1f2a] rounded-lg overflow-hidden mb-5">
|
||||||
|
{article.featured_image && (
|
||||||
|
<img
|
||||||
|
src={article.featured_image}
|
||||||
|
alt=""
|
||||||
|
className="w-full h-40 object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="p-4">
|
||||||
|
<p className="text-[10px] font-mono uppercase tracking-widest text-[#1D4ED8] mb-1">
|
||||||
|
Promoting
|
||||||
|
</p>
|
||||||
|
<h2 className="font-display text-lg font-bold text-white leading-tight">
|
||||||
|
{article.title}
|
||||||
|
</h2>
|
||||||
|
{article.excerpt && (
|
||||||
|
<p className="text-[#888] text-sm mt-2 line-clamp-3 font-body">
|
||||||
|
{strip(article.excerpt)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href={article.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-[#1D4ED8] text-xs font-mono mt-3 inline-block hover:underline break-all"
|
||||||
|
>
|
||||||
|
{article.url}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Caption editor — copy-pastable for any platform */}
|
||||||
|
<section className="mb-6">
|
||||||
|
<label className="block text-[10px] font-mono uppercase tracking-widest text-[#1D4ED8] mb-2">
|
||||||
|
Caption
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={caption}
|
||||||
|
onChange={(e) => setCaption(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
className="w-full bg-[#FFFFFF] border border-[#1a1f2a] rounded-lg px-3 py-2 text-white text-sm font-body focus:outline-none focus:border-[#1D4ED8]"
|
||||||
|
placeholder="Caption shown on platforms that accept one…"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => copyToClipboard(caption, "caption")}
|
||||||
|
className="mt-2 text-xs text-[#1D4ED8] hover:underline font-body"
|
||||||
|
>
|
||||||
|
{copied === "caption" ? "✓ Copied" : "Copy caption to clipboard"}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Platform buttons */}
|
||||||
|
<section className="space-y-3 mb-6">
|
||||||
|
<a
|
||||||
|
href={urls.linkedin}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center justify-between w-full bg-[#0A66C2] hover:bg-[#004182] text-white font-body font-semibold text-sm rounded-lg px-4 py-3.5 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-3">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.063 2.063 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
|
||||||
|
LinkedIn
|
||||||
|
</span>
|
||||||
|
<span className="text-xs opacity-80">Share</span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={urls.x}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center justify-between w-full bg-black hover:bg-[#1a1a1a] text-white font-body font-semibold text-sm rounded-lg px-4 py-3.5 transition-colors border border-[#333]"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-3">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
|
||||||
|
X (Twitter)
|
||||||
|
</span>
|
||||||
|
<span className="text-xs opacity-80">Post</span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={urls.facebook}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center justify-between w-full bg-[#1877F2] hover:bg-[#0d65d9] text-white font-body font-semibold text-sm rounded-lg px-4 py-3.5 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-3">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
|
||||||
|
Facebook
|
||||||
|
</span>
|
||||||
|
<span className="text-xs opacity-80">Share</span>
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
copyToClipboard(caption, "instagram");
|
||||||
|
window.location.href = "instagram://camera";
|
||||||
|
setTimeout(() => {
|
||||||
|
if (document.visibilityState === "visible") {
|
||||||
|
window.open("https://www.instagram.com/", "_blank");
|
||||||
|
}
|
||||||
|
}, 600);
|
||||||
|
}}
|
||||||
|
className="flex items-center justify-between w-full bg-gradient-to-r from-[#833ab4] via-[#fd1d1d] to-[#fcb045] hover:opacity-90 text-white font-body font-semibold text-sm rounded-lg px-4 py-3.5 transition-opacity"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-3">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/></svg>
|
||||||
|
Instagram
|
||||||
|
</span>
|
||||||
|
<span className="text-xs opacity-80">
|
||||||
|
{copied === "instagram" ? "Caption copied · opening IG…" : "Copy + open"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href={urls.email}
|
||||||
|
className="flex items-center justify-between w-full bg-[#FFFFFF] hover:bg-[#1f2937] text-white font-body font-semibold text-sm rounded-lg px-4 py-3.5 transition-colors border border-[#222]"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-3">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
|
||||||
|
Email
|
||||||
|
</span>
|
||||||
|
<span className="text-xs opacity-80">Compose</span>
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p className="text-xs text-[#555] font-body leading-relaxed">
|
||||||
|
Each button opens the platform's share sheet pre-filled with the
|
||||||
|
article URL and caption. Instagram has no URL-share API — its
|
||||||
|
button copies the caption to your clipboard and opens the IG app
|
||||||
|
so you can paste it into a new post or story. One-tap server-side
|
||||||
|
posting via LinkedIn / X / Meta APIs is coming next; the buttons
|
||||||
|
above will stop opening external sheets and post silently
|
||||||
|
instead.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</AdminShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@ export default function ReviewActions({ id, currentStatus }: Props) {
|
|||||||
value={note}
|
value={note}
|
||||||
onChange={(e) => setNote(e.target.value)}
|
onChange={(e) => setNote(e.target.value)}
|
||||||
placeholder="Reviewer note (optional)"
|
placeholder="Reviewer note (optional)"
|
||||||
className="w-full rounded border border-[#1f2937] bg-[#070a10] p-2 text-sm text-[#e5e7eb] focus:border-[#3b82f6] focus:outline-none"
|
className="w-full rounded border border-[#1f2937] bg-[#070a10] p-2 text-sm text-[#e5e7eb] focus:border-[#1D4ED8] focus:outline-none"
|
||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
<div className="mt-3 flex flex-wrap gap-3 text-sm">
|
<div className="mt-3 flex flex-wrap gap-3 text-sm">
|
||||||
|
|||||||
@@ -41,12 +41,12 @@ export default async function ReviewDetailPage({ params }: { params: Promise<{ i
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-6xl px-6 py-10 text-[#e5e7eb]">
|
<main className="mx-auto max-w-6xl px-6 py-10 text-[#e5e7eb]">
|
||||||
<Link href="/admin/review-queue" className="text-sm text-[#60a5fa] hover:underline">
|
<Link href="/admin/review-queue" className="text-sm text-[#1D4ED8] hover:underline">
|
||||||
← Back to queue
|
← Back to queue
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<header className="mt-4 mb-6 border-b border-[#222] pb-4">
|
<header className="mt-4 mb-6 border-b border-[#222] pb-4">
|
||||||
<div className="flex items-center gap-3 text-xs uppercase tracking-wider text-[#60a5fa]">
|
<div className="flex items-center gap-3 text-xs uppercase tracking-wider text-[#1D4ED8]">
|
||||||
<span>{persona?.name || "—"}</span>
|
<span>{persona?.name || "—"}</span>
|
||||||
<span className="text-[#4b5563]">·</span>
|
<span className="text-[#4b5563]">·</span>
|
||||||
<span>{art.category}</span>
|
<span>{art.category}</span>
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export default async function ReviewQueuePage() {
|
|||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={r.id}
|
key={r.id}
|
||||||
className="rounded border border-[#1f2937] bg-[#0b0f17] p-5 hover:border-[#3b82f6] transition-colors"
|
className="rounded border border-[#1f2937] bg-[#0b0f17] p-5 hover:border-[#1D4ED8] transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
{persona?.avatar_url ? (
|
{persona?.avatar_url ? (
|
||||||
@@ -85,7 +85,7 @@ export default async function ReviewQueuePage() {
|
|||||||
<div className="h-14 w-14 rounded-full bg-[#1f2937]" />
|
<div className="h-14 w-14 rounded-full bg-[#1f2937]" />
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-xs uppercase tracking-wider text-[#60a5fa]">
|
<div className="text-xs uppercase tracking-wider text-[#1D4ED8]">
|
||||||
{persona?.name || "—"} · {r.category}
|
{persona?.name || "—"} · {r.category}
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-semibold mt-1">{r.title}</h2>
|
<h2 className="text-xl font-semibold mt-1">{r.title}</h2>
|
||||||
@@ -100,7 +100,7 @@ export default async function ReviewQueuePage() {
|
|||||||
<div className="mt-4 flex flex-wrap items-center gap-3 text-xs">
|
<div className="mt-4 flex flex-wrap items-center gap-3 text-xs">
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/review-queue/${r.id}`}
|
href={`/admin/review-queue/${r.id}`}
|
||||||
className="rounded bg-[#1d4ed8] px-3 py-1.5 font-medium text-white hover:bg-[#2563eb]"
|
className="rounded bg-[#1d4ed8] px-3 py-1.5 font-medium text-white hover:bg-[#1E3A8A]"
|
||||||
>
|
>
|
||||||
Review
|
Review
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export default function RmpHubPage() {
|
|||||||
|
|
||||||
useEffect(() => { if (!loading && !user) router?.push('/login'); }, [user, loading, router]);
|
useEffect(() => { if (!loading && !user) router?.push('/login'); }, [user, loading, router]);
|
||||||
|
|
||||||
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" /></div>;
|
if (loading) return <div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center"><div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" /></div>;
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -68,7 +68,7 @@ export default function RmpHubPage() {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
]?.map(section => (
|
]?.map(section => (
|
||||||
<div key={section?.title} className="bg-[#111] border border-[#252525] rounded-lg p-4 space-y-3">
|
<div key={section?.title} className="bg-[#111] border border-[#DCE6F2] rounded-lg p-4 space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-2xl">{section?.icon}</span>
|
<span className="text-2xl">{section?.icon}</span>
|
||||||
<div>
|
<div>
|
||||||
@@ -78,19 +78,19 @@ export default function RmpHubPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{section?.links?.map(l => (
|
{section?.links?.map(l => (
|
||||||
<Link key={l?.href} href={l?.href} className="block px-2 py-1 rounded text-xs text-[#888] hover:text-white hover:bg-[#1a1a1a] transition-colors">
|
<Link key={l?.href} href={l?.href} className="block px-2 py-1 rounded text-xs text-[#888] hover:text-white hover:bg-[#FFFFFF] transition-colors">
|
||||||
→ {l?.label}
|
→ {l?.label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Link href={section?.href} className="block w-full text-center px-3 py-1.5 bg-[#1a2535] border border-[#2a3a50] rounded text-xs text-[#3b82f6] hover:bg-[#0d1a2d] transition-colors">
|
<Link href={section?.href} className="block w-full text-center px-3 py-1.5 bg-[#FFFFFF] border border-[#2a3a50] rounded text-xs text-[#1D4ED8] hover:bg-[#0d1a2d] transition-colors">
|
||||||
Open {section?.title}
|
Open {section?.title}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-[#0d1117] border border-[#1a2535] rounded-lg p-4">
|
<div className="bg-[#0d1117] border border-[#FFFFFF] rounded-lg p-4">
|
||||||
<p className="text-xs text-[#555] mb-3">Quick Navigation</p>
|
<p className="text-xs text-[#555] mb-3">Quick Navigation</p>
|
||||||
<RmpAdminNav />
|
<RmpAdminNav />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ const ROLE_COLORS: Record<string, string> = {
|
|||||||
editor: 'text-purple-400 bg-purple-400/10 border-purple-400/20',
|
editor: 'text-purple-400 bg-purple-400/10 border-purple-400/20',
|
||||||
moderator: 'text-orange-400 bg-orange-400/10 border-orange-400/20',
|
moderator: 'text-orange-400 bg-orange-400/10 border-orange-400/20',
|
||||||
contributor: 'text-blue-400 bg-blue-400/10 border-blue-400/20',
|
contributor: 'text-blue-400 bg-blue-400/10 border-blue-400/20',
|
||||||
reader: 'text-[#888] bg-[#1a1a1a] border-[#333]',
|
reader: 'text-[#888] bg-[#FFFFFF] border-[#333]',
|
||||||
};
|
};
|
||||||
|
|
||||||
const ROLE_ICONS: Record<string, React.ReactNode> = {
|
const ROLE_ICONS: Record<string, React.ReactNode> = {
|
||||||
@@ -324,7 +324,7 @@ export default function AdminRolesPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
<div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
<p className="text-[#555] text-sm font-body">Loading roles panel…</p>
|
<p className="text-[#555] text-sm font-body">Loading roles panel…</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -376,7 +376,7 @@ export default function AdminRolesPage() {
|
|||||||
key={role}
|
key={role}
|
||||||
onClick={() => { setSelectedRole(role === selectedRole ? 'all' : role); setActiveTab('assign'); }}
|
onClick={() => { setSelectedRole(role === selectedRole ? 'all' : role); setActiveTab('assign'); }}
|
||||||
className={`bg-[#111] border rounded-lg p-4 text-left hover:border-[#333] transition-all ${
|
className={`bg-[#111] border rounded-lg p-4 text-left hover:border-[#333] transition-all ${
|
||||||
selectedRole === role ? 'border-[#3b82f6] bg-[#0d1a2d]' : 'border-[#252525]'
|
selectedRole === role ? 'border-[#1D4ED8] bg-[#0d1a2d]' : 'border-[#DCE6F2]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-body font-semibold border mb-2 ${color}`}>
|
<div className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-body font-semibold border mb-2 ${color}`}>
|
||||||
@@ -391,7 +391,7 @@ export default function AdminRolesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-1 mb-6 bg-[#111] border border-[#252525] rounded-lg p-1 w-fit">
|
<div className="flex gap-1 mb-6 bg-[#111] border border-[#DCE6F2] rounded-lg p-1 w-fit">
|
||||||
{([
|
{([
|
||||||
{ id: 'assign', label: 'Assign Roles', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z' },
|
{ id: 'assign', label: 'Assign Roles', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z' },
|
||||||
{ id: 'permissions', label: 'Permissions', icon: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z' },
|
{ id: 'permissions', label: 'Permissions', icon: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z' },
|
||||||
@@ -402,7 +402,7 @@ export default function AdminRolesPage() {
|
|||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-body font-medium transition-all ${
|
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-body font-medium transition-all ${
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'bg-[#3b82f6] text-white'
|
? 'bg-[#1D4ED8] text-white'
|
||||||
: 'text-[#888] hover:text-white'
|
: 'text-[#888] hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -416,9 +416,9 @@ export default function AdminRolesPage() {
|
|||||||
|
|
||||||
{/* ── Tab: Assign Roles ── */}
|
{/* ── Tab: Assign Roles ── */}
|
||||||
{activeTab === 'assign' && (
|
{activeTab === 'assign' && (
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-hidden">
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="px-5 py-4 border-b border-[#1a1a1a] flex flex-col sm:flex-row gap-3">
|
<div className="px-5 py-4 border-b border-[#FFFFFF] flex flex-col sm:flex-row gap-3">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#555]" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#555]" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
@@ -428,13 +428,13 @@ export default function AdminRolesPage() {
|
|||||||
placeholder="Search users…"
|
placeholder="Search users…"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="w-full bg-[#0d0d0d] border border-[#252525] rounded-lg pl-9 pr-4 py-2 text-sm text-white placeholder-[#555] font-body focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#F8FAFC] border border-[#DCE6F2] rounded-lg pl-9 pr-4 py-2 text-sm text-white placeholder-[#555] font-body focus:outline-none focus:border-[#1D4ED8]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
value={selectedRole}
|
value={selectedRole}
|
||||||
onChange={(e) => setSelectedRole(e.target.value)}
|
onChange={(e) => setSelectedRole(e.target.value)}
|
||||||
className="bg-[#0d0d0d] border border-[#252525] rounded-lg px-3 py-2 text-sm text-white font-body focus:outline-none focus:border-[#3b82f6]"
|
className="bg-[#F8FAFC] border border-[#DCE6F2] rounded-lg px-3 py-2 text-sm text-white font-body focus:outline-none focus:border-[#1D4ED8]"
|
||||||
>
|
>
|
||||||
<option value="all">All Roles</option>
|
<option value="all">All Roles</option>
|
||||||
{ROLES.map((r) => (
|
{ROLES.map((r) => (
|
||||||
@@ -447,7 +447,7 @@ export default function AdminRolesPage() {
|
|||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[#1a1a1a]">
|
<tr className="border-b border-[#FFFFFF]">
|
||||||
<th className="px-5 py-3 text-left text-xs font-body font-bold text-[#555] uppercase tracking-wider">User</th>
|
<th className="px-5 py-3 text-left text-xs font-body font-bold text-[#555] uppercase tracking-wider">User</th>
|
||||||
<th className="px-5 py-3 text-left text-xs font-body font-bold text-[#555] uppercase tracking-wider">Current Role</th>
|
<th className="px-5 py-3 text-left text-xs font-body font-bold text-[#555] uppercase tracking-wider">Current Role</th>
|
||||||
<th className="px-5 py-3 text-left text-xs font-body font-bold text-[#555] uppercase tracking-wider hidden sm:table-cell">Status</th>
|
<th className="px-5 py-3 text-left text-xs font-body font-bold text-[#555] uppercase tracking-wider hidden sm:table-cell">Status</th>
|
||||||
@@ -455,7 +455,7 @@ export default function AdminRolesPage() {
|
|||||||
<th className="px-5 py-3 text-left text-xs font-body font-bold text-[#555] uppercase tracking-wider">Assign Role</th>
|
<th className="px-5 py-3 text-left text-xs font-body font-bold text-[#555] uppercase tracking-wider">Assign Role</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[#1a1a1a]">
|
<tbody className="divide-y divide-[#FFFFFF]">
|
||||||
{filteredUsers.length === 0 ? (
|
{filteredUsers.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="px-5 py-10 text-center text-[#555] text-sm font-body">
|
<td colSpan={5} className="px-5 py-10 text-center text-[#555] text-sm font-body">
|
||||||
@@ -464,10 +464,10 @@ export default function AdminRolesPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredUsers.map((u) => (
|
filteredUsers.map((u) => (
|
||||||
<tr key={u.id} className="hover:bg-[#0d0d0d] transition-colors">
|
<tr key={u.id} className="hover:bg-[#F8FAFC] transition-colors">
|
||||||
<td className="px-5 py-3">
|
<td className="px-5 py-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 rounded-full bg-[#1a1a1a] flex items-center justify-center flex-shrink-0 text-xs font-body font-bold text-[#888]">
|
<div className="w-8 h-8 rounded-full bg-[#FFFFFF] flex items-center justify-center flex-shrink-0 text-xs font-body font-bold text-[#888]">
|
||||||
{u.avatar_url
|
{u.avatar_url
|
||||||
? <img src={u.avatar_url} alt={u.full_name} className="w-8 h-8 rounded-full object-cover" />
|
? <img src={u.avatar_url} alt={u.full_name} className="w-8 h-8 rounded-full object-cover" />
|
||||||
: getInitials(u.full_name || u.email)
|
: getInitials(u.full_name || u.email)
|
||||||
@@ -484,7 +484,7 @@ export default function AdminRolesPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3 hidden sm:table-cell">
|
<td className="px-5 py-3 hidden sm:table-cell">
|
||||||
<span className={`text-xs font-body font-semibold px-2 py-0.5 rounded-full ${
|
<span className={`text-xs font-body font-semibold px-2 py-0.5 rounded-full ${
|
||||||
u.is_active ? 'text-green-400 bg-green-400/10' : 'text-[#555] bg-[#1a1a1a]'
|
u.is_active ? 'text-green-400 bg-green-400/10' : 'text-[#555] bg-[#FFFFFF]'
|
||||||
}`}>
|
}`}>
|
||||||
{u.is_active ? 'Active' : 'Inactive'}
|
{u.is_active ? 'Active' : 'Inactive'}
|
||||||
</span>
|
</span>
|
||||||
@@ -500,14 +500,14 @@ export default function AdminRolesPage() {
|
|||||||
defaultValue={u.role}
|
defaultValue={u.role}
|
||||||
onChange={(e) => handleAssignRole(u.id, e.target.value)}
|
onChange={(e) => handleAssignRole(u.id, e.target.value)}
|
||||||
disabled={processingUserId === u.id}
|
disabled={processingUserId === u.id}
|
||||||
className="bg-[#0d0d0d] border border-[#252525] rounded-lg px-2 py-1.5 text-xs text-white font-body focus:outline-none focus:border-[#3b82f6] disabled:opacity-50"
|
className="bg-[#F8FAFC] border border-[#DCE6F2] rounded-lg px-2 py-1.5 text-xs text-white font-body focus:outline-none focus:border-[#1D4ED8] disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{ROLES.map((r) => (
|
{ROLES.map((r) => (
|
||||||
<option key={r} value={r} className="capitalize">{r.charAt(0).toUpperCase() + r.slice(1)}</option>
|
<option key={r} value={r} className="capitalize">{r.charAt(0).toUpperCase() + r.slice(1)}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{processingUserId === u.id && (
|
{processingUserId === u.id && (
|
||||||
<div className="w-4 h-4 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
<div className="w-4 h-4 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -519,7 +519,7 @@ export default function AdminRolesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filteredUsers.length > 0 && (
|
{filteredUsers.length > 0 && (
|
||||||
<div className="px-5 py-3 border-t border-[#1a1a1a]">
|
<div className="px-5 py-3 border-t border-[#FFFFFF]">
|
||||||
<p className="text-xs text-[#555] font-body">{filteredUsers.length} user{filteredUsers.length !== 1 ? 's' : ''} shown</p>
|
<p className="text-xs text-[#555] font-body">{filteredUsers.length} user{filteredUsers.length !== 1 ? 's' : ''} shown</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -535,9 +535,9 @@ export default function AdminRolesPage() {
|
|||||||
const color = ROLE_COLORS[perm.role] || ROLE_COLORS['reader'];
|
const color = ROLE_COLORS[perm.role] || ROLE_COLORS['reader'];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={perm.role} className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
<div key={perm.role} className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-hidden">
|
||||||
{/* Role Header */}
|
{/* Role Header */}
|
||||||
<div className="px-5 py-4 border-b border-[#1a1a1a] flex items-center justify-between">
|
<div className="px-5 py-4 border-b border-[#FFFFFF] flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-body font-semibold border ${color}`}>
|
<div className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-body font-semibold border ${color}`}>
|
||||||
{ROLE_ICONS[perm.role]}
|
{ROLE_ICONS[perm.role]}
|
||||||
@@ -557,7 +557,7 @@ export default function AdminRolesPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleSavePermissions}
|
onClick={handleSavePermissions}
|
||||||
disabled={savingPermissions}
|
disabled={savingPermissions}
|
||||||
className="px-3 py-1.5 text-xs font-body font-semibold bg-[#3b82f6] hover:bg-blue-500 text-white rounded-lg transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
className="px-3 py-1.5 text-xs font-body font-semibold bg-[#1D4ED8] hover:bg-blue-500 text-white rounded-lg transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
{savingPermissions && <div className="w-3 h-3 border-2 border-white border-t-transparent rounded-full animate-spin" />}
|
{savingPermissions && <div className="w-3 h-3 border-2 border-white border-t-transparent rounded-full animate-spin" />}
|
||||||
Save Changes
|
Save Changes
|
||||||
@@ -593,12 +593,12 @@ export default function AdminRolesPage() {
|
|||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditedPermissions((prev) => ({ ...prev, [key]: !val }))}
|
onClick={() => setEditedPermissions((prev) => ({ ...prev, [key]: !val }))}
|
||||||
className={`relative w-9 h-5 rounded-full transition-colors ${val ? 'bg-[#3b82f6]' : 'bg-[#333]'}`}
|
className={`relative w-9 h-5 rounded-full transition-colors ${val ? 'bg-[#1D4ED8]' : 'bg-[#333]'}`}
|
||||||
>
|
>
|
||||||
<span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${val ? 'translate-x-4' : 'translate-x-0.5'}`} />
|
<span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${val ? 'translate-x-4' : 'translate-x-0.5'}`} />
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<span className={`w-5 h-5 rounded-full flex items-center justify-center flex-shrink-0 ${val ? 'bg-green-400/10' : 'bg-[#1a1a1a]'}`}>
|
<span className={`w-5 h-5 rounded-full flex items-center justify-center flex-shrink-0 ${val ? 'bg-green-400/10' : 'bg-[#FFFFFF]'}`}>
|
||||||
{val ? (
|
{val ? (
|
||||||
<svg className="w-3 h-3 text-green-400" fill="none" stroke="currentColor" strokeWidth="3" viewBox="0 0 24 24">
|
<svg className="w-3 h-3 text-green-400" fill="none" stroke="currentColor" strokeWidth="3" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
@@ -631,9 +631,9 @@ export default function AdminRolesPage() {
|
|||||||
|
|
||||||
{/* ── Tab: Activity Logs ── */}
|
{/* ── Tab: Activity Logs ── */}
|
||||||
{activeTab === 'activity' && (
|
{activeTab === 'activity' && (
|
||||||
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-lg overflow-hidden">
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="px-5 py-4 border-b border-[#1a1a1a] flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between">
|
<div className="px-5 py-4 border-b border-[#FFFFFF] flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between">
|
||||||
<h2 className="text-sm font-display font-bold text-white">Role-Based Activity</h2>
|
<h2 className="text-sm font-display font-bold text-white">Role-Based Activity</h2>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-[#555] font-body">Filter by role:</span>
|
<span className="text-xs text-[#555] font-body">Filter by role:</span>
|
||||||
@@ -644,8 +644,8 @@ export default function AdminRolesPage() {
|
|||||||
onClick={() => setActivityRoleFilter(r)}
|
onClick={() => setActivityRoleFilter(r)}
|
||||||
className={`px-2.5 py-1 rounded-md text-xs font-body font-medium transition-colors capitalize ${
|
className={`px-2.5 py-1 rounded-md text-xs font-body font-medium transition-colors capitalize ${
|
||||||
activityRoleFilter === r
|
activityRoleFilter === r
|
||||||
? 'bg-[#3b82f6] text-white'
|
? 'bg-[#1D4ED8] text-white'
|
||||||
: 'bg-[#1a1a1a] text-[#888] hover:text-white'
|
: 'bg-[#FFFFFF] text-[#888] hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{r}
|
{r}
|
||||||
@@ -656,7 +656,7 @@ export default function AdminRolesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Log List */}
|
{/* Log List */}
|
||||||
<div className="divide-y divide-[#1a1a1a]">
|
<div className="divide-y divide-[#FFFFFF]">
|
||||||
{activityLogs.length === 0 ? (
|
{activityLogs.length === 0 ? (
|
||||||
<div className="px-5 py-12 text-center">
|
<div className="px-5 py-12 text-center">
|
||||||
<svg className="w-10 h-10 text-[#333] mx-auto mb-3" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
|
<svg className="w-10 h-10 text-[#333] mx-auto mb-3" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
|
||||||
@@ -666,13 +666,13 @@ export default function AdminRolesPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
activityLogs.map((log) => {
|
activityLogs.map((log) => {
|
||||||
const actionColor = ACTION_COLORS[log.action] || 'text-[#888] bg-[#1a1a1a]';
|
const actionColor = ACTION_COLORS[log.action] || 'text-[#888] bg-[#FFFFFF]';
|
||||||
const actionLabel = ACTION_LABELS[log.action] || log.action.replace(/_/g, ' ');
|
const actionLabel = ACTION_LABELS[log.action] || log.action.replace(/_/g, ' ');
|
||||||
const performer = log.user_profiles?.full_name || log.performed_by_email || 'System';
|
const performer = log.user_profiles?.full_name || log.performed_by_email || 'System';
|
||||||
const performerRole = log.user_profiles?.role;
|
const performerRole = log.user_profiles?.role;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={log.id} className="px-5 py-3.5 flex items-start gap-4 hover:bg-[#0d0d0d] transition-colors">
|
<div key={log.id} className="px-5 py-3.5 flex items-start gap-4 hover:bg-[#F8FAFC] transition-colors">
|
||||||
{/* Action badge */}
|
{/* Action badge */}
|
||||||
<div className={`mt-0.5 px-2 py-0.5 rounded text-xs font-body font-semibold flex-shrink-0 ${actionColor}`}>
|
<div className={`mt-0.5 px-2 py-0.5 rounded text-xs font-body font-semibold flex-shrink-0 ${actionColor}`}>
|
||||||
{actionLabel}
|
{actionLabel}
|
||||||
@@ -720,7 +720,7 @@ export default function AdminRolesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activityLogs.length > 0 && (
|
{activityLogs.length > 0 && (
|
||||||
<div className="px-5 py-3 border-t border-[#1a1a1a]">
|
<div className="px-5 py-3 border-t border-[#FFFFFF]">
|
||||||
<p className="text-xs text-[#555] font-body">{activityLogs.length} log entries shown</p>
|
<p className="text-xs text-[#555] font-body">{activityLogs.length} log entries shown</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ export default function AdminSuspensionsPage() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0d1117] flex items-center justify-center">
|
<div className="min-h-screen bg-[#0d1117] flex items-center justify-center">
|
||||||
<div className="w-6 h-6 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
<div className="w-6 h-6 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -288,7 +288,7 @@ export default function AdminSuspensionsPage() {
|
|||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
className={`px-4 py-2.5 text-sm font-semibold capitalize transition-colors border-b-2 -mb-px ${
|
className={`px-4 py-2.5 text-sm font-semibold capitalize transition-colors border-b-2 -mb-px ${
|
||||||
activeTab === tab
|
activeTab === tab
|
||||||
? 'text-[#3b82f6] border-[#3b82f6]'
|
? 'text-[#1D4ED8] border-[#1D4ED8]'
|
||||||
: 'text-[#555] border-transparent hover:text-[#888]'
|
: 'text-[#555] border-transparent hover:text-[#888]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -304,7 +304,7 @@ export default function AdminSuspensionsPage() {
|
|||||||
|
|
||||||
{loadingData ? (
|
{loadingData ? (
|
||||||
<div className="flex items-center justify-center py-16">
|
<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 className="w-6 h-6 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -336,7 +336,7 @@ export default function AdminSuspensionsPage() {
|
|||||||
<tr key={s.id} className="border-b border-[#1a2030] hover:bg-[#0d1117]/50 transition-colors">
|
<tr key={s.id} className="border-b border-[#1a2030] hover:bg-[#0d1117]/50 transition-colors">
|
||||||
<td className="px-5 py-3.5">
|
<td className="px-5 py-3.5">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 rounded-full bg-[#1f2937] flex items-center justify-center text-[#3b82f6] font-bold text-sm flex-shrink-0 overflow-hidden">
|
<div className="w-8 h-8 rounded-full bg-[#1f2937] flex items-center justify-center text-[#1D4ED8] font-bold text-sm flex-shrink-0 overflow-hidden">
|
||||||
{s.user?.avatar_url ? (
|
{s.user?.avatar_url ? (
|
||||||
<img src={s.user.avatar_url} alt={s.user.full_name || s.user.email} className="w-full h-full object-cover" />
|
<img src={s.user.avatar_url} alt={s.user.full_name || s.user.email} className="w-full h-full object-cover" />
|
||||||
) : (
|
) : (
|
||||||
@@ -415,7 +415,7 @@ export default function AdminSuspensionsPage() {
|
|||||||
<tr key={s.id} className="border-b border-[#1a2030] hover:bg-[#0d1117]/50 transition-colors">
|
<tr key={s.id} className="border-b border-[#1a2030] hover:bg-[#0d1117]/50 transition-colors">
|
||||||
<td className="px-5 py-3.5">
|
<td className="px-5 py-3.5">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-7 h-7 rounded-full bg-[#1f2937] flex items-center justify-center text-[#3b82f6] text-xs font-bold flex-shrink-0">
|
<div className="w-7 h-7 rounded-full bg-[#1f2937] flex items-center justify-center text-[#1D4ED8] text-xs font-bold flex-shrink-0">
|
||||||
{(s.user?.full_name?.[0] || s.user?.email?.[0] || '?').toUpperCase()}
|
{(s.user?.full_name?.[0] || s.user?.email?.[0] || '?').toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -465,7 +465,7 @@ export default function AdminSuspensionsPage() {
|
|||||||
placeholder="Search users..."
|
placeholder="Search users..."
|
||||||
value={userSearch}
|
value={userSearch}
|
||||||
onChange={(e) => setUserSearch(e.target.value)}
|
onChange={(e) => setUserSearch(e.target.value)}
|
||||||
className="w-full bg-[#111827] border border-[#1f2937] text-white text-sm rounded-lg pl-9 pr-3 py-2 placeholder-[#444] focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#111827] border border-[#1f2937] text-white text-sm rounded-lg pl-9 pr-3 py-2 placeholder-[#444] focus:outline-none focus:border-[#1D4ED8]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -488,7 +488,7 @@ export default function AdminSuspensionsPage() {
|
|||||||
<tr key={u.id} className="border-b border-[#1a2030] hover:bg-[#0d1117]/50 transition-colors">
|
<tr key={u.id} className="border-b border-[#1a2030] hover:bg-[#0d1117]/50 transition-colors">
|
||||||
<td className="px-5 py-3.5">
|
<td className="px-5 py-3.5">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 rounded-full bg-[#1f2937] flex items-center justify-center text-[#3b82f6] font-bold text-sm flex-shrink-0 overflow-hidden">
|
<div className="w-8 h-8 rounded-full bg-[#1f2937] flex items-center justify-center text-[#1D4ED8] font-bold text-sm flex-shrink-0 overflow-hidden">
|
||||||
{u.avatar_url ? (
|
{u.avatar_url ? (
|
||||||
<img src={u.avatar_url} alt={u.full_name || u.email} className="w-full h-full object-cover" />
|
<img src={u.avatar_url} alt={u.full_name || u.email} className="w-full h-full object-cover" />
|
||||||
) : (
|
) : (
|
||||||
@@ -572,7 +572,7 @@ export default function AdminSuspensionsPage() {
|
|||||||
<form onSubmit={handleSuspend} className="px-6 py-5 space-y-4">
|
<form onSubmit={handleSuspend} className="px-6 py-5 space-y-4">
|
||||||
{/* User info */}
|
{/* User info */}
|
||||||
<div className="flex items-center gap-3 bg-[#0d1117] rounded-lg p-3">
|
<div className="flex items-center gap-3 bg-[#0d1117] rounded-lg p-3">
|
||||||
<div className="w-9 h-9 rounded-full bg-[#1f2937] flex items-center justify-center text-[#3b82f6] font-bold text-sm flex-shrink-0 overflow-hidden">
|
<div className="w-9 h-9 rounded-full bg-[#1f2937] flex items-center justify-center text-[#1D4ED8] font-bold text-sm flex-shrink-0 overflow-hidden">
|
||||||
{selectedUser.avatar_url ? (
|
{selectedUser.avatar_url ? (
|
||||||
<img src={selectedUser.avatar_url} alt={selectedUser.full_name || selectedUser.email} className="w-full h-full object-cover" />
|
<img src={selectedUser.avatar_url} alt={selectedUser.full_name || selectedUser.email} className="w-full h-full object-cover" />
|
||||||
) : (
|
) : (
|
||||||
@@ -637,7 +637,7 @@ export default function AdminSuspensionsPage() {
|
|||||||
<select
|
<select
|
||||||
value={suspendDuration ?? ''}
|
value={suspendDuration ?? ''}
|
||||||
onChange={(e) => setSuspendDuration(e.target.value ? parseInt(e.target.value) : null)}
|
onChange={(e) => setSuspendDuration(e.target.value ? parseInt(e.target.value) : null)}
|
||||||
className="w-full bg-[#0d1117] border border-[#1f2937] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#0d1117] border border-[#1f2937] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-[#1D4ED8]"
|
||||||
>
|
>
|
||||||
{DURATION_OPTIONS.filter(d => d.value !== null).map((d) => (
|
{DURATION_OPTIONS.filter(d => d.value !== null).map((d) => (
|
||||||
<option key={d.value} value={d.value!}>{d.label}</option>
|
<option key={d.value} value={d.value!}>{d.label}</option>
|
||||||
@@ -657,7 +657,7 @@ export default function AdminSuspensionsPage() {
|
|||||||
onChange={(e) => setSuspendReason(e.target.value)}
|
onChange={(e) => setSuspendReason(e.target.value)}
|
||||||
placeholder="Explain the reason for this action..."
|
placeholder="Explain the reason for this action..."
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full bg-[#0d1117] border border-[#1f2937] text-white text-sm rounded-lg px-3 py-2.5 placeholder-[#444] focus:outline-none focus:border-[#3b82f6] resize-none"
|
className="w-full bg-[#0d1117] border border-[#1f2937] text-white text-sm rounded-lg px-3 py-2.5 placeholder-[#444] focus:outline-none focus:border-[#1D4ED8] resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -729,7 +729,7 @@ export default function AdminSuspensionsPage() {
|
|||||||
onChange={(e) => setLiftReason(e.target.value)}
|
onChange={(e) => setLiftReason(e.target.value)}
|
||||||
placeholder="Reason for lifting this restriction..."
|
placeholder="Reason for lifting this restriction..."
|
||||||
rows={2}
|
rows={2}
|
||||||
className="w-full bg-[#0d1117] border border-[#1f2937] text-white text-sm rounded-lg px-3 py-2.5 placeholder-[#444] focus:outline-none focus:border-[#3b82f6] resize-none"
|
className="w-full bg-[#0d1117] border border-[#1f2937] text-white text-sm rounded-lg px-3 py-2.5 placeholder-[#444] focus:outline-none focus:border-[#1D4ED8] resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 pt-1">
|
<div className="flex items-center gap-3 pt-1">
|
||||||
|
|||||||
@@ -31,14 +31,14 @@ interface Invitation {
|
|||||||
type Tab = 'users' | 'invitations';
|
type Tab = 'users' | 'invitations';
|
||||||
|
|
||||||
const ROLE_OPTIONS = [
|
const ROLE_OPTIONS = [
|
||||||
{ value: 'reader', label: 'Reader', color: 'text-[#888] bg-[#1a1a1a]' },
|
{ value: 'reader', label: 'Reader', color: 'text-[#888] bg-[#FFFFFF]' },
|
||||||
{ value: 'contributor', label: 'Contributor', color: 'text-blue-400 bg-blue-400/10' },
|
{ value: 'contributor', label: 'Contributor', color: 'text-blue-400 bg-blue-400/10' },
|
||||||
{ value: 'editor', label: 'Editor', color: 'text-purple-400 bg-purple-400/10' },
|
{ value: 'editor', label: 'Editor', color: 'text-purple-400 bg-purple-400/10' },
|
||||||
{ value: 'admin', label: 'Admin', color: 'text-red-400 bg-red-400/10' },
|
{ value: 'admin', label: 'Admin', color: 'text-red-400 bg-red-400/10' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ROLE_COLORS: Record<string, string> = {
|
const ROLE_COLORS: Record<string, string> = {
|
||||||
reader: 'text-[#888] bg-[#1a1a1a]',
|
reader: 'text-[#888] bg-[#FFFFFF]',
|
||||||
contributor: 'text-blue-400 bg-blue-400/10',
|
contributor: 'text-blue-400 bg-blue-400/10',
|
||||||
editor: 'text-purple-400 bg-purple-400/10',
|
editor: 'text-purple-400 bg-purple-400/10',
|
||||||
admin: 'text-red-400 bg-red-400/10',
|
admin: 'text-red-400 bg-red-400/10',
|
||||||
@@ -47,7 +47,7 @@ const ROLE_COLORS: Record<string, string> = {
|
|||||||
const STATUS_COLORS: Record<string, string> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
pending: 'text-yellow-400 bg-yellow-400/10',
|
pending: 'text-yellow-400 bg-yellow-400/10',
|
||||||
accepted: 'text-green-400 bg-green-400/10',
|
accepted: 'text-green-400 bg-green-400/10',
|
||||||
revoked: 'text-[#555] bg-[#1a1a1a]',
|
revoked: 'text-[#555] bg-[#FFFFFF]',
|
||||||
expired: 'text-orange-400 bg-orange-400/10',
|
expired: 'text-orange-400 bg-orange-400/10',
|
||||||
email_failed: 'text-red-400 bg-red-400/10',
|
email_failed: 'text-red-400 bg-red-400/10',
|
||||||
};
|
};
|
||||||
@@ -246,7 +246,7 @@ export default function AdminUsersPage() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0d1117] flex items-center justify-center">
|
<div className="min-h-screen bg-[#0d1117] flex items-center justify-center">
|
||||||
<div className="w-6 h-6 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
<div className="w-6 h-6 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -276,7 +276,7 @@ export default function AdminUsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowInviteModal(true)}
|
onClick={() => setShowInviteModal(true)}
|
||||||
className="flex items-center gap-2 bg-[#3b82f6] hover:bg-[#2563eb] text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors"
|
className="flex items-center gap-2 bg-[#1D4ED8] hover:bg-[#1E3A8A] text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
|
||||||
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 12 19.79 19.79 0 0 1 1.61 3.41 2 2 0 0 1 3.6 1.22h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.91 8.96a16 16 0 0 0 6 6l.96-.96a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 21.5 16.5z" />
|
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 12 19.79 19.79 0 0 1 1.61 3.41 2 2 0 0 1 3.6 1.22h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.91 8.96a16 16 0 0 0 6 6l.96-.96a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 21.5 16.5z" />
|
||||||
@@ -306,7 +306,7 @@ export default function AdminUsersPage() {
|
|||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
className={`px-4 py-2.5 text-sm font-semibold capitalize transition-colors border-b-2 -mb-px ${
|
className={`px-4 py-2.5 text-sm font-semibold capitalize transition-colors border-b-2 -mb-px ${
|
||||||
activeTab === tab
|
activeTab === tab
|
||||||
? 'text-[#3b82f6] border-[#3b82f6]'
|
? 'text-[#1D4ED8] border-[#1D4ED8]'
|
||||||
: 'text-[#555] border-transparent hover:text-[#888]'
|
: 'text-[#555] border-transparent hover:text-[#888]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -331,14 +331,14 @@ export default function AdminUsersPage() {
|
|||||||
placeholder={activeTab === 'users' ? 'Search users...' : 'Search by email...'}
|
placeholder={activeTab === 'users' ? 'Search users...' : 'Search by email...'}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="w-full bg-[#111827] border border-[#1f2937] text-white text-sm rounded-lg pl-9 pr-3 py-2 placeholder-[#444] focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#111827] border border-[#1f2937] text-white text-sm rounded-lg pl-9 pr-3 py-2 placeholder-[#444] focus:outline-none focus:border-[#1D4ED8]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{activeTab === 'users' && (
|
{activeTab === 'users' && (
|
||||||
<select
|
<select
|
||||||
value={roleFilter}
|
value={roleFilter}
|
||||||
onChange={(e) => setRoleFilter(e.target.value)}
|
onChange={(e) => setRoleFilter(e.target.value)}
|
||||||
className="bg-[#111827] border border-[#1f2937] text-[#ccc] text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-[#3b82f6]"
|
className="bg-[#111827] border border-[#1f2937] text-[#ccc] text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-[#1D4ED8]"
|
||||||
>
|
>
|
||||||
<option value="all">All Roles</option>
|
<option value="all">All Roles</option>
|
||||||
{ROLE_OPTIONS.map((r) => (
|
{ROLE_OPTIONS.map((r) => (
|
||||||
@@ -353,7 +353,7 @@ export default function AdminUsersPage() {
|
|||||||
<div className="bg-[#111827] border border-[#1f2937] rounded-xl overflow-hidden">
|
<div className="bg-[#111827] border border-[#1f2937] rounded-xl overflow-hidden">
|
||||||
{loadingData ? (
|
{loadingData ? (
|
||||||
<div className="flex items-center justify-center py-16">
|
<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 className="w-6 h-6 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : filteredUsers.length === 0 ? (
|
) : filteredUsers.length === 0 ? (
|
||||||
<div className="text-center py-16 text-[#555]">
|
<div className="text-center py-16 text-[#555]">
|
||||||
@@ -379,7 +379,7 @@ export default function AdminUsersPage() {
|
|||||||
<tr key={u.id} className="border-b border-[#1a2030] hover:bg-[#0d1117]/50 transition-colors">
|
<tr key={u.id} className="border-b border-[#1a2030] hover:bg-[#0d1117]/50 transition-colors">
|
||||||
<td className="px-5 py-3.5">
|
<td className="px-5 py-3.5">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 rounded-full bg-[#1f2937] flex items-center justify-center text-[#3b82f6] font-bold text-sm flex-shrink-0 overflow-hidden">
|
<div className="w-8 h-8 rounded-full bg-[#1f2937] flex items-center justify-center text-[#1D4ED8] font-bold text-sm flex-shrink-0 overflow-hidden">
|
||||||
{u.avatar_url ? (
|
{u.avatar_url ? (
|
||||||
<img src={u.avatar_url} alt={u.full_name || u.email} className="w-full h-full object-cover" />
|
<img src={u.avatar_url} alt={u.full_name || u.email} className="w-full h-full object-cover" />
|
||||||
) : (
|
) : (
|
||||||
@@ -393,7 +393,7 @@ export default function AdminUsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3.5">
|
<td className="px-4 py-3.5">
|
||||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold uppercase tracking-wide ${ROLE_COLORS[u.role] || 'text-[#888] bg-[#1a1a1a]'}`}>
|
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold uppercase tracking-wide ${ROLE_COLORS[u.role] || 'text-[#888] bg-[#FFFFFF]'}`}>
|
||||||
{u.role}
|
{u.role}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -418,7 +418,7 @@ export default function AdminUsersPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => openEditRole(u)}
|
onClick={() => openEditRole(u)}
|
||||||
disabled={processingIds.has(u.id)}
|
disabled={processingIds.has(u.id)}
|
||||||
className="text-[#555] hover:text-[#3b82f6] transition-colors text-xs font-medium px-2 py-1 rounded hover:bg-[#1f2937] disabled:opacity-50"
|
className="text-[#555] hover:text-[#1D4ED8] transition-colors text-xs font-medium px-2 py-1 rounded hover:bg-[#1f2937] disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Edit Role
|
Edit Role
|
||||||
</button>
|
</button>
|
||||||
@@ -455,7 +455,7 @@ export default function AdminUsersPage() {
|
|||||||
<div className="bg-[#111827] border border-[#1f2937] rounded-xl overflow-hidden">
|
<div className="bg-[#111827] border border-[#1f2937] rounded-xl overflow-hidden">
|
||||||
{loadingData ? (
|
{loadingData ? (
|
||||||
<div className="flex items-center justify-center py-16">
|
<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 className="w-6 h-6 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : filteredInvitations.length === 0 ? (
|
) : filteredInvitations.length === 0 ? (
|
||||||
<div className="text-center py-16 text-[#555]">
|
<div className="text-center py-16 text-[#555]">
|
||||||
@@ -465,7 +465,7 @@ export default function AdminUsersPage() {
|
|||||||
<p className="text-sm">No invitations sent yet</p>
|
<p className="text-sm">No invitations sent yet</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowInviteModal(true)}
|
onClick={() => setShowInviteModal(true)}
|
||||||
className="mt-3 text-[#3b82f6] hover:text-[#60a5fa] text-sm transition-colors"
|
className="mt-3 text-[#1D4ED8] hover:text-[#1D4ED8] text-sm transition-colors"
|
||||||
>
|
>
|
||||||
Send your first invitation →
|
Send your first invitation →
|
||||||
</button>
|
</button>
|
||||||
@@ -491,19 +491,19 @@ export default function AdminUsersPage() {
|
|||||||
<tr key={inv.id} className="border-b border-[#1a2030] hover:bg-[#0d1117]/50 transition-colors">
|
<tr key={inv.id} className="border-b border-[#1a2030] hover:bg-[#0d1117]/50 transition-colors">
|
||||||
<td className="px-5 py-3.5">
|
<td className="px-5 py-3.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-7 h-7 rounded-full bg-[#1f2937] flex items-center justify-center text-[#3b82f6] text-xs font-bold flex-shrink-0">
|
<div className="w-7 h-7 rounded-full bg-[#1f2937] flex items-center justify-center text-[#1D4ED8] text-xs font-bold flex-shrink-0">
|
||||||
{inv.email[0].toUpperCase()}
|
{inv.email[0].toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-white text-sm">{inv.email}</span>
|
<span className="text-white text-sm">{inv.email}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3.5">
|
<td className="px-4 py-3.5">
|
||||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold uppercase tracking-wide ${ROLE_COLORS[inv.role] || 'text-[#888] bg-[#1a1a1a]'}`}>
|
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold uppercase tracking-wide ${ROLE_COLORS[inv.role] || 'text-[#888] bg-[#FFFFFF]'}`}>
|
||||||
{inv.role}
|
{inv.role}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3.5">
|
<td className="px-4 py-3.5">
|
||||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold capitalize ${STATUS_COLORS[displayStatus] || 'text-[#888] bg-[#1a1a1a]'}`}>
|
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold capitalize ${STATUS_COLORS[displayStatus] || 'text-[#888] bg-[#FFFFFF]'}`}>
|
||||||
{displayStatus.replace('_', ' ')}
|
{displayStatus.replace('_', ' ')}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -564,7 +564,7 @@ export default function AdminUsersPage() {
|
|||||||
value={inviteEmail}
|
value={inviteEmail}
|
||||||
onChange={(e) => setInviteEmail(e.target.value)}
|
onChange={(e) => setInviteEmail(e.target.value)}
|
||||||
placeholder="contributor@example.com"
|
placeholder="contributor@example.com"
|
||||||
className="w-full bg-[#0d1117] border border-[#1f2937] text-white text-sm rounded-lg px-3 py-2.5 placeholder-[#444] focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#0d1117] border border-[#1f2937] text-white text-sm rounded-lg px-3 py-2.5 placeholder-[#444] focus:outline-none focus:border-[#1D4ED8]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -574,7 +574,7 @@ export default function AdminUsersPage() {
|
|||||||
<select
|
<select
|
||||||
value={inviteRole}
|
value={inviteRole}
|
||||||
onChange={(e) => setInviteRole(e.target.value)}
|
onChange={(e) => setInviteRole(e.target.value)}
|
||||||
className="w-full bg-[#0d1117] border border-[#1f2937] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#0d1117] border border-[#1f2937] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-[#1D4ED8]"
|
||||||
>
|
>
|
||||||
<option value="contributor">Contributor — can submit articles for review</option>
|
<option value="contributor">Contributor — can submit articles for review</option>
|
||||||
<option value="editor">Editor — can publish and manage articles</option>
|
<option value="editor">Editor — can publish and manage articles</option>
|
||||||
@@ -590,7 +590,7 @@ export default function AdminUsersPage() {
|
|||||||
onChange={(e) => setInviteMessage(e.target.value)}
|
onChange={(e) => setInviteMessage(e.target.value)}
|
||||||
placeholder="Add a personal note to the invitation email..."
|
placeholder="Add a personal note to the invitation email..."
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full bg-[#0d1117] border border-[#1f2937] text-white text-sm rounded-lg px-3 py-2.5 placeholder-[#444] focus:outline-none focus:border-[#3b82f6] resize-none"
|
className="w-full bg-[#0d1117] border border-[#1f2937] text-white text-sm rounded-lg px-3 py-2.5 placeholder-[#444] focus:outline-none focus:border-[#1D4ED8] resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-[#0d1117] border border-[#1f2937] rounded-lg p-3">
|
<div className="bg-[#0d1117] border border-[#1f2937] rounded-lg p-3">
|
||||||
@@ -609,7 +609,7 @@ export default function AdminUsersPage() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={inviteSending || !inviteEmail.trim()}
|
disabled={inviteSending || !inviteEmail.trim()}
|
||||||
className="flex-1 bg-[#3b82f6] hover:bg-[#2563eb] disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-semibold py-2.5 rounded-lg transition-colors flex items-center justify-center gap-2"
|
className="flex-1 bg-[#1D4ED8] hover:bg-[#1E3A8A] disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-semibold py-2.5 rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
{inviteSending ? (
|
{inviteSending ? (
|
||||||
<>
|
<>
|
||||||
@@ -644,7 +644,7 @@ export default function AdminUsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-5 space-y-4">
|
<div className="px-6 py-5 space-y-4">
|
||||||
<div className="flex items-center gap-3 bg-[#0d1117] rounded-lg p-3">
|
<div className="flex items-center gap-3 bg-[#0d1117] rounded-lg p-3">
|
||||||
<div className="w-9 h-9 rounded-full bg-[#1f2937] flex items-center justify-center text-[#3b82f6] font-bold text-sm flex-shrink-0 overflow-hidden">
|
<div className="w-9 h-9 rounded-full bg-[#1f2937] flex items-center justify-center text-[#1D4ED8] font-bold text-sm flex-shrink-0 overflow-hidden">
|
||||||
{editingUser.avatar_url ? (
|
{editingUser.avatar_url ? (
|
||||||
<img src={editingUser.avatar_url} alt={editingUser.full_name || editingUser.email} className="w-full h-full object-cover" />
|
<img src={editingUser.avatar_url} alt={editingUser.full_name || editingUser.email} className="w-full h-full object-cover" />
|
||||||
) : (
|
) : (
|
||||||
@@ -666,7 +666,7 @@ export default function AdminUsersPage() {
|
|||||||
key={r.value}
|
key={r.value}
|
||||||
className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||||
editRole === r.value
|
editRole === r.value
|
||||||
? 'border-[#3b82f6] bg-[#3b82f6]/10'
|
? 'border-[#1D4ED8] bg-[#1D4ED8]/10'
|
||||||
: 'border-[#1f2937] hover:border-[#2a3a50]'
|
: 'border-[#1f2937] hover:border-[#2a3a50]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -679,9 +679,9 @@ export default function AdminUsersPage() {
|
|||||||
className="sr-only"
|
className="sr-only"
|
||||||
/>
|
/>
|
||||||
<span className={`w-3.5 h-3.5 rounded-full border-2 flex items-center justify-center flex-shrink-0 ${
|
<span className={`w-3.5 h-3.5 rounded-full border-2 flex items-center justify-center flex-shrink-0 ${
|
||||||
editRole === r.value ? 'border-[#3b82f6]' : 'border-[#444]'
|
editRole === r.value ? 'border-[#1D4ED8]' : 'border-[#444]'
|
||||||
}`}>
|
}`}>
|
||||||
{editRole === r.value && <span className="w-1.5 h-1.5 rounded-full bg-[#3b82f6]" />}
|
{editRole === r.value && <span className="w-1.5 h-1.5 rounded-full bg-[#1D4ED8]" />}
|
||||||
</span>
|
</span>
|
||||||
<span className={`text-xs font-semibold uppercase tracking-wide px-2 py-0.5 rounded ${r.color}`}>
|
<span className={`text-xs font-semibold uppercase tracking-wide px-2 py-0.5 rounded ${r.color}`}>
|
||||||
{r.label}
|
{r.label}
|
||||||
@@ -700,7 +700,7 @@ export default function AdminUsersPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleEditSave}
|
onClick={handleEditSave}
|
||||||
disabled={editSaving || editRole === editingUser.role}
|
disabled={editSaving || editRole === editingUser.role}
|
||||||
className="flex-1 bg-[#3b82f6] hover:bg-[#2563eb] disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-semibold py-2.5 rounded-lg transition-colors flex items-center justify-center gap-2"
|
className="flex-1 bg-[#1D4ED8] hover:bg-[#1E3A8A] disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-semibold py-2.5 rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
{editSaving ? (
|
{editSaving ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ export default function WPUsersAdminPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredUsers.map(u => (
|
{filteredUsers.map(u => (
|
||||||
<tr key={u.id} className="border-b border-[#1a1a1a] hover:bg-[#151515] transition-colors">
|
<tr key={u.id} className="border-b border-[#FFFFFF] hover:bg-[#151515] transition-colors">
|
||||||
<td className="px-4 py-3 text-[#555] font-mono text-xs">{u.wp_id}</td>
|
<td className="px-4 py-3 text-[#555] font-mono text-xs">{u.wp_id}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className="text-white font-medium">{u.wp_username}</span>
|
<span className="text-white font-medium">{u.wp_username}</span>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Advertise With AV Beat — Reach Broadcast Engineering Professionals',
|
title: 'Advertise With AV Beat — Reach Pro AV & Live Production Professionals',
|
||||||
description: 'Advertise with AV Beat to reach broadcast engineers, production professionals, and media technology decision-makers. Display ads, sponsored content, and newsletter placements.',
|
description: 'Advertise with AV Beat to reach pro AV integrators, live production engineers, display tech specialists, and media decision-makers. Display ads, sponsored content, and newsletter placements.',
|
||||||
alternates: { canonical: '/advertise' },
|
alternates: { canonical: '/advertise' },
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: 'Advertise With AV Beat',
|
title: 'Advertise With AV Beat',
|
||||||
description: 'Reach broadcast engineers, production professionals, and media technology decision-makers.',
|
description: 'Reach pro AV integrators, live production engineers, and display tech decision-makers.',
|
||||||
url: '/advertise',
|
url: '/advertise',
|
||||||
type: 'website',
|
type: 'website',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -44,15 +44,17 @@ export default function AdvertisePage() {
|
|||||||
|
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
{/* DO NOT OVERRIDE — Advertise page hero section */}
|
{/* DO NOT OVERRIDE — Advertise page hero section */}
|
||||||
<div className="bg-[#0d1520] border-b border-[#1e3a5f] py-12">
|
<div className="bg-[#0d1520] border-b border-[#DCE6F2] py-12">
|
||||||
<div className="max-w-container mx-auto px-4 text-center">
|
<div className="max-w-container mx-auto px-4 text-center">
|
||||||
<span className="section-label mb-3 inline-block">Advertise</span>
|
<span className="section-label mb-3 inline-block">Advertise</span>
|
||||||
<h1 className="font-heading text-white text-4xl font-bold mb-4">Reach Broadcast Engineering Professionals</h1>
|
<h1 className="font-heading text-white text-4xl font-bold mb-4">Reach Pro AV & Live Production Professionals</h1>
|
||||||
<p className="text-[#aaa] font-body text-lg max-w-2xl mx-auto mb-6">
|
<p className="text-[#aaa] font-body text-lg max-w-2xl mx-auto mb-6">
|
||||||
AV Beat is the leading digital platform for broadcast engineering professionals. Connect your brand with decision-makers, engineers, and executives across the broadcast industry.
|
AV Beat is the leading digital platform for pro AV, live production, and display tech professionals. Connect your brand with decision-makers, integrators, and executives across the pro AV industry.
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href="#contact"
|
href="https://relevantmediaproperties.com/av-beat---media-kit"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
className="btn-subscribe py-3 px-8 text-base inline-block">
|
className="btn-subscribe py-3 px-8 text-base inline-block">
|
||||||
Request Media Kit
|
Request Media Kit
|
||||||
</a>
|
</a>
|
||||||
@@ -64,12 +66,12 @@ export default function AdvertisePage() {
|
|||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<span className="section-label">Our Audience</span>
|
<span className="section-label">Our Audience</span>
|
||||||
<div className="flex-1 h-px bg-[#2a2a2a]" />
|
<div className="flex-1 h-px bg-[#DCE6F2]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
{stats.map((stat) => (
|
{stats.map((stat) => (
|
||||||
<div key={stat.label} className="bg-[#111] border border-[#222] p-5 text-center">
|
<div key={stat.label} className="bg-[#111] border border-[#222] p-5 text-center">
|
||||||
<div className="font-heading text-[#3b82f6] text-2xl font-bold mb-1">{stat.value}</div>
|
<div className="font-heading text-[#1D4ED8] text-2xl font-bold mb-1">{stat.value}</div>
|
||||||
<div className="font-body text-[#777] text-xs uppercase tracking-wide">{stat.label}</div>
|
<div className="font-body text-[#777] text-xs uppercase tracking-wide">{stat.label}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -80,25 +82,25 @@ export default function AdvertisePage() {
|
|||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<span className="section-label">Ad Placements</span>
|
<span className="section-label">Ad Placements</span>
|
||||||
<div className="flex-1 h-px bg-[#2a2a2a]" />
|
<div className="flex-1 h-px bg-[#DCE6F2]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm font-body">
|
<table className="w-full text-sm font-body">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[#222]">
|
<tr className="border-b border-[#222]">
|
||||||
<th className="text-left py-3 px-4 text-[#3b82f6] font-bold text-xs uppercase tracking-wider">Placement</th>
|
<th className="text-left py-3 px-4 text-[#1D4ED8] font-bold text-xs uppercase tracking-wider">Placement</th>
|
||||||
<th className="text-left py-3 px-4 text-[#3b82f6] font-bold text-xs uppercase tracking-wider">Dimensions</th>
|
<th className="text-left py-3 px-4 text-[#1D4ED8] font-bold text-xs uppercase tracking-wider">Dimensions</th>
|
||||||
<th className="text-left py-3 px-4 text-[#3b82f6] font-bold text-xs uppercase tracking-wider">Location</th>
|
<th className="text-left py-3 px-4 text-[#1D4ED8] font-bold text-xs uppercase tracking-wider">Location</th>
|
||||||
<th className="text-left py-3 px-4 text-[#3b82f6] font-bold text-xs uppercase tracking-wider">Rate</th>
|
<th className="text-left py-3 px-4 text-[#1D4ED8] font-bold text-xs uppercase tracking-wider">Rate</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{adZones.map((zone, i) => (
|
{adZones.map((zone, i) => (
|
||||||
<tr key={zone.name} className={`border-b border-[#1a1a1a] ${i % 2 === 0 ? "bg-[#0d0d0d]" : "bg-[#111]"}`}>
|
<tr key={zone.name} className={`border-b border-[#FFFFFF] ${i % 2 === 0 ? "bg-[#F8FAFC]" : "bg-[#111]"}`}>
|
||||||
<td className="py-3 px-4 text-[#e0e0e0] font-medium">{zone.name}</td>
|
<td className="py-3 px-4 text-[#e0e0e0] font-medium">{zone.name}</td>
|
||||||
<td className="py-3 px-4 text-[#888] font-mono text-xs">{zone.dimensions}</td>
|
<td className="py-3 px-4 text-[#888] font-mono text-xs">{zone.dimensions}</td>
|
||||||
<td className="py-3 px-4 text-[#777]">{zone.placement}</td>
|
<td className="py-3 px-4 text-[#777]">{zone.placement}</td>
|
||||||
<td className="py-3 px-4 text-[#3b82f6] font-medium">{zone.cpm}</td>
|
<td className="py-3 px-4 text-[#1D4ED8] font-medium">{zone.cpm}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -114,7 +116,7 @@ export default function AdvertisePage() {
|
|||||||
<section id="contact" className="mb-12">
|
<section id="contact" className="mb-12">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<span className="section-label">Contact Sales</span>
|
<span className="section-label">Contact Sales</span>
|
||||||
<div className="flex-1 h-px bg-[#2a2a2a]" />
|
<div className="flex-1 h-px bg-[#DCE6F2]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||||
{/* Contact info */}
|
{/* Contact info */}
|
||||||
@@ -123,23 +125,23 @@ export default function AdvertisePage() {
|
|||||||
<h3 className="font-heading text-[#e0e0e0] text-lg font-bold mb-4">Sales Contact</h3>
|
<h3 className="font-heading text-[#e0e0e0] text-lg font-bold mb-4">Sales Contact</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-body text-[#3b82f6] text-xs font-bold uppercase tracking-wider mb-1">Advertising Sales</p>
|
<p className="font-body text-[#1D4ED8] text-xs font-bold uppercase tracking-wider mb-1">Advertising Sales</p>
|
||||||
<p className="font-body text-[#e0e0e0] font-medium">Jeff Victor</p>
|
<p className="font-body text-[#e0e0e0] font-medium">Jeff Victor</p>
|
||||||
<a href="mailto:jeff@avbeat.com" className="font-body text-[#3b82f6] text-sm hover:underline">
|
<a href="mailto:advertising@relevantmediaproperties.com" className="font-body text-[#1D4ED8] text-sm hover:underline">
|
||||||
jeff@avbeat.com
|
advertising@relevantmediaproperties.com
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-[#222] pt-4">
|
<div className="border-t border-[#222] pt-4">
|
||||||
<p className="font-body text-[#3b82f6] text-xs font-bold uppercase tracking-wider mb-1">Publication</p>
|
<p className="font-body text-[#1D4ED8] text-xs font-bold uppercase tracking-wider mb-1">Publication</p>
|
||||||
<p className="font-body text-[#777] text-sm">AV Beat</p>
|
<p className="font-body text-[#777] text-sm">AV Beat</p>
|
||||||
<p className="font-body text-[#777] text-sm">Relevant Media Properties</p>
|
<p className="font-body text-[#777] text-sm">Relevant Media Properties</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-[#222] pt-4">
|
<div className="border-t border-[#222] pt-4">
|
||||||
<p className="font-body text-[#3b82f6] text-xs font-bold uppercase tracking-wider mb-2">What We Offer</p>
|
<p className="font-body text-[#1D4ED8] text-xs font-bold uppercase tracking-wider mb-2">What We Offer</p>
|
||||||
<ul className="space-y-1.5">
|
<ul className="space-y-1.5">
|
||||||
{["Display advertising (web)", "Newsletter sponsorships", "Sponsored content", "Event coverage partnerships", "Podcast sponsorships"].map((item) => (
|
{["Display advertising (web)", "Newsletter sponsorships", "Sponsored content", "Event coverage partnerships", "Podcast sponsorships"].map((item) => (
|
||||||
<li key={item} className="flex items-start gap-2 text-[#777] text-sm font-body">
|
<li key={item} className="flex items-start gap-2 text-[#777] text-sm font-body">
|
||||||
<span className="text-[#3b82f6] mt-0.5">✓</span>
|
<span className="text-[#1D4ED8] mt-0.5">✓</span>
|
||||||
{item}
|
{item}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -152,16 +154,16 @@ export default function AdvertisePage() {
|
|||||||
{/* Form */}
|
{/* Form */}
|
||||||
<div className="lg:col-span-8">
|
<div className="lg:col-span-8">
|
||||||
{submitted ? (
|
{submitted ? (
|
||||||
<div className="bg-[#111] border border-[#3b82f6] p-8 text-center">
|
<div className="bg-[#111] border border-[#1D4ED8] p-8 text-center">
|
||||||
<div className="w-12 h-12 rounded-full bg-[#3b82f6]/20 flex items-center justify-center mx-auto mb-4">
|
<div className="w-12 h-12 rounded-full bg-[#1D4ED8]/20 flex items-center justify-center mx-auto mb-4">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
<path d="M5 12l4 4 10-10" stroke="#3b82f6" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
<path d="M5 12l4 4 10-10" stroke="#1D4ED8" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-heading text-[#e0e0e0] text-xl font-bold mb-2">Message Sent!</h3>
|
<h3 className="font-heading text-[#e0e0e0] text-xl font-bold mb-2">Message Sent!</h3>
|
||||||
<p className="font-body text-[#777] text-sm">
|
<p className="font-body text-[#777] text-sm">
|
||||||
Thanks for reaching out. Jeff will be in touch within one business day at{" "}
|
Thanks for reaching out. Jeff will be in touch within one business day at{" "}
|
||||||
<a href="mailto:jeff@avbeat.com" className="text-[#3b82f6] hover:underline">jeff@avbeat.com</a>.
|
<a href="mailto:advertising@relevantmediaproperties.com" className="text-[#1D4ED8] hover:underline">advertising@relevantmediaproperties.com</a>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -187,7 +189,7 @@ export default function AdvertisePage() {
|
|||||||
value={formData.company}
|
value={formData.company}
|
||||||
onChange={(e) => setFormData({ ...formData, company: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, company: e.target.value })}
|
||||||
className="search-input w-full py-2.5 px-3 text-sm"
|
className="search-input w-full py-2.5 px-3 text-sm"
|
||||||
placeholder="Acme Broadcast Co."
|
placeholder="Acme AV Systems"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,7 +221,7 @@ export default function AdvertisePage() {
|
|||||||
<select
|
<select
|
||||||
value={formData.budget}
|
value={formData.budget}
|
||||||
onChange={(e) => setFormData({ ...formData, budget: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, budget: e.target.value })}
|
||||||
className="search-input w-full py-2.5 px-3 text-sm bg-[#1a1a1a]">
|
className="search-input w-full py-2.5 px-3 text-sm bg-[#FFFFFF]">
|
||||||
<option value="">Select a range...</option>
|
<option value="">Select a range...</option>
|
||||||
<option value="under-5k">Under $5,000</option>
|
<option value="under-5k">Under $5,000</option>
|
||||||
<option value="5k-15k">$5,000 – $15,000</option>
|
<option value="5k-15k">$5,000 – $15,000</option>
|
||||||
@@ -245,7 +247,7 @@ export default function AdvertisePage() {
|
|||||||
{loading ? "Sending..." : "Send Inquiry"}
|
{loading ? "Sending..." : "Send Inquiry"}
|
||||||
</button>
|
</button>
|
||||||
<p className="text-[#555] text-xs font-body">
|
<p className="text-[#555] text-xs font-body">
|
||||||
Or email directly: <a href="mailto:jeff@avbeat.com" className="text-[#3b82f6] hover:underline">jeff@avbeat.com</a>
|
Or email directly: <a href="mailto:advertising@relevantmediaproperties.com" className="text-[#1D4ED8] hover:underline">advertising@relevantmediaproperties.com</a>
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export async function POST(request: NextRequest) {
|
|||||||
html: `
|
html: `
|
||||||
<div style="font-family:Arial,sans-serif;max-width:600px;background:#0a0a0a;color:#e5e5e5;padding:32px;border-radius:8px;">
|
<div style="font-family:Arial,sans-serif;max-width:600px;background:#0a0a0a;color:#e5e5e5;padding:32px;border-radius:8px;">
|
||||||
<div style="margin-bottom:24px;">
|
<div style="margin-bottom:24px;">
|
||||||
<h1 style="color:#3b82f6;font-size:20px;margin:0 0 4px;">Relevant Media Properties</h1>
|
<h1 style="color:#1D4ED8;font-size:20px;margin:0 0 4px;">Relevant Media Properties</h1>
|
||||||
<p style="color:#555;font-size:12px;margin:0;">AV Beat · AV Beat — Company Story Engine</p>
|
<p style="color:#555;font-size:12px;margin:0;">AV Beat · AV Beat — Company Story Engine</p>
|
||||||
</div>
|
</div>
|
||||||
<h2 style="font-size:18px;color:#fff;margin:0 0 12px;">New Company Stories Queued for Review</h2>
|
<h2 style="font-size:18px;color:#fff;margin:0 0 12px;">New Company Stories Queued for Review</h2>
|
||||||
@@ -32,11 +32,11 @@ export async function POST(request: NextRequest) {
|
|||||||
${moreText}
|
${moreText}
|
||||||
</div>
|
</div>
|
||||||
<a href="${process.env.NEXT_PUBLIC_SITE_URL || 'https://avbeat.com'}/admin/company-tracker"
|
<a href="${process.env.NEXT_PUBLIC_SITE_URL || 'https://avbeat.com'}/admin/company-tracker"
|
||||||
style="display:inline-block;background:#3b82f6;color:#fff;text-decoration:none;padding:10px 20px;border-radius:6px;font-size:14px;font-weight:bold;">
|
style="display:inline-block;background:#1D4ED8;color:#fff;text-decoration:none;padding:10px 20px;border-radius:6px;font-size:14px;font-weight:bold;">
|
||||||
Review Story Queue →
|
Review Story Queue →
|
||||||
</a>
|
</a>
|
||||||
<p style="color:#444;font-size:12px;margin-top:24px;">
|
<p style="color:#444;font-size:12px;margin-top:24px;">
|
||||||
Manage settings at <a href="${process.env.NEXT_PUBLIC_SITE_URL || 'https://avbeat.com'}/admin/company-tracker" style="color:#3b82f6;">Company Tracker</a>
|
Manage settings at <a href="${process.env.NEXT_PUBLIC_SITE_URL || 'https://avbeat.com'}/admin/company-tracker" style="color:#1D4ED8;">Company Tracker</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -22,12 +22,12 @@ export async function POST(request: NextRequest) {
|
|||||||
date: a.date,
|
date: a.date,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const systemPrompt = `You are a personalized content recommendation engine for AV Beat, a broadcast engineering news platform.
|
const systemPrompt = `You are a personalized content recommendation engine for AV Beat, a pro AV / live production / display tech news platform.
|
||||||
Given a reader's topic interests and reading history, select the 4 most relevant articles from the provided article list.
|
Given a reader's topic interests and reading history, select the 4 most relevant articles from the provided article list.
|
||||||
Return ONLY a valid JSON array of slugs in order of relevance. Example: ["slug-1","slug-2","slug-3","slug-4"]
|
Return ONLY a valid JSON array of slugs in order of relevance. Example: ["slug-1","slug-2","slug-3","slug-4"]
|
||||||
Do not include any explanation or markdown — only the raw JSON array.`;
|
Do not include any explanation or markdown — only the raw JSON array.`;
|
||||||
|
|
||||||
const userPrompt = `Reader interests: ${interests?.length ? interests.join(', ') : 'general broadcast engineering'}
|
const userPrompt = `Reader interests: ${interests?.length ? interests.join(', ') : 'general pro AV and live production'}
|
||||||
Reading history (recently read slugs): ${readingHistory?.length ? readingHistory.join(', ') : 'none'}
|
Reading history (recently read slugs): ${readingHistory?.length ? readingHistory.join(', ') : 'none'}
|
||||||
|
|
||||||
Available articles:
|
Available articles:
|
||||||
|
|||||||
130
src/app/api/articles/rating/route.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* Article star ratings — no-auth, one-vote-per-browser-session.
|
||||||
|
*
|
||||||
|
* GET /api/articles/rating?slug=<slug>
|
||||||
|
* → { avg: number|null, count: number, my_rating: number|null }
|
||||||
|
*
|
||||||
|
* POST /api/articles/rating
|
||||||
|
* body { slug: string, rating: 1..5 }
|
||||||
|
* → { ok: true, avg, count, my_rating }
|
||||||
|
*
|
||||||
|
* Storage: distribute.article_ratings (centralized RMP cross-property table).
|
||||||
|
* Each property writes with its own `property` value so the same table
|
||||||
|
* powers BB / AV Beat / SNS / etc. — see the top-level constant below.
|
||||||
|
*
|
||||||
|
* Session: a uuid stored in the `av_rate_sid` cookie (1y) — the (property,
|
||||||
|
* slug, session_id) tuple is UNIQUE so a duplicate POST from the same
|
||||||
|
* browser session for the same article is a no-op. Clearing cookies /
|
||||||
|
* opening incognito creates a new session, which is acceptable per the
|
||||||
|
* "one per computer within a session" spec.
|
||||||
|
*/
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { createAdminClient } from "@/lib/supabase/admin";
|
||||||
|
import { randomUUID, createHash } from "crypto";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const PROPERTY = "avbeat";
|
||||||
|
const SESSION_COOKIE = "av_rate_sid";
|
||||||
|
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 365; // 1 year
|
||||||
|
|
||||||
|
function getOrMintSession(req: NextRequest): { sid: string; mint: boolean } {
|
||||||
|
const existing = req.cookies.get(SESSION_COOKIE)?.value;
|
||||||
|
if (existing && /^[0-9a-f-]{30,40}$/i.test(existing)) {
|
||||||
|
return { sid: existing, mint: false };
|
||||||
|
}
|
||||||
|
return { sid: randomUUID(), mint: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashIp(req: NextRequest): string | null {
|
||||||
|
const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|
||||||
|
|| req.headers.get("x-real-ip")
|
||||||
|
|| null;
|
||||||
|
if (!ip) return null;
|
||||||
|
// SHA-256 truncated — enough for soft analytics, not reversible
|
||||||
|
return createHash("sha256").update(ip).digest("hex").slice(0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachSessionCookie(res: NextResponse, sid: string) {
|
||||||
|
res.cookies.set(SESSION_COOKIE, sid, {
|
||||||
|
maxAge: SESSION_TTL_SECONDS,
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
httpOnly: false, // client JS may read it for optimistic UI
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSummary(slug: string, sid: string) {
|
||||||
|
const sb = createAdminClient("distribute");
|
||||||
|
const [agg, mine] = await Promise.all([
|
||||||
|
sb.from("article_rating_aggregates")
|
||||||
|
.select("real_count, real_avg")
|
||||||
|
.eq("property", PROPERTY)
|
||||||
|
.eq("article_slug", slug)
|
||||||
|
.maybeSingle(),
|
||||||
|
sb.from("article_ratings")
|
||||||
|
.select("rating")
|
||||||
|
.eq("property", PROPERTY)
|
||||||
|
.eq("article_slug", slug)
|
||||||
|
.eq("session_id", sid)
|
||||||
|
.maybeSingle(),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
avg: agg.data?.real_avg ?? null,
|
||||||
|
count: agg.data?.real_count ?? 0,
|
||||||
|
my_rating: mine.data?.rating ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const slug = new URL(req.url).searchParams.get("slug");
|
||||||
|
if (!slug) {
|
||||||
|
return NextResponse.json({ error: "slug required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const { sid, mint } = getOrMintSession(req);
|
||||||
|
const summary = await loadSummary(slug, sid);
|
||||||
|
const res = NextResponse.json(summary);
|
||||||
|
if (mint) attachSessionCookie(res, sid);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
let body: { slug?: unknown; rating?: unknown };
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "invalid json" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const slug = typeof body.slug === "string" ? body.slug : null;
|
||||||
|
const rating = typeof body.rating === "number" ? Math.round(body.rating) : null;
|
||||||
|
if (!slug || !rating || rating < 1 || rating > 5) {
|
||||||
|
return NextResponse.json({ error: "slug + rating (1..5) required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sid, mint } = getOrMintSession(req);
|
||||||
|
const sb = createAdminClient("distribute");
|
||||||
|
|
||||||
|
// Insert; on conflict (property, slug, session_id) we silently no-op
|
||||||
|
// because the spec says one rating per session. We then return the
|
||||||
|
// CURRENT summary so the client renders what's actually stored.
|
||||||
|
const { error } = await sb
|
||||||
|
.from("article_ratings")
|
||||||
|
.insert({
|
||||||
|
property: PROPERTY,
|
||||||
|
article_slug: slug,
|
||||||
|
rating,
|
||||||
|
session_id: sid,
|
||||||
|
ip_hash: hashIp(req),
|
||||||
|
user_agent: req.headers.get("user-agent")?.slice(0, 200) ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 23505 = unique_violation — duplicate vote from same session, expected
|
||||||
|
if (error && (error as { code?: string }).code !== "23505") {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = await loadSummary(slug, sid);
|
||||||
|
const res = NextResponse.json({ ok: true, ...summary });
|
||||||
|
if (mint) attachSessionCookie(res, sid);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
// Layer 1 — the rich subscriber registry (95k+ rows)
|
// Layer 1 — the rich subscriber registry (95k+ rows)
|
||||||
const { data: sub } = await svc
|
const { data: sub } = await svc
|
||||||
.schema("bb")
|
.schema("av")
|
||||||
.from("email_subscribers")
|
.from("email_subscribers")
|
||||||
.select("id, status")
|
.select("id, status")
|
||||||
.eq("email_domain", email.split("@")[1])
|
.eq("email_domain", email.split("@")[1])
|
||||||
@@ -39,7 +39,7 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
// Layer 2 — the lean property-specific list
|
// Layer 2 — the lean property-specific list
|
||||||
const { data: nl } = await svc
|
const { data: nl } = await svc
|
||||||
.schema("bb")
|
.schema("av")
|
||||||
.from("newsletter_subscribers")
|
.from("newsletter_subscribers")
|
||||||
.select("id")
|
.select("id")
|
||||||
.ilike("email", email)
|
.ilike("email", email)
|
||||||
|
|||||||
@@ -1,66 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { createClient } from '@/lib/supabase/server';
|
|
||||||
import { hybridAI } from '@/lib/ai/hybridRouter';
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(_req: NextRequest) {
|
||||||
try {
|
return NextResponse.json({ error: 'Feature unavailable' }, { status: 410 });
|
||||||
const body = await req.json();
|
|
||||||
const { thread_id, question, thread_title, thread_body, replies } = body;
|
|
||||||
|
|
||||||
if (!thread_id || !question) {
|
|
||||||
return NextResponse.json({ error: 'thread_id and question are required' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingContext = replies?.length
|
|
||||||
? replies.map((r: any) => `${r.author_name}: ${r.body}`).join('\n\n')
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const result = await hybridAI(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content: `You are the AV Beat AI Assistant — a knowledgeable, helpful assistant for broadcast engineering professionals. You have deep expertise in broadcast technology including live production, IP workflows, SMPTE ST 2110, audio engineering, cameras, streaming, post-production, MAM systems, and broadcast industry trends.
|
|
||||||
|
|
||||||
You are responding in a community forum. Be helpful, technically accurate, and conversational. Acknowledge when a question is complex or when professional consultation is advisable. Keep responses focused and practical — broadcast engineers value concise, actionable information.
|
|
||||||
|
|
||||||
Important: You are an AI assistant. Be transparent about this if asked. Do not pretend to be a human.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
content: `Forum thread: "${thread_title}"
|
|
||||||
|
|
||||||
Original post: ${thread_body}
|
|
||||||
|
|
||||||
${existingContext ? `Existing discussion:\n${existingContext}\n\n` : ''}New question: ${question}
|
|
||||||
|
|
||||||
Please provide a helpful, technically accurate response for this broadcast engineering forum thread.`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
{ maxTokens: 600, isBackground: false, priority: 2 }
|
|
||||||
);
|
|
||||||
|
|
||||||
const aiResponseText = result.text;
|
|
||||||
|
|
||||||
if (!aiResponseText) {
|
|
||||||
return NextResponse.json({ error: 'No response from AI' }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabase = await createClient();
|
|
||||||
const { data: savedReply, error: saveError } = await supabase
|
|
||||||
.from('forum_replies')
|
|
||||||
.insert({
|
|
||||||
thread_id,
|
|
||||||
body: aiResponseText,
|
|
||||||
author_name: 'AV Beat AI',
|
|
||||||
is_ai_response: true,
|
|
||||||
})
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (saveError) throw saveError;
|
|
||||||
|
|
||||||
return NextResponse.json({ reply: savedReply });
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { attachAuthorUsernames, buildAuthorUsernameMap } from '@/lib/forum/authorUsernames';
|
||||||
|
|
||||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
try {
|
try {
|
||||||
@@ -30,7 +31,13 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
|
|
||||||
if (repliesError) throw repliesError;
|
if (repliesError) throw repliesError;
|
||||||
|
|
||||||
return NextResponse.json({ thread, replies: replies || [] });
|
const usernameMap = await buildAuthorUsernameMap(supabase);
|
||||||
|
const [threadWithUsername] = attachAuthorUsernames([thread], usernameMap);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
thread: threadWithUsername,
|
||||||
|
replies: attachAuthorUsernames(replies, usernameMap),
|
||||||
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { ensureForumProfile } from '@/lib/forum/profile';
|
import { ensureForumProfile } from '@/lib/forum/profile';
|
||||||
|
import { attachAuthorUsernames, buildAuthorUsernameMap } from '@/lib/forum/authorUsernames';
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -50,7 +51,13 @@ export async function GET(req: NextRequest) {
|
|||||||
const { data, error, count } = await query;
|
const { data, error, count } = await query;
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
return NextResponse.json({ threads: data, total: count, page, limit });
|
const usernameMap = await buildAuthorUsernameMap(supabase);
|
||||||
|
return NextResponse.json({
|
||||||
|
threads: attachAuthorUsernames(data, usernameMap),
|
||||||
|
total: count,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export const revalidate = 120;
|
|||||||
|
|
||||||
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
const SUPABASE_ANON = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const SUPABASE_ANON = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||||
const SCHEMA = process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "bb";
|
const SCHEMA = process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "av";
|
||||||
|
|
||||||
function client() {
|
function client() {
|
||||||
return createClient(SUPABASE_URL, SUPABASE_ANON, {
|
return createClient(SUPABASE_URL, SUPABASE_ANON, {
|
||||||
@@ -16,24 +16,19 @@ function client() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
req: Request,
|
_req: Request,
|
||||||
{ params }: { params: Promise<{ username: string }> },
|
{ params }: { params: Promise<{ username: string }> },
|
||||||
) {
|
) {
|
||||||
const { username } = await params;
|
const { username } = await params;
|
||||||
const sb = client();
|
const sb = client();
|
||||||
|
|
||||||
// 1) Profile — accept EITHER username OR display_name (URL-encoded).
|
|
||||||
// forum_threads/forum_replies store author_name = display_name,
|
|
||||||
// so the link in the UI uses the display name; we resolve it here.
|
|
||||||
// Multiple profiles can share a display_name (Michael Carter, Alex Rivera,
|
|
||||||
// etc.) — pick the earliest-created profile deterministically rather than
|
|
||||||
// erroring out with maybeSingle().
|
|
||||||
const decoded = decodeURIComponent(username);
|
const decoded = decodeURIComponent(username);
|
||||||
|
|
||||||
let { data: profile } = await sb
|
let { data: profile } = await sb
|
||||||
.from("forum_user_profiles")
|
.from("forum_user_profiles")
|
||||||
.select("*")
|
.select("*")
|
||||||
.eq("username", decoded)
|
.eq("username", decoded)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
const r2 = await sb
|
const r2 = await sb
|
||||||
.from("forum_user_profiles")
|
.from("forum_user_profiles")
|
||||||
@@ -48,51 +43,79 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: "not found" }, { status: 404 });
|
return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Their threads (use display_name match since virtual users live
|
const displayName = profile.display_name as string;
|
||||||
// outside the auth.users / bb.user_profiles graph and the
|
|
||||||
// forum_threads.author_id is NULL for them).
|
const [
|
||||||
const displayName = (profile as any).display_name as string;
|
{ count: threadCount },
|
||||||
const { data: threads } = await sb
|
{ count: replyCount },
|
||||||
|
{ data: threads },
|
||||||
|
{ data: replies },
|
||||||
|
] = await Promise.all([
|
||||||
|
sb
|
||||||
.from("forum_threads")
|
.from("forum_threads")
|
||||||
.select("id,title,reply_count,view_count,vote_score,created_at,category_id,forum_categories(name,slug)")
|
.select("*", { count: "exact", head: true })
|
||||||
.eq("author_name", displayName)
|
.eq("author_name", displayName),
|
||||||
.order("created_at", { ascending: false })
|
sb
|
||||||
.limit(50);
|
|
||||||
|
|
||||||
// 3) Recent replies
|
|
||||||
const { data: replies } = await sb
|
|
||||||
.from("forum_replies")
|
.from("forum_replies")
|
||||||
.select("id,thread_id,body,vote_score,created_at,forum_threads(title)")
|
.select("*", { count: "exact", head: true })
|
||||||
|
.eq("author_name", displayName),
|
||||||
|
sb
|
||||||
|
.from("forum_threads")
|
||||||
|
.select(
|
||||||
|
"id,title,reply_count,view_count,vote_score,created_at,category_id,forum_categories(name,slug)",
|
||||||
|
)
|
||||||
.eq("author_name", displayName)
|
.eq("author_name", displayName)
|
||||||
.order("created_at", { ascending: false })
|
.order("created_at", { ascending: false })
|
||||||
.limit(20);
|
.limit(50),
|
||||||
|
sb
|
||||||
|
.from("forum_replies")
|
||||||
|
.select("id,thread_id,body,vote_score,created_at")
|
||||||
|
.eq("author_name", displayName)
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
.limit(20),
|
||||||
|
]);
|
||||||
|
|
||||||
// 4) Computed counts
|
const threadIds = [...new Set((replies || []).map((r) => r.thread_id).filter(Boolean))];
|
||||||
const threadCount = (threads || []).length;
|
let titleByThread: Record<string, string> = {};
|
||||||
const replyCount = (replies || []).length;
|
if (threadIds.length > 0) {
|
||||||
|
const { data: threadTitles } = await sb
|
||||||
|
.from("forum_threads")
|
||||||
|
.select("id,title")
|
||||||
|
.in("id", threadIds);
|
||||||
|
titleByThread = Object.fromEntries(
|
||||||
|
(threadTitles || []).map((t) => [t.id, t.title]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const repliesWithTitles = (replies || []).map((r) => ({
|
||||||
|
...r,
|
||||||
|
forum_threads: { title: titleByThread[r.thread_id] || "(thread)" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const totalThreads = threadCount ?? 0;
|
||||||
|
const totalReplies = replyCount ?? 0;
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
profile: {
|
profile: {
|
||||||
username: (profile as any).username,
|
username: profile.username,
|
||||||
display_name: (profile as any).display_name,
|
display_name: profile.display_name,
|
||||||
bio: (profile as any).bio || null,
|
bio: profile.bio || null,
|
||||||
avatar_url: (profile as any).avatar_url || null,
|
avatar_url: profile.avatar_url || null,
|
||||||
role_title: (profile as any).role_title || null,
|
role_title: profile.role_title || null,
|
||||||
company: (profile as any).company || null,
|
company: profile.company || null,
|
||||||
location_city: (profile as any).location_city || null,
|
location_city: profile.location_city || null,
|
||||||
location_country: (profile as any).location_country || null,
|
location_country: profile.location_country || null,
|
||||||
years_experience: (profile as any).years_experience || null,
|
years_experience: profile.years_experience || null,
|
||||||
ai_persona_archetype: (profile as any).ai_persona_archetype || null,
|
expertise_level: profile.expertise_level || null,
|
||||||
expertise_level: (profile as any).expertise_level || null,
|
expertise_tags: profile.expertise_tags || [],
|
||||||
expertise_tags: (profile as any).expertise_tags || [],
|
signature: profile.signature || null,
|
||||||
writing_voice: (profile as any).writing_voice || null,
|
join_date: profile.join_date || null,
|
||||||
signature: (profile as any).signature || null,
|
last_seen_at: profile.last_seen_at || null,
|
||||||
join_date: (profile as any).join_date || null,
|
thread_count: totalThreads,
|
||||||
last_seen_at: (profile as any).last_seen_at || null,
|
reply_count: totalReplies,
|
||||||
thread_count: threadCount,
|
post_count: totalThreads + totalReplies,
|
||||||
reply_count: replyCount,
|
|
||||||
},
|
},
|
||||||
threads: threads || [],
|
threads: threads || [],
|
||||||
replies: replies || [],
|
replies: repliesWithTitles,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,7 @@ export async function POST(req: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const noteHtml = note
|
const noteHtml = note
|
||||||
? `<div style="background:#1a1a1a;border-left:3px solid #3b82f6;padding:12px 16px;margin-bottom:20px;border-radius:2px;">
|
? `<div style="background:#1a1a1a;border-left:3px solid #1D4ED8;padding:12px 16px;margin-bottom:20px;border-radius:2px;">
|
||||||
<p style="margin:0;color:#aaa;font-size:14px;font-style:italic;">"${note}"</p>
|
<p style="margin:0;color:#aaa;font-size:14px;font-style:italic;">"${note}"</p>
|
||||||
</div>`
|
</div>`
|
||||||
: "";
|
: "";
|
||||||
@@ -45,7 +45,7 @@ export async function POST(req: NextRequest) {
|
|||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<tr>
|
<tr>
|
||||||
<td style="background:#0a0a0a;border-bottom:1px solid #1a1a1a;padding:20px 28px;">
|
<td style="background:#0a0a0a;border-bottom:1px solid #1a1a1a;padding:20px 28px;">
|
||||||
<p style="margin:0;color:#3b82f6;font-size:11px;font-weight:700;letter-spacing:3px;text-transform:uppercase;">AV Beat</p>
|
<p style="margin:0;color:#1D4ED8;font-size:11px;font-weight:700;letter-spacing:3px;text-transform:uppercase;">AV Beat</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
@@ -53,10 +53,10 @@ export async function POST(req: NextRequest) {
|
|||||||
<td style="padding:28px;">
|
<td style="padding:28px;">
|
||||||
<p style="margin:0 0 8px;color:#555;font-size:11px;text-transform:uppercase;letter-spacing:2px;">Someone shared an article with you</p>
|
<p style="margin:0 0 8px;color:#555;font-size:11px;text-transform:uppercase;letter-spacing:2px;">Someone shared an article with you</p>
|
||||||
${noteHtml}
|
${noteHtml}
|
||||||
<p style="margin:0 0 6px;color:#3b82f6;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:2px;">NEWS</p>
|
<p style="margin:0 0 6px;color:#1D4ED8;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:2px;">NEWS</p>
|
||||||
<h1 style="margin:0 0 12px;color:#f0f0f0;font-size:22px;font-weight:700;line-height:1.3;">${articleTitle}</h1>
|
<h1 style="margin:0 0 12px;color:#f0f0f0;font-size:22px;font-weight:700;line-height:1.3;">${articleTitle}</h1>
|
||||||
<p style="margin:0 0 24px;color:#999;font-size:14px;line-height:1.6;">${articleExcerpt}</p>
|
<p style="margin:0 0 24px;color:#999;font-size:14px;line-height:1.6;">${articleExcerpt}</p>
|
||||||
<a href="${articleUrl}" style="display:inline-block;background:#3b82f6;color:#fff;text-decoration:none;font-size:13px;font-weight:700;padding:12px 24px;border-radius:2px;letter-spacing:1px;text-transform:uppercase;">Read Article</a>
|
<a href="${articleUrl}" style="display:inline-block;background:#1D4ED8;color:#fff;text-decoration:none;font-size:13px;font-weight:700;padding:12px 24px;border-radius:2px;letter-spacing:1px;text-transform:uppercase;">Read Article</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ async function sendWelcome(email: string, topics?: string[]) {
|
|||||||
|
|
||||||
const topicsBlock =
|
const topicsBlock =
|
||||||
topics && topics.length > 0
|
topics && topics.length > 0
|
||||||
? `<p style="color:#aaa;font-size:14px;line-height:1.5;margin:14px 0 0;">You'll receive coverage on: <strong style="color:#3b82f6;">${topics.join(', ')}</strong></p>`
|
? `<p style="color:#aaa;font-size:14px;line-height:1.5;margin:14px 0 0;">You'll receive coverage on: <strong style="color:#1D4ED8;">${topics.join(', ')}</strong></p>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
await transporter.sendMail({
|
await transporter.sendMail({
|
||||||
@@ -96,24 +96,24 @@ async function sendWelcome(email: string, topics?: string[]) {
|
|||||||
html: `
|
html: `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width,initial-scale=1" /><title>Welcome to AV Beat</title></head>
|
<html><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width,initial-scale=1" /><title>Welcome to AV Beat</title></head>
|
||||||
<body style="margin:0;padding:0;background:#0a0e15;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;">
|
<body style="margin:0;padding:0;background:#F8FAFC;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;">
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background:#0a0e15;padding:40px 16px;">
|
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background:#F8FAFC;padding:40px 16px;">
|
||||||
<tr><td align="center">
|
<tr><td align="center">
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" width="600" style="max-width:600px;background:#11192a;border:1px solid #1e3a5f;border-radius:10px;overflow:hidden;">
|
<table role="presentation" cellpadding="0" cellspacing="0" width="600" style="max-width:600px;background:#FFFFFF;border:1px solid #DCE6F2;border-radius:10px;overflow:hidden;">
|
||||||
<tr><td style="padding:32px 36px 8px;">
|
<tr><td style="padding:32px 36px 8px;">
|
||||||
<div style="font-family:'Courier New',monospace;font-size:10px;font-weight:700;letter-spacing:0.22em;text-transform:uppercase;color:#3b82f6;">AV Beat Newsletter</div>
|
<div style="font-family:'Courier New',monospace;font-size:10px;font-weight:700;letter-spacing:0.22em;text-transform:uppercase;color:#1D4ED8;">AV Beat Newsletter</div>
|
||||||
<h1 style="margin:14px 0 8px;font-size:24px;line-height:1.2;color:#ffffff;font-weight:800;">You're in</h1>
|
<h1 style="margin:14px 0 8px;font-size:24px;line-height:1.2;color:#ffffff;font-weight:800;">You're in</h1>
|
||||||
<p style="margin:0;color:#cfd6e1;font-size:15px;line-height:1.55;">Thanks for subscribing to <strong style="color:#ffffff;">AV Beat</strong> — the digital platform for broadcast engineering professionals.</p>
|
<p style="margin:0;color:#0F172A;font-size:15px;line-height:1.55;">Thanks for subscribing to <strong style="color:#ffffff;">AV Beat</strong> — the digital platform for pro AV, live production, and display tech professionals.</p>
|
||||||
${topicsBlock}
|
${topicsBlock}
|
||||||
</td></tr>
|
</td></tr>
|
||||||
<tr><td style="padding:18px 36px 8px;">
|
<tr><td style="padding:18px 36px 8px;">
|
||||||
<p style="margin:0;color:#98a3b3;font-size:14px;line-height:1.6;">You'll get one editorial dispatch a week — industry news, product launches, NAB/IBC coverage, and deep-dives. No spam, no sales calls.</p>
|
<p style="margin:0;color:#475569;font-size:14px;line-height:1.6;">You'll get one editorial dispatch a week — industry news, product launches, InfoComm/ISE coverage, and deep-dives. No spam, no sales calls.</p>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
<tr><td style="padding:18px 36px 26px;" align="center">
|
<tr><td style="padding:18px 36px 26px;" align="center">
|
||||||
<a href="${siteUrl}/news" style="display:inline-block;background:#3b82f6;color:#ffffff;text-decoration:none;padding:11px 22px;border-radius:6px;font-weight:700;font-size:13px;letter-spacing:0.05em;text-transform:uppercase;">Browse Industry News</a>
|
<a href="${siteUrl}/news" style="display:inline-block;background:#1D4ED8;color:#ffffff;text-decoration:none;padding:11px 22px;border-radius:6px;font-weight:700;font-size:13px;letter-spacing:0.05em;text-transform:uppercase;">Browse AV Industry News</a>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
<tr><td style="padding:18px 36px 28px;border-top:1px solid #1e3a5f;">
|
<tr><td style="padding:18px 36px 28px;border-top:1px solid #DCE6F2;">
|
||||||
<p style="margin:0;color:#5a6470;font-size:11px;line-height:1.5;">You're receiving this because you signed up at <a href="${siteUrl}" style="color:#3b82f6;text-decoration:none;">avbeat.com</a>. <a href="${siteUrl}/unsubscribe?email=${encodeURIComponent(email)}" style="color:#98a3b3;">Unsubscribe anytime</a>.</p>
|
<p style="margin:0;color:#8a7e6e;font-size:11px;line-height:1.5;">You're receiving this because you signed up at <a href="${siteUrl}" style="color:#1D4ED8;text-decoration:none;">avbeat.com</a>. <a href="${siteUrl}/unsubscribe?email=${encodeURIComponent(email)}" style="color:#475569;">Unsubscribe anytime</a>.</p>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
</table>
|
</table>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
|
|||||||
@@ -124,37 +124,37 @@ export async function POST(req: NextRequest) {
|
|||||||
const articlesHtml = articles
|
const articlesHtml = articles
|
||||||
.map(
|
.map(
|
||||||
(a: { title: string; excerpt?: string; url?: string; image?: string; category?: string }) => `
|
(a: { title: string; excerpt?: string; url?: string; image?: string; category?: string }) => `
|
||||||
<div style="border-bottom:1px solid #1e3a5f;padding:20px 0;">
|
<div style="border-bottom:1px solid #DCE6F2;padding:20px 0;">
|
||||||
${a.category ? `<span style="color:#3b82f6;font-size:11px;font-weight:bold;text-transform:uppercase;letter-spacing:1px;">${a.category}</span>` : ''}
|
${a.category ? `<span style="color:#1D4ED8;font-size:11px;font-weight:bold;text-transform:uppercase;letter-spacing:1px;">${a.category}</span>` : ''}
|
||||||
<h2 style="color:#e0e0e0;font-size:18px;margin:8px 0 6px;">
|
<h2 style="color:#e0e0e0;font-size:18px;margin:8px 0 6px;">
|
||||||
${a.url ? `<a href="${siteUrl}${a.url}" style="color:#e0e0e0;text-decoration:none;">${a.title}</a>` : a.title}
|
${a.url ? `<a href="${siteUrl}${a.url}" style="color:#e0e0e0;text-decoration:none;">${a.title}</a>` : a.title}
|
||||||
</h2>
|
</h2>
|
||||||
${a.excerpt ? `<p style="color:#888;font-size:14px;margin:0 0 10px;">${a.excerpt}</p>` : ''}
|
${a.excerpt ? `<p style="color:#888;font-size:14px;margin:0 0 10px;">${a.excerpt}</p>` : ''}
|
||||||
${a.url ? `<a href="${siteUrl}${a.url}" style="color:#3b82f6;font-size:13px;text-decoration:none;">Read more →</a>` : ''}
|
${a.url ? `<a href="${siteUrl}${a.url}" style="color:#1D4ED8;font-size:13px;text-decoration:none;">Read more →</a>` : ''}
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
)
|
)
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
const emailHtml = `
|
const emailHtml = `
|
||||||
<div style="background:#0d1520;padding:0;font-family:sans-serif;max-width:600px;margin:0 auto;">
|
<div style="background:#F8FAFC;padding:0;font-family:sans-serif;max-width:600px;margin:0 auto;">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div style="background:#111;border-bottom:2px solid #3b82f6;padding:24px 32px;">
|
<div style="background:#111;border-bottom:2px solid #1D4ED8;padding:24px 32px;">
|
||||||
<h1 style="color:#3b82f6;font-size:22px;margin:0;font-weight:bold;">AV Beat</h1>
|
<h1 style="color:#1D4ED8;font-size:22px;margin:0;font-weight:bold;">AV Beat</h1>
|
||||||
<p style="color:#555;font-size:12px;margin:4px 0 0;">News & Intelligence for Broadcast, Post-Production & Streaming Technology</p>
|
<p style="color:#555;font-size:12px;margin:4px 0 0;">Pro AV, Display and Live Production Tech</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
<div style="padding:32px;">
|
<div style="padding:32px;">
|
||||||
${previewText ? `<p style="color:#aaa;font-size:15px;margin:0 0 24px;">${previewText}</p>` : ''}
|
${previewText ? `<p style="color:#aaa;font-size:15px;margin:0 0 24px;">${previewText}</p>` : ''}
|
||||||
${customMessage ? `<div style="background:#1a2535;border-left:3px solid #3b82f6;padding:16px;margin-bottom:24px;"><p style="color:#e0e0e0;font-size:14px;margin:0;">${customMessage}</p></div>` : ''}
|
${customMessage ? `<div style="background:#FFFFFF;border-left:3px solid #1D4ED8;padding:16px;margin-bottom:24px;"><p style="color:#e0e0e0;font-size:14px;margin:0;">${customMessage}</p></div>` : ''}
|
||||||
${articlesHtml}
|
${articlesHtml}
|
||||||
</div>
|
</div>
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div style="background:#0a0f1a;padding:24px 32px;border-top:1px solid #1e3a5f;">
|
<div style="background:#F8FAFC;padding:24px 32px;border-top:1px solid #DCE6F2;">
|
||||||
<p style="color:#555;font-size:12px;margin:0;">You're receiving this because you subscribed at avbeat.com.</p>
|
<p style="color:#555;font-size:12px;margin:0;">You're receiving this because you subscribed at avbeat.com.</p>
|
||||||
<p style="color:#555;font-size:12px;margin:8px 0 0;">
|
<p style="color:#555;font-size:12px;margin:8px 0 0;">
|
||||||
<a href="${siteUrl}" style="color:#3b82f6;">Visit AV Beat</a> ·
|
<a href="${siteUrl}" style="color:#1D4ED8;">Visit AV Beat</a> ·
|
||||||
<a href="${siteUrl}/unsubscribe?email={{email}}" style="color:#3b82f6;">Unsubscribe</a>
|
<a href="${siteUrl}/unsubscribe?email={{email}}" style="color:#1D4ED8;">Unsubscribe</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export async function POST(req: NextRequest) {
|
|||||||
subject_prefix: subject_prefix || '',
|
subject_prefix: subject_prefix || '',
|
||||||
header_text: header_text || '',
|
header_text: header_text || '',
|
||||||
footer_text: footer_text || '',
|
footer_text: footer_text || '',
|
||||||
accent_color: accent_color || '#3b82f6',
|
accent_color: accent_color || '#1D4ED8',
|
||||||
article_blocks: article_blocks || [],
|
article_blocks: article_blocks || [],
|
||||||
is_preset: false,
|
is_preset: false,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -33,8 +33,7 @@ export async function GET(request: Request) {
|
|||||||
source: "imported" as const,
|
source: "imported" as const,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Decorate + reorder so premium and current-advertiser stories surface
|
// Decorate with promotion badges; preserve wp_published_at order.
|
||||||
// at the top of the feed.
|
|
||||||
const decorated = await decorateHomepageFeed("avbeat", base);
|
const decorated = await decorateHomepageFeed("avbeat", base);
|
||||||
const payload = decorated.map((d) => ({
|
const payload = decorated.map((d) => ({
|
||||||
...d.article,
|
...d.article,
|
||||||
|
|||||||
@@ -110,8 +110,52 @@ export async function POST(request: NextRequest) {
|
|||||||
.trim()
|
.trim()
|
||||||
.slice(0, 240);
|
.slice(0, 240);
|
||||||
|
|
||||||
|
// Passthrough mode (skip_rewrite=true): publish the original PR body
|
||||||
|
// VERBATIM with byline = firm + noindex=true on the wp_imported_posts
|
||||||
|
// row. No AI rewrite, no Google News indexing, no sitemap. For clients
|
||||||
|
// flagged in distribute.users.skip_rewrite (e.g. Mike Nann / Writeturn).
|
||||||
|
const skipRewrite = !!body?.skip_rewrite;
|
||||||
|
if (skipRewrite) {
|
||||||
|
const wpId = Math.floor(900_000 + Math.random() * 99_000_000);
|
||||||
|
const wpSlug = `${baseSlug}-pr-${randSuffix()}`;
|
||||||
|
const byline = contributor?.contact_name || contributor?.firm_name || 'PR Submission';
|
||||||
|
const { error: wpErr } = await supabase
|
||||||
|
.schema('av')
|
||||||
|
.from('wp_imported_posts')
|
||||||
|
.insert({
|
||||||
|
wp_id: wpId,
|
||||||
|
wp_slug: wpSlug,
|
||||||
|
title,
|
||||||
|
excerpt,
|
||||||
|
content,
|
||||||
|
author_name: byline,
|
||||||
|
category: 'Industry News',
|
||||||
|
status: 'published',
|
||||||
|
wp_published_at: new Date().toISOString(),
|
||||||
|
noindex: true,
|
||||||
|
});
|
||||||
|
if (wpErr) {
|
||||||
|
return NextResponse.json({ error: wpErr.message, where: 'passthrough wp_imported_posts insert' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the audit trail in original_submissions even for passthrough.
|
||||||
|
await supabase.schema('av').from('original_submissions').insert({
|
||||||
|
submission_id: submissionId,
|
||||||
|
property,
|
||||||
|
title,
|
||||||
|
body: content,
|
||||||
|
featured_image_url: featuredImage,
|
||||||
|
attachments,
|
||||||
|
contributor,
|
||||||
|
contributor_email: contributor?.email || null,
|
||||||
|
raw_payload: body,
|
||||||
|
native_article_id: null,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true, slug: wpSlug, status: 'published_passthrough', noindex: true }, { status: 201 });
|
||||||
|
}
|
||||||
|
|
||||||
const { data: article, error: artErr } = await supabase
|
const { data: article, error: artErr } = await supabase
|
||||||
.schema('bb')
|
.schema('av')
|
||||||
.from('native_articles')
|
.from('native_articles')
|
||||||
.insert({
|
.insert({
|
||||||
slug,
|
slug,
|
||||||
@@ -129,7 +173,7 @@ export async function POST(request: NextRequest) {
|
|||||||
if (artErr) return NextResponse.json({ error: artErr.message, where: 'native_articles insert' }, { status: 500 });
|
if (artErr) return NextResponse.json({ error: artErr.message, where: 'native_articles insert' }, { status: 500 });
|
||||||
|
|
||||||
const { error: subErr } = await supabase
|
const { error: subErr } = await supabase
|
||||||
.schema('bb')
|
.schema('av')
|
||||||
.from('original_submissions')
|
.from('original_submissions')
|
||||||
.insert({
|
.insert({
|
||||||
submission_id: submissionId,
|
submission_id: submissionId,
|
||||||
|
|||||||
@@ -227,11 +227,11 @@ export default function ArticleDetailClient({ article, relatedArticles, relatedF
|
|||||||
|
|
||||||
{/* Breadcrumb navigation */}
|
{/* Breadcrumb navigation */}
|
||||||
{/* DO NOT OVERRIDE — article breadcrumb and back navigation */}
|
{/* DO NOT OVERRIDE — article breadcrumb and back navigation */}
|
||||||
<div className="bg-[#0d0d0d] border-b border-[#1a1a1a]">
|
<div className="bg-[#F8FAFC] border-b border-[#FFFFFF]">
|
||||||
<div className="max-w-container mx-auto px-4 py-2.5 flex items-center gap-2 text-xs font-body text-[#555]">
|
<div className="max-w-container mx-auto px-4 py-2.5 flex items-center gap-2 text-xs font-body text-[#555]">
|
||||||
<Link href="/home-page" className="hover:text-[#3b82f6] transition-colors">Home</Link>
|
<Link href="/home-page" className="hover:text-[#1D4ED8] transition-colors">Home</Link>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<Link href={sectionHref} className="hover:text-[#3b82f6] transition-colors">{sectionLabel}</Link>
|
<Link href={sectionHref} className="hover:text-[#1D4ED8] transition-colors">{sectionLabel}</Link>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span className="text-[#888] truncate max-w-[300px]">{article.title}</span>
|
<span className="text-[#888] truncate max-w-[300px]">{article.title}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -245,14 +245,14 @@ export default function ArticleDetailClient({ article, relatedArticles, relatedF
|
|||||||
{/* Article Header */}
|
{/* Article Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<Link href={sectionHref} className="text-xs font-bold uppercase tracking-widest text-[#3b82f6] hover:underline">
|
<Link href={sectionHref} className="text-xs font-bold uppercase tracking-widest text-[#1D4ED8] hover:underline">
|
||||||
{article.category}
|
{article.category}
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-xs text-[#777]">{article.date}</span>
|
<span className="text-xs text-[#777]">{article.date}</span>
|
||||||
<span className="text-xs text-[#555]">·</span>
|
<span className="text-xs text-[#555]">·</span>
|
||||||
<span className="text-xs text-[#777]">{article.readTime}</span>
|
<span className="text-xs text-[#777]">{article.readTime}</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="font-heading text-3xl md:text-4xl font-bold mb-4 leading-tight text-[#f0f0f0]">
|
<h1 className="font-serif text-3xl md:text-4xl font-bold mb-4 leading-tight text-[#1e293b]">
|
||||||
{article.title}
|
{article.title}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
@@ -271,7 +271,7 @@ export default function ArticleDetailClient({ article, relatedArticles, relatedF
|
|||||||
<div>
|
<div>
|
||||||
<Link
|
<Link
|
||||||
href={`/authors/${article.authorSlug}`}
|
href={`/authors/${article.authorSlug}`}
|
||||||
className="font-heading font-bold text-sm hover:text-[#3b82f6] transition-colors text-[#e0e0e0]">
|
className="font-serif font-bold text-sm hover:text-[#1D4ED8] transition-colors text-[#1e293b]">
|
||||||
{article.author}
|
{article.author}
|
||||||
</Link>
|
</Link>
|
||||||
<p className="font-body text-xs text-[#777]">{article.authorTitle}</p>
|
<p className="font-body text-xs text-[#777]">{article.authorTitle}</p>
|
||||||
@@ -286,7 +286,7 @@ export default function ArticleDetailClient({ article, relatedArticles, relatedF
|
|||||||
<>
|
<>
|
||||||
<a href={`mailto:?subject=${encodeURIComponent(article.title)}&body=${encodeURIComponent(articleUrl)}`}
|
<a href={`mailto:?subject=${encodeURIComponent(article.title)}&body=${encodeURIComponent(articleUrl)}`}
|
||||||
aria-label="Share via email" title="Email"
|
aria-label="Share via email" title="Email"
|
||||||
className="flex items-center justify-center w-8 h-8 rounded-sm text-[#888] hover:text-[#3b82f6] hover:bg-[#1a1a1a] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6]">
|
className="flex items-center justify-center w-8 h-8 rounded-sm text-[#888] hover:text-[#1D4ED8] hover:bg-[#FFFFFF] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-[#1D4ED8]">
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
||||||
@@ -294,39 +294,39 @@ export default function ArticleDetailClient({ article, relatedArticles, relatedF
|
|||||||
</a>
|
</a>
|
||||||
<a href={`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(articleUrl)}`}
|
<a href={`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(articleUrl)}`}
|
||||||
target="_blank" rel="noopener noreferrer" aria-label="Share on LinkedIn" title="LinkedIn"
|
target="_blank" rel="noopener noreferrer" aria-label="Share on LinkedIn" title="LinkedIn"
|
||||||
className="flex items-center justify-center w-8 h-8 rounded-sm text-[#888] hover:text-[#0a66c2] hover:bg-[#1a1a1a] transition-colors">
|
className="flex items-center justify-center w-8 h-8 rounded-sm text-[#888] hover:text-[#0a66c2] hover:bg-[#FFFFFF] transition-colors">
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M19 0h-14C2.24 0 0 2.24 0 5v14c0 2.76 2.24 5 5 5h14c2.76 0 5-2.24 5-5V5c0-2.76-2.24-5-5-5zM8 19H5V8h3v11zM6.5 6.73a1.75 1.75 0 1 1 0-3.5 1.75 1.75 0 0 1 0 3.5zM20 19h-3v-5.6c0-3.37-4-3.12-4 0V19h-3V8h3v1.77c1.4-2.59 7-2.78 7 2.48V19z"/></svg>
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M19 0h-14C2.24 0 0 2.24 0 5v14c0 2.76 2.24 5 5 5h14c2.76 0 5-2.24 5-5V5c0-2.76-2.24-5-5-5zM8 19H5V8h3v11zM6.5 6.73a1.75 1.75 0 1 1 0-3.5 1.75 1.75 0 0 1 0 3.5zM20 19h-3v-5.6c0-3.37-4-3.12-4 0V19h-3V8h3v1.77c1.4-2.59 7-2.78 7 2.48V19z"/></svg>
|
||||||
</a>
|
</a>
|
||||||
<a href={`https://twitter.com/intent/tweet?url=${encodeURIComponent(articleUrl)}&text=${encodeURIComponent(article.title)}&via=BroadcastBeat`}
|
<a href={`https://twitter.com/intent/tweet?url=${encodeURIComponent(articleUrl)}&text=${encodeURIComponent(article.title)}&via=BroadcastBeat`}
|
||||||
target="_blank" rel="noopener noreferrer" aria-label="Share on X" title="X (Twitter)"
|
target="_blank" rel="noopener noreferrer" aria-label="Share on X" title="X (Twitter)"
|
||||||
className="flex items-center justify-center w-8 h-8 rounded-sm text-[#888] hover:text-white hover:bg-[#1a1a1a] transition-colors">
|
className="flex items-center justify-center w-8 h-8 rounded-sm text-[#888] hover:text-white hover:bg-[#FFFFFF] transition-colors">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
|
||||||
</a>
|
</a>
|
||||||
<a href={`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(articleUrl)}`}
|
<a href={`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(articleUrl)}`}
|
||||||
target="_blank" rel="noopener noreferrer" aria-label="Share on Facebook" title="Facebook"
|
target="_blank" rel="noopener noreferrer" aria-label="Share on Facebook" title="Facebook"
|
||||||
className="flex items-center justify-center w-8 h-8 rounded-sm text-[#888] hover:text-[#1877f2] hover:bg-[#1a1a1a] transition-colors">
|
className="flex items-center justify-center w-8 h-8 rounded-sm text-[#888] hover:text-[#1877f2] hover:bg-[#FFFFFF] transition-colors">
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M24 12.073c0-6.627-5.373-12-12-12S0 5.446 0 12.073C0 18.062 4.388 23.027 10.125 23.927v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M24 12.073c0-6.627-5.373-12-12-12S0 5.446 0 12.073C0 18.062 4.388 23.027 10.125 23.927v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
|
||||||
</a>
|
</a>
|
||||||
<a href={`https://www.reddit.com/submit?url=${encodeURIComponent(articleUrl)}&title=${encodeURIComponent(article.title)}`}
|
<a href={`https://www.reddit.com/submit?url=${encodeURIComponent(articleUrl)}&title=${encodeURIComponent(article.title)}`}
|
||||||
target="_blank" rel="noopener noreferrer" aria-label="Share on Reddit" title="Reddit"
|
target="_blank" rel="noopener noreferrer" aria-label="Share on Reddit" title="Reddit"
|
||||||
className="flex items-center justify-center w-8 h-8 rounded-sm text-[#888] hover:text-[#ff4500] hover:bg-[#1a1a1a] transition-colors">
|
className="flex items-center justify-center w-8 h-8 rounded-sm text-[#888] hover:text-[#ff4500] hover:bg-[#FFFFFF] transition-colors">
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z"/></svg>
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z"/></svg>
|
||||||
</a>
|
</a>
|
||||||
<a href={`https://api.whatsapp.com/send?text=${encodeURIComponent(article.title + " " + articleUrl)}`}
|
<a href={`https://api.whatsapp.com/send?text=${encodeURIComponent(article.title + " " + articleUrl)}`}
|
||||||
target="_blank" rel="noopener noreferrer" aria-label="Share on WhatsApp" title="WhatsApp"
|
target="_blank" rel="noopener noreferrer" aria-label="Share on WhatsApp" title="WhatsApp"
|
||||||
className="flex items-center justify-center w-8 h-8 rounded-sm text-[#888] hover:text-[#25d366] hover:bg-[#1a1a1a] transition-colors">
|
className="flex items-center justify-center w-8 h-8 rounded-sm text-[#888] hover:text-[#25d366] hover:bg-[#FFFFFF] transition-colors">
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M.057 24l1.687-6.163a11.867 11.867 0 0 1-1.587-5.945C.16 5.335 5.495 0 12.05 0a11.817 11.817 0 0 1 8.413 3.488 11.824 11.824 0 0 1 3.48 8.414c-.003 6.557-5.338 11.892-11.893 11.892a11.9 11.9 0 0 1-5.688-1.448L.057 24zm6.597-3.807c1.676.995 3.276 1.591 5.392 1.592 5.448 0 9.886-4.434 9.889-9.885.002-5.462-4.415-9.89-9.881-9.892-5.452 0-9.887 4.434-9.889 9.884-.001 2.225.651 3.891 1.746 5.634l-.999 3.648 3.742-.981zm11.387-5.464c-.074-.124-.272-.198-.57-.347-.297-.149-1.758-.868-2.031-.967-.272-.099-.47-.149-.669.149-.198.297-.768.967-.941 1.165-.173.198-.347.223-.644.074-.297-.149-1.255-.462-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.297-.347.446-.521.151-.172.2-.296.3-.495.099-.198.05-.372-.025-.521-.075-.148-.669-1.611-.916-2.206-.242-.579-.487-.501-.669-.51l-.57-.01c-.198 0-.52.074-.792.372s-1.04 1.016-1.04 2.479 1.065 2.876 1.213 3.074c.149.198 2.095 3.2 5.076 4.487.711.306 1.265.489 1.697.626.713.226 1.362.194 1.875.118.572-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413z"/></svg>
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M.057 24l1.687-6.163a11.867 11.867 0 0 1-1.587-5.945C.16 5.335 5.495 0 12.05 0a11.817 11.817 0 0 1 8.413 3.488 11.824 11.824 0 0 1 3.48 8.414c-.003 6.557-5.338 11.892-11.893 11.892a11.9 11.9 0 0 1-5.688-1.448L.057 24zm6.597-3.807c1.676.995 3.276 1.591 5.392 1.592 5.448 0 9.886-4.434 9.889-9.885.002-5.462-4.415-9.89-9.881-9.892-5.452 0-9.887 4.434-9.889 9.884-.001 2.225.651 3.891 1.746 5.634l-.999 3.648 3.742-.981zm11.387-5.464c-.074-.124-.272-.198-.57-.347-.297-.149-1.758-.868-2.031-.967-.272-.099-.47-.149-.669.149-.198.297-.768.967-.941 1.165-.173.198-.347.223-.644.074-.297-.149-1.255-.462-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.297-.347.446-.521.151-.172.2-.296.3-.495.099-.198.05-.372-.025-.521-.075-.148-.669-1.611-.916-2.206-.242-.579-.487-.501-.669-.51l-.57-.01c-.198 0-.52.074-.792.372s-1.04 1.016-1.04 2.479 1.065 2.876 1.213 3.074c.149.198 2.095 3.2 5.076 4.487.711.306 1.265.489 1.697.626.713.226 1.362.194 1.875.118.572-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413z"/></svg>
|
||||||
</a>
|
</a>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
onClick={async () => { try { await navigator.clipboard.writeText(articleUrl); } catch {} }}
|
onClick={async () => { try { await navigator.clipboard.writeText(articleUrl); } catch {} }}
|
||||||
aria-label="Copy article link" title="Copy link"
|
aria-label="Copy article link" title="Copy link"
|
||||||
className="flex items-center justify-center w-8 h-8 rounded-sm text-[#888] hover:text-[#3b82f6] hover:bg-[#1a1a1a] transition-colors">
|
className="flex items-center justify-center w-8 h-8 rounded-sm text-[#888] hover:text-[#1D4ED8] hover:bg-[#FFFFFF] transition-colors">
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<span className="w-px h-5 bg-[#252525] mx-1" aria-hidden="true" />
|
<span className="w-px h-5 bg-[#DCE6F2] mx-1" aria-hidden="true" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
@@ -334,29 +334,29 @@ export default function ArticleDetailClient({ article, relatedArticles, relatedF
|
|||||||
onClick={handleSaveToReadingList}
|
onClick={handleSaveToReadingList}
|
||||||
disabled={savingState !== "idle"}
|
disabled={savingState !== "idle"}
|
||||||
aria-label={isSaved ? "Remove from reading list" : "Save to reading list"}
|
aria-label={isSaved ? "Remove from reading list" : "Save to reading list"}
|
||||||
className="text-xs font-bold uppercase tracking-widest text-[#3b82f6] hover:text-blue-300 disabled:opacity-50 transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6]">
|
className="text-xs font-bold uppercase tracking-widest text-[#1D4ED8] hover:text-blue-300 disabled:opacity-50 transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-[#1D4ED8]">
|
||||||
{savingState === "saving" ? "Saving..." : savingState === "removing" ? "Removing..." : isSaved ? "✓ Saved" : "Save"}
|
{savingState === "saving" ? "Saving..." : savingState === "removing" ? "Removing..." : isSaved ? "✓ Saved" : "Save"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Featured Image */}
|
{/* Featured Image — editorial standard: cap at 480px high. */}
|
||||||
<div className="mb-8 overflow-hidden rounded-sm">
|
<figure className="mb-8 overflow-hidden rounded-sm bg-[#F1F5F9] flex items-center justify-center" style={{maxHeight: 480}}>
|
||||||
<AppImage
|
<AppImage
|
||||||
src={article.image}
|
src={article.image}
|
||||||
alt={article.alt}
|
alt={article.alt}
|
||||||
width={800}
|
width={800}
|
||||||
height={450}
|
height={450}
|
||||||
className="w-full h-auto object-cover"
|
className="max-h-[480px] w-auto max-w-full object-contain"
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
</div>
|
</figure>
|
||||||
|
|
||||||
{/* Article Body */}
|
{/* Article Body */}
|
||||||
{/* DO NOT OVERRIDE — article body prose styles */}
|
{/* DO NOT OVERRIDE — article body prose styles */}
|
||||||
<div
|
<div
|
||||||
className="bb-article-prose prose prose-invert max-w-none mb-8"
|
className="bb-article-prose prose max-w-none mb-8"
|
||||||
dangerouslySetInnerHTML={{ __html: article.content }}
|
dangerouslySetInnerHTML={{ __html: article.content }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -368,7 +368,7 @@ export default function ArticleDetailClient({ article, relatedArticles, relatedF
|
|||||||
{article.tags.map((tag) => (
|
{article.tags.map((tag) => (
|
||||||
<span
|
<span
|
||||||
key={tag}
|
key={tag}
|
||||||
className="px-3 py-1 text-xs font-body bg-[#1a1a1a] border border-[#2a2a2a] text-[#888] hover:border-[#3b82f6] hover:text-[#3b82f6] transition-colors rounded-sm cursor-default">
|
className="px-3 py-1 text-xs font-body bg-[#FFFFFF] border border-[#DCE6F2] text-[#888] hover:border-[#1D4ED8] hover:text-[#1D4ED8] transition-colors rounded-sm cursor-default">
|
||||||
{tag}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -380,7 +380,7 @@ export default function ArticleDetailClient({ article, relatedArticles, relatedF
|
|||||||
<div className="pt-6 border-t border-[#222]">
|
<div className="pt-6 border-t border-[#222]">
|
||||||
<Link
|
<Link
|
||||||
href={sectionHref}
|
href={sectionHref}
|
||||||
className="inline-flex items-center gap-2 text-[#3b82f6] font-body text-sm hover:underline focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6]">
|
className="inline-flex items-center gap-2 text-[#1D4ED8] font-body text-sm hover:underline focus:outline-none focus-visible:ring-1 focus-visible:ring-[#1D4ED8]">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||||
<polyline points="15 18 9 12 15 6" />
|
<polyline points="15 18 9 12 15 6" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -389,16 +389,19 @@ export default function ArticleDetailClient({ article, relatedArticles, relatedF
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Article comments — anonymous read, signed-in members can
|
{/* Article comments — anonymous read, signed-in members can
|
||||||
post + vote + reply. AI personas participate too (tagged). */}
|
post + vote + reply. */}
|
||||||
<ArticleComments articleSlug={article.slug} />
|
<ArticleComments articleSlug={article.slug} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar — every right-rail block (ads, Related Articles, Related
|
||||||
<aside className="lg:col-span-4 space-y-6">
|
Forum Posts) shares the same 300px column width on lg+ so left +
|
||||||
|
right edges align cleanly with the ad inventory below. */}
|
||||||
|
<aside className="lg:col-span-4">
|
||||||
|
<div className="space-y-6 w-full lg:w-[300px] lg:ml-auto">
|
||||||
{/* Related Articles */}
|
{/* Related Articles */}
|
||||||
{/* DO NOT OVERRIDE — related articles sidebar */}
|
{/* DO NOT OVERRIDE — related articles sidebar */}
|
||||||
<div className="bg-[#111] border border-[#222] p-5">
|
<div className="bg-[#111] border border-[#222] p-5">
|
||||||
<h3 className="font-body font-bold text-xs text-[#3b82f6] uppercase tracking-widest mb-4 pb-2 border-b border-[#222]">
|
<h3 className="font-body font-bold text-xs text-[#1D4ED8] uppercase tracking-widest mb-4 pb-2 border-b border-[#222]">
|
||||||
Related Articles
|
Related Articles
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -417,10 +420,10 @@ export default function ArticleDetailClient({ article, relatedArticles, relatedF
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-[#3b82f6] font-body text-[9px] font-bold uppercase tracking-wider mb-0.5">
|
<p className="text-[#1D4ED8] font-body text-[9px] font-bold uppercase tracking-wider mb-0.5">
|
||||||
{related.category}
|
{related.category}
|
||||||
</p>
|
</p>
|
||||||
<p className="font-heading text-[#e0e0e0] text-xs font-bold group-hover:text-[#3b82f6] transition-colors line-clamp-2 leading-snug">
|
<p className="font-heading text-[#e0e0e0] text-xs font-bold group-hover:text-[#1D4ED8] transition-colors line-clamp-2 leading-snug">
|
||||||
{related.title}
|
{related.title}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -435,19 +438,19 @@ export default function ArticleDetailClient({ article, relatedArticles, relatedF
|
|||||||
recently-active threads so the box is never empty. */}
|
recently-active threads so the box is never empty. */}
|
||||||
{relatedForumThreads.length > 0 && (
|
{relatedForumThreads.length > 0 && (
|
||||||
<div className="bg-[#111] border border-[#222] p-5">
|
<div className="bg-[#111] border border-[#222] p-5">
|
||||||
<h3 className="font-body font-bold text-xs text-[#3b82f6] uppercase tracking-widest mb-4 pb-2 border-b border-[#222]">
|
<h3 className="font-body font-bold text-xs text-[#1D4ED8] uppercase tracking-widest mb-4 pb-2 border-b border-[#222]">
|
||||||
Related Forum Posts
|
Related Forum Posts
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="space-y-3.5">
|
<ul className="space-y-3.5">
|
||||||
{relatedForumThreads.map((t) => (
|
{relatedForumThreads.map((t) => (
|
||||||
<li key={t.id}>
|
<li key={t.id}>
|
||||||
<Link href={`/forum/thread/${t.id}`} className="group block">
|
<Link href={`/forum/thread/${t.id}`} className="group block">
|
||||||
<p className="font-heading text-[#e0e0e0] text-xs font-bold group-hover:text-[#3b82f6] transition-colors line-clamp-2 leading-snug">
|
<p className="font-heading text-[#e0e0e0] text-xs font-bold group-hover:text-[#1D4ED8] transition-colors line-clamp-2 leading-snug">
|
||||||
{t.title}
|
{t.title}
|
||||||
</p>
|
</p>
|
||||||
<p className="flex items-center gap-2 mt-1 text-[10px] text-[#666]">
|
<p className="flex items-center gap-2 mt-1 text-[10px] text-[#666]">
|
||||||
{t.category_name && (
|
{t.category_name && (
|
||||||
<span className="text-[#3b82f6] font-body font-bold uppercase tracking-wider">
|
<span className="text-[#1D4ED8] font-body font-bold uppercase tracking-wider">
|
||||||
{t.category_name}
|
{t.category_name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -461,7 +464,7 @@ export default function ArticleDetailClient({ article, relatedArticles, relatedF
|
|||||||
</ul>
|
</ul>
|
||||||
<Link
|
<Link
|
||||||
href="/forum"
|
href="/forum"
|
||||||
className="block mt-4 pt-3 border-t border-[#222] text-[11px] text-[#3b82f6] hover:underline font-body font-semibold uppercase tracking-wider"
|
className="block mt-4 pt-3 border-t border-[#222] text-[11px] text-[#1D4ED8] hover:underline font-body font-semibold uppercase tracking-wider"
|
||||||
>
|
>
|
||||||
Browse all forum threads →
|
Browse all forum threads →
|
||||||
</Link>
|
</Link>
|
||||||
@@ -470,6 +473,7 @@ export default function ArticleDetailClient({ article, relatedArticles, relatedF
|
|||||||
|
|
||||||
{/* Sidebar ad stack — 300x600 + 300x250 rotating */}
|
{/* Sidebar ad stack — 300x600 + 300x250 rotating */}
|
||||||
<SidebarAdStack />
|
<SidebarAdStack />
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -479,7 +483,7 @@ export default function ArticleDetailClient({ article, relatedArticles, relatedF
|
|||||||
<div
|
<div
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
className="fixed bottom-4 right-4 bg-[#1a1a1a] border border-[#3b82f6] text-white px-4 py-3 rounded-sm text-sm font-body shadow-lg z-50">
|
className="fixed bottom-4 right-4 bg-[#FFFFFF] border border-[#1D4ED8] text-white px-4 py-3 rounded-sm text-sm font-body shadow-lg z-50">
|
||||||
{saveToast}
|
{saveToast}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export default function CommentsSection({ articleSlug }: CommentsSectionProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mt-10 pt-8 border-t border-[#2a2a2a]">
|
<section className="mt-10 pt-8 border-t border-[#DCE6F2]">
|
||||||
{/* Section Header */}
|
{/* Section Header */}
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<span className="section-label">Comments</span>
|
<span className="section-label">Comments</span>
|
||||||
@@ -127,15 +127,15 @@ export default function CommentsSection({ articleSlug }: CommentsSectionProps) {
|
|||||||
{comments.length} {comments.length === 1 ? "comment" : "comments"}
|
{comments.length} {comments.length === 1 ? "comment" : "comments"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 h-px bg-[#2a2a2a]" />
|
<div className="flex-1 h-px bg-[#DCE6F2]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Comment Form */}
|
{/* Comment Form */}
|
||||||
{user ? (
|
{user ? (
|
||||||
<form onSubmit={handleSubmit} className="mb-8">
|
<form onSubmit={handleSubmit} className="mb-8">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<div className="w-8 h-8 rounded-full bg-[#1e3a5f] border border-[#2a3a50] flex items-center justify-center flex-shrink-0 mt-0.5">
|
<div className="w-8 h-8 rounded-full bg-[#DCE6F2] border border-[#2a3a50] flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
<span className="font-body text-[10px] font-bold text-[#3b82f6]">
|
<span className="font-body text-[10px] font-bold text-[#1D4ED8]">
|
||||||
{getInitials(user.user_metadata?.full_name || user.email || "U")}
|
{getInitials(user.user_metadata?.full_name || user.email || "U")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,10 +146,10 @@ export default function CommentsSection({ articleSlug }: CommentsSectionProps) {
|
|||||||
placeholder="Share your thoughts on this article..."
|
placeholder="Share your thoughts on this article..."
|
||||||
rows={3}
|
rows={3}
|
||||||
maxLength={1000}
|
maxLength={1000}
|
||||||
className="w-full bg-[#1a1a1a] border border-[#2a2a2a] text-[#e0e0e0] font-body text-sm px-3 py-2.5 rounded-sm focus:outline-none focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6] transition-colors placeholder-[#444] resize-none"
|
className="w-full bg-[#FFFFFF] border border-[#DCE6F2] text-[#e0e0e0] font-body text-sm px-3 py-2.5 rounded-sm focus:outline-none focus:border-[#1D4ED8] focus:ring-1 focus:ring-[#1D4ED8] transition-colors placeholder-[#444] resize-none"
|
||||||
/>
|
/>
|
||||||
{error && (
|
{error && (
|
||||||
<p className="font-body text-xs text-[#cc0000] mt-1.5">{error}</p>
|
<p className="font-body text-xs text-[#1D4ED8] mt-1.5">{error}</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between mt-2">
|
<div className="flex items-center justify-between mt-2">
|
||||||
<span className="font-body text-[11px] text-[#555]">
|
<span className="font-body text-[11px] text-[#555]">
|
||||||
@@ -158,7 +158,7 @@ export default function CommentsSection({ articleSlug }: CommentsSectionProps) {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting || !newComment.trim()}
|
disabled={submitting || !newComment.trim()}
|
||||||
className="bg-[#3b82f6] hover:bg-[#2563eb] text-white font-body text-xs font-bold uppercase tracking-wide px-5 py-2 rounded-sm transition-colors disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3b82f6]">
|
className="bg-[#1D4ED8] hover:bg-[#1E3A8A] text-white font-body text-xs font-bold uppercase tracking-wide px-5 py-2 rounded-sm transition-colors disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#1D4ED8]">
|
||||||
{submitting ? "Posting..." : "Post Comment"}
|
{submitting ? "Posting..." : "Post Comment"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -166,14 +166,14 @@ export default function CommentsSection({ articleSlug }: CommentsSectionProps) {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<div className="mb-8 bg-[#0d1520] border border-[#1e3a5f] rounded-sm p-5 flex items-center justify-between gap-4">
|
<div className="mb-8 bg-[#0d1520] border border-[#DCE6F2] rounded-sm p-5 flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-body text-sm font-bold text-[#e0e0e0] mb-0.5">Join the conversation</p>
|
<p className="font-body text-sm font-bold text-[#e0e0e0] mb-0.5">Join the conversation</p>
|
||||||
<p className="font-body text-xs text-[#666]">Sign in to post a comment on this article.</p>
|
<p className="font-body text-xs text-[#666]">Sign in to post a comment on this article.</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
className="flex-shrink-0 bg-[#3b82f6] hover:bg-[#2563eb] text-white font-body text-xs font-bold uppercase tracking-wide px-5 py-2 rounded-sm transition-colors">
|
className="flex-shrink-0 bg-[#1D4ED8] hover:bg-[#1E3A8A] text-white font-body text-xs font-bold uppercase tracking-wide px-5 py-2 rounded-sm transition-colors">
|
||||||
Sign In
|
Sign In
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -208,7 +208,7 @@ export default function CommentsSection({ articleSlug }: CommentsSectionProps) {
|
|||||||
const isOwn = user?.id === comment.user_id;
|
const isOwn = user?.id === comment.user_id;
|
||||||
return (
|
return (
|
||||||
<div key={comment.id} className="flex gap-3 group">
|
<div key={comment.id} className="flex gap-3 group">
|
||||||
<div className="w-8 h-8 rounded-full bg-[#1a2535] border border-[#2a3a50] flex items-center justify-center flex-shrink-0 mt-0.5 overflow-hidden">
|
<div className="w-8 h-8 rounded-full bg-[#FFFFFF] border border-[#2a3a50] flex items-center justify-center flex-shrink-0 mt-0.5 overflow-hidden">
|
||||||
{comment.user_profiles?.avatar_url ? (
|
{comment.user_profiles?.avatar_url ? (
|
||||||
<img
|
<img
|
||||||
src={comment.user_profiles.avatar_url}
|
src={comment.user_profiles.avatar_url}
|
||||||
@@ -216,7 +216,7 @@ export default function CommentsSection({ articleSlug }: CommentsSectionProps) {
|
|||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="font-body text-[10px] font-bold text-[#3b82f6]">
|
<span className="font-body text-[10px] font-bold text-[#1D4ED8]">
|
||||||
{getInitials(name)}
|
{getInitials(name)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -230,7 +230,7 @@ export default function CommentsSection({ articleSlug }: CommentsSectionProps) {
|
|||||||
onClick={() => handleDelete(comment.id)}
|
onClick={() => handleDelete(comment.id)}
|
||||||
disabled={deletingId === comment.id}
|
disabled={deletingId === comment.id}
|
||||||
aria-label="Delete comment"
|
aria-label="Delete comment"
|
||||||
className="ml-auto opacity-0 group-hover:opacity-100 font-body text-[11px] text-[#555] hover:text-[#cc0000] transition-all disabled:opacity-30">
|
className="ml-auto opacity-0 group-hover:opacity-100 font-body text-[11px] text-[#555] hover:text-[#1D4ED8] transition-all disabled:opacity-30">
|
||||||
{deletingId === comment.id ? "Deleting..." : "Delete"}
|
{deletingId === comment.id ? "Deleting..." : "Delete"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { redirect } from "next/navigation";
|
import { cleanExcerpt } from "@/lib/articles/excerpt";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
import ArticleDetailPage from "./ArticleDetailClient";
|
import ArticleDetailPage from "./ArticleDetailClient";
|
||||||
import type { Article } from "@/lib/articles/types";
|
import type { Article } from "@/lib/articles/types";
|
||||||
import {
|
import {
|
||||||
@@ -9,7 +10,10 @@ import {
|
|||||||
getRelatedForumThreads, type RelatedForumThread,
|
getRelatedForumThreads, type RelatedForumThread,
|
||||||
} from "@/lib/articles/legacy-source";
|
} from "@/lib/articles/legacy-source";
|
||||||
|
|
||||||
export const revalidate = 3600;
|
// Deleted or unpublished posts must 404 immediately — never serve a stale
|
||||||
|
// cached article shell for up to an hour after DB removal.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const revalidate = 0;
|
||||||
export const dynamicParams = true;
|
export const dynamicParams = true;
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
@@ -51,14 +55,19 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|||||||
if (!article) {
|
if (!article) {
|
||||||
return { title: "Article Not Found", description: "The article you are looking for does not exist." };
|
return { title: "Article Not Found", description: "The article you are looking for does not exist." };
|
||||||
}
|
}
|
||||||
|
const noindex = !!article.noindex;
|
||||||
return {
|
return {
|
||||||
title: article.title,
|
title: article.title,
|
||||||
description: article.excerpt,
|
description: cleanExcerpt(article.excerpt),
|
||||||
alternates: { canonical: `/articles/${article.slug}` },
|
alternates: { canonical: `/articles/${article.slug}` },
|
||||||
|
robots: noindex
|
||||||
|
? { index: false, follow: false, nocache: true, googleBot: { index: false, follow: false, noimageindex: true } }
|
||||||
|
: undefined,
|
||||||
|
other: noindex ? { "googlebot-news": "noindex" } : undefined,
|
||||||
openGraph: {
|
openGraph: {
|
||||||
type: "article",
|
type: "article",
|
||||||
title: article.title,
|
title: article.title,
|
||||||
description: article.excerpt,
|
description: cleanExcerpt(article.excerpt),
|
||||||
url: `/articles/${article.slug}`,
|
url: `/articles/${article.slug}`,
|
||||||
images: [{ url: article.image, width: 1200, height: 630, alt: article.alt }],
|
images: [{ url: article.image, width: 1200, height: 630, alt: article.alt }],
|
||||||
authors: [article.author],
|
authors: [article.author],
|
||||||
@@ -68,7 +77,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|||||||
twitter: {
|
twitter: {
|
||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
title: article.title,
|
title: article.title,
|
||||||
description: article.excerpt,
|
description: cleanExcerpt(article.excerpt),
|
||||||
images: [article.image],
|
images: [article.image],
|
||||||
creator: "@AV Beat",
|
creator: "@AV Beat",
|
||||||
},
|
},
|
||||||
@@ -84,16 +93,14 @@ export default async function ArticlePage({ params }: PageProps) {
|
|||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const { article, related, relatedForumThreads } = await loadArticle(slug);
|
const { article, related, relatedForumThreads } = await loadArticle(slug);
|
||||||
if (!article) {
|
if (!article) {
|
||||||
// Slug isn't in any of our article tables — send the reader to the news
|
notFound();
|
||||||
// index so a click from the homepage/ticker doesn't dead-end on 404.
|
|
||||||
redirect("/news");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const articleSchema = {
|
const articleSchema = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "Article",
|
"@type": "Article",
|
||||||
headline: article.title,
|
headline: article.title,
|
||||||
description: article.excerpt,
|
description: cleanExcerpt(article.excerpt),
|
||||||
image: [absoluteImage(article.image)],
|
image: [absoluteImage(article.image)],
|
||||||
author: { "@type": "Person", name: article.author },
|
author: { "@type": "Person", name: article.author },
|
||||||
publisher: {
|
publisher: {
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export default function ClientReadout() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<pre style={{ padding: 20, fontFamily: "monospace", whiteSpace: "pre-wrap", background: "#1a1a1a", color: "#bbb", borderTop: "1px solid #333" }}>
|
<pre style={{ padding: 20, fontFamily: "monospace", whiteSpace: "pre-wrap", background: "#FFFFFF", color: "#bbb", borderTop: "1px solid #333" }}>
|
||||||
{JSON.stringify(out, null, 2)}
|
{JSON.stringify(out, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export default function AuthorProfilePage() {
|
|||||||
excerpt: a.excerpt || "",
|
excerpt: a.excerpt || "",
|
||||||
category: a.category || "News",
|
category: a.category || "News",
|
||||||
date: a.date || "",
|
date: a.date || "",
|
||||||
image: a.image || "/assets/images/article-placeholder.svg",
|
image: a.image || "/assets/images/article-placeholder-v2.svg",
|
||||||
alt: a.alt || a.title,
|
alt: a.alt || a.title,
|
||||||
readTime: a.readTime || "1 min read",
|
readTime: a.readTime || "1 min read",
|
||||||
}));
|
}));
|
||||||
@@ -113,7 +113,7 @@ export default function AuthorProfilePage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
<main className="bg-[#111111] min-h-screen">
|
<main className="bg-[#F8FAFC] min-h-screen">
|
||||||
<aside className="float-right ml-6 mb-6 hidden lg:block max-w-[300px]"><SidebarAdStack /></aside>
|
<aside className="float-right ml-6 mb-6 hidden lg:block max-w-[300px]"><SidebarAdStack /></aside>
|
||||||
{/* Cover Image */}
|
{/* Cover Image */}
|
||||||
<div className="relative h-40 md:h-52 overflow-hidden">
|
<div className="relative h-40 md:h-52 overflow-hidden">
|
||||||
@@ -126,14 +126,14 @@ export default function AuthorProfilePage() {
|
|||||||
className="w-full h-full object-cover opacity-40"
|
className="w-full h-full object-cover opacity-40"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-[#111111] via-[#111111]/60 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-[#F8FAFC] via-[#F8FAFC]/60 to-transparent" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-w-container mx-auto px-4">
|
<div className="max-w-container mx-auto px-4">
|
||||||
{/* Author Header */}
|
{/* Author Header */}
|
||||||
<div className="relative -mt-16 md:-mt-20 mb-6">
|
<div className="relative -mt-16 md:-mt-20 mb-6">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-end gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-end gap-4">
|
||||||
<div className="w-24 h-24 md:w-28 md:h-28 rounded-full overflow-hidden border-4 border-[#111111] flex-shrink-0 bg-[#1a1a1a]">
|
<div className="w-24 h-24 md:w-28 md:h-28 rounded-full overflow-hidden border-4 border-[#F8FAFC] flex-shrink-0 bg-[#FFFFFF]">
|
||||||
{profile && (
|
{profile && (
|
||||||
<AppImage
|
<AppImage
|
||||||
src={profile.avatar_url}
|
src={profile.avatar_url}
|
||||||
@@ -145,10 +145,10 @@ export default function AuthorProfilePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="pb-1 flex-1">
|
<div className="pb-1 flex-1">
|
||||||
<h1 className="font-heading text-2xl md:text-3xl font-bold text-[#e8e8e8] leading-tight">
|
<h1 className="font-heading text-2xl md:text-3xl font-bold text-[#0F172A] leading-tight">
|
||||||
{loading ? "Loading…" : profile?.display_name || "Author"}
|
{loading ? "Loading…" : profile?.display_name || "Author"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="font-body text-sm text-[#3b82f6] mt-0.5">
|
<p className="font-body text-sm text-[#1D4ED8] mt-0.5">
|
||||||
{profile?.role || "Contributor"}
|
{profile?.role || "Contributor"}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap items-center gap-3 mt-2">
|
<div className="flex flex-wrap items-center gap-3 mt-2">
|
||||||
@@ -180,7 +180,7 @@ export default function AuthorProfilePage() {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
aria-label={`${profile.display_name} on Twitter`}
|
aria-label={`${profile.display_name} on Twitter`}
|
||||||
className="w-8 h-8 flex items-center justify-center bg-[#1a1a1a] border border-[#2a2a2a] hover:border-[#3b82f6] hover:text-[#3b82f6] text-[#888] transition-colors rounded-sm"
|
className="w-8 h-8 flex items-center justify-center bg-[#FFFFFF] border border-[#DCE6F2] hover:border-[#1D4ED8] hover:text-[#1D4ED8] text-[#888] transition-colors rounded-sm"
|
||||||
>
|
>
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||||
@@ -193,7 +193,7 @@ export default function AuthorProfilePage() {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
aria-label={`${profile.display_name} on LinkedIn`}
|
aria-label={`${profile.display_name} on LinkedIn`}
|
||||||
className="w-8 h-8 flex items-center justify-center bg-[#1a1a1a] border border-[#2a2a2a] hover:border-[#3b82f6] hover:text-[#3b82f6] text-[#888] transition-colors rounded-sm"
|
className="w-8 h-8 flex items-center justify-center bg-[#FFFFFF] border border-[#DCE6F2] hover:border-[#1D4ED8] hover:text-[#1D4ED8] text-[#888] transition-colors rounded-sm"
|
||||||
>
|
>
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
|
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
|
||||||
@@ -204,7 +204,7 @@ export default function AuthorProfilePage() {
|
|||||||
<a
|
<a
|
||||||
href={`mailto:${profile.email}`}
|
href={`mailto:${profile.email}`}
|
||||||
aria-label={`Email ${profile.display_name}`}
|
aria-label={`Email ${profile.display_name}`}
|
||||||
className="w-8 h-8 flex items-center justify-center bg-[#1a1a1a] border border-[#2a2a2a] hover:border-[#3b82f6] hover:text-[#3b82f6] text-[#888] transition-colors rounded-sm"
|
className="w-8 h-8 flex items-center justify-center bg-[#FFFFFF] border border-[#DCE6F2] hover:border-[#1D4ED8] hover:text-[#1D4ED8] text-[#888] transition-colors rounded-sm"
|
||||||
>
|
>
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
|
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
|
||||||
@@ -223,7 +223,7 @@ export default function AuthorProfilePage() {
|
|||||||
{profile.specialties.map((spec) => (
|
{profile.specialties.map((spec) => (
|
||||||
<span
|
<span
|
||||||
key={spec}
|
key={spec}
|
||||||
className="font-body text-xs text-[#3b82f6] bg-[#1e3a5f]/30 border border-[#1e3a5f] px-2.5 py-1 rounded-sm"
|
className="font-body text-xs text-[#1D4ED8] bg-[#DCE6F2]/30 border border-[#DCE6F2] px-2.5 py-1 rounded-sm"
|
||||||
>
|
>
|
||||||
{spec}
|
{spec}
|
||||||
</span>
|
</span>
|
||||||
@@ -232,12 +232,12 @@ export default function AuthorProfilePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-0 border-b border-[#2a2a2a] mb-6">
|
<div className="flex gap-0 border-b border-[#DCE6F2] mb-6">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab("articles")}
|
onClick={() => setActiveTab("articles")}
|
||||||
className={`font-body text-xs font-bold uppercase tracking-wider px-4 py-3 border-b-2 transition-colors ${
|
className={`font-body text-xs font-bold uppercase tracking-wider px-4 py-3 border-b-2 transition-colors ${
|
||||||
activeTab === "articles"
|
activeTab === "articles"
|
||||||
? "border-[#3b82f6] text-[#3b82f6]"
|
? "border-[#1D4ED8] text-[#1D4ED8]"
|
||||||
: "border-transparent text-[#666] hover:text-[#aaa]"
|
: "border-transparent text-[#666] hover:text-[#aaa]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -247,7 +247,7 @@ export default function AuthorProfilePage() {
|
|||||||
onClick={() => setActiveTab("about")}
|
onClick={() => setActiveTab("about")}
|
||||||
className={`font-body text-xs font-bold uppercase tracking-wider px-4 py-3 border-b-2 transition-colors ${
|
className={`font-body text-xs font-bold uppercase tracking-wider px-4 py-3 border-b-2 transition-colors ${
|
||||||
activeTab === "about"
|
activeTab === "about"
|
||||||
? "border-[#3b82f6] text-[#3b82f6]"
|
? "border-[#1D4ED8] text-[#1D4ED8]"
|
||||||
: "border-transparent text-[#666] hover:text-[#aaa]"
|
: "border-transparent text-[#666] hover:text-[#aaa]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -271,11 +271,11 @@ export default function AuthorProfilePage() {
|
|||||||
<Link
|
<Link
|
||||||
key={article.slug}
|
key={article.slug}
|
||||||
href={`/articles/${article.slug}`}
|
href={`/articles/${article.slug}`}
|
||||||
className="group bg-[#1a1a1a] border border-[#2a2a2a] hover:border-[#3b82f6] transition-colors rounded-sm overflow-hidden article-card"
|
className="group bg-[#FFFFFF] border border-[#DCE6F2] hover:border-[#1D4ED8] transition-colors rounded-sm overflow-hidden article-card"
|
||||||
>
|
>
|
||||||
<div className="img-zoom h-44 overflow-hidden">
|
<div className="img-zoom h-44 overflow-hidden">
|
||||||
<AppImage
|
<AppImage
|
||||||
src={article.image || "/assets/images/article-placeholder.svg"}
|
src={article.image || "/assets/images/article-placeholder-v2.svg"}
|
||||||
alt={article.alt}
|
alt={article.alt}
|
||||||
width={400}
|
width={400}
|
||||||
height={176}
|
height={176}
|
||||||
@@ -284,13 +284,13 @@ export default function AuthorProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<span className="font-body text-[9px] font-bold uppercase tracking-widest text-[#3b82f6]">
|
<span className="font-body text-[9px] font-bold uppercase tracking-widest text-[#1D4ED8]">
|
||||||
{article.category}
|
{article.category}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-body text-[10px] text-[#555]">·</span>
|
<span className="font-body text-[10px] text-[#555]">·</span>
|
||||||
<span className="font-body text-[10px] text-[#555]">{article.readTime}</span>
|
<span className="font-body text-[10px] text-[#555]">{article.readTime}</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-heading text-sm font-bold text-[#e0e0e0] group-hover:text-[#3b82f6] transition-colors leading-snug mb-2 line-clamp-2">
|
<h3 className="font-heading text-sm font-bold text-[#0F172A] group-hover:text-[#1D4ED8] transition-colors leading-snug mb-2 line-clamp-2">
|
||||||
{article.title}
|
{article.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="font-body text-xs text-[#777] leading-relaxed line-clamp-2 mb-3">
|
<p className="font-body text-xs text-[#777] leading-relaxed line-clamp-2 mb-3">
|
||||||
@@ -308,10 +308,10 @@ export default function AuthorProfilePage() {
|
|||||||
{/* About tab */}
|
{/* About tab */}
|
||||||
{activeTab === "about" && profile && (
|
{activeTab === "about" && profile && (
|
||||||
<div className="max-w-3xl pb-10 space-y-5">
|
<div className="max-w-3xl pb-10 space-y-5">
|
||||||
<div className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm p-6">
|
<div className="bg-[#FFFFFF] border border-[#DCE6F2] rounded-sm p-6">
|
||||||
<p className="section-label mb-4">Biography</p>
|
<p className="section-label mb-4">Biography</p>
|
||||||
{profile.bio ? (
|
{profile.bio ? (
|
||||||
<p className="font-body text-sm text-[#aaaaaa] leading-relaxed whitespace-pre-line">
|
<p className="font-body text-sm text-[#1e293b] leading-relaxed whitespace-pre-line">
|
||||||
{profile.bio}
|
{profile.bio}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -328,7 +328,7 @@ export default function AuthorProfilePage() {
|
|||||||
profile.address_line1 ||
|
profile.address_line1 ||
|
||||||
profile.city ||
|
profile.city ||
|
||||||
profile.country) && (
|
profile.country) && (
|
||||||
<div className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm p-6">
|
<div className="bg-[#FFFFFF] border border-[#DCE6F2] rounded-sm p-6">
|
||||||
<p className="section-label mb-4">Contact</p>
|
<p className="section-label mb-4">Contact</p>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||||
{addressLines.length > 0 && (
|
{addressLines.length > 0 && (
|
||||||
@@ -336,7 +336,7 @@ export default function AuthorProfilePage() {
|
|||||||
<p className="font-body text-xs font-bold uppercase tracking-wider text-[#555] mb-1">
|
<p className="font-body text-xs font-bold uppercase tracking-wider text-[#555] mb-1">
|
||||||
Address
|
Address
|
||||||
</p>
|
</p>
|
||||||
<address className="font-body text-sm text-[#cccccc] not-italic leading-relaxed">
|
<address className="font-body text-sm text-[#1e293b] not-italic leading-relaxed">
|
||||||
{addressLines.map((line, i) => (
|
{addressLines.map((line, i) => (
|
||||||
<span key={i}>
|
<span key={i}>
|
||||||
{line}
|
{line}
|
||||||
@@ -353,7 +353,7 @@ export default function AuthorProfilePage() {
|
|||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href={`tel:${profile.phone.replace(/\s+/g, "")}`}
|
href={`tel:${profile.phone.replace(/\s+/g, "")}`}
|
||||||
className="font-body text-sm text-[#3b82f6] hover:underline"
|
className="font-body text-sm text-[#1D4ED8] hover:underline"
|
||||||
>
|
>
|
||||||
{profile.phone}
|
{profile.phone}
|
||||||
</a>
|
</a>
|
||||||
@@ -366,7 +366,7 @@ export default function AuthorProfilePage() {
|
|||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href={`mailto:${profile.email}`}
|
href={`mailto:${profile.email}`}
|
||||||
className="font-body text-sm text-[#3b82f6] hover:underline break-all"
|
className="font-body text-sm text-[#1D4ED8] hover:underline break-all"
|
||||||
>
|
>
|
||||||
{profile.email}
|
{profile.email}
|
||||||
</a>
|
</a>
|
||||||
@@ -381,7 +381,7 @@ export default function AuthorProfilePage() {
|
|||||||
href={profile.website}
|
href={profile.website}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="font-body text-sm text-[#3b82f6] hover:underline break-all"
|
className="font-body text-sm text-[#1D4ED8] hover:underline break-all"
|
||||||
>
|
>
|
||||||
{profile.website.replace(/^https?:\/\//, "").replace(/\/$/, "")}
|
{profile.website.replace(/^https?:\/\//, "").replace(/\/$/, "")}
|
||||||
</a>
|
</a>
|
||||||
@@ -392,7 +392,7 @@ export default function AuthorProfilePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Profile facts */}
|
{/* Profile facts */}
|
||||||
<div className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-sm p-6">
|
<div className="bg-[#FFFFFF] border border-[#DCE6F2] rounded-sm p-6">
|
||||||
<p className="section-label mb-4">Profile</p>
|
<p className="section-label mb-4">Profile</p>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{locationLine && (
|
{locationLine && (
|
||||||
@@ -400,7 +400,7 @@ export default function AuthorProfilePage() {
|
|||||||
<p className="font-body text-xs font-bold uppercase tracking-wider text-[#555] mb-1">
|
<p className="font-body text-xs font-bold uppercase tracking-wider text-[#555] mb-1">
|
||||||
Location
|
Location
|
||||||
</p>
|
</p>
|
||||||
<p className="font-body text-sm text-[#cccccc]">{locationLine}</p>
|
<p className="font-body text-sm text-[#1e293b]">{locationLine}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{profile.joined_label && (
|
{profile.joined_label && (
|
||||||
@@ -408,14 +408,14 @@ export default function AuthorProfilePage() {
|
|||||||
<p className="font-body text-xs font-bold uppercase tracking-wider text-[#555] mb-1">
|
<p className="font-body text-xs font-bold uppercase tracking-wider text-[#555] mb-1">
|
||||||
Member Since
|
Member Since
|
||||||
</p>
|
</p>
|
||||||
<p className="font-body text-sm text-[#cccccc]">{profile.joined_label}</p>
|
<p className="font-body text-sm text-[#1e293b]">{profile.joined_label}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<p className="font-body text-xs font-bold uppercase tracking-wider text-[#555] mb-1">
|
<p className="font-body text-xs font-bold uppercase tracking-wider text-[#555] mb-1">
|
||||||
Articles Published
|
Articles Published
|
||||||
</p>
|
</p>
|
||||||
<p className="font-body text-sm text-[#cccccc]">
|
<p className="font-body text-sm text-[#1e293b]">
|
||||||
{articleTotal.toLocaleString()}
|
{articleTotal.toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -423,7 +423,7 @@ export default function AuthorProfilePage() {
|
|||||||
<p className="font-body text-xs font-bold uppercase tracking-wider text-[#555] mb-1">
|
<p className="font-body text-xs font-bold uppercase tracking-wider text-[#555] mb-1">
|
||||||
Role
|
Role
|
||||||
</p>
|
</p>
|
||||||
<p className="font-body text-sm text-[#cccccc]">{profile.role}</p>
|
<p className="font-body text-sm text-[#1e293b]">{profile.role}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ export default function ContributorDashboard() {
|
|||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors text-left ${
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors text-left ${
|
||||||
view === item.id
|
view === item.id
|
||||||
? 'bg-[#c9a84c]/10 text-[#c9a84c] border border-[#c9a84c]/20'
|
? 'bg-[#c9a84c]/10 text-[#c9a84c] border border-[#c9a84c]/20'
|
||||||
: 'text-[#888] hover:text-white hover:bg-[#1a1a1a]'
|
: 'text-[#888] hover:text-white hover:bg-[#FFFFFF]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-base w-5 text-center">{item.icon}</span>
|
<span className="text-base w-5 text-center">{item.icon}</span>
|
||||||
@@ -339,7 +339,7 @@ export default function ContributorDashboard() {
|
|||||||
<button onClick={() => setView('new-post')} className="px-5 py-2.5 bg-[#c9a84c] text-black font-semibold rounded-lg text-sm hover:bg-[#b8973b] transition-colors">
|
<button onClick={() => setView('new-post')} className="px-5 py-2.5 bg-[#c9a84c] text-black font-semibold rounded-lg text-sm hover:bg-[#b8973b] transition-colors">
|
||||||
+ New Post
|
+ New Post
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setView('my-posts')} className="px-5 py-2.5 bg-[#1a1a1a] border border-[#333] text-white rounded-lg text-sm hover:bg-[#222] transition-colors">
|
<button onClick={() => setView('my-posts')} className="px-5 py-2.5 bg-[#FFFFFF] border border-[#333] text-white rounded-lg text-sm hover:bg-[#222] transition-colors">
|
||||||
View My Posts
|
View My Posts
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -453,7 +453,7 @@ export default function ContributorDashboard() {
|
|||||||
{submittingPost ? 'Submitting...' : profile?.role === 'contributor' ? 'Submit for Review' : 'Publish Post'}
|
{submittingPost ? 'Submitting...' : profile?.role === 'contributor' ? 'Submit for Review' : 'Publish Post'}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => setView('overview')}
|
<button type="button" onClick={() => setView('overview')}
|
||||||
className="px-5 py-2.5 bg-[#1a1a1a] border border-[#333] text-[#888] rounded-lg text-sm hover:text-white hover:bg-[#222] transition-colors">
|
className="px-5 py-2.5 bg-[#FFFFFF] border border-[#333] text-[#888] rounded-lg text-sm hover:text-white hover:bg-[#222] transition-colors">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ interface Stats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PUBLICATIONS = [
|
const PUBLICATIONS = [
|
||||||
{ id: 'broadcast-beat', name: 'AV Beat', short: 'BB' },
|
{ id: 'avbeat', name: 'AV Beat', short: 'AVB' },
|
||||||
{ id: 'av-beat', name: 'AV Beat', short: 'AV' },
|
{ id: 'av-beat', name: 'AV Beat', short: 'AV' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ function renderMarkdown(text: string): string {
|
|||||||
.replace(/^# (.+)$/gm, '<h1 class="text-xl font-bold text-white mt-5 mb-2">$1</h1>')
|
.replace(/^# (.+)$/gm, '<h1 class="text-xl font-bold text-white mt-5 mb-2">$1</h1>')
|
||||||
.replace(/\*\*(.+?)\*\*/g, '<strong class="text-white font-semibold">$1</strong>')
|
.replace(/\*\*(.+?)\*\*/g, '<strong class="text-white font-semibold">$1</strong>')
|
||||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||||
.replace(/`([^`]+)`/g, '<code class="bg-[#1a2535] text-[#3b82f6] px-1 py-0.5 rounded text-sm font-mono">$1</code>')
|
.replace(/`([^`]+)`/g, '<code class="bg-[#FFFFFF] text-[#1D4ED8] px-1 py-0.5 rounded text-sm font-mono">$1</code>')
|
||||||
.replace(/^- (.+)$/gm, '<li class="ml-4 list-disc text-[#aaa]">$1</li>')
|
.replace(/^- (.+)$/gm, '<li class="ml-4 list-disc text-[#aaa]">$1</li>')
|
||||||
.replace(/^\d+\. (.+)$/gm, '<li class="ml-4 list-decimal text-[#aaa]">$1</li>')
|
.replace(/^\d+\. (.+)$/gm, '<li class="ml-4 list-decimal text-[#aaa]">$1</li>')
|
||||||
.replace(/\n\n/g, '</p><p class="mb-3 text-[#ccc] leading-relaxed">')
|
.replace(/\n\n/g, '</p><p class="mb-3 text-[#ccc] leading-relaxed">')
|
||||||
@@ -294,7 +294,7 @@ function DraftPreviewModal({ content, title, penName, siteId, onClose, onSave }:
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onSave(true)}
|
onClick={() => onSave(true)}
|
||||||
className="bg-[#3b82f6] hover:bg-[#2563eb] text-white text-sm px-4 py-2 rounded-lg transition-colors font-medium"
|
className="bg-[#1D4ED8] hover:bg-[#1E3A8A] text-white text-sm px-4 py-2 rounded-lg transition-colors font-medium"
|
||||||
>
|
>
|
||||||
Open in Editor →
|
Open in Editor →
|
||||||
</button>
|
</button>
|
||||||
@@ -353,7 +353,7 @@ function RegenerateModal({ onClose, onRegenerate }: RegenerateModalProps) {
|
|||||||
onClick={() => { setSelectedVariant(v.prompt); setCustomPrompt(''); }}
|
onClick={() => { setSelectedVariant(v.prompt); setCustomPrompt(''); }}
|
||||||
className={`text-left text-xs px-3 py-2 rounded border transition-colors ${
|
className={`text-left text-xs px-3 py-2 rounded border transition-colors ${
|
||||||
selectedVariant === v.prompt
|
selectedVariant === v.prompt
|
||||||
? 'border-[#3b82f6] bg-[#1e3a5f] text-white'
|
? 'border-[#1D4ED8] bg-[#DCE6F2] text-white'
|
||||||
: 'border-[#1e2a3a] text-[#aaa] hover:border-[#2a3a50] hover:text-white hover:bg-[#161f2e]'
|
: 'border-[#1e2a3a] text-[#aaa] hover:border-[#2a3a50] hover:text-white hover:bg-[#161f2e]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -370,7 +370,7 @@ function RegenerateModal({ onClose, onRegenerate }: RegenerateModalProps) {
|
|||||||
onChange={e => { setCustomPrompt(e.target.value); setSelectedVariant(null); }}
|
onChange={e => { setCustomPrompt(e.target.value); setSelectedVariant(null); }}
|
||||||
placeholder="e.g. Rewrite this focusing on the European market impact..."
|
placeholder="e.g. Rewrite this focusing on the European market impact..."
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full bg-[#0d1117] border border-[#1e2a3a] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-[#3b82f6] placeholder-[#333] resize-none"
|
className="w-full bg-[#0d1117] border border-[#1e2a3a] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-[#1D4ED8] placeholder-[#333] resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -386,7 +386,7 @@ function RegenerateModal({ onClose, onRegenerate }: RegenerateModalProps) {
|
|||||||
<button onClick={onClose} className="text-[#555] hover:text-[#aaa] text-sm transition-colors">Cancel</button>
|
<button onClick={onClose} className="text-[#555] hover:text-[#aaa] text-sm transition-colors">Cancel</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
className="bg-[#3b82f6] hover:bg-[#2563eb] text-white text-sm px-4 py-2 rounded-lg transition-colors font-medium"
|
className="bg-[#1D4ED8] hover:bg-[#1E3A8A] text-white text-sm px-4 py-2 rounded-lg transition-colors font-medium"
|
||||||
>
|
>
|
||||||
Regenerate ↺
|
Regenerate ↺
|
||||||
</button>
|
</button>
|
||||||
@@ -418,7 +418,7 @@ function StyleGuidesPanel({ onClose, onSelectPenName }: StyleGuidesPanelProps) {
|
|||||||
const found = PEN_NAME_PROFILES.find(p => p.name === e.target.value);
|
const found = PEN_NAME_PROFILES.find(p => p.name === e.target.value);
|
||||||
if (found) side === 'left' ? setLeftPen(found) : setRightPen(found);
|
if (found) side === 'left' ? setLeftPen(found) : setRightPen(found);
|
||||||
}}
|
}}
|
||||||
className="w-full bg-[#0d1117] border border-[#1e2a3a] text-white text-sm rounded px-2 py-1.5 focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#0d1117] border border-[#1e2a3a] text-white text-sm rounded px-2 py-1.5 focus:outline-none focus:border-[#1D4ED8]"
|
||||||
>
|
>
|
||||||
{PEN_NAME_PROFILES.map(p => (
|
{PEN_NAME_PROFILES.map(p => (
|
||||||
<option key={p.name} value={p.name}>{p.name} ({p.site})</option>
|
<option key={p.name} value={p.name}>{p.name} ({p.site})</option>
|
||||||
@@ -434,7 +434,7 @@ function StyleGuidesPanel({ onClose, onSelectPenName }: StyleGuidesPanelProps) {
|
|||||||
</span>
|
</span>
|
||||||
<span className="text-white font-semibold text-sm">{profile.name}</span>
|
<span className="text-white font-semibold text-sm">{profile.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[#3b82f6] text-xs">{profile.title}</p>
|
<p className="text-[#1D4ED8] text-xs">{profile.title}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -458,7 +458,7 @@ function StyleGuidesPanel({ onClose, onSelectPenName }: StyleGuidesPanelProps) {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[#555] text-[10px] uppercase tracking-wider font-bold mb-1">Example Lead</p>
|
<p className="text-[#555] text-[10px] uppercase tracking-wider font-bold mb-1">Example Lead</p>
|
||||||
<p className="text-[#aaa] text-xs italic leading-relaxed border-l-2 border-[#3b82f6]/40 pl-3">"{profile.exampleLead}"</p>
|
<p className="text-[#aaa] text-xs italic leading-relaxed border-l-2 border-[#1D4ED8]/40 pl-3">"{profile.exampleLead}"</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -485,7 +485,7 @@ function StyleGuidesPanel({ onClose, onSelectPenName }: StyleGuidesPanelProps) {
|
|||||||
<button
|
<button
|
||||||
key={f}
|
key={f}
|
||||||
onClick={() => setFilterSite(f)}
|
onClick={() => setFilterSite(f)}
|
||||||
className={`text-xs px-2.5 py-1 rounded transition-colors ${filterSite === f ? 'bg-[#3b82f6] text-white' : 'bg-[#1e2a3a] text-[#aaa] hover:text-white'}`}
|
className={`text-xs px-2.5 py-1 rounded transition-colors ${filterSite === f ? 'bg-[#1D4ED8] text-white' : 'bg-[#1e2a3a] text-[#aaa] hover:text-white'}`}
|
||||||
>
|
>
|
||||||
{f === 'ALL' ? 'All Writers' : f === 'BB' ? 'AV Beat' : 'AV Beat'}
|
{f === 'ALL' ? 'All Writers' : f === 'BB' ? 'AV Beat' : 'AV Beat'}
|
||||||
</button>
|
</button>
|
||||||
@@ -514,7 +514,7 @@ function StyleGuidesPanel({ onClose, onSelectPenName }: StyleGuidesPanelProps) {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-0.5">
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
<span className={`text-[9px] font-bold px-1 rounded ${p.site === 'AV' ? 'bg-orange-900/40 text-orange-400' : 'bg-blue-900/40 text-blue-400'}`}>{p.site}</span>
|
<span className={`text-[9px] font-bold px-1 rounded ${p.site === 'AV' ? 'bg-orange-900/40 text-orange-400' : 'bg-blue-900/40 text-blue-400'}`}>{p.site}</span>
|
||||||
<span className="text-white text-xs font-medium group-hover:text-[#3b82f6] transition-colors">{p.name}</span>
|
<span className="text-white text-xs font-medium group-hover:text-[#1D4ED8] transition-colors">{p.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[#555] text-[10px]">{p.title}</p>
|
<p className="text-[#555] text-[10px]">{p.title}</p>
|
||||||
</button>
|
</button>
|
||||||
@@ -560,14 +560,14 @@ function ResponseHistorySidebar({ messages, onJumpTo, onClose }: ResponseHistory
|
|||||||
className="w-full text-left p-3 rounded border border-[#1e2a3a] hover:border-[#2a3a50] hover:bg-[#161f2e] transition-colors group"
|
className="w-full text-left p-3 rounded border border-[#1e2a3a] hover:border-[#2a3a50] hover:bg-[#161f2e] transition-colors group"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<span className="text-[10px] text-[#3b82f6] font-bold">Response #{idx + 1}</span>
|
<span className="text-[10px] text-[#1D4ED8] font-bold">Response #{idx + 1}</span>
|
||||||
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${msg.provider === 'ollama' ? 'bg-green-500' : 'bg-yellow-500'}`} />
|
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${msg.provider === 'ollama' ? 'bg-green-500' : 'bg-yellow-500'}`} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[#aaa] text-xs line-clamp-3 leading-relaxed">{preview}…</p>
|
<p className="text-[#aaa] text-xs line-clamp-3 leading-relaxed">{preview}…</p>
|
||||||
<div className="flex items-center gap-3 mt-2">
|
<div className="flex items-center gap-3 mt-2">
|
||||||
<span className="text-[10px] text-[#444]">{msg.timestamp.toLocaleTimeString()}</span>
|
<span className="text-[10px] text-[#444]">{msg.timestamp.toLocaleTimeString()}</span>
|
||||||
<span className="text-[10px] text-[#444]">{wordCount} words</span>
|
<span className="text-[10px] text-[#444]">{wordCount} words</span>
|
||||||
<span className="text-[10px] text-[#3b82f6] opacity-0 group-hover:opacity-100 transition-opacity">Jump ↑</span>
|
<span className="text-[10px] text-[#1D4ED8] opacity-0 group-hover:opacity-100 transition-opacity">Jump ↑</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -927,7 +927,7 @@ Respond conversationally and helpfully. If asked to write a story, write the ful
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-[#111827] border-b border-[#1e2a3a] px-4 py-3 flex items-center justify-between">
|
<div className="bg-[#111827] border-b border-[#1e2a3a] px-4 py-3 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link href="/admin" className="text-[#555] hover:text-[#3b82f6] text-sm transition-colors">← Dashboard</Link>
|
<Link href="/admin" className="text-[#555] hover:text-[#1D4ED8] text-sm transition-colors">← Dashboard</Link>
|
||||||
<span className="text-[#333]">/</span>
|
<span className="text-[#333]">/</span>
|
||||||
<h1 className="text-white font-bold text-sm tracking-wide">AI Console</h1>
|
<h1 className="text-white font-bold text-sm tracking-wide">AI Console</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -943,18 +943,18 @@ Respond conversationally and helpfully. If asked to write a story, write the ful
|
|||||||
onClick={() => setShowHistorySidebar(v => !v)}
|
onClick={() => setShowHistorySidebar(v => !v)}
|
||||||
className={`text-xs px-2.5 py-1.5 rounded border transition-colors ${
|
className={`text-xs px-2.5 py-1.5 rounded border transition-colors ${
|
||||||
showHistorySidebar
|
showHistorySidebar
|
||||||
? 'bg-[#1e3a5f] border-[#3b82f6]/40 text-[#3b82f6]'
|
? 'bg-[#DCE6F2] border-[#1D4ED8]/40 text-[#1D4ED8]'
|
||||||
: 'bg-[#1e2a3a] border-[#2a3a50] text-[#555] hover:text-[#aaa]'
|
: 'bg-[#1e2a3a] border-[#2a3a50] text-[#555] hover:text-[#aaa]'
|
||||||
}`}
|
}`}
|
||||||
title="Toggle response history sidebar"
|
title="Toggle response history sidebar"
|
||||||
>
|
>
|
||||||
History
|
History
|
||||||
{assistantMessageCount > 0 && (
|
{assistantMessageCount > 0 && (
|
||||||
<span className="ml-1.5 bg-[#3b82f6] text-white text-[9px] font-bold px-1 py-0.5 rounded-full">{assistantMessageCount}</span>
|
<span className="ml-1.5 bg-[#1D4ED8] text-white text-[9px] font-bold px-1 py-0.5 rounded-full">{assistantMessageCount}</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<Link href="/dashboard/ai-console/health" className="text-[#555] hover:text-[#3b82f6] text-xs transition-colors">Health</Link>
|
<Link href="/dashboard/ai-console/health" className="text-[#555] hover:text-[#1D4ED8] text-xs transition-colors">Health</Link>
|
||||||
<Link href="/dashboard/ai-console/settings" className="text-[#555] hover:text-[#3b82f6] text-xs transition-colors">Settings</Link>
|
<Link href="/dashboard/ai-console/settings" className="text-[#555] hover:text-[#1D4ED8] text-xs transition-colors">Settings</Link>
|
||||||
<button onClick={handleNewSession} className="text-xs bg-[#1e2a3a] hover:bg-[#253347] text-[#aaa] px-3 py-1.5 rounded transition-colors">
|
<button onClick={handleNewSession} className="text-xs bg-[#1e2a3a] hover:bg-[#253347] text-[#aaa] px-3 py-1.5 rounded transition-colors">
|
||||||
+ New Session
|
+ New Session
|
||||||
</button>
|
</button>
|
||||||
@@ -1002,7 +1002,7 @@ Respond conversationally and helpfully. If asked to write a story, write the ful
|
|||||||
key={session.id}
|
key={session.id}
|
||||||
className={`group p-2.5 rounded border cursor-pointer transition-colors ${
|
className={`group p-2.5 rounded border cursor-pointer transition-colors ${
|
||||||
currentSessionId === session.id
|
currentSessionId === session.id
|
||||||
? 'border-[#3b82f6]/40 bg-[#1e2a3a]'
|
? 'border-[#1D4ED8]/40 bg-[#1e2a3a]'
|
||||||
: 'border-[#1e2a3a] hover:border-[#2a3a50] hover:bg-[#161f2e]'
|
: 'border-[#1e2a3a] hover:border-[#2a3a50] hover:bg-[#161f2e]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -1046,11 +1046,11 @@ Respond conversationally and helpfully. If asked to write a story, write the ful
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
fetch('/api/ai/console?action=ping').then(r => r.json()).then(setAiStatus).catch(() => {});
|
fetch('/api/ai/console?action=ping').then(r => r.json()).then(setAiStatus).catch(() => {});
|
||||||
}}
|
}}
|
||||||
className="text-[10px] text-[#555] hover:text-[#3b82f6] transition-colors"
|
className="text-[10px] text-[#555] hover:text-[#1D4ED8] transition-colors"
|
||||||
>
|
>
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
<Link href="/dashboard/ai-console/health" className="text-[10px] text-[#555] hover:text-[#3b82f6] transition-colors">
|
<Link href="/dashboard/ai-console/health" className="text-[10px] text-[#555] hover:text-[#1D4ED8] transition-colors">
|
||||||
Health Dashboard →
|
Health Dashboard →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -1066,7 +1066,7 @@ Respond conversationally and helpfully. If asked to write a story, write the ful
|
|||||||
<select
|
<select
|
||||||
value={selectedSite}
|
value={selectedSite}
|
||||||
onChange={e => setSelectedSite(Number(e.target.value) as 1 | 2)}
|
onChange={e => setSelectedSite(Number(e.target.value) as 1 | 2)}
|
||||||
className="bg-[#1e2a3a] border border-[#2a3a50] text-white text-xs rounded px-2 py-1 focus:outline-none focus:border-[#3b82f6]"
|
className="bg-[#1e2a3a] border border-[#2a3a50] text-white text-xs rounded px-2 py-1 focus:outline-none focus:border-[#1D4ED8]"
|
||||||
>
|
>
|
||||||
<option value={1}>AV Beat</option>
|
<option value={1}>AV Beat</option>
|
||||||
<option value={2}>AV Beat</option>
|
<option value={2}>AV Beat</option>
|
||||||
@@ -1077,7 +1077,7 @@ Respond conversationally and helpfully. If asked to write a story, write the ful
|
|||||||
<select
|
<select
|
||||||
value={selectedPenName}
|
value={selectedPenName}
|
||||||
onChange={e => setSelectedPenName(e.target.value)}
|
onChange={e => setSelectedPenName(e.target.value)}
|
||||||
className="bg-[#1e2a3a] border border-[#2a3a50] text-white text-xs rounded px-2 py-1 focus:outline-none focus:border-[#3b82f6]"
|
className="bg-[#1e2a3a] border border-[#2a3a50] text-white text-xs rounded px-2 py-1 focus:outline-none focus:border-[#1D4ED8]"
|
||||||
>
|
>
|
||||||
{penNames.map(name => (
|
{penNames.map(name => (
|
||||||
<option key={name} value={name}>{name}</option>
|
<option key={name} value={name}>{name}</option>
|
||||||
@@ -1086,7 +1086,7 @@ Respond conversationally and helpfully. If asked to write a story, write the ful
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowStyleGuides(true)}
|
onClick={() => setShowStyleGuides(true)}
|
||||||
className="text-[10px] text-[#3b82f6] hover:text-[#60a5fa] transition-colors ml-auto"
|
className="text-[10px] text-[#1D4ED8] hover:text-[#1D4ED8] transition-colors ml-auto"
|
||||||
>
|
>
|
||||||
View style guide →
|
View style guide →
|
||||||
</button>
|
</button>
|
||||||
@@ -1123,7 +1123,7 @@ Respond conversationally and helpfully. If asked to write a story, write the ful
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={`rounded-lg px-4 py-3 ${
|
<div className={`rounded-lg px-4 py-3 ${
|
||||||
msg.role === 'user' ?'bg-[#1e3a5f] text-white text-sm' :'bg-[#111827] border border-[#1e2a3a] text-[#ccc] text-sm'
|
msg.role === 'user' ?'bg-[#DCE6F2] text-white text-sm' :'bg-[#111827] border border-[#1e2a3a] text-[#ccc] text-sm'
|
||||||
}`}>
|
}`}>
|
||||||
{msg.role === 'user' ? (
|
{msg.role === 'user' ? (
|
||||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||||
@@ -1151,7 +1151,7 @@ Respond conversationally and helpfully. If asked to write a story, write the ful
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDraftPreview(true)}
|
onClick={() => setShowDraftPreview(true)}
|
||||||
className="text-[10px] text-[#3b82f6] hover:text-[#60a5fa] bg-[#111827] border border-[#3b82f6]/30 px-2 py-1 rounded transition-colors font-medium"
|
className="text-[10px] text-[#1D4ED8] hover:text-[#1D4ED8] bg-[#111827] border border-[#1D4ED8]/30 px-2 py-1 rounded transition-colors font-medium"
|
||||||
>
|
>
|
||||||
Preview Draft
|
Preview Draft
|
||||||
</button>
|
</button>
|
||||||
@@ -1195,9 +1195,9 @@ Respond conversationally and helpfully. If asked to write a story, write the ful
|
|||||||
<div className="flex justify-start">
|
<div className="flex justify-start">
|
||||||
<div className="bg-[#111827] border border-[#1e2a3a] rounded-lg px-4 py-3">
|
<div className="bg-[#111827] border border-[#1e2a3a] rounded-lg px-4 py-3">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<div className="w-1.5 h-1.5 bg-[#3b82f6] rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
<div className="w-1.5 h-1.5 bg-[#1D4ED8] rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||||
<div className="w-1.5 h-1.5 bg-[#3b82f6] rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
<div className="w-1.5 h-1.5 bg-[#1D4ED8] rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||||
<div className="w-1.5 h-1.5 bg-[#3b82f6] rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
<div className="w-1.5 h-1.5 bg-[#1D4ED8] rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1225,13 +1225,13 @@ Respond conversationally and helpfully. If asked to write a story, write the ful
|
|||||||
onChange={e => setUrlInput(e.target.value)}
|
onChange={e => setUrlInput(e.target.value)}
|
||||||
onKeyDown={e => e.key === 'Enter' && handleFetchUrl()}
|
onKeyDown={e => e.key === 'Enter' && handleFetchUrl()}
|
||||||
placeholder="https://example.com/press-release"
|
placeholder="https://example.com/press-release"
|
||||||
className="flex-1 bg-[#1e2a3a] border border-[#2a3a50] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-[#3b82f6] placeholder-[#444]"
|
className="flex-1 bg-[#1e2a3a] border border-[#2a3a50] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-[#1D4ED8] placeholder-[#444]"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleFetchUrl}
|
onClick={handleFetchUrl}
|
||||||
disabled={fetchingUrl}
|
disabled={fetchingUrl}
|
||||||
className="bg-[#3b82f6] hover:bg-[#2563eb] text-white text-xs px-3 py-2 rounded transition-colors disabled:opacity-50"
|
className="bg-[#1D4ED8] hover:bg-[#1E3A8A] text-white text-xs px-3 py-2 rounded transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{fetchingUrl ? 'Fetching...' : 'Add'}
|
{fetchingUrl ? 'Fetching...' : 'Add'}
|
||||||
</button>
|
</button>
|
||||||
@@ -1244,19 +1244,19 @@ Respond conversationally and helpfully. If asked to write a story, write the ful
|
|||||||
<div className="flex gap-2 mb-2">
|
<div className="flex gap-2 mb-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowUrlInput(!showUrlInput)}
|
onClick={() => setShowUrlInput(!showUrlInput)}
|
||||||
className="text-xs text-[#555] hover:text-[#3b82f6] bg-[#111827] border border-[#1e2a3a] px-2 py-1 rounded transition-colors"
|
className="text-xs text-[#555] hover:text-[#1D4ED8] bg-[#111827] border border-[#1e2a3a] px-2 py-1 rounded transition-colors"
|
||||||
>
|
>
|
||||||
+ Add URL
|
+ Add URL
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setInput(prev => prev + '\n\n[Paste story content here for reference]')}
|
onClick={() => setInput(prev => prev + '\n\n[Paste story content here for reference]')}
|
||||||
className="text-xs text-[#555] hover:text-[#3b82f6] bg-[#111827] border border-[#1e2a3a] px-2 py-1 rounded transition-colors"
|
className="text-xs text-[#555] hover:text-[#1D4ED8] bg-[#111827] border border-[#1e2a3a] px-2 py-1 rounded transition-colors"
|
||||||
>
|
>
|
||||||
+ Add Story
|
+ Add Story
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setInput(prev => prev + `\n\nWrite in the style of ${selectedPenName}.`)}
|
onClick={() => setInput(prev => prev + `\n\nWrite in the style of ${selectedPenName}.`)}
|
||||||
className="text-xs text-[#555] hover:text-[#3b82f6] bg-[#111827] border border-[#1e2a3a] px-2 py-1 rounded transition-colors"
|
className="text-xs text-[#555] hover:text-[#1D4ED8] bg-[#111827] border border-[#1e2a3a] px-2 py-1 rounded transition-colors"
|
||||||
>
|
>
|
||||||
+ Add Style
|
+ Add Style
|
||||||
</button>
|
</button>
|
||||||
@@ -1274,13 +1274,13 @@ Respond conversationally and helpfully. If asked to write a story, write the ful
|
|||||||
}}
|
}}
|
||||||
placeholder="Ask the AI to write a story, generate headlines, translate content..."
|
placeholder="Ask the AI to write a story, generate headlines, translate content..."
|
||||||
rows={3}
|
rows={3}
|
||||||
className="flex-1 bg-[#111827] border border-[#1e2a3a] text-white text-sm rounded-lg px-4 py-3 focus:outline-none focus:border-[#3b82f6] placeholder-[#333] resize-none"
|
className="flex-1 bg-[#111827] border border-[#1e2a3a] text-white text-sm rounded-lg px-4 py-3 focus:outline-none focus:border-[#1D4ED8] placeholder-[#333] resize-none"
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSend()}
|
onClick={() => handleSend()}
|
||||||
disabled={isStreaming || !input.trim()}
|
disabled={isStreaming || !input.trim()}
|
||||||
className="bg-[#3b82f6] hover:bg-[#2563eb] disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm px-4 py-2 rounded-lg transition-colors font-medium flex-1"
|
className="bg-[#1D4ED8] hover:bg-[#1E3A8A] disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm px-4 py-2 rounded-lg transition-colors font-medium flex-1"
|
||||||
>
|
>
|
||||||
{isStreaming ? (
|
{isStreaming ? (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export default function AIConsoleSettingsPage() {
|
|||||||
if (loading || loadingSettings) {
|
if (loading || loadingSettings) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0d1117] flex items-center justify-center">
|
<div className="min-h-screen bg-[#0d1117] flex items-center justify-center">
|
||||||
<div className="w-6 h-6 border-2 border-[#3b82f6]/30 border-t-[#3b82f6] rounded-full animate-spin" />
|
<div className="w-6 h-6 border-2 border-[#1D4ED8]/30 border-t-[#1D4ED8] rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -127,14 +127,14 @@ export default function AIConsoleSettingsPage() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-[#111827] border-b border-[#1e2a3a] px-6 py-4 flex items-center justify-between">
|
<div className="bg-[#111827] border-b border-[#1e2a3a] px-6 py-4 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link href="/dashboard/ai-console" className="text-[#555] hover:text-[#3b82f6] text-sm transition-colors">← AI Console</Link>
|
<Link href="/dashboard/ai-console" className="text-[#555] hover:text-[#1D4ED8] text-sm transition-colors">← AI Console</Link>
|
||||||
<span className="text-[#333]">/</span>
|
<span className="text-[#333]">/</span>
|
||||||
<h1 className="text-white font-bold text-sm">AI Console Settings</h1>
|
<h1 className="text-white font-bold text-sm">AI Console Settings</h1>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="bg-[#3b82f6] hover:bg-[#2563eb] disabled:opacity-50 text-white text-sm px-4 py-2 rounded-lg transition-colors font-medium"
|
className="bg-[#1D4ED8] hover:bg-[#1E3A8A] disabled:opacity-50 text-white text-sm px-4 py-2 rounded-lg transition-colors font-medium"
|
||||||
>
|
>
|
||||||
{saving ? 'Saving...' : 'Save Settings'}
|
{saving ? 'Saving...' : 'Save Settings'}
|
||||||
</button>
|
</button>
|
||||||
@@ -177,7 +177,7 @@ export default function AIConsoleSettingsPage() {
|
|||||||
value={settings.ollama_endpoint}
|
value={settings.ollama_endpoint}
|
||||||
onChange={e => update('ollama_endpoint', e.target.value)}
|
onChange={e => update('ollama_endpoint', e.target.value)}
|
||||||
placeholder="http://192.168.1.x:11434 or https://your-tunnel.ngrok.io"
|
placeholder="http://192.168.1.x:11434 or https://your-tunnel.ngrok.io"
|
||||||
className="w-full bg-[#0d1117] border border-[#2a3a50] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-[#3b82f6] placeholder-[#333]"
|
className="w-full bg-[#0d1117] border border-[#2a3a50] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-[#1D4ED8] placeholder-[#333]"
|
||||||
/>
|
/>
|
||||||
<p className="text-[#444] text-[10px] mt-1">
|
<p className="text-[#444] text-[10px] mt-1">
|
||||||
Use direct IP if on the same network, or a tunnel URL (ngrok, Cloudflare Tunnel, Tailscale) for remote access.
|
Use direct IP if on the same network, or a tunnel URL (ngrok, Cloudflare Tunnel, Tailscale) for remote access.
|
||||||
@@ -212,7 +212,7 @@ export default function AIConsoleSettingsPage() {
|
|||||||
<select
|
<select
|
||||||
value={settings.ollama_model_default}
|
value={settings.ollama_model_default}
|
||||||
onChange={e => update('ollama_model_default', e.target.value)}
|
onChange={e => update('ollama_model_default', e.target.value)}
|
||||||
className="w-full bg-[#0d1117] border border-[#2a3a50] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#0d1117] border border-[#2a3a50] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-[#1D4ED8]"
|
||||||
>
|
>
|
||||||
<option value="">Auto (largest available)</option>
|
<option value="">Auto (largest available)</option>
|
||||||
{connectionResult.models.map(m => <option key={m} value={m}>{m}</option>)}
|
{connectionResult.models.map(m => <option key={m} value={m}>{m}</option>)}
|
||||||
@@ -223,7 +223,7 @@ export default function AIConsoleSettingsPage() {
|
|||||||
<select
|
<select
|
||||||
value={settings.ollama_model_translation}
|
value={settings.ollama_model_translation}
|
||||||
onChange={e => update('ollama_model_translation', e.target.value)}
|
onChange={e => update('ollama_model_translation', e.target.value)}
|
||||||
className="w-full bg-[#0d1117] border border-[#2a3a50] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#0d1117] border border-[#2a3a50] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-[#1D4ED8]"
|
||||||
>
|
>
|
||||||
<option value="">Same as default</option>
|
<option value="">Same as default</option>
|
||||||
{connectionResult.models.map(m => <option key={m} value={m}>{m}</option>)}
|
{connectionResult.models.map(m => <option key={m} value={m}>{m}</option>)}
|
||||||
@@ -234,7 +234,7 @@ export default function AIConsoleSettingsPage() {
|
|||||||
<select
|
<select
|
||||||
value={settings.ollama_model_background}
|
value={settings.ollama_model_background}
|
||||||
onChange={e => update('ollama_model_background', e.target.value)}
|
onChange={e => update('ollama_model_background', e.target.value)}
|
||||||
className="w-full bg-[#0d1117] border border-[#2a3a50] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#0d1117] border border-[#2a3a50] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-[#1D4ED8]"
|
||||||
>
|
>
|
||||||
<option value="">Same as default</option>
|
<option value="">Same as default</option>
|
||||||
{connectionResult.models.map(m => <option key={m} value={m}>{m}</option>)}
|
{connectionResult.models.map(m => <option key={m} value={m}>{m}</option>)}
|
||||||
@@ -250,7 +250,7 @@ export default function AIConsoleSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => update('auto_failover', !settings.auto_failover)}
|
onClick={() => update('auto_failover', !settings.auto_failover)}
|
||||||
className={`w-10 h-5 rounded-full transition-colors relative ${settings.auto_failover ? 'bg-[#3b82f6]' : 'bg-[#2a3a50]'}`}
|
className={`w-10 h-5 rounded-full transition-colors relative ${settings.auto_failover ? 'bg-[#1D4ED8]' : 'bg-[#2a3a50]'}`}
|
||||||
>
|
>
|
||||||
<div className={`w-4 h-4 bg-white rounded-full absolute top-0.5 transition-transform ${settings.auto_failover ? 'translate-x-5' : 'translate-x-0.5'}`} />
|
<div className={`w-4 h-4 bg-white rounded-full absolute top-0.5 transition-transform ${settings.auto_failover ? 'translate-x-5' : 'translate-x-0.5'}`} />
|
||||||
</button>
|
</button>
|
||||||
@@ -263,7 +263,7 @@ export default function AIConsoleSettingsPage() {
|
|||||||
value={settings.failover_timeout_seconds}
|
value={settings.failover_timeout_seconds}
|
||||||
onChange={e => update('failover_timeout_seconds', Number(e.target.value))}
|
onChange={e => update('failover_timeout_seconds', Number(e.target.value))}
|
||||||
min={3} max={30}
|
min={3} max={30}
|
||||||
className="w-32 bg-[#0d1117] border border-[#2a3a50] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-[#3b82f6]"
|
className="w-32 bg-[#0d1117] border border-[#2a3a50] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-[#1D4ED8]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -301,7 +301,7 @@ export default function AIConsoleSettingsPage() {
|
|||||||
<select
|
<select
|
||||||
value={settings.default_site_id}
|
value={settings.default_site_id}
|
||||||
onChange={e => update('default_site_id', Number(e.target.value))}
|
onChange={e => update('default_site_id', Number(e.target.value))}
|
||||||
className="w-full bg-[#0d1117] border border-[#2a3a50] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#0d1117] border border-[#2a3a50] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-[#1D4ED8]"
|
||||||
>
|
>
|
||||||
<option value={1}>AV Beat</option>
|
<option value={1}>AV Beat</option>
|
||||||
<option value={2}>AV Beat</option>
|
<option value={2}>AV Beat</option>
|
||||||
@@ -312,7 +312,7 @@ export default function AIConsoleSettingsPage() {
|
|||||||
<select
|
<select
|
||||||
value={settings.default_pen_name_bb}
|
value={settings.default_pen_name_bb}
|
||||||
onChange={e => update('default_pen_name_bb', e.target.value)}
|
onChange={e => update('default_pen_name_bb', e.target.value)}
|
||||||
className="w-full bg-[#0d1117] border border-[#2a3a50] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#0d1117] border border-[#2a3a50] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-[#1D4ED8]"
|
||||||
>
|
>
|
||||||
{BB_PEN_NAMES.map(n => <option key={n} value={n}>{n}</option>)}
|
{BB_PEN_NAMES.map(n => <option key={n} value={n}>{n}</option>)}
|
||||||
</select>
|
</select>
|
||||||
@@ -322,7 +322,7 @@ export default function AIConsoleSettingsPage() {
|
|||||||
<select
|
<select
|
||||||
value={settings.default_pen_name_av}
|
value={settings.default_pen_name_av}
|
||||||
onChange={e => update('default_pen_name_av', e.target.value)}
|
onChange={e => update('default_pen_name_av', e.target.value)}
|
||||||
className="w-full bg-[#0d1117] border border-[#2a3a50] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#0d1117] border border-[#2a3a50] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-[#1D4ED8]"
|
||||||
>
|
>
|
||||||
{AV_PEN_NAMES.map(n => <option key={n} value={n}>{n}</option>)}
|
{AV_PEN_NAMES.map(n => <option key={n} value={n}>{n}</option>)}
|
||||||
</select>
|
</select>
|
||||||
@@ -335,7 +335,7 @@ export default function AIConsoleSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => update('save_session_history', !settings.save_session_history)}
|
onClick={() => update('save_session_history', !settings.save_session_history)}
|
||||||
className={`w-10 h-5 rounded-full transition-colors relative ${settings.save_session_history ? 'bg-[#3b82f6]' : 'bg-[#2a3a50]'}`}
|
className={`w-10 h-5 rounded-full transition-colors relative ${settings.save_session_history ? 'bg-[#1D4ED8]' : 'bg-[#2a3a50]'}`}
|
||||||
>
|
>
|
||||||
<div className={`w-4 h-4 bg-white rounded-full absolute top-0.5 transition-transform ${settings.save_session_history ? 'translate-x-5' : 'translate-x-0.5'}`} />
|
<div className={`w-4 h-4 bg-white rounded-full absolute top-0.5 transition-transform ${settings.save_session_history ? 'translate-x-5' : 'translate-x-0.5'}`} />
|
||||||
</button>
|
</button>
|
||||||
@@ -346,7 +346,7 @@ export default function AIConsoleSettingsPage() {
|
|||||||
<select
|
<select
|
||||||
value={settings.history_retention_days}
|
value={settings.history_retention_days}
|
||||||
onChange={e => update('history_retention_days', Number(e.target.value))}
|
onChange={e => update('history_retention_days', Number(e.target.value))}
|
||||||
className="w-full bg-[#0d1117] border border-[#2a3a50] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#0d1117] border border-[#2a3a50] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-[#1D4ED8]"
|
||||||
>
|
>
|
||||||
<option value={30}>30 days</option>
|
<option value={30}>30 days</option>
|
||||||
<option value={60}>60 days</option>
|
<option value={60}>60 days</option>
|
||||||
@@ -361,7 +361,7 @@ export default function AIConsoleSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => update('streaming_enabled', !settings.streaming_enabled)}
|
onClick={() => update('streaming_enabled', !settings.streaming_enabled)}
|
||||||
className={`w-10 h-5 rounded-full transition-colors relative ${settings.streaming_enabled ? 'bg-[#3b82f6]' : 'bg-[#2a3a50]'}`}
|
className={`w-10 h-5 rounded-full transition-colors relative ${settings.streaming_enabled ? 'bg-[#1D4ED8]' : 'bg-[#2a3a50]'}`}
|
||||||
>
|
>
|
||||||
<div className={`w-4 h-4 bg-white rounded-full absolute top-0.5 transition-transform ${settings.streaming_enabled ? 'translate-x-5' : 'translate-x-0.5'}`} />
|
<div className={`w-4 h-4 bg-white rounded-full absolute top-0.5 transition-transform ${settings.streaming_enabled ? 'translate-x-5' : 'translate-x-0.5'}`} />
|
||||||
</button>
|
</button>
|
||||||
@@ -380,7 +380,7 @@ export default function AIConsoleSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => update('background_use_local', !settings.background_use_local)}
|
onClick={() => update('background_use_local', !settings.background_use_local)}
|
||||||
className={`w-10 h-5 rounded-full transition-colors relative ${settings.background_use_local ? 'bg-[#3b82f6]' : 'bg-[#2a3a50]'}`}
|
className={`w-10 h-5 rounded-full transition-colors relative ${settings.background_use_local ? 'bg-[#1D4ED8]' : 'bg-[#2a3a50]'}`}
|
||||||
>
|
>
|
||||||
<div className={`w-4 h-4 bg-white rounded-full absolute top-0.5 transition-transform ${settings.background_use_local ? 'translate-x-5' : 'translate-x-0.5'}`} />
|
<div className={`w-4 h-4 bg-white rounded-full absolute top-0.5 transition-transform ${settings.background_use_local ? 'translate-x-5' : 'translate-x-0.5'}`} />
|
||||||
</button>
|
</button>
|
||||||
@@ -393,7 +393,7 @@ export default function AIConsoleSettingsPage() {
|
|||||||
value={settings.background_timeout_seconds}
|
value={settings.background_timeout_seconds}
|
||||||
onChange={e => update('background_timeout_seconds', Number(e.target.value))}
|
onChange={e => update('background_timeout_seconds', Number(e.target.value))}
|
||||||
min={30} max={300}
|
min={30} max={300}
|
||||||
className="w-32 bg-[#0d1117] border border-[#2a3a50] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-[#3b82f6]"
|
className="w-32 bg-[#0d1117] border border-[#2a3a50] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-[#1D4ED8]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -404,7 +404,7 @@ export default function AIConsoleSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => update('priority_queue_enabled', !settings.priority_queue_enabled)}
|
onClick={() => update('priority_queue_enabled', !settings.priority_queue_enabled)}
|
||||||
className={`w-10 h-5 rounded-full transition-colors relative ${settings.priority_queue_enabled ? 'bg-[#3b82f6]' : 'bg-[#2a3a50]'}`}
|
className={`w-10 h-5 rounded-full transition-colors relative ${settings.priority_queue_enabled ? 'bg-[#1D4ED8]' : 'bg-[#2a3a50]'}`}
|
||||||
>
|
>
|
||||||
<div className={`w-4 h-4 bg-white rounded-full absolute top-0.5 transition-transform ${settings.priority_queue_enabled ? 'translate-x-5' : 'translate-x-0.5'}`} />
|
<div className={`w-4 h-4 bg-white rounded-full absolute top-0.5 transition-transform ${settings.priority_queue_enabled ? 'translate-x-5' : 'translate-x-0.5'}`} />
|
||||||
</button>
|
</button>
|
||||||
@@ -422,7 +422,7 @@ export default function AIConsoleSettingsPage() {
|
|||||||
'Classification and extraction tasks',
|
'Classification and extraction tasks',
|
||||||
].map((item, i) => (
|
].map((item, i) => (
|
||||||
<li key={i} className="flex items-center gap-2 text-xs text-[#555]">
|
<li key={i} className="flex items-center gap-2 text-xs text-[#555]">
|
||||||
<span className="w-4 h-4 rounded-full bg-[#1e2a3a] text-[#3b82f6] text-[10px] flex items-center justify-center font-bold flex-shrink-0">{i + 1}</span>
|
<span className="w-4 h-4 rounded-full bg-[#1e2a3a] text-[#1D4ED8] text-[10px] flex items-center justify-center font-bold flex-shrink-0">{i + 1}</span>
|
||||||
{item}
|
{item}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -130,9 +130,9 @@ export default function OverviewTab({ companyId, selectedCompany }: Props) {
|
|||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="revenue"
|
dataKey="revenue"
|
||||||
stroke={selectedCompany === 'rmp' ? '#3b82f6' : '#a855f7'}
|
stroke={selectedCompany === 'rmp' ? '#1D4ED8' : '#a855f7'}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
dot={{ fill: selectedCompany === 'rmp' ? '#3b82f6' : '#a855f7', r: 3 }}
|
dot={{ fill: selectedCompany === 'rmp' ? '#1D4ED8' : '#a855f7', r: 3 }}
|
||||||
/>
|
/>
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
|||||||
@@ -269,13 +269,13 @@ export default function EditorialDashboard() {
|
|||||||
|
|
||||||
function SortIcon({ col }: { col: typeof sortBy }) {
|
function SortIcon({ col }: { col: typeof sortBy }) {
|
||||||
if (sortBy !== col) return <span className="text-[#444] ml-1">↕</span>;
|
if (sortBy !== col) return <span className="text-[#444] ml-1">↕</span>;
|
||||||
return <span className="text-[#3b82f6] ml-1">{sortDir === 'desc' ? '↓' : '↑'}</span>;
|
return <span className="text-[#1D4ED8] ml-1">{sortDir === 'desc' ? '↓' : '↑'}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading || !authChecked) {
|
if (loading || !authChecked) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
||||||
<div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
<div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -285,7 +285,7 @@ export default function EditorialDashboard() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0a0a] text-[#e0e0e0]">
|
<div className="min-h-screen bg-[#0a0a0a] text-[#e0e0e0]">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="border-b border-[#1e1e1e] bg-[#0d0d0d]">
|
<div className="border-b border-[#1e1e1e] bg-[#F8FAFC]">
|
||||||
<div className="max-w-[1400px] mx-auto px-6 py-5 flex items-center justify-between gap-4">
|
<div className="max-w-[1400px] mx-auto px-6 py-5 flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-white tracking-tight">Editorial Dashboard</h1>
|
<h1 className="text-xl font-bold text-white tracking-tight">Editorial Dashboard</h1>
|
||||||
@@ -294,13 +294,13 @@ export default function EditorialDashboard() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link
|
<Link
|
||||||
href="/admin/articles"
|
href="/admin/articles"
|
||||||
className="px-3 py-1.5 text-xs font-semibold bg-[#3b82f6] hover:bg-blue-500 text-white rounded transition-colors"
|
className="px-3 py-1.5 text-xs font-semibold bg-[#1D4ED8] hover:bg-blue-500 text-white rounded transition-colors"
|
||||||
>
|
>
|
||||||
+ New Article
|
+ New Article
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/adops"
|
href="/dashboard/adops"
|
||||||
className="px-3 py-1.5 text-xs font-semibold bg-[#1a1a1a] hover:bg-[#252525] text-[#aaa] border border-[#2a2a2a] rounded transition-colors"
|
className="px-3 py-1.5 text-xs font-semibold bg-[#FFFFFF] hover:bg-[#DCE6F2] text-[#aaa] border border-[#DCE6F2] rounded transition-colors"
|
||||||
>
|
>
|
||||||
Ad Ops →
|
Ad Ops →
|
||||||
</Link>
|
</Link>
|
||||||
@@ -325,7 +325,7 @@ export default function EditorialDashboard() {
|
|||||||
<button
|
<button
|
||||||
key={card.label}
|
key={card.label}
|
||||||
onClick={card.onClick}
|
onClick={card.onClick}
|
||||||
className="bg-[#111] border border-[#1e1e1e] rounded-lg p-4 text-left hover:border-[#2a2a2a] transition-colors cursor-pointer"
|
className="bg-[#111] border border-[#1e1e1e] rounded-lg p-4 text-left hover:border-[#DCE6F2] transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
<div className={`text-xl font-bold ${card.color}`}>{card.value}</div>
|
<div className={`text-xl font-bold ${card.color}`}>{card.value}</div>
|
||||||
<div className="text-[11px] text-[#666] mt-1 uppercase tracking-wider">{card.label}</div>
|
<div className="text-[11px] text-[#666] mt-1 uppercase tracking-wider">{card.label}</div>
|
||||||
@@ -344,7 +344,7 @@ export default function EditorialDashboard() {
|
|||||||
onClick={() => setStatusFilter(s)}
|
onClick={() => setStatusFilter(s)}
|
||||||
className={`px-3 py-1.5 text-xs font-semibold capitalize transition-colors ${
|
className={`px-3 py-1.5 text-xs font-semibold capitalize transition-colors ${
|
||||||
statusFilter === s
|
statusFilter === s
|
||||||
? 'bg-[#3b82f6] text-white'
|
? 'bg-[#1D4ED8] text-white'
|
||||||
: 'text-[#666] hover:text-[#aaa]'
|
: 'text-[#666] hover:text-[#aaa]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -360,7 +360,7 @@ export default function EditorialDashboard() {
|
|||||||
placeholder="Search articles, clients, campaigns..."
|
placeholder="Search articles, clients, campaigns..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={e => setSearchQuery(e.target.value)}
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
className="w-full bg-[#111] border border-[#1e1e1e] rounded-lg px-3 py-1.5 text-xs text-[#ccc] placeholder-[#444] focus:outline-none focus:border-[#3b82f6] transition-colors"
|
className="w-full bg-[#111] border border-[#1e1e1e] rounded-lg px-3 py-1.5 text-xs text-[#ccc] placeholder-[#444] focus:outline-none focus:border-[#1D4ED8] transition-colors"
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
@@ -376,10 +376,10 @@ export default function EditorialDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pipeline Table */}
|
{/* Pipeline Table */}
|
||||||
<div className="bg-[#0d0d0d] border border-[#1e1e1e] rounded-xl overflow-hidden">
|
<div className="bg-[#F8FAFC] border border-[#1e1e1e] rounded-xl overflow-hidden">
|
||||||
{loadingData ? (
|
{loadingData ? (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<div className="w-6 h-6 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
<div className="w-6 h-6 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : filtered.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
<div className="text-center py-20 text-[#444] text-sm">No articles found</div>
|
<div className="text-center py-20 text-[#444] text-sm">No articles found</div>
|
||||||
@@ -387,7 +387,7 @@ export default function EditorialDashboard() {
|
|||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[#1a1a1a] bg-[#0a0a0a]">
|
<tr className="border-b border-[#FFFFFF] bg-[#0a0a0a]">
|
||||||
<th className="text-left px-4 py-3 text-[#555] font-semibold uppercase tracking-wider w-[30%]">Article</th>
|
<th className="text-left px-4 py-3 text-[#555] font-semibold uppercase tracking-wider w-[30%]">Article</th>
|
||||||
<th className="text-left px-3 py-3 text-[#555] font-semibold uppercase tracking-wider">Status</th>
|
<th className="text-left px-3 py-3 text-[#555] font-semibold uppercase tracking-wider">Status</th>
|
||||||
<th
|
<th
|
||||||
@@ -437,7 +437,7 @@ export default function EditorialDashboard() {
|
|||||||
<span className="text-[#555]">{article.author_name}</span>
|
<span className="text-[#555]">{article.author_name}</span>
|
||||||
)}
|
)}
|
||||||
{article.category && (
|
{article.category && (
|
||||||
<span className="text-[#3b82f6]/70 bg-[#3b82f6]/10 px-1.5 py-0.5 rounded text-[10px]">
|
<span className="text-[#1D4ED8]/70 bg-[#1D4ED8]/10 px-1.5 py-0.5 rounded text-[10px]">
|
||||||
{article.category}
|
{article.category}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ export default function ShowCalendarPage() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
||||||
<div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
<div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -281,7 +281,7 @@ export default function ShowCalendarPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="border-b border-[#1a1a1a] bg-[#0f0f0f]">
|
<div className="border-b border-[#FFFFFF] bg-[#0f0f0f]">
|
||||||
<div className="max-w-7xl mx-auto px-4 py-4">
|
<div className="max-w-7xl mx-auto px-4 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -294,13 +294,13 @@ export default function ShowCalendarPage() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => { setEditingEvent({ site_id: 1, year: new Date().getFullYear(), seo_tier: 3, event_type: 'trade_show' }); setShowEventModal(true); }}
|
onClick={() => { setEditingEvent({ site_id: 1, year: new Date().getFullYear(), seo_tier: 3, event_type: 'trade_show' }); setShowEventModal(true); }}
|
||||||
className="px-3 py-1.5 bg-[#3b82f6] hover:bg-blue-500 text-white text-sm rounded-lg transition-colors"
|
className="px-3 py-1.5 bg-[#1D4ED8] hover:bg-blue-500 text-white text-sm rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
+ Add Event
|
+ Add Event
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowGenerateModal(true)}
|
onClick={() => setShowGenerateModal(true)}
|
||||||
className="px-3 py-1.5 bg-[#1a1a1a] hover:bg-[#252525] border border-[#333] text-white text-sm rounded-lg transition-colors"
|
className="px-3 py-1.5 bg-[#FFFFFF] hover:bg-[#DCE6F2] border border-[#333] text-white text-sm rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
Generate Story
|
Generate Story
|
||||||
</button>
|
</button>
|
||||||
@@ -316,8 +316,8 @@ export default function ShowCalendarPage() {
|
|||||||
onClick={() => setSiteFilter(s)}
|
onClick={() => setSiteFilter(s)}
|
||||||
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
|
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
|
||||||
siteFilter === s
|
siteFilter === s
|
||||||
? 'bg-[#3b82f6] text-white'
|
? 'bg-[#1D4ED8] text-white'
|
||||||
: 'bg-[#1a1a1a] text-[#888] hover:text-white border border-[#333]'
|
: 'bg-[#FFFFFF] text-[#888] hover:text-white border border-[#333]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{s === '' ? 'All Sites' : s === '1' ? '🔵 AV Beat' : '🟠 AV Beat'}
|
{s === '' ? 'All Sites' : s === '1' ? '🔵 AV Beat' : '🟠 AV Beat'}
|
||||||
@@ -326,14 +326,14 @@ export default function ShowCalendarPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-1 mt-4 border-b border-[#1a1a1a]">
|
<div className="flex gap-1 mt-4 border-b border-[#FFFFFF]">
|
||||||
{tabs.map(tab => (
|
{tabs.map(tab => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'border-[#3b82f6] text-white'
|
? 'border-[#1D4ED8] text-white'
|
||||||
: 'border-transparent text-[#666] hover:text-white'
|
: 'border-transparent text-[#666] hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -347,7 +347,7 @@ export default function ShowCalendarPage() {
|
|||||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||||
{loadingData ? (
|
{loadingData ? (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
|
<div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -383,7 +383,7 @@ export default function ShowCalendarPage() {
|
|||||||
<span>Engine: {formatDate(event.story_engine_start_date)} – {formatDate(event.story_engine_end_date)}</span>
|
<span>Engine: {formatDate(event.story_engine_start_date)} – {formatDate(event.story_engine_end_date)}</span>
|
||||||
{event.official_site && (
|
{event.official_site && (
|
||||||
<a href={`https://${event.official_site}`} target="_blank" rel="noopener noreferrer"
|
<a href={`https://${event.official_site}`} target="_blank" rel="noopener noreferrer"
|
||||||
className="text-[#3b82f6] hover:underline">{event.official_site}</a>
|
className="text-[#1D4ED8] hover:underline">{event.official_site}</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 mt-2 text-xs text-[#555]">
|
<div className="flex items-center gap-3 mt-2 text-xs text-[#555]">
|
||||||
@@ -400,13 +400,13 @@ export default function ShowCalendarPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleExtractStyle(event.event_name)}
|
onClick={() => handleExtractStyle(event.event_name)}
|
||||||
disabled={actionLoading === 'style-' + event.event_name}
|
disabled={actionLoading === 'style-' + event.event_name}
|
||||||
className="px-2 py-1 bg-[#1a1a1a] hover:bg-[#252525] border border-[#333] text-[#888] hover:text-white text-xs rounded transition-colors disabled:opacity-50"
|
className="px-2 py-1 bg-[#FFFFFF] hover:bg-[#DCE6F2] border border-[#333] text-[#888] hover:text-white text-xs rounded transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{actionLoading === 'style-' + event.event_name ? 'Extracting...' : 'Extract Style'}
|
{actionLoading === 'style-' + event.event_name ? 'Extracting...' : 'Extract Style'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setEditingEvent(event); setShowEventModal(true); }}
|
onClick={() => { setEditingEvent(event); setShowEventModal(true); }}
|
||||||
className="px-2 py-1 bg-[#1a1a1a] hover:bg-[#252525] border border-[#333] text-[#888] hover:text-white text-xs rounded transition-colors"
|
className="px-2 py-1 bg-[#FFFFFF] hover:bg-[#DCE6F2] border border-[#333] text-[#888] hover:text-white text-xs rounded transition-colors"
|
||||||
>
|
>
|
||||||
Edit Dates
|
Edit Dates
|
||||||
</button>
|
</button>
|
||||||
@@ -440,8 +440,8 @@ export default function ShowCalendarPage() {
|
|||||||
onClick={() => setQueueStatus(s)}
|
onClick={() => setQueueStatus(s)}
|
||||||
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
|
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
|
||||||
queueStatus === s
|
queueStatus === s
|
||||||
? 'bg-[#3b82f6] text-white'
|
? 'bg-[#1D4ED8] text-white'
|
||||||
: 'bg-[#1a1a1a] text-[#888] hover:text-white border border-[#333]'
|
: 'bg-[#FFFFFF] text-[#888] hover:text-white border border-[#333]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{s.replace('_', ' ')}
|
{s.replace('_', ' ')}
|
||||||
@@ -460,7 +460,7 @@ export default function ShowCalendarPage() {
|
|||||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
<SiteBadge siteId={item.site_id} />
|
<SiteBadge siteId={item.site_id} />
|
||||||
{item.source_show && (
|
{item.source_show && (
|
||||||
<span className="text-xs text-[#555] bg-[#1a1a1a] px-1.5 py-0.5 rounded">{item.source_show}</span>
|
<span className="text-xs text-[#555] bg-[#FFFFFF] px-1.5 py-0.5 rounded">{item.source_show}</span>
|
||||||
)}
|
)}
|
||||||
{item.show_phase && (
|
{item.show_phase && (
|
||||||
<span className={`text-xs ${PHASE_LABELS[item.show_phase]?.color || 'text-gray-500'}`}>
|
<span className={`text-xs ${PHASE_LABELS[item.show_phase]?.color || 'text-gray-500'}`}>
|
||||||
@@ -470,7 +470,7 @@ export default function ShowCalendarPage() {
|
|||||||
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
||||||
item.status === 'published' ? 'bg-green-900/20 text-green-400' :
|
item.status === 'published' ? 'bg-green-900/20 text-green-400' :
|
||||||
item.status === 'rejected' ? 'bg-red-900/20 text-red-400' :
|
item.status === 'rejected' ? 'bg-red-900/20 text-red-400' :
|
||||||
item.status === 'generated'? 'bg-blue-900/20 text-blue-400' : 'bg-[#1a1a1a] text-[#888]'
|
item.status === 'generated'? 'bg-blue-900/20 text-blue-400' : 'bg-[#FFFFFF] text-[#888]'
|
||||||
}`}>{item.status}</span>
|
}`}>{item.status}</span>
|
||||||
{item.quality_confidence !== null && (
|
{item.quality_confidence !== null && (
|
||||||
<span className={`text-xs ${item.quality_confidence >= 0.85 ? 'text-green-400' : item.quality_confidence >= 0.7 ? 'text-yellow-400' : 'text-red-400'}`}>
|
<span className={`text-xs ${item.quality_confidence >= 0.85 ? 'text-green-400' : item.quality_confidence >= 0.7 ? 'text-yellow-400' : 'text-red-400'}`}>
|
||||||
@@ -490,7 +490,7 @@ export default function ShowCalendarPage() {
|
|||||||
|
|
||||||
{/* Expanded content */}
|
{/* Expanded content */}
|
||||||
{expandedItem === item.id && item.generated_content && (
|
{expandedItem === item.id && item.generated_content && (
|
||||||
<div className="mt-3 p-3 bg-[#0a0a0a] rounded-lg border border-[#1a1a1a]">
|
<div className="mt-3 p-3 bg-[#0a0a0a] rounded-lg border border-[#FFFFFF]">
|
||||||
<div
|
<div
|
||||||
className="text-[#aaa] text-xs prose prose-invert max-w-none"
|
className="text-[#aaa] text-xs prose prose-invert max-w-none"
|
||||||
dangerouslySetInnerHTML={{ __html: item.generated_content.slice(0, 1500) + (item.generated_content.length > 1500 ? '...' : '') }}
|
dangerouslySetInnerHTML={{ __html: item.generated_content.slice(0, 1500) + (item.generated_content.length > 1500 ? '...' : '') }}
|
||||||
@@ -501,7 +501,7 @@ export default function ShowCalendarPage() {
|
|||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => setExpandedItem(expandedItem === item.id ? null : item.id)}
|
onClick={() => setExpandedItem(expandedItem === item.id ? null : item.id)}
|
||||||
className="px-2 py-1 bg-[#1a1a1a] hover:bg-[#252525] border border-[#333] text-[#888] hover:text-white text-xs rounded transition-colors"
|
className="px-2 py-1 bg-[#FFFFFF] hover:bg-[#DCE6F2] border border-[#333] text-[#888] hover:text-white text-xs rounded transition-colors"
|
||||||
>
|
>
|
||||||
{expandedItem === item.id ? 'Hide' : 'Preview'}
|
{expandedItem === item.id ? 'Hide' : 'Preview'}
|
||||||
</button>
|
</button>
|
||||||
@@ -541,7 +541,7 @@ export default function ShowCalendarPage() {
|
|||||||
<select
|
<select
|
||||||
value={showFilter}
|
value={showFilter}
|
||||||
onChange={e => setShowFilter(e.target.value)}
|
onChange={e => setShowFilter(e.target.value)}
|
||||||
className="bg-[#1a1a1a] border border-[#333] text-white text-xs rounded px-2 py-1"
|
className="bg-[#FFFFFF] border border-[#333] text-white text-xs rounded px-2 py-1"
|
||||||
>
|
>
|
||||||
<option value="">All Shows</option>
|
<option value="">All Shows</option>
|
||||||
{events.map(e => <option key={e.id} value={e.id}>{e.event_name}</option>)}
|
{events.map(e => <option key={e.id} value={e.id}>{e.event_name}</option>)}
|
||||||
@@ -564,13 +564,13 @@ export default function ShowCalendarPage() {
|
|||||||
<tr><td colSpan={5} className="text-center py-8 text-[#555]">No exhibitors found.</td></tr>
|
<tr><td colSpan={5} className="text-center py-8 text-[#555]">No exhibitors found.</td></tr>
|
||||||
)}
|
)}
|
||||||
{exhibitors.map(ex => (
|
{exhibitors.map(ex => (
|
||||||
<tr key={ex.id} className="border-b border-[#1a1a1a] hover:bg-[#0f0f0f]">
|
<tr key={ex.id} className="border-b border-[#FFFFFF] hover:bg-[#0f0f0f]">
|
||||||
<td className="px-4 py-3 text-white font-medium">{ex.company_name}</td>
|
<td className="px-4 py-3 text-white font-medium">{ex.company_name}</td>
|
||||||
<td className="px-4 py-3 text-[#888]">{ex.booth_number || '—'}</td>
|
<td className="px-4 py-3 text-[#888]">{ex.booth_number || '—'}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
||||||
ex.preview_story_status === 'published' ? 'bg-green-900/20 text-green-400' :
|
ex.preview_story_status === 'published' ? 'bg-green-900/20 text-green-400' :
|
||||||
ex.preview_story_status === 'generated'? 'bg-blue-900/20 text-blue-400' : 'bg-[#1a1a1a] text-[#888]'
|
ex.preview_story_status === 'generated'? 'bg-blue-900/20 text-blue-400' : 'bg-[#FFFFFF] text-[#888]'
|
||||||
}`}>{ex.preview_story_status}</span>
|
}`}>{ex.preview_story_status}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
@@ -613,14 +613,14 @@ export default function ShowCalendarPage() {
|
|||||||
Updated: {formatDate(profile.last_updated)}
|
Updated: {formatDate(profile.last_updated)}
|
||||||
</p>
|
</p>
|
||||||
{expandedItem === profile.id && profile.style_guide_text && (
|
{expandedItem === profile.id && profile.style_guide_text && (
|
||||||
<div className="mt-3 p-3 bg-[#0a0a0a] rounded-lg border border-[#1a1a1a]">
|
<div className="mt-3 p-3 bg-[#0a0a0a] rounded-lg border border-[#FFFFFF]">
|
||||||
<p className="text-[#aaa] text-xs whitespace-pre-wrap">{profile.style_guide_text}</p>
|
<p className="text-[#aaa] text-xs whitespace-pre-wrap">{profile.style_guide_text}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setExpandedItem(expandedItem === profile.id ? null : profile.id)}
|
onClick={() => setExpandedItem(expandedItem === profile.id ? null : profile.id)}
|
||||||
className="px-2 py-1 bg-[#1a1a1a] hover:bg-[#252525] border border-[#333] text-[#888] hover:text-white text-xs rounded transition-colors flex-shrink-0"
|
className="px-2 py-1 bg-[#FFFFFF] hover:bg-[#DCE6F2] border border-[#333] text-[#888] hover:text-white text-xs rounded transition-colors flex-shrink-0"
|
||||||
>
|
>
|
||||||
{expandedItem === profile.id ? 'Hide' : 'View Guide'}
|
{expandedItem === profile.id ? 'Hide' : 'View Guide'}
|
||||||
</button>
|
</button>
|
||||||
@@ -637,24 +637,24 @@ export default function ShowCalendarPage() {
|
|||||||
<div className="bg-[#111] border border-[#1e1e1e] rounded-xl p-6">
|
<div className="bg-[#111] border border-[#1e1e1e] rounded-xl p-6">
|
||||||
<h2 className="text-white font-semibold mb-4">Show Engine Settings</h2>
|
<h2 className="text-white font-semibold mb-4">Show Engine Settings</h2>
|
||||||
<div className="space-y-4 text-sm text-[#888]">
|
<div className="space-y-4 text-sm text-[#888]">
|
||||||
<div className="p-3 bg-[#0a0a0a] rounded-lg border border-[#1a1a1a]">
|
<div className="p-3 bg-[#0a0a0a] rounded-lg border border-[#FFFFFF]">
|
||||||
<p className="text-white font-medium mb-1">Annual Date Discovery</p>
|
<p className="text-white font-medium mb-1">Annual Date Discovery</p>
|
||||||
<p>Runs automatically on January 1 each year. Searches official show websites and Google for confirmed dates. Sends admin notification when dates are set or when manual review is needed.</p>
|
<p>Runs automatically on January 1 each year. Searches official show websites and Google for confirmed dates. Sends admin notification when dates are set or when manual review is needed.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-[#0a0a0a] rounded-lg border border-[#1a1a1a]">
|
<div className="p-3 bg-[#0a0a0a] rounded-lg border border-[#FFFFFF]">
|
||||||
<p className="text-white font-medium mb-1">Pen Name Rosters</p>
|
<p className="text-white font-medium mb-1">Pen Name Rosters</p>
|
||||||
<p className="mb-2"><strong className="text-[#aaa]">AV Beat (12 writers):</strong> Michael Strand, David Harlow, Karen Fielding, James Mercer (primary NAB), Peter Calloway, Sandra Voss, Brian Kowalski, Laura Pennington, Thomas Reeves, Christine Vale, Marcus Webb, Ellen Forsythe</p>
|
<p className="mb-2"><strong className="text-[#aaa]">AV Beat (12 writers):</strong> Michael Strand, David Harlow, Karen Fielding, James Mercer (primary NAB), Peter Calloway, Sandra Voss, Brian Kowalski, Laura Pennington, Thomas Reeves, Christine Vale, Marcus Webb, Ellen Forsythe</p>
|
||||||
<p><strong className="text-[#aaa]">AV Beat (7 writers):</strong> Rex Chandler, Dana Flux, Derek Wainwright, Sloane Rigging, Chip Crosspoint, Blair Presenter, Jordan Lumen</p>
|
<p><strong className="text-[#aaa]">AV Beat (7 writers):</strong> Rex Chandler, Dana Flux, Derek Wainwright, Sloane Rigging, Chip Crosspoint, Blair Presenter, Jordan Lumen</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-[#0a0a0a] rounded-lg border border-[#1a1a1a]">
|
<div className="p-3 bg-[#0a0a0a] rounded-lg border border-[#FFFFFF]">
|
||||||
<p className="text-white font-medium mb-1">SEO Tier Priority</p>
|
<p className="text-white font-medium mb-1">SEO Tier Priority</p>
|
||||||
<p>Tier 1 (Max): NAB Las Vegas, IBC, InfoComm, ISE · Tier 2 (High): NAB NY, BroadcastAsia, CABSAT, AES, InfoComm EDGE/China/LatAm · Tier 3 (Standard): MPTS, SET Expo, NATPE, InfoComm India, FORTÉ LIVE · Tier 4 (Light): NewsTECHForum, TAB, SVVS, Hamburg Open, AVIXA Regional</p>
|
<p>Tier 1 (Max): NAB Las Vegas, IBC, InfoComm, ISE · Tier 2 (High): NAB NY, BroadcastAsia, CABSAT, AES, InfoComm EDGE/China/LatAm · Tier 3 (Standard): MPTS, SET Expo, NATPE, InfoComm India, FORTÉ LIVE · Tier 4 (Light): NewsTECHForum, TAB, SVVS, Hamburg Open, AVIXA Regional</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-[#0a0a0a] rounded-lg border border-[#1a1a1a]">
|
<div className="p-3 bg-[#0a0a0a] rounded-lg border border-[#FFFFFF]">
|
||||||
<p className="text-white font-medium mb-1">NAB Cross-Post to AV Beat</p>
|
<p className="text-white font-medium mb-1">NAB Cross-Post to AV Beat</p>
|
||||||
<p>NAB exhibitors with AV-eligible products (projectors, displays, conferencing, control systems, digital signage, installed audio, AV-over-IP) automatically generate stories on AV Beat using AV Beat pen names.</p>
|
<p>NAB exhibitors with AV-eligible products (projectors, displays, conferencing, control systems, digital signage, installed audio, AV-over-IP) automatically generate stories on AV Beat using AV Beat pen names.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-[#0a0a0a] rounded-lg border border-[#1a1a1a]">
|
<div className="p-3 bg-[#0a0a0a] rounded-lg border border-[#FFFFFF]">
|
||||||
<p className="text-white font-medium mb-1">Blocked Competitor Sources</p>
|
<p className="text-white font-medium mb-1">Blocked Competitor Sources</p>
|
||||||
<p>BB: tvtechnology.com, broadcastingcable.com, sportsvideo.org, digitaltveurope.com, nexttv.com · AV: ravepubs.com, avnetwork.com, commercialintegrator.com, systemscontractor.com, avinteractive.com · Note: infocommshow.org, iseurope.org, nabshow.com, avixa.org are PRIMARY sources — not blocked.</p>
|
<p>BB: tvtechnology.com, broadcastingcable.com, sportsvideo.org, digitaltveurope.com, nexttv.com · AV: ravepubs.com, avnetwork.com, commercialintegrator.com, systemscontractor.com, avinteractive.com · Note: infocommshow.org, iseurope.org, nabshow.com, avixa.org are PRIMARY sources — not blocked.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -779,14 +779,14 @@ export default function ShowCalendarPage() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={actionLoading === 'save-event'}
|
disabled={actionLoading === 'save-event'}
|
||||||
className="flex-1 py-2 bg-[#3b82f6] hover:bg-blue-500 text-white text-sm rounded-lg transition-colors disabled:opacity-50"
|
className="flex-1 py-2 bg-[#1D4ED8] hover:bg-blue-500 text-white text-sm rounded-lg transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{actionLoading === 'save-event' ? 'Saving...' : 'Save Event'}
|
{actionLoading === 'save-event' ? 'Saving...' : 'Save Event'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setShowEventModal(false); setEditingEvent(null); }}
|
onClick={() => { setShowEventModal(false); setEditingEvent(null); }}
|
||||||
className="px-4 py-2 bg-[#1a1a1a] hover:bg-[#252525] border border-[#333] text-white text-sm rounded-lg transition-colors"
|
className="px-4 py-2 bg-[#FFFFFF] hover:bg-[#DCE6F2] border border-[#333] text-white text-sm rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -857,14 +857,14 @@ export default function ShowCalendarPage() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={actionLoading === 'generate'}
|
disabled={actionLoading === 'generate'}
|
||||||
className="flex-1 py-2 bg-[#3b82f6] hover:bg-blue-500 text-white text-sm rounded-lg transition-colors disabled:opacity-50"
|
className="flex-1 py-2 bg-[#1D4ED8] hover:bg-blue-500 text-white text-sm rounded-lg transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{actionLoading === 'generate' ? 'Generating...' : 'Generate Story'}
|
{actionLoading === 'generate' ? 'Generating...' : 'Generate Story'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowGenerateModal(false)}
|
onClick={() => setShowGenerateModal(false)}
|
||||||
className="px-4 py-2 bg-[#1a1a1a] hover:bg-[#252525] border border-[#333] text-white text-sm rounded-lg transition-colors"
|
className="px-4 py-2 bg-[#FFFFFF] hover:bg-[#DCE6F2] border border-[#333] text-white text-sm rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -13,25 +13,25 @@ export default function Error({
|
|||||||
console.error("[route error]", error);
|
console.error("[route error]", error);
|
||||||
}, [error]);
|
}, [error]);
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-[#0d0d0d] text-[#e0e0e0] p-6">
|
<div className="min-h-screen flex items-center justify-center bg-[#F8FAFC] text-[#0F172A] p-6">
|
||||||
<div className="max-w-md text-center">
|
<div className="max-w-md text-center">
|
||||||
<h2 className="font-heading text-2xl mb-3">Something went wrong</h2>
|
<h2 className="font-heading text-2xl mb-3">Something went wrong</h2>
|
||||||
<p className="text-[#888] text-sm mb-4 font-body">
|
<p className="text-[#475569] text-sm mb-4 font-body">
|
||||||
We hit an error rendering this page. Try again, or head back to the home page.
|
We hit an error rendering this page. Try again, or head back to the home page.
|
||||||
</p>
|
</p>
|
||||||
{error?.digest && (
|
{error?.digest && (
|
||||||
<p className="text-[#444] text-xs font-mono mb-4">ref: {error.digest}</p>
|
<p className="text-[#666] text-xs font-mono mb-4">ref: {error.digest}</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-3 justify-center">
|
<div className="flex gap-3 justify-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => reset()}
|
onClick={() => reset()}
|
||||||
className="px-4 py-2 bg-[#3b82f6] text-white text-sm rounded-sm hover:bg-[#2563eb] transition-colors"
|
className="px-4 py-2 bg-[#1D4ED8] text-white text-sm rounded-sm hover:bg-[#1E3A8A] transition-colors"
|
||||||
>
|
>
|
||||||
Try again
|
Try again
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
href="/home-page"
|
href="/home-page"
|
||||||
className="px-4 py-2 border border-[#333] text-sm rounded-sm hover:border-[#3b82f6] transition-colors"
|
className="px-4 py-2 border border-[#DCE6F2] text-[#0F172A] text-sm rounded-sm hover:border-[#1D4ED8] transition-colors"
|
||||||
>
|
>
|
||||||
Home
|
Home
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -74,27 +74,27 @@ function TopThreadsWidget({ categoryId }: { categoryId: string }) {
|
|||||||
|
|
||||||
const getMetricValue = (thread: ForumThread) => {
|
const getMetricValue = (thread: ForumThread) => {
|
||||||
if (activeMetric === 'upvotes') return { value: thread.upvotes ?? 0, label: 'votes', color: 'text-[#22c55e]' };
|
if (activeMetric === 'upvotes') return { value: thread.upvotes ?? 0, label: 'votes', color: 'text-[#22c55e]' };
|
||||||
if (activeMetric === 'replies') return { value: thread.reply_count, label: 'replies', color: 'text-[#3b82f6]' };
|
if (activeMetric === 'replies') return { value: thread.reply_count, label: 'replies', color: 'text-[#1D4ED8]' };
|
||||||
return { value: thread.view_count, label: 'views', color: 'text-[#f59e0b]' };
|
return { value: thread.view_count, label: 'views', color: 'text-[#f59e0b]' };
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[#1a1a1a] border border-[#252525] rounded-xl overflow-hidden mb-6">
|
<div className="bg-[#FFFFFF] border border-[#DCE6F2] rounded-xl overflow-hidden mb-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-[#252525]">
|
<div className="flex items-center justify-between px-5 py-4 border-b border-[#DCE6F2]">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-lg">🏆</span>
|
<span className="text-lg">🏆</span>
|
||||||
<h2 className="text-white font-heading font-bold text-base">Top Performing Threads</h2>
|
<h2 className="text-[#0F172A] font-heading font-bold text-base">Top Performing Threads</h2>
|
||||||
</div>
|
</div>
|
||||||
{/* Metric Tabs */}
|
{/* Metric Tabs */}
|
||||||
<div className="flex gap-1 bg-[#111] border border-[#2a2a2a] rounded-lg p-1">
|
<div className="flex gap-1 bg-[#111] border border-[#DCE6F2] rounded-lg p-1">
|
||||||
{metrics.map(m => (
|
{metrics.map(m => (
|
||||||
<button
|
<button
|
||||||
key={m.value}
|
key={m.value}
|
||||||
onClick={() => setActiveMetric(m.value)}
|
onClick={() => setActiveMetric(m.value)}
|
||||||
className={`px-3 py-1 rounded-md text-xs font-body font-semibold transition-colors whitespace-nowrap ${
|
className={`px-3 py-1 rounded-md text-xs font-body font-semibold transition-colors whitespace-nowrap ${
|
||||||
activeMetric === m.value
|
activeMetric === m.value
|
||||||
? 'bg-[#3b82f6] text-white'
|
? 'bg-[#1D4ED8] text-white'
|
||||||
: 'text-[#666] hover:text-[#aaa]'
|
: 'text-[#666] hover:text-[#aaa]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -111,12 +111,12 @@ function TopThreadsWidget({ categoryId }: { categoryId: string }) {
|
|||||||
<>
|
<>
|
||||||
{[...Array(5)].map((_, i) => (
|
{[...Array(5)].map((_, i) => (
|
||||||
<div key={i} className="flex items-center gap-3 px-5 py-3">
|
<div key={i} className="flex items-center gap-3 px-5 py-3">
|
||||||
<div className="w-6 h-6 bg-[#252525] rounded animate-pulse flex-shrink-0" />
|
<div className="w-6 h-6 bg-[#DCE6F2] rounded animate-pulse flex-shrink-0" />
|
||||||
<div className="flex-1 space-y-1.5">
|
<div className="flex-1 space-y-1.5">
|
||||||
<div className="h-3.5 bg-[#252525] rounded animate-pulse w-3/4" />
|
<div className="h-3.5 bg-[#DCE6F2] rounded animate-pulse w-3/4" />
|
||||||
<div className="h-2.5 bg-[#1e1e1e] rounded animate-pulse w-1/3" />
|
<div className="h-2.5 bg-[#1e1e1e] rounded animate-pulse w-1/3" />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-10 h-8 bg-[#252525] rounded animate-pulse flex-shrink-0" />
|
<div className="w-10 h-8 bg-[#DCE6F2] rounded animate-pulse flex-shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
@@ -132,7 +132,7 @@ function TopThreadsWidget({ categoryId }: { categoryId: string }) {
|
|||||||
<Link
|
<Link
|
||||||
key={thread.id}
|
key={thread.id}
|
||||||
href={`/forum/thread/${thread.id}`}
|
href={`/forum/thread/${thread.id}`}
|
||||||
className="flex items-center gap-3 px-5 py-3 hover:bg-[#1e2a3a] transition-colors group"
|
className="flex items-center gap-3 px-5 py-3 hover:bg-[#e2e8f0] transition-colors group"
|
||||||
>
|
>
|
||||||
{/* Rank */}
|
{/* Rank */}
|
||||||
<span className={`flex-shrink-0 w-6 text-center font-heading font-bold text-sm ${
|
<span className={`flex-shrink-0 w-6 text-center font-heading font-bold text-sm ${
|
||||||
@@ -142,11 +142,11 @@ function TopThreadsWidget({ categoryId }: { categoryId: string }) {
|
|||||||
</span>
|
</span>
|
||||||
{/* Thread info */}
|
{/* Thread info */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-white font-body text-sm font-semibold group-hover:text-[#3b82f6] transition-colors truncate leading-snug">
|
<p className="text-[#0F172A] font-body text-sm font-semibold group-hover:text-[#1D4ED8] transition-colors truncate leading-snug">
|
||||||
{thread.title}
|
{thread.title}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[#555] font-body text-xs mt-0.5">
|
<p className="text-[#555] font-body text-xs mt-0.5">
|
||||||
<Link href={`/forum/user/${encodeURIComponent(thread.author_name)}`} className="text-[#666] hover:text-[#3b82f6] hover:underline" onClick={(e) => e.stopPropagation()}>{thread.author_name}</Link>
|
<Link href={`/forum/user/${encodeURIComponent(thread.author_name)}`} className="text-[#666] hover:text-[#1D4ED8] hover:underline" onClick={(e) => e.stopPropagation()}>{thread.author_name}</Link>
|
||||||
{' · '}{timeAgo(thread.created_at)}
|
{' · '}{timeAgo(thread.created_at)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -277,36 +277,36 @@ export default function ForumCategoryPage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
<main className="min-h-screen bg-[#111111]">
|
<main className="min-h-screen bg-[#F8FAFC]">
|
||||||
{/* Breadcrumb + Header */}
|
{/* Breadcrumb + Header */}
|
||||||
<div className="bg-[#1a2535] border-b border-[#2a3a50]">
|
<div className="bg-[#FFFFFF] border-b border-[#2a3a50]">
|
||||||
<div className="max-w-container mx-auto px-4 py-8">
|
<div className="max-w-container mx-auto px-4 py-8">
|
||||||
<nav className="text-sm font-body text-[#666] mb-3">
|
<nav className="text-sm font-body text-[#666] mb-3">
|
||||||
<Link href="/forum" className="hover:text-[#3b82f6] transition-colors">Forum</Link>
|
<Link href="/forum" className="hover:text-[#1D4ED8] transition-colors">Forum</Link>
|
||||||
<span className="mx-2">›</span>
|
<span className="mx-2">›</span>
|
||||||
<span className="text-[#aab4c4]">{category?.name || slug}</span>
|
<span className="text-[#0F172A]">{category?.name || slug}</span>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-2xl">{category?.icon || '💬'}</span>
|
<span className="text-2xl">{category?.icon || '💬'}</span>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-heading font-bold text-white">{category?.name || 'Loading...'}</h1>
|
<h1 className="text-2xl font-heading font-bold text-[#0F172A]">{category?.name || 'Loading...'}</h1>
|
||||||
{category && (
|
{category && (
|
||||||
<p className="text-[#aab4c4] font-body text-sm mt-0.5">{category.thread_count} threads</p>
|
<p className="text-[#475569] font-body text-sm mt-0.5">{category.thread_count} threads</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowNewThread(!showNewThread)}
|
onClick={() => setShowNewThread(!showNewThread)}
|
||||||
className="flex-shrink-0 bg-[#3b82f6] hover:bg-[#2563eb] text-white font-body font-semibold text-sm px-4 py-2 rounded-lg transition-colors"
|
className="brand-gradient flex-shrink-0 font-body font-semibold text-sm px-4 py-2 rounded-full"
|
||||||
>
|
>
|
||||||
+ New Thread
|
+ New Thread
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
href={`/forum/login?next=${typeof window === 'undefined' ? '/forum' : encodeURIComponent(window.location.pathname)}`}
|
href={`/forum/login?next=${typeof window === 'undefined' ? '/forum' : encodeURIComponent(window.location.pathname)}`}
|
||||||
className="flex-shrink-0 bg-[#3b82f6] hover:bg-[#2563eb] text-white font-body font-semibold text-sm px-4 py-2 rounded-lg transition-colors"
|
className="brand-gradient flex-shrink-0 font-body font-semibold text-sm px-4 py-2 rounded-full"
|
||||||
>
|
>
|
||||||
Sign in to post
|
Sign in to post
|
||||||
</Link>
|
</Link>
|
||||||
@@ -318,8 +318,8 @@ export default function ForumCategoryPage() {
|
|||||||
<div className="max-w-container mx-auto px-4 py-6">
|
<div className="max-w-container mx-auto px-4 py-6">
|
||||||
{/* New Thread Form — auth-gated */}
|
{/* New Thread Form — auth-gated */}
|
||||||
{showNewThread && isAuthenticated && (
|
{showNewThread && isAuthenticated && (
|
||||||
<form onSubmit={handleNewThread} className="bg-[#1a2535] border border-[#2a3a50] rounded-lg p-5 mb-6">
|
<form onSubmit={handleNewThread} className="bg-[#FFFFFF] border border-[#DCE6F2] rounded-lg p-5 mb-6">
|
||||||
<h3 className="text-white font-heading font-semibold mb-4">Start a New Thread</h3>
|
<h3 className="text-[#0F172A] font-heading font-semibold mb-4">Start a New Thread</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -327,7 +327,7 @@ export default function ForumCategoryPage() {
|
|||||||
value={newTitle}
|
value={newTitle}
|
||||||
onChange={e => setNewTitle(e.target.value)}
|
onChange={e => setNewTitle(e.target.value)}
|
||||||
required
|
required
|
||||||
className="w-full bg-[#111] border border-[#333] rounded-lg px-3 py-2 text-white font-body text-sm placeholder-[#555] focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#F8FAFC] border border-[#DCE6F2] rounded-lg px-3 py-2 text-[#0F172A] font-body text-sm placeholder-[#888] focus:outline-none focus:border-[#1D4ED8]"
|
||||||
/>
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
placeholder="What would you like to discuss? *"
|
placeholder="What would you like to discuss? *"
|
||||||
@@ -335,14 +335,14 @@ export default function ForumCategoryPage() {
|
|||||||
onChange={e => setNewBody(e.target.value)}
|
onChange={e => setNewBody(e.target.value)}
|
||||||
required
|
required
|
||||||
rows={5}
|
rows={5}
|
||||||
className="w-full bg-[#111] border border-[#333] rounded-lg px-3 py-2 text-white font-body text-sm placeholder-[#555] focus:outline-none focus:border-[#3b82f6] resize-none"
|
className="w-full bg-[#F8FAFC] border border-[#DCE6F2] rounded-lg px-3 py-2 text-[#0F172A] font-body text-sm placeholder-[#888] focus:outline-none focus:border-[#1D4ED8] resize-none"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<ImageAttachButton onInsert={(md) => setNewBody((b) => (b || "") + md)} />
|
<ImageAttachButton onInsert={(md) => setNewBody((b) => (b || "") + md)} />
|
||||||
<span className="text-[10px] text-[#666]">Auto-optimized, never > 20 MB</span>
|
<span className="text-[10px] text-[#666]">Auto-optimized, never > 20 MB</span>
|
||||||
</div>
|
</div>
|
||||||
{newError && (
|
{newError && (
|
||||||
<div className="bg-[#2a0a0a] border border-[#cc0000] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
|
<div className="bg-[#2a0a0a] border border-[#1D4ED8] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
|
||||||
{newError}
|
{newError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -350,14 +350,14 @@ export default function ForumCategoryPage() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="bg-[#3b82f6] hover:bg-[#2563eb] disabled:opacity-50 text-white font-body font-semibold text-sm px-5 py-2 rounded-lg transition-colors"
|
className="bg-[#1D4ED8] hover:bg-[#1E3A8A] disabled:opacity-50 text-white font-body font-semibold text-sm px-5 py-2 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
{submitting ? 'Posting...' : 'Post Thread'}
|
{submitting ? 'Posting...' : 'Post Thread'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowNewThread(false)}
|
onClick={() => setShowNewThread(false)}
|
||||||
className="bg-[#252525] hover:bg-[#333] text-[#aaa] font-body text-sm px-5 py-2 rounded-lg transition-colors"
|
className="bg-[#DCE6F2] hover:bg-[#cbd5e1] text-[#0F172A] font-body text-sm px-5 py-2 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -378,7 +378,7 @@ export default function ForumCategoryPage() {
|
|||||||
placeholder="Search threads in this category..."
|
placeholder="Search threads in this category..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => setSearch(e.target.value)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
className="w-full bg-[#1a1a1a] border border-[#2a2a2a] focus:border-[#3b82f6] rounded-lg pl-9 pr-4 py-2.5 text-white font-body text-sm placeholder-[#555] focus:outline-none transition-colors"
|
className="w-full bg-[#FFFFFF] border border-[#DCE6F2] focus:border-[#1D4ED8] rounded-lg pl-9 pr-4 py-2.5 text-[#0F172A] font-body text-sm placeholder-[#888] focus:outline-none transition-colors"
|
||||||
/>
|
/>
|
||||||
{search && (
|
{search && (
|
||||||
<button
|
<button
|
||||||
@@ -393,16 +393,16 @@ export default function ForumCategoryPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sort Options */}
|
{/* Sort Options — segmented pill control; active uses brand-gradient */}
|
||||||
<div className="flex gap-1 bg-[#1a1a1a] border border-[#2a2a2a] rounded-lg p-1 flex-shrink-0">
|
<div className="flex gap-1 bg-[#FFFFFF] border border-[#DCE6F2] rounded-full p-1 flex-shrink-0">
|
||||||
{sortOptions.map(opt => (
|
{sortOptions.map(opt => (
|
||||||
<button
|
<button
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
onClick={() => setSort(opt.value)}
|
onClick={() => setSort(opt.value)}
|
||||||
className={`px-3 py-1.5 rounded-md text-xs font-body font-semibold transition-colors whitespace-nowrap ${
|
className={`px-3 py-1 rounded-full text-xs font-body font-semibold transition-colors whitespace-nowrap ${
|
||||||
sort === opt.value
|
sort === opt.value
|
||||||
? 'bg-[#3b82f6] text-white'
|
? 'brand-gradient'
|
||||||
: 'text-[#888] hover:text-white'
|
: 'text-[#475569] hover:text-[#0F172A]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
@@ -414,7 +414,7 @@ export default function ForumCategoryPage() {
|
|||||||
{(loading || threadsLoading) && (
|
{(loading || threadsLoading) && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{[...Array(10)].map((_, i) => (
|
{[...Array(10)].map((_, i) => (
|
||||||
<div key={i} className="bg-[#1a1a1a] rounded-lg h-16 animate-pulse border border-[#252525]" />
|
<div key={i} className="bg-[#FFFFFF] rounded-lg h-16 animate-pulse border border-[#DCE6F2]" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -426,8 +426,8 @@ export default function ForumCategoryPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !threadsLoading && !error && threads.length === 0 && (
|
{!loading && !threadsLoading && !error && threads.length === 0 && (
|
||||||
<div className="text-center py-16 text-[#555] font-body">
|
<div className="text-center py-16 text-[#475569] font-body">
|
||||||
<p className="text-lg mb-2">
|
<p className="text-lg mb-2 text-[#0F172A] font-semibold">
|
||||||
{search ? 'No threads match your search' : sort === 'unanswered' ? 'No unanswered threads' : 'No threads yet'}
|
{search ? 'No threads match your search' : sort === 'unanswered' ? 'No unanswered threads' : 'No threads yet'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
@@ -445,12 +445,12 @@ export default function ForumCategoryPage() {
|
|||||||
<Link
|
<Link
|
||||||
key={thread.id}
|
key={thread.id}
|
||||||
href={`/forum/thread/${thread.id}`}
|
href={`/forum/thread/${thread.id}`}
|
||||||
className="flex items-center gap-4 bg-[#1a1a1a] hover:bg-[#1e2a3a] border border-[#252525] hover:border-[#3b82f6] rounded-lg px-4 py-3 transition-all group"
|
className="flex items-center gap-4 bg-[#FFFFFF] hover:bg-[#e2e8f0] border border-[#DCE6F2] hover:border-[#1D4ED8] rounded-lg px-4 py-3 transition-all group"
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{thread.status === 'pinned' && (
|
{thread.status === 'pinned' && (
|
||||||
<span className="text-xs bg-[#3b82f6]/20 text-[#3b82f6] px-2 py-0.5 rounded font-body font-semibold">PINNED</span>
|
<span className="text-xs bg-[#1D4ED8]/20 text-[#1D4ED8] px-2 py-0.5 rounded font-body font-semibold">PINNED</span>
|
||||||
)}
|
)}
|
||||||
{thread.status === 'closed' && (
|
{thread.status === 'closed' && (
|
||||||
<span className="text-xs bg-[#555]/20 text-[#888] px-2 py-0.5 rounded font-body">CLOSED</span>
|
<span className="text-xs bg-[#555]/20 text-[#888] px-2 py-0.5 rounded font-body">CLOSED</span>
|
||||||
@@ -458,23 +458,23 @@ export default function ForumCategoryPage() {
|
|||||||
{thread.reply_count === 0 && (
|
{thread.reply_count === 0 && (
|
||||||
<span className="text-xs bg-[#f59e0b]/10 text-[#f59e0b] px-2 py-0.5 rounded font-body">UNANSWERED</span>
|
<span className="text-xs bg-[#f59e0b]/10 text-[#f59e0b] px-2 py-0.5 rounded font-body">UNANSWERED</span>
|
||||||
)}
|
)}
|
||||||
<h3 className="text-white font-body font-semibold text-sm group-hover:text-[#3b82f6] transition-colors truncate">
|
<h3 className="text-[#0F172A] font-body font-semibold text-sm group-hover:text-[#1D4ED8] transition-colors truncate">
|
||||||
{thread.title}
|
{thread.title}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[#666] font-body text-xs mt-0.5">
|
<p className="text-[#666] font-body text-xs mt-0.5">
|
||||||
by <Link href={`/forum/user/${encodeURIComponent(thread.author_name)}`} className="text-[#888] hover:text-[#3b82f6] hover:underline" onClick={(e) => e.stopPropagation()}>{thread.author_name}</Link>
|
by <Link href={`/forum/user/${encodeURIComponent(thread.author_name)}`} className="text-[#888] hover:text-[#1D4ED8] hover:underline" onClick={(e) => e.stopPropagation()}>{thread.author_name}</Link>
|
||||||
{' · '}{timeAgo(thread.created_at)}
|
{' · '}{timeAgo(thread.created_at)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0 text-right hidden sm:block">
|
<div className="flex-shrink-0 text-right hidden sm:block">
|
||||||
<div className="text-[#aaa] font-body text-sm font-semibold">{thread.reply_count}</div>
|
<div className="text-[#0F172A] font-body text-sm font-semibold">{thread.reply_count}</div>
|
||||||
<div className="text-[#555] font-body text-xs">replies</div>
|
<div className="text-[#475569] font-body text-xs">replies</div>
|
||||||
</div>
|
</div>
|
||||||
{sort === 'top-voted' && (
|
{sort === 'top-voted' && (
|
||||||
<div className="flex-shrink-0 text-right hidden md:block">
|
<div className="flex-shrink-0 text-right hidden md:block">
|
||||||
<div className="text-[#22c55e] font-body text-sm font-semibold">+{thread.upvotes ?? 0}</div>
|
<div className="text-[#22c55e] font-body text-sm font-semibold">+{thread.upvotes ?? 0}</div>
|
||||||
<div className="text-[#555] font-body text-xs">votes</div>
|
<div className="text-[#475569] font-body text-xs">votes</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex-shrink-0 text-right hidden md:block">
|
<div className="flex-shrink-0 text-right hidden md:block">
|
||||||
|
|||||||
@@ -30,21 +30,21 @@ function ForgotInner() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
<main className="min-h-screen bg-gradient-to-b from-[#0d0d0d] via-[#111111] to-[#0d0d0d]">
|
<main className="min-h-screen bg-gradient-to-b from-[#F8FAFC] via-[#F8FAFC] to-[#F8FAFC]">
|
||||||
<div className="max-w-md mx-auto px-4 py-16">
|
<div className="max-w-md mx-auto px-4 py-16">
|
||||||
<div className="text-center mb-6">
|
<div className="text-center mb-6">
|
||||||
<div className="text-[10px] uppercase tracking-widest text-[#3b82f6] font-mono mb-2">Account recovery</div>
|
<div className="text-[10px] uppercase tracking-widest text-[#1D4ED8] font-mono mb-2">Account recovery</div>
|
||||||
<h1 className="text-3xl font-heading font-bold text-white">Reset your password</h1>
|
<h1 className="text-3xl font-heading font-bold text-[#0F172A]">Reset your password</h1>
|
||||||
<p className="text-[#888] font-body text-sm mt-2">
|
<p className="text-[#888] font-body text-sm mt-2">
|
||||||
Enter your email — we’ll send a link to set a new password.
|
Enter your email — we’ll send a link to set a new password.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form
|
<form
|
||||||
onSubmit={submit}
|
onSubmit={submit}
|
||||||
className="bg-[#1a1a1a] border border-[#252525] rounded-lg p-6 space-y-4 shadow-2xl"
|
className="bg-[#FFFFFF] border border-[#DCE6F2] rounded-lg p-6 space-y-4 shadow-2xl"
|
||||||
>
|
>
|
||||||
{err && (
|
{err && (
|
||||||
<div className="bg-[#2a0a0a] border border-[#cc0000] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
|
<div className="bg-[#2a0a0a] border border-[#1D4ED8] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
|
||||||
{err}
|
{err}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -63,19 +63,19 @@ function ForgotInner() {
|
|||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
required
|
required
|
||||||
className="w-full bg-[#0d0d0d] border border-[#2a2a2a] rounded-lg px-3 py-2 text-white font-body text-sm focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#F8FAFC] border border-[#DCE6F2] rounded-lg px-3 py-2 text-[#0F172A] font-body text-sm focus:outline-none focus:border-[#1D4ED8]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={busy || !email.trim()}
|
disabled={busy || !email.trim()}
|
||||||
className="w-full bg-[#3b82f6] hover:bg-[#2563eb] disabled:opacity-50 text-white font-body font-semibold text-sm px-5 py-3 rounded-lg transition-colors"
|
className="w-full bg-[#1D4ED8] hover:bg-[#1E3A8A] disabled:opacity-50 text-white font-body font-semibold text-sm px-5 py-3 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
{busy ? "Sending…" : "Send reset link"}
|
{busy ? "Sending…" : "Send reset link"}
|
||||||
</button>
|
</button>
|
||||||
<div className="text-center text-xs font-body text-[#888] pt-2 border-t border-[#252525]">
|
<div className="text-center text-xs font-body text-[#888] pt-2 border-t border-[#DCE6F2]">
|
||||||
Remembered it?{" "}
|
Remembered it?{" "}
|
||||||
<Link href="/forum/login" className="text-[#3b82f6] hover:underline font-semibold">
|
<Link href="/forum/login" className="text-[#1D4ED8] hover:underline font-semibold">
|
||||||
Back to sign in
|
Back to sign in
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'The Crew Lounge — AV Beat',
|
title: 'The Integration Room — AV Beat',
|
||||||
description: 'The Crew Lounge: connect with broadcast, motion picture and post production professionals worldwide. Discuss live production, IP workflows, streaming, audio, cameras, AI automation, and more.',
|
description: 'The Integration Room: connect with pro AV integrators, live production engineers, and display tech specialists worldwide. Discuss AV-over-IP, control systems, projection & display, audio engineering, conferencing, AI automation, and more.',
|
||||||
alternates: { canonical: '/forum' },
|
alternates: { canonical: '/forum' },
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: 'The Crew Lounge — AV Beat',
|
title: 'The Integration Room — AV Beat',
|
||||||
description: 'The Crew Lounge: connect with broadcast, motion picture and post production professionals worldwide. Ask questions, share expertise, and discuss broadcast technology.',
|
description: 'The Integration Room: connect with pro AV integrators, live production engineers, and display tech specialists worldwide. Ask questions, share expertise, and discuss AV technology.',
|
||||||
url: '/forum',
|
url: '/forum',
|
||||||
type: 'website',
|
type: 'website',
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary',
|
card: 'summary',
|
||||||
title: 'The Crew Lounge — AV Beat',
|
title: 'The Integration Room — AV Beat',
|
||||||
description: 'The Crew Lounge: connect with broadcast, motion picture and post production professionals worldwide on AV Beat.',
|
description: 'The Integration Room: connect with pro AV integrators, live production engineers, and display tech specialists worldwide on AV Beat.',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -21,8 +21,8 @@ export default function ForumLayout({ children }: { children: React.ReactNode })
|
|||||||
const schema = {
|
const schema = {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'WebPage',
|
'@type': 'WebPage',
|
||||||
name: 'AV Beat — The Crew Lounge',
|
name: 'AV Beat — The Integration Room',
|
||||||
description: 'The Crew Lounge: an industry community for broadcast, motion picture and post production professionals worldwide.',
|
description: 'The Integration Room: an industry community for pro AV integrators, live production engineers, and display tech professionals worldwide.',
|
||||||
url: 'https://avbeat.com/forum',
|
url: 'https://avbeat.com/forum',
|
||||||
isPartOf: {
|
isPartOf: {
|
||||||
'@type': 'WebSite',
|
'@type': 'WebSite',
|
||||||
@@ -33,7 +33,7 @@ export default function ForumLayout({ children }: { children: React.ReactNode })
|
|||||||
'@type': 'BreadcrumbList',
|
'@type': 'BreadcrumbList',
|
||||||
itemListElement: [
|
itemListElement: [
|
||||||
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://avbeat.com' },
|
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://avbeat.com' },
|
||||||
{ '@type': 'ListItem', position: 2, name: 'The Crew Lounge', item: 'https://avbeat.com/forum' },
|
{ '@type': 'ListItem', position: 2, name: 'The Integration Room', item: 'https://avbeat.com/forum' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -51,21 +51,21 @@ function ForumLoginInner() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
<main className="min-h-screen bg-gradient-to-b from-[#0d0d0d] via-[#111111] to-[#0d0d0d]">
|
<main className="min-h-screen bg-gradient-to-b from-[#F8FAFC] via-[#F8FAFC] to-[#F8FAFC]">
|
||||||
<div className="max-w-md mx-auto px-4 py-16">
|
<div className="max-w-md mx-auto px-4 py-16">
|
||||||
<div className="text-center mb-6">
|
<div className="text-center mb-6">
|
||||||
<div className="text-[10px] uppercase tracking-widest text-[#3b82f6] font-mono mb-2">Welcome back</div>
|
<div className="text-[10px] uppercase tracking-widest text-[#1D4ED8] font-mono mb-2">Welcome back</div>
|
||||||
<h1 className="text-3xl font-heading font-bold text-white">Sign in to AV Beat</h1>
|
<h1 className="text-3xl font-heading font-bold text-[#0F172A]">Sign in to AV Beat</h1>
|
||||||
<p className="text-[#888] font-body text-sm mt-2">
|
<p className="text-[#888] font-body text-sm mt-2">
|
||||||
Your account covers the forum, newsletter preferences, and saved articles.
|
Your account covers the forum, newsletter preferences, and saved articles.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="bg-[#1a1a1a] border border-[#252525] rounded-lg p-6 space-y-4 shadow-2xl"
|
className="bg-[#FFFFFF] border border-[#DCE6F2] rounded-lg p-6 space-y-4 shadow-2xl"
|
||||||
>
|
>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-[#2a0a0a] border border-[#cc0000] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
|
<div className="bg-[#2a0a0a] border border-[#1D4ED8] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -79,7 +79,7 @@ function ForumLoginInner() {
|
|||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
required
|
required
|
||||||
className="w-full bg-[#0d0d0d] border border-[#2a2a2a] rounded-lg px-3 py-2 text-white font-body text-sm focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#F8FAFC] border border-[#DCE6F2] rounded-lg px-3 py-2 text-[#0F172A] font-body text-sm focus:outline-none focus:border-[#1D4ED8]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -92,20 +92,20 @@ function ForumLoginInner() {
|
|||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
required
|
required
|
||||||
className="w-full bg-[#0d0d0d] border border-[#2a2a2a] rounded-lg px-3 py-2 text-white font-body text-sm focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#F8FAFC] border border-[#DCE6F2] rounded-lg px-3 py-2 text-[#0F172A] font-body text-sm focus:outline-none focus:border-[#1D4ED8]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full bg-[#3b82f6] hover:bg-[#2563eb] disabled:opacity-50 text-white font-body font-semibold text-sm px-5 py-2.5 rounded-lg transition-colors"
|
className="w-full bg-[#1D4ED8] hover:bg-[#1E3A8A] disabled:opacity-50 text-white font-body font-semibold text-sm px-5 py-2.5 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
{loading ? 'Signing in…' : 'Sign In'}
|
{loading ? 'Signing in…' : 'Sign In'}
|
||||||
</button>
|
</button>
|
||||||
<div className="text-right text-xs font-body">
|
<div className="text-right text-xs font-body">
|
||||||
<Link
|
<Link
|
||||||
href={`/forum/forgot-password?email=${encodeURIComponent(email)}`}
|
href={`/forum/forgot-password?email=${encodeURIComponent(email)}`}
|
||||||
className="text-[#888] hover:text-[#3b82f6] hover:underline"
|
className="text-[#888] hover:text-[#1D4ED8] hover:underline"
|
||||||
>
|
>
|
||||||
Forgot password?
|
Forgot password?
|
||||||
</Link>
|
</Link>
|
||||||
@@ -115,7 +115,7 @@ function ForumLoginInner() {
|
|||||||
No account yet?{' '}
|
No account yet?{' '}
|
||||||
<Link
|
<Link
|
||||||
href={`/forum/register?next=${encodeURIComponent(next)}`}
|
href={`/forum/register?next=${encodeURIComponent(next)}`}
|
||||||
className="text-[#3b82f6] hover:underline"
|
className="text-[#1D4ED8] hover:underline"
|
||||||
>
|
>
|
||||||
Create one
|
Create one
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -21,11 +21,17 @@ interface ForumCategory {
|
|||||||
last_activity_at?: string | null;
|
last_activity_at?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function profileHref(thread: { author_username?: string | null; author_name: string }): string {
|
||||||
|
const slug = thread.author_username || thread.author_name;
|
||||||
|
return `/forum/user/${encodeURIComponent(slug)}`;
|
||||||
|
}
|
||||||
|
|
||||||
interface ForumThread {
|
interface ForumThread {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
author_id?: string | null;
|
author_id?: string | null;
|
||||||
author_name: string;
|
author_name: string;
|
||||||
|
author_username?: string | null;
|
||||||
reply_count: number;
|
reply_count: number;
|
||||||
view_count: number;
|
view_count: number;
|
||||||
last_reply_at: string;
|
last_reply_at: string;
|
||||||
@@ -103,27 +109,27 @@ function TopThreadsWidget({ categoryId }: { categoryId?: string }) {
|
|||||||
|
|
||||||
const getMetricValue = (thread: ForumThread) => {
|
const getMetricValue = (thread: ForumThread) => {
|
||||||
if (activeMetric === 'upvotes') return { value: thread.upvotes ?? 0, label: 'votes', color: 'text-[#22c55e]' };
|
if (activeMetric === 'upvotes') return { value: thread.upvotes ?? 0, label: 'votes', color: 'text-[#22c55e]' };
|
||||||
if (activeMetric === 'replies') return { value: thread.reply_count, label: 'replies', color: 'text-[#3b82f6]' };
|
if (activeMetric === 'replies') return { value: thread.reply_count, label: 'replies', color: 'text-[#1D4ED8]' };
|
||||||
return { value: thread.view_count, label: 'views', color: 'text-[#f59e0b]' };
|
return { value: thread.view_count, label: 'views', color: 'text-[#f59e0b]' };
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[#1a1a1a] border border-[#252525] rounded-xl overflow-hidden mb-8">
|
<div className="bg-[#FFFFFF] border border-[#DCE6F2] rounded-xl overflow-hidden mb-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-[#252525]">
|
<div className="flex items-center justify-between px-5 py-4 border-b border-[#DCE6F2]">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-lg">🏆</span>
|
<span className="text-lg">🏆</span>
|
||||||
<h2 className="text-white font-heading font-bold text-base">Top Performing Threads</h2>
|
<h2 className="text-[#0F172A] font-heading font-bold text-base">Top Performing Threads</h2>
|
||||||
</div>
|
</div>
|
||||||
{/* Metric Tabs */}
|
{/* Metric Tabs */}
|
||||||
<div className="flex gap-1 bg-[#111] border border-[#2a2a2a] rounded-lg p-1">
|
<div className="flex gap-1 bg-[#111] border border-[#DCE6F2] rounded-lg p-1">
|
||||||
{metrics.map(m => (
|
{metrics.map(m => (
|
||||||
<button
|
<button
|
||||||
key={m.value}
|
key={m.value}
|
||||||
onClick={() => setActiveMetric(m.value)}
|
onClick={() => setActiveMetric(m.value)}
|
||||||
className={`px-3 py-1 rounded-md text-xs font-body font-semibold transition-colors whitespace-nowrap ${
|
className={`px-3 py-1 rounded-md text-xs font-body font-semibold transition-colors whitespace-nowrap ${
|
||||||
activeMetric === m.value
|
activeMetric === m.value
|
||||||
? 'bg-[#3b82f6] text-white'
|
? 'bg-[#1D4ED8] text-white'
|
||||||
: 'text-[#666] hover:text-[#aaa]'
|
: 'text-[#666] hover:text-[#aaa]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -140,18 +146,18 @@ function TopThreadsWidget({ categoryId }: { categoryId?: string }) {
|
|||||||
<>
|
<>
|
||||||
{[...Array(5)].map((_, i) => (
|
{[...Array(5)].map((_, i) => (
|
||||||
<div key={i} className="flex items-center gap-3 px-5 py-3">
|
<div key={i} className="flex items-center gap-3 px-5 py-3">
|
||||||
<div className="w-6 h-6 bg-[#252525] rounded animate-pulse flex-shrink-0" />
|
<div className="w-6 h-6 bg-[#DCE6F2] rounded animate-pulse flex-shrink-0" />
|
||||||
<div className="flex-1 space-y-1.5">
|
<div className="flex-1 space-y-1.5">
|
||||||
<div className="h-3.5 bg-[#252525] rounded animate-pulse w-3/4" />
|
<div className="h-3.5 bg-[#DCE6F2] rounded animate-pulse w-3/4" />
|
||||||
<div className="h-2.5 bg-[#1e1e1e] rounded animate-pulse w-1/3" />
|
<div className="h-2.5 bg-[#1e1e1e] rounded animate-pulse w-1/3" />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-10 h-8 bg-[#252525] rounded animate-pulse flex-shrink-0" />
|
<div className="w-10 h-8 bg-[#DCE6F2] rounded animate-pulse flex-shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!loading && threads.length === 0 && (
|
{!loading && threads.length === 0 && (
|
||||||
<div className="px-5 py-8 text-center text-[#555] font-body text-sm">
|
<div className="px-5 py-8 text-center text-[#475569] font-body text-sm">
|
||||||
No threads yet. Be the first to start a discussion!
|
No threads yet. Be the first to start a discussion!
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -161,7 +167,7 @@ function TopThreadsWidget({ categoryId }: { categoryId?: string }) {
|
|||||||
<Link
|
<Link
|
||||||
key={thread.id}
|
key={thread.id}
|
||||||
href={`/forum/thread/${thread.id}`}
|
href={`/forum/thread/${thread.id}`}
|
||||||
className="flex items-center gap-3 px-5 py-3 hover:bg-[#1e2a3a] transition-colors group"
|
className="flex items-center gap-3 px-5 py-3 hover:bg-[#e2e8f0] transition-colors group"
|
||||||
>
|
>
|
||||||
{/* Rank */}
|
{/* Rank */}
|
||||||
<span className={`flex-shrink-0 w-6 text-center font-heading font-bold text-sm ${
|
<span className={`flex-shrink-0 w-6 text-center font-heading font-bold text-sm ${
|
||||||
@@ -171,13 +177,13 @@ function TopThreadsWidget({ categoryId }: { categoryId?: string }) {
|
|||||||
</span>
|
</span>
|
||||||
{/* Thread info */}
|
{/* Thread info */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-white font-body text-sm font-semibold group-hover:text-[#3b82f6] transition-colors truncate leading-snug">
|
<p className="text-[#0F172A] font-body text-sm font-semibold group-hover:text-[#1D4ED8] transition-colors truncate leading-snug">
|
||||||
{thread.title}
|
{thread.title}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[#555] font-body text-xs mt-0.5">
|
<p className="text-[#475569] font-body text-xs mt-0.5">
|
||||||
<Link href={`/forum/user/${encodeURIComponent(thread.author_name)}`} className="text-[#666] hover:text-[#3b82f6] hover:underline" onClick={(e) => e.stopPropagation()}>{thread.author_name}</Link>
|
<Link href={profileHref(thread)} className="text-[#475569] hover:text-[#1D4ED8] hover:underline" onClick={(e) => e.stopPropagation()}>{thread.author_name}</Link>
|
||||||
{thread.forum_categories && (
|
{thread.forum_categories && (
|
||||||
<> · <span className="text-[#3b82f6]">{thread.forum_categories.name}</span></>
|
<> · <span className="text-[#1D4ED8]">{thread.forum_categories.name}</span></>
|
||||||
)}
|
)}
|
||||||
{' · '}{timeAgo(thread.created_at)}
|
{' · '}{timeAgo(thread.created_at)}
|
||||||
</p>
|
</p>
|
||||||
@@ -283,25 +289,25 @@ export default function ForumIndexPage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
<main className="min-h-screen bg-[#111111]">
|
<main className="min-h-screen bg-[#F8FAFC]">
|
||||||
{/* Hero — width-aligned with content below + featured-box pattern */}
|
{/* Hero — width-aligned with content below + featured-box pattern */}
|
||||||
<div className="max-w-container mx-auto px-4">
|
<div className="max-w-container mx-auto px-4">
|
||||||
<div className="bg-[#1a2535] border-b border-[#2a3a50] py-5 px-4">
|
<div className="bg-[#FFFFFF] border-b border-[#2a3a50] py-5 px-4">
|
||||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-3 mb-1">
|
<div className="flex items-center gap-3 mb-1">
|
||||||
<img src="/logos/crew-lounge-logo.svg" alt="The Crew Lounge" className="w-8 h-8" />
|
<img src="/logos/crew-lounge-logo.svg" alt="The Integration Room" className="w-8 h-8" />
|
||||||
<h1 className="text-3xl font-heading font-bold text-white">The Crew Lounge</h1>
|
<h1 className="text-3xl font-heading font-bold text-[#0F172A]">The Integration Room</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[#aab4c4] font-body text-sm max-w-2xl">
|
<p className="text-[#475569] font-body text-sm max-w-2xl">
|
||||||
Connect with broadcast, motion picture, and post production pros worldwide.
|
Connect with pro AV integrators, live production engineers, and display tech specialists worldwide.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
{currentUserId ? (
|
{currentUserId ? (
|
||||||
<Link
|
<Link
|
||||||
href="/forum/new"
|
href="/forum/new"
|
||||||
className="inline-flex items-center gap-2 bg-[#3b82f6] hover:bg-[#2563eb] text-white font-body font-semibold text-sm rounded-lg px-5 py-2.5 transition-colors"
|
className="brand-gradient inline-flex items-center gap-2 font-body font-semibold text-sm rounded-full px-5 py-2"
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||||
<path d="M12 5v14M5 12h14" />
|
<path d="M12 5v14M5 12h14" />
|
||||||
@@ -311,7 +317,7 @@ export default function ForumIndexPage() {
|
|||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
href="/forum/login?next=/forum"
|
href="/forum/login?next=/forum"
|
||||||
className="inline-flex items-center gap-2 bg-[#3b82f6] hover:bg-[#2563eb] text-white font-body font-semibold text-sm rounded-lg px-5 py-2.5 transition-colors"
|
className="brand-gradient inline-flex items-center gap-2 font-body font-semibold text-sm rounded-full px-5 py-2"
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||||
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4M10 17l5-5-5-5M15 12H3" />
|
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4M10 17l5-5-5-5M15 12H3" />
|
||||||
@@ -328,12 +334,12 @@ export default function ForumIndexPage() {
|
|||||||
<div className="lg:grid lg:grid-cols-[1fr,300px] lg:gap-8">
|
<div className="lg:grid lg:grid-cols-[1fr,300px] lg:gap-8">
|
||||||
<div>
|
<div>
|
||||||
|
|
||||||
{/* Feed Mode Tabs */}
|
{/* Feed Mode Tabs — slim pill toggle; active uses brand-gradient */}
|
||||||
<div className="flex items-center gap-1 bg-[#1a1a1a] border border-[#2a2a2a] rounded-lg p-1 mb-6 w-fit">
|
<div className="flex items-center gap-1 bg-[#FFFFFF] border border-[#DCE6F2] rounded-full p-1 mb-6 w-fit">
|
||||||
<button
|
<button
|
||||||
onClick={() => setFeedMode('discover')}
|
onClick={() => setFeedMode('discover')}
|
||||||
className={`px-4 py-2 rounded-md text-sm font-body font-semibold transition-colors flex items-center gap-2 ${
|
className={`px-4 py-1.5 rounded-full text-sm font-body font-semibold transition-colors flex items-center gap-2 ${
|
||||||
feedMode === 'discover' ? 'bg-[#3b82f6] text-white' : 'text-[#888] hover:text-white'
|
feedMode === 'discover' ? 'brand-gradient' : 'text-[#475569] hover:text-[#0F172A]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||||
@@ -343,8 +349,8 @@ export default function ForumIndexPage() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setFeedMode('following')}
|
onClick={() => setFeedMode('following')}
|
||||||
className={`px-4 py-2 rounded-md text-sm font-body font-semibold transition-colors flex items-center gap-2 ${
|
className={`px-4 py-1.5 rounded-full text-sm font-body font-semibold transition-colors flex items-center gap-2 ${
|
||||||
feedMode === 'following' ? 'bg-[#3b82f6] text-white' : 'text-[#888] hover:text-white'
|
feedMode === 'following' ? 'brand-gradient' : 'text-[#475569] hover:text-[#0F172A]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||||
@@ -359,11 +365,11 @@ export default function ForumIndexPage() {
|
|||||||
{feedMode === 'following' && (
|
{feedMode === 'following' && (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
{!currentUserId && (
|
{!currentUserId && (
|
||||||
<div className="bg-[#1a2535] border border-[#2a3a50] rounded-lg p-8 text-center">
|
<div className="bg-[#FFFFFF] border border-[#DCE6F2] rounded-lg p-8 text-center">
|
||||||
<p className="text-[#aab4c4] font-body text-sm mb-3">Sign in to see threads from people you follow.</p>
|
<p className="text-[#475569] font-body text-sm mb-3">Sign in to see threads from people you follow.</p>
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
className="inline-block bg-[#3b82f6] text-white font-body text-sm font-bold px-5 py-2 rounded-lg hover:bg-[#2563eb] transition-colors"
|
className="brand-gradient inline-block font-body text-sm font-bold px-5 py-2 rounded-full"
|
||||||
>
|
>
|
||||||
Sign In
|
Sign In
|
||||||
</Link>
|
</Link>
|
||||||
@@ -372,19 +378,19 @@ export default function ForumIndexPage() {
|
|||||||
{currentUserId && followingLoading && (
|
{currentUserId && followingLoading && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{[...Array(6)].map((_, i) => (
|
{[...Array(6)].map((_, i) => (
|
||||||
<div key={i} className="bg-[#1a1a1a] rounded-lg h-16 animate-pulse border border-[#252525]" />
|
<div key={i} className="bg-[#FFFFFF] rounded-lg h-16 animate-pulse border border-[#DCE6F2]" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentUserId && !followingLoading && followingThreads.length === 0 && (
|
{currentUserId && !followingLoading && followingThreads.length === 0 && (
|
||||||
<div className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-lg p-10 text-center">
|
<div className="bg-[#FFFFFF] border border-[#DCE6F2] rounded-lg p-10 text-center">
|
||||||
<div className="text-3xl mb-3">👥</div>
|
<div className="text-3xl mb-3">👥</div>
|
||||||
<p className="text-[#aaa] font-body text-sm font-semibold mb-1">Your following feed is empty</p>
|
<p className="text-[#0F172A] font-body text-sm font-semibold mb-1">Your following feed is empty</p>
|
||||||
<p className="text-[#555] font-body text-sm mb-4">Follow other members to see their threads here.</p>
|
<p className="text-[#475569] font-body text-sm mb-4">Follow other members to see their threads here.</p>
|
||||||
<Link
|
<Link
|
||||||
href="/forum"
|
href="/forum"
|
||||||
onClick={() => setFeedMode('discover')}
|
onClick={() => setFeedMode('discover')}
|
||||||
className="inline-block bg-[#3b82f6] text-white font-body text-sm font-bold px-5 py-2 rounded-lg hover:bg-[#2563eb] transition-colors"
|
className="inline-block bg-[#1D4ED8] text-white font-body text-sm font-bold px-5 py-2 rounded-lg hover:bg-[#1E3A8A] transition-colors"
|
||||||
>
|
>
|
||||||
Discover Members
|
Discover Members
|
||||||
</Link>
|
</Link>
|
||||||
@@ -398,18 +404,18 @@ export default function ForumIndexPage() {
|
|||||||
<Link
|
<Link
|
||||||
key={thread.id}
|
key={thread.id}
|
||||||
href={`/forum/thread/${thread.id}`}
|
href={`/forum/thread/${thread.id}`}
|
||||||
className="flex items-center gap-4 bg-[#1a1a1a] hover:bg-[#1e2a3a] border border-[#252525] hover:border-[#3b82f6] rounded-lg px-4 py-3 transition-all group"
|
className="flex items-center gap-4 bg-[#FFFFFF] hover:bg-[#e2e8f0] border border-[#DCE6F2] hover:border-[#1D4ED8] rounded-lg px-4 py-3 transition-all group"
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="text-white font-body font-semibold text-sm group-hover:text-[#3b82f6] transition-colors truncate">
|
<h3 className="text-[#0F172A] font-body font-semibold text-sm group-hover:text-[#1D4ED8] transition-colors truncate">
|
||||||
{thread.title}
|
{thread.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[#666] font-body text-xs mt-0.5">
|
<p className="text-[#666] font-body text-xs mt-0.5">
|
||||||
by{' '}
|
by{' '}
|
||||||
<Link
|
<Link
|
||||||
href={`/profile/${thread.author_id}`}
|
href={profileHref(thread)}
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
className="text-[#3b82f6] hover:underline"
|
className="text-[#1D4ED8] hover:underline"
|
||||||
>
|
>
|
||||||
{thread.author_name}
|
{thread.author_name}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -420,12 +426,12 @@ export default function ForumIndexPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0 text-right hidden sm:block">
|
<div className="flex-shrink-0 text-right hidden sm:block">
|
||||||
<div className="text-[#aaa] font-body text-sm font-semibold">{thread.reply_count}</div>
|
<div className="text-[#0F172A] font-body text-sm font-semibold">{thread.reply_count}</div>
|
||||||
<div className="text-[#555] font-body text-xs">replies</div>
|
<div className="text-[#475569] font-body text-xs">replies</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0 text-right hidden md:block">
|
<div className="flex-shrink-0 text-right hidden md:block">
|
||||||
<div className="text-[#aaa] font-body text-sm font-semibold">{thread.view_count}</div>
|
<div className="text-[#0F172A] font-body text-sm font-semibold">{thread.view_count}</div>
|
||||||
<div className="text-[#555] font-body text-xs">views</div>
|
<div className="text-[#475569] font-body text-xs">views</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
@@ -450,7 +456,7 @@ export default function ForumIndexPage() {
|
|||||||
placeholder="Search threads..."
|
placeholder="Search threads..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => setSearch(e.target.value)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
className="w-full bg-[#1a1a1a] border border-[#2a2a2a] focus:border-[#3b82f6] rounded-lg pl-9 pr-4 py-2.5 text-white font-body text-sm placeholder-[#555] focus:outline-none transition-colors"
|
className="w-full bg-[#FFFFFF] border border-[#DCE6F2] focus:border-[#1D4ED8] rounded-lg pl-9 pr-4 py-2.5 text-[#0F172A] font-body text-sm placeholder-[#888] focus:outline-none transition-colors"
|
||||||
/>
|
/>
|
||||||
{search && (
|
{search && (
|
||||||
<button
|
<button
|
||||||
@@ -470,7 +476,7 @@ export default function ForumIndexPage() {
|
|||||||
<select
|
<select
|
||||||
value={selectedCategory}
|
value={selectedCategory}
|
||||||
onChange={e => setSelectedCategory(e.target.value)}
|
onChange={e => setSelectedCategory(e.target.value)}
|
||||||
className="appearance-none bg-[#1a1a1a] border border-[#2a2a2a] focus:border-[#3b82f6] rounded-lg pl-3 pr-8 py-2.5 text-sm font-body text-white focus:outline-none transition-colors cursor-pointer min-w-[160px]"
|
className="appearance-none bg-[#FFFFFF] border border-[#DCE6F2] focus:border-[#1D4ED8] rounded-lg pl-3 pr-8 py-2.5 text-sm font-body text-[#0F172A] focus:outline-none transition-colors cursor-pointer min-w-[160px]"
|
||||||
>
|
>
|
||||||
<option value="">All Categories</option>
|
<option value="">All Categories</option>
|
||||||
{categories.map(cat => (
|
{categories.map(cat => (
|
||||||
@@ -482,16 +488,16 @@ export default function ForumIndexPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sort Options */}
|
{/* Sort Options — segmented pill control; active uses brand-gradient */}
|
||||||
<div className="flex gap-1 bg-[#1a1a1a] border border-[#2a2a2a] rounded-lg p-1">
|
<div className="flex gap-1 bg-[#FFFFFF] border border-[#DCE6F2] rounded-full p-1">
|
||||||
{sortOptions.map(opt => (
|
{sortOptions.map(opt => (
|
||||||
<button
|
<button
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
onClick={() => setSort(opt.value)}
|
onClick={() => setSort(opt.value)}
|
||||||
className={`px-3 py-1.5 rounded-md text-xs font-body font-semibold transition-colors whitespace-nowrap ${
|
className={`px-3 py-1 rounded-full text-xs font-body font-semibold transition-colors whitespace-nowrap ${
|
||||||
sort === opt.value
|
sort === opt.value
|
||||||
? 'bg-[#3b82f6] text-white'
|
? 'brand-gradient'
|
||||||
: 'text-[#888] hover:text-white'
|
: 'text-[#475569] hover:text-[#0F172A]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
@@ -506,13 +512,13 @@ export default function ForumIndexPage() {
|
|||||||
{threadsLoading && (
|
{threadsLoading && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{[...Array(6)].map((_, i) => (
|
{[...Array(6)].map((_, i) => (
|
||||||
<div key={i} className="bg-[#1a1a1a] rounded-lg h-14 animate-pulse border border-[#252525]" />
|
<div key={i} className="bg-[#FFFFFF] rounded-lg h-14 animate-pulse border border-[#DCE6F2]" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!threadsLoading && threads.length === 0 && (
|
{!threadsLoading && threads.length === 0 && (
|
||||||
<div className="text-center py-12 text-[#555] font-body">
|
<div className="text-center py-12 text-[#475569] font-body">
|
||||||
<p className="text-base mb-1">No threads found</p>
|
<p className="text-base mb-1 text-[#0F172A] font-semibold">No threads found</p>
|
||||||
<p className="text-sm">Try adjusting your search or filters.</p>
|
<p className="text-sm">Try adjusting your search or filters.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -523,28 +529,28 @@ export default function ForumIndexPage() {
|
|||||||
<Link
|
<Link
|
||||||
key={thread.id}
|
key={thread.id}
|
||||||
href={`/forum/thread/${thread.id}`}
|
href={`/forum/thread/${thread.id}`}
|
||||||
className="flex items-center gap-4 bg-[#1a1a1a] hover:bg-[#1e2a3a] border border-[#252525] hover:border-[#3b82f6] rounded-lg px-4 py-3 transition-all group"
|
className="flex items-center gap-4 bg-[#FFFFFF] hover:bg-[#e2e8f0] border border-[#DCE6F2] hover:border-[#1D4ED8] rounded-lg px-4 py-3 transition-all group"
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="text-white font-body font-semibold text-sm group-hover:text-[#3b82f6] transition-colors truncate">
|
<h3 className="text-[#0F172A] font-body font-semibold text-sm group-hover:text-[#1D4ED8] transition-colors truncate">
|
||||||
{thread.title}
|
{thread.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[#666] font-body text-xs mt-0.5">
|
<p className="text-[#666] font-body text-xs mt-0.5">
|
||||||
by <Link href={`/forum/user/${encodeURIComponent(thread.author_name)}`} className="text-[#888] hover:text-[#3b82f6] hover:underline" onClick={(e) => e.stopPropagation()}>{thread.author_name}</Link>
|
by <Link href={profileHref(thread)} className="text-[#888] hover:text-[#1D4ED8] hover:underline" onClick={(e) => e.stopPropagation()}>{thread.author_name}</Link>
|
||||||
{thread.forum_categories && (
|
{thread.forum_categories && (
|
||||||
<> · <span className="text-[#3b82f6]">{thread.forum_categories.name}</span></>
|
<> · <span className="text-[#1D4ED8]">{thread.forum_categories.name}</span></>
|
||||||
)}
|
)}
|
||||||
{' · '}{timeAgo(thread.created_at)}
|
{' · '}{timeAgo(thread.created_at)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0 text-right hidden sm:block">
|
<div className="flex-shrink-0 text-right hidden sm:block">
|
||||||
<div className="text-[#aaa] font-body text-sm font-semibold">{thread.reply_count}</div>
|
<div className="text-[#0F172A] font-body text-sm font-semibold">{thread.reply_count}</div>
|
||||||
<div className="text-[#555] font-body text-xs">replies</div>
|
<div className="text-[#475569] font-body text-xs">replies</div>
|
||||||
</div>
|
</div>
|
||||||
{sort === 'top-voted' && (
|
{sort === 'top-voted' && (
|
||||||
<div className="flex-shrink-0 text-right hidden md:block">
|
<div className="flex-shrink-0 text-right hidden md:block">
|
||||||
<div className="text-[#22c55e] font-body text-sm font-semibold">+{thread.upvotes ?? 0}</div>
|
<div className="text-[#22c55e] font-body text-sm font-semibold">+{thread.upvotes ?? 0}</div>
|
||||||
<div className="text-[#555] font-body text-xs">votes</div>
|
<div className="text-[#475569] font-body text-xs">votes</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -560,7 +566,7 @@ export default function ForumIndexPage() {
|
|||||||
{loading && (
|
{loading && (
|
||||||
<div className="space-y-3 mb-8">
|
<div className="space-y-3 mb-8">
|
||||||
{[...Array(8)].map((_, i) => (
|
{[...Array(8)].map((_, i) => (
|
||||||
<div key={i} className="bg-[#1a1a1a] rounded-lg h-20 animate-pulse border border-[#252525]" />
|
<div key={i} className="bg-[#FFFFFF] rounded-lg h-20 animate-pulse border border-[#DCE6F2]" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -578,39 +584,39 @@ export default function ForumIndexPage() {
|
|||||||
|
|
||||||
{!loading && !error && (
|
{!loading && !error && (
|
||||||
<>
|
<>
|
||||||
<h2 className="text-white font-heading font-bold text-base mb-3">Browse Categories</h2>
|
<h2 className="text-[#0F172A] font-heading font-bold text-base mb-3">Browse Categories</h2>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{categories.map(cat => (
|
{categories.map(cat => (
|
||||||
<Link
|
<Link
|
||||||
key={cat.id}
|
key={cat.id}
|
||||||
href={`/forum/${cat.slug}`}
|
href={`/forum/${cat.slug}`}
|
||||||
className="flex items-center gap-4 bg-[#1a1a1a] hover:bg-[#1e2a3a] border border-[#252525] hover:border-[#3b82f6] rounded-lg p-4 transition-all group"
|
className="flex items-center gap-4 bg-[#FFFFFF] hover:bg-[#e2e8f0] border border-[#DCE6F2] hover:border-[#1D4ED8] rounded-lg p-4 transition-all group"
|
||||||
>
|
>
|
||||||
<span className="text-3xl flex-shrink-0 w-10 text-center">{cat.icon}</span>
|
<span className="text-3xl flex-shrink-0 w-10 text-center">{cat.icon}</span>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h2 className="text-white font-heading font-semibold text-lg group-hover:text-[#3b82f6] transition-colors">
|
<h2 className="text-[#0F172A] font-heading font-semibold text-lg group-hover:text-[#1D4ED8] transition-colors">
|
||||||
{cat.name}
|
{cat.name}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-[#888] font-body text-sm mt-0.5 truncate">{cat.description}</p>
|
<p className="text-[#475569] font-body text-sm mt-0.5 truncate">{cat.description}</p>
|
||||||
{cat.last_thread_title && (
|
{cat.last_thread_title && (
|
||||||
<p className="text-[10px] text-[#666] font-body mt-1.5 truncate">
|
<p className="text-[10px] text-[#475569] font-body mt-1.5 truncate">
|
||||||
<span className="text-[#555]">Last post:</span>{' '}
|
<span className="text-[#666]">Last post:</span>{' '}
|
||||||
<span className="text-[#aaa]">{cat.last_thread_title}</span>
|
<span className="text-[#1e293b]">{cat.last_thread_title}</span>
|
||||||
{cat.last_activity_at && (
|
{cat.last_activity_at && (
|
||||||
<span className="text-[#555]"> · {timeAgoFresh(cat.last_activity_at, cat.id)}</span>
|
<span className="text-[#666]"> · {timeAgoFresh(cat.last_activity_at, cat.id)}</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0 text-right hidden sm:block">
|
<div className="flex-shrink-0 text-right hidden sm:block">
|
||||||
<div className="text-white font-body font-semibold text-sm">{cat.thread_count}</div>
|
<div className="text-[#0F172A] font-body font-semibold text-sm">{cat.thread_count}</div>
|
||||||
<div className="text-[#666] font-body text-xs">threads</div>
|
<div className="text-[#666] font-body text-xs">threads</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0 text-right hidden md:block">
|
<div className="flex-shrink-0 text-right hidden md:block">
|
||||||
<div className="text-white font-body font-semibold text-sm">{cat.post_count}</div>
|
<div className="text-[#0F172A] font-body font-semibold text-sm">{cat.post_count}</div>
|
||||||
<div className="text-[#666] font-body text-xs">posts</div>
|
<div className="text-[#666] font-body text-xs">posts</div>
|
||||||
</div>
|
</div>
|
||||||
<svg className="w-4 h-4 text-[#555] group-hover:text-[#3b82f6] flex-shrink-0 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 text-[#555] group-hover:text-[#1D4ED8] flex-shrink-0 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -624,12 +630,11 @@ export default function ForumIndexPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Forum guidelines */}
|
{/* Forum guidelines */}
|
||||||
<div className="mt-8 bg-[#1a2535] border border-[#2a3a50] rounded-lg p-5">
|
<div className="mt-8 bg-[#FFFFFF] border border-[#DCE6F2] rounded-lg p-5">
|
||||||
<h3 className="text-white font-heading font-semibold mb-2">Community Guidelines</h3>
|
<h3 className="text-[#0F172A] font-heading font-semibold mb-2">Community Guidelines</h3>
|
||||||
<ul className="text-[#aab4c4] font-body text-sm space-y-1">
|
<ul className="text-[#475569] font-body text-sm space-y-1">
|
||||||
<li>• Be respectful and professional — this is a community of peers</li>
|
<li>• Be respectful and professional — this is a community of peers</li>
|
||||||
<li>• Share real-world experience and cite sources when possible</li>
|
<li>• Share real-world experience and cite sources when possible</li>
|
||||||
<li>• The <span className="text-[#3b82f6]">AV Beat AI</span> assistant is available in threads to help answer questions — it is clearly labeled</li>
|
|
||||||
<li>• No spam, self-promotion, or vendor pitches without disclosure</li>
|
<li>• No spam, self-promotion, or vendor pitches without disclosure</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export default function EditForumProfilePage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
<main className="min-h-screen bg-[#111111]">
|
<main className="min-h-screen bg-[#F8FAFC]">
|
||||||
<div className="max-w-2xl mx-auto px-4 py-12 text-[#666] font-body text-sm">Loading…</div>
|
<div className="max-w-2xl mx-auto px-4 py-12 text-[#666] font-body text-sm">Loading…</div>
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
@@ -85,7 +85,7 @@ export default function EditForumProfilePage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
<main className="min-h-screen bg-[#111111]">
|
<main className="min-h-screen bg-[#F8FAFC]">
|
||||||
<div className="max-w-2xl mx-auto px-4 py-12 text-[#888] font-body text-sm">
|
<div className="max-w-2xl mx-auto px-4 py-12 text-[#888] font-body text-sm">
|
||||||
Profile unavailable.
|
Profile unavailable.
|
||||||
</div>
|
</div>
|
||||||
@@ -98,13 +98,13 @@ export default function EditForumProfilePage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
<main className="min-h-screen bg-[#111111]">
|
<main className="min-h-screen bg-[#F8FAFC]">
|
||||||
<div className="max-w-2xl mx-auto px-4 py-8">
|
<div className="max-w-2xl mx-auto px-4 py-8">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h1 className="text-2xl font-heading font-bold text-white">Edit Profile</h1>
|
<h1 className="text-2xl font-heading font-bold text-white">Edit Profile</h1>
|
||||||
<Link
|
<Link
|
||||||
href={`/forum/user/${profile.username}`}
|
href={`/forum/user/${profile.username}`}
|
||||||
className="text-xs font-body text-[#3b82f6] hover:underline"
|
className="text-xs font-body text-[#1D4ED8] hover:underline"
|
||||||
>
|
>
|
||||||
View public profile →
|
View public profile →
|
||||||
</Link>
|
</Link>
|
||||||
@@ -112,10 +112,10 @@ export default function EditForumProfilePage() {
|
|||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="bg-[#1a1a1a] border border-[#252525] rounded-lg p-6 space-y-4"
|
className="bg-[#FFFFFF] border border-[#DCE6F2] rounded-lg p-6 space-y-4"
|
||||||
>
|
>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-[#2a0a0a] border border-[#cc0000] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
|
<div className="bg-[#2a0a0a] border border-[#1D4ED8] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -132,7 +132,7 @@ export default function EditForumProfilePage() {
|
|||||||
onChange={(e) => update('username', e.target.value)}
|
onChange={(e) => update('username', e.target.value)}
|
||||||
pattern="[A-Za-z0-9_]{3,24}"
|
pattern="[A-Za-z0-9_]{3,24}"
|
||||||
required
|
required
|
||||||
className="w-full bg-[#0d0d0d] border border-[#2a2a2a] rounded-lg px-3 py-2 text-white font-body text-sm focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#F8FAFC] border border-[#DCE6F2] rounded-lg px-3 py-2 text-white font-body text-sm focus:outline-none focus:border-[#1D4ED8]"
|
||||||
/>
|
/>
|
||||||
<p className="text-[11px] font-body text-[#666] mt-1">
|
<p className="text-[11px] font-body text-[#666] mt-1">
|
||||||
3-24 characters. Letters, numbers, underscore.
|
3-24 characters. Letters, numbers, underscore.
|
||||||
@@ -145,7 +145,7 @@ export default function EditForumProfilePage() {
|
|||||||
value={profile.display_name}
|
value={profile.display_name}
|
||||||
onChange={(e) => update('display_name', e.target.value)}
|
onChange={(e) => update('display_name', e.target.value)}
|
||||||
required
|
required
|
||||||
className="w-full bg-[#0d0d0d] border border-[#2a2a2a] rounded-lg px-3 py-2 text-white font-body text-sm focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#F8FAFC] border border-[#DCE6F2] rounded-lg px-3 py-2 text-white font-body text-sm focus:outline-none focus:border-[#1D4ED8]"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@ export default function EditForumProfilePage() {
|
|||||||
value={profile.bio || ''}
|
value={profile.bio || ''}
|
||||||
onChange={(e) => update('bio', e.target.value)}
|
onChange={(e) => update('bio', e.target.value)}
|
||||||
rows={4}
|
rows={4}
|
||||||
className="w-full bg-[#0d0d0d] border border-[#2a2a2a] rounded-lg px-3 py-2 text-white font-body text-sm focus:outline-none focus:border-[#3b82f6] resize-none"
|
className="w-full bg-[#F8FAFC] border border-[#DCE6F2] rounded-lg px-3 py-2 text-white font-body text-sm focus:outline-none focus:border-[#1D4ED8] resize-none"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
@@ -164,7 +164,7 @@ export default function EditForumProfilePage() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={profile.role_title || ''}
|
value={profile.role_title || ''}
|
||||||
onChange={(e) => update('role_title', e.target.value)}
|
onChange={(e) => update('role_title', e.target.value)}
|
||||||
className="w-full bg-[#0d0d0d] border border-[#2a2a2a] rounded-lg px-3 py-2 text-white font-body text-sm focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#F8FAFC] border border-[#DCE6F2] rounded-lg px-3 py-2 text-white font-body text-sm focus:outline-none focus:border-[#1D4ED8]"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Company">
|
<Field label="Company">
|
||||||
@@ -172,7 +172,7 @@ export default function EditForumProfilePage() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={profile.company || ''}
|
value={profile.company || ''}
|
||||||
onChange={(e) => update('company', e.target.value)}
|
onChange={(e) => update('company', e.target.value)}
|
||||||
className="w-full bg-[#0d0d0d] border border-[#2a2a2a] rounded-lg px-3 py-2 text-white font-body text-sm focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#F8FAFC] border border-[#DCE6F2] rounded-lg px-3 py-2 text-white font-body text-sm focus:outline-none focus:border-[#1D4ED8]"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
@@ -183,7 +183,7 @@ export default function EditForumProfilePage() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={profile.location_city || ''}
|
value={profile.location_city || ''}
|
||||||
onChange={(e) => update('location_city', e.target.value)}
|
onChange={(e) => update('location_city', e.target.value)}
|
||||||
className="w-full bg-[#0d0d0d] border border-[#2a2a2a] rounded-lg px-3 py-2 text-white font-body text-sm focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#F8FAFC] border border-[#DCE6F2] rounded-lg px-3 py-2 text-white font-body text-sm focus:outline-none focus:border-[#1D4ED8]"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Country">
|
<Field label="Country">
|
||||||
@@ -191,7 +191,7 @@ export default function EditForumProfilePage() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={profile.location_country || ''}
|
value={profile.location_country || ''}
|
||||||
onChange={(e) => update('location_country', e.target.value)}
|
onChange={(e) => update('location_country', e.target.value)}
|
||||||
className="w-full bg-[#0d0d0d] border border-[#2a2a2a] rounded-lg px-3 py-2 text-white font-body text-sm focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#F8FAFC] border border-[#DCE6F2] rounded-lg px-3 py-2 text-white font-body text-sm focus:outline-none focus:border-[#1D4ED8]"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,14 +202,14 @@ export default function EditForumProfilePage() {
|
|||||||
value={profile.avatar_url || ''}
|
value={profile.avatar_url || ''}
|
||||||
onChange={(e) => update('avatar_url', e.target.value)}
|
onChange={(e) => update('avatar_url', e.target.value)}
|
||||||
placeholder="https://…"
|
placeholder="https://…"
|
||||||
className="w-full bg-[#0d0d0d] border border-[#2a2a2a] rounded-lg px-3 py-2 text-white font-body text-sm focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#F8FAFC] border border-[#DCE6F2] rounded-lg px-3 py-2 text-white font-body text-sm focus:outline-none focus:border-[#1D4ED8]"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="bg-[#3b82f6] hover:bg-[#2563eb] disabled:opacity-50 text-white font-body font-semibold text-sm px-5 py-2.5 rounded-lg transition-colors"
|
className="bg-[#1D4ED8] hover:bg-[#1E3A8A] disabled:opacity-50 text-white font-body font-semibold text-sm px-5 py-2.5 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
{saving ? 'Saving…' : 'Save Profile'}
|
{saving ? 'Saving…' : 'Save Profile'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -101,12 +101,12 @@ function ForumRegisterInner() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
<main className="min-h-screen bg-gradient-to-b from-[#0d0d0d] via-[#111111] to-[#0d0d0d]">
|
<main className="min-h-screen bg-gradient-to-b from-[#F8FAFC] via-[#F8FAFC] to-[#F8FAFC]">
|
||||||
<div className="max-w-5xl mx-auto px-4 py-12 grid grid-cols-1 lg:grid-cols-[1fr_440px] gap-10 items-start">
|
<div className="max-w-5xl mx-auto px-4 py-12 grid grid-cols-1 lg:grid-cols-[1fr_440px] gap-10 items-start">
|
||||||
{/* Left rail — what membership gets you */}
|
{/* Left rail — what membership gets you */}
|
||||||
<aside className="hidden lg:block">
|
<aside className="hidden lg:block">
|
||||||
<div className="text-[10px] uppercase tracking-widest text-[#3b82f6] font-mono mb-3">Membership</div>
|
<div className="text-[10px] uppercase tracking-widest text-[#1D4ED8] font-mono mb-3">Membership</div>
|
||||||
<h1 className="font-heading text-white text-4xl xl:text-5xl font-bold leading-tight mb-4">
|
<h1 className="font-heading text-[#0F172A] text-4xl xl:text-5xl font-bold leading-tight mb-4">
|
||||||
Join AV Beat
|
Join AV Beat
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-[#cbd5e1] text-base leading-relaxed mb-6 max-w-md">
|
<p className="text-[#cbd5e1] text-base leading-relaxed mb-6 max-w-md">
|
||||||
@@ -122,22 +122,22 @@ function ForumRegisterInner() {
|
|||||||
<li key={String(title)} className="flex gap-3 items-start">
|
<li key={String(title)} className="flex gap-3 items-start">
|
||||||
<span className="text-xl leading-none flex-shrink-0">{icon}</span>
|
<span className="text-xl leading-none flex-shrink-0">{icon}</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold text-white">{title}</div>
|
<div className="font-semibold text-[#0F172A]">{title}</div>
|
||||||
<div className="text-xs text-[#9ca3af] mt-0.5">{desc}</div>
|
<div className="text-xs text-[#9ca3af] mt-0.5">{desc}</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<div className="mt-6 text-[11px] text-[#666] leading-relaxed max-w-md border-t border-[#252525] pt-4">
|
<div className="mt-6 text-[11px] text-[#666] leading-relaxed max-w-md border-t border-[#DCE6F2] pt-4">
|
||||||
<strong className="text-[#888]">PR firms & contributors:</strong> a separate contributor application is required to submit press releases.
|
<strong className="text-[#888]">PR firms & contributors:</strong> a separate contributor application is required to submit press releases.
|
||||||
{" "}
|
{" "}
|
||||||
<Link href="/about/advertise" className="text-[#3b82f6] hover:underline">Contact us</Link> if you represent a manufacturer or agency.
|
<Link href="/about/advertise" className="text-[#1D4ED8] hover:underline">Contact us</Link> if you represent a manufacturer or agency.
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Mobile-only hero (collapsed copy) */}
|
{/* Mobile-only hero (collapsed copy) */}
|
||||||
<div className="lg:hidden text-center mb-2">
|
<div className="lg:hidden text-center mb-2">
|
||||||
<h1 className="font-heading text-white text-2xl font-bold">Join AV Beat</h1>
|
<h1 className="font-heading text-[#0F172A] text-2xl font-bold">Join AV Beat</h1>
|
||||||
<p className="text-[#888] font-body text-sm mt-1">
|
<p className="text-[#888] font-body text-sm mt-1">
|
||||||
One account for the forum, newsletter preferences, and saved articles.
|
One account for the forum, newsletter preferences, and saved articles.
|
||||||
</p>
|
</p>
|
||||||
@@ -145,14 +145,14 @@ function ForumRegisterInner() {
|
|||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="bg-[#1a1a1a] border border-[#252525] rounded-lg p-6 space-y-4 shadow-2xl"
|
className="bg-[#FFFFFF] border border-[#DCE6F2] rounded-lg p-6 space-y-4 shadow-2xl"
|
||||||
>
|
>
|
||||||
<div className="hidden lg:block mb-2">
|
<div className="hidden lg:block mb-2">
|
||||||
<div className="text-[10px] uppercase tracking-widest text-[#3b82f6] font-mono mb-1">Create your account</div>
|
<div className="text-[10px] uppercase tracking-widest text-[#1D4ED8] font-mono mb-1">Create your account</div>
|
||||||
<div className="text-[#888] text-xs">Takes 30 seconds. Free, no card needed.</div>
|
<div className="text-[#888] text-xs">Takes 30 seconds. Free, no card needed.</div>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-[#2a0a0a] border border-[#cc0000] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
|
<div className="bg-[#2a0a0a] border border-[#1D4ED8] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -171,7 +171,7 @@ function ForumRegisterInner() {
|
|||||||
onChange={(e) => setFullName(e.target.value)}
|
onChange={(e) => setFullName(e.target.value)}
|
||||||
autoComplete="name"
|
autoComplete="name"
|
||||||
required
|
required
|
||||||
className="w-full bg-[#0d0d0d] border border-[#2a2a2a] rounded-lg px-3 py-2 text-white font-body text-sm focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#F8FAFC] border border-[#DCE6F2] rounded-lg px-3 py-2 text-[#0F172A] font-body text-sm focus:outline-none focus:border-[#1D4ED8]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -184,7 +184,7 @@ function ForumRegisterInner() {
|
|||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
required
|
required
|
||||||
className="w-full bg-[#0d0d0d] border border-[#2a2a2a] rounded-lg px-3 py-2 text-white font-body text-sm focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#F8FAFC] border border-[#DCE6F2] rounded-lg px-3 py-2 text-[#0F172A] font-body text-sm focus:outline-none focus:border-[#1D4ED8]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -198,7 +198,7 @@ function ForumRegisterInner() {
|
|||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
required
|
required
|
||||||
minLength={6}
|
minLength={6}
|
||||||
className="w-full bg-[#0d0d0d] border border-[#2a2a2a] rounded-lg px-3 py-2 text-white font-body text-sm focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#F8FAFC] border border-[#DCE6F2] rounded-lg px-3 py-2 text-[#0F172A] font-body text-sm focus:outline-none focus:border-[#1D4ED8]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -211,29 +211,29 @@ function ForumRegisterInner() {
|
|||||||
onChange={(e) => setConfirm(e.target.value)}
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
required
|
required
|
||||||
className="w-full bg-[#0d0d0d] border border-[#2a2a2a] rounded-lg px-3 py-2 text-white font-body text-sm focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#F8FAFC] border border-[#DCE6F2] rounded-lg px-3 py-2 text-[#0F172A] font-body text-sm focus:outline-none focus:border-[#1D4ED8]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full bg-[#3b82f6] hover:bg-[#2563eb] disabled:opacity-50 text-white font-body font-semibold text-sm px-5 py-3 rounded-lg transition-colors"
|
className="w-full bg-[#1D4ED8] hover:bg-[#1E3A8A] disabled:opacity-50 text-white font-body font-semibold text-sm px-5 py-3 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
{loading ? 'Creating account…' : 'Create Free Account'}
|
{loading ? 'Creating account…' : 'Create Free Account'}
|
||||||
</button>
|
</button>
|
||||||
<p className="text-[10px] text-[#666] leading-relaxed text-center px-2">
|
<p className="text-[10px] text-[#666] leading-relaxed text-center px-2">
|
||||||
By creating an account you agree to our{' '}
|
By creating an account you agree to our{' '}
|
||||||
<Link href="/terms" className="text-[#888] hover:text-[#3b82f6] underline">terms</Link>
|
<Link href="/terms" className="text-[#888] hover:text-[#1D4ED8] underline">terms</Link>
|
||||||
{' '}and{' '}
|
{' '}and{' '}
|
||||||
<Link href="/privacy" className="text-[#888] hover:text-[#3b82f6] underline">privacy policy</Link>.
|
<Link href="/privacy" className="text-[#888] hover:text-[#1D4ED8] underline">privacy policy</Link>.
|
||||||
We’ll never sell your email.
|
We’ll never sell your email.
|
||||||
</p>
|
</p>
|
||||||
<SocialAuthButtons next={next} />
|
<SocialAuthButtons next={next} />
|
||||||
<div className="text-center text-xs font-body text-[#888] pt-2 border-t border-[#252525]">
|
<div className="text-center text-xs font-body text-[#888] pt-2 border-t border-[#DCE6F2]">
|
||||||
Already have an account?{' '}
|
Already have an account?{' '}
|
||||||
<Link
|
<Link
|
||||||
href={`/forum/login?next=${encodeURIComponent(next)}`}
|
href={`/forum/login?next=${encodeURIComponent(next)}`}
|
||||||
className="text-[#3b82f6] hover:underline font-semibold"
|
className="text-[#1D4ED8] hover:underline font-semibold"
|
||||||
>
|
>
|
||||||
Sign in
|
Sign in
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -40,20 +40,20 @@ function ResetInner() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
<main className="min-h-screen bg-gradient-to-b from-[#0d0d0d] via-[#111111] to-[#0d0d0d]">
|
<main className="min-h-screen bg-gradient-to-b from-[#F8FAFC] via-[#F8FAFC] to-[#F8FAFC]">
|
||||||
<div className="max-w-md mx-auto px-4 py-16">
|
<div className="max-w-md mx-auto px-4 py-16">
|
||||||
<div className="text-center mb-6">
|
<div className="text-center mb-6">
|
||||||
<div className="text-[10px] uppercase tracking-widest text-[#3b82f6] font-mono mb-2">Reset password</div>
|
<div className="text-[10px] uppercase tracking-widest text-[#1D4ED8] font-mono mb-2">Reset password</div>
|
||||||
<h1 className="text-3xl font-heading font-bold text-white">Set a new password</h1>
|
<h1 className="text-3xl font-heading font-bold text-[#0F172A]">Set a new password</h1>
|
||||||
</div>
|
</div>
|
||||||
{hasSession === false ? (
|
{hasSession === false ? (
|
||||||
<div className="bg-[#1a1a1a] border border-[#252525] rounded-lg p-6 text-center">
|
<div className="bg-[#FFFFFF] border border-[#DCE6F2] rounded-lg p-6 text-center">
|
||||||
<p className="text-[#cbd5e1] mb-4 text-sm">
|
<p className="text-[#cbd5e1] mb-4 text-sm">
|
||||||
This reset link is expired or invalid.
|
This reset link is expired or invalid.
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href="/forum/forgot-password"
|
href="/forum/forgot-password"
|
||||||
className="inline-block bg-[#3b82f6] hover:bg-[#2563eb] text-white font-semibold text-sm px-5 py-2 rounded"
|
className="inline-block bg-[#1D4ED8] hover:bg-[#1E3A8A] text-white font-semibold text-sm px-5 py-2 rounded"
|
||||||
>
|
>
|
||||||
Request a new link
|
Request a new link
|
||||||
</Link>
|
</Link>
|
||||||
@@ -61,10 +61,10 @@ function ResetInner() {
|
|||||||
) : (
|
) : (
|
||||||
<form
|
<form
|
||||||
onSubmit={submit}
|
onSubmit={submit}
|
||||||
className="bg-[#1a1a1a] border border-[#252525] rounded-lg p-6 space-y-4 shadow-2xl"
|
className="bg-[#FFFFFF] border border-[#DCE6F2] rounded-lg p-6 space-y-4 shadow-2xl"
|
||||||
>
|
>
|
||||||
{err && (
|
{err && (
|
||||||
<div className="bg-[#2a0a0a] border border-[#cc0000] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
|
<div className="bg-[#2a0a0a] border border-[#1D4ED8] text-[#ff8a8a] font-body text-sm px-3 py-2 rounded">
|
||||||
{err}
|
{err}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -84,7 +84,7 @@ function ResetInner() {
|
|||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
minLength={8}
|
minLength={8}
|
||||||
required
|
required
|
||||||
className="w-full bg-[#0d0d0d] border border-[#2a2a2a] rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#F8FAFC] border border-[#DCE6F2] rounded-lg px-3 py-2 text-[#0F172A] text-sm focus:outline-none focus:border-[#1D4ED8]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -97,13 +97,13 @@ function ResetInner() {
|
|||||||
onChange={(e) => setConfirm(e.target.value)}
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
required
|
required
|
||||||
className="w-full bg-[#0d0d0d] border border-[#2a2a2a] rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-[#3b82f6]"
|
className="w-full bg-[#F8FAFC] border border-[#DCE6F2] rounded-lg px-3 py-2 text-[#0F172A] text-sm focus:outline-none focus:border-[#1D4ED8]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="w-full bg-[#3b82f6] hover:bg-[#2563eb] disabled:opacity-50 text-white font-semibold text-sm px-5 py-3 rounded-lg"
|
className="w-full bg-[#1D4ED8] hover:bg-[#1E3A8A] disabled:opacity-50 text-white font-semibold text-sm px-5 py-3 rounded-lg"
|
||||||
>
|
>
|
||||||
{busy ? "Saving…" : "Update password"}
|
{busy ? "Saving…" : "Update password"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||