Initial scaffold: Marti poetry workbench
Static-analysis-first poetry editor based on Borges architecture. Three-column layout (sidebar | editor | analysis), AI opt-in and off by default. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
out/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
12
electron.vite.config.ts
Normal file
12
electron.vite.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { resolve } from 'path'
|
||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
main: { plugins: [externalizeDepsPlugin()] },
|
||||
preload: { plugins: [externalizeDepsPlugin()] },
|
||||
renderer: {
|
||||
resolve: { alias: { '@renderer': resolve('src/renderer') } },
|
||||
plugins: [react()]
|
||||
}
|
||||
})
|
||||
6233
package-lock.json
generated
Normal file
6233
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
60
package.json
Normal file
60
package.json
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "marti",
|
||||
"version": "0.1.0",
|
||||
"description": "A poet's workbench — static analysis first, AI opt-in",
|
||||
"main": "out/main/index.js",
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"build": "electron-vite build",
|
||||
"preview": "electron-vite preview",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
|
||||
"package": "npm run build && electron-builder --mac",
|
||||
"package:win": "npm run build && electron-builder --win",
|
||||
"package:linux": "npm run build && electron-builder --linux"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"electron": "^33.0.0",
|
||||
"electron-builder": "^24.0.0",
|
||||
"electron-vite": "^2.3.0",
|
||||
"typescript": "^5.4.0",
|
||||
"vite": "^5.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.29.0",
|
||||
"@codemirror/commands": "^6.6.0",
|
||||
"@codemirror/lang-markdown": "^6.3.0",
|
||||
"@codemirror/language": "^6.10.0",
|
||||
"@codemirror/search": "^6.5.0",
|
||||
"@codemirror/state": "^6.4.0",
|
||||
"@codemirror/view": "^6.35.0",
|
||||
"@electron-toolkit/preload": "^3.0.0",
|
||||
"@electron-toolkit/utils": "^3.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"zustand": "^4.5.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.marti.editor",
|
||||
"productName": "Marti",
|
||||
"icon": "resources/icon.png",
|
||||
"mac": {
|
||||
"icon": "resources/icon.icns",
|
||||
"target": [{ "target": "dmg", "arch": "universal" }]
|
||||
},
|
||||
"win": {
|
||||
"icon": "resources/icon.ico",
|
||||
"target": [{ "target": "nsis", "arch": ["x64"] }]
|
||||
},
|
||||
"linux": {
|
||||
"icon": "resources/icon.png",
|
||||
"target": [{ "target": "AppImage", "arch": ["x64"] }]
|
||||
},
|
||||
"files": ["out/**/*"],
|
||||
"extraResources": [{ "from": "resources/icon.png", "to": "icon.png" }]
|
||||
}
|
||||
}
|
||||
35
src/main/aiService.ts
Normal file
35
src/main/aiService.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import { BrowserWindow } from 'electron'
|
||||
import { getApiKey } from './globalConfig'
|
||||
|
||||
export interface ChatPayload {
|
||||
messages: { role: 'user' | 'assistant'; content: string }[]
|
||||
poemContent: string
|
||||
}
|
||||
|
||||
const MODEL = 'claude-sonnet-4-6'
|
||||
|
||||
function client(): Anthropic {
|
||||
const key = getApiKey()
|
||||
if (!key) throw new Error('No API key configured')
|
||||
return new Anthropic({ apiKey: key })
|
||||
}
|
||||
|
||||
export async function streamChatMessage(payload: ChatPayload, win: BrowserWindow): Promise<void> {
|
||||
const system = `You are a thoughtful reader and craft-focused interlocutor for a poet. The poet has shared their poem with you. Respond with care and curiosity — ask questions, reflect on what you notice, offer observations. Never prescribe what a poem should do. Never use the word "delve."\n\nPoem:\n${payload.poemContent}`
|
||||
|
||||
const stream = await client().messages.stream({
|
||||
model: MODEL,
|
||||
max_tokens: 1024,
|
||||
system,
|
||||
messages: payload.messages
|
||||
})
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
|
||||
win.webContents.send('ai:chunk', chunk.delta.text)
|
||||
}
|
||||
}
|
||||
|
||||
win.webContents.send('ai:done')
|
||||
}
|
||||
237
src/main/fileSystem.ts
Normal file
237
src/main/fileSystem.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { readdir, readFile, writeFile, mkdir, unlink, rename as fsRename, rm } from 'fs/promises'
|
||||
import { join, basename } from 'path'
|
||||
import { getCollectionRoot } from './globalConfig'
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PoemMeta {
|
||||
title?: string
|
||||
form?: string
|
||||
tags?: string[]
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface PoemFile {
|
||||
id: string
|
||||
path: string
|
||||
lineCount: number
|
||||
wordCount: number
|
||||
meta: PoemMeta
|
||||
}
|
||||
|
||||
export interface CollectionConfig {
|
||||
poems: Record<string, PoemMeta>
|
||||
}
|
||||
|
||||
export interface RevisionMeta {
|
||||
id: string
|
||||
timestamp: number
|
||||
lineCount: number
|
||||
}
|
||||
|
||||
// ─── Path helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
const martiDir = (): string => join(getCollectionRoot(), '.marti')
|
||||
const configFile = (): string => join(martiDir(), 'config.json')
|
||||
const orderFile = (): string => join(martiDir(), 'order.json')
|
||||
const sessionFile = (): string => join(martiDir(), 'session.json')
|
||||
const revisionsDir = (): string => join(martiDir(), 'revisions')
|
||||
|
||||
const MAX_REVISIONS = 50
|
||||
|
||||
function assertInCollection(filePath: string): void {
|
||||
const root = getCollectionRoot()
|
||||
const resolved = filePath.startsWith('/') ? filePath : join(root, filePath)
|
||||
if (!resolved.startsWith(root)) throw new Error('Access denied: path outside collection')
|
||||
}
|
||||
|
||||
// ─── Collection config ────────────────────────────────────────────────────────
|
||||
|
||||
async function readConfig(): Promise<CollectionConfig> {
|
||||
try {
|
||||
return JSON.parse(await readFile(configFile(), 'utf-8'))
|
||||
} catch {
|
||||
return { poems: {} }
|
||||
}
|
||||
}
|
||||
|
||||
async function writeConfig(cfg: CollectionConfig): Promise<void> {
|
||||
await mkdir(martiDir(), { recursive: true })
|
||||
await writeFile(configFile(), JSON.stringify(cfg, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
export async function getPoemMeta(poemId: string): Promise<PoemMeta> {
|
||||
const cfg = await readConfig()
|
||||
return cfg.poems[poemId] ?? {}
|
||||
}
|
||||
|
||||
export async function setPoemMeta(poemId: string, meta: PoemMeta): Promise<void> {
|
||||
const cfg = await readConfig()
|
||||
cfg.poems[poemId] = { ...cfg.poems[poemId], ...meta }
|
||||
await writeConfig(cfg)
|
||||
}
|
||||
|
||||
export async function getCollectionConfig(): Promise<CollectionConfig> {
|
||||
return readConfig()
|
||||
}
|
||||
|
||||
// ─── Poem listing ────────────────────────────────────────────────────────────
|
||||
|
||||
function countLines(text: string): number {
|
||||
return text.split('\n').filter(l => l.trim()).length
|
||||
}
|
||||
|
||||
function countWords(text: string): number {
|
||||
return text.trim() === '' ? 0 : (text.match(/\b\w+\b/g) ?? []).length
|
||||
}
|
||||
|
||||
async function readOrderList(): Promise<string[]> {
|
||||
try {
|
||||
return JSON.parse(await readFile(orderFile(), 'utf-8'))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveOrderList(order: string[]): Promise<void> {
|
||||
await mkdir(martiDir(), { recursive: true })
|
||||
await writeFile(orderFile(), JSON.stringify(order, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
export async function listPoems(): Promise<PoemFile[]> {
|
||||
await mkdir(getCollectionRoot(), { recursive: true })
|
||||
const [entries, order, cfg] = await Promise.all([
|
||||
readdir(getCollectionRoot(), { withFileTypes: true }),
|
||||
readOrderList(),
|
||||
readConfig()
|
||||
])
|
||||
|
||||
const mdFiles = entries.filter(e => e.isFile() && e.name.endsWith('.md') && !e.name.startsWith('.'))
|
||||
|
||||
const poems: PoemFile[] = await Promise.all(
|
||||
mdFiles.map(async (e) => {
|
||||
const id = e.name.replace(/\.md$/, '')
|
||||
const path = join(getCollectionRoot(), e.name)
|
||||
const content = await readFile(path, 'utf-8').catch(() => '')
|
||||
return { id, path, lineCount: countLines(content), wordCount: countWords(content), meta: cfg.poems[id] ?? {} }
|
||||
})
|
||||
)
|
||||
|
||||
const map = new Map(poems.map(p => [p.id, p]))
|
||||
const ordered = order.filter(id => map.has(id)).map(id => map.get(id)!)
|
||||
const rest = poems.filter(p => !order.includes(p.id)).sort((a, b) => a.id.localeCompare(b.id))
|
||||
return [...ordered, ...rest]
|
||||
}
|
||||
|
||||
// ─── Poem CRUD ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function readPoem(filePath: string): Promise<string> {
|
||||
assertInCollection(filePath)
|
||||
return readFile(filePath, 'utf-8')
|
||||
}
|
||||
|
||||
export async function writePoem(filePath: string, content: string): Promise<void> {
|
||||
assertInCollection(filePath)
|
||||
await writeFile(filePath, content, 'utf-8')
|
||||
}
|
||||
|
||||
export async function createPoem(name: string): Promise<{ id: string; path: string }> {
|
||||
const id = name
|
||||
const path = join(getCollectionRoot(), `${name}.md`)
|
||||
assertInCollection(path)
|
||||
await writeFile(path, '', 'utf-8')
|
||||
return { id, path }
|
||||
}
|
||||
|
||||
export async function renamePoem(oldPath: string, newName: string): Promise<string> {
|
||||
assertInCollection(oldPath)
|
||||
const newPath = join(getCollectionRoot(), `${newName}.md`)
|
||||
assertInCollection(newPath)
|
||||
await fsRename(oldPath, newPath)
|
||||
const cfg = await readConfig()
|
||||
const oldId = basename(oldPath, '.md')
|
||||
if (cfg.poems[oldId]) {
|
||||
cfg.poems[newName] = cfg.poems[oldId]
|
||||
delete cfg.poems[oldId]
|
||||
await writeConfig(cfg)
|
||||
}
|
||||
return newPath
|
||||
}
|
||||
|
||||
export async function deletePoem(filePath: string): Promise<void> {
|
||||
assertInCollection(filePath)
|
||||
await rm(filePath, { force: true })
|
||||
const id = basename(filePath, '.md')
|
||||
const cfg = await readConfig()
|
||||
delete cfg.poems[id]
|
||||
await writeConfig(cfg)
|
||||
}
|
||||
|
||||
// ─── Session ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function readSession(): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
return JSON.parse(await readFile(sessionFile(), 'utf-8'))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeSession(data: Record<string, unknown>): Promise<void> {
|
||||
await mkdir(martiDir(), { recursive: true })
|
||||
await writeFile(sessionFile(), JSON.stringify(data), 'utf-8')
|
||||
}
|
||||
|
||||
// ─── Revisions ────────────────────────────────────────────────────────────────
|
||||
|
||||
function poemSlug(filePath: string): string {
|
||||
return basename(filePath, '.md').replace(/[^a-zA-Z0-9_-]/g, '_')
|
||||
}
|
||||
|
||||
function shortId(): string {
|
||||
return Math.random().toString(36).slice(2, 7)
|
||||
}
|
||||
|
||||
export async function saveRevision(filePath: string, content: string): Promise<void> {
|
||||
assertInCollection(filePath)
|
||||
const slug = poemSlug(filePath)
|
||||
const dir = join(revisionsDir(), slug)
|
||||
await mkdir(dir, { recursive: true })
|
||||
const timestamp = Date.now()
|
||||
const id = `${timestamp}_${shortId()}`
|
||||
await writeFile(
|
||||
join(dir, `${id}.json`),
|
||||
JSON.stringify({ id, timestamp, lineCount: countLines(content), content }),
|
||||
'utf-8'
|
||||
)
|
||||
const entries = (await readdir(dir)).filter(e => e.endsWith('.json')).sort()
|
||||
if (entries.length > MAX_REVISIONS) {
|
||||
await Promise.all(entries.slice(0, entries.length - MAX_REVISIONS).map(f => unlink(join(dir, f))))
|
||||
}
|
||||
}
|
||||
|
||||
export async function listRevisions(filePath: string): Promise<RevisionMeta[]> {
|
||||
assertInCollection(filePath)
|
||||
const slug = poemSlug(filePath)
|
||||
const dir = join(revisionsDir(), slug)
|
||||
try {
|
||||
const entries = (await readdir(dir)).filter(e => e.endsWith('.json')).sort().reverse()
|
||||
return Promise.all(
|
||||
entries.map(async (f) => {
|
||||
const raw = JSON.parse(await readFile(join(dir, f), 'utf-8'))
|
||||
return { id: raw.id, timestamp: raw.timestamp, lineCount: raw.lineCount }
|
||||
})
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadRevision(filePath: string, revisionId: string): Promise<string> {
|
||||
assertInCollection(filePath)
|
||||
if (!/^[\w-]+$/.test(revisionId)) throw new Error('Invalid revision ID')
|
||||
const slug = poemSlug(filePath)
|
||||
const revPath = join(revisionsDir(), slug, `${revisionId}.json`)
|
||||
const raw = JSON.parse(await readFile(revPath, 'utf-8'))
|
||||
return raw.content
|
||||
}
|
||||
47
src/main/globalConfig.ts
Normal file
47
src/main/globalConfig.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'fs'
|
||||
|
||||
export interface GlobalConfig {
|
||||
collectionPath?: string
|
||||
fontSize?: number
|
||||
theme?: 'dark' | 'light'
|
||||
editorFont?: 'serif' | 'mono'
|
||||
lineWrap?: boolean
|
||||
ai?: {
|
||||
enabled: boolean
|
||||
apiKey?: string
|
||||
}
|
||||
}
|
||||
|
||||
const CONFIG_DIR = join(homedir(), '.marti')
|
||||
const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
|
||||
|
||||
function loadFromDisk(): GlobalConfig {
|
||||
try {
|
||||
return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
let _config: GlobalConfig = loadFromDisk()
|
||||
|
||||
export const getCollectionRoot = (): string =>
|
||||
_config.collectionPath ?? join(homedir(), 'Documents', 'marti-poems')
|
||||
|
||||
export const getApiKey = (): string | undefined =>
|
||||
_config.ai?.apiKey ?? process.env.ANTHROPIC_API_KEY
|
||||
|
||||
export const isAIEnabled = (): boolean =>
|
||||
!!(_config.ai?.enabled && getApiKey())
|
||||
|
||||
export function readGlobalConfig(): GlobalConfig {
|
||||
return { ..._config }
|
||||
}
|
||||
|
||||
export function writeGlobalConfig(updates: Partial<GlobalConfig>): void {
|
||||
_config = { ..._config, ...updates }
|
||||
mkdirSync(CONFIG_DIR, { recursive: true })
|
||||
writeFileSync(CONFIG_FILE, JSON.stringify(_config, null, 2), 'utf-8')
|
||||
}
|
||||
151
src/main/index.ts
Normal file
151
src/main/index.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { app, BrowserWindow, shell, nativeImage, Menu, dialog } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
import { registerIpcHandlers } from './ipcHandlers'
|
||||
import { getCollectionRoot, readGlobalConfig } from './globalConfig'
|
||||
|
||||
app.setName('Marti')
|
||||
|
||||
function send(win: BrowserWindow, action: string): void {
|
||||
if (!win.isDestroyed()) win.webContents.send('menu:action', action)
|
||||
}
|
||||
|
||||
function buildAppMenu(win: BrowserWindow): void {
|
||||
const isMac = process.platform === 'darwin'
|
||||
|
||||
const template: Electron.MenuItemConstructorOptions[] = [
|
||||
...(isMac ? ([{
|
||||
label: 'Marti',
|
||||
submenu: [
|
||||
{
|
||||
label: 'About Marti',
|
||||
click: () => dialog.showMessageBox(win, { type: 'info', title: 'Marti', message: 'Marti', detail: `Version ${app.getVersion()}\n\nA poet's workbench.` })
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ label: 'Preferences…', accelerator: 'CmdOrCtrl+,', click: () => send(win, 'openSettings') },
|
||||
{ type: 'separator' },
|
||||
{ role: 'services' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'quit' }
|
||||
]
|
||||
}] as Electron.MenuItemConstructorOptions[]) : []),
|
||||
|
||||
{
|
||||
label: 'File',
|
||||
submenu: [
|
||||
{ label: 'New Poem', accelerator: 'CmdOrCtrl+N', click: () => send(win, 'newPoem') },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Save', accelerator: 'CmdOrCtrl+S', click: () => send(win, 'save') },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Open Collection in Finder', click: () => shell.openPath(getCollectionRoot()) },
|
||||
{ type: 'separator' },
|
||||
...(!isMac ? ([
|
||||
{ label: 'Preferences…', accelerator: 'CmdOrCtrl+,', click: () => send(win, 'openSettings') },
|
||||
{ type: 'separator' }
|
||||
] as Electron.MenuItemConstructorOptions[]) : []),
|
||||
isMac ? { role: 'close' } : { role: 'quit' }
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
label: 'Edit',
|
||||
submenu: [
|
||||
{ role: 'undo' }, { role: 'redo' }, { type: 'separator' },
|
||||
{ role: 'cut' }, { role: 'copy' }, { role: 'paste' },
|
||||
{ type: 'separator' }, { role: 'selectAll' }
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
label: 'View',
|
||||
submenu: [
|
||||
{ label: 'Focus Mode', accelerator: 'CmdOrCtrl+Shift+G', click: () => send(win, 'toggleFocusMode') },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Toggle Poem List', accelerator: 'CmdOrCtrl+Shift+1', click: () => send(win, 'toggleSidebar') },
|
||||
{ label: 'Toggle Analysis Panel', accelerator: 'CmdOrCtrl+Shift+2', click: () => send(win, 'toggleAnalysis') },
|
||||
{ label: 'Toggle AI Chat', accelerator: 'CmdOrCtrl+Shift+3', click: () => send(win, 'toggleChat') },
|
||||
{ label: 'Toggle Revision History', accelerator: 'CmdOrCtrl+Shift+R', click: () => send(win, 'toggleRevisions') },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Toggle Dark Mode', click: () => send(win, 'toggleTheme') },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Increase Font Size', accelerator: 'CmdOrCtrl+=', click: () => send(win, 'fontIncrease') },
|
||||
{ label: 'Decrease Font Size', accelerator: 'CmdOrCtrl+-', click: () => send(win, 'fontDecrease') },
|
||||
{ label: 'Reset Font Size', accelerator: 'CmdOrCtrl+0', click: () => send(win, 'fontReset') },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Toggle Developer Tools', accelerator: 'CmdOrCtrl+Option+I', click: () => win.webContents.toggleDevTools() }
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
label: 'Window',
|
||||
submenu: [
|
||||
{ role: 'minimize' }, { role: 'zoom' },
|
||||
...(isMac ? ([{ type: 'separator' }, { role: 'front' }] as Electron.MenuItemConstructorOptions[]) : [])
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
|
||||
}
|
||||
|
||||
function createWindow(): BrowserWindow {
|
||||
const icon = nativeImage.createFromPath(join(__dirname, '../../resources/icon.png'))
|
||||
const config = readGlobalConfig()
|
||||
const isDark = config.theme !== 'light'
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 900,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
title: 'Marti',
|
||||
titleBarStyle: 'hiddenInset',
|
||||
vibrancy: 'sidebar',
|
||||
backgroundColor: isDark ? '#18181a' : '#f4f1f0',
|
||||
icon,
|
||||
show: false,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.on('ready-to-show', () => mainWindow.show())
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
|
||||
return mainWindow
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
electronApp.setAppUserModelId('com.marti.editor')
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
app.dock.setIcon(nativeImage.createFromPath(join(__dirname, '../../resources/icon.png')))
|
||||
}
|
||||
|
||||
app.on('browser-window-created', (_, window) => optimizer.watchWindowShortcuts(window))
|
||||
|
||||
registerIpcHandlers()
|
||||
const mainWindow = createWindow()
|
||||
buildAppMenu(mainWindow)
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit()
|
||||
})
|
||||
72
src/main/ipcHandlers.ts
Normal file
72
src/main/ipcHandlers.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { ipcMain, BrowserWindow, dialog } from 'electron'
|
||||
import {
|
||||
listPoems, readPoem, writePoem, createPoem, renamePoem, deletePoem,
|
||||
getPoemMeta, setPoemMeta, saveOrderList,
|
||||
getCollectionConfig,
|
||||
readSession, writeSession,
|
||||
saveRevision, listRevisions, loadRevision
|
||||
} from './fileSystem'
|
||||
import { analyzePoem } from './staticAnalysis'
|
||||
import { streamChatMessage } from './aiService'
|
||||
import { readGlobalConfig, writeGlobalConfig, isAIEnabled } from './globalConfig'
|
||||
|
||||
export function registerIpcHandlers(): void {
|
||||
// Poems
|
||||
ipcMain.handle('poems:list', () => listPoems())
|
||||
ipcMain.handle('poems:read', (_, path: string) => readPoem(path))
|
||||
ipcMain.handle('poems:write', (_, path: string, content: string) => writePoem(path, content))
|
||||
ipcMain.handle('poems:create', (_, name: string) => createPoem(name))
|
||||
ipcMain.handle('poems:rename', (_, oldPath: string, newName: string) => renamePoem(oldPath, newName))
|
||||
ipcMain.handle('poems:delete', (_, path: string) => deletePoem(path))
|
||||
ipcMain.handle('poems:getMeta', (_, id: string) => getPoemMeta(id))
|
||||
ipcMain.handle('poems:setMeta', (_, id: string, meta) => setPoemMeta(id, meta))
|
||||
ipcMain.handle('poems:saveOrder', (_, order: string[]) => saveOrderList(order))
|
||||
|
||||
// Collection
|
||||
ipcMain.handle('collection:getConfig', () => getCollectionConfig())
|
||||
|
||||
// Session
|
||||
ipcMain.handle('session:read', () => readSession())
|
||||
ipcMain.handle('session:write', (_, data) => writeSession(data))
|
||||
|
||||
// Revisions
|
||||
ipcMain.handle('revisions:save', (_, path: string, content: string) => saveRevision(path, content))
|
||||
ipcMain.handle('revisions:list', (_, path: string) => listRevisions(path))
|
||||
ipcMain.handle('revisions:load', (_, path: string, id: string) => loadRevision(path, id))
|
||||
|
||||
// Static analysis (run in main to keep renderer fast)
|
||||
ipcMain.handle('analysis:run', (_, content: string) => analyzePoem(content))
|
||||
|
||||
// Config
|
||||
ipcMain.handle('config:read', () => readGlobalConfig())
|
||||
ipcMain.handle('config:write', (_, updates) => writeGlobalConfig(updates))
|
||||
ipcMain.handle('config:pickFolder', async () => {
|
||||
const win = BrowserWindow.getFocusedWindow()
|
||||
if (!win) return null
|
||||
const result = await dialog.showOpenDialog(win, { properties: ['openDirectory', 'createDirectory'] })
|
||||
return result.canceled ? null : result.filePaths[0]
|
||||
})
|
||||
ipcMain.handle('config:aiEnabled', () => isAIEnabled())
|
||||
|
||||
// AI chat (only active if AI is enabled)
|
||||
ipcMain.handle('ai:streamChat', async (event, payload) => {
|
||||
if (!isAIEnabled()) throw new Error('AI is not enabled')
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win) throw new Error('No window')
|
||||
await streamChatMessage(payload, win)
|
||||
})
|
||||
|
||||
// Context menus
|
||||
ipcMain.handle('menu:poemContext', async (event, _poemId: string) => {
|
||||
const { Menu, MenuItem } = await import('electron')
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win) return null
|
||||
return new Promise<string | null>((resolve) => {
|
||||
const menu = new Menu()
|
||||
menu.append(new MenuItem({ label: 'Rename', click: () => resolve('rename') }))
|
||||
menu.append(new MenuItem({ type: 'separator' }))
|
||||
menu.append(new MenuItem({ label: 'Delete', click: () => resolve('delete') }))
|
||||
menu.popup({ window: win, callback: () => resolve(null) })
|
||||
})
|
||||
})
|
||||
}
|
||||
128
src/main/staticAnalysis.ts
Normal file
128
src/main/staticAnalysis.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
export interface LineMetrics {
|
||||
lineNumber: number
|
||||
text: string
|
||||
syllableCount: number
|
||||
charCount: number
|
||||
endWord: string
|
||||
rhymeLetter: string
|
||||
hasAlliteration: boolean
|
||||
}
|
||||
|
||||
export interface PoemAnalysis {
|
||||
lines: LineMetrics[]
|
||||
lineCount: number
|
||||
wordCount: number
|
||||
uniqueWordRatio: number
|
||||
rhymeScheme: string
|
||||
detectedForm: string | null
|
||||
avgSyllablesPerLine: number
|
||||
alliterationCount: number
|
||||
longestLine: number
|
||||
shortestLine: number
|
||||
}
|
||||
|
||||
// Vowel-cluster syllable heuristic — good enough for display without a dict dependency
|
||||
function countSyllables(word: string): number {
|
||||
word = word.toLowerCase().replace(/[^a-z]/g, '')
|
||||
if (!word) return 0
|
||||
if (word.length <= 3) return 1
|
||||
// Strip common silent suffixes
|
||||
word = word.replace(/(?:[^laeiouy]es|[^laeiouy]ed|[^laeiouy]e)$/, '')
|
||||
word = word.replace(/^y/, '')
|
||||
const matches = word.match(/[aeiouy]{1,2}/g)
|
||||
return Math.max(1, matches ? matches.length : 1)
|
||||
}
|
||||
|
||||
function lineEndWord(line: string): string {
|
||||
const words = line.trim().split(/\s+/)
|
||||
return (words[words.length - 1] ?? '').toLowerCase().replace(/[^a-z]/g, '')
|
||||
}
|
||||
|
||||
function rhymeKey(word: string): string {
|
||||
if (!word || word.length <= 2) return word
|
||||
return word.slice(-3)
|
||||
}
|
||||
|
||||
function hasAlliteration(line: string): boolean {
|
||||
const words = (line.toLowerCase().match(/\b[a-z]+/g) ?? []).filter(w => w.length > 1)
|
||||
if (words.length < 3) return false
|
||||
const initials = words.map(w => w[0])
|
||||
const freq: Record<string, number> = {}
|
||||
for (const c of initials) freq[c] = (freq[c] ?? 0) + 1
|
||||
return Object.values(freq).some(v => v >= 3)
|
||||
}
|
||||
|
||||
function buildRhymeScheme(endWords: string[]): string {
|
||||
const map = new Map<string, string>()
|
||||
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||
let next = 0
|
||||
return endWords.map(w => {
|
||||
if (!w) return ' '
|
||||
const key = rhymeKey(w)
|
||||
if (!map.has(key)) {
|
||||
if (next >= letters.length) return '?'
|
||||
map.set(key, letters[next++])
|
||||
}
|
||||
return map.get(key)!
|
||||
}).join('')
|
||||
}
|
||||
|
||||
function detectForm(lineCount: number, rhymeScheme: string): string | null {
|
||||
if (lineCount === 3) return 'haiku (3 lines)'
|
||||
if (lineCount === 5) return 'cinquain'
|
||||
if (lineCount === 14) {
|
||||
if (rhymeScheme.startsWith('ABABCDCDEFEFGG')) return 'Shakespearean sonnet'
|
||||
if (/^ABBAABBA(CDECDE|CDCDCD)/.test(rhymeScheme)) return 'Petrarchan sonnet'
|
||||
return 'sonnet (14 lines)'
|
||||
}
|
||||
if (lineCount === 19) return 'villanelle (19 lines)'
|
||||
if (lineCount === 6) return 'sestet'
|
||||
if (lineCount === 8) return 'octave'
|
||||
return null
|
||||
}
|
||||
|
||||
export function analyzePoem(content: string): PoemAnalysis {
|
||||
const rawLines = content.split('\n')
|
||||
const nonEmptyLines = rawLines.filter(l => l.trim())
|
||||
|
||||
const endWords = nonEmptyLines.map(l => lineEndWord(l))
|
||||
const rhymeScheme = buildRhymeScheme(endWords)
|
||||
const rhemeLettersByLine = rhymeScheme.split('')
|
||||
|
||||
let nonEmptyIdx = 0
|
||||
const lines: LineMetrics[] = rawLines.map((text, i) => {
|
||||
const isEmpty = !text.trim()
|
||||
const endWord = isEmpty ? '' : (endWords[nonEmptyIdx] ?? '')
|
||||
const rhymeLetter = isEmpty ? '' : (rhemeLettersByLine[nonEmptyIdx] ?? '')
|
||||
if (!isEmpty) nonEmptyIdx++
|
||||
|
||||
return {
|
||||
lineNumber: i + 1,
|
||||
text,
|
||||
syllableCount: text.trim().split(/\s+/).filter(Boolean).reduce((s, w) => s + countSyllables(w), 0),
|
||||
charCount: text.length,
|
||||
endWord,
|
||||
rhymeLetter,
|
||||
hasAlliteration: hasAlliteration(text)
|
||||
}
|
||||
})
|
||||
|
||||
const words = content.toLowerCase().match(/\b[a-z]+\b/g) ?? []
|
||||
const uniqueWords = new Set(words)
|
||||
|
||||
const nonEmptyLineLengths = nonEmptyLines.map(l => l.length)
|
||||
const totalSyllables = lines.reduce((s, l) => s + l.syllableCount, 0)
|
||||
|
||||
return {
|
||||
lines,
|
||||
lineCount: nonEmptyLines.length,
|
||||
wordCount: words.length,
|
||||
uniqueWordRatio: words.length > 0 ? +(uniqueWords.size / words.length).toFixed(2) : 0,
|
||||
rhymeScheme,
|
||||
detectedForm: detectForm(nonEmptyLines.length, rhymeScheme),
|
||||
avgSyllablesPerLine: nonEmptyLines.length > 0 ? +(totalSyllables / nonEmptyLines.length).toFixed(1) : 0,
|
||||
alliterationCount: lines.filter(l => l.hasAlliteration).length,
|
||||
longestLine: nonEmptyLineLengths.length ? Math.max(...nonEmptyLineLengths) : 0,
|
||||
shortestLine: nonEmptyLineLengths.length ? Math.min(...nonEmptyLineLengths) : 0
|
||||
}
|
||||
}
|
||||
72
src/preload/index.ts
Normal file
72
src/preload/index.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import type { PoemFile, PoemMeta, RevisionMeta, CollectionConfig } from '../main/fileSystem'
|
||||
import type { PoemAnalysis } from '../main/staticAnalysis'
|
||||
import type { GlobalConfig } from '../main/globalConfig'
|
||||
import type { ChatPayload } from '../main/aiService'
|
||||
|
||||
contextBridge.exposeInMainWorld('api', {
|
||||
// Poems
|
||||
listPoems: (): Promise<PoemFile[]> => ipcRenderer.invoke('poems:list'),
|
||||
readPoem: (path: string): Promise<string> => ipcRenderer.invoke('poems:read', path),
|
||||
writePoem: (path: string, content: string): Promise<void> => ipcRenderer.invoke('poems:write', path, content),
|
||||
createPoem: (name: string): Promise<{ id: string; path: string }> => ipcRenderer.invoke('poems:create', name),
|
||||
renamePoem: (oldPath: string, newName: string): Promise<string> => ipcRenderer.invoke('poems:rename', oldPath, newName),
|
||||
deletePoem: (path: string): Promise<void> => ipcRenderer.invoke('poems:delete', path),
|
||||
getPoemMeta: (id: string): Promise<PoemMeta> => ipcRenderer.invoke('poems:getMeta', id),
|
||||
setPoemMeta: (id: string, meta: PoemMeta): Promise<void> => ipcRenderer.invoke('poems:setMeta', id, meta),
|
||||
saveOrder: (order: string[]): Promise<void> => ipcRenderer.invoke('poems:saveOrder', order),
|
||||
|
||||
// Collection
|
||||
getCollectionConfig: (): Promise<CollectionConfig> => ipcRenderer.invoke('collection:getConfig'),
|
||||
|
||||
// Session
|
||||
readSession: (): Promise<Record<string, unknown>> => ipcRenderer.invoke('session:read'),
|
||||
writeSession: (data: Record<string, unknown>): Promise<void> => ipcRenderer.invoke('session:write', data),
|
||||
|
||||
// Revisions
|
||||
saveRevision: (path: string, content: string): Promise<void> => ipcRenderer.invoke('revisions:save', path, content),
|
||||
listRevisions: (path: string): Promise<RevisionMeta[]> => ipcRenderer.invoke('revisions:list', path),
|
||||
loadRevision: (path: string, id: string): Promise<string> => ipcRenderer.invoke('revisions:load', path, id),
|
||||
|
||||
// Static analysis
|
||||
analyzePoem: (content: string): Promise<PoemAnalysis> => ipcRenderer.invoke('analysis:run', content),
|
||||
|
||||
// Config
|
||||
readConfig: (): Promise<GlobalConfig> => ipcRenderer.invoke('config:read'),
|
||||
writeConfig: (updates: Partial<GlobalConfig>): Promise<void> => ipcRenderer.invoke('config:write', updates),
|
||||
pickFolder: (): Promise<string | null> => ipcRenderer.invoke('config:pickFolder'),
|
||||
isAIEnabled: (): Promise<boolean> => ipcRenderer.invoke('config:aiEnabled'),
|
||||
|
||||
// AI chat
|
||||
streamChatMessage: (payload: ChatPayload, onChunk: (chunk: string) => void): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunkHandler = (_: Electron.IpcRendererEvent, chunk: string): void => onChunk(chunk)
|
||||
const doneHandler = (): void => {
|
||||
ipcRenderer.removeListener('ai:chunk', chunkHandler)
|
||||
ipcRenderer.removeListener('ai:done', doneHandler)
|
||||
ipcRenderer.removeListener('ai:error', errorHandler)
|
||||
resolve()
|
||||
}
|
||||
const errorHandler = (_: Electron.IpcRendererEvent, message: string): void => {
|
||||
ipcRenderer.removeListener('ai:chunk', chunkHandler)
|
||||
ipcRenderer.removeListener('ai:done', doneHandler)
|
||||
ipcRenderer.removeListener('ai:error', errorHandler)
|
||||
reject(new Error(message))
|
||||
}
|
||||
ipcRenderer.on('ai:chunk', chunkHandler)
|
||||
ipcRenderer.on('ai:done', doneHandler)
|
||||
ipcRenderer.on('ai:error', errorHandler)
|
||||
ipcRenderer.invoke('ai:streamChat', payload).catch(reject)
|
||||
})
|
||||
},
|
||||
|
||||
// Context menus
|
||||
showPoemContextMenu: (poemId: string): Promise<string | null> => ipcRenderer.invoke('menu:poemContext', poemId),
|
||||
|
||||
// Menu
|
||||
onMenuAction: (handler: (action: string) => void): (() => void) => {
|
||||
const listener = (_: Electron.IpcRendererEvent, action: string): void => handler(action)
|
||||
ipcRenderer.on('menu:action', listener)
|
||||
return () => ipcRenderer.removeListener('menu:action', listener)
|
||||
}
|
||||
})
|
||||
215
src/renderer/App.tsx
Normal file
215
src/renderer/App.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMartiStore } from './store/martiStore'
|
||||
import { PoemSidebar } from './components/Sidebar/PoemSidebar'
|
||||
import { MarkdownEditor } from './components/Editor/MarkdownEditor'
|
||||
import { AnalysisPanel } from './components/Analysis/AnalysisPanel'
|
||||
import { ChatPanel } from './components/AIChat/ChatPanel'
|
||||
import { Dashboard } from './components/Dashboard/Dashboard'
|
||||
import { SettingsDialog } from './components/Settings/SettingsDialog'
|
||||
import { RevisionPanel } from './components/Revisions/RevisionPanel'
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
const {
|
||||
sidebarOpen, setSidebarOpen,
|
||||
analysisOpen, setAnalysisOpen,
|
||||
chatOpen, setChatOpen,
|
||||
focusMode, toggleFocusMode,
|
||||
revisionPanelOpen, toggleRevisionPanel,
|
||||
theme, toggleTheme,
|
||||
fontSize, setFontSize,
|
||||
aiEnabled,
|
||||
activePoemId, isDirty, activePoemPath, activePoemContent, markSaved,
|
||||
poems, setPoems,
|
||||
initPrefs, loadSession, saveSession
|
||||
} = useMartiStore()
|
||||
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
async function init(): Promise<void> {
|
||||
await initPrefs()
|
||||
const poemsList = await window.api.listPoems()
|
||||
setPoems(poemsList)
|
||||
await loadSession()
|
||||
}
|
||||
init()
|
||||
}, [])
|
||||
|
||||
// Auto-save on content change (debounced via revision timer in editor)
|
||||
useEffect(() => {
|
||||
if (!isDirty || !activePoemPath) return
|
||||
const t = setTimeout(async () => {
|
||||
await window.api.writePoem(activePoemPath, activePoemContent)
|
||||
markSaved()
|
||||
}, 1500)
|
||||
return () => clearTimeout(t)
|
||||
}, [activePoemContent, isDirty])
|
||||
|
||||
useEffect(() => {
|
||||
return window.api.onMenuAction(async (action) => {
|
||||
if (action === 'save') {
|
||||
if (activePoemPath && isDirty) {
|
||||
await window.api.writePoem(activePoemPath, activePoemContent)
|
||||
await window.api.saveRevision(activePoemPath, activePoemContent)
|
||||
markSaved()
|
||||
}
|
||||
} else if (action === 'newPoem') {
|
||||
const name = `Untitled ${poems.length + 1}`
|
||||
const created = await window.api.createPoem(name)
|
||||
const refreshed = await window.api.listPoems()
|
||||
setPoems(refreshed)
|
||||
const p = refreshed.find(x => x.path === created.path)
|
||||
if (p) {
|
||||
const content = await window.api.readPoem(p.path)
|
||||
useMartiStore.getState().setActivePoem(p.path, p.id, content)
|
||||
}
|
||||
} else if (action === 'toggleSidebar') {
|
||||
setSidebarOpen(!sidebarOpen)
|
||||
} else if (action === 'toggleAnalysis') {
|
||||
setAnalysisOpen(!analysisOpen)
|
||||
} else if (action === 'toggleChat') {
|
||||
if (aiEnabled) setChatOpen(!chatOpen)
|
||||
} else if (action === 'toggleRevisions') {
|
||||
toggleRevisionPanel()
|
||||
} else if (action === 'toggleFocusMode') {
|
||||
toggleFocusMode()
|
||||
} else if (action === 'toggleTheme') {
|
||||
toggleTheme()
|
||||
} else if (action === 'fontIncrease') {
|
||||
setFontSize(fontSize + 1)
|
||||
} else if (action === 'fontDecrease') {
|
||||
setFontSize(fontSize - 1)
|
||||
} else if (action === 'fontReset') {
|
||||
setFontSize(16)
|
||||
} else if (action === 'openSettings') {
|
||||
setSettingsOpen(true)
|
||||
}
|
||||
})
|
||||
}, [activePoemPath, isDirty, activePoemContent, sidebarOpen, analysisOpen, chatOpen, focusMode, fontSize, poems, aiEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = async (e: KeyboardEvent): Promise<void> => {
|
||||
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key === 's') {
|
||||
e.preventDefault()
|
||||
if (activePoemPath && isDirty) {
|
||||
await window.api.writePoem(activePoemPath, activePoemContent)
|
||||
await window.api.saveRevision(activePoemPath, activePoemContent)
|
||||
markSaved()
|
||||
}
|
||||
} else if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'G') {
|
||||
e.preventDefault()
|
||||
toggleFocusMode()
|
||||
} else if (e.key === 'Escape' && focusMode) {
|
||||
toggleFocusMode()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [activePoemPath, isDirty, activePoemContent, focusMode])
|
||||
|
||||
// Persist session on poem change
|
||||
useEffect(() => {
|
||||
if (activePoemId) saveSession()
|
||||
}, [activePoemId])
|
||||
|
||||
const activePoem = poems.find(p => p.id === activePoemId)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="app-layout"
|
||||
data-sidebar={sidebarOpen ? 'open' : 'closed'}
|
||||
data-analysis={analysisOpen ? 'open' : 'closed'}
|
||||
data-chat={aiEnabled && chatOpen ? 'open' : 'closed'}
|
||||
data-focus={focusMode ? 'on' : 'off'}
|
||||
>
|
||||
{/* Titlebar */}
|
||||
<div className="app-titlebar">
|
||||
<span className="app-titlebar-title">
|
||||
Marti{activePoem ? ` — ${activePoem.meta.title ?? activePoem.id}${isDirty ? ' ●' : ''}` : ''}
|
||||
</span>
|
||||
<div className="app-titlebar-right">
|
||||
<div className="app-layout-toggle">
|
||||
<button
|
||||
className={`app-layout-toggle-seg${sidebarOpen ? ' active' : ''}`}
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
title={sidebarOpen ? 'Hide poem list' : 'Show poem list'}
|
||||
/>
|
||||
<div className="app-layout-toggle-seg app-layout-toggle-seg--mid" />
|
||||
<button
|
||||
className={`app-layout-toggle-seg${analysisOpen ? ' active' : ''}`}
|
||||
onClick={() => setAnalysisOpen(!analysisOpen)}
|
||||
title={analysisOpen ? 'Hide analysis' : 'Show analysis'}
|
||||
/>
|
||||
{aiEnabled && (
|
||||
<>
|
||||
<div className="app-layout-toggle-seg app-layout-toggle-seg--mid" style={{ width: 4 }} />
|
||||
<button
|
||||
className={`app-layout-toggle-seg${chatOpen ? ' active' : ''}`}
|
||||
onClick={() => setChatOpen(!chatOpen)}
|
||||
title={chatOpen ? 'Hide AI chat' : 'Show AI chat'}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className={`app-titlebar-btn${revisionPanelOpen ? ' active' : ''}`}
|
||||
onClick={toggleRevisionPanel}
|
||||
title="Revision history"
|
||||
disabled={!activePoemId}
|
||||
>⟳</button>
|
||||
<button
|
||||
className={`app-titlebar-btn${focusMode ? ' active' : ''}`}
|
||||
onClick={toggleFocusMode}
|
||||
title="Focus mode (⌘⇧G)"
|
||||
>⊡</button>
|
||||
<button className="app-titlebar-btn" onClick={toggleTheme} title={theme === 'dark' ? 'Light mode' : 'Dark mode'}>
|
||||
{theme === 'dark' ? '☀' : '☾'}
|
||||
</button>
|
||||
<button className="app-titlebar-btn" onClick={() => setSettingsOpen(true)} title="Preferences (⌘,)">⚙</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className="sidebar">
|
||||
<PoemSidebar />
|
||||
</aside>
|
||||
|
||||
{/* Editor */}
|
||||
<main className="editor-area" style={{ position: 'relative' }}>
|
||||
{activePoemId ? (
|
||||
<>
|
||||
<MarkdownEditor />
|
||||
{revisionPanelOpen && <RevisionPanel />}
|
||||
</>
|
||||
) : (
|
||||
<Dashboard />
|
||||
)}
|
||||
{focusMode && (
|
||||
<button className="focus-exit" onClick={toggleFocusMode} title="Exit focus mode">✕</button>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Analysis panel */}
|
||||
<aside className="analysis-area">
|
||||
<AnalysisPanel />
|
||||
</aside>
|
||||
|
||||
{/* AI Chat — only rendered when AI is enabled */}
|
||||
{aiEnabled && (
|
||||
<aside className="chat-area">
|
||||
<ChatPanel />
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{settingsOpen && (
|
||||
<SettingsDialog
|
||||
onClose={async () => {
|
||||
setSettingsOpen(false)
|
||||
const refreshed = await window.api.listPoems()
|
||||
setPoems(refreshed)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
85
src/renderer/components/AIChat/ChatPanel.tsx
Normal file
85
src/renderer/components/AIChat/ChatPanel.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useRef, useEffect, useState } from 'react'
|
||||
import { useMartiStore } from '../../store/martiStore'
|
||||
import type { ChatMessage } from '../../types/marti'
|
||||
|
||||
export function ChatPanel(): JSX.Element {
|
||||
const {
|
||||
chatMessages, chatStreaming, activePoemContent,
|
||||
addChatMessage, updateLastChatMessage, clearChat, setChatStreaming
|
||||
} = useMartiStore()
|
||||
|
||||
const [input, setInput] = useState('')
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [chatMessages])
|
||||
|
||||
async function send(): Promise<void> {
|
||||
const text = input.trim()
|
||||
if (!text || chatStreaming) return
|
||||
setInput('')
|
||||
|
||||
const userMsg: ChatMessage = { id: Date.now().toString(), role: 'user', content: text }
|
||||
addChatMessage(userMsg)
|
||||
|
||||
const assistantMsg: ChatMessage = { id: (Date.now() + 1).toString(), role: 'assistant', content: '' }
|
||||
addChatMessage(assistantMsg)
|
||||
setChatStreaming(true)
|
||||
|
||||
try {
|
||||
const history = [...chatMessages, userMsg].map(m => ({ role: m.role, content: m.content }))
|
||||
await window.api.streamChatMessage(
|
||||
{ messages: history, poemContent: activePoemContent },
|
||||
(chunk) => updateLastChatMessage(useMartiStore.getState().chatMessages.at(-1)!.content + chunk)
|
||||
)
|
||||
} catch (err) {
|
||||
updateLastChatMessage(`Error: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
setChatStreaming(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-panel">
|
||||
<div className="chat-header">
|
||||
<span className="chat-header-title">Conversation</span>
|
||||
{chatMessages.length > 0 && (
|
||||
<button className="chat-new-btn" onClick={clearChat} title="Clear">✕</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="chat-messages">
|
||||
{chatMessages.length === 0 && (
|
||||
<div className="chat-placeholder">Ask a question about your poem or just talk through it.</div>
|
||||
)}
|
||||
{chatMessages.map(msg => (
|
||||
<div key={msg.id} className={`chat-message chat-message-${msg.role}`}>
|
||||
<div className="chat-bubble">
|
||||
{msg.content || (msg.role === 'assistant' && chatStreaming ? (
|
||||
<div className="chat-typing"><span /><span /><span /></div>
|
||||
) : null)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
<div className="chat-input-area">
|
||||
<div className="chat-input-row">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="chat-input"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }}
|
||||
placeholder="Message…"
|
||||
rows={1}
|
||||
/>
|
||||
<button className="chat-send-btn" onClick={send} disabled={!input.trim() || chatStreaming}>↑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
130
src/renderer/components/Analysis/AnalysisPanel.tsx
Normal file
130
src/renderer/components/Analysis/AnalysisPanel.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useMartiStore } from '../../store/martiStore'
|
||||
|
||||
const RHYME_COLORS = [
|
||||
'#8b75ff', '#ff7b7b', '#5bc4a0', '#ffb347', '#7ec8e3',
|
||||
'#f9a8d4', '#86efac', '#fde68a', '#c4b5fd', '#fb923c'
|
||||
]
|
||||
|
||||
function rhymeColor(letter: string): string {
|
||||
const idx = letter.charCodeAt(0) - 65
|
||||
return RHYME_COLORS[idx % RHYME_COLORS.length]
|
||||
}
|
||||
|
||||
export function AnalysisPanel(): JSX.Element {
|
||||
const { analysis, analysisLoading, activePoemId } = useMartiStore()
|
||||
|
||||
if (!activePoemId) {
|
||||
return (
|
||||
<div className="analysis-panel">
|
||||
<div className="analysis-header"><span className="analysis-title">Analysis</span></div>
|
||||
<div className="analysis-empty">Open a poem to see analysis.</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!analysis && !analysisLoading) {
|
||||
return (
|
||||
<div className="analysis-panel">
|
||||
<div className="analysis-header"><span className="analysis-title">Analysis</span></div>
|
||||
<div className="analysis-empty">Start writing to see analysis.</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="analysis-panel">
|
||||
<div className="analysis-header">
|
||||
<span className="analysis-title">Analysis</span>
|
||||
{analysisLoading && <span className="analysis-loading" />}
|
||||
</div>
|
||||
|
||||
{analysis && (
|
||||
<div className="analysis-body">
|
||||
|
||||
{/* Form */}
|
||||
{analysis.detectedForm && (
|
||||
<div className="analysis-section">
|
||||
<div className="analysis-label">Form</div>
|
||||
<div className="analysis-form-badge">{analysis.detectedForm}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="analysis-section">
|
||||
<div className="analysis-label">Metrics</div>
|
||||
<div className="analysis-stats-grid">
|
||||
<div className="analysis-stat">
|
||||
<div className="analysis-stat-value">{analysis.lineCount}</div>
|
||||
<div className="analysis-stat-label">lines</div>
|
||||
</div>
|
||||
<div className="analysis-stat">
|
||||
<div className="analysis-stat-value">{analysis.wordCount}</div>
|
||||
<div className="analysis-stat-label">words</div>
|
||||
</div>
|
||||
<div className="analysis-stat">
|
||||
<div className="analysis-stat-value">{analysis.avgSyllablesPerLine}</div>
|
||||
<div className="analysis-stat-label">avg syl/line</div>
|
||||
</div>
|
||||
<div className="analysis-stat">
|
||||
<div className="analysis-stat-value">{Math.round(analysis.uniqueWordRatio * 100)}%</div>
|
||||
<div className="analysis-stat-label">unique words</div>
|
||||
</div>
|
||||
<div className="analysis-stat">
|
||||
<div className="analysis-stat-value">{analysis.longestLine}</div>
|
||||
<div className="analysis-stat-label">longest line</div>
|
||||
</div>
|
||||
<div className="analysis-stat">
|
||||
<div className="analysis-stat-value">{analysis.alliterationCount}</div>
|
||||
<div className="analysis-stat-label">alliterative lines</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rhyme scheme */}
|
||||
{analysis.rhymeScheme.trim() && (
|
||||
<div className="analysis-section">
|
||||
<div className="analysis-label">Rhyme scheme</div>
|
||||
<div className="analysis-rhyme-scheme">
|
||||
{analysis.rhymeScheme.split('').map((letter, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="rhyme-letter"
|
||||
style={{ color: letter.trim() ? rhymeColor(letter) : 'var(--text3)' }}
|
||||
title={letter.trim() ? `Rhyme group ${letter}` : undefined}
|
||||
>
|
||||
{letter.trim() ? letter : '·'}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Per-line syllables */}
|
||||
<div className="analysis-section">
|
||||
<div className="analysis-label">Lines</div>
|
||||
<div className="analysis-lines">
|
||||
{analysis.lines.map((line, i) => (
|
||||
line.text.trim() ? (
|
||||
<div key={i} className="analysis-line-row">
|
||||
<span
|
||||
className="line-rhyme-dot"
|
||||
style={{ color: line.rhymeLetter ? rhymeColor(line.rhymeLetter) : 'transparent' }}
|
||||
>
|
||||
{line.rhymeLetter || '·'}
|
||||
</span>
|
||||
<span className="line-text">{line.text}</span>
|
||||
<span className="line-syl">{line.syllableCount}</span>
|
||||
{line.hasAlliteration && <span className="line-flag" title="Alliteration">~</span>}
|
||||
</div>
|
||||
) : (
|
||||
<div key={i} className="analysis-line-spacer" />
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
60
src/renderer/components/Dashboard/Dashboard.tsx
Normal file
60
src/renderer/components/Dashboard/Dashboard.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useMartiStore } from '../../store/martiStore'
|
||||
|
||||
export function Dashboard(): JSX.Element {
|
||||
const { poems, setPoems, setActivePoem } = useMartiStore()
|
||||
|
||||
const totalLines = poems.reduce((s, p) => s + p.lineCount, 0)
|
||||
const totalWords = poems.reduce((s, p) => s + p.wordCount, 0)
|
||||
|
||||
async function openPoem(path: string, id: string): Promise<void> {
|
||||
const content = await window.api.readPoem(path)
|
||||
setActivePoem(path, id, content)
|
||||
}
|
||||
|
||||
async function newPoem(): Promise<void> {
|
||||
const name = `Untitled ${poems.length + 1}`
|
||||
const created = await window.api.createPoem(name)
|
||||
const refreshed = await window.api.listPoems()
|
||||
setPoems(refreshed)
|
||||
const p = refreshed.find(x => x.path === created.path)
|
||||
if (p) {
|
||||
const content = await window.api.readPoem(p.path)
|
||||
setActivePoem(p.path, p.id, content)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<div className="dashboard-greeting">Good to have you.</div>
|
||||
|
||||
<div className="dashboard-stats-row">
|
||||
<div className="dashboard-stat">
|
||||
<div className="dashboard-stat-value">{poems.length}</div>
|
||||
<div className="dashboard-stat-label">{poems.length === 1 ? 'poem' : 'poems'}</div>
|
||||
</div>
|
||||
<div className="dashboard-stat">
|
||||
<div className="dashboard-stat-value">{totalLines}</div>
|
||||
<div className="dashboard-stat-label">lines</div>
|
||||
</div>
|
||||
<div className="dashboard-stat">
|
||||
<div className="dashboard-stat-value">{totalWords}</div>
|
||||
<div className="dashboard-stat-label">words</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="dashboard-new-btn" onClick={newPoem}>New poem</button>
|
||||
|
||||
{poems.length > 0 && (
|
||||
<div className="dashboard-section">
|
||||
<div className="dashboard-section-title">All poems</div>
|
||||
{poems.map(poem => (
|
||||
<div key={poem.id} className="dashboard-row" onClick={() => openPoem(poem.path, poem.id)}>
|
||||
<span className="dashboard-row-title">{poem.meta.title ?? poem.id}</span>
|
||||
<span className="dashboard-row-meta">{poem.lineCount} lines</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
89
src/renderer/components/Editor/MarkdownEditor.tsx
Normal file
89
src/renderer/components/Editor/MarkdownEditor.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { EditorView, keymap, lineNumbers } from '@codemirror/view'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
import { markdown } from '@codemirror/lang-markdown'
|
||||
import { useMartiStore } from '../../store/martiStore'
|
||||
|
||||
export function MarkdownEditor(): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const viewRef = useRef<EditorView | null>(null)
|
||||
const { activePoemContent, activePoemId, fontSize, editorFont, lineWrap, setContent, setAnalysis, setAnalysisLoading } = useMartiStore()
|
||||
|
||||
const analysisTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const runAnalysis = useCallback(async (content: string) => {
|
||||
if (analysisTimer.current) clearTimeout(analysisTimer.current)
|
||||
analysisTimer.current = setTimeout(async () => {
|
||||
if (!content.trim()) { setAnalysis(null); return }
|
||||
setAnalysisLoading(true)
|
||||
try {
|
||||
const result = await window.api.analyzePoem(content)
|
||||
setAnalysis(result)
|
||||
} finally {
|
||||
setAnalysisLoading(false)
|
||||
}
|
||||
}, 400)
|
||||
}, [setAnalysis, setAnalysisLoading])
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
const updateListener = EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
const content = update.state.doc.toString()
|
||||
setContent(content)
|
||||
runAnalysis(content)
|
||||
}
|
||||
})
|
||||
|
||||
const lineWrapExtension = lineWrap ? EditorView.lineWrapping : []
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: activePoemContent,
|
||||
extensions: [
|
||||
history(),
|
||||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||||
markdown(),
|
||||
lineNumbers(),
|
||||
updateListener,
|
||||
lineWrapExtension,
|
||||
EditorView.theme({
|
||||
'&': { height: '100%', background: 'transparent' },
|
||||
'.cm-scroller': { overflow: 'auto', fontFamily: editorFont === 'mono' ? '"JetBrains Mono", "Fira Code", monospace' : 'Georgia, "Times New Roman", serif', fontSize: `${fontSize}px`, lineHeight: '1.9' },
|
||||
'.cm-content': { padding: '40px 32px 40px', maxWidth: '640px', margin: '0 auto' },
|
||||
'.cm-line': { padding: '0' },
|
||||
'.cm-cursor': { borderLeftColor: 'var(--accent)' },
|
||||
'.cm-selectionBackground': { background: 'rgba(139,117,255,0.18) !important' },
|
||||
'.cm-focused .cm-selectionBackground': { background: 'rgba(139,117,255,0.28) !important' },
|
||||
'.cm-gutters': { background: 'transparent', border: 'none', color: 'var(--text3)', fontSize: '11px' },
|
||||
'.cm-lineNumbers .cm-gutterElement': { padding: '0 8px 0 4px', minWidth: '32px' }
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
const view = new EditorView({ state, parent: containerRef.current })
|
||||
viewRef.current = view
|
||||
|
||||
// Run initial analysis
|
||||
if (activePoemContent.trim()) runAnalysis(activePoemContent)
|
||||
|
||||
return () => {
|
||||
view.destroy()
|
||||
viewRef.current = null
|
||||
}
|
||||
}, [activePoemId, editorFont, lineWrap])
|
||||
|
||||
// Sync font size without rebuilding the editor
|
||||
useEffect(() => {
|
||||
if (viewRef.current) {
|
||||
viewRef.current.dom.style.setProperty('--editor-font-size', `${fontSize}px`)
|
||||
}
|
||||
}, [fontSize])
|
||||
|
||||
return (
|
||||
<div className="editor-scroll">
|
||||
<div ref={containerRef} style={{ height: '100%' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
42
src/renderer/components/Revisions/RevisionPanel.tsx
Normal file
42
src/renderer/components/Revisions/RevisionPanel.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMartiStore } from '../../store/martiStore'
|
||||
import type { RevisionMeta } from '../../types/marti'
|
||||
|
||||
export function RevisionPanel(): JSX.Element {
|
||||
const { activePoemPath, setContent, toggleRevisionPanel } = useMartiStore()
|
||||
const [revisions, setRevisions] = useState<RevisionMeta[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activePoemPath) return
|
||||
window.api.listRevisions(activePoemPath).then(setRevisions)
|
||||
}, [activePoemPath])
|
||||
|
||||
async function restore(id: string): Promise<void> {
|
||||
if (!activePoemPath) return
|
||||
const content = await window.api.loadRevision(activePoemPath, id)
|
||||
setContent(content)
|
||||
toggleRevisionPanel()
|
||||
}
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
return new Date(ts).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="revision-panel">
|
||||
<div className="revision-header">
|
||||
<span className="revision-header-title">Revisions</span>
|
||||
<button className="revision-close" onClick={toggleRevisionPanel}>✕</button>
|
||||
</div>
|
||||
<div className="revision-list">
|
||||
{revisions.length === 0 && <div style={{ padding: 12, fontSize: 12, color: 'var(--text3)' }}>No revisions yet.</div>}
|
||||
{revisions.map(r => (
|
||||
<div key={r.id} className="revision-item" onClick={() => restore(r.id)}>
|
||||
<div className="revision-item-time">{formatTime(r.timestamp)}</div>
|
||||
<div className="revision-item-wc">{r.lineCount} lines</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
123
src/renderer/components/Settings/SettingsDialog.tsx
Normal file
123
src/renderer/components/Settings/SettingsDialog.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useMartiStore } from '../../store/martiStore'
|
||||
|
||||
interface Props {
|
||||
onClose(): void
|
||||
}
|
||||
|
||||
export function SettingsDialog({ onClose }: Props): JSX.Element {
|
||||
const { fontSize, setFontSize, editorFont, setEditorFont, lineWrap, setLineWrap, setAIEnabled } = useMartiStore()
|
||||
|
||||
const [collectionPath, setCollectionPath] = useState('')
|
||||
const [aiEnabled, setLocalAIEnabled] = useState(false)
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [tab, setTab] = useState<'editor' | 'ai'>('editor')
|
||||
|
||||
useEffect(() => {
|
||||
window.api.readConfig().then(cfg => {
|
||||
setCollectionPath(cfg.collectionPath ?? '')
|
||||
setLocalAIEnabled(cfg.ai?.enabled ?? false)
|
||||
setApiKey(cfg.ai?.apiKey ?? '')
|
||||
})
|
||||
}, [])
|
||||
|
||||
async function save(): Promise<void> {
|
||||
await window.api.writeConfig({
|
||||
collectionPath: collectionPath || undefined,
|
||||
ai: { enabled: aiEnabled, apiKey: apiKey || undefined }
|
||||
})
|
||||
const enabled = await window.api.isAIEnabled()
|
||||
setAIEnabled(enabled)
|
||||
onClose()
|
||||
}
|
||||
|
||||
async function pickFolder(): Promise<void> {
|
||||
const path = await window.api.pickFolder()
|
||||
if (path) setCollectionPath(path)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="settings-dialog">
|
||||
<div className="settings-header">
|
||||
<span className="settings-title">Preferences</span>
|
||||
<button className="settings-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
<div className="settings-body">
|
||||
<nav className="settings-nav">
|
||||
<button className={`settings-nav-item${tab === 'editor' ? ' active' : ''}`} onClick={() => setTab('editor')}>Editor</button>
|
||||
<button className={`settings-nav-item${tab === 'ai' ? ' active' : ''}`} onClick={() => setTab('ai')}>AI</button>
|
||||
</nav>
|
||||
<div className="settings-content">
|
||||
{tab === 'editor' && (
|
||||
<>
|
||||
<div className="settings-section-title">Collection</div>
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">Poems folder</label>
|
||||
<div className="settings-field-row">
|
||||
<input value={collectionPath} onChange={e => setCollectionPath(e.target.value)} placeholder="~/Documents/marti-poems" />
|
||||
<button className="settings-pick-btn" onClick={pickFolder}>Choose…</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section-title" style={{ marginTop: 20 }}>Editor</div>
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">Font size</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<button className="settings-pick-btn" onClick={() => setFontSize(fontSize - 1)}>−</button>
|
||||
<span style={{ minWidth: 28, textAlign: 'center' }}>{fontSize}</span>
|
||||
<button className="settings-pick-btn" onClick={() => setFontSize(fontSize + 1)}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">Font style</label>
|
||||
<select value={editorFont} onChange={e => setEditorFont(e.target.value as 'serif' | 'mono')}>
|
||||
<option value="serif">Serif</option>
|
||||
<option value="mono">Monospace</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">
|
||||
<input type="checkbox" checked={lineWrap} onChange={e => setLineWrap(e.target.checked)} style={{ marginRight: 6 }} />
|
||||
Wrap long lines
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'ai' && (
|
||||
<>
|
||||
<div className="settings-section-title">AI — opt-in</div>
|
||||
<p className="settings-hint" style={{ marginBottom: 16 }}>
|
||||
AI features are off by default. When enabled, your poem text is sent to Anthropic's servers for processing. Your poems are never used to train models.
|
||||
</p>
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">
|
||||
<input type="checkbox" checked={aiEnabled} onChange={e => setLocalAIEnabled(e.target.checked)} style={{ marginRight: 6 }} />
|
||||
Enable AI conversation
|
||||
</label>
|
||||
</div>
|
||||
{aiEnabled && (
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">Anthropic API key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={e => setApiKey(e.target.value)}
|
||||
placeholder="sk-ant-…"
|
||||
/>
|
||||
<div className="settings-hint">Your key is stored locally and never shared.</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '12px 20px', borderTop: '1px solid var(--border)', display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button className="btn-secondary" onClick={onClose}>Cancel</button>
|
||||
<button className="btn-primary" onClick={save} style={{ padding: '0 20px' }}>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
99
src/renderer/components/Sidebar/PoemSidebar.tsx
Normal file
99
src/renderer/components/Sidebar/PoemSidebar.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useState } from 'react'
|
||||
import { useMartiStore } from '../../store/martiStore'
|
||||
|
||||
export function PoemSidebar(): JSX.Element {
|
||||
const { poems, setPoems, activePoemId, setActivePoem, saveSession, isDirty, activePoemPath, activePoemContent, markSaved } = useMartiStore()
|
||||
const [search, setSearch] = useState('')
|
||||
const [renaming, setRenaming] = useState<string | null>(null)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
|
||||
const filtered = poems.filter(p => {
|
||||
const label = p.meta.title ?? p.id
|
||||
return label.toLowerCase().includes(search.toLowerCase())
|
||||
})
|
||||
|
||||
async function openPoem(path: string, id: string): Promise<void> {
|
||||
if (isDirty && activePoemPath) {
|
||||
await window.api.writePoem(activePoemPath, activePoemContent)
|
||||
await window.api.saveRevision(activePoemPath, activePoemContent)
|
||||
markSaved()
|
||||
}
|
||||
const content = await window.api.readPoem(path)
|
||||
setActivePoem(path, id, content)
|
||||
await saveSession()
|
||||
}
|
||||
|
||||
async function handleContextMenu(e: React.MouseEvent, id: string): Promise<void> {
|
||||
e.preventDefault()
|
||||
const action = await window.api.showPoemContextMenu(id)
|
||||
if (action === 'rename') {
|
||||
setRenaming(id)
|
||||
setRenameValue(id)
|
||||
} else if (action === 'delete') {
|
||||
const poem = poems.find(p => p.id === id)
|
||||
if (poem) {
|
||||
await window.api.deletePoem(poem.path)
|
||||
const refreshed = await window.api.listPoems()
|
||||
setPoems(refreshed)
|
||||
if (activePoemId === id) setActivePoem('', '', '')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function commitRename(id: string): Promise<void> {
|
||||
const poem = poems.find(p => p.id === id)
|
||||
if (!poem || !renameValue.trim() || renameValue === id) { setRenaming(null); return }
|
||||
await window.api.renamePoem(poem.path, renameValue.trim())
|
||||
const refreshed = await window.api.listPoems()
|
||||
setPoems(refreshed)
|
||||
setRenaming(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sidebar-header">
|
||||
<span className="sidebar-title">Poems</span>
|
||||
</div>
|
||||
<div className="sidebar-search">
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search…"
|
||||
/>
|
||||
</div>
|
||||
<div className="sidebar-list">
|
||||
{filtered.map(poem => (
|
||||
<div
|
||||
key={poem.id}
|
||||
className={`poem-item${activePoemId === poem.id ? ' active' : ''}`}
|
||||
onClick={() => openPoem(poem.path, poem.id)}
|
||||
onContextMenu={e => handleContextMenu(e, poem.id)}
|
||||
>
|
||||
{renaming === poem.id ? (
|
||||
<input
|
||||
className="inline-edit"
|
||||
value={renameValue}
|
||||
autoFocus
|
||||
onChange={e => setRenameValue(e.target.value)}
|
||||
onBlur={() => commitRename(poem.id)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') commitRename(poem.id)
|
||||
if (e.key === 'Escape') setRenaming(null)
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<div className="poem-item-main">
|
||||
<div className="poem-item-title">{poem.meta.title ?? poem.id}</div>
|
||||
<div className="poem-item-meta">{poem.lineCount} {poem.lineCount === 1 ? 'line' : 'lines'}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="sidebar-empty">{search ? 'No matches' : 'No poems yet'}</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
12
src/renderer/index.html
Normal file
12
src/renderer/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Marti</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
10
src/renderer/main.tsx
Normal file
10
src/renderer/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './styles/app.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
)
|
||||
152
src/renderer/store/martiStore.ts
Normal file
152
src/renderer/store/martiStore.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { create } from 'zustand'
|
||||
import type { PoemFile, PoemAnalysis, ChatMessage } from '../types/marti'
|
||||
|
||||
interface MartiStore {
|
||||
// Layout
|
||||
sidebarOpen: boolean
|
||||
analysisOpen: boolean
|
||||
chatOpen: boolean
|
||||
focusMode: boolean
|
||||
revisionPanelOpen: boolean
|
||||
theme: 'dark' | 'light'
|
||||
fontSize: number
|
||||
editorFont: 'serif' | 'mono'
|
||||
lineWrap: boolean
|
||||
aiEnabled: boolean
|
||||
|
||||
// Content
|
||||
poems: PoemFile[]
|
||||
activePoemId: string | null
|
||||
activePoemPath: string | null
|
||||
activePoemContent: string
|
||||
isDirty: boolean
|
||||
|
||||
// Analysis
|
||||
analysis: PoemAnalysis | null
|
||||
analysisLoading: boolean
|
||||
|
||||
// AI chat
|
||||
chatMessages: ChatMessage[]
|
||||
chatStreaming: boolean
|
||||
|
||||
// Actions
|
||||
setSidebarOpen(v: boolean): void
|
||||
setAnalysisOpen(v: boolean): void
|
||||
setChatOpen(v: boolean): void
|
||||
toggleFocusMode(): void
|
||||
toggleRevisionPanel(): void
|
||||
toggleTheme(): void
|
||||
setFontSize(n: number): void
|
||||
setEditorFont(f: 'serif' | 'mono'): void
|
||||
setLineWrap(v: boolean): void
|
||||
setAIEnabled(v: boolean): void
|
||||
setPoems(poems: PoemFile[]): void
|
||||
setActivePoem(path: string, id: string, content: string): void
|
||||
setContent(content: string): void
|
||||
markSaved(): void
|
||||
setAnalysis(a: PoemAnalysis | null): void
|
||||
setAnalysisLoading(v: boolean): void
|
||||
addChatMessage(msg: ChatMessage): void
|
||||
updateLastChatMessage(content: string): void
|
||||
clearChat(): void
|
||||
setChatStreaming(v: boolean): void
|
||||
initPrefs(): Promise<void>
|
||||
loadSession(): Promise<void>
|
||||
saveSession(): Promise<void>
|
||||
}
|
||||
|
||||
export const useMartiStore = create<MartiStore>((set, get) => ({
|
||||
sidebarOpen: true,
|
||||
analysisOpen: true,
|
||||
chatOpen: false,
|
||||
focusMode: false,
|
||||
revisionPanelOpen: false,
|
||||
theme: 'dark',
|
||||
fontSize: 16,
|
||||
editorFont: 'serif',
|
||||
lineWrap: false,
|
||||
aiEnabled: false,
|
||||
|
||||
poems: [],
|
||||
activePoemId: null,
|
||||
activePoemPath: null,
|
||||
activePoemContent: '',
|
||||
isDirty: false,
|
||||
|
||||
analysis: null,
|
||||
analysisLoading: false,
|
||||
|
||||
chatMessages: [],
|
||||
chatStreaming: false,
|
||||
|
||||
setSidebarOpen: (v) => set({ sidebarOpen: v }),
|
||||
setAnalysisOpen: (v) => set({ analysisOpen: v }),
|
||||
setChatOpen: (v) => set({ chatOpen: v }),
|
||||
toggleFocusMode: () => set(s => ({ focusMode: !s.focusMode })),
|
||||
toggleRevisionPanel: () => set(s => ({ revisionPanelOpen: !s.revisionPanelOpen })),
|
||||
toggleTheme: () => {
|
||||
const next = get().theme === 'dark' ? 'light' : 'dark'
|
||||
set({ theme: next })
|
||||
document.documentElement.classList.toggle('light', next === 'light')
|
||||
window.api.writeConfig({ theme: next })
|
||||
},
|
||||
setFontSize: (n) => {
|
||||
const clamped = Math.max(12, Math.min(28, n))
|
||||
set({ fontSize: clamped })
|
||||
window.api.writeConfig({ fontSize: clamped })
|
||||
},
|
||||
setEditorFont: (f) => {
|
||||
set({ editorFont: f })
|
||||
window.api.writeConfig({ editorFont: f })
|
||||
},
|
||||
setLineWrap: (v) => {
|
||||
set({ lineWrap: v })
|
||||
window.api.writeConfig({ lineWrap: v })
|
||||
},
|
||||
setAIEnabled: (v) => set({ aiEnabled: v }),
|
||||
setPoems: (poems) => set({ poems }),
|
||||
setActivePoem: (path, id, content) => set({ activePoemPath: path, activePoemId: id, activePoemContent: content, isDirty: false, analysis: null, chatMessages: [] }),
|
||||
setContent: (content) => set({ activePoemContent: content, isDirty: true }),
|
||||
markSaved: () => set({ isDirty: false }),
|
||||
setAnalysis: (a) => set({ analysis: a }),
|
||||
setAnalysisLoading: (v) => set({ analysisLoading: v }),
|
||||
addChatMessage: (msg) => set(s => ({ chatMessages: [...s.chatMessages, msg] })),
|
||||
updateLastChatMessage: (content) => set(s => {
|
||||
const msgs = [...s.chatMessages]
|
||||
if (msgs.length > 0) msgs[msgs.length - 1] = { ...msgs[msgs.length - 1], content }
|
||||
return { chatMessages: msgs }
|
||||
}),
|
||||
clearChat: () => set({ chatMessages: [] }),
|
||||
setChatStreaming: (v) => set({ chatStreaming: v }),
|
||||
|
||||
initPrefs: async () => {
|
||||
const cfg = await window.api.readConfig()
|
||||
const isDark = cfg.theme !== 'light'
|
||||
document.documentElement.classList.toggle('light', !isDark)
|
||||
const aiEnabled = await window.api.isAIEnabled()
|
||||
set({
|
||||
theme: cfg.theme ?? 'dark',
|
||||
fontSize: cfg.fontSize ?? 16,
|
||||
editorFont: cfg.editorFont ?? 'serif',
|
||||
lineWrap: cfg.lineWrap ?? false,
|
||||
aiEnabled
|
||||
})
|
||||
},
|
||||
|
||||
loadSession: async () => {
|
||||
const session = await window.api.readSession()
|
||||
const { poems, setActivePoem } = get()
|
||||
if (session.activePoemId && typeof session.activePoemId === 'string') {
|
||||
const poem = poems.find(p => p.id === session.activePoemId)
|
||||
if (poem) {
|
||||
const content = await window.api.readPoem(poem.path)
|
||||
setActivePoem(poem.path, poem.id, content)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
saveSession: async () => {
|
||||
const { activePoemId } = get()
|
||||
await window.api.writeSession({ activePoemId })
|
||||
}
|
||||
}))
|
||||
350
src/renderer/styles/app.css
Normal file
350
src/renderer/styles/app.css
Normal file
@@ -0,0 +1,350 @@
|
||||
/* ── Reset & base ─────────────────────────────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #18181a;
|
||||
--bg2: #202024;
|
||||
--bg3: #28282e;
|
||||
--border: #35353d;
|
||||
--text: #d8d4e8;
|
||||
--text2: #888098;
|
||||
--text3: #524e60;
|
||||
--accent: #8b75ff;
|
||||
--accent2: #6a55d4;
|
||||
--danger: #b05060;
|
||||
--success: #5a9070;
|
||||
--sidebar-w: 210px;
|
||||
--analysis-w: 260px;
|
||||
--chat-w: 290px;
|
||||
--titlebar-h: 38px;
|
||||
}
|
||||
|
||||
:root.light {
|
||||
--bg: #f4f1f0;
|
||||
--bg2: #ede9e8;
|
||||
--bg3: #e4e0de;
|
||||
--border: #ccc7c4;
|
||||
--text: #1e1a2a;
|
||||
--text2: #6a6278;
|
||||
--text3: #a09aae;
|
||||
--accent: #6248d4;
|
||||
--accent2: #4a30b2;
|
||||
--danger: #902030;
|
||||
--success: #3a7050;
|
||||
}
|
||||
|
||||
html, body, #root { height: 100%; width: 100%; overflow: hidden; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-size: 13px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: subpixel-antialiased;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
button { cursor: pointer; border: none; background: none; color: inherit; font: inherit; }
|
||||
input, textarea, select {
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
background: var(--bg3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
outline: none;
|
||||
}
|
||||
input[type="checkbox"] { width: auto; padding: 0; }
|
||||
input:focus, textarea:focus, select:focus { border-color: var(--accent); }
|
||||
textarea { resize: vertical; }
|
||||
|
||||
/* ── Layout ───────────────────────────────────────────────────────────────── */
|
||||
.app-layout {
|
||||
display: grid;
|
||||
height: 100vh;
|
||||
grid-template-rows: var(--titlebar-h) 1fr;
|
||||
grid-template-columns: var(--sidebar-w) 1fr var(--analysis-w);
|
||||
grid-template-areas:
|
||||
"titlebar titlebar titlebar"
|
||||
"sidebar editor analysis";
|
||||
transition: grid-template-columns 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* With AI chat enabled */
|
||||
.app-layout[data-chat="open"] {
|
||||
grid-template-columns: var(--sidebar-w) 1fr var(--analysis-w) var(--chat-w);
|
||||
grid-template-areas:
|
||||
"titlebar titlebar titlebar titlebar"
|
||||
"sidebar editor analysis chat";
|
||||
}
|
||||
|
||||
.app-layout[data-sidebar="closed"] { grid-template-columns: 0 1fr var(--analysis-w); }
|
||||
.app-layout[data-analysis="closed"] { grid-template-columns: var(--sidebar-w) 1fr 0; }
|
||||
.app-layout[data-sidebar="closed"][data-analysis="closed"] { grid-template-columns: 0 1fr 0; }
|
||||
|
||||
.app-layout[data-sidebar="closed"][data-chat="open"] { grid-template-columns: 0 1fr var(--analysis-w) var(--chat-w); }
|
||||
.app-layout[data-analysis="closed"][data-chat="open"] { grid-template-columns: var(--sidebar-w) 1fr 0 var(--chat-w); }
|
||||
.app-layout[data-sidebar="closed"][data-analysis="closed"][data-chat="open"] { grid-template-columns: 0 1fr 0 var(--chat-w); }
|
||||
|
||||
.app-layout[data-focus="on"] { grid-template-columns: 0 1fr 0 !important; }
|
||||
.app-layout[data-focus="on"][data-chat="open"] { grid-template-columns: 0 1fr 0 0 !important; }
|
||||
|
||||
/* ── Titlebar ─────────────────────────────────────────────────────────────── */
|
||||
.app-titlebar {
|
||||
grid-area: titlebar;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px 0 80px;
|
||||
background: var(--bg2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
-webkit-app-region: drag;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.app-titlebar-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
.app-titlebar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
.app-layout-toggle {
|
||||
display: flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.app-layout-toggle-seg {
|
||||
width: 22px;
|
||||
height: 18px;
|
||||
background: var(--bg3);
|
||||
border: none;
|
||||
border-left: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.app-layout-toggle-seg:first-child { border-left: none; }
|
||||
.app-layout-toggle-seg.active { background: var(--accent); }
|
||||
.app-layout-toggle-seg--mid { width: 28px; cursor: default; pointer-events: none; }
|
||||
.app-titlebar-btn {
|
||||
width: 26px; height: 20px; border-radius: 4px; font-size: 14px;
|
||||
color: var(--text2); transition: color 0.12s, background 0.12s;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.app-titlebar-btn:hover { color: var(--text); background: var(--bg3); }
|
||||
.app-titlebar-btn.active { color: var(--accent); }
|
||||
.app-titlebar-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
/* ── Sidebar ──────────────────────────────────────────────────────────────── */
|
||||
.sidebar {
|
||||
grid-area: sidebar;
|
||||
display: flex; flex-direction: column;
|
||||
background: transparent;
|
||||
border-right: 1px solid var(--border);
|
||||
overflow: hidden; min-width: 0;
|
||||
}
|
||||
.sidebar-header {
|
||||
display: flex; align-items: center;
|
||||
padding: 0 8px 0 12px; height: 38px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0; gap: 4px;
|
||||
}
|
||||
.sidebar-title { font-size: 11px; font-weight: 600; color: var(--text2); text-transform: uppercase; letter-spacing: 0.06em; flex: 1; }
|
||||
.sidebar-search { padding: 6px 8px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
|
||||
.sidebar-search input { width: 100%; font-size: 12px; }
|
||||
.sidebar-list { flex: 1; overflow-y: auto; padding: 4px 0; }
|
||||
.sidebar-empty { font-size: 12px; color: var(--text3); padding: 12px; }
|
||||
|
||||
.poem-item {
|
||||
display: flex; align-items: center;
|
||||
padding: 7px 12px; gap: 8px; cursor: pointer;
|
||||
transition: background 0.1s; position: relative;
|
||||
}
|
||||
.poem-item:hover { background: var(--bg3); }
|
||||
.poem-item.active { background: var(--bg3); }
|
||||
.poem-item.active::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 2px; background: var(--accent); }
|
||||
.poem-item-main { flex: 1; min-width: 0; }
|
||||
.poem-item-title { font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.poem-item-meta { font-size: 11px; color: var(--text3); margin-top: 1px; }
|
||||
|
||||
/* ── Editor area ──────────────────────────────────────────────────────────── */
|
||||
.editor-area {
|
||||
grid-area: editor;
|
||||
display: flex; flex-direction: column;
|
||||
overflow: hidden; background: var(--bg); min-width: 0;
|
||||
}
|
||||
.editor-scroll { flex: 1; overflow-y: auto; overscroll-behavior: none; }
|
||||
|
||||
/* CodeMirror */
|
||||
.cm-editor { height: 100%; }
|
||||
.cm-editor.cm-focused { outline: none; }
|
||||
|
||||
/* ── Analysis panel ───────────────────────────────────────────────────────── */
|
||||
.analysis-area {
|
||||
grid-area: analysis;
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--bg2);
|
||||
border-left: 1px solid var(--border);
|
||||
overflow: hidden; min-width: 0;
|
||||
}
|
||||
.analysis-panel { display: flex; flex-direction: column; height: 100%; }
|
||||
.analysis-header {
|
||||
display: flex; align-items: center;
|
||||
padding: 0 12px; height: 38px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0; gap: 8px;
|
||||
}
|
||||
.analysis-title { font-size: 11px; font-weight: 600; color: var(--text2); text-transform: uppercase; letter-spacing: 0.06em; flex: 1; }
|
||||
.analysis-loading {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--accent); opacity: 0.7;
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse { 0%, 100% { opacity: 0.3 } 50% { opacity: 1 } }
|
||||
.analysis-empty { font-size: 12px; color: var(--text3); padding: 16px 12px; }
|
||||
.analysis-body { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.analysis-section {}
|
||||
.analysis-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.07em; color: var(--text3); margin-bottom: 6px; }
|
||||
.analysis-form-badge { font-size: 12px; color: var(--accent); font-weight: 500; }
|
||||
|
||||
.analysis-stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||
.analysis-stat { display: flex; flex-direction: column; gap: 2px; }
|
||||
.analysis-stat-value { font-size: 18px; font-weight: 300; color: var(--text); line-height: 1; }
|
||||
.analysis-stat-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text3); }
|
||||
|
||||
.analysis-rhyme-scheme { display: flex; flex-wrap: wrap; gap: 3px; font-size: 13px; font-weight: 600; font-family: monospace; }
|
||||
.rhyme-letter { transition: color 0.1s; }
|
||||
|
||||
.analysis-lines { display: flex; flex-direction: column; gap: 2px; }
|
||||
.analysis-line-row {
|
||||
display: flex; align-items: baseline; gap: 6px;
|
||||
font-size: 11px; min-height: 18px;
|
||||
}
|
||||
.line-rhyme-dot { font-size: 10px; font-weight: 700; font-family: monospace; width: 12px; flex-shrink: 0; text-align: center; }
|
||||
.line-text { flex: 1; color: var(--text2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: Georgia, serif; font-size: 11px; }
|
||||
.line-syl { color: var(--text3); font-variant-numeric: tabular-nums; font-size: 10px; flex-shrink: 0; }
|
||||
.line-flag { color: var(--accent); font-size: 10px; flex-shrink: 0; }
|
||||
.analysis-line-spacer { height: 6px; }
|
||||
|
||||
/* ── Chat area ────────────────────────────────────────────────────────────── */
|
||||
.chat-area {
|
||||
grid-area: chat;
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--bg2);
|
||||
border-left: 1px solid var(--border);
|
||||
overflow: hidden; min-width: 0;
|
||||
}
|
||||
.chat-panel { display: flex; flex-direction: column; height: 100%; }
|
||||
.chat-header {
|
||||
display: flex; align-items: center;
|
||||
padding: 0 12px; height: 38px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0; gap: 6px;
|
||||
}
|
||||
.chat-header-title { font-size: 11px; font-weight: 600; color: var(--text2); text-transform: uppercase; letter-spacing: 0.06em; flex: 1; }
|
||||
.chat-new-btn { font-size: 11px; color: var(--text3); padding: 2px 6px; border-radius: 3px; }
|
||||
.chat-new-btn:hover { color: var(--accent); background: var(--bg3); }
|
||||
.chat-messages {
|
||||
flex: 1; overflow-y: auto; padding: 12px;
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
.chat-placeholder { font-size: 13px; color: var(--text3); line-height: 1.6; text-align: center; margin-top: 24px; }
|
||||
.chat-message { max-width: 100%; }
|
||||
.chat-message-user { align-self: flex-end; }
|
||||
.chat-message-assistant { align-self: flex-start; }
|
||||
.chat-bubble { padding: 8px 12px; border-radius: 8px; font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
|
||||
.chat-message-user .chat-bubble { background: var(--accent); color: #fff; }
|
||||
.chat-message-assistant .chat-bubble { background: var(--bg3); color: var(--text); }
|
||||
.chat-typing { display: flex; gap: 4px; }
|
||||
.chat-typing span { width: 6px; height: 6px; border-radius: 50%; background: var(--text3); animation: typing 1.2s infinite; }
|
||||
.chat-typing span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.chat-typing span:nth-child(3) { animation-delay: 0.4s; }
|
||||
@keyframes typing { 0%, 80%, 100% { opacity: 0.3 } 40% { opacity: 1 } }
|
||||
.chat-input-area { border-top: 1px solid var(--border); padding: 10px; flex-shrink: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.chat-input-row { display: flex; gap: 6px; align-items: flex-end; }
|
||||
.chat-input { flex: 1; min-height: 36px; max-height: 120px; resize: none; font-size: 13px; padding: 8px 10px; border-radius: 6px; line-height: 1.5; }
|
||||
.chat-send-btn { width: 32px; height: 36px; border-radius: 6px; background: var(--accent); color: #fff; font-size: 16px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: opacity 0.12s; }
|
||||
.chat-send-btn:hover { opacity: 0.85; }
|
||||
.chat-send-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
/* ── Dashboard ────────────────────────────────────────────────────────────── */
|
||||
.dashboard { flex: 1; overflow-y: auto; padding: 40px 40px; }
|
||||
.dashboard-greeting { font-size: 22px; font-weight: 300; color: var(--text); margin-bottom: 28px; }
|
||||
.dashboard-stats-row { display: flex; gap: 28px; margin-bottom: 28px; }
|
||||
.dashboard-stat { display: flex; flex-direction: column; gap: 2px; }
|
||||
.dashboard-stat-value { font-size: 28px; font-weight: 300; color: var(--text); line-height: 1; }
|
||||
.dashboard-stat-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text3); }
|
||||
.dashboard-new-btn { padding: 8px 20px; border-radius: 6px; background: var(--accent); color: #fff; font-size: 13px; font-weight: 500; margin-bottom: 32px; transition: opacity 0.12s; }
|
||||
.dashboard-new-btn:hover { opacity: 0.85; }
|
||||
.dashboard-section { }
|
||||
.dashboard-section-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text3); margin-bottom: 8px; }
|
||||
.dashboard-row { display: flex; align-items: baseline; gap: 8px; padding: 8px 0; border-bottom: 1px solid var(--border); cursor: pointer; }
|
||||
.dashboard-row:last-child { border-bottom: none; }
|
||||
.dashboard-row:hover .dashboard-row-title { color: var(--accent); }
|
||||
.dashboard-row-title { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px; }
|
||||
.dashboard-row-meta { font-size: 11px; color: var(--text3); flex-shrink: 0; }
|
||||
|
||||
/* ── Revisions ────────────────────────────────────────────────────────────── */
|
||||
.revision-panel {
|
||||
position: absolute; inset-y: 0; right: 0; width: 220px;
|
||||
background: var(--bg2); border-left: 1px solid var(--border);
|
||||
display: flex; flex-direction: column; z-index: 10;
|
||||
}
|
||||
.revision-header { display: flex; align-items: center; padding: 0 10px; height: 38px; border-bottom: 1px solid var(--border); flex-shrink: 0; gap: 6px; }
|
||||
.revision-header-title { font-size: 12px; font-weight: 600; color: var(--text2); flex: 1; }
|
||||
.revision-close { width: 20px; height: 20px; font-size: 14px; color: var(--text3); display: flex; align-items: center; justify-content: center; border-radius: 3px; }
|
||||
.revision-close:hover { color: var(--text); background: var(--bg3); }
|
||||
.revision-list { flex: 1; overflow-y: auto; padding: 4px; }
|
||||
.revision-item { padding: 8px 10px; border-radius: 5px; cursor: pointer; font-size: 12px; margin-bottom: 2px; transition: background 0.1s; }
|
||||
.revision-item:hover { background: var(--bg3); }
|
||||
.revision-item-time { color: var(--text2); }
|
||||
.revision-item-wc { color: var(--text3); font-size: 11px; margin-top: 2px; }
|
||||
|
||||
/* ── Settings ─────────────────────────────────────────────────────────────── */
|
||||
.settings-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
||||
.settings-dialog { background: var(--bg2); border: 1px solid var(--border); border-radius: 12px; width: 520px; max-height: 80vh; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.settings-header { display: flex; align-items: center; padding: 16px 20px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
|
||||
.settings-title { font-size: 16px; font-weight: 600; flex: 1; }
|
||||
.settings-close { width: 24px; height: 24px; font-size: 16px; border-radius: 4px; color: var(--text3); }
|
||||
.settings-close:hover { color: var(--text); background: var(--bg3); }
|
||||
.settings-body { display: flex; flex: 1; overflow: hidden; }
|
||||
.settings-nav { width: 130px; border-right: 1px solid var(--border); padding: 12px 0; flex-shrink: 0; }
|
||||
.settings-nav-item { width: 100%; padding: 8px 16px; text-align: left; font-size: 13px; color: var(--text2); }
|
||||
.settings-nav-item:hover { color: var(--text); background: var(--bg3); }
|
||||
.settings-nav-item.active { color: var(--accent); background: var(--bg3); }
|
||||
.settings-content { flex: 1; overflow-y: auto; padding: 20px; }
|
||||
.settings-section-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text3); margin-bottom: 14px; }
|
||||
.settings-field { margin-bottom: 14px; }
|
||||
.settings-label { font-size: 12px; color: var(--text2); display: block; margin-bottom: 5px; }
|
||||
.settings-field input, .settings-field select { width: 100%; }
|
||||
.settings-hint { font-size: 11px; color: var(--text3); margin-top: 4px; line-height: 1.5; }
|
||||
.settings-field-row { display: flex; gap: 8px; align-items: flex-end; }
|
||||
.settings-field-row input { flex: 1; }
|
||||
.settings-pick-btn { height: 29px; padding: 0 10px; border-radius: 4px; background: var(--bg3); border: 1px solid var(--border); font-size: 12px; color: var(--text2); flex-shrink: 0; }
|
||||
.settings-pick-btn:hover { color: var(--text); }
|
||||
.btn-primary { flex: 1; height: 30px; border-radius: 4px; background: var(--accent); color: #fff; font-weight: 600; font-size: 12px; }
|
||||
.btn-primary:hover { opacity: 0.85; }
|
||||
.btn-secondary { height: 30px; padding: 0 12px; border-radius: 4px; background: var(--bg3); color: var(--text2); font-size: 12px; border: 1px solid var(--border); }
|
||||
.btn-secondary:hover { color: var(--text); }
|
||||
|
||||
/* Focus mode */
|
||||
.focus-exit { position: absolute; top: 16px; right: 20px; z-index: 50; font-size: 18px; color: var(--text3); width: 32px; height: 32px; border-radius: 6px; display: flex; align-items: center; justify-content: center; }
|
||||
.focus-exit:hover { color: var(--text); background: var(--bg3); }
|
||||
|
||||
/* Inline rename */
|
||||
.inline-edit { background: none; border: none; padding: 0; font: inherit; color: inherit; width: 100%; outline: none; border-bottom: 1px solid var(--accent); }
|
||||
|
||||
/* Scrollbars */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text3); }
|
||||
34
src/renderer/types/global.d.ts
vendored
Normal file
34
src/renderer/types/global.d.ts
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { PoemFile, PoemMeta, RevisionMeta, CollectionConfig } from '../main/fileSystem'
|
||||
import type { PoemAnalysis } from '../main/staticAnalysis'
|
||||
import type { GlobalConfig } from '../main/globalConfig'
|
||||
import type { ChatPayload } from '../main/aiService'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
api: {
|
||||
listPoems(): Promise<PoemFile[]>
|
||||
readPoem(path: string): Promise<string>
|
||||
writePoem(path: string, content: string): Promise<void>
|
||||
createPoem(name: string): Promise<{ id: string; path: string }>
|
||||
renamePoem(oldPath: string, newName: string): Promise<string>
|
||||
deletePoem(path: string): Promise<void>
|
||||
getPoemMeta(id: string): Promise<PoemMeta>
|
||||
setPoemMeta(id: string, meta: PoemMeta): Promise<void>
|
||||
saveOrder(order: string[]): Promise<void>
|
||||
getCollectionConfig(): Promise<CollectionConfig>
|
||||
readSession(): Promise<Record<string, unknown>>
|
||||
writeSession(data: Record<string, unknown>): Promise<void>
|
||||
saveRevision(path: string, content: string): Promise<void>
|
||||
listRevisions(path: string): Promise<RevisionMeta[]>
|
||||
loadRevision(path: string, id: string): Promise<string>
|
||||
analyzePoem(content: string): Promise<PoemAnalysis>
|
||||
readConfig(): Promise<GlobalConfig>
|
||||
writeConfig(updates: Partial<GlobalConfig>): Promise<void>
|
||||
pickFolder(): Promise<string | null>
|
||||
isAIEnabled(): Promise<boolean>
|
||||
streamChatMessage(payload: ChatPayload, onChunk: (chunk: string) => void): Promise<void>
|
||||
showPoemContextMenu(poemId: string): Promise<string | null>
|
||||
onMenuAction(handler: (action: string) => void): () => void
|
||||
}
|
||||
}
|
||||
}
|
||||
49
src/renderer/types/marti.ts
Normal file
49
src/renderer/types/marti.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export interface PoemMeta {
|
||||
title?: string
|
||||
form?: string
|
||||
tags?: string[]
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface PoemFile {
|
||||
id: string
|
||||
path: string
|
||||
lineCount: number
|
||||
wordCount: number
|
||||
meta: PoemMeta
|
||||
}
|
||||
|
||||
export interface RevisionMeta {
|
||||
id: string
|
||||
timestamp: number
|
||||
lineCount: number
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface LineMetrics {
|
||||
lineNumber: number
|
||||
text: string
|
||||
syllableCount: number
|
||||
charCount: number
|
||||
endWord: string
|
||||
rhymeLetter: string
|
||||
hasAlliteration: boolean
|
||||
}
|
||||
|
||||
export interface PoemAnalysis {
|
||||
lines: LineMetrics[]
|
||||
lineCount: number
|
||||
wordCount: number
|
||||
uniqueWordRatio: number
|
||||
rhymeScheme: string
|
||||
detectedForm: string | null
|
||||
avgSyllablesPerLine: number
|
||||
alliterationCount: number
|
||||
longestLine: number
|
||||
shortestLine: number
|
||||
}
|
||||
1
tsconfig.json
Normal file
1
tsconfig.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "files": [], "references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }] }
|
||||
5
tsconfig.node.json
Normal file
5
tsconfig.node.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
||||
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"],
|
||||
"compilerOptions": { "composite": true, "types": ["electron-vite/node"] }
|
||||
}
|
||||
10
tsconfig.web.json
Normal file
10
tsconfig.web.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
|
||||
"include": ["src/renderer/**/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"baseUrl": ".",
|
||||
"paths": { "@renderer/*": ["src/renderer/*"] },
|
||||
"types": ["vite/client"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user