You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
269 lines
12 KiB
269 lines
12 KiB
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; |
|
import { IOSDevice } from './frames/IOSDevice.jsx'; |
|
import { AndroidDevice } from './frames/AndroidDevice.jsx'; |
|
import { PhoneApp } from './PhoneApp.jsx'; |
|
|
|
const TWEAKS_DEFAULT = { |
|
homeVariant: 'cards', |
|
docVariant: 'rich', |
|
density: 'comfort', |
|
accent: 'teal', |
|
font: 'inter', |
|
layout: 'single', |
|
screen: 'home', |
|
device: 'ios', |
|
scale: 'auto', |
|
showIntro: true, |
|
}; |
|
|
|
const SCALE_OPTIONS = [ |
|
{ id: 'auto', lb: 'Авто' }, |
|
{ id: '0.5', lb: '50%' }, |
|
{ id: '0.75', lb: '75%' }, |
|
{ id: '1', lb: '100%' }, |
|
]; |
|
|
|
const HOME_OPTIONS = [ |
|
{ id: 'cards', lb: 'Карточки' }, |
|
{ id: 'list', lb: 'Лента' }, |
|
{ id: 'feed', lb: 'Таймлайн' }, |
|
]; |
|
const DOC_OPTIONS = [ |
|
{ id: 'rich', lb: 'Карточки+' }, |
|
{ id: 'list', lb: 'Список' }, |
|
{ id: 'photo', lb: 'Плитка' }, |
|
]; |
|
const DENSITY_OPTIONS = [ |
|
{ id: 'comfort', lb: 'Комф.' }, |
|
{ id: 'compact', lb: 'Плотно' }, |
|
]; |
|
const ACCENT_OPTIONS = [ |
|
{ id: 'teal', lb: 'Тил', primary: '#1F8F85', darker: '#166B63', dark: '#0F4A44', p50: '#E3F4F2', p100: '#C7E8E4', p200: '#9BD6CE', warm50: '#FDF8EE', warm100: '#F5EDDF' }, |
|
{ id: 'terra', lb: 'Терра', primary: '#C77A4C', darker: '#A65C33', dark: '#7F4426', p50: '#FBEFE4', p100: '#F5DDC9', p200: '#EBC19F', warm50: '#F4F7F3', warm100: '#E5ECE4' }, |
|
{ id: 'marine', lb: 'Марин', primary: '#3C6EA8', darker: '#23538B', dark: '#193C66', p50: '#E4EDF8', p100: '#C8DAEE', p200: '#9DBDDE', warm50: '#FBF6EE', warm100: '#F2E8D5' }, |
|
]; |
|
const FONT_OPTIONS = [ |
|
{ id: 'manrope', lb: 'Manrope', base: '"Manrope", system-ui, sans-serif', narrow: '"Oswald", sans-serif' }, |
|
{ id: 'inter', lb: 'Inter', base: '"Inter", system-ui, sans-serif', narrow: '"Oswald", sans-serif' }, |
|
{ id: 'golos', lb: 'Golos', base: '"Golos Text", system-ui, sans-serif', narrow: '"Oswald", sans-serif' }, |
|
]; |
|
const SCREEN_OPTIONS = [ |
|
{ id: 'home', lb: 'Главная' }, |
|
{ id: 'doctors', lb: 'Врачи' }, |
|
{ id: 'doctor:syndaev', lb: 'Карточка врача' }, |
|
{ id: 'booking-specs', lb: 'Запись: специализация' }, |
|
{ id: 'booking-doctor:ent', lb: 'Запись: врач' }, |
|
{ id: 'booking-time:syndaev', lb: 'Запись: время' }, |
|
{ id: 'booking-confirm:syndaev:1:16:00', lb: 'Запись: подтверждение' }, |
|
{ id: 'booking-success', lb: 'Запись: успех' }, |
|
{ id: 'appts', lb: 'Приёмы' }, |
|
{ id: 'appt:a1', lb: 'Детали приёма' }, |
|
{ id: 'results', lb: 'Результаты' }, |
|
{ id: 'result-audio', lb: 'Аудиограмма' }, |
|
{ id: 'result:r2', lb: 'Эндоскопия носоглотки' }, |
|
{ id: 'recovery', lb: 'Восстановление' }, |
|
{ id: 'audiotest', lb: 'Тест слуха' }, |
|
{ id: 'chat', lb: 'Чат' }, |
|
{ id: 'profile', lb: 'Профиль' }, |
|
{ id: 'qr', lb: 'QR' }, |
|
{ id: 'telemed', lb: 'Телемед' }, |
|
{ id: 'medcard', lb: 'Медкарта' }, |
|
{ id: 'notifications', lb: 'Уведомления' }, |
|
]; |
|
|
|
function applyTheme(tw) { |
|
const a = ACCENT_OPTIONS.find(x => x.id === tw.accent) || ACCENT_OPTIONS[0]; |
|
const f = FONT_OPTIONS.find(x => x.id === tw.font) || FONT_OPTIONS[0]; |
|
const r = document.documentElement.style; |
|
r.setProperty('--c-primary', a.primary); |
|
r.setProperty('--c-primary-darker', a.darker); |
|
r.setProperty('--c-primary-dark', a.dark); |
|
r.setProperty('--c-primary-50', a.p50); |
|
r.setProperty('--c-primary-100', a.p100); |
|
r.setProperty('--c-primary-200', a.p200); |
|
r.setProperty('--c-warm-50', a.warm50); |
|
r.setProperty('--c-warm-100', a.warm100); |
|
r.setProperty('--font-base', f.base); |
|
r.setProperty('--font-narrow', f.narrow); |
|
document.body.style.fontFamily = f.base; |
|
} |
|
|
|
function Phone({ device = 'ios', screen, ctx, label, sublabel }) { |
|
const content = <PhoneApp initialScreen={screen} ctx={ctx} />; |
|
const frame = device === 'android' |
|
? <AndroidDevice>{content}</AndroidDevice> |
|
: <IOSDevice>{content}</IOSDevice>; |
|
if (!label) return frame; |
|
return ( |
|
<div className="phone-cell"> |
|
{frame} |
|
<div className="label">{label}</div> |
|
{sublabel && <div className="sublabel">{sublabel}</div>} |
|
</div> |
|
); |
|
} |
|
|
|
function TweaksPanel({ tw, setTw, onClose }) { |
|
const group = (title, children) => ( |
|
<div className="tweaks-section"> |
|
<div className="label">{title}</div> |
|
<div className="tweaks-options">{children}</div> |
|
</div> |
|
); |
|
const opts = (options, key) => |
|
options.map(o => ( |
|
<button key={o.id} onClick={() => setTw({ ...tw, [key]: o.id })} className={tw[key] === o.id ? 'on' : ''}> |
|
{o.lb} |
|
</button> |
|
)); |
|
|
|
return ( |
|
<div className="tweaks-panel"> |
|
<h3> |
|
Tweaks |
|
<button onClick={onClose} title="Закрыть">×</button> |
|
</h3> |
|
{group('Экран', |
|
<select value={tw.screen} onChange={e => setTw({ ...tw, screen: e.target.value })}> |
|
{SCREEN_OPTIONS.map(s => <option key={s.id} value={s.id}>{s.lb}</option>)} |
|
</select> |
|
)} |
|
{group('Устройство', opts([{id:'ios',lb:'iOS'},{id:'android',lb:'Android'}], 'device'))} |
|
{tw.layout === 'single' && group('Масштаб', opts(SCALE_OPTIONS, 'scale'))} |
|
{group('Компоновка', opts([ |
|
{ id:'single', lb:'1 телефон' }, |
|
{ id:'home3', lb:'Главная ×3' }, |
|
{ id:'flow', lb:'Флоу записи' }, |
|
{ id:'variants', lb:'Все варианты' }, |
|
], 'layout'))} |
|
{group('Главный экран', opts(HOME_OPTIONS, 'homeVariant'))} |
|
{group('Карточки врачей', opts(DOC_OPTIONS, 'docVariant'))} |
|
{group('Плотность', opts(DENSITY_OPTIONS, 'density'))} |
|
{group('Цвет', |
|
ACCENT_OPTIONS.map(a => ( |
|
<button key={a.id} onClick={() => setTw({ ...tw, accent: a.id })} className={tw.accent === a.id ? 'on' : ''} style={{ |
|
display: 'flex', alignItems: 'center', gap: 6 |
|
}}> |
|
<span style={{ width: 10, height: 10, borderRadius: 999, background: a.primary, display: 'inline-block' }} /> |
|
{a.lb} |
|
</button> |
|
)) |
|
)} |
|
{group('Шрифт', opts(FONT_OPTIONS, 'font'))} |
|
</div> |
|
); |
|
} |
|
|
|
function FitWrap({ children, w = 402, h = 874, userScale = 'auto' }) { |
|
const outerRef = useRef(null); |
|
const [autoScale, setAutoScale] = useState(1); |
|
useEffect(() => { |
|
const outer = outerRef.current; |
|
if (!outer) return; |
|
const stage = outer.parentElement; |
|
if (!stage) return; |
|
const measure = () => { |
|
const padding = 48; |
|
const availW = stage.clientWidth - padding; |
|
const availH = stage.clientHeight - padding; |
|
const s = Math.min(availW / w, availH / h, 1); |
|
setAutoScale(Math.max(s, 0.3)); |
|
}; |
|
measure(); |
|
const ro = new ResizeObserver(measure); |
|
ro.observe(stage); |
|
window.addEventListener('resize', measure); |
|
return () => { ro.disconnect(); window.removeEventListener('resize', measure); }; |
|
}, [w, h]); |
|
const scale = userScale === 'auto' ? autoScale : parseFloat(userScale); |
|
return ( |
|
<div ref={outerRef} style={{ width: w * scale, height: h * scale, position: 'relative', flexShrink: 0 }}> |
|
<div style={{ |
|
width: w, height: h, transformOrigin: 'top left', |
|
transform: `scale(${scale})`, |
|
}}>{children}</div> |
|
</div> |
|
); |
|
} |
|
|
|
export default function App() { |
|
const [tw, setTw] = useState(TWEAKS_DEFAULT); |
|
const [panelOpen, setPanelOpen] = useState(true); |
|
const [introVisible, setIntroVisible] = useState(tw.showIntro !== false); |
|
|
|
useEffect(() => { applyTheme(tw); }, [tw.accent, tw.font]); |
|
|
|
const ctx = { homeVariant: tw.homeVariant, docVariant: tw.docVariant, density: tw.density }; |
|
|
|
const content = useMemo(() => { |
|
if (tw.layout === 'home3') { |
|
return ( |
|
<div className="phones-grid"> |
|
<Phone device={tw.device} screen="home" ctx={{ ...ctx, homeVariant: 'cards' }} label="Главная — Карточки" sublabel="CTA + сетка специализаций" /> |
|
<Phone device={tw.device} screen="home" ctx={{ ...ctx, homeVariant: 'list' }} label="Главная — Лента" sublabel="Список с деталями" /> |
|
<Phone device={tw.device} screen="home" ctx={{ ...ctx, homeVariant: 'feed' }} label="Главная — Таймлайн" sublabel="Лента здоровья" /> |
|
</div> |
|
); |
|
} |
|
if (tw.layout === 'flow') { |
|
return ( |
|
<div className="phones-grid"> |
|
<Phone device={tw.device} screen="booking-specs" ctx={ctx} label="1 · Специализация" /> |
|
<Phone device={tw.device} screen="booking-doctor:ent" ctx={ctx} label="2 · Выбор врача" /> |
|
<Phone device={tw.device} screen="booking-time:syndaev" ctx={ctx} label="3 · Дата и время" /> |
|
<Phone device={tw.device} screen="booking-confirm:syndaev:1:16:00" ctx={ctx} label="4 · Подтверждение" /> |
|
<Phone device={tw.device} screen="booking-success" ctx={ctx} label="Успех" /> |
|
</div> |
|
); |
|
} |
|
if (tw.layout === 'variants') { |
|
return ( |
|
<div className="phones-grid"> |
|
<Phone device={tw.device} screen="home" ctx={ctx} label="Главная" /> |
|
<Phone device={tw.device} screen="doctors" ctx={ctx} label="Врачи" /> |
|
<Phone device={tw.device} screen="doctor:syndaev" ctx={ctx} label="Карточка врача" /> |
|
<Phone device={tw.device} screen="booking-time:syndaev" ctx={ctx} label="Запись: время" /> |
|
<Phone device={tw.device} screen="appts" ctx={ctx} label="Мои приёмы" /> |
|
<Phone device={tw.device} screen="appt:a1" ctx={ctx} label="Детали приёма" /> |
|
<Phone device={tw.device} screen="result-audio" ctx={ctx} label="Аудиограмма" /> |
|
<Phone device={tw.device} screen="recovery" ctx={ctx} label="Восстановление" /> |
|
<Phone device={tw.device} screen="audiotest" ctx={ctx} label="Тест слуха" /> |
|
<Phone device={tw.device} screen="chat" ctx={ctx} label="Чат с врачом" /> |
|
<Phone device={tw.device} screen="profile" ctx={ctx} label="Профиль" /> |
|
<Phone device={tw.device} screen="qr" ctx={ctx} label="QR пациента" /> |
|
<Phone device={tw.device} screen="telemed" ctx={ctx} label="Видеозвонок" /> |
|
<Phone device={tw.device} screen="medcard" ctx={ctx} label="Медкарта" /> |
|
<Phone device={tw.device} screen="notifications" ctx={ctx} label="Уведомления" /> |
|
<Phone device={tw.device} screen="booking-specs" ctx={ctx} label="Запись" /> |
|
</div> |
|
); |
|
} |
|
return ( |
|
<FitWrap userScale={tw.scale}> |
|
<Phone device={tw.device} screen={tw.screen} ctx={ctx} /> |
|
</FitWrap> |
|
); |
|
}, [tw, ctx.homeVariant, ctx.docVariant, ctx.density]); |
|
|
|
const stageClass = tw.layout === 'single' ? 'stage' : 'stage grid-mode'; |
|
|
|
return ( |
|
<div style={{ width: '100%', height: '100%', position: 'relative' }} data-density={tw.density}> |
|
<div className={stageClass}> |
|
{content} |
|
</div> |
|
|
|
{introVisible && ( |
|
<div className="intro-banner"> |
|
<span>📱 <b>Клиника УГН</b> · мобильный прототип · откройте <b>Tweaks</b> справа, чтобы переключать экраны и варианты.</span> |
|
<button onClick={() => { setIntroVisible(false); setTw({ ...tw, showIntro: false }); }}>×</button> |
|
</div> |
|
)} |
|
|
|
{panelOpen && <TweaksPanel tw={tw} setTw={setTw} onClose={() => setPanelOpen(false)} />} |
|
{!panelOpen && ( |
|
<button className="tweaks-fab" onClick={() => setPanelOpen(true)} title="Tweaks">⚙</button> |
|
)} |
|
</div> |
|
); |
|
}
|
|
|