diff --git a/src/main/fileSystem.ts b/src/main/fileSystem.ts index 3761606..bfe22cc 100644 --- a/src/main/fileSystem.ts +++ b/src/main/fileSystem.ts @@ -1,6 +1,6 @@ import { readdir, readFile, writeFile, mkdir, unlink, rename as fsRename, rm } from 'fs/promises' import { join, dirname, basename } from 'path' -import type { FileNode, RevisionMeta, SearchMatch, SearchFileResult } from '../renderer/types/editor' +import type { FileNode, RevisionMeta, SearchMatch, SearchFileResult, Submission } from '../renderer/types/editor' import { getDraftRoot } from './globalConfig' const hohoffDir = (): string => join(getDraftRoot(), '.hohoff') @@ -9,6 +9,7 @@ const sessionFile = (): string => join(hohoffDir(), 'session.json') const telemetryFile = (): string => join(hohoffDir(), 'telemetry.json') const revisionsDir = (): string => join(hohoffDir(), 'revisions') export const getStoryBiblePath = (): string => join(hohoffDir(), 'Story Bible.md') +const submissionsFile = (): string => join(hohoffDir(), 'submissions.json') const STORY_BIBLE_TEMPLATE = `# Story Bible @@ -556,3 +557,16 @@ export async function replaceInFiles( } return modified } + +export async function readSubmissions(): Promise { + try { + return JSON.parse(await readFile(submissionsFile(), 'utf-8')) + } catch { + return [] + } +} + +export async function writeSubmissions(data: Submission[]): Promise { + await mkdir(hohoffDir(), { recursive: true }) + await writeFile(submissionsFile(), JSON.stringify(data, null, 2), 'utf-8') +} diff --git a/src/main/ipcHandlers.ts b/src/main/ipcHandlers.ts index 18479a2..099ae40 100644 --- a/src/main/ipcHandlers.ts +++ b/src/main/ipcHandlers.ts @@ -2,11 +2,11 @@ import { ipcMain, dialog, BrowserWindow } from 'electron' import { readFileSync, writeFileSync, unlinkSync } from 'fs' import { extname, basename, join } from 'path' import { tmpdir } from 'os' -import { listDraftFiles, readMarkdownFile, writeMarkdownFile, getProjectWordCount, saveOrderFile, readSession, writeSession, saveRevision, listRevisions, loadRevision, deleteRevision, renameFileOrDir, deleteFileOrDir, createMarkdownFile, createSubdirectory, moveFileOrDir, readStoryBibleFile, openStoryBibleFile, writeStoryBibleFile, searchAcrossFiles, replaceInFiles, readAllDraftFiles, readProjectConfig, writeProjectConfig, PROJECT_CONFIG_FIELDS, readTelemetry } from './fileSystem' +import { listDraftFiles, readMarkdownFile, writeMarkdownFile, getProjectWordCount, saveOrderFile, readSession, writeSession, saveRevision, listRevisions, loadRevision, deleteRevision, renameFileOrDir, deleteFileOrDir, createMarkdownFile, createSubdirectory, moveFileOrDir, readStoryBibleFile, openStoryBibleFile, writeStoryBibleFile, searchAcrossFiles, replaceInFiles, readAllDraftFiles, readProjectConfig, writeProjectConfig, PROJECT_CONFIG_FIELDS, readTelemetry, readSubmissions, writeSubmissions } from './fileSystem' import type { SearchOptions, ProjectConfig } from './fileSystem' import { streamMessage, resetClient } from './aiService' import { onWordSnapshot, flushTelemetry } from './telemetry' -import type { AIPayload, Attachment } from '../renderer/types/editor' +import type { AIPayload, Attachment, Submission } from '../renderer/types/editor' import { readGlobalConfig, writeGlobalConfig, getProjectTitle, addRecentProject, updateRecentProjectTitle } from './globalConfig' import type { GlobalConfig } from './globalConfig' @@ -158,6 +158,14 @@ export function registerIpcHandlers(): void { return await readTelemetry() }) + ipcMain.handle('submissions:read', async (): Promise => { + return await readSubmissions() + }) + + ipcMain.handle('submissions:write', async (_event, data: Submission[]): Promise => { + await writeSubmissions(data) + }) + ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => { try { const storyBibleContent = (await readStoryBibleFile()) ?? undefined diff --git a/src/preload/index.ts b/src/preload/index.ts index 441e1dd..84c3088 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,5 @@ import { contextBridge, ipcRenderer } from 'electron' -import type { FileNode, AIPayload, RevisionMeta, Attachment, SearchFileResult, GlobalConfig, TelemetryData } from '../renderer/types/editor' +import type { FileNode, AIPayload, RevisionMeta, Attachment, SearchFileResult, GlobalConfig, TelemetryData, Submission } from '../renderer/types/editor' interface SearchOptions { caseSensitive: boolean @@ -136,4 +136,10 @@ contextBridge.exposeInMainWorld('api', { readTelemetry: (): Promise => ipcRenderer.invoke('telemetry:read'), + + readSubmissions: (): Promise => + ipcRenderer.invoke('submissions:read'), + + writeSubmissions: (data: Submission[]): Promise => + ipcRenderer.invoke('submissions:write', data), }) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index cd6578e..c816057 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -11,6 +11,7 @@ import { RevisionPanel } from './components/Revisions/RevisionPanel' import { ProjectSearchModal } from './components/Search/ProjectSearchModal' import { SettingsDialog } from './components/Settings/SettingsDialog' import { HomeScreen } from './components/Home/HomeScreen' +import { SubmissionsScreen } from './components/Submissions/SubmissionsScreen' import { useEditorStore } from './store/editorStore' import './styles/app.css' @@ -19,7 +20,7 @@ export default function App(): JSX.Element { setFileTree, activeFilePath, isDirty, markSaved, activeFileContent, theme, toggleTheme, loadSession, revisionPanelOpen, toggleRevisionPanel, fontSize, setFontSize, openProjectSearch, clearActiveFile, initPrefs, focusMode, toggleFocusMode, - showHome, goHome, setActiveFile + showHome, goHome, setActiveFile, showSubmissions, goSubmissions } = useEditorStore() const handleOpenStoryBible = async (): Promise => { @@ -215,12 +216,21 @@ export default function App(): JSX.Element { > πŸ“– +
{showHome ? ( + ) : showSubmissions ? ( + ) : ( <> diff --git a/src/renderer/components/Submissions/SubmissionsScreen.css b/src/renderer/components/Submissions/SubmissionsScreen.css new file mode 100644 index 0000000..c42adb6 --- /dev/null +++ b/src/renderer/components/Submissions/SubmissionsScreen.css @@ -0,0 +1,347 @@ +.subs-screen { + display: flex; + height: 100%; + overflow: hidden; + background: var(--page-bg); +} + +/* ── List panel ─────────────────────────────────────────────────── */ +.subs-list { + width: 240px; + min-width: 200px; + display: flex; + flex-direction: column; + border-right: 1px solid var(--border); + overflow: hidden; +} + +.subs-list-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 16px 12px; + border-bottom: 1px solid var(--border); +} + +.subs-list-title { + font-family: var(--font-sans); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-muted); +} + +.subs-add-btn { + background: none; + border: 1px solid var(--border); + color: var(--text-muted); + font-size: 16px; + line-height: 1; + width: 22px; + height: 22px; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.12s, border-color 0.12s; +} + +.subs-add-btn:hover { + color: var(--text-primary); + border-color: var(--text-muted); +} + +.subs-list-items { + flex: 1; + overflow-y: auto; + padding: 6px 0; +} + +.subs-empty { + font-family: var(--font-sans); + font-size: 12px; + color: var(--text-muted); + padding: 20px 16px; +} + +.subs-list-item { + display: flex; + flex-direction: column; + gap: 4px; + width: 100%; + text-align: left; + background: none; + border: none; + padding: 10px 16px; + cursor: pointer; + border-radius: 0; + transition: background 0.1s; +} + +.subs-list-item:hover { + background: var(--active-bg); +} + +.subs-list-item.selected { + background: var(--active-bg); +} + +.subs-item-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.subs-item-recipient { + font-family: var(--font-sans); + font-size: 13px; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; +} + +.subs-item-meta { + display: flex; + align-items: center; + gap: 6px; +} + +.subs-item-type { + font-family: var(--font-sans); + font-size: 10px; + color: var(--text-muted); + letter-spacing: 0.04em; +} + +/* ── Status dots ────────────────────────────────────────────────── */ +.subs-status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} + +.status-drafting { background: var(--text-muted); opacity: 0.4; } +.status-submitted { background: #6b9bd2; } +.status-awaiting { background: #c9924a; } +.status-shortlisted { background: #9b72cf; } +.status-rejected { background: var(--text-muted); opacity: 0.3; } +.status-accepted { background: #5a9e6f; } +.status-withdrawn { background: var(--text-muted); opacity: 0.25; } + +/* ── Deadline badges ────────────────────────────────────────────── */ +.deadline-badge { + font-family: var(--font-sans); + font-size: 10px; + padding: 1px 5px; + border-radius: 3px; + white-space: nowrap; +} + +.deadline-ok { color: var(--text-muted); } +.deadline-soon { color: #c9924a; } +.deadline-past { color: var(--text-muted); opacity: 0.5; } + +/* ── Detail panel ───────────────────────────────────────────────── */ +.subs-detail { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.subs-no-selection { + flex: 1; + display: flex; + align-items: center; + justify-content: center; +} + +.subs-new-btn { + background: none; + border: 1px solid var(--border); + color: var(--text-muted); + font-family: var(--font-sans); + font-size: 12px; + letter-spacing: 0.06em; + padding: 8px 18px; + border-radius: 5px; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; +} + +.subs-new-btn:hover { + color: var(--text-primary); + border-color: var(--text-muted); +} + +.subs-detail-header { + padding: 24px 32px 16px; + border-bottom: 1px solid var(--border); +} + +.subs-detail-fields { + display: flex; + flex-direction: column; + gap: 10px; +} + +.subs-field-row { + display: flex; + align-items: center; + gap: 10px; +} + +.subs-recipient-input { + flex: 1; + background: none; + border: none; + border-bottom: 1px solid var(--border); + font-family: var(--font-serif); + font-size: 18px; + color: var(--text-primary); + padding: 2px 0; + outline: none; + transition: border-color 0.15s; +} + +.subs-recipient-input:focus { + border-bottom-color: var(--text-muted); +} + +.subs-recipient-input::placeholder { + color: var(--text-muted); + opacity: 0.5; +} + +.subs-select { + background: none; + border: 1px solid var(--border); + color: var(--text-muted); + font-family: var(--font-sans); + font-size: 11px; + padding: 4px 8px; + border-radius: 4px; + cursor: pointer; + outline: none; +} + +.subs-select:focus { + border-color: var(--text-muted); +} + +.subs-dates-row { + align-items: center; +} + +.subs-date-label { + display: flex; + align-items: center; + gap: 6px; + font-family: var(--font-sans); + font-size: 11px; + color: var(--text-muted); +} + +.subs-date-input { + background: none; + border: 1px solid var(--border); + color: var(--text-muted); + font-family: var(--font-sans); + font-size: 11px; + padding: 3px 6px; + border-radius: 4px; + outline: none; +} + +.subs-date-input:focus { + border-color: var(--text-muted); +} + +.subs-delete-btn { + margin-left: auto; + background: none; + border: none; + color: var(--text-muted); + font-family: var(--font-sans); + font-size: 11px; + opacity: 0.5; + cursor: pointer; + padding: 4px 0; + transition: opacity 0.15s; +} + +.subs-delete-btn:hover { + opacity: 1; +} + +/* ── Body ───────────────────────────────────────────────────────── */ +.subs-detail-body { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 24px 32px; + gap: 20px; +} + +.subs-section { + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; + min-height: 0; +} + +.subs-notes-section { + flex: 0 0 auto; + min-height: 80px; + max-height: 140px; +} + +.subs-section-label { + font-family: var(--font-sans); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-muted); +} + +.subs-query-letter, +.subs-notes { + flex: 1; + background: none; + border: 1px solid var(--border); + border-radius: 5px; + color: var(--text-primary); + font-family: var(--font-serif); + font-size: 14px; + line-height: 1.7; + padding: 14px 16px; + resize: none; + outline: none; + transition: border-color 0.15s; +} + +.subs-query-letter:focus, +.subs-notes:focus { + border-color: var(--text-muted); +} + +.subs-query-letter::placeholder, +.subs-notes::placeholder { + color: var(--text-muted); + opacity: 0.4; +} + +.subs-notes { + font-family: var(--font-sans); + font-size: 13px; + line-height: 1.6; +} diff --git a/src/renderer/components/Submissions/SubmissionsScreen.tsx b/src/renderer/components/Submissions/SubmissionsScreen.tsx new file mode 100644 index 0000000..0c8fadb --- /dev/null +++ b/src/renderer/components/Submissions/SubmissionsScreen.tsx @@ -0,0 +1,253 @@ +import { useEffect, useState, useCallback } from 'react' +import type { Submission, SubmissionStatus, SubmissionType } from '../../types/editor' +import './SubmissionsScreen.css' + +const STATUS_LABELS: Record = { + drafting: 'Drafting', + submitted: 'Submitted', + awaiting: 'Awaiting', + shortlisted: 'Shortlisted', + rejected: 'Rejected', + accepted: 'Accepted', + withdrawn: 'Withdrawn', +} + +const TYPE_LABELS: Record = { + agent: 'Agent', + publisher: 'Publisher', + competition: 'Competition', + other: 'Other', +} + +function newSubmission(): Submission { + return { + id: crypto.randomUUID(), + recipient: '', + type: 'publisher', + dateSubmitted: null, + deadline: null, + status: 'drafting', + queryLetter: '', + notes: '', + createdAt: Date.now(), + updatedAt: Date.now(), + } +} + +function daysUntil(isoDate: string): number { + const deadline = new Date(isoDate + 'T00:00:00').getTime() + const now = new Date().setHours(0, 0, 0, 0) + return Math.ceil((deadline - now) / 86400000) +} + +function DeadlineBadge({ date }: { date: string }): JSX.Element { + const days = daysUntil(date) + const label = days === 0 ? 'today' : days === 1 ? '1 day' : days < 0 ? `${Math.abs(days)}d ago` : `${days}d` + const cls = days < 0 ? 'deadline-past' : days <= 7 ? 'deadline-soon' : 'deadline-ok' + return {label} +} + +export function SubmissionsScreen(): JSX.Element { + const [submissions, setSubmissions] = useState([]) + const [selectedId, setSelectedId] = useState(null) + const [draft, setDraft] = useState(null) + const [dirty, setDirty] = useState(false) + + useEffect(() => { + window.api.readSubmissions().then(data => { + setSubmissions(data) + if (data.length > 0) { + setSelectedId(data[0].id) + setDraft(data[0]) + } + }).catch(console.error) + }, []) + + const save = useCallback(async (updated: Submission[]) => { + setSubmissions(updated) + await window.api.writeSubmissions(updated) + }, []) + + function selectSubmission(sub: Submission): void { + if (dirty && draft) { + const updated = submissions.map(s => s.id === draft.id ? draft : s) + save(updated).catch(console.error) + setDirty(false) + } + setSelectedId(sub.id) + setDraft({ ...sub }) + } + + function addNew(): void { + if (dirty && draft) { + const updated = submissions.map(s => s.id === draft.id ? draft : s) + save(updated).catch(console.error) + setDirty(false) + } + const sub = newSubmission() + const updated = [sub, ...submissions] + setSubmissions(updated) + setSelectedId(sub.id) + setDraft(sub) + window.api.writeSubmissions(updated).catch(console.error) + } + + function deleteSelected(): void { + if (!selectedId) return + const updated = submissions.filter(s => s.id !== selectedId) + save(updated).catch(console.error) + setDirty(false) + if (updated.length > 0) { + setSelectedId(updated[0].id) + setDraft({ ...updated[0] }) + } else { + setSelectedId(null) + setDraft(null) + } + } + + function updateDraft(key: K, value: Submission[K]): void { + if (!draft) return + setDraft(prev => prev ? { ...prev, [key]: value, updatedAt: Date.now() } : prev) + setDirty(true) + } + + function saveDraft(): void { + if (!draft) return + const updated = submissions.map(s => s.id === draft.id ? draft : s) + save(updated).catch(console.error) + setDirty(false) + } + + const sorted = [...submissions].sort((a, b) => { + // Sort: active statuses first, then by deadline, then by updatedAt + const activeStatuses: SubmissionStatus[] = ['drafting', 'submitted', 'awaiting', 'shortlisted'] + const aActive = activeStatuses.includes(a.status) + const bActive = activeStatuses.includes(b.status) + if (aActive !== bActive) return aActive ? -1 : 1 + if (a.deadline && b.deadline) return a.deadline.localeCompare(b.deadline) + if (a.deadline) return -1 + if (b.deadline) return 1 + return b.updatedAt - a.updatedAt + }) + + return ( +
+
+
+ Submissions + +
+
+ {sorted.length === 0 && ( +
No submissions yet
+ )} + {sorted.map(sub => ( + + ))} +
+
+ +
+ {!draft ? ( +
+ +
+ ) : ( + <> +
+
+
+ updateDraft('recipient', e.target.value)} + onBlur={saveDraft} + /> + + +
+
+ + + +
+
+
+ +
+
+
Query letter
+