🎉 initial commit

This commit is contained in:
2026-06-08 17:15:43 +10:00
commit bfece01c67
33 changed files with 9920 additions and 0 deletions

35
.gitignore vendored Normal file
View File

@@ -0,0 +1,35 @@
# Dependencies
node_modules/
# Build output
out/
dist/
# TypeScript incremental build info
*.tsbuildinfo
# Electron builder artifacts
dist_electron/
release/
# OS
.DS_Store
Thumbs.db
# Editor
.idea/
.vscode/
*.swp
*.swo
# Environment / secrets
.env
.env.local
.env.*.local
# Logs
*.log
npm-debug.log*
# Claude Code
.claude/settings.local.json

12
electron.vite.config.ts Normal file
View 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()]
}
})

6246
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

61
package.json Normal file
View File

@@ -0,0 +1,61 @@
{
"name": "borges",
"version": "1.0.0",
"description": "Flash fiction writing, analysis, and submission tracking",
"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",
"marked": "^17.0.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"zustand": "^4.5.0"
},
"build": {
"appId": "com.borges.editor",
"productName": "Borges",
"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" }]
}
}

BIN
resources/icon.icns Normal file

Binary file not shown.

BIN
resources/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

BIN
resources/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

123
src/main/aiService.ts Normal file
View File

@@ -0,0 +1,123 @@
import Anthropic from '@anthropic-ai/sdk'
import { getApiKey } from './globalConfig'
import type { Market } from './fileSystem'
export type AnalysisMode = 'compression' | 'ending' | 'tone' | 'market_fit' | 'chat'
export interface AIPayload {
mode: AnalysisMode
storyContent: string
storyId: string
wordCountTarget?: number
targetMarket?: Market
collectionContext?: string
useCollectionContext: boolean
useMarketBrief: boolean
conversationHistory: { role: 'user' | 'assistant'; content: string }[]
userMessage: string
}
let _client: Anthropic | null = null
export function resetClient(): void {
_client = null
}
function getClient(): Anthropic {
if (!_client) {
const apiKey = getApiKey()
if (!apiKey || apiKey === 'your-api-key-here') {
throw new Error('API key not set. Open Borges → Preferences to configure it.')
}
_client = new Anthropic({ apiKey })
}
return _client
}
function buildSystemPrompt(payload: AIPayload): string {
const wordConstraint = payload.wordCountTarget
? `The target word count is ${payload.wordCountTarget} words.`
: ''
const collectionSection =
payload.useCollectionContext && payload.collectionContext
? `\n\n=== COLLECTION CONTEXT ===\n${payload.collectionContext}\n=== END COLLECTION CONTEXT ===`
: ''
const marketSection =
payload.useMarketBrief && payload.targetMarket
? `\n\n=== MARKET BRIEF: ${payload.targetMarket.name} ===\nWord count range: ${payload.targetMarket.wordCountMin ?? 0}${payload.targetMarket.wordCountMax}\nSimultaneous subs allowed: ${payload.targetMarket.simultaneousSubs}\nGenres: ${payload.targetMarket.genres.join(', ')}\n${payload.targetMarket.notes ? `Notes: ${payload.targetMarket.notes}` : ''}\n=== END MARKET BRIEF ===`
: ''
const storyBlock = `\n\n=== STORY ===\n${payload.storyContent}\n=== END STORY ===`
const modeInstructions: Record<AnalysisMode, string> = {
chat: 'You are a literary editor helping with flash fiction. Answer questions about the story — craft, compression, character, market fit, or anything else the writer asks. Be specific and cite exact passages.',
compression: `You are a flash fiction editor specializing in compression. Identify every sentence, clause, or phrase carrying redundant or low-yield content. Flag passive constructions, hedging adverbs, and over-explained beats.
For each issue, use this EXACT format:
ISSUE: Compression
PASSAGE: "[exact verbatim text from the story]"
PROBLEM: [one sentence explaining what is bloated]
SUGGESTION: "[rewritten tighter version]"
Be rigorous — flash fiction tolerates no fat. List every instance, then give a brief summary.`,
ending: `You are a flash fiction editor evaluating the story's ending. Look at the final paragraph (or final sentence for very short pieces).
Evaluate:
- Is the weight right? Does it carry the emotional/thematic load of everything before it?
- Is it earned? Does the story build toward this moment?
- Does it close, open, or pivot — and is that the right choice for this story?
Write a prose critique of 150300 words. No annotation format — this is a direct assessment. Be honest about what isn't working and concrete about what would.`,
tone: `You are a flash fiction editor mapping tonal register. First, identify the dominant tone of the piece (e.g., dread, irony, tenderness, menace, melancholy). Then find every sentence that drifts out of that tone.
For each outlier, use this EXACT format:
ISSUE: Tone drift
PASSAGE: "[exact verbatim text from the story]"
PROBLEM: [one sentence: how does this sentence break the tonal register?]
SUGGESTION: "[rewritten version that holds the dominant tone]"
List all outliers, then name the dominant tone in a single closing sentence.`,
market_fit: `You are a literary submissions editor. Evaluate how well this flash fiction piece fits the target market described in the Market Brief above.
Provide:
1. A brief qualitative assessment (23 sentences): what aspects of the piece align with this market's aesthetic, and what works against it?
2. Two or three specific, actionable suggestions for bringing the piece closer to what this market publishes.
Be candid. If the fit is poor, say so plainly.`
}
return `You are an expert flash fiction editor. ${wordConstraint}${collectionSection}${marketSection}${storyBlock}\n\n${modeInstructions[payload.mode]}`
}
export async function streamMessage(
payload: AIPayload,
onChunk: (chunk: string) => void
): Promise<void> {
const client = getClient()
const messages: Anthropic.MessageParam[] = [
...payload.conversationHistory.slice(-10),
{ role: 'user', content: payload.userMessage }
]
const stream = client.messages.stream({
model: 'claude-sonnet-4-6',
max_tokens: 4096,
system: buildSystemPrompt(payload),
messages
})
for await (const chunk of stream) {
if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
onChunk(chunk.delta.text)
}
}
await stream.finalMessage()
}

321
src/main/fileSystem.ts Normal file
View File

@@ -0,0 +1,321 @@
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 StoryMeta {
title?: string
wordCountTarget?: number
tags?: string[]
notes?: string
}
export interface Market {
id: string
name: string
url?: string
wordCountMin?: number
wordCountMax: number
simultaneousSubs: boolean
responseTimeWeeks?: number
genres: string[]
notes?: string
active: boolean
}
export interface Submission {
id: string
storyId: string
marketId: string
submittedAt: string
status: 'pending' | 'withdrawn' | 'rejected' | 'accepted' | 'pending-revision'
statusUpdatedAt: string
notes?: string
simultaneous: boolean
}
export interface CollectionConfig {
stories: Record<string, StoryMeta>
collectionContext?: string
}
export interface RevisionMeta {
id: string
timestamp: number
wordCount: number
}
// ─── Path helpers ─────────────────────────────────────────────────────────────
const borgesDir = (): string => join(getCollectionRoot(), '.borges')
const configFile = (): string => join(borgesDir(), 'config.json')
const orderFile = (): string => join(borgesDir(), 'order.json')
const sessionFile = (): string => join(borgesDir(), 'session.json')
const marketsFile = (): string => join(borgesDir(), 'markets.json')
const submissionsFile = (): string => join(borgesDir(), 'submissions.json')
const revisionsDir = (): string => join(borgesDir(), '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 { stories: {} }
}
}
async function writeConfig(cfg: CollectionConfig): Promise<void> {
await mkdir(borgesDir(), { recursive: true })
await writeFile(configFile(), JSON.stringify(cfg, null, 2), 'utf-8')
}
export async function getStoryMeta(storyId: string): Promise<StoryMeta> {
const cfg = await readConfig()
return cfg.stories[storyId] ?? {}
}
export async function setStoryMeta(storyId: string, meta: StoryMeta): Promise<void> {
const cfg = await readConfig()
cfg.stories[storyId] = { ...cfg.stories[storyId], ...meta }
await writeConfig(cfg)
}
export async function getCollectionConfig(): Promise<CollectionConfig> {
return readConfig()
}
export async function setCollectionContext(context: string): Promise<void> {
const cfg = await readConfig()
cfg.collectionContext = context
await writeConfig(cfg)
}
// ─── Story listing ────────────────────────────────────────────────────────────
export interface StoryFile {
id: string
path: string
wordCount: number
meta: StoryMeta
}
function countWords(text: string): number {
return text.trim() === '' ? 0 : text.trim().split(/\s+/).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(borgesDir(), { recursive: true })
await writeFile(orderFile(), JSON.stringify(order, null, 2), 'utf-8')
}
export async function listStories(): Promise<StoryFile[]> {
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 stories: StoryFile[] = 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, wordCount: countWords(content), meta: cfg.stories[id] ?? {} }
})
)
const map = new Map(stories.map((s) => [s.id, s]))
const ordered = order.filter((id) => map.has(id)).map((id) => map.get(id)!)
const rest = stories.filter((s) => !order.includes(s.id)).sort((a, b) => a.id.localeCompare(b.id))
return [...ordered, ...rest]
}
// ─── Story CRUD ───────────────────────────────────────────────────────────────
export async function readStory(filePath: string): Promise<string> {
assertInCollection(filePath)
return readFile(filePath, 'utf-8')
}
export async function writeStory(filePath: string, content: string): Promise<void> {
assertInCollection(filePath)
await writeFile(filePath, content, 'utf-8')
}
export async function createStory(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 renameStory(oldPath: string, newName: string): Promise<string> {
assertInCollection(oldPath)
const newPath = join(getCollectionRoot(), `${newName}.md`)
assertInCollection(newPath)
await fsRename(oldPath, newPath)
// Update meta key
const cfg = await readConfig()
const oldId = basename(oldPath, '.md')
if (cfg.stories[oldId]) {
cfg.stories[newName] = cfg.stories[oldId]
delete cfg.stories[oldId]
await writeConfig(cfg)
}
return newPath
}
export async function deleteStory(filePath: string): Promise<void> {
assertInCollection(filePath)
await rm(filePath, { force: true })
const id = basename(filePath, '.md')
const cfg = await readConfig()
delete cfg.stories[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(borgesDir(), { recursive: true })
await writeFile(sessionFile(), JSON.stringify(data), 'utf-8')
}
// ─── Markets ──────────────────────────────────────────────────────────────────
export async function listMarkets(): Promise<Market[]> {
try {
return JSON.parse(await readFile(marketsFile(), 'utf-8'))
} catch {
return []
}
}
export async function saveMarkets(markets: Market[]): Promise<void> {
await mkdir(borgesDir(), { recursive: true })
await writeFile(marketsFile(), JSON.stringify(markets, null, 2), 'utf-8')
}
export async function upsertMarket(market: Market): Promise<void> {
const markets = await listMarkets()
const idx = markets.findIndex((m) => m.id === market.id)
if (idx === -1) markets.push(market)
else markets[idx] = market
await saveMarkets(markets)
}
export async function deleteMarket(id: string): Promise<void> {
const markets = await listMarkets()
await saveMarkets(markets.filter((m) => m.id !== id))
}
// ─── Submissions ──────────────────────────────────────────────────────────────
export async function listSubmissions(): Promise<Submission[]> {
try {
return JSON.parse(await readFile(submissionsFile(), 'utf-8'))
} catch {
return []
}
}
export async function saveSubmissions(subs: Submission[]): Promise<void> {
await mkdir(borgesDir(), { recursive: true })
await writeFile(submissionsFile(), JSON.stringify(subs, null, 2), 'utf-8')
}
export async function addSubmission(sub: Submission): Promise<void> {
const subs = await listSubmissions()
subs.push(sub)
await saveSubmissions(subs)
}
export async function updateSubmission(id: string, updates: Partial<Submission>): Promise<void> {
const subs = await listSubmissions()
const idx = subs.findIndex((s) => s.id === id)
if (idx === -1) throw new Error('Submission not found')
subs[idx] = { ...subs[idx], ...updates }
await saveSubmissions(subs)
}
// ─── Revisions ────────────────────────────────────────────────────────────────
function storySlug(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 = storySlug(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, wordCount: countWords(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 = storySlug(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, wordCount: raw.wordCount }
})
)
} 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 = storySlug(filePath)
const revPath = join(revisionsDir(), slug, `${revisionId}.json`)
const raw = JSON.parse(await readFile(revPath, 'utf-8'))
return raw.content
}

38
src/main/globalConfig.ts Normal file
View File

@@ -0,0 +1,38 @@
import { join } from 'path'
import { homedir } from 'os'
import { readFileSync, writeFileSync, mkdirSync } from 'fs'
export interface GlobalConfig {
apiKey?: string
collectionPath?: string
fontSize?: number
theme?: 'dark' | 'light'
defaultWordCountTarget?: number
}
const CONFIG_DIR = join(homedir(), '.borges')
const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
let _config: GlobalConfig = {}
try {
_config = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'))
} catch {
// first run
}
export const getCollectionRoot = (): string =>
_config.collectionPath ?? join(homedir(), 'Documents', 'borges-collection')
export const getApiKey = (): string | undefined =>
_config.apiKey ?? process.env.ANTHROPIC_API_KEY
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')
}

149
src/main/index.ts Normal file
View File

@@ -0,0 +1,149 @@
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 } from './globalConfig'
app.setName('Borges')
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: 'Borges',
submenu: [
{
label: 'About Borges',
click: () => dialog.showMessageBox(win, { type: 'info', title: 'Borges', message: 'Borges', detail: `Version ${app.getVersion()}\n\nFlash fiction writing, analysis, and submission tracking.` })
},
{ 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 Story', accelerator: 'CmdOrCtrl+N', click: () => send(win, 'newStory') },
{ type: 'separator' },
{ label: 'Save', accelerator: 'CmdOrCtrl+S', click: () => send(win, 'save') },
{ type: 'separator' },
{ label: 'Open Collection Folder 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 Story List', accelerator: 'CmdOrCtrl+Shift+1', click: () => send(win, 'toggleSidebar') },
{ label: 'Toggle Submission Panel', accelerator: 'CmdOrCtrl+Shift+2', click: () => send(win, 'toggleSubmissionPanel') },
{ 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') }
]
},
{
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 mainWindow = new BrowserWindow({
width: 1440,
height: 900,
minWidth: 900,
minHeight: 600,
title: 'Borges',
titleBarStyle: 'hiddenInset',
icon,
show: false,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false
}
})
mainWindow.on('ready-to-show', () => mainWindow.show())
mainWindow.on('enter-full-screen', () => mainWindow.setWindowButtonVisibility(false))
mainWindow.on('leave-full-screen', () => mainWindow.setWindowButtonVisibility(true))
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.borges.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()
})

89
src/main/ipcHandlers.ts Normal file
View File

@@ -0,0 +1,89 @@
import { ipcMain, dialog, BrowserWindow } from 'electron'
import {
listStories, readStory, writeStory, createStory, renameStory, deleteStory,
getStoryMeta, setStoryMeta, getCollectionConfig, setCollectionContext,
saveOrderList, readSession, writeSession,
listMarkets, upsertMarket, deleteMarket,
listSubmissions, addSubmission, updateSubmission,
saveRevision, listRevisions, loadRevision
} from './fileSystem'
import type { Market, Submission, StoryMeta } from './fileSystem'
import { streamMessage, resetClient } from './aiService'
import type { AIPayload } from './aiService'
import { readGlobalConfig, writeGlobalConfig } from './globalConfig'
import type { GlobalConfig } from './globalConfig'
export function registerIpcHandlers(): void {
// ── Stories ──────────────────────────────────────────────────────────────────
ipcMain.handle('stories:list', async () => listStories())
ipcMain.handle('stories:read', async (_e, path: string) => readStory(path))
ipcMain.handle('stories:write', async (_e, path: string, content: string) => writeStory(path, content))
ipcMain.handle('stories:create', async (_e, name: string) => createStory(name))
ipcMain.handle('stories:rename', async (_e, oldPath: string, newName: string) => renameStory(oldPath, newName))
ipcMain.handle('stories:delete', async (_e, path: string) => deleteStory(path))
ipcMain.handle('stories:getMeta', async (_e, storyId: string) => getStoryMeta(storyId))
ipcMain.handle('stories:setMeta', async (_e, storyId: string, meta: StoryMeta) => setStoryMeta(storyId, meta))
ipcMain.handle('stories:saveOrder', async (_e, order: string[]) => saveOrderList(order))
// ── Collection config ─────────────────────────────────────────────────────────
ipcMain.handle('collection:getConfig', async () => getCollectionConfig())
ipcMain.handle('collection:setContext', async (_e, context: string) => setCollectionContext(context))
// ── Session ──────────────────────────────────────────────────────────────────
ipcMain.handle('session:read', async () => readSession())
ipcMain.handle('session:write', async (_e, data: Record<string, unknown>) => writeSession(data))
// ── Markets ──────────────────────────────────────────────────────────────────
ipcMain.handle('markets:list', async () => listMarkets())
ipcMain.handle('markets:upsert', async (_e, market: Market) => upsertMarket(market))
ipcMain.handle('markets:delete', async (_e, id: string) => deleteMarket(id))
// ── Submissions ──────────────────────────────────────────────────────────────
ipcMain.handle('submissions:list', async () => listSubmissions())
ipcMain.handle('submissions:add', async (_e, sub: Submission) => addSubmission(sub))
ipcMain.handle('submissions:update', async (_e, id: string, updates: Partial<Submission>) => updateSubmission(id, updates))
// ── Revisions ─────────────────────────────────────────────────────────────────
ipcMain.handle('revisions:save', async (_e, path: string, content: string) => saveRevision(path, content))
ipcMain.handle('revisions:list', async (_e, path: string) => listRevisions(path))
ipcMain.handle('revisions:load', async (_e, path: string, id: string) => loadRevision(path, id))
// ── Config ────────────────────────────────────────────────────────────────────
ipcMain.handle('config:read', async () => readGlobalConfig())
ipcMain.handle('config:write', async (_e, updates: Partial<GlobalConfig>) => {
writeGlobalConfig(updates)
if (updates.apiKey !== undefined) resetClient()
})
ipcMain.handle('config:pickFolder', async (event): Promise<string | null> => {
const win = BrowserWindow.fromWebContents(event.sender)
const result = await dialog.showOpenDialog(win!, { properties: ['openDirectory'] })
return result.canceled ? null : result.filePaths[0]
})
// ── AI streaming ──────────────────────────────────────────────────────────────
ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => {
try {
let pending = ''
let flushTimer: ReturnType<typeof setTimeout> | null = null
const flush = (): void => {
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null }
if (pending && !event.sender.isDestroyed()) {
event.sender.send('ai:chunk', pending)
pending = ''
}
}
await streamMessage(payload, (chunk: string) => {
pending += chunk
if (!flushTimer) flushTimer = setTimeout(flush, 30)
})
flush()
if (!event.sender.isDestroyed()) event.sender.send('ai:done')
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (!event.sender.isDestroyed()) event.sender.send('ai:error', message)
}
})
}

75
src/preload/index.ts Normal file
View File

@@ -0,0 +1,75 @@
import { contextBridge, ipcRenderer } from 'electron'
import type { Market, Submission, StoryFile, StoryMeta, RevisionMeta, CollectionConfig } from '../main/fileSystem'
import type { AIPayload } from '../main/aiService'
import type { GlobalConfig } from '../main/globalConfig'
contextBridge.exposeInMainWorld('api', {
// Stories
listStories: (): Promise<StoryFile[]> => ipcRenderer.invoke('stories:list'),
readStory: (path: string): Promise<string> => ipcRenderer.invoke('stories:read', path),
writeStory: (path: string, content: string): Promise<void> => ipcRenderer.invoke('stories:write', path, content),
createStory: (name: string): Promise<{ id: string; path: string }> => ipcRenderer.invoke('stories:create', name),
renameStory: (oldPath: string, newName: string): Promise<string> => ipcRenderer.invoke('stories:rename', oldPath, newName),
deleteStory: (path: string): Promise<void> => ipcRenderer.invoke('stories:delete', path),
getStoryMeta: (storyId: string): Promise<StoryMeta> => ipcRenderer.invoke('stories:getMeta', storyId),
setStoryMeta: (storyId: string, meta: StoryMeta): Promise<void> => ipcRenderer.invoke('stories:setMeta', storyId, meta),
saveOrder: (order: string[]): Promise<void> => ipcRenderer.invoke('stories:saveOrder', order),
// Collection
getCollectionConfig: (): Promise<CollectionConfig> => ipcRenderer.invoke('collection:getConfig'),
setCollectionContext: (context: string): Promise<void> => ipcRenderer.invoke('collection:setContext', context),
// Session
readSession: (): Promise<Record<string, unknown>> => ipcRenderer.invoke('session:read'),
writeSession: (data: Record<string, unknown>): Promise<void> => ipcRenderer.invoke('session:write', data),
// Markets
listMarkets: (): Promise<Market[]> => ipcRenderer.invoke('markets:list'),
upsertMarket: (market: Market): Promise<void> => ipcRenderer.invoke('markets:upsert', market),
deleteMarket: (id: string): Promise<void> => ipcRenderer.invoke('markets:delete', id),
// Submissions
listSubmissions: (): Promise<Submission[]> => ipcRenderer.invoke('submissions:list'),
addSubmission: (sub: Submission): Promise<void> => ipcRenderer.invoke('submissions:add', sub),
updateSubmission: (id: string, updates: Partial<Submission>): Promise<void> => ipcRenderer.invoke('submissions:update', id, updates),
// 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),
// 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'),
// AI
streamAIMessage: (payload: AIPayload, 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:streamMessage', payload).catch(reject)
})
},
// 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)
}
})

228
src/renderer/App.tsx Normal file
View File

@@ -0,0 +1,228 @@
import { useEffect, useState } from 'react'
import { useBorgesStore } from './store/borgesStore'
import { StorySidebar } from './components/Sidebar/StorySidebar'
import { MarkdownEditor } from './components/Editor/MarkdownEditor'
import { AnalysisToolbar } from './components/Toolbar/AnalysisToolbar'
import { SubmissionPanel } from './components/SubmissionPanel/SubmissionPanel'
import { ChatPanel } from './components/AIChat/ChatPanel'
import { Dashboard } from './components/Dashboard/Dashboard'
import { SettingsDialog } from './components/Settings/SettingsDialog'
export default function App(): JSX.Element {
const {
sidebarOpen, setSidebarOpen,
submissionPanelOpen, setSubmissionPanelOpen,
chatOpen, setChatOpen,
focusMode, toggleFocusMode,
theme, toggleTheme,
fontSize, setFontSize,
activeStoryId, isDirty, activeStoryPath, activeStoryContent, markSaved,
stories, setStories,
setMarkets,
setSubmissions,
revisionPanelOpen, toggleRevisionPanel,
initPrefs, loadSession
} = useBorgesStore()
const [settingsOpen, setSettingsOpen] = useState(false)
const [, setIsFirstRun] = useState(false)
// Initialise app
useEffect(() => {
async function init(): Promise<void> {
await initPrefs()
const [storiesList, marketsList, subsList] = await Promise.all([
window.api.listStories(),
window.api.listMarkets(),
window.api.listSubmissions()
])
setStories(storiesList)
setMarkets(marketsList)
setSubmissions(subsList)
await loadSession()
const cfg = await window.api.readConfig()
if (!cfg.apiKey) {
setIsFirstRun(true)
setSettingsOpen(true)
}
}
init()
}, [])
// Menu actions
useEffect(() => {
return window.api.onMenuAction(async (action) => {
if (action === 'save') {
if (activeStoryPath && isDirty) {
await window.api.writeStory(activeStoryPath, activeStoryContent)
await window.api.saveRevision(activeStoryPath, activeStoryContent)
markSaved()
}
} else if (action === 'newStory') {
const name = `Story ${stories.length + 1}`
const created = await window.api.createStory(name)
const refreshed = await window.api.listStories()
setStories(refreshed)
const s = refreshed.find((x) => x.path === created.path)
if (s) {
const content = await window.api.readStory(s.path)
useBorgesStore.getState().setActiveStory(s.path, s.id, content)
}
} else if (action === 'toggleSidebar') {
setSidebarOpen(!sidebarOpen)
} else if (action === 'toggleSubmissionPanel') {
setSubmissionPanelOpen(!submissionPanelOpen)
} else if (action === 'toggleChat') {
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(15)
} else if (action === 'openSettings') {
setSettingsOpen(true)
}
})
}, [activeStoryPath, isDirty, activeStoryContent, sidebarOpen, submissionPanelOpen, chatOpen, focusMode, fontSize, stories])
// Keyboard shortcuts
useEffect(() => {
const handler = async (e: KeyboardEvent): Promise<void> => {
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key === 's') {
e.preventDefault()
if (activeStoryPath && isDirty) {
await window.api.writeStory(activeStoryPath, activeStoryContent)
await window.api.saveRevision(activeStoryPath, activeStoryContent)
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)
}, [activeStoryPath, isDirty, activeStoryContent, focusMode])
// Toggle layout segment counts
const toggleSeg = (panel: 'sidebar' | 'sub' | 'chat'): void => {
if (panel === 'sidebar') setSidebarOpen(!sidebarOpen)
else if (panel === 'sub') setSubmissionPanelOpen(!submissionPanelOpen)
else setChatOpen(!chatOpen)
}
return (
<div
className="app-layout"
data-sidebar={sidebarOpen ? 'open' : 'closed'}
data-sub={submissionPanelOpen ? 'open' : 'closed'}
data-chat={chatOpen ? 'open' : 'closed'}
data-focus={focusMode ? 'on' : 'off'}
>
{/* Titlebar */}
<div className="app-titlebar">
<span className="app-titlebar-title">
Borges{activeStoryId ? `${useBorgesStore.getState().stories.find(s => s.id === activeStoryId)?.meta.title || activeStoryId}${isDirty ? ' ●' : ''}` : ''}
</span>
<div className="app-titlebar-right">
<div className="app-layout-toggle">
<button
className={`app-layout-toggle-seg${sidebarOpen ? ' active' : ''}`}
onClick={() => toggleSeg('sidebar')}
title={sidebarOpen ? 'Hide story list' : 'Show story list'}
/>
<div className="app-layout-toggle-seg app-layout-toggle-seg--mid" />
<button
className={`app-layout-toggle-seg${submissionPanelOpen ? ' active' : ''}`}
onClick={() => toggleSeg('sub')}
title={submissionPanelOpen ? 'Hide submission panel' : 'Show submission panel'}
/>
<div className="app-layout-toggle-seg app-layout-toggle-seg--mid" style={{ width: '4px' }} />
<button
className={`app-layout-toggle-seg${chatOpen ? ' active' : ''}`}
onClick={() => toggleSeg('chat')}
title={chatOpen ? 'Hide AI chat' : 'Show AI chat'}
/>
</div>
<button
className={`app-titlebar-btn${revisionPanelOpen ? ' active' : ''}`}
onClick={toggleRevisionPanel}
title="Revision history"
disabled={!activeStoryId}
></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" style={{ overflow: 'hidden' }}>
<StorySidebar />
</aside>
{/* Editor area */}
<main className="editor-area">
{activeStoryId ? (
<>
<AnalysisToolbar />
<MarkdownEditor />
</>
) : (
<Dashboard />
)}
</main>
{/* Submission panel */}
<aside style={{ gridArea: 'subpanel', overflow: 'hidden', minWidth: 0 }}>
<SubmissionPanel />
</aside>
{/* Chat panel */}
<aside className="chat-area">
<ChatPanel />
</aside>
{/* Focus mode exit */}
{focusMode && (
<button className="focus-exit" onClick={toggleFocusMode} title="Exit focus mode (Esc)"></button>
)}
{/* Settings */}
{settingsOpen && (
<SettingsDialog
onClose={async () => {
setSettingsOpen(false)
setIsFirstRun(false)
// Refresh stories in case collection path changed
const refreshed = await window.api.listStories()
setStories(refreshed)
}}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,137 @@
import { useRef, useEffect, useState } from 'react'
import { useBorgesStore } from '../../store/borgesStore'
import type { ChatMessage, TextAnnotation } from '../../types/borges'
import { marked } from 'marked'
function renderMarkdown(text: string): string {
try {
return marked(text) as string
} catch {
return text
}
}
function MessageBubble({ msg }: { msg: ChatMessage }): JSX.Element {
const isUser = msg.role === 'user'
return (
<div className={`chat-message chat-message-${isUser ? 'user' : 'assistant'}`}>
<div
className="chat-bubble"
dangerouslySetInnerHTML={isUser ? undefined : { __html: renderMarkdown(msg.content) }}
>
{isUser ? msg.content : undefined}
</div>
</div>
)
}
function parseAnnotationsFromText(text: string, doc: string): TextAnnotation[] {
const anns: TextAnnotation[] = []
const re = /ISSUE:[^\n]*\nPASSAGE:\s*"([^"]+)"\nPROBLEM:\s*([^\n]+)(?:\nSUGGESTION:\s*"([^"]*)")?/g
let m: RegExpExecArray | null
while ((m = re.exec(text)) !== null) {
const passage = m[1]?.trim()
const problem = m[2]?.trim()
if (!passage || !problem || !doc.includes(passage)) continue
anns.push({ id: `ann-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, passage, problem, suggestion: m[3]?.trim() || undefined })
}
return anns
}
export function ChatPanel(): JSX.Element {
const {
chatHistory, isAILoading, aiError, activeStoryId, activeStoryContent,
analysisMode, useCollectionContext, useMarketBrief, selectedMarketId, markets, stories,
addUserMessage, startAssistantMessage, appendToLastMessage, setAILoading, setAIError,
newChat, setAnnotations
} = useBorgesStore()
const [input, setInput] = useState('')
const scrollRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = scrollRef.current
if (el) el.scrollTop = el.scrollHeight
}, [chatHistory])
const send = async (): Promise<void> => {
const text = input.trim()
if (!text || !activeStoryId || isAILoading) return
setInput('')
const story = stories.find((s) => s.id === activeStoryId)
const selectedMarket = markets.find((m) => m.id === selectedMarketId)
const cfg = await window.api.getCollectionConfig()
setAIError(null)
addUserMessage(text)
startAssistantMessage()
setAILoading(true)
try {
let full = ''
await window.api.streamAIMessage(
{
mode: analysisMode === 'none' ? 'chat' : analysisMode,
storyContent: activeStoryContent,
storyId: activeStoryId,
wordCountTarget: story?.meta.wordCountTarget,
targetMarket: selectedMarket ?? undefined,
collectionContext: cfg.collectionContext,
useCollectionContext: useCollectionContext && !!cfg.collectionContext,
useMarketBrief: useMarketBrief && !!selectedMarket,
conversationHistory: chatHistory.slice(-10).map((m) => ({ role: m.role, content: m.content })),
userMessage: text
},
(chunk) => {
appendToLastMessage(chunk)
full += chunk
}
)
if (full.includes('PASSAGE:')) {
const parsed = parseAnnotationsFromText(full, activeStoryContent)
if (parsed.length > 0) setAnnotations(parsed)
}
} catch (err) {
setAIError(err instanceof Error ? err.message : 'Error')
} finally {
setAILoading(false)
}
}
return (
<div className="chat-panel">
<div className="chat-header">
<span className="chat-header-title">AI Chat</span>
{chatHistory.length > 0 && (
<button className="chat-new-btn" onClick={newChat} title="New conversation">+ New</button>
)}
</div>
<div className="chat-messages" ref={scrollRef}>
{!activeStoryId && <p className="chat-placeholder">Open a story to start a conversation.</p>}
{activeStoryId && chatHistory.length === 0 && (
<p className="chat-placeholder">Ask anything about this story craft, compression, character, market fit</p>
)}
{chatHistory.map((msg) => <MessageBubble key={msg.id} msg={msg} />)}
{isAILoading && chatHistory[chatHistory.length - 1]?.content === '' && (
<div className="chat-typing"><span /><span /><span /></div>
)}
{aiError && <div className="chat-error">{aiError}</div>}
</div>
<div className="chat-input-area">
<div className="chat-input-row">
<textarea
className="chat-input"
value={input}
placeholder={activeStoryId ? 'Message…' : ''}
disabled={!activeStoryId || isAILoading}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() }
}}
rows={1}
/><button className="chat-send-btn" onClick={send} disabled={!input.trim() || !activeStoryId || isAILoading}></button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,119 @@
import { useBorgesStore } from '../../store/borgesStore'
function daysSince(iso: string): number {
return Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000)
}
export function Dashboard(): JSX.Element {
const { stories, submissions, markets, setActiveStory, markSaved, isDirty, activeStoryPath, activeStoryContent } = useBorgesStore()
const openStory = async (path: string, id: string): Promise<void> => {
if (isDirty && activeStoryPath) {
await window.api.writeStory(activeStoryPath, activeStoryContent)
await window.api.saveRevision(activeStoryPath, activeStoryContent)
markSaved()
}
const content = await window.api.readStory(path)
setActiveStory(path, id, content)
}
// Stories ready to submit: no active pending, never submitted or last status rejected
const readyToSubmit = stories.filter((story) => {
const storySubs = submissions.filter((s) => s.storyId === story.id)
const hasActive = storySubs.some((s) => s.status === 'pending' || s.status === 'pending-revision')
if (hasActive) return false
if (storySubs.length === 0) return true
const last = storySubs.sort((a, b) => b.submittedAt.localeCompare(a.submittedAt))[0]
return last.status === 'rejected' || last.status === 'withdrawn'
})
// Pending submissions
const pending = submissions
.filter((s) => s.status === 'pending' || s.status === 'pending-revision')
.sort((a, b) => a.submittedAt.localeCompare(b.submittedAt))
// Recent activity (last 10 non-pending changes)
const recent = submissions
.filter((s) => s.status === 'accepted' || s.status === 'rejected')
.sort((a, b) => b.statusUpdatedAt.localeCompare(a.statusUpdatedAt))
.slice(0, 10)
const totalWords = stories.reduce((s, story) => s + story.wordCount, 0)
return (
<div className="dashboard">
<div className="dashboard-greeting">
{stories.length === 0
? 'Welcome to Borges. Create your first story to get started.'
: `${stories.length} ${stories.length === 1 ? 'story' : 'stories'} · ${totalWords.toLocaleString()} words total`}
</div>
<div className="dashboard-grid">
{/* Ready to submit */}
<div className="dashboard-card">
<div className="dashboard-card-title">Ready to submit ({readyToSubmit.length})</div>
{readyToSubmit.length === 0 && <div className="dashboard-empty">All stories are out or in progress.</div>}
{readyToSubmit.slice(0, 8).map((story) => (
<div key={story.id} className="dashboard-row" onClick={() => openStory(story.path, story.id)}>
<span className="dashboard-row-title">{story.meta.title || story.id}</span>
<span className="dashboard-row-meta">{story.wordCount.toLocaleString()}w</span>
</div>
))}
</div>
{/* Pending submissions */}
<div className="dashboard-card">
<div className="dashboard-card-title">Out ({pending.length})</div>
{pending.length === 0 && <div className="dashboard-empty">Nothing currently submitted.</div>}
{pending.map((sub) => {
const story = stories.find((s) => s.id === sub.storyId)
const market = markets.find((m) => m.id === sub.marketId)
const days = daysSince(sub.submittedAt)
const isOverdue = market?.responseTimeWeeks && days > market.responseTimeWeeks * 7
return (
<div key={sub.id} className="dashboard-row" onClick={() => story && openStory(story.path, story.id)}>
<span className="dashboard-row-title">{story?.meta.title || sub.storyId} {market?.name ?? sub.marketId}</span>
<span className={`dashboard-row-meta${isOverdue ? ' dashboard-row-flag' : ''}`}>{days}d{isOverdue ? ' ⚠' : ''}</span>
</div>
)
})}
</div>
{/* Recent activity */}
<div className="dashboard-card">
<div className="dashboard-card-title">Recent activity</div>
{recent.length === 0 && <div className="dashboard-empty">No acceptances or rejections yet.</div>}
{recent.map((sub) => {
const story = stories.find((s) => s.id === sub.storyId)
const market = markets.find((m) => m.id === sub.marketId)
return (
<div key={sub.id} className="dashboard-row" onClick={() => story && openStory(story.path, story.id)}>
<span className="dashboard-row-title">{story?.meta.title || sub.storyId}</span>
<span className={`dashboard-row-meta`} style={{ color: sub.status === 'accepted' ? 'var(--success)' : 'var(--text3)' }}>
{sub.status === 'accepted' ? '✓' : '✗'} {market?.name ?? sub.marketId}
</span>
</div>
)
})}
</div>
{/* Word count overview */}
<div className="dashboard-card">
<div className="dashboard-card-title">Word counts</div>
{stories.length === 0 && <div className="dashboard-empty">No stories yet.</div>}
{[...stories].sort((a, b) => b.wordCount - a.wordCount).slice(0, 10).map((story) => {
const target = story.meta.wordCountTarget
return (
<div key={story.id} className="dashboard-row" onClick={() => openStory(story.path, story.id)}>
<span className="dashboard-row-title">{story.meta.title || story.id}</span>
<span className="dashboard-row-meta">
{story.wordCount.toLocaleString()}{target ? `/${target.toLocaleString()}` : ''}
</span>
</div>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,231 @@
import { useEffect, useRef } from 'react'
import { EditorView, keymap, Decoration, type DecorationSet, ViewPlugin, type ViewUpdate } from '@codemirror/view'
import { EditorState, StateField, StateEffect, RangeSetBuilder, Compartment } from '@codemirror/state'
import { markdown } from '@codemirror/lang-markdown'
import { syntaxHighlighting, HighlightStyle } from '@codemirror/language'
import { tags } from '@lezer/highlight'
import { history, defaultKeymap, historyKeymap } from '@codemirror/commands'
import { search, searchKeymap } from '@codemirror/search'
import { useBorgesStore } from '../../store/borgesStore'
import type { TextAnnotation } from '../../types/borges'
import { WordCountBar } from './WordCountBar'
export const setAnnotationsEffect = StateEffect.define<TextAnnotation[]>()
const annField = StateField.define<TextAnnotation[]>({
create: () => [],
update(anns, tr) {
for (const e of tr.effects) if (e.is(setAnnotationsEffect)) return e.value
return anns
}
})
function annDecorations(anns: TextAnnotation[], doc: { toString(): string }): DecorationSet {
const builder = new RangeSetBuilder<Decoration>()
const text = doc.toString()
const sorted = anns
.filter((a) => a.passage)
.map((a) => {
const from = text.indexOf(a.passage!)
return from !== -1 ? { from, to: from + a.passage!.length, id: a.id } : null
})
.filter(Boolean)
.sort((a, b) => a!.from - b!.from) as { from: number; to: number; id: string }[]
for (const { from, to, id } of sorted) {
if (from < to) builder.add(from, to, Decoration.mark({ class: 'ann-highlight', attributes: { 'data-ann-id': id } }))
}
return builder.finish()
}
const annPlugin = ViewPlugin.fromClass(class {
decorations: DecorationSet
constructor(view: EditorView) { this.decorations = annDecorations(view.state.field(annField), view.state.doc) }
update(update: ViewUpdate): void {
if (update.docChanged || update.transactions.some(t => t.effects.some(e => e.is(setAnnotationsEffect)))) {
this.decorations = annDecorations(update.state.field(annField), update.state.doc)
}
}
}, { decorations: v => v.decorations })
const themeCompartment = new Compartment()
const fontSizeCompartment = new Compartment()
function buildTheme(fontSize: number, isDark: boolean): ReturnType<typeof EditorView.theme> {
return EditorView.theme({
'&': { height: '100%', background: 'transparent' },
'.cm-scroller': { fontFamily: 'Georgia, "Times New Roman", serif', fontSize: `${fontSize}px`, lineHeight: '1.85', overflow: 'auto' },
'.cm-content': { maxWidth: '680px', margin: '0 auto', padding: '0 24px', caretColor: 'var(--accent)' },
'.cm-line': { padding: '0' },
'.cm-cursor': { borderLeftColor: 'var(--accent)', borderLeftWidth: '2px' },
'.cm-selectionBackground': { background: 'rgba(200,169,110,0.25)' },
'&.cm-focused .cm-selectionBackground': { background: 'rgba(200,169,110,0.35)' },
'.cm-gutters': { display: 'none' },
'.cm-placeholder': { color: 'var(--text3)', fontStyle: 'italic' },
}, { dark: isDark })
}
const markdownHighlight = HighlightStyle.define([
{ tag: tags.heading1, fontWeight: '700', fontSize: '1.3em' },
{ tag: tags.heading2, fontWeight: '700', fontSize: '1.15em' },
{ tag: tags.heading3, fontWeight: '700' },
{ tag: tags.emphasis, fontStyle: 'italic' },
{ tag: tags.strong, fontWeight: '700' },
{ tag: tags.link, color: 'var(--accent)' },
{ tag: tags.url, color: 'var(--accent)', opacity: 0.7 },
{ tag: tags.comment, color: 'var(--text3)' },
])
export function MarkdownEditor(): JSX.Element {
const { activeStoryPath, activeStoryContent, activeStoryId,
annotations, theme, fontSize, revisionPanelOpen, toggleRevisionPanel, revisions, setRevisions } = useBorgesStore()
const editorRef = useRef<HTMLDivElement>(null)
const viewRef = useRef<EditorView | null>(null)
const lastPathRef = useRef<string | null>(null)
// Initialize editor
useEffect(() => {
if (!editorRef.current) return
const isDark = theme === 'dark'
const view = new EditorView({
state: EditorState.create({
doc: '',
extensions: [
history(),
markdown(),
syntaxHighlighting(markdownHighlight),
search({ top: true }),
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap]),
annField,
annPlugin,
themeCompartment.of(buildTheme(fontSize, isDark)),
fontSizeCompartment.of([]),
EditorView.lineWrapping,
EditorView.updateListener.of((update) => {
if (update.docChanged) {
const content = update.state.doc.toString()
useBorgesStore.getState().setContent(content)
}
}),
EditorView.domEventHandlers({
keydown: (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault()
useBorgesStore.getState().markSaved()
const { activeStoryPath, activeStoryContent } = useBorgesStore.getState()
if (activeStoryPath) {
window.api.writeStory(activeStoryPath, activeStoryContent).then(() =>
window.api.saveRevision(activeStoryPath, activeStoryContent)
).then(async () => {
const revs = await window.api.listRevisions(activeStoryPath)
useBorgesStore.getState().setRevisions(revs)
})
}
}
}
})
]
}),
parent: editorRef.current
})
viewRef.current = view
return () => { view.destroy(); viewRef.current = null }
}, [])
// Sync content when active story changes
useEffect(() => {
const view = viewRef.current
if (!view) return
if (activeStoryPath !== lastPathRef.current) {
lastPathRef.current = activeStoryPath
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: activeStoryContent } })
view.dispatch({ effects: setAnnotationsEffect.of([]) })
// Load revisions
if (activeStoryPath) {
window.api.listRevisions(activeStoryPath).then(setRevisions)
}
}
}, [activeStoryPath, activeStoryContent, setRevisions])
// Sync annotations into editor
useEffect(() => {
const view = viewRef.current
if (!view) return
view.dispatch({ effects: setAnnotationsEffect.of(annotations) })
}, [annotations])
// Update theme
useEffect(() => {
viewRef.current?.dispatch({ effects: themeCompartment.reconfigure(buildTheme(fontSize, theme === 'dark')) })
}, [theme, fontSize])
if (!activeStoryId) {
return <div className="no-story-placeholder">Select a story or create a new one</div>
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative' }}>
<WordCountBar />
<div style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
<div ref={editorRef} style={{ height: '100%' }} />
{revisionPanelOpen && activeStoryPath && (
<RevisionPanel
path={activeStoryPath}
revisions={revisions}
onClose={toggleRevisionPanel}
onRestore={async (content) => {
const view = viewRef.current
if (view) {
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: content } })
}
useBorgesStore.getState().setContent(content)
}}
/>
)}
</div>
</div>
)
}
// ─── Inline Revision Panel ────────────────────────────────────────────────────
import type { RevisionMeta } from '../../types/borges'
interface RevisionPanelProps {
path: string
revisions: RevisionMeta[]
onClose: () => void
onRestore: (content: string) => void
}
function RevisionPanel({ path, revisions, onClose, onRestore }: RevisionPanelProps): JSX.Element {
const formatTime = (ts: number): string => {
const d = new Date(ts)
const now = new Date()
const isToday = d.toDateString() === now.toDateString()
if (isToday) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
}
return (
<div className="revision-panel">
<div className="revision-header">
<span className="revision-header-title">Revision History</span>
<button className="revision-close" onClick={onClose}></button>
</div>
<div className="revision-list">
{revisions.length === 0 && <div style={{ padding: '12px', fontSize: '13px', color: 'var(--text3)' }}>No revisions yet. Save to create one.</div>}
{revisions.map((rev) => (
<div key={rev.id} className="revision-item" onClick={async () => {
const content = await window.api.loadRevision(path, rev.id)
onRestore(content)
}}>
<div className="revision-item-time">{formatTime(rev.timestamp)}</div>
<div className="revision-item-wc">{rev.wordCount.toLocaleString()} words</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,83 @@
import { useState } from 'react'
import { useBorgesStore } from '../../store/borgesStore'
export function WordCountBar(): JSX.Element {
const { activeStoryContent, activeStoryId, stories, markets, selectedMarketId } = useBorgesStore()
const [editingTarget, setEditingTarget] = useState(false)
const [targetInput, setTargetInput] = useState('')
const story = stories.find((s) => s.id === activeStoryId)
const selectedMarket = markets.find((m) => m.id === selectedMarketId)
const wordCount = activeStoryContent.trim() === '' ? 0 : activeStoryContent.trim().split(/\s+/).length
// Determine target: story's own target, or selected market's max
const target = story?.meta.wordCountTarget ?? selectedMarket?.wordCountMax ?? null
let pct = 0
let colorClass = ''
if (target) {
pct = Math.min(wordCount / target, 1)
if (wordCount > target) colorClass = 'red'
else if (wordCount >= target * 0.9) colorClass = 'amber'
}
const saveTarget = async (val: string): Promise<void> => {
setEditingTarget(false)
const num = parseInt(val, 10)
if (!activeStoryId) return
if (isNaN(num) || num <= 0) {
// Clear target
const meta = story?.meta ?? {}
await window.api.setStoryMeta(activeStoryId, { ...meta, wordCountTarget: undefined })
} else {
const meta = story?.meta ?? {}
await window.api.setStoryMeta(activeStoryId, { ...meta, wordCountTarget: num })
}
const refreshed = await window.api.listStories()
useBorgesStore.getState().setStories(refreshed)
}
return (
<div className="wordcount-bar">
<span className={`wordcount-number${colorClass ? ' ' + colorClass : ''}`}>
{wordCount.toLocaleString()}
</span>
{target && (
<>
<span style={{ color: 'var(--text3)', fontSize: '11px' }}>/ {target.toLocaleString()}</span>
<div className="wordcount-bar-track">
<div className={`wordcount-bar-fill${colorClass ? ' ' + colorClass : ''}`} style={{ width: `${pct * 100}%` }} />
</div>
</>
)}
{editingTarget ? (
<input
autoFocus
type="number"
value={targetInput}
onChange={(e) => setTargetInput(e.target.value)}
onBlur={() => saveTarget(targetInput)}
onKeyDown={(e) => {
if (e.key === 'Enter') saveTarget(targetInput)
if (e.key === 'Escape') setEditingTarget(false)
}}
style={{ width: '80px', fontSize: '12px', padding: '1px 6px', height: '22px' }}
placeholder="target"
/>
) : (
<button
className="wordcount-target-btn"
onClick={() => { setTargetInput(String(target ?? '')); setEditingTarget(true) }}
>
{target ? 'edit target' : 'set target'}
</button>
)}
{selectedMarket && (
<span style={{ fontSize: '11px', color: 'var(--text3)', marginLeft: 'auto' }}>
{selectedMarket.name}
</span>
)}
</div>
)
}

View File

@@ -0,0 +1,140 @@
import { useState, useEffect } from 'react'
import { useBorgesStore } from '../../store/borgesStore'
interface Props {
onClose: () => void
}
type Tab = 'general' | 'editor' | 'collection'
export function SettingsDialog({ onClose }: Props): JSX.Element {
const { theme, fontSize, setFontSize } = useBorgesStore()
const [tab, setTab] = useState<Tab>('general')
const [apiKey, setApiKey] = useState('')
const [collectionPath, setCollectionPath] = useState('')
const [defaultTarget, setDefaultTarget] = useState('')
const [collectionContext, setCollectionContext] = useState('')
const [saving, setSaving] = useState(false)
useEffect(() => {
window.api.readConfig().then((cfg) => {
setApiKey(cfg.apiKey ?? '')
setCollectionPath(cfg.collectionPath ?? '')
setDefaultTarget(String(cfg.defaultWordCountTarget ?? ''))
})
window.api.getCollectionConfig().then((cfg) => {
setCollectionContext(cfg.collectionContext ?? '')
})
}, [])
const save = async (): Promise<void> => {
setSaving(true)
await window.api.writeConfig({
apiKey: apiKey.trim() || undefined,
collectionPath: collectionPath || undefined,
defaultWordCountTarget: defaultTarget ? parseInt(defaultTarget) : undefined,
theme
})
if (tab === 'collection') {
await window.api.setCollectionContext(collectionContext)
}
setSaving(false)
onClose()
}
const pickFolder = async (): Promise<void> => {
const folder = await window.api.pickFolder()
if (folder) setCollectionPath(folder)
}
return (
<div className="settings-overlay" onClick={onClose}>
<div className="settings-dialog" onClick={(e) => e.stopPropagation()}>
<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">
{(['general', 'editor', 'collection'] as Tab[]).map((t) => (
<button key={t} className={`settings-nav-item${tab === t ? ' active' : ''}`} onClick={() => setTab(t)}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))}
</nav>
<div className="settings-content">
{tab === 'general' && (
<div>
<div className="settings-section-title">General</div>
<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">Used for all AI features (Compression, Ending, Tone, Market fit).</div>
</div>
<div className="settings-field">
<label className="settings-label">Collection folder</label>
<div className="settings-field-row">
<input value={collectionPath} onChange={(e) => setCollectionPath(e.target.value)} placeholder="~/Documents/my-collection" />
<button className="settings-pick-btn" onClick={pickFolder}>Browse</button>
</div>
<div className="settings-hint">The directory containing your .md files and .borges/ folder.</div>
</div>
</div>
)}
{tab === 'editor' && (
<div>
<div className="settings-section-title">Editor</div>
<div className="settings-field">
<label className="settings-label">Font size ({fontSize}px)</label>
<input
type="range"
min="11"
max="24"
value={fontSize}
onChange={(e) => setFontSize(parseInt(e.target.value))}
style={{ width: '100%' }}
/>
</div>
<div className="settings-field">
<label className="settings-label">Default word count target</label>
<input
type="number"
value={defaultTarget}
onChange={(e) => setDefaultTarget(e.target.value)}
placeholder="e.g. 1000"
/>
<div className="settings-hint">Applied to new stories. Per-story target overrides this.</div>
</div>
</div>
)}
{tab === 'collection' && (
<div>
<div className="settings-section-title">Collection</div>
<div className="settings-field">
<label className="settings-label">Collection context</label>
<textarea
className="context-textarea"
value={collectionContext}
onChange={(e) => setCollectionContext(e.target.value)}
placeholder="Describe the themes, aesthetic, and goals of your collection. This is injected into AI prompts when 'Collection context' is enabled."
rows={8}
/>
<div className="settings-hint">Enable via the 'Collection' toggle in the analysis toolbar.</div>
</div>
</div>
)}
<div style={{ marginTop: '20px', display: 'flex', gap: '8px' }}>
<button className="btn-primary" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
<button className="btn-secondary" onClick={onClose}>Cancel</button>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,172 @@
import { useState, useRef } from 'react'
import { useBorgesStore } from '../../store/borgesStore'
import type { StoryFile, Submission } from '../../types/borges'
function getSubmissionBadge(storyId: string, submissions: Submission[]): { label: string; cls: string } {
const storySubmissions = submissions.filter((s) => s.storyId === storyId)
if (storySubmissions.length === 0) return { label: 'unsent', cls: 'badge-unsent' }
const active = storySubmissions.find((s) => s.status === 'pending' || s.status === 'pending-revision')
if (active) return { label: active.status === 'pending' ? 'out' : 'revision', cls: active.status === 'pending' ? 'badge-pending' : 'badge-pending-revision' }
const last = storySubmissions.sort((a, b) => b.submittedAt.localeCompare(a.submittedAt))[0]
return { label: last.status, cls: `badge-${last.status}` }
}
interface StoryItemProps {
story: StoryFile
index: number
isActive: boolean
onClick: () => void
onContextMenu: (e: React.MouseEvent) => void
onDragStart: (index: number) => void
onDragOver: (index: number) => void
onDrop: () => void
}
function StoryItem({ story, index, isActive, onClick, onContextMenu, onDragStart, onDragOver, onDrop }: StoryItemProps): JSX.Element {
const submissions = useBorgesStore((s) => s.submissions)
const badge = getSubmissionBadge(story.id, submissions)
return (
<div
className={`story-item${isActive ? ' active' : ''}`}
onClick={onClick}
onContextMenu={onContextMenu}
draggable
onDragStart={() => onDragStart(index)}
onDragOver={(e) => { e.preventDefault(); onDragOver(index) }}
onDrop={onDrop}
>
<div className="story-item-main">
<div className="story-item-title">{story.meta.title || story.id}</div>
<div className="story-item-wc">{story.wordCount.toLocaleString()} words</div>
</div>
<span className={`story-item-badge ${badge.cls}`}>{badge.label}</span>
</div>
)
}
interface ContextMenuState {
x: number
y: number
story: StoryFile
}
export function StorySidebar(): JSX.Element {
const { stories, activeStoryPath, activeStoryId, activeStoryContent, isDirty, setStories, moveStory, markSaved } = useBorgesStore()
const [search, setSearch] = useState('')
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
const [renaming, setRenaming] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
const dragFrom = useRef<number>(-1)
const filtered = search
? stories.filter((s) => (s.meta.title || s.id).toLowerCase().includes(search.toLowerCase()))
: stories
const openStory = async (story: StoryFile): Promise<void> => {
if (isDirty && activeStoryPath) {
await window.api.writeStory(activeStoryPath, activeStoryContent)
await window.api.saveRevision(activeStoryPath, activeStoryContent)
markSaved()
}
const content = await window.api.readStory(story.path)
useBorgesStore.getState().setActiveStory(story.path, story.id, content)
}
const handleNew = async (): Promise<void> => {
const name = `Story ${stories.length + 1}`
const created = await window.api.createStory(name)
const refreshed = await window.api.listStories()
setStories(refreshed)
const newStory = refreshed.find((s) => s.path === created.path)
if (newStory) openStory(newStory)
}
const handleRename = async (story: StoryFile, newName: string): Promise<void> => {
if (!newName.trim() || newName === story.id) { setRenaming(null); return }
const newPath = await window.api.renameStory(story.path, newName.trim())
const refreshed = await window.api.listStories()
setStories(refreshed)
// if this was the active story, reopen it
if (story.path === activeStoryPath) {
const updated = refreshed.find((s) => s.path === newPath)
if (updated) openStory(updated)
}
setRenaming(null)
}
const handleDelete = async (story: StoryFile): Promise<void> => {
setContextMenu(null)
await window.api.deleteStory(story.path)
if (story.path === activeStoryPath) useBorgesStore.getState().clearActiveStory()
const refreshed = await window.api.listStories()
setStories(refreshed)
}
return (
<div className="sidebar" onClick={() => contextMenu && setContextMenu(null)}>
<div className="sidebar-header">
<span className="sidebar-title">Stories</span>
<button className="sidebar-btn" onClick={handleNew} title="New story">+</button>
</div>
<div className="sidebar-search">
<input
type="text"
placeholder="Filter stories…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="sidebar-list">
{filtered.map((story, idx) => (
<div key={story.id}>
{renaming === story.id ? (
<div style={{ padding: '6px 12px' }}>
<input
className="inline-edit"
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => handleRename(story, renameValue)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRename(story, renameValue)
if (e.key === 'Escape') setRenaming(null)
}}
/>
</div>
) : (
<StoryItem
story={story}
index={idx}
isActive={story.id === activeStoryId}
onClick={() => openStory(story)}
onContextMenu={(e) => {
e.preventDefault()
setContextMenu({ x: e.clientX, y: e.clientY, story })
}}
onDragStart={(i) => { dragFrom.current = i }}
onDragOver={(i) => { if (dragFrom.current !== -1 && dragFrom.current !== i) moveStory(dragFrom.current, i) }}
onDrop={() => { dragFrom.current = -1 }}
/>
)}
</div>
))}
</div>
{contextMenu && (
<div
className="context-menu"
style={{ left: contextMenu.x, top: contextMenu.y }}
onClick={(e) => e.stopPropagation()}
>
<button className="context-menu-item" onClick={() => {
setRenaming(contextMenu.story.id)
setRenameValue(contextMenu.story.id)
setContextMenu(null)
}}>Rename</button>
<div className="context-menu-sep" />
<button className="context-menu-item danger" onClick={() => handleDelete(contextMenu.story)}>Delete</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,187 @@
import { useState } from 'react'
import { useBorgesStore } from '../../store/borgesStore'
import type { Market } from '../../types/borges'
const EMPTY_MARKET: Omit<Market, 'id'> = {
name: '',
url: '',
wordCountMax: 1000,
wordCountMin: undefined,
simultaneousSubs: false,
responseTimeWeeks: undefined,
genres: [],
notes: '',
active: true
}
function slug(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
}
export function MarketsTab(): JSX.Element {
const { markets, setMarkets, activeStoryId, stories, selectedMarketId, setSelectedMarketId } = useBorgesStore()
const [editingMarket, setEditingMarket] = useState<Market | null>(null)
const [isNew, setIsNew] = useState(false)
const [showInactive, setShowInactive] = useState(false)
const [filterMatch, setFilterMatch] = useState(false)
const activeStory = stories.find((s) => s.id === activeStoryId)
const visible = markets.filter((m) => {
if (!showInactive && !m.active) return false
if (filterMatch && activeStory) {
const wc = activeStory.wordCount
if (wc > m.wordCountMax) return false
if (m.wordCountMin && wc < m.wordCountMin) return false
}
return true
})
const save = async (market: Market): Promise<void> => {
await window.api.upsertMarket(market)
const refreshed = await window.api.listMarkets()
setMarkets(refreshed)
setEditingMarket(null)
setIsNew(false)
}
const del = async (id: string): Promise<void> => {
await window.api.deleteMarket(id)
const refreshed = await window.api.listMarkets()
setMarkets(refreshed)
if (selectedMarketId === id) setSelectedMarketId(null)
}
if (editingMarket) {
return <MarketForm market={editingMarket} isNew={isNew} onSave={save} onCancel={() => { setEditingMarket(null); setIsNew(false) }} onDelete={isNew ? undefined : () => del(editingMarket.id).then(() => { setEditingMarket(null); setIsNew(false) })} />
}
return (
<div>
<div style={{ display: 'flex', gap: '6px', marginBottom: '10px', flexWrap: 'wrap' }}>
<button
className="btn-primary"
style={{ flex: 1, fontSize: '12px' }}
onClick={() => { setIsNew(true); setEditingMarket({ ...EMPTY_MARKET, id: '' }) }}
>
+ Add market
</button>
</div>
<div style={{ display: 'flex', gap: '8px', marginBottom: '10px', fontSize: '11px', color: 'var(--text3)' }}>
<label style={{ display: 'flex', gap: '4px', alignItems: 'center', cursor: 'pointer' }}>
<input type="checkbox" checked={filterMatch} onChange={(e) => setFilterMatch(e.target.checked)} />
Match story
</label>
<label style={{ display: 'flex', gap: '4px', alignItems: 'center', cursor: 'pointer' }}>
<input type="checkbox" checked={showInactive} onChange={(e) => setShowInactive(e.target.checked)} />
Show inactive
</label>
</div>
{visible.length === 0 && <div className="dashboard-empty">No markets yet. Add one to start tracking submissions.</div>}
{visible.map((market) => {
const isSelected = market.id === selectedMarketId
const storyWc = activeStory?.wordCount ?? 0
const fits = storyWc > 0 && storyWc <= market.wordCountMax && (!market.wordCountMin || storyWc >= market.wordCountMin)
return (
<div key={market.id} className={`market-item${!market.active ? ' inactive' : ''}`} style={{ borderColor: isSelected ? 'var(--accent)' : undefined }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '6px' }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span className="market-item-name">{market.name}</span>
{fits && activeStory && <span className="market-match-badge">fits</span>}
</div>
<div className="market-item-wc">
{market.wordCountMin ? `${market.wordCountMin}` : 'up to '}{market.wordCountMax} words · {market.simultaneousSubs ? 'sim-subs ok' : 'no sim-subs'}
{market.responseTimeWeeks ? ` · ~${market.responseTimeWeeks}wk` : ''}
</div>
{market.genres.length > 0 && <div className="market-item-tags">{market.genres.join(', ')}</div>}
</div>
</div>
<div className="market-actions">
<button onClick={() => {
setSelectedMarketId(isSelected ? null : market.id)
}}>{isSelected ? 'Deselect' : 'Select'}</button>
<button onClick={() => setEditingMarket(market)}>Edit</button>
</div>
</div>
)
})}
</div>
)
}
interface MarketFormProps {
market: Market
isNew: boolean
onSave: (m: Market) => void
onCancel: () => void
onDelete?: () => void
}
function MarketForm({ market, isNew, onSave, onCancel, onDelete }: MarketFormProps): JSX.Element {
const [form, setForm] = useState<Market>({ ...market })
const update = <K extends keyof Market>(key: K, value: Market[K]): void => setForm((f) => ({ ...f, [key]: value }))
const handleSave = (): void => {
if (!form.name.trim()) return
const id = form.id || slug(form.name)
onSave({ ...form, id })
}
return (
<div className="market-form">
<div className="market-form-title">{isNew ? 'Add market' : 'Edit market'}</div>
<div className="form-field">
<label className="form-label">Name *</label>
<input value={form.name} onChange={(e) => update('name', e.target.value)} placeholder="Smokelong Quarterly" />
</div>
<div className="form-field">
<label className="form-label">Submission URL</label>
<input value={form.url ?? ''} onChange={(e) => update('url', e.target.value)} placeholder="https://…" />
</div>
<div className="form-row">
<div className="form-field">
<label className="form-label">Min words</label>
<input type="number" value={form.wordCountMin ?? ''} onChange={(e) => update('wordCountMin', e.target.value ? parseInt(e.target.value) : undefined)} placeholder="0" />
</div>
<div className="form-field">
<label className="form-label">Max words *</label>
<input type="number" value={form.wordCountMax} onChange={(e) => update('wordCountMax', parseInt(e.target.value) || 1000)} />
</div>
</div>
<div className="form-row">
<div className="form-field">
<label className="form-label">Response (weeks)</label>
<input type="number" value={form.responseTimeWeeks ?? ''} onChange={(e) => update('responseTimeWeeks', e.target.value ? parseInt(e.target.value) : undefined)} />
</div>
<div className="form-field">
<label className="form-label">Sim-subs</label>
<select value={form.simultaneousSubs ? 'yes' : 'no'} onChange={(e) => update('simultaneousSubs', e.target.value === 'yes')}>
<option value="yes">Allowed</option>
<option value="no">Not allowed</option>
</select>
</div>
</div>
<div className="form-field">
<label className="form-label">Genres (comma-separated)</label>
<input value={form.genres.join(', ')} onChange={(e) => update('genres', e.target.value.split(',').map((g) => g.trim()).filter(Boolean))} placeholder="flash, micro, speculative" />
</div>
<div className="form-field">
<label className="form-label">Notes / editor preferences</label>
<textarea value={form.notes ?? ''} onChange={(e) => update('notes', e.target.value)} rows={3} placeholder="Editor preferences, submission history, tone…" />
</div>
<div className="form-field">
<label style={{ display: 'flex', gap: '6px', alignItems: 'center', cursor: 'pointer' }}>
<input type="checkbox" checked={form.active} onChange={(e) => update('active', e.target.checked)} />
<span className="form-label" style={{ margin: 0 }}>Active (open for submissions)</span>
</label>
</div>
<div className="form-actions">
<button className="btn-primary" onClick={handleSave}>Save</button>
<button className="btn-secondary" onClick={onCancel}>Cancel</button>
{onDelete && <button className="btn-danger" onClick={onDelete}>Delete</button>}
</div>
</div>
)
}

View File

@@ -0,0 +1,173 @@
import { useState } from 'react'
import { useBorgesStore } from '../../store/borgesStore'
import type { Submission } from '../../types/borges'
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' })
}
export function StoryTab(): JSX.Element {
const { activeStoryId, submissions, setSubmissions, markets } = useBorgesStore()
const [showPicker, setShowPicker] = useState(false)
const [marketFilter, setMarketFilter] = useState('')
const [editingNotes, setEditingNotes] = useState<string | null>(null)
const [notesValue, setNotesValue] = useState('')
if (!activeStoryId) {
return <div style={{ color: 'var(--text3)', fontSize: '13px', padding: '8px 0' }}>Open a story to see submissions.</div>
}
const storySubmissions = submissions
.filter((s) => s.storyId === activeStoryId)
.sort((a, b) => b.submittedAt.localeCompare(a.submittedAt))
const pendingElsewhere = storySubmissions.filter((s) => s.status === 'pending')
const activeSubs = storySubmissions.filter((s) => s.status === 'pending' || s.status === 'pending-revision')
const simSubWarning = (marketId: string): string | null => {
const market = markets.find((m) => m.id === marketId)
if (!market) return null
if (!market.simultaneousSubs && activeSubs.length > 0) {
return `${market.name} does not allow simultaneous submissions. This story is already out elsewhere.`
}
if (activeSubs.length > 0) {
const nonSimMarkets = activeSubs.map((s) => markets.find((m) => m.id === s.marketId)).filter((m) => m && !m.simultaneousSubs)
if (nonSimMarkets.length > 0) {
return `${nonSimMarkets[0]?.name} (pending) does not allow simultaneous submissions.`
}
}
return null
}
const submitTo = async (marketId: string): Promise<void> => {
const market = markets.find((m) => m.id === marketId)
if (!market) return
const now = new Date().toISOString()
const sub: Submission = {
id: `sub-${Date.now()}`,
storyId: activeStoryId,
marketId,
submittedAt: now,
status: 'pending',
statusUpdatedAt: now,
simultaneous: activeSubs.length > 0
}
await window.api.addSubmission(sub)
const refreshed = await window.api.listSubmissions()
setSubmissions(refreshed)
setShowPicker(false)
}
const updateStatus = async (id: string, status: Submission['status']): Promise<void> => {
await window.api.updateSubmission(id, { status, statusUpdatedAt: new Date().toISOString() })
const refreshed = await window.api.listSubmissions()
setSubmissions(refreshed)
}
const saveNotes = async (id: string, notes: string): Promise<void> => {
await window.api.updateSubmission(id, { notes })
const refreshed = await window.api.listSubmissions()
setSubmissions(refreshed)
setEditingNotes(null)
}
const filteredMarkets = markets.filter((m) => m.active && m.name.toLowerCase().includes(marketFilter.toLowerCase()))
return (
<div>
{activeSubs.length === 0 && pendingElsewhere.length === 0 && (
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '10px' }}>
No active submissions.
</div>
)}
<button className="sub-btn" onClick={() => setShowPicker(true)} disabled={markets.filter((m) => m.active).length === 0}>
Submit to market
</button>
{storySubmissions.length > 0 && (
<>
<div className="sub-list-title">Submission history</div>
{storySubmissions.map((sub) => {
const market = markets.find((m) => m.id === sub.marketId)
return (
<div key={sub.id} className="sub-item">
<div className="sub-item-header">
<span className="sub-item-market">{market?.name ?? sub.marketId}</span>
<span className="sub-item-date">{formatDate(sub.submittedAt)}</span>
</div>
<div className="sub-item-status">
<select
value={sub.status}
onChange={(e) => updateStatus(sub.id, e.target.value as Submission['status'])}
>
<option value="pending">Pending</option>
<option value="pending-revision">Pending revision</option>
<option value="accepted">Accepted</option>
<option value="rejected">Rejected</option>
<option value="withdrawn">Withdrawn</option>
</select>
</div>
{editingNotes === sub.id ? (
<div className="sub-item-notes">
<textarea
autoFocus
value={notesValue}
onChange={(e) => setNotesValue(e.target.value)}
onBlur={() => saveNotes(sub.id, notesValue)}
onKeyDown={(e) => { if (e.key === 'Escape') setEditingNotes(null) }}
placeholder="Editor feedback, notes…"
/>
</div>
) : (
<div
style={{ marginTop: '4px', fontSize: '11px', color: 'var(--text3)', cursor: 'pointer' }}
onClick={() => { setEditingNotes(sub.id); setNotesValue(sub.notes ?? '') }}
>
{sub.notes ? sub.notes : '+ add note'}
</div>
)}
</div>
)
})}
</>
)}
{showPicker && (
<div className="modal-overlay" onClick={() => setShowPicker(false)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-title">Submit to market</div>
<div className="modal-search">
<input
autoFocus
type="text"
placeholder="Search markets…"
value={marketFilter}
onChange={(e) => setMarketFilter(e.target.value)}
/>
</div>
<div className="modal-list">
{filteredMarkets.length === 0 && (
<div style={{ color: 'var(--text3)', fontSize: '13px' }}>No markets. Add some in the Markets tab.</div>
)}
{filteredMarkets.map((market) => {
const warn = simSubWarning(market.id)
return (
<button key={market.id} className="modal-market-btn" onClick={() => submitTo(market.id)}>
<div className="modal-market-btn-name">{market.name}</div>
<div className="modal-market-btn-wc">{market.wordCountMin ? `${market.wordCountMin}` : 'up to '}{market.wordCountMax} words · {market.simultaneousSubs ? 'sim-subs ok' : 'no sim-subs'}</div>
{warn && <div style={{ fontSize: '11px', color: 'var(--warn)', marginTop: '3px' }}> {warn}</div>}
</button>
)
})}
</div>
<div className="modal-actions">
<button className="btn-secondary" onClick={() => setShowPicker(false)}>Cancel</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,29 @@
import { useBorgesStore } from '../../store/borgesStore'
import { StoryTab } from './StoryTab'
import { MarketsTab } from './MarketsTab'
export function SubmissionPanel(): JSX.Element {
const { submissionPanelTab, setSubmissionPanelTab } = useBorgesStore()
return (
<div className="sub-panel">
<div className="sub-panel-tabs">
<button
className={`sub-panel-tab${submissionPanelTab === 'story' ? ' active' : ''}`}
onClick={() => setSubmissionPanelTab('story')}
>
Story
</button>
<button
className={`sub-panel-tab${submissionPanelTab === 'markets' ? ' active' : ''}`}
onClick={() => setSubmissionPanelTab('markets')}
>
Markets
</button>
</div>
<div className="sub-panel-body">
{submissionPanelTab === 'story' ? <StoryTab /> : <MarketsTab />}
</div>
</div>
)
}

View File

@@ -0,0 +1,135 @@
import { useBorgesStore } from '../../store/borgesStore'
type AnalysisModeAI = 'compression' | 'ending' | 'tone' | 'market_fit' | 'chat'
const MODES: { mode: AnalysisModeAI; label: string; title: string }[] = [
{ mode: 'compression', label: 'Compression', title: 'Find redundancy, hedging, and low-yield content' },
{ mode: 'ending', label: 'Ending', title: 'Evaluate the final paragraph' },
{ mode: 'tone', label: 'Tone', title: 'Map tonal register and flag outliers' },
{ mode: 'market_fit', label: 'Market fit', title: 'Evaluate fit against selected market' },
]
export function AnalysisToolbar(): JSX.Element {
const { analysisMode, setAnalysisMode, clearAnnotations, activeStoryId,
useCollectionContext, toggleCollectionContext, useMarketBrief, toggleMarketBrief, selectedMarketId,
isAILoading, activeStoryContent, activeStoryPath, addUserMessage, startAssistantMessage,
appendToLastMessage, setAILoading, setAIError, setAnnotations, setChatOpen, markets
} = useBorgesStore()
const selectedMarket = markets.find((m) => m.id === selectedMarketId)
const runAnalysis = async (mode: AnalysisModeAI): Promise<void> => {
if (!activeStoryId || !activeStoryPath || isAILoading) return
const story = useBorgesStore.getState().stories.find((s) => s.id === activeStoryId)
const cfg = await window.api.getCollectionConfig()
if (analysisMode === mode) {
clearAnnotations()
return
}
setAnalysisMode(mode)
clearAnnotations()
setChatOpen(true)
const modeLabels: Record<AnalysisModeAI | 'none', string> = {
compression: 'Run compression analysis',
ending: 'Evaluate the ending',
tone: 'Map tonal register',
market_fit: 'Evaluate market fit',
chat: 'Chat',
none: ''
}
addUserMessage(modeLabels[mode])
startAssistantMessage()
setAILoading(true)
setAIError(null)
try {
let fullResponse = ''
await window.api.streamAIMessage(
{
mode,
storyContent: activeStoryContent,
storyId: activeStoryId,
wordCountTarget: story?.meta.wordCountTarget,
targetMarket: selectedMarket ?? undefined,
collectionContext: cfg.collectionContext,
useCollectionContext: useCollectionContext && !!cfg.collectionContext,
useMarketBrief: useMarketBrief && !!selectedMarket,
conversationHistory: [],
userMessage: modeLabels[mode]
},
(chunk) => {
appendToLastMessage(chunk)
fullResponse += chunk
}
)
// Parse annotations from compression/tone responses
if (mode === 'compression' || mode === 'tone') {
const parsed = parseAnnotations(fullResponse, activeStoryContent)
if (parsed.length > 0) setAnnotations(parsed)
}
} catch (err) {
setAIError(err instanceof Error ? err.message : 'Error')
} finally {
setAILoading(false)
}
}
return (
<div className="analysis-toolbar">
{MODES.map(({ mode, label, title }) => (
<button
key={mode}
className={`analysis-toolbar-btn${analysisMode === mode ? ' active' : ''}`}
title={title}
onClick={() => runAnalysis(mode)}
disabled={isAILoading || !activeStoryId || (mode === 'market_fit' && !selectedMarketId)}
>
{label}
</button>
))}
<div className="analysis-toolbar-sep" />
<button
className={`analysis-toolbar-toggle${useCollectionContext ? ' on' : ''}`}
onClick={toggleCollectionContext}
title="Inject collection context into AI prompt"
>
<span className="toggle-dot" />
Collection
</button>
<button
className={`analysis-toolbar-toggle${useMarketBrief && selectedMarketId ? ' on' : ''}`}
onClick={toggleMarketBrief}
title="Inject market brief into AI prompt"
disabled={!selectedMarketId}
>
<span className="toggle-dot" />
Market brief
</button>
</div>
)
}
function parseAnnotations(text: string, doc: string): import('../../types/borges').TextAnnotation[] {
const annotations: import('../../types/borges').TextAnnotation[] = []
const blockRe = /ISSUE:[^\n]*\nPASSAGE:\s*"([^"]+)"\nPROBLEM:\s*([^\n]+)(?:\nSUGGESTION:\s*"([^"]*)")?/g
let m: RegExpExecArray | null
while ((m = blockRe.exec(text)) !== null) {
const passage = m[1]?.trim()
const problem = m[2]?.trim()
const suggestion = m[3]?.trim()
if (!passage || !problem) continue
if (!doc.includes(passage)) continue
annotations.push({
id: `ann-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
passage,
problem,
suggestion: suggestion || undefined
})
}
return annotations
}

12
src/renderer/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Borges</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

10
src/renderer/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './styles/app.css'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

View File

@@ -0,0 +1,262 @@
import { create } from 'zustand'
import type { StoryFile, Market, Submission, ChatMessage, AnalysisMode, TextAnnotation, RevisionMeta } from '../types/borges'
interface BorgesState {
// Stories
stories: StoryFile[]
setStories: (stories: StoryFile[]) => void
moveStory: (fromIdx: number, toIdx: number) => void
// Active story
activeStoryPath: string | null
activeStoryId: string | null
activeStoryContent: string
isDirty: boolean
setActiveStory: (path: string, id: string, content: string) => void
setContent: (content: string) => void
markSaved: () => void
clearActiveStory: () => void
// Markets & Submissions
markets: Market[]
setMarkets: (markets: Market[]) => void
submissions: Submission[]
setSubmissions: (submissions: Submission[]) => void
selectedMarketId: string | null
setSelectedMarketId: (id: string | null) => void
// AI chat
chatHistory: ChatMessage[]
chatHistoryByStory: Record<string, ChatMessage[]>
isAILoading: boolean
aiError: string | null
addUserMessage: (text: string) => void
startAssistantMessage: () => void
appendToLastMessage: (chunk: string) => void
setAILoading: (v: boolean) => void
setAIError: (e: string | null) => void
newChat: () => void
// Analysis
analysisMode: AnalysisMode
setAnalysisMode: (mode: AnalysisMode) => void
annotations: TextAnnotation[]
setAnnotations: (anns: TextAnnotation[]) => void
removeAnnotation: (id: string) => void
clearAnnotations: () => void
useCollectionContext: boolean
toggleCollectionContext: () => void
useMarketBrief: boolean
toggleMarketBrief: () => void
// Revisions
revisionPanelOpen: boolean
toggleRevisionPanel: () => void
revisions: RevisionMeta[]
setRevisions: (revisions: RevisionMeta[]) => void
// UI layout
sidebarOpen: boolean
setSidebarOpen: (v: boolean) => void
submissionPanelOpen: boolean
setSubmissionPanelOpen: (v: boolean) => void
chatOpen: boolean
setChatOpen: (v: boolean) => void
focusMode: boolean
toggleFocusMode: () => void
submissionPanelTab: 'story' | 'markets'
setSubmissionPanelTab: (tab: 'story' | 'markets') => void
// Theme & font
theme: 'dark' | 'light'
toggleTheme: () => void
fontSize: number
setFontSize: (size: number) => void
initPrefs: () => Promise<void>
// Session
loadSession: () => Promise<void>
}
let _sessionTimer: ReturnType<typeof setTimeout> | null = null
function scheduleSave(getData: () => Record<string, unknown>): void {
if (_sessionTimer) clearTimeout(_sessionTimer)
_sessionTimer = setTimeout(() => {
const api = (window as Window).api
api?.writeSession(getData()).catch(console.error)
}, 1500)
}
let _autoSaveTimer: ReturnType<typeof setTimeout> | null = null
function scheduleAutoSave(getData: () => { path: string; content: string } | null): void {
if (_autoSaveTimer) clearTimeout(_autoSaveTimer)
_autoSaveTimer = setTimeout(() => {
const d = getData()
if (!d) return
const api = (window as Window).api
api?.writeStory(d.path, d.content).catch(console.error)
}, 10_000)
}
export const useBorgesStore = create<BorgesState>((set, get) => ({
stories: [],
setStories: (stories) => set({ stories }),
moveStory: (fromIdx, toIdx) => {
set((s) => {
const stories = [...s.stories]
const [moved] = stories.splice(fromIdx, 1)
stories.splice(toIdx, 0, moved)
const order = stories.map((s) => s.id)
window.api.saveOrder(order).catch(console.error)
return { stories }
})
},
activeStoryPath: null,
activeStoryId: null,
activeStoryContent: '',
isDirty: false,
setActiveStory: (path, id, content) => {
const s = get()
const history = s.chatHistoryByStory[id] ?? []
set({ activeStoryPath: path, activeStoryId: id, activeStoryContent: content, isDirty: false, chatHistory: history, annotations: [], analysisMode: 'none' })
scheduleSave(() => ({ activeStoryPath: get().activeStoryPath, chatHistoryByStory: get().chatHistoryByStory }))
},
setContent: (content) => {
set({ activeStoryContent: content, isDirty: true })
scheduleAutoSave(() => {
const { activeStoryPath, activeStoryContent } = get()
return activeStoryPath ? { path: activeStoryPath, content: activeStoryContent } : null
})
},
markSaved: () => set({ isDirty: false }),
clearActiveStory: () => set({ activeStoryPath: null, activeStoryId: null, activeStoryContent: '', isDirty: false, chatHistory: [], annotations: [], analysisMode: 'none' }),
markets: [],
setMarkets: (markets) => set({ markets }),
submissions: [],
setSubmissions: (submissions) => set({ submissions }),
selectedMarketId: null,
setSelectedMarketId: (id) => set({ selectedMarketId: id }),
chatHistory: [],
chatHistoryByStory: {},
isAILoading: false,
aiError: null,
addUserMessage: (text) => {
const msg: ChatMessage = { id: `user-${Date.now()}`, role: 'user', content: text }
set((s) => {
const history = [...s.chatHistory, msg]
const chatHistoryByStory = s.activeStoryId
? { ...s.chatHistoryByStory, [s.activeStoryId]: history }
: s.chatHistoryByStory
return { chatHistory: history, chatHistoryByStory }
})
scheduleSave(() => ({ activeStoryPath: get().activeStoryPath, chatHistoryByStory: get().chatHistoryByStory }))
},
startAssistantMessage: () => {
const msg: ChatMessage = { id: `asst-${Date.now()}`, role: 'assistant', content: '' }
set((s) => ({ chatHistory: [...s.chatHistory, msg] }))
},
appendToLastMessage: (chunk) => {
set((s) => {
const history = [...s.chatHistory]
const last = history[history.length - 1]
if (last?.role === 'assistant') history[history.length - 1] = { ...last, content: last.content + chunk }
return { chatHistory: history }
})
},
setAILoading: (isAILoading) => {
if (!isAILoading) {
const s = get()
if (s.activeStoryId) {
const chatHistoryByStory = { ...s.chatHistoryByStory, [s.activeStoryId]: s.chatHistory }
set({ isAILoading, chatHistoryByStory })
scheduleSave(() => ({ activeStoryPath: get().activeStoryPath, chatHistoryByStory: get().chatHistoryByStory }))
return
}
}
set({ isAILoading })
},
setAIError: (aiError) => set({ aiError }),
newChat: () => {
set((s) => {
const chatHistoryByStory = s.activeStoryId
? { ...s.chatHistoryByStory, [s.activeStoryId]: [] }
: s.chatHistoryByStory
return { chatHistory: [], chatHistoryByStory }
})
},
analysisMode: 'none',
setAnalysisMode: (analysisMode) => set({ analysisMode }),
annotations: [],
setAnnotations: (annotations) => set({ annotations }),
removeAnnotation: (id) => set((s) => ({ annotations: s.annotations.filter((a) => a.id !== id) })),
clearAnnotations: () => set({ annotations: [], analysisMode: 'none' }),
useCollectionContext: false,
toggleCollectionContext: () => set((s) => ({ useCollectionContext: !s.useCollectionContext })),
useMarketBrief: false,
toggleMarketBrief: () => set((s) => ({ useMarketBrief: !s.useMarketBrief })),
revisionPanelOpen: false,
toggleRevisionPanel: () => set((s) => ({ revisionPanelOpen: !s.revisionPanelOpen })),
revisions: [],
setRevisions: (revisions) => set({ revisions }),
sidebarOpen: localStorage.getItem('sidebarOpen') !== 'false',
setSidebarOpen: (v) => { localStorage.setItem('sidebarOpen', String(v)); set({ sidebarOpen: v }) },
submissionPanelOpen: localStorage.getItem('submissionPanelOpen') !== 'false',
setSubmissionPanelOpen: (v) => { localStorage.setItem('submissionPanelOpen', String(v)); set({ submissionPanelOpen: v }) },
chatOpen: localStorage.getItem('chatOpen') !== 'false',
setChatOpen: (v) => { localStorage.setItem('chatOpen', String(v)); set({ chatOpen: v }) },
focusMode: false,
toggleFocusMode: () => set((s) => ({ focusMode: !s.focusMode })),
submissionPanelTab: 'story',
setSubmissionPanelTab: (tab) => set({ submissionPanelTab: tab }),
theme: 'dark',
toggleTheme: () => {
set((s) => {
const next = s.theme === 'dark' ? 'light' : 'dark'
window.api.writeConfig({ theme: next })
document.documentElement.classList.toggle('light', next === 'light')
return { theme: next }
})
},
fontSize: 15,
setFontSize: (size) => {
const clamped = Math.max(11, Math.min(24, size))
window.api.writeConfig({ fontSize: clamped })
set({ fontSize: clamped })
},
initPrefs: async () => {
const cfg = await window.api.readConfig()
const fontSize = Math.max(11, Math.min(24, cfg.fontSize ?? 15))
const theme = cfg.theme ?? 'dark'
document.documentElement.classList.toggle('light', theme === 'light')
set({ fontSize, theme })
},
loadSession: async () => {
try {
const data = await window.api.readSession()
const patch: Partial<BorgesState> = {}
if (data.chatHistoryByStory && typeof data.chatHistoryByStory === 'object') {
patch.chatHistoryByStory = data.chatHistoryByStory as Record<string, ChatMessage[]>
}
set(patch)
if (typeof data.activeStoryPath === 'string') {
try {
const content = await window.api.readStory(data.activeStoryPath)
const id = data.activeStoryPath.split('/').pop()?.replace(/\.md$/, '') ?? ''
get().setActiveStory(data.activeStoryPath, id, content)
} catch {
// file moved/deleted
}
}
} catch {
// no session
}
}
}))

710
src/renderer/styles/app.css Normal file
View File

@@ -0,0 +1,710 @@
/* ── Reset & base ─────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #1a1a1a;
--bg2: #222222;
--bg3: #2a2a2a;
--border: #333333;
--text: #d4c9b8;
--text2: #8a7f70;
--text3: #5a5048;
--accent: #c8a96e;
--accent2: #a07040;
--danger: #b05050;
--success: #5a9060;
--warn: #b08040;
--pending: #4a7ab0;
--sidebar-w: 220px;
--sub-w: 280px;
--chat-w: 300px;
--toolbar-h: 40px;
--titlebar-h: 38px;
}
:root.light {
--bg: #f5f0ea;
--bg2: #ede8e0;
--bg3: #e5dfd5;
--border: #ccc5b8;
--text: #2a2218;
--text2: #7a6f5e;
--text3: #aaa090;
--accent: #7a5828;
--accent2: #9a6a38;
--danger: #903030;
--success: #3a6840;
--warn: #806020;
--pending: #305890;
}
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: 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: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(--sub-w) var(--chat-w);
grid-template-areas:
"titlebar titlebar titlebar titlebar"
"sidebar editor subpanel chat";
transition: grid-template-columns 0.18s ease;
}
.app-layout[data-sidebar="closed"] {
grid-template-columns: 0 1fr var(--sub-w) var(--chat-w);
}
.app-layout[data-sub="closed"] {
grid-template-columns: var(--sidebar-w) 1fr 0 var(--chat-w);
}
.app-layout[data-chat="closed"] {
grid-template-columns: var(--sidebar-w) 1fr var(--sub-w) 0;
}
.app-layout[data-sidebar="closed"][data-sub="closed"] {
grid-template-columns: 0 1fr 0 var(--chat-w);
}
.app-layout[data-sidebar="closed"][data-chat="closed"] {
grid-template-columns: 0 1fr var(--sub-w) 0;
}
.app-layout[data-sub="closed"][data-chat="closed"] {
grid-template-columns: var(--sidebar-w) 1fr 0 0;
}
.app-layout[data-sidebar="closed"][data-sub="closed"][data-chat="closed"] {
grid-template-columns: 0 1fr 0 0;
}
.app-layout[data-focus="on"] {
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); }
/* ── Sidebar ──────────────────────────────────────────────────────────────── */
.sidebar {
grid-area: sidebar;
display: flex;
flex-direction: column;
background: var(--bg2);
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-btn { width: 24px; height: 24px; border-radius: 4px; color: var(--text2); font-size: 16px; display: flex; align-items: center; justify-content: center; }
.sidebar-btn:hover { color: var(--text); background: var(--bg3); }
.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; }
/* Story item */
.story-item {
display: flex;
align-items: center;
padding: 6px 12px;
gap: 8px;
cursor: pointer;
border-radius: 0;
transition: background 0.1s;
position: relative;
}
.story-item:hover { background: var(--bg3); }
.story-item.active { background: var(--bg3); }
.story-item.active::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 2px; background: var(--accent); }
.story-item-main { flex: 1; min-width: 0; }
.story-item-title { font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.story-item-wc { font-size: 11px; color: var(--text3); margin-top: 1px; }
.story-item-badge {
font-size: 10px;
padding: 1px 5px;
border-radius: 3px;
font-weight: 600;
flex-shrink: 0;
}
.badge-unsent { background: var(--bg3); color: var(--text3); }
.badge-pending { background: var(--pending); color: #fff; }
.badge-accepted { background: var(--success); color: #fff; }
.badge-rejected { background: var(--bg3); color: var(--text3); }
.badge-pending-revision { background: var(--warn); color: #fff; }
.badge-withdrawn { background: var(--bg3); color: var(--text3); }
/* ── Editor area ──────────────────────────────────────────────────────────── */
.editor-area {
grid-area: editor;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg);
min-width: 0;
}
/* Word count bar */
.wordcount-bar {
display: flex;
align-items: center;
padding: 0 16px;
height: 32px;
background: var(--bg2);
border-bottom: 1px solid var(--border);
gap: 10px;
flex-shrink: 0;
font-size: 12px;
color: var(--text2);
}
.wordcount-number { font-weight: 600; color: var(--text); }
.wordcount-number.amber { color: var(--warn); }
.wordcount-number.red { color: var(--danger); }
.wordcount-bar-track { flex: 1; height: 3px; background: var(--bg3); border-radius: 2px; max-width: 200px; }
.wordcount-bar-fill { height: 100%; border-radius: 2px; background: var(--accent); transition: width 0.2s, background 0.2s; }
.wordcount-bar-fill.amber { background: var(--warn); }
.wordcount-bar-fill.red { background: var(--danger); }
.wordcount-target-btn { font-size: 11px; color: var(--text3); padding: 2px 6px; border-radius: 3px; }
.wordcount-target-btn:hover { color: var(--accent); background: var(--bg3); }
/* Analysis toolbar */
.analysis-toolbar {
display: flex;
align-items: center;
padding: 0 12px;
height: var(--toolbar-h);
border-bottom: 1px solid var(--border);
background: var(--bg2);
gap: 4px;
flex-shrink: 0;
}
.analysis-toolbar-btn {
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
color: var(--text2);
transition: background 0.12s, color 0.12s;
}
.analysis-toolbar-btn:hover { background: var(--bg3); color: var(--text); }
.analysis-toolbar-btn.active { background: var(--accent); color: var(--bg); }
.analysis-toolbar-sep { width: 1px; height: 20px; background: var(--border); margin: 0 4px; }
.analysis-toolbar-toggle {
padding: 3px 8px;
border-radius: 4px;
font-size: 11px;
display: flex;
align-items: center;
gap: 5px;
color: var(--text3);
}
.analysis-toolbar-toggle:hover { color: var(--text2); }
.analysis-toolbar-toggle.on { color: var(--accent); }
.toggle-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--bg3);
border: 1px solid var(--text3);
flex-shrink: 0;
transition: background 0.1s, border-color 0.1s;
}
.analysis-toolbar-toggle.on .toggle-dot { background: var(--accent); border-color: var(--accent); }
/* Editor scroll */
.editor-scroll {
flex: 1;
overflow-y: auto;
padding: 32px 0;
}
/* CodeMirror overrides */
.cm-editor { height: 100%; }
.cm-editor.cm-focused { outline: none; }
.cm-scroller { font-family: 'Georgia', 'Times New Roman', serif; line-height: 1.8; }
.cm-content { max-width: 680px; margin: 0 auto; padding: 0 24px; }
.cm-line { padding: 0; }
.cm-cursor { border-left-color: var(--accent) !important; }
.cm-selectionBackground { background: rgba(200, 169, 110, 0.25) !important; }
.cm-focused .cm-selectionBackground { background: rgba(200, 169, 110, 0.35) !important; }
.cm-gutters { display: none; }
/* Annotation highlight */
.ann-highlight {
background: rgba(200, 169, 110, 0.18);
border-bottom: 1px solid var(--accent);
cursor: pointer;
border-radius: 2px;
}
.ann-highlight:hover { background: rgba(200, 169, 110, 0.28); }
/* Dashboard */
.dashboard {
flex: 1;
overflow-y: auto;
padding: 32px;
}
.dashboard-greeting {
font-size: 22px;
font-weight: 300;
color: var(--text);
margin-bottom: 32px;
}
.dashboard-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.dashboard-card {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
}
.dashboard-card-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text2);
margin-bottom: 12px;
}
.dashboard-row {
display: flex;
align-items: baseline;
gap: 8px;
padding: 6px 0;
border-bottom: 1px solid var(--border);
font-size: 13px;
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; }
.dashboard-row-meta { font-size: 11px; color: var(--text3); flex-shrink: 0; }
.dashboard-row-flag { font-size: 11px; color: var(--warn); flex-shrink: 0; }
.dashboard-empty { font-size: 13px; color: var(--text3); padding: 8px 0; }
/* ── Submission panel ─────────────────────────────────────────────────────── */
.sub-panel {
grid-area: subpanel;
display: flex;
flex-direction: column;
background: var(--bg2);
border-left: 1px solid var(--border);
overflow: hidden;
min-width: 0;
}
.sub-panel-tabs {
display: flex;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.sub-panel-tab {
flex: 1;
height: 38px;
font-size: 12px;
font-weight: 500;
color: var(--text3);
border-bottom: 2px solid transparent;
transition: color 0.12s, border-color 0.12s;
}
.sub-panel-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.sub-panel-body { flex: 1; overflow-y: auto; padding: 12px; }
/* Submissions list */
.sub-list-title { font-size: 11px; font-weight: 600; color: var(--text2); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 8px; }
.sub-item {
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
margin-bottom: 8px;
background: var(--bg3);
}
.sub-item-header { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.sub-item-market { font-weight: 600; font-size: 13px; flex: 1; }
.sub-item-date { font-size: 11px; color: var(--text3); }
.sub-item-status { display: flex; align-items: center; gap: 6px; }
.sub-item-status select { font-size: 12px; padding: 2px 4px; }
.sub-item-notes { margin-top: 6px; }
.sub-item-notes textarea { width: 100%; font-size: 12px; min-height: 48px; }
.sub-warn { font-size: 12px; color: var(--warn); padding: 6px; background: rgba(180, 130, 60, 0.1); border-radius: 4px; margin-bottom: 8px; }
.sub-btn {
width: 100%;
height: 32px;
border-radius: 5px;
font-size: 13px;
font-weight: 500;
background: var(--accent);
color: var(--bg);
margin-bottom: 12px;
transition: opacity 0.12s;
}
.sub-btn:hover { opacity: 0.85; }
.sub-btn:disabled { opacity: 0.4; cursor: default; }
/* Markets table */
.market-item {
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
margin-bottom: 8px;
background: var(--bg3);
cursor: pointer;
transition: border-color 0.1s;
}
.market-item:hover { border-color: var(--accent); }
.market-item.inactive { opacity: 0.5; }
.market-item-name { font-weight: 600; font-size: 13px; }
.market-item-wc { font-size: 11px; color: var(--text3); margin-top: 2px; }
.market-item-tags { font-size: 11px; color: var(--text3); margin-top: 2px; }
.market-actions { display: flex; gap: 6px; margin-top: 6px; }
.market-actions button { font-size: 11px; padding: 2px 8px; border-radius: 3px; background: var(--bg2); border: 1px solid var(--border); color: var(--text2); }
.market-actions button:hover { color: var(--text); border-color: var(--text3); }
.market-match-badge { font-size: 10px; font-weight: 600; color: var(--success); padding: 1px 5px; background: rgba(90, 144, 96, 0.15); border-radius: 3px; }
/* Market form */
.market-form { padding: 8px 0; }
.market-form-title { font-size: 13px; font-weight: 600; margin-bottom: 12px; }
.form-field { margin-bottom: 10px; }
.form-label { font-size: 11px; color: var(--text2); display: block; margin-bottom: 4px; }
.form-field input, .form-field textarea, .form-field select { width: 100%; }
.form-row { display: flex; gap: 8px; }
.form-row .form-field { flex: 1; }
.form-actions { display: flex; gap: 8px; margin-top: 12px; }
.btn-primary {
flex: 1; height: 30px; border-radius: 4px;
background: var(--accent); color: var(--bg); 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); }
.btn-danger { height: 30px; padding: 0 12px; border-radius: 4px; background: var(--danger); color: #fff; font-size: 12px; }
/* Market picker modal */
.modal-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.5);
display: flex; align-items: center; justify-content: center;
z-index: 200;
}
.modal {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px;
width: 380px;
max-height: 80vh;
display: flex;
flex-direction: column;
gap: 12px;
}
.modal-title { font-size: 15px; font-weight: 600; }
.modal-search input { width: 100%; }
.modal-list { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 6px; }
.modal-market-btn {
width: 100%; text-align: left; padding: 8px 10px;
border: 1px solid var(--border); border-radius: 6px; background: var(--bg3);
transition: border-color 0.1s;
}
.modal-market-btn:hover { border-color: var(--accent); }
.modal-market-btn-name { font-weight: 600; font-size: 13px; }
.modal-market-btn-wc { font-size: 11px; color: var(--text3); }
.modal-actions { display: flex; justify-content: flex-end; }
/* ── 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-header-btn { width: 24px; height: 24px; border-radius: 4px; font-size: 13px; color: var(--text3); display: flex; align-items: center; justify-content: center; }
.chat-header-btn:hover { color: var(--text); 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: var(--bg); }
.chat-message-assistant .chat-bubble { background: var(--bg3); color: var(--text); }
.chat-bubble p { margin-bottom: 6px; }
.chat-bubble p:last-child { margin-bottom: 0; }
.chat-bubble code { font-family: monospace; font-size: 12px; background: rgba(0,0,0,0.2); padding: 1px 4px; border-radius: 3px; }
.chat-bubble strong { font-weight: 700; }
.chat-bubble em { font-style: italic; }
.chat-typing { display: flex; gap: 4px; padding: 8px 12px; }
.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-error { font-size: 12px; color: var(--danger); background: rgba(176, 80, 80, 0.1); padding: 6px 10px; border-radius: 4px; }
.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 input */
.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: var(--bg);
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; }
/* Annotations panel */
.annotations-panel { flex: 1; overflow-y: auto; padding: 8px 12px; }
.ann-item {
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
margin-bottom: 8px;
background: var(--bg3);
font-size: 12px;
}
.ann-item-passage { font-style: italic; color: var(--text2); margin-bottom: 4px; border-left: 2px solid var(--accent); padding-left: 6px; }
.ann-item-problem { color: var(--text); margin-bottom: 4px; }
.ann-item-suggestion { color: var(--text2); font-size: 11px; }
.ann-item-actions { display: flex; gap: 6px; margin-top: 6px; }
.ann-item-actions button { font-size: 11px; padding: 1px 6px; border-radius: 3px; background: var(--bg2); border: 1px solid var(--border); color: var(--text2); }
.ann-item-actions button:hover { color: var(--text); }
/* ── Revision panel ───────────────────────────────────────────────────────── */
.revision-panel {
position: absolute; inset-y: 0; right: 0;
width: 240px;
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: 560px;
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: 140px; 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); border-radius: 0; }
.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, .settings-field textarea { width: 100%; }
.settings-hint { font-size: 11px; color: var(--text3); margin-top: 4px; }
.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); }
/* Context textarea */
.context-textarea { width: 100%; min-height: 120px; font-size: 13px; font-family: inherit; }
/* Focus mode */
.focus-exit { position: fixed; 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); }
/* Context menu */
.context-menu {
position: fixed;
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
padding: 4px;
z-index: 300;
min-width: 140px;
}
.context-menu-item { padding: 6px 12px; border-radius: 4px; font-size: 13px; width: 100%; text-align: left; }
.context-menu-item:hover { background: var(--bg3); }
.context-menu-item.danger { color: var(--danger); }
.context-menu-sep { height: 1px; background: var(--border); margin: 4px 0; }
/* 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); }
/* Misc */
.inline-edit { background: none; border: none; padding: 0; font: inherit; color: inherit; width: 100%; outline: none; border-bottom: 1px solid var(--accent); }
.no-story-placeholder { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--text3); font-size: 14px; }

View File

@@ -0,0 +1,62 @@
export interface StoryMeta {
title?: string
wordCountTarget?: number
tags?: string[]
notes?: string
}
export interface StoryFile {
id: string
path: string
wordCount: number
meta: StoryMeta
}
export interface Market {
id: string
name: string
url?: string
wordCountMin?: number
wordCountMax: number
simultaneousSubs: boolean
responseTimeWeeks?: number
genres: string[]
notes?: string
active: boolean
}
export interface Submission {
id: string
storyId: string
marketId: string
submittedAt: string
status: 'pending' | 'withdrawn' | 'rejected' | 'accepted' | 'pending-revision'
statusUpdatedAt: string
notes?: string
simultaneous: boolean
}
export interface RevisionMeta {
id: string
timestamp: number
wordCount: number
}
export interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
}
export type AnalysisMode = 'compression' | 'ending' | 'tone' | 'market_fit' | 'chat' | 'none'
export interface TextAnnotation {
id: string
passage: string
problem: string
suggestion?: string
from?: number
to?: number
applied?: boolean
dismissed?: boolean
}

65
src/renderer/types/global.d.ts vendored Normal file
View File

@@ -0,0 +1,65 @@
import type { StoryFile, StoryMeta, Market, Submission, RevisionMeta } from './borges'
type AnalysisModeAI = 'compression' | 'ending' | 'tone' | 'market_fit' | 'chat'
interface AIPayload {
mode: AnalysisModeAI
storyContent: string
storyId: string
wordCountTarget?: number
targetMarket?: Market
collectionContext?: string
useCollectionContext: boolean
useMarketBrief: boolean
conversationHistory: { role: 'user' | 'assistant'; content: string }[]
userMessage: string
}
interface GlobalConfig {
apiKey?: string
collectionPath?: string
fontSize?: number
theme?: 'dark' | 'light'
defaultWordCountTarget?: number
}
interface CollectionConfig {
stories: Record<string, StoryMeta>
collectionContext?: string
}
declare global {
interface Window {
api: {
listStories(): Promise<StoryFile[]>
readStory(path: string): Promise<string>
writeStory(path: string, content: string): Promise<void>
createStory(name: string): Promise<{ id: string; path: string }>
renameStory(oldPath: string, newName: string): Promise<string>
deleteStory(path: string): Promise<void>
getStoryMeta(storyId: string): Promise<StoryMeta>
setStoryMeta(storyId: string, meta: StoryMeta): Promise<void>
saveOrder(order: string[]): Promise<void>
getCollectionConfig(): Promise<CollectionConfig>
setCollectionContext(context: string): Promise<void>
readSession(): Promise<Record<string, unknown>>
writeSession(data: Record<string, unknown>): Promise<void>
listMarkets(): Promise<Market[]>
upsertMarket(market: Market): Promise<void>
deleteMarket(id: string): Promise<void>
listSubmissions(): Promise<Submission[]>
addSubmission(sub: Submission): Promise<void>
updateSubmission(id: string, updates: Partial<Submission>): Promise<void>
saveRevision(path: string, content: string): Promise<void>
listRevisions(path: string): Promise<RevisionMeta[]>
loadRevision(path: string, id: string): Promise<string>
readConfig(): Promise<GlobalConfig>
writeConfig(updates: Partial<GlobalConfig>): Promise<void>
pickFolder(): Promise<string | null>
streamAIMessage(payload: AIPayload, onChunk: (chunk: string) => void): Promise<void>
onMenuAction(handler: (action: string) => void): () => void
}
}
}
export {}

1
tsconfig.json Normal file
View File

@@ -0,0 +1 @@
{ "files": [], "references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }] }

5
tsconfig.node.json Normal file
View 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
View 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"]
}
}