Files
mobile-app-prototip/src/App.jsx
T
arazor72 31b861e452 Add endoscopy result viewer screen
New screen for viewing endoscopy results: 2×2 snapshot grid with
simulated endoscopic view (radial gradient + specular highlight),
diagnosis chips, conclusion, numbered recommendations, PDF/share
actions, and full-screen image viewer with pagination.

Wired via nav route `result:<id>` from ResultsScreen and exposed in
Tweaks screen selector.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 18:32:03 +05:00

270 lines
12 KiB
React
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.
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>
);
}