V3 Batch 1: foundations + tools for Xero, Monday, Intercom, Airtable, Notion + HubSpot apps
This commit is contained in:
parent
7afa3208ac
commit
093cc13aef
1
servers/airtable/src/apps/automation-monitor/App.tsx
Normal file
1
servers/airtable/src/apps/automation-monitor/App.tsx
Normal file
@ -0,0 +1 @@
|
||||
import React,{useState,useMemo,useTransition,useCallback}from'react';const useDebounce=<T,>(value:T,delay:number=300):T=>{const[debouncedValue,setDebouncedValue]=useState(value);React.useEffect(()=>{const handler=setTimeout(()=>setDebouncedValue(value),delay);return()=>clearTimeout(handler)},[value,delay]);return debouncedValue};const useToast=()=>{const[toasts,setToasts]=useState<Array<{id:number;message:string;type:string}>>([]);const showToast=useCallback((message:string,type:string='info')=>{const id=Date.now();setToasts(prev=>[...prev,{id,message,type}]);setTimeout(()=>setToasts(prev=>prev.filter(t=>t.id!==id)),3000)},[]);return{toasts,showToast}};interface AutomationRun{id:string;name:string;status:string;trigger:string;actions:number;runTime:string;duration:number}const mockRuns:AutomationRun[]=[{id:'run1',name:'Send Welcome Email',status:'success',trigger:'Record created',actions:3,runTime:'2024-02-13 10:30',duration:2.5},{id:'run2',name:'Update Slack Channel',status:'success',trigger:'Field updated',actions:2,runTime:'2024-02-13 09:15',duration:1.2},{id:'run3',name:'Create Task in Asana',status:'failed',trigger:'Record created',actions:4,runTime:'2024-02-12 16:45',duration:0.8},{id:'run4',name:'Sync to Google Sheets',status:'success',trigger:'View updated',actions:1,runTime:'2024-02-12 14:20',duration:3.1}];const App:React.FC=()=>{const[searchQuery,setSearchQuery]=useState('');const[selectedRun,setSelectedRun]=useState<AutomationRun|null>(null);const[isPending,startTransition]=useTransition();const{toasts,showToast}=useToast();const debouncedSearch=useDebounce(searchQuery,300);const filteredRuns=useMemo(()=>{return mockRuns.filter(run=>run.name.toLowerCase().includes(debouncedSearch.toLowerCase())||run.trigger.toLowerCase().includes(debouncedSearch.toLowerCase()))},[debouncedSearch]);const stats=useMemo(()=>({total:mockRuns.length,success:mockRuns.filter(r=>r.status==='success').length,failed:mockRuns.filter(r=>r.status==='failed').length}),[]);const handleRunClick=(run:AutomationRun)=>{startTransition(()=>{setSelectedRun(run);showToast(`Viewing: ${run.name}`,'info')})};return(<div className="app-container"><header className="app-header"><h1>Automation Monitor</h1><p className="subtitle">View automation runs</p></header><div className="stats-grid"><div className="stat-card"><div className="stat-value">{stats.total}</div><div className="stat-label">Total Runs</div></div><div className="stat-card"><div className="stat-value">{stats.success}</div><div className="stat-label">Successful</div></div><div className="stat-card"><div className="stat-value">{stats.failed}</div><div className="stat-label">Failed</div></div></div><div className="search-container"><input type="text" placeholder="Search automation runs..." value={searchQuery} onChange={(e)=>setSearchQuery(e.target.value)} className="search-input"/></div>{filteredRuns.length===0?(<div className="empty-state"><div className="empty-icon">⚙️</div><h3>No runs found</h3><p>Try adjusting your search query</p></div>):(<div className="runs-list">{filteredRuns.map(run=>(<div key={run.id} className="run-card" onClick={()=>handleRunClick(run)}><div className="run-header"><h3>{run.name}</h3><span className={`badge badge-${run.status}`}>{run.status}</span></div><div className="run-body"><div className="run-stat"><span className="label">Trigger:</span><span className="value">{run.trigger}</span></div><div className="run-stat"><span className="label">Actions:</span><span className="value">{run.actions}</span></div><div className="run-stat"><span className="label">Duration:</span><span className="value">{run.duration}s</span></div><div className="run-stat"><span className="label">Run Time:</span><span className="value">{run.runTime}</span></div></div></div>))}</div>)}{selectedRun&&(<div className="detail-panel"><h2>{selectedRun.name}</h2><p><strong>Status:</strong> {selectedRun.status}</p><p><strong>Trigger:</strong> {selectedRun.trigger}</p><p><strong>Actions:</strong> {selectedRun.actions}</p><p><strong>Duration:</strong> {selectedRun.duration}s</p><p><strong>Run Time:</strong> {selectedRun.runTime}</p></div>)}<div className="toast-container">{toasts.map(toast=>(<div key={toast.id} className={`toast toast-${toast.type}`}>{toast.message}</div>))}</div></div>)};export default App;
|
||||
12
servers/airtable/src/apps/automation-monitor/index.html
Normal file
12
servers/airtable/src/apps/automation-monitor/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Airtable Automation Monitor</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1
servers/airtable/src/apps/automation-monitor/main.tsx
Normal file
1
servers/airtable/src/apps/automation-monitor/main.tsx
Normal file
@ -0,0 +1 @@
|
||||
import React,{lazy,Suspense}from'react';import{createRoot}from'react-dom/client';import'./styles.css';const App=lazy(()=>import('./App'));class ErrorBoundary extends React.Component<{children:React.ReactNode},{hasError:boolean;error?:Error}>{constructor(props:{children:React.ReactNode}){super(props);this.state={hasError:false}}static getDerivedStateFromError(error:Error){return{hasError:true,error}}componentDidCatch(error:Error,errorInfo:React.ErrorInfo){console.error('ErrorBoundary caught:',error,errorInfo)}render(){if(this.state.hasError){return(<div className="error-boundary"><h1>Something went wrong</h1><p>{this.state.error?.message}</p><button onClick={()=>window.location.reload()}>Reload</button></div>)}return this.props.children}}const LoadingSkeleton=()=>(<div className="loading-skeleton"><div className="skeleton-header shimmer"/><div className="skeleton-content"><div className="skeleton-card shimmer"/><div className="skeleton-card shimmer"/><div className="skeleton-card shimmer"/></div></div>);const root=createRoot(document.getElementById('root')!);root.render(<React.StrictMode><ErrorBoundary><Suspense fallback={<LoadingSkeleton/>}><App/></Suspense></ErrorBoundary></React.StrictMode>);
|
||||
1
servers/airtable/src/apps/automation-monitor/styles.css
Normal file
1
servers/airtable/src/apps/automation-monitor/styles.css
Normal file
@ -0,0 +1 @@
|
||||
:root{--bg-primary:#0f172a;--bg-secondary:#1e293b;--bg-tertiary:#334155;--text-primary:#f1f5f9;--text-secondary:#cbd5e1;--text-muted:#94a3b8;--accent:#3b82f6;--accent-hover:#2563eb;--success:#10b981;--warning:#f59e0b;--error:#ef4444;--border:#334155;--shadow:rgba(0,0,0,0.3)}*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,sans-serif;background:var(--bg-primary);color:var(--text-primary);line-height:1.6}#root{min-height:100vh}.app-container{max-width:1200px;margin:0 auto;padding:2rem}.app-header{margin-bottom:2rem}.app-header h1{font-size:2rem;font-weight:700;margin-bottom:0.5rem}.subtitle{color:var(--text-secondary);font-size:1rem}.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem;margin-bottom:2rem}.stat-card{background:var(--bg-secondary);padding:1.5rem;border-radius:0.75rem;border:1px solid var(--border);transition:transform 0.2s,box-shadow 0.2s}.stat-card:hover{transform:translateY(-2px);box-shadow:0 4px 12px var(--shadow)}.stat-value{font-size:2.5rem;font-weight:700;color:var(--accent)}.stat-label{color:var(--text-secondary);font-size:0.875rem;margin-top:0.5rem}.search-container{margin-bottom:2rem}.search-input{width:100%;padding:0.875rem 1rem;background:var(--bg-secondary);border:1px solid var(--border);border-radius:0.5rem;color:var(--text-primary);font-size:1rem;transition:border-color 0.2s}.search-input:focus{outline:none;border-color:var(--accent)}.runs-list{display:flex;flex-direction:column;gap:1rem;margin-bottom:2rem}.run-card{background:var(--bg-secondary);border:1px solid var(--border);border-radius:0.75rem;padding:1.5rem;cursor:pointer;transition:transform 0.2s,box-shadow 0.2s,border-color 0.2s}.run-card:hover{transform:translateY(-2px);box-shadow:0 8px 16px var(--shadow);border-color:var(--accent)}.run-header{display:flex;justify-content:space-between;align-items:start;margin-bottom:1rem}.run-header h3{font-size:1.125rem;font-weight:600;color:var(--text-primary)}.badge{padding:0.25rem 0.75rem;border-radius:1rem;font-size:0.75rem;font-weight:600;text-transform:uppercase}.badge-success{background:rgba(16,185,129,0.2);color:var(--success)}.badge-failed{background:rgba(239,68,68,0.2);color:var(--error)}.run-body{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:0.75rem}.run-stat{display:flex;flex-direction:column;gap:0.25rem;font-size:0.875rem}.run-stat .label{color:var(--text-secondary)}.run-stat .value{color:var(--text-primary);font-weight:500}.detail-panel{background:var(--bg-secondary);border:1px solid var(--border);border-radius:0.75rem;padding:1.5rem;margin-top:2rem}.detail-panel h2{font-size:1.5rem;margin-bottom:1rem}.empty-state{text-align:center;padding:4rem 2rem}.empty-icon{font-size:4rem;margin-bottom:1rem}.empty-state h3{font-size:1.5rem;margin-bottom:0.5rem}.empty-state p{color:var(--text-secondary)}.toast-container{position:fixed;top:1rem;right:1rem;z-index:1000;display:flex;flex-direction:column;gap:0.5rem}.toast{background:var(--bg-secondary);border:1px solid var(--border);border-radius:0.5rem;padding:1rem 1.5rem;min-width:250px;box-shadow:0 4px 12px var(--shadow);animation:slideIn 0.3s ease-out}.toast-success{border-left:4px solid var(--success)}.toast-error{border-left:4px solid var(--error)}.toast-info{border-left:4px solid var(--accent)}@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}.shimmer{background:linear-gradient(90deg,var(--bg-secondary) 0%,var(--bg-tertiary) 50%,var(--bg-secondary) 100%);background-size:200% 100%;animation:shimmer 1.5s infinite}@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}.loading-skeleton{padding:2rem}.skeleton-header{height:3rem;border-radius:0.5rem;margin-bottom:2rem}.skeleton-content{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:1rem}.skeleton-card{height:150px;border-radius:0.75rem}.error-boundary{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;padding:2rem;text-align:center}.error-boundary h1{font-size:2rem;margin-bottom:1rem;color:var(--error)}.error-boundary button{margin-top:1rem;padding:0.75rem 1.5rem;background:var(--accent);color:white;border:none;border-radius:0.5rem;cursor:pointer;font-size:1rem;transition:background 0.2s}.error-boundary button:hover{background:var(--accent-hover)}@media (max-width:768px){.app-container{padding:1rem}.stats-grid{grid-template-columns:1fr}.app-header h1{font-size:1.5rem}.run-body{grid-template-columns:1fr}}
|
||||
156
servers/airtable/src/apps/base-browser/App.tsx
Normal file
156
servers/airtable/src/apps/base-browser/App.tsx
Normal file
@ -0,0 +1,156 @@
|
||||
import React, { useState, useMemo, useTransition, useCallback } from 'react';
|
||||
|
||||
const useDebounce = <T,>(value: T, delay: number = 300): T => {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(handler);
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
};
|
||||
|
||||
const useToast = () => {
|
||||
const [toasts, setToasts] = useState<Array<{ id: number; message: string; type: string }>>([]);
|
||||
|
||||
const showToast = useCallback((message: string, type: string = 'info') => {
|
||||
const id = Date.now();
|
||||
setToasts(prev => [...prev, { id, message, type }]);
|
||||
setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), 3000);
|
||||
}, []);
|
||||
|
||||
return { toasts, showToast };
|
||||
};
|
||||
|
||||
interface Base {
|
||||
id: string;
|
||||
name: string;
|
||||
permissionLevel: string;
|
||||
tables: number;
|
||||
lastModified: string;
|
||||
}
|
||||
|
||||
const mockBases: Base[] = [
|
||||
{ id: 'app1', name: 'Marketing Hub', permissionLevel: 'owner', tables: 12, lastModified: '2024-02-10' },
|
||||
{ id: 'app2', name: 'Sales CRM', permissionLevel: 'editor', tables: 8, lastModified: '2024-02-12' },
|
||||
{ id: 'app3', name: 'Product Roadmap', permissionLevel: 'owner', tables: 5, lastModified: '2024-02-13' },
|
||||
{ id: 'app4', name: 'HR Operations', permissionLevel: 'viewer', tables: 15, lastModified: '2024-02-11' },
|
||||
{ id: 'app5', name: 'Content Calendar', permissionLevel: 'editor', tables: 6, lastModified: '2024-02-09' },
|
||||
];
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedBase, setSelectedBase] = useState<Base | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const { toasts, showToast } = useToast();
|
||||
|
||||
const debouncedSearch = useDebounce(searchQuery, 300);
|
||||
|
||||
const filteredBases = useMemo(() => {
|
||||
return mockBases.filter(base =>
|
||||
base.name.toLowerCase().includes(debouncedSearch.toLowerCase())
|
||||
);
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const stats = useMemo(() => ({
|
||||
total: mockBases.length,
|
||||
owned: mockBases.filter(b => b.permissionLevel === 'owner').length,
|
||||
shared: mockBases.filter(b => b.permissionLevel !== 'owner').length,
|
||||
}), []);
|
||||
|
||||
const handleBaseClick = (base: Base) => {
|
||||
startTransition(() => {
|
||||
setSelectedBase(base);
|
||||
showToast(`Opened base: ${base.name}`, 'success');
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<header className="app-header">
|
||||
<h1>Airtable Base Browser</h1>
|
||||
<p className="subtitle">Browse and manage your Airtable bases</p>
|
||||
</header>
|
||||
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.total}</div>
|
||||
<div className="stat-label">Total Bases</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.owned}</div>
|
||||
<div className="stat-label">Owned</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.shared}</div>
|
||||
<div className="stat-label">Shared</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="search-container">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search bases..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{filteredBases.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">📦</div>
|
||||
<h3>No bases found</h3>
|
||||
<p>Try adjusting your search query</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="data-grid">
|
||||
{filteredBases.map(base => (
|
||||
<div
|
||||
key={base.id}
|
||||
className="data-card"
|
||||
onClick={() => handleBaseClick(base)}
|
||||
>
|
||||
<div className="card-header">
|
||||
<h3>{base.name}</h3>
|
||||
<span className={`badge badge-${base.permissionLevel}`}>
|
||||
{base.permissionLevel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="card-stat">
|
||||
<span className="label">Tables:</span>
|
||||
<span className="value">{base.tables}</span>
|
||||
</div>
|
||||
<div className="card-stat">
|
||||
<span className="label">Last Modified:</span>
|
||||
<span className="value">{base.lastModified}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedBase && (
|
||||
<div className="detail-panel">
|
||||
<h2>Base Details: {selectedBase.name}</h2>
|
||||
<p>ID: {selectedBase.id}</p>
|
||||
<p>Permission: {selectedBase.permissionLevel}</p>
|
||||
<p>Tables: {selectedBase.tables}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="toast-container">
|
||||
{toasts.map(toast => (
|
||||
<div key={toast.id} className={`toast toast-${toast.type}`}>
|
||||
{toast.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
12
servers/airtable/src/apps/base-browser/index.html
Normal file
12
servers/airtable/src/apps/base-browser/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Airtable Base Browser</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
58
servers/airtable/src/apps/base-browser/main.tsx
Normal file
58
servers/airtable/src/apps/base-browser/main.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './styles.css';
|
||||
|
||||
const App = lazy(() => import('./App'));
|
||||
|
||||
class ErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode },
|
||||
{ hasError: boolean; error?: Error }
|
||||
> {
|
||||
constructor(props: { children: React.ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('ErrorBoundary caught:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="error-boundary">
|
||||
<h1>Something went wrong</h1>
|
||||
<p>{this.state.error?.message}</p>
|
||||
<button onClick={() => window.location.reload()}>Reload</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const LoadingSkeleton = () => (
|
||||
<div className="loading-skeleton">
|
||||
<div className="skeleton-header shimmer" />
|
||||
<div className="skeleton-content">
|
||||
<div className="skeleton-card shimmer" />
|
||||
<div className="skeleton-card shimmer" />
|
||||
<div className="skeleton-card shimmer" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<LoadingSkeleton />}>
|
||||
<App />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
350
servers/airtable/src/apps/base-browser/styles.css
Normal file
350
servers/airtable/src/apps/base-browser/styles.css
Normal file
@ -0,0 +1,350 @@
|
||||
:root {
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-tertiary: #334155;
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #cbd5e1;
|
||||
--text-muted: #94a3b8;
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--error: #ef4444;
|
||||
--border: #334155;
|
||||
--shadow: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-secondary);
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px var(--shadow);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.data-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.data-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.data-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 16px var(--shadow);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.badge-owner {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.badge-editor {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.badge-viewer {
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.card-stat {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.card-stat .label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.card-stat .value {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-panel {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.detail-panel h2 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem 1.5rem;
|
||||
min-width: 250px;
|
||||
box-shadow: 0 4px 12px var(--shadow);
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
border-left: 4px solid var(--success);
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
border-left: 4px solid var(--error);
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
border-left: 4px solid var(--accent);
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-secondary) 0%,
|
||||
var(--bg-tertiary) 50%,
|
||||
var(--bg-secondary) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-skeleton {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-header {
|
||||
height: 3rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
height: 150px;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.error-boundary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-boundary h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.error-boundary button {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.error-boundary button:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.data-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
1
servers/airtable/src/apps/dashboard/App.tsx
Normal file
1
servers/airtable/src/apps/dashboard/App.tsx
Normal file
@ -0,0 +1 @@
|
||||
import React,{useState,useMemo,useTransition,useCallback}from'react';const useDebounce=<T,>(value:T,delay:number=300):T=>{const[debouncedValue,setDebouncedValue]=useState(value);React.useEffect(()=>{const handler=setTimeout(()=>setDebouncedValue(value),delay);return()=>clearTimeout(handler)},[value,delay]);return debouncedValue};const useToast=()=>{const[toasts,setToasts]=useState<Array<{id:number;message:string;type:string}>>([]);const showToast=useCallback((message:string,type:string='info')=>{const id=Date.now();setToasts(prev=>[...prev,{id,message,type}]);setTimeout(()=>setToasts(prev=>prev.filter(t=>t.id!==id)),3000)},[]);return{toasts,showToast}};interface BaseStats{id:string;name:string;records:number;tables:number;lastActivity:string}const mockBases:BaseStats[]=[{id:'base1',name:'Sales CRM',records:2450,tables:8,lastActivity:'2 hours ago'},{id:'base2',name:'Marketing Hub',records:1850,tables:12,lastActivity:'30 minutes ago'},{id:'base3',name:'Product Roadmap',records:680,tables:5,lastActivity:'1 day ago'},{id:'base4',name:'HR Operations',records:1250,tables:10,lastActivity:'3 hours ago'}];const App:React.FC=()=>{const[bases,setBases]=useState(mockBases);const[selectedBase,setSelectedBase]=useState<BaseStats|null>(null);const[isPending,startTransition]=useTransition();const{toasts,showToast}=useToast();const stats=useMemo(()=>({totalBases:bases.length,totalRecords:bases.reduce((sum,b)=>sum+b.records,0),totalTables:bases.reduce((sum,b)=>sum+b.tables,0),avgRecords:Math.floor(bases.reduce((sum,b)=>sum+b.records,0)/bases.length)}),[bases]);const handleBaseClick=(base:BaseStats)=>{startTransition(()=>{setSelectedBase(base);showToast(`Viewing: ${base.name}`,'info')})};return(<div className="app-container"><header className="app-header"><h1>Airtable Dashboard</h1><p className="subtitle">Cross-base overview, record counts, activity</p></header><div className="stats-grid"><div className="stat-card stat-highlight"><div className="stat-value">{stats.totalBases}</div><div className="stat-label">Total Bases</div></div><div className="stat-card stat-highlight"><div className="stat-value">{stats.totalRecords.toLocaleString()}</div><div className="stat-label">Total Records</div></div><div className="stat-card"><div className="stat-value">{stats.totalTables}</div><div className="stat-label">Total Tables</div></div><div className="stat-card"><div className="stat-value">{stats.avgRecords}</div><div className="stat-label">Avg Records/Base</div></div></div><div className="activity-section"><h2>Recent Activity</h2><div className="activity-list">{bases.map(base=>(<div key={base.id} className="activity-card" onClick={()=>handleBaseClick(base)}><div className="activity-header"><h3>{base.name}</h3><span className="activity-time">{base.lastActivity}</span></div><div className="activity-stats"><div className="activity-stat"><span className="label">Records:</span><span className="value">{base.records.toLocaleString()}</span></div><div className="activity-stat"><span className="label">Tables:</span><span className="value">{base.tables}</span></div></div></div>))}</div></div><div className="overview-grid"><div className="overview-card"><h3>Quick Stats</h3><div className="quick-stats"><div className="quick-stat"><span>Most Active:</span><strong>Marketing Hub</strong></div><div className="quick-stat"><span>Largest:</span><strong>Sales CRM</strong></div><div className="quick-stat"><span>Recent Update:</span><strong>30 min ago</strong></div></div></div><div className="overview-card"><h3>System Health</h3><div className="health-indicators"><div className="health-item"><span className="health-dot health-good"></span><span>API Status: Operational</span></div><div className="health-item"><span className="health-dot health-good"></span><span>Sync Status: Up to date</span></div><div className="health-item"><span className="health-dot health-good"></span><span>Storage: 45% used</span></div></div></div></div>{selectedBase&&(<div className="detail-panel"><h2>{selectedBase.name}</h2><p><strong>Records:</strong> {selectedBase.records.toLocaleString()}</p><p><strong>Tables:</strong> {selectedBase.tables}</p><p><strong>Last Activity:</strong> {selectedBase.lastActivity}</p></div>)}<div className="toast-container">{toasts.map(toast=>(<div key={toast.id} className={`toast toast-${toast.type}`}>{toast.message}</div>))}</div></div>)};export default App;
|
||||
12
servers/airtable/src/apps/dashboard/index.html
Normal file
12
servers/airtable/src/apps/dashboard/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Airtable Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1
servers/airtable/src/apps/dashboard/main.tsx
Normal file
1
servers/airtable/src/apps/dashboard/main.tsx
Normal file
@ -0,0 +1 @@
|
||||
import React,{lazy,Suspense}from'react';import{createRoot}from'react-dom/client';import'./styles.css';const App=lazy(()=>import('./App'));class ErrorBoundary extends React.Component<{children:React.ReactNode},{hasError:boolean;error?:Error}>{constructor(props:{children:React.ReactNode}){super(props);this.state={hasError:false}}static getDerivedStateFromError(error:Error){return{hasError:true,error}}componentDidCatch(error:Error,errorInfo:React.ErrorInfo){console.error('ErrorBoundary caught:',error,errorInfo)}render(){if(this.state.hasError){return(<div className="error-boundary"><h1>Something went wrong</h1><p>{this.state.error?.message}</p><button onClick={()=>window.location.reload()}>Reload</button></div>)}return this.props.children}}const LoadingSkeleton=()=>(<div className="loading-skeleton"><div className="skeleton-header shimmer"/><div className="skeleton-content"><div className="skeleton-card shimmer"/><div className="skeleton-card shimmer"/><div className="skeleton-card shimmer"/></div></div>);const root=createRoot(document.getElementById('root')!);root.render(<React.StrictMode><ErrorBoundary><Suspense fallback={<LoadingSkeleton/>}><App/></Suspense></ErrorBoundary></React.StrictMode>);
|
||||
1
servers/airtable/src/apps/dashboard/styles.css
Normal file
1
servers/airtable/src/apps/dashboard/styles.css
Normal file
File diff suppressed because one or more lines are too long
157
servers/airtable/src/apps/field-manager/App.tsx
Normal file
157
servers/airtable/src/apps/field-manager/App.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
import React, { useState, useMemo, useTransition, useCallback } from 'react';
|
||||
|
||||
const useDebounce = <T,>(value: T, delay: number = 300): T => {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(handler);
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
};
|
||||
|
||||
const useToast = () => {
|
||||
const [toasts, setToasts] = useState<Array<{ id: number; message: string; type: string }>>([]);
|
||||
|
||||
const showToast = useCallback((message: string, type: string = 'info') => {
|
||||
const id = Date.now();
|
||||
setToasts(prev => [...prev, { id, message, type }]);
|
||||
setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), 3000);
|
||||
}, []);
|
||||
|
||||
return { toasts, showToast };
|
||||
};
|
||||
|
||||
interface Field {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const mockFields: Field[] = [
|
||||
{ id: 'fld1', name: 'Name', type: 'singleLineText', required: true, description: 'Contact name' },
|
||||
{ id: 'fld2', name: 'Email', type: 'email', required: true, description: 'Email address' },
|
||||
{ id: 'fld3', name: 'Phone', type: 'phoneNumber', required: false, description: 'Phone number' },
|
||||
{ id: 'fld4', name: 'Status', type: 'singleSelect', required: true, description: 'Current status' },
|
||||
{ id: 'fld5', name: 'Notes', type: 'multilineText', required: false, description: 'Additional notes' },
|
||||
{ id: 'fld6', name: 'Created', type: 'dateTime', required: true, description: 'Creation date' },
|
||||
];
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedField, setSelectedField] = useState<Field | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const { toasts, showToast } = useToast();
|
||||
|
||||
const debouncedSearch = useDebounce(searchQuery, 300);
|
||||
|
||||
const filteredFields = useMemo(() => {
|
||||
return mockFields.filter(field =>
|
||||
field.name.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
|
||||
field.type.toLowerCase().includes(debouncedSearch.toLowerCase())
|
||||
);
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const stats = useMemo(() => ({
|
||||
total: mockFields.length,
|
||||
required: mockFields.filter(f => f.required).length,
|
||||
optional: mockFields.filter(f => !f.required).length,
|
||||
}), []);
|
||||
|
||||
const handleFieldClick = (field: Field) => {
|
||||
startTransition(() => {
|
||||
setSelectedField(field);
|
||||
showToast(`Selected field: ${field.name}`, 'success');
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<header className="app-header">
|
||||
<h1>Field Manager</h1>
|
||||
<p className="subtitle">View and configure table fields</p>
|
||||
</header>
|
||||
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.total}</div>
|
||||
<div className="stat-label">Total Fields</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.required}</div>
|
||||
<div className="stat-label">Required</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.optional}</div>
|
||||
<div className="stat-label">Optional</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="search-container">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search fields..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{filteredFields.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">🔍</div>
|
||||
<h3>No fields found</h3>
|
||||
<p>Try adjusting your search query</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="data-grid">
|
||||
{filteredFields.map(field => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="data-card"
|
||||
onClick={() => handleFieldClick(field)}
|
||||
>
|
||||
<div className="card-header">
|
||||
<h3>{field.name}</h3>
|
||||
{field.required && <span className="badge badge-required">Required</span>}
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="card-stat">
|
||||
<span className="label">Type:</span>
|
||||
<span className="value">{field.type}</span>
|
||||
</div>
|
||||
<div className="card-stat">
|
||||
<span className="label">Description:</span>
|
||||
<span className="value">{field.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedField && (
|
||||
<div className="detail-panel">
|
||||
<h2>Field Details: {selectedField.name}</h2>
|
||||
<p><strong>ID:</strong> {selectedField.id}</p>
|
||||
<p><strong>Type:</strong> {selectedField.type}</p>
|
||||
<p><strong>Required:</strong> {selectedField.required ? 'Yes' : 'No'}</p>
|
||||
<p><strong>Description:</strong> {selectedField.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="toast-container">
|
||||
{toasts.map(toast => (
|
||||
<div key={toast.id} className={`toast toast-${toast.type}`}>
|
||||
{toast.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
12
servers/airtable/src/apps/field-manager/index.html
Normal file
12
servers/airtable/src/apps/field-manager/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Airtable Field Manager</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
58
servers/airtable/src/apps/field-manager/main.tsx
Normal file
58
servers/airtable/src/apps/field-manager/main.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './styles.css';
|
||||
|
||||
const App = lazy(() => import('./App'));
|
||||
|
||||
class ErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode },
|
||||
{ hasError: boolean; error?: Error }
|
||||
> {
|
||||
constructor(props: { children: React.ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('ErrorBoundary caught:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="error-boundary">
|
||||
<h1>Something went wrong</h1>
|
||||
<p>{this.state.error?.message}</p>
|
||||
<button onClick={() => window.location.reload()}>Reload</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const LoadingSkeleton = () => (
|
||||
<div className="loading-skeleton">
|
||||
<div className="skeleton-header shimmer" />
|
||||
<div className="skeleton-content">
|
||||
<div className="skeleton-card shimmer" />
|
||||
<div className="skeleton-card shimmer" />
|
||||
<div className="skeleton-card shimmer" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<LoadingSkeleton />}>
|
||||
<App />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
1
servers/airtable/src/apps/field-manager/styles.css
Normal file
1
servers/airtable/src/apps/field-manager/styles.css
Normal file
@ -0,0 +1 @@
|
||||
:root{--bg-primary:#0f172a;--bg-secondary:#1e293b;--bg-tertiary:#334155;--text-primary:#f1f5f9;--text-secondary:#cbd5e1;--text-muted:#94a3b8;--accent:#3b82f6;--accent-hover:#2563eb;--success:#10b981;--warning:#f59e0b;--error:#ef4444;--border:#334155;--shadow:rgba(0,0,0,0.3)}*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,sans-serif;background:var(--bg-primary);color:var(--text-primary);line-height:1.6}#root{min-height:100vh}.app-container{max-width:1400px;margin:0 auto;padding:2rem}.app-header{margin-bottom:2rem}.app-header h1{font-size:2rem;font-weight:700;margin-bottom:0.5rem}.subtitle{color:var(--text-secondary);font-size:1rem}.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem;margin-bottom:2rem}.stat-card{background:var(--bg-secondary);padding:1.5rem;border-radius:0.75rem;border:1px solid var(--border);transition:transform 0.2s,box-shadow 0.2s}.stat-card:hover{transform:translateY(-2px);box-shadow:0 4px 12px var(--shadow)}.stat-value{font-size:2.5rem;font-weight:700;color:var(--accent)}.stat-label{color:var(--text-secondary);font-size:0.875rem;margin-top:0.5rem}.search-container{margin-bottom:2rem}.search-input{width:100%;padding:0.875rem 1rem;background:var(--bg-secondary);border:1px solid var(--border);border-radius:0.5rem;color:var(--text-primary);font-size:1rem;transition:border-color 0.2s}.search-input:focus{outline:none;border-color:var(--accent)}.data-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:1rem;margin-bottom:2rem}.data-card{background:var(--bg-secondary);border:1px solid var(--border);border-radius:0.75rem;padding:1.5rem;cursor:pointer;transition:transform 0.2s,box-shadow 0.2s,border-color 0.2s}.data-card:hover{transform:translateY(-2px);box-shadow:0 8px 16px var(--shadow);border-color:var(--accent)}.card-header{display:flex;justify-content:space-between;align-items:start;margin-bottom:1rem}.card-header h3{font-size:1.125rem;font-weight:600;color:var(--text-primary)}.badge{padding:0.25rem 0.75rem;border-radius:1rem;font-size:0.75rem;font-weight:600;text-transform:uppercase}.badge-required{background:rgba(239,68,68,0.2);color:var(--error)}.card-body{display:flex;flex-direction:column;gap:0.5rem}.card-stat{display:flex;justify-content:space-between;font-size:0.875rem}.card-stat .label{color:var(--text-secondary)}.card-stat .value{color:var(--text-primary);font-weight:500}.detail-panel{background:var(--bg-secondary);border:1px solid var(--border);border-radius:0.75rem;padding:1.5rem;margin-top:2rem}.detail-panel h2{font-size:1.5rem;margin-bottom:1rem}.empty-state{text-align:center;padding:4rem 2rem}.empty-icon{font-size:4rem;margin-bottom:1rem}.empty-state h3{font-size:1.5rem;margin-bottom:0.5rem}.empty-state p{color:var(--text-secondary)}.toast-container{position:fixed;top:1rem;right:1rem;z-index:1000;display:flex;flex-direction:column;gap:0.5rem}.toast{background:var(--bg-secondary);border:1px solid var(--border);border-radius:0.5rem;padding:1rem 1.5rem;min-width:250px;box-shadow:0 4px 12px var(--shadow);animation:slideIn 0.3s ease-out}.toast-success{border-left:4px solid var(--success)}.toast-error{border-left:4px solid var(--error)}.toast-info{border-left:4px solid var(--accent)}@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}.shimmer{background:linear-gradient(90deg,var(--bg-secondary) 0%,var(--bg-tertiary) 50%,var(--bg-secondary) 100%);background-size:200% 100%;animation:shimmer 1.5s infinite}@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}.loading-skeleton{padding:2rem}.skeleton-header{height:3rem;border-radius:0.5rem;margin-bottom:2rem}.skeleton-content{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:1rem}.skeleton-card{height:150px;border-radius:0.75rem}.error-boundary{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;padding:2rem;text-align:center}.error-boundary h1{font-size:2rem;margin-bottom:1rem;color:var(--error)}.error-boundary button{margin-top:1rem;padding:0.75rem 1.5rem;background:var(--accent);color:white;border:none;border-radius:0.5rem;cursor:pointer;font-size:1rem;transition:background 0.2s}.error-boundary button:hover{background:var(--accent-hover)}@media (max-width:768px){.app-container{padding:1rem}.stats-grid{grid-template-columns:1fr}.data-grid{grid-template-columns:1fr}.app-header h1{font-size:1.5rem}}
|
||||
1
servers/airtable/src/apps/form-builder/App.tsx
Normal file
1
servers/airtable/src/apps/form-builder/App.tsx
Normal file
@ -0,0 +1 @@
|
||||
import React,{useState,useMemo,useTransition,useCallback}from'react';const useDebounce=<T,>(value:T,delay:number=300):T=>{const[debouncedValue,setDebouncedValue]=useState(value);React.useEffect(()=>{const handler=setTimeout(()=>setDebouncedValue(value),delay);return()=>clearTimeout(handler)},[value,delay]);return debouncedValue};const useToast=()=>{const[toasts,setToasts]=useState<Array<{id:number;message:string;type:string}>>([]);const showToast=useCallback((message:string,type:string='info')=>{const id=Date.now();setToasts(prev=>[...prev,{id,message,type}]);setTimeout(()=>setToasts(prev=>prev.filter(t=>t.id!==id)),3000)},[]);return{toasts,showToast}};interface FormField{id:string;label:string;type:string;required:boolean}const mockFields:FormField[]=[{id:'f1',label:'Name',type:'text',required:true},{id:'f2',label:'Email',type:'email',required:true},{id:'f3',label:'Phone',type:'tel',required:false},{id:'f4',label:'Message',type:'textarea',required:false}];const App:React.FC=()=>{const[fields,setFields]=useState(mockFields);const[formData,setFormData]=useState<Record<string,string>>({});const[isPending,startTransition]=useTransition();const{toasts,showToast}=useToast();const stats=useMemo(()=>({total:fields.length,required:fields.filter(f=>f.required).length,filled:Object.keys(formData).length}),[fields,formData]);const handleFieldChange=(id:string,value:string)=>{startTransition(()=>{setFormData(prev=>({...prev,[id]:value}))})};const handleSubmit=()=>{showToast('Form submitted successfully!','success')};const handleAddField=()=>{const newField={id:`f${fields.length+1}`,label:'New Field',type:'text',required:false};setFields([...fields,newField]);showToast('Field added','success')};return(<div className="app-container"><header className="app-header"><h1>Form Builder</h1><p className="subtitle">Create Airtable forms</p></header><div className="stats-grid"><div className="stat-card"><div className="stat-value">{stats.total}</div><div className="stat-label">Fields</div></div><div className="stat-card"><div className="stat-value">{stats.required}</div><div className="stat-label">Required</div></div><div className="stat-card"><div className="stat-value">{stats.filled}</div><div className="stat-label">Filled</div></div></div><div className="form-builder"><div className="builder-header"><h2>Form Preview</h2><button className="btn btn-primary" onClick={handleAddField}>Add Field</button></div><div className="form-preview">{fields.map(field=>(<div key={field.id} className="form-group"><label>{field.label}{field.required&&<span className="required">*</span>}</label>{field.type==='textarea'?(<textarea className="form-input" onChange={(e)=>handleFieldChange(field.id,e.target.value)} placeholder={`Enter ${field.label.toLowerCase()}`}/>):(<input type={field.type} className="form-input" onChange={(e)=>handleFieldChange(field.id,e.target.value)} placeholder={`Enter ${field.label.toLowerCase()}`}/>)}</div>))}<button className="btn btn-success btn-submit" onClick={handleSubmit}>Submit Form</button></div></div>{fields.length===0&&(<div className="empty-state"><div className="empty-icon">📝</div><h3>No fields yet</h3><p>Add fields to start building your form</p></div>)}<div className="toast-container">{toasts.map(toast=>(<div key={toast.id} className={`toast toast-${toast.type}`}>{toast.message}</div>))}</div></div>)};export default App;
|
||||
12
servers/airtable/src/apps/form-builder/index.html
Normal file
12
servers/airtable/src/apps/form-builder/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Airtable Form Builder</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1
servers/airtable/src/apps/form-builder/main.tsx
Normal file
1
servers/airtable/src/apps/form-builder/main.tsx
Normal file
@ -0,0 +1 @@
|
||||
import React,{lazy,Suspense}from'react';import{createRoot}from'react-dom/client';import'./styles.css';const App=lazy(()=>import('./App'));class ErrorBoundary extends React.Component<{children:React.ReactNode},{hasError:boolean;error?:Error}>{constructor(props:{children:React.ReactNode}){super(props);this.state={hasError:false}}static getDerivedStateFromError(error:Error){return{hasError:true,error}}componentDidCatch(error:Error,errorInfo:React.ErrorInfo){console.error('ErrorBoundary caught:',error,errorInfo)}render(){if(this.state.hasError){return(<div className="error-boundary"><h1>Something went wrong</h1><p>{this.state.error?.message}</p><button onClick={()=>window.location.reload()}>Reload</button></div>)}return this.props.children}}const LoadingSkeleton=()=>(<div className="loading-skeleton"><div className="skeleton-header shimmer"/><div className="skeleton-content"><div className="skeleton-card shimmer"/><div className="skeleton-card shimmer"/><div className="skeleton-card shimmer"/></div></div>);const root=createRoot(document.getElementById('root')!);root.render(<React.StrictMode><ErrorBoundary><Suspense fallback={<LoadingSkeleton/>}><App/></Suspense></ErrorBoundary></React.StrictMode>);
|
||||
1
servers/airtable/src/apps/form-builder/styles.css
Normal file
1
servers/airtable/src/apps/form-builder/styles.css
Normal file
@ -0,0 +1 @@
|
||||
:root{--bg-primary:#0f172a;--bg-secondary:#1e293b;--bg-tertiary:#334155;--text-primary:#f1f5f9;--text-secondary:#cbd5e1;--text-muted:#94a3b8;--accent:#3b82f6;--accent-hover:#2563eb;--success:#10b981;--warning:#f59e0b;--error:#ef4444;--border:#334155;--shadow:rgba(0,0,0,0.3)}*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,sans-serif;background:var(--bg-primary);color:var(--text-primary);line-height:1.6}#root{min-height:100vh}.app-container{max-width:1000px;margin:0 auto;padding:2rem}.app-header{margin-bottom:2rem}.app-header h1{font-size:2rem;font-weight:700;margin-bottom:0.5rem}.subtitle{color:var(--text-secondary);font-size:1rem}.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem;margin-bottom:2rem}.stat-card{background:var(--bg-secondary);padding:1.5rem;border-radius:0.75rem;border:1px solid var(--border);transition:transform 0.2s,box-shadow 0.2s}.stat-card:hover{transform:translateY(-2px);box-shadow:0 4px 12px var(--shadow)}.stat-value{font-size:2.5rem;font-weight:700;color:var(--accent)}.stat-label{color:var(--text-secondary);font-size:0.875rem;margin-top:0.5rem}.form-builder{background:var(--bg-secondary);border:1px solid var(--border);border-radius:0.75rem;padding:2rem;margin-bottom:2rem}.builder-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:2rem;padding-bottom:1rem;border-bottom:2px solid var(--border)}.builder-header h2{font-size:1.5rem}.btn{padding:0.75rem 1.5rem;border-radius:0.5rem;border:none;cursor:pointer;font-size:1rem;font-weight:500;transition:all 0.2s}.btn-primary{background:var(--accent);color:white}.btn-primary:hover{background:var(--accent-hover);transform:translateY(-1px)}.btn-success{background:var(--success);color:white}.btn-success:hover{background:#059669;transform:translateY(-1px)}.btn-submit{width:100%;margin-top:1rem}.form-preview{display:flex;flex-direction:column;gap:1.5rem}.form-group{display:flex;flex-direction:column;gap:0.5rem}.form-group label{color:var(--text-secondary);font-weight:500;font-size:0.875rem}.required{color:var(--error);margin-left:0.25rem}.form-input{padding:0.875rem 1rem;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:0.5rem;color:var(--text-primary);font-size:1rem;transition:border-color 0.2s}.form-input:focus{outline:none;border-color:var(--accent)}.form-input::placeholder{color:var(--text-muted)}.empty-state{text-align:center;padding:4rem 2rem}.empty-icon{font-size:4rem;margin-bottom:1rem}.empty-state h3{font-size:1.5rem;margin-bottom:0.5rem}.empty-state p{color:var(--text-secondary)}.toast-container{position:fixed;top:1rem;right:1rem;z-index:1000;display:flex;flex-direction:column;gap:0.5rem}.toast{background:var(--bg-secondary);border:1px solid var(--border);border-radius:0.5rem;padding:1rem 1.5rem;min-width:250px;box-shadow:0 4px 12px var(--shadow);animation:slideIn 0.3s ease-out}.toast-success{border-left:4px solid var(--success)}.toast-error{border-left:4px solid var(--error)}.toast-info{border-left:4px solid var(--accent)}@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}.shimmer{background:linear-gradient(90deg,var(--bg-secondary) 0%,var(--bg-tertiary) 50%,var(--bg-secondary) 100%);background-size:200% 100%;animation:shimmer 1.5s infinite}@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}.loading-skeleton{padding:2rem}.skeleton-header{height:3rem;border-radius:0.5rem;margin-bottom:2rem}.skeleton-content{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:1rem}.skeleton-card{height:150px;border-radius:0.75rem}.error-boundary{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;padding:2rem;text-align:center}.error-boundary h1{font-size:2rem;margin-bottom:1rem;color:var(--error)}.error-boundary button{margin-top:1rem;padding:0.75rem 1.5rem;background:var(--accent);color:white;border:none;border-radius:0.5rem;cursor:pointer;font-size:1rem;transition:background 0.2s}.error-boundary button:hover{background:var(--accent-hover)}@media (max-width:768px){.app-container{padding:1rem}.stats-grid{grid-template-columns:1fr}.app-header h1{font-size:1.5rem}.builder-header{flex-direction:column;align-items:flex-start;gap:1rem}.btn{width:100%}}
|
||||
1
servers/airtable/src/apps/gallery-view/App.tsx
Normal file
1
servers/airtable/src/apps/gallery-view/App.tsx
Normal file
@ -0,0 +1 @@
|
||||
import React,{useState,useMemo,useTransition,useCallback}from'react';const useDebounce=<T,>(value:T,delay:number=300):T=>{const[debouncedValue,setDebouncedValue]=useState(value);React.useEffect(()=>{const handler=setTimeout(()=>setDebouncedValue(value),delay);return()=>clearTimeout(handler)},[value,delay]);return debouncedValue};const useToast=()=>{const[toasts,setToasts]=useState<Array<{id:number;message:string;type:string}>>([]);const showToast=useCallback((message:string,type:string='info')=>{const id=Date.now();setToasts(prev=>[...prev,{id,message,type}]);setTimeout(()=>setToasts(prev=>prev.filter(t=>t.id!==id)),3000)},[]);return{toasts,showToast}};interface GalleryItem{id:string;title:string;description:string;image:string;category:string;price:number}const mockItems:GalleryItem[]=Array.from({length:12},(_, i)=>({id:`item${i+1}`,title:`Product ${i+1}`,description:`Description for product ${i+1}`,image:`🎨`,category:['Art','Photo','Design'][i%3],price:Math.floor(Math.random()*500)+50}));const App:React.FC=()=>{const[searchQuery,setSearchQuery]=useState('');const[selectedItem,setSelectedItem]=useState<GalleryItem|null>(null);const[isPending,startTransition]=useTransition();const{toasts,showToast}=useToast();const debouncedSearch=useDebounce(searchQuery,300);const filteredItems=useMemo(()=>{return mockItems.filter(item=>item.title.toLowerCase().includes(debouncedSearch.toLowerCase())||item.category.toLowerCase().includes(debouncedSearch.toLowerCase()))},[debouncedSearch]);const stats=useMemo(()=>({total:filteredItems.length,categories:new Set(filteredItems.map(i=>i.category)).size,avgPrice:Math.floor(filteredItems.reduce((sum,i)=>sum+i.price,0)/filteredItems.length)}),[filteredItems]);const handleItemClick=(item:GalleryItem)=>{startTransition(()=>{setSelectedItem(item);showToast(`Selected: ${item.title}`,'success')})};return(<div className="app-container"><header className="app-header"><h1>Gallery View</h1><p className="subtitle">Records as visual cards with attachments</p></header><div className="stats-grid"><div className="stat-card"><div className="stat-value">{stats.total}</div><div className="stat-label">Items</div></div><div className="stat-card"><div className="stat-value">{stats.categories}</div><div className="stat-label">Categories</div></div><div className="stat-card"><div className="stat-value">${stats.avgPrice}</div><div className="stat-label">Avg Price</div></div></div><div className="search-container"><input type="text" placeholder="Search gallery..." value={searchQuery} onChange={(e)=>setSearchQuery(e.target.value)} className="search-input"/></div>{filteredItems.length===0?(<div className="empty-state"><div className="empty-icon">🖼️</div><h3>No items found</h3><p>Try adjusting your search query</p></div>):(<div className="gallery-grid">{filteredItems.map(item=>(<div key={item.id} className="gallery-card" onClick={()=>handleItemClick(item)}><div className="gallery-image">{item.image}</div><div className="gallery-content"><h3>{item.title}</h3><p className="gallery-category">{item.category}</p><p className="gallery-description">{item.description}</p><div className="gallery-footer"><span className="price">${item.price}</span></div></div></div>))}</div>)}{selectedItem&&(<div className="detail-panel"><h2>{selectedItem.title}</h2><p><strong>Category:</strong> {selectedItem.category}</p><p><strong>Price:</strong> ${selectedItem.price}</p><p><strong>Description:</strong> {selectedItem.description}</p></div>)}<div className="toast-container">{toasts.map(toast=>(<div key={toast.id} className={`toast toast-${toast.type}`}>{toast.message}</div>))}</div></div>)};export default App;
|
||||
12
servers/airtable/src/apps/gallery-view/index.html
Normal file
12
servers/airtable/src/apps/gallery-view/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Airtable Gallery View</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1
servers/airtable/src/apps/gallery-view/main.tsx
Normal file
1
servers/airtable/src/apps/gallery-view/main.tsx
Normal file
@ -0,0 +1 @@
|
||||
import React,{lazy,Suspense}from'react';import{createRoot}from'react-dom/client';import'./styles.css';const App=lazy(()=>import('./App'));class ErrorBoundary extends React.Component<{children:React.ReactNode},{hasError:boolean;error?:Error}>{constructor(props:{children:React.ReactNode}){super(props);this.state={hasError:false}}static getDerivedStateFromError(error:Error){return{hasError:true,error}}componentDidCatch(error:Error,errorInfo:React.ErrorInfo){console.error('ErrorBoundary caught:',error,errorInfo)}render(){if(this.state.hasError){return(<div className="error-boundary"><h1>Something went wrong</h1><p>{this.state.error?.message}</p><button onClick={()=>window.location.reload()}>Reload</button></div>)}return this.props.children}}const LoadingSkeleton=()=>(<div className="loading-skeleton"><div className="skeleton-header shimmer"/><div className="skeleton-content"><div className="skeleton-card shimmer"/><div className="skeleton-card shimmer"/><div className="skeleton-card shimmer"/></div></div>);const root=createRoot(document.getElementById('root')!);root.render(<React.StrictMode><ErrorBoundary><Suspense fallback={<LoadingSkeleton/>}><App/></Suspense></ErrorBoundary></React.StrictMode>);
|
||||
1
servers/airtable/src/apps/gallery-view/styles.css
Normal file
1
servers/airtable/src/apps/gallery-view/styles.css
Normal file
@ -0,0 +1 @@
|
||||
:root{--bg-primary:#0f172a;--bg-secondary:#1e293b;--bg-tertiary:#334155;--text-primary:#f1f5f9;--text-secondary:#cbd5e1;--text-muted:#94a3b8;--accent:#3b82f6;--accent-hover:#2563eb;--success:#10b981;--warning:#f59e0b;--error:#ef4444;--border:#334155;--shadow:rgba(0,0,0,0.3)}*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,sans-serif;background:var(--bg-primary);color:var(--text-primary);line-height:1.6}#root{min-height:100vh}.app-container{max-width:1600px;margin:0 auto;padding:2rem}.app-header{margin-bottom:2rem}.app-header h1{font-size:2rem;font-weight:700;margin-bottom:0.5rem}.subtitle{color:var(--text-secondary);font-size:1rem}.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem;margin-bottom:2rem}.stat-card{background:var(--bg-secondary);padding:1.5rem;border-radius:0.75rem;border:1px solid var(--border);transition:transform 0.2s,box-shadow 0.2s}.stat-card:hover{transform:translateY(-2px);box-shadow:0 4px 12px var(--shadow)}.stat-value{font-size:2.5rem;font-weight:700;color:var(--accent)}.stat-label{color:var(--text-secondary);font-size:0.875rem;margin-top:0.5rem}.search-container{margin-bottom:2rem}.search-input{width:100%;padding:0.875rem 1rem;background:var(--bg-secondary);border:1px solid var(--border);border-radius:0.5rem;color:var(--text-primary);font-size:1rem;transition:border-color 0.2s}.search-input:focus{outline:none;border-color:var(--accent)}.gallery-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1.5rem;margin-bottom:2rem}.gallery-card{background:var(--bg-secondary);border:1px solid var(--border);border-radius:0.75rem;overflow:hidden;cursor:pointer;transition:transform 0.2s,box-shadow 0.2s}.gallery-card:hover{transform:translateY(-4px);box-shadow:0 8px 16px var(--shadow)}.gallery-image{background:var(--bg-tertiary);height:200px;display:flex;align-items:center;justify-content:center;font-size:4rem;border-bottom:1px solid var(--border)}.gallery-content{padding:1.5rem}.gallery-content h3{font-size:1.125rem;margin-bottom:0.5rem;color:var(--text-primary)}.gallery-category{color:var(--accent);font-size:0.875rem;font-weight:600;text-transform:uppercase;margin-bottom:0.5rem}.gallery-description{color:var(--text-secondary);font-size:0.875rem;margin-bottom:1rem}.gallery-footer{display:flex;justify-content:space-between;align-items:center}.price{font-size:1.25rem;font-weight:700;color:var(--success)}.detail-panel{background:var(--bg-secondary);border:1px solid var(--border);border-radius:0.75rem;padding:1.5rem;margin-top:2rem}.detail-panel h2{font-size:1.5rem;margin-bottom:1rem}.empty-state{text-align:center;padding:4rem 2rem}.empty-icon{font-size:4rem;margin-bottom:1rem}.empty-state h3{font-size:1.5rem;margin-bottom:0.5rem}.empty-state p{color:var(--text-secondary)}.toast-container{position:fixed;top:1rem;right:1rem;z-index:1000;display:flex;flex-direction:column;gap:0.5rem}.toast{background:var(--bg-secondary);border:1px solid var(--border);border-radius:0.5rem;padding:1rem 1.5rem;min-width:250px;box-shadow:0 4px 12px var(--shadow);animation:slideIn 0.3s ease-out}.toast-success{border-left:4px solid var(--success)}.toast-error{border-left:4px solid var(--error)}.toast-info{border-left:4px solid var(--accent)}@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}.shimmer{background:linear-gradient(90deg,var(--bg-secondary) 0%,var(--bg-tertiary) 50%,var(--bg-secondary) 100%);background-size:200% 100%;animation:shimmer 1.5s infinite}@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}.loading-skeleton{padding:2rem}.skeleton-header{height:3rem;border-radius:0.5rem;margin-bottom:2rem}.skeleton-content{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:1rem}.skeleton-card{height:150px;border-radius:0.75rem}.error-boundary{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;padding:2rem;text-align:center}.error-boundary h1{font-size:2rem;margin-bottom:1rem;color:var(--error)}.error-boundary button{margin-top:1rem;padding:0.75rem 1.5rem;background:var(--accent);color:white;border:none;border-radius:0.5rem;cursor:pointer;font-size:1rem;transition:background 0.2s}.error-boundary button:hover{background:var(--accent-hover)}@media (max-width:768px){.app-container{padding:1rem}.stats-grid{grid-template-columns:1fr}.app-header h1{font-size:1.5rem}.gallery-grid{grid-template-columns:1fr}}
|
||||
1
servers/airtable/src/apps/grid-view/App.tsx
Normal file
1
servers/airtable/src/apps/grid-view/App.tsx
Normal file
@ -0,0 +1 @@
|
||||
import React,{useState,useMemo,useTransition,useCallback}from'react';const useDebounce=<T,>(value:T,delay:number=300):T=>{const[debouncedValue,setDebouncedValue]=useState(value);React.useEffect(()=>{const handler=setTimeout(()=>setDebouncedValue(value),delay);return()=>clearTimeout(handler)},[value,delay]);return debouncedValue};const useToast=()=>{const[toasts,setToasts]=useState<Array<{id:number;message:string;type:string}>>([]);const showToast=useCallback((message:string,type:string='info')=>{const id=Date.now();setToasts(prev=>[...prev,{id,message,type}]);setTimeout(()=>setToasts(prev=>prev.filter(t=>t.id!==id)),3000)},[]);return{toasts,showToast}};interface GridRecord{id:string;name:string;email:string;status:string;amount:number;date:string}const mockData:GridRecord[]=Array.from({length:20},(_, i)=>({id:`rec${i+1}`,name:`Contact ${i+1}`,email:`user${i+1}@example.com`,status:['Active','Inactive','Pending'][i%3],amount:Math.floor(Math.random()*10000),date:new Date(2024,0,i+1).toISOString().split('T')[0]}));const App:React.FC=()=>{const[searchQuery,setSearchQuery]=useState('');const[editingCell,setEditingCell]=useState<{row:number;col:string}|null>(null);const[data,setData]=useState(mockData);const[isPending,startTransition]=useTransition();const{toasts,showToast}=useToast();const debouncedSearch=useDebounce(searchQuery,300);const filteredData=useMemo(()=>{return data.filter(rec=>rec.name.toLowerCase().includes(debouncedSearch.toLowerCase())||rec.email.toLowerCase().includes(debouncedSearch.toLowerCase()))},[data,debouncedSearch]);const stats=useMemo(()=>({total:filteredData.length,active:filteredData.filter(r=>r.status==='Active').length,totalAmount:filteredData.reduce((sum,r)=>sum+r.amount,0)}),[filteredData]);const handleCellEdit=(rowIndex:number,field:keyof GridRecord,value:string)=>{startTransition(()=>{const newData=[...data];newData[rowIndex]={...newData[rowIndex],[field]:value};setData(newData);showToast('Cell updated','success');setEditingCell(null)})};return(<div className="app-container"><header className="app-header"><h1>Grid View</h1><p className="subtitle">Records in spreadsheet-like grid</p></header><div className="stats-grid"><div className="stat-card"><div className="stat-value">{stats.total}</div><div className="stat-label">Records</div></div><div className="stat-card"><div className="stat-value">{stats.active}</div><div className="stat-label">Active</div></div><div className="stat-card"><div className="stat-value">${stats.totalAmount.toLocaleString()}</div><div className="stat-label">Total Amount</div></div></div><div className="search-container"><input type="text" placeholder="Search records..." value={searchQuery} onChange={(e)=>setSearchQuery(e.target.value)} className="search-input"/></div>{filteredData.length===0?(<div className="empty-state"><div className="empty-icon">📊</div><h3>No records found</h3><p>Try adjusting your search query</p></div>):(<div className="grid-container"><table className="grid-table"><thead><tr><th>ID</th><th>Name</th><th>Email</th><th>Status</th><th>Amount</th><th>Date</th></tr></thead><tbody>{filteredData.map((record,idx)=>(<tr key={record.id}><td>{record.id}</td><td onDoubleClick={()=>setEditingCell({row:idx,col:'name'})}>{editingCell?.row===idx&&editingCell?.col==='name'?(<input autoFocus defaultValue={record.name} onBlur={(e)=>handleCellEdit(idx,'name',e.target.value)}/>):record.name}</td><td onDoubleClick={()=>setEditingCell({row:idx,col:'email'})}>{editingCell?.row===idx&&editingCell?.col==='email'?(<input autoFocus defaultValue={record.email} onBlur={(e)=>handleCellEdit(idx,'email',e.target.value)}/>):record.email}</td><td><span className={`badge badge-${record.status.toLowerCase()}`}>{record.status}</span></td><td>${record.amount.toLocaleString()}</td><td>{record.date}</td></tr>))}</tbody></table></div>)}<div className="toast-container">{toasts.map(toast=>(<div key={toast.id} className={`toast toast-${toast.type}`}>{toast.message}</div>))}</div></div>)};export default App;
|
||||
12
servers/airtable/src/apps/grid-view/index.html
Normal file
12
servers/airtable/src/apps/grid-view/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Airtable Grid View</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1
servers/airtable/src/apps/grid-view/main.tsx
Normal file
1
servers/airtable/src/apps/grid-view/main.tsx
Normal file
@ -0,0 +1 @@
|
||||
import React,{lazy,Suspense}from'react';import{createRoot}from'react-dom/client';import'./styles.css';const App=lazy(()=>import('./App'));class ErrorBoundary extends React.Component<{children:React.ReactNode},{hasError:boolean;error?:Error}>{constructor(props:{children:React.ReactNode}){super(props);this.state={hasError:false}}static getDerivedStateFromError(error:Error){return{hasError:true,error}}componentDidCatch(error:Error,errorInfo:React.ErrorInfo){console.error('ErrorBoundary caught:',error,errorInfo)}render(){if(this.state.hasError){return(<div className="error-boundary"><h1>Something went wrong</h1><p>{this.state.error?.message}</p><button onClick={()=>window.location.reload()}>Reload</button></div>)}return this.props.children}}const LoadingSkeleton=()=>(<div className="loading-skeleton"><div className="skeleton-header shimmer"/><div className="skeleton-content"><div className="skeleton-card shimmer"/><div className="skeleton-card shimmer"/><div className="skeleton-card shimmer"/></div></div>);const root=createRoot(document.getElementById('root')!);root.render(<React.StrictMode><ErrorBoundary><Suspense fallback={<LoadingSkeleton/>}><App/></Suspense></ErrorBoundary></React.StrictMode>);
|
||||
1
servers/airtable/src/apps/grid-view/styles.css
Normal file
1
servers/airtable/src/apps/grid-view/styles.css
Normal file
@ -0,0 +1 @@
|
||||
:root{--bg-primary:#0f172a;--bg-secondary:#1e293b;--bg-tertiary:#334155;--text-primary:#f1f5f9;--text-secondary:#cbd5e1;--text-muted:#94a3b8;--accent:#3b82f6;--accent-hover:#2563eb;--success:#10b981;--warning:#f59e0b;--error:#ef4444;--border:#334155;--shadow:rgba(0,0,0,0.3)}*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,sans-serif;background:var(--bg-primary);color:var(--text-primary);line-height:1.6}#root{min-height:100vh}.app-container{max-width:1600px;margin:0 auto;padding:2rem}.app-header{margin-bottom:2rem}.app-header h1{font-size:2rem;font-weight:700;margin-bottom:0.5rem}.subtitle{color:var(--text-secondary);font-size:1rem}.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem;margin-bottom:2rem}.stat-card{background:var(--bg-secondary);padding:1.5rem;border-radius:0.75rem;border:1px solid var(--border);transition:transform 0.2s,box-shadow 0.2s}.stat-card:hover{transform:translateY(-2px);box-shadow:0 4px 12px var(--shadow)}.stat-value{font-size:2.5rem;font-weight:700;color:var(--accent)}.stat-label{color:var(--text-secondary);font-size:0.875rem;margin-top:0.5rem}.search-container{margin-bottom:2rem}.search-input{width:100%;padding:0.875rem 1rem;background:var(--bg-secondary);border:1px solid var(--border);border-radius:0.5rem;color:var(--text-primary);font-size:1rem;transition:border-color 0.2s}.search-input:focus{outline:none;border-color:var(--accent)}.grid-container{background:var(--bg-secondary);border:1px solid var(--border);border-radius:0.75rem;overflow:auto}.grid-table{width:100%;border-collapse:collapse}.grid-table thead{background:var(--bg-tertiary);position:sticky;top:0}.grid-table th{padding:1rem;text-align:left;font-weight:600;border-bottom:2px solid var(--border);white-space:nowrap}.grid-table td{padding:0.875rem 1rem;border-bottom:1px solid var(--border);transition:background 0.2s}.grid-table tbody tr:hover{background:var(--bg-tertiary)}.grid-table tbody tr:last-child td{border-bottom:none}.grid-table td input{width:100%;padding:0.5rem;background:var(--bg-primary);border:1px solid var(--accent);border-radius:0.25rem;color:var(--text-primary);font-size:0.875rem}.badge{padding:0.25rem 0.75rem;border-radius:1rem;font-size:0.75rem;font-weight:600;text-transform:uppercase;display:inline-block}.badge-active{background:rgba(16,185,129,0.2);color:var(--success)}.badge-inactive{background:rgba(148,163,184,0.2);color:var(--text-muted)}.badge-pending{background:rgba(245,158,11,0.2);color:var(--warning)}.empty-state{text-align:center;padding:4rem 2rem}.empty-icon{font-size:4rem;margin-bottom:1rem}.empty-state h3{font-size:1.5rem;margin-bottom:0.5rem}.empty-state p{color:var(--text-secondary)}.toast-container{position:fixed;top:1rem;right:1rem;z-index:1000;display:flex;flex-direction:column;gap:0.5rem}.toast{background:var(--bg-secondary);border:1px solid var(--border);border-radius:0.5rem;padding:1rem 1.5rem;min-width:250px;box-shadow:0 4px 12px var(--shadow);animation:slideIn 0.3s ease-out}.toast-success{border-left:4px solid var(--success)}.toast-error{border-left:4px solid var(--error)}.toast-info{border-left:4px solid var(--accent)}@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}.shimmer{background:linear-gradient(90deg,var(--bg-secondary) 0%,var(--bg-tertiary) 50%,var(--bg-secondary) 100%);background-size:200% 100%;animation:shimmer 1.5s infinite}@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}.loading-skeleton{padding:2rem}.skeleton-header{height:3rem;border-radius:0.5rem;margin-bottom:2rem}.skeleton-content{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:1rem}.skeleton-card{height:150px;border-radius:0.75rem}.error-boundary{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;padding:2rem;text-align:center}.error-boundary h1{font-size:2rem;margin-bottom:1rem;color:var(--error)}.error-boundary button{margin-top:1rem;padding:0.75rem 1.5rem;background:var(--accent);color:white;border:none;border-radius:0.5rem;cursor:pointer;font-size:1rem;transition:background 0.2s}.error-boundary button:hover{background:var(--accent-hover)}@media (max-width:768px){.app-container{padding:1rem}.stats-grid{grid-template-columns:1fr}.app-header h1{font-size:1.5rem}.grid-container{overflow-x:auto}.grid-table{min-width:800px}}
|
||||
1
servers/airtable/src/apps/import-export/App.tsx
Normal file
1
servers/airtable/src/apps/import-export/App.tsx
Normal file
@ -0,0 +1 @@
|
||||
import React,{useState,useMemo,useTransition,useCallback}from'react';const useDebounce=<T,>(value:T,delay:number=300):T=>{const[debouncedValue,setDebouncedValue]=useState(value);React.useEffect(()=>{const handler=setTimeout(()=>setDebouncedValue(value),delay);return()=>clearTimeout(handler)},[value,delay]);return debouncedValue};const useToast=()=>{const[toasts,setToasts]=useState<Array<{id:number;message:string;type:string}>>([]);const showToast=useCallback((message:string,type:string='info')=>{const id=Date.now();setToasts(prev=>[...prev,{id,message,type}]);setTimeout(()=>setToasts(prev=>prev.filter(t=>t.id!==id)),3000)},[]);return{toasts,showToast}};interface ImportJob{id:string;filename:string;records:number;status:string;date:string}const mockJobs:ImportJob[]=[{id:'j1',filename:'contacts.csv',records:150,status:'completed',date:'2024-02-13'},{id:'j2',filename:'products.csv',records:45,status:'processing',date:'2024-02-12'},{id:'j3',filename:'orders.csv',records:320,status:'completed',date:'2024-02-11'}];const App:React.FC=()=>{const[jobs,setJobs]=useState(mockJobs);const[isPending,startTransition]=useTransition();const{toasts,showToast}=useToast();const stats=useMemo(()=>({total:jobs.length,completed:jobs.filter(j=>j.status==='completed').length,totalRecords:jobs.reduce((sum,j)=>sum+j.records,0)}),[jobs]);const handleImport=()=>{showToast('Import started','success')};const handleExport=()=>{showToast('Export started','success')};return(<div className="app-container"><header className="app-header"><h1>Import / Export</h1><p className="subtitle">CSV import/export UI</p></header><div className="stats-grid"><div className="stat-card"><div className="stat-value">{stats.total}</div><div className="stat-label">Total Jobs</div></div><div className="stat-card"><div className="stat-value">{stats.completed}</div><div className="stat-label">Completed</div></div><div className="stat-card"><div className="stat-value">{stats.totalRecords}</div><div className="stat-label">Records Processed</div></div></div><div className="action-panel"><div className="panel-section"><h2>Import CSV</h2><div className="file-input-wrapper"><input type="file" id="csv-import" accept=".csv" className="file-input"/><label htmlFor="csv-import" className="file-label">Choose CSV File</label></div><button className="btn btn-primary" onClick={handleImport}>Start Import</button></div><div className="panel-section"><h2>Export CSV</h2><select className="select-input"><option>All Records</option><option>Filtered Records</option><option>Current View</option></select><button className="btn btn-success" onClick={handleExport}>Export to CSV</button></div></div><div className="jobs-list"><h2>Recent Jobs</h2>{jobs.length===0?(<div className="empty-state"><div className="empty-icon">📁</div><h3>No jobs yet</h3><p>Start importing or exporting data</p></div>):(<div className="data-grid">{jobs.map(job=>(<div key={job.id} className="data-card"><div className="card-header"><h3>{job.filename}</h3><span className={`badge badge-${job.status}`}>{job.status}</span></div><div className="card-body"><div className="card-stat"><span className="label">Records:</span><span className="value">{job.records}</span></div><div className="card-stat"><span className="label">Date:</span><span className="value">{job.date}</span></div></div></div>))}</div>)}</div><div className="toast-container">{toasts.map(toast=>(<div key={toast.id} className={`toast toast-${toast.type}`}>{toast.message}</div>))}</div></div>)};export default App;
|
||||
12
servers/airtable/src/apps/import-export/index.html
Normal file
12
servers/airtable/src/apps/import-export/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Airtable Import/Export</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1
servers/airtable/src/apps/import-export/main.tsx
Normal file
1
servers/airtable/src/apps/import-export/main.tsx
Normal file
@ -0,0 +1 @@
|
||||
import React,{lazy,Suspense}from'react';import{createRoot}from'react-dom/client';import'./styles.css';const App=lazy(()=>import('./App'));class ErrorBoundary extends React.Component<{children:React.ReactNode},{hasError:boolean;error?:Error}>{constructor(props:{children:React.ReactNode}){super(props);this.state={hasError:false}}static getDerivedStateFromError(error:Error){return{hasError:true,error}}componentDidCatch(error:Error,errorInfo:React.ErrorInfo){console.error('ErrorBoundary caught:',error,errorInfo)}render(){if(this.state.hasError){return(<div className="error-boundary"><h1>Something went wrong</h1><p>{this.state.error?.message}</p><button onClick={()=>window.location.reload()}>Reload</button></div>)}return this.props.children}}const LoadingSkeleton=()=>(<div className="loading-skeleton"><div className="skeleton-header shimmer"/><div className="skeleton-content"><div className="skeleton-card shimmer"/><div className="skeleton-card shimmer"/><div className="skeleton-card shimmer"/></div></div>);const root=createRoot(document.getElementById('root')!);root.render(<React.StrictMode><ErrorBoundary><Suspense fallback={<LoadingSkeleton/>}><App/></Suspense></ErrorBoundary></React.StrictMode>);
|
||||
1
servers/airtable/src/apps/import-export/styles.css
Normal file
1
servers/airtable/src/apps/import-export/styles.css
Normal file
File diff suppressed because one or more lines are too long
1
servers/airtable/src/apps/kanban-board/App.tsx
Normal file
1
servers/airtable/src/apps/kanban-board/App.tsx
Normal file
@ -0,0 +1 @@
|
||||
import React,{useState,useMemo,useTransition,useCallback}from'react';const useDebounce=<T,>(value:T,delay:number=300):T=>{const[debouncedValue,setDebouncedValue]=useState(value);React.useEffect(()=>{const handler=setTimeout(()=>setDebouncedValue(value),delay);return()=>clearTimeout(handler)},[value,delay]);return debouncedValue};const useToast=()=>{const[toasts,setToasts]=useState<Array<{id:number;message:string;type:string}>>([]);const showToast=useCallback((message:string,type:string='info')=>{const id=Date.now();setToasts(prev=>[...prev,{id,message,type}]);setTimeout(()=>setToasts(prev=>prev.filter(t=>t.id!==id)),3000)},[]);return{toasts,showToast}};interface Card{id:string;title:string;description:string;status:string}const mockCards:Card[]=[{id:'c1',title:'Design Review',description:'Review new mockups',status:'Todo'},{id:'c2',title:'API Integration',description:'Connect to Airtable API',status:'In Progress'},{id:'c3',title:'Testing',description:'QA testing',status:'In Progress'},{id:'c4',title:'Deployment',description:'Deploy to production',status:'Done'},{id:'c5',title:'Documentation',description:'Write user docs',status:'Todo'}];const columns=['Todo','In Progress','Done'];const App:React.FC=()=>{const[cards,setCards]=useState(mockCards);const[searchQuery,setSearchQuery]=useState('');const[isPending,startTransition]=useTransition();const{toasts,showToast}=useToast();const debouncedSearch=useDebounce(searchQuery,300);const filteredCards=useMemo(()=>{return cards.filter(card=>card.title.toLowerCase().includes(debouncedSearch.toLowerCase()))},[cards,debouncedSearch]);const stats=useMemo(()=>({total:filteredCards.length,todo:filteredCards.filter(c=>c.status==='Todo').length,inProgress:filteredCards.filter(c=>c.status==='In Progress').length}),[filteredCards]);const getColumnCards=(column:string)=>filteredCards.filter(card=>card.status===column);const handleCardClick=(card:Card)=>{showToast(`Selected: ${card.title}`,'info')};return(<div className="app-container"><header className="app-header"><h1>Kanban Board</h1><p className="subtitle">Records grouped by single-select field</p></header><div className="stats-grid"><div className="stat-card"><div className="stat-value">{stats.total}</div><div className="stat-label">Total Cards</div></div><div className="stat-card"><div className="stat-value">{stats.todo}</div><div className="stat-label">Todo</div></div><div className="stat-card"><div className="stat-value">{stats.inProgress}</div><div className="stat-label">In Progress</div></div></div><div className="search-container"><input type="text" placeholder="Search cards..." value={searchQuery} onChange={(e)=>setSearchQuery(e.target.value)} className="search-input"/></div><div className="kanban-container">{columns.map(column=>(<div key={column} className="kanban-column"><div className="column-header"><h3>{column}</h3><span className="column-count">{getColumnCards(column).length}</span></div><div className="column-cards">{getColumnCards(column).map(card=>(<div key={card.id} className="kanban-card" onClick={()=>handleCardClick(card)}><h4>{card.title}</h4><p>{card.description}</p></div>))}{getColumnCards(column).length===0&&(<div className="empty-column">No cards</div>)}</div></div>))}</div><div className="toast-container">{toasts.map(toast=>(<div key={toast.id} className={`toast toast-${toast.type}`}>{toast.message}</div>))}</div></div>)};export default App;
|
||||
12
servers/airtable/src/apps/kanban-board/index.html
Normal file
12
servers/airtable/src/apps/kanban-board/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Airtable Kanban Board</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1
servers/airtable/src/apps/kanban-board/main.tsx
Normal file
1
servers/airtable/src/apps/kanban-board/main.tsx
Normal file
@ -0,0 +1 @@
|
||||
import React,{lazy,Suspense}from'react';import{createRoot}from'react-dom/client';import'./styles.css';const App=lazy(()=>import('./App'));class ErrorBoundary extends React.Component<{children:React.ReactNode},{hasError:boolean;error?:Error}>{constructor(props:{children:React.ReactNode}){super(props);this.state={hasError:false}}static getDerivedStateFromError(error:Error){return{hasError:true,error}}componentDidCatch(error:Error,errorInfo:React.ErrorInfo){console.error('ErrorBoundary caught:',error,errorInfo)}render(){if(this.state.hasError){return(<div className="error-boundary"><h1>Something went wrong</h1><p>{this.state.error?.message}</p><button onClick={()=>window.location.reload()}>Reload</button></div>)}return this.props.children}}const LoadingSkeleton=()=>(<div className="loading-skeleton"><div className="skeleton-header shimmer"/><div className="skeleton-content"><div className="skeleton-card shimmer"/><div className="skeleton-card shimmer"/><div className="skeleton-card shimmer"/></div></div>);const root=createRoot(document.getElementById('root')!);root.render(<React.StrictMode><ErrorBoundary><Suspense fallback={<LoadingSkeleton/>}><App/></Suspense></ErrorBoundary></React.StrictMode>);
|
||||
1
servers/airtable/src/apps/kanban-board/styles.css
Normal file
1
servers/airtable/src/apps/kanban-board/styles.css
Normal file
@ -0,0 +1 @@
|
||||
:root{--bg-primary:#0f172a;--bg-secondary:#1e293b;--bg-tertiary:#334155;--text-primary:#f1f5f9;--text-secondary:#cbd5e1;--text-muted:#94a3b8;--accent:#3b82f6;--accent-hover:#2563eb;--success:#10b981;--warning:#f59e0b;--error:#ef4444;--border:#334155;--shadow:rgba(0,0,0,0.3)}*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,sans-serif;background:var(--bg-primary);color:var(--text-primary);line-height:1.6}#root{min-height:100vh}.app-container{max-width:1600px;margin:0 auto;padding:2rem}.app-header{margin-bottom:2rem}.app-header h1{font-size:2rem;font-weight:700;margin-bottom:0.5rem}.subtitle{color:var(--text-secondary);font-size:1rem}.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem;margin-bottom:2rem}.stat-card{background:var(--bg-secondary);padding:1.5rem;border-radius:0.75rem;border:1px solid var(--border);transition:transform 0.2s,box-shadow 0.2s}.stat-card:hover{transform:translateY(-2px);box-shadow:0 4px 12px var(--shadow)}.stat-value{font-size:2.5rem;font-weight:700;color:var(--accent)}.stat-label{color:var(--text-secondary);font-size:0.875rem;margin-top:0.5rem}.search-container{margin-bottom:2rem}.search-input{width:100%;padding:0.875rem 1rem;background:var(--bg-secondary);border:1px solid var(--border);border-radius:0.5rem;color:var(--text-primary);font-size:1rem;transition:border-color 0.2s}.search-input:focus{outline:none;border-color:var(--accent)}.kanban-container{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:1.5rem}.kanban-column{background:var(--bg-secondary);border:1px solid var(--border);border-radius:0.75rem;padding:1rem}.column-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;padding-bottom:0.75rem;border-bottom:2px solid var(--border)}.column-header h3{font-size:1.125rem;font-weight:600}.column-count{background:var(--accent);color:white;padding:0.25rem 0.75rem;border-radius:1rem;font-size:0.875rem;font-weight:600}.column-cards{display:flex;flex-direction:column;gap:0.75rem;min-height:200px}.kanban-card{background:var(--bg-tertiary);border:1px solid var(--border);border-radius:0.5rem;padding:1rem;cursor:pointer;transition:transform 0.2s,box-shadow 0.2s}.kanban-card:hover{transform:translateY(-2px);box-shadow:0 4px 8px var(--shadow)}.kanban-card h4{font-size:1rem;margin-bottom:0.5rem;color:var(--text-primary)}.kanban-card p{font-size:0.875rem;color:var(--text-secondary)}.empty-column{text-align:center;padding:2rem 1rem;color:var(--text-muted);font-style:italic}.toast-container{position:fixed;top:1rem;right:1rem;z-index:1000;display:flex;flex-direction:column;gap:0.5rem}.toast{background:var(--bg-secondary);border:1px solid var(--border);border-radius:0.5rem;padding:1rem 1.5rem;min-width:250px;box-shadow:0 4px 12px var(--shadow);animation:slideIn 0.3s ease-out}.toast-success{border-left:4px solid var(--success)}.toast-error{border-left:4px solid var(--error)}.toast-info{border-left:4px solid var(--accent)}@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}.shimmer{background:linear-gradient(90deg,var(--bg-secondary) 0%,var(--bg-tertiary) 50%,var(--bg-secondary) 100%);background-size:200% 100%;animation:shimmer 1.5s infinite}@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}.loading-skeleton{padding:2rem}.skeleton-header{height:3rem;border-radius:0.5rem;margin-bottom:2rem}.skeleton-content{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:1rem}.skeleton-card{height:150px;border-radius:0.75rem}.error-boundary{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;padding:2rem;text-align:center}.error-boundary h1{font-size:2rem;margin-bottom:1rem;color:var(--error)}.error-boundary button{margin-top:1rem;padding:0.75rem 1.5rem;background:var(--accent);color:white;border:none;border-radius:0.5rem;cursor:pointer;font-size:1rem;transition:background 0.2s}.error-boundary button:hover{background:var(--accent-hover)}@media (max-width:768px){.app-container{padding:1rem}.stats-grid{grid-template-columns:1fr}.app-header h1{font-size:1.5rem}.kanban-container{grid-template-columns:1fr}}
|
||||
211
servers/airtable/src/apps/record-editor/App.tsx
Normal file
211
servers/airtable/src/apps/record-editor/App.tsx
Normal file
@ -0,0 +1,211 @@
|
||||
import React, { useState, useMemo, useTransition, useCallback } from 'react';
|
||||
|
||||
const useDebounce = <T,>(value: T, delay: number = 300): T => {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(handler);
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
};
|
||||
|
||||
const useToast = () => {
|
||||
const [toasts, setToasts] = useState<Array<{ id: number; message: string; type: string }>>([]);
|
||||
|
||||
const showToast = useCallback((message: string, type: string = 'info') => {
|
||||
const id = Date.now();
|
||||
setToasts(prev => [...prev, { id, message, type }]);
|
||||
setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), 3000);
|
||||
}, []);
|
||||
|
||||
return { toasts, showToast };
|
||||
};
|
||||
|
||||
interface RecordData {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
status: string;
|
||||
notes: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const mockRecord: RecordData = {
|
||||
id: 'rec123',
|
||||
name: 'John Doe',
|
||||
email: 'john.doe@example.com',
|
||||
phone: '+1 555-0123',
|
||||
status: 'Active',
|
||||
notes: 'Important client contact',
|
||||
createdAt: '2024-02-01',
|
||||
};
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [record, setRecord] = useState<RecordData>(mockRecord);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const { toasts, showToast } = useToast();
|
||||
|
||||
const debouncedRecord = useDebounce(record, 300);
|
||||
|
||||
const stats = useMemo(() => ({
|
||||
fields: Object.keys(record).length - 1, // exclude ID
|
||||
modified: hasChanges ? 'Yes' : 'No',
|
||||
status: record.status,
|
||||
}), [record, hasChanges]);
|
||||
|
||||
const handleFieldChange = (field: keyof RecordData, value: string) => {
|
||||
startTransition(() => {
|
||||
setRecord(prev => ({ ...prev, [field]: value }));
|
||||
setHasChanges(true);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
showToast('Record saved successfully', 'success');
|
||||
setHasChanges(false);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setRecord(mockRecord);
|
||||
setHasChanges(false);
|
||||
setIsEditing(false);
|
||||
showToast('Changes cancelled', 'info');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<header className="app-header">
|
||||
<h1>Record Editor</h1>
|
||||
<p className="subtitle">View and edit single record</p>
|
||||
</header>
|
||||
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.fields}</div>
|
||||
<div className="stat-label">Fields</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.modified}</div>
|
||||
<div className="stat-label">Modified</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.status}</div>
|
||||
<div className="stat-label">Status</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="actions-bar">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setIsEditing(!isEditing)}
|
||||
>
|
||||
{isEditing ? 'View Mode' : 'Edit Mode'}
|
||||
</button>
|
||||
{hasChanges && (
|
||||
<>
|
||||
<button className="btn btn-success" onClick={handleSave}>
|
||||
Save Changes
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={handleCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="record-form">
|
||||
<div className="form-group">
|
||||
<label>Record ID</label>
|
||||
<input type="text" value={record.id} disabled className="form-input" />
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={record.name}
|
||||
onChange={(e) => handleFieldChange('name', e.target.value)}
|
||||
disabled={!isEditing}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={record.email}
|
||||
onChange={(e) => handleFieldChange('email', e.target.value)}
|
||||
disabled={!isEditing}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Phone</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={record.phone}
|
||||
onChange={(e) => handleFieldChange('phone', e.target.value)}
|
||||
disabled={!isEditing}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Status</label>
|
||||
<select
|
||||
value={record.status}
|
||||
onChange={(e) => handleFieldChange('status', e.target.value)}
|
||||
disabled={!isEditing}
|
||||
className="form-input"
|
||||
>
|
||||
<option>Active</option>
|
||||
<option>Inactive</option>
|
||||
<option>Pending</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Notes</label>
|
||||
<textarea
|
||||
value={record.notes}
|
||||
onChange={(e) => handleFieldChange('notes', e.target.value)}
|
||||
disabled={!isEditing}
|
||||
className="form-input form-textarea"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Created At</label>
|
||||
<input type="text" value={record.createdAt} disabled className="form-input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isEditing && (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">✏️</div>
|
||||
<h3>View Mode</h3>
|
||||
<p>Click "Edit Mode" to make changes</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="toast-container">
|
||||
{toasts.map(toast => (
|
||||
<div key={toast.id} className={`toast toast-${toast.type}`}>
|
||||
{toast.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
12
servers/airtable/src/apps/record-editor/index.html
Normal file
12
servers/airtable/src/apps/record-editor/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Airtable Record Editor</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
58
servers/airtable/src/apps/record-editor/main.tsx
Normal file
58
servers/airtable/src/apps/record-editor/main.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './styles.css';
|
||||
|
||||
const App = lazy(() => import('./App'));
|
||||
|
||||
class ErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode },
|
||||
{ hasError: boolean; error?: Error }
|
||||
> {
|
||||
constructor(props: { children: React.ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('ErrorBoundary caught:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="error-boundary">
|
||||
<h1>Something went wrong</h1>
|
||||
<p>{this.state.error?.message}</p>
|
||||
<button onClick={() => window.location.reload()}>Reload</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const LoadingSkeleton = () => (
|
||||
<div className="loading-skeleton">
|
||||
<div className="skeleton-header shimmer" />
|
||||
<div className="skeleton-content">
|
||||
<div className="skeleton-card shimmer" />
|
||||
<div className="skeleton-card shimmer" />
|
||||
<div className="skeleton-card shimmer" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<LoadingSkeleton />}>
|
||||
<App />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
342
servers/airtable/src/apps/record-editor/styles.css
Normal file
342
servers/airtable/src/apps/record-editor/styles.css
Normal file
@ -0,0 +1,342 @@
|
||||
:root {
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-tertiary: #334155;
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #cbd5e1;
|
||||
--text-muted: #94a3b8;
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--error: #ef4444;
|
||||
--border: #334155;
|
||||
--shadow: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-secondary);
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px var(--shadow);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.actions-bar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #059669;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.record-form {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.form-input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem 1.5rem;
|
||||
min-width: 250px;
|
||||
box-shadow: 0 4px 12px var(--shadow);
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
border-left: 4px solid var(--success);
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
border-left: 4px solid var(--error);
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
border-left: 4px solid var(--accent);
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-secondary) 0%,
|
||||
var(--bg-tertiary) 50%,
|
||||
var(--bg-secondary) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-skeleton {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-header {
|
||||
height: 3rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
height: 150px;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.error-boundary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-boundary h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.error-boundary button {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.error-boundary button:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.actions-bar {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
1
servers/airtable/src/apps/schema-designer/App.tsx
Normal file
1
servers/airtable/src/apps/schema-designer/App.tsx
Normal file
@ -0,0 +1 @@
|
||||
import React,{useState,useMemo,useTransition,useCallback}from'react';const useDebounce=<T,>(value:T,delay:number=300):T=>{const[debouncedValue,setDebouncedValue]=useState(value);React.useEffect(()=>{const handler=setTimeout(()=>setDebouncedValue(value),delay);return()=>clearTimeout(handler)},[value,delay]);return debouncedValue};const useToast=()=>{const[toasts,setToasts]=useState<Array<{id:number;message:string;type:string}>>([]);const showToast=useCallback((message:string,type:string='info')=>{const id=Date.now();setToasts(prev=>[...prev,{id,message,type}]);setTimeout(()=>setToasts(prev=>prev.filter(t=>t.id!==id)),3000)},[]);return{toasts,showToast}};interface Table{id:string;name:string;fields:Array<{name:string;type:string}>}const mockTables:Table[]=[{id:'tbl1',name:'Contacts',fields:[{name:'Name',type:'text'},{name:'Email',type:'email'},{name:'Phone',type:'phone'}]},{id:'tbl2',name:'Companies',fields:[{name:'Company Name',type:'text'},{name:'Industry',type:'select'}]},{id:'tbl3',name:'Projects',fields:[{name:'Title',type:'text'},{name:'Status',type:'select'},{name:'Due Date',type:'date'}]}];const App:React.FC=()=>{const[tables,setTables]=useState(mockTables);const[selectedTable,setSelectedTable]=useState<Table|null>(null);const[isPending,startTransition]=useTransition();const{toasts,showToast}=useToast();const stats=useMemo(()=>({tables:tables.length,fields:tables.reduce((sum,t)=>sum+t.fields.length,0),avgFields:Math.floor(tables.reduce((sum,t)=>sum+t.fields.length,0)/tables.length)}),[tables]);const handleTableClick=(table:Table)=>{startTransition(()=>{setSelectedTable(table);showToast(`Viewing schema: ${table.name}`,'info')})};const handleAddTable=()=>{const newTable={id:`tbl${tables.length+1}`,name:'New Table',fields:[{name:'Name',type:'text'}]};setTables([...tables,newTable]);showToast('Table added','success')};return(<div className="app-container"><header className="app-header"><h1>Schema Designer</h1><p className="subtitle">Visual table schema</p></header><div className="stats-grid"><div className="stat-card"><div className="stat-value">{stats.tables}</div><div className="stat-label">Tables</div></div><div className="stat-card"><div className="stat-value">{stats.fields}</div><div className="stat-label">Total Fields</div></div><div className="stat-card"><div className="stat-value">{stats.avgFields}</div><div className="stat-label">Avg Fields/Table</div></div></div><button className="btn btn-primary btn-add" onClick={handleAddTable}>Add New Table</button><div className="schema-canvas">{tables.map(table=>(<div key={table.id} className="table-node" onClick={()=>handleTableClick(table)}><div className="table-header"><h3>{table.name}</h3><span className="field-count">{table.fields.length} fields</span></div><div className="table-fields">{table.fields.map((field,idx)=>(<div key={idx} className="field-row"><span className="field-name">{field.name}</span><span className="field-type">{field.type}</span></div>))}</div></div>))}</div>{selectedTable&&(<div className="detail-panel"><h2>{selectedTable.name}</h2><h3>Fields:</h3><ul>{selectedTable.fields.map((field,idx)=>(<li key={idx}><strong>{field.name}</strong>: {field.type}</li>))}</ul></div>)}<div className="toast-container">{toasts.map(toast=>(<div key={toast.id} className={`toast toast-${toast.type}`}>{toast.message}</div>))}</div></div>)};export default App;
|
||||
12
servers/airtable/src/apps/schema-designer/index.html
Normal file
12
servers/airtable/src/apps/schema-designer/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Airtable Schema Designer</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1
servers/airtable/src/apps/schema-designer/main.tsx
Normal file
1
servers/airtable/src/apps/schema-designer/main.tsx
Normal file
@ -0,0 +1 @@
|
||||
import React,{lazy,Suspense}from'react';import{createRoot}from'react-dom/client';import'./styles.css';const App=lazy(()=>import('./App'));class ErrorBoundary extends React.Component<{children:React.ReactNode},{hasError:boolean;error?:Error}>{constructor(props:{children:React.ReactNode}){super(props);this.state={hasError:false}}static getDerivedStateFromError(error:Error){return{hasError:true,error}}componentDidCatch(error:Error,errorInfo:React.ErrorInfo){console.error('ErrorBoundary caught:',error,errorInfo)}render(){if(this.state.hasError){return(<div className="error-boundary"><h1>Something went wrong</h1><p>{this.state.error?.message}</p><button onClick={()=>window.location.reload()}>Reload</button></div>)}return this.props.children}}const LoadingSkeleton=()=>(<div className="loading-skeleton"><div className="skeleton-header shimmer"/><div className="skeleton-content"><div className="skeleton-card shimmer"/><div className="skeleton-card shimmer"/><div className="skeleton-card shimmer"/></div></div>);const root=createRoot(document.getElementById('root')!);root.render(<React.StrictMode><ErrorBoundary><Suspense fallback={<LoadingSkeleton/>}><App/></Suspense></ErrorBoundary></React.StrictMode>);
|
||||
1
servers/airtable/src/apps/schema-designer/styles.css
Normal file
1
servers/airtable/src/apps/schema-designer/styles.css
Normal file
@ -0,0 +1 @@
|
||||
:root{--bg-primary:#0f172a;--bg-secondary:#1e293b;--bg-tertiary:#334155;--text-primary:#f1f5f9;--text-secondary:#cbd5e1;--text-muted:#94a3b8;--accent:#3b82f6;--accent-hover:#2563eb;--success:#10b981;--warning:#f59e0b;--error:#ef4444;--border:#334155;--shadow:rgba(0,0,0,0.3)}*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,sans-serif;background:var(--bg-primary);color:var(--text-primary);line-height:1.6}#root{min-height:100vh}.app-container{max-width:1400px;margin:0 auto;padding:2rem}.app-header{margin-bottom:2rem}.app-header h1{font-size:2rem;font-weight:700;margin-bottom:0.5rem}.subtitle{color:var(--text-secondary);font-size:1rem}.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem;margin-bottom:2rem}.stat-card{background:var(--bg-secondary);padding:1.5rem;border-radius:0.75rem;border:1px solid var(--border);transition:transform 0.2s,box-shadow 0.2s}.stat-card:hover{transform:translateY(-2px);box-shadow:0 4px 12px var(--shadow)}.stat-value{font-size:2.5rem;font-weight:700;color:var(--accent)}.stat-label{color:var(--text-secondary);font-size:0.875rem;margin-top:0.5rem}.btn{padding:0.75rem 1.5rem;border-radius:0.5rem;border:none;cursor:pointer;font-size:1rem;font-weight:500;transition:all 0.2s}.btn-primary{background:var(--accent);color:white}.btn-primary:hover{background:var(--accent-hover);transform:translateY(-1px)}.btn-add{margin-bottom:2rem}.schema-canvas{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1.5rem;margin-bottom:2rem;padding:2rem;background:var(--bg-secondary);border:1px solid var(--border);border-radius:0.75rem;min-height:400px}.table-node{background:var(--bg-tertiary);border:2px solid var(--border);border-radius:0.5rem;cursor:pointer;transition:transform 0.2s,box-shadow 0.2s,border-color 0.2s;height:fit-content}.table-node:hover{transform:translateY(-4px);box-shadow:0 8px 16px var(--shadow);border-color:var(--accent)}.table-header{padding:1rem;background:var(--bg-secondary);border-bottom:2px solid var(--border);display:flex;justify-content:space-between;align-items:center}.table-header h3{font-size:1rem;font-weight:600}.field-count{background:var(--accent);color:white;padding:0.25rem 0.5rem;border-radius:0.5rem;font-size:0.75rem}.table-fields{padding:1rem}.field-row{display:flex;justify-content:space-between;padding:0.5rem;border-bottom:1px solid var(--border);font-size:0.875rem}.field-row:last-child{border-bottom:none}.field-name{color:var(--text-primary);font-weight:500}.field-type{color:var(--text-secondary);font-family:monospace;font-size:0.75rem}.detail-panel{background:var(--bg-secondary);border:1px solid var(--border);border-radius:0.75rem;padding:1.5rem}.detail-panel h2{font-size:1.5rem;margin-bottom:1rem}.detail-panel h3{font-size:1.125rem;margin:1rem 0 0.5rem}.detail-panel ul{padding-left:1.5rem}.detail-panel li{margin-bottom:0.5rem;color:var(--text-secondary)}.toast-container{position:fixed;top:1rem;right:1rem;z-index:1000;display:flex;flex-direction:column;gap:0.5rem}.toast{background:var(--bg-secondary);border:1px solid var(--border);border-radius:0.5rem;padding:1rem 1.5rem;min-width:250px;box-shadow:0 4px 12px var(--shadow);animation:slideIn 0.3s ease-out}.toast-success{border-left:4px solid var(--success)}.toast-error{border-left:4px solid var(--error)}.toast-info{border-left:4px solid var(--accent)}@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}.shimmer{background:linear-gradient(90deg,var(--bg-secondary) 0%,var(--bg-tertiary) 50%,var(--bg-secondary) 100%);background-size:200% 100%;animation:shimmer 1.5s infinite}@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}.loading-skeleton{padding:2rem}.skeleton-header{height:3rem;border-radius:0.5rem;margin-bottom:2rem}.skeleton-content{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:1rem}.skeleton-card{height:150px;border-radius:0.75rem}.error-boundary{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;padding:2rem;text-align:center}.error-boundary h1{font-size:2rem;margin-bottom:1rem;color:var(--error)}.error-boundary button{margin-top:1rem;padding:0.75rem 1.5rem;background:var(--accent);color:white;border:none;border-radius:0.5rem;cursor:pointer;font-size:1rem;transition:background 0.2s}.error-boundary button:hover{background:var(--accent-hover)}@media (max-width:768px){.app-container{padding:1rem}.stats-grid{grid-template-columns:1fr}.app-header h1{font-size:1.5rem}.schema-canvas{grid-template-columns:1fr;padding:1rem}}
|
||||
1
servers/airtable/src/apps/search-dashboard/App.tsx
Normal file
1
servers/airtable/src/apps/search-dashboard/App.tsx
Normal file
@ -0,0 +1 @@
|
||||
import React,{useState,useMemo,useTransition,useCallback}from'react';const useDebounce=<T,>(value:T,delay:number=300):T=>{const[debouncedValue,setDebouncedValue]=useState(value);React.useEffect(()=>{const handler=setTimeout(()=>setDebouncedValue(value),delay);return()=>clearTimeout(handler)},[value,delay]);return debouncedValue};const useToast=()=>{const[toasts,setToasts]=useState<Array<{id:number;message:string;type:string}>>([]);const showToast=useCallback((message:string,type:string='info')=>{const id=Date.now();setToasts(prev=>[...prev,{id,message,type}]);setTimeout(()=>setToasts(prev=>prev.filter(t=>t.id!==id)),3000)},[]);return{toasts,showToast}};interface SearchResult{id:string;title:string;base:string;table:string;type:string;snippet:string}const mockResults:SearchResult[]=[{id:'r1',title:'John Doe',base:'CRM',table:'Contacts',type:'record',snippet:'Marketing contact from Q4 campaign'},{id:'r2',title:'Q1 Sales Report',base:'Sales',table:'Reports',type:'attachment',snippet:'Quarterly sales analysis and projections'},{id:'r3',title:'Product Launch',base:'Marketing',table:'Projects',type:'record',snippet:'New product launch timeline and tasks'},{id:'r4',title:'Team Meeting Notes',base:'Operations',table:'Notes',type:'record',snippet:'Weekly sync meeting notes and action items'}];const App:React.FC=()=>{const[searchQuery,setSearchQuery]=useState('');const[selectedResult,setSelectedResult]=useState<SearchResult|null>(null);const[isPending,startTransition]=useTransition();const{toasts,showToast}=useToast();const debouncedSearch=useDebounce(searchQuery,300);const filteredResults=useMemo(()=>{if(!debouncedSearch)return mockResults;return mockResults.filter(r=>r.title.toLowerCase().includes(debouncedSearch.toLowerCase())||r.snippet.toLowerCase().includes(debouncedSearch.toLowerCase()))},[debouncedSearch]);const stats=useMemo(()=>({total:filteredResults.length,records:filteredResults.filter(r=>r.type==='record').length,attachments:filteredResults.filter(r=>r.type==='attachment').length}),[filteredResults]);const handleResultClick=(result:SearchResult)=>{startTransition(()=>{setSelectedResult(result);showToast(`Opened: ${result.title}`,'success')})};return(<div className="app-container"><header className="app-header"><h1>Search Dashboard</h1><p className="subtitle">Search across bases and records</p></header><div className="stats-grid"><div className="stat-card"><div className="stat-value">{stats.total}</div><div className="stat-label">Results</div></div><div className="stat-card"><div className="stat-value">{stats.records}</div><div className="stat-label">Records</div></div><div className="stat-card"><div className="stat-value">{stats.attachments}</div><div className="stat-label">Attachments</div></div></div><div className="search-container"><input type="text" placeholder="Search across all bases..." value={searchQuery} onChange={(e)=>setSearchQuery(e.target.value)} className="search-input" autoFocus/></div>{filteredResults.length===0?(<div className="empty-state"><div className="empty-icon">🔍</div><h3>No results found</h3><p>Try a different search query</p></div>):(<div className="results-list">{filteredResults.map(result=>(<div key={result.id} className="result-card" onClick={()=>handleResultClick(result)}><div className="result-header"><h3>{result.title}</h3><span className={`badge badge-${result.type}`}>{result.type}</span></div><div className="result-meta"><span>{result.base}</span> → <span>{result.table}</span></div><p className="result-snippet">{result.snippet}</p></div>))}</div>)}{selectedResult&&(<div className="detail-panel"><h2>{selectedResult.title}</h2><p><strong>Base:</strong> {selectedResult.base}</p><p><strong>Table:</strong> {selectedResult.table}</p><p><strong>Type:</strong> {selectedResult.type}</p><p><strong>Snippet:</strong> {selectedResult.snippet}</p></div>)}<div className="toast-container">{toasts.map(toast=>(<div key={toast.id} className={`toast toast-${toast.type}`}>{toast.message}</div>))}</div></div>)};export default App;
|
||||
12
servers/airtable/src/apps/search-dashboard/index.html
Normal file
12
servers/airtable/src/apps/search-dashboard/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Airtable Search Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1
servers/airtable/src/apps/search-dashboard/main.tsx
Normal file
1
servers/airtable/src/apps/search-dashboard/main.tsx
Normal file
@ -0,0 +1 @@
|
||||
import React,{lazy,Suspense}from'react';import{createRoot}from'react-dom/client';import'./styles.css';const App=lazy(()=>import('./App'));class ErrorBoundary extends React.Component<{children:React.ReactNode},{hasError:boolean;error?:Error}>{constructor(props:{children:React.ReactNode}){super(props);this.state={hasError:false}}static getDerivedStateFromError(error:Error){return{hasError:true,error}}componentDidCatch(error:Error,errorInfo:React.ErrorInfo){console.error('ErrorBoundary caught:',error,errorInfo)}render(){if(this.state.hasError){return(<div className="error-boundary"><h1>Something went wrong</h1><p>{this.state.error?.message}</p><button onClick={()=>window.location.reload()}>Reload</button></div>)}return this.props.children}}const LoadingSkeleton=()=>(<div className="loading-skeleton"><div className="skeleton-header shimmer"/><div className="skeleton-content"><div className="skeleton-card shimmer"/><div className="skeleton-card shimmer"/><div className="skeleton-card shimmer"/></div></div>);const root=createRoot(document.getElementById('root')!);root.render(<React.StrictMode><ErrorBoundary><Suspense fallback={<LoadingSkeleton/>}><App/></Suspense></ErrorBoundary></React.StrictMode>);
|
||||
1
servers/airtable/src/apps/search-dashboard/styles.css
Normal file
1
servers/airtable/src/apps/search-dashboard/styles.css
Normal file
@ -0,0 +1 @@
|
||||
:root{--bg-primary:#0f172a;--bg-secondary:#1e293b;--bg-tertiary:#334155;--text-primary:#f1f5f9;--text-secondary:#cbd5e1;--text-muted:#94a3b8;--accent:#3b82f6;--accent-hover:#2563eb;--success:#10b981;--warning:#f59e0b;--error:#ef4444;--border:#334155;--shadow:rgba(0,0,0,0.3)}*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,sans-serif;background:var(--bg-primary);color:var(--text-primary);line-height:1.6}#root{min-height:100vh}.app-container{max-width:1200px;margin:0 auto;padding:2rem}.app-header{margin-bottom:2rem}.app-header h1{font-size:2rem;font-weight:700;margin-bottom:0.5rem}.subtitle{color:var(--text-secondary);font-size:1rem}.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem;margin-bottom:2rem}.stat-card{background:var(--bg-secondary);padding:1.5rem;border-radius:0.75rem;border:1px solid var(--border);transition:transform 0.2s,box-shadow 0.2s}.stat-card:hover{transform:translateY(-2px);box-shadow:0 4px 12px var(--shadow)}.stat-value{font-size:2.5rem;font-weight:700;color:var(--accent)}.stat-label{color:var(--text-secondary);font-size:0.875rem;margin-top:0.5rem}.search-container{margin-bottom:2rem}.search-input{width:100%;padding:1.25rem 1.5rem;background:var(--bg-secondary);border:1px solid var(--border);border-radius:0.75rem;color:var(--text-primary);font-size:1.125rem;transition:border-color 0.2s,box-shadow 0.2s}.search-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(59,130,246,0.1)}.results-list{display:flex;flex-direction:column;gap:1rem;margin-bottom:2rem}.result-card{background:var(--bg-secondary);border:1px solid var(--border);border-radius:0.75rem;padding:1.5rem;cursor:pointer;transition:transform 0.2s,box-shadow 0.2s,border-color 0.2s}.result-card:hover{transform:translateY(-2px);box-shadow:0 8px 16px var(--shadow);border-color:var(--accent)}.result-header{display:flex;justify-content:space-between;align-items:start;margin-bottom:0.75rem}.result-header h3{font-size:1.25rem;font-weight:600;color:var(--text-primary)}.badge{padding:0.25rem 0.75rem;border-radius:1rem;font-size:0.75rem;font-weight:600;text-transform:uppercase}.badge-record{background:rgba(59,130,246,0.2);color:var(--accent)}.badge-attachment{background:rgba(16,185,129,0.2);color:var(--success)}.result-meta{font-size:0.875rem;color:var(--text-muted);margin-bottom:0.75rem}.result-meta span{color:var(--text-secondary)}.result-snippet{font-size:0.875rem;color:var(--text-secondary);line-height:1.5}.detail-panel{background:var(--bg-secondary);border:1px solid var(--border);border-radius:0.75rem;padding:1.5rem;margin-top:2rem}.detail-panel h2{font-size:1.5rem;margin-bottom:1rem}.empty-state{text-align:center;padding:4rem 2rem}.empty-icon{font-size:4rem;margin-bottom:1rem}.empty-state h3{font-size:1.5rem;margin-bottom:0.5rem}.empty-state p{color:var(--text-secondary)}.toast-container{position:fixed;top:1rem;right:1rem;z-index:1000;display:flex;flex-direction:column;gap:0.5rem}.toast{background:var(--bg-secondary);border:1px solid var(--border);border-radius:0.5rem;padding:1rem 1.5rem;min-width:250px;box-shadow:0 4px 12px var(--shadow);animation:slideIn 0.3s ease-out}.toast-success{border-left:4px solid var(--success)}.toast-error{border-left:4px solid var(--error)}.toast-info{border-left:4px solid var(--accent)}@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}.shimmer{background:linear-gradient(90deg,var(--bg-secondary) 0%,var(--bg-tertiary) 50%,var(--bg-secondary) 100%);background-size:200% 100%;animation:shimmer 1.5s infinite}@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}.loading-skeleton{padding:2rem}.skeleton-header{height:3rem;border-radius:0.5rem;margin-bottom:2rem}.skeleton-content{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:1rem}.skeleton-card{height:150px;border-radius:0.75rem}.error-boundary{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;padding:2rem;text-align:center}.error-boundary h1{font-size:2rem;margin-bottom:1rem;color:var(--error)}.error-boundary button{margin-top:1rem;padding:0.75rem 1.5rem;background:var(--accent);color:white;border:none;border-radius:0.5rem;cursor:pointer;font-size:1rem;transition:background 0.2s}.error-boundary button:hover{background:var(--accent-hover)}@media (max-width:768px){.app-container{padding:1rem}.stats-grid{grid-template-columns:1fr}.app-header h1{font-size:1.5rem}}
|
||||
184
servers/airtable/src/apps/table-viewer/App.tsx
Normal file
184
servers/airtable/src/apps/table-viewer/App.tsx
Normal file
@ -0,0 +1,184 @@
|
||||
import React, { useState, useMemo, useTransition, useCallback } from 'react';
|
||||
|
||||
const useDebounce = <T,>(value: T, delay: number = 300): T => {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(handler);
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
};
|
||||
|
||||
const useToast = () => {
|
||||
const [toasts, setToasts] = useState<Array<{ id: number; message: string; type: string }>>([]);
|
||||
|
||||
const showToast = useCallback((message: string, type: string = 'info') => {
|
||||
const id = Date.now();
|
||||
setToasts(prev => [...prev, { id, message, type }]);
|
||||
setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), 3000);
|
||||
}, []);
|
||||
|
||||
return { toasts, showToast };
|
||||
};
|
||||
|
||||
interface Record {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const mockRecords: Record[] = Array.from({ length: 50 }, (_, i) => ({
|
||||
id: `rec${i + 1}`,
|
||||
name: `Contact ${i + 1}`,
|
||||
email: `user${i + 1}@example.com`,
|
||||
status: ['Active', 'Inactive', 'Pending'][i % 3],
|
||||
createdAt: new Date(2024, 0, i + 1).toISOString().split('T')[0],
|
||||
}));
|
||||
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedTable, setSelectedTable] = useState('Contacts');
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const { toasts, showToast } = useToast();
|
||||
|
||||
const debouncedSearch = useDebounce(searchQuery, 300);
|
||||
|
||||
const filteredRecords = useMemo(() => {
|
||||
return mockRecords.filter(record =>
|
||||
record.name.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
|
||||
record.email.toLowerCase().includes(debouncedSearch.toLowerCase())
|
||||
);
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const paginatedRecords = useMemo(() => {
|
||||
const start = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
return filteredRecords.slice(start, start + ITEMS_PER_PAGE);
|
||||
}, [filteredRecords, currentPage]);
|
||||
|
||||
const totalPages = Math.ceil(filteredRecords.length / ITEMS_PER_PAGE);
|
||||
|
||||
const stats = useMemo(() => ({
|
||||
total: filteredRecords.length,
|
||||
active: filteredRecords.filter(r => r.status === 'Active').length,
|
||||
inactive: filteredRecords.filter(r => r.status === 'Inactive').length,
|
||||
}), [filteredRecords]);
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
startTransition(() => {
|
||||
setCurrentPage(page);
|
||||
showToast(`Page ${page}`, 'info');
|
||||
});
|
||||
};
|
||||
|
||||
const handleRecordClick = (record: Record) => {
|
||||
showToast(`Selected: ${record.name}`, 'success');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<header className="app-header">
|
||||
<h1>Table Viewer: {selectedTable}</h1>
|
||||
<p className="subtitle">Browse table records with pagination</p>
|
||||
</header>
|
||||
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.total}</div>
|
||||
<div className="stat-label">Total Records</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.active}</div>
|
||||
<div className="stat-label">Active</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.inactive}</div>
|
||||
<div className="stat-label">Inactive</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="search-container">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search records..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{paginatedRecords.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">🔍</div>
|
||||
<h3>No records found</h3>
|
||||
<p>Try adjusting your search query</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginatedRecords.map(record => (
|
||||
<tr key={record.id} onClick={() => handleRecordClick(record)}>
|
||||
<td>{record.name}</td>
|
||||
<td>{record.email}</td>
|
||||
<td>
|
||||
<span className={`badge badge-${record.status.toLowerCase()}`}>
|
||||
{record.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>{record.createdAt}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="pagination">
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="pagination-btn"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="pagination-info">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="pagination-btn"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="toast-container">
|
||||
{toasts.map(toast => (
|
||||
<div key={toast.id} className={`toast toast-${toast.type}`}>
|
||||
{toast.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
12
servers/airtable/src/apps/table-viewer/index.html
Normal file
12
servers/airtable/src/apps/table-viewer/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Airtable Table Viewer</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
58
servers/airtable/src/apps/table-viewer/main.tsx
Normal file
58
servers/airtable/src/apps/table-viewer/main.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './styles.css';
|
||||
|
||||
const App = lazy(() => import('./App'));
|
||||
|
||||
class ErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode },
|
||||
{ hasError: boolean; error?: Error }
|
||||
> {
|
||||
constructor(props: { children: React.ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('ErrorBoundary caught:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="error-boundary">
|
||||
<h1>Something went wrong</h1>
|
||||
<p>{this.state.error?.message}</p>
|
||||
<button onClick={() => window.location.reload()}>Reload</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const LoadingSkeleton = () => (
|
||||
<div className="loading-skeleton">
|
||||
<div className="skeleton-header shimmer" />
|
||||
<div className="skeleton-content">
|
||||
<div className="skeleton-card shimmer" />
|
||||
<div className="skeleton-card shimmer" />
|
||||
<div className="skeleton-card shimmer" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<LoadingSkeleton />}>
|
||||
<App />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
364
servers/airtable/src/apps/table-viewer/styles.css
Normal file
364
servers/airtable/src/apps/table-viewer/styles.css
Normal file
@ -0,0 +1,364 @@
|
||||
:root {
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-tertiary: #334155;
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #cbd5e1;
|
||||
--text-muted: #94a3b8;
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--error: #ef4444;
|
||||
--border: #334155;
|
||||
--shadow: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-secondary);
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px var(--shadow);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.table-container {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.data-table thead {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.data-table tbody tr {
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.data-table tbody tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
padding: 1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.badge-active {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.badge-inactive {
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.badge-pending {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.pagination-btn:hover:not(:disabled) {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.pagination-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem 1.5rem;
|
||||
min-width: 250px;
|
||||
box-shadow: 0 4px 12px var(--shadow);
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
border-left: 4px solid var(--success);
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
border-left: 4px solid var(--error);
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
border-left: 4px solid var(--accent);
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-secondary) 0%,
|
||||
var(--bg-tertiary) 50%,
|
||||
var(--bg-secondary) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-skeleton {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-header {
|
||||
height: 3rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
height: 150px;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.error-boundary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-boundary h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.error-boundary button {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.error-boundary button:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
min-width: 600px;
|
||||
}
|
||||
}
|
||||
1
servers/airtable/src/apps/view-browser/App.tsx
Normal file
1
servers/airtable/src/apps/view-browser/App.tsx
Normal file
@ -0,0 +1 @@
|
||||
import React,{useState,useMemo,useTransition,useCallback}from'react';const useDebounce=<T,>(value:T,delay:number=300):T=>{const[debouncedValue,setDebouncedValue]=useState(value);React.useEffect(()=>{const handler=setTimeout(()=>setDebouncedValue(value),delay);return()=>clearTimeout(handler)},[value,delay]);return debouncedValue};const useToast=()=>{const[toasts,setToasts]=useState<Array<{id:number;message:string;type:string}>>([]);const showToast=useCallback((message:string,type:string='info')=>{const id=Date.now();setToasts(prev=>[...prev,{id,message,type}]);setTimeout(()=>setToasts(prev=>prev.filter(t=>t.id!==id)),3000)},[]);return{toasts,showToast}};interface View{id:string;name:string;type:string;table:string;records:number}const mockViews:View[]=[{id:'viw1',name:'All Contacts',type:'Grid',table:'Contacts',records:150},{id:'viw2',name:'Active Only',type:'Grid',table:'Contacts',records:120},{id:'viw3',name:'Sales Pipeline',type:'Kanban',table:'Deals',records:45},{id:'viw4',name:'Calendar View',type:'Calendar',table:'Events',records:32},{id:'viw5',name:'Gallery',type:'Gallery',table:'Products',records:88}];const App:React.FC=()=>{const[searchQuery,setSearchQuery]=useState('');const[selectedView,setSelectedView]=useState<View|null>(null);const[isPending,startTransition]=useTransition();const{toasts,showToast}=useToast();const debouncedSearch=useDebounce(searchQuery,300);const filteredViews=useMemo(()=>{return mockViews.filter(view=>view.name.toLowerCase().includes(debouncedSearch.toLowerCase())||view.type.toLowerCase().includes(debouncedSearch.toLowerCase()))},[debouncedSearch]);const stats=useMemo(()=>({total:mockViews.length,grid:mockViews.filter(v=>v.type==='Grid').length,other:mockViews.filter(v=>v.type!=='Grid').length}),[]);const handleViewClick=(view:View)=>{startTransition(()=>{setSelectedView(view);showToast(`Opened view: ${view.name}`,'success')})};return(<div className="app-container"><header className="app-header"><h1>View Browser</h1><p className="subtitle">List views per table</p></header><div className="stats-grid"><div className="stat-card"><div className="stat-value">{stats.total}</div><div className="stat-label">Total Views</div></div><div className="stat-card"><div className="stat-value">{stats.grid}</div><div className="stat-label">Grid Views</div></div><div className="stat-card"><div className="stat-value">{stats.other}</div><div className="stat-label">Other Views</div></div></div><div className="search-container"><input type="text" placeholder="Search views..." value={searchQuery} onChange={(e)=>setSearchQuery(e.target.value)} className="search-input"/></div>{filteredViews.length===0?(<div className="empty-state"><div className="empty-icon">👁️</div><h3>No views found</h3><p>Try adjusting your search query</p></div>):(<div className="data-grid">{filteredViews.map(view=>(<div key={view.id} className="data-card" onClick={()=>handleViewClick(view)}><div className="card-header"><h3>{view.name}</h3><span className="badge badge-type">{view.type}</span></div><div className="card-body"><div className="card-stat"><span className="label">Table:</span><span className="value">{view.table}</span></div><div className="card-stat"><span className="label">Records:</span><span className="value">{view.records}</span></div></div></div>))}</div>)}{selectedView&&(<div className="detail-panel"><h2>View Details: {selectedView.name}</h2><p><strong>ID:</strong> {selectedView.id}</p><p><strong>Type:</strong> {selectedView.type}</p><p><strong>Table:</strong> {selectedView.table}</p><p><strong>Records:</strong> {selectedView.records}</p></div>)}<div className="toast-container">{toasts.map(toast=>(<div key={toast.id} className={`toast toast-${toast.type}`}>{toast.message}</div>))}</div></div>)};export default App;
|
||||
12
servers/airtable/src/apps/view-browser/index.html
Normal file
12
servers/airtable/src/apps/view-browser/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Airtable View Browser</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1
servers/airtable/src/apps/view-browser/main.tsx
Normal file
1
servers/airtable/src/apps/view-browser/main.tsx
Normal file
@ -0,0 +1 @@
|
||||
import React,{lazy,Suspense}from'react';import{createRoot}from'react-dom/client';import'./styles.css';const App=lazy(()=>import('./App'));class ErrorBoundary extends React.Component<{children:React.ReactNode},{hasError:boolean;error?:Error}>{constructor(props:{children:React.ReactNode}){super(props);this.state={hasError:false}}static getDerivedStateFromError(error:Error){return{hasError:true,error}}componentDidCatch(error:Error,errorInfo:React.ErrorInfo){console.error('ErrorBoundary caught:',error,errorInfo)}render(){if(this.state.hasError){return(<div className="error-boundary"><h1>Something went wrong</h1><p>{this.state.error?.message}</p><button onClick={()=>window.location.reload()}>Reload</button></div>)}return this.props.children}}const LoadingSkeleton=()=>(<div className="loading-skeleton"><div className="skeleton-header shimmer"/><div className="skeleton-content"><div className="skeleton-card shimmer"/><div className="skeleton-card shimmer"/><div className="skeleton-card shimmer"/></div></div>);const root=createRoot(document.getElementById('root')!);root.render(<React.StrictMode><ErrorBoundary><Suspense fallback={<LoadingSkeleton/>}><App/></Suspense></ErrorBoundary></React.StrictMode>);
|
||||
1
servers/airtable/src/apps/view-browser/styles.css
Normal file
1
servers/airtable/src/apps/view-browser/styles.css
Normal file
@ -0,0 +1 @@
|
||||
:root{--bg-primary:#0f172a;--bg-secondary:#1e293b;--bg-tertiary:#334155;--text-primary:#f1f5f9;--text-secondary:#cbd5e1;--text-muted:#94a3b8;--accent:#3b82f6;--accent-hover:#2563eb;--success:#10b981;--warning:#f59e0b;--error:#ef4444;--border:#334155;--shadow:rgba(0,0,0,0.3)}*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,sans-serif;background:var(--bg-primary);color:var(--text-primary);line-height:1.6}#root{min-height:100vh}.app-container{max-width:1400px;margin:0 auto;padding:2rem}.app-header{margin-bottom:2rem}.app-header h1{font-size:2rem;font-weight:700;margin-bottom:0.5rem}.subtitle{color:var(--text-secondary);font-size:1rem}.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem;margin-bottom:2rem}.stat-card{background:var(--bg-secondary);padding:1.5rem;border-radius:0.75rem;border:1px solid var(--border);transition:transform 0.2s,box-shadow 0.2s}.stat-card:hover{transform:translateY(-2px);box-shadow:0 4px 12px var(--shadow)}.stat-value{font-size:2.5rem;font-weight:700;color:var(--accent)}.stat-label{color:var(--text-secondary);font-size:0.875rem;margin-top:0.5rem}.search-container{margin-bottom:2rem}.search-input{width:100%;padding:0.875rem 1rem;background:var(--bg-secondary);border:1px solid var(--border);border-radius:0.5rem;color:var(--text-primary);font-size:1rem;transition:border-color 0.2s}.search-input:focus{outline:none;border-color:var(--accent)}.data-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:1rem;margin-bottom:2rem}.data-card{background:var(--bg-secondary);border:1px solid var(--border);border-radius:0.75rem;padding:1.5rem;cursor:pointer;transition:transform 0.2s,box-shadow 0.2s,border-color 0.2s}.data-card:hover{transform:translateY(-2px);box-shadow:0 8px 16px var(--shadow);border-color:var(--accent)}.card-header{display:flex;justify-content:space-between;align-items:start;margin-bottom:1rem}.card-header h3{font-size:1.125rem;font-weight:600;color:var(--text-primary)}.badge{padding:0.25rem 0.75rem;border-radius:1rem;font-size:0.75rem;font-weight:600;text-transform:uppercase}.badge-type{background:rgba(59,130,246,0.2);color:var(--accent)}.card-body{display:flex;flex-direction:column;gap:0.5rem}.card-stat{display:flex;justify-content:space-between;font-size:0.875rem}.card-stat .label{color:var(--text-secondary)}.card-stat .value{color:var(--text-primary);font-weight:500}.detail-panel{background:var(--bg-secondary);border:1px solid var(--border);border-radius:0.75rem;padding:1.5rem;margin-top:2rem}.detail-panel h2{font-size:1.5rem;margin-bottom:1rem}.empty-state{text-align:center;padding:4rem 2rem}.empty-icon{font-size:4rem;margin-bottom:1rem}.empty-state h3{font-size:1.5rem;margin-bottom:0.5rem}.empty-state p{color:var(--text-secondary)}.toast-container{position:fixed;top:1rem;right:1rem;z-index:1000;display:flex;flex-direction:column;gap:0.5rem}.toast{background:var(--bg-secondary);border:1px solid var(--border);border-radius:0.5rem;padding:1rem 1.5rem;min-width:250px;box-shadow:0 4px 12px var(--shadow);animation:slideIn 0.3s ease-out}.toast-success{border-left:4px solid var(--success)}.toast-error{border-left:4px solid var(--error)}.toast-info{border-left:4px solid var(--accent)}@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}.shimmer{background:linear-gradient(90deg,var(--bg-secondary) 0%,var(--bg-tertiary) 50%,var(--bg-secondary) 100%);background-size:200% 100%;animation:shimmer 1.5s infinite}@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}.loading-skeleton{padding:2rem}.skeleton-header{height:3rem;border-radius:0.5rem;margin-bottom:2rem}.skeleton-content{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:1rem}.skeleton-card{height:150px;border-radius:0.75rem}.error-boundary{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;padding:2rem;text-align:center}.error-boundary h1{font-size:2rem;margin-bottom:1rem;color:var(--error)}.error-boundary button{margin-top:1rem;padding:0.75rem 1.5rem;background:var(--accent);color:white;border:none;border-radius:0.5rem;cursor:pointer;font-size:1rem;transition:background 0.2s}.error-boundary button:hover{background:var(--accent-hover)}@media (max-width:768px){.app-container{padding:1rem}.stats-grid{grid-template-columns:1fr}.data-grid{grid-template-columns:1fr}.app-header h1{font-size:1.5rem}}
|
||||
1
servers/airtable/src/apps/webhook-manager/App.tsx
Normal file
1
servers/airtable/src/apps/webhook-manager/App.tsx
Normal file
@ -0,0 +1 @@
|
||||
import React,{useState,useMemo,useTransition,useCallback}from'react';const useDebounce=<T,>(value:T,delay:number=300):T=>{const[debouncedValue,setDebouncedValue]=useState(value);React.useEffect(()=>{const handler=setTimeout(()=>setDebouncedValue(value),delay);return()=>clearTimeout(handler)},[value,delay]);return debouncedValue};const useToast=()=>{const[toasts,setToasts]=useState<Array<{id:number;message:string;type:string}>>([]);const showToast=useCallback((message:string,type:string='info')=>{const id=Date.now();setToasts(prev=>[...prev,{id,message,type}]);setTimeout(()=>setToasts(prev=>prev.filter(t=>t.id!==id)),3000)},[]);return{toasts,showToast}};interface Webhook{id:string;url:string;event:string;status:string;lastTriggered:string}const mockWebhooks:Webhook[]=[{id:'wh1',url:'https://example.com/webhook1',event:'record.created',status:'active',lastTriggered:'2024-02-13'},{id:'wh2',url:'https://example.com/webhook2',event:'record.updated',status:'active',lastTriggered:'2024-02-12'},{id:'wh3',url:'https://example.com/webhook3',event:'record.deleted',status:'inactive',lastTriggered:'2024-02-10'}];const App:React.FC=()=>{const[webhooks,setWebhooks]=useState(mockWebhooks);const[searchQuery,setSearchQuery]=useState('');const[isPending,startTransition]=useTransition();const{toasts,showToast}=useToast();const debouncedSearch=useDebounce(searchQuery,300);const filteredWebhooks=useMemo(()=>{return webhooks.filter(wh=>wh.url.toLowerCase().includes(debouncedSearch.toLowerCase())||wh.event.toLowerCase().includes(debouncedSearch.toLowerCase()))},[webhooks,debouncedSearch]);const stats=useMemo(()=>({total:webhooks.length,active:webhooks.filter(w=>w.status==='active').length,inactive:webhooks.filter(w=>w.status==='inactive').length}),[webhooks]);const handleToggleStatus=(id:string)=>{startTransition(()=>{setWebhooks(prev=>prev.map(wh=>wh.id===id?{...wh,status:wh.status==='active'?'inactive':'active'}:wh));showToast('Webhook status updated','success')})};return(<div className="app-container"><header className="app-header"><h1>Webhook Manager</h1><p className="subtitle">Webhook subscriptions</p></header><div className="stats-grid"><div className="stat-card"><div className="stat-value">{stats.total}</div><div className="stat-label">Total Webhooks</div></div><div className="stat-card"><div className="stat-value">{stats.active}</div><div className="stat-label">Active</div></div><div className="stat-card"><div className="stat-value">{stats.inactive}</div><div className="stat-label">Inactive</div></div></div><div className="search-container"><input type="text" placeholder="Search webhooks..." value={searchQuery} onChange={(e)=>setSearchQuery(e.target.value)} className="search-input"/></div>{filteredWebhooks.length===0?(<div className="empty-state"><div className="empty-icon">🔗</div><h3>No webhooks found</h3><p>Try adjusting your search query</p></div>):(<div className="data-grid">{filteredWebhooks.map(webhook=>(<div key={webhook.id} className="data-card"><div className="card-header"><h3>{webhook.event}</h3><span className={`badge badge-${webhook.status}`}>{webhook.status}</span></div><div className="card-body"><div className="card-stat"><span className="label">URL:</span><span className="value webhook-url">{webhook.url}</span></div><div className="card-stat"><span className="label">Last Triggered:</span><span className="value">{webhook.lastTriggered}</span></div><button className="btn btn-toggle" onClick={()=>handleToggleStatus(webhook.id)}>Toggle Status</button></div></div>))}</div>)}<div className="toast-container">{toasts.map(toast=>(<div key={toast.id} className={`toast toast-${toast.type}`}>{toast.message}</div>))}</div></div>)};export default App;
|
||||
12
servers/airtable/src/apps/webhook-manager/index.html
Normal file
12
servers/airtable/src/apps/webhook-manager/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Airtable Webhook Manager</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1
servers/airtable/src/apps/webhook-manager/main.tsx
Normal file
1
servers/airtable/src/apps/webhook-manager/main.tsx
Normal file
@ -0,0 +1 @@
|
||||
import React,{lazy,Suspense}from'react';import{createRoot}from'react-dom/client';import'./styles.css';const App=lazy(()=>import('./App'));class ErrorBoundary extends React.Component<{children:React.ReactNode},{hasError:boolean;error?:Error}>{constructor(props:{children:React.ReactNode}){super(props);this.state={hasError:false}}static getDerivedStateFromError(error:Error){return{hasError:true,error}}componentDidCatch(error:Error,errorInfo:React.ErrorInfo){console.error('ErrorBoundary caught:',error,errorInfo)}render(){if(this.state.hasError){return(<div className="error-boundary"><h1>Something went wrong</h1><p>{this.state.error?.message}</p><button onClick={()=>window.location.reload()}>Reload</button></div>)}return this.props.children}}const LoadingSkeleton=()=>(<div className="loading-skeleton"><div className="skeleton-header shimmer"/><div className="skeleton-content"><div className="skeleton-card shimmer"/><div className="skeleton-card shimmer"/><div className="skeleton-card shimmer"/></div></div>);const root=createRoot(document.getElementById('root')!);root.render(<React.StrictMode><ErrorBoundary><Suspense fallback={<LoadingSkeleton/>}><App/></Suspense></ErrorBoundary></React.StrictMode>);
|
||||
1
servers/airtable/src/apps/webhook-manager/styles.css
Normal file
1
servers/airtable/src/apps/webhook-manager/styles.css
Normal file
@ -0,0 +1 @@
|
||||
:root{--bg-primary:#0f172a;--bg-secondary:#1e293b;--bg-tertiary:#334155;--text-primary:#f1f5f9;--text-secondary:#cbd5e1;--text-muted:#94a3b8;--accent:#3b82f6;--accent-hover:#2563eb;--success:#10b981;--warning:#f59e0b;--error:#ef4444;--border:#334155;--shadow:rgba(0,0,0,0.3)}*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,sans-serif;background:var(--bg-primary);color:var(--text-primary);line-height:1.6}#root{min-height:100vh}.app-container{max-width:1400px;margin:0 auto;padding:2rem}.app-header{margin-bottom:2rem}.app-header h1{font-size:2rem;font-weight:700;margin-bottom:0.5rem}.subtitle{color:var(--text-secondary);font-size:1rem}.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem;margin-bottom:2rem}.stat-card{background:var(--bg-secondary);padding:1.5rem;border-radius:0.75rem;border:1px solid var(--border);transition:transform 0.2s,box-shadow 0.2s}.stat-card:hover{transform:translateY(-2px);box-shadow:0 4px 12px var(--shadow)}.stat-value{font-size:2.5rem;font-weight:700;color:var(--accent)}.stat-label{color:var(--text-secondary);font-size:0.875rem;margin-top:0.5rem}.search-container{margin-bottom:2rem}.search-input{width:100%;padding:0.875rem 1rem;background:var(--bg-secondary);border:1px solid var(--border);border-radius:0.5rem;color:var(--text-primary);font-size:1rem;transition:border-color 0.2s}.search-input:focus{outline:none;border-color:var(--accent)}.data-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:1rem;margin-bottom:2rem}.data-card{background:var(--bg-secondary);border:1px solid var(--border);border-radius:0.75rem;padding:1.5rem;transition:transform 0.2s,box-shadow 0.2s}.data-card:hover{transform:translateY(-2px);box-shadow:0 8px 16px var(--shadow)}.card-header{display:flex;justify-content:space-between;align-items:start;margin-bottom:1rem}.card-header h3{font-size:1.125rem;font-weight:600;color:var(--text-primary)}.badge{padding:0.25rem 0.75rem;border-radius:1rem;font-size:0.75rem;font-weight:600;text-transform:uppercase}.badge-active{background:rgba(16,185,129,0.2);color:var(--success)}.badge-inactive{background:rgba(148,163,184,0.2);color:var(--text-muted)}.card-body{display:flex;flex-direction:column;gap:0.75rem}.card-stat{display:flex;flex-direction:column;gap:0.25rem;font-size:0.875rem}.card-stat .label{color:var(--text-secondary)}.card-stat .value{color:var(--text-primary);font-weight:500}.webhook-url{word-break:break-all;font-family:monospace;font-size:0.75rem}.btn-toggle{padding:0.5rem 1rem;background:var(--accent);color:white;border:none;border-radius:0.5rem;cursor:pointer;font-size:0.875rem;transition:background 0.2s}.btn-toggle:hover{background:var(--accent-hover)}.empty-state{text-align:center;padding:4rem 2rem}.empty-icon{font-size:4rem;margin-bottom:1rem}.empty-state h3{font-size:1.5rem;margin-bottom:0.5rem}.empty-state p{color:var(--text-secondary)}.toast-container{position:fixed;top:1rem;right:1rem;z-index:1000;display:flex;flex-direction:column;gap:0.5rem}.toast{background:var(--bg-secondary);border:1px solid var(--border);border-radius:0.5rem;padding:1rem 1.5rem;min-width:250px;box-shadow:0 4px 12px var(--shadow);animation:slideIn 0.3s ease-out}.toast-success{border-left:4px solid var(--success)}.toast-error{border-left:4px solid var(--error)}.toast-info{border-left:4px solid var(--accent)}@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}.shimmer{background:linear-gradient(90deg,var(--bg-secondary) 0%,var(--bg-tertiary) 50%,var(--bg-secondary) 100%);background-size:200% 100%;animation:shimmer 1.5s infinite}@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}.loading-skeleton{padding:2rem}.skeleton-header{height:3rem;border-radius:0.5rem;margin-bottom:2rem}.skeleton-content{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:1rem}.skeleton-card{height:150px;border-radius:0.75rem}.error-boundary{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;padding:2rem;text-align:center}.error-boundary h1{font-size:2rem;margin-bottom:1rem;color:var(--error)}.error-boundary button{margin-top:1rem;padding:0.75rem 1.5rem;background:var(--accent);color:white;border:none;border-radius:0.5rem;cursor:pointer;font-size:1rem;transition:background 0.2s}.error-boundary button:hover{background:var(--accent-hover)}@media (max-width:768px){.app-container{padding:1rem}.stats-grid{grid-template-columns:1fr}.data-grid{grid-template-columns:1fr}.app-header h1{font-size:1.5rem}}
|
||||
110
servers/intercom/src/apps/admin-panel/App.tsx
Normal file
110
servers/intercom/src/apps/admin-panel/App.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
import React, { useState, useMemo, useTransition, useCallback, useEffect } from 'react';
|
||||
|
||||
const useDebounce = <T,>(value: T, delay: number): T => {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(handler);
|
||||
}, [value, delay]);
|
||||
return debouncedValue;
|
||||
};
|
||||
|
||||
interface Toast { id: number; message: string; type: 'success' | 'error' | 'info'; }
|
||||
const useToast = () => {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const showToast = useCallback((message: string, type: Toast['type'] = 'info') => {
|
||||
const id = Date.now();
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 3000);
|
||||
}, []);
|
||||
return { toasts, showToast };
|
||||
};
|
||||
|
||||
interface Admin { id: string; name: string; email: string; role: 'owner' | 'admin' | 'agent'; status: 'available' | 'busy' | 'away' | 'offline'; activeConversations: number; resolvedToday: number; }
|
||||
|
||||
const mockAdmins: Admin[] = [
|
||||
{ id: '1', name: 'John Doe', email: 'john@company.com', role: 'owner', status: 'available', activeConversations: 3, resolvedToday: 12 },
|
||||
{ id: '2', name: 'Jane Smith', email: 'jane@company.com', role: 'admin', status: 'busy', activeConversations: 5, resolvedToday: 8 },
|
||||
{ id: '3', name: 'Mike Johnson', email: 'mike@company.com', role: 'agent', status: 'available', activeConversations: 2, resolvedToday: 15 },
|
||||
{ id: '4', name: 'Sarah Williams', email: 'sarah@company.com', role: 'agent', status: 'away', activeConversations: 0, resolvedToday: 6 },
|
||||
];
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedAdmin, setSelectedAdmin] = useState<Admin | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const { toasts, showToast } = useToast();
|
||||
const debouncedSearch = useDebounce(searchTerm, 300);
|
||||
|
||||
const stats = useMemo(() => ({
|
||||
total: mockAdmins.length,
|
||||
available: mockAdmins.filter((a) => a.status === 'available').length,
|
||||
activeConvs: mockAdmins.reduce((sum, a) => sum + a.activeConversations, 0),
|
||||
resolvedToday: mockAdmins.reduce((sum, a) => sum + a.resolvedToday, 0),
|
||||
}), []);
|
||||
|
||||
const filteredAdmins = useMemo(() => {
|
||||
if (!debouncedSearch) return mockAdmins;
|
||||
const term = debouncedSearch.toLowerCase();
|
||||
return mockAdmins.filter((a) => a.name.toLowerCase().includes(term) || a.email.toLowerCase().includes(term));
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const handleSearch = (value: string) => { startTransition(() => { setSearchTerm(value); }); };
|
||||
const handleAdminClick = (admin: Admin) => { setSelectedAdmin(admin); showToast(`Viewing admin ${admin.name}`, 'info'); };
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<header className="app-header">
|
||||
<h1>Admin Panel</h1>
|
||||
<p>View admin list and availability</p>
|
||||
</header>
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card"><div className="stat-label">Total Admins</div><div className="stat-value">{stats.total}</div></div>
|
||||
<div className="stat-card"><div className="stat-label">Available Now</div><div className="stat-value">{stats.available}</div></div>
|
||||
<div className="stat-card"><div className="stat-label">Active Conversations</div><div className="stat-value">{stats.activeConvs}</div></div>
|
||||
<div className="stat-card"><div className="stat-label">Resolved Today</div><div className="stat-value">{stats.resolvedToday}</div></div>
|
||||
</div>
|
||||
<div className="search-section">
|
||||
<input type="text" placeholder="Search admins..." value={searchTerm} onChange={(e) => handleSearch(e.target.value)} className="search-input" />
|
||||
{isPending && <div className="search-pending">Searching...</div>}
|
||||
</div>
|
||||
{filteredAdmins.length === 0 ? (
|
||||
<div className="empty-state"><div className="empty-icon">⚙️</div><h3>No admins found</h3><p>Try adjusting your search criteria</p></div>
|
||||
) : (
|
||||
<div className="data-grid">
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Email</th><th>Role</th><th>Status</th><th>Active Conversations</th><th>Resolved Today</th></tr></thead>
|
||||
<tbody>
|
||||
{filteredAdmins.map((admin) => (
|
||||
<tr key={admin.id} onClick={() => handleAdminClick(admin)} className={selectedAdmin?.id === admin.id ? 'selected' : ''}>
|
||||
<td><strong>{admin.name}</strong></td>
|
||||
<td>{admin.email}</td>
|
||||
<td><span className={`plan-badge plan-${admin.role}`}>{admin.role}</span></td>
|
||||
<td><span className={`status-badge status-${admin.status === 'available' ? 'active' : admin.status === 'offline' ? 'archived' : 'draft'}`}>{admin.status}</span></td>
|
||||
<td>{admin.activeConversations}</td>
|
||||
<td>{admin.resolvedToday}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{selectedAdmin && (
|
||||
<div className="detail-panel">
|
||||
<h3>Admin Details</h3>
|
||||
<div className="detail-content">
|
||||
<p><strong>Name:</strong> {selectedAdmin.name}</p>
|
||||
<p><strong>Email:</strong> {selectedAdmin.email}</p>
|
||||
<p><strong>Role:</strong> {selectedAdmin.role}</p>
|
||||
<p><strong>Status:</strong> {selectedAdmin.status}</p>
|
||||
<p><strong>Active Conversations:</strong> {selectedAdmin.activeConversations}</p>
|
||||
<p><strong>Resolved Today:</strong> {selectedAdmin.resolvedToday}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="toast-container">{toasts.map((toast) => (<div key={toast.id} className={`toast toast-${toast.type}`}>{toast.message}</div>))}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
13
servers/intercom/src/apps/admin-panel/index.html
Normal file
13
servers/intercom/src/apps/admin-panel/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Admin Panel - Intercom MCP</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
59
servers/intercom/src/apps/admin-panel/main.tsx
Normal file
59
servers/intercom/src/apps/admin-panel/main.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './styles.css';
|
||||
|
||||
const App = lazy(() => import('./App'));
|
||||
|
||||
class ErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode },
|
||||
{ hasError: boolean; error?: Error }
|
||||
> {
|
||||
constructor(props: { children: React.ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('Admin Panel Error:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="error-container">
|
||||
<h1>Something went wrong</h1>
|
||||
<p>{this.state.error?.message}</p>
|
||||
<button onClick={() => window.location.reload()}>Reload</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const LoadingSkeleton = () => (
|
||||
<div className="loading-skeleton">
|
||||
<div className="skeleton-header shimmer"></div>
|
||||
<div className="skeleton-stats">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="skeleton-card shimmer"></div>
|
||||
))}
|
||||
</div>
|
||||
<div className="skeleton-content shimmer"></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<LoadingSkeleton />}>
|
||||
<App />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
409
servers/intercom/src/apps/admin-panel/styles.css
Normal file
409
servers/intercom/src/apps/admin-panel/styles.css
Normal file
@ -0,0 +1,409 @@
|
||||
:root {
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-tertiary: #334155;
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #cbd5e1;
|
||||
--text-muted: #94a3b8;
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
--success: #10b981;
|
||||
--error: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
--border: #475569;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: linear-gradient(135deg, var(--accent), var(--success));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.app-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.2);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.search-section {
|
||||
margin-bottom: 2rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 1rem 1.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 12px;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.search-pending {
|
||||
position: absolute;
|
||||
right: 1.5rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Data Grid */
|
||||
.data-grid {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
thead {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
border-top: 1px solid var(--border);
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
tbody tr.selected {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-inactive {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.status-blocked {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Detail Panel */
|
||||
.detail-panel {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.detail-panel h3 {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.detail-content p {
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.detail-content strong {
|
||||
color: var(--text-primary);
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 2rem;
|
||||
right: 2rem;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
animation: slideIn 0.3s ease;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background: var(--error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shimmer Loading */
|
||||
.shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-secondary) 0%,
|
||||
var(--bg-tertiary) 50%,
|
||||
var(--bg-secondary) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-skeleton {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-header {
|
||||
height: 60px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
height: 100px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.skeleton-content {
|
||||
height: 400px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Error Container */
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-container h1 {
|
||||
color: var(--error);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-container button {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.error-container button:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.app-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.data-grid {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
left: 1rem;
|
||||
}
|
||||
}
|
||||
248
servers/intercom/src/apps/article-editor/App.tsx
Normal file
248
servers/intercom/src/apps/article-editor/App.tsx
Normal file
@ -0,0 +1,248 @@
|
||||
import React, { useState, useMemo, useTransition, useCallback, useEffect } from 'react';
|
||||
|
||||
const useDebounce = <T,>(value: T, delay: number): T => {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(handler);
|
||||
}, [value, delay]);
|
||||
return debouncedValue;
|
||||
};
|
||||
|
||||
interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'info';
|
||||
}
|
||||
|
||||
const useToast = () => {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const showToast = useCallback((message: string, type: Toast['type'] = 'info') => {
|
||||
const id = Date.now();
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, 3000);
|
||||
}, []);
|
||||
|
||||
return { toasts, showToast };
|
||||
};
|
||||
|
||||
interface Article {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
status: 'draft' | 'published' | 'archived';
|
||||
author: string;
|
||||
views: number;
|
||||
helpful: number;
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
const mockArticles: Article[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Getting Started with Intercom',
|
||||
content: 'Learn the basics of using Intercom for customer support...',
|
||||
status: 'published',
|
||||
author: 'John Doe',
|
||||
views: 1250,
|
||||
helpful: 95,
|
||||
lastUpdated: '2024-01-15',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Advanced Automation Workflows',
|
||||
content: 'Create powerful automation to streamline your support...',
|
||||
status: 'draft',
|
||||
author: 'Jane Smith',
|
||||
views: 0,
|
||||
helpful: 0,
|
||||
lastUpdated: '2024-01-14',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Understanding Analytics',
|
||||
content: 'Deep dive into Intercom analytics and reporting...',
|
||||
status: 'published',
|
||||
author: 'Mike Johnson',
|
||||
views: 850,
|
||||
helpful: 72,
|
||||
lastUpdated: '2024-01-10',
|
||||
},
|
||||
];
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedArticle, setSelectedArticle] = useState<Article | null>(null);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [editContent, setEditContent] = useState('');
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const { toasts, showToast } = useToast();
|
||||
|
||||
const debouncedSearch = useDebounce(searchTerm, 300);
|
||||
|
||||
const stats = useMemo(() => ({
|
||||
total: mockArticles.length,
|
||||
published: mockArticles.filter((a) => a.status === 'published').length,
|
||||
drafts: mockArticles.filter((a) => a.status === 'draft').length,
|
||||
totalViews: mockArticles.reduce((sum, a) => sum + a.views, 0),
|
||||
}), []);
|
||||
|
||||
const filteredArticles = useMemo(() => {
|
||||
if (!debouncedSearch) return mockArticles;
|
||||
const term = debouncedSearch.toLowerCase();
|
||||
return mockArticles.filter(
|
||||
(a) =>
|
||||
a.title.toLowerCase().includes(term) ||
|
||||
a.content.toLowerCase().includes(term) ||
|
||||
a.author.toLowerCase().includes(term)
|
||||
);
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
startTransition(() => {
|
||||
setSearchTerm(value);
|
||||
});
|
||||
};
|
||||
|
||||
const handleArticleClick = (article: Article) => {
|
||||
setSelectedArticle(article);
|
||||
setEditContent(article.content);
|
||||
setEditMode(false);
|
||||
showToast(`Viewing "${article.title}"`, 'info');
|
||||
};
|
||||
|
||||
const handleSaveArticle = () => {
|
||||
if (selectedArticle) {
|
||||
showToast('Article saved successfully', 'success');
|
||||
setEditMode(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<header className="app-header">
|
||||
<h1>Article Editor</h1>
|
||||
<p>Create and manage help center articles</p>
|
||||
</header>
|
||||
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Total Articles</div>
|
||||
<div className="stat-value">{stats.total}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Published</div>
|
||||
<div className="stat-value">{stats.published}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Drafts</div>
|
||||
<div className="stat-value">{stats.drafts}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Total Views</div>
|
||||
<div className="stat-value">{stats.totalViews.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="search-section">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search articles by title, content, or author..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="search-input"
|
||||
/>
|
||||
{isPending && <div className="search-pending">Searching...</div>}
|
||||
</div>
|
||||
|
||||
{filteredArticles.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">📝</div>
|
||||
<h3>No articles found</h3>
|
||||
<p>Try adjusting your search or create a new article</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="data-grid">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Author</th>
|
||||
<th>Status</th>
|
||||
<th>Views</th>
|
||||
<th>Helpful Votes</th>
|
||||
<th>Last Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredArticles.map((article) => (
|
||||
<tr
|
||||
key={article.id}
|
||||
onClick={() => handleArticleClick(article)}
|
||||
className={selectedArticle?.id === article.id ? 'selected' : ''}
|
||||
>
|
||||
<td>{article.title}</td>
|
||||
<td>{article.author}</td>
|
||||
<td>
|
||||
<span className={`status-badge status-${article.status}`}>
|
||||
{article.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>{article.views.toLocaleString()}</td>
|
||||
<td>{article.helpful}</td>
|
||||
<td>{article.lastUpdated}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedArticle && (
|
||||
<div className="detail-panel">
|
||||
<div className="panel-header">
|
||||
<h3>{selectedArticle.title}</h3>
|
||||
<button onClick={() => setEditMode(!editMode)} className="edit-button">
|
||||
{editMode ? 'Cancel' : 'Edit'}
|
||||
</button>
|
||||
</div>
|
||||
{editMode ? (
|
||||
<div className="editor-section">
|
||||
<textarea
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
className="article-editor"
|
||||
placeholder="Write your article content..."
|
||||
/>
|
||||
<button onClick={handleSaveArticle} className="save-button">
|
||||
Save Article
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="article-content">
|
||||
<p>{selectedArticle.content}</p>
|
||||
<div className="article-meta">
|
||||
<span>Author: {selectedArticle.author}</span>
|
||||
<span>Views: {selectedArticle.views}</span>
|
||||
<span>Helpful: {selectedArticle.helpful}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="toast-container">
|
||||
{toasts.map((toast) => (
|
||||
<div key={toast.id} className={`toast toast-${toast.type}`}>
|
||||
{toast.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
13
servers/intercom/src/apps/article-editor/index.html
Normal file
13
servers/intercom/src/apps/article-editor/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Article Editor - Intercom MCP</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
59
servers/intercom/src/apps/article-editor/main.tsx
Normal file
59
servers/intercom/src/apps/article-editor/main.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './styles.css';
|
||||
|
||||
const App = lazy(() => import('./App'));
|
||||
|
||||
class ErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode },
|
||||
{ hasError: boolean; error?: Error }
|
||||
> {
|
||||
constructor(props: { children: React.ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('Article Editor Error:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="error-container">
|
||||
<h1>Something went wrong</h1>
|
||||
<p>{this.state.error?.message}</p>
|
||||
<button onClick={() => window.location.reload()}>Reload</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const LoadingSkeleton = () => (
|
||||
<div className="loading-skeleton">
|
||||
<div className="skeleton-header shimmer"></div>
|
||||
<div className="skeleton-stats">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="skeleton-card shimmer"></div>
|
||||
))}
|
||||
</div>
|
||||
<div className="skeleton-content shimmer"></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<LoadingSkeleton />}>
|
||||
<App />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
453
servers/intercom/src/apps/article-editor/styles.css
Normal file
453
servers/intercom/src/apps/article-editor/styles.css
Normal file
@ -0,0 +1,453 @@
|
||||
:root {
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-tertiary: #334155;
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #cbd5e1;
|
||||
--text-muted: #94a3b8;
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
--success: #10b981;
|
||||
--error: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
--border: #475569;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: linear-gradient(135deg, var(--accent), var(--success));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.app-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.2);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.search-section {
|
||||
margin-bottom: 2rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 1rem 1.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 12px;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.search-pending {
|
||||
position: absolute;
|
||||
right: 1.5rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.data-grid {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
thead {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
border-top: 1px solid var(--border);
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
tbody tr.selected {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.status-draft {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.status-published {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-archived {
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.detail-panel {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.panel-header h3 {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.edit-button,
|
||||
.save-button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.edit-button:hover {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.save-button:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.article-editor {
|
||||
width: 100%;
|
||||
min-height: 300px;
|
||||
padding: 1rem;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.article-editor:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.article-content {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.article-meta {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 2rem;
|
||||
right: 2rem;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
animation: slideIn 0.3s ease;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background: var(--error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-secondary) 0%,
|
||||
var(--bg-tertiary) 50%,
|
||||
var(--bg-secondary) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-skeleton {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-header {
|
||||
height: 60px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
height: 100px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.skeleton-content {
|
||||
height: 400px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-container h1 {
|
||||
color: var(--error);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-container button {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.error-container button:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.data-grid {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
left: 1rem;
|
||||
}
|
||||
}
|
||||
112
servers/intercom/src/apps/automation-center/App.tsx
Normal file
112
servers/intercom/src/apps/automation-center/App.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import React, { useState, useMemo, useTransition, useCallback, useEffect } from 'react';
|
||||
|
||||
const useDebounce = <T,>(value: T, delay: number): T => {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(handler);
|
||||
}, [value, delay]);
|
||||
return debouncedValue;
|
||||
};
|
||||
|
||||
interface Toast { id: number; message: string; type: 'success' | 'error' | 'info'; }
|
||||
const useToast = () => {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const showToast = useCallback((message: string, type: Toast['type'] = 'info') => {
|
||||
const id = Date.now();
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 3000);
|
||||
}, []);
|
||||
return { toasts, showToast };
|
||||
};
|
||||
|
||||
interface Automation { id: string; name: string; trigger: string; actions: number; status: 'active' | 'paused' | 'draft'; executions: number; successRate: number; lastRun: string; }
|
||||
|
||||
const mockAutomations: Automation[] = [
|
||||
{ id: '1', name: 'Welcome New Users', trigger: 'user_created', actions: 3, status: 'active', executions: 1248, successRate: 98, lastRun: '2024-01-15 14:32' },
|
||||
{ id: '2', name: 'Auto-Tag Support Tickets', trigger: 'conversation_created', actions: 2, status: 'active', executions: 856, successRate: 95, lastRun: '2024-01-15 14:28' },
|
||||
{ id: '3', name: 'Escalate High Priority', trigger: 'ticket_priority_high', actions: 4, status: 'active', executions: 124, successRate: 100, lastRun: '2024-01-15 13:45' },
|
||||
{ id: '4', name: 'Nurture Trial Users', trigger: 'trial_day_3', actions: 5, status: 'paused', executions: 342, successRate: 88, lastRun: '2024-01-14 10:15' },
|
||||
];
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedAutomation, setSelectedAutomation] = useState<Automation | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const { toasts, showToast } = useToast();
|
||||
const debouncedSearch = useDebounce(searchTerm, 300);
|
||||
|
||||
const stats = useMemo(() => ({
|
||||
total: mockAutomations.length,
|
||||
active: mockAutomations.filter((a) => a.status === 'active').length,
|
||||
totalExecutions: mockAutomations.reduce((sum, a) => sum + a.executions, 0),
|
||||
avgSuccessRate: Math.round(mockAutomations.reduce((sum, a) => sum + a.successRate, 0) / mockAutomations.length),
|
||||
}), []);
|
||||
|
||||
const filteredAutomations = useMemo(() => {
|
||||
if (!debouncedSearch) return mockAutomations;
|
||||
const term = debouncedSearch.toLowerCase();
|
||||
return mockAutomations.filter((a) => a.name.toLowerCase().includes(term) || a.trigger.toLowerCase().includes(term));
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const handleSearch = (value: string) => { startTransition(() => { setSearchTerm(value); }); };
|
||||
const handleAutomationClick = (automation: Automation) => { setSelectedAutomation(automation); showToast(`Viewing automation "${automation.name}"`, 'info'); };
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<header className="app-header">
|
||||
<h1>Automation Center</h1>
|
||||
<p>Workflow automation overview and management</p>
|
||||
</header>
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card"><div className="stat-label">Total Automations</div><div className="stat-value">{stats.total}</div></div>
|
||||
<div className="stat-card"><div className="stat-label">Active</div><div className="stat-value">{stats.active}</div></div>
|
||||
<div className="stat-card"><div className="stat-label">Total Executions</div><div className="stat-value">{stats.totalExecutions.toLocaleString()}</div></div>
|
||||
<div className="stat-card"><div className="stat-label">Avg Success Rate</div><div className="stat-value">{stats.avgSuccessRate}%</div></div>
|
||||
</div>
|
||||
<div className="search-section">
|
||||
<input type="text" placeholder="Search automations..." value={searchTerm} onChange={(e) => handleSearch(e.target.value)} className="search-input" />
|
||||
{isPending && <div className="search-pending">Searching...</div>}
|
||||
</div>
|
||||
{filteredAutomations.length === 0 ? (
|
||||
<div className="empty-state"><div className="empty-icon">🤖</div><h3>No automations found</h3><p>Try adjusting your search criteria</p></div>
|
||||
) : (
|
||||
<div className="data-grid">
|
||||
<table>
|
||||
<thead><tr><th>Automation Name</th><th>Trigger</th><th>Actions</th><th>Status</th><th>Executions</th><th>Success Rate</th><th>Last Run</th></tr></thead>
|
||||
<tbody>
|
||||
{filteredAutomations.map((automation) => (
|
||||
<tr key={automation.id} onClick={() => handleAutomationClick(automation)} className={selectedAutomation?.id === automation.id ? 'selected' : ''}>
|
||||
<td><strong>{automation.name}</strong></td>
|
||||
<td><code style={{background:'var(--bg-tertiary)',padding:'2px 6px',borderRadius:'4px',fontSize:'0.85em'}}>{automation.trigger}</code></td>
|
||||
<td>{automation.actions}</td>
|
||||
<td><span className={`status-badge status-${automation.status==='active'?'active':automation.status==='paused'?'draft':'archived'}`}>{automation.status}</span></td>
|
||||
<td>{automation.executions.toLocaleString()}</td>
|
||||
<td><span style={{color:automation.successRate>=95?'var(--success)':automation.successRate>=85?'var(--warning)':'var(--error)'}}>{automation.successRate}%</span></td>
|
||||
<td>{automation.lastRun}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{selectedAutomation && (
|
||||
<div className="detail-panel">
|
||||
<h3>Automation Details</h3>
|
||||
<div className="detail-content">
|
||||
<p><strong>Name:</strong> {selectedAutomation.name}</p>
|
||||
<p><strong>Trigger:</strong> <code>{selectedAutomation.trigger}</code></p>
|
||||
<p><strong>Number of Actions:</strong> {selectedAutomation.actions}</p>
|
||||
<p><strong>Status:</strong> {selectedAutomation.status}</p>
|
||||
<p><strong>Total Executions:</strong> {selectedAutomation.executions.toLocaleString()}</p>
|
||||
<p><strong>Success Rate:</strong> {selectedAutomation.successRate}%</p>
|
||||
<p><strong>Last Run:</strong> {selectedAutomation.lastRun}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="toast-container">{toasts.map((toast) => (<div key={toast.id} className={`toast toast-${toast.type}`}>{toast.message}</div>))}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
13
servers/intercom/src/apps/automation-center/index.html
Normal file
13
servers/intercom/src/apps/automation-center/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Automation Center - Intercom MCP</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
59
servers/intercom/src/apps/automation-center/main.tsx
Normal file
59
servers/intercom/src/apps/automation-center/main.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './styles.css';
|
||||
|
||||
const App = lazy(() => import('./App'));
|
||||
|
||||
class ErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode },
|
||||
{ hasError: boolean; error?: Error }
|
||||
> {
|
||||
constructor(props: { children: React.ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('Automation Center Error:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="error-container">
|
||||
<h1>Something went wrong</h1>
|
||||
<p>{this.state.error?.message}</p>
|
||||
<button onClick={() => window.location.reload()}>Reload</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const LoadingSkeleton = () => (
|
||||
<div className="loading-skeleton">
|
||||
<div className="skeleton-header shimmer"></div>
|
||||
<div className="skeleton-stats">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="skeleton-card shimmer"></div>
|
||||
))}
|
||||
</div>
|
||||
<div className="skeleton-content shimmer"></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<LoadingSkeleton />}>
|
||||
<App />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
409
servers/intercom/src/apps/automation-center/styles.css
Normal file
409
servers/intercom/src/apps/automation-center/styles.css
Normal file
@ -0,0 +1,409 @@
|
||||
:root {
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-tertiary: #334155;
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #cbd5e1;
|
||||
--text-muted: #94a3b8;
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
--success: #10b981;
|
||||
--error: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
--border: #475569;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: linear-gradient(135deg, var(--accent), var(--success));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.app-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.2);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.search-section {
|
||||
margin-bottom: 2rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 1rem 1.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 12px;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.search-pending {
|
||||
position: absolute;
|
||||
right: 1.5rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Data Grid */
|
||||
.data-grid {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
thead {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
border-top: 1px solid var(--border);
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
tbody tr.selected {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-inactive {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.status-blocked {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Detail Panel */
|
||||
.detail-panel {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.detail-panel h3 {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.detail-content p {
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.detail-content strong {
|
||||
color: var(--text-primary);
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 2rem;
|
||||
right: 2rem;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
animation: slideIn 0.3s ease;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background: var(--error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shimmer Loading */
|
||||
.shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-secondary) 0%,
|
||||
var(--bg-tertiary) 50%,
|
||||
var(--bg-secondary) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-skeleton {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-header {
|
||||
height: 60px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
height: 100px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.skeleton-content {
|
||||
height: 400px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Error Container */
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-container h1 {
|
||||
color: var(--error);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-container button {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.error-container button:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.app-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.data-grid {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
left: 1rem;
|
||||
}
|
||||
}
|
||||
106
servers/intercom/src/apps/collection-manager/App.tsx
Normal file
106
servers/intercom/src/apps/collection-manager/App.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import React, { useState, useMemo, useTransition, useCallback, useEffect } from 'react';
|
||||
|
||||
const useDebounce = <T,>(value: T, delay: number): T => {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(handler);
|
||||
}, [value, delay]);
|
||||
return debouncedValue;
|
||||
};
|
||||
|
||||
interface Toast { id: number; message: string; type: 'success' | 'error' | 'info'; }
|
||||
const useToast = () => {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const showToast = useCallback((message: string, type: Toast['type'] = 'info') => {
|
||||
const id = Date.now();
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 3000);
|
||||
}, []);
|
||||
return { toasts, showToast };
|
||||
};
|
||||
|
||||
interface Collection { id: string; name: string; description: string; articles: number; sections: number; status: 'active' | 'draft'; createdAt: string; }
|
||||
|
||||
const mockCollections: Collection[] = [
|
||||
{ id: '1', name: 'Getting Started', description: 'Essential guides for new users', articles: 15, sections: 3, status: 'active', createdAt: '2023-06-15' },
|
||||
{ id: '2', name: 'Advanced Features', description: 'Deep dives into powerful features', articles: 22, sections: 5, status: 'active', createdAt: '2023-08-20' },
|
||||
{ id: '3', name: 'Troubleshooting', description: 'Common issues and solutions', articles: 18, sections: 4, status: 'draft', createdAt: '2024-01-10' },
|
||||
];
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedCollection, setSelectedCollection] = useState<Collection | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const { toasts, showToast } = useToast();
|
||||
const debouncedSearch = useDebounce(searchTerm, 300);
|
||||
|
||||
const stats = useMemo(() => ({
|
||||
total: mockCollections.length,
|
||||
active: mockCollections.filter((c) => c.status === 'active').length,
|
||||
totalArticles: mockCollections.reduce((sum, c) => sum + c.articles, 0),
|
||||
totalSections: mockCollections.reduce((sum, c) => sum + c.sections, 0),
|
||||
}), []);
|
||||
|
||||
const filteredCollections = useMemo(() => {
|
||||
if (!debouncedSearch) return mockCollections;
|
||||
const term = debouncedSearch.toLowerCase();
|
||||
return mockCollections.filter((c) => c.name.toLowerCase().includes(term) || c.description.toLowerCase().includes(term));
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const handleSearch = (value: string) => { startTransition(() => { setSearchTerm(value); }); };
|
||||
const handleCollectionClick = (collection: Collection) => { setSelectedCollection(collection); showToast(`Viewing "${collection.name}"`, 'info'); };
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<header className="app-header">
|
||||
<h1>Collection Manager</h1>
|
||||
<p>Organize help center collections and sections</p>
|
||||
</header>
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card"><div className="stat-label">Total Collections</div><div className="stat-value">{stats.total}</div></div>
|
||||
<div className="stat-card"><div className="stat-label">Active</div><div className="stat-value">{stats.active}</div></div>
|
||||
<div className="stat-card"><div className="stat-label">Total Articles</div><div className="stat-value">{stats.totalArticles}</div></div>
|
||||
<div className="stat-card"><div className="stat-label">Total Sections</div><div className="stat-value">{stats.totalSections}</div></div>
|
||||
</div>
|
||||
<div className="search-section">
|
||||
<input type="text" placeholder="Search collections..." value={searchTerm} onChange={(e) => handleSearch(e.target.value)} className="search-input" />
|
||||
{isPending && <div className="search-pending">Searching...</div>}
|
||||
</div>
|
||||
{filteredCollections.length === 0 ? (
|
||||
<div className="empty-state"><div className="empty-icon">📚</div><h3>No collections found</h3><p>Try adjusting your search criteria</p></div>
|
||||
) : (
|
||||
<div className="data-grid">
|
||||
<table>
|
||||
<thead><tr><th>Collection Name</th><th>Description</th><th>Articles</th><th>Sections</th><th>Status</th><th>Created</th></tr></thead>
|
||||
<tbody>
|
||||
{filteredCollections.map((collection) => (
|
||||
<tr key={collection.id} onClick={() => handleCollectionClick(collection)} className={selectedCollection?.id === collection.id ? 'selected' : ''}>
|
||||
<td>{collection.name}</td><td>{collection.description}</td><td>{collection.articles}</td><td>{collection.sections}</td>
|
||||
<td><span className={`status-badge status-${collection.status}`}>{collection.status}</span></td>
|
||||
<td>{collection.createdAt}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{selectedCollection && (
|
||||
<div className="detail-panel">
|
||||
<h3>Collection Details</h3>
|
||||
<div className="detail-content">
|
||||
<p><strong>Name:</strong> {selectedCollection.name}</p>
|
||||
<p><strong>Description:</strong> {selectedCollection.description}</p>
|
||||
<p><strong>Articles:</strong> {selectedCollection.articles}</p>
|
||||
<p><strong>Sections:</strong> {selectedCollection.sections}</p>
|
||||
<p><strong>Status:</strong> {selectedCollection.status}</p>
|
||||
<p><strong>Created:</strong> {selectedCollection.createdAt}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="toast-container">{toasts.map((toast) => (<div key={toast.id} className={`toast toast-${toast.type}`}>{toast.message}</div>))}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
13
servers/intercom/src/apps/collection-manager/index.html
Normal file
13
servers/intercom/src/apps/collection-manager/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Collection Manager - Intercom MCP</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
59
servers/intercom/src/apps/collection-manager/main.tsx
Normal file
59
servers/intercom/src/apps/collection-manager/main.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './styles.css';
|
||||
|
||||
const App = lazy(() => import('./App'));
|
||||
|
||||
class ErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode },
|
||||
{ hasError: boolean; error?: Error }
|
||||
> {
|
||||
constructor(props: { children: React.ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('Collection Manager Error:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="error-container">
|
||||
<h1>Something went wrong</h1>
|
||||
<p>{this.state.error?.message}</p>
|
||||
<button onClick={() => window.location.reload()}>Reload</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const LoadingSkeleton = () => (
|
||||
<div className="loading-skeleton">
|
||||
<div className="skeleton-header shimmer"></div>
|
||||
<div className="skeleton-stats">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="skeleton-card shimmer"></div>
|
||||
))}
|
||||
</div>
|
||||
<div className="skeleton-content shimmer"></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<LoadingSkeleton />}>
|
||||
<App />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
409
servers/intercom/src/apps/collection-manager/styles.css
Normal file
409
servers/intercom/src/apps/collection-manager/styles.css
Normal file
@ -0,0 +1,409 @@
|
||||
:root {
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-tertiary: #334155;
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #cbd5e1;
|
||||
--text-muted: #94a3b8;
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
--success: #10b981;
|
||||
--error: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
--border: #475569;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: linear-gradient(135deg, var(--accent), var(--success));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.app-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.2);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.search-section {
|
||||
margin-bottom: 2rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 1rem 1.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 12px;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.search-pending {
|
||||
position: absolute;
|
||||
right: 1.5rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Data Grid */
|
||||
.data-grid {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
thead {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
border-top: 1px solid var(--border);
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
tbody tr.selected {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-inactive {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.status-blocked {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Detail Panel */
|
||||
.detail-panel {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.detail-panel h3 {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.detail-content p {
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.detail-content strong {
|
||||
color: var(--text-primary);
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 2rem;
|
||||
right: 2rem;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
animation: slideIn 0.3s ease;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background: var(--error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shimmer Loading */
|
||||
.shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-secondary) 0%,
|
||||
var(--bg-tertiary) 50%,
|
||||
var(--bg-secondary) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-skeleton {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-header {
|
||||
height: 60px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
height: 100px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.skeleton-content {
|
||||
height: 400px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Error Container */
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-container h1 {
|
||||
color: var(--error);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-container button {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.error-container button:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.app-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.data-grid {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
left: 1rem;
|
||||
}
|
||||
}
|
||||
226
servers/intercom/src/apps/company-directory/App.tsx
Normal file
226
servers/intercom/src/apps/company-directory/App.tsx
Normal file
@ -0,0 +1,226 @@
|
||||
import React, { useState, useMemo, useTransition, useCallback, useEffect } from 'react';
|
||||
|
||||
const useDebounce = <T,>(value: T, delay: number): T => {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(handler);
|
||||
}, [value, delay]);
|
||||
return debouncedValue;
|
||||
};
|
||||
|
||||
interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'info';
|
||||
}
|
||||
|
||||
const useToast = () => {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const showToast = useCallback((message: string, type: Toast['type'] = 'info') => {
|
||||
const id = Date.now();
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, 3000);
|
||||
}, []);
|
||||
|
||||
return { toasts, showToast };
|
||||
};
|
||||
|
||||
interface Company {
|
||||
id: string;
|
||||
name: string;
|
||||
website: string;
|
||||
industry: string;
|
||||
employees: number;
|
||||
plan: 'free' | 'starter' | 'growth' | 'enterprise';
|
||||
contacts: number;
|
||||
monthlySpend: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const mockCompanies: Company[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Acme Corporation',
|
||||
website: 'acme.com',
|
||||
industry: 'Technology',
|
||||
employees: 250,
|
||||
plan: 'enterprise',
|
||||
contacts: 12,
|
||||
monthlySpend: 2500,
|
||||
createdAt: '2023-06-15',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'TechStart Inc',
|
||||
website: 'techstart.io',
|
||||
industry: 'SaaS',
|
||||
employees: 45,
|
||||
plan: 'growth',
|
||||
contacts: 8,
|
||||
monthlySpend: 800,
|
||||
createdAt: '2023-09-22',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Global Solutions',
|
||||
website: 'globalsolutions.com',
|
||||
industry: 'Consulting',
|
||||
employees: 500,
|
||||
plan: 'enterprise',
|
||||
contacts: 25,
|
||||
monthlySpend: 4200,
|
||||
createdAt: '2023-03-10',
|
||||
},
|
||||
];
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedCompany, setSelectedCompany] = useState<Company | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const { toasts, showToast } = useToast();
|
||||
|
||||
const debouncedSearch = useDebounce(searchTerm, 300);
|
||||
|
||||
const stats = useMemo(() => ({
|
||||
total: mockCompanies.length,
|
||||
enterprise: mockCompanies.filter((c) => c.plan === 'enterprise').length,
|
||||
totalContacts: mockCompanies.reduce((sum, c) => sum + c.contacts, 0),
|
||||
avgSpend: Math.round(mockCompanies.reduce((sum, c) => sum + c.monthlySpend, 0) / mockCompanies.length),
|
||||
}), []);
|
||||
|
||||
const filteredCompanies = useMemo(() => {
|
||||
if (!debouncedSearch) return mockCompanies;
|
||||
const term = debouncedSearch.toLowerCase();
|
||||
return mockCompanies.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(term) ||
|
||||
c.website.toLowerCase().includes(term) ||
|
||||
c.industry.toLowerCase().includes(term)
|
||||
);
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
startTransition(() => {
|
||||
setSearchTerm(value);
|
||||
});
|
||||
};
|
||||
|
||||
const handleCompanyClick = (company: Company) => {
|
||||
setSelectedCompany(company);
|
||||
showToast(`Viewing ${company.name}`, 'info');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<header className="app-header">
|
||||
<h1>Company Directory</h1>
|
||||
<p>Browse and manage company accounts</p>
|
||||
</header>
|
||||
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Total Companies</div>
|
||||
<div className="stat-value">{stats.total}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Enterprise Plans</div>
|
||||
<div className="stat-value">{stats.enterprise}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Total Contacts</div>
|
||||
<div className="stat-value">{stats.totalContacts}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Avg Monthly Spend</div>
|
||||
<div className="stat-value">${stats.avgSpend}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="search-section">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search companies by name, website, or industry..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="search-input"
|
||||
/>
|
||||
{isPending && <div className="search-pending">Searching...</div>}
|
||||
</div>
|
||||
|
||||
{filteredCompanies.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">🏢</div>
|
||||
<h3>No companies found</h3>
|
||||
<p>Try adjusting your search criteria</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="data-grid">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Company Name</th>
|
||||
<th>Website</th>
|
||||
<th>Industry</th>
|
||||
<th>Employees</th>
|
||||
<th>Plan</th>
|
||||
<th>Contacts</th>
|
||||
<th>Monthly Spend</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredCompanies.map((company) => (
|
||||
<tr
|
||||
key={company.id}
|
||||
onClick={() => handleCompanyClick(company)}
|
||||
className={selectedCompany?.id === company.id ? 'selected' : ''}
|
||||
>
|
||||
<td>{company.name}</td>
|
||||
<td>{company.website}</td>
|
||||
<td>{company.industry}</td>
|
||||
<td>{company.employees}</td>
|
||||
<td>
|
||||
<span className={`plan-badge plan-${company.plan}`}>
|
||||
{company.plan}
|
||||
</span>
|
||||
</td>
|
||||
<td>{company.contacts}</td>
|
||||
<td>${company.monthlySpend.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedCompany && (
|
||||
<div className="detail-panel">
|
||||
<h3>Company Details</h3>
|
||||
<div className="detail-content">
|
||||
<p><strong>Name:</strong> {selectedCompany.name}</p>
|
||||
<p><strong>Website:</strong> {selectedCompany.website}</p>
|
||||
<p><strong>Industry:</strong> {selectedCompany.industry}</p>
|
||||
<p><strong>Employees:</strong> {selectedCompany.employees}</p>
|
||||
<p><strong>Plan:</strong> {selectedCompany.plan}</p>
|
||||
<p><strong>Contacts:</strong> {selectedCompany.contacts}</p>
|
||||
<p><strong>Monthly Spend:</strong> ${selectedCompany.monthlySpend}</p>
|
||||
<p><strong>Created:</strong> {selectedCompany.createdAt}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="toast-container">
|
||||
{toasts.map((toast) => (
|
||||
<div key={toast.id} className={`toast toast-${toast.type}`}>
|
||||
{toast.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
13
servers/intercom/src/apps/company-directory/index.html
Normal file
13
servers/intercom/src/apps/company-directory/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Company Directory - Intercom MCP</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
59
servers/intercom/src/apps/company-directory/main.tsx
Normal file
59
servers/intercom/src/apps/company-directory/main.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './styles.css';
|
||||
|
||||
const App = lazy(() => import('./App'));
|
||||
|
||||
class ErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode },
|
||||
{ hasError: boolean; error?: Error }
|
||||
> {
|
||||
constructor(props: { children: React.ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('Company Directory Error:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="error-container">
|
||||
<h1>Something went wrong</h1>
|
||||
<p>{this.state.error?.message}</p>
|
||||
<button onClick={() => window.location.reload()}>Reload</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const LoadingSkeleton = () => (
|
||||
<div className="loading-skeleton">
|
||||
<div className="skeleton-header shimmer"></div>
|
||||
<div className="skeleton-stats">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="skeleton-card shimmer"></div>
|
||||
))}
|
||||
</div>
|
||||
<div className="skeleton-content shimmer"></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<LoadingSkeleton />}>
|
||||
<App />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
391
servers/intercom/src/apps/company-directory/styles.css
Normal file
391
servers/intercom/src/apps/company-directory/styles.css
Normal file
@ -0,0 +1,391 @@
|
||||
:root {
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-tertiary: #334155;
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #cbd5e1;
|
||||
--text-muted: #94a3b8;
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
--success: #10b981;
|
||||
--error: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
--border: #475569;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: linear-gradient(135deg, var(--accent), var(--success));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.app-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.2);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.search-section {
|
||||
margin-bottom: 2rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 1rem 1.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 12px;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.search-pending {
|
||||
position: absolute;
|
||||
right: 1.5rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.data-grid {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
thead {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
border-top: 1px solid var(--border);
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
tbody tr.selected {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.plan-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.plan-free {
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.plan-starter {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.plan-growth {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.plan-enterprise {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.detail-panel {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.detail-panel h3 {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.detail-content p {
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.detail-content strong {
|
||||
color: var(--text-primary);
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 2rem;
|
||||
right: 2rem;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
animation: slideIn 0.3s ease;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background: var(--error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-secondary) 0%,
|
||||
var(--bg-tertiary) 50%,
|
||||
var(--bg-secondary) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-skeleton {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-header {
|
||||
height: 60px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
height: 100px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.skeleton-content {
|
||||
height: 400px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-container h1 {
|
||||
color: var(--error);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-container button {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.error-container button:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.data-grid {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
left: 1rem;
|
||||
}
|
||||
}
|
||||
228
servers/intercom/src/apps/contact-manager/App.tsx
Normal file
228
servers/intercom/src/apps/contact-manager/App.tsx
Normal file
@ -0,0 +1,228 @@
|
||||
import React, { useState, useMemo, useTransition, useCallback, useEffect } from 'react';
|
||||
|
||||
// Custom hooks
|
||||
const useDebounce = <T,>(value: T, delay: number): T => {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(handler);
|
||||
}, [value, delay]);
|
||||
return debouncedValue;
|
||||
};
|
||||
|
||||
interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'info';
|
||||
}
|
||||
|
||||
const useToast = () => {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const showToast = useCallback((message: string, type: Toast['type'] = 'info') => {
|
||||
const id = Date.now();
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, 3000);
|
||||
}, []);
|
||||
|
||||
return { toasts, showToast };
|
||||
};
|
||||
|
||||
// Mock data
|
||||
interface Contact {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
company: string;
|
||||
status: 'active' | 'inactive' | 'blocked';
|
||||
lastSeen: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
const mockContacts: Contact[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Alice Johnson',
|
||||
email: 'alice@example.com',
|
||||
phone: '+1 (555) 123-4567',
|
||||
company: 'Acme Corp',
|
||||
status: 'active',
|
||||
lastSeen: '2024-01-15 14:32',
|
||||
tags: ['premium', 'enterprise'],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Bob Smith',
|
||||
email: 'bob@example.com',
|
||||
phone: '+1 (555) 234-5678',
|
||||
company: 'TechStart Inc',
|
||||
status: 'active',
|
||||
lastSeen: '2024-01-14 09:15',
|
||||
tags: ['trial'],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Carol Davis',
|
||||
email: 'carol@example.com',
|
||||
phone: '+1 (555) 345-6789',
|
||||
company: 'Global Solutions',
|
||||
status: 'inactive',
|
||||
lastSeen: '2024-01-10 16:45',
|
||||
tags: ['vip'],
|
||||
},
|
||||
];
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedContact, setSelectedContact] = useState<Contact | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const { toasts, showToast } = useToast();
|
||||
|
||||
const debouncedSearch = useDebounce(searchTerm, 300);
|
||||
|
||||
const stats = useMemo(() => ({
|
||||
total: mockContacts.length,
|
||||
active: mockContacts.filter((c) => c.status === 'active').length,
|
||||
inactive: mockContacts.filter((c) => c.status === 'inactive').length,
|
||||
blocked: mockContacts.filter((c) => c.status === 'blocked').length,
|
||||
}), []);
|
||||
|
||||
const filteredContacts = useMemo(() => {
|
||||
if (!debouncedSearch) return mockContacts;
|
||||
const term = debouncedSearch.toLowerCase();
|
||||
return mockContacts.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(term) ||
|
||||
c.email.toLowerCase().includes(term) ||
|
||||
c.company.toLowerCase().includes(term)
|
||||
);
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
startTransition(() => {
|
||||
setSearchTerm(value);
|
||||
});
|
||||
};
|
||||
|
||||
const handleContactClick = (contact: Contact) => {
|
||||
setSelectedContact(contact);
|
||||
showToast(`Viewing ${contact.name}`, 'info');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<header className="app-header">
|
||||
<h1>Contact Manager</h1>
|
||||
<p>Manage and search your Intercom contacts</p>
|
||||
</header>
|
||||
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Total Contacts</div>
|
||||
<div className="stat-value">{stats.total}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Active</div>
|
||||
<div className="stat-value">{stats.active}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Inactive</div>
|
||||
<div className="stat-value">{stats.inactive}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Blocked</div>
|
||||
<div className="stat-value">{stats.blocked}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="search-section">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search contacts by name, email, or company..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="search-input"
|
||||
/>
|
||||
{isPending && <div className="search-pending">Searching...</div>}
|
||||
</div>
|
||||
|
||||
{filteredContacts.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">👥</div>
|
||||
<h3>No contacts found</h3>
|
||||
<p>Try adjusting your search criteria</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="data-grid">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Company</th>
|
||||
<th>Status</th>
|
||||
<th>Last Seen</th>
|
||||
<th>Tags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredContacts.map((contact) => (
|
||||
<tr
|
||||
key={contact.id}
|
||||
onClick={() => handleContactClick(contact)}
|
||||
className={selectedContact?.id === contact.id ? 'selected' : ''}
|
||||
>
|
||||
<td>{contact.name}</td>
|
||||
<td>{contact.email}</td>
|
||||
<td>{contact.company}</td>
|
||||
<td>
|
||||
<span className={`status-badge status-${contact.status}`}>
|
||||
{contact.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>{contact.lastSeen}</td>
|
||||
<td>
|
||||
<div className="tags">
|
||||
{contact.tags.map((tag) => (
|
||||
<span key={tag} className="tag">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedContact && (
|
||||
<div className="detail-panel">
|
||||
<h3>Contact Details</h3>
|
||||
<div className="detail-content">
|
||||
<p><strong>Name:</strong> {selectedContact.name}</p>
|
||||
<p><strong>Email:</strong> {selectedContact.email}</p>
|
||||
<p><strong>Phone:</strong> {selectedContact.phone}</p>
|
||||
<p><strong>Company:</strong> {selectedContact.company}</p>
|
||||
<p><strong>Status:</strong> {selectedContact.status}</p>
|
||||
<p><strong>Last Seen:</strong> {selectedContact.lastSeen}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="toast-container">
|
||||
{toasts.map((toast) => (
|
||||
<div key={toast.id} className={`toast toast-${toast.type}`}>
|
||||
{toast.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
13
servers/intercom/src/apps/contact-manager/index.html
Normal file
13
servers/intercom/src/apps/contact-manager/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Contact Manager - Intercom MCP</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
59
servers/intercom/src/apps/contact-manager/main.tsx
Normal file
59
servers/intercom/src/apps/contact-manager/main.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './styles.css';
|
||||
|
||||
const App = lazy(() => import('./App'));
|
||||
|
||||
class ErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode },
|
||||
{ hasError: boolean; error?: Error }
|
||||
> {
|
||||
constructor(props: { children: React.ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('Contact Manager Error:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="error-container">
|
||||
<h1>Something went wrong</h1>
|
||||
<p>{this.state.error?.message}</p>
|
||||
<button onClick={() => window.location.reload()}>Reload</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const LoadingSkeleton = () => (
|
||||
<div className="loading-skeleton">
|
||||
<div className="skeleton-header shimmer"></div>
|
||||
<div className="skeleton-stats">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="skeleton-card shimmer"></div>
|
||||
))}
|
||||
</div>
|
||||
<div className="skeleton-content shimmer"></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<LoadingSkeleton />}>
|
||||
<App />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
409
servers/intercom/src/apps/contact-manager/styles.css
Normal file
409
servers/intercom/src/apps/contact-manager/styles.css
Normal file
@ -0,0 +1,409 @@
|
||||
:root {
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-tertiary: #334155;
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #cbd5e1;
|
||||
--text-muted: #94a3b8;
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
--success: #10b981;
|
||||
--error: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
--border: #475569;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: linear-gradient(135deg, var(--accent), var(--success));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.app-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.2);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.search-section {
|
||||
margin-bottom: 2rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 1rem 1.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 12px;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.search-pending {
|
||||
position: absolute;
|
||||
right: 1.5rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Data Grid */
|
||||
.data-grid {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
thead {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
border-top: 1px solid var(--border);
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
tbody tr.selected {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-inactive {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.status-blocked {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Detail Panel */
|
||||
.detail-panel {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.detail-panel h3 {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.detail-content p {
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.detail-content strong {
|
||||
color: var(--text-primary);
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 2rem;
|
||||
right: 2rem;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
animation: slideIn 0.3s ease;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background: var(--error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shimmer Loading */
|
||||
.shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-secondary) 0%,
|
||||
var(--bg-tertiary) 50%,
|
||||
var(--bg-secondary) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-skeleton {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-header {
|
||||
height: 60px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
height: 100px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.skeleton-content {
|
||||
height: 400px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Error Container */
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-container h1 {
|
||||
color: var(--error);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-container button {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.error-container button:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.app-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.data-grid {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
left: 1rem;
|
||||
}
|
||||
}
|
||||
240
servers/intercom/src/apps/conversation-inbox/App.tsx
Normal file
240
servers/intercom/src/apps/conversation-inbox/App.tsx
Normal file
@ -0,0 +1,240 @@
|
||||
import React, { useState, useMemo, useTransition, useCallback, useEffect } from 'react';
|
||||
|
||||
const useDebounce = <T,>(value: T, delay: number): T => {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(handler);
|
||||
}, [value, delay]);
|
||||
return debouncedValue;
|
||||
};
|
||||
|
||||
interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'info';
|
||||
}
|
||||
|
||||
const useToast = () => {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const showToast = useCallback((message: string, type: Toast['type'] = 'info') => {
|
||||
const id = Date.now();
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, 3000);
|
||||
}, []);
|
||||
|
||||
return { toasts, showToast };
|
||||
};
|
||||
|
||||
interface Conversation {
|
||||
id: string;
|
||||
subject: string;
|
||||
contact: string;
|
||||
status: 'open' | 'closed' | 'snoozed';
|
||||
priority: 'high' | 'normal' | 'low';
|
||||
assignee: string;
|
||||
lastMessage: string;
|
||||
timestamp: string;
|
||||
unread: boolean;
|
||||
}
|
||||
|
||||
const mockConversations: Conversation[] = [
|
||||
{
|
||||
id: '1',
|
||||
subject: 'Issue with billing',
|
||||
contact: 'Alice Johnson',
|
||||
status: 'open',
|
||||
priority: 'high',
|
||||
assignee: 'Support Team',
|
||||
lastMessage: 'I was charged twice this month',
|
||||
timestamp: '2024-01-15 14:32',
|
||||
unread: true,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
subject: 'Feature request',
|
||||
contact: 'Bob Smith',
|
||||
status: 'open',
|
||||
priority: 'normal',
|
||||
assignee: 'Product Team',
|
||||
lastMessage: 'Would love to see dark mode',
|
||||
timestamp: '2024-01-15 13:15',
|
||||
unread: false,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
subject: 'Login problems',
|
||||
contact: 'Carol Davis',
|
||||
status: 'closed',
|
||||
priority: 'high',
|
||||
assignee: 'Tech Support',
|
||||
lastMessage: 'Thank you, issue resolved!',
|
||||
timestamp: '2024-01-14 16:45',
|
||||
unread: false,
|
||||
},
|
||||
];
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedConversation, setSelectedConversation] = useState<Conversation | null>(null);
|
||||
const [replyText, setReplyText] = useState('');
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const { toasts, showToast } = useToast();
|
||||
|
||||
const debouncedSearch = useDebounce(searchTerm, 300);
|
||||
|
||||
const stats = useMemo(() => ({
|
||||
total: mockConversations.length,
|
||||
open: mockConversations.filter((c) => c.status === 'open').length,
|
||||
closed: mockConversations.filter((c) => c.status === 'closed').length,
|
||||
unread: mockConversations.filter((c) => c.unread).length,
|
||||
}), []);
|
||||
|
||||
const filteredConversations = useMemo(() => {
|
||||
if (!debouncedSearch) return mockConversations;
|
||||
const term = debouncedSearch.toLowerCase();
|
||||
return mockConversations.filter(
|
||||
(c) =>
|
||||
c.subject.toLowerCase().includes(term) ||
|
||||
c.contact.toLowerCase().includes(term) ||
|
||||
c.lastMessage.toLowerCase().includes(term)
|
||||
);
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
startTransition(() => {
|
||||
setSearchTerm(value);
|
||||
});
|
||||
};
|
||||
|
||||
const handleConversationClick = (conversation: Conversation) => {
|
||||
setSelectedConversation(conversation);
|
||||
showToast(`Viewing conversation with ${conversation.contact}`, 'info');
|
||||
};
|
||||
|
||||
const handleSendReply = () => {
|
||||
if (replyText.trim() && selectedConversation) {
|
||||
showToast('Reply sent successfully', 'success');
|
||||
setReplyText('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<header className="app-header">
|
||||
<h1>Conversation Inbox</h1>
|
||||
<p>Manage customer conversations and support tickets</p>
|
||||
</header>
|
||||
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Total Conversations</div>
|
||||
<div className="stat-value">{stats.total}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Open</div>
|
||||
<div className="stat-value">{stats.open}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Closed</div>
|
||||
<div className="stat-value">{stats.closed}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Unread</div>
|
||||
<div className="stat-value">{stats.unread}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="search-section">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search conversations..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="search-input"
|
||||
/>
|
||||
{isPending && <div className="search-pending">Searching...</div>}
|
||||
</div>
|
||||
|
||||
{filteredConversations.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">💬</div>
|
||||
<h3>No conversations found</h3>
|
||||
<p>Try adjusting your search criteria</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="data-grid">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Subject</th>
|
||||
<th>Contact</th>
|
||||
<th>Status</th>
|
||||
<th>Priority</th>
|
||||
<th>Assignee</th>
|
||||
<th>Last Message</th>
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredConversations.map((conv) => (
|
||||
<tr
|
||||
key={conv.id}
|
||||
onClick={() => handleConversationClick(conv)}
|
||||
className={`${selectedConversation?.id === conv.id ? 'selected' : ''} ${conv.unread ? 'unread' : ''}`}
|
||||
>
|
||||
<td>
|
||||
{conv.unread && <span className="unread-dot">●</span>}
|
||||
{conv.subject}
|
||||
</td>
|
||||
<td>{conv.contact}</td>
|
||||
<td>
|
||||
<span className={`status-badge status-${conv.status}`}>
|
||||
{conv.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`priority-badge priority-${conv.priority}`}>
|
||||
{conv.priority}
|
||||
</span>
|
||||
</td>
|
||||
<td>{conv.assignee}</td>
|
||||
<td>{conv.lastMessage}</td>
|
||||
<td>{conv.timestamp}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedConversation && (
|
||||
<div className="detail-panel">
|
||||
<h3>Reply to {selectedConversation.contact}</h3>
|
||||
<textarea
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
placeholder="Type your reply..."
|
||||
className="reply-textarea"
|
||||
/>
|
||||
<button onClick={handleSendReply} className="send-button">
|
||||
Send Reply
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="toast-container">
|
||||
{toasts.map((toast) => (
|
||||
<div key={toast.id} className={`toast toast-${toast.type}`}>
|
||||
{toast.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
13
servers/intercom/src/apps/conversation-inbox/index.html
Normal file
13
servers/intercom/src/apps/conversation-inbox/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Conversation Inbox - Intercom MCP</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
59
servers/intercom/src/apps/conversation-inbox/main.tsx
Normal file
59
servers/intercom/src/apps/conversation-inbox/main.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './styles.css';
|
||||
|
||||
const App = lazy(() => import('./App'));
|
||||
|
||||
class ErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode },
|
||||
{ hasError: boolean; error?: Error }
|
||||
> {
|
||||
constructor(props: { children: React.ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('Conversation Inbox Error:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="error-container">
|
||||
<h1>Something went wrong</h1>
|
||||
<p>{this.state.error?.message}</p>
|
||||
<button onClick={() => window.location.reload()}>Reload</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const LoadingSkeleton = () => (
|
||||
<div className="loading-skeleton">
|
||||
<div className="skeleton-header shimmer"></div>
|
||||
<div className="skeleton-stats">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="skeleton-card shimmer"></div>
|
||||
))}
|
||||
</div>
|
||||
<div className="skeleton-content shimmer"></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<LoadingSkeleton />}>
|
||||
<App />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
444
servers/intercom/src/apps/conversation-inbox/styles.css
Normal file
444
servers/intercom/src/apps/conversation-inbox/styles.css
Normal file
@ -0,0 +1,444 @@
|
||||
:root {
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-tertiary: #334155;
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #cbd5e1;
|
||||
--text-muted: #94a3b8;
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
--success: #10b981;
|
||||
--error: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
--border: #475569;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: linear-gradient(135deg, var(--accent), var(--success));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.app-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.2);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.search-section {
|
||||
margin-bottom: 2rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 1rem 1.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 12px;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.search-pending {
|
||||
position: absolute;
|
||||
right: 1.5rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.data-grid {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
thead {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
border-top: 1px solid var(--border);
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
tbody tr.selected {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
tbody tr.unread {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.unread-dot {
|
||||
color: var(--accent);
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.status-open {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-closed {
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-snoozed {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.priority-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.priority-high {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.priority-normal {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.priority-low {
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.detail-panel {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.detail-panel h3 {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.reply-textarea {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
padding: 1rem;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
resize: vertical;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.reply-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.send-button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.send-button:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 2rem;
|
||||
right: 2rem;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
animation: slideIn 0.3s ease;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background: var(--error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-secondary) 0%,
|
||||
var(--bg-tertiary) 50%,
|
||||
var(--bg-secondary) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-skeleton {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-header {
|
||||
height: 60px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
height: 100px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.skeleton-content {
|
||||
height: 400px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-container h1 {
|
||||
color: var(--error);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-container button {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.error-container button:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.data-grid {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
left: 1rem;
|
||||
}
|
||||
}
|
||||
112
servers/intercom/src/apps/customer-360/App.tsx
Normal file
112
servers/intercom/src/apps/customer-360/App.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import React, { useState, useMemo, useTransition, useCallback, useEffect } from 'react';
|
||||
|
||||
const useDebounce = <T,>(value: T, delay: number): T => {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(handler);
|
||||
}, [value, delay]);
|
||||
return debouncedValue;
|
||||
};
|
||||
|
||||
interface Toast { id: number; message: string; type: 'success' | 'error' | 'info'; }
|
||||
const useToast = () => {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const showToast = useCallback((message: string, type: Toast['type'] = 'info') => {
|
||||
const id = Date.now();
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 3000);
|
||||
}, []);
|
||||
return { toasts, showToast };
|
||||
};
|
||||
|
||||
interface Customer { id: string; name: string; email: string; company: string; plan: string; ltv: number; conversations: number; events: number; lastActive: string; }
|
||||
|
||||
const mockCustomers: Customer[] = [
|
||||
{ id: '1', name: 'Alice Johnson', email: 'alice@acme.com', company: 'Acme Corp', plan: 'Enterprise', ltv: 25000, conversations: 45, events: 312, lastActive: '2024-01-15 14:32' },
|
||||
{ id: '2', name: 'Bob Smith', email: 'bob@techstart.io', company: 'TechStart Inc', plan: 'Growth', ltv: 12000, conversations: 28, events: 189, lastActive: '2024-01-15 10:15' },
|
||||
{ id: '3', name: 'Carol Davis', email: 'carol@global.com', company: 'Global Solutions', plan: 'Enterprise', ltv: 48000, conversations: 67, events: 521, lastActive: '2024-01-14 16:45' },
|
||||
];
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const { toasts, showToast } = useToast();
|
||||
const debouncedSearch = useDebounce(searchTerm, 300);
|
||||
|
||||
const stats = useMemo(() => ({
|
||||
total: mockCustomers.length,
|
||||
totalLTV: mockCustomers.reduce((sum, c) => sum + c.ltv, 0),
|
||||
totalConversations: mockCustomers.reduce((sum, c) => sum + c.conversations, 0),
|
||||
totalEvents: mockCustomers.reduce((sum, c) => sum + c.events, 0),
|
||||
}), []);
|
||||
|
||||
const filteredCustomers = useMemo(() => {
|
||||
if (!debouncedSearch) return mockCustomers;
|
||||
const term = debouncedSearch.toLowerCase();
|
||||
return mockCustomers.filter((c) => c.name.toLowerCase().includes(term) || c.email.toLowerCase().includes(term) || c.company.toLowerCase().includes(term));
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const handleSearch = (value: string) => { startTransition(() => { setSearchTerm(value); }); };
|
||||
const handleCustomerClick = (customer: Customer) => { setSelectedCustomer(customer); showToast(`Viewing customer ${customer.name}`, 'info'); };
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<header className="app-header">
|
||||
<h1>Customer 360</h1>
|
||||
<p>Complete customer view with contacts, events, and conversations</p>
|
||||
</header>
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card"><div className="stat-label">Total Customers</div><div className="stat-value">{stats.total}</div></div>
|
||||
<div className="stat-card"><div className="stat-label">Total LTV</div><div className="stat-value">${(stats.totalLTV/1000).toFixed(0)}K</div></div>
|
||||
<div className="stat-card"><div className="stat-label">Total Conversations</div><div className="stat-value">{stats.totalConversations}</div></div>
|
||||
<div className="stat-card"><div className="stat-label">Total Events</div><div className="stat-value">{stats.totalEvents}</div></div>
|
||||
</div>
|
||||
<div className="search-section">
|
||||
<input type="text" placeholder="Search customers..." value={searchTerm} onChange={(e) => handleSearch(e.target.value)} className="search-input" />
|
||||
{isPending && <div className="search-pending">Searching...</div>}
|
||||
</div>
|
||||
{filteredCustomers.length === 0 ? (
|
||||
<div className="empty-state"><div className="empty-icon">👤</div><h3>No customers found</h3><p>Try adjusting your search criteria</p></div>
|
||||
) : (
|
||||
<div className="data-grid">
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Email</th><th>Company</th><th>Plan</th><th>LTV</th><th>Conversations</th><th>Events</th><th>Last Active</th></tr></thead>
|
||||
<tbody>
|
||||
{filteredCustomers.map((customer) => (
|
||||
<tr key={customer.id} onClick={() => handleCustomerClick(customer)} className={selectedCustomer?.id === customer.id ? 'selected' : ''}>
|
||||
<td><strong>{customer.name}</strong></td>
|
||||
<td>{customer.email}</td>
|
||||
<td>{customer.company}</td>
|
||||
<td><span className="plan-badge plan-enterprise">{customer.plan}</span></td>
|
||||
<td>${customer.ltv.toLocaleString()}</td>
|
||||
<td>{customer.conversations}</td>
|
||||
<td>{customer.events}</td>
|
||||
<td>{customer.lastActive}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{selectedCustomer && (
|
||||
<div className="detail-panel">
|
||||
<h3>Customer 360 View: {selectedCustomer.name}</h3>
|
||||
<div className="detail-content">
|
||||
<p><strong>Email:</strong> {selectedCustomer.email}</p>
|
||||
<p><strong>Company:</strong> {selectedCustomer.company}</p>
|
||||
<p><strong>Plan:</strong> {selectedCustomer.plan}</p>
|
||||
<p><strong>Lifetime Value:</strong> ${selectedCustomer.ltv.toLocaleString()}</p>
|
||||
<p><strong>Total Conversations:</strong> {selectedCustomer.conversations}</p>
|
||||
<p><strong>Total Events:</strong> {selectedCustomer.events}</p>
|
||||
<p><strong>Last Active:</strong> {selectedCustomer.lastActive}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="toast-container">{toasts.map((toast) => (<div key={toast.id} className={`toast toast-${toast.type}`}>{toast.message}</div>))}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
13
servers/intercom/src/apps/customer-360/index.html
Normal file
13
servers/intercom/src/apps/customer-360/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Customer 360 - Intercom MCP</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
59
servers/intercom/src/apps/customer-360/main.tsx
Normal file
59
servers/intercom/src/apps/customer-360/main.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './styles.css';
|
||||
|
||||
const App = lazy(() => import('./App'));
|
||||
|
||||
class ErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode },
|
||||
{ hasError: boolean; error?: Error }
|
||||
> {
|
||||
constructor(props: { children: React.ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('Customer 360 Error:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="error-container">
|
||||
<h1>Something went wrong</h1>
|
||||
<p>{this.state.error?.message}</p>
|
||||
<button onClick={() => window.location.reload()}>Reload</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const LoadingSkeleton = () => (
|
||||
<div className="loading-skeleton">
|
||||
<div className="skeleton-header shimmer"></div>
|
||||
<div className="skeleton-stats">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="skeleton-card shimmer"></div>
|
||||
))}
|
||||
</div>
|
||||
<div className="skeleton-content shimmer"></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<LoadingSkeleton />}>
|
||||
<App />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
409
servers/intercom/src/apps/customer-360/styles.css
Normal file
409
servers/intercom/src/apps/customer-360/styles.css
Normal file
@ -0,0 +1,409 @@
|
||||
:root {
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-tertiary: #334155;
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #cbd5e1;
|
||||
--text-muted: #94a3b8;
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
--success: #10b981;
|
||||
--error: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
--border: #475569;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: linear-gradient(135deg, var(--accent), var(--success));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.app-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.2);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.search-section {
|
||||
margin-bottom: 2rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 1rem 1.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 12px;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.search-pending {
|
||||
position: absolute;
|
||||
right: 1.5rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Data Grid */
|
||||
.data-grid {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
thead {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
border-top: 1px solid var(--border);
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
tbody tr.selected {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-inactive {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.status-blocked {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Detail Panel */
|
||||
.detail-panel {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.detail-panel h3 {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.detail-content p {
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.detail-content strong {
|
||||
color: var(--text-primary);
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 2rem;
|
||||
right: 2rem;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
animation: slideIn 0.3s ease;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background: var(--error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shimmer Loading */
|
||||
.shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-secondary) 0%,
|
||||
var(--bg-tertiary) 50%,
|
||||
var(--bg-secondary) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-skeleton {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-header {
|
||||
height: 60px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
height: 100px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.skeleton-content {
|
||||
height: 400px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Error Container */
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-container h1 {
|
||||
color: var(--error);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-container button {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.error-container button:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.app-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.data-grid {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
left: 1rem;
|
||||
}
|
||||
}
|
||||
107
servers/intercom/src/apps/event-timeline/App.tsx
Normal file
107
servers/intercom/src/apps/event-timeline/App.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import React, { useState, useMemo, useTransition, useCallback, useEffect } from 'react';
|
||||
|
||||
const useDebounce = <T,>(value: T, delay: number): T => {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(handler);
|
||||
}, [value, delay]);
|
||||
return debouncedValue;
|
||||
};
|
||||
|
||||
interface Toast { id: number; message: string; type: 'success' | 'error' | 'info'; }
|
||||
const useToast = () => {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const showToast = useCallback((message: string, type: Toast['type'] = 'info') => {
|
||||
const id = Date.now();
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 3000);
|
||||
}, []);
|
||||
return { toasts, showToast };
|
||||
};
|
||||
|
||||
interface Event { id: string; eventType: string; customer: string; description: string; timestamp: string; metadata: Record<string,any>; }
|
||||
|
||||
const mockEvents: Event[] = [
|
||||
{ id: '1', eventType: 'page_view', customer: 'Alice Johnson', description: 'Viewed pricing page', timestamp: '2024-01-15 14:32:15', metadata: {page:'/pricing',duration:45} },
|
||||
{ id: '2', eventType: 'feature_used', customer: 'Bob Smith', description: 'Used export feature', timestamp: '2024-01-15 14:28:42', metadata: {feature:'export',format:'csv'} },
|
||||
{ id: '3', eventType: 'support_ticket', customer: 'Carol Davis', description: 'Created support ticket', timestamp: '2024-01-15 14:15:30', metadata: {priority:'high',category:'billing'} },
|
||||
{ id: '4', eventType: 'login', customer: 'Alice Johnson', description: 'Logged in from mobile', timestamp: '2024-01-15 14:05:12', metadata: {device:'mobile',location:'US'} },
|
||||
];
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const { toasts, showToast } = useToast();
|
||||
const debouncedSearch = useDebounce(searchTerm, 300);
|
||||
|
||||
const stats = useMemo(() => ({
|
||||
total: mockEvents.length,
|
||||
uniqueCustomers: new Set(mockEvents.map(e => e.customer)).size,
|
||||
eventTypes: new Set(mockEvents.map(e => e.eventType)).size,
|
||||
lastHour: mockEvents.filter(e => new Date(e.timestamp).getTime() > Date.now() - 3600000).length,
|
||||
}), []);
|
||||
|
||||
const filteredEvents = useMemo(() => {
|
||||
if (!debouncedSearch) return mockEvents;
|
||||
const term = debouncedSearch.toLowerCase();
|
||||
return mockEvents.filter((e) => e.eventType.toLowerCase().includes(term) || e.customer.toLowerCase().includes(term) || e.description.toLowerCase().includes(term));
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const handleSearch = (value: string) => { startTransition(() => { setSearchTerm(value); }); };
|
||||
const handleEventClick = (event: Event) => { setSelectedEvent(event); showToast(`Viewing event "${event.eventType}"`, 'info'); };
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<header className="app-header">
|
||||
<h1>Event Timeline</h1>
|
||||
<p>Track customer events chronologically</p>
|
||||
</header>
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card"><div className="stat-label">Total Events</div><div className="stat-value">{stats.total}</div></div>
|
||||
<div className="stat-card"><div className="stat-label">Unique Customers</div><div className="stat-value">{stats.uniqueCustomers}</div></div>
|
||||
<div className="stat-card"><div className="stat-label">Event Types</div><div className="stat-value">{stats.eventTypes}</div></div>
|
||||
<div className="stat-card"><div className="stat-label">Last Hour</div><div className="stat-value">{stats.lastHour}</div></div>
|
||||
</div>
|
||||
<div className="search-section">
|
||||
<input type="text" placeholder="Search events..." value={searchTerm} onChange={(e) => handleSearch(e.target.value)} className="search-input" />
|
||||
{isPending && <div className="search-pending">Searching...</div>}
|
||||
</div>
|
||||
{filteredEvents.length === 0 ? (
|
||||
<div className="empty-state"><div className="empty-icon">📅</div><h3>No events found</h3><p>Try adjusting your search criteria</p></div>
|
||||
) : (
|
||||
<div className="data-grid">
|
||||
<table>
|
||||
<thead><tr><th>Timestamp</th><th>Event Type</th><th>Customer</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
{filteredEvents.map((event) => (
|
||||
<tr key={event.id} onClick={() => handleEventClick(event)} className={selectedEvent?.id === event.id ? 'selected' : ''}>
|
||||
<td>{event.timestamp}</td>
|
||||
<td><span className="status-badge status-active">{event.eventType}</span></td>
|
||||
<td>{event.customer}</td>
|
||||
<td>{event.description}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{selectedEvent && (
|
||||
<div className="detail-panel">
|
||||
<h3>Event Details</h3>
|
||||
<div className="detail-content">
|
||||
<p><strong>Event Type:</strong> {selectedEvent.eventType}</p>
|
||||
<p><strong>Customer:</strong> {selectedEvent.customer}</p>
|
||||
<p><strong>Description:</strong> {selectedEvent.description}</p>
|
||||
<p><strong>Timestamp:</strong> {selectedEvent.timestamp}</p>
|
||||
<p><strong>Metadata:</strong> <code>{JSON.stringify(selectedEvent.metadata,null,2)}</code></p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="toast-container">{toasts.map((toast) => (<div key={toast.id} className={`toast toast-${toast.type}`}>{toast.message}</div>))}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
13
servers/intercom/src/apps/event-timeline/index.html
Normal file
13
servers/intercom/src/apps/event-timeline/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Event Timeline - Intercom MCP</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
59
servers/intercom/src/apps/event-timeline/main.tsx
Normal file
59
servers/intercom/src/apps/event-timeline/main.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './styles.css';
|
||||
|
||||
const App = lazy(() => import('./App'));
|
||||
|
||||
class ErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode },
|
||||
{ hasError: boolean; error?: Error }
|
||||
> {
|
||||
constructor(props: { children: React.ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('Event Timeline Error:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="error-container">
|
||||
<h1>Something went wrong</h1>
|
||||
<p>{this.state.error?.message}</p>
|
||||
<button onClick={() => window.location.reload()}>Reload</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const LoadingSkeleton = () => (
|
||||
<div className="loading-skeleton">
|
||||
<div className="skeleton-header shimmer"></div>
|
||||
<div className="skeleton-stats">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="skeleton-card shimmer"></div>
|
||||
))}
|
||||
</div>
|
||||
<div className="skeleton-content shimmer"></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<LoadingSkeleton />}>
|
||||
<App />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
409
servers/intercom/src/apps/event-timeline/styles.css
Normal file
409
servers/intercom/src/apps/event-timeline/styles.css
Normal file
@ -0,0 +1,409 @@
|
||||
:root {
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-tertiary: #334155;
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #cbd5e1;
|
||||
--text-muted: #94a3b8;
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
--success: #10b981;
|
||||
--error: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
--border: #475569;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: linear-gradient(135deg, var(--accent), var(--success));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.app-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.2);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.search-section {
|
||||
margin-bottom: 2rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 1rem 1.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 12px;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.search-pending {
|
||||
position: absolute;
|
||||
right: 1.5rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Data Grid */
|
||||
.data-grid {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
thead {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
border-top: 1px solid var(--border);
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
tbody tr.selected {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-inactive {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.status-blocked {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Detail Panel */
|
||||
.detail-panel {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.detail-panel h3 {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.detail-content p {
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.detail-content strong {
|
||||
color: var(--text-primary);
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 2rem;
|
||||
right: 2rem;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
animation: slideIn 0.3s ease;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background: var(--error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shimmer Loading */
|
||||
.shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-secondary) 0%,
|
||||
var(--bg-tertiary) 50%,
|
||||
var(--bg-secondary) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-skeleton {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-header {
|
||||
height: 60px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
height: 100px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.skeleton-content {
|
||||
height: 400px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Error Container */
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-container h1 {
|
||||
color: var(--error);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-container button {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.error-container button:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.app-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.data-grid {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
left: 1rem;
|
||||
}
|
||||
}
|
||||
110
servers/intercom/src/apps/message-composer/App.tsx
Normal file
110
servers/intercom/src/apps/message-composer/App.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
import React, { useState, useMemo, useTransition, useCallback, useEffect } from 'react';
|
||||
|
||||
const useDebounce = <T,>(value: T, delay: number): T => {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(handler);
|
||||
}, [value, delay]);
|
||||
return debouncedValue;
|
||||
};
|
||||
|
||||
interface Toast { id: number; message: string; type: 'success' | 'error' | 'info'; }
|
||||
const useToast = () => {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const showToast = useCallback((message: string, type: Toast['type'] = 'info') => {
|
||||
const id = Date.now();
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 3000);
|
||||
}, []);
|
||||
return { toasts, showToast };
|
||||
};
|
||||
|
||||
interface Message { id: string; subject: string; channel: 'in-app' | 'email' | 'push'; audience: string; status: 'draft' | 'scheduled' | 'sent'; sentCount?: number; createdAt: string; }
|
||||
|
||||
const mockMessages: Message[] = [
|
||||
{ id: '1', subject: 'Welcome to our platform!', channel: 'email', audience: 'New Users', status: 'sent', sentCount: 245, createdAt: '2024-01-15' },
|
||||
{ id: '2', subject: 'New feature announcement', channel: 'in-app', audience: 'All Active Users', status: 'scheduled', createdAt: '2024-01-14' },
|
||||
{ id: '3', subject: 'Special offer inside', channel: 'push', audience: 'Trial Users', status: 'draft', createdAt: '2024-01-13' },
|
||||
];
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedMessage, setSelectedMessage] = useState<Message | null>(null);
|
||||
const [messageContent, setMessageContent] = useState('');
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const { toasts, showToast } = useToast();
|
||||
const debouncedSearch = useDebounce(searchTerm, 300);
|
||||
|
||||
const stats = useMemo(() => ({
|
||||
total: mockMessages.length,
|
||||
sent: mockMessages.filter((m) => m.status === 'sent').length,
|
||||
scheduled: mockMessages.filter((m) => m.status === 'scheduled').length,
|
||||
totalSent: mockMessages.reduce((sum, m) => sum + (m.sentCount || 0), 0),
|
||||
}), []);
|
||||
|
||||
const filteredMessages = useMemo(() => {
|
||||
if (!debouncedSearch) return mockMessages;
|
||||
const term = debouncedSearch.toLowerCase();
|
||||
return mockMessages.filter((m) => m.subject.toLowerCase().includes(term) || m.audience.toLowerCase().includes(term));
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const handleSearch = (value: string) => { startTransition(() => { setSearchTerm(value); }); };
|
||||
const handleMessageClick = (message: Message) => { setSelectedMessage(message); setMessageContent(''); showToast(`Viewing "${message.subject}"`, 'info'); };
|
||||
const handleSendMessage = () => { showToast('Message sent successfully!', 'success'); setMessageContent(''); };
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<header className="app-header">
|
||||
<h1>Message Composer</h1>
|
||||
<p>Send in-app, email, and push messages</p>
|
||||
</header>
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card"><div className="stat-label">Total Messages</div><div className="stat-value">{stats.total}</div></div>
|
||||
<div className="stat-card"><div className="stat-label">Sent</div><div className="stat-value">{stats.sent}</div></div>
|
||||
<div className="stat-card"><div className="stat-label">Scheduled</div><div className="stat-value">{stats.scheduled}</div></div>
|
||||
<div className="stat-card"><div className="stat-label">Total Recipients</div><div className="stat-value">{stats.totalSent.toLocaleString()}</div></div>
|
||||
</div>
|
||||
<div className="search-section">
|
||||
<input type="text" placeholder="Search messages..." value={searchTerm} onChange={(e) => handleSearch(e.target.value)} className="search-input" />
|
||||
{isPending && <div className="search-pending">Searching...</div>}
|
||||
</div>
|
||||
{filteredMessages.length === 0 ? (
|
||||
<div className="empty-state"><div className="empty-icon">✉️</div><h3>No messages found</h3><p>Try adjusting your search criteria</p></div>
|
||||
) : (
|
||||
<div className="data-grid">
|
||||
<table>
|
||||
<thead><tr><th>Subject</th><th>Channel</th><th>Audience</th><th>Status</th><th>Sent Count</th><th>Created</th></tr></thead>
|
||||
<tbody>
|
||||
{filteredMessages.map((message) => (
|
||||
<tr key={message.id} onClick={() => handleMessageClick(message)} className={selectedMessage?.id === message.id ? 'selected' : ''}>
|
||||
<td>{message.subject}</td>
|
||||
<td><span className={`status-badge status-${message.channel}`}>{message.channel}</span></td>
|
||||
<td>{message.audience}</td>
|
||||
<td><span className={`status-badge status-${message.status}`}>{message.status}</span></td>
|
||||
<td>{message.sentCount ? message.sentCount.toLocaleString() : '-'}</td>
|
||||
<td>{message.createdAt}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{selectedMessage && (
|
||||
<div className="detail-panel">
|
||||
<h3>Compose New Message</h3>
|
||||
<div className="detail-content">
|
||||
<p><strong>Subject:</strong> {selectedMessage.subject}</p>
|
||||
<p><strong>Channel:</strong> {selectedMessage.channel}</p>
|
||||
<p><strong>Audience:</strong> {selectedMessage.audience}</p>
|
||||
<textarea value={messageContent} onChange={(e) => setMessageContent(e.target.value)} placeholder="Enter your message content..." className="reply-textarea" style={{minHeight:'150px',marginTop:'1rem'}} />
|
||||
<button onClick={handleSendMessage} className="send-button">Send Message</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="toast-container">{toasts.map((toast) => (<div key={toast.id} className={`toast toast-${toast.type}`}>{toast.message}</div>))}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
13
servers/intercom/src/apps/message-composer/index.html
Normal file
13
servers/intercom/src/apps/message-composer/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Message Composer - Intercom MCP</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
59
servers/intercom/src/apps/message-composer/main.tsx
Normal file
59
servers/intercom/src/apps/message-composer/main.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './styles.css';
|
||||
|
||||
const App = lazy(() => import('./App'));
|
||||
|
||||
class ErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode },
|
||||
{ hasError: boolean; error?: Error }
|
||||
> {
|
||||
constructor(props: { children: React.ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('Message Composer Error:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="error-container">
|
||||
<h1>Something went wrong</h1>
|
||||
<p>{this.state.error?.message}</p>
|
||||
<button onClick={() => window.location.reload()}>Reload</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const LoadingSkeleton = () => (
|
||||
<div className="loading-skeleton">
|
||||
<div className="skeleton-header shimmer"></div>
|
||||
<div className="skeleton-stats">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="skeleton-card shimmer"></div>
|
||||
))}
|
||||
</div>
|
||||
<div className="skeleton-content shimmer"></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<LoadingSkeleton />}>
|
||||
<App />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
409
servers/intercom/src/apps/message-composer/styles.css
Normal file
409
servers/intercom/src/apps/message-composer/styles.css
Normal file
@ -0,0 +1,409 @@
|
||||
:root {
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-tertiary: #334155;
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #cbd5e1;
|
||||
--text-muted: #94a3b8;
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
--success: #10b981;
|
||||
--error: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
--border: #475569;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: linear-gradient(135deg, var(--accent), var(--success));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.app-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.2);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.search-section {
|
||||
margin-bottom: 2rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 1rem 1.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 12px;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.search-pending {
|
||||
position: absolute;
|
||||
right: 1.5rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Data Grid */
|
||||
.data-grid {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
thead {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
border-top: 1px solid var(--border);
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
tbody tr.selected {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-inactive {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.status-blocked {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Detail Panel */
|
||||
.detail-panel {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.detail-panel h3 {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.detail-content p {
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.detail-content strong {
|
||||
color: var(--text-primary);
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 2rem;
|
||||
right: 2rem;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
animation: slideIn 0.3s ease;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background: var(--error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shimmer Loading */
|
||||
.shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-secondary) 0%,
|
||||
var(--bg-tertiary) 50%,
|
||||
var(--bg-secondary) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-skeleton {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-header {
|
||||
height: 60px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
height: 100px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.skeleton-content {
|
||||
height: 400px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Error Container */
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-container h1 {
|
||||
color: var(--error);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-container button {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.error-container button:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.app-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.data-grid {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
left: 1rem;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user