Initial commit
This commit is contained in:
commit
2683eae6b3
36 files changed
+7369
No files matched your search
+503
@@ -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()
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user