mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-05 14:19:04 +00:00
feat(tools): страница /convert для конвертера Sweet Home 3D, этап 2
Артефакт один — scripts/sh3d-convert/dist/index.html, самодостаточный файл, как и лендинг: раскладывается копированием, без сборщиков и CDN. Собирается склейкой тех же модулей, которые проверяют гейты этапа 1; относительных импортов на странице нет, поэтому она работает из любого каталога и без сети. Три обещания страницы проверяются тестами, а не текстом: 1. Ни одного сетевого вызова и ни одной внешней загрузки. Ищутся формы ВЫЗОВА, а не имена API: первая редакция теста краснела на собственном комментарии, где эти API перечислены как запрещённые. Шрифты системные — <link> к шрифтам это внешний запрос при загрузке, а обещание «файл не покидает браузер» должно быть буквальным. Заодно поэтому на странице нет аналитики. 2. Конвертер внутри страницы даёт тот же результат, что модули репозитория: код извлекается из собранного артефакта, прогоняется на фикстуре и сравнивается с golden. Склейка, потерявшая модуль, краснеет. 3. Собранная страница не отстала от исходников: build-page.mjs --check сверяет закоммиченный артефакт с пересборкой. Плюс полнота переводов: у каждой подписи есть ru и en, словари совпадают по ключам, у каждого кода отчёта и каждого кода отказа есть человеческий текст. Иначе пользователь увидит пустую надпись или `not_zip`. Отчёт показывается ДО скачивания и по этажам: сколько комнат, стен и проёмов, какая клетка сетки, и полный список мест, где конвертер принял решение, — спрямлённая дуга, обрезанная толщина, непривязанный проём, пропущенный этаж без комнат. Это требование задачи: искать чужие ошибки в готовом плане из сотен объектов хуже, чем прочитать их заранее. Гайды (ru/en) получили раздел про импорт из Sweet Home 3D с прямой записью, что это необязательный ярлык, а не шаг настройки: SCOPE обещает «no external editors», и обходить это молчанием нельзя. Свидетели этапа 2, все проверены отрицательным прогоном: добавленный в страницу fetch, потерянный русский перевод, правка dist руками, склейка без модуля sh3d.mjs — каждая краснит свой тест. Гейты: npm test 1871 tests, 1870 pass, 0 fail; pytest без HA 378 passed, 3 skipped. Осталось владельцу: раскладка на хост (из песочницы SSH недоступен) и проверка на настоящем файле Sweet Home 3D. Issue: #446 User-Visible: no
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Сборка страницы /convert в один самодостаточный файл (#446).
|
||||
*
|
||||
* Артефакт один: `dist/index.html`. Так же устроен лендинг, и так же его
|
||||
* раскладывают на хост — копированием, без сборщиков и без CDN.
|
||||
*
|
||||
* Модули склеиваются, а не грузятся по относительным путям: страница обязана
|
||||
* работать из любого каталога и без сети. Склейка нарочно тупая — снимаются
|
||||
* строки `import ... from '...'` и префикс `export `. Многострочный импорт
|
||||
* ломает такую склейку молча, поэтому он ОТКАЗ, а не предупреждение.
|
||||
*
|
||||
* node scripts/sh3d-convert/build-page.mjs [--check]
|
||||
*
|
||||
* `--check` не пишет файл, а сверяет закоммиченный артефакт с пересборкой:
|
||||
* этим тест ловит правку страницы или модулей без пересборки.
|
||||
*/
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ORDER = ['xml.mjs', 'zip.mjs', 'convert.mjs', 'sh3d.mjs', 'page/app.mjs'];
|
||||
const MARKER = '/* <!--BUNDLE--> */';
|
||||
|
||||
const stripModuleSyntax = (source, name) => {
|
||||
const lines = source.split('\n');
|
||||
const out = [];
|
||||
for (const [index, line] of lines.entries()) {
|
||||
if (/^\s*import\s/.test(line)) {
|
||||
if (!/;\s*$/.test(line)) {
|
||||
throw new Error(
|
||||
`${name}:${index + 1}: многострочный import ломает склейку — запишите его в одну строку`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
out.push(line.replace(/^export\s+(?=const|function|class|async|let)/, ''));
|
||||
}
|
||||
const remaining = out.find((line) => /^\s*export\s/.test(line));
|
||||
if (remaining) throw new Error(`${name}: не снят export: ${remaining.trim()}`);
|
||||
return out.join('\n');
|
||||
};
|
||||
|
||||
export function buildPage() {
|
||||
const shell = readFileSync(join(HERE, 'page/shell.html'), 'utf8');
|
||||
if (!shell.includes(MARKER)) throw new Error('в shell.html нет метки вставки');
|
||||
const bundle = ORDER
|
||||
.map((name) => `// --- ${name} ---\n${stripModuleSyntax(readFileSync(join(HERE, name), 'utf8'), name)}`)
|
||||
.join('\n');
|
||||
return shell.replace(MARKER, `${bundle}\nmount();`);
|
||||
}
|
||||
|
||||
const page = buildPage();
|
||||
const target = join(HERE, 'dist', 'index.html');
|
||||
if (process.argv.includes('--check')) {
|
||||
const committed = readFileSync(target, 'utf8');
|
||||
if (committed !== page) {
|
||||
console.error('FAILED: dist/index.html отстал от исходников —'
|
||||
+ ' пересоберите: node scripts/sh3d-convert/build-page.mjs');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('ok: dist/index.html совпадает с пересборкой');
|
||||
} else {
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, page);
|
||||
console.log(`dist/index.html: ${page.length} Б`);
|
||||
}
|
||||
Vendored
+1258
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Страница /convert: интерфейс конвертера (#446).
|
||||
*
|
||||
* Вся конверсия идёт здесь, в браузере. Ни одного сетевого вызова на этой
|
||||
* странице нет и быть не должно: план дома — приватные данные, а сама страница
|
||||
* живёт на хосте, который по нашему описанию расходный и потенциально
|
||||
* враждебный. Отсутствие `fetch`/`XMLHttpRequest`/`WebSocket`/`sendBeacon`
|
||||
* проверяется тестом по собранному артефакту, а не обещанием в тексте.
|
||||
*
|
||||
* Отчёт показывается ДО скачивания — это требование задачи, а не украшение:
|
||||
* человек должен узнать про спрямлённую дугу и непривязанную дверь заранее, а
|
||||
* не искать их потом в плане из сотен объектов.
|
||||
*/
|
||||
import { readSh3d } from '../sh3d.mjs';
|
||||
import { ConvertError, MIN_HOUSEPLAN, convertHome } from '../convert.mjs';
|
||||
|
||||
const TOOL_VERSION = 'sh3d-convert 0.1';
|
||||
const MAX_FILE_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
/** Словари вынесены в экспорт: полноту ключей проверяет тест страницы. */
|
||||
export const TEXT = {
|
||||
ru: {
|
||||
'page.title': 'Конвертер Sweet Home 3D → House Plan',
|
||||
'page.lead': 'Файл плана из Sweet Home 3D превращается в документ импорта House Plan — по одному на этаж.',
|
||||
'page.privacy': 'Конверсия идёт в вашем браузере. Файл никуда не загружается: страница не делает ни одного сетевого запроса.',
|
||||
'page.requires': `Требуется House Plan ${MIN_HOUSEPLAN} или новее.`,
|
||||
'drop.title': 'Перетащите сюда файл .sh3d',
|
||||
'drop.or': 'или',
|
||||
'drop.button': 'Выбрать файл',
|
||||
'drop.hint': 'Ничего не уходит с вашего компьютера',
|
||||
'busy': 'Читаю файл…',
|
||||
'result.title': 'Что получилось',
|
||||
'result.download': 'Скачать этаж',
|
||||
'result.rooms': 'комнат',
|
||||
'result.walls': 'стен',
|
||||
'result.openings': 'проёмов',
|
||||
'result.cell': 'клетка сетки',
|
||||
'result.skipped': 'этаж пропущен: комнат нет',
|
||||
'result.notes': 'На что посмотреть после импорта',
|
||||
'howto.title': 'Как импортировать',
|
||||
'howto.1': 'Откройте карточку House Plan → Общие настройки → Резервная копия и перенос.',
|
||||
'howto.2': 'Выберите импорт и укажите скачанный файл: House Plan покажет предпросмотр и ничего не запишет до подтверждения.',
|
||||
'howto.3': 'Этаж добавится рядом с существующими пространствами. Привязку комнат к областям Home Assistant и расстановку устройств сделайте в редакторе — в файле их нет.',
|
||||
'error.title': 'Не получилось',
|
||||
'error.retry': 'Выбрать другой файл',
|
||||
'back': 'На главную',
|
||||
'note.vertices_snapped': 'Вершины комнат выровнены по осевым линиям стен: {count}. Sweet Home 3D обводит комнаты по внутренним граням, иначе общая стена превратилась бы в две.',
|
||||
'note.curved_wall_straightened': 'Круглых стен спрямлено: {count}. Дуг в House Plan нет.',
|
||||
'note.thickness_clamped': 'Стен толще {limit} см обрезано до предела: {count}.',
|
||||
'note.edge_without_wall': 'Границ без стены: {count}. Такая граница останется открытой — стены в файле там не было.',
|
||||
'note.opening_unhosted': 'Проём не привязался ни к одной стене и не перенесён: {id}.',
|
||||
'note.opening_shortened': 'Проём шире стены и обрезан до неё: {id}.',
|
||||
'note.opening_without_width': 'Проём без ширины пропущен: {id}.',
|
||||
'note.room_without_polygon': 'Комната без контура пропущена: {id}.',
|
||||
'note.room_collapsed': 'Контур комнаты рассыпался после выравнивания и пропущен: {id}.',
|
||||
'note.level_without_rooms': 'Этаж «{title}» пропущен: в нём нет ни одной комнаты. House Plan строит геометрию по комнатам — нарисуйте их в Sweet Home 3D.',
|
||||
'note.furniture_dropped': 'Мебель, материалы, свет и камеры не переносятся: House Plan — живая карта дома, а не редактор интерьера.',
|
||||
'code.not_zip': 'Это не файл Sweet Home 3D: архив не читается.',
|
||||
'code.entry_missing': 'В архиве нет Home.xml. Пересохраните план в Sweet Home 3D 5.3 или новее.',
|
||||
'code.encrypted': 'Архив защищён паролем.',
|
||||
'code.zip64': 'Такой архив (zip64) не поддерживается.',
|
||||
'code.entry_too_large': 'Файл слишком большой.',
|
||||
'code.entity_declaration': 'В файле объявлены XML-сущности — такой файл не читается.',
|
||||
'code.entity_reference': 'В файле есть неизвестная XML-сущность.',
|
||||
'code.nothing_to_convert': 'В плане нет ни одной комнаты. House Plan строит геометрию по комнатам: нарисуйте их в Sweet Home 3D и сохраните файл заново.',
|
||||
'code.room_too_complex': 'Слишком сложный контур комнаты.',
|
||||
'code.unit_not_metric': 'План сохранён не в метрических единицах.',
|
||||
'code.file_too_large': 'Файл больше 64 МБ.',
|
||||
'code.unknown': 'Неизвестная ошибка чтения файла.',
|
||||
},
|
||||
en: {
|
||||
'page.title': 'Sweet Home 3D → House Plan converter',
|
||||
'page.lead': 'Turns a Sweet Home 3D file into House Plan import documents — one per level.',
|
||||
'page.privacy': 'The conversion runs in your browser. The file is never uploaded: this page makes no network requests at all.',
|
||||
'page.requires': `Requires House Plan ${MIN_HOUSEPLAN} or newer.`,
|
||||
'drop.title': 'Drop a .sh3d file here',
|
||||
'drop.or': 'or',
|
||||
'drop.button': 'Choose a file',
|
||||
'drop.hint': 'Nothing leaves your computer',
|
||||
'busy': 'Reading the file…',
|
||||
'result.title': 'What came out',
|
||||
'result.download': 'Download level',
|
||||
'result.rooms': 'rooms',
|
||||
'result.walls': 'walls',
|
||||
'result.openings': 'openings',
|
||||
'result.cell': 'grid cell',
|
||||
'result.skipped': 'level skipped: no rooms',
|
||||
'result.notes': 'What to check after the import',
|
||||
'howto.title': 'How to import',
|
||||
'howto.1': 'Open the House Plan card → General settings → Backup & transfer.',
|
||||
'howto.2': 'Pick import and choose the downloaded file: House Plan shows a preview and writes nothing until you confirm.',
|
||||
'howto.3': 'The level is added next to your existing spaces. Bind rooms to Home Assistant areas and place devices in the editor — the file carries neither.',
|
||||
'error.title': 'That did not work',
|
||||
'error.retry': 'Choose another file',
|
||||
'back': 'Back to the site',
|
||||
'note.vertices_snapped': 'Room vertices aligned to wall centrelines: {count}. Sweet Home 3D traces rooms along inner faces, so without this a shared wall would become two.',
|
||||
'note.curved_wall_straightened': 'Curved walls straightened: {count}. House Plan has no arcs.',
|
||||
'note.thickness_clamped': 'Walls thicker than {limit} cm clamped to the limit: {count}.',
|
||||
'note.edge_without_wall': 'Boundaries without a wall: {count}. They stay open — there was no wall in the file.',
|
||||
'note.opening_unhosted': 'This opening matched no wall and was skipped: {id}.',
|
||||
'note.opening_shortened': 'Opening wider than its wall, shortened to fit: {id}.',
|
||||
'note.opening_without_width': 'Opening without a width, skipped: {id}.',
|
||||
'note.room_without_polygon': 'Room without an outline, skipped: {id}.',
|
||||
'note.room_collapsed': 'Room outline collapsed after alignment and was skipped: {id}.',
|
||||
'note.level_without_rooms': 'Level “{title}” skipped: it has no rooms. House Plan builds geometry from rooms — draw them in Sweet Home 3D.',
|
||||
'note.furniture_dropped': 'Furniture, materials, lights and cameras are not carried over: House Plan is a live map of the home, not an interior editor.',
|
||||
'code.not_zip': 'This is not a Sweet Home 3D file: the archive cannot be read.',
|
||||
'code.entry_missing': 'The archive has no Home.xml. Re-save the plan in Sweet Home 3D 5.3 or newer.',
|
||||
'code.encrypted': 'The archive is password protected.',
|
||||
'code.zip64': 'This archive (zip64) is not supported.',
|
||||
'code.entry_too_large': 'The file is too large.',
|
||||
'code.entity_declaration': 'The file declares XML entities and cannot be read.',
|
||||
'code.entity_reference': 'The file contains an unknown XML entity.',
|
||||
'code.nothing_to_convert': 'The plan has no rooms. House Plan builds geometry from rooms: draw them in Sweet Home 3D and save the file again.',
|
||||
'code.room_too_complex': 'A room outline is too complex.',
|
||||
'code.unit_not_metric': 'The plan is not saved in metric units.',
|
||||
'code.file_too_large': 'The file is larger than 64 MB.',
|
||||
'code.unknown': 'Unknown error while reading the file.',
|
||||
},
|
||||
};
|
||||
|
||||
let lang = 'en';
|
||||
|
||||
const fill = (template, values) =>
|
||||
String(template).replace(/\{(\w+)\}/g, (whole, key) =>
|
||||
(values && values[key] !== undefined ? String(values[key]) : whole));
|
||||
|
||||
const t = (key, values) => fill(TEXT[lang][key] ?? TEXT.en[key] ?? key, values);
|
||||
|
||||
function applyLanguage(next) {
|
||||
lang = TEXT[next] ? next : 'en';
|
||||
document.documentElement.lang = lang;
|
||||
try {
|
||||
localStorage.setItem('hp-convert-lang', lang);
|
||||
} catch {
|
||||
// Приватный режим: язык просто не запомнится, это не ошибка.
|
||||
}
|
||||
for (const node of document.querySelectorAll('[data-i18n]')) {
|
||||
node.textContent = t(node.dataset.i18n);
|
||||
}
|
||||
for (const node of document.querySelectorAll('[data-lang]')) {
|
||||
node.classList.toggle('on', node.dataset.lang === lang);
|
||||
}
|
||||
if (lastResult) renderResult(lastResult);
|
||||
}
|
||||
|
||||
let lastResult = null;
|
||||
|
||||
const element = (tag, className, text) => {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
};
|
||||
|
||||
const show = (id, visible) => {
|
||||
document.getElementById(id).hidden = !visible;
|
||||
};
|
||||
|
||||
function noteText(note) {
|
||||
const key = `note.${note.code}`;
|
||||
const known = TEXT[lang][key] || TEXT.en[key];
|
||||
return known ? fill(known, note) : `${note.code}: ${JSON.stringify(note)}`;
|
||||
}
|
||||
|
||||
function download(name, document_) {
|
||||
const blob = new Blob([`${JSON.stringify(document_, null, 2)}\n`], {
|
||||
type: 'application/json',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = element('a');
|
||||
link.href = url;
|
||||
link.download = name;
|
||||
link.click();
|
||||
// Ссылка живёт ровно до клика: держать её незачем, а память жалко.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
|
||||
function renderResult(result) {
|
||||
lastResult = result;
|
||||
const { documents, report, stem } = result;
|
||||
const host = document.getElementById('levels');
|
||||
host.textContent = '';
|
||||
let downloadIndex = 0;
|
||||
for (const level of report.levels) {
|
||||
const card = element('div', 'level');
|
||||
card.append(element('h3', null, level.title || level.id || '—'));
|
||||
if (level.skipped) {
|
||||
card.append(element('p', 'muted', t('result.skipped')));
|
||||
} else {
|
||||
const stats = element('p', 'stats');
|
||||
stats.textContent = [
|
||||
`${level.rooms} ${t('result.rooms')}`,
|
||||
`${level.walls} ${t('result.walls')}`,
|
||||
`${level.openings} ${t('result.openings')}`,
|
||||
`${t('result.cell')}: ${level.cellCm} cm`,
|
||||
].join(' · ');
|
||||
card.append(stats);
|
||||
const index = downloadIndex++;
|
||||
const button = element('button', 'btn primary', t('result.download'));
|
||||
button.addEventListener('click', () =>
|
||||
download(`${stem}.${level.spaceId}.json`, documents[index]));
|
||||
card.append(button);
|
||||
}
|
||||
const notes = [...(level.notes || [])];
|
||||
if (notes.length) {
|
||||
card.append(element('h4', 'notes-title', t('result.notes')));
|
||||
const list = element('ul', 'notes');
|
||||
for (const note of notes) list.append(element('li', null, noteText(note)));
|
||||
card.append(list);
|
||||
}
|
||||
host.append(card);
|
||||
}
|
||||
const shared = element('ul', 'notes');
|
||||
for (const item of report.items) shared.append(element('li', null, noteText(item)));
|
||||
document.getElementById('shared-notes').replaceChildren(shared);
|
||||
show('result', true);
|
||||
show('error', false);
|
||||
show('busy', false);
|
||||
}
|
||||
|
||||
function renderError(code, detail) {
|
||||
const key = `code.${code}`;
|
||||
const known = TEXT[lang][key] || TEXT.en[key];
|
||||
document.getElementById('error-text').textContent = known || t('code.unknown');
|
||||
const details = document.getElementById('error-detail');
|
||||
details.textContent = detail && !known ? detail : '';
|
||||
details.hidden = !details.textContent;
|
||||
show('error', true);
|
||||
show('result', false);
|
||||
show('busy', false);
|
||||
}
|
||||
|
||||
async function handleFile(file) {
|
||||
if (!file) return;
|
||||
lastResult = null;
|
||||
show('busy', true);
|
||||
show('error', false);
|
||||
show('result', false);
|
||||
try {
|
||||
if (file.size > MAX_FILE_BYTES) {
|
||||
renderError('file_too_large');
|
||||
return;
|
||||
}
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
const home = await readSh3d(bytes);
|
||||
const { documents, report } = convertHome(home, { toolVersion: TOOL_VERSION });
|
||||
renderResult({
|
||||
documents,
|
||||
report,
|
||||
stem: file.name.replace(/\.sh3d$/i, '') || 'plan',
|
||||
});
|
||||
} catch (error) {
|
||||
const code = error && error.code ? error.code : 'unknown';
|
||||
renderError(code, error instanceof ConvertError ? error.message : String(error?.message || ''));
|
||||
}
|
||||
}
|
||||
|
||||
export function mount() {
|
||||
const input = document.getElementById('file');
|
||||
const zone = document.getElementById('drop');
|
||||
input.addEventListener('change', () => handleFile(input.files[0]));
|
||||
document.getElementById('pick').addEventListener('click', () => input.click());
|
||||
document.getElementById('retry').addEventListener('click', () => input.click());
|
||||
for (const event of ['dragenter', 'dragover']) {
|
||||
zone.addEventListener(event, (raw) => {
|
||||
raw.preventDefault();
|
||||
zone.classList.add('over');
|
||||
});
|
||||
}
|
||||
for (const event of ['dragleave', 'drop']) {
|
||||
zone.addEventListener(event, () => zone.classList.remove('over'));
|
||||
}
|
||||
zone.addEventListener('drop', (raw) => {
|
||||
raw.preventDefault();
|
||||
handleFile(raw.dataTransfer?.files?.[0]);
|
||||
});
|
||||
for (const node of document.querySelectorAll('[data-lang]')) {
|
||||
node.addEventListener('click', () => applyLanguage(node.dataset.lang));
|
||||
}
|
||||
let saved = null;
|
||||
try {
|
||||
saved = localStorage.getItem('hp-convert-lang');
|
||||
} catch {
|
||||
saved = null;
|
||||
}
|
||||
applyLanguage(saved || (String(navigator.language || '').toLowerCase().startsWith('ru') ? 'ru' : 'en'));
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sweet Home 3D → House Plan converter</title>
|
||||
<meta name="description" content="Convert a Sweet Home 3D .sh3d floor plan into a House Plan import document. Runs entirely in your browser: the file is never uploaded.">
|
||||
<meta name="robots" content="index,follow">
|
||||
<meta name="theme-color" content="#0b1420">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🏠</text></svg>">
|
||||
<style>
|
||||
/* Палитра лендинга. Шрифты — системные: страница не делает ни одного
|
||||
сетевого запроса, включая шрифтовые, и работает без сети. */
|
||||
:root{
|
||||
--bg:#0b1420; --bg-2:#0e1a2b; --panel:rgba(18,32,52,.62); --panel-solid:#101d30;
|
||||
--line:rgba(148,180,220,.14); --line-strong:rgba(148,180,220,.28);
|
||||
--text:#e9f1fb; --muted:#93a7bf; --accent:#3ea6ff; --accent-soft:rgba(62,166,255,.14);
|
||||
--amber:#ffb454; --ok:#3ecf9a; --danger:#ff5d5d; --radius:18px;
|
||||
--font:"Manrope",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:var(--font);background:var(--bg);color:var(--text);line-height:1.55;
|
||||
padding:32px 20px 64px;display:flex;justify-content:center}
|
||||
main{width:100%;max-width:860px}
|
||||
header{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:28px}
|
||||
.brand{display:flex;align-items:center;gap:10px;color:var(--text);text-decoration:none;font-weight:700}
|
||||
.langs{display:flex;gap:6px}
|
||||
.langs button{background:transparent;border:1px solid var(--line);color:var(--muted);
|
||||
border-radius:999px;padding:4px 12px;font:inherit;font-size:14px;cursor:pointer}
|
||||
.langs button.on{border-color:var(--accent);color:var(--accent);background:var(--accent-soft)}
|
||||
h1{font-size:clamp(24px,4vw,34px);line-height:1.2;margin-bottom:12px}
|
||||
h2{font-size:20px;margin:28px 0 12px}
|
||||
h3{font-size:17px;margin-bottom:6px}
|
||||
h4{font-size:14px;color:var(--muted);font-weight:600;margin:12px 0 4px}
|
||||
p{color:var(--muted)}
|
||||
p.lead{color:var(--text);font-size:17px;margin-bottom:16px}
|
||||
.card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);
|
||||
padding:18px 20px;margin:14px 0}
|
||||
.privacy{border-color:rgba(62,207,154,.35);background:rgba(62,207,154,.08)}
|
||||
.requires{border-color:rgba(255,180,84,.35);background:rgba(255,180,84,.08)}
|
||||
#drop{border:1.5px dashed var(--line-strong);border-radius:var(--radius);padding:36px 20px;
|
||||
text-align:center;background:var(--bg-2);transition:border-color .15s,background .15s}
|
||||
#drop.over{border-color:var(--accent);background:var(--accent-soft)}
|
||||
#drop p{margin:6px 0}
|
||||
.btn{font:inherit;font-weight:600;border-radius:12px;padding:10px 18px;cursor:pointer;
|
||||
border:1px solid var(--line-strong);background:var(--panel-solid);color:var(--text)}
|
||||
.btn.primary{background:var(--accent);border-color:transparent;color:#04121f}
|
||||
.btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||
.level{background:var(--panel-solid);border:1px solid var(--line);border-radius:14px;
|
||||
padding:16px 18px;margin:12px 0}
|
||||
.stats{color:var(--text);font-size:15px;margin-bottom:12px}
|
||||
.notes{margin:6px 0 0 18px;color:var(--muted);font-size:14px}
|
||||
.notes li{margin:4px 0}
|
||||
.muted{color:var(--muted)}
|
||||
ol{margin-left:20px;color:var(--muted)}
|
||||
ol li{margin:6px 0}
|
||||
#error{border-color:rgba(255,93,93,.4);background:rgba(255,93,93,.08)}
|
||||
#error-detail{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;
|
||||
color:var(--muted);margin-top:8px;word-break:break-word}
|
||||
footer{margin-top:36px;color:var(--muted);font-size:14px}
|
||||
footer a{color:var(--accent)}
|
||||
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<a class="brand" href="/">🏠 House Plan</a>
|
||||
<div class="langs">
|
||||
<button type="button" data-lang="ru">RU</button>
|
||||
<button type="button" data-lang="en">EN</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<h1 data-i18n="page.title">Sweet Home 3D → House Plan converter</h1>
|
||||
<p class="lead" data-i18n="page.lead"></p>
|
||||
<div class="card privacy"><p data-i18n="page.privacy"></p></div>
|
||||
<div class="card requires"><p data-i18n="page.requires"></p></div>
|
||||
|
||||
<div id="drop">
|
||||
<h3 data-i18n="drop.title"></h3>
|
||||
<p data-i18n="drop.or"></p>
|
||||
<button class="btn primary" id="pick" type="button" data-i18n="drop.button"></button>
|
||||
<p class="muted" data-i18n="drop.hint"></p>
|
||||
<input id="file" type="file" accept=".sh3d,application/zip" hidden>
|
||||
</div>
|
||||
|
||||
<div class="card" id="busy" hidden><p data-i18n="busy"></p></div>
|
||||
|
||||
<div class="card" id="error" hidden>
|
||||
<h3 data-i18n="error.title"></h3>
|
||||
<p id="error-text"></p>
|
||||
<p id="error-detail" hidden></p>
|
||||
<p><button class="btn" id="retry" type="button" data-i18n="error.retry"></button></p>
|
||||
</div>
|
||||
|
||||
<section id="result" hidden>
|
||||
<h2 data-i18n="result.title"></h2>
|
||||
<div id="levels"></div>
|
||||
<div id="shared-notes"></div>
|
||||
<h2 data-i18n="howto.title"></h2>
|
||||
<ol>
|
||||
<li data-i18n="howto.1"></li>
|
||||
<li data-i18n="howto.2"></li>
|
||||
<li data-i18n="howto.3"></li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<a href="/" data-i18n="back">Back to the site</a> ·
|
||||
<a href="https://github.com/Matysh/houseplan-card">GitHub</a>
|
||||
</footer>
|
||||
</main>
|
||||
<script type="module">
|
||||
/* <!--BUNDLE--> */
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user