feat: add StatusWidget component and integrate it into the app with public status URL support
This commit is contained in:
@@ -6,3 +6,6 @@ HOST=127.0.0.1
|
||||
# Docker Compose: port published on the server for an HTTPS reverse proxy.
|
||||
PWA_PORT=8080
|
||||
PWA_BIND_ADDRESS=127.0.0.1
|
||||
|
||||
# Public status hosting URL, embedded in the production bundle.
|
||||
# VITE_STATUS_URL=https://your-status-domain.example
|
||||
|
||||
@@ -5,6 +5,8 @@ RUN npm ci
|
||||
COPY index.html tsconfig.json vite.config.ts ./
|
||||
COPY src ./src
|
||||
COPY public ./public
|
||||
ARG VITE_STATUS_URL=
|
||||
ENV VITE_STATUS_URL=$VITE_STATUS_URL
|
||||
RUN npm run build
|
||||
|
||||
FROM node:24-alpine
|
||||
|
||||
@@ -332,3 +332,11 @@ npm run icons
|
||||
|
||||
Эта команда пересоздаёт PNG-иконки из исходного логотипа. Иконки и шрифты
|
||||
поставляются вместе с приложением и не требуют сети после первого открытия.
|
||||
|
||||
## Виджет статуса RideBus
|
||||
|
||||
Встроенный iframe показывает свежий статус этого сервиса и API. При недоступности мониторинга отображается отсутствие данных. Адрес хостинга задаётся публичной переменной сборки: `VITE_STATUS_URL`. Если адрес не задан, production-виджет скрыт; в development используется http://127.0.0.1:5188. После изменения переменной нужна новая сборка.
|
||||
|
||||
Виджет не получает сессию пользователя, API-ключи или данные приложения. Состояния, оформление, русская/английская локализация и обновления обслуживаются проектом ridebus-status.
|
||||
|
||||
При запуске через Docker Compose укажите переменную URL в корневом `.env` проекта и пересоберите образ (`docker compose up -d --build`). URL передаётся как build argument; смена только runtime environment не обновляет уже собранный интерфейс.
|
||||
|
||||
@@ -5,6 +5,8 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_STATUS_URL: ${VITE_STATUS_URL:-}
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
environment:
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useAppTheme } from './lib/theme';
|
||||
import { Dialog } from './components/Dialog';
|
||||
import { FeatureDiscovery } from './components/FeatureDiscovery';
|
||||
import { StopList, StopListItem } from './components/StopList';
|
||||
import StatusWidget from './components/StatusWidget';
|
||||
import { positiveParam } from './lib/links';
|
||||
import { isTravelDate, localDate } from './lib/time';
|
||||
import { countLabel, groupStops, type StopGroup } from './lib/stopGroups';
|
||||
@@ -225,7 +226,7 @@ export default function App() {
|
||||
{pickerOpen && <Dialog title="Выбрать остановку" onClose={() => setPickerOpen(false)}>{stopCatalog()}</Dialog>}
|
||||
</>}
|
||||
</main>
|
||||
<footer className="footer"><span>ridebus<small>Расписание в вашем кармане</small></span><div><NavLink to={`/about${urlSuffix}`}>О проекте <ArrowRight size={18} /></NavLink><a href="https://ridebus.by" target="_blank" rel="noreferrer">Сайт RideBus <ArrowUpRight size={20} /></a>{(import.meta.env.DEV || mode === 'demo') && <button onClick={() => switchMode(mode !== 'demo')}>{mode === 'demo' ? 'Реальное расписание' : 'Посмотреть пример'}</button>}</div></footer>
|
||||
<footer className="footer"><span>ridebus<small>Расписание в вашем кармане</small></span><div><StatusWidget url={import.meta.env.VITE_STATUS_URL || (import.meta.env.DEV ? 'http://127.0.0.1:5188' : undefined)} services={['pwa', 'api']} locale="ru" theme={theme.resolved} /><NavLink to={`/about${urlSuffix}`}>О проекте <ArrowRight size={18} /></NavLink><a href="https://ridebus.by" target="_blank" rel="noreferrer">Сайт RideBus <ArrowUpRight size={20} /></a>{(import.meta.env.DEV || mode === 'demo') && <button onClick={() => switchMode(mode !== 'demo')}>{mode === 'demo' ? 'Реальное расписание' : 'Посмотреть пример'}</button>}</div></footer>
|
||||
{navigation(true)}
|
||||
<FeatureDiscovery home={homePage} restartKey={discoveryRestart} />
|
||||
{notice && <div className="toast" role="status">{notice}<button aria-label="Закрыть уведомление" onClick={() => setNotice('')}><X size={16} /></button></div>}
|
||||
|
||||
63
src/components/StatusWidget.tsx
Normal file
63
src/components/StatusWidget.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
url?: string;
|
||||
locale?: 'ru' | 'en';
|
||||
theme?: 'light' | 'dark' | 'auto';
|
||||
services?: string[];
|
||||
className?: string;
|
||||
};
|
||||
type WidgetStatus = 'operational' | 'degraded' | 'down' | 'unknown' | 'maintenance';
|
||||
|
||||
/** Embed the public RideBus status without sharing app sessions or API keys. */
|
||||
export default function StatusWidget({ url, locale = 'ru', theme = 'auto', services = [], className }: Props) {
|
||||
const frame = useRef<HTMLIFrameElement>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [status, setStatus] = useState<WidgetStatus>('unknown');
|
||||
let source = '', origin = '', destination = '';
|
||||
try {
|
||||
if (url) {
|
||||
const target = new URL(url);
|
||||
if (!['https:', 'http:'].includes(target.protocol) || target.username || target.password) throw new Error('Invalid status URL');
|
||||
origin = target.origin;
|
||||
destination = new URL(`/?lang=${locale}`, origin).href;
|
||||
const embed = new URL('/embed', origin);
|
||||
embed.searchParams.set('lang', locale);
|
||||
embed.searchParams.set('theme', theme);
|
||||
if (services.length) embed.searchParams.set('services', services.join(','));
|
||||
source = embed.href;
|
||||
}
|
||||
} catch { /* An unset or invalid deployment URL leaves the widget disabled. */ }
|
||||
|
||||
const title = locale === 'ru' ? 'Статус сервисов RideBus' : 'RideBus service status';
|
||||
const fallback = locale === 'ru' ? 'Статус не получен' : 'Status unavailable';
|
||||
const labels: Record<WidgetStatus, string> = locale === 'ru'
|
||||
? { operational: 'Сервисы работают', degraded: 'Есть перебои', down: 'Сервисы недоступны', unknown: 'Нет свежих данных', maintenance: 'Плановые работы' }
|
||||
: { operational: 'Services operational', degraded: 'Service disruption', down: 'Services unavailable', unknown: 'No fresh status data', maintenance: 'Scheduled maintenance' };
|
||||
useEffect(() => {
|
||||
setReady(false);
|
||||
if (!source) return;
|
||||
let lastUpdate = 0;
|
||||
const request = () => frame.current?.contentWindow?.postMessage({ type: 'ridebus-status-widget-request' }, origin);
|
||||
function receive(event: MessageEvent) {
|
||||
if (event.origin !== origin || event.source !== frame.current?.contentWindow || event.data?.type !== 'ridebus-status-widget' || event.data.version !== 1 || !['operational', 'degraded', 'down', 'unknown', 'maintenance'].includes(event.data.status)) return;
|
||||
lastUpdate = Date.now();
|
||||
setStatus(event.data.status);
|
||||
setReady(true);
|
||||
}
|
||||
function visible() { if (!document.hidden) { if (Date.now() - lastUpdate >= 90000) setReady(false); request(); } }
|
||||
const timer = setInterval(() => { if (lastUpdate && Date.now() - lastUpdate >= 90000) setReady(false); }, 5000);
|
||||
window.addEventListener('message', receive);
|
||||
window.addEventListener('online', request);
|
||||
document.addEventListener('visibilitychange', visible);
|
||||
request();
|
||||
return () => { clearInterval(timer); window.removeEventListener('message', receive); window.removeEventListener('online', request); document.removeEventListener('visibilitychange', visible); };
|
||||
}, [source, origin]);
|
||||
|
||||
if (!source) return null;
|
||||
return <span className={className} data-ridebus-status-widget style={{ display: 'inline-block', position: 'relative', width: 260, maxWidth: '100%', height: 44, flexShrink: 0, verticalAlign: 'middle' }}>
|
||||
<style>{'[data-ridebus-status-widget] iframe{transition:transform 160ms cubic-bezier(.2,.75,.25,1)}[data-ridebus-status-widget]:hover iframe{transform:translateY(-1px)}[data-ridebus-status-widget]>a:focus-visible{outline:2px solid #7777aa;outline-offset:2px}@media(prefers-reduced-motion:reduce){[data-ridebus-status-widget] iframe{transition:none}[data-ridebus-status-widget]:hover iframe{transform:none}}'}</style>
|
||||
<iframe ref={frame} src={source} title={title} width="260" height="44" loading="lazy" referrerPolicy="no-referrer" sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox" aria-hidden="true" tabIndex={-1} onLoad={() => frame.current?.contentWindow?.postMessage({ type: 'ridebus-status-widget-request' }, origin)} style={{ display: 'block', width: '100%', height: 44, border: 0, visibility: ready ? 'visible' : 'hidden', borderRadius: 14, pointerEvents: 'none' }} />
|
||||
<a href={destination} target="_blank" rel="noopener noreferrer" aria-label={`${title}: ${ready ? labels[status] : fallback}`} aria-live="polite" style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', gap: 9, padding: '0 12px', borderRadius: 14, background: ready ? 'transparent' : theme === 'dark' ? '#202636' : '#f0f1f5', color: theme === 'dark' ? '#bac2d5' : '#636b80', fontSize: 13, fontWeight: 700, textDecoration: 'none', whiteSpace: 'nowrap' }}>{!ready && <><span aria-hidden="true" style={{ width: 7, height: 7, borderRadius: '50%', background: 'currentColor', flexShrink: 0 }} />{fallback}<span aria-hidden="true" style={{ marginLeft: 'auto' }}>↗</span></>}</a>
|
||||
</span>;
|
||||
}
|
||||
@@ -276,3 +276,5 @@ svg, .vkuiIcon { flex-shrink: 0; }
|
||||
@include discovery.components;
|
||||
@include stops.components;
|
||||
@include motion.components;
|
||||
|
||||
.footer > div { flex-wrap: wrap; row-gap: 12px; max-width: 100%; }
|
||||
|
||||
Reference in New Issue
Block a user