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
77 changes: 77 additions & 0 deletions frontend/src/components/prompts/prompt-editor-drawer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { useTranslation } from 'react-i18next'
import type { SelectOption } from '../../features/options/options-types'
import type { PromptInput, PromptRecord } from '../../features/prompts/prompts-types'
import { Button } from '../ui/button'
import { InlineError } from '../ui/inline-error'
import { PromptForm } from './prompt-form'

export type PromptEditorState =
| { mode: 'create' }
| { mode: 'edit'; prompt: PromptRecord }
| null

type PromptEditorDrawerProps = {
state: PromptEditorState
isSaving: boolean
errorMessage?: string | null
modelOptions: SelectOption[]
categoryOptions: SelectOption[]
optionsLoading: boolean
onSubmit: (value: PromptInput) => Promise<void>
onClose: () => void
}

export function PromptEditorDrawer({
state,
isSaving,
errorMessage,
modelOptions,
categoryOptions,
optionsLoading,
onSubmit,
onClose,
}: PromptEditorDrawerProps) {
const { t } = useTranslation()

if (!state) return null

const isEdit = state.mode === 'edit'
const title = isEdit ? t('prompts.edit') : t('prompts.create')

return (
<div className="drawer-backdrop" role="presentation" onMouseDown={onClose}>
<aside
className="drawer-panel"
role="dialog"
aria-modal="true"
aria-labelledby="prompt-editor-title"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="drawer-header">
<div>
<p className="eyebrow">{t('prompts.eyebrow')}</p>
<h2 id="prompt-editor-title">{title}</h2>
</div>
<Button type="button" variant="ghost" onClick={onClose}>
{t('common.close')}
</Button>
</div>

<div className="drawer-body">
{errorMessage ? <InlineError message={errorMessage} /> : null}
<PromptForm
key={isEdit ? state.prompt.id : 'create-prompt'}
initialValue={isEdit ? state.prompt : null}
isSaving={isSaving}
modelOptions={modelOptions}
categoryOptions={categoryOptions}
optionsLoading={optionsLoading}
showCancel
onSubmit={onSubmit}
onCancelEdit={onClose}
/>
</div>
</aside>
</div>
)
}
4 changes: 3 additions & 1 deletion frontend/src/components/prompts/prompt-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type PromptFormProps = {
modelOptions: SelectOption[]
categoryOptions: SelectOption[]
optionsLoading: boolean
showCancel?: boolean
onSubmit: (value: PromptInput) => Promise<void>
onCancelEdit: () => void
}
Expand All @@ -28,6 +29,7 @@ export function PromptForm({
modelOptions,
categoryOptions,
optionsLoading,
showCancel = false,
onSubmit,
onCancelEdit,
}: PromptFormProps) {
Expand Down Expand Up @@ -129,7 +131,7 @@ export function PromptForm({
<Button type="submit" disabled={optionsLoading || isSaving}>
{isSaving ? t('common.saving') : isEdit ? t('prompts.form.update') : t('prompts.form.add')}
</Button>
{isEdit ? (
{isEdit || showCancel ? (
<Button type="button" variant="ghost" onClick={onCancelEdit}>
{t('common.cancel')}
</Button>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ export const en = {
description: 'Save the prompts that worked, then find and reuse them faster.',
edit: 'Edit prompt',
create: 'Save a proven prompt',
newPrompt: 'New Prompt',
listTitle: 'Saved prompts',
listDescription: 'Open any card to review model, category, rating, and full prompt text.',
dropdownError: 'Unable to load dropdown options',
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ export const es = {
description: 'Guarda los prompts que funcionaron, luego encuéntralos y reutilízalos más rápido.',
edit: 'Editar prompt',
create: 'Guardar un prompt probado',
newPrompt: 'Nuevo prompt',
listTitle: 'Prompts guardados',
listDescription: 'Abre cualquier tarjeta para revisar modelo, categoría, calificación y texto completo del prompt.',
dropdownError: 'No se pudieron cargar las opciones desplegables',
Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib/validation/prompt-schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import { promptSchema } from './prompt-schemas'

const validPrompt = {
title: 'Answer generator',
model_name: 'gpt',
prompt_text: 'Generate answer',
category: 'qa',
Expand Down
69 changes: 43 additions & 26 deletions frontend/src/pages/prompts/prompts-page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { PromptEditorDrawer, type PromptEditorState } from '../../components/prompts/prompt-editor-drawer'
import { PromptList } from '../../components/prompts/prompt-list'
import { Button } from '../../components/ui/button'
import { Card } from '../../components/ui/card'
import { ConfirmDialog } from '../../components/ui/confirm-dialog'
import { InlineError } from '../../components/ui/inline-error'
import { PageHeader } from '../../components/ui/page-header'
import { PromptForm } from '../../components/prompts/prompt-form'
import { PromptList } from '../../components/prompts/prompt-list'
import { useAuth } from '../../features/auth/auth-store'
import {
listCategoryOptions,
Expand All @@ -27,7 +28,7 @@ export function PromptsPage() {
const { t } = useTranslation()
const { session } = useAuth()
const queryClient = useQueryClient()
const [editingPrompt, setEditingPrompt] = useState<PromptRecord | null>(null)
const [editorState, setEditorState] = useState<PromptEditorState>(null)
const [promptToDelete, setPromptToDelete] = useState<PromptRecord | null>(null)
const [error, setError] = useState<string | null>(null)

Expand Down Expand Up @@ -76,6 +77,7 @@ export function PromptsPage() {
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['prompts', userId] })
setEditorState(null)
setError(null)
},
onError: (err) => {
Expand All @@ -85,14 +87,14 @@ export function PromptsPage() {

const updateMutation = useMutation({
mutationFn: async (value: PromptInput) => {
if (!token || userId === null || !editingPrompt) {
if (!token || userId === null || editorState?.mode !== 'edit') {
throw new Error(t('prompts.errors.selectedUnavailable'))
}
return updatePrompt(token, editingPrompt.id, userId, value)
return updatePrompt(token, editorState.prompt.id, userId, value)
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['prompts', userId] })
setEditingPrompt(null)
setEditorState(null)
setError(null)
},
onError: (err) => {
Expand All @@ -117,7 +119,7 @@ export function PromptsPage() {
})

const handleSubmit = async (value: PromptInput) => {
if (editingPrompt) {
if (editorState?.mode === 'edit') {
await updateMutation.mutateAsync(value)
return
}
Expand All @@ -132,18 +134,39 @@ export function PromptsPage() {
})
}

const openCreateDrawer = () => {
setError(null)
setEditorState({ mode: 'create' })
}

const openEditDrawer = (prompt: PromptRecord) => {
setError(null)
setEditorState({ mode: 'edit', prompt })
}

const closeEditorDrawer = () => {
setEditorState(null)
}

const prompts = promptsQuery.data ?? []
const averageRating = prompts.length
? prompts.reduce((total, prompt) => total + prompt.rate, 0) / prompts.length
: 0
const categoryCount = new Set(prompts.map((prompt) => prompt.category)).size
const optionErrorMessage = categoriesQuery.error || modelsQuery.error ? t('prompts.dropdownError') : null
const drawerErrorMessage = error ?? optionErrorMessage

return (
<section className="stack">
<PageHeader
eyebrow={t('prompts.eyebrow')}
title={t('prompts.title')}
description={t('prompts.description')}
actions={(
<Button type="button" onClick={openCreateDrawer}>
{t('prompts.newPrompt')}
</Button>
)}
/>

<div className="stats-grid">
Expand All @@ -161,23 +184,6 @@ export function PromptsPage() {
</Card>
</div>

<Card className="composer-card">
<h1>{editingPrompt ? t('prompts.edit') : t('prompts.create')}</h1>
<PromptForm
key={editingPrompt?.id ?? 'new-prompt'}
initialValue={editingPrompt}
isSaving={createMutation.isPending || updateMutation.isPending}
modelOptions={modelsQuery.data?.items ?? []}
categoryOptions={categoriesQuery.data?.items ?? []}
optionsLoading={categoriesQuery.isLoading || modelsQuery.isLoading}
onSubmit={handleSubmit}
onCancelEdit={() => setEditingPrompt(null)}
/>
{categoriesQuery.error || modelsQuery.error ? (
<InlineError message={t('prompts.dropdownError')} />
) : null}
</Card>

<Card className="library-card">
<div className="section-heading">
<div>
Expand All @@ -186,15 +192,26 @@ export function PromptsPage() {
</div>
</div>
{promptsQuery.isLoading ? <p className="muted">{t('common.loading')}</p> : null}
{error ? <InlineError message={error} /> : null}
{error && !editorState ? <InlineError message={error} /> : null}
{optionErrorMessage && !editorState ? <InlineError message={optionErrorMessage} /> : null}
{promptsQuery.data ? (
<PromptList
prompts={prompts}
onEdit={setEditingPrompt}
onEdit={openEditDrawer}
onDelete={setPromptToDelete}
/>
) : null}
</Card>
<PromptEditorDrawer
state={editorState}
isSaving={createMutation.isPending || updateMutation.isPending}
errorMessage={drawerErrorMessage}
modelOptions={modelsQuery.data?.items ?? []}
categoryOptions={categoriesQuery.data?.items ?? []}
optionsLoading={categoriesQuery.isLoading || modelsQuery.isLoading}
onSubmit={handleSubmit}
onClose={closeEditorDrawer}
/>
<ConfirmDialog
open={Boolean(promptToDelete)}
title={t('prompts.deleteTitle')}
Expand Down
48 changes: 47 additions & 1 deletion frontend/src/styles/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,47 @@ textarea:focus-visible {
flex-wrap: wrap;
}

.drawer-backdrop {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
justify-content: flex-end;
background: rgb(18 12 8 / 0.62);
backdrop-filter: blur(6px);
}

.drawer-panel {
width: min(640px, 100vw);
height: 100vh;
height: 100dvh;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: var(--space-4);
border-left: 1px solid var(--border);
padding: var(--space-6);
background: var(--surface);
box-shadow: var(--shadow);
overflow: hidden;
}

.drawer-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
}

.drawer-body {
min-height: 0;
display: grid;
align-content: start;
gap: var(--space-4);
overflow: auto;
padding-right: var(--space-1);
scrollbar-gutter: stable;
}

.marketing-page {
width: min(1180px, calc(100% - 2rem));
margin: 0 auto;
Expand Down Expand Up @@ -965,7 +1006,6 @@ textarea:focus-visible {
line-height: 1.1;
}

.composer-card,
.library-card {
display: grid;
gap: var(--space-4);
Expand Down Expand Up @@ -1053,6 +1093,12 @@ textarea:focus-visible {
padding: var(--space-4);
}

.drawer-panel {
width: 100%;
border-left: 0;
padding: var(--space-4);
}

.filter-grid {
grid-template-columns: 1fr;
}
Expand Down
Loading