新增管理員頁面、Word PDF 預覽、租賃日期欄位、SSL docker-compose
- 新增 /admin.html:上傳/刪除範本,HTTP Basic Auth 保護 - Word 預覽改用 LibreOffice PDF 轉換,帶入表單參數即時顯示 - 新增租賃開始/結束年月日、租期年數佔位符支援 - 預覽 loading 遮罩,修正 hidden 被 CSS display:flex 覆蓋的問題 - 左右欄 UI 重構,右欄固定顯示 Word 預覽 - 新增 docker-compose.yml + nginx SSL reverse proxy - admin 密碼改由 ADMIN_PASSWORD 環境變數設定 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
62
public/admin.html
Normal file
62
public/admin.html
Normal file
@@ -0,0 +1,62 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>管理員 — 租屋契約範本</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="app-shell">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">Admin</p>
|
||||
<h1>範本管理</h1>
|
||||
</div>
|
||||
<a href="/" class="status-pill" style="text-decoration:none">← 返回</a>
|
||||
</header>
|
||||
|
||||
<div id="loginSection">
|
||||
<div class="tool-panel" style="max-width:360px">
|
||||
<div>
|
||||
<p class="eyebrow">管理員登入</p>
|
||||
<h2>輸入密碼</h2>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>密碼</span>
|
||||
<input id="passwordInput" type="password" autocomplete="current-password">
|
||||
</label>
|
||||
<button id="loginButton" class="primary-button">登入</button>
|
||||
<p id="loginError" class="message"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="adminSection" hidden>
|
||||
<section class="workspace" style="grid-template-columns: 1fr 1fr">
|
||||
<div class="tool-panel">
|
||||
<div>
|
||||
<p class="eyebrow">上傳範本</p>
|
||||
<h2>新增 / 覆蓋</h2>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>選擇檔案(.docx / .doc)</span>
|
||||
<input id="uploadInput" type="file" accept=".doc,.docx">
|
||||
</label>
|
||||
<button id="uploadButton" class="primary-button">上傳</button>
|
||||
<p id="uploadMessage" class="message"></p>
|
||||
</div>
|
||||
|
||||
<div class="tool-panel">
|
||||
<div>
|
||||
<p class="eyebrow">現有範本</p>
|
||||
<h2>刪除</h2>
|
||||
</div>
|
||||
<div id="templateList"><p class="message">載入中…</p></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/admin.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
125
public/admin.js
Normal file
125
public/admin.js
Normal file
@@ -0,0 +1,125 @@
|
||||
let adminPassword = sessionStorage.getItem('adminPassword');
|
||||
|
||||
const loginSection = document.getElementById('loginSection');
|
||||
const adminSection = document.getElementById('adminSection');
|
||||
const loginError = document.getElementById('loginError');
|
||||
const uploadMessage = document.getElementById('uploadMessage');
|
||||
|
||||
if (adminPassword) {
|
||||
checkAuth();
|
||||
} else {
|
||||
showLogin();
|
||||
}
|
||||
|
||||
document.getElementById('loginButton').addEventListener('click', () => {
|
||||
adminPassword = document.getElementById('passwordInput').value;
|
||||
checkAuth();
|
||||
});
|
||||
|
||||
document.getElementById('passwordInput').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') document.getElementById('loginButton').click();
|
||||
});
|
||||
|
||||
document.getElementById('uploadButton').addEventListener('click', async () => {
|
||||
const file = document.getElementById('uploadInput').files[0];
|
||||
uploadMessage.textContent = '';
|
||||
if (!file) { uploadMessage.textContent = '請選擇檔案。'; return; }
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('template', file);
|
||||
|
||||
const res = await fetchAdmin('/api/admin/templates', { method: 'POST', body: formData });
|
||||
const body = await res.json().catch(() => ({}));
|
||||
|
||||
if (res.ok) {
|
||||
uploadMessage.textContent = `已上傳:${body.name}`;
|
||||
document.getElementById('uploadInput').value = '';
|
||||
loadTemplates();
|
||||
} else {
|
||||
uploadMessage.textContent = body.error || '上傳失敗。';
|
||||
}
|
||||
});
|
||||
|
||||
async function checkAuth() {
|
||||
loginError.textContent = '';
|
||||
try {
|
||||
const res = await fetchAdmin('/api/admin/check');
|
||||
if (res.ok) {
|
||||
sessionStorage.setItem('adminPassword', adminPassword);
|
||||
showAdmin();
|
||||
} else {
|
||||
loginError.textContent = '密碼錯誤。';
|
||||
adminPassword = null;
|
||||
sessionStorage.removeItem('adminPassword');
|
||||
showLogin();
|
||||
}
|
||||
} catch {
|
||||
loginError.textContent = '連線失敗。';
|
||||
}
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
loginSection.hidden = false;
|
||||
adminSection.hidden = true;
|
||||
}
|
||||
|
||||
function showAdmin() {
|
||||
loginSection.hidden = true;
|
||||
adminSection.hidden = false;
|
||||
loadTemplates();
|
||||
}
|
||||
|
||||
async function loadTemplates() {
|
||||
const list = document.getElementById('templateList');
|
||||
try {
|
||||
const res = await fetch('/api/templates');
|
||||
const { templates } = await res.json();
|
||||
|
||||
if (templates.length === 0) {
|
||||
list.innerHTML = '<p class="message">尚無範本。</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.replaceChildren(...templates.map((name) => {
|
||||
const row = document.createElement('div');
|
||||
row.style.cssText = 'display:flex;align-items:center;gap:12px;padding:8px 0;border-bottom:1px solid var(--border)';
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.textContent = name;
|
||||
label.style.flex = '1';
|
||||
label.style.fontSize = '0.9rem';
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'secondary-button';
|
||||
btn.textContent = '刪除';
|
||||
btn.style.minHeight = '34px';
|
||||
btn.style.padding = '0 14px';
|
||||
btn.addEventListener('click', () => deleteTemplate(name, row));
|
||||
|
||||
row.append(label, btn);
|
||||
return row;
|
||||
}));
|
||||
} catch {
|
||||
list.innerHTML = '<p class="message">載入失敗。</p>';
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTemplate(name, row) {
|
||||
if (!confirm(`確定要刪除「${name}」?此動作無法復原。`)) return;
|
||||
const res = await fetchAdmin(`/api/admin/templates/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
row.remove();
|
||||
if (!document.getElementById('templateList').children.length) {
|
||||
document.getElementById('templateList').innerHTML = '<p class="message">尚無範本。</p>';
|
||||
}
|
||||
} else {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
alert(body.error || '刪除失敗。');
|
||||
}
|
||||
}
|
||||
|
||||
function fetchAdmin(url, options = {}) {
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set('Authorization', `Basic ${btoa('admin:' + adminPassword)}`);
|
||||
return fetch(url, { ...options, headers });
|
||||
}
|
||||
127
public/app.js
127
public/app.js
@@ -6,11 +6,31 @@ const shareButton = document.querySelector('#shareButton');
|
||||
const message = document.querySelector('#message');
|
||||
const resultTitle = document.querySelector('#resultTitle');
|
||||
const connectionStatus = document.querySelector('#connectionStatus');
|
||||
const previewFrame = document.querySelector('#previewFrame');
|
||||
const previewLoading = document.querySelector('#previewLoading');
|
||||
|
||||
let currentPdfBlob = null;
|
||||
let currentPdfFileName = '租屋契約.pdf';
|
||||
|
||||
loadTemplates();
|
||||
setDefaultLeaseDates();
|
||||
|
||||
document.getElementById('leaseStart').addEventListener('change', (e) => {
|
||||
const [y, m, d] = e.target.value.split('-').map(Number);
|
||||
if (!y || !m || !d) return;
|
||||
document.getElementById('leaseEnd').value = toDateInputValue(computeLeaseEnd(new Date(y, m - 1, d)));
|
||||
schedulePreview();
|
||||
});
|
||||
|
||||
templateSelect.addEventListener('change', () => loadPreview());
|
||||
|
||||
let previewTimer = null;
|
||||
function schedulePreview() {
|
||||
clearTimeout(previewTimer);
|
||||
previewTimer = setTimeout(loadPreview, 600);
|
||||
}
|
||||
|
||||
form.querySelectorAll('input').forEach((el) => el.addEventListener('input', schedulePreview));
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
@@ -29,6 +49,8 @@ form.addEventListener('submit', async (event) => {
|
||||
monthlyRent: formData.get('monthlyRent'),
|
||||
paymentDay: formData.get('paymentDay'),
|
||||
deposit: formData.get('deposit'),
|
||||
leaseStart: formData.get('leaseStart'),
|
||||
leaseEnd: formData.get('leaseEnd'),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -54,16 +76,12 @@ form.addEventListener('submit', async (event) => {
|
||||
});
|
||||
|
||||
downloadButton.addEventListener('click', () => {
|
||||
if (!currentPdfBlob) {
|
||||
return;
|
||||
}
|
||||
if (!currentPdfBlob) return;
|
||||
downloadPdf();
|
||||
});
|
||||
|
||||
shareButton.addEventListener('click', async () => {
|
||||
if (!currentPdfBlob) {
|
||||
return;
|
||||
}
|
||||
if (!currentPdfBlob) return;
|
||||
|
||||
const file = new File([currentPdfBlob], currentPdfFileName, { type: 'application/pdf' });
|
||||
if (!navigator.canShare || !navigator.canShare({ files: [file] })) {
|
||||
@@ -72,10 +90,7 @@ shareButton.addEventListener('click', async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.share({
|
||||
title: '租屋契約 PDF',
|
||||
files: [file],
|
||||
});
|
||||
await navigator.share({ title: '租屋契約 PDF', files: [file] });
|
||||
} catch (error) {
|
||||
if (error.name !== 'AbortError') {
|
||||
message.textContent = '分享失敗,已保留下載按鈕。';
|
||||
@@ -86,9 +101,7 @@ shareButton.addEventListener('click', async () => {
|
||||
async function loadTemplates() {
|
||||
try {
|
||||
const response = await fetch('/api/templates');
|
||||
if (!response.ok) {
|
||||
throw new Error('無法讀取範本。');
|
||||
}
|
||||
if (!response.ok) throw new Error('無法讀取範本。');
|
||||
|
||||
const body = await response.json();
|
||||
templateSelect.replaceChildren(
|
||||
@@ -96,15 +109,16 @@ async function loadTemplates() {
|
||||
);
|
||||
|
||||
if (body.templates.length === 0) {
|
||||
templateSelect.append(new Option('templates 資料夾沒有 .doc 範本', ''));
|
||||
templateSelect.append(new Option('templates 資料夾沒有範本', ''));
|
||||
templateSelect.disabled = true;
|
||||
submitButton.disabled = true;
|
||||
connectionStatus.textContent = '缺少範本';
|
||||
message.textContent = '請先將 .doc 範本放進 templates 資料夾。';
|
||||
message.textContent = '請先將 .docx 範本放進 templates 資料夾。';
|
||||
return;
|
||||
}
|
||||
|
||||
connectionStatus.textContent = '可使用';
|
||||
loadPreview();
|
||||
} catch (error) {
|
||||
templateSelect.append(new Option('讀取失敗', ''));
|
||||
templateSelect.disabled = true;
|
||||
@@ -114,6 +128,53 @@ async function loadTemplates() {
|
||||
}
|
||||
}
|
||||
|
||||
let previewGen = 0;
|
||||
let previewAbortController = null;
|
||||
let previewBlobUrl = null;
|
||||
|
||||
async function loadPreview() {
|
||||
const name = templateSelect.value;
|
||||
if (!name) {
|
||||
previewFrame.src = 'about:blank';
|
||||
previewLoading.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (previewAbortController) previewAbortController.abort();
|
||||
previewAbortController = new AbortController();
|
||||
const { signal } = previewAbortController;
|
||||
const gen = ++previewGen;
|
||||
|
||||
const formData = new FormData(form);
|
||||
const params = new URLSearchParams({
|
||||
monthlyRent: formData.get('monthlyRent') || '',
|
||||
paymentDay: formData.get('paymentDay') || '',
|
||||
deposit: formData.get('deposit') || '',
|
||||
leaseStart: formData.get('leaseStart') || '',
|
||||
leaseEnd: formData.get('leaseEnd') || '',
|
||||
});
|
||||
|
||||
previewLoading.hidden = false;
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/templates/${encodeURIComponent(name)}/preview?${params}`,
|
||||
{ signal },
|
||||
);
|
||||
if (!res.ok) throw new Error('preview failed');
|
||||
const blob = await res.blob();
|
||||
if (gen !== previewGen) return;
|
||||
|
||||
if (previewBlobUrl) URL.revokeObjectURL(previewBlobUrl);
|
||||
previewBlobUrl = URL.createObjectURL(blob);
|
||||
previewFrame.src = `${previewBlobUrl}#toolbar=0`;
|
||||
} catch {
|
||||
// AbortError 或其他錯誤都忽略,交給 finally 處理
|
||||
} finally {
|
||||
if (gen === previewGen) previewLoading.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
function setBusy(isBusy) {
|
||||
submitButton.disabled = isBusy || templateSelect.disabled;
|
||||
submitButton.textContent = isBusy ? '產生中' : '產生 PDF';
|
||||
@@ -128,9 +189,7 @@ function resetPdf() {
|
||||
}
|
||||
|
||||
function canShareCurrentPdf() {
|
||||
if (!currentPdfBlob || !navigator.canShare) {
|
||||
return false;
|
||||
}
|
||||
if (!currentPdfBlob || !navigator.canShare) return false;
|
||||
const file = new File([currentPdfBlob], currentPdfFileName, { type: 'application/pdf' });
|
||||
return navigator.canShare({ files: [file] });
|
||||
}
|
||||
@@ -147,15 +206,9 @@ function downloadPdf() {
|
||||
}
|
||||
|
||||
function getFileNameFromDisposition(disposition) {
|
||||
if (!disposition) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!disposition) return '';
|
||||
const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
|
||||
if (utf8Match) {
|
||||
return decodeURIComponent(utf8Match[1]);
|
||||
}
|
||||
|
||||
if (utf8Match) return decodeURIComponent(utf8Match[1]);
|
||||
const asciiMatch = disposition.match(/filename="([^"]+)"/i);
|
||||
return asciiMatch ? asciiMatch[1] : '';
|
||||
}
|
||||
@@ -164,3 +217,25 @@ function buildDefaultFileName(templateName) {
|
||||
const baseName = String(templateName || '租屋契約').replace(/\.[^.]+$/, '');
|
||||
return `${baseName}.pdf`;
|
||||
}
|
||||
|
||||
function setDefaultLeaseDates() {
|
||||
const start = new Date();
|
||||
start.setDate(1);
|
||||
start.setMonth(start.getMonth() + 1);
|
||||
document.getElementById('leaseStart').value = toDateInputValue(start);
|
||||
document.getElementById('leaseEnd').value = toDateInputValue(computeLeaseEnd(start));
|
||||
}
|
||||
|
||||
function computeLeaseEnd(startDate) {
|
||||
const end = new Date(startDate);
|
||||
end.setFullYear(end.getFullYear() + 1);
|
||||
end.setDate(end.getDate() - 1);
|
||||
return end;
|
||||
}
|
||||
|
||||
function toDateInputValue(date) {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
@@ -13,54 +13,84 @@
|
||||
<p class="eyebrow">Word to PDF</p>
|
||||
<h1>租屋契約 PDF 產生器</h1>
|
||||
</div>
|
||||
<span id="connectionStatus" class="status-pill">連線中</span>
|
||||
<div style="display:flex;gap:10px;align-items:center">
|
||||
<a href="/admin.html" class="status-pill" style="text-decoration:none">管理員</a>
|
||||
<span id="connectionStatus" class="status-pill">連線中</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="workspace" aria-label="租屋契約資料">
|
||||
<form id="contractForm" class="tool-panel">
|
||||
<label class="field">
|
||||
<span>Word 範本</span>
|
||||
<select id="template" name="template" required></select>
|
||||
</label>
|
||||
<div class="left-col">
|
||||
<form id="contractForm">
|
||||
<div class="tool-panel">
|
||||
<label class="field">
|
||||
<span>Word 範本</span>
|
||||
<select id="template" name="template" required></select>
|
||||
</label>
|
||||
|
||||
<div class="field-grid">
|
||||
<label class="field">
|
||||
<span>每月租金</span>
|
||||
<input name="monthlyRent" type="number" inputmode="numeric" min="1" step="1" value="8000" required>
|
||||
</label>
|
||||
<button id="submitButton" class="primary-button" type="submit">
|
||||
產生 PDF
|
||||
</button>
|
||||
|
||||
<label class="field">
|
||||
<span>繳款日期</span>
|
||||
<input name="paymentDay" type="number" inputmode="numeric" min="1" max="31" step="1" value="18" required>
|
||||
</label>
|
||||
<div class="result-inline" aria-label="PDF 結果">
|
||||
<div>
|
||||
<p class="eyebrow">PDF</p>
|
||||
<h2 id="resultTitle">尚未產生</h2>
|
||||
<p id="message" class="message">請輸入資料後產生 PDF。</p>
|
||||
</div>
|
||||
<div class="result-actions">
|
||||
<button id="downloadButton" class="secondary-button" type="button" disabled>
|
||||
下載 PDF
|
||||
</button>
|
||||
<button id="shareButton" class="secondary-button" type="button" disabled>
|
||||
分享 PDF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span>保證金</span>
|
||||
<input name="deposit" type="number" inputmode="numeric" min="1" step="1" value="16000" required>
|
||||
</label>
|
||||
<div class="tool-panel fields-panel">
|
||||
<div class="field-grid">
|
||||
<label class="field">
|
||||
<span>每月租金</span>
|
||||
<input name="monthlyRent" type="number" inputmode="numeric" min="1" step="1" value="8000" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>繳款日期</span>
|
||||
<input name="paymentDay" type="number" inputmode="numeric" min="1" max="31" step="1" value="18" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>保證金</span>
|
||||
<input name="deposit" type="number" inputmode="numeric" min="1" step="1" value="16000" required>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field-grid field-grid--2">
|
||||
<label class="field">
|
||||
<span>租賃開始日</span>
|
||||
<input id="leaseStart" name="leaseStart" type="date" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>租賃結束日</span>
|
||||
<input id="leaseEnd" name="leaseEnd" type="date" required>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="right-col">
|
||||
<div class="preview-panel">
|
||||
<p class="eyebrow">Word 預覽</p>
|
||||
<div class="preview-wrap">
|
||||
<iframe id="previewFrame" src="about:blank" title="Word 範本預覽"></iframe>
|
||||
<div id="previewLoading" class="preview-loading" hidden>
|
||||
<div class="preview-spinner"></div>
|
||||
<span>載入中</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="submitButton" class="primary-button" type="submit">
|
||||
產生 PDF
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<section class="result-panel" aria-label="PDF 結果">
|
||||
<div>
|
||||
<p class="eyebrow">PDF</p>
|
||||
<h2 id="resultTitle">尚未產生</h2>
|
||||
<p id="message" class="message">請輸入資料後產生 PDF。</p>
|
||||
</div>
|
||||
|
||||
<div class="result-actions">
|
||||
<button id="downloadButton" class="secondary-button" type="button" disabled>
|
||||
下載 PDF
|
||||
</button>
|
||||
<button id="shareButton" class="secondary-button" type="button" disabled>
|
||||
分享 PDF
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
--muted: #64716b;
|
||||
--accent: #0f766e;
|
||||
--accent-hover: #0b5f59;
|
||||
--warning: #9a5b00;
|
||||
--shadow: 0 18px 45px rgba(15, 35, 30, 0.08);
|
||||
}
|
||||
|
||||
@@ -16,6 +15,10 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
@@ -33,7 +36,7 @@ select {
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
width: min(1080px, calc(100% - 32px));
|
||||
width: min(1280px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 32px 0;
|
||||
}
|
||||
@@ -82,31 +85,46 @@ h2 {
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.65fr);
|
||||
grid-template-columns: 360px 1fr;
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.tool-panel,
|
||||
.result-panel {
|
||||
.left-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.right-col {
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
}
|
||||
|
||||
.tool-panel {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.tool-panel {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.fields-panel {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.field-grid--2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -166,16 +184,16 @@ h2 {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.result-panel {
|
||||
.result-inline {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
min-height: 216px;
|
||||
padding: 22px;
|
||||
gap: 16px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.message {
|
||||
min-height: 46px;
|
||||
margin: 10px 0 0;
|
||||
min-height: 40px;
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
@@ -185,21 +203,87 @@ h2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.preview-panel {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
height: calc(100vh - 120px);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.preview-panel .eyebrow {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.preview-wrap {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
#previewFrame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.preview-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
background: rgba(246, 248, 247, 0.85);
|
||||
border-radius: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.preview-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.75s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.right-col {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.preview-panel {
|
||||
height: 480px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.app-shell {
|
||||
width: min(100% - 24px, 560px);
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.workspace,
|
||||
.field-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: grid;
|
||||
}
|
||||
@@ -211,4 +295,9 @@ h2 {
|
||||
.primary-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field-grid,
|
||||
.field-grid--2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user