feat: initial version
publish-content / build (push) Canceled after 0s

This commit is contained in:
2026-09-05 20:39:54 +05:00
parent 960e098ff7
commit b1b4d29803
24 changed files with 4321 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
name: publish-content
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build:content
env:
CONTENT_SHA: ${{ github.sha }}
- uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./out
publish_branch: published
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
out/
*.log
+145
View File
@@ -0,0 +1,145 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import matter from 'gray-matter';
import { parseDoc, validateDoc, serializeDoc } from './md.js';
import { optimizeImage, hashFile } from './images.js';
const ROOT = process.cwd();
const CONTENT = path.join(ROOT, 'content');
const OUT = path.join(ROOT, 'out');
const cfg = JSON.parse(await fs.readFile(path.join(ROOT, 'config.json'), 'utf8'));
if (cfg.repo === 'USER/REPO') {
console.error('Заполните config.json: поле "repo" (например "ivanov/panel-content").');
process.exit(1);
}
// CONTENT_SHA задаёт CI; локально используем ветку (TTL кэша jsDelivr у ветки больше).
const sha = process.env.CONTENT_SHA ?? cfg.branch;
const CDN = `https://cdn.jsdelivr.net/gh/${cfg.repo}@${sha}`;
const errors = [];
const imageCache = new Map();
function report(file, pos, hint) {
errors.push({ file, line: pos?.line ?? 0, column: pos?.column ?? 0, hint });
}
// Относительные пути картинок резолвятся от .md-файла, оптимизируются sharp-ом
// и переписываются на абсолютный URL (jsDelivr @sha).
async function resolveImage(src, { absPath, id, pos }) {
if (!src) return null;
if (/^https?:\/\//i.test(src)) {
if (!/^https:\/\//i.test(src)) {
report(absPath, pos, `внешняя картинка должна быть https: ${src}`);
return null;
}
return src;
}
const abs = path.resolve(path.dirname(absPath), src);
if (!abs.startsWith(path.join(CONTENT, path.sep))) {
report(absPath, pos, `путь картинки вне content/: ${src}`);
return null;
}
try {
await fs.access(abs);
} catch {
report(absPath, pos, `файл картинки не найден: ${src}`);
return null;
}
const hash = await hashFile(abs);
const name = `${id}-${hash}.webp`;
if (!imageCache.has(name)) {
await optimizeImage(abs, path.join(OUT, 'img', name), { width: 640 });
imageCache.set(name, true);
}
return `${CDN}/img/${name}`;
}
async function main() {
await fs.rm(OUT, { recursive: true, force: true });
await fs.mkdir(path.join(OUT, 'docs'), { recursive: true });
const docsDir = path.join(CONTENT, 'docs');
const files = (await fs.readdir(docsDir)).filter((f) => f.endsWith('.md'));
if (!files.length) {
console.error('В content/docs/ нет .md файлов.');
process.exit(1);
}
const index = [];
for (const fileName of files) {
const absPath = path.join(docsDir, fileName);
const id = fileName.replace(/\.md$/, '');
const { data, content } = matter(await fs.readFile(absPath, 'utf8'));
if (typeof data.title !== 'string' || !data.title.trim()) {
report(absPath, null, 'front-matter: обязательно поле title (строка)');
continue;
}
const ast = parseDoc(content);
errors.push(
...validateDoc(ast, fileName).map((e) => ({
file: e.file,
line: e.line,
column: e.column,
hint: `${e.type}: ${e.hint}`,
})),
);
if (errors.some((e) => e.file === fileName)) continue;
const blocks = await serializeDoc(ast, {
resolveImage: (src, pos) => resolveImage(src, { absPath, id, pos }),
});
// Заголовочная картинка: относительный путь от content/assets, кроп 636×340 (318×170 @2x).
let header = null;
if (data.header) {
const headerPath = path.resolve(CONTENT, 'assets', data.header);
try {
await fs.access(headerPath);
} catch {
report(absPath, null, `front-matter header: файл не найден content/assets/${data.header}`);
continue;
}
const name = `${id}-header.webp`;
await optimizeImage(headerPath, path.join(OUT, 'img', name), {
width: 636,
height: 340,
});
header = `${CDN}/img/${name}`;
}
await fs.writeFile(
path.join(OUT, 'docs', `${id}.json`),
JSON.stringify({ id, title: data.title, header, blocks }),
);
index.push({
id,
title: data.title,
order: Number.isFinite(data.order) ? data.order : 1000,
hidden: Boolean(data.hidden),
header,
url: `${CDN}/docs/${id}.json`,
});
}
if (errors.length) {
console.error('\nОшибки сборки контента:');
for (const e of errors) {
console.error(` ${e.file}${e.line ? `:${e.line}${e.column ? `:${e.column}` : ''}` : ''}${e.hint}`);
}
process.exit(1);
}
index.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
await fs.writeFile(path.join(OUT, 'index.json'), JSON.stringify(index, null, 2));
const docsCount = index.length;
const size = (await fs.stat(path.join(OUT, 'index.json'))).size;
console.log(`Готово: ${docsCount} документ(ов), index.json ${size} байт → out/`);
}
await main();
+18
View File
@@ -0,0 +1,18 @@
import sharp from 'sharp';
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
export async function hashFile(p) {
const buf = await fs.readFile(p);
return crypto.createHash('sha256').update(buf).digest('hex').slice(0, 8);
}
export async function optimizeImage(src, dest, { width = 640, height = null } = {}) {
await fs.mkdir(path.dirname(dest), { recursive: true });
let img = sharp(src);
img = height
? img.resize(width, height, { fit: 'cover' })
: img.resize({ width, withoutEnlargement: true });
await img.webp({ quality: 80 }).toFile(dest);
}
+178
View File
@@ -0,0 +1,178 @@
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkGfm from 'remark-gfm';
const parser = unified().use(remarkParse).use(remarkGfm);
export function parseDoc(md) {
return parser.parse(md);
}
// Белый список узлов mdast — всё прочее = ошибка сборки.
const ALLOWED = new Set([
'root',
'heading',
'paragraph',
'text',
'emphasis',
'strong',
'delete',
'inlineCode',
'break',
'link',
'image',
'list',
'listItem',
'blockquote',
'thematicBreak',
]);
const HINTS = {
html: 'сырой HTML в markdown запрещён',
code: 'блоки кода запрещены (подсветка не поддерживается)',
table: 'таблицы не поддерживаются',
footnoteDefinition: 'сноски не поддерживаются',
linkReference: 'используйте обычные ссылки [текст](https://…)',
imageReference: 'используйте обычные картинки ![](путь)',
definition: 'ссылки-определения не поддерживаются',
yaml: 'yaml-блок внутри документа запрещён',
};
const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
export function validateDoc(root, fileName) {
const errors = [];
const walk = (node) => {
for (const child of node.children ?? []) {
const pos = child.position?.start ?? {};
if (!ALLOWED.has(child.type)) {
errors.push({
file: fileName,
line: pos.line ?? 0,
column: pos.column ?? 0,
type: child.type,
hint: HINTS[child.type] ?? 'не поддерживается белым списком',
});
continue;
}
if (child.type === 'link' && !/^https?:\/\//i.test(child.url)) {
errors.push({
file: fileName,
line: pos.line ?? 0,
column: pos.column ?? 0,
type: 'link',
hint: `ссылка должна быть http(s): ${child.url}`,
});
continue;
}
if (
child.type === 'image' &&
SCHEME_RE.test(child.url) &&
!/^https?:\/\//i.test(child.url)
) {
errors.push({
file: fileName,
line: pos.line ?? 0,
column: pos.column ?? 0,
type: 'image',
hint: `картинка должна быть http(s) или относительным путём: ${child.url}`,
});
continue;
}
walk(child);
}
};
walk(root);
return errors;
}
// --- Сериализация в мини-AST для DocRenderer.svelte ---
// Блоки: ["p", ...inline] | ["h1".."h6", ...inline] | ["ul"|"ol", [items]]
// | ["blockquote", ...blocks] | ["hr"]
// Инлайн: строка | ["em"|"strong"|"del", ...inline] | ["code", text]
// | ["br"] | ["a", href, ...inline] | ["img", url, alt]
const norm = (s) => s.replace(/\s+/g, ' ');
export async function serializeDoc(root, ctx) {
const blocks = [];
for (const child of root.children) {
blocks.push(...(await serBlock(child, ctx)));
}
return blocks;
}
async function serBlock(node, ctx) {
switch (node.type) {
case 'heading':
return [['h' + Math.min(node.depth, 6), ...(await inline(node.children, ctx))]];
case 'paragraph': {
if (node.children.length === 1 && node.children[0].type === 'image') {
const img = await serImage(node.children[0], ctx);
return img ? [img] : [];
}
return [['p', ...(await inline(node.children, ctx))]];
}
case 'list': {
const items = [];
for (const item of node.children) items.push(await serItem(item, ctx));
return [[node.ordered ? 'ol' : 'ul', items]];
}
case 'blockquote': {
const blocks = [];
for (const child of node.children) blocks.push(...(await serBlock(child, ctx)));
return [['blockquote', ...blocks]];
}
case 'thematicBreak':
return [['hr']];
default:
return [];
}
}
async function serItem(item, ctx) {
const prefix = item.checked === true ? '☑ ' : item.checked === false ? '☐ ' : '';
const single =
item.children.length === 1 && item.children[0].type === 'paragraph';
const inner = single
? await inline(item.children[0].children, ctx)
: (await Promise.all(item.children.map((c) => serBlock(c, ctx)))).flat();
return prefix ? ['li', prefix, ...inner] : ['li', ...inner];
}
async function inline(nodes, ctx) {
const out = [];
for (const node of nodes) {
const serialized = await serInline(node, ctx);
if (serialized !== null && serialized !== '') out.push(serialized);
}
return out;
}
async function serInline(node, ctx) {
switch (node.type) {
case 'text':
return norm(node.value);
case 'emphasis':
return ['em', ...(await inline(node.children, ctx))];
case 'strong':
return ['strong', ...(await inline(node.children, ctx))];
case 'delete':
return ['del', ...(await inline(node.children, ctx))];
case 'inlineCode':
return ['code', node.value];
case 'break':
return ['br'];
case 'link':
return ['a', node.url, ...(await inline(node.children, ctx))];
case 'image':
return serImage(node, ctx);
default:
return null;
}
}
async function serImage(node, ctx) {
const url = await ctx.resolveImage(node.url, node.position?.start);
return url ? ['img', url, node.alt ?? ''] : null;
}
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Panel Config</title>
<!-- Extension Helper обязателен для всех html расширения -->
<script src="https://extension-files.twitch.tv/helper/v1/twitch-ext.min.js"></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/config/main.js"></script>
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
{
"repo": "USER/REPO",
"branch": "published"
}
+18
View File
@@ -0,0 +1,18 @@
---
title: О канале
order: 1
---
Привет! Я стримлю **игры и музыку** по вечерам.
## Расписание
- Вт, Чт, Сб — с 19:00 МСК
- Иногда *утренние* стримы в выходные
> Расписание может меняться — следи за постами в Discord.
## Где меня найти
1. [Telegram](https://t.me/example) — анонсы
2. [Discord](https://discord.gg/example) — общение
+16
View File
@@ -0,0 +1,16 @@
---
title: Правила чата
order: 2
---
## Общие правила
1. Уважай друг друга — *без* оскорблений и политики.
2. Без спама и капса.
3. Вопросы задавай в чат — модераторы помогут.
## Наказания
- Первый раз — предупреждение
- Второй раз — **таймаут 10 минут**
- Третий раз — бан
+3272
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "twitch-panel-bio",
"private": true,
"type": "module",
"version": "0.1.0",
"scripts": {
"dev": "vite",
"build:viewer": "vite build -c vite.build.viewer.js",
"build:config": "vite build -c vite.build.config.js",
"build": "npm run build:viewer && npm run build:config && node scripts/pack.js",
"build:content": "node builder/build.js"
},
"dependencies": {
"gray-matter": "^4.0.3",
"remark-gfm": "^4.0.0",
"remark-parse": "^11.0.0",
"sharp": "^0.33.5",
"svelte": "^5.0.0",
"unified": "^11.0.4"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@vitejs/plugin-basic-ssl": "^1.2.0",
"vite": "^6.0.0",
"vite-plugin-singlefile": "^2.0.3"
}
}
+6
View File
@@ -0,0 +1,6 @@
import fs from 'node:fs/promises';
await fs.mkdir('dist/pkg', { recursive: true });
await fs.copyFile('dist/viewer/viewer.html', 'dist/pkg/viewer.html');
await fs.copyFile('dist/config/config.html', 'dist/pkg/config.html');
console.log('dist/pkg готов: viewer.html + config.html — загрузите их содержимое как zip-билд в консоли Twitch.');
+149
View File
@@ -0,0 +1,149 @@
<script>
import { isTwitch, getBroadcasterConfig, saveBroadcasterConfig } from '../shared/twitch.js';
import { INDEX_URL } from '../shared/content.js';
let list = $state([]); // рабочая копия: { id, title, hidden }
let status = $state('loading'); // loading | ready | error
let error = $state('');
let saved = $state(false);
const savedCfg = getBroadcasterConfig();
async function load() {
status = 'loading';
error = '';
try {
const res = await fetch(INDEX_URL);
if (!res.ok) throw new Error(`index.json: HTTP ${res.status}`);
const index = await res.json();
const hidden = new Set(savedCfg?.hidden ?? []);
const order = new Map((savedCfg?.order ?? []).map((id, i) => [id, i]));
list = index
.filter((d) => !d.hidden)
.map((d) => ({ id: d.id, title: d.title, hidden: hidden.has(d.id) }))
.sort((a, b) => (order.get(a.id) ?? Infinity) - (order.get(b.id) ?? Infinity));
status = 'ready';
} catch (e) {
console.error(e);
error = String(e.message ?? e);
status = 'error';
}
}
load();
function move(i, dir) {
const j = i + dir;
if (j < 0 || j >= list.length) return;
[list[i], list[j]] = [list[j], list[i]];
}
function save() {
saved = saveBroadcasterConfig({
v: 1,
hidden: list.filter((d) => d.hidden).map((d) => d.id),
order: list.map((d) => d.id),
});
}
</script>
<div class="config">
<h1>Документы панели</h1>
<p class="muted">Порядок и видимость для этого канала. Содержимое документов правится в git-репозитории.</p>
{#if status === 'loading'}
<p class="muted">Загрузка…</p>
{:else if status === 'error'}
<p>Не удалось загрузить index.json.</p>
<p class="muted">{error}</p>
<button onclick={load}>Повторить</button>
{:else}
{#each list as doc, i (doc.id)}
<div class="row">
<span class="title">{doc.title}</span>
<span class="controls">
<button title="Выше" onclick={() => move(i, -1)} disabled={i === 0}></button>
<button title="Ниже" onclick={() => move(i, 1)} disabled={i === list.length - 1}></button>
<label>
<input type="checkbox" bind:checked={doc.hidden} />
скрыть
</label>
</span>
</div>
{:else}
<p class="muted">Список пуст: соберите контент билдером и сделайте push.</p>
{/each}
<div class="save">
<button onclick={save}>Сохранить</button>
{#if saved}
<span class="ok">Сохранено</span>
{:else if !isTwitch}
<span class="muted">Локальный режим: сохранение доступно только в Creator Dashboard.</span>
{/if}
</div>
{/if}
</div>
<style>
.config {
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
color: #efeff1;
background: #0e0e10;
min-height: 100vh;
box-sizing: border-box;
padding: 16px;
max-width: 640px;
}
h1 {
font-size: 1.2em;
}
.muted {
color: #adadb8;
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: 1px solid #3a3a3d;
border-radius: 6px;
margin-bottom: 6px;
}
.controls {
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
label {
font-size: 0.85em;
color: #adadb8;
}
button {
background: #3a3a3d;
color: #efeff1;
border: 0;
border-radius: 6px;
height: 28px;
min-width: 28px;
cursor: pointer;
}
button:hover {
filter: brightness(1.2);
}
.save {
margin-top: 12px;
display: flex;
align-items: center;
gap: 10px;
}
.save button {
min-width: 100px;
height: 32px;
}
.ok {
color: #00f593;
}
</style>
+4
View File
@@ -0,0 +1,4 @@
import { mount } from 'svelte';
import App from './App.svelte';
export default mount(App, { target: document.getElementById('app') });
+139
View File
@@ -0,0 +1,139 @@
<script>
// Рекурсивный рендерер мини-AST из билдера. Никакого {@html}: строка = текст
// (эскейпит Svelte), узел = [tag, ...rest]. Неизвестное — пропускаем с warning.
let { nodes = [] } = $props();
// Картинки разрешены только с контентных CDN-хостов (даже если JSON подменили).
const IMG_HOSTS = /^https:\/\/(cdn\.jsdelivr\.net|raw\.githubusercontent\.com)\//;
const LINK_RE = /^https?:\/\//i;
function safeImg(src) {
return typeof src === 'string' && IMG_HOSTS.test(src) ? src : null;
}
function safeLink(href) {
return typeof href === 'string' && LINK_RE.test(href) ? href : null;
}
</script>
{#snippet children(ns)}
{#each ns as c}{@render node(c)}{/each}
{/snippet}
{#snippet node(n)}
{#if typeof n === 'string'}
{n}
{:else if Array.isArray(n)}
{@const [tag, ...rest] = n}
{#if tag === 'p'}
<p>{@render children(rest)}</p>
{:else if tag === 'br'}
<br />
{:else if tag === 'hr'}
<hr />
{:else if tag === 'em'}
<em>{@render children(rest)}</em>
{:else if tag === 'strong'}
<strong>{@render children(rest)}</strong>
{:else if tag === 'del'}
<del>{@render children(rest)}</del>
{:else if tag === 'code'}
<code>{rest[0]}</code>
{:else if tag === 'a'}
{@const href = safeLink(rest[0])}
{#if href}
<a href={href} target="_blank" rel="noopener noreferrer">{@render children(rest.slice(1))}</a>
{:else}
{@render children(rest.slice(1))}
{/if}
{:else if tag === 'img'}
{@const src = safeImg(rest[0])}
{#if src}
<img src={src} alt={rest[1] ?? ''} loading="lazy" />
{/if}
{:else if /^h[1-6]$/.test(tag)}
<svelte:element this={tag}>{@render children(rest)}</svelte:element>
{:else if tag === 'ul' || tag === 'ol'}
<svelte:element this={tag}>
{#each rest[0] ?? [] as item}{@render node(item)}{/each}
</svelte:element>
{:else if tag === 'li'}
<li>{@render children(rest)}</li>
{:else if tag === 'blockquote'}
<blockquote>{#each rest as b}{@render node(b)}{/each}</blockquote>
{:else}
{@const _warn = console.warn('DocRenderer: неизвестный узел', tag)}
{/if}
{/if}
{/snippet}
{#each nodes as n}{@render node(n)}{/each}
<style>
p {
margin: 0.35em 0;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0.8em 0 0.3em;
line-height: 1.25;
}
h1 {
font-size: 1.15em;
}
h2 {
font-size: 1.08em;
}
h3,
h4,
h5,
h6 {
font-size: 1em;
}
ul,
ol {
margin: 0.35em 0;
padding-left: 1.4em;
}
li {
margin: 0.15em 0;
}
blockquote {
margin: 0.5em 0;
padding: 0.1em 0.8em;
border-left: 3px solid var(--muted);
color: var(--muted);
}
hr {
border: 0;
border-top: 1px solid var(--border);
margin: 0.8em 0;
}
a {
color: var(--link);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
img {
max-width: 100%;
border-radius: 6px;
display: block;
margin: 0.5em 0;
}
code {
font-family: ui-monospace, Consolas, monospace;
font-size: 0.9em;
background: var(--border);
border-radius: 4px;
padding: 0.1em 0.35em;
}
del {
opacity: 0.6;
}
</style>
+6
View File
@@ -0,0 +1,6 @@
import cfg from '../../config.json';
// Индекс контента публикуется CI в ветку `published`; raw.githubusercontent
// отдаёт его с кэшем 5 минут и CORS *. Документы и картинки внутри индекса —
// абсолютные URL на jsDelivr, запиннованные на SHA коммита (иммутабельные).
export const INDEX_URL = `https://raw.githubusercontent.com/${cfg.repo}/${cfg.branch}/index.json`;
+46
View File
@@ -0,0 +1,46 @@
// Обёртка над Twitch Extension Helper с заглушкой для локальной разработки:
// вне Twitch (npm run dev) всё работает, сохранение конфига просто логируется.
const ext = globalThis.Twitch?.ext ?? null;
export const isTwitch = Boolean(ext);
const contextListeners = [];
const authListeners = [];
if (ext) {
ext.onContext((ctx) => contextListeners.forEach((cb) => cb(ctx)));
ext.onAuthorized((auth) => authListeners.forEach((cb) => cb(auth)));
}
export function onContext(cb) {
contextListeners.push(cb);
if (!ext) cb({ theme: 'dark' });
}
export function onAuthorized(cb) {
authListeners.push(cb);
}
// Сегмент broadcaster — публичные настройки канала (JSON или null).
export function getBroadcasterConfig() {
if (!ext) return null;
const raw = ext.configuration?.broadcaster?.content;
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
console.warn('twitch.js: broadcaster config не парсится как JSON');
return null;
}
}
// Разрешено вызывать только из config-вью от имени стримера.
export function saveBroadcasterConfig(value) {
if (!ext) {
console.warn('[dev] saveBroadcasterConfig:', value);
return false;
}
ext.configuration.set('broadcaster', '1', JSON.stringify(value));
return true;
}
+177
View File
@@ -0,0 +1,177 @@
<script>
import { onContext, getBroadcasterConfig } from '../shared/twitch.js';
import { INDEX_URL } from '../shared/content.js';
import DocRenderer from '../shared/DocRenderer.svelte';
let theme = $state('dark');
onContext((ctx) => {
if (ctx.theme) theme = ctx.theme;
});
let docs = $state([]);
let current = $state(0);
let status = $state('loading'); // loading | ready | empty | error
let loadError = $state('');
let doc = $state(null);
let docError = $state('');
// Per-channel исключения из конфиг-сегмента: скрыть/перепорядочить.
const overrides = getBroadcasterConfig();
async function loadIndex() {
status = 'loading';
loadError = '';
try {
const res = await fetch(INDEX_URL);
if (!res.ok) throw new Error(`index.json: HTTP ${res.status}`);
let list = await res.json();
if (overrides) {
const hidden = new Set(overrides.hidden ?? []);
const order = new Map((overrides.order ?? []).map((id, i) => [id, i]));
list = list.filter((d) => !hidden.has(d.id));
list.sort((a, b) => (order.get(a.id) ?? Infinity) - (order.get(b.id) ?? Infinity));
}
docs = list;
if (!list.length) {
status = 'empty';
return;
}
status = 'ready';
await loadDoc(0);
} catch (e) {
console.error(e);
loadError = String(e.message ?? e);
status = 'error';
}
}
async function loadDoc(i) {
current = i;
doc = null;
docError = '';
try {
const res = await fetch(docs[i].url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
doc = await res.json();
} catch (e) {
console.error(e);
docError = String(e.message ?? e);
}
}
const prev = () => current > 0 && loadDoc(current - 1);
const next = () => current < docs.length - 1 && loadDoc(current + 1);
loadIndex();
</script>
<div class="panel" data-theme={theme}>
{#if status === 'loading'}
<p class="muted center">Загрузка…</p>
{:else if status === 'error'}
<p class="center">Не удалось загрузить документы.</p>
<p class="muted center">{loadError}</p>
<p class="center"><button onclick={loadIndex}>Повторить</button></p>
{:else if status === 'empty'}
<p class="muted center">Документов пока нет.</p>
{:else}
{#if docs[current]?.header}
<img class="header" src={docs[current].header} alt="" />
{/if}
<main>
<h1 class="title">{doc?.title ?? docs[current].title}</h1>
{#if docError}
<p class="muted">Не удалось загрузить документ ({docError}).</p>
{:else if doc}
<DocRenderer nodes={doc.blocks} />
{:else}
<p class="muted">Загрузка…</p>
{/if}
</main>
{#if docs.length > 1}
<nav>
<button onclick={prev} disabled={current === 0} aria-label="Предыдущий документ"></button>
<span class="counter">{current + 1} / {docs.length}</span>
<button onclick={next} disabled={current === docs.length - 1} aria-label="Следующий документ"></button>
</nav>
{/if}
{/if}
</div>
<style>
.panel {
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
padding: 10px;
gap: 8px;
/* Тема приходит из Twitch (onContext) */
--text: #efeff1;
--muted: #adadb8;
--border: #3a3a3d;
--link: #bf94ff;
color: var(--text);
}
.panel[data-theme='light'] {
--text: #0e0e10;
--muted: #53535f;
--border: #dcdde1;
--link: #6441a5;
}
.header {
width: 100%;
height: 150px;
object-fit: cover;
border-radius: 6px;
flex-shrink: 0;
}
main {
flex: 1;
overflow-y: auto;
min-height: 0;
padding-right: 2px;
}
.title {
font-size: 1.1em;
margin: 0 0 0.4em;
}
.muted {
color: var(--muted);
}
.center {
text-align: center;
}
nav {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
border-top: 1px solid var(--border);
padding-top: 6px;
}
.counter {
color: var(--muted);
font-size: 0.85em;
min-width: 48px;
text-align: center;
}
button {
background: var(--border);
color: var(--text);
border: 0;
border-radius: 6px;
min-width: 32px;
height: 28px;
font-size: 1.1em;
cursor: pointer;
}
button:hover:not(:disabled) {
filter: brightness(1.2);
}
button:disabled {
opacity: 0.4;
cursor: default;
}
</style>
+5
View File
@@ -0,0 +1,5 @@
import { mount } from 'svelte';
import App from './App.svelte';
import './viewer.css';
export default mount(App, { target: document.getElementById('app') });
+16
View File
@@ -0,0 +1,16 @@
/* Панель живёт в iframe 318×496; фон прозрачный — подложку даёт Twitch. */
:root {
color-scheme: dark light;
}
html,
body {
height: 100%;
margin: 0;
}
body {
background: transparent;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
}
#app {
height: 100%;
}
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Panel</title>
<!-- Extension Helper обязателен для всех html расширения -->
<script src="https://extension-files.twitch.tv/helper/v1/twitch-ext.min.js"></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/viewer/main.js"></script>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { viteSingleFile } from 'vite-plugin-singlefile';
// Конфиг-вью (панель управления в Creator Dashboard): отдельный самодостаточный HTML.
export default defineConfig({
plugins: [svelte(), viteSingleFile()],
build: {
outDir: 'dist/config',
emptyOutDir: true,
rollupOptions: { input: 'config.html' },
},
});
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { viteSingleFile } from 'vite-plugin-singlefile';
// Вьювер-панель: один самодостаточный HTML без внешних js/css.
export default defineConfig({
plugins: [svelte(), viteSingleFile()],
build: {
outDir: 'dist/viewer',
emptyOutDir: true,
rollupOptions: { input: 'viewer.html' },
},
});
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import basicSsl from '@vitejs/plugin-basic-ssl';
// Dev-сервер: обслуживает и /viewer.html, и /config.html на одном origin
// (Twitch требует один Base URI). HTTPS — обязательное требование Twitch.
export default defineConfig({
plugins: [svelte(), basicSsl()],
server: { port: 8080 },
});