The prior null-safe esc patch only matched 'const esc =' declarations; Menu/Navbar/Logo use 'function esc(str: string)' syntax and slipped through. Patched those three to coerce non-strings the same way. Added "Clear chat" button in the modal header that appears when there's any message history. Confirms with the user before posting to the new clear_history endpoint, which deletes all messages + the thread row for the current site (usage rows are preserved for billing).
420 lines
15 KiB
TypeScript
420 lines
15 KiB
TypeScript
import React, { CSSProperties, useCallback, useRef, useState } from 'react';
|
|
import { useNode, UserComponent } from '@craftjs/core';
|
|
import { cssPropsToString } from '../../utils/style-helpers';
|
|
import { useSiteDesign } from '../../state/SiteDesignContext';
|
|
|
|
/* ---------- Types ---------- */
|
|
|
|
interface LogoProps {
|
|
type?: 'text' | 'image';
|
|
text?: string;
|
|
imageSrc?: string;
|
|
imageWidth?: string;
|
|
href?: string;
|
|
fontFamily?: string;
|
|
fontSize?: string;
|
|
fontWeight?: string;
|
|
color?: string;
|
|
style?: CSSProperties;
|
|
}
|
|
|
|
/* ---------- Image upload helper ---------- */
|
|
|
|
async function uploadToWhp(file: File): Promise<string | null> {
|
|
const cfg = (window as any).WHP_CONFIG;
|
|
if (!cfg) return URL.createObjectURL(file);
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
try {
|
|
const resp = await fetch(`${cfg.apiUrl}?action=upload_asset&site_id=${cfg.siteId}`, {
|
|
method: 'POST',
|
|
headers: { 'X-CSRF-Token': cfg.csrfToken },
|
|
body: formData,
|
|
});
|
|
const data = await resp.json();
|
|
if (data.success && data.url) return data.url;
|
|
return null;
|
|
} catch { return null; }
|
|
}
|
|
|
|
/* ---------- Helper: escape HTML ---------- */
|
|
function esc(str: any): string {
|
|
str = String(str ?? "");
|
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
/* ---------- Component ---------- */
|
|
|
|
export const Logo: UserComponent<LogoProps> = ({
|
|
type = 'text',
|
|
text = 'MySite',
|
|
imageSrc = '',
|
|
imageWidth = '120px',
|
|
href = '/',
|
|
fontFamily = 'Inter, sans-serif',
|
|
fontSize = '20px',
|
|
fontWeight = '700',
|
|
color,
|
|
style = {},
|
|
}) => {
|
|
const {
|
|
connectors: { connect, drag },
|
|
} = useNode();
|
|
|
|
const { design } = useSiteDesign();
|
|
const resolvedColor = color || design.textColor;
|
|
|
|
return (
|
|
<a
|
|
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
|
href={href}
|
|
onClick={(e) => e.preventDefault()}
|
|
style={{
|
|
textDecoration: 'none',
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
flexShrink: 0,
|
|
...style,
|
|
}}
|
|
>
|
|
{type === 'image' && imageSrc ? (
|
|
<img
|
|
src={imageSrc}
|
|
alt={text || 'Logo'}
|
|
style={{ width: imageWidth, height: 'auto', display: 'block' }}
|
|
/>
|
|
) : (
|
|
<span style={{
|
|
fontWeight,
|
|
fontSize,
|
|
fontFamily,
|
|
color: resolvedColor,
|
|
}}>
|
|
{text}
|
|
</span>
|
|
)}
|
|
</a>
|
|
);
|
|
};
|
|
|
|
/* ---------- Settings panel ---------- */
|
|
|
|
const LogoSettings: React.FC = () => {
|
|
const { actions: { setProp }, props } = useNode((node) => ({
|
|
props: node.data.props as LogoProps,
|
|
}));
|
|
|
|
const { design } = useSiteDesign();
|
|
const logoType = props.type || 'text';
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const [showBrowser, setShowBrowser] = useState(false);
|
|
const [browserAssets, setBrowserAssets] = useState<any[]>([]);
|
|
const [browserLoading, setBrowserLoading] = useState(false);
|
|
|
|
const fontFamilies = [
|
|
{ label: 'Inter', value: 'Inter, sans-serif' },
|
|
{ label: 'Roboto', value: 'Roboto, sans-serif' },
|
|
{ label: 'Poppins', value: 'Poppins, sans-serif' },
|
|
{ label: 'Montserrat', value: 'Montserrat, sans-serif' },
|
|
{ label: 'Playfair', value: 'Playfair Display, serif' },
|
|
{ label: 'Merriweather', value: 'Merriweather, serif' },
|
|
{ label: 'Source Code', value: 'Source Code Pro, monospace' },
|
|
{ label: 'Open Sans', value: 'Open Sans, sans-serif' },
|
|
];
|
|
|
|
const handleLogoUpload = useCallback(async (file: File) => {
|
|
const url = await uploadToWhp(file);
|
|
if (url) setProp((p: LogoProps) => { p.imageSrc = url; });
|
|
}, [setProp]);
|
|
|
|
const handleBrowse = useCallback(async () => {
|
|
if (showBrowser) { setShowBrowser(false); return; }
|
|
const cfg = (window as any).WHP_CONFIG;
|
|
if (!cfg) return;
|
|
setBrowserLoading(true);
|
|
try {
|
|
const resp = await fetch(`${cfg.apiUrl}?action=list_assets&site_id=${cfg.siteId}`);
|
|
const data = await resp.json();
|
|
if (data.success && Array.isArray(data.assets)) {
|
|
const images = data.assets.filter((a: any) => (a.type || '').startsWith('image'));
|
|
setBrowserAssets(images);
|
|
setShowBrowser(true);
|
|
}
|
|
} catch (e) {
|
|
console.error('Browse failed:', e);
|
|
} finally {
|
|
setBrowserLoading(false);
|
|
}
|
|
}, [showBrowser]);
|
|
|
|
/* ---- Shared styles ---- */
|
|
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 };
|
|
const inputStyle: CSSProperties = {
|
|
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
|
|
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
|
};
|
|
const btnSmall: CSSProperties = {
|
|
padding: '2px 6px', fontSize: 11, background: '#27272a', color: '#a1a1aa',
|
|
border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer',
|
|
};
|
|
const btnActive: CSSProperties = {
|
|
...btnSmall, background: '#3b82f6', color: '#fff', borderColor: '#3b82f6',
|
|
};
|
|
|
|
return (
|
|
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
|
{/* Type toggle */}
|
|
<div>
|
|
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Logo Type</label>
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
<button
|
|
onClick={() => setProp((p: LogoProps) => { p.type = 'text'; })}
|
|
style={logoType === 'text' ? btnActive : btnSmall}
|
|
>
|
|
<i className="fa fa-font" style={{ marginRight: 3 }} />Text
|
|
</button>
|
|
<button
|
|
onClick={() => setProp((p: LogoProps) => { p.type = 'image'; })}
|
|
style={logoType === 'image' ? btnActive : btnSmall}
|
|
>
|
|
<i className="fa fa-image" style={{ marginRight: 3 }} />Image
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{logoType === 'text' ? (
|
|
<>
|
|
<div>
|
|
<label style={labelStyle}>Logo Text</label>
|
|
<input
|
|
type="text"
|
|
value={props.text || ''}
|
|
onChange={(e) => setProp((p: LogoProps) => { p.text = e.target.value; })}
|
|
style={inputStyle}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label style={labelStyle}>Font Family</label>
|
|
<select
|
|
value={props.fontFamily || 'Inter, sans-serif'}
|
|
onChange={(e) => setProp((p: LogoProps) => { p.fontFamily = e.target.value; })}
|
|
style={{ ...inputStyle, cursor: 'pointer' }}
|
|
>
|
|
{fontFamilies.map((f) => (
|
|
<option key={f.value} value={f.value}>{f.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 6 }}>
|
|
<div style={{ flex: 1 }}>
|
|
<label style={labelStyle}>Size</label>
|
|
<input
|
|
type="text"
|
|
value={props.fontSize || '20px'}
|
|
onChange={(e) => setProp((p: LogoProps) => { p.fontSize = e.target.value; })}
|
|
placeholder="20px"
|
|
style={inputStyle}
|
|
/>
|
|
</div>
|
|
<div style={{ flex: 1 }}>
|
|
<label style={labelStyle}>Weight</label>
|
|
<select
|
|
value={props.fontWeight || '700'}
|
|
onChange={(e) => setProp((p: LogoProps) => { p.fontWeight = e.target.value; })}
|
|
style={{ ...inputStyle, cursor: 'pointer' }}
|
|
>
|
|
<option value="300">Light</option>
|
|
<option value="400">Normal</option>
|
|
<option value="500">Medium</option>
|
|
<option value="600">Semi</option>
|
|
<option value="700">Bold</option>
|
|
<option value="800">Extra Bold</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label style={labelStyle}>Color</label>
|
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
|
<input
|
|
type="color"
|
|
value={props.color || design.textColor}
|
|
onChange={(e) => setProp((p: LogoProps) => { p.color = e.target.value; })}
|
|
style={{ width: 28, height: 24, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
|
|
/>
|
|
<span style={{ fontSize: 10, color: '#71717a' }}>{props.color || 'Auto'}</span>
|
|
<button
|
|
onClick={() => setProp((p: LogoProps) => { p.color = undefined; })}
|
|
style={{ ...btnSmall, fontSize: 9, padding: '2px 4px' }}
|
|
title="Reset to auto"
|
|
>Auto</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
{/* Image logo controls */}
|
|
{props.imageSrc ? (
|
|
<div style={{ borderRadius: 6, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
|
|
<img src={props.imageSrc} alt="" style={{ width: '100%', height: 'auto', display: 'block', maxHeight: 80, objectFit: 'contain', background: '#18181b' }} />
|
|
<button
|
|
onClick={() => setProp((p: LogoProps) => { p.imageSrc = ''; })}
|
|
style={{ position: 'absolute', top: 4, right: 4, width: 20, height: 20, borderRadius: '50%', background: 'rgba(0,0,0,0.7)', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
|
title="Remove image"
|
|
>
|
|
<i className="fa fa-times" />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div
|
|
style={{ padding: '14px 12px', border: '2px dashed #3f3f46', borderRadius: 6, textAlign: 'center', color: '#71717a', fontSize: 11, cursor: 'pointer' }}
|
|
onClick={() => fileInputRef.current?.click()}
|
|
onDragOver={(e) => { e.preventDefault(); e.currentTarget.style.borderColor = '#3b82f6'; }}
|
|
onDragLeave={(e) => { e.currentTarget.style.borderColor = '#3f3f46'; }}
|
|
onDrop={async (e) => {
|
|
e.preventDefault();
|
|
e.currentTarget.style.borderColor = '#3f3f46';
|
|
const file = e.dataTransfer.files?.[0];
|
|
if (file && file.type.startsWith('image/')) await handleLogoUpload(file);
|
|
}}
|
|
>
|
|
<i className="fa fa-cloud-upload" style={{ fontSize: 18, display: 'block', marginBottom: 4, color: '#3b82f6' }} />
|
|
Drop logo or click to upload
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
<button
|
|
onClick={() => fileInputRef.current?.click()}
|
|
style={{ flex: 1, padding: '6px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: '#3b82f6', color: '#fff', fontWeight: 500 }}
|
|
>
|
|
<i className="fa fa-upload" style={{ marginRight: 3 }} /> Upload
|
|
</button>
|
|
<button
|
|
onClick={handleBrowse}
|
|
style={{ flex: 1, padding: '6px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: showBrowser ? '#3b82f6' : '#27272a', color: showBrowser ? '#fff' : '#e4e4e7' }}
|
|
>
|
|
<i className={`fa ${browserLoading ? 'fa-spinner fa-spin' : 'fa-folder-open'}`} style={{ marginRight: 3 }} /> Browse
|
|
</button>
|
|
</div>
|
|
|
|
{/* Browse grid */}
|
|
{showBrowser && (
|
|
<div style={{ maxHeight: 150, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, background: '#18181b', borderRadius: 6, padding: 4 }}>
|
|
{browserAssets.map(asset => (
|
|
<div
|
|
key={asset.name}
|
|
onClick={() => { setProp((p: LogoProps) => { p.imageSrc = asset.url; }); setShowBrowser(false); }}
|
|
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1' }}
|
|
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#3b82f6'; }}
|
|
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}
|
|
>
|
|
<img src={asset.url} alt={asset.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
|
</div>
|
|
))}
|
|
{browserAssets.length === 0 && (
|
|
<p style={{ gridColumn: '1 / -1', textAlign: 'center', color: '#71717a', fontSize: 11, padding: '8px 0', margin: 0 }}>No images uploaded yet.</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }}
|
|
onChange={(e) => { const file = e.target.files?.[0]; if (file) handleLogoUpload(file); e.target.value = ''; }} />
|
|
|
|
{/* URL paste input */}
|
|
<div>
|
|
<input
|
|
type="text"
|
|
value={props.imageSrc || ''}
|
|
onChange={(e) => setProp((p: LogoProps) => { p.imageSrc = e.target.value; })}
|
|
placeholder="Or paste image URL..."
|
|
style={{ ...inputStyle, fontSize: 10, color: '#71717a' }}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label style={labelStyle}>Logo Width</label>
|
|
<input
|
|
type="text"
|
|
value={props.imageWidth || '120px'}
|
|
onChange={(e) => setProp((p: LogoProps) => { p.imageWidth = e.target.value; })}
|
|
placeholder="120px"
|
|
style={inputStyle}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Link URL */}
|
|
<div>
|
|
<label style={labelStyle}>Link URL</label>
|
|
<input
|
|
type="text"
|
|
value={props.href || '/'}
|
|
onChange={(e) => setProp((p: LogoProps) => { p.href = e.target.value; })}
|
|
placeholder="/"
|
|
style={inputStyle}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/* ---------- Craft config ---------- */
|
|
|
|
Logo.craft = {
|
|
displayName: 'Logo',
|
|
props: {
|
|
type: 'text',
|
|
text: 'MySite',
|
|
imageSrc: '',
|
|
imageWidth: '120px',
|
|
href: '/',
|
|
fontFamily: 'Inter, sans-serif',
|
|
fontSize: '20px',
|
|
fontWeight: '700',
|
|
color: undefined,
|
|
style: {},
|
|
} as LogoProps,
|
|
rules: {
|
|
canDrag: () => true,
|
|
canMoveIn: () => false,
|
|
canMoveOut: () => true,
|
|
},
|
|
related: {
|
|
settings: LogoSettings,
|
|
},
|
|
};
|
|
|
|
/* ---------- HTML export ---------- */
|
|
|
|
(Logo as any).toHtml = (props: LogoProps, _childrenHtml: string) => {
|
|
const href = props.href || '/';
|
|
|
|
let innerHtml: string;
|
|
if (props.type === 'image' && props.imageSrc) {
|
|
const imgStyle = cssPropsToString({ width: props.imageWidth || '120px', height: 'auto', display: 'block' });
|
|
innerHtml = `<img src="${esc(props.imageSrc)}" alt="${esc(props.text || 'Logo')}"${imgStyle ? ` style="${imgStyle}"` : ''} />`;
|
|
} else {
|
|
const spanStyle = cssPropsToString({
|
|
fontWeight: props.fontWeight || '700',
|
|
fontSize: props.fontSize || '20px',
|
|
fontFamily: props.fontFamily || 'Inter, sans-serif',
|
|
color: props.color || '#1f2937',
|
|
});
|
|
innerHtml = `<span${spanStyle ? ` style="${spanStyle}"` : ''}>${esc(props.text || 'MySite')}</span>`;
|
|
}
|
|
|
|
const aStyle = cssPropsToString({
|
|
textDecoration: 'none',
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
flexShrink: '0',
|
|
...props.style,
|
|
});
|
|
|
|
return {
|
|
html: `<a href="${esc(href)}"${aStyle ? ` style="${aStyle}"` : ''}>${innerHtml}</a>`,
|
|
};
|
|
};
|