🎉 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