From 01e4d05b56f97f6feb4daa4a5a62cf78b0a0c852 Mon Sep 17 00:00:00 2001 From: aomukai Date: Thu, 13 Nov 2025 09:27:32 +0900 Subject: [PATCH] Saving, export and compendium features updated, some UI changes added --- favicon.ico | 1 + main.html | 108 +++++++++++++++--- restore.html | 256 +++++++++++++++++++++++++++++++++++++++++++ src/app.js | 272 +++++++++++++++++++++++++++++++++++++++++++++- src/save-utils.js | 57 +++++----- src/styles.css | 179 ++++++++++++++++++++++++++---- 6 files changed, 809 insertions(+), 64 deletions(-) create mode 100644 favicon.ico create mode 100644 restore.html 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 @@ Writingway 2.0 + @@ -34,7 +35,8 @@
-
+
@@ -42,11 +44,15 @@
-

+

+ +

-
+ +
@@ -161,13 +169,24 @@
-
- -
@@ -423,19 +442,78 @@
- + 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

+ +
+ + + +
+
+ +
+

2) Delete DB and restore

+
When you click Delete and Restore the script will:
+
    +
  1. Confirm you want to proceed.
  2. +
  3. Delete WritingwayDB (this is the destructive step).
  4. +
  5. Create a fresh DB and import the pasted data.
  6. +
  7. Set localStorage['writingway:lastProject'] to the first project id (if present).
  8. +
+
+ + + +
+
+ +
+

Logs

+
Ready.
+
+ + + + + \ 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);