🎉 initial commit
This commit is contained in:
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal 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
12
electron.vite.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { resolve } from 'path'
|
||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
main: { plugins: [externalizeDepsPlugin()] },
|
||||
preload: { plugins: [externalizeDepsPlugin()] },
|
||||
renderer: {
|
||||
resolve: { alias: { '@renderer': resolve('src/renderer') } },
|
||||
plugins: [react()]
|
||||
}
|
||||
})
|
||||
6246
package-lock.json
generated
Normal file
6246
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
61
package.json
Normal file
61
package.json
Normal 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
BIN
resources/icon.icns
Normal file
Binary file not shown.
BIN
resources/icon.ico
Normal file
BIN
resources/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
BIN
resources/icon.png
Normal file
BIN
resources/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 239 KiB |
123
src/main/aiService.ts
Normal file
123
src/main/aiService.ts
Normal 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 150–300 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 (2–3 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
321
src/main/fileSystem.ts
Normal 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
38
src/main/globalConfig.ts
Normal 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
149
src/main/index.ts
Normal 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
89
src/main/ipcHandlers.ts
Normal 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
75
src/preload/index.ts
Normal 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
228
src/renderer/App.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
137
src/renderer/components/AIChat/ChatPanel.tsx
Normal file
137
src/renderer/components/AIChat/ChatPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
119
src/renderer/components/Dashboard/Dashboard.tsx
Normal file
119
src/renderer/components/Dashboard/Dashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
231
src/renderer/components/Editor/MarkdownEditor.tsx
Normal file
231
src/renderer/components/Editor/MarkdownEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
83
src/renderer/components/Editor/WordCountBar.tsx
Normal file
83
src/renderer/components/Editor/WordCountBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
140
src/renderer/components/Settings/SettingsDialog.tsx
Normal file
140
src/renderer/components/Settings/SettingsDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
172
src/renderer/components/Sidebar/StorySidebar.tsx
Normal file
172
src/renderer/components/Sidebar/StorySidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
187
src/renderer/components/SubmissionPanel/MarketsTab.tsx
Normal file
187
src/renderer/components/SubmissionPanel/MarketsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
173
src/renderer/components/SubmissionPanel/StoryTab.tsx
Normal file
173
src/renderer/components/SubmissionPanel/StoryTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
src/renderer/components/SubmissionPanel/SubmissionPanel.tsx
Normal file
29
src/renderer/components/SubmissionPanel/SubmissionPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
135
src/renderer/components/Toolbar/AnalysisToolbar.tsx
Normal file
135
src/renderer/components/Toolbar/AnalysisToolbar.tsx
Normal 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
12
src/renderer/index.html
Normal 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
10
src/renderer/main.tsx
Normal 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>
|
||||
)
|
||||
262
src/renderer/store/borgesStore.ts
Normal file
262
src/renderer/store/borgesStore.ts
Normal 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
710
src/renderer/styles/app.css
Normal 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; }
|
||||
62
src/renderer/types/borges.ts
Normal file
62
src/renderer/types/borges.ts
Normal 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
65
src/renderer/types/global.d.ts
vendored
Normal 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
1
tsconfig.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "files": [], "references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }] }
|
||||
5
tsconfig.node.json
Normal file
5
tsconfig.node.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
||||
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"],
|
||||
"compilerOptions": { "composite": true, "types": ["electron-vite/node"] }
|
||||
}
|
||||
10
tsconfig.web.json
Normal file
10
tsconfig.web.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
|
||||
"include": ["src/renderer/**/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"baseUrl": ".",
|
||||
"paths": { "@renderer/*": ["src/renderer/*"] },
|
||||
"types": ["vite/client"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user