Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
# and CI/CD documentation. Replace placeholder values in real environments.

# Docker Compose host ports for local inspection.
# Valid deployment modes are local and production. Local publishes service ports
# directly. Production publishes only Caddy ports 80/443 and requires ./Caddyfile.
DEPLOY_ENV=local
MARIADB_HOST_PORT=3306
REDIS_HOST_PORT=6379

Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ htmlcov/
# Environment variables
.env

# Production deployment config
Caddyfile

# Local runtime logs
backend.log
frontend.log
3 changes: 3 additions & 0 deletions Caddyfile.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
example.com {
reverse_proxy frontend:80
}
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ If Gmail SMTP is used, use an app password rather than an account password. Scop
## Documentation

- [`frontend/README.md`](frontend/README.md): frontend local development, production build, Docker, nginx proxying, and environment notes.
- [`webapi/README.md`](webapi/README.md): backend deployment, MariaDB operations, API checks, configuration, and troubleshooting.
- [`webapi/README.md`](webapi/README.md): backend deployment, MariaDB operations, API checks, [`admin` and `god` user elevation](webapi/README.md#administrative-users-and-roles), configuration, and troubleshooting.
- [`webapi/tests/README.md`](webapi/tests/README.md): backend test commands and CI/CD test mapping.
- [`plans/dbdiagram.md`](plans/dbdiagram.md): database and ORM diagram details for dbdiagram.io.

Expand Down
16 changes: 16 additions & 0 deletions docker-compose.local.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
services:
mariadb:
ports:
- "${MARIADB_HOST_PORT:-3306}:3306"

redis:
ports:
- "${REDIS_HOST_PORT:-6379}:6379"

backend:
ports:
- "8000:8000"

frontend:
ports:
- "80:80"
16 changes: 16 additions & 0 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
services:
caddy:
image: caddy:2
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- frontend

volumes:
caddy_data:
caddy_config:
12 changes: 4 additions & 8 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ services:
MARIADB_DATABASE: ${MARIADB_DATABASE:-crud_data}
MARIADB_USER: ${MARIADB_USER:-webapi_user}
MARIADB_PASSWORD: ${MARIADB_PASSWORD:-replace_with_local_database_password}
ports:
- "${MARIADB_HOST_PORT:-3306}:3306"
volumes:
- webapi-mariadb-data:/var/lib/mysql
healthcheck:
Expand All @@ -19,8 +17,6 @@ services:

redis:
image: redis:7-alpine
ports:
- "${REDIS_HOST_PORT:-6379}:6379"
healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
interval: 10s
Expand All @@ -40,8 +36,8 @@ services:
ENV_MAIL_PASSWORD: ${ENV_MAIL_PASSWORD:-replace_with_local_mail_app_password}
ENV_MAIL_FROM: ${ENV_MAIL_FROM:-test@example.com}
ENV_SECRET_KEY: ${ENV_SECRET_KEY:-replace_with_local_jwt_secret}
ports:
- "8000:8000"
expose:
- "8000"
depends_on:
mariadb:
condition: service_healthy
Expand All @@ -57,8 +53,8 @@ services:
frontend:
build:
context: ./frontend
ports:
- "80:80"
expose:
- "80"
depends_on:
backend:
condition: service_healthy
Expand Down
67 changes: 67 additions & 0 deletions frontend/src/components/prompts/prompt-detail-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { PromptRecord } from '../../features/prompts/prompts-types'
import { Button } from '../ui/button'

type PromptDetailDialogProps = {
prompt: PromptRecord | null
onClose: () => void
onEdit: (prompt: PromptRecord) => void
onDelete: (prompt: PromptRecord) => void
}

export function PromptDetailDialog({
prompt,
onClose,
onEdit,
onDelete,
}: PromptDetailDialogProps) {
if (!prompt) return null

const handleEdit = () => {
onClose()
onEdit(prompt)
}

const handleDelete = () => {
onClose()
onDelete(prompt)
}

return (
<div className="dialog-backdrop" role="presentation" onMouseDown={onClose}>
<div
className="dialog-panel prompt-detail-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="prompt-detail-title"
onMouseDown={(event) => event.stopPropagation()}
>
<h2 id="prompt-detail-title">Prompt details</h2>
<div className="prompt-detail-body">
<div className="prompt-detail-field">
<span className="label">Model</span>
<p>{prompt.model_name}</p>
</div>
<div className="prompt-detail-field">
<span className="label">Prompt</span>
<p className="prompt-detail-text">{prompt.prompt_text}</p>
</div>
<div className="prompt-detail-meta">
<span className="badge">{prompt.category}</span>
<span className="muted">Rating {prompt.rate}/5</span>
</div>
</div>
<div className="dialog-actions">
<Button variant="ghost" onClick={onClose}>
Close
</Button>
<Button variant="ghost" onClick={handleEdit}>
Edit
</Button>
<Button variant="danger" onClick={handleDelete}>
Delete
</Button>
</div>
</div>
</div>
)
}
15 changes: 12 additions & 3 deletions frontend/src/components/prompts/prompt-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { SelectOption } from '../../features/options/options-types'
import type { PromptInput, PromptRecord } from '../../features/prompts/prompts-types'
import { promptSchema } from '../../lib/validation/prompt-schemas'
import { Button } from '../ui/button'
import { ComboboxInput } from '../ui/combobox-input'
import { Input } from '../ui/input'
import { RatingInput } from '../ui/rating-input'
import { Select } from '../ui/select'
Expand Down Expand Up @@ -59,6 +60,14 @@ export function PromptForm({
return
}

const selectedModel = modelOptions.find((option) => option.value === parsed.data.model_name)
if (!selectedModel) {
const message = 'Select a known model from the list.'
setFieldErrors({ model_name: message })
setError(message)
return
}

setError(null)
setFieldErrors({})
await onSubmit(parsed.data)
Expand All @@ -69,12 +78,12 @@ export function PromptForm({

return (
<form className="prompt-form" onSubmit={handleSubmit}>
<Select
<ComboboxInput
label="Model"
options={modelOptions}
value={form.model_name}
disabled={optionsLoading || isSaving}
onChange={(e) => handleChange('model_name', e.target.value)}
onChange={(value) => handleChange('model_name', value)}
error={fieldErrors.model_name}
/>
<Input
Expand Down Expand Up @@ -102,7 +111,7 @@ export function PromptForm({
{error ? <p className="field-error">{error}</p> : null}

<div className="row gap-sm">
<Button type="submit" disabled={isSaving}>
<Button type="submit" disabled={optionsLoading || isSaving}>
{isSaving ? 'Saving...' : isEdit ? 'Update prompt' : 'Add prompt'}
</Button>
{isEdit ? (
Expand Down
65 changes: 43 additions & 22 deletions frontend/src/components/prompts/prompt-list.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,27 @@
import { useState } from 'react'
import type { PromptRecord } from '../../features/prompts/prompts-types'
import { Button } from '../ui/button'
import { EmptyState } from '../ui/empty-state'
import { PromptDetailDialog } from './prompt-detail-dialog'

type PromptListProps = {
prompts: PromptRecord[]
onEdit: (prompt: PromptRecord) => void
onDelete: (prompt: PromptRecord) => void
}

function summarizePromptText(text: string, maxLength = 140) {
const normalizedText = text.trim().replace(/\s+/g, ' ')

if (normalizedText.length <= maxLength) {
return normalizedText
}

return `${normalizedText.slice(0, maxLength).trimEnd()}...`
}

export function PromptList({ prompts, onEdit, onDelete }: PromptListProps) {
const [selectedPrompt, setSelectedPrompt] = useState<PromptRecord | null>(null)

if (!prompts.length) {
return (
<EmptyState
Expand All @@ -19,26 +32,34 @@ export function PromptList({ prompts, onEdit, onDelete }: PromptListProps) {
}

return (
<div className="list">
{prompts.map((prompt) => (
<article key={prompt.id} className="list-item">
<div>
<h3>{prompt.model_name}</h3>
<p>{prompt.prompt_text}</p>
<p className="muted">
{prompt.category} · Rating {prompt.rate}/5
</p>
</div>
<div className="row gap-sm">
<Button variant="ghost" onClick={() => onEdit(prompt)}>
Edit
</Button>
<Button variant="danger" onClick={() => onDelete(prompt)}>
Delete
</Button>
</div>
</article>
))}
</div>
<>
<div className="list prompt-list">
{prompts.map((prompt) => {
const summary = summarizePromptText(prompt.prompt_text)

return (
<button
key={prompt.id}
type="button"
className="prompt-list-item"
onClick={() => setSelectedPrompt(prompt)}
>
<span className="prompt-list-summary">{summary}</span>
<span className="prompt-list-meta">
<span className="badge">{prompt.category}</span>
<span className="muted">Rating {prompt.rate}/5</span>
</span>
<span className="sr-only">View prompt details</span>
</button>
)
})}
</div>
<PromptDetailDialog
prompt={selectedPrompt}
onClose={() => setSelectedPrompt(null)}
onEdit={onEdit}
onDelete={onDelete}
/>
</>
)
}
Loading
Loading