Files
ewoooc/MOMO Pro/app/ui.jsx

270 lines
9.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// MOMO Pro - 共用 UI 元件
// ===== Badge =====
const Badge = ({ tone = 'secondary', children, dot = false, style }) => {
const tones = {
primary: { bg: 'var(--momo-primary-100)', text: 'var(--momo-primary-700)', dot: 'var(--momo-primary)' },
success: { bg: 'var(--momo-success-bg)', text: 'var(--momo-success-text)', dot: 'var(--momo-success)' },
danger: { bg: 'var(--momo-danger-bg)', text: 'var(--momo-danger-text)', dot: 'var(--momo-danger)' },
warning: { bg: 'var(--momo-warning-bg)', text: 'var(--momo-warning-text)', dot: 'var(--momo-warning)' },
info: { bg: 'var(--momo-info-bg)', text: 'var(--momo-info-text)', dot: 'var(--momo-info)' },
secondary: { bg: 'var(--momo-bg-muted)', text: 'var(--momo-text-secondary)', dot: 'var(--momo-text-tertiary)' },
};
const t = tones[tone] || tones.secondary;
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '3px 10px',
background: t.bg, color: t.text,
fontSize: 'var(--momo-font-size-xs)',
fontWeight: 'var(--momo-font-weight-medium)',
borderRadius: 'var(--momo-radius-pill)',
lineHeight: 1.5,
whiteSpace: 'nowrap',
...style,
}}>
{dot && <span style={{ width: 6, height: 6, borderRadius: '50%', background: t.dot }} />}
{children}
</span>
);
};
// ===== Button =====
const Button = ({ variant = 'gradient', size = 'md', icon, iconRight, children, onClick, style, disabled, type = 'button' }) => {
const sizes = {
sm: { padding: '6px 12px', fontSize: 'var(--momo-font-size-xs)', height: 30, gap: 6 },
md: { padding: '8px 16px', fontSize: 'var(--momo-font-size-sm)', height: 38, gap: 8 },
lg: { padding: '10px 20px', fontSize: 'var(--momo-font-size-base)', height: 44, gap: 10 },
};
const variants = {
gradient: {
background: 'var(--momo-gradient-primary)',
color: 'var(--momo-text-inverse)',
boxShadow: 'var(--momo-shadow-sm)',
},
solid: {
background: 'var(--momo-primary)',
color: 'var(--momo-text-inverse)',
},
outline: {
background: 'var(--momo-bg-surface)',
color: 'var(--momo-primary)',
border: '1.5px solid var(--momo-primary)',
},
ghost: {
background: 'transparent',
color: 'var(--momo-text-secondary)',
},
'ghost-hover': {
background: 'var(--momo-bg-subtle)',
color: 'var(--momo-text-primary)',
},
secondary: {
background: 'var(--momo-bg-surface)',
color: 'var(--momo-text-primary)',
border: '1px solid var(--momo-border)',
},
danger: {
background: 'var(--momo-danger)',
color: 'var(--momo-text-inverse)',
},
};
const s = sizes[size];
const v = variants[variant] || variants.gradient;
return (
<button type={type} onClick={onClick} disabled={disabled}
className="momo-btn"
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
gap: s.gap, padding: s.padding, fontSize: s.fontSize, height: s.height,
fontWeight: 'var(--momo-font-weight-medium)',
borderRadius: 'var(--momo-radius-md)',
border: 'none',
transition: 'var(--momo-transition-base), transform var(--momo-duration-fast)',
opacity: disabled ? 0.5 : 1,
cursor: disabled ? 'not-allowed' : 'pointer',
...v,
...style,
}}>
{icon && <Icon name={icon} size={size === 'sm' ? 14 : 16} />}
{children}
{iconRight && <Icon name={iconRight} size={size === 'sm' ? 14 : 16} />}
</button>
);
};
// ===== Avatar =====
const Avatar = ({ name = '?', size = 32, gradient = false, style }) => {
const initial = (name || '?').trim().charAt(0).toUpperCase();
// 從名字 hash 出穩定色相
let hash = 0;
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
const hue = Math.abs(hash) % 360;
return (
<div style={{
width: size, height: size,
borderRadius: '50%',
background: gradient ? 'var(--momo-gradient-primary)' : `hsl(${hue}, 65%, 88%)`,
color: gradient ? '#fff' : `hsl(${hue}, 70%, 30%)`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: size * 0.42,
fontWeight: 600,
flexShrink: 0,
...style,
}}>{initial}</div>
);
};
// ===== Card =====
const Card = ({ children, style, cardStyle = 'shadow', padding = true, hoverable = false }) => {
const styles = {
shadow: { boxShadow: 'var(--momo-shadow-md)', border: '1px solid transparent' },
flat: { boxShadow: 'none', border: '1px solid transparent', background: 'var(--momo-bg-subtle)' },
bordered:{ boxShadow: 'none', border: '1px solid var(--momo-border)' },
};
return (
<div style={{
background: 'var(--momo-bg-surface)',
borderRadius: 'var(--momo-radius-lg)',
padding: padding ? 'var(--momo-space-5)' : 0,
transition: 'var(--momo-transition-base), transform var(--momo-duration-fast)',
...styles[cardStyle],
...(hoverable ? { cursor: 'pointer' } : {}),
...style,
}}>{children}</div>
);
};
// ===== Input =====
const Input = ({ icon, value, onChange, placeholder, style, type = 'text', size = 'md' }) => {
const heights = { sm: 32, md: 38, lg: 44 };
return (
<div style={{
display: 'flex', alignItems: 'center',
background: 'var(--momo-bg-surface)',
border: '1px solid var(--momo-border)',
borderRadius: 'var(--momo-radius-md)',
padding: '0 12px',
height: heights[size],
transition: 'var(--momo-transition-base)',
...style,
}}>
{icon && <Icon name={icon} size={16} color="var(--momo-text-tertiary)" style={{ marginRight: 8 }} />}
<input type={type} value={value} onChange={onChange} placeholder={placeholder}
style={{
flex: 1, border: 'none', outline: 'none', background: 'transparent',
fontSize: 'var(--momo-font-size-sm)',
color: 'var(--momo-text-primary)',
}} />
</div>
);
};
// ===== Checkbox =====
const Checkbox = ({ checked, onChange, indeterminate = false, style }) => {
const ref = React.useRef(null);
React.useEffect(() => {
if (ref.current) ref.current.indeterminate = indeterminate;
}, [indeterminate]);
return (
<label style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', position: 'relative',
width: 18, height: 18,
...style,
}}>
<input ref={ref} type="checkbox" checked={!!checked} onChange={onChange}
style={{ position: 'absolute', opacity: 0, width: 0, height: 0 }} />
<span style={{
width: 18, height: 18,
borderRadius: 4,
border: `1.5px solid ${checked || indeterminate ? 'var(--momo-primary)' : 'var(--momo-border-dark)'}`,
background: checked || indeterminate ? 'var(--momo-gradient-primary)' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'var(--momo-transition-base)',
}}>
{checked && <Icon name="check" size={12} color="#fff" strokeWidth={3} />}
{indeterminate && !checked && <span style={{ width: 8, height: 2, background: '#fff', borderRadius: 1 }} />}
</span>
</label>
);
};
// ===== Page Header =====
const PageHeader = ({ title, subtitle, actions, breadcrumbs }) => (
<div style={{
display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between',
gap: 'var(--momo-space-4)',
marginBottom: 'var(--momo-space-5)',
}}>
<div>
{breadcrumbs && (
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
fontSize: 'var(--momo-font-size-xs)',
color: 'var(--momo-text-tertiary)',
marginBottom: 6,
}}>
{breadcrumbs.map((b, i) => (
<React.Fragment key={i}>
{i > 0 && <Icon name="chevronRight" size={12} />}
<span style={{ color: i === breadcrumbs.length - 1 ? 'var(--momo-text-secondary)' : 'inherit' }}>{b}</span>
</React.Fragment>
))}
</div>
)}
<h1 style={{
margin: 0,
fontSize: 'var(--momo-font-size-xl)',
fontWeight: 'var(--momo-font-weight-bold)',
color: 'var(--momo-text-primary)',
letterSpacing: '-0.01em',
}}>{title}</h1>
{subtitle && <div style={{
marginTop: 4,
fontSize: 'var(--momo-font-size-sm)',
color: 'var(--momo-text-secondary)',
}}>{subtitle}</div>}
</div>
{actions && <div style={{ display: 'flex', gap: 8 }}>{actions}</div>}
</div>
);
// ===== Tag標籤統一元件 — 全站唯一入口) =====
// 用法:<Tag tone="caramel">推薦</Tag> <Tag tone="ink" mono>MOMO 領先</Tag>
// tone: caramel | honey | rust | mahogany | earth | ink | muted | success
// 不要再用任何寫死色碼做標籤!
const Tag = ({ tone = 'earth', mono = false, dot = false, size = 'sm', children, style }) => {
const sizes = {
xs: { pad: '1px 6px', fs: 10, gap: 4 },
sm: { pad: '2px 8px', fs: 11, gap: 5 },
md: { pad: '3px 10px', fs: 12, gap: 6 },
};
const s = sizes[size] || sizes.sm;
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: s.gap,
padding: s.pad,
fontSize: s.fs,
fontWeight: 600,
lineHeight: 1.5,
whiteSpace: 'nowrap',
borderRadius: 3,
background: `var(--momo-tag-${tone}-bg)`,
color: `var(--momo-tag-${tone}-text)`,
border: `1px solid var(--momo-tag-${tone}-border)`,
fontFamily: mono ? 'var(--momo-font-family-mono)' : 'var(--momo-font-family-base)',
letterSpacing: mono ? '0.02em' : 0,
...style,
}}>
{dot && <span style={{
width: 5, height: 5, borderRadius: '50%',
background: `var(--momo-tag-${tone}-text)`, opacity: 0.7,
}} />}
{children}
</span>
);
};
Object.assign(window, { Badge, Button, Avatar, Card, Input, Checkbox, PageHeader, Tag });