✨ submission tracking
This commit is contained in:
@@ -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<Submission[]> {
|
||||
try {
|
||||
return JSON.parse(await readFile(submissionsFile(), 'utf-8'))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeSubmissions(data: Submission[]): Promise<void> {
|
||||
await mkdir(hohoffDir(), { recursive: true })
|
||||
await writeFile(submissionsFile(), JSON.stringify(data, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
@@ -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<Submission[]> => {
|
||||
return await readSubmissions()
|
||||
})
|
||||
|
||||
ipcMain.handle('submissions:write', async (_event, data: Submission[]): Promise<void> => {
|
||||
await writeSubmissions(data)
|
||||
})
|
||||
|
||||
ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => {
|
||||
try {
|
||||
const storyBibleContent = (await readStoryBibleFile()) ?? undefined
|
||||
|
||||
@@ -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<TelemetryData> =>
|
||||
ipcRenderer.invoke('telemetry:read'),
|
||||
|
||||
readSubmissions: (): Promise<Submission[]> =>
|
||||
ipcRenderer.invoke('submissions:read'),
|
||||
|
||||
writeSubmissions: (data: Submission[]): Promise<void> =>
|
||||
ipcRenderer.invoke('submissions:write', data),
|
||||
})
|
||||
|
||||
@@ -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<void> => {
|
||||
@@ -215,12 +216,21 @@ export default function App(): JSX.Element {
|
||||
>
|
||||
<span className="sidebar-nav-icon">📖</span>
|
||||
</button>
|
||||
<button
|
||||
className={`sidebar-nav-btn${showSubmissions ? ' active' : ''}`}
|
||||
onClick={goSubmissions}
|
||||
title="Submissions"
|
||||
>
|
||||
<span className="sidebar-nav-icon">✉</span>
|
||||
</button>
|
||||
</div>
|
||||
<FileTree />
|
||||
</aside>
|
||||
<main className="editor-area" style={{ position: 'relative' }}>
|
||||
{showHome ? (
|
||||
<HomeScreen />
|
||||
) : showSubmissions ? (
|
||||
<SubmissionsScreen />
|
||||
) : (
|
||||
<>
|
||||
<AnalysisToolbar />
|
||||
|
||||
347
src/renderer/components/Submissions/SubmissionsScreen.css
Normal file
347
src/renderer/components/Submissions/SubmissionsScreen.css
Normal file
@@ -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;
|
||||
}
|
||||
253
src/renderer/components/Submissions/SubmissionsScreen.tsx
Normal file
253
src/renderer/components/Submissions/SubmissionsScreen.tsx
Normal file
@@ -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<SubmissionStatus, string> = {
|
||||
drafting: 'Drafting',
|
||||
submitted: 'Submitted',
|
||||
awaiting: 'Awaiting',
|
||||
shortlisted: 'Shortlisted',
|
||||
rejected: 'Rejected',
|
||||
accepted: 'Accepted',
|
||||
withdrawn: 'Withdrawn',
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<SubmissionType, string> = {
|
||||
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 <span className={`deadline-badge ${cls}`}>{label}</span>
|
||||
}
|
||||
|
||||
export function SubmissionsScreen(): JSX.Element {
|
||||
const [submissions, setSubmissions] = useState<Submission[]>([])
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [draft, setDraft] = useState<Submission | null>(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<K extends keyof Submission>(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 (
|
||||
<div className="subs-screen">
|
||||
<div className="subs-list">
|
||||
<div className="subs-list-header">
|
||||
<span className="subs-list-title">Submissions</span>
|
||||
<button className="subs-add-btn" onClick={addNew} title="New submission">+</button>
|
||||
</div>
|
||||
<div className="subs-list-items">
|
||||
{sorted.length === 0 && (
|
||||
<div className="subs-empty">No submissions yet</div>
|
||||
)}
|
||||
{sorted.map(sub => (
|
||||
<button
|
||||
key={sub.id}
|
||||
className={`subs-list-item${selectedId === sub.id ? ' selected' : ''}`}
|
||||
onClick={() => selectSubmission(sub)}
|
||||
>
|
||||
<div className="subs-item-top">
|
||||
<span className="subs-item-recipient">{sub.recipient || 'Untitled'}</span>
|
||||
<span className={`subs-status-dot status-${sub.status}`} title={STATUS_LABELS[sub.status]} />
|
||||
</div>
|
||||
<div className="subs-item-meta">
|
||||
<span className="subs-item-type">{TYPE_LABELS[sub.type]}</span>
|
||||
{sub.deadline && <DeadlineBadge date={sub.deadline} />}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="subs-detail">
|
||||
{!draft ? (
|
||||
<div className="subs-no-selection">
|
||||
<button className="subs-new-btn" onClick={addNew}>New submission</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="subs-detail-header">
|
||||
<div className="subs-detail-fields">
|
||||
<div className="subs-field-row">
|
||||
<input
|
||||
className="subs-recipient-input"
|
||||
placeholder="Recipient name"
|
||||
value={draft.recipient}
|
||||
onChange={e => updateDraft('recipient', e.target.value)}
|
||||
onBlur={saveDraft}
|
||||
/>
|
||||
<select
|
||||
className="subs-select"
|
||||
value={draft.type}
|
||||
onChange={e => { updateDraft('type', e.target.value as SubmissionType); saveDraft() }}
|
||||
>
|
||||
{(Object.keys(TYPE_LABELS) as SubmissionType[]).map(t => (
|
||||
<option key={t} value={t}>{TYPE_LABELS[t]}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="subs-select"
|
||||
value={draft.status}
|
||||
onChange={e => { updateDraft('status', e.target.value as SubmissionStatus); saveDraft() }}
|
||||
>
|
||||
{(Object.keys(STATUS_LABELS) as SubmissionStatus[]).map(s => (
|
||||
<option key={s} value={s}>{STATUS_LABELS[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="subs-field-row subs-dates-row">
|
||||
<label className="subs-date-label">
|
||||
<span>Submitted</span>
|
||||
<input
|
||||
type="date"
|
||||
className="subs-date-input"
|
||||
value={draft.dateSubmitted ?? ''}
|
||||
onChange={e => updateDraft('dateSubmitted', e.target.value || null)}
|
||||
onBlur={saveDraft}
|
||||
/>
|
||||
</label>
|
||||
<label className="subs-date-label">
|
||||
<span>Deadline</span>
|
||||
<input
|
||||
type="date"
|
||||
className="subs-date-input"
|
||||
value={draft.deadline ?? ''}
|
||||
onChange={e => updateDraft('deadline', e.target.value || null)}
|
||||
onBlur={saveDraft}
|
||||
/>
|
||||
</label>
|
||||
<button className="subs-delete-btn" onClick={deleteSelected} title="Delete submission">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="subs-detail-body">
|
||||
<div className="subs-section">
|
||||
<div className="subs-section-label">Query letter</div>
|
||||
<textarea
|
||||
className="subs-query-letter"
|
||||
placeholder="Write your query letter for this submission…"
|
||||
value={draft.queryLetter}
|
||||
onChange={e => updateDraft('queryLetter', e.target.value)}
|
||||
onBlur={saveDraft}
|
||||
/>
|
||||
</div>
|
||||
<div className="subs-section subs-notes-section">
|
||||
<div className="subs-section-label">Notes</div>
|
||||
<textarea
|
||||
className="subs-notes"
|
||||
placeholder="Contacts, requirements, response…"
|
||||
value={draft.notes}
|
||||
onChange={e => updateDraft('notes', e.target.value)}
|
||||
onBlur={saveDraft}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -111,6 +111,11 @@ interface EditorState {
|
||||
goHome: () => void
|
||||
leaveHome: () => void
|
||||
|
||||
// Submissions screen
|
||||
showSubmissions: boolean
|
||||
goSubmissions: () => void
|
||||
leaveSubmissions: () => void
|
||||
|
||||
// Session persistence
|
||||
loadSession: () => Promise<void>
|
||||
}
|
||||
@@ -190,6 +195,7 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
||||
activeFileContent: content,
|
||||
isDirty: false,
|
||||
showHome: false,
|
||||
showSubmissions: false,
|
||||
chatHistory: existing,
|
||||
annotations: savedAnnotationState?.annotations.filter(a => !a.applied && !a.dismissed) ?? [],
|
||||
analysisMode: savedAnnotationState?.mode ?? 'none'
|
||||
@@ -679,9 +685,13 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
||||
clearPendingScrollToLine: () => set({ pendingScrollToLine: null }),
|
||||
|
||||
showHome: false,
|
||||
goHome: () => set({ showHome: true }),
|
||||
goHome: () => set({ showHome: true, showSubmissions: false }),
|
||||
leaveHome: () => set({ showHome: false }),
|
||||
|
||||
showSubmissions: false,
|
||||
goSubmissions: () => set({ showSubmissions: true, showHome: false }),
|
||||
leaveSubmissions: () => set({ showSubmissions: false }),
|
||||
|
||||
loadSession: async () => {
|
||||
const api = (window as unknown as { api?: { readSession: () => Promise<Record<string, unknown>>; readFile: (p: string) => Promise<string> } }).api
|
||||
if (!api) return
|
||||
|
||||
@@ -106,3 +106,19 @@ export interface SearchFileResult {
|
||||
relativePath: string
|
||||
matches: SearchMatch[]
|
||||
}
|
||||
|
||||
export type SubmissionType = 'agent' | 'publisher' | 'competition' | 'other'
|
||||
export type SubmissionStatus = 'drafting' | 'submitted' | 'awaiting' | 'shortlisted' | 'rejected' | 'accepted' | 'withdrawn'
|
||||
|
||||
export interface Submission {
|
||||
id: string
|
||||
recipient: string
|
||||
type: SubmissionType
|
||||
dateSubmitted: string | null // ISO date YYYY-MM-DD
|
||||
deadline: string | null // ISO date YYYY-MM-DD
|
||||
status: SubmissionStatus
|
||||
queryLetter: string
|
||||
notes: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
10
src/renderer/types/global.d.ts
vendored
10
src/renderer/types/global.d.ts
vendored
@@ -1,4 +1,4 @@
|
||||
import type { FileNode, AIPayload, RevisionMeta, Attachment, SearchFileResult, GlobalConfig } from './editor'
|
||||
import type { FileNode, AIPayload, RevisionMeta, Attachment, SearchFileResult, GlobalConfig, TelemetryData, Submission } from './editor'
|
||||
import type { ExportOptions } from '../components/Export/ExportDialog'
|
||||
|
||||
interface SearchOptions {
|
||||
@@ -40,6 +40,14 @@ declare global {
|
||||
pickProjectFolder: () => Promise<string | null>
|
||||
exportPDF: (content: string, fileName: string) => Promise<void>
|
||||
exportProjectPDF: (opts: ExportOptions) => Promise<void>
|
||||
readAllDraftFiles: () => Promise<{ relativePath: string; content: string }[]>
|
||||
trackWordSnapshot: (filePath: string, wordCount: number) => Promise<void>
|
||||
flushTelemetry: () => Promise<void>
|
||||
readTelemetry: () => Promise<TelemetryData>
|
||||
readSubmissions: () => Promise<Submission[]>
|
||||
writeSubmissions: (data: Submission[]) => Promise<void>
|
||||
readSession: () => Promise<Record<string, unknown>>
|
||||
writeSession: (data: Record<string, unknown>) => Promise<void>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user