Compare commits
10 Commits
e1c7e27065
...
a353d54bbf
| Author | SHA1 | Date | |
|---|---|---|---|
| a353d54bbf | |||
| 3644885b39 | |||
| cf32c4f82a | |||
| be257ac71e | |||
| 22d664a450 | |||
| 79f42f5b0f | |||
| 1bcae54005 | |||
| 060c26649d | |||
| c09f3c95fc | |||
| aadef607b6 |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "borges",
|
||||
"version": "1.1.2",
|
||||
"version": "1.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "borges",
|
||||
"version": "1.1.2",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.29.0",
|
||||
"@codemirror/commands": "^6.6.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "borges",
|
||||
"version": "1.1.2",
|
||||
"version": "1.3.0",
|
||||
"description": "Flash fiction writing, analysis, and submission tracking",
|
||||
"main": "out/main/index.js",
|
||||
"scripts": {
|
||||
|
||||
27
scripts/fix-ghost-sessions.mjs
Normal file
27
scripts/fix-ghost-sessions.mjs
Normal file
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env node
|
||||
// One-time migration: remove ghost sessions created by clicking into a document
|
||||
// without typing. These have activeMs === 0 (no keystrokes recorded).
|
||||
|
||||
import { readFileSync, writeFileSync, copyFileSync } from 'fs'
|
||||
|
||||
const TELEMETRY = `${process.env.HOME}/Library/Application Support/Borges/.borges/telemetry.json`
|
||||
const BACKUP = TELEMETRY + '.ghost-bak'
|
||||
|
||||
const sessions = JSON.parse(readFileSync(TELEMETRY, 'utf-8'))
|
||||
|
||||
const ghosts = sessions.filter(s => s.activeMs === 0)
|
||||
const cleaned = sessions.filter(s => s.activeMs > 0)
|
||||
|
||||
if (ghosts.length === 0) {
|
||||
console.log('No ghost sessions found.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
for (const s of ghosts) {
|
||||
console.log(`Removing ghost session ${s.id} (${s.storyId}) date=${s.date} words ${s.wordsStart}→${s.wordsEnd}`)
|
||||
}
|
||||
|
||||
copyFileSync(TELEMETRY, BACKUP)
|
||||
console.log(`\nBacked up to ${BACKUP}`)
|
||||
writeFileSync(TELEMETRY, JSON.stringify(cleaned, null, 2), 'utf-8')
|
||||
console.log(`Done. Removed ${ghosts.length} ghost session(s) → ${cleaned.length} total (was ${sessions.length}).`)
|
||||
31
scripts/fix-wpm.mjs
Normal file
31
scripts/fix-wpm.mjs
Normal file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env node
|
||||
// One-time migration: zero out WPM for sessions that are clearly paste artifacts.
|
||||
// A session is flagged when activeMs < 5000 (sub-5-second burst) and the resulting
|
||||
// WPM exceeds 300 — no human types that fast.
|
||||
|
||||
import { readFileSync, writeFileSync, cpSync } from 'fs'
|
||||
|
||||
const TELEMETRY = `${process.env.HOME}/Library/Application Support/Borges/.borges/telemetry.json`
|
||||
const BACKUP = TELEMETRY + '.bak'
|
||||
|
||||
const sessions = JSON.parse(readFileSync(TELEMETRY, 'utf-8'))
|
||||
|
||||
let fixed = 0
|
||||
const patched = sessions.map(s => {
|
||||
if (s.wpm > 300 && s.activeMs < 5000) {
|
||||
fixed++
|
||||
console.log(`Fixing session ${s.id} (${s.storyId}): wpm ${s.wpm} → 0 [activeMs=${s.activeMs}, words ${s.wordsStart}→${s.wordsEnd}]`)
|
||||
return { ...s, wpm: 0 }
|
||||
}
|
||||
return s
|
||||
})
|
||||
|
||||
if (fixed === 0) {
|
||||
console.log('No sessions to fix.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
cpSync(TELEMETRY, BACKUP)
|
||||
console.log(`Backed up to ${BACKUP}`)
|
||||
writeFileSync(TELEMETRY, JSON.stringify(patched, null, 2), 'utf-8')
|
||||
console.log(`Done. Fixed ${fixed} session(s).`)
|
||||
58
scripts/merge-sessions.mjs
Normal file
58
scripts/merge-sessions.mjs
Normal file
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env node
|
||||
// One-time migration: merge consecutive same-story sessions that were split by
|
||||
// the blur-flush bug (every tab-out ended the session prematurely).
|
||||
// Sessions of the same story on the same date with a gap < GAP_MS are merged.
|
||||
|
||||
import { readFileSync, writeFileSync, copyFileSync } from 'fs'
|
||||
|
||||
const GAP_MS = 10 * 60 * 1000 // 10-minute gap = new session
|
||||
const TELEMETRY = `${process.env.HOME}/Library/Application Support/Borges/.borges/telemetry.json`
|
||||
const BACKUP = TELEMETRY + '.merge-bak'
|
||||
|
||||
const sessions = JSON.parse(readFileSync(TELEMETRY, 'utf-8'))
|
||||
|
||||
// Sort chronologically so we can walk them in order
|
||||
const sorted = [...sessions].sort((a, b) => a.startedAt - b.startedAt)
|
||||
|
||||
const merged = []
|
||||
let mergeCount = 0
|
||||
|
||||
for (const s of sorted) {
|
||||
const prev = merged[merged.length - 1]
|
||||
|
||||
const sameStory = prev && prev.storyId === s.storyId
|
||||
const sameDate = prev && prev.date === s.date
|
||||
const gapMs = prev ? s.startedAt - prev.endedAt : Infinity
|
||||
|
||||
if (sameStory && sameDate && gapMs < GAP_MS) {
|
||||
// Merge s into prev
|
||||
const combinedActiveMs = prev.activeMs + s.activeMs
|
||||
const typedWords = s.wordsEnd - prev.wordsStart
|
||||
const wpm = combinedActiveMs > 0
|
||||
? Math.round(Math.max(0, typedWords) / (combinedActiveMs / 60_000))
|
||||
: 0
|
||||
|
||||
console.log(
|
||||
`Merging ${s.id} into ${prev.id} (${prev.storyId}) ` +
|
||||
`gap=${(gapMs / 60000).toFixed(1)}m words ${prev.wordsStart}→${s.wordsEnd} wpm ${prev.wpm}→${wpm}`
|
||||
)
|
||||
|
||||
prev.endedAt = s.endedAt
|
||||
prev.wordsEnd = s.wordsEnd
|
||||
prev.activeMs = combinedActiveMs
|
||||
prev.wpm = wpm
|
||||
mergeCount++
|
||||
} else {
|
||||
merged.push({ ...s })
|
||||
}
|
||||
}
|
||||
|
||||
if (mergeCount === 0) {
|
||||
console.log('No sessions to merge.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
copyFileSync(TELEMETRY, BACKUP)
|
||||
console.log(`\nBacked up to ${BACKUP}`)
|
||||
writeFileSync(TELEMETRY, JSON.stringify(merged, null, 2), 'utf-8')
|
||||
console.log(`Done. Merged ${mergeCount} session(s) → ${merged.length} total (was ${sessions.length}).`)
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import type { Market, Submission, StoryMeta, TelemetrySession } from './fileSystem'
|
||||
import { streamMessage, streamPrompt, resetClient } from './aiService'
|
||||
import type { AIPayload } from './aiService'
|
||||
import { readGlobalConfig, writeGlobalConfig } from './globalConfig'
|
||||
import { readGlobalConfig, writeGlobalConfig, getApiKey } from './globalConfig'
|
||||
import type { GlobalConfig } from './globalConfig'
|
||||
|
||||
export function registerIpcHandlers(): void {
|
||||
@@ -54,6 +54,7 @@ export function registerIpcHandlers(): void {
|
||||
ipcMain.handle('telemetry:read', async () => readTelemetry())
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────────────────
|
||||
ipcMain.handle('config:isAIEnabled', async () => !!getApiKey())
|
||||
ipcMain.handle('config:read', async () => readGlobalConfig())
|
||||
ipcMain.handle('config:write', async (_e, updates: Partial<GlobalConfig>) => {
|
||||
writeGlobalConfig(updates)
|
||||
|
||||
@@ -39,6 +39,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
loadRevision: (path: string, id: string): Promise<string> => ipcRenderer.invoke('revisions:load', path, id),
|
||||
|
||||
// Config
|
||||
isAIEnabled: (): Promise<boolean> => ipcRenderer.invoke('config:isAIEnabled'),
|
||||
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'),
|
||||
|
||||
@@ -6,6 +6,7 @@ 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 { MarketsView } from './components/MarketsView/MarketsView'
|
||||
import { SettingsDialog } from './components/Settings/SettingsDialog'
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
@@ -21,11 +22,12 @@ export default function App(): JSX.Element {
|
||||
setMarkets,
|
||||
setSubmissions,
|
||||
revisionPanelOpen, toggleRevisionPanel,
|
||||
mainView,
|
||||
initPrefs, loadSession
|
||||
} = useBorgesStore()
|
||||
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [, setIsFirstRun] = useState(false)
|
||||
const [aiEnabled, setAiEnabled] = useState(false)
|
||||
|
||||
// Initialise app
|
||||
useEffect(() => {
|
||||
@@ -40,11 +42,7 @@ export default function App(): JSX.Element {
|
||||
setMarkets(marketsList)
|
||||
setSubmissions(subsList)
|
||||
await loadSession()
|
||||
const cfg = await window.api.readConfig()
|
||||
if (!cfg.apiKey) {
|
||||
setIsFirstRun(true)
|
||||
setSettingsOpen(true)
|
||||
}
|
||||
setAiEnabled(await window.api.isAIEnabled())
|
||||
}
|
||||
init()
|
||||
}, [])
|
||||
@@ -146,12 +144,16 @@ export default function App(): JSX.Element {
|
||||
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'}
|
||||
/>
|
||||
{aiEnabled && (
|
||||
<>
|
||||
<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' : ''}`}
|
||||
@@ -186,13 +188,15 @@ export default function App(): JSX.Element {
|
||||
|
||||
{/* Editor area */}
|
||||
<main className="editor-area">
|
||||
{activeStoryId ? (
|
||||
{mainView === 'markets' ? (
|
||||
<MarketsView />
|
||||
) : activeStoryId ? (
|
||||
<>
|
||||
<AnalysisToolbar />
|
||||
{aiEnabled && <AnalysisToolbar />}
|
||||
<MarkdownEditor />
|
||||
</>
|
||||
) : (
|
||||
<Dashboard />
|
||||
<Dashboard aiEnabled={aiEnabled} />
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -202,9 +206,11 @@ export default function App(): JSX.Element {
|
||||
</aside>
|
||||
|
||||
{/* Chat panel */}
|
||||
<aside className="chat-area">
|
||||
<ChatPanel />
|
||||
</aside>
|
||||
{aiEnabled && (
|
||||
<aside className="chat-area">
|
||||
<ChatPanel />
|
||||
</aside>
|
||||
)}
|
||||
|
||||
|
||||
{/* Settings */}
|
||||
|
||||
@@ -6,7 +6,12 @@ function daysSince(iso: string): number {
|
||||
return Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000)
|
||||
}
|
||||
|
||||
export function Dashboard(): JSX.Element {
|
||||
type StoryRow =
|
||||
| { kind: 'out'; storyId: string; storyTitle: string; storyPath: string; marketName: string; days: number; overdue: boolean }
|
||||
| { kind: 'ready'; storyId: string; storyTitle: string; storyPath: string; wordCount: number }
|
||||
| { kind: 'accepted' | 'rejected'; storyId: string; storyTitle: string; storyPath: string; marketName: string }
|
||||
|
||||
export function Dashboard({ aiEnabled }: { aiEnabled: boolean }): JSX.Element {
|
||||
const { stories, submissions, markets, setActiveStory, markSaved, isDirty, activeStoryPath, activeStoryContent } = useBorgesStore()
|
||||
|
||||
const openStory = async (path: string, id: string): Promise<void> => {
|
||||
@@ -19,32 +24,51 @@ export function Dashboard(): JSX.Element {
|
||||
setActiveStory(path, id, content)
|
||||
}
|
||||
|
||||
// Stories ready to submit: no active pending, never submitted or last status rejected
|
||||
const readyToSubmit = stories.filter((story) => {
|
||||
const rows: StoryRow[] = []
|
||||
|
||||
// Out: stories with active pending submissions
|
||||
const pendingSubs = submissions
|
||||
.filter((s) => s.status === 'pending' || s.status === 'pending-revision')
|
||||
.sort((a, b) => a.submittedAt.localeCompare(b.submittedAt))
|
||||
for (const sub of pendingSubs) {
|
||||
const story = stories.find((s) => s.id === sub.storyId)
|
||||
const market = markets.find((m) => m.id === sub.marketId)
|
||||
if (!story) continue
|
||||
const days = daysSince(sub.submittedAt)
|
||||
const overdue = !!(market?.responseTimeWeeks && days > market.responseTimeWeeks * 7)
|
||||
rows.push({ kind: 'out', storyId: story.id, storyTitle: story.meta.title || story.id, storyPath: story.path, marketName: market?.name ?? sub.marketId, days, overdue })
|
||||
}
|
||||
|
||||
// Ready: no active pending
|
||||
const outIds = new Set(pendingSubs.map((s) => s.storyId))
|
||||
const readyStories = stories.filter((story) => {
|
||||
if (outIds.has(story.id)) return false
|
||||
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'
|
||||
})
|
||||
for (const story of readyStories) {
|
||||
rows.push({ kind: 'ready', storyId: story.id, storyTitle: story.meta.title || story.id, storyPath: story.path, wordCount: story.wordCount })
|
||||
}
|
||||
|
||||
// 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
|
||||
// Recent activity
|
||||
const recentSubs = submissions
|
||||
.filter((s) => s.status === 'accepted' || s.status === 'rejected')
|
||||
.sort((a, b) => b.statusUpdatedAt.localeCompare(a.statusUpdatedAt))
|
||||
.slice(0, 10)
|
||||
.slice(0, 8)
|
||||
for (const sub of recentSubs) {
|
||||
const story = stories.find((s) => s.id === sub.storyId)
|
||||
const market = markets.find((m) => m.id === sub.marketId)
|
||||
if (!story) continue
|
||||
rows.push({ kind: sub.status as 'accepted' | 'rejected', storyId: story.id, storyTitle: story.meta.title || story.id, storyPath: story.path, marketName: market?.name ?? sub.marketId })
|
||||
}
|
||||
|
||||
const totalWords = stories.reduce((s, story) => s + story.wordCount, 0)
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<PromptHero />
|
||||
{aiEnabled && <PromptHero />}
|
||||
<div className="dashboard-greeting">
|
||||
{stories.length === 0
|
||||
? 'Welcome to Borges. Create your first story to get started.'
|
||||
@@ -52,73 +76,44 @@ export function Dashboard(): JSX.Element {
|
||||
</div>
|
||||
|
||||
<div className="dashboard-grid">
|
||||
{/* Ready to submit */}
|
||||
{/* Stories — all statuses in one card */}
|
||||
<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 className="dashboard-card-title">Stories</div>
|
||||
{rows.length === 0 && <div className="dashboard-empty">No stories yet.</div>}
|
||||
{rows.map((row, i) => (
|
||||
<div key={i} className="dashboard-row" onClick={() => openStory(row.storyPath, row.storyId)}>
|
||||
<span className="dashboard-row-title">{row.storyTitle}</span>
|
||||
{row.kind === 'out' && (
|
||||
<>
|
||||
<span className="dashboard-row-meta" style={{ color: 'var(--text2)' }}>{row.marketName}</span>
|
||||
<span className={row.overdue ? 'dashboard-row-flag' : 'dashboard-row-meta'}>{row.days}d{row.overdue ? ' ⚠' : ''}</span>
|
||||
<span className="dashboard-status-badge dashboard-status-out">out</span>
|
||||
</>
|
||||
)}
|
||||
{row.kind === 'ready' && (
|
||||
<>
|
||||
<span className="dashboard-row-meta">{row.wordCount.toLocaleString()}w</span>
|
||||
<span className="dashboard-status-badge dashboard-status-ready">ready</span>
|
||||
</>
|
||||
)}
|
||||
{row.kind === 'accepted' && (
|
||||
<>
|
||||
<span className="dashboard-row-meta" style={{ color: 'var(--text2)' }}>{row.marketName}</span>
|
||||
<span className="dashboard-status-badge dashboard-status-accepted">accepted</span>
|
||||
</>
|
||||
)}
|
||||
{row.kind === 'rejected' && (
|
||||
<>
|
||||
<span className="dashboard-row-meta" style={{ color: 'var(--text3)' }}>{row.marketName}</span>
|
||||
<span className="dashboard-status-badge dashboard-status-rejected">rejected</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>
|
||||
|
||||
{/* Writing stats */}
|
||||
<WritingStats stories={stories} />
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
|
||||
@@ -108,10 +108,12 @@ export function MarkdownEditor(): JSX.Element {
|
||||
storyId: string
|
||||
startedAt: number
|
||||
wordsStart: number
|
||||
pastedWords: number // words added via paste, excluded from WPM
|
||||
activeMs: number // accumulated active typing ms
|
||||
intervalStart: number // start of current active interval
|
||||
lastKeystroke: number // last doc change timestamp
|
||||
} | null>(null)
|
||||
const nextPasteWordsRef = useRef<number>(0)
|
||||
const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
function flushSession(): void {
|
||||
@@ -120,13 +122,14 @@ export function MarkdownEditor(): JSX.Element {
|
||||
const now = Date.now()
|
||||
// Close active interval if still ongoing
|
||||
const activeMs = s.activeMs + (now - s.lastKeystroke < IDLE_MS ? now - s.intervalStart : 0)
|
||||
const wordsEnd = wordCount(useBorgesStore.getState().activeStoryContent)
|
||||
const wordsEnd = wordCount(viewRef.current?.state.doc.toString() ?? useBorgesStore.getState().activeStoryContent)
|
||||
const durationMs = now - s.startedAt
|
||||
const wpm = activeMs > 0 ? Math.round((wordsEnd - s.wordsStart) / (activeMs / 60_000)) : 0
|
||||
const typedWords = wordsEnd - s.wordsStart - s.pastedWords
|
||||
const wpm = activeMs > 0 ? Math.round(Math.max(0, typedWords) / (activeMs / 60_000)) : 0
|
||||
sessionRef.current = null
|
||||
if (flushTimerRef.current) { clearTimeout(flushTimerRef.current); flushTimerRef.current = null }
|
||||
// Only persist if something was actually written
|
||||
if (wordsEnd - s.wordsStart <= 0 && durationMs < 5000) return
|
||||
// Only persist if the user actually typed something
|
||||
if (activeMs === 0) return
|
||||
window.api.appendTelemetrySession({
|
||||
id: shortId(),
|
||||
storyId: s.storyId,
|
||||
@@ -149,6 +152,7 @@ export function MarkdownEditor(): JSX.Element {
|
||||
storyId,
|
||||
startedAt: now,
|
||||
wordsStart: wordCount(content),
|
||||
pastedWords: 0,
|
||||
activeMs: 0,
|
||||
intervalStart: now,
|
||||
lastKeystroke: now,
|
||||
@@ -161,6 +165,10 @@ export function MarkdownEditor(): JSX.Element {
|
||||
s.intervalStart = now
|
||||
}
|
||||
s.lastKeystroke = now
|
||||
if (nextPasteWordsRef.current > 0) {
|
||||
s.pastedWords += nextPasteWordsRef.current
|
||||
nextPasteWordsRef.current = 0
|
||||
}
|
||||
}
|
||||
// Reset flush-on-idle timer
|
||||
if (flushTimerRef.current) clearTimeout(flushTimerRef.current)
|
||||
@@ -201,6 +209,10 @@ export function MarkdownEditor(): JSX.Element {
|
||||
}
|
||||
}),
|
||||
EditorView.domEventHandlers({
|
||||
paste: (e) => {
|
||||
const text = e.clipboardData?.getData('text') ?? ''
|
||||
if (text) nextPasteWordsRef.current = wordCount(text)
|
||||
},
|
||||
contextmenu: (e) => {
|
||||
e.preventDefault()
|
||||
window.api.showEditorContextMenu()
|
||||
|
||||
211
src/renderer/components/MarketsView/MarketsView.tsx
Normal file
211
src/renderer/components/MarketsView/MarketsView.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
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, '')
|
||||
}
|
||||
|
||||
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 [genresRaw, setGenresRaw] = useState<string>(market.genres.join(', '))
|
||||
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="mv-form market-form">
|
||||
<div className="mv-form-title 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={genresRaw}
|
||||
onChange={(e) => setGenresRaw(e.target.value)}
|
||||
onBlur={(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>
|
||||
<label className="form-checkbox">
|
||||
<input type="checkbox" checked={form.active} onChange={(e) => update('active', e.target.checked)} />
|
||||
<span>Active (open for submissions)</span>
|
||||
</label>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
export function MarketsView(): JSX.Element {
|
||||
const { markets, setMarkets, submissions, activeStoryId, stories, selectedMarketId, setSelectedMarketId } = useBorgesStore()
|
||||
const [editingMarket, setEditingMarket] = useState<Market | null>(null)
|
||||
const [isNew, setIsNew] = useState(false)
|
||||
const [showInactive, setShowInactive] = useState(false)
|
||||
|
||||
const activeStory = stories.find((s) => s.id === activeStoryId)
|
||||
|
||||
const visible = markets.filter((m) => showInactive || m.active)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
const getMarketSubmissionCount = (marketId: string): number =>
|
||||
submissions.filter((s) => s.marketId === marketId).length
|
||||
|
||||
const getMarketActiveCount = (marketId: string): number =>
|
||||
submissions.filter((s) => s.marketId === marketId && (s.status === 'pending' || s.status === 'pending-revision')).length
|
||||
|
||||
return (
|
||||
<div className="mv-layout">
|
||||
<div className="mv-header">
|
||||
<h1 className="mv-title">Markets</h1>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<label style={{ display: 'flex', gap: '6px', alignItems: 'center', cursor: 'pointer', fontSize: '12px', color: 'var(--text3)' }}>
|
||||
<input type="checkbox" checked={showInactive} onChange={(e) => setShowInactive(e.target.checked)} />
|
||||
Show inactive
|
||||
</label>
|
||||
<button
|
||||
className="btn-primary mv-add-btn"
|
||||
onClick={() => { setIsNew(true); setEditingMarket({ ...EMPTY_MARKET, id: '' }) }}
|
||||
>
|
||||
+ Add market
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mv-body">
|
||||
{editingMarket ? (
|
||||
<div className="mv-form-wrap">
|
||||
<MarketForm
|
||||
market={editingMarket}
|
||||
isNew={isNew}
|
||||
onSave={save}
|
||||
onCancel={() => { setEditingMarket(null); setIsNew(false) }}
|
||||
onDelete={isNew ? undefined : () => del(editingMarket.id).then(() => { setEditingMarket(null); setIsNew(false) })}
|
||||
/>
|
||||
</div>
|
||||
) : visible.length === 0 ? (
|
||||
<div className="mv-empty">
|
||||
No markets yet. Add one to start tracking submissions.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mv-grid">
|
||||
{visible.map((market) => {
|
||||
const storyWc = activeStory?.wordCount ?? 0
|
||||
const fits = storyWc > 0 && storyWc <= market.wordCountMax && (!market.wordCountMin || storyWc >= market.wordCountMin)
|
||||
const activeSubs = getMarketActiveCount(market.id)
|
||||
const totalSubs = getMarketSubmissionCount(market.id)
|
||||
return (
|
||||
<div key={market.id} className={`mv-card${!market.active ? ' mv-card--inactive' : ''}`}>
|
||||
<div className="mv-card-top">
|
||||
<div className="mv-card-name">
|
||||
{market.name}
|
||||
{fits && activeStory && <span className="market-match-badge" style={{ marginLeft: '8px' }}>fits</span>}
|
||||
{selectedMarketId === market.id && <span className="mv-badge-selected">selected</span>}
|
||||
{!market.active && <span className="mv-badge-inactive">closed</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '4px', flexShrink: 0 }}>
|
||||
<button className="mv-card-edit" onClick={() => setSelectedMarketId(selectedMarketId === market.id ? null : market.id)}>
|
||||
{selectedMarketId === market.id ? 'Deselect' : 'Select'}
|
||||
</button>
|
||||
<button className="mv-card-edit" onClick={() => setEditingMarket(market)}>Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mv-card-meta">
|
||||
{market.wordCountMin ? `${market.wordCountMin}–` : 'Up to '}{market.wordCountMax} words
|
||||
{' · '}{market.simultaneousSubs ? 'sim-subs ok' : 'no sim-subs'}
|
||||
{market.responseTimeWeeks ? ` · ~${market.responseTimeWeeks}wk response` : ''}
|
||||
</div>
|
||||
{market.genres.length > 0 && (
|
||||
<div className="mv-card-genres">{market.genres.join(', ')}</div>
|
||||
)}
|
||||
{market.url && (
|
||||
<a className="mv-card-url" href={market.url} target="_blank" rel="noreferrer">{market.url}</a>
|
||||
)}
|
||||
{market.notes && <div className="mv-card-notes">{market.notes}</div>}
|
||||
{totalSubs > 0 && (
|
||||
<div className="mv-card-subs">
|
||||
{activeSubs > 0 ? `${activeSubs} active` : 'no active'} · {totalSubs} total submission{totalSubs !== 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,7 +10,6 @@ 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('')
|
||||
@@ -18,7 +17,6 @@ export function SettingsDialog({ onClose }: Props): JSX.Element {
|
||||
|
||||
useEffect(() => {
|
||||
window.api.readConfig().then((cfg) => {
|
||||
setApiKey(cfg.apiKey ?? '')
|
||||
setCollectionPath(cfg.collectionPath ?? '')
|
||||
setDefaultTarget(String(cfg.defaultWordCountTarget ?? ''))
|
||||
})
|
||||
@@ -30,7 +28,6 @@ export function SettingsDialog({ onClose }: Props): JSX.Element {
|
||||
const save = async (): Promise<void> => {
|
||||
setSaving(true)
|
||||
await window.api.writeConfig({
|
||||
apiKey: apiKey.trim() || undefined,
|
||||
collectionPath: collectionPath || undefined,
|
||||
defaultWordCountTarget: defaultTarget ? parseInt(defaultTarget) : undefined,
|
||||
theme
|
||||
@@ -66,16 +63,6 @@ export function SettingsDialog({ onClose }: Props): JSX.Element {
|
||||
{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">
|
||||
@@ -121,10 +108,10 @@ export function SettingsDialog({ onClose }: Props): JSX.Element {
|
||||
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."
|
||||
placeholder="Describe the themes, aesthetic, and goals of your collection."
|
||||
rows={8}
|
||||
/>
|
||||
<div className="settings-hint">Enable via the 'Collection' toggle in the analysis toolbar.</div>
|
||||
<div className="settings-hint">Used when the 'Collection' context toggle is enabled.</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -45,7 +45,7 @@ function StoryItem({ story, index, isActive, onClick, onContextMenu, onDragStart
|
||||
}
|
||||
|
||||
export function StorySidebar(): JSX.Element {
|
||||
const { stories, activeStoryPath, activeStoryId, activeStoryContent, isDirty, setStories, moveStory, markSaved, clearActiveStory } = useBorgesStore()
|
||||
const { stories, activeStoryPath, activeStoryId, activeStoryContent, isDirty, setStories, moveStory, markSaved, clearActiveStory, mainView, setMainView } = useBorgesStore()
|
||||
const [search, setSearch] = useState('')
|
||||
const [renaming, setRenaming] = useState<string | null>(null)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
@@ -63,6 +63,7 @@ export function StorySidebar(): JSX.Element {
|
||||
}
|
||||
const content = await window.api.readStory(story.path)
|
||||
useBorgesStore.getState().setActiveStory(story.path, story.id, content)
|
||||
setMainView('editor')
|
||||
}
|
||||
|
||||
const handleNew = async (): Promise<void> => {
|
||||
@@ -109,13 +110,21 @@ export function StorySidebar(): JSX.Element {
|
||||
<div className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<button
|
||||
className={`sidebar-btn${!activeStoryId ? ' active' : ''}`}
|
||||
onClick={clearActiveStory}
|
||||
className={`sidebar-btn${mainView === 'editor' && !activeStoryId ? ' active' : ''}`}
|
||||
onClick={() => { setMainView('editor'); clearActiveStory() }}
|
||||
title="Home"
|
||||
>⌂</button>
|
||||
<span className="sidebar-title">Stories</span>
|
||||
<button className="sidebar-btn" onClick={handleNew} title="New story">+</button>
|
||||
</div>
|
||||
<div className="sidebar-nav">
|
||||
<button
|
||||
className={`sidebar-nav-btn${mainView === 'markets' ? ' active' : ''}`}
|
||||
onClick={() => { setMainView('markets'); clearActiveStory() }}
|
||||
>
|
||||
Markets
|
||||
</button>
|
||||
</div>
|
||||
<div className="sidebar-search">
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -15,14 +15,20 @@ export function StoryTab(): JSX.Element {
|
||||
const [notesValue, setNotesValue] = useState('')
|
||||
|
||||
if (!activeStoryId) {
|
||||
return <div style={{ color: 'var(--text3)', fontSize: '13px', padding: '8px 0' }}>Open a story to see submissions.</div>
|
||||
return (
|
||||
<div className="sub-tab">
|
||||
<div className="sub-tab-body sub-empty">Open a story to see submissions.</div>
|
||||
<div className="sub-tab-footer">
|
||||
<button className="sub-btn" disabled>Submit to market…</button>
|
||||
</div>
|
||||
</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 => {
|
||||
@@ -75,21 +81,14 @@ export function StoryTab(): JSX.Element {
|
||||
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) => {
|
||||
<div className="sub-tab">
|
||||
<div className="sub-tab-body">
|
||||
{storySubmissions.length === 0 ? (
|
||||
<div className="sub-empty">No submissions yet.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="sub-list-title">History</div>
|
||||
{storySubmissions.map((sub) => {
|
||||
const market = markets.find((m) => m.id === sub.marketId)
|
||||
return (
|
||||
<div key={sub.id} className="sub-item">
|
||||
@@ -131,8 +130,15 @@ export function StoryTab(): JSX.Element {
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sub-tab-footer">
|
||||
<button className="sub-btn" onClick={() => setShowPicker(true)} disabled={markets.filter((m) => m.active).length === 0}>
|
||||
Submit to market…
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showPicker && (
|
||||
<div className="modal-overlay" onClick={() => setShowPicker(false)}>
|
||||
|
||||
@@ -1,28 +1,13 @@
|
||||
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 className="sub-panel-header">
|
||||
<span className="sub-panel-title">Submissions</span>
|
||||
</div>
|
||||
<div className="sub-panel-body">
|
||||
{submissionPanelTab === 'story' ? <StoryTab /> : <MarketsTab />}
|
||||
<StoryTab />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -64,8 +64,8 @@ interface BorgesState {
|
||||
setChatOpen: (v: boolean) => void
|
||||
focusMode: boolean
|
||||
toggleFocusMode: () => void
|
||||
submissionPanelTab: 'story' | 'markets'
|
||||
setSubmissionPanelTab: (tab: 'story' | 'markets') => void
|
||||
mainView: 'editor' | 'markets'
|
||||
setMainView: (view: 'editor' | 'markets') => void
|
||||
|
||||
// Theme & font
|
||||
theme: 'dark' | 'light'
|
||||
@@ -212,8 +212,8 @@ export const useBorgesStore = create<BorgesState>((set, get) => ({
|
||||
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 }),
|
||||
mainView: 'editor',
|
||||
setMainView: (view) => set({ mainView: view }),
|
||||
|
||||
theme: 'dark',
|
||||
toggleTheme: () => {
|
||||
|
||||
@@ -178,12 +178,17 @@ textarea { resize: vertical; }
|
||||
.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-btn.active { color: var(--accent); }
|
||||
.sidebar-search {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sidebar-search input { width: 100%; font-size: 12px; }
|
||||
.sidebar-nav { padding: 4px 8px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
|
||||
.sidebar-nav-btn { width: 100%; text-align: left; padding: 4px 8px; border-radius: 4px; font-size: 12px; color: var(--text2); }
|
||||
.sidebar-nav-btn:hover { color: var(--text); background: var(--bg3); }
|
||||
.sidebar-nav-btn.active { color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); }
|
||||
.sidebar-list { flex: 1; overflow-y: auto; padding: 4px 0; }
|
||||
|
||||
/* Story item */
|
||||
@@ -376,6 +381,11 @@ textarea { resize: vertical; }
|
||||
.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; }
|
||||
.dashboard-status-badge { font-size: 10px; font-weight: 600; letter-spacing: 0.04em; padding: 2px 6px; border-radius: 4px; flex-shrink: 0; text-transform: uppercase; }
|
||||
.dashboard-status-out { background: color-mix(in srgb, var(--accent) 15%, transparent); color: var(--accent); }
|
||||
.dashboard-status-ready { background: color-mix(in srgb, var(--text3) 15%, transparent); color: var(--text2); }
|
||||
.dashboard-status-accepted { background: color-mix(in srgb, var(--success) 15%, transparent); color: var(--success); }
|
||||
.dashboard-status-rejected { background: color-mix(in srgb, var(--text3) 10%, transparent); color: var(--text3); }
|
||||
.writing-stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px 8px; margin-bottom: 4px; }
|
||||
.writing-stat { display: flex; flex-direction: column; gap: 2px; }
|
||||
.writing-stat-value { font-size: 22px; font-weight: 300; color: var(--text); line-height: 1; }
|
||||
@@ -465,27 +475,33 @@ textarea { resize: vertical; }
|
||||
grid-area: subpanel;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--bg2);
|
||||
border-left: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
.sub-panel-tabs {
|
||||
.sub-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 38px;
|
||||
padding: 0 12px;
|
||||
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-title { font-size: 11px; font-weight: 600; color: var(--text2); text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
.sub-panel-body { flex: 1; overflow: hidden; display: flex; flex-direction: column; }
|
||||
.sub-tab { display: flex; flex-direction: column; flex: 1; }
|
||||
.sub-tab-body { flex: 1; overflow-y: auto; padding: 12px; }
|
||||
.sub-tab-footer {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 10px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.sub-panel-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
.sub-panel-body { flex: 1; overflow-y: auto; padding: 12px; }
|
||||
.sub-empty { font-size: 12px; color: var(--text3); }
|
||||
|
||||
/* Submissions list */
|
||||
.sub-list-title { font-size: 11px; font-weight: 600; color: var(--text2); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 8px; }
|
||||
@@ -506,13 +522,12 @@ textarea { resize: vertical; }
|
||||
.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;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
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; }
|
||||
@@ -547,6 +562,8 @@ textarea { resize: vertical; }
|
||||
.form-row { display: flex; gap: 8px; }
|
||||
.form-row .form-field { flex: 1; }
|
||||
.form-actions { display: flex; gap: 8px; margin-top: 12px; }
|
||||
.form-checkbox { display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 12px; color: var(--text2); margin-bottom: 10px; }
|
||||
.form-checkbox input[type="checkbox"] { width: auto; flex-shrink: 0; }
|
||||
.btn-primary {
|
||||
flex: 1; height: 30px; border-radius: 4px;
|
||||
background: var(--accent); color: var(--bg); font-weight: 600; font-size: 12px;
|
||||
@@ -560,6 +577,45 @@ textarea { resize: vertical; }
|
||||
.btn-secondary:hover { color: var(--text); }
|
||||
.btn-danger { height: 30px; padding: 0 12px; border-radius: 4px; background: var(--danger); color: #fff; font-size: 12px; }
|
||||
|
||||
/* ── Markets view (main area) ─────────────────────────────────────────────── */
|
||||
.mv-layout { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||
.mv-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 20px 32px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mv-title { font-size: 18px; font-weight: 700; color: var(--text); }
|
||||
.mv-body { flex: 1; overflow-y: auto; padding: 24px 32px; }
|
||||
.mv-empty { color: var(--text3); font-size: 13px; }
|
||||
.mv-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.mv-card {
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.mv-card--inactive { opacity: 0.5; }
|
||||
.mv-card-top { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 6px; }
|
||||
.mv-card-name { font-size: 14px; font-weight: 600; flex: 1; }
|
||||
.mv-card-edit { font-size: 11px; padding: 2px 8px; border-radius: 3px; background: var(--bg3); border: 1px solid var(--border); color: var(--text2); flex-shrink: 0; }
|
||||
.mv-card-edit:hover { color: var(--text); border-color: var(--text3); }
|
||||
.mv-card-meta { font-size: 12px; color: var(--text3); margin-bottom: 4px; }
|
||||
.mv-card-genres { font-size: 11px; color: var(--text3); margin-bottom: 4px; }
|
||||
.mv-card-url { font-size: 11px; color: var(--accent); display: block; margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mv-card-notes { font-size: 12px; color: var(--text2); margin-top: 6px; border-top: 1px solid var(--border); padding-top: 6px; }
|
||||
.mv-card-subs { font-size: 11px; color: var(--text3); margin-top: 6px; }
|
||||
.mv-badge-inactive { font-size: 10px; font-weight: 600; color: var(--text3); padding: 1px 5px; background: var(--bg3); border-radius: 3px; margin-left: 6px; }
|
||||
.mv-badge-selected { font-size: 10px; font-weight: 600; color: var(--accent); padding: 1px 5px; background: color-mix(in srgb, var(--accent) 15%, transparent); border-radius: 3px; margin-left: 6px; }
|
||||
.mv-form-wrap { max-width: 480px; }
|
||||
.mv-add-btn { flex: none; padding: 0 14px; }
|
||||
.mv-form { padding: 0; }
|
||||
.mv-form-title { font-size: 16px; font-weight: 600; margin-bottom: 16px; }
|
||||
|
||||
/* Market picker modal */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0;
|
||||
|
||||
1
src/renderer/types/global.d.ts
vendored
1
src/renderer/types/global.d.ts
vendored
@@ -55,6 +55,7 @@ declare global {
|
||||
loadRevision(path: string, id: string): Promise<string>
|
||||
appendTelemetrySession(session: TelemetrySession): Promise<void>
|
||||
readTelemetry(): Promise<TelemetrySession[]>
|
||||
isAIEnabled(): Promise<boolean>
|
||||
readConfig(): Promise<GlobalConfig>
|
||||
writeConfig(updates: Partial<GlobalConfig>): Promise<void>
|
||||
pickFolder(): Promise<string | null>
|
||||
|
||||
Reference in New Issue
Block a user