diff --git a/favicon.ico b/favicon.ico
new file mode 100644
index 0000000..57c2c66
--- /dev/null
+++ b/favicon.ico
@@ -0,0 +1 @@
+favicon placeholder
diff --git a/main.html b/main.html
index cf9febb..86b88e7 100644
--- a/main.html
+++ b/main.html
@@ -7,6 +7,7 @@
-
+
@@ -42,11 +44,15 @@
-
+
+
+
+
+
-
Codex
+
Export project (ZIP)
+
New
project
@@ -161,13 +169,24 @@
-
-
- + New Scene
+
@@ -423,19 +442,78 @@
-
+
-
Story bible placeholder. Add character bios and world notes here
- later.
-
+
+
+
+ Categories
+ +
+
+
+
+
+
+
+
+
+
+
+ Select an entry or create a new one.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Save
+ Delete
+
+
+
+
diff --git a/restore.html b/restore.html
new file mode 100644
index 0000000..ffcdbe0
--- /dev/null
+++ b/restore.html
@@ -0,0 +1,256 @@
+
+
+
+
+
+
+
Writingway DB Restore Utility
+
+
+
+
+
+
Writingway — DB Restore Utility
+
+
This tool helps you safely delete the existing WritingwayDB in this browser
+ profile and restore it from a JSON dump you paste below. Do not proceed unless you have the JSON
+ dump copied from the app console (between JSON_START and JSON_END) .
+
+
+
+
1) Paste JSON dump
+
+
+ Validate JSON
+ Download dump as file
+
+
+
+
+
+
2) Delete DB and restore
+
When you click Delete and Restore the script will:
+
+ Confirm you want to proceed.
+ Delete WritingwayDB (this is the destructive step).
+ Create a fresh DB and import the pasted data.
+ Set localStorage['writingway:lastProject'] to the first project id (if present).
+
+
+ Delete DB and Restore
+ Create recovered DB (non-destructive)
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/app.js b/src/app.js
index 29fd6c6..cb27446 100644
--- a/src/app.js
+++ b/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,
diff --git a/src/save-utils.js b/src/save-utils.js
index d4d2394..7fa5408 100644
--- a/src/save-utils.js
+++ b/src/save-utils.js
@@ -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;
}
diff --git a/src/styles.css b/src/styles.css
index a9dca72..ee02755 100644
--- a/src/styles.css
+++ b/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);