💄 spruce up markets and submissions

This commit is contained in:
2026-06-15 22:32:36 +10:00
parent 22d664a450
commit be257ac71e
7 changed files with 319 additions and 59 deletions

View File

@@ -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,6 +22,7 @@ export default function App(): JSX.Element {
setMarkets,
setSubmissions,
revisionPanelOpen, toggleRevisionPanel,
mainView,
initPrefs, loadSession
} = useBorgesStore()
@@ -186,7 +188,9 @@ export default function App(): JSX.Element {
{/* Editor area */}
<main className="editor-area">
{activeStoryId ? (
{mainView === 'markets' ? (
<MarketsView />
) : activeStoryId ? (
<>
{aiEnabled && <AnalysisToolbar />}
<MarkdownEditor />

View File

@@ -0,0 +1,205 @@
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 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={form.genres.join(', ')} onChange={(e) => update('genres', e.target.value.split(',').map((g) => g.trim()).filter(Boolean))} placeholder="flash, micro, speculative" />
</div>
<div className="form-field">
<label className="form-label">Notes / editor preferences</label>
<textarea value={form.notes ?? ''} onChange={(e) => update('notes', e.target.value)} rows={3} placeholder="Editor preferences, submission history, tone…" />
</div>
<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>
)
}

View File

@@ -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"

View File

@@ -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)}>

View File

@@ -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>
)

View File

@@ -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: () => {

View File

@@ -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 */
@@ -470,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; }
@@ -511,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; }
@@ -552,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;
@@ -565,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;