← back to Norma
feat(modals): fan out ResizableModal to 22 modals across 15 files
ee9135a577094d1b2a04a46b051f306c125c7761 · 2026-05-20 13:38:31 -0700 · Steve Abrams
Dispatched 4 parallel claude --print workers via the max-it skill. Each
worker refactored a batch of modal-bearing components to use the shared
<ResizableModal modalId="…"/> wrapper, picking up full-screen toggle,
corner drag-resize, and per-modalId localStorage size persistence.
Modals converted (22):
ApiCredentialsSection (4) — api-key-create / api-platform-connect /
api-key-revoke-confirm / api-platform-disconnect-confirm
ManageTabsModal — manage-tabs
AddGrantModal — grant-create
AddPetitionModal — petition-create
ScheduleModal — pipeline-schedule
MemberEmailModal — member-email-generator
BulkScheduleModal — social-bulk-schedule
CompareModal — email-analyzer-compare-versions
ContactsTab — contacts-import-csv
InTheNewsTab — intelligence-discover-articles
JournalistHub (2) — journalist-article-detail / journalist-add
OldLeadsTab — leads-rekindle-letter
ApiRegistryModal — api-registry
LibraryTab — library-item-create
AdvocacyTargets — advocacy-petition-template
XSessionsTab — xsessions-new-scan
StatementsTab — statements-generate
PartnersTab — partners-add
OnBoardTab (2) — onboard-trigger-discovery / onboard-nonprofit-signup
OutreachPlaybookTab (2) — outreach-add-pipeline-contact / outreach-generate-letter
SocialTab (2) — social-add-stream / social-compose-post
PlatformPreview (2, prev) — platform-preview-save-confirm / platform-preview-publish-confirm
ComposeModal (prev) — email-compose
Skipped (intentionally, per ResizableModal rules):
PetitionDraftModal — side-drawer, not centered modal
PipelineTab/PetitionsTab/GrantsTab/NewsTab/EmailSendsTab/EmailAnalyzer/
ApiRegistryButton — no inline modals (delegate to external Modal*)
DashboardTab, PulseFeed, TopicRadar, NetworkGraph3D, EnhancedMindMap,
PulseGlobe — no centered modals (inline panels only)
Pre-existing TS errors in lib/auth.ts (SESSION_SECRET narrowing) +
QueryResultRow generics in unrelated files left untouched.
Files touched
M components/ManageTabsModal.tsxM components/api-registry/ApiRegistryModal.tsxM components/contacts/ContactsTab.tsxM components/email-analyzer/CompareModal.tsxM components/email-sends/ComposeModal.tsxM components/email-sends/MemberEmailModal.tsxM components/geo/AdvocacyTargets.tsxM components/grants/AddGrantModal.tsxM components/intelligence/InTheNewsTab.tsxM components/journalists/JournalistHub.tsxM components/leads/OldLeadsTab.tsxM components/library/LibraryTab.tsxM components/onboard/OnBoardTab.tsxM components/outreach/OutreachPlaybookTab.tsxM components/partners/PartnersTab.tsxM components/petitions/AddPetitionModal.tsxM components/petitions/PlatformPreview.tsxM components/pipeline/ScheduleModal.tsxM components/settings/ApiCredentialsSection.tsxM components/social/SocialTab.tsxM components/social/phase2/BulkScheduleModal.tsxM components/statements/StatementsTab.tsxM components/xsessions/XSessionsTab.tsx
Diff
commit ee9135a577094d1b2a04a46b051f306c125c7761
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed May 20 13:38:31 2026 -0700
feat(modals): fan out ResizableModal to 22 modals across 15 files
Dispatched 4 parallel claude --print workers via the max-it skill. Each
worker refactored a batch of modal-bearing components to use the shared
<ResizableModal modalId="…"/> wrapper, picking up full-screen toggle,
corner drag-resize, and per-modalId localStorage size persistence.
Modals converted (22):
ApiCredentialsSection (4) — api-key-create / api-platform-connect /
api-key-revoke-confirm / api-platform-disconnect-confirm
ManageTabsModal — manage-tabs
AddGrantModal — grant-create
AddPetitionModal — petition-create
ScheduleModal — pipeline-schedule
MemberEmailModal — member-email-generator
BulkScheduleModal — social-bulk-schedule
CompareModal — email-analyzer-compare-versions
ContactsTab — contacts-import-csv
InTheNewsTab — intelligence-discover-articles
JournalistHub (2) — journalist-article-detail / journalist-add
OldLeadsTab — leads-rekindle-letter
ApiRegistryModal — api-registry
LibraryTab — library-item-create
AdvocacyTargets — advocacy-petition-template
XSessionsTab — xsessions-new-scan
StatementsTab — statements-generate
PartnersTab — partners-add
OnBoardTab (2) — onboard-trigger-discovery / onboard-nonprofit-signup
OutreachPlaybookTab (2) — outreach-add-pipeline-contact / outreach-generate-letter
SocialTab (2) — social-add-stream / social-compose-post
PlatformPreview (2, prev) — platform-preview-save-confirm / platform-preview-publish-confirm
ComposeModal (prev) — email-compose
Skipped (intentionally, per ResizableModal rules):
PetitionDraftModal — side-drawer, not centered modal
PipelineTab/PetitionsTab/GrantsTab/NewsTab/EmailSendsTab/EmailAnalyzer/
ApiRegistryButton — no inline modals (delegate to external Modal*)
DashboardTab, PulseFeed, TopicRadar, NetworkGraph3D, EnhancedMindMap,
PulseGlobe — no centered modals (inline panels only)
Pre-existing TS errors in lib/auth.ts (SESSION_SECRET narrowing) +
QueryResultRow generics in unrelated files left untouched.
---
components/ManageTabsModal.tsx | 370 ++++++++++---------------
components/api-registry/ApiRegistryModal.tsx | 206 ++++++--------
components/contacts/ContactsTab.tsx | 94 +++----
components/email-analyzer/CompareModal.tsx | 69 +----
components/email-sends/ComposeModal.tsx | 133 +++------
components/email-sends/MemberEmailModal.tsx | 206 +++++---------
components/geo/AdvocacyTargets.tsx | 94 +++----
components/grants/AddGrantModal.tsx | 167 ++++-------
components/intelligence/InTheNewsTab.tsx | 108 ++------
components/journalists/JournalistHub.tsx | 298 +++++++++-----------
components/leads/OldLeadsTab.tsx | 117 ++++----
components/library/LibraryTab.tsx | 227 ++++++++-------
components/onboard/OnBoardTab.tsx | 252 +++++++----------
components/outreach/OutreachPlaybookTab.tsx | 229 +++++++--------
components/partners/PartnersTab.tsx | 116 ++++----
components/petitions/AddPetitionModal.tsx | 233 ++++++----------
components/petitions/PlatformPreview.tsx | 121 +++-----
components/pipeline/ScheduleModal.tsx | 98 +++----
components/settings/ApiCredentialsSection.tsx | 337 +++++++++++-----------
components/social/SocialTab.tsx | 138 +++------
components/social/phase2/BulkScheduleModal.tsx | 187 +++++--------
components/statements/StatementsTab.tsx | 89 +++---
components/xsessions/XSessionsTab.tsx | 156 +++++------
23 files changed, 1621 insertions(+), 2424 deletions(-)
diff --git a/components/ManageTabsModal.tsx b/components/ManageTabsModal.tsx
index db8047b..8dd4fc1 100644
--- a/components/ManageTabsModal.tsx
+++ b/components/ManageTabsModal.tsx
@@ -1,8 +1,9 @@
'use client';
-import { useState, useMemo, useCallback } from 'react';
-import { X, Eye, EyeOff, Search, RotateCcw } from 'lucide-react';
+import { useState, useMemo } from 'react';
+import { Eye, EyeOff, Search, RotateCcw } from 'lucide-react';
import type { TabId } from './Sidebar';
+import ResizableModal from './shared/ResizableModal';
/* ─── Types ──────────────────────────────────────────────────────────────── */
@@ -64,248 +65,171 @@ export default function ManageTabsModal({
const hiddenCount = hiddenTabs.size;
- const handleBackdropClick = useCallback(
- (e: React.MouseEvent) => {
- if (e.target === e.currentTarget) onClose();
- },
- [onClose],
- );
-
if (!open) return null;
return (
- <div
- onClick={handleBackdropClick}
- style={{
- position: 'fixed',
- inset: 0,
- zIndex: 200,
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- backgroundColor: 'rgba(0, 0, 0, 0.5)',
- backdropFilter: 'blur(4px)',
- }}
+ <ResizableModal
+ modalId="manage-tabs"
+ title={
+ <div>
+ <div style={{ fontSize: 16, fontWeight: 700 }}>Manage Tabs</div>
+ <div style={{ fontSize: 11, color: 'var(--color-text-muted)', fontWeight: 400, marginTop: 2 }}>
+ {hiddenCount === 0
+ ? 'All tabs visible'
+ : `${hiddenCount} tab${hiddenCount === 1 ? '' : 's'} hidden`}
+ </div>
+ </div>
+ }
+ onClose={onClose}
+ defaultWidth={520}
>
+ {/* Search + Reset */}
<div
- role="dialog"
- aria-modal="true"
- aria-label="Manage sidebar tabs"
style={{
- width: '100%',
- maxWidth: 480,
- maxHeight: '80vh',
display: 'flex',
- flexDirection: 'column',
- backgroundColor: 'var(--color-surface)',
- border: '1px solid var(--color-border)',
- borderRadius: 'var(--radius-lg)',
- boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
- overflow: 'hidden',
+ alignItems: 'center',
+ gap: 8,
+ marginBottom: 12,
}}
>
- {/* Header */}
- <div
- style={{
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'space-between',
- padding: '16px 20px 12px',
- borderBottom: '1px solid var(--color-border)',
- flexShrink: 0,
- }}
- >
- <div>
- <h2
- style={{
- fontSize: 16,
- fontWeight: 700,
- color: 'var(--color-text)',
- margin: 0,
- fontFamily: 'var(--font-heading)',
- }}
- >
- Manage Tabs
- </h2>
- <p
- style={{
- fontSize: 12,
- color: 'var(--color-text-muted)',
- margin: '2px 0 0',
- }}
- >
- {hiddenCount === 0
- ? 'All tabs visible'
- : `${hiddenCount} tab${hiddenCount === 1 ? '' : 's'} hidden`}
- </p>
- </div>
+ <div style={{ position: 'relative', flex: 1 }}>
+ <Search
+ size={14}
+ style={{
+ position: 'absolute',
+ left: 10,
+ top: '50%',
+ transform: 'translateY(-50%)',
+ color: 'var(--color-text-muted)',
+ pointerEvents: 'none',
+ }}
+ />
+ <input
+ type="text"
+ className="input"
+ placeholder="Filter tabs..."
+ value={search}
+ onChange={(e) => setSearch(e.target.value)}
+ style={{
+ paddingLeft: 32,
+ fontSize: 13,
+ height: 34,
+ width: '100%',
+ }}
+ />
+ </div>
+ {hiddenCount > 0 && (
<button
type="button"
- onClick={onClose}
- aria-label="Close"
+ onClick={onResetAll}
className="btn btn-ghost btn-sm"
- style={{ padding: 6 }}
+ style={{
+ fontSize: 12,
+ gap: 4,
+ flexShrink: 0,
+ whiteSpace: 'nowrap',
+ }}
>
- <X size={18} />
+ <RotateCcw size={13} />
+ Show All
</button>
- </div>
-
- {/* Search + Reset */}
- <div
- style={{
- display: 'flex',
- alignItems: 'center',
- gap: 8,
- padding: '10px 20px',
- borderBottom: '1px solid var(--color-border)',
- flexShrink: 0,
- }}
- >
- <div style={{ position: 'relative', flex: 1 }}>
- <Search
- size={14}
- style={{
- position: 'absolute',
- left: 10,
- top: '50%',
- transform: 'translateY(-50%)',
- color: 'var(--color-text-muted)',
- pointerEvents: 'none',
- }}
- />
- <input
- type="text"
- className="input"
- placeholder="Filter tabs..."
- value={search}
- onChange={(e) => setSearch(e.target.value)}
- style={{
- paddingLeft: 32,
- fontSize: 13,
- height: 34,
- width: '100%',
- }}
- />
- </div>
- {hiddenCount > 0 && (
- <button
- type="button"
- onClick={onResetAll}
- className="btn btn-ghost btn-sm"
- style={{
- fontSize: 12,
- gap: 4,
- flexShrink: 0,
- whiteSpace: 'nowrap',
- }}
- >
- <RotateCcw size={13} />
- Show All
- </button>
- )}
- </div>
-
- {/* Tab list */}
- <div
- style={{
- flex: 1,
- overflowY: 'auto',
- padding: '8px 12px 16px',
- }}
- >
- {Array.from(sections.entries()).map(([sectionId, tabs]) => (
- <div key={sectionId} style={{ marginBottom: 12 }}>
- <div
- style={{
- fontSize: 10,
- fontWeight: 700,
- letterSpacing: '0.08em',
- textTransform: 'uppercase',
- color: 'var(--color-text-muted)',
- padding: '6px 8px 4px',
- }}
- >
- {SECTION_LABELS[sectionId] || sectionId}
- </div>
- {tabs.map((tab) => {
- const isHidden = hiddenTabs.has(tab.id);
- return (
- <button
- key={tab.id}
- type="button"
- onClick={() => onToggleTab(tab.id)}
- style={{
- display: 'flex',
- alignItems: 'center',
- gap: 10,
- width: '100%',
- padding: '8px 10px',
- borderRadius: 'var(--radius-sm)',
- border: 'none',
- cursor: 'pointer',
- backgroundColor: 'transparent',
- color: isHidden
- ? 'var(--color-text-muted)'
- : 'var(--color-text)',
- fontSize: 13,
- fontWeight: 500,
- textAlign: 'left',
- opacity: isHidden ? 0.5 : 1,
- transition: 'background-color 0.15s, opacity 0.15s',
- }}
- onMouseEnter={(e) => {
- e.currentTarget.style.backgroundColor =
- 'var(--color-surface-el)';
- }}
- onMouseLeave={(e) => {
- e.currentTarget.style.backgroundColor = 'transparent';
- }}
- >
- {isHidden ? (
- <EyeOff
- size={15}
- style={{ color: 'var(--color-text-muted)', flexShrink: 0 }}
- />
- ) : (
- <Eye
- size={15}
- style={{ color: 'var(--color-primary)', flexShrink: 0 }}
- />
- )}
- <span style={{ flex: 1 }}>{tab.label}</span>
- {isHidden && (
- <span
- style={{
- fontSize: 10,
- color: 'var(--color-text-muted)',
- padding: '1px 6px',
- borderRadius: 10,
- border: '1px solid var(--color-border)',
- }}
- >
- hidden
- </span>
- )}
- </button>
- );
- })}
- </div>
- ))}
+ )}
+ </div>
- {sections.size === 0 && (
+ {/* Tab list */}
+ <div>
+ {Array.from(sections.entries()).map(([sectionId, tabs]) => (
+ <div key={sectionId} style={{ marginBottom: 12 }}>
<div
style={{
- textAlign: 'center',
- padding: '24px 0',
+ fontSize: 10,
+ fontWeight: 700,
+ letterSpacing: '0.08em',
+ textTransform: 'uppercase',
color: 'var(--color-text-muted)',
- fontSize: 13,
+ padding: '6px 8px 4px',
}}
>
- No tabs match your search
+ {SECTION_LABELS[sectionId] || sectionId}
</div>
- )}
- </div>
+ {tabs.map((tab) => {
+ const isHidden = hiddenTabs.has(tab.id);
+ return (
+ <button
+ key={tab.id}
+ type="button"
+ onClick={() => onToggleTab(tab.id)}
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: 10,
+ width: '100%',
+ padding: '8px 10px',
+ borderRadius: 'var(--radius-sm)',
+ border: 'none',
+ cursor: 'pointer',
+ backgroundColor: 'transparent',
+ color: isHidden
+ ? 'var(--color-text-muted)'
+ : 'var(--color-text)',
+ fontSize: 13,
+ fontWeight: 500,
+ textAlign: 'left',
+ opacity: isHidden ? 0.5 : 1,
+ transition: 'background-color 0.15s, opacity 0.15s',
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.backgroundColor =
+ 'var(--color-surface-el)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = 'transparent';
+ }}
+ >
+ {isHidden ? (
+ <EyeOff
+ size={15}
+ style={{ color: 'var(--color-text-muted)', flexShrink: 0 }}
+ />
+ ) : (
+ <Eye
+ size={15}
+ style={{ color: 'var(--color-primary)', flexShrink: 0 }}
+ />
+ )}
+ <span style={{ flex: 1 }}>{tab.label}</span>
+ {isHidden && (
+ <span
+ style={{
+ fontSize: 10,
+ color: 'var(--color-text-muted)',
+ padding: '1px 6px',
+ borderRadius: 10,
+ border: '1px solid var(--color-border)',
+ }}
+ >
+ hidden
+ </span>
+ )}
+ </button>
+ );
+ })}
+ </div>
+ ))}
+
+ {sections.size === 0 && (
+ <div
+ style={{
+ textAlign: 'center',
+ padding: '24px 0',
+ color: 'var(--color-text-muted)',
+ fontSize: 13,
+ }}
+ >
+ No tabs match your search
+ </div>
+ )}
</div>
- </div>
+ </ResizableModal>
);
}
diff --git a/components/api-registry/ApiRegistryModal.tsx b/components/api-registry/ApiRegistryModal.tsx
index 25ce8b1..4f809a8 100644
--- a/components/api-registry/ApiRegistryModal.tsx
+++ b/components/api-registry/ApiRegistryModal.tsx
@@ -6,9 +6,10 @@
import { useEffect, useMemo, useState } from 'react';
import {
- X, Search, CheckCircle2, AlertCircle, MinusCircle, Copy, ExternalLink,
+ Search, CheckCircle2, AlertCircle, MinusCircle, Copy, ExternalLink,
ChevronDown, ChevronRight, Filter, Eye, Lock,
} from 'lucide-react';
+import ResizableModal from '../shared/ResizableModal';
type Status = 'set' | 'missing' | 'partial';
@@ -282,136 +283,107 @@ export default function ApiRegistryModal({ onClose }: { onClose: () => void }) {
}, [data, filtered]);
return (
- <div
- onClick={onClose}
- style={{
- position: 'fixed', inset: 0, zIndex: 200,
- background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)',
- display: 'flex', alignItems: 'center', justifyContent: 'center',
- padding: 20,
- }}
+ <ResizableModal
+ modalId="api-registry"
+ title={
+ <div>
+ <div style={{ fontSize: 18, fontWeight: 700, color: 'var(--color-text)' }}>
+ APIs, Tokens & MCP Servers
+ </div>
+ <div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 2 }}>
+ Comprehensive admin view. Values never leave the server — set/missing only.
+ </div>
+ </div>
+ }
+ onClose={onClose}
+ defaultWidth={920}
>
- <div
- onClick={e => e.stopPropagation()}
- style={{
- background: 'var(--color-bg)',
- border: '1px solid var(--color-border)',
- borderRadius: 'var(--radius-lg)',
- width: '100%', maxWidth: 920, maxHeight: '92vh',
- display: 'flex', flexDirection: 'column',
- boxShadow: '0 24px 80px rgba(0,0,0,0.6)',
- }}
- >
- {/* Header */}
+ {/* Summary bar */}
+ {data && (
<div style={{
- padding: '16px 20px', borderBottom: '1px solid var(--color-border)',
- display: 'flex', alignItems: 'center', justifyContent: 'space-between',
+ padding: '10px 0', marginBottom: 12, borderBottom: '1px solid var(--color-border)',
+ display: 'flex', gap: 16, fontSize: 12, color: 'var(--color-text-secondary)',
+ flexWrap: 'wrap',
}}>
- <div>
- <div style={{ fontSize: 18, fontWeight: 700, color: 'var(--color-text)' }}>
- APIs, Tokens & MCP Servers
- </div>
- <div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 2 }}>
- Comprehensive admin view. Values never leave the server — set/missing only.
- </div>
- </div>
- <button
- onClick={onClose}
- className="btn btn-ghost btn-sm"
- style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}
- aria-label="Close"
- >
- <X size={14} />
- </button>
+ <span><strong style={{ color: 'var(--color-text)' }}>{data.summary.total}</strong> total</span>
+ <span style={{ color: '#34d399' }}>● {data.summary.set} configured</span>
+ <span style={{ color: '#fbbf24' }}>● {data.summary.partial} partial</span>
+ <span style={{ color: '#fb7185' }}>● {data.summary.missing} missing</span>
+ {data.summary.requiredMissing > 0 && (
+ <span style={{ color: '#fb7185', fontWeight: 700 }}>
+ ⚠ {data.summary.requiredMissing} REQUIRED missing
+ </span>
+ )}
</div>
+ )}
- {/* Summary bar */}
- {data && (
+ {/* Controls */}
+ <div style={{
+ padding: '12px 0', display: 'flex', gap: 10, alignItems: 'center',
+ borderBottom: '1px solid var(--color-border)', flexWrap: 'wrap', marginBottom: 16,
+ }}>
+ <div style={{ position: 'relative', flex: 1, minWidth: 220 }}>
+ <Search size={14} style={{
+ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)',
+ color: 'var(--color-text-muted)',
+ }} />
+ <input
+ className="input"
+ placeholder="Search by name, env var, scope, purpose..."
+ value={search}
+ onChange={e => setSearch(e.target.value)}
+ style={{ paddingLeft: 32, fontSize: 13 }}
+ />
+ </div>
+ <select
+ className="input"
+ value={statusFilter}
+ onChange={e => setStatusFilter(e.target.value as typeof statusFilter)}
+ style={{ fontSize: 12, padding: '6px 10px', minWidth: 180 }}
+ >
+ <option value="all">All statuses</option>
+ <option value="set">Configured only</option>
+ <option value="missing">Missing / partial</option>
+ <option value="required-missing">⚠ Required & missing</option>
+ </select>
+ </div>
+
+ {/* Content */}
+ <div>
+ {loading && (
+ <div style={{ textAlign: 'center', color: 'var(--color-text-muted)', padding: 60 }}>
+ Loading registry...
+ </div>
+ )}
+ {err && (
<div style={{
- padding: '10px 20px', borderBottom: '1px solid var(--color-border)',
- display: 'flex', gap: 16, fontSize: 12, color: 'var(--color-text-secondary)',
- flexWrap: 'wrap', background: 'var(--color-surface)',
+ padding: 16, borderRadius: 'var(--radius-md)',
+ background: 'rgba(244,63,94,0.12)', color: '#fb7185',
}}>
- <span><strong style={{ color: 'var(--color-text)' }}>{data.summary.total}</strong> total</span>
- <span style={{ color: '#34d399' }}>● {data.summary.set} configured</span>
- <span style={{ color: '#fbbf24' }}>● {data.summary.partial} partial</span>
- <span style={{ color: '#fb7185' }}>● {data.summary.missing} missing</span>
- {data.summary.requiredMissing > 0 && (
- <span style={{ color: '#fb7185', fontWeight: 700 }}>
- ⚠ {data.summary.requiredMissing} REQUIRED missing
- </span>
- )}
+ {err}
</div>
)}
-
- {/* Controls */}
- <div style={{
- padding: '12px 20px', display: 'flex', gap: 10, alignItems: 'center',
- borderBottom: '1px solid var(--color-border)', flexWrap: 'wrap',
- }}>
- <div style={{ position: 'relative', flex: 1, minWidth: 220 }}>
- <Search size={14} style={{
- position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)',
- color: 'var(--color-text-muted)',
- }} />
- <input
- className="input"
- placeholder="Search by name, env var, scope, purpose..."
- value={search}
- onChange={e => setSearch(e.target.value)}
- style={{ paddingLeft: 32, fontSize: 13 }}
- />
+ {!loading && !err && data && grouped.size === 0 && (
+ <div style={{ textAlign: 'center', color: 'var(--color-text-muted)', padding: 60 }}>
+ No integrations match.
</div>
- <select
- className="input"
- value={statusFilter}
- onChange={e => setStatusFilter(e.target.value as typeof statusFilter)}
- style={{ fontSize: 12, padding: '6px 10px', minWidth: 180 }}
- >
- <option value="all">All statuses</option>
- <option value="set">Configured only</option>
- <option value="missing">Missing / partial</option>
- <option value="required-missing">⚠ Required & missing</option>
- </select>
- </div>
-
- {/* Content */}
- <div style={{ flex: 1, overflowY: 'auto', padding: 20 }}>
- {loading && (
- <div style={{ textAlign: 'center', color: 'var(--color-text-muted)', padding: 60 }}>
- Loading registry...
- </div>
- )}
- {err && (
+ )}
+ {Array.from(grouped.entries()).map(([cat, list]) => (
+ <div key={cat} style={{ marginBottom: 24 }}>
<div style={{
- padding: 16, borderRadius: 'var(--radius-md)',
- background: 'rgba(244,63,94,0.12)', color: '#fb7185',
+ fontSize: 11, fontWeight: 700, textTransform: 'uppercase',
+ letterSpacing: '0.12em', color: 'var(--color-text-muted)',
+ marginBottom: 10, paddingBottom: 6,
+ borderBottom: '1px solid var(--color-border)',
}}>
- {err}
+ {cat} <span style={{ color: 'var(--color-text-muted)', fontWeight: 400 }}>({list.length})</span>
</div>
- )}
- {!loading && !err && data && grouped.size === 0 && (
- <div style={{ textAlign: 'center', color: 'var(--color-text-muted)', padding: 60 }}>
- No integrations match.
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
+ {list.map(e => <EntryCard key={e.id} entry={e} />)}
</div>
- )}
- {Array.from(grouped.entries()).map(([cat, list]) => (
- <div key={cat} style={{ marginBottom: 24 }}>
- <div style={{
- fontSize: 11, fontWeight: 700, textTransform: 'uppercase',
- letterSpacing: '0.12em', color: 'var(--color-text-muted)',
- marginBottom: 10, paddingBottom: 6,
- borderBottom: '1px solid var(--color-border)',
- }}>
- {cat} <span style={{ color: 'var(--color-text-muted)', fontWeight: 400 }}>({list.length})</span>
- </div>
- <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
- {list.map(e => <EntryCard key={e.id} entry={e} />)}
- </div>
- </div>
- ))}
- </div>
+ </div>
+ ))}
</div>
- </div>
+ </ResizableModal>
);
}
diff --git a/components/contacts/ContactsTab.tsx b/components/contacts/ContactsTab.tsx
index 05bd915..595a135 100644
--- a/components/contacts/ContactsTab.tsx
+++ b/components/contacts/ContactsTab.tsx
@@ -9,6 +9,7 @@ import {
ChevronDown, ChevronUp, Link2, ArrowRight,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
+import ResizableModal from '../shared/ResizableModal';
/* ─── Types ──────────────────────────────────────────────────────────────── */
@@ -782,61 +783,46 @@ export default function ContactsTab() {
</AnimatePresence>
{/* ── Import Modal ───────────────────────────────────────────────── */}
- <AnimatePresence>
- {showImport && (
- <motion.div
- initial={{ opacity: 0 }}
- animate={{ opacity: 1 }}
- exit={{ opacity: 0 }}
- style={{ position: 'fixed', inset: 0, zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
+ {showImport && (
+ <ResizableModal
+ modalId="contacts-import-csv"
+ title="Import LinkedIn CSV"
+ onClose={() => setShowImport(false)}
+ defaultWidth={520}
+ >
+ <div
+ onClick={() => fileRef.current?.click()}
+ style={{
+ border: '2px dashed var(--color-border)',
+ borderRadius: 12, padding: 32, textAlign: 'center', cursor: 'pointer',
+ transition: 'border-color 0.15s',
+ }}
+ onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--color-primary)'; }}
+ onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--color-border)'; }}
>
- <div style={{ position: 'absolute', inset: 0, backgroundColor: 'rgba(0,0,0,0.6)' }} onClick={() => setShowImport(false)} />
- <motion.div
- initial={{ scale: 0.95, opacity: 0 }}
- animate={{ scale: 1, opacity: 1 }}
- exit={{ scale: 0.95, opacity: 0 }}
- className="card"
- style={{ position: 'relative', padding: 24, width: 440, zIndex: 1 }}
- >
- <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
- <h3 style={{ fontSize: 16, fontWeight: 600, color: 'var(--color-text)' }}>Import LinkedIn CSV</h3>
- <button className="btn btn-ghost btn-sm" onClick={() => setShowImport(false)}><X size={16} /></button>
- </div>
- <div
- onClick={() => fileRef.current?.click()}
- style={{
- border: '2px dashed var(--color-border)',
- borderRadius: 12, padding: 32, textAlign: 'center', cursor: 'pointer',
- transition: 'border-color 0.15s',
- }}
- onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--color-primary)'; }}
- onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--color-border)'; }}
- >
- <Upload size={32} style={{ margin: '0 auto 8px', color: 'var(--color-text-muted)' }} />
- <div style={{ fontSize: 13, fontWeight: 500, color: 'var(--color-text)' }}>Click to upload CSV</div>
- <div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 4 }}>
- LinkedIn Connections.csv (First Name, Last Name, Email, Company, Position)
- </div>
- </div>
- <input
- ref={fileRef}
- type="file"
- accept=".csv"
- style={{ display: 'none' }}
- onChange={e => {
- const file = e.target.files?.[0];
- if (file) handleImport(file);
- }}
- />
- {importing && (
- <div style={{ marginTop: 16, display: 'flex', alignItems: 'center', gap: 8, justifyContent: 'center', color: 'var(--color-primary)' }}>
- <Loader2 size={16} className="spinner" /> Importing...
- </div>
- )}
- </motion.div>
- </motion.div>
- )}
- </AnimatePresence>
+ <Upload size={32} style={{ margin: '0 auto 8px', color: 'var(--color-text-muted)' }} />
+ <div style={{ fontSize: 13, fontWeight: 500, color: 'var(--color-text)' }}>Click to upload CSV</div>
+ <div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 4 }}>
+ LinkedIn Connections.csv (First Name, Last Name, Email, Company, Position)
+ </div>
+ </div>
+ <input
+ ref={fileRef}
+ type="file"
+ accept=".csv"
+ style={{ display: 'none' }}
+ onChange={e => {
+ const file = e.target.files?.[0];
+ if (file) handleImport(file);
+ }}
+ />
+ {importing && (
+ <div style={{ marginTop: 16, display: 'flex', alignItems: 'center', gap: 8, justifyContent: 'center', color: 'var(--color-primary)' }}>
+ <Loader2 size={16} className="spinner" /> Importing...
+ </div>
+ )}
+ </ResizableModal>
+ )}
</div>
);
}
diff --git a/components/email-analyzer/CompareModal.tsx b/components/email-analyzer/CompareModal.tsx
index 8bba0e5..b81bbbb 100644
--- a/components/email-analyzer/CompareModal.tsx
+++ b/components/email-analyzer/CompareModal.tsx
@@ -1,10 +1,10 @@
'use client';
-import { X } from 'lucide-react';
import type { VersionEntry } from './VersionTimeline';
import ProsConsPanel, { type ProsConsData } from './ProsConsPanel';
import { sanitizeHtml } from './sanitize';
import ScorePanel from './ScorePanel';
+import ResizableModal from '../shared/ResizableModal';
/* ─── Types ──────────────────────────────────────────────────────────────── */
@@ -29,66 +29,13 @@ export default function CompareModal({
onApplySuggestion,
}: CompareModalProps) {
return (
- <div
- style={{
- position: 'fixed',
- inset: 0,
- zIndex: 100,
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- backgroundColor: 'rgba(0,0,0,0.3)',
- }}
- onClick={onClose}
+ <ResizableModal
+ modalId="email-analyzer-compare-versions"
+ title={`Compare: v${compareA.versionNumber} vs v${compareB.versionNumber}`}
+ onClose={onClose}
+ defaultWidth={1000}
>
- <div
- onClick={(e) => e.stopPropagation()}
- style={{
- width: '90%',
- maxWidth: 1000,
- maxHeight: '85vh',
- overflowY: 'auto',
- backgroundColor: 'var(--color-surface)',
- border: '1px solid var(--color-border)',
- borderRadius: 16,
- boxShadow: '0 12px 48px rgba(0,0,0,0.12)',
- }}
- >
- {/* Header */}
- <div
- style={{
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'space-between',
- padding: '14px 20px',
- borderBottom: '1px solid var(--color-border)',
- }}
- >
- <h3
- style={{
- fontSize: 15,
- fontWeight: 700,
- color: 'var(--color-text)',
- margin: 0,
- }}
- >
- Compare: v{compareA.versionNumber} vs v{compareB.versionNumber}
- </h3>
- <button
- type="button"
- onClick={onClose}
- style={{
- background: 'none',
- border: 'none',
- cursor: 'pointer',
- color: 'var(--color-text-muted)',
- padding: 4,
- }}
- >
- <X size={16} />
- </button>
- </div>
-
+ <div style={{ margin: '-16px -20px' }}>
{/* Side-by-side content */}
<div style={{ display: 'flex', minHeight: 300 }}>
<div
@@ -193,6 +140,6 @@ export default function CompareModal({
/>
</div>
</div>
- </div>
+ </ResizableModal>
);
}
diff --git a/components/email-sends/ComposeModal.tsx b/components/email-sends/ComposeModal.tsx
index 01ab104..f73a170 100644
--- a/components/email-sends/ComposeModal.tsx
+++ b/components/email-sends/ComposeModal.tsx
@@ -1,7 +1,6 @@
'use client';
import { useState, useRef, useEffect, useCallback } from 'react';
-import { motion, AnimatePresence } from 'framer-motion';
import {
X,
Loader2,
@@ -33,6 +32,7 @@ import { buildEml, htmlToText } from '@/lib/export';
import EmojiPicker from './EmojiPicker';
import ImageDropZone, { useImageDrop, type InlineImage } from './ImageDropZone';
import HTMLPreview from './HTMLPreview';
+import ResizableModal from '../shared/ResizableModal';
/* ─── Types ──────────────────────────────────────────────────────────────── */
@@ -523,92 +523,54 @@ Return ONLY valid JSON in this exact format:
const showEditor = previewMode !== 'toggle';
const showPreview = previewMode !== 'off';
+ if (!open) return null;
+
return (
- <AnimatePresence>
- {open && (
- <motion.div
- initial={{ opacity: 0 }}
- animate={{ opacity: 1 }}
- exit={{ opacity: 0 }}
- style={{
- position: 'fixed', inset: 0, zIndex: 100,
- display: 'flex', alignItems: 'center', justifyContent: 'center',
- backgroundColor: 'rgba(0,0,0,0.35)',
- }}
- onClick={handleClose}
- >
- <motion.div
- initial={{ scale: 0.95, opacity: 0 }}
- animate={{ scale: 1, opacity: 1 }}
- exit={{ scale: 0.95, opacity: 0 }}
- onClick={e => e.stopPropagation()}
- style={{
- backgroundColor: 'var(--color-surface)',
- border: '1px solid var(--color-border)',
- borderRadius: 16,
- width: '100%',
- maxWidth: previewMode === 'split' ? 1200 : 900,
- maxHeight: '90vh',
- overflowY: 'auto',
- boxShadow: '0 12px 48px rgba(0,0,0,0.15)',
- transition: 'max-width 300ms ease',
- }}
- >
- {/* ── Header ──────────────────────────────────────────────────── */}
- <div
+ <ResizableModal
+ modalId="email-compose"
+ title={
+ <span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
+ <Mail size={18} style={{ color: 'var(--color-primary)' }} />
+ <span style={{ fontSize: 15, fontWeight: 700, color: 'var(--color-text)' }}>
+ Compose Email
+ </span>
+ {cached && cached.bodyText && (
+ <span style={{
+ fontSize: 11, color: 'var(--color-primary)', fontWeight: 500,
+ padding: '2px 8px', borderRadius: 9999,
+ backgroundColor: 'var(--color-primary-dim)',
+ border: '1px solid rgba(10,124,89,0.25)',
+ }}>
+ Draft restored
+ </span>
+ )}
+ {(subject || topic || bodyText) && (
+ <button
+ onClick={() => {
+ clearCache();
+ setSubject(''); setTopic(''); setBodyText('');
+ setSelectedType('statements'); setTone('formal'); setParagraphs(3);
+ setAudiences(new Set(['all-supporters'])); setCustomEmail('');
+ setInlineImages([]);
+ addToast('Draft discarded', 'info');
+ }}
style={{
- display: 'flex', alignItems: 'center', justifyContent: 'space-between',
- padding: '16px 24px',
- borderBottom: '1px solid var(--color-border)',
+ background: 'none', border: '1px solid var(--color-border)',
+ borderRadius: 6, cursor: 'pointer', color: 'var(--color-text-muted)',
+ padding: '4px 10px', fontSize: 11, fontWeight: 500,
+ marginLeft: 8,
}}
>
- <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
- <Mail size={18} style={{ color: 'var(--color-primary)' }} />
- <h3 style={{ fontSize: 15, fontWeight: 700, color: 'var(--color-text)', margin: 0 }}>
- Compose Email
- </h3>
- {cached && cached.bodyText && (
- <span style={{
- fontSize: 11, color: 'var(--color-primary)', fontWeight: 500,
- padding: '2px 8px', borderRadius: 9999,
- backgroundColor: 'var(--color-primary-dim)',
- border: '1px solid rgba(10,124,89,0.25)',
- }}>
- Draft restored
- </span>
- )}
- </div>
- <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
- {(subject || topic || bodyText) && (
- <button
- onClick={() => {
- clearCache();
- setSubject(''); setTopic(''); setBodyText('');
- setSelectedType('statements'); setTone('formal'); setParagraphs(3);
- setAudiences(new Set(['all-supporters'])); setCustomEmail('');
- setInlineImages([]);
- addToast('Draft discarded', 'info');
- }}
- style={{
- background: 'none', border: '1px solid var(--color-border)',
- borderRadius: 6, cursor: 'pointer', color: 'var(--color-text-muted)',
- padding: '4px 10px', fontSize: 11, fontWeight: 500,
- }}
- >
- Discard
- </button>
- )}
- <button
- onClick={handleClose}
- style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', padding: 4 }}
- >
- <X size={16} />
- </button>
- </div>
- </div>
-
- {/* ── Body: Two columns ───────────────────────────────────────── */}
- <div style={{ display: 'flex', minHeight: 480 }}>
+ Discard
+ </button>
+ )}
+ </span>
+ }
+ onClose={handleClose}
+ defaultWidth={previewMode === 'split' ? 1200 : 900}
+ >
+ {/* ── Body: Two columns ───────────────────────────────────────── */}
+ <div style={{ display: 'flex', minHeight: 480, margin: '-16px -20px' }}>
{/* LEFT — editor area (60% normally, flexible in split mode) */}
<div style={{
@@ -1212,9 +1174,6 @@ Return ONLY valid JSON in this exact format:
)}
</div>
</div>
- </motion.div>
- </motion.div>
- )}
- </AnimatePresence>
+ </ResizableModal>
);
}
diff --git a/components/email-sends/MemberEmailModal.tsx b/components/email-sends/MemberEmailModal.tsx
index c3df925..a6cdc6f 100644
--- a/components/email-sends/MemberEmailModal.tsx
+++ b/components/email-sends/MemberEmailModal.tsx
@@ -1,8 +1,7 @@
'use client';
-import { useState, useCallback } from 'react';
+import { useState } from 'react';
import {
- X,
Loader2,
Sparkles,
Send,
@@ -20,6 +19,7 @@ import {
} from 'lucide-react';
import { useToast } from '@/components/ToastProvider';
import { useOrg } from '@/components/OrgProvider';
+import ResizableModal from '@/components/shared/ResizableModal';
/* ─── Types ──────────────────────────────────────────────────────────────── */
@@ -111,13 +111,6 @@ export default function MemberEmailModal({ open, onClose }: MemberEmailModalProp
const [editBody, setEditBody] = useState('');
const [copied, setCopied] = useState(false);
- const handleBackdropClick = useCallback(
- (e: React.MouseEvent) => {
- if (e.target === e.currentTarget) onClose();
- },
- [onClose],
- );
-
/* ── Generate email via API ─────────────────────────────────────────────── */
async function handleGenerate() {
if (!topic.trim()) {
@@ -194,114 +187,80 @@ export default function MemberEmailModal({ open, onClose }: MemberEmailModalProp
const activeBody = editMode ? editBody : generatedBody;
return (
- <div
- onClick={handleBackdropClick}
- style={{
- position: 'fixed',
- inset: 0,
- zIndex: 200,
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- backgroundColor: 'rgba(0, 0, 0, 0.5)',
- backdropFilter: 'blur(4px)',
- }}
+ <ResizableModal
+ modalId="member-email-generator"
+ title={
+ <span style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
+ <span
+ style={{
+ width: 28,
+ height: 28,
+ borderRadius: 8,
+ background: 'linear-gradient(135deg, #10b981, #059669)',
+ display: 'inline-flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ flexShrink: 0,
+ }}
+ >
+ <Sparkles size={15} style={{ color: '#fff' }} />
+ </span>
+ <span style={{ display: 'flex', flexDirection: 'column', lineHeight: 1.15 }}>
+ <span style={{ fontSize: 14, fontWeight: 700, color: 'var(--color-text)' }}>
+ AI Member Email Generator
+ </span>
+ <span style={{ fontSize: 11, color: 'var(--color-text-muted)', fontWeight: 400 }}>
+ Generate and send targeted emails to your members
+ </span>
+ </span>
+ </span>
+ }
+ onClose={onClose}
+ defaultWidth={hasGenerated ? 960 : 580}
+ footer={
+ hasGenerated ? (
+ <>
+ <button
+ type="button"
+ onClick={handleReset}
+ className="btn btn-ghost btn-sm"
+ style={{ fontSize: 12, color: 'var(--color-text-muted)', marginRight: 'auto' }}
+ >
+ Start Over
+ </button>
+ <button
+ type="button"
+ onClick={handleSend}
+ className="btn btn-primary"
+ style={{ gap: 8, fontSize: 13, fontWeight: 700 }}
+ >
+ <Send size={14} />
+ Send to Members
+ </button>
+ </>
+ ) : undefined
+ }
>
<div
- role="dialog"
- aria-modal="true"
- aria-label="AI Member Email Generator"
style={{
- width: '100%',
- maxWidth: hasGenerated ? 960 : 580,
- maxHeight: '90vh',
display: 'flex',
- flexDirection: 'column',
- backgroundColor: 'var(--color-surface)',
- border: '1px solid var(--color-border)',
- borderRadius: 'var(--radius-lg)',
- boxShadow: '0 20px 60px rgba(0, 0, 0, 0.4)',
- overflow: 'hidden',
- transition: 'max-width 0.3s ease',
+ gap: 0,
+ marginLeft: -20,
+ marginRight: -20,
+ marginTop: -16,
+ marginBottom: -16,
}}
>
- {/* ── Header ──────────────────────────────────────────────────────────── */}
- <div
- style={{
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'space-between',
- padding: '16px 20px 12px',
- borderBottom: '1px solid var(--color-border)',
- flexShrink: 0,
- }}
- >
- <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
- <div
- style={{
- width: 32,
- height: 32,
- borderRadius: 8,
- background: 'linear-gradient(135deg, #10b981, #059669)',
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- }}
- >
- <Sparkles size={16} style={{ color: '#fff' }} />
- </div>
- <div>
- <h2
- style={{
- fontSize: 16,
- fontWeight: 700,
- color: 'var(--color-text)',
- margin: 0,
- }}
- >
- AI Member Email Generator
- </h2>
- <p
- style={{
- fontSize: 11,
- color: 'var(--color-text-muted)',
- margin: '1px 0 0',
- }}
- >
- Generate and send targeted emails to your members
- </p>
- </div>
- </div>
- <button
- type="button"
- onClick={onClose}
- aria-label="Close"
- className="btn btn-ghost btn-sm"
- style={{ padding: 6 }}
- >
- <X size={18} />
- </button>
- </div>
-
- {/* ── Content area ────────────────────────────────────────────────────── */}
+ {/* ── Left: Form ────────────────────────────────────────────────────── */}
<div
style={{
- flex: 1,
+ flex: hasGenerated ? '0 0 380px' : '1',
+ padding: '16px 20px 20px',
overflowY: 'auto',
- display: 'flex',
- gap: 0,
+ borderRight: hasGenerated ? '1px solid var(--color-border)' : 'none',
+ transition: 'flex 0.3s ease',
}}
>
- {/* ── Left: Form ────────────────────────────────────────────────────── */}
- <div
- style={{
- flex: hasGenerated ? '0 0 380px' : '1',
- padding: '16px 20px 20px',
- overflowY: 'auto',
- borderRight: hasGenerated ? '1px solid var(--color-border)' : 'none',
- transition: 'flex 0.3s ease',
- }}
- >
{/* Email Type selector */}
<label
style={{
@@ -691,39 +650,6 @@ export default function MemberEmailModal({ open, onClose }: MemberEmailModalProp
</div>
)}
</div>
-
- {/* ── Footer ──────────────────────────────────────────────────────────── */}
- {hasGenerated && (
- <div
- style={{
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'space-between',
- padding: '12px 20px',
- borderTop: '1px solid var(--color-border)',
- flexShrink: 0,
- }}
- >
- <button
- type="button"
- onClick={handleReset}
- className="btn btn-ghost btn-sm"
- style={{ fontSize: 12, color: 'var(--color-text-muted)' }}
- >
- Start Over
- </button>
- <button
- type="button"
- onClick={handleSend}
- className="btn btn-primary"
- style={{ gap: 8, fontSize: 13, fontWeight: 700 }}
- >
- <Send size={14} />
- Send to Members
- </button>
- </div>
- )}
- </div>
- </div>
+ </ResizableModal>
);
}
diff --git a/components/geo/AdvocacyTargets.tsx b/components/geo/AdvocacyTargets.tsx
index 44b2968..1caba16 100644
--- a/components/geo/AdvocacyTargets.tsx
+++ b/components/geo/AdvocacyTargets.tsx
@@ -1,8 +1,9 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
-import { Target, ChevronDown, ChevronUp, AlertTriangle, FileText, X } from 'lucide-react';
+import { Target, ChevronDown, ChevronUp, AlertTriangle, FileText } from 'lucide-react';
import { useToast } from '@/components/ToastProvider';
+import ResizableModal from '../shared/ResizableModal';
/* ─── Types ──────────────────────────────────────────────────────────────── */
interface ContributingFactor {
@@ -91,64 +92,13 @@ We urge immediate action to address these critical issues in our community.
[Organization]`;
return (
- <div
- style={{
- position: 'fixed',
- inset: 0,
- backgroundColor: 'rgba(0,0,0,0.6)',
- zIndex: 100,
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- padding: 20,
- }}
- onClick={onClose}
- >
- <div
- style={{
- backgroundColor: 'var(--color-surface)',
- border: '1px solid var(--color-border)',
- borderRadius: 16,
- padding: 24,
- maxWidth: 540,
- width: '100%',
- maxHeight: '80vh',
- overflow: 'auto',
- }}
- onClick={(e) => e.stopPropagation()}
- >
- <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
- <h3 style={{ fontSize: 15, fontWeight: 700, color: 'var(--color-text)' }}>
- Petition Template — {target.zip}
- </h3>
- <button
- onClick={onClose}
- style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', padding: 4 }}
- aria-label="Close modal"
- >
- <X size={18} />
- </button>
- </div>
- <textarea
- readOnly
- value={template}
- aria-label="Generated advocacy template"
- style={{
- width: '100%',
- height: 300,
- padding: '12px 14px',
- borderRadius: 8,
- border: '1px solid var(--color-border)',
- backgroundColor: 'var(--color-surface-el)',
- color: 'var(--color-text)',
- fontSize: 12,
- lineHeight: 1.7,
- resize: 'vertical',
- fontFamily: 'inherit',
- outline: 'none',
- }}
- />
- <div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
+ <ResizableModal
+ modalId="advocacy-petition-template"
+ title={`Petition Template — ${target.zip}`}
+ onClose={onClose}
+ defaultWidth={540}
+ footer={
+ <>
<button
onClick={() => { navigator.clipboard.writeText(template); }}
style={{
@@ -179,9 +129,29 @@ We urge immediate action to address these critical issues in our community.
>
Close
</button>
- </div>
- </div>
- </div>
+ </>
+ }
+ >
+ <textarea
+ readOnly
+ value={template}
+ aria-label="Generated advocacy template"
+ style={{
+ width: '100%',
+ height: 300,
+ padding: '12px 14px',
+ borderRadius: 8,
+ border: '1px solid var(--color-border)',
+ backgroundColor: 'var(--color-surface-el)',
+ color: 'var(--color-text)',
+ fontSize: 12,
+ lineHeight: 1.7,
+ resize: 'vertical',
+ fontFamily: 'inherit',
+ outline: 'none',
+ }}
+ />
+ </ResizableModal>
);
}
diff --git a/components/grants/AddGrantModal.tsx b/components/grants/AddGrantModal.tsx
index fe9a48d..f4a8f23 100644
--- a/components/grants/AddGrantModal.tsx
+++ b/components/grants/AddGrantModal.tsx
@@ -1,9 +1,9 @@
'use client';
import { useState, useEffect, useRef } from 'react';
-import { motion, AnimatePresence } from 'framer-motion';
-import { X, Plus, Award, Info } from 'lucide-react';
+import { Plus, Award, Info } from 'lucide-react';
import type { Grant } from './GrantCard';
+import ResizableModal from '../shared/ResizableModal';
/* ─── Types ──────────────────────────────────────────────────────────────── */
interface AddGrantModalProps {
@@ -164,13 +164,7 @@ export default function AddGrantModal({
if (isOpen) setTimeout(() => firstInputRef.current?.focus(), 80);
}, [isOpen]);
- /* Escape closes */
- useEffect(() => {
- if (!isOpen) return;
- const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
- document.addEventListener('keydown', handler);
- return () => document.removeEventListener('keydown', handler);
- }, [isOpen, onClose]);
+ /* Escape closes — handled by ResizableModal */
function set(field: keyof FormState, value: string) {
setForm((prev) => ({ ...prev, [field]: value }));
@@ -265,79 +259,57 @@ export default function AddGrantModal({
}
/* ─── Render ──────────────────────────────────────────────────────────── */
+ if (!isOpen) return null;
+
return (
- <AnimatePresence>
- {isOpen && (
+ <ResizableModal
+ modalId={isEditMode ? 'grant-edit' : 'grant-create'}
+ title={
+ <span className="flex items-center gap-2">
+ <Award size={14} style={{ color: 'var(--color-primary)' }} aria-hidden="true" />
+ {isEditMode ? 'Edit Grant' : 'Add Grant'}
+ </span>
+ }
+ onClose={onClose}
+ defaultWidth={780}
+ footer={
<>
- {/* Backdrop */}
- <motion.div
- key="backdrop"
- initial={{ opacity: 0 }}
- animate={{ opacity: 1 }}
- exit={{ opacity: 0 }}
- transition={{ duration: 0.18 }}
+ <button
+ type="button"
onClick={onClose}
- style={{
- position: 'fixed', inset: 0,
- backgroundColor: 'rgba(0,0,0,0.65)',
- backdropFilter: 'blur(3px)',
- zIndex: 40,
- }}
- aria-hidden="true"
- />
-
- {/* Modal panel */}
- <motion.div
- key="modal"
- initial={{ opacity: 0, scale: 0.96, y: 12 }}
- animate={{ opacity: 1, scale: 1, y: 0 }}
- exit={{ opacity: 0, scale: 0.96, y: 12 }}
- transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
- role="dialog"
- aria-modal="true"
- aria-label={isEditMode ? 'Edit grant' : 'Add grant'}
- style={{
- position: 'fixed', inset: 0,
- display: 'flex', alignItems: 'flex-start', justifyContent: 'center',
- zIndex: 50,
- padding: '1rem',
- paddingTop: 'clamp(1rem, 5vh, 3rem)',
- overflowY: 'auto',
- }}
+ disabled={submitting}
+ className="btn btn-secondary"
+ >
+ Cancel
+ </button>
+ <button
+ type="button"
+ onClick={() => handleSubmit({ preventDefault: () => {} } as React.FormEvent)}
+ disabled={submitting}
+ className="btn btn-primary"
>
- <div
- style={{
- backgroundColor: 'var(--color-surface)',
- border: '1px solid var(--color-border)',
- borderRadius: 'var(--radius-lg)',
- width: '100%', maxWidth: 780,
- boxShadow: '0 24px 60px rgba(0,0,0,0.5)',
- marginBottom: '2rem',
- }}
- >
- {/* Header */}
- <div
- className="flex items-center justify-between px-5 py-4"
- style={{ borderBottom: '1px solid var(--color-border)' }}
- >
- <div className="flex items-center gap-2">
- <Award size={16} style={{ color: 'var(--color-primary)' }} aria-hidden="true" />
- <h2
- id="grant-modal-title"
- className="font-semibold text-sm"
- style={{ color: 'var(--color-text)' }}
- >
- {isEditMode ? 'Edit Grant' : 'Add Grant'}
- </h2>
- </div>
- <button type="button" onClick={onClose} className="btn btn-ghost btn-sm" aria-label="Close modal">
- <X size={15} aria-hidden="true" />
- </button>
- </div>
-
- {/* Form */}
- <form onSubmit={handleSubmit} noValidate>
- <div className="px-5 py-5">
+ {submitting ? (
+ <>
+ <span className="spinner" style={{ width: 14, height: 14, borderWidth: 2 }} aria-hidden="true" />
+ {isEditMode ? 'Saving...' : 'Adding...'}
+ </>
+ ) : isEditMode ? (
+ 'Save Changes'
+ ) : (
+ <>
+ <Plus size={15} aria-hidden="true" />
+ Add Grant
+ </>
+ )}
+ </button>
+ </>
+ }
+ >
+ <form
+ onSubmit={handleSubmit}
+ noValidate
+ aria-labelledby="grant-modal-title"
+ >
{/* Usage guidance */}
<div style={{ backgroundColor: 'rgba(16, 185, 129, 0.08)', borderLeft: '3px solid var(--color-primary)', borderRadius: 'var(--radius-sm)', padding: '10px 14px', marginBottom: '4px', fontSize: '13px', color: 'var(--color-text-secondary)', lineHeight: '1.5', display: 'flex', gap: 8, alignItems: 'flex-start' }}>
<Info size={14} style={{ color: 'var(--color-primary)', flexShrink: 0, marginTop: 1 }} />
@@ -623,42 +595,7 @@ export default function AddGrantModal({
</div>
</div>
</div>
- </div>
-
- {/* Footer */}
- <div
- className="flex items-center justify-end gap-2 px-5 py-4"
- style={{ borderTop: '1px solid var(--color-border)' }}
- >
- <button
- type="button"
- onClick={onClose}
- disabled={submitting}
- className="btn btn-secondary"
- >
- Cancel
- </button>
- <button type="submit" disabled={submitting} className="btn btn-primary">
- {submitting ? (
- <>
- <span className="spinner" style={{ width: 14, height: 14, borderWidth: 2 }} aria-hidden="true" />
- {isEditMode ? 'Saving...' : 'Adding...'}
- </>
- ) : isEditMode ? (
- 'Save Changes'
- ) : (
- <>
- <Plus size={15} aria-hidden="true" />
- Add Grant
- </>
- )}
- </button>
- </div>
- </form>
- </div>
- </motion.div>
- </>
- )}
- </AnimatePresence>
+ </form>
+ </ResizableModal>
);
}
diff --git a/components/intelligence/InTheNewsTab.tsx b/components/intelligence/InTheNewsTab.tsx
index e90d424..2fbaf0d 100644
--- a/components/intelligence/InTheNewsTab.tsx
+++ b/components/intelligence/InTheNewsTab.tsx
@@ -20,12 +20,13 @@ import { motion, AnimatePresence } from 'framer-motion';
import {
Newspaper, Users, TrendingUp,
ArrowUp, ArrowDown, Mail, FileText, AlertTriangle,
- Radio, Clock, Search, X, ExternalLink, Tag, BookOpen,
+ Radio, Clock, Search, ExternalLink, Tag, BookOpen,
CheckCircle, Loader, Link2, ChevronDown, ChevronRight,
Sparkles,
} from 'lucide-react';
import { useToast } from '@/components/ToastProvider';
import { getOutletFaviconUrl } from '@/lib/outlet-logo';
+import ResizableModal from '@/components/shared/ResizableModal';
/* ─── Types ──────────────────────────────────────────────────────────────── */
@@ -411,13 +412,6 @@ function DiscoverModal({
inputRef.current?.focus();
}, []);
- // Close on Escape
- useEffect(() => {
- const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
- window.addEventListener('keydown', handler);
- return () => window.removeEventListener('keydown', handler);
- }, [onClose]);
-
const handleSearch = useCallback(async () => {
const name = searchName.trim();
if (name.length < 2) return;
@@ -484,66 +478,35 @@ function DiscoverModal({
const relevancePct = (score: number) => Math.round(score * 100);
return (
- <div
- style={{
- position: 'fixed', inset: 0, zIndex: 200,
- display: 'flex', alignItems: 'flex-start', justifyContent: 'center',
- paddingTop: 60, paddingBottom: 20,
- background: 'rgba(0,0,0,0.65)', backdropFilter: 'blur(6px)',
- overflowY: 'auto',
- }}
- onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
- role="dialog"
- aria-modal="true"
- aria-label="Discover journalist articles"
- >
- <motion.div
- initial={{ opacity: 0, scale: 0.95, y: -20 }}
- animate={{ opacity: 1, scale: 1, y: 0 }}
- exit={{ opacity: 0, scale: 0.95, y: -20 }}
- transition={{ duration: 0.2 }}
- style={{
- background: 'var(--color-surface)',
- border: '1px solid var(--color-border)',
- borderRadius: 'var(--radius-lg)',
- width: '100%', maxWidth: 720,
- margin: '0 16px',
- overflow: 'hidden',
- }}
- >
- {/* Header */}
- <div style={{
- padding: '20px 24px 16px',
- borderBottom: '1px solid var(--color-border)',
- display: 'flex', alignItems: 'center', gap: 12,
- }}>
- <div style={{
- width: 38, height: 38, borderRadius: 10,
+ <ResizableModal
+ modalId="intelligence-discover-articles"
+ title={
+ <span style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
+ <span style={{
+ width: 28, height: 28, borderRadius: 8,
background: 'linear-gradient(135deg, #10b981, #6366f1)',
- display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
- <Sparkles size={18} color="#fff" />
- </div>
- <div style={{ flex: 1 }}>
- <h3 style={{ fontSize: 16, fontWeight: 700, color: 'var(--color-text)', margin: 0 }}>
- Discover Journalist Articles
- </h3>
- <p style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 2 }}>
- Search by journalist name to find relevant articles and link them to your feed
- </p>
- </div>
- <button
- className="btn btn-ghost"
- onClick={onClose}
- style={{ padding: 6, flexShrink: 0 }}
- aria-label="Close discover modal"
- >
- <X size={18} />
- </button>
- </div>
+ <Sparkles size={14} color="#fff" />
+ </span>
+ Discover Journalist Articles
+ </span>
+ }
+ onClose={onClose}
+ defaultWidth={720}
+ footer={
+ <button className="btn btn-ghost btn-sm" onClick={onClose}>
+ Close
+ </button>
+ }
+ >
+ <div>
+ <p style={{ fontSize: 12, color: 'var(--color-text-muted)', margin: '0 0 12px' }}>
+ Search by journalist name to find relevant articles and link them to your feed
+ </p>
{/* Search bar */}
- <div style={{ padding: '16px 24px', borderBottom: '1px solid var(--color-border)' }}>
+ <div style={{ paddingBottom: 16, borderBottom: '1px solid var(--color-border)', marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ position: 'relative', flex: 1 }}>
<Search
@@ -574,7 +537,7 @@ function DiscoverModal({
</div>
{/* Results */}
- <div style={{ maxHeight: '60vh', overflowY: 'auto', padding: '16px 24px' }}>
+ <div>
{/* Meta info */}
{result && (
<div style={{ marginBottom: 12, display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
@@ -796,19 +759,8 @@ function DiscoverModal({
</div>
)}
</div>
-
- {/* Footer */}
- <div style={{
- padding: '12px 24px',
- borderTop: '1px solid var(--color-border)',
- display: 'flex', justifyContent: 'flex-end', gap: 8,
- }}>
- <button className="btn btn-ghost btn-sm" onClick={onClose}>
- Close
- </button>
- </div>
- </motion.div>
- </div>
+ </div>
+ </ResizableModal>
);
}
diff --git a/components/journalists/JournalistHub.tsx b/components/journalists/JournalistHub.tsx
index 15dfa1d..e4bcb28 100644
--- a/components/journalists/JournalistHub.tsx
+++ b/components/journalists/JournalistHub.tsx
@@ -4,12 +4,13 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Search, Filter, UserPlus, Newspaper, Building2, Tag, Calendar,
- ArrowLeft, ExternalLink, ChevronDown, ChevronRight, X, Users, BookOpen, TrendingUp,
+ ArrowLeft, ExternalLink, ChevronDown, ChevronRight, Users, BookOpen, TrendingUp,
Mail, Twitter, RefreshCw, SortAsc, Download, CheckSquare, Square,
Network, Lightbulb, Contact, Globe, Play, Radio, Rss, Share2, Info,
} from 'lucide-react';
import { useToast } from '@/components/ToastProvider';
import { getOutletFaviconUrl } from '@/lib/outlet-logo';
+import ResizableModal from '@/components/shared/ResizableModal';
import JournalistMindMap from './JournalistMindMap';
import JournalistEntityFlow from './JournalistEntityFlow';
import ReporterSearch from './ReporterSearch';
@@ -1019,149 +1020,119 @@ function ArticlesPanel({ articles }: { articles: Article[] }) {
/* ═══════════════════════════════════════════════════════════════════════════ */
function ArticleDetailModal({ article: a, onClose }: { article: Article; onClose: () => void }) {
return (
- <div
- style={{
- position: 'fixed', inset: 0, zIndex: 100,
- display: 'flex', alignItems: 'center', justifyContent: 'center',
- background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)',
- }}
- onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
+ <ResizableModal
+ modalId="journalist-article-detail"
+ title={
+ <span style={{ display: 'inline-block', borderLeft: `3px solid ${SENTIMENT_COLORS[a.sentiment] || '#6366f1'}`, paddingLeft: 10, fontSize: 16, fontWeight: 700 }}>
+ {a.title}
+ </span>
+ }
+ onClose={onClose}
+ defaultWidth={640}
>
- <motion.div
- initial={{ opacity: 0, scale: 0.95, y: 20 }}
- animate={{ opacity: 1, scale: 1, y: 0 }}
- exit={{ opacity: 0, scale: 0.95, y: 20 }}
- style={{
- background: 'var(--color-surface)',
- border: '1px solid var(--color-border)',
- borderRadius: 'var(--radius-lg)',
- width: '100%', maxWidth: 640, maxHeight: '85vh',
- overflow: 'auto', padding: 0,
- }}
- >
- {/* Header with sentiment stripe */}
- <div style={{
- borderTop: `3px solid ${SENTIMENT_COLORS[a.sentiment] || '#6366f1'}`,
- padding: '20px 24px 16px',
- borderBottom: '1px solid var(--color-border)',
+ {/* Meta */}
+ <div className="flex flex-wrap items-center gap-3 mb-3" style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
+ <span style={{ fontWeight: 600, color: 'var(--color-primary)' }}>
+ {a.outlet && (
+ <img
+ src={getOutletFaviconUrl(a.outlet, 24) || ''}
+ alt=""
+ width={14}
+ height={14}
+ style={{ borderRadius: 3, verticalAlign: 'middle', marginRight: 4, display: 'inline-block' }}
+ onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
+ />
+ )}
+ {a.outlet}
+ </span>
+ <span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
+ <Calendar size={12} />
+ {new Date(a.published_date).toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
+ </span>
+ </div>
+
+ {/* Stats bar */}
+ <div className="flex flex-wrap items-center gap-4 mb-4" style={{ fontSize: 12, paddingBottom: 12, borderBottom: '1px solid var(--color-border)' }}>
+ {a.word_count > 0 && (
+ <span style={{ display: 'flex', alignItems: 'center', gap: 4, color: 'var(--color-text-secondary)' }}>
+ <BookOpen size={13} />
+ {a.word_count.toLocaleString()} words
+ <span style={{ color: 'var(--color-text-muted)' }}>({Math.ceil(a.word_count / 250)} min read)</span>
+ </span>
+ )}
+ <span className="badge" style={{
+ fontSize: 10, padding: '2px 8px',
+ backgroundColor: `${SENTIMENT_COLORS[a.sentiment]}20`,
+ color: SENTIMENT_COLORS[a.sentiment],
+ border: `1px solid ${SENTIMENT_COLORS[a.sentiment]}40`,
}}>
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
- <div style={{ flex: 1 }}>
- <h2 style={{ fontSize: 18, fontWeight: 700, color: 'var(--color-text)', lineHeight: 1.3, margin: 0 }}>
- {a.title}
- </h2>
- <div className="flex flex-wrap items-center gap-3 mt-2" style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
- <span style={{ fontWeight: 600, color: 'var(--color-primary)' }}>
- {a.outlet && (
- <img
- src={getOutletFaviconUrl(a.outlet, 24) || ''}
- alt=""
- width={14}
- height={14}
- style={{ borderRadius: 3, verticalAlign: 'middle', marginRight: 4, display: 'inline-block' }}
- onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
- />
- )}
- {a.outlet}
- </span>
- <span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
- <Calendar size={12} />
- {new Date(a.published_date).toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
- </span>
- </div>
- </div>
- <button onClick={onClose} className="btn btn-ghost" style={{ padding: 4, flexShrink: 0 }}>
- <X size={18} />
- </button>
- </div>
+ {a.sentiment}
+ </span>
+ </div>
- {/* Stats bar */}
- <div className="flex flex-wrap items-center gap-4 mt-3" style={{ fontSize: 12 }}>
- {a.word_count > 0 && (
- <span style={{ display: 'flex', alignItems: 'center', gap: 4, color: 'var(--color-text-secondary)' }}>
- <BookOpen size={13} />
- {a.word_count.toLocaleString()} words
- <span style={{ color: 'var(--color-text-muted)' }}>({Math.ceil(a.word_count / 250)} min read)</span>
- </span>
- )}
- <span className="badge" style={{
- fontSize: 10, padding: '2px 8px',
- backgroundColor: `${SENTIMENT_COLORS[a.sentiment]}20`,
- color: SENTIMENT_COLORS[a.sentiment],
- border: `1px solid ${SENTIMENT_COLORS[a.sentiment]}40`,
- }}>
- {a.sentiment}
- </span>
+ {/* Summary */}
+ {a.summary && (
+ <div style={{ marginBottom: 20 }}>
+ <div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--color-text-muted)', marginBottom: 8 }}>
+ Summary
</div>
+ <p style={{ fontSize: 14, color: 'var(--color-text)', lineHeight: 1.7, margin: 0 }}>
+ {a.summary}
+ </p>
</div>
+ )}
- {/* Body */}
- <div style={{ padding: '20px 24px' }}>
- {/* Summary */}
- {a.summary && (
- <div style={{ marginBottom: 20 }}>
- <div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--color-text-muted)', marginBottom: 8 }}>
- Summary
- </div>
- <p style={{ fontSize: 14, color: 'var(--color-text)', lineHeight: 1.7, margin: 0 }}>
- {a.summary}
- </p>
- </div>
- )}
-
- {/* Topics */}
- {a.topics?.length > 0 && (
- <div style={{ marginBottom: 16 }}>
- <div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--color-text-muted)', marginBottom: 8 }}>
- Topics
- </div>
- <div className="flex flex-wrap gap-2">
- {a.topics.map((t) => (
- <span key={t} className="badge" style={{ fontSize: 11, padding: '3px 10px', backgroundColor: '#a855f720', color: '#a855f7', border: '1px solid #a855f740' }}>{t}</span>
- ))}
- </div>
- </div>
- )}
+ {/* Topics */}
+ {a.topics?.length > 0 && (
+ <div style={{ marginBottom: 16 }}>
+ <div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--color-text-muted)', marginBottom: 8 }}>
+ Topics
+ </div>
+ <div className="flex flex-wrap gap-2">
+ {a.topics.map((t) => (
+ <span key={t} className="badge" style={{ fontSize: 11, padding: '3px 10px', backgroundColor: '#a855f720', color: '#a855f7', border: '1px solid #a855f740' }}>{t}</span>
+ ))}
+ </div>
+ </div>
+ )}
- {/* Tags */}
- {a.tags?.length > 0 && (
- <div style={{ marginBottom: 20 }}>
- <div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--color-text-muted)', marginBottom: 8 }}>
- Tags
- </div>
- <div className="flex flex-wrap gap-2">
- {a.tags.map((t) => (
- <span key={t} className="badge" style={{ fontSize: 11, padding: '3px 10px' }}>{t}</span>
- ))}
- </div>
- </div>
- )}
+ {/* Tags */}
+ {a.tags?.length > 0 && (
+ <div style={{ marginBottom: 20 }}>
+ <div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--color-text-muted)', marginBottom: 8 }}>
+ Tags
+ </div>
+ <div className="flex flex-wrap gap-2">
+ {a.tags.map((t) => (
+ <span key={t} className="badge" style={{ fontSize: 11, padding: '3px 10px' }}>{t}</span>
+ ))}
+ </div>
+ </div>
+ )}
- {/* Read Article CTA */}
- {a.url && (
- <a
- href={a.url}
- target="_blank"
- rel="noopener noreferrer"
- className="btn btn-primary"
- style={{
- display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
- width: '100%', padding: '12px 20px', fontSize: 14, fontWeight: 600,
- textDecoration: 'none',
- }}
- >
- <ExternalLink size={16} />
- Read Full Article
- </a>
- )}
- {!a.url && (
- <div style={{ textAlign: 'center', padding: '12px 0', color: 'var(--color-text-muted)', fontSize: 13 }}>
- No article URL available
- </div>
- )}
+ {/* Read Article CTA */}
+ {a.url && (
+ <a
+ href={a.url}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="btn btn-primary"
+ style={{
+ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
+ width: '100%', padding: '12px 20px', fontSize: 14, fontWeight: 600,
+ textDecoration: 'none',
+ }}
+ >
+ <ExternalLink size={16} />
+ Read Full Article
+ </a>
+ )}
+ {!a.url && (
+ <div style={{ textAlign: 'center', padding: '12px 0', color: 'var(--color-text-muted)', fontSize: 13 }}>
+ No article URL available
</div>
- </motion.div>
- </div>
+ )}
+ </ResizableModal>
);
}
@@ -1394,27 +1365,32 @@ function AddJournalistModal({ onClose, onAdded }: { onClose: () => void; onAdded
const labelStyle = { fontSize: 11, color: 'var(--color-text-muted)', fontWeight: 600, display: 'block', marginBottom: 4 } as React.CSSProperties;
return (
- <div style={{ position: 'fixed', inset: 0, zIndex: 60, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
- <div style={{ position: 'absolute', inset: 0, backgroundColor: 'rgba(0,0,0,0.6)' }} onClick={onClose} />
- <motion.div
- initial={{ opacity: 0, scale: 0.95 }}
- animate={{ opacity: 1, scale: 1 }}
- className="card"
- style={{ position: 'relative', zIndex: 1, width: '100%', maxWidth: 520, padding: 24, maxHeight: '90vh', overflowY: 'auto' }}
- role="dialog"
- aria-modal="true"
- aria-labelledby="add-j-title"
- >
- <div className="flex items-center justify-between mb-4">
- <h3 id="add-j-title" style={{ fontSize: 16, fontWeight: 700, color: 'var(--color-text)' }}>Add Journalist</h3>
- <button className="btn btn-ghost btn-sm" onClick={onClose}><X size={16} /></button>
- </div>
- <div style={{ backgroundColor: 'rgba(16, 185, 129, 0.08)', borderLeft: '3px solid var(--color-primary)', borderRadius: 'var(--radius-sm)', padding: '10px 14px', marginBottom: '16px', fontSize: '13px', color: 'var(--color-text-secondary)', lineHeight: '1.5', display: 'flex', gap: 8, alignItems: 'flex-start' }}>
- <Info size={14} style={{ color: 'var(--color-primary)', flexShrink: 0, marginTop: 1 }} />
- <span>Add a journalist to your watchlist. Enter their name, outlet, and beat to track their coverage of your issues.</span>
- </div>
+ <ResizableModal
+ modalId="journalist-add"
+ title="Add Journalist"
+ onClose={onClose}
+ defaultWidth={520}
+ footer={
+ <>
+ <button type="button" className="btn btn-ghost btn-sm" onClick={onClose}>Cancel</button>
+ <button
+ type="submit"
+ form="add-journalist-form"
+ className="btn btn-primary btn-sm"
+ disabled={saving || !form.name || !form.outlet}
+ >
+ {saving ? <span className="spinner" style={{ width: 14, height: 14 }} /> : <UserPlus size={14} />}
+ <span className="ml-1">Add Journalist</span>
+ </button>
+ </>
+ }
+ >
+ <div style={{ backgroundColor: 'rgba(16, 185, 129, 0.08)', borderLeft: '3px solid var(--color-primary)', borderRadius: 'var(--radius-sm)', padding: '10px 14px', marginBottom: '16px', fontSize: '13px', color: 'var(--color-text-secondary)', lineHeight: '1.5', display: 'flex', gap: 8, alignItems: 'flex-start' }}>
+ <Info size={14} style={{ color: 'var(--color-primary)', flexShrink: 0, marginTop: 1 }} />
+ <span>Add a journalist to your watchlist. Enter their name, outlet, and beat to track their coverage of your issues.</span>
+ </div>
- <form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
+ <form id="add-journalist-form" onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* ── Core Info (always visible) ─────────────────────────────────── */}
<div style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--color-text-muted)', marginBottom: -4 }}>
Core Info
@@ -1521,15 +1497,7 @@ function AddJournalistModal({ onClose, onAdded }: { onClose: () => void; onAdded
</div>
)}
- <div className="flex justify-end gap-2 mt-2">
- <button type="button" className="btn btn-ghost btn-sm" onClick={onClose}>Cancel</button>
- <button type="submit" className="btn btn-primary btn-sm" disabled={saving || !form.name || !form.outlet}>
- {saving ? <span className="spinner" style={{ width: 14, height: 14 }} /> : <UserPlus size={14} />}
- <span className="ml-1">Add Journalist</span>
- </button>
- </div>
- </form>
- </motion.div>
- </div>
+ </form>
+ </ResizableModal>
);
}
diff --git a/components/leads/OldLeadsTab.tsx b/components/leads/OldLeadsTab.tsx
index 98e8774..8493a4d 100644
--- a/components/leads/OldLeadsTab.tsx
+++ b/components/leads/OldLeadsTab.tsx
@@ -11,6 +11,7 @@ import {
import { useToast } from '../ToastProvider';
import { useDebounce } from '@/hooks/useDebounce';
import { SkeletonList, SkeletonStats } from '../Skeleton';
+import ResizableModal from '../shared/ResizableModal';
/* ─── Types ──────────────────────────────────────────────────────────────── */
interface Lead {
@@ -220,12 +221,6 @@ function LetterModal({ lead, onClose }: { lead: Lead; onClose: () => void }) {
const [loading, setLoading] = useState(true);
const [copied, setCopied] = useState(false);
- useEffect(() => {
- const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
- window.addEventListener('keydown', handleKey);
- return () => window.removeEventListener('keydown', handleKey);
- }, [onClose]);
-
useEffect(() => {
async function gen() {
try {
@@ -256,71 +251,59 @@ function LetterModal({ lead, onClose }: { lead: Lead; onClose: () => void }) {
}
return (
- <div style={{
- position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.6)',
- display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000,
- }} onClick={onClose}>
- <motion.div
- initial={{ opacity: 0, scale: 0.95 }}
- animate={{ opacity: 1, scale: 1 }}
- style={{
- backgroundColor: 'var(--color-surface)', borderRadius: 16,
- border: '1px solid var(--color-border)', padding: 24,
- width: '100%', maxWidth: 600, maxHeight: '80vh', overflowY: 'auto',
- }}
- onClick={e => e.stopPropagation()}
- >
- <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
- <h3 style={{ fontSize: 16, fontWeight: 700, color: 'var(--color-text)', margin: 0, display: 'flex', alignItems: 'center', gap: 8 }}>
- <Flame size={18} style={{ color: '#f97316' }} />
- Rekindle Letter — {lead.name}
- </h3>
- <button onClick={onClose} style={{ border: 'none', background: 'transparent', color: 'var(--color-text-muted)', cursor: 'pointer', fontSize: 18 }}>x</button>
+ <ResizableModal
+ modalId="leads-rekindle-letter"
+ title={
+ <span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
+ <Flame size={18} style={{ color: '#f97316' }} />
+ Rekindle Letter — {lead.name}
+ </span>
+ }
+ onClose={onClose}
+ defaultWidth={600}
+ >
+ {loading ? (
+ <div style={{ textAlign: 'center', padding: 40 }}>
+ <Sparkles size={24} style={{ color: '#f97316', animation: 'spin 2s linear infinite' }} />
+ <p style={{ color: 'var(--color-text-muted)', marginTop: 12, fontSize: 13 }}>AI is crafting your rekindle letter...</p>
</div>
-
- {loading ? (
- <div style={{ textAlign: 'center', padding: 40 }}>
- <Sparkles size={24} style={{ color: '#f97316', animation: 'spin 2s linear infinite' }} />
- <p style={{ color: 'var(--color-text-muted)', marginTop: 12, fontSize: 13 }}>AI is crafting your rekindle letter...</p>
- </div>
- ) : letter ? (
- <>
- <div style={{ marginBottom: 12 }}>
- <label style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', display: 'block', marginBottom: 4 }}>Subject Line</label>
- <div style={{ padding: '8px 12px', borderRadius: 8, backgroundColor: 'var(--color-surface-el)', border: '1px solid var(--color-border)', fontSize: 14, fontWeight: 600, color: 'var(--color-text)' }}>
- {letter.subject}
- </div>
+ ) : letter ? (
+ <>
+ <div style={{ marginBottom: 12 }}>
+ <label style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', display: 'block', marginBottom: 4 }}>Subject Line</label>
+ <div style={{ padding: '8px 12px', borderRadius: 8, backgroundColor: 'var(--color-surface-el)', border: '1px solid var(--color-border)', fontSize: 14, fontWeight: 600, color: 'var(--color-text)' }}>
+ {letter.subject}
</div>
- <div style={{ marginBottom: 12 }}>
- <label style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', display: 'block', marginBottom: 4 }}>Letter Body</label>
- <div style={{ padding: '12px 14px', borderRadius: 8, backgroundColor: 'var(--color-surface-el)', border: '1px solid var(--color-border)', fontSize: 13, color: 'var(--color-text)', lineHeight: 1.7, whiteSpace: 'pre-wrap' }}>
- {letter.body}
- </div>
+ </div>
+ <div style={{ marginBottom: 12 }}>
+ <label style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', display: 'block', marginBottom: 4 }}>Letter Body</label>
+ <div style={{ padding: '12px 14px', borderRadius: 8, backgroundColor: 'var(--color-surface-el)', border: '1px solid var(--color-border)', fontSize: 13, color: 'var(--color-text)', lineHeight: 1.7, whiteSpace: 'pre-wrap' }}>
+ {letter.body}
</div>
- {letter.tone_notes && (
- <p style={{ fontSize: 11, color: 'var(--color-text-muted)', fontStyle: 'italic', margin: '0 0 16px' }}>
- <AlertCircle size={11} style={{ display: 'inline', marginRight: 4 }} />
- AI Note: {letter.tone_notes}
- </p>
+ </div>
+ {letter.tone_notes && (
+ <p style={{ fontSize: 11, color: 'var(--color-text-muted)', fontStyle: 'italic', margin: '0 0 16px' }}>
+ <AlertCircle size={11} style={{ display: 'inline', marginRight: 4 }} />
+ AI Note: {letter.tone_notes}
+ </p>
+ )}
+ <div style={{ display: 'flex', gap: 8 }}>
+ <button onClick={handleCopy}
+ style={{ padding: '8px 16px', borderRadius: 8, border: 'none', backgroundColor: '#10b981', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>
+ {copied ? 'Copied!' : 'Copy to Clipboard'}
+ </button>
+ {lead.email && (
+ <a href={'mailto:' + lead.email + '?subject=' + encodeURIComponent(letter.subject) + '&body=' + encodeURIComponent(letter.body)}
+ style={{ padding: '8px 16px', borderRadius: 8, border: 'none', backgroundColor: 'rgba(249,115,22,0.15)', color: '#f97316', fontSize: 13, fontWeight: 600, textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 4 }}>
+ <Send size={13} /> Open in Email
+ </a>
)}
- <div style={{ display: 'flex', gap: 8 }}>
- <button onClick={handleCopy}
- style={{ padding: '8px 16px', borderRadius: 8, border: 'none', backgroundColor: '#10b981', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>
- {copied ? 'Copied!' : 'Copy to Clipboard'}
- </button>
- {lead.email && (
- <a href={'mailto:' + lead.email + '?subject=' + encodeURIComponent(letter.subject) + '&body=' + encodeURIComponent(letter.body)}
- style={{ padding: '8px 16px', borderRadius: 8, border: 'none', backgroundColor: 'rgba(249,115,22,0.15)', color: '#f97316', fontSize: 13, fontWeight: 600, textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 4 }}>
- <Send size={13} /> Open in Email
- </a>
- )}
- </div>
- </>
- ) : (
- <p style={{ color: '#f87171', fontSize: 13 }}>Failed to generate letter. Try again.</p>
- )}
- </motion.div>
- </div>
+ </div>
+ </>
+ ) : (
+ <p style={{ color: '#f87171', fontSize: 13 }}>Failed to generate letter. Try again.</p>
+ )}
+ </ResizableModal>
);
}
diff --git a/components/library/LibraryTab.tsx b/components/library/LibraryTab.tsx
index db916be..a3ac267 100644
--- a/components/library/LibraryTab.tsx
+++ b/components/library/LibraryTab.tsx
@@ -4,12 +4,13 @@ import { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Plus, Search, Star, Copy, FileText, Code, Archive,
- X, RefreshCw, Sparkles, ChevronDown, ChevronUp,
+ RefreshCw, Sparkles, ChevronDown, ChevronUp,
Tag, Bookmark, ExternalLink, Trash2, Edit3, Check,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
import { useDebounce } from '@/hooks/useDebounce';
import SortDropdown from '../shared/SortDropdown';
+import ResizableModal from '../shared/ResizableModal';
import { useClientSort, SortConfig } from '@/hooks/useClientSort';
const LIB_SORT_OPTIONS = [
@@ -185,136 +186,128 @@ function LibraryModal({
}
return (
- <div
- className="fixed inset-0 z-50 flex items-center justify-center"
- style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
- onClick={onClose}
+ <ResizableModal
+ modalId={isEdit ? 'library-item-edit' : 'library-item-create'}
+ title={isEdit ? 'Edit Library Item' : 'Add to Library'}
+ onClose={onClose}
+ defaultWidth={720}
+ footer={
+ <>
+ <button type="button" className="btn btn-ghost" onClick={onClose}>Cancel</button>
+ <button
+ type="button"
+ className="btn btn-primary"
+ disabled={saving || !form.title.trim()}
+ onClick={() => handleSubmit({ preventDefault() {} } as React.FormEvent)}
+ >
+ {saving ? <><RefreshCw size={14} className="animate-spin" /><span>Saving...</span></> : <>{isEdit ? 'Update' : 'Save'}</>}
+ </button>
+ </>
+ }
>
- <motion.div
- initial={{ opacity: 0, scale: 0.95 }}
- animate={{ opacity: 1, scale: 1 }}
- className="w-full max-w-2xl mx-4 rounded-xl p-6 max-h-[85vh] overflow-y-auto"
- style={{ backgroundColor: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
- onClick={(e) => e.stopPropagation()}
- >
- <div className="flex items-center justify-between mb-5">
- <h2 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>
- {isEdit ? 'Edit Library Item' : 'Add to Library'}
- </h2>
- <button onClick={onClose} className="btn btn-ghost btn-sm"><X size={16} /></button>
+ <form onSubmit={handleSubmit} className="space-y-4">
+ {/* Title */}
+ <div>
+ <label className="block text-sm font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Title</label>
+ <input
+ className="input w-full"
+ value={form.title}
+ onChange={(e) => updateForm('title', e.target.value)}
+ placeholder="e.g. Community Action Alert Template"
+ autoFocus
+ />
</div>
- <form onSubmit={handleSubmit} className="space-y-4">
- {/* Title */}
+ {/* Row: Type + Email Type + Voice Mode */}
+ <div className="grid grid-cols-3 gap-3">
<div>
- <label className="block text-sm font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Title</label>
- <input
- className="input w-full"
- value={form.title}
- onChange={(e) => updateForm('title', e.target.value)}
- placeholder="e.g. Community Action Alert Template"
- autoFocus
- />
+ <label className="block text-sm font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Type</label>
+ <select className="input w-full" value={form.item_type} onChange={(e) => updateForm('item_type', e.target.value)}>
+ <option value="template">Template</option>
+ <option value="snippet">Snippet</option>
+ <option value="archived_draft">Archived Draft</option>
+ </select>
</div>
-
- {/* Row: Type + Email Type + Voice Mode */}
- <div className="grid grid-cols-3 gap-3">
- <div>
- <label className="block text-sm font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Type</label>
- <select className="input w-full" value={form.item_type} onChange={(e) => updateForm('item_type', e.target.value)}>
- <option value="template">Template</option>
- <option value="snippet">Snippet</option>
- <option value="archived_draft">Archived Draft</option>
- </select>
- </div>
- <div>
- <label className="block text-sm font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Email Type</label>
- <select className="input w-full" value={form.email_type} onChange={(e) => updateForm('email_type', e.target.value)}>
- <option value="action">Action</option>
- <option value="policy">Policy</option>
- <option value="resources">Resources</option>
- </select>
- </div>
- <div>
- <label className="block text-sm font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Voice</label>
- <select className="input w-full" value={form.voice_mode} onChange={(e) => updateForm('voice_mode', e.target.value)}>
- <option value="formal">Formal</option>
- <option value="passionate">Passionate</option>
- <option value="urgent">Urgent</option>
- <option value="conversational">Conversational</option>
- </select>
- </div>
- </div>
-
- {/* Description */}
<div>
- <label className="block text-sm font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Description</label>
- <input
- className="input w-full"
- value={form.description}
- onChange={(e) => updateForm('description', e.target.value)}
- placeholder="Brief description of this item"
- />
+ <label className="block text-sm font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Email Type</label>
+ <select className="input w-full" value={form.email_type} onChange={(e) => updateForm('email_type', e.target.value)}>
+ <option value="action">Action</option>
+ <option value="policy">Policy</option>
+ <option value="resources">Resources</option>
+ </select>
</div>
-
- {/* Content */}
<div>
- <div className="flex items-center justify-between mb-1">
- <label className="text-sm font-medium" style={{ color: 'var(--color-text-secondary)' }}>Content</label>
- <button
- type="button"
- className="btn btn-ghost btn-sm"
- onClick={handleGenerate}
- disabled={generating}
- >
- {generating ? (
- <><RefreshCw size={13} className="animate-spin" /><span>Generating...</span></>
- ) : (
- <><Sparkles size={13} /><span>AI Generate</span></>
- )}
- </button>
- </div>
- <textarea
- className="input w-full"
- rows={8}
- value={form.content_text}
- onChange={(e) => updateForm('content_text', e.target.value)}
- placeholder="Content text..."
- />
+ <label className="block text-sm font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Voice</label>
+ <select className="input w-full" value={form.voice_mode} onChange={(e) => updateForm('voice_mode', e.target.value)}>
+ <option value="formal">Formal</option>
+ <option value="passionate">Passionate</option>
+ <option value="urgent">Urgent</option>
+ <option value="conversational">Conversational</option>
+ </select>
</div>
+ </div>
- {/* Tags */}
- <div>
- <label className="block text-sm font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Tags (comma-separated)</label>
- <input
- className="input w-full"
- value={form.tags}
- onChange={(e) => updateForm('tags', e.target.value)}
- placeholder="e.g. action, community, policy reform"
- />
+ {/* Description */}
+ <div>
+ <label className="block text-sm font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Description</label>
+ <input
+ className="input w-full"
+ value={form.description}
+ onChange={(e) => updateForm('description', e.target.value)}
+ placeholder="Brief description of this item"
+ />
+ </div>
+
+ {/* Content */}
+ <div>
+ <div className="flex items-center justify-between mb-1">
+ <label className="text-sm font-medium" style={{ color: 'var(--color-text-secondary)' }}>Content</label>
+ <button
+ type="button"
+ className="btn btn-ghost btn-sm"
+ onClick={handleGenerate}
+ disabled={generating}
+ >
+ {generating ? (
+ <><RefreshCw size={13} className="animate-spin" /><span>Generating...</span></>
+ ) : (
+ <><Sparkles size={13} /><span>AI Generate</span></>
+ )}
+ </button>
</div>
+ <textarea
+ className="input w-full"
+ rows={8}
+ value={form.content_text}
+ onChange={(e) => updateForm('content_text', e.target.value)}
+ placeholder="Content text..."
+ />
+ </div>
- {/* Pin toggle */}
- <label className="flex items-center gap-2 cursor-pointer">
- <input
- type="checkbox"
- checked={form.is_pinned}
- onChange={(e) => updateForm('is_pinned', e.target.checked)}
- />
- <span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>Pin to top of library</span>
- </label>
+ {/* Tags */}
+ <div>
+ <label className="block text-sm font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Tags (comma-separated)</label>
+ <input
+ className="input w-full"
+ value={form.tags}
+ onChange={(e) => updateForm('tags', e.target.value)}
+ placeholder="e.g. action, community, policy reform"
+ />
+ </div>
- {error && <p className="text-sm" style={{ color: '#f87171' }}>{error}</p>}
+ {/* Pin toggle */}
+ <label className="flex items-center gap-2 cursor-pointer">
+ <input
+ type="checkbox"
+ checked={form.is_pinned}
+ onChange={(e) => updateForm('is_pinned', e.target.checked)}
+ />
+ <span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>Pin to top of library</span>
+ </label>
- <div className="flex justify-end gap-2 pt-2">
- <button type="button" className="btn btn-ghost" onClick={onClose}>Cancel</button>
- <button type="submit" className="btn btn-primary" disabled={saving || !form.title.trim()}>
- {saving ? <><RefreshCw size={14} className="animate-spin" /><span>Saving...</span></> : <>{isEdit ? 'Update' : 'Save'}</>}
- </button>
- </div>
- </form>
- </motion.div>
- </div>
+ {error && <p className="text-sm" style={{ color: '#f87171' }}>{error}</p>}
+ </form>
+ </ResizableModal>
);
}
diff --git a/components/onboard/OnBoardTab.tsx b/components/onboard/OnBoardTab.tsx
index 0f347fe..8ca4c5d 100644
--- a/components/onboard/OnBoardTab.tsx
+++ b/components/onboard/OnBoardTab.tsx
@@ -31,7 +31,6 @@ import {
XCircle,
Search,
Filter,
- X,
ClipboardList,
Globe,
Users,
@@ -40,6 +39,7 @@ import {
Vote,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
+import ResizableModal from '../shared/ResizableModal';
/* ─── Types ─────────────────────────────────────────────────────────────────── */
@@ -404,105 +404,67 @@ function TriggerDialog({
}
return (
- <motion.div
- initial={{ opacity: 0 }}
- animate={{ opacity: 1 }}
- exit={{ opacity: 0 }}
- style={{
- position: 'fixed',
- inset: 0,
- backgroundColor: 'rgba(0,0,0,0.6)',
- backdropFilter: 'blur(4px)',
- zIndex: 50,
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- padding: 16,
- }}
- onClick={onClose}
- >
- <motion.div
- initial={{ scale: 0.92, opacity: 0 }}
- animate={{ scale: 1, opacity: 1 }}
- exit={{ scale: 0.92, opacity: 0 }}
- transition={{ type: 'spring', stiffness: 320, damping: 28 }}
- className="card"
- style={{
- width: '100%',
- maxWidth: 440,
- padding: 24,
- backgroundColor: 'var(--color-surface)',
- }}
- onClick={(e) => e.stopPropagation()}
- role="dialog"
- aria-modal="true"
- aria-label="Trigger discovery"
- >
- <div className="flex items-center justify-between mb-4">
- <div className="flex items-center gap-2">
- <Zap size={16} style={{ color: '#f59e0b' }} aria-hidden="true" />
- <h3 className="font-semibold text-sm" style={{ color: 'var(--color-text)' }}>
- Trigger Discovery
- </h3>
- </div>
+ <ResizableModal
+ modalId="onboard-trigger-discovery"
+ title={
+ <span className="flex items-center gap-2">
+ <Zap size={16} style={{ color: '#f59e0b' }} aria-hidden="true" />
+ Trigger Discovery
+ </span>
+ }
+ onClose={onClose}
+ defaultWidth={440}
+ footer={
+ <>
<button
type="button"
onClick={onClose}
className="btn btn-ghost btn-sm"
- aria-label="Close dialog"
- style={{ padding: '0.25rem' }}
>
- <X size={14} aria-hidden="true" />
+ Cancel
</button>
+ <button
+ type="button"
+ className="btn btn-primary btn-sm"
+ disabled={!topic.trim()}
+ onClick={(e) => handleSubmit(e as unknown as React.FormEvent)}
+ >
+ <Zap size={13} aria-hidden="true" />
+ Trigger
+ </button>
+ </>
+ }
+ >
+ <p className="text-xs mb-4" style={{ color: 'var(--color-text-muted)' }}>
+ Enter a topic to trigger the onboard agent. It will discover relevant non-profits and
+ add them to the discovery queue.
+ </p>
+
+ <form onSubmit={handleSubmit} className="flex flex-col gap-3">
+ <div>
+ <label
+ htmlFor="trigger-topic"
+ className="text-xs font-medium mb-1 block"
+ style={{ color: 'var(--color-text-secondary)' }}
+ >
+ Topic
+ </label>
+ <input
+ id="trigger-topic"
+ type="text"
+ value={topic}
+ onChange={(e) => setTopic(e.target.value)}
+ placeholder='e.g. "Climate Action", "Education Policy"'
+ className="input"
+ style={{ fontSize: '0.85rem' }}
+ autoFocus
+ required
+ />
</div>
-
- <p className="text-xs mb-4" style={{ color: 'var(--color-text-muted)' }}>
- Enter a topic to trigger the onboard agent. It will discover relevant non-profits and
- add them to the discovery queue.
- </p>
-
- <form onSubmit={handleSubmit} className="flex flex-col gap-3">
- <div>
- <label
- htmlFor="trigger-topic"
- className="text-xs font-medium mb-1 block"
- style={{ color: 'var(--color-text-secondary)' }}
- >
- Topic
- </label>
- <input
- id="trigger-topic"
- type="text"
- value={topic}
- onChange={(e) => setTopic(e.target.value)}
- placeholder='e.g. "Climate Action", "Education Policy"'
- className="input"
- style={{ fontSize: '0.85rem' }}
- autoFocus
- required
- />
- </div>
-
- <div className="flex items-center justify-end gap-2 mt-1">
- <button
- type="button"
- onClick={onClose}
- className="btn btn-ghost btn-sm"
- >
- Cancel
- </button>
- <button
- type="submit"
- className="btn btn-primary btn-sm"
- disabled={!topic.trim()}
- >
- <Zap size={13} aria-hidden="true" />
- Trigger
- </button>
- </div>
- </form>
- </motion.div>
- </motion.div>
+ {/* Hidden submit for Enter-key */}
+ <button type="submit" style={{ display: 'none' }} aria-hidden="true" tabIndex={-1} />
+ </form>
+ </ResizableModal>
);
}
@@ -710,37 +672,46 @@ function SignupFormModal({
}
return (
- <div
- style={{
- position: 'fixed', inset: 0, zIndex: 100,
- display: 'flex', alignItems: 'center', justifyContent: 'center',
- background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)',
- }}
- onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
+ <ResizableModal
+ modalId="onboard-nonprofit-signup"
+ title={
+ <span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
+ <ClipboardList size={20} color="var(--color-primary)" />
+ Nonprofit Signup
+ </span>
+ }
+ onClose={onClose}
+ defaultWidth={640}
+ footer={
+ <>
+ {step > 1 && (
+ <button className="btn btn-secondary" onClick={() => setStep(step - 1)} style={{ marginRight: 'auto' }}>
+ <ChevronLeft size={14} /> Back
+ </button>
+ )}
+ <button className="btn btn-ghost" onClick={onClose}>Cancel</button>
+ {step < totalSteps ? (
+ <button className="btn btn-primary" onClick={() => setStep(step + 1)}>
+ Next <ChevronRight size={14} />
+ </button>
+ ) : (
+ <button
+ className="btn btn-primary"
+ onClick={handleSubmit}
+ disabled={submitting || !orgName.trim()}
+ style={{ display: 'flex', alignItems: 'center', gap: 6 }}
+ >
+ {submitting ? (
+ <><span className="spinner" style={{ width: 14, height: 14 }} /> Registering...</>
+ ) : (
+ <><CheckCircle2 size={14} /> Complete Signup</>
+ )}
+ </button>
+ )}
+ </>
+ }
>
- <motion.div
- initial={{ opacity: 0, scale: 0.95 }}
- animate={{ opacity: 1, scale: 1 }}
- exit={{ opacity: 0, scale: 0.95 }}
- style={{
- background: 'var(--color-surface)',
- border: '1px solid var(--color-border)',
- borderRadius: 'var(--radius-lg)',
- width: '100%', maxWidth: 640, maxHeight: '90vh',
- overflow: 'auto', padding: 24,
- }}
- >
- {/* Header */}
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
- <h2 style={{ fontSize: 17, fontWeight: 700, color: 'var(--color-text)', display: 'flex', alignItems: 'center', gap: 8 }}>
- <ClipboardList size={20} color="var(--color-primary)" />
- Nonprofit Signup
- </h2>
- <button onClick={onClose} className="btn btn-ghost" style={{ padding: 4 }}>
- <X size={18} />
- </button>
- </div>
-
+ <>
{/* Progress */}
<div style={{ display: 'flex', gap: 4, marginBottom: 20 }}>
{Array.from({ length: totalSteps }).map((_, i) => (
@@ -988,39 +959,8 @@ function SignupFormModal({
</div>
)}
- {/* Navigation */}
- <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 20 }}>
- <div>
- {step > 1 && (
- <button className="btn btn-secondary" onClick={() => setStep(step - 1)}>
- <ChevronLeft size={14} /> Back
- </button>
- )}
- </div>
- <div style={{ display: 'flex', gap: 8 }}>
- <button className="btn btn-ghost" onClick={onClose}>Cancel</button>
- {step < totalSteps ? (
- <button className="btn btn-primary" onClick={() => setStep(step + 1)}>
- Next <ChevronRight size={14} />
- </button>
- ) : (
- <button
- className="btn btn-primary"
- onClick={handleSubmit}
- disabled={submitting || !orgName.trim()}
- style={{ display: 'flex', alignItems: 'center', gap: 6 }}
- >
- {submitting ? (
- <><span className="spinner" style={{ width: 14, height: 14 }} /> Registering...</>
- ) : (
- <><CheckCircle2 size={14} /> Complete Signup</>
- )}
- </button>
- )}
- </div>
- </div>
- </motion.div>
- </div>
+ </>
+ </ResizableModal>
);
}
diff --git a/components/outreach/OutreachPlaybookTab.tsx b/components/outreach/OutreachPlaybookTab.tsx
index ee9f6a9..c4026a9 100644
--- a/components/outreach/OutreachPlaybookTab.tsx
+++ b/components/outreach/OutreachPlaybookTab.tsx
@@ -12,6 +12,7 @@ import {
Briefcase, GraduationCap, Scale, Megaphone,
Video, PhoneCall, UserCheck, Pause, XCircle, Zap, Info,
} from 'lucide-react';
+import ResizableModal from '../shared/ResizableModal';
/* ═══════════════════════════════════════════════════════════════════════════
TYPES
@@ -722,14 +723,31 @@ function AddContactModal({ onClose, onAdded }: { onClose: () => void; onAdded: (
}
return (
- <ModalOverlay onClose={onClose}>
- <div style={{ maxWidth: 560, width: '100%', maxHeight: '90vh', overflow: 'auto' }}>
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
- <h2 style={{ fontSize: 18, fontWeight: 700, color: 'var(--color-text)' }}>Add Pipeline Contact</h2>
- <button onClick={onClose} style={{ border: 'none', background: 'none', cursor: 'pointer', color: 'var(--color-text-muted)' }}>
- <X size={18} />
+ <ResizableModal
+ modalId="outreach-add-pipeline-contact"
+ title="Add Pipeline Contact"
+ onClose={onClose}
+ defaultWidth={560}
+ footer={
+ <>
+ <button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 8, border: '1px solid var(--color-border)', cursor: 'pointer', backgroundColor: 'transparent', color: 'var(--color-text-secondary)', fontSize: 13 }}>
+ Cancel
</button>
- </div>
+ <button
+ type="button"
+ disabled={saving}
+ onClick={(e) => handleSubmit(e as unknown as React.FormEvent)}
+ style={{
+ padding: '8px 20px', borderRadius: 8, border: 'none', cursor: 'pointer', fontSize: 13, fontWeight: 600,
+ background: 'linear-gradient(135deg, #10b981, #059669)', color: '#fff', opacity: saving ? 0.7 : 1,
+ }}
+ >
+ {saving ? 'Saving...' : 'Add Contact'}
+ </button>
+ </>
+ }
+ >
+ <>
<div style={{ backgroundColor: 'rgba(16, 185, 129, 0.08)', borderLeft: '3px solid var(--color-primary)', borderRadius: 'var(--radius-sm)', padding: '10px 14px', marginBottom: '20px', fontSize: '13px', color: 'var(--color-text-secondary)', lineHeight: '1.5', display: 'flex', gap: 8, alignItems: 'flex-start' }}>
<Info size={14} style={{ color: 'var(--color-primary)', flexShrink: 0, marginTop: 1 }} />
<span>Add a new outreach contact for your playbook campaigns. Select the target type (official, coalition, media) and fill in location and channel details to enable targeted outreach tracking.</span>
@@ -817,21 +835,11 @@ function AddContactModal({ onClose, onAdded }: { onClose: () => void; onAdded: (
<FormField label="Notes" full>
<textarea value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} rows={3} style={{ ...inputStyle, resize: 'vertical' }} placeholder="Any context for this contact..." />
</FormField>
-
- <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 16 }}>
- <button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 8, border: '1px solid var(--color-border)', cursor: 'pointer', backgroundColor: 'transparent', color: 'var(--color-text-secondary)', fontSize: 13 }}>
- Cancel
- </button>
- <button type="submit" disabled={saving} style={{
- padding: '8px 20px', borderRadius: 8, border: 'none', cursor: 'pointer', fontSize: 13, fontWeight: 600,
- background: 'linear-gradient(135deg, #10b981, #059669)', color: '#fff', opacity: saving ? 0.7 : 1,
- }}>
- {saving ? 'Saving...' : 'Add Contact'}
- </button>
- </div>
+ {/* Hidden submit for Enter-key */}
+ <button type="submit" style={{ display: 'none' }} aria-hidden="true" tabIndex={-1} />
</form>
- </div>
- </ModalOverlay>
+ </>
+ </ResizableModal>
);
}
@@ -981,99 +989,98 @@ function TemplatesView() {
)}
{/* Generate modal */}
- <AnimatePresence>
- {showGenerateModal && (
- <ModalOverlay onClose={() => { setShowGenerateModal(false); setGeneratedLetter(null); }}>
- <div style={{ maxWidth: 640, width: '100%', maxHeight: '90vh', overflow: 'auto' }}>
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
- <h2 style={{ fontSize: 18, fontWeight: 700, color: 'var(--color-text)', display: 'flex', alignItems: 'center', gap: 8 }}>
- <Sparkles size={18} style={{ color: '#f59e0b' }} /> Generate Letter
- </h2>
- <button onClick={() => { setShowGenerateModal(false); setGeneratedLetter(null); }} style={{ border: 'none', background: 'none', cursor: 'pointer', color: 'var(--color-text-muted)' }}>
- <X size={18} />
+ {showGenerateModal && (
+ <ResizableModal
+ modalId="outreach-generate-letter"
+ title={
+ <span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
+ <Sparkles size={18} style={{ color: '#f59e0b' }} /> Generate Letter
+ </span>
+ }
+ onClose={() => { setShowGenerateModal(false); setGeneratedLetter(null); }}
+ defaultWidth={640}
+ footer={
+ !generatedLetter ? (
+ <>
+ <button onClick={() => setShowGenerateModal(false)} style={{ padding: '8px 16px', borderRadius: 8, border: '1px solid var(--color-border)', cursor: 'pointer', backgroundColor: 'transparent', color: 'var(--color-text-secondary)', fontSize: 13 }}>
+ Cancel
</button>
- </div>
-
- {!generatedLetter ? (
- <>
- <FormField label="Select Pipeline Contact" full>
- <select value={selectedEntryId} onChange={e => setSelectedEntryId(e.target.value)} style={inputStyle}>
- <option value="">Choose a contact...</option>
- {pipelineEntries.map(e => (
- <option key={e.id} value={e.id}>{e.office_name}{e.official_name ? ` (${e.official_name})` : ''} - {e.state || 'N/A'}</option>
- ))}
- </select>
- </FormField>
- <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 16 }}>
- <button onClick={() => setShowGenerateModal(false)} style={{ padding: '8px 16px', borderRadius: 8, border: '1px solid var(--color-border)', cursor: 'pointer', backgroundColor: 'transparent', color: 'var(--color-text-secondary)', fontSize: 13 }}>
- Cancel
- </button>
- <button
- onClick={generateLetter}
- disabled={!selectedEntryId || generating}
- style={{
- display: 'flex', alignItems: 'center', gap: 6,
- padding: '8px 20px', borderRadius: 8, border: 'none', cursor: 'pointer',
- fontSize: 13, fontWeight: 600,
- background: 'linear-gradient(135deg, #f59e0b, #d97706)', color: '#fff',
- opacity: (!selectedEntryId || generating) ? 0.6 : 1,
- }}
- >
- {generating ? <><RefreshCw size={14} className="animate-spin" /> Generating...</> : <><Sparkles size={14} /> Generate</>}
- </button>
- </div>
- </>
- ) : (
- <>
- {generatedLetter.subject && (
- <div style={{ marginBottom: 12 }}>
- <label style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', display: 'block', marginBottom: 4 }}>SUBJECT</label>
- <div style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)', padding: 8, borderRadius: 8, backgroundColor: 'var(--color-surface-el)', border: '1px solid var(--color-border)' }}>
- {generatedLetter.subject}
- </div>
- </div>
- )}
- <div>
- <label style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', display: 'block', marginBottom: 4 }}>BODY</label>
- <div style={{ fontSize: 13, color: 'var(--color-text)', padding: 12, borderRadius: 8, backgroundColor: 'var(--color-surface-el)', border: '1px solid var(--color-border)', whiteSpace: 'pre-wrap', lineHeight: 1.6, maxHeight: 400, overflow: 'auto' }}>
- {generatedLetter.body}
- </div>
- </div>
- {generatedLetter.notes && (
- <div style={{ marginTop: 12, fontSize: 12, color: 'var(--color-text-muted)', padding: 8, borderRadius: 8, backgroundColor: 'rgba(245,158,11,0.08)', border: '1px solid rgba(245,158,11,0.2)' }}>
- <strong>AI Notes:</strong> {generatedLetter.notes}
- </div>
- )}
- <div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
- <button
- onClick={() => {
- const full = generatedLetter.subject
- ? `Subject: ${generatedLetter.subject}\n\n${generatedLetter.body}`
- : generatedLetter.body;
- copyToClipboard(full);
- }}
- style={{
- display: 'flex', alignItems: 'center', gap: 6,
- padding: '8px 16px', borderRadius: 8, border: 'none', cursor: 'pointer',
- fontSize: 13, fontWeight: 600,
- background: 'linear-gradient(135deg, #10b981, #059669)', color: '#fff',
- }}
- >
- <Copy size={14} /> {copied ? 'Copied!' : 'Copy to Clipboard'}
- </button>
- <button
- onClick={() => setGeneratedLetter(null)}
- style={{ padding: '8px 16px', borderRadius: 8, border: '1px solid var(--color-border)', cursor: 'pointer', backgroundColor: 'transparent', color: 'var(--color-text-secondary)', fontSize: 13 }}
- >
- Generate Another
- </button>
+ <button
+ onClick={generateLetter}
+ disabled={!selectedEntryId || generating}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 6,
+ padding: '8px 20px', borderRadius: 8, border: 'none', cursor: 'pointer',
+ fontSize: 13, fontWeight: 600,
+ background: 'linear-gradient(135deg, #f59e0b, #d97706)', color: '#fff',
+ opacity: (!selectedEntryId || generating) ? 0.6 : 1,
+ }}
+ >
+ {generating ? <><RefreshCw size={14} className="animate-spin" /> Generating...</> : <><Sparkles size={14} /> Generate</>}
+ </button>
+ </>
+ ) : (
+ <>
+ <button
+ onClick={() => {
+ const full = generatedLetter.subject
+ ? `Subject: ${generatedLetter.subject}\n\n${generatedLetter.body}`
+ : generatedLetter.body;
+ copyToClipboard(full);
+ }}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 6,
+ padding: '8px 16px', borderRadius: 8, border: 'none', cursor: 'pointer',
+ fontSize: 13, fontWeight: 600,
+ background: 'linear-gradient(135deg, #10b981, #059669)', color: '#fff',
+ }}
+ >
+ <Copy size={14} /> {copied ? 'Copied!' : 'Copy to Clipboard'}
+ </button>
+ <button
+ onClick={() => setGeneratedLetter(null)}
+ style={{ padding: '8px 16px', borderRadius: 8, border: '1px solid var(--color-border)', cursor: 'pointer', backgroundColor: 'transparent', color: 'var(--color-text-secondary)', fontSize: 13 }}
+ >
+ Generate Another
+ </button>
+ </>
+ )
+ }
+ >
+ {!generatedLetter ? (
+ <FormField label="Select Pipeline Contact" full>
+ <select value={selectedEntryId} onChange={e => setSelectedEntryId(e.target.value)} style={inputStyle}>
+ <option value="">Choose a contact...</option>
+ {pipelineEntries.map(e => (
+ <option key={e.id} value={e.id}>{e.office_name}{e.official_name ? ` (${e.official_name})` : ''} - {e.state || 'N/A'}</option>
+ ))}
+ </select>
+ </FormField>
+ ) : (
+ <>
+ {generatedLetter.subject && (
+ <div style={{ marginBottom: 12 }}>
+ <label style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', display: 'block', marginBottom: 4 }}>SUBJECT</label>
+ <div style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)', padding: 8, borderRadius: 8, backgroundColor: 'var(--color-surface-el)', border: '1px solid var(--color-border)' }}>
+ {generatedLetter.subject}
</div>
- </>
+ </div>
)}
- </div>
- </ModalOverlay>
- )}
- </AnimatePresence>
+ <div>
+ <label style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', display: 'block', marginBottom: 4 }}>BODY</label>
+ <div style={{ fontSize: 13, color: 'var(--color-text)', padding: 12, borderRadius: 8, backgroundColor: 'var(--color-surface-el)', border: '1px solid var(--color-border)', whiteSpace: 'pre-wrap', lineHeight: 1.6, maxHeight: 400, overflow: 'auto' }}>
+ {generatedLetter.body}
+ </div>
+ </div>
+ {generatedLetter.notes && (
+ <div style={{ marginTop: 12, fontSize: 12, color: 'var(--color-text-muted)', padding: 8, borderRadius: 8, backgroundColor: 'rgba(245,158,11,0.08)', border: '1px solid rgba(245,158,11,0.2)' }}>
+ <strong>AI Notes:</strong> {generatedLetter.notes}
+ </div>
+ )}
+ </>
+ )}
+ </ResizableModal>
+ )}
</>
);
}
diff --git a/components/partners/PartnersTab.tsx b/components/partners/PartnersTab.tsx
index ffa95d7..c1f5b1e 100644
--- a/components/partners/PartnersTab.tsx
+++ b/components/partners/PartnersTab.tsx
@@ -16,6 +16,7 @@ import { useDebounce } from '@/hooks/useDebounce';
import { SkeletonList } from '../Skeleton';
import { useClientSort, SortConfig } from '@/hooks/useClientSort';
import SortDropdown from '../shared/SortDropdown';
+import ResizableModal from '../shared/ResizableModal';
/* ─── Types ──────────────────────────────────────────────────────────────── */
type PartnerType = 'nonprofit' | 'government' | 'corporation' | 'university' | 'media' | 'foundation' | 'legal' | 'other';
@@ -462,12 +463,6 @@ function AddPartnerModal({ onClose, onAdded }: { onClose: () => void; onAdded: (
});
const [saving, setSaving] = useState(false);
- useEffect(() => {
- const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
- window.addEventListener('keydown', h);
- return () => window.removeEventListener('keydown', h);
- }, [onClose]);
-
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.name.trim()) return;
@@ -487,61 +482,70 @@ function AddPartnerModal({ onClose, onAdded }: { onClose: () => void; onAdded: (
const inp: React.CSSProperties = { width: '100%', padding: '8px 10px', borderRadius: 8, border: '1px solid var(--color-border)', backgroundColor: 'var(--color-surface-el)', color: 'var(--color-text)', fontSize: 13 };
return (
- <div style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.65)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }} onClick={onClose}>
- <motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }}
- style={{ backgroundColor: 'var(--color-surface)', borderRadius: 16, border: '1px solid var(--color-border)', padding: 24, width: '100%', maxWidth: 540, maxHeight: '82vh', overflowY: 'auto' }}
- onClick={e => e.stopPropagation()}>
- <h3 style={{ fontSize: 18, fontWeight: 700, color: 'var(--color-text)', margin: '0 0 12px' }}>Add Partner / Prospect</h3>
- <div style={{ backgroundColor: 'rgba(16, 185, 129, 0.08)', borderLeft: '3px solid var(--color-primary)', borderRadius: 'var(--radius-sm)', padding: '10px 14px', marginBottom: '16px', fontSize: '13px', color: 'var(--color-text-secondary)', lineHeight: '1.5', display: 'flex', gap: 8, alignItems: 'flex-start' }}>
- <Info size={14} style={{ color: 'var(--color-primary)', flexShrink: 0, marginTop: 1 }} />
- <span>Register a new partner or prospect organization. Choose whether to add them as a current partner or place them in the vetting queue for review before formalizing the relationship.</span>
+ <ResizableModal
+ modalId="partners-add"
+ title="Add Partner / Prospect"
+ onClose={onClose}
+ defaultWidth={540}
+ footer={
+ <>
+ <button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 8, border: '1px solid var(--color-border)', backgroundColor: 'transparent', color: 'var(--color-text-secondary)', fontSize: 13, cursor: 'pointer' }}>Cancel</button>
+ <button
+ type="button"
+ disabled={saving || !form.name.trim()}
+ onClick={(e) => handleSubmit(e as unknown as React.FormEvent)}
+ style={{ padding: '8px 16px', borderRadius: 8, border: 'none', backgroundColor: form.is_prospect ? '#6366f1' : '#10b981', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer', opacity: saving ? 0.7 : 1 }}
+ >
+ {saving ? 'Saving…' : form.is_prospect ? 'Add to Vetting' : 'Add Partner'}
+ </button>
+ </>
+ }
+ >
+ <div style={{ backgroundColor: 'rgba(16, 185, 129, 0.08)', borderLeft: '3px solid var(--color-primary)', borderRadius: 'var(--radius-sm)', padding: '10px 14px', marginBottom: '16px', fontSize: '13px', color: 'var(--color-text-secondary)', lineHeight: '1.5', display: 'flex', gap: 8, alignItems: 'flex-start' }}>
+ <Info size={14} style={{ color: 'var(--color-primary)', flexShrink: 0, marginTop: 1 }} />
+ <span>Register a new partner or prospect organization. Choose whether to add them as a current partner or place them in the vetting queue for review before formalizing the relationship.</span>
+ </div>
+ <form onSubmit={handleSubmit}>
+ <div style={{ display: 'flex', gap: 8, marginBottom: 14 }}>
+ {[false, true].map(val => (
+ <button key={String(val)} type="button" onClick={() => setForm(f => ({ ...f, is_prospect: val }))}
+ style={{ flex: 1, padding: '8px', borderRadius: 8, border: `1.5px solid ${form.is_prospect === val ? '#6366f1' : 'var(--color-border)'}`, backgroundColor: form.is_prospect === val ? 'rgba(99,102,241,0.12)' : 'transparent', color: form.is_prospect === val ? '#818cf8' : 'var(--color-text-secondary)', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>
+ {val ? '🔍 Add to Vetting Queue' : '🤝 Add as Current Partner'}
+ </button>
+ ))}
</div>
- <form onSubmit={handleSubmit}>
- <div style={{ display: 'flex', gap: 8, marginBottom: 14 }}>
- {[false, true].map(val => (
- <button key={String(val)} type="button" onClick={() => setForm(f => ({ ...f, is_prospect: val }))}
- style={{ flex: 1, padding: '8px', borderRadius: 8, border: `1.5px solid ${form.is_prospect === val ? '#6366f1' : 'var(--color-border)'}`, backgroundColor: form.is_prospect === val ? 'rgba(99,102,241,0.12)' : 'transparent', color: form.is_prospect === val ? '#818cf8' : 'var(--color-text-secondary)', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}>
- {val ? '🔍 Add to Vetting Queue' : '🤝 Add as Current Partner'}
- </button>
- ))}
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
+ <div>
+ <label style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', display: 'block', marginBottom: 4 }}>Type *</label>
+ <select value={form.partner_type} onChange={e => setForm(f => ({ ...f, partner_type: e.target.value as PartnerType }))} style={inp}>
+ {Object.entries(TYPE_CONFIG).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
+ </select>
</div>
- <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
- <div>
- <label style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', display: 'block', marginBottom: 4 }}>Type *</label>
- <select value={form.partner_type} onChange={e => setForm(f => ({ ...f, partner_type: e.target.value as PartnerType }))} style={inp}>
- {Object.entries(TYPE_CONFIG).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
- </select>
- </div>
- <div>
- <label style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', display: 'block', marginBottom: 4 }}>Organization Name *</label>
- <input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} style={inp} />
- </div>
- <div>
- <label style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', display: 'block', marginBottom: 4 }}>Contact Name</label>
- <input value={form.contact_name} onChange={e => setForm(f => ({ ...f, contact_name: e.target.value }))} style={inp} />
- </div>
- <div>
- <label style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', display: 'block', marginBottom: 4 }}>Twitter Handle</label>
- <input value={form.twitter_handle} onChange={e => setForm(f => ({ ...f, twitter_handle: e.target.value }))} placeholder="@handle" style={inp} />
- </div>
- <div style={{ gridColumn: '1 / -1' }}>
- <label style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', display: 'block', marginBottom: 4 }}>Website</label>
- <input value={form.website_url} onChange={e => setForm(f => ({ ...f, website_url: e.target.value }))} placeholder="https://..." style={inp} />
- </div>
+ <div>
+ <label style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', display: 'block', marginBottom: 4 }}>Organization Name *</label>
+ <input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} style={inp} />
</div>
- <div style={{ marginBottom: 14 }}>
- <label style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', display: 'block', marginBottom: 4 }}>Description</label>
- <textarea value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} rows={2} style={{ ...inp, resize: 'vertical' as const }} />
+ <div>
+ <label style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', display: 'block', marginBottom: 4 }}>Contact Name</label>
+ <input value={form.contact_name} onChange={e => setForm(f => ({ ...f, contact_name: e.target.value }))} style={inp} />
</div>
- <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
- <button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 8, border: '1px solid var(--color-border)', backgroundColor: 'transparent', color: 'var(--color-text-secondary)', fontSize: 13, cursor: 'pointer' }}>Cancel</button>
- <button type="submit" disabled={saving || !form.name.trim()} style={{ padding: '8px 16px', borderRadius: 8, border: 'none', backgroundColor: form.is_prospect ? '#6366f1' : '#10b981', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer', opacity: saving ? 0.7 : 1 }}>
- {saving ? 'Saving…' : form.is_prospect ? 'Add to Vetting' : 'Add Partner'}
- </button>
+ <div>
+ <label style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', display: 'block', marginBottom: 4 }}>Twitter Handle</label>
+ <input value={form.twitter_handle} onChange={e => setForm(f => ({ ...f, twitter_handle: e.target.value }))} placeholder="@handle" style={inp} />
</div>
- </form>
- </motion.div>
- </div>
+ <div style={{ gridColumn: '1 / -1' }}>
+ <label style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', display: 'block', marginBottom: 4 }}>Website</label>
+ <input value={form.website_url} onChange={e => setForm(f => ({ ...f, website_url: e.target.value }))} placeholder="https://..." style={inp} />
+ </div>
+ </div>
+ <div style={{ marginBottom: 14 }}>
+ <label style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', display: 'block', marginBottom: 4 }}>Description</label>
+ <textarea value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} rows={2} style={{ ...inp, resize: 'vertical' as const }} />
+ </div>
+ {/* Hidden submit for Enter-key */}
+ <button type="submit" style={{ display: 'none' }} aria-hidden="true" tabIndex={-1} />
+ </form>
+ </ResizableModal>
);
}
diff --git a/components/petitions/AddPetitionModal.tsx b/components/petitions/AddPetitionModal.tsx
index 1336179..c268b94 100644
--- a/components/petitions/AddPetitionModal.tsx
+++ b/components/petitions/AddPetitionModal.tsx
@@ -1,13 +1,13 @@
'use client';
import { useState, useEffect, useRef } from 'react';
-import { motion, AnimatePresence } from 'framer-motion';
import {
X, Plus, Send, CheckCircle, AlertCircle, Loader2,
- Link2, Users, Globe, Facebook, Instagram, Printer, Bell, Zap,
- Copy, Share2, ExternalLink, ChevronDown, ChevronUp, Info,
+ Link2, Users, Globe, Printer, Bell, Zap,
+ Copy, Share2, ExternalLink, Info,
} from 'lucide-react';
import type { Petition } from './PetitionCard';
+import ResizableModal from '../shared/ResizableModal';
/* ─── Types ──────────────────────────────────────────────────────────────── */
interface AddPetitionModalProps {
@@ -313,92 +313,89 @@ export default function AddPetitionModal({
}
}
+ if (!isOpen) return null;
+
return (
- <AnimatePresence>
- {isOpen && (
+ <ResizableModal
+ modalId={isEditMode ? 'petition-edit' : 'petition-create'}
+ title={
+ <span className="flex items-center gap-2">
+ <Plus size={14} style={{ color: 'var(--color-primary)' }} aria-hidden="true" />
+ {isEditMode ? 'Edit Petition' : 'Add Petition'}
+ </span>
+ }
+ onClose={onClose}
+ defaultWidth={560}
+ footer={
<>
- {/* Backdrop */}
- <motion.div
- key="backdrop"
- initial={{ opacity: 0 }}
- animate={{ opacity: 1 }}
- exit={{ opacity: 0 }}
- transition={{ duration: 0.18 }}
+ <button
+ type="button"
onClick={onClose}
- style={{
- position: 'fixed',
- inset: 0,
- backgroundColor: 'rgba(0,0,0,0.65)',
- backdropFilter: 'blur(3px)',
- zIndex: 40,
- }}
- aria-hidden="true"
- />
+ disabled={submitting || publishing}
+ className="btn btn-secondary"
+ >
+ {publishStep === 'done' ? 'Close' : 'Cancel'}
+ </button>
- {/* Modal panel */}
- <motion.div
- key="modal"
- initial={{ opacity: 0, scale: 0.96, y: 12 }}
- animate={{ opacity: 1, scale: 1, y: 0 }}
- exit={{ opacity: 0, scale: 0.96, y: 12 }}
- transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
- role="dialog"
- aria-modal="true"
- aria-label={isEditMode ? 'Edit petition' : 'Add petition'}
- style={{
- position: 'fixed',
- inset: 0,
- display: 'flex',
- alignItems: 'flex-start',
- justifyContent: 'center',
- zIndex: 50,
- padding: '1rem',
- paddingTop: 'clamp(1rem, 8vh, 5rem)',
- overflowY: 'auto',
- }}
+ {/* Save only button */}
+ <button
+ type="button"
+ onClick={() => handleSubmit({ preventDefault: () => {} } as React.FormEvent)}
+ disabled={submitting || publishing}
+ className="btn btn-ghost"
+ style={{ border: '1px solid var(--color-border)' }}
>
- <div
+ {submitting ? (
+ <>
+ <span className="spinner" style={{ width: 14, height: 14, borderWidth: 2 }} aria-hidden="true" />
+ {isEditMode ? 'Saving...' : 'Save Only'}
+ </>
+ ) : isEditMode ? (
+ 'Save Changes'
+ ) : (
+ 'Save Only'
+ )}
+ </button>
+
+ {/* Create & Publish Now button (only for new petitions with platforms selected) */}
+ {!isEditMode && (
+ <button
+ type="button"
+ onClick={(e) => handlePublishNow(e as unknown as React.FormEvent)}
+ disabled={submitting || publishing || selectedPlatforms.size === 0 || publishStep === 'done'}
+ className="btn btn-primary"
style={{
- backgroundColor: 'var(--color-surface)',
- border: '1px solid var(--color-border)',
- borderRadius: 'var(--radius-lg)',
- width: '100%',
- maxWidth: 560,
- boxShadow: '0 24px 60px rgba(0,0,0,0.5)',
+ opacity: selectedPlatforms.size === 0 ? 0.5 : 1,
+ background: selectedPlatforms.size > 0 ? 'linear-gradient(135deg, var(--color-primary), #7c3aed)' : undefined,
}}
>
- {/* Header */}
- <div
- className="flex items-center justify-between px-5 py-4"
- style={{ borderBottom: '1px solid var(--color-border)' }}
- >
- <div className="flex items-center gap-2">
- <Plus
- size={16}
- style={{ color: 'var(--color-primary)' }}
- aria-hidden="true"
- />
- <h2
- className="font-semibold text-sm"
- style={{ color: 'var(--color-text)' }}
- id="modal-title"
- >
- {isEditMode ? 'Edit Petition' : 'Add Petition'}
- </h2>
- </div>
- <button
- type="button"
- onClick={onClose}
- className="btn btn-ghost btn-sm"
- aria-label="Close modal"
- >
- <X size={15} aria-hidden="true" />
- </button>
- </div>
-
- {/* Form */}
- <form onSubmit={handleSubmit} noValidate>
- <div className="px-5 py-4 flex flex-col gap-4">
+ {publishing ? (
+ <>
+ <span className="spinner" style={{ width: 14, height: 14, borderWidth: 2 }} aria-hidden="true" />
+ Publishing...
+ </>
+ ) : publishStep === 'done' ? (
+ <>
+ <CheckCircle size={15} aria-hidden="true" />
+ Published!
+ </>
+ ) : (
+ <>
+ <Send size={15} aria-hidden="true" />
+ Create & Publish ({selectedPlatforms.size})
+ </>
+ )}
+ </button>
+ )}
+ </>
+ }
+ >
+ <form
+ onSubmit={handleSubmit}
+ noValidate
+ aria-label={isEditMode ? 'Edit petition' : 'Add petition'}
+ >
+ <div className="flex flex-col gap-4">
{/* Usage guidance */}
<div style={{ backgroundColor: 'rgba(16, 185, 129, 0.08)', borderLeft: '3px solid var(--color-primary)', borderRadius: 'var(--radius-sm)', padding: '10px 14px', fontSize: '13px', color: 'var(--color-text-secondary)', lineHeight: '1.5', display: 'flex', gap: 8, alignItems: 'flex-start' }}>
<Info size={14} style={{ color: 'var(--color-primary)', flexShrink: 0, marginTop: 1 }} />
@@ -651,7 +648,7 @@ export default function AddPetitionModal({
{/* Publish Platforms (only for new petitions) */}
{!isEditMode && (
- <div className="px-5 pb-2">
+ <div className="pb-2">
<div className="flex items-center justify-between mb-2">
<label className="text-xs font-medium" style={{ color: 'var(--color-text-secondary)' }}>
Publish Now To
@@ -729,76 +726,8 @@ export default function AddPetitionModal({
<PromoteShareSection petitionUrl={form.url} petitionTitle={form.title} />
)}
- {/* Footer */}
- <div
- className="flex items-center justify-end gap-2 px-5 py-4"
- style={{ borderTop: '1px solid var(--color-border)' }}
- >
- <button
- type="button"
- onClick={onClose}
- disabled={submitting || publishing}
- className="btn btn-secondary"
- >
- {publishStep === 'done' ? 'Close' : 'Cancel'}
- </button>
-
- {/* Save only button */}
- <button
- type="submit"
- disabled={submitting || publishing}
- className="btn btn-ghost"
- style={{ border: '1px solid var(--color-border)' }}
- >
- {submitting ? (
- <>
- <span className="spinner" style={{ width: 14, height: 14, borderWidth: 2 }} aria-hidden="true" />
- {isEditMode ? 'Saving...' : 'Save Only'}
- </>
- ) : isEditMode ? (
- 'Save Changes'
- ) : (
- 'Save Only'
- )}
- </button>
-
- {/* Create & Publish Now button (only for new petitions with platforms selected) */}
- {!isEditMode && (
- <button
- type="button"
- onClick={handlePublishNow}
- disabled={submitting || publishing || selectedPlatforms.size === 0 || publishStep === 'done'}
- className="btn btn-primary"
- style={{
- opacity: selectedPlatforms.size === 0 ? 0.5 : 1,
- background: selectedPlatforms.size > 0 ? 'linear-gradient(135deg, var(--color-primary), #7c3aed)' : undefined,
- }}
- >
- {publishing ? (
- <>
- <span className="spinner" style={{ width: 14, height: 14, borderWidth: 2 }} aria-hidden="true" />
- Publishing...
- </>
- ) : publishStep === 'done' ? (
- <>
- <CheckCircle size={15} aria-hidden="true" />
- Published!
- </>
- ) : (
- <>
- <Send size={15} aria-hidden="true" />
- Create & Publish ({selectedPlatforms.size})
- </>
- )}
- </button>
- )}
- </div>
- </form>
- </div>
- </motion.div>
- </>
- )}
- </AnimatePresence>
+ </form>
+ </ResizableModal>
);
}
diff --git a/components/petitions/PlatformPreview.tsx b/components/petitions/PlatformPreview.tsx
index 628463b..2404793 100644
--- a/components/petitions/PlatformPreview.tsx
+++ b/components/petitions/PlatformPreview.tsx
@@ -33,6 +33,7 @@ import {
Check,
AlertTriangle,
} from 'lucide-react';
+import ResizableModal from '../shared/ResizableModal';
/* ─── Types ──────────────────────────────────────────────────────────────── */
export interface PlatformPreviewPetition {
@@ -1026,6 +1027,7 @@ function POAPreview({
/* ─── Sub-component: Confirmation Dialog ─────────────────────────────────── */
interface ConfirmDialogProps {
isOpen: boolean;
+ modalId: string;
title: string;
message: string;
confirmLabel: string;
@@ -1036,6 +1038,7 @@ interface ConfirmDialogProps {
function ConfirmDialog({
isOpen,
+ modalId,
title,
message,
confirmLabel,
@@ -1043,94 +1046,44 @@ function ConfirmDialog({
onConfirm,
onCancel,
}: ConfirmDialogProps) {
+ if (!isOpen) return null;
return (
- <AnimatePresence>
- {isOpen && (
+ <ResizableModal
+ modalId={modalId}
+ title={title}
+ onClose={onCancel}
+ defaultWidth={400}
+ footer={
<>
- <motion.div
- key="confirm-backdrop"
- initial={{ opacity: 0 }}
- animate={{ opacity: 1 }}
- exit={{ opacity: 0 }}
- transition={{ duration: 0.15 }}
+ <button
+ type="button"
onClick={onCancel}
- style={{
- position: 'fixed',
- inset: 0,
- backgroundColor: 'rgba(0,0,0,0.55)',
- backdropFilter: 'blur(2px)',
- zIndex: 60,
- }}
- aria-hidden="true"
- />
- <motion.div
- key="confirm-dialog"
- initial={{ opacity: 0, scale: 0.95, y: 8 }}
- animate={{ opacity: 1, scale: 1, y: 0 }}
- exit={{ opacity: 0, scale: 0.95, y: 8 }}
- transition={{ duration: 0.18, ease: [0.16, 1, 0.3, 1] }}
- role="alertdialog"
- aria-modal="true"
- aria-labelledby="confirm-title"
- aria-describedby="confirm-msg"
- style={{
- position: 'fixed',
- top: '50%',
- left: '50%',
- transform: 'translate(-50%, -50%)',
- zIndex: 70,
- width: '100%',
- maxWidth: 380,
- backgroundColor: 'var(--color-surface)',
- border: '1px solid var(--color-border)',
- borderRadius: 'var(--radius-lg)',
- padding: '1.25rem',
- boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
- }}
+ className="btn btn-secondary btn-sm"
>
- <h3
- id="confirm-title"
- style={{
- color: 'var(--color-text)',
- fontWeight: 600,
- fontSize: '0.9375rem',
- marginBottom: '0.5rem',
- }}
- >
- {title}
- </h3>
- <p
- id="confirm-msg"
- style={{
- color: 'var(--color-text-secondary)',
- fontSize: '0.875rem',
- lineHeight: 1.5,
- marginBottom: '1rem',
- }}
- >
- {message}
- </p>
- <div className="flex justify-end gap-2">
- <button
- type="button"
- onClick={onCancel}
- className="btn btn-secondary btn-sm"
- >
- Cancel
- </button>
- <button
- type="button"
- onClick={onConfirm}
- className="btn btn-sm"
- style={confirmStyle}
- >
- {confirmLabel}
- </button>
- </div>
- </motion.div>
+ Cancel
+ </button>
+ <button
+ type="button"
+ onClick={onConfirm}
+ className="btn btn-sm"
+ style={confirmStyle}
+ >
+ {confirmLabel}
+ </button>
</>
- )}
- </AnimatePresence>
+ }
+ >
+ <p
+ id="confirm-msg"
+ style={{
+ color: 'var(--color-text-secondary)',
+ fontSize: '0.875rem',
+ lineHeight: 1.5,
+ }}
+ >
+ {message}
+ </p>
+ </ResizableModal>
);
}
@@ -1608,6 +1561,7 @@ export default function PlatformPreview({
{/* ── Confirmation Dialogs ──────────────────────────────────────────── */}
<ConfirmDialog
isOpen={confirmTarget === 'save'}
+ modalId="platform-preview-save-confirm"
title="Save as Draft?"
message={`"${petition.title}" will be saved as a draft petition and can be edited or published later.`}
confirmLabel="Save Draft"
@@ -1622,6 +1576,7 @@ export default function PlatformPreview({
<ConfirmDialog
isOpen={confirmTarget === 'publish'}
+ modalId="platform-preview-publish-confirm"
title="Publish to Pulse of America?"
message={`"${petition.title}" will be published as an active petition on the Pulse of America platform and will be publicly visible immediately.`}
confirmLabel="Publish Now"
diff --git a/components/pipeline/ScheduleModal.tsx b/components/pipeline/ScheduleModal.tsx
index 470e6cc..ddea939 100644
--- a/components/pipeline/ScheduleModal.tsx
+++ b/components/pipeline/ScheduleModal.tsx
@@ -1,9 +1,9 @@
'use client';
import { useState } from 'react';
-import { motion, AnimatePresence } from 'framer-motion';
-import { CalendarClock, X, Loader2, Check, Info } from 'lucide-react';
+import { CalendarClock, Loader2, Check, Info } from 'lucide-react';
import { useToast } from '@/components/ToastProvider';
+import ResizableModal from '@/components/shared/ResizableModal';
const PLATFORMS = [
{ id: 'twitter', label: 'Twitter / X', color: '#1d9bf0' },
@@ -85,48 +85,39 @@ export default function ScheduleModal({ open, onClose, contentId, contentType, t
}
}
+ if (!open) return null;
+
return (
- <AnimatePresence>
- {open && (
- <motion.div
- initial={{ opacity: 0 }}
- animate={{ opacity: 1 }}
- exit={{ opacity: 0 }}
- style={{
- position: 'fixed', inset: 0, zIndex: 100,
- display: 'flex', alignItems: 'center', justifyContent: 'center',
- backgroundColor: 'rgba(0,0,0,0.6)',
- }}
- onClick={onClose}
- >
- <motion.div
- initial={{ scale: 0.95, opacity: 0 }}
- animate={{ scale: 1, opacity: 1 }}
- exit={{ scale: 0.95, opacity: 0 }}
- onClick={e => e.stopPropagation()}
- style={{
- backgroundColor: 'var(--color-surface)',
- border: '1px solid var(--color-border)',
- borderRadius: 16,
- padding: 24,
- width: '100%',
- maxWidth: 440,
- boxShadow: '0 12px 48px rgba(0,0,0,0.5)',
- }}
+ <ResizableModal
+ modalId="pipeline-schedule"
+ title={
+ <span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
+ <CalendarClock size={16} style={{ color: 'var(--color-primary)' }} />
+ Schedule Dispatch
+ </span>
+ }
+ onClose={onClose}
+ defaultWidth={440}
+ footer={
+ <>
+ <button className="btn btn-ghost btn-sm" onClick={onClose}>Cancel</button>
+ <button
+ className="btn btn-primary btn-sm"
+ onClick={handleSchedule}
+ disabled={submitting || done || selected.size === 0}
+ style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 120 }}
>
- {/* Header */}
- <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
- <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
- <CalendarClock size={18} style={{ color: 'var(--color-primary)' }} />
- <h3 style={{ fontSize: 15, fontWeight: 700, color: 'var(--color-text)', margin: 0 }}>
- Schedule Dispatch
- </h3>
- </div>
- <button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', padding: 4 }}>
- <X size={16} />
- </button>
- </div>
-
+ {submitting ? (
+ <><Loader2 size={14} className="spinner" /> Scheduling...</>
+ ) : done ? (
+ <><Check size={14} /> Scheduled!</>
+ ) : (
+ <><CalendarClock size={14} /> Schedule</>
+ )}
+ </button>
+ </>
+ }
+ >
{/* Usage guidance */}
<div style={{ backgroundColor: 'rgba(16, 185, 129, 0.08)', borderLeft: '3px solid var(--color-primary)', borderRadius: 'var(--radius-sm)', padding: '10px 14px', marginBottom: 16, fontSize: '13px', color: 'var(--color-text-secondary)', lineHeight: '1.5', display: 'flex', gap: 8, alignItems: 'flex-start' }}>
<Info size={14} style={{ color: 'var(--color-primary)', flexShrink: 0, marginTop: 1 }} />
@@ -189,27 +180,6 @@ export default function ScheduleModal({ open, onClose, contentId, contentType, t
/>
</div>
- {/* Actions */}
- <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
- <button className="btn btn-ghost btn-sm" onClick={onClose}>Cancel</button>
- <button
- className="btn btn-primary btn-sm"
- onClick={handleSchedule}
- disabled={submitting || done || selected.size === 0}
- style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 120 }}
- >
- {submitting ? (
- <><Loader2 size={14} className="spinner" /> Scheduling...</>
- ) : done ? (
- <><Check size={14} /> Scheduled!</>
- ) : (
- <><CalendarClock size={14} /> Schedule</>
- )}
- </button>
- </div>
- </motion.div>
- </motion.div>
- )}
- </AnimatePresence>
+ </ResizableModal>
);
}
diff --git a/components/settings/ApiCredentialsSection.tsx b/components/settings/ApiCredentialsSection.tsx
index 0c86922..4971c3d 100644
--- a/components/settings/ApiCredentialsSection.tsx
+++ b/components/settings/ApiCredentialsSection.tsx
@@ -2,12 +2,13 @@
import { useState, useEffect, useCallback } from 'react';
import {
- Key, Shield, Plus, Trash2, X, RefreshCw, Copy, Check,
+ Key, Shield, Plus, Trash2, RefreshCw, Copy, Check,
ChevronDown, ChevronRight, AlertTriangle, CheckCircle2, XCircle,
Clock, Wifi, WifiOff, Eye, EyeOff, Globe, Mail, Unplug, Plug,
MessageSquare, Cloud, Camera, Zap,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
+import ResizableModal from '../shared/ResizableModal';
/* ─── Types ──────────────────────────────────────────────────────────────── */
@@ -538,156 +539,121 @@ export default function ApiCredentialsSection() {
{/* ── Generate Key Modal ────────────────────────────────────────────── */}
{showGenerateModal && (
- <div style={{
- position: 'fixed', inset: 0, zIndex: 1000,
- background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center',
- }}>
- <div className="card" style={{ padding: 24, width: 440, maxWidth: '90vw' }}>
- {generatedKey ? (
- /* ── Key Created — One-time display ── */
+ <ResizableModal
+ modalId={generatedKey ? 'api-key-generated' : 'api-key-create'}
+ title={generatedKey ? 'API Key Generated' : 'Generate API Key'}
+ onClose={closeGenerateModal}
+ defaultWidth={520}
+ footer={
+ generatedKey ? (
+ <button className="btn btn-primary" onClick={closeGenerateModal}>
+ Done
+ </button>
+ ) : (
<>
- <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
- <CheckCircle2 size={20} style={{ color: 'var(--color-success)' }} />
- <h3 style={{ margin: 0, fontSize: 16 }}>API Key Generated</h3>
- </div>
-
- <div style={{
- background: 'var(--color-surface-el)', borderRadius: 'var(--radius-md)',
- padding: 12, marginBottom: 12, position: 'relative',
- }}>
- <code style={{ fontSize: 12, wordBreak: 'break-all', lineHeight: 1.5 }}>
- {generatedKey}
- </code>
- <button
- className="btn btn-ghost btn-sm"
- onClick={copyKey}
- style={{ position: 'absolute', top: 8, right: 8 }}
- >
- {keyCopied ? <Check size={14} style={{ color: 'var(--color-success)' }} /> : <Copy size={14} />}
- </button>
- </div>
-
- <div style={{
- display: 'flex', alignItems: 'center', gap: 6, padding: 10,
- background: 'rgba(244, 63, 94, 0.1)', borderRadius: 'var(--radius-sm)',
- marginBottom: 16, fontSize: 12, color: 'var(--color-error)',
- }}>
- <AlertTriangle size={14} />
- This key will not be shown again. Copy it now.
- </div>
-
- <button className="btn btn-primary" onClick={closeGenerateModal} style={{ width: '100%' }}>
- Done
+ <button className="btn btn-ghost" onClick={closeGenerateModal}>Cancel</button>
+ <button
+ className="btn btn-primary"
+ onClick={handleGenerateKey}
+ disabled={generating || !newKeyName.trim()}
+ >
+ {generating ? <><RefreshCw size={14} className="spinner" /> Generating...</> : <><Key size={14} /> Generate</>}
</button>
</>
- ) : (
- /* ── Key Creation Form ── */
- <>
- <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
- <h3 style={{ margin: 0, fontSize: 16 }}>Generate API Key</h3>
- <button className="btn btn-ghost btn-sm" onClick={closeGenerateModal}><X size={16} /></button>
- </div>
+ )
+ }
+ >
+ {generatedKey ? (
+ /* ── Key Created — One-time display ── */
+ <>
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
+ <CheckCircle2 size={20} style={{ color: 'var(--color-success)' }} />
+ <span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>Your key has been created.</span>
+ </div>
- <label style={{ display: 'block', marginBottom: 12 }}>
- <span style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4, display: 'block' }}>Name</span>
- <input
- className="input"
- value={newKeyName}
- onChange={(e) => setNewKeyName(e.target.value)}
- placeholder="e.g. Webhook Integration"
- style={{ width: '100%' }}
- />
- </label>
-
- <label style={{ display: 'block', marginBottom: 12 }}>
- <span style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4, display: 'block' }}>Scopes</span>
- <div style={{ display: 'flex', gap: 8 }}>
- {['read', 'write', 'admin'].map((scope) => (
- <button
- key={scope}
- className={`btn btn-sm ${newKeyScopes.includes(scope) ? 'btn-primary' : 'btn-ghost'}`}
- onClick={() => toggleScope(scope)}
- style={{ textTransform: 'capitalize' }}
- >
- {scope}
- </button>
- ))}
- </div>
- </label>
-
- <label style={{ display: 'block', marginBottom: 16 }}>
- <span style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4, display: 'block' }}>
- Expires in (days) — leave empty for no expiry
- </span>
- <input
- className="input"
- type="number"
- value={newKeyExpiry}
- onChange={(e) => setNewKeyExpiry(e.target.value)}
- placeholder="90"
- min="1"
- style={{ width: 120 }}
- />
- </label>
-
- <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
- <button className="btn btn-ghost" onClick={closeGenerateModal}>Cancel</button>
- <button
- className="btn btn-primary"
- onClick={handleGenerateKey}
- disabled={generating || !newKeyName.trim()}
- >
- {generating ? <><RefreshCw size={14} className="spinner" /> Generating...</> : <><Key size={14} /> Generate</>}
- </button>
- </div>
- </>
- )}
- </div>
- </div>
- )}
+ <div style={{
+ background: 'var(--color-surface-el)', borderRadius: 'var(--radius-md)',
+ padding: 12, marginBottom: 12, position: 'relative',
+ }}>
+ <code style={{ fontSize: 12, wordBreak: 'break-all', lineHeight: 1.5 }}>
+ {generatedKey}
+ </code>
+ <button
+ className="btn btn-ghost btn-sm"
+ onClick={copyKey}
+ style={{ position: 'absolute', top: 8, right: 8 }}
+ >
+ {keyCopied ? <Check size={14} style={{ color: 'var(--color-success)' }} /> : <Copy size={14} />}
+ </button>
+ </div>
- {/* ── Connect Platform Modal ────────────────────────────────────────── */}
- {connectPlatform && (
- <div style={{
- position: 'fixed', inset: 0, zIndex: 1000,
- background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center',
- }}>
- <div className="card" style={{ padding: 24, width: 440, maxWidth: '90vw' }}>
- <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
- <h3 style={{ margin: 0, fontSize: 16, textTransform: 'capitalize' }}>
- Connect {connectPlatform}
- </h3>
- <button className="btn btn-ghost btn-sm" onClick={() => setConnectPlatform(null)}><X size={16} /></button>
- </div>
+ <div style={{
+ display: 'flex', alignItems: 'center', gap: 6, padding: 10,
+ background: 'rgba(244, 63, 94, 0.1)', borderRadius: 'var(--radius-sm)',
+ fontSize: 12, color: 'var(--color-error)',
+ }}>
+ <AlertTriangle size={14} />
+ This key will not be shown again. Copy it now.
+ </div>
+ </>
+ ) : (
+ /* ── Key Creation Form ── */
+ <>
+ <label style={{ display: 'block', marginBottom: 12 }}>
+ <span style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4, display: 'block' }}>Name</span>
+ <input
+ className="input"
+ value={newKeyName}
+ onChange={(e) => setNewKeyName(e.target.value)}
+ placeholder="e.g. Webhook Integration"
+ style={{ width: '100%' }}
+ />
+ </label>
- {(PLATFORM_FIELDS[connectPlatform] || []).map((field) => (
- <label key={field.key} style={{ display: 'block', marginBottom: 12 }}>
- <span style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4, display: 'block' }}>
- {field.label}
- </span>
- <div style={{ position: 'relative' }}>
- <input
- className="input"
- type={field.secret && !fieldVisibility[field.key] ? 'password' : 'text'}
- value={connectFields[field.key] || ''}
- onChange={(e) => setConnectFields((prev) => ({ ...prev, [field.key]: e.target.value }))}
- placeholder={field.secret ? '••••••••' : field.label}
- style={{ width: '100%', paddingRight: field.secret ? 36 : undefined }}
- />
- {field.secret && (
+ <label style={{ display: 'block', marginBottom: 12 }}>
+ <span style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4, display: 'block' }}>Scopes</span>
+ <div style={{ display: 'flex', gap: 8 }}>
+ {['read', 'write', 'admin'].map((scope) => (
<button
- className="btn btn-ghost btn-sm"
- style={{ position: 'absolute', right: 4, top: '50%', transform: 'translateY(-50%)' }}
- onClick={() => setFieldVisibility((prev) => ({ ...prev, [field.key]: !prev[field.key] }))}
+ key={scope}
+ className={`btn btn-sm ${newKeyScopes.includes(scope) ? 'btn-primary' : 'btn-ghost'}`}
+ onClick={() => toggleScope(scope)}
+ style={{ textTransform: 'capitalize' }}
>
- {fieldVisibility[field.key] ? <EyeOff size={14} /> : <Eye size={14} />}
+ {scope}
</button>
- )}
+ ))}
</div>
</label>
- ))}
- <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 16 }}>
+ <label style={{ display: 'block' }}>
+ <span style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4, display: 'block' }}>
+ Expires in (days) — leave empty for no expiry
+ </span>
+ <input
+ className="input"
+ type="number"
+ value={newKeyExpiry}
+ onChange={(e) => setNewKeyExpiry(e.target.value)}
+ placeholder="90"
+ min="1"
+ style={{ width: 120 }}
+ />
+ </label>
+ </>
+ )}
+ </ResizableModal>
+ )}
+
+ {/* ── Connect Platform Modal ────────────────────────────────────────── */}
+ {connectPlatform && (
+ <ResizableModal
+ modalId="api-platform-connect"
+ title={<span style={{ textTransform: 'capitalize' }}>Connect {connectPlatform}</span>}
+ onClose={() => setConnectPlatform(null)}
+ defaultWidth={520}
+ footer={
+ <>
<button className="btn btn-ghost" onClick={() => setConnectPlatform(null)}>Cancel</button>
<button
className="btn btn-primary"
@@ -696,55 +662,86 @@ export default function ApiCredentialsSection() {
>
{connecting ? <><RefreshCw size={14} className="spinner" /> Connecting...</> : <><Plug size={14} /> Connect</>}
</button>
- </div>
- </div>
- </div>
+ </>
+ }
+ >
+ {(PLATFORM_FIELDS[connectPlatform] || []).map((field) => (
+ <label key={field.key} style={{ display: 'block', marginBottom: 12 }}>
+ <span style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4, display: 'block' }}>
+ {field.label}
+ </span>
+ <div style={{ position: 'relative' }}>
+ <input
+ className="input"
+ type={field.secret && !fieldVisibility[field.key] ? 'password' : 'text'}
+ value={connectFields[field.key] || ''}
+ onChange={(e) => setConnectFields((prev) => ({ ...prev, [field.key]: e.target.value }))}
+ placeholder={field.secret ? '••••••••' : field.label}
+ style={{ width: '100%', paddingRight: field.secret ? 36 : undefined }}
+ />
+ {field.secret && (
+ <button
+ className="btn btn-ghost btn-sm"
+ style={{ position: 'absolute', right: 4, top: '50%', transform: 'translateY(-50%)' }}
+ onClick={() => setFieldVisibility((prev) => ({ ...prev, [field.key]: !prev[field.key] }))}
+ >
+ {fieldVisibility[field.key] ? <EyeOff size={14} /> : <Eye size={14} />}
+ </button>
+ )}
+ </div>
+ </label>
+ ))}
+ </ResizableModal>
)}
{/* ── Revoke Key Confirm ────────────────────────────────────────────── */}
{revokeId && (
- <div style={{
- position: 'fixed', inset: 0, zIndex: 1000,
- background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center',
- }}>
- <div className="card" style={{ padding: 24, width: 380, textAlign: 'center' }}>
- <AlertTriangle size={32} style={{ color: 'var(--color-error)', marginBottom: 12 }} />
- <h3 style={{ margin: '0 0 8px', fontSize: 16 }}>Revoke API Key?</h3>
- <p style={{ color: 'var(--color-text-secondary)', fontSize: 13, marginBottom: 16 }}>
- This key will immediately stop working. This action cannot be undone.
- </p>
- <div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
+ <ResizableModal
+ modalId="api-key-revoke-confirm"
+ title="Revoke API Key?"
+ onClose={() => setRevokeId(null)}
+ defaultWidth={400}
+ footer={
+ <>
<button className="btn btn-ghost" onClick={() => setRevokeId(null)}>Cancel</button>
<button className="btn btn-danger" onClick={handleRevokeKey} disabled={revoking}>
{revoking ? 'Revoking...' : 'Revoke Key'}
</button>
- </div>
+ </>
+ }
+ >
+ <div style={{ textAlign: 'center' }}>
+ <AlertTriangle size={32} style={{ color: 'var(--color-error)', marginBottom: 12 }} />
+ <p style={{ color: 'var(--color-text-secondary)', fontSize: 13, margin: 0 }}>
+ This key will immediately stop working. This action cannot be undone.
+ </p>
</div>
- </div>
+ </ResizableModal>
)}
{/* ── Disconnect Confirm ────────────────────────────────────────────── */}
{disconnectPlatform && (
- <div style={{
- position: 'fixed', inset: 0, zIndex: 1000,
- background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center',
- }}>
- <div className="card" style={{ padding: 24, width: 380, textAlign: 'center' }}>
- <Unplug size={32} style={{ color: 'var(--color-warning)', marginBottom: 12 }} />
- <h3 style={{ margin: '0 0 8px', fontSize: 16, textTransform: 'capitalize' }}>
- Disconnect {disconnectPlatform}?
- </h3>
- <p style={{ color: 'var(--color-text-secondary)', fontSize: 13, marginBottom: 16 }}>
- All stored credentials will be cleared. The agent will stop working until reconnected.
- </p>
- <div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
+ <ResizableModal
+ modalId="api-platform-disconnect-confirm"
+ title={<span style={{ textTransform: 'capitalize' }}>Disconnect {disconnectPlatform}?</span>}
+ onClose={() => setDisconnectPlatform(null)}
+ defaultWidth={400}
+ footer={
+ <>
<button className="btn btn-ghost" onClick={() => setDisconnectPlatform(null)}>Cancel</button>
<button className="btn btn-danger" onClick={handleDisconnect} disabled={disconnecting}>
{disconnecting ? 'Disconnecting...' : 'Disconnect'}
</button>
- </div>
+ </>
+ }
+ >
+ <div style={{ textAlign: 'center' }}>
+ <Unplug size={32} style={{ color: 'var(--color-warning)', marginBottom: 12 }} />
+ <p style={{ color: 'var(--color-text-secondary)', fontSize: 13, margin: 0 }}>
+ All stored credentials will be cleared. The agent will stop working until reconnected.
+ </p>
</div>
- </div>
+ </ResizableModal>
)}
</div>
);
diff --git a/components/social/SocialTab.tsx b/components/social/SocialTab.tsx
index fdae3da..119930d 100644
--- a/components/social/SocialTab.tsx
+++ b/components/social/SocialTab.tsx
@@ -13,6 +13,7 @@ import {
History,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
+import ResizableModal from '../shared/ResizableModal';
import AnalyticsDashboard from './phase2/AnalyticsDashboard';
import ApprovalQueue from './phase2/ApprovalQueue';
import AIImageGenerator from './phase2/AIImageGenerator';
@@ -947,57 +948,38 @@ function AddStreamModal({
onClose: () => void;
}) {
return (
- <div
- style={{
- position: 'fixed', inset: 0, zIndex: 60,
- background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(4px)',
- display: 'flex', alignItems: 'center', justifyContent: 'center',
- }}
- onClick={(e) => e.target === e.currentTarget && onClose()}
+ <ResizableModal
+ modalId="social-add-stream"
+ title="Add Stream"
+ onClose={onClose}
+ defaultWidth={560}
>
- <motion.div
- initial={{ opacity: 0, scale: 0.95 }}
- animate={{ opacity: 1, scale: 1 }}
- exit={{ opacity: 0, scale: 0.95 }}
- transition={{ duration: 0.15 }}
- style={{
- background: 'var(--color-surface)', borderRadius: 'var(--radius-lg)',
- border: '1px solid var(--color-border)', padding: 24, width: 560, maxWidth: '95vw',
- }}
- >
- <div className="flex items-center justify-between" style={{ marginBottom: 18 }}>
- <h3 style={{ fontSize: 16, fontWeight: 700, color: 'var(--color-text)' }}>Add Stream</h3>
- <button className="btn btn-ghost btn-sm" onClick={onClose} style={{ minHeight: 'unset', minWidth: 'unset', padding: 6 }}>
- <X size={16} />
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 10 }}>
+ {(Object.entries(STREAM_TYPE_META) as [StreamType, typeof STREAM_TYPE_META[StreamType]][]).map(([type, meta]) => (
+ <button
+ key={type}
+ onClick={() => { onAdd(type); onClose(); }}
+ style={{
+ textAlign: 'left', padding: 14, background: 'var(--color-bg)',
+ border: '1px solid var(--color-border)', borderRadius: 'var(--radius-md)',
+ cursor: 'pointer', transition: 'border-color 0.15s ease, background 0.15s ease',
+ }}
+ onMouseEnter={(e) => {
+ (e.currentTarget as HTMLButtonElement).style.borderColor = 'var(--color-primary)';
+ (e.currentTarget as HTMLButtonElement).style.background = 'rgba(10,124,89,0.04)';
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLButtonElement).style.borderColor = 'var(--color-border)';
+ (e.currentTarget as HTMLButtonElement).style.background = 'var(--color-bg)';
+ }}
+ >
+ <meta.Icon size={18} style={{ color: 'var(--color-primary)', marginBottom: 6 }} />
+ <div style={{ fontSize: 13, fontWeight: 700, color: 'var(--color-text)', marginBottom: 3 }}>{meta.label}</div>
+ <div style={{ fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>{meta.desc}</div>
</button>
- </div>
- <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 10 }}>
- {(Object.entries(STREAM_TYPE_META) as [StreamType, typeof STREAM_TYPE_META[StreamType]][]).map(([type, meta]) => (
- <button
- key={type}
- onClick={() => { onAdd(type); onClose(); }}
- style={{
- textAlign: 'left', padding: 14, background: 'var(--color-bg)',
- border: '1px solid var(--color-border)', borderRadius: 'var(--radius-md)',
- cursor: 'pointer', transition: 'border-color 0.15s ease, background 0.15s ease',
- }}
- onMouseEnter={(e) => {
- (e.currentTarget as HTMLButtonElement).style.borderColor = 'var(--color-primary)';
- (e.currentTarget as HTMLButtonElement).style.background = 'rgba(10,124,89,0.04)';
- }}
- onMouseLeave={(e) => {
- (e.currentTarget as HTMLButtonElement).style.borderColor = 'var(--color-border)';
- (e.currentTarget as HTMLButtonElement).style.background = 'var(--color-bg)';
- }}
- >
- <meta.Icon size={18} style={{ color: 'var(--color-primary)', marginBottom: 6 }} />
- <div style={{ fontSize: 13, fontWeight: 700, color: 'var(--color-text)', marginBottom: 3 }}>{meta.label}</div>
- <div style={{ fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>{meta.desc}</div>
- </button>
- ))}
- </div>
- </motion.div>
- </div>
+ ))}
+ </div>
+ </ResizableModal>
);
}
@@ -1296,52 +1278,16 @@ function ComposeModal({ onClose, prefill = '', editPost, onSaved }: ComposeModal
}
return (
- <div
- style={{
- position: 'fixed', inset: 0, zIndex: 70,
- background: 'rgba(0,0,0,0.45)', backdropFilter: 'blur(6px)',
- display: 'flex', alignItems: 'center', justifyContent: 'center',
- padding: 20,
- }}
- onClick={(e) => e.target === e.currentTarget && onClose()}
- >
- <motion.div
- initial={{ opacity: 0, y: 20, scale: 0.97 }}
- animate={{ opacity: 1, y: 0, scale: 1 }}
- exit={{ opacity: 0, y: 20, scale: 0.97 }}
- transition={{ duration: 0.18 }}
- style={{
- width: '100%', maxWidth: 1000,
- background: 'var(--color-surface)',
- borderRadius: 'var(--radius-lg)',
- border: '1px solid var(--color-border)',
- boxShadow: '0 20px 60px rgba(0,0,0,0.18)',
- display: 'flex',
- flexDirection: 'column',
- maxHeight: '90vh',
- overflow: 'hidden',
- }}
+ <>
+ <ResizableModal
+ modalId="social-compose-post"
+ title={editPost ? 'Edit Post' : 'New Post'}
+ onClose={onClose}
+ defaultWidth={1000}
+ defaultHeight={720}
>
- {/* Modal header */}
- <div className="flex items-center justify-between" style={{
- padding: '14px 20px',
- borderBottom: '1px solid var(--color-border)',
- flexShrink: 0,
- }}>
- <h2 style={{ fontSize: 16, fontWeight: 700, color: 'var(--color-text)', margin: 0 }}>
- {editPost ? 'Edit Post' : 'New Post'}
- </h2>
- <button
- className="btn btn-ghost btn-sm"
- onClick={onClose}
- style={{ minHeight: 'unset', minWidth: 'unset', padding: 6 }}
- >
- <X size={18} />
- </button>
- </div>
-
- {/* Two-panel body */}
- <div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
+ {/* Negate ResizableModal body padding so split-panel + dividers extend edge-to-edge */}
+ <div style={{ margin: '-16px -20px', display: 'flex', height: '100%', overflow: 'hidden' }}>
{/* LEFT PANEL — 55% */}
<div style={{
@@ -1684,7 +1630,7 @@ function ComposeModal({ onClose, prefill = '', editPost, onSaved }: ComposeModal
</div>
</div>
</div>
- </motion.div>
+ </ResizableModal>
{/* Nested AI Image Generator modal */}
{showAIImage && (
@@ -1693,7 +1639,7 @@ function ComposeModal({ onClose, prefill = '', editPost, onSaved }: ComposeModal
onClose={() => setShowAIImage(false)}
/>
)}
- </div>
+ </>
);
}
diff --git a/components/social/phase2/BulkScheduleModal.tsx b/components/social/phase2/BulkScheduleModal.tsx
index 2c59f12..d22edd3 100644
--- a/components/social/phase2/BulkScheduleModal.tsx
+++ b/components/social/phase2/BulkScheduleModal.tsx
@@ -1,13 +1,13 @@
'use client';
-import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
-import { motion, AnimatePresence } from 'framer-motion';
+import { useState, useEffect, useMemo, useRef } from 'react';
import {
- X, Upload, FileText, Calendar, Clock, Check, ChevronDown,
+ Upload, FileText, Calendar, Clock, Check,
Twitter, Instagram, Linkedin, Facebook, Globe, AtSign, Video,
AlertCircle, Sparkles,
} from 'lucide-react';
import { useToast } from '../../ToastProvider';
+import ResizableModal from '../../shared/ResizableModal';
/* ─────────────────────────────────────────────────────────────────────────── */
/* TYPES & CONSTANTS */
@@ -317,69 +317,80 @@ export default function BulkScheduleModal({ open, onClose, onScheduled }: BulkSc
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
+ if (!open) return null;
+
return (
- <AnimatePresence>
- {open && (
- <div
- style={{
- position: 'fixed', inset: 0, zIndex: 80,
- background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(6px)',
- display: 'flex', alignItems: 'center', justifyContent: 'center',
- padding: 20,
- }}
- onClick={(e) => e.target === e.currentTarget && onClose()}
- >
- <motion.div
- initial={{ opacity: 0, y: 20, scale: 0.97 }}
- animate={{ opacity: 1, y: 0, scale: 1 }}
- exit={{ opacity: 0, y: 20, scale: 0.97 }}
- transition={{ duration: 0.18 }}
+ <ResizableModal
+ modalId="social-bulk-schedule"
+ title={
+ <span className="flex items-center gap-2">
+ <Sparkles size={15} style={{ color: 'var(--color-primary)' }} />
+ <span>Bulk Schedule Posts</span>
+ {parsedPosts.length > 0 && (
+ <span style={{
+ padding: '2px 9px', borderRadius: 99, fontSize: 11, fontWeight: 700,
+ background: 'rgba(16,185,129,0.15)', color: 'var(--color-primary)',
+ border: '1px solid rgba(16,185,129,0.3)',
+ }}>
+ {parsedPosts.length} posts detected
+ </span>
+ )}
+ </span>
+ }
+ onClose={onClose}
+ defaultWidth={980}
+ footer={
+ <>
+ <div style={{ flex: 1 }}>
+ {error && (
+ <div className="flex items-center gap-2" style={{ fontSize: 12, color: 'var(--color-error)' }}>
+ <AlertCircle size={13} />
+ {error}
+ </div>
+ )}
+ </div>
+ <button
+ onClick={onClose}
+ className="btn btn-ghost btn-sm"
+ disabled={submitting}
+ >
+ Cancel
+ </button>
+ <button
+ onClick={handleSubmit}
+ disabled={submitting || parsedPosts.length === 0 || selectedPlatforms.length === 0}
+ className="btn btn-primary"
style={{
- width: '100%', maxWidth: 980,
- background: 'var(--color-surface)',
- borderRadius: 'var(--radius-lg)',
- border: '1px solid var(--color-border)',
- boxShadow: '0 24px 64px rgba(0,0,0,0.22)',
- display: 'flex', flexDirection: 'column',
- maxHeight: '92vh', overflow: 'hidden',
+ display: 'inline-flex', alignItems: 'center', gap: 6,
+ opacity: (submitting || parsedPosts.length === 0 || selectedPlatforms.length === 0) ? 0.6 : 1,
}}
- role="dialog"
- aria-modal="true"
- aria-label="Bulk Schedule Modal"
>
- {/* Modal header */}
- <div className="flex items-center justify-between" style={{
- padding: '14px 20px',
- borderBottom: '1px solid var(--color-border)',
- flexShrink: 0,
- }}>
- <div className="flex items-center gap-2">
- <Sparkles size={16} style={{ color: 'var(--color-primary)' }} />
- <h2 style={{ fontSize: 16, fontWeight: 700, color: 'var(--color-text)', margin: 0 }}>
- Bulk Schedule Posts
- </h2>
- {parsedPosts.length > 0 && (
- <span style={{
- padding: '2px 9px', borderRadius: 99, fontSize: 11, fontWeight: 700,
- background: 'rgba(16,185,129,0.15)', color: 'var(--color-primary)',
- border: '1px solid rgba(16,185,129,0.3)',
- }}>
- {parsedPosts.length} posts detected
- </span>
- )}
- </div>
- <button
- className="btn btn-ghost btn-sm"
- onClick={onClose}
- style={{ padding: 6, minHeight: 'unset', minWidth: 'unset' }}
- aria-label="Close bulk schedule modal"
- >
- <X size={18} />
- </button>
- </div>
-
+ {submitting ? (
+ <>
+ <div className="spinner" style={{ width: 14, height: 14, borderWidth: 2 }} />
+ Scheduling…
+ </>
+ ) : (
+ <>
+ <Calendar size={14} />
+ Schedule {parsedPosts.length > 0 ? `${parsedPosts.length} ` : ''}post{parsedPosts.length !== 1 ? 's' : ''}
+ </>
+ )}
+ </button>
+ </>
+ }
+ >
{/* Two-panel body */}
- <div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
+ <div style={{
+ display: 'flex',
+ flex: 1,
+ overflow: 'hidden',
+ marginLeft: -20,
+ marginRight: -20,
+ marginTop: -16,
+ marginBottom: -16,
+ minHeight: 480,
+ }}>
{/* ── LEFT PANEL: Content input ─────────────────────────────── */}
<div style={{
@@ -661,58 +672,6 @@ export default function BulkScheduleModal({ open, onClose, onScheduled }: BulkSc
</div>
</div>
- {/* Footer */}
- <div style={{
- padding: '12px 20px',
- borderTop: '1px solid var(--color-border)',
- display: 'flex', alignItems: 'center', justifyContent: 'space-between',
- flexShrink: 0,
- background: 'var(--color-surface-el)',
- flexWrap: 'wrap',
- gap: 8,
- }}>
- <div style={{ flex: 1 }}>
- {error && (
- <div className="flex items-center gap-2" style={{ fontSize: 12, color: 'var(--color-error)' }}>
- <AlertCircle size={13} />
- {error}
- </div>
- )}
- </div>
- <div className="flex items-center gap-2">
- <button
- onClick={onClose}
- className="btn btn-ghost btn-sm"
- disabled={submitting}
- >
- Cancel
- </button>
- <button
- onClick={handleSubmit}
- disabled={submitting || parsedPosts.length === 0 || selectedPlatforms.length === 0}
- className="btn btn-primary"
- style={{
- display: 'inline-flex', alignItems: 'center', gap: 6,
- opacity: (submitting || parsedPosts.length === 0 || selectedPlatforms.length === 0) ? 0.6 : 1,
- }}
- >
- {submitting ? (
- <>
- <div className="spinner" style={{ width: 14, height: 14, borderWidth: 2 }} />
- Scheduling…
- </>
- ) : (
- <>
- <Calendar size={14} />
- Schedule {parsedPosts.length > 0 ? `${parsedPosts.length} ` : ''}post{parsedPosts.length !== 1 ? 's' : ''}
- </>
- )}
- </button>
- </div>
- </div>
- </motion.div>
- </div>
- )}
- </AnimatePresence>
+ </ResizableModal>
);
}
diff --git a/components/statements/StatementsTab.tsx b/components/statements/StatementsTab.tsx
index 1faf658..01557ba 100644
--- a/components/statements/StatementsTab.tsx
+++ b/components/statements/StatementsTab.tsx
@@ -11,6 +11,7 @@ import {
} from 'lucide-react';
import { useToast } from '@/components/ToastProvider';
import { getOutletFaviconUrl } from '@/lib/outlet-logo';
+import ResizableModal from '@/components/shared/ResizableModal';
/* ─── Types ──────────────────────────────────────────────────────────────── */
@@ -209,36 +210,41 @@ function GenerateModal({
}
return (
- <div
- style={{
- position: 'fixed', inset: 0, zIndex: 100,
- display: 'flex', alignItems: 'center', justifyContent: 'center',
- background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)',
- }}
- onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
- >
- <motion.div
- initial={{ opacity: 0, scale: 0.95 }}
- animate={{ opacity: 1, scale: 1 }}
- exit={{ opacity: 0, scale: 0.95 }}
- style={{
- background: 'var(--color-surface)',
- border: '1px solid var(--color-border)',
- borderRadius: 'var(--radius-lg)',
- width: '100%', maxWidth: 620, maxHeight: '90vh',
- overflow: 'auto', padding: 24,
- }}
- >
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
- <h2 style={{ fontSize: 18, fontWeight: 600, color: 'var(--color-text)', display: 'flex', alignItems: 'center', gap: 8 }}>
- <Sparkles size={20} color="var(--color-secondary)" />
- Generate AI Statement
- </h2>
- <button onClick={onClose} className="btn btn-ghost" style={{ padding: 4 }}>
- <X size={18} />
+ <ResizableModal
+ modalId="statements-generate"
+ title={
+ <span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
+ <Sparkles size={20} color="var(--color-secondary)" />
+ Generate AI Statement
+ </span>
+ }
+ onClose={onClose}
+ defaultWidth={620}
+ footer={
+ <>
+ <button className="btn btn-secondary" onClick={onClose}>Cancel</button>
+ <button
+ className="btn btn-primary"
+ onClick={handleGenerate}
+ disabled={generating || !eventTitle.trim()}
+ style={{ display: 'flex', alignItems: 'center', gap: 6 }}
+ >
+ {generating ? (
+ <>
+ <span className="spinner" style={{ width: 14, height: 14 }} />
+ Generating...
+ </>
+ ) : (
+ <>
+ <Sparkles size={14} />
+ Generate Statement
+ </>
+ )}
</button>
- </div>
-
+ </>
+ }
+ >
+ <>
{newsItems.length > 0 && (
<div style={{ marginBottom: 16 }}>
<label style={{ fontSize: 13, color: 'var(--color-text-secondary)', marginBottom: 6, display: 'block' }}>
@@ -345,29 +351,8 @@ function GenerateModal({
</div>
</div>
- <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 20 }}>
- <button className="btn btn-secondary" onClick={onClose}>Cancel</button>
- <button
- className="btn btn-primary"
- onClick={handleGenerate}
- disabled={generating || !eventTitle.trim()}
- style={{ display: 'flex', alignItems: 'center', gap: 6 }}
- >
- {generating ? (
- <>
- <span className="spinner" style={{ width: 14, height: 14 }} />
- Generating...
- </>
- ) : (
- <>
- <Sparkles size={14} />
- Generate Statement
- </>
- )}
- </button>
- </div>
- </motion.div>
- </div>
+ </>
+ </ResizableModal>
);
}
diff --git a/components/xsessions/XSessionsTab.tsx b/components/xsessions/XSessionsTab.tsx
index 2f6e491..efa4ce7 100644
--- a/components/xsessions/XSessionsTab.tsx
+++ b/components/xsessions/XSessionsTab.tsx
@@ -3,13 +3,14 @@
import { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
- Search, Plus, Trash2, Check, X, RefreshCw, Clock,
+ Search, Plus, Trash2, Check, RefreshCw, Clock,
Heart, Repeat, MessageCircle, Eye, ChevronDown, ChevronUp,
Hash, Twitter, Image, Filter,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
import SortDropdown from '../shared/SortDropdown';
import { useClientSort, SortConfig } from '@/hooks/useClientSort';
+import ResizableModal from '../shared/ResizableModal';
const XSESS_SORT_OPTIONS = [
{ value: 'date_desc', label: 'Newest First' },
@@ -96,12 +97,6 @@ function NewScanModal({ onClose, onCreated }: { onClose: () => void; onCreated:
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
- useEffect(() => {
- const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
- window.addEventListener('keydown', handleKey);
- return () => window.removeEventListener('keydown', handleKey);
- }, [onClose]);
-
const presets = [
'policy advocacy',
'nonprofit reform',
@@ -140,87 +135,80 @@ function NewScanModal({ onClose, onCreated }: { onClose: () => void; onCreated:
}
return (
- <div
- className="fixed inset-0 z-50 flex items-center justify-center"
- style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
- onClick={onClose}
- >
- <motion.div
- initial={{ opacity: 0, scale: 0.95 }}
- animate={{ opacity: 1, scale: 1 }}
- className="w-full max-w-lg mx-4 rounded-xl p-6"
- style={{ backgroundColor: 'var(--color-surface)', border: '1px solid var(--color-border)' }}
- onClick={(e) => e.stopPropagation()}
- >
- <div className="flex items-center justify-between mb-5">
- <h2 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>
- New X Scan
- </h2>
- <button onClick={onClose} className="btn btn-ghost btn-sm">
- <X size={16} />
+ <ResizableModal
+ modalId="xsessions-new-scan"
+ title="New X Scan"
+ onClose={onClose}
+ defaultWidth={520}
+ footer={
+ <>
+ <button type="button" className="btn btn-ghost" onClick={onClose}>
+ Cancel
</button>
+ <button
+ type="button"
+ className="btn btn-primary"
+ disabled={loading || !terms.trim()}
+ onClick={(e) => handleSubmit(e as unknown as React.FormEvent)}
+ >
+ {loading ? (
+ <>
+ <RefreshCw size={14} className="animate-spin" />
+ <span>Scanning...</span>
+ </>
+ ) : (
+ <>
+ <Search size={14} />
+ <span>Start Scan</span>
+ </>
+ )}
+ </button>
+ </>
+ }
+ >
+ <form onSubmit={handleSubmit}>
+ <label className="block text-sm font-medium mb-2" style={{ color: 'var(--color-text-secondary)' }}>
+ Search Terms
+ </label>
+ <input
+ type="text"
+ className="input w-full mb-3"
+ value={terms}
+ onChange={(e) => setTerms(e.target.value)}
+ placeholder="e.g. policy advocacy"
+ aria-label="Search terms"
+ autoFocus
+ />
+
+ <label className="block text-sm font-medium mb-2" style={{ color: 'var(--color-text-secondary)' }}>
+ Quick Presets
+ </label>
+ <div className="flex flex-wrap gap-2 mb-4">
+ {presets.map((p) => (
+ <button
+ key={p}
+ type="button"
+ className="text-xs px-2 py-1 rounded-full transition-colors"
+ style={{
+ backgroundColor: terms === p ? 'var(--color-primary)' : 'var(--color-surface-el)',
+ color: terms === p ? '#fff' : 'var(--color-text-secondary)',
+ border: '1px solid var(--color-border)',
+ }}
+ onClick={() => setTerms(p)}
+ >
+ {p}
+ </button>
+ ))}
</div>
- <form onSubmit={handleSubmit}>
- <label className="block text-sm font-medium mb-2" style={{ color: 'var(--color-text-secondary)' }}>
- Search Terms
- </label>
- <input
- type="text"
- className="input w-full mb-3"
- value={terms}
- onChange={(e) => setTerms(e.target.value)}
- placeholder="e.g. policy advocacy"
- aria-label="Search terms"
- autoFocus
- />
-
- <label className="block text-sm font-medium mb-2" style={{ color: 'var(--color-text-secondary)' }}>
- Quick Presets
- </label>
- <div className="flex flex-wrap gap-2 mb-4">
- {presets.map((p) => (
- <button
- key={p}
- type="button"
- className="text-xs px-2 py-1 rounded-full transition-colors"
- style={{
- backgroundColor: terms === p ? 'var(--color-primary)' : 'var(--color-surface-el)',
- color: terms === p ? '#fff' : 'var(--color-text-secondary)',
- border: '1px solid var(--color-border)',
- }}
- onClick={() => setTerms(p)}
- >
- {p}
- </button>
- ))}
- </div>
-
- {error && (
- <p className="text-sm mb-3" style={{ color: '#f87171' }}>{error}</p>
- )}
+ {error && (
+ <p className="text-sm mb-3" style={{ color: '#f87171' }}>{error}</p>
+ )}
- <div className="flex justify-end gap-2 mt-4">
- <button type="button" className="btn btn-ghost" onClick={onClose}>
- Cancel
- </button>
- <button type="submit" className="btn btn-primary" disabled={loading || !terms.trim()}>
- {loading ? (
- <>
- <RefreshCw size={14} className="animate-spin" />
- <span>Scanning...</span>
- </>
- ) : (
- <>
- <Search size={14} />
- <span>Start Scan</span>
- </>
- )}
- </button>
- </div>
- </form>
- </motion.div>
- </div>
+ {/* Hidden submit to enable Enter-key submission */}
+ <button type="submit" style={{ display: 'none' }} aria-hidden="true" tabIndex={-1} />
+ </form>
+ </ResizableModal>
);
}
← dfa8907 feat(inbox): +16 themes (v17-v32) — total 32 live + dropdown
·
back to Norma
·
fix(gmail): scope Gmail CRM to current user's mailbox, not h 8f71b14 →