Saving, export and compendium features updated, some UI changes added
This commit is contained in:
parent
d4a14fd6f2
commit
01e4d05b56
6 changed files with 809 additions and 64 deletions
1
favicon.ico
Normal file
1
favicon.ico
Normal file
|
|
@ -0,0 +1 @@
|
|||
favicon placeholder
|
||||
108
main.html
108
main.html
|
|
@ -7,6 +7,7 @@
|
|||
<title>Writingway 2.0</title>
|
||||
<!-- Note: This file should be named main.html and opened via start.bat -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/dexie@3.2.4/dist/dexie.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
|
||||
<script defer src="src/prompts.js"></script>
|
||||
<script defer src="src/generation.js"></script>
|
||||
<script defer src="src/ai.js"></script>
|
||||
|
|
@ -34,7 +35,8 @@
|
|||
|
||||
<!-- AI Status -->
|
||||
<div class="ai-status">
|
||||
<div class="ai-status-item" style="cursor:pointer;" @click="showAISettings = true">
|
||||
<div class="ai-status-item" style="cursor:pointer;" @click="showAISettings = true"
|
||||
title="Open AI settings" aria-label="Open AI settings" role="button">
|
||||
<div class="status-dot" :class="aiStatus"></div>
|
||||
<span x-text="aiStatusText"></span>
|
||||
</div>
|
||||
|
|
@ -42,11 +44,15 @@
|
|||
|
||||
<div class="project-info" x-data="{ openMenu:false }" style="position:relative;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;">
|
||||
<h2 x-text="currentProject.name" style="margin:0;padding-right:8px;"></h2>
|
||||
<h2 style="margin:0;padding-right:8px;">
|
||||
<button class="project-name-button" @click.stop="openCompendium()"
|
||||
title="Open Compendium"
|
||||
style="background:none;border:none;padding:0;font-size:1.1em;color:inherit;cursor:pointer;">
|
||||
<span x-text="currentProject.name"></span>
|
||||
</button>
|
||||
</h2>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:6px;">
|
||||
<button class="btn btn-secondary" @click.stop="showCodexPanel = true"
|
||||
title="Codex">Codex</button>
|
||||
<button class="menu-button" @click.stop="openMenu = !openMenu"
|
||||
title="Project menu">⋯</button>
|
||||
<div x-show="openMenu" x-cloak @click.away="openMenu = false" x-transition
|
||||
|
|
@ -66,6 +72,8 @@
|
|||
</template>
|
||||
</div>
|
||||
<div style="border-top:1px solid var(--border);margin:6px 0"></div>
|
||||
<button @click.stop="exportProject(); openMenu=false">Export project (ZIP)</button>
|
||||
<div style="margin-top:6px"></div>
|
||||
<button @click.stop="showNewProjectModal = true; openMenu=false">New
|
||||
project</button>
|
||||
</div>
|
||||
|
|
@ -161,13 +169,24 @@
|
|||
</template>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;padding:10px;gap:8px;">
|
||||
<button class="add-scene-btn" @click="openNewSceneModal()">
|
||||
+ New Scene
|
||||
<div class="sidebar-actions"
|
||||
style="display:flex;flex-direction:row;padding:10px;gap:8px;align-items:center;">
|
||||
<button class="add-scene-btn" @click="openNewChapterModal()" aria-label="New chapter"
|
||||
title="New Chapter">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button class="add-scene-btn" @click="openNewChapterModal()">
|
||||
+ New Chapter
|
||||
<button class="add-scene-btn" @click="openNewSceneModal()" aria-label="New scene"
|
||||
title="New Scene">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path
|
||||
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zM14 3.5L18.5 8H15a1 1 0 0 1-1-1V3.5z"
|
||||
fill="currentColor" />
|
||||
<path d="M8 12h8v2H8v-2zm0 4h5v2H8v-2z" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -423,19 +442,78 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Codex Slide Panel -->
|
||||
<!-- Compendium Slide Panel -->
|
||||
<div class="slide-panel-right" :class="{ 'open': showCodexPanel }" x-cloak aria-hidden="true">
|
||||
<div class="slide-panel-header">
|
||||
<h3 style="margin:0;">Codex</h3>
|
||||
<h3 style="margin:0;">Compendium</h3>
|
||||
<div>
|
||||
<button class="btn btn-secondary" @click="showCodexPanel = false">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="slide-panel-body">
|
||||
<p style="color:var(--text-secondary);">Story bible placeholder. Add character bios and world notes here
|
||||
later.</p>
|
||||
<textarea
|
||||
style="width:100%;height:100%;padding:8px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text-primary);border-radius:6px;">Codex notes go here.</textarea>
|
||||
<div style="display:flex;gap:12px;height:100%;">
|
||||
<div style="width:35%;overflow:auto;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
|
||||
<strong style="color:var(--text-primary);">Categories</strong>
|
||||
<button class="btn btn-secondary" @click="createCompendiumEntry(currentCompCategory)"
|
||||
title="New entry">+</button>
|
||||
</div>
|
||||
|
||||
<template x-for="cat in compendiumCategories" :key="cat">
|
||||
<div style="margin-bottom:12px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;cursor:pointer;"
|
||||
@click="loadCompendiumCategory(cat)">
|
||||
<div style="text-transform:capitalize;font-weight:700;color:var(--text-primary);"
|
||||
x-text="cat"></div>
|
||||
<div style="font-size:12px;color:var(--text-secondary);"
|
||||
x-text="(compendiumCounts[cat] || 0)"></div>
|
||||
</div>
|
||||
<div x-show="currentCompCategory === cat" x-cloak style="margin-top:6px;">
|
||||
<template x-for="e in compendiumList" :key="e.id">
|
||||
<div @click="selectCompendiumEntry(e.id)"
|
||||
:class="{ 'active': currentCompEntry && currentCompEntry.id === e.id }"
|
||||
style="padding:8px;border-radius:6px;background:var(--bg-tertiary);margin-bottom:6px;cursor:pointer;">
|
||||
<div style="font-weight:600;color:var(--text-primary);" x-text="e.title">
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--text-secondary);"
|
||||
x-text="new Date(e.modified).toLocaleString()"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div style="flex:1;display:flex;flex-direction:column;">
|
||||
<div style="flex:0 0 auto;margin-bottom:6px;">
|
||||
<template x-if="!currentCompEntry">
|
||||
<div style="color:var(--text-secondary);">Select an entry or create a new one.</div>
|
||||
</template>
|
||||
<template x-if="currentCompEntry">
|
||||
<div>
|
||||
<input type="text" x-model="currentCompEntry.title" placeholder="Entry title"
|
||||
style="width:100%;padding:8px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text-primary);border-radius:6px;margin-bottom:6px;">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div style="flex:1;overflow:auto;">
|
||||
<template x-if="currentCompEntry">
|
||||
<textarea x-model="currentCompEntry.body" placeholder="Write compendium entry body here"
|
||||
style="width:100%;height:100%;padding:8px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text-primary);border-radius:6px;"></textarea>
|
||||
</template>
|
||||
</div>
|
||||
<div style="flex:0 0 auto;display:flex;gap:8px;margin-top:8px;align-items:center;">
|
||||
<button class="btn btn-primary" @click="saveCompendiumEntry"
|
||||
:disabled="!currentCompEntry">Save</button>
|
||||
<button class="btn btn-secondary" @click="deleteCompendiumEntry(currentCompEntry.id)"
|
||||
x-show="currentCompEntry">Delete</button>
|
||||
<span style="margin-left:8px;font-size:13px;color:var(--text-secondary);"
|
||||
class="compendium-save-status"
|
||||
:class="{ 'saving': compendiumSaveStatus === 'Saving...' }"
|
||||
x-text="compendiumSaveStatus"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
256
restore.html
Normal file
256
restore.html
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Writingway DB Restore Utility</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial;
|
||||
padding: 18px;
|
||||
color: #111;
|
||||
background: #f7f7fb
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 0
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: white;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, .06);
|
||||
margin-bottom: 12px
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
height: 260px;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #ccc;
|
||||
background: #fff;
|
||||
cursor: pointer
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: #0069ff;
|
||||
color: white;
|
||||
border-color: #0069ff
|
||||
}
|
||||
|
||||
button.warn {
|
||||
background: #ff6b6b;
|
||||
color: white;
|
||||
border-color: #ff6b6b
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #0f1724;
|
||||
color: #d1d5db;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
overflow: auto;
|
||||
height: 160px
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #6b7280
|
||||
}
|
||||
</style>
|
||||
<script src="https://cdn.jsdelivr.net/npm/dexie@3.2.4/dist/dexie.min.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>Writingway — DB Restore Utility</h1>
|
||||
<div class="panel">
|
||||
<div class="muted">This tool helps you safely delete the existing <code>WritingwayDB</code> in this browser
|
||||
profile and restore it from a JSON dump you paste below. <strong>Do not proceed unless you have the JSON
|
||||
dump copied from the app console (between JSON_START and JSON_END)</strong>.</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>1) Paste JSON dump</h3>
|
||||
<textarea id="dump" placeholder="Paste JSON here (between JSON_START and JSON_END)"></textarea>
|
||||
<div style="margin-top:8px;display:flex;gap:8px">
|
||||
<button id="validate">Validate JSON</button>
|
||||
<button id="download">Download dump as file</button>
|
||||
<span id="validateResult" class="muted"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>2) Delete DB and restore</h3>
|
||||
<div class="muted">When you click <strong>Delete and Restore</strong> the script will:</div>
|
||||
<ol>
|
||||
<li>Confirm you want to proceed.</li>
|
||||
<li>Delete <code>WritingwayDB</code> (this is the destructive step).</li>
|
||||
<li>Create a fresh DB and import the pasted data.</li>
|
||||
<li>Set <code>localStorage['writingway:lastProject']</code> to the first project id (if present).</li>
|
||||
</ol>
|
||||
<div style="margin-top:8px;display:flex;gap:8px">
|
||||
<button id="deleteRestore" class="warn">Delete DB and Restore</button>
|
||||
<button id="createBackupDb">Create recovered DB (non-destructive)</button>
|
||||
<span id="opResult" class="muted"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>Logs</h3>
|
||||
<pre id="log">Ready.</pre>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const dumpEl = document.getElementById('dump');
|
||||
const logEl = document.getElementById('log');
|
||||
const validateEl = document.getElementById('validate');
|
||||
const downloadEl = document.getElementById('download');
|
||||
const deleteRestoreEl = document.getElementById('deleteRestore');
|
||||
const createBackupDbEl = document.getElementById('createBackupDb');
|
||||
const validateResult = document.getElementById('validateResult');
|
||||
function log(...args) { console.log(...args); logEl.textContent += '\n' + args.map(a => (typeof a === 'object' ? JSON.stringify(a, null, 2) : String(a))).join(' '); logEl.scrollTop = logEl.scrollHeight }
|
||||
|
||||
function parseDump() {
|
||||
const txt = dumpEl.value.trim();
|
||||
if (!txt) throw new Error('No JSON present in textarea');
|
||||
try { return JSON.parse(txt); } catch (e) { throw new Error('Invalid JSON: ' + (e && e.message)); }
|
||||
}
|
||||
|
||||
validateEl.addEventListener('click', () => {
|
||||
validateResult.textContent = '';
|
||||
try {
|
||||
const dump = parseDump();
|
||||
const keys = Object.keys(dump);
|
||||
validateResult.textContent = 'Valid JSON — stores: ' + keys.join(', ');
|
||||
log('Validated dump stores:', keys);
|
||||
} catch (e) {
|
||||
validateResult.textContent = 'Invalid JSON — see logs';
|
||||
log('Validate error', e.message || e);
|
||||
}
|
||||
});
|
||||
|
||||
downloadEl.addEventListener('click', () => {
|
||||
try {
|
||||
const txt = dumpEl.value;
|
||||
if (!txt) { alert('Paste the JSON dump first'); return; }
|
||||
const blob = new Blob([txt], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = 'writingway_dump.json'; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
|
||||
log('Downloaded dump to writingway_dump.json');
|
||||
} catch (e) { log('Download error', e); }
|
||||
});
|
||||
|
||||
async function performRestore(opts = { dbName: 'WritingwayDB', createBackup: false, backupName: 'WritingwayDB_recovered' }) {
|
||||
try {
|
||||
const dump = parseDump();
|
||||
log('Parsed dump stores:', Object.keys(dump));
|
||||
|
||||
if (opts.createBackup) {
|
||||
// create a new DB name and populate it without deleting the original
|
||||
const dbName = opts.backupName || 'WritingwayDB_recovered';
|
||||
log('Creating backup DB (non-destructive) named', dbName);
|
||||
await populateDexie(dbName, dump);
|
||||
log('Backup DB created:', dbName);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// Confirm
|
||||
const c1 = confirm('This will DELETE the existing IndexedDB database "WritingwayDB" in this browser profile and replace it with the pasted dump. Are you sure?');
|
||||
if (!c1) { log('User aborted'); return { ok: false }; }
|
||||
const c2 = confirm('FINAL CONFIRM — Delete and restore now? Click OK to proceed.');
|
||||
if (!c2) { log('User aborted'); return { ok: false }; }
|
||||
|
||||
// delete
|
||||
log('Deleting existing DB WritingwayDB...');
|
||||
await new Promise((res, rej) => {
|
||||
const req = indexedDB.deleteDatabase('WritingwayDB');
|
||||
req.onsuccess = () => { log('indexedDB.deleteDatabase success'); res(); };
|
||||
req.onerror = (e) => { log('indexedDB.deleteDatabase error', e); rej(e); };
|
||||
req.onblocked = () => { log('Delete blocked (close other tabs)'); };
|
||||
});
|
||||
|
||||
// create and populate
|
||||
log('Creating fresh DB and populating...');
|
||||
await populateDexie('WritingwayDB', dump);
|
||||
|
||||
// attempt to set lastProject
|
||||
if (dump.projects && dump.projects.length) {
|
||||
try {
|
||||
localStorage.setItem('writingway:lastProject', dump.projects[0].id);
|
||||
log('Set writingway:lastProject to', dump.projects[0].id);
|
||||
} catch (e) { log('Failed to set lastProject in localStorage', e); }
|
||||
}
|
||||
|
||||
log('Restore complete. Please reload the main app page (e.g., open main.html or reload the existing tab).');
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
log('Restore failed', err && err.message ? err.message : err);
|
||||
return { ok: false, error: err };
|
||||
}
|
||||
}
|
||||
|
||||
async function populateDexie(dbName, dump) {
|
||||
// Use Dexie (loaded from CDN) to create a DB and bulk-put content
|
||||
if (!window.Dexie) throw new Error('Dexie library not available');
|
||||
const d = new Dexie(dbName);
|
||||
// Define stores matching app schema (compatible)
|
||||
d.version(1).stores({
|
||||
projects: 'id, name, created, modified',
|
||||
chapters: 'id, projectId, title, order, created, modified',
|
||||
scenes: 'id, projectId, chapterId, title, order, created, modified',
|
||||
content: 'sceneId, text, wordCount',
|
||||
prompts: 'id, projectId, category, title, created, modified',
|
||||
codex: 'id, projectId, title, created, modified',
|
||||
compendium: 'id, projectId, category, title, modified, tags',
|
||||
content_backups: 'id, sceneId, text, created'
|
||||
});
|
||||
await d.open();
|
||||
// Use transaction to bulk put
|
||||
await d.transaction('rw', d.projects, d.chapters, d.scenes, d.content, d.prompts, d.codex, d.compendium, d.content_backups, async () => {
|
||||
if (dump.projects && dump.projects.length) await d.projects.bulkPut(dump.projects).catch(e => log('projects bulkPut err', e));
|
||||
if (dump.chapters && dump.chapters.length) await d.chapters.bulkPut(dump.chapters).catch(e => log('chapters bulkPut err', e));
|
||||
if (dump.scenes && dump.scenes.length) await d.scenes.bulkPut(dump.scenes).catch(e => log('scenes bulkPut err', e));
|
||||
if (dump.content && dump.content.length) await d.content.bulkPut(dump.content).catch(e => log('content bulkPut err', e));
|
||||
if (dump.prompts && dump.prompts.length) await d.prompts.bulkPut(dump.prompts).catch(e => log('prompts bulkPut err', e));
|
||||
if (dump.codex && dump.codex.length) await d.codex.bulkPut(dump.codex).catch(e => log('codex bulkPut err', e));
|
||||
if (dump.compendium && dump.compendium.length) await d.compendium.bulkPut(dump.compendium).catch(e => log('compendium bulkPut err', e));
|
||||
if (dump.content_backups && dump.content_backups.length) await d.content_backups.bulkPut(dump.content_backups).catch(e => log('content_backups bulkPut err', e));
|
||||
});
|
||||
await d.close();
|
||||
log('populateDexie finished for', dbName);
|
||||
}
|
||||
|
||||
deleteRestoreEl.addEventListener('click', async () => {
|
||||
deleteRestoreEl.disabled = true; deleteRestoreEl.textContent = 'Working...';
|
||||
const res = await performRestore({ dbName: 'WritingwayDB', createBackup: false });
|
||||
deleteRestoreEl.disabled = false; deleteRestoreEl.textContent = 'Delete DB and Restore';
|
||||
if (res.ok) alert('Restore finished. Reload the main app page now.');
|
||||
});
|
||||
|
||||
createBackupDbEl.addEventListener('click', async () => {
|
||||
createBackupDbEl.disabled = true; createBackupDbEl.textContent = 'Working...';
|
||||
const res = await performRestore({ createBackup: true, backupName: 'WritingwayDB_recovered' });
|
||||
createBackupDbEl.disabled = false; createBackupDbEl.textContent = 'Create recovered DB (non-destructive)';
|
||||
if (res.ok) alert('Backup DB created as WritingwayDB_recovered. You can open the app after changing DB name or inspect via DevTools.');
|
||||
});
|
||||
|
||||
// Initialize
|
||||
log('Ready. Paste JSON dump and click Validate to confirm.');
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
272
src/app.js
272
src/app.js
|
|
@ -140,6 +140,61 @@ db.version(4).stores({
|
|||
// noop migration; new installs will get empty compendium
|
||||
});
|
||||
|
||||
// Add compound index for compendium queries to speed up category lookups
|
||||
// This creates a compound index on [projectId+category] which Dexie will use
|
||||
// when querying by both fields together (e.g., { projectId, category }).
|
||||
// Use a new DB version so existing installs get the index via Dexie migration.
|
||||
db.version(5).stores({
|
||||
compendium: 'id, [projectId+category], projectId, category, title, modified, tags'
|
||||
}).upgrade(async tx => {
|
||||
// noop: index addition handled by Dexie
|
||||
});
|
||||
|
||||
// Expose the global Dexie instance for debugging and console usage
|
||||
try { window.db = window.db || db; } catch (e) { /* ignore in non-browser env */ }
|
||||
|
||||
// Dev helpers: wait for Alpine app to be attached, force-save current scene, and dump DB
|
||||
window.__waitForApp = function (timeout = 5000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const start = Date.now();
|
||||
(function check() {
|
||||
try {
|
||||
const el = document.querySelector('[x-data="app"]');
|
||||
const app = (el && el.__x && el.__x.$data) ? el.__x.$data : null;
|
||||
if (app) return resolve(app);
|
||||
} catch (e) { /* ignore */ }
|
||||
if (Date.now() - start > timeout) return reject(new Error('timeout waiting for app'));
|
||||
setTimeout(check, 100);
|
||||
})();
|
||||
});
|
||||
};
|
||||
|
||||
window.__forceSave = async function () {
|
||||
const app = await window.__waitForApp().catch(e => null);
|
||||
if (!app) throw new Error('app not ready');
|
||||
if (window.Save && typeof window.Save.saveScene === 'function') {
|
||||
return await window.Save.saveScene(app);
|
||||
}
|
||||
if (typeof app.saveScene === 'function') return await app.saveScene();
|
||||
throw new Error('no save function available');
|
||||
};
|
||||
|
||||
window.__dumpWritingway = async function () {
|
||||
try {
|
||||
const d = window.db || new Dexie('WritingwayDB');
|
||||
await d.open();
|
||||
console.log('projects:', await d.projects.toArray());
|
||||
console.log('chapters:', await d.chapters.toArray());
|
||||
console.log('scenes:', await d.scenes.toArray());
|
||||
console.log('content:', await d.content.toArray());
|
||||
d.close();
|
||||
} catch (e) {
|
||||
console.error('dump err', e);
|
||||
}
|
||||
};
|
||||
|
||||
// Note: Dexie will open when first used; no automatic recovery toggles are present.
|
||||
|
||||
// Alpine.js App
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('app', () => ({
|
||||
|
|
@ -187,7 +242,15 @@ document.addEventListener('alpine:init', () => {
|
|||
promptEditorContent: '',
|
||||
newPromptTitle: '',
|
||||
|
||||
// Compendium state
|
||||
compendiumCategories: ['lore', 'characters', 'places', 'items', 'notes'],
|
||||
compendiumCounts: {},
|
||||
currentCompCategory: 'lore',
|
||||
compendiumList: [],
|
||||
currentCompEntry: null,
|
||||
|
||||
// AI State
|
||||
compendiumSaveStatus: '',
|
||||
aiWorker: null,
|
||||
aiStatus: 'loading', // loading, ready, error
|
||||
aiStatusText: 'Initializing...',
|
||||
|
|
@ -210,6 +273,8 @@ document.addEventListener('alpine:init', () => {
|
|||
// Load projects and last project selection, but don't let DB failures block AI initialization
|
||||
try {
|
||||
await this.loadProjects();
|
||||
// One-time migration: ensure scenes have a projectId so they are discoverable
|
||||
try { await this.migrateMissingSceneProjectIds(); } catch (e) { /* ignore */ }
|
||||
// restore last selected project from localStorage if present
|
||||
const last = localStorage.getItem('writingway:lastProject');
|
||||
if (last && this.projects.find(p => p.id === last)) {
|
||||
|
|
@ -356,6 +421,33 @@ document.addEventListener('alpine:init', () => {
|
|||
this.projects = await db.projects.orderBy('created').reverse().toArray();
|
||||
},
|
||||
|
||||
// Fix scenes that may have been saved without a projectId (legacy or accidental overwrite).
|
||||
// Uses chapter.projectId when available, otherwise assigns the first project in the DB.
|
||||
async migrateMissingSceneProjectIds() {
|
||||
try {
|
||||
const scenes = await db.scenes.toArray();
|
||||
if (!scenes || scenes.length === 0) return;
|
||||
const projects = await db.projects.toArray();
|
||||
const defaultProject = projects && projects[0] ? projects[0].id : null;
|
||||
|
||||
for (const s of scenes) {
|
||||
if (!s.projectId) {
|
||||
let projectId = null;
|
||||
if (s.chapterId) {
|
||||
const ch = await db.chapters.get(s.chapterId).catch(() => null);
|
||||
if (ch && ch.projectId) projectId = ch.projectId;
|
||||
}
|
||||
if (!projectId && defaultProject) projectId = defaultProject;
|
||||
if (projectId) {
|
||||
await db.scenes.update(s.id, { projectId });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('migrateMissingSceneProjectIds failed:', e);
|
||||
}
|
||||
},
|
||||
|
||||
async selectProject(projectId) {
|
||||
const proj = await db.projects.get(projectId);
|
||||
if (!proj) return;
|
||||
|
|
@ -385,6 +477,65 @@ document.addEventListener('alpine:init', () => {
|
|||
}
|
||||
},
|
||||
|
||||
// Export the current project as a ZIP file containing scenes (Markdown), metadata, and compendium
|
||||
async exportProject() {
|
||||
if (!this.currentProject) return;
|
||||
try {
|
||||
if (typeof JSZip === 'undefined') {
|
||||
alert('ZIP export library is not loaded.');
|
||||
return;
|
||||
}
|
||||
|
||||
const zip = new JSZip();
|
||||
const pid = this.currentProject.id;
|
||||
|
||||
const meta = { project: this.currentProject, chapters: [], exportedAt: new Date().toISOString() };
|
||||
|
||||
const chapters = await db.chapters.where('projectId').equals(pid).sortBy('order');
|
||||
for (const ch of chapters) {
|
||||
const chapterObj = { id: ch.id, title: ch.title, order: ch.order, scenes: [] };
|
||||
const scenes = await db.scenes.where('projectId').equals(pid).and(s => s.chapterId === ch.id).sortBy('order');
|
||||
for (const s of scenes) {
|
||||
// fetch content robustly (primary lookup, then sceneId fallback)
|
||||
let content = null;
|
||||
try { content = await db.content.get(s.id); } catch (e) { content = null; }
|
||||
if (!content) {
|
||||
try { content = await db.content.where('sceneId').equals(s.id).first(); } catch (e) { content = null; }
|
||||
}
|
||||
const text = content ? (content.text || '') : '';
|
||||
|
||||
const safeTitle = (s.title || 'scene').replace(/[^a-z0-9\-_. ]/ig, '_').slice(0, 80).trim();
|
||||
const filename = `scenes/${String(s.order).padStart(2, '0')}-${safeTitle || s.id}.md`;
|
||||
chapterObj.scenes.push({ id: s.id, title: s.title, order: s.order, filename });
|
||||
zip.file(filename, text || '');
|
||||
}
|
||||
meta.chapters.push(chapterObj);
|
||||
}
|
||||
|
||||
// include compendium and other project-level stores
|
||||
try {
|
||||
const comp = await db.compendium.where('projectId').equals(pid).toArray();
|
||||
zip.file('compendium.json', JSON.stringify(comp || [], null, 2));
|
||||
} catch (e) {
|
||||
// ignore if compendium doesn't exist
|
||||
}
|
||||
|
||||
// include raw project JSON/metadata
|
||||
zip.file('metadata.json', JSON.stringify(meta, null, 2));
|
||||
|
||||
const blob = await zip.generateAsync({ type: 'blob' });
|
||||
const nameSafe = (this.currentProject.name || 'project').replace(/[^a-z0-9\-_. ]/ig, '_').slice(0, 80).trim();
|
||||
const fname = `${nameSafe || 'writingway_project'}.zip`;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = fname; document.body.appendChild(a); a.click(); a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
console.error('Export failed:', e);
|
||||
alert('Export failed: ' + (e && e.message ? e.message : e));
|
||||
}
|
||||
},
|
||||
|
||||
// Prompts management
|
||||
async loadPrompts() {
|
||||
// Delegate to prompts module
|
||||
|
|
@ -395,6 +546,100 @@ document.addEventListener('alpine:init', () => {
|
|||
this.prompts = [];
|
||||
},
|
||||
|
||||
// Compendium methods
|
||||
async openCompendium() {
|
||||
this.showCodexPanel = true;
|
||||
// load counts and default category
|
||||
await this.loadCompendiumCounts();
|
||||
await this.loadCompendiumCategory(this.currentCompCategory);
|
||||
},
|
||||
|
||||
async loadCompendiumCounts() {
|
||||
try {
|
||||
const counts = {};
|
||||
for (const c of this.compendiumCategories) {
|
||||
const list = await (window.Compendium ? window.Compendium.listByCategory(this.currentProject.id, c) : []);
|
||||
counts[c] = list.length;
|
||||
}
|
||||
this.compendiumCounts = counts;
|
||||
} catch (e) {
|
||||
console.warn('Failed to load compendium counts:', e);
|
||||
this.compendiumCounts = {};
|
||||
}
|
||||
},
|
||||
|
||||
async loadCompendiumCategory(category) {
|
||||
if (!this.currentProject) return;
|
||||
this.currentCompCategory = category;
|
||||
try {
|
||||
if (window.Compendium && typeof window.Compendium.listByCategory === 'function') {
|
||||
this.compendiumList = await window.Compendium.listByCategory(this.currentProject.id, category) || [];
|
||||
} else {
|
||||
this.compendiumList = [];
|
||||
}
|
||||
// clear current entry selection
|
||||
this.currentCompEntry = null;
|
||||
await this.loadCompendiumCounts();
|
||||
} catch (e) {
|
||||
console.error('Failed to load compendium category:', e);
|
||||
}
|
||||
},
|
||||
|
||||
async createCompendiumEntry(category) {
|
||||
if (!this.currentProject) return;
|
||||
const cat = category || this.currentCompCategory || this.compendiumCategories[0];
|
||||
try {
|
||||
const entry = await window.Compendium.createEntry(this.currentProject.id, { category: cat, title: 'New Entry', body: '' });
|
||||
await this.loadCompendiumCategory(cat);
|
||||
this.selectCompendiumEntry(entry.id);
|
||||
} catch (e) {
|
||||
console.error('Failed to create compendium entry:', e);
|
||||
}
|
||||
},
|
||||
|
||||
async selectCompendiumEntry(id) {
|
||||
try {
|
||||
const e = await window.Compendium.getEntry(id);
|
||||
this.currentCompEntry = e || null;
|
||||
} catch (err) {
|
||||
console.error('Failed to load compendium entry:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async saveCompendiumEntry() {
|
||||
if (!this.currentCompEntry || !this.currentCompEntry.id) return;
|
||||
try {
|
||||
this.compendiumSaveStatus = 'Saving...';
|
||||
const updates = {
|
||||
title: this.currentCompEntry.title || '',
|
||||
body: this.currentCompEntry.body || '',
|
||||
tags: JSON.parse(JSON.stringify(this.currentCompEntry.tags || []))
|
||||
};
|
||||
await window.Compendium.updateEntry(this.currentCompEntry.id, updates);
|
||||
await this.loadCompendiumCategory(this.currentCompCategory);
|
||||
await this.loadCompendiumCounts();
|
||||
this.compendiumSaveStatus = 'Saved';
|
||||
setTimeout(() => { this.compendiumSaveStatus = ''; }, 2000);
|
||||
} catch (e) {
|
||||
console.error('Failed to save compendium entry:', e);
|
||||
this.compendiumSaveStatus = 'Error';
|
||||
setTimeout(() => { this.compendiumSaveStatus = ''; }, 3000);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteCompendiumEntry(id) {
|
||||
if (!id) return;
|
||||
if (!confirm('Delete this compendium entry?')) return;
|
||||
try {
|
||||
await window.Compendium.deleteEntry(id);
|
||||
this.currentCompEntry = null;
|
||||
await this.loadCompendiumCategory(this.currentCompCategory);
|
||||
await this.loadCompendiumCounts();
|
||||
} catch (e) {
|
||||
console.error('Failed to delete compendium entry:', e);
|
||||
}
|
||||
},
|
||||
|
||||
async createPrompt(category) {
|
||||
if (window.Prompts && typeof window.Prompts.createPrompt === 'function') {
|
||||
return window.Prompts.createPrompt(this, category);
|
||||
|
|
@ -440,7 +685,22 @@ document.addEventListener('alpine:init', () => {
|
|||
.sortBy('order');
|
||||
|
||||
for (let s of scenesForChapter) {
|
||||
const content = await db.content.get(s.id);
|
||||
// Primary lookup by primary key (sceneId), but some restored DBs may have
|
||||
// stored content records with a different primary key. Use a fallback
|
||||
// lookup by the `sceneId` property if primary-key get() returns nothing.
|
||||
let content = null;
|
||||
try {
|
||||
content = await db.content.get(s.id);
|
||||
} catch (e) {
|
||||
content = null;
|
||||
}
|
||||
if (!content) {
|
||||
try {
|
||||
content = await db.content.where('sceneId').equals(s.id).first();
|
||||
} catch (e) {
|
||||
content = null;
|
||||
}
|
||||
}
|
||||
s.wordCount = content ? content.wordCount : 0;
|
||||
// load persisted generation options into scene if present
|
||||
s.povCharacter = s.povCharacter || s.povCharacter === '' ? s.povCharacter : '';
|
||||
|
|
@ -568,7 +828,15 @@ document.addEventListener('alpine:init', () => {
|
|||
|
||||
async loadScene(sceneId) {
|
||||
const scene = await db.scenes.get(sceneId);
|
||||
const content = await db.content.get(sceneId);
|
||||
// Load content for the scene, using a primary-key get() first and a
|
||||
// fallback where('sceneId') lookup for robustness across DB variants.
|
||||
let content = null;
|
||||
try {
|
||||
content = await db.content.get(sceneId);
|
||||
} catch (e) { content = null; }
|
||||
if (!content) {
|
||||
try { content = await db.content.where('sceneId').equals(sceneId).first(); } catch (e) { content = null; }
|
||||
}
|
||||
|
||||
this.currentScene = {
|
||||
...scene,
|
||||
|
|
|
|||
|
|
@ -1,64 +1,67 @@
|
|||
// Save utilities for scenes. Exposes window.Save.saveScene(app)
|
||||
(function () {
|
||||
async function saveScene(app) {
|
||||
if (!app || !app.db) return;
|
||||
if (!app) return false;
|
||||
try {
|
||||
app.isSaving = true;
|
||||
app.saveStatus = 'saving';
|
||||
app.saveStatus = 'Saving...';
|
||||
|
||||
const scene = app.currentScene;
|
||||
if (!scene) {
|
||||
app.saveStatus = 'no-scene';
|
||||
app.saveStatus = 'No scene';
|
||||
app.isSaving = false;
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
// compute word count from scene content (simple split)
|
||||
// compute word count from scene content
|
||||
const contentText = (scene.content || '').trim();
|
||||
const wordCount = contentText ? contentText.split(/\s+/).length : 0;
|
||||
const wordCount = contentText ? contentText.split(/\s+/).filter(w => w.length > 0).length : 0;
|
||||
|
||||
// persist content and scene metadata
|
||||
// persist content and scene metadata using the global `db` instance
|
||||
const contentRecord = {
|
||||
sceneId: scene.id,
|
||||
text: scene.content || '',
|
||||
updatedAt: Date.now(),
|
||||
wordCount: wordCount,
|
||||
modified: new Date()
|
||||
};
|
||||
|
||||
await app.db.content.put(contentRecord);
|
||||
await db.content.put(contentRecord);
|
||||
|
||||
// persist scene fields we keep in scenes table (title, order, chapterId, pov options)
|
||||
const scenePatch = {
|
||||
id: scene.id,
|
||||
projectId: scene.projectId || (app.currentProject && app.currentProject.id) || null,
|
||||
title: scene.title || '',
|
||||
order: typeof scene.order === 'number' ? scene.order : 0,
|
||||
chapterId: scene.chapterId || null,
|
||||
povCharacter: scene.povCharacter || '',
|
||||
pov: scene.pov || '',
|
||||
tense: scene.tense || '',
|
||||
updatedAt: Date.now(),
|
||||
wordCount,
|
||||
modified: new Date(),
|
||||
wordCount
|
||||
};
|
||||
|
||||
await app.db.scenes.put(scenePatch);
|
||||
await db.scenes.put(scenePatch);
|
||||
|
||||
// update in-memory lists so UI reflects new counts
|
||||
const ch = app.chapters.find((c) => c.id === scene.chapterId);
|
||||
if (ch && Array.isArray(ch.scenes)) {
|
||||
const s = ch.scenes.find((x) => x.id === scene.id);
|
||||
if (s) Object.assign(s, scenePatch);
|
||||
}
|
||||
// Update in-memory lists
|
||||
try {
|
||||
const ch = app.chapters && app.chapters.find((c) => c.id === scene.chapterId);
|
||||
if (ch && Array.isArray(ch.scenes)) {
|
||||
const s = ch.scenes.find((x) => x.id === scene.id);
|
||||
if (s) Object.assign(s, scenePatch);
|
||||
}
|
||||
|
||||
// ensure app.scenes (flat) gets updated if present
|
||||
if (Array.isArray(app.scenes)) {
|
||||
const ss = app.scenes.find((x) => x.id === scene.id);
|
||||
if (ss) Object.assign(ss, scenePatch);
|
||||
}
|
||||
if (Array.isArray(app.scenes)) {
|
||||
const ss = app.scenes.find((x) => x.id === scene.id);
|
||||
if (ss) Object.assign(ss, scenePatch);
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
app.saveStatus = 'saved';
|
||||
app.saveStatus = 'Saved';
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('saveScene error', err);
|
||||
app.saveStatus = 'error';
|
||||
throw err;
|
||||
app.saveStatus = 'Error';
|
||||
return false;
|
||||
} finally {
|
||||
app.isSaving = false;
|
||||
}
|
||||
|
|
|
|||
179
src/styles.css
179
src/styles.css
|
|
@ -38,6 +38,7 @@ body {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
|
|
@ -56,14 +57,27 @@ body {
|
|||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.project-info {
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
.sidebar-actions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 12px 0;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.project-info h2 {
|
||||
font-size: 16px;
|
||||
margin-bottom: 5px;
|
||||
@media (max-width: 420px) {
|
||||
.add-scene-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.add-scene-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.project-info .word-count {
|
||||
|
|
@ -71,6 +85,39 @@ body {
|
|||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Keep project name sizing reasonable */
|
||||
.project-name-button {
|
||||
font-size: 1rem !important;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Make project name visually respond on hover (like scene items) to indicate it's clickable */
|
||||
.project-name-button {
|
||||
display: inline-block;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.12s ease, color 0.12s ease, transform 0.12s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.project-name-button:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
transform: translateY(-1px);
|
||||
border-left: 4px solid var(--accent);
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.project-name-button:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(74, 158, 255, 0.06);
|
||||
}
|
||||
|
||||
.project-info h2 {
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ai-status {
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
|
@ -116,6 +163,33 @@ body {
|
|||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
/* leave space at the bottom so absolute-bottom controls don't overlap list items */
|
||||
padding-bottom: 140px;
|
||||
}
|
||||
|
||||
/* Center project name under the Writingway header */
|
||||
.project-info {
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: relative;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.project-info>div {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.project-info .menu-button {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 12px;
|
||||
}
|
||||
|
||||
.project-info .action-menu {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 36px;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.chapter-item {
|
||||
|
|
@ -123,14 +197,18 @@ body {
|
|||
}
|
||||
|
||||
.chapter-header {
|
||||
padding: 10px 12px;
|
||||
padding: 12px 14px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
border-radius: 6px;
|
||||
border-left: 4px solid transparent;
|
||||
transition: background 0.15s ease, color 0.15s ease, border-left-color 0.15s ease;
|
||||
}
|
||||
|
||||
/* Caret for expand/collapse with smooth rotation */
|
||||
|
|
@ -164,6 +242,12 @@ body {
|
|||
opacity: 1;
|
||||
}
|
||||
|
||||
.chapter-header:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
.move-select {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
|
|
@ -276,20 +360,26 @@ body {
|
|||
.scene-item {
|
||||
padding: 12px 15px;
|
||||
margin-bottom: 5px;
|
||||
background: var(--bg-tertiary);
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 2px solid transparent;
|
||||
border-left: 4px solid transparent;
|
||||
}
|
||||
|
||||
.scene-item:hover {
|
||||
background: var(--bg-primary);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
.scene-item.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-primary);
|
||||
background: linear-gradient(90deg, rgba(74, 158, 255, 0.06), rgba(74, 158, 255, 0.02));
|
||||
border-left: 6px solid var(--accent);
|
||||
padding-left: 12px;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.scene-item .scene-title {
|
||||
|
|
@ -303,20 +393,68 @@ body {
|
|||
}
|
||||
|
||||
.add-scene-btn {
|
||||
margin: 10px;
|
||||
padding: 12px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
margin: 6px;
|
||||
padding: 8px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: background 0.2s;
|
||||
transition: background 0.12s ease, transform 0.12s ease, opacity 0.12s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.add-scene-btn:hover {
|
||||
background: var(--accent-hover);
|
||||
background: rgba(58, 127, 217, 0.10);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.add-scene-btn:active {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.add-scene-btn svg {
|
||||
display: block;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Position the icon buttons centered vertically in the sidebar */
|
||||
.sidebar-actions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 12px 0;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.add-scene-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.add-scene-btn svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Make sure buttons are still usable on narrow sidebars */
|
||||
@media (max-width: 420px) {
|
||||
.add-scene-btn {
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Editor Area */
|
||||
|
|
@ -382,7 +520,6 @@ body {
|
|||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Beat Panel */
|
||||
.beat-panel {
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border);
|
||||
|
|
@ -420,6 +557,8 @@ body {
|
|||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* (kept original .context-info below) */
|
||||
|
||||
.generate-btn {
|
||||
padding: 12px 24px;
|
||||
background: var(--accent);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue