Saving, export and compendium features updated, some UI changes added

This commit is contained in:
aomukai 2025-11-13 09:27:32 +09:00
parent d4a14fd6f2
commit 01e4d05b56
6 changed files with 809 additions and 64 deletions

1
favicon.ico Normal file
View file

@ -0,0 +1 @@
favicon placeholder

108
main.html
View file

@ -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
View 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>

View file

@ -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,

View file

@ -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;
}

View file

@ -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);