Як я зробив сайт який кожного разу інший: частина 3
У першій частині ми підняли FastAPI, у другій — налаштували OLS. Тепер найцікавіше: фронтенд який щоразу виглядає інакше.
Ідея: теми як набори CSS змінних
Замість того щоб мати кілька HTML-файлів або перезавантажувати сторінку, вся магія відбувається через CSS custom properties. Кожна тема — це просто об’єкт з набором змінних:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const THEMES = [
{
name: 'void',
layout: 'default',
vars: {
'--bg': '#0a0a0f',
'--accent': '#7c3aed',
'--accent2': '#06b6d4',
'--font-head': "'Space Grotesk', sans-serif",
// ...
}
},
{
name: 'matrix',
layout: 'terminal',
vars: {
'--bg': '#000d00',
'--accent': '#00ff41',
'--font-head': "'Space Mono', monospace",
// ...
}
},
// ще 4 теми...
];
Застосування теми — одна функція:
1
2
3
4
5
6
7
8
9
10
11
12
13
function applyTheme(theme) {
const root = document.documentElement;
document.body.classList.remove('layout-default','layout-sidebar','layout-magazine','layout-terminal');
document.body.classList.add('layout-' + theme.layout);
for (const [k, v] of Object.entries(theme.vars)) {
root.style.setProperty(k, v);
}
}
// При завантаженні і при кожному новому артиклі
const theme = THEMES[Math.floor(Math.random() * THEMES.length)];
applyTheme(theme);
Всього 6 тем і 3 лейаути: default (стандартний), sidebar (фото ліворуч, текст праворуч), magazine (широке фото зверху), terminal (вузький, моноширинний шрифт).
Стримінг JSON — найцікавіша частина
Модель повертає JSON, але ми отримуємо його шматками. Як показувати текст поступово якщо JSON ще неповний?
Відповідь: парсимо часткові дані регулярними виразами. Поки JSON не завершений, витягуємо поля по мірі їх появи:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function renderPartial(text) {
// Спочатку пробуємо повний парсинг
const full = tryParse(text);
if (full) {
titleEl.innerHTML = full.title;
subtitleEl.innerHTML = full.subtitle;
bodyEl.innerHTML = full.body;
return;
}
// Якщо JSON неповний — витягуємо поля частково
if (!titleDone) {
// Завершений title
const tm = text.match(/"title"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/s);
if (tm) {
titleEl.textContent = tm[1];
titleDone = true;
} else {
// Ще стримиться — показуємо з курсором
const p = text.match(/"title"\s*:\s*"([^"]*)/s);
if (p) titleEl.innerHTML = p[1] + '<span class="cursor"></span>';
}
}
// Аналогічно для subtitle і body...
}
Курсор, що блимає, додає відчуття живого друку:
1
2
3
4
5
6
7
.cursor {
display: inline-block;
width: 2px; height: 1.1em;
background: var(--accent);
animation: blink 0.7s step-end infinite;
}
@keyframes blink { 50% { opacity: 0; } }
EventSource на клієнті
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const evtSource = new EventSource(`/generate?lang=${encodeURIComponent(lang)}`);
evtSource.onmessage = (e) => {
const data = JSON.parse(e.data);
if (data.type === 'text') {
rawBuffer += data.chunk;
renderPartial(rawBuffer);
}
if (data.type === 'image') {
document.getElementById('article-img').src = data.url;
}
if (data.type === 'done') {
evtSource.close();
document.querySelectorAll('.cursor').forEach(c => c.remove());
}
};
Мова визначається автоматично з браузера:
1
const lang = navigator.language || navigator.languages?.[0] || 'en';
І передається на бекенд, де маппиться до назви мови для промпту: "uk" → "українською", "de" → "auf Deutsch" тощо.
SVG Favicon — кубик для гри в кості
Замість PNG використовуємо SVG — він чітко виглядає на будь-якому розмірі та щільності екрану. Тривимірний кубик складається з трьох паралелограмів:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<!-- Верхня грань -->
<polygon points="100,4 196,52 100,100 4,52" fill="#7c3aed"/>
<!-- Права грань -->
<polygon points="100,100 196,52 196,148 100,196" fill="#5b21b6"/>
<!-- Ліва грань -->
<polygon points="100,100 4,52 4,148 100,196" fill="#9d4ff7"/>
<!-- Крапка на верхній грані (1) -->
<circle cx="100" cy="52" r="8" fill="white" opacity="0.95"/>
<!-- Крапки на правій грані (2) -->
<circle cx="166" cy="76" r="7" fill="white" opacity="0.9"/>
<circle cx="118" cy="172" r="7" fill="white" opacity="0.9"/>
<!-- Крапки на лівій грані (3) -->
<circle cx="70" cy="76" r="7" fill="white" opacity="0.9"/>
<circle cx="40" cy="124" r="7" fill="white" opacity="0.9"/>
<circle cx="22" cy="172" r="7" fill="white" opacity="0.9"/>
</svg>
Підключення в <head>:
1
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
Підсумок усієї серії
За три статті ми зібрали сайт де:
- Бекенд — FastAPI + Groq (Llama 3.3 70B) + Pollinations.ai, все безкоштовно
- Стримінг — SSE з частковим парсингом JSON в реальному часі
- Дизайн — 6 випадкових тем × 3 лейаути = 18 варіантів вигляду
- Інфраструктура — OpenLiteSpeed + CyberPanel + systemd + Let’s Encrypt
Повний код доступний на madtech.work — просто оновіть сторінку кілька разів щоб побачити різні теми в дії.