Clears the 124 CODE_SMELL findings from the SonarQube scan (server, client, and bot). All changes are behaviour-preserving refactors — no route, protocol, schema, or config changes — verified against the full server (381) and client (43) test suites plus a clean client build. By rule: - S3776 (20, cognitive complexity): extract helpers/handlers so each function drops under the threshold — shard model upsert builders, page/wiki update, block validation, notification stream mapping (dispatch table), SSO mobile login, shard ingest deps, uo-link socket backfill/connect, the bot slash- command dispatchers + discord manager, and the Shard/UserDetail/HeroEditor/ CharacterStats React components. - S4624 (34, nested template literals): pull inner templates into locals / a withQs() helper; rewrite shardEvents.describe() as a formatter table. - S3358 (35, nested ternaries): lift to if/else vars, lookup maps, small components, or guarded JSX expressions. - S6479 (12, array-index React keys): key by stable content instead of index (two in-editor lists left as-is; index matches their by-index edit model). - S6353 (6): [0-9]/[^0-9] -> \d/\D. S125 (5): reword state-shape comments that parsed as code. S3800/S3782 (botScore): JSDoc-type PATH_WEIGHTS tuples. - S6481 (2): memoize Auth/Site context values (and SiteContext brand). - S4144: dedupe HeroEditor upload handler into useImageUpload(). - S1126 (2), S6035, S5869 (redundant A-Z under /i), S5843 (town-name regex -> prefix list): assorted one-liners. Co-Authored-By: Claude <noreply@anthropic.com>
192 lines
6.1 KiB
JavaScript
192 lines
6.1 KiB
JavaScript
import { Link } from 'react-router-dom'
|
|
import DOMPurify from 'dompurify'
|
|
|
|
const MOON_IMAGE = '/assets/img/hero-moon.png'
|
|
|
|
// Font family tokens a line may opt into; default is the page serif.
|
|
const FONT = { display: 'var(--display)', sans: 'var(--sans)' }
|
|
|
|
// fontSize may be a number (px, from the editor) or a CSS string (e.g. a clamp()
|
|
// used by the pre-populated default so the hero stays responsive until edited).
|
|
function sizeToCss(v) {
|
|
return typeof v === 'number' ? `${v}px` : v
|
|
}
|
|
|
|
function lineStyle(line) {
|
|
return {
|
|
display: 'block', // each line stacks (so a span line behaves like the others)
|
|
margin: line.marginTop != null ? `${line.marginTop}px 0 0` : '0',
|
|
fontFamily: FONT[line.font] || undefined,
|
|
fontSize: sizeToCss(line.fontSize),
|
|
color: line.color || 'inherit',
|
|
fontWeight: line.weight || undefined,
|
|
fontStyle: line.italic ? 'italic' : undefined,
|
|
letterSpacing: line.letterSpacing || undefined,
|
|
textTransform: line.transform || undefined,
|
|
lineHeight: line.lineHeight || undefined,
|
|
maxWidth: line.maxWidth ? `${line.maxWidth}px` : undefined,
|
|
marginLeft: line.maxWidth ? 'auto' : undefined,
|
|
marginRight: line.maxWidth ? 'auto' : undefined,
|
|
}
|
|
}
|
|
|
|
function TextBlock({ props }) {
|
|
const align = props.align || 'center'
|
|
return (
|
|
<div style={{ textAlign: align, textShadow: '0 2px 22px rgba(0,0,0,0.82)' }}>
|
|
{(props.lines || []).map((line) => {
|
|
const Tag = /^(h1|h2|h3|p|span|div)$/.test(line.tag) ? line.tag : 'p'
|
|
const key = `${line.tag}:${(line.text || '').slice(0, 40)}`
|
|
// A rich-text line (e.g. the homepage teaser) carries sanitized HTML;
|
|
// sanitize again on render as defense in depth. Others render as text.
|
|
if (line.html) {
|
|
return (
|
|
<Tag
|
|
key={key}
|
|
className="hero-rich"
|
|
style={lineStyle(line)}
|
|
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(line.text || '') }}
|
|
/>
|
|
)
|
|
}
|
|
return (
|
|
<Tag key={key} style={lineStyle(line)}>
|
|
{line.text}
|
|
</Tag>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Buttons({ props }) {
|
|
const justify = { left: 'flex-start', right: 'flex-end' }[props.align] || 'center'
|
|
return (
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: props.gap ?? 12, justifyContent: justify }}>
|
|
{(props.items || []).map((b) => (
|
|
<Link key={`${b.to || ''}:${b.label || ''}`} to={b.to || '#'} className={`btn ${b.variant === 'ghost' ? 'btn-ghost' : 'btn-primary'}`}>
|
|
{b.label}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Badge({ props }) {
|
|
return (
|
|
<span
|
|
className="sans"
|
|
style={{
|
|
display: 'inline-block',
|
|
padding: '6px 14px',
|
|
background: props.bgColor || 'rgba(11,22,48,0.6)',
|
|
color: props.textColor || '#c2d2e6',
|
|
borderRadius: props.borderRadius ?? 999,
|
|
fontSize: '0.74rem',
|
|
fontWeight: 700,
|
|
letterSpacing: '0.18em',
|
|
textTransform: 'uppercase',
|
|
}}
|
|
>
|
|
{props.text}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function HeroImage({ props }) {
|
|
if (!props.src) {
|
|
// Editor placeholder until an image is chosen (a srcless image never ships live).
|
|
return (
|
|
<div
|
|
className="sans"
|
|
style={{ width: 160, height: 100, display: 'grid', placeItems: 'center', border: '1px dashed var(--accent)', borderRadius: 8, color: 'var(--muted)', fontSize: '0.8rem', background: 'rgba(11,22,48,0.4)' }}
|
|
>
|
|
Upload an image
|
|
</div>
|
|
)
|
|
}
|
|
return (
|
|
<img
|
|
src={props.src}
|
|
alt={props.alt || ''}
|
|
style={{ width: `${props.width || 40}%`, height: 'auto', display: 'block', borderRadius: 8 }}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function content(element) {
|
|
switch (element.type) {
|
|
case 'text_block':
|
|
return <TextBlock props={element.props || {}} />
|
|
case 'buttons':
|
|
return <Buttons props={element.props || {}} />
|
|
case 'moon': {
|
|
const props = element.props || {}
|
|
const size = props.size || 96
|
|
const glow = props.glow ?? 0.45
|
|
// Image source is configurable; old layouts with no src fall back to the
|
|
// default hero moon so they render exactly as before. Size/glow unchanged.
|
|
return (
|
|
<img
|
|
src={props.src || MOON_IMAGE}
|
|
alt={props.alt || ''}
|
|
draggable={false}
|
|
style={{
|
|
width: size,
|
|
height: 'auto',
|
|
display: 'block',
|
|
filter: glow ? `drop-shadow(0 0 ${size * 0.45}px rgba(216,226,239,${glow}))` : undefined,
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
case 'badge':
|
|
return <Badge props={element.props || {}} />
|
|
case 'image':
|
|
return <HeroImage props={element.props || {}} />
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
// Absolute-positioned wrapper + type-specific content. In `editor` mode the inner
|
|
// content is made non-interactive (so clicks select/drag the wrapper) and the
|
|
// wrapper takes selection styling + an onPointerDown handler.
|
|
export default function HeroElement({
|
|
element,
|
|
wrapperStyle,
|
|
editor = false,
|
|
selected = false,
|
|
onPointerDown,
|
|
children,
|
|
}) {
|
|
const anchor = element.anchor || 'center'
|
|
const transform = { center: 'translate(-50%, -50%)', 'top-right': 'translateX(-100%)' }[anchor]
|
|
// text_block/buttons may set a box width (px); kept within the containing block
|
|
// (the hero section live, or the editor canvas) with small side gutters.
|
|
const boxWidth =
|
|
(element.type === 'text_block' || element.type === 'buttons') && element.props?.width
|
|
? `min(${element.props.width}px, calc(100% - 36px))`
|
|
: undefined
|
|
const cls = [editor ? 'hero-el-editable' : '', selected ? 'is-selected' : ''].filter(Boolean).join(' ')
|
|
return (
|
|
<div
|
|
className={cls || undefined}
|
|
onPointerDown={onPointerDown}
|
|
style={{
|
|
position: 'absolute',
|
|
left: `${element.x}%`,
|
|
top: `${element.y}%`,
|
|
zIndex: element.z || 0,
|
|
transform,
|
|
width: boxWidth,
|
|
cursor: editor ? 'move' : undefined,
|
|
...wrapperStyle,
|
|
}}
|
|
>
|
|
<div style={editor ? { pointerEvents: 'none' } : undefined}>{content(element)}</div>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|