Browse Source

Add articles list and detail screens

Enriched the 4 articles in data.js with id, doctorId, hero color,
emoji, date, lede, and structured body blocks (paragraphs, headings,
bullet lists, tone-variant callouts).

New screens:
- ArticlesScreen — tag-filtered list with hero-card layout
- ArticleDetailScreen — hero block, meta row, lede, body renderer
  (p/h/ul/callout), author card linking to doctor profile, CTA to
  book the author, and "Related" footer

Home card and feed sections now link to detail and list screens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
main
parent
commit
de96895e9c
  1. 5
      src/App.jsx
  2. 3
      src/PhoneApp.jsx
  3. 111
      src/data.js
  4. 234
      src/screens/screens-articles.jsx
  5. 17
      src/screens/screens-home.jsx

5
src/App.jsx

@ -69,6 +69,11 @@ const SCREEN_OPTIONS = [
{ id: 'telemed', lb: 'Телемед' },
{ id: 'medcard', lb: 'Медкарта' },
{ id: 'notifications', lb: 'Уведомления' },
{ id: 'articles', lb: 'Статьи врачей' },
{ id: 'article:otitis-kids', lb: 'Статья: отит у ребёнка' },
{ id: 'article:septoplasty-recovery', lb: 'Статья: восстановление после септопластики' },
{ id: 'article:throat-pregnancy', lb: 'Статья: горло при беременности' },
{ id: 'article:hearing-check', lb: 'Статья: когда проверить слух' },
];
function applyTheme(tw) {

3
src/PhoneApp.jsx

@ -13,6 +13,7 @@ import {
ChatTabScreen, ProfileTabScreen, QRScreen,
TelemedScreen, MedcardScreen, NotificationsScreen,
} from './screens/screens-misc.jsx';
import { ArticlesScreen, ArticleDetailScreen } from './screens/screens-articles.jsx';
function renderScreen(screenId, nav, ctx) {
const parts = screenId.split(':');
@ -40,6 +41,8 @@ function renderScreen(screenId, nav, ctx) {
case 'telemed': return <TelemedScreen nav={nav} />;
case 'medcard': return <MedcardScreen nav={nav} />;
case 'notifications': return <NotificationsScreen nav={nav} />;
case 'articles': return <ArticlesScreen nav={nav} />;
case 'article': return <ArticleDetailScreen nav={nav} articleId={parts[1]} />;
default: return <div style={{padding: 40, textAlign: 'center'}}>Экран не найден: {screenId}</div>;
}
}

111
src/data.js

@ -60,10 +60,113 @@ export const CLINIC_DATA = {
{ id: 'r5', name: 'Посев на микрофлору', date: '20 апр 2026',doctor: 'syndaev', status: 'pending', kind: 'lab' },
],
articles: [
{ tag: 'Дети', title: 'Как понять, что у ребёнка отит', mins: 4, author: 'Макарова Л.Г.' },
{ tag: 'Операции', title: 'Восстановление после септопластики', mins: 6, author: 'Синдяев А.В.' },
{ tag: 'Беременность', title: 'Безопасно лечим горло при беременности', mins: 5, author: 'Лобанова И.Ю.' },
{ tag: 'Слух', title: 'Когда пора проверить слух', mins: 3, author: 'Семерикова Н.А.' },
{
id: 'otitis-kids',
tag: 'Дети',
title: 'Как понять, что у ребёнка отит',
mins: 4,
author: 'Макарова Л.Г.',
doctorId: 'makarova',
hero: '#F5EDDF',
emoji: '👶',
date: '12 апр 2026',
lede: 'Ребёнок капризничает и трёт ушко? Рассказываем, когда это повод срочно показаться врачу, а когда можно подождать.',
body: [
{ type: 'p', text: 'Отит — одна из самых частых причин обращения к педиатру у детей до 5 лет. Пик заболеваемости приходится на осенне-зимний сезон: ОРВИ даёт осложнение на ухо, и слизистая слуховой трубы воспаляется следом за носоглоткой.' },
{ type: 'h', text: 'На что обращать внимание' },
{ type: 'ul', items: [
'Ребёнок дёргает или трёт ушко',
'Беспокойный сон, плач при кормлении',
'Температура выше 38°C без видимой причины',
'Снижение реакции на тихие звуки, просит повторить',
'Выделения из слухового прохода',
] },
{ type: 'h', text: 'Когда нужен врач в тот же день' },
{ type: 'p', text: 'Если ребёнку меньше 2 лет, боль длится более суток, температура выше 38,5°C или появились гнойные выделения — не откладывайте визит. При двустороннем остром отите антибиотик нужен почти всегда.' },
{ type: 'callout', tone: 'danger', title: 'Чего не делать до осмотра', text: 'Не закапывайте борный спирт и масла, не грейте ухо компрессами, не давайте анальгин. Это может навредить и смазать картину для врача.' },
{ type: 'p', text: 'Если есть сомнения — позвоните в клинику или напишите в чат. Детский ЛОР поможет оценить ситуацию и подскажет, нужно ли везти ребёнка на приём сегодня.' },
],
},
{
id: 'septoplasty-recovery',
tag: 'Операции',
title: 'Восстановление после септопластики',
mins: 6,
author: 'Синдяев А.В.',
doctorId: 'syndaev',
hero: '#E3F4F2',
emoji: '🩺',
date: '8 апр 2026',
lede: 'Что происходит после операции по исправлению носовой перегородки и как ускорить восстановление.',
body: [
{ type: 'p', text: 'Септопластика — операция короткая, но восстановление идёт постепенно. Большая часть отёка уходит за первые 2 недели, окончательная форма носа формируется к 3-му месяцу.' },
{ type: 'h', text: 'Первая неделя' },
{ type: 'p', text: 'Сразу после операции в нос устанавливают силиконовые сплинты или тампоны — дышать приходится ртом. Это временно: сплинты снимают на 3–5 день. В этот период важен постельный режим, холод на переносицу первые сутки, обезболивание по схеме.' },
{ type: 'h', text: 'Со 2-й недели — промывания' },
{ type: 'ul', items: [
'Солевой раствор 4–6 раз в день',
'Мягкое удаление корочек у ЛОР-врача',
'Контрольная эндоскопия на 10–14 день',
] },
{ type: 'callout', tone: 'warn', title: 'Ограничения на месяц', text: 'Нельзя: баня, сауна, бассейн, спорт, авиаперелёты, алкоголь, острая и горячая пища. Сморкаться — только поочерёдно каждой ноздрёй, без усилия.' },
{ type: 'h', text: 'Когда срочно к врачу' },
{ type: 'p', text: 'Сильная боль, не снимающаяся анальгетиками, температура выше 38,5°C, обильное кровотечение, резкое нарастание отёка с покраснением кожи — повод связаться с хирургом в тот же день. В приложении есть чат с вашим врачом.' },
],
},
{
id: 'throat-pregnancy',
tag: 'Беременность',
title: 'Безопасно лечим горло при беременности',
mins: 5,
author: 'Лобанова И.Ю.',
doctorId: 'lobanova',
hero: '#FDF8E6',
emoji: '🤱',
date: '2 апр 2026',
lede: 'Большинство привычных препаратов при беременности запрещены. Разбираем, что точно безопасно, а что — только по назначению врача.',
body: [
{ type: 'p', text: 'При беременности иммунитет естественно снижается, и горло болит чаще обычного. Главная задача — не навредить малышу лечением: многие антисептики и анальгетики проникают через плаценту.' },
{ type: 'h', text: 'Безопасно на любом сроке' },
{ type: 'ul', items: [
'Полоскание солевым раствором (1 ч. л. соли на стакан тёплой воды)',
'Полоскание отваром ромашки',
'Обильное тёплое питьё, мёд (если нет аллергии)',
'Ингаляции физраствором через небулайзер',
] },
{ type: 'h', text: 'Можно по назначению врача' },
{ type: 'p', text: 'Пастилки Лизобакт и Фарингосепт, спрей Тантум Верде (со 2-го триместра), Мирамистин — применяют после оценки пользы и рисков. Антибиотики — только по строгим показаниям и только группы пенициллинов или цефалоспоринов.' },
{ type: 'callout', tone: 'danger', title: 'Категорически нельзя', text: 'Спиртсодержащие антисептики, аспирин, большинство леденцов с ментолом, прогревания, горчичники. Народная «смазка горла люголем» — только после осмотра врача.' },
{ type: 'h', text: 'Когда срочно к ЛОРу' },
{ type: 'p', text: 'Температура выше 38°C более 2 дней, гнойные налёты на миндалинах, трудно открывать рот, асимметрия горла или сильная боль с одной стороны — немедленно на приём. Возможен паратонзиллярный абсцесс, который требует вскрытия.' },
],
},
{
id: 'hearing-check',
tag: 'Слух',
title: 'Когда пора проверить слух',
mins: 3,
author: 'Семерикова Н.А.',
doctorId: 'semerikova',
hero: '#FCF1F0',
emoji: '👂',
date: '28 мар 2026',
lede: 'Снижение слуха часто развивается медленно, и человек замечает проблему последним. На что обратить внимание, чтобы не упустить момент.',
body: [
{ type: 'p', text: 'В отличие от зрения, слух редко падает резко — исключение только травма или острая сенсоневральная тугоухость, когда помощь нужна в первые 72 часа. Обычно снижение копится годами и становится заметным, когда теряется уже 20–30%.' },
{ type: 'h', text: 'Бытовые признаки' },
{ type: 'ul', items: [
'Часто просите повторить сказанное',
'Увеличиваете громкость ТВ больше, чем раньше',
'Плохо разбираете речь в шумном кафе',
'Появился звон или шум в ушах',
'Близкие говорят, что вы «кричите», когда говорите нормально',
] },
{ type: 'h', text: 'Группы риска' },
{ type: 'p', text: 'Люди после 50, музыканты, рабочие шумных производств, любители наушников на высокой громкости, перенёсшие черепно-мозговую травму или ототоксичные препараты (некоторые антибиотики и химиотерапия).' },
{ type: 'callout', tone: 'info', title: 'Быстрая проверка', text: 'В приложении есть тест слуха на 3 минуты — покажет ориентир. Если результат пограничный или хуже нормы — запишитесь на аудиометрию у сурдолога для точного диагноза.' },
{ type: 'p', text: 'Ранняя коррекция (слуховой аппарат или лечение причины) останавливает дальнейшее снижение и сохраняет речевую зону коры мозга активной — это критично для качества жизни в пожилом возрасте.' },
],
},
],
recovery: {
op: 'Септопластика',

234
src/screens/screens-articles.jsx

@ -0,0 +1,234 @@
import React, { useState } from 'react';
import { I } from '../icons.jsx';
import { CLINIC_DATA } from '../data.js';
import { Avatar, ScreenHeader } from '../components.jsx';
function ArticleHero({ article, big = false }) {
return (
<div style={{
background: article.hero,
height: big ? 180 : 100,
borderRadius: big ? 0 : 14,
padding: big ? '18px 20px' : 14,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
position: 'relative',
overflow: 'hidden',
}}>
<span className="chip chip-warm" style={{ alignSelf: 'flex-start', background: 'rgba(255,255,255,0.85)' }}>{article.tag}</span>
<div style={{
position: 'absolute',
right: big ? 18 : 12,
bottom: big ? 14 : 8,
fontSize: big ? 72 : 36,
opacity: 0.9,
lineHeight: 1,
}}>{article.emoji}</div>
</div>
);
}
function renderBlock(b, i) {
if (b.type === 'p') {
return <p key={i} style={{ margin: '0 0 14px', fontSize: 15, lineHeight: 1.6, color: 'var(--c-fg-2)' }}>{b.text}</p>;
}
if (b.type === 'h') {
return <h3 key={i} style={{ margin: '22px 0 10px', fontSize: 17, fontWeight: 700, color: 'var(--c-fg-1)', lineHeight: 1.3 }}>{b.text}</h3>;
}
if (b.type === 'ul') {
return (
<ul key={i} style={{ margin: '0 0 16px', paddingLeft: 0, listStyle: 'none' }}>
{b.items.map((it, j) => (
<li key={j} style={{ display: 'flex', gap: 10, alignItems: 'flex-start', padding: '4px 0', fontSize: 15, lineHeight: 1.5, color: 'var(--c-fg-2)' }}>
<span style={{ width: 6, height: 6, borderRadius: 999, background: 'var(--c-primary-darker)', flexShrink: 0, marginTop: 9 }} />
<span>{it}</span>
</li>
))}
</ul>
);
}
if (b.type === 'callout') {
const tones = {
danger: { bg: 'var(--c-accent-50)', border: 'var(--c-accent)', icon: I.shield, color: 'var(--c-accent)' },
info: { bg: 'var(--c-primary-50)', border: 'var(--c-primary-darker)', icon: I.stetho, color: 'var(--c-primary-darker)' },
warn: { bg: 'var(--c-warning-50)', border: 'var(--c-warning)', icon: I.bell, color: 'var(--c-warning)' },
};
const t = tones[b.tone] || tones.info;
const IconC = t.icon;
return (
<div key={i} style={{
background: t.bg, borderLeft: `3px solid ${t.border}`, borderRadius: 12,
padding: 14, margin: '6px 0 18px', display: 'flex', gap: 12, alignItems: 'flex-start',
}}>
<IconC size={20} style={{ color: t.color, flexShrink: 0, marginTop: 1 }} />
<div>
{b.title && <div style={{ fontSize: 14, fontWeight: 700, color: 'var(--c-fg-1)', marginBottom: 4 }}>{b.title}</div>}
<div style={{ fontSize: 14, color: 'var(--c-fg-2)', lineHeight: 1.5 }}>{b.text}</div>
</div>
</div>
);
}
return null;
}
export function ArticlesScreen({ nav }) {
const { articles } = CLINIC_DATA;
const tags = ['Все', ...Array.from(new Set(articles.map(a => a.tag)))];
const [tag, setTag] = useState('Все');
const filtered = tag === 'Все' ? articles : articles.filter(a => a.tag === tag);
return (
<div style={{ paddingBottom: 40 }}>
<ScreenHeader title="Статьи врачей" onBack={() => nav.pop()} rightIcon={I.search} />
<div className="pills" style={{ marginBottom: 14 }}>
{tags.map(t => (
<button key={t} onClick={() => setTag(t)} className={'pill' + (tag === t ? ' on' : '')}>{t}</button>
))}
</div>
<div style={{ padding: '0 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
{filtered.map(a => (
<button key={a.id} onClick={() => nav.push('article:' + a.id)} className="press card" style={{
padding: 0, overflow: 'hidden', textAlign: 'left',
}}>
<ArticleHero article={a} />
<div style={{ padding: 14 }}>
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--c-fg-1)', lineHeight: 1.3, marginBottom: 6 }}>{a.title}</div>
<div className="sub" style={{ fontSize: 12, marginBottom: 8 }}>{a.lede}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'var(--c-fg-3)' }}>
<span style={{ fontWeight: 700 }}>{a.author}</span>
<span className="dot" />
<span>{a.mins} мин чтения</span>
</div>
</div>
</button>
))}
</div>
</div>
);
}
export function ArticleDetailScreen({ nav, articleId }) {
const a = CLINIC_DATA.articles.find(x => x.id === articleId) || CLINIC_DATA.articles[0];
const doctor = CLINIC_DATA.doctors.find(d => d.id === a.doctorId);
const related = CLINIC_DATA.articles.filter(x => x.id !== a.id).slice(0, 2);
const [bookmarked, setBookmarked] = useState(false);
return (
<div style={{ paddingBottom: 40 }}>
{/* Floating back button over hero */}
<div style={{
position: 'sticky', top: 0, zIndex: 10,
padding: '12px 16px', display: 'flex', justifyContent: 'space-between',
}}>
<button onClick={() => nav.pop()} className="press" style={{
width: 38, height: 38, borderRadius: 999, background: 'rgba(255,255,255,0.92)',
backdropFilter: 'blur(10px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: 'var(--sh-sm)',
}}>
<I.chevL size={20} style={{ color: 'var(--c-fg-1)' }} />
</button>
<button onClick={() => setBookmarked(b => !b)} className="press" style={{
width: 38, height: 38, borderRadius: 999, background: 'rgba(255,255,255,0.92)',
backdropFilter: 'blur(10px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: 'var(--sh-sm)',
}}>
<I.heart size={18} style={{ color: bookmarked ? 'var(--c-accent)' : 'var(--c-fg-1)' }}
fill={bookmarked ? 'var(--c-accent)' : 'none'} />
</button>
</div>
{/* Hero pulled up to fill under status bar area */}
<div style={{ marginTop: -60, paddingTop: 60, background: a.hero, position: 'relative' }}>
<div style={{
padding: '0 20px 24px', minHeight: 140,
display: 'flex', flexDirection: 'column', justifyContent: 'flex-end',
position: 'relative',
}}>
<span className="chip chip-warm" style={{ alignSelf: 'flex-start', background: 'rgba(255,255,255,0.85)', marginBottom: 12 }}>{a.tag}</span>
<h1 style={{ margin: 0, fontSize: 24, fontWeight: 700, color: 'var(--c-fg-1)', lineHeight: 1.2, maxWidth: '72%' }}>{a.title}</h1>
<div style={{
position: 'absolute', right: 12, bottom: 8,
fontSize: 82, opacity: 0.85, lineHeight: 1,
}}>{a.emoji}</div>
</div>
</div>
{/* Meta row */}
<div style={{ padding: '16px 20px 8px', display: 'flex', alignItems: 'center', gap: 12 }}>
<Avatar init={doctor.init} size={40} />
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 700 }}>{doctor.name.split(' ').slice(0, 2).join(' ')}</div>
<div className="sub" style={{ fontSize: 12 }}>{a.date} · {a.mins} мин чтения</div>
</div>
<button className="press" style={{
width: 36, height: 36, borderRadius: 999, background: 'var(--c-bg)',
display: 'flex', alignItems: 'center', justifyContent: 'center', border: '1px solid var(--c-border)',
}}>
<I.arrow size={16} style={{ color: 'var(--c-fg-2)' }} />
</button>
</div>
<div className="divider" style={{ margin: '8px 20px' }} />
{/* Lede */}
<div style={{ padding: '8px 20px 0' }}>
<p style={{ margin: '0 0 18px', fontSize: 16, fontWeight: 500, color: 'var(--c-fg-1)', lineHeight: 1.5 }}>{a.lede}</p>
</div>
{/* Body */}
<div style={{ padding: '0 20px' }}>
{a.body.map(renderBlock)}
</div>
{/* Author card */}
<div style={{ padding: '16px 20px 6px' }}>
<div className="card" style={{
padding: 16, display: 'flex', gap: 14, alignItems: 'center',
background: 'linear-gradient(135deg, var(--c-primary-100), var(--c-primary-50))', border: 0,
}}>
<Avatar init={doctor.init} size={52} />
<div style={{ flex: 1 }}>
<div className="sub" style={{ fontSize: 11, marginBottom: 2 }}>Автор статьи</div>
<div style={{ fontSize: 15, fontWeight: 700, marginBottom: 2 }}>{doctor.name.split(' ').slice(0, 2).join(' ')}</div>
<div className="sub" style={{ fontSize: 12 }}>{doctor.spec} · {doctor.exp} лет</div>
</div>
<button onClick={() => nav.push('doctor:' + doctor.id)} className="btn-s" style={{ padding: '8px 14px', fontSize: 13 }}>Профиль</button>
</div>
</div>
{/* CTA */}
<div style={{ padding: '16px 20px 6px' }}>
<button onClick={() => nav.push('booking-time:' + doctor.id)} className="btn-p block">
<I.calendar size={18} /> Записаться к автору
</button>
</div>
{/* Related */}
{related.length > 0 && (
<div style={{ padding: '22px 20px 0' }}>
<h2 className="h-sec" style={{ marginBottom: 12 }}>Ещё по теме</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{related.map(r => (
<button key={r.id} onClick={() => { nav.pop(); nav.push('article:' + r.id); }} className="press card" style={{
display: 'flex', gap: 12, alignItems: 'center', padding: 12, textAlign: 'left',
}}>
<div style={{
width: 56, height: 56, borderRadius: 12, background: r.hero, flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 26,
}}>{r.emoji}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, color: 'var(--c-primary-dark)', fontWeight: 700, textTransform: 'uppercase', letterSpacing: .5, marginBottom: 3 }}>{r.tag}</div>
<div style={{ fontSize: 14, fontWeight: 700, lineHeight: 1.3, marginBottom: 3 }}>{r.title}</div>
<div className="sub" style={{ fontSize: 12 }}>{r.mins} мин</div>
</div>
<I.chev size={16} style={{ color: 'var(--c-fg-4)' }} />
</button>
))}
</div>
</div>
)}
</div>
);
}

17
src/screens/screens-home.jsx

@ -118,14 +118,15 @@ export function HomeCardsScreen({ nav }) {
<SectionHeader title="Статьи врачей" action="Все" onAction={() => nav.push('articles')} />
<div style={{ display: 'flex', gap: 12, overflowX: 'auto', padding: '0 20px 8px' }} className="noscroll">
{articles.map((a, i) => (
<button key={i} className="press card" style={{
<button key={a.id} onClick={() => nav.push('article:' + a.id)} className="press card" style={{
flexShrink: 0, width: 220, padding: 0, overflow: 'hidden', textAlign: 'left',
}}>
<div style={{
height: 90, background: ['#F5EDDF','#E3F4F2','#FDF8E6','#FCF1F0'][i % 4],
display: 'flex', alignItems: 'flex-end', padding: 12,
height: 90, background: a.hero || ['#F5EDDF','#E3F4F2','#FDF8E6','#FCF1F0'][i % 4],
display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', padding: 12,
}}>
<span className="chip chip-warm" style={{ background: 'rgba(255,255,255,0.85)' }}>{a.tag}</span>
<span style={{ fontSize: 28, lineHeight: 1 }}>{a.emoji}</span>
</div>
<div style={{ padding: 12 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--c-fg-1)', lineHeight: 1.3, marginBottom: 6, minHeight: 34 }}>{a.title}</div>
@ -273,12 +274,12 @@ export function HomeFeedScreen({ nav }) {
</div>
<div style={{ padding: '18px 20px 8px' }}>
<SectionHeader title="Полезно прочитать" pad="0 0 0 0" />
<SectionHeader title="Полезно прочитать" action="Все" onAction={() => nav.push('articles')} pad="0 0 0 0" />
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{articles.slice(0,3).map((a,i)=>(
<button key={i} className="press card" style={{ display: 'flex', gap: 12, alignItems: 'center', padding: 14, textAlign: 'left' }}>
<div style={{ width: 56, height: 56, borderRadius: 12, background: ['#F5EDDF','#E3F4F2','#FDF8E6'][i%3], flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 24 }}>
{['📖','👂','🤱'][i%3]}
{articles.slice(0,3).map((a)=>(
<button key={a.id} onClick={() => nav.push('article:' + a.id)} className="press card" style={{ display: 'flex', gap: 12, alignItems: 'center', padding: 14, textAlign: 'left' }}>
<div style={{ width: 56, height: 56, borderRadius: 12, background: a.hero, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 28 }}>
{a.emoji}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 11, color: 'var(--c-primary-dark)', fontWeight: 700, textTransform: 'uppercase', letterSpacing: .5, marginBottom: 3 }}>{a.tag}</div>

Loading…
Cancel
Save