add Ko-fi bean
This commit is contained in:
190
frontend/src/components/journal/JournalEditor.tsx
Normal file
190
frontend/src/components/journal/JournalEditor.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import type { JournalEntry } from '../../types/journal'
|
||||
import type { BossResult, BossBattle } from '../../types/game'
|
||||
|
||||
interface JournalEditorProps {
|
||||
entry?: JournalEntry | null
|
||||
bossResults?: BossResult[]
|
||||
bosses?: BossBattle[]
|
||||
onSave: (data: { title: string; body: string; bossResultId: number | null }) => void
|
||||
onDelete?: () => void
|
||||
onCancel: () => void
|
||||
isSaving?: boolean
|
||||
isDeleting?: boolean
|
||||
}
|
||||
|
||||
type Tab = 'write' | 'preview'
|
||||
|
||||
export function JournalEditor({
|
||||
entry,
|
||||
bossResults = [],
|
||||
bosses = [],
|
||||
onSave,
|
||||
onDelete,
|
||||
onCancel,
|
||||
isSaving = false,
|
||||
isDeleting = false,
|
||||
}: JournalEditorProps) {
|
||||
const [title, setTitle] = useState(entry?.title ?? '')
|
||||
const [body, setBody] = useState(entry?.body ?? '')
|
||||
const [bossResultId, setBossResultId] = useState<number | null>(entry?.bossResultId ?? null)
|
||||
const [activeTab, setActiveTab] = useState<Tab>('write')
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (entry) {
|
||||
setTitle(entry.title)
|
||||
setBody(entry.body)
|
||||
setBossResultId(entry.bossResultId)
|
||||
}
|
||||
}, [entry])
|
||||
|
||||
const bossMap = new Map(bosses.map((b) => [b.id, b]))
|
||||
|
||||
const getBossName = (result: BossResult): string => {
|
||||
const boss = bossMap.get(result.bossBattleId)
|
||||
return boss?.name ?? `Boss #${result.bossBattleId}`
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!title.trim() || !body.trim()) return
|
||||
onSave({ title: title.trim(), body: body.trim(), bossResultId })
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (showDeleteConfirm) {
|
||||
onDelete?.()
|
||||
} else {
|
||||
setShowDeleteConfirm(true)
|
||||
}
|
||||
}
|
||||
|
||||
const canSave = title.trim().length > 0 && body.trim().length > 0
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="journal-title" className="block text-sm font-medium text-text-secondary mb-1">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
id="journal-title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Entry title..."
|
||||
className="w-full px-3 py-2 bg-surface-1 border border-border rounded-lg text-text-primary placeholder:text-text-tertiary focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="journal-boss" className="block text-sm font-medium text-text-secondary mb-1">
|
||||
Linked Boss Battle (optional)
|
||||
</label>
|
||||
<select
|
||||
id="journal-boss"
|
||||
value={bossResultId ?? ''}
|
||||
onChange={(e) => setBossResultId(e.target.value ? Number(e.target.value) : null)}
|
||||
className="w-full px-3 py-2 bg-surface-1 border border-border rounded-lg text-text-primary focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
disabled={isSaving}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{bossResults.map((result) => (
|
||||
<option key={result.id} value={result.id}>
|
||||
{getBossName(result)} ({result.result === 'won' ? 'Victory' : 'Defeat'})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('write')}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||
activeTab === 'write'
|
||||
? 'bg-surface-2 text-text-primary'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
Write
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('preview')}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||
activeTab === 'preview'
|
||||
? 'bg-surface-2 text-text-primary'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'write' ? (
|
||||
<textarea
|
||||
id="journal-body"
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder="Write your journal entry in markdown..."
|
||||
rows={12}
|
||||
className="w-full px-3 py-2 bg-surface-1 border border-border rounded-lg text-text-primary placeholder:text-text-tertiary focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm resize-y"
|
||||
disabled={isSaving}
|
||||
/>
|
||||
) : (
|
||||
<div className="min-h-[300px] px-4 py-3 bg-surface-1 border border-border rounded-lg overflow-auto">
|
||||
{body.trim() ? (
|
||||
<div className="prose prose-invert light:prose max-w-none">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{body}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-text-tertiary italic">Nothing to preview</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div>
|
||||
{entry && onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className={`px-4 py-2 rounded-lg transition-colors ${
|
||||
showDeleteConfirm
|
||||
? 'bg-red-600 text-white hover:bg-red-700'
|
||||
: 'text-red-400 hover:text-red-300 hover:bg-red-900/20'
|
||||
}`}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : showDeleteConfirm ? 'Click again to confirm' : 'Delete'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isSaving}
|
||||
className="px-4 py-2 text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSave || isSaving}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isSaving ? 'Saving...' : entry ? 'Save Changes' : 'Create Entry'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
83
frontend/src/components/journal/JournalEntryView.tsx
Normal file
83
frontend/src/components/journal/JournalEntryView.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import type { JournalEntry } from '../../types/journal'
|
||||
import type { BossResult, BossBattle } from '../../types/game'
|
||||
|
||||
interface JournalEntryViewProps {
|
||||
entry: JournalEntry
|
||||
bossResult?: BossResult | null
|
||||
boss?: BossBattle | null
|
||||
onEdit?: () => void
|
||||
onBack?: () => void
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export function JournalEntryView({
|
||||
entry,
|
||||
bossResult,
|
||||
boss,
|
||||
onEdit,
|
||||
onBack,
|
||||
}: JournalEntryViewProps) {
|
||||
return (
|
||||
<article className="max-w-3xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
{onBack && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="text-text-secondary hover:text-text-primary transition-colors flex items-center gap-1"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Journal
|
||||
</button>
|
||||
)}
|
||||
{onEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
className="px-4 py-2 bg-surface-2 text-text-primary rounded-lg hover:bg-surface-3 transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-text-primary mb-3">{entry.title}</h1>
|
||||
<div className="flex items-center gap-4 text-sm text-text-secondary">
|
||||
<time>{formatDate(entry.createdAt)}</time>
|
||||
{entry.updatedAt !== entry.createdAt && (
|
||||
<span className="text-text-tertiary">(edited {formatDate(entry.updatedAt)})</span>
|
||||
)}
|
||||
</div>
|
||||
{boss && bossResult && (
|
||||
<div className="mt-3">
|
||||
<span className="inline-flex items-center gap-2 px-3 py-1 bg-purple-900/40 text-purple-300 light:bg-purple-100 light:text-purple-800 rounded-full text-sm">
|
||||
<span>Linked to:</span>
|
||||
<span className="font-medium">{boss.name}</span>
|
||||
<span className={bossResult.result === 'won' ? 'text-green-400' : 'text-red-400'}>
|
||||
({bossResult.result === 'won' ? 'Victory' : 'Defeat'})
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="prose prose-invert light:prose max-w-none">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{entry.body}</ReactMarkdown>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
102
frontend/src/components/journal/JournalList.tsx
Normal file
102
frontend/src/components/journal/JournalList.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import type { JournalEntry } from '../../types/journal'
|
||||
import type { BossResult, BossBattle } from '../../types/game'
|
||||
|
||||
interface JournalListProps {
|
||||
entries: JournalEntry[]
|
||||
runId: number
|
||||
bossResults?: BossResult[]
|
||||
bosses?: BossBattle[]
|
||||
onNewEntry?: () => void
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function getPreviewSnippet(body: string, maxLength = 120): string {
|
||||
const stripped = body.replace(/[#*_`~[\]]/g, '').replace(/\n+/g, ' ').trim()
|
||||
if (stripped.length <= maxLength) return stripped
|
||||
return stripped.slice(0, maxLength).trim() + '...'
|
||||
}
|
||||
|
||||
export function JournalList({
|
||||
entries,
|
||||
runId,
|
||||
bossResults = [],
|
||||
bosses = [],
|
||||
onNewEntry,
|
||||
}: JournalListProps) {
|
||||
const bossResultMap = new Map(bossResults.map((br) => [br.id, br]))
|
||||
const bossMap = new Map(bosses.map((b) => [b.id, b]))
|
||||
|
||||
const getBossName = (bossResultId: number | null): string | null => {
|
||||
if (bossResultId == null) return null
|
||||
const result = bossResultMap.get(bossResultId)
|
||||
if (!result) return null
|
||||
const boss = bossMap.get(result.bossBattleId)
|
||||
return boss?.name ?? null
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-text-secondary mb-4">No journal entries yet.</p>
|
||||
{onNewEntry && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewEntry}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Write your first entry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{onNewEntry && (
|
||||
<div className="flex justify-end mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewEntry}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
New Entry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{entries.map((entry) => {
|
||||
const bossName = getBossName(entry.bossResultId)
|
||||
return (
|
||||
<Link
|
||||
key={entry.id}
|
||||
to={`/runs/${runId}/journal/${entry.id}`}
|
||||
className="block p-4 bg-surface-1 rounded-lg hover:bg-surface-2 transition-colors border border-border"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-semibold text-text-primary truncate">{entry.title}</h3>
|
||||
<p className="text-sm text-text-secondary mt-1">{getPreviewSnippet(entry.body)}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end shrink-0 gap-1">
|
||||
<time className="text-xs text-text-tertiary">{formatDate(entry.createdAt)}</time>
|
||||
{bossName && (
|
||||
<span className="text-xs px-2 py-0.5 bg-purple-900/40 text-purple-300 light:bg-purple-100 light:text-purple-800 rounded-full">
|
||||
{bossName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
77
frontend/src/components/journal/JournalSection.tsx
Normal file
77
frontend/src/components/journal/JournalSection.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useState } from 'react'
|
||||
import { useJournalEntries, useCreateJournalEntry } from '../../hooks/useJournal'
|
||||
import { JournalList } from './JournalList'
|
||||
import { JournalEditor } from './JournalEditor'
|
||||
import type { BossResult, BossBattle } from '../../types/game'
|
||||
|
||||
interface JournalSectionProps {
|
||||
runId: number
|
||||
bossResults?: BossResult[]
|
||||
bosses?: BossBattle[]
|
||||
}
|
||||
|
||||
type Mode = 'list' | 'new'
|
||||
|
||||
export function JournalSection({ runId, bossResults = [], bosses = [] }: JournalSectionProps) {
|
||||
const [mode, setMode] = useState<Mode>('list')
|
||||
|
||||
const { data: entries, isLoading, error } = useJournalEntries(runId)
|
||||
const createEntry = useCreateJournalEntry(runId)
|
||||
|
||||
const handleNewEntry = () => {
|
||||
setMode('new')
|
||||
}
|
||||
|
||||
const handleSave = (data: { title: string; body: string; bossResultId: number | null }) => {
|
||||
createEntry.mutate(data, {
|
||||
onSuccess: () => setMode('list'),
|
||||
})
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setMode('list')
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-lg bg-status-failed-bg p-4 text-status-failed">
|
||||
Failed to load journal entries.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (mode === 'new') {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-text-primary mb-4">New Journal Entry</h2>
|
||||
<JournalEditor
|
||||
bossResults={bossResults}
|
||||
bosses={bosses}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
isSaving={createEntry.isPending}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<JournalList
|
||||
entries={entries ?? []}
|
||||
runId={runId}
|
||||
bossResults={bossResults}
|
||||
bosses={bosses}
|
||||
onNewEntry={handleNewEntry}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
4
frontend/src/components/journal/index.ts
Normal file
4
frontend/src/components/journal/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { JournalList } from './JournalList'
|
||||
export { JournalEditor } from './JournalEditor'
|
||||
export { JournalEntryView } from './JournalEntryView'
|
||||
export { JournalSection } from './JournalSection'
|
||||
Reference in New Issue
Block a user