Initial commit

This commit is contained in:
juergen committed 2026-11-13 11:50:48 +01:00
commit 2683eae6b3
36 files changed
+7369

No files matched your search

+503
View File
@@ -0,0 +1,503 @@
const form = document.getElementById('export-form')
const submitButton = document.getElementById('submit-button')
const statusPanel = document.getElementById('status-panel')
const statusTitle = document.getElementById('status-title')
const statusEyebrow = document.getElementById('status-eyebrow')
const statusBadge = document.getElementById('status-badge')
const statusMessage = document.getElementById('status-message')
const progressBar = document.querySelector('.progress')
const progressFill = document.getElementById('progress-fill')
const statusPages = document.getElementById('status-pages')
const errorBox = document.getElementById('error-box')
const errorMessage = document.getElementById('error-message')
const resultBox = document.getElementById('result-box')
const resultMeta = document.getElementById('result-meta')
const downloadButton = document.getElementById('download-button')
const warningsBox = document.getElementById('warnings')
const warningsCount = document.getElementById('warnings-count')
const warningsList = document.getElementById('warnings-list')
const configNotice = document.getElementById('config-notice')
const pageFilter = document.getElementById('page-filter')
const startPageSelect = document.getElementById('start-page')
const pageStatus = document.getElementById('page-status')
const includeUnpublishedInput = document.getElementById('include-unpublished')
const includeTocInput = document.getElementById('include-toc')
const inlineImagesInput = document.getElementById('inline-images')
const maxPagesInput = document.getElementById('max-pages')
let pollTimer = null
let networkFailures = 0
let activeJobId = null
let serverConfigured = true
let allPages = []
let wikiBaseUrl = ''
let savedStartPage = null
let savedLocale = null
function setSubmitBusy(busy, label = 'Create PDF') {
submitButton.disabled = busy || !serverConfigured
submitButton.textContent = busy ? 'Working…' : label
}
function showConfigNotice(message) {
configNotice.hidden = false
configNotice.textContent = message
}
function stagePercent(job) {
if (job.status === 'done') return 100
const found = Number(job.pagesFound) || 0
const done = Number(job.pagesDone) || 0
switch (job.stage) {
case 'queued':
return 2
case 'connect':
return 6
case 'list':
return 12
case 'select':
return 20
case 'fetch':
return found > 0 ? 24 + Math.round((Math.min(done, found) / found) * 62) : 24
case 'build':
return 89
case 'render':
return 95
case 'done':
return 100
default:
return job.status === 'running' ? 12 : 0
}
}
function renderJob(job) {
statusPanel.hidden = false
statusPanel.dataset.status = job.status
activeJobId = job.id
const titles = {
queued: 'Export queued',
running: 'Exporting…',
done: 'PDF ready',
error: 'Export failed',
}
statusTitle.textContent = titles[job.status] || 'Export'
statusBadge.textContent = job.status === 'running' ? 'working' : job.status
statusMessage.textContent = job.message || ''
statusEyebrow.textContent = job.request?.startPage
? `Starting page · ${job.request.startPage}`
: 'Export'
const percent = stagePercent(job)
progressFill.style.width = `${percent}%`
progressBar.setAttribute('aria-valuenow', String(percent))
if (job.pagesFound > 0) {
const warningText = job.warnings?.length
? ` · ${job.warnings.length} warning${job.warnings.length === 1 ? '' : 's'}`
: ''
statusPages.textContent = `Pages: ${job.pagesDone} / ${job.pagesFound}${warningText}`
} else {
statusPages.textContent = ''
}
if (job.status === 'error' && job.error) {
errorBox.hidden = false
errorMessage.textContent = job.error.code
? `${job.error.message} (${job.error.code})`
: job.error.message
} else {
errorBox.hidden = true
errorMessage.textContent = ''
}
if (job.status === 'done' && job.result) {
resultBox.hidden = false
const result = job.result
resultMeta.textContent = `${result.pageCount} page${result.pageCount === 1 ? '' : 's'} · locale ${result.locale} · starting at ${result.startPath}`
downloadButton.href = result.downloadUrl
downloadButton.setAttribute('download', result.filename)
} else {
resultBox.hidden = true
downloadButton.removeAttribute('href')
}
if (job.warnings?.length) {
warningsBox.hidden = false
warningsCount.textContent = String(job.warnings.length)
warningsList.replaceChildren()
for (const warning of job.warnings) {
const item = document.createElement('li')
item.textContent = warning
warningsList.append(item)
}
} else {
warningsBox.hidden = true
warningsList.replaceChildren()
}
const finished = job.status === 'done' || job.status === 'error'
setSubmitBusy(!finished)
}
function showRequestError(message) {
statusPanel.hidden = false
statusPanel.dataset.status = 'error'
statusTitle.textContent = 'Export failed'
statusBadge.textContent = 'error'
statusMessage.textContent = message
statusEyebrow.textContent = 'Export'
progressFill.style.width = '0%'
progressBar.setAttribute('aria-valuenow', '0')
statusPages.textContent = ''
resultBox.hidden = true
errorBox.hidden = false
errorMessage.textContent = message
setSubmitBusy(false)
}
function stopPolling() {
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
}
async function pollJob(jobId) {
stopPolling()
const tick = async () => {
try {
const response = await fetch(`/api/exports/${encodeURIComponent(jobId)}`, {
cache: 'no-store',
})
if (response.status === 404) {
showRequestError('The export job disappeared. It may have expired. Start a new export.')
return
}
if (!response.ok) {
let message = `The server answered with HTTP ${response.status}.`
try {
const payload = await response.json()
if (payload.error) message = payload.error
} catch {
// Keep the status message.
}
throw new Error(message)
}
networkFailures = 0
const payload = await response.json()
renderJob(payload.job)
if (payload.job.status === 'done' || payload.job.status === 'error') {
return
}
pollTimer = setTimeout(tick, 1000)
} catch (error) {
networkFailures += 1
if (networkFailures >= 10) {
showRequestError(`Lost contact with the server: ${error.message}`)
return
}
statusMessage.textContent = `Connection problem, retrying… (${error.message})`
pollTimer = setTimeout(tick, 2000)
}
}
tick()
}
function readSessionForm() {
try {
const saved = JSON.parse(sessionStorage.getItem('wikiPrintForm') || 'null')
if (!saved) return
if (saved.startPage) {
savedStartPage = saved.startPage
savedLocale = saved.locale || ''
}
if (saved.maxPages) maxPagesInput.value = saved.maxPages
if (typeof saved.includeUnpublished === 'boolean') {
includeUnpublishedInput.checked = saved.includeUnpublished
}
if (typeof saved.includeToc === 'boolean') {
includeTocInput.checked = saved.includeToc
}
if (typeof saved.inlineImages === 'boolean') {
inlineImagesInput.checked = saved.inlineImages
}
} catch {
// Ignore malformed session storage.
}
}
function saveSessionForm(payload) {
try {
sessionStorage.setItem(
'wikiPrintForm',
JSON.stringify({
startPage: payload.startPage,
locale: payload.locale,
maxPages: payload.maxPages,
includeUnpublished: payload.includeUnpublished,
includeToc: payload.includeToc,
inlineImages: payload.inlineImages,
}),
)
} catch {
// Session storage is optional.
}
}
function pageMatches(page, query) {
if (!query) return true
const haystack = `${page.title || ''} ${page.path} ${page.locale}`.toLowerCase()
return haystack.includes(query)
}
function appendPageOption(parent, page) {
const option = document.createElement('option')
option.value = page.path
option.dataset.locale = page.locale
const title = String(page.title || '').trim()
option.textContent = title && title !== page.path ? `${title} — ${page.path}` : page.path
if (!page.isPublished) option.textContent += ' (unpublished)'
parent.append(option)
}
function renderPageOptions() {
const query = pageFilter.value.trim().toLowerCase()
const includeUnpublished = includeUnpublishedInput.checked
const previous = startPageSelect.selectedOptions[0]
const previousPath = previous?.value || ''
const previousLocale = previous?.dataset.locale || ''
const filteredPages = allPages.filter(
(page) => (includeUnpublished || page.isPublished) && pageMatches(page, query),
)
startPageSelect.replaceChildren()
const placeholder = document.createElement('option')
placeholder.value = ''
placeholder.textContent = filteredPages.length ? 'Select a starting page…' : 'No matching pages'
placeholder.disabled = true
placeholder.selected = true
startPageSelect.append(placeholder)
const locales = [...new Set(filteredPages.map((page) => page.locale))]
if (locales.length > 1) {
for (const locale of locales) {
const group = document.createElement('optgroup')
group.label = locale
for (const page of filteredPages.filter((candidate) => candidate.locale === locale)) {
appendPageOption(group, page)
}
startPageSelect.append(group)
}
} else {
for (const page of filteredPages) appendPageOption(startPageSelect, page)
}
let restore = null
if (savedStartPage) {
restore = { path: savedStartPage, locale: savedLocale }
savedStartPage = null
savedLocale = null
} else if (previousPath) {
restore = { path: previousPath, locale: previousLocale }
}
if (restore) {
const options = [...startPageSelect.options]
const match = options.find(
(option) =>
option.value === restore.path &&
(!restore.locale || option.dataset.locale === restore.locale),
)
if (match) startPageSelect.selectedIndex = options.indexOf(match)
}
startPageSelect.disabled = false
pageFilter.disabled = false
pageStatus.classList.toggle('error', filteredPages.length === 0)
const host = (() => {
try {
return new URL(wikiBaseUrl).host
} catch {
return 'Wiki.js'
}
})()
if (filteredPages.length === 0) {
pageStatus.textContent = allPages.length
? 'No pages match this filter.'
: 'This wiki returned no pages.'
return
}
const hidden = includeUnpublished
? ''
: ' · unpublished pages hidden'
pageStatus.textContent = `Showing ${filteredPages.length} of ${allPages.length} pages from ${host}${hidden}`
}
async function loadPages() {
pageStatus.classList.remove('error')
pageStatus.textContent = 'Loading the page list from Wiki.js…'
pageFilter.disabled = true
startPageSelect.disabled = true
try {
const response = await fetch('/api/pages', { cache: 'no-store' })
let payload = null
try {
payload = await response.json()
} catch {
// Keep the HTTP status as the error message.
}
if (!response.ok) {
const error = new Error(
payload?.error || `The server answered with HTTP ${response.status}.`,
)
error.code = payload?.code
throw error
}
allPages = Array.isArray(payload.pages) ? payload.pages : []
wikiBaseUrl = payload.baseUrl || ''
renderPageOptions()
} catch (error) {
allPages = []
pageFilter.disabled = true
startPageSelect.disabled = true
startPageSelect.replaceChildren(new Option('Could not load pages', ''))
pageStatus.classList.add('error')
pageStatus.textContent = error.message
if (error.code === 'WIKI_URL_MISSING' || error.code === 'WIKI_API_KEY_MISSING') {
serverConfigured = false
showConfigNotice(error.message)
setSubmitBusy(false)
}
}
}
async function loadConfig() {
try {
const response = await fetch('/api/config', { cache: 'no-store' })
if (!response.ok) return true
const config = await response.json()
if (config.maxPages) {
maxPagesInput.value = String(config.maxPages)
maxPagesInput.max = String(Math.max(5000, config.maxPages))
}
const missing = []
if (!config.wikiUrlConfigured) missing.push('WIKI_URL')
if (!config.apiKeyConfigured) missing.push('WIKI_API_KEY')
if (missing.length > 0) {
serverConfigured = false
showConfigNotice(
`No wiki connection is configured on this server. Set ${missing.join(' and ')} and restart the container, then reload this page.`,
)
setSubmitBusy(false)
return false
}
return true
} catch {
// Let the page-list request report the actual connection problem.
return true
}
}
pageFilter.addEventListener('input', renderPageOptions)
includeUnpublishedInput.addEventListener('change', renderPageOptions)
form.addEventListener('submit', async (event) => {
event.preventDefault()
stopPolling()
if (!form.reportValidity()) return
const selected = startPageSelect.selectedOptions[0]
const startPage = selected?.value?.trim() || ''
const locale = selected?.dataset.locale || ''
const payload = {
startPage,
locale,
maxPages: Number(maxPagesInput.value) || 500,
includeUnpublished: includeUnpublishedInput.checked,
includeToc: includeTocInput.checked,
inlineImages: inlineImagesInput.checked,
}
if (!serverConfigured) {
showRequestError(
'The wiki connection is not configured on the server. Set WIKI_URL and WIKI_API_KEY, then restart the container.',
)
return
}
if (!startPage) {
showRequestError('Select a starting page from the list.')
return
}
saveSessionForm(payload)
errorBox.hidden = true
resultBox.hidden = true
warningsBox.hidden = true
warningsList.replaceChildren()
statusPanel.hidden = false
statusPanel.dataset.status = 'queued'
statusTitle.textContent = 'Starting export…'
statusBadge.textContent = 'queued'
statusMessage.textContent = 'Sending the request to the server…'
statusPages.textContent = ''
progressFill.style.width = '2%'
progressBar.setAttribute('aria-valuenow', '2')
setSubmitBusy(true)
try {
const response = await fetch('/api/exports', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
let body = null
try {
body = await response.json()
} catch {
// Ignore non-JSON error bodies.
}
if (!response.ok) {
throw new Error(body?.error || `The server answered with HTTP ${response.status}.`)
}
renderJob(body.job)
networkFailures = 0
pollJob(body.job.id)
} catch (error) {
showRequestError(error.message)
}
})
async function init() {
readSessionForm()
const configured = await loadConfig()
if (configured) await loadPages()
}
init()
+126
View File
@@ -0,0 +1,126 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>wiki-print · Wiki.js to PDF</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div class="page-shell">
<header class="site-header">
<div class="brand">
<div class="brand-mark" aria-hidden="true">W</div>
<div>
<div class="brand-name">wiki-print</div>
<div class="brand-subtitle">Wiki.js subtree → one linked PDF</div>
</div>
</div>
<a class="header-link" href="/healthz">health</a>
</header>
<main>
<section class="hero">
<h1>Export a Wiki.js page tree to PDF</h1>
<p>
The wiki URL and API key are configured on the server through <code>WIKI_URL</code> and
<code>WIKI_API_KEY</code>. Pick a starting page from the list below. That page and every
page beneath it are combined into a single PDF. Links between exported pages become
internal PDF links; links to other pages and websites stay clickable online links.
</p>
</section>
<form id="export-form" class="card" novalidate>
<p id="config-notice" class="config-notice" role="alert" hidden></p>
<div class="field">
<label for="page-filter">Starting page</label>
<input
id="page-filter"
type="search"
placeholder="Filter pages by title or path…"
autocomplete="off"
spellcheck="false"
disabled
>
<select id="start-page" required aria-label="Starting page" disabled>
<option value="">Loading pages…</option>
</select>
<p id="page-status" class="hint">Loading the page list from Wiki.js…</p>
</div>
<details class="advanced">
<summary>Advanced options</summary>
<div class="field max-pages-field">
<label for="max-pages">Maximum pages</label>
<input id="max-pages" name="maxPages" type="number" min="1" max="5000" value="500">
<p class="hint">The export stops with an error if the subtree is larger.</p>
</div>
<div class="checks">
<label class="check">
<input id="include-unpublished" name="includeUnpublished" type="checkbox">
<span>Include unpublished pages</span>
</label>
<label class="check">
<input id="include-toc" name="includeToc" type="checkbox" checked>
<span>Add a table of contents</span>
</label>
<label class="check">
<input id="inline-images" name="inlineImages" type="checkbox" checked>
<span>Embed images in the PDF</span>
</label>
</div>
</details>
<div class="actions">
<button id="submit-button" class="button button-primary" type="submit">Create PDF</button>
<span class="actions-note">Exports run one at a time and can take a few minutes.</span>
</div>
</form>
<section id="status-panel" class="card status-card" hidden aria-live="polite">
<div class="status-heading">
<div>
<div id="status-eyebrow" class="status-eyebrow">Export</div>
<h2 id="status-title">Queued</h2>
</div>
<span id="status-badge" class="badge">queued</span>
</div>
<p id="status-message" class="status-message"></p>
<div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div id="progress-fill" class="progress-fill"></div>
</div>
<div id="status-pages" class="status-pages"></div>
<div id="error-box" class="error-box" hidden>
<strong>Export failed</strong>
<p id="error-message"></p>
</div>
<div id="result-box" class="result-box" hidden>
<div>
<div class="result-title">Your PDF is ready</div>
<div id="result-meta" class="result-meta"></div>
</div>
<a id="download-button" class="button button-primary" href="#">Download PDF</a>
</div>
<details id="warnings" class="warnings" hidden>
<summary><span id="warnings-count">0</span> warning(s)</summary>
<ul id="warnings-list"></ul>
</details>
</section>
</main>
<footer class="site-footer">
<span>Links are preserved with Chromium's PDF renderer. The produced file works offline for the exported subtree.</span>
</footer>
</div>
<script src="/app.js" defer></script>
</body>
</html>
+473
View File
@@ -0,0 +1,473 @@
:root {
--ink: #16202b;
--muted: #607080;
--line: #d9e1ea;
--line-strong: #c2ceda;
--canvas: #f5f7fa;
--card: #ffffff;
--accent: #0b63b6;
--accent-dark: #084b8b;
--accent-soft: #eaf3fc;
--danger: #b42318;
--danger-soft: #fff1ef;
--success: #217a46;
--success-soft: #eefaf2;
--warning: #8a5b00;
--warning-soft: #fff8e6;
--shadow: 0 18px 50px rgba(20, 38, 58, 0.08);
--radius: 16px;
}
* { box-sizing: border-box; }
html {
background: var(--canvas);
color: var(--ink);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.5;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at 10% 0%, rgba(11, 99, 182, 0.10), transparent 28rem),
radial-gradient(circle at 100% 5%, rgba(80, 150, 215, 0.09), transparent 24rem),
var(--canvas);
}
code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 0.88em;
background: #edf2f7;
border: 1px solid #e0e8f0;
border-radius: 5px;
padding: 0.08em 0.32em;
}
.page-shell {
width: min(920px, calc(100% - 40px));
margin: 0 auto;
padding: 32px 0 48px;
}
.site-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 54px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.brand-mark {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: 12px;
background: var(--accent);
color: #fff;
font-weight: 800;
font-size: 20px;
box-shadow: 0 8px 18px rgba(11, 99, 182, 0.24);
}
.brand-name {
font-size: 18px;
font-weight: 750;
letter-spacing: -0.02em;
}
.brand-subtitle {
color: var(--muted);
font-size: 12.5px;
}
.header-link {
color: var(--muted);
font-size: 12px;
text-decoration: none;
border: 1px solid var(--line);
border-radius: 999px;
padding: 5px 11px;
background: rgba(255, 255, 255, 0.65);
}
.header-link:hover { color: var(--accent); }
.hero {
max-width: 760px;
margin-bottom: 28px;
}
.hero h1 {
margin: 0 0 12px;
font-size: clamp(30px, 5vw, 46px);
line-height: 1.08;
letter-spacing: -0.04em;
}
.hero p {
margin: 0;
color: var(--muted);
font-size: 16px;
max-width: 700px;
}
.card {
background: var(--card);
border: 1px solid rgba(194, 206, 218, 0.75);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 28px;
}
form.card {
display: grid;
gap: 22px;
}
.field {
display: grid;
gap: 7px;
}
.field > label {
font-weight: 650;
font-size: 14px;
}
.optional {
color: var(--muted);
font-weight: 450;
font-size: 12px;
}
input[type="text"],
input[type="url"],
input[type="password"],
input[type="number"],
input[type="search"],
select {
width: 100%;
border: 1px solid var(--line-strong);
border-radius: 10px;
background: #fbfcfe;
color: var(--ink);
font: inherit;
font-size: 15px;
padding: 12px 13px;
outline: none;
transition: border-color 120ms ease, box-shadow 120ms ease, background 120ms ease;
}
select { cursor: pointer; }
input:disabled,
select:disabled {
background: #f1f4f7;
color: var(--muted);
cursor: not-allowed;
}
input:focus {
border-color: var(--accent);
background: #fff;
box-shadow: 0 0 0 4px rgba(11, 99, 182, 0.12);
}
input::placeholder { color: #93a1b0; }
.hint {
margin: 0;
color: var(--muted);
font-size: 12.5px;
}
.hint.error { color: var(--danger); }
.max-pages-field {
margin-top: 18px;
max-width: 240px;
}
.config-notice {
margin: 0;
border: 1px solid #f0c7c2;
border-radius: 12px;
background: var(--danger-soft);
color: #7b1c14;
padding: 12px 14px;
font-size: 13.5px;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid transparent;
border-radius: 10px;
font: inherit;
font-size: 14px;
font-weight: 650;
padding: 11px 17px;
cursor: pointer;
text-decoration: none;
transition: background 120ms ease, border-color 120ms ease, transform 120ms ease;
white-space: nowrap;
}
.button:active { transform: translateY(1px); }
.button-primary {
background: var(--accent);
color: #fff;
box-shadow: 0 8px 20px rgba(11, 99, 182, 0.18);
}
.button-primary:hover { background: var(--accent-dark); }
.button-primary:disabled {
background: #9db8d2;
cursor: not-allowed;
box-shadow: none;
}
.button-quiet {
background: #f2f6fa;
border-color: var(--line);
color: #405263;
}
.button-quiet:hover { border-color: var(--line-strong); background: #eaf1f8; }
.advanced {
border-top: 1px solid var(--line);
padding-top: 18px;
}
.advanced summary {
cursor: pointer;
color: var(--muted);
font-size: 13.5px;
font-weight: 650;
user-select: none;
}
.advanced summary:hover { color: var(--accent); }
.advanced-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
margin-top: 18px;
}
.checks {
display: grid;
gap: 10px;
margin-top: 18px;
}
.check {
display: flex;
align-items: center;
gap: 9px;
color: #334556;
font-size: 14px;
}
.check input {
width: 16px;
height: 16px;
accent-color: var(--accent);
}
.actions {
display: flex;
align-items: center;
gap: 16px;
padding-top: 4px;
}
.button-primary { min-width: 130px; }
.actions-note {
color: var(--muted);
font-size: 12.5px;
}
.status-card {
margin-top: 24px;
}
.status-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
}
.status-eyebrow {
color: var(--accent);
font-size: 11px;
font-weight: 750;
letter-spacing: 0.12em;
text-transform: uppercase;
margin-bottom: 3px;
}
.status-heading h2 {
margin: 0;
font-size: 22px;
letter-spacing: -0.02em;
}
.badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
background: #edf2f7;
color: #526476;
font-size: 11.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 5px 10px;
}
.status-card[data-status="running"] .badge {
background: var(--accent-soft);
color: var(--accent-dark);
}
.status-card[data-status="done"] .badge {
background: var(--success-soft);
color: var(--success);
}
.status-card[data-status="error"] .badge {
background: var(--danger-soft);
color: var(--danger);
}
.status-message {
margin: 14px 0 16px;
color: #405263;
}
.progress {
height: 9px;
overflow: hidden;
border-radius: 999px;
background: #e9eef4;
}
.progress-fill {
height: 100%;
width: 0;
border-radius: inherit;
background: linear-gradient(90deg, #0b63b6, #4b9fe0);
transition: width 350ms ease;
}
.status-pages {
min-height: 18px;
margin-top: 8px;
color: var(--muted);
font-size: 12.5px;
}
.error-box {
margin-top: 18px;
border: 1px solid #f0c7c2;
border-radius: 12px;
background: var(--danger-soft);
color: #7b1c14;
padding: 14px 16px;
}
.error-box strong { font-size: 13.5px; }
.error-box p { margin: 5px 0 0; font-size: 14px; }
.result-box {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin-top: 18px;
border: 1px solid #c5e7d3;
border-radius: 12px;
background: var(--success-soft);
padding: 16px;
}
.result-title {
color: #175c36;
font-weight: 700;
}
.result-meta {
margin-top: 2px;
color: #3d6b52;
font-size: 12.5px;
}
.warnings {
margin-top: 16px;
border-top: 1px solid var(--line);
padding-top: 14px;
}
.warnings summary {
color: var(--warning);
cursor: pointer;
font-size: 13px;
font-weight: 650;
}
.warnings ul {
margin: 10px 0 0;
padding-left: 20px;
color: #5b5140;
font-size: 13px;
}
.warnings li { margin: 4px 0; overflow-wrap: anywhere; }
.site-footer {
margin-top: 28px;
color: #7b8896;
font-size: 12px;
text-align: center;
}
@media (max-width: 640px) {
.page-shell {
width: min(100% - 24px, 920px);
padding-top: 20px;
}
.site-header { margin-bottom: 36px; }
.card { padding: 20px; }
.advanced-grid { grid-template-columns: 1fr; }
.actions {
align-items: flex-start;
flex-direction: column;
gap: 10px;
}
.result-box {
align-items: flex-start;
flex-direction: column;
}
}