Sitesmith: AI site builder addon (frontend) #1
25
craft/src/panels/sitesmith/ChatInput.tsx
Normal file
25
craft/src/panels/sitesmith/ChatInput.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React, { useState, KeyboardEvent } from 'react';
|
||||
|
||||
interface Props { disabled?: boolean; placeholder?: string; onSend: (text: string) => void; }
|
||||
|
||||
export const ChatInput: React.FC<Props> = ({ disabled, placeholder, onSend }) => {
|
||||
const [v, setV] = useState('');
|
||||
const fire = () => { const t = v.trim(); if (!t || disabled) return; onSend(t); setV(''); };
|
||||
const onKey = (e: KeyboardEvent<HTMLTextAreaElement>) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); fire(); } };
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 8, padding: '8px 0' }}>
|
||||
<textarea value={v} onChange={(e) => setV(e.target.value)} onKeyDown={onKey} rows={2} disabled={disabled}
|
||||
placeholder={placeholder || 'Describe what you want...'}
|
||||
style={{
|
||||
flex: 1, background: disabled ? '#1f1f24' : '#0f0f17', color: '#e5e7eb',
|
||||
border: '1px solid #3f3f46', borderRadius: 6, padding: 10, fontSize: 14, resize: 'none',
|
||||
}} />
|
||||
<button onClick={fire} disabled={disabled || v.trim() === ''}
|
||||
style={{
|
||||
background: disabled ? '#27272a' : '#7c3aed', color: '#fff',
|
||||
border: 'none', padding: '0 16px', borderRadius: 6,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer', fontWeight: 500,
|
||||
}}>→</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
32
craft/src/panels/sitesmith/MessageList.tsx
Normal file
32
craft/src/panels/sitesmith/MessageList.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { SitesmithMessage } from '../../types/sitesmith';
|
||||
|
||||
export const MessageList: React.FC<{ messages: SitesmithMessage[] }> = ({ messages }) => {
|
||||
const extract = (m: SitesmithMessage): string => {
|
||||
if (m.role === 'user') return m.content;
|
||||
try { const obj = JSON.parse(m.content); if (obj.type === 'ask') return obj.question; if (obj.message) return obj.message; } catch {}
|
||||
return m.content;
|
||||
};
|
||||
return (
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 0', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{messages.length === 0 && (
|
||||
<div style={{ color: '#71717a', fontSize: 13, textAlign: 'center', padding: 30 }}>
|
||||
Describe the site you want and Sitesmith builds it. e.g. "A two-page site for a small bakery, friendly tone, photo of cupcakes in the hero."
|
||||
</div>
|
||||
)}
|
||||
{messages.map((m, i) => {
|
||||
const isUser = m.role === 'user';
|
||||
return (
|
||||
<div key={i} style={{
|
||||
alignSelf: isUser ? 'flex-end' : 'flex-start',
|
||||
maxWidth: '80%', padding: '10px 14px', borderRadius: 10,
|
||||
background: isUser ? '#312e81' : '#1f2937', color: '#f3f4f6',
|
||||
fontSize: 14, whiteSpace: 'pre-wrap',
|
||||
}}>
|
||||
{extract(m)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
94
craft/src/panels/sitesmith/SitesmithModal.tsx
Normal file
94
craft/src/panels/sitesmith/SitesmithModal.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { useEditorConfig } from '../../state/EditorConfigContext';
|
||||
import { useSitesmith } from '../../hooks/useSitesmith';
|
||||
import { useApplyAiResponse } from '../../utils/apply-ai-response';
|
||||
import { summarizeCanvas } from '../../utils/canvas-summary';
|
||||
import { UpgradeBanner } from './UpgradeBanner';
|
||||
import { ScopeConfirmDialog } from './ScopeConfirmDialog';
|
||||
import { MessageList } from './MessageList';
|
||||
import { ChatInput } from './ChatInput';
|
||||
import { SitesmithResponse } from '../../types/sitesmith';
|
||||
|
||||
interface Props { onClose: () => void; }
|
||||
|
||||
export const SitesmithModal: React.FC<Props> = ({ onClose }) => {
|
||||
const cfg = useEditorConfig();
|
||||
const siteId = cfg.whpConfig?.siteId ?? 0;
|
||||
const { query } = useEditor();
|
||||
const { summary, messages, send, loading } = useSitesmith(siteId);
|
||||
const apply = useApplyAiResponse();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [pendingReplace, setPendingReplace] = useState<SitesmithResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const canChat = summary && (summary.status === 'OK_BONUS' || summary.status === 'OK_MONTHLY');
|
||||
|
||||
const overlay: React.CSSProperties = { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.8)', zIndex: 9000, display: 'flex', alignItems: 'center', justifyContent: 'center' };
|
||||
const panel: React.CSSProperties = { background: '#0f0f17', border: '1px solid #27272a', borderRadius: 12, width: 'min(720px, 90vw)', maxHeight: '90vh', display: 'flex', flexDirection: 'column' };
|
||||
const header: React.CSSProperties = { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 18px', borderBottom: '1px solid #27272a' };
|
||||
const body: React.CSSProperties = { flex: 1, padding: '14px 18px', overflowY: 'auto', display: 'flex', flexDirection: 'column' };
|
||||
const footer: React.CSSProperties = { padding: '12px 18px', borderTop: '1px solid #27272a' };
|
||||
const closeBtn:React.CSSProperties = { background: 'transparent', color: '#a1a1aa', border: 'none', fontSize: 18, cursor: 'pointer' };
|
||||
const errBox: React.CSSProperties = { background: '#3b1d1d', border: '1px solid #7f1d1d', color: '#fecaca', padding: '8px 12px', borderRadius: 6, marginBottom: 10, fontSize: 13 };
|
||||
|
||||
const handleSend = async (text: string) => {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const canvas = summarizeCanvas(query.getSerializedNodes());
|
||||
const result = await send(text, canvas);
|
||||
if (!result.ok) { setError(result.message || 'Failed'); return; }
|
||||
if (result.response.type === 'replace' && result.response.scope === 'site') {
|
||||
setPendingReplace(result.response);
|
||||
return;
|
||||
}
|
||||
const applied = await apply(result.response);
|
||||
if (!applied.ok) setError(applied.message || 'Apply failed');
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const confirmReplace = async () => {
|
||||
if (!pendingReplace) return;
|
||||
const r = await apply(pendingReplace);
|
||||
setPendingReplace(null);
|
||||
if (!r.ok) setError(r.message || 'Apply failed');
|
||||
};
|
||||
|
||||
return (
|
||||
<div role="dialog" aria-modal="true" style={overlay}>
|
||||
<div style={panel}>
|
||||
<div style={header}>
|
||||
<div style={{ fontWeight: 600, color: '#fff' }}>✨ Sitesmith</div>
|
||||
{summary && summary.enabled && (
|
||||
<div style={{ fontSize: 12, color: '#a1a1aa' }}>
|
||||
{summary.monthly_used} / {summary.monthly_cap} this month
|
||||
{summary.bonus_credits > 0 && ` • +${summary.bonus_credits} bonus`}
|
||||
</div>
|
||||
)}
|
||||
<button onClick={onClose} aria-label="Close" style={closeBtn}>✕</button>
|
||||
</div>
|
||||
<div style={body}>
|
||||
<UpgradeBanner summary={summary} />
|
||||
{error && <div role="alert" style={errBox}>{error}</div>}
|
||||
{loading
|
||||
? <div style={{ color: '#71717a', textAlign: 'center', padding: 30 }}>Loading…</div>
|
||||
: <MessageList messages={messages} />}
|
||||
</div>
|
||||
<div style={footer}>
|
||||
<ChatInput
|
||||
disabled={!canChat || busy}
|
||||
placeholder={!canChat ? 'Upgrade your plan to use Sitesmith' : busy ? 'Thinking…' : 'Describe what you want…'}
|
||||
onSend={handleSend}
|
||||
/>
|
||||
</div>
|
||||
<ScopeConfirmDialog
|
||||
open={!!pendingReplace}
|
||||
pendingMessage={pendingReplace && 'message' in pendingReplace ? (pendingReplace as any).message : undefined}
|
||||
onConfirm={confirmReplace}
|
||||
onCancel={() => setPendingReplace(null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import { DeviceMode } from '../../types';
|
||||
import { TemplateModal } from './TemplateModal';
|
||||
import { HeadCodeModal } from './HeadCodeModal';
|
||||
import { SitesmithButton } from '../sitesmith/SitesmithButton';
|
||||
import { SitesmithModal } from '../sitesmith/SitesmithModal';
|
||||
|
||||
interface TopBarProps {
|
||||
device: DeviceMode;
|
||||
@@ -273,14 +274,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
|
||||
</div>
|
||||
<TemplateModal open={templateModalOpen} onClose={() => setTemplateModalOpen(false)} />
|
||||
<HeadCodeModal open={headCodeModalOpen} onClose={() => setHeadCodeModalOpen(false)} />
|
||||
{sitesmithOpen && (
|
||||
<div role="dialog" style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.6)', zIndex: 9000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ background: '#0f0f17', color: '#fff', padding: 32, borderRadius: 10 }}>
|
||||
<p>SitesmithModal will land in Task 20.</p>
|
||||
<button onClick={() => setSitesmithOpen(false)} style={{ background: '#7c3aed', color: '#fff', border: 'none', padding: '8px 14px', borderRadius: 6 }}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sitesmithOpen && <SitesmithModal onClose={() => setSitesmithOpen(false)} />}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user