+
Login
Use your API account credentials.
diff --git a/frontend/src/pages/not-found/not-found-page.tsx b/frontend/src/pages/not-found/not-found-page.tsx
new file mode 100644
index 0000000..6dbca98
--- /dev/null
+++ b/frontend/src/pages/not-found/not-found-page.tsx
@@ -0,0 +1,15 @@
+import { Link } from 'react-router-dom'
+import { Card } from '../../components/ui/card'
+
+export function NotFoundPage() {
+ return (
+
+
+ 404
+ Page not found
+ This route does not exist in Prompt Catalog.
+ Back to landing
+
+
+ )
+}
diff --git a/frontend/src/pages/prompts/prompts-page.tsx b/frontend/src/pages/prompts/prompts-page.tsx
index 716329d..853521f 100644
--- a/frontend/src/pages/prompts/prompts-page.tsx
+++ b/frontend/src/pages/prompts/prompts-page.tsx
@@ -1,7 +1,9 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
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'
@@ -24,6 +26,7 @@ export function PromptsPage() {
const { session } = useAuth()
const queryClient = useQueryClient()
const [editingPrompt, setEditingPrompt] = useState
(null)
+ const [promptToDelete, setPromptToDelete] = useState(null)
const [error, setError] = useState(null)
const token = session.token
@@ -120,16 +123,20 @@ export function PromptsPage() {
await createMutation.mutateAsync(value)
}
- const handleDelete = (prompt: PromptRecord) => {
- const confirmed = window.confirm('Delete this prompt?')
- if (!confirmed) {
- return
- }
- deleteMutation.mutate(prompt)
+ const handleDelete = () => {
+ if (!promptToDelete) return
+ deleteMutation.mutate(promptToDelete, {
+ onSuccess: () => setPromptToDelete(null),
+ })
}
return (
+
{editingPrompt ? 'Edit prompt' : 'Create prompt'}
) : null}
+ setPromptToDelete(null)}
+ onConfirm={handleDelete}
+ />
)
}
diff --git a/frontend/src/pages/register/register-page.tsx b/frontend/src/pages/register/register-page.tsx
new file mode 100644
index 0000000..aff1e45
--- /dev/null
+++ b/frontend/src/pages/register/register-page.tsx
@@ -0,0 +1,59 @@
+import { useState, type FormEvent } from 'react'
+import { Link, useNavigate } from 'react-router-dom'
+import { Button } from '../../components/ui/button'
+import { Card } from '../../components/ui/card'
+import { InlineError } from '../../components/ui/inline-error'
+import { Input } from '../../components/ui/input'
+import { signup } from '../../features/auth/auth-service'
+import { registerSchema } from '../../lib/validation/auth-schemas'
+
+export function RegisterPage() {
+ const navigate = useNavigate()
+ const [form, setForm] = useState({ name: '', last_name: '', email: '', username: '', password: '' })
+ const [error, setError] = useState(null)
+ const [loading, setLoading] = useState(false)
+
+ const updateField = (field: keyof typeof form, value: string) => {
+ setForm((current) => ({ ...current, [field]: value }))
+ }
+
+ const onSubmit = async (event: FormEvent) => {
+ event.preventDefault()
+ const parsed = registerSchema.safeParse(form)
+ if (!parsed.success) {
+ setError(parsed.error.issues[0]?.message ?? 'Check your registration details')
+ return
+ }
+
+ try {
+ setLoading(true)
+ setError(null)
+ await signup({ ...parsed.data, role: 'user' })
+ navigate('/login?registered=1', { replace: true })
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Unable to create account'
+ setError(message.includes('400') ? 'Username or email is already registered' : message)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
+ Create account
+ Start building a catalog of prompts you can trust and reuse.
+
+
+
+ )
+}
diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css
index 2ce5e6d..df057d8 100644
--- a/frontend/src/styles/base.css
+++ b/frontend/src/styles/base.css
@@ -12,7 +12,9 @@ body {
margin: 0;
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
font-size: var(--font-md);
- background: var(--bg);
+ background:
+ radial-gradient(circle at top left, color-mix(in srgb, var(--accent) 16%, transparent), transparent 30rem),
+ var(--bg);
color: var(--text);
}
@@ -28,17 +30,25 @@ h1 {
margin-bottom: var(--space-2);
}
+a {
+ color: inherit;
+}
+
h2 {
font-size: 1.125rem;
margin-bottom: var(--space-3);
}
.page {
- max-width: 960px;
+ max-width: 1120px;
margin: 0 auto;
padding: var(--space-6) var(--space-4);
}
+.app-main {
+ padding-bottom: var(--space-8);
+}
+
.centered-page {
min-height: 100vh;
display: grid;
@@ -46,6 +56,10 @@ h2 {
padding: var(--space-4);
}
+.auth-gradient {
+ background: radial-gradient(circle at 20% 10%, color-mix(in srgb, var(--accent-strong) 20%, transparent), transparent 28rem);
+}
+
.topbar {
display: flex;
justify-content: space-between;
@@ -54,6 +68,18 @@ h2 {
margin-bottom: var(--space-6);
}
+.app-topbar {
+ position: sticky;
+ top: var(--space-4);
+ z-index: 10;
+ padding: var(--space-3);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ background: color-mix(in srgb, var(--surface) 90%, transparent);
+ box-shadow: var(--shadow);
+ backdrop-filter: blur(18px);
+}
+
.topbar-actions {
display: flex;
align-items: center;
@@ -68,16 +94,46 @@ h2 {
text-decoration: none;
}
+.nav-link {
+ padding: 0.45rem 0.7rem;
+ border-radius: 999px;
+}
+
.nav-link.active,
.text-link:hover {
color: var(--accent);
}
+.nav-link.active {
+ background: color-mix(in srgb, var(--accent) 12%, transparent);
+}
+
+.button-link {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--radius-sm);
+ border: 1px solid var(--accent);
+ padding: 0.58rem 0.95rem;
+ background: var(--accent);
+ color: #fff;
+ font-size: var(--font-sm);
+ font-weight: 700;
+ text-decoration: none;
+}
+
+.button-link-secondary {
+ background: transparent;
+ color: var(--text);
+ border-color: var(--border);
+}
+
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-4);
+ box-shadow: 0 1px 0 rgb(15 23 42 / 0.03);
}
.auth-card {
@@ -94,6 +150,10 @@ h2 {
align-items: center;
}
+.wrap {
+ flex-wrap: wrap;
+}
+
.gap-sm {
gap: var(--space-2);
}
@@ -233,6 +293,155 @@ h2 {
font-size: var(--font-sm);
}
+.badge-accent {
+ border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
+ color: var(--accent);
+ background: color-mix(in srgb, var(--accent) 10%, transparent);
+}
+
+.badge-danger {
+ border-color: color-mix(in srgb, var(--danger) 35%, var(--border));
+ color: var(--danger);
+ background: color-mix(in srgb, var(--danger) 10%, transparent);
+}
+
+.success-panel {
+ border: 1px solid color-mix(in srgb, var(--success) 30%, var(--border));
+ border-radius: var(--radius-sm);
+ padding: var(--space-3);
+ color: var(--success);
+ background: color-mix(in srgb, var(--success) 10%, transparent);
+ font-size: var(--font-sm);
+}
+
+.eyebrow {
+ color: var(--accent);
+ font-size: var(--font-sm);
+ font-weight: 800;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.page-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+ gap: var(--space-4);
+}
+
+.page-header-actions {
+ display: flex;
+ gap: var(--space-2);
+ flex-wrap: wrap;
+}
+
+.empty-state {
+ display: grid;
+ gap: var(--space-2);
+ place-items: center;
+ border: 1px dashed var(--border);
+ border-radius: var(--radius-md);
+ padding: var(--space-8);
+ text-align: center;
+ background: color-mix(in srgb, var(--accent) 6%, transparent);
+}
+
+.dialog-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 50;
+ display: grid;
+ place-items: center;
+ padding: var(--space-4);
+ background: rgb(2 6 23 / 0.55);
+}
+
+.dialog-panel {
+ width: min(440px, 100%);
+ display: grid;
+ gap: var(--space-4);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: var(--space-6);
+ background: var(--surface);
+ box-shadow: var(--shadow);
+}
+
+.dialog-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: var(--space-2);
+ flex-wrap: wrap;
+}
+
+.marketing-page {
+ width: min(1180px, calc(100% - 2rem));
+ margin: 0 auto;
+ padding: var(--space-6) 0 var(--space-12);
+}
+
+.marketing-nav,
+.architecture-strip {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-3);
+ flex-wrap: wrap;
+}
+
+.marketing-nav {
+ margin-bottom: var(--space-12);
+}
+
+.hero-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1.08fr) minmax(300px, 0.72fr);
+ gap: var(--space-8);
+ align-items: center;
+}
+
+.hero-title {
+ max-width: 12ch;
+ font-size: var(--font-xl);
+ line-height: 0.95;
+ letter-spacing: -0.06em;
+}
+
+.hero-copy {
+ max-width: 62ch;
+ color: var(--muted);
+ font-size: 1.08rem;
+ line-height: 1.75;
+}
+
+.showcase-card {
+ display: grid;
+ gap: var(--space-4);
+ min-height: 320px;
+ align-content: center;
+ border-radius: var(--radius-lg);
+ background:
+ linear-gradient(145deg, color-mix(in srgb, var(--accent) 14%, transparent), transparent),
+ var(--surface);
+ box-shadow: var(--shadow);
+}
+
+.feature-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: var(--space-4);
+ margin: var(--space-12) 0;
+}
+
+.architecture-strip {
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: var(--space-4);
+ background: var(--surface-strong);
+ color: var(--muted);
+ font-size: var(--font-sm);
+}
+
.result-panel {
display: grid;
gap: var(--space-2);
@@ -257,6 +466,24 @@ h2 {
align-items: flex-start;
}
+ .app-topbar {
+ position: static;
+ }
+
+ .page-header {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .hero-grid,
+ .feature-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .marketing-nav {
+ margin-bottom: var(--space-8);
+ }
+
.filter-grid {
grid-template-columns: 1fr;
}
diff --git a/frontend/src/styles/themes.css b/frontend/src/styles/themes.css
index 8066943..b1ad375 100644
--- a/frontend/src/styles/themes.css
+++ b/frontend/src/styles/themes.css
@@ -2,19 +2,27 @@
:root[data-theme='light'] {
--bg: #f4f6f8;
--surface: #ffffff;
+ --surface-strong: #eef2ff;
--text: #1e293b;
--muted: #64748b;
--border: #dbe3ea;
--accent: #2563eb;
+ --accent-strong: #6d28d9;
--danger: #dc2626;
+ --success: #047857;
+ --shadow: 0 24px 70px rgb(30 41 59 / 0.12);
}
:root[data-theme='dark'] {
--bg: #111827;
--surface: #1f2937;
+ --surface-strong: #172033;
--text: #e5e7eb;
--muted: #94a3b8;
--border: #334155;
--accent: #60a5fa;
+ --accent-strong: #a78bfa;
--danger: #f87171;
-}
\ No newline at end of file
+ --success: #34d399;
+ --shadow: 0 24px 70px rgb(0 0 0 / 0.28);
+}
diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css
index 20c93d9..bbb7f02 100644
--- a/frontend/src/styles/tokens.css
+++ b/frontend/src/styles/tokens.css
@@ -5,13 +5,16 @@
--space-4: 1rem;
--space-6: 1.5rem;
--space-8: 2rem;
+ --space-12: 3rem;
--radius-sm: 0.375rem;
--radius-md: 0.625rem;
+ --radius-lg: 1rem;
--font-sm: 0.875rem;
--font-md: 1rem;
--font-lg: 1.25rem;
+ --font-xl: clamp(2.2rem, 7vw, 4.75rem);
--transition-fast: 120ms ease;
-}
\ No newline at end of file
+}
diff --git a/scripts/run-compose-stack.sh b/scripts/run-compose-stack.sh
index 97c33b2..cf1f1ec 100755
--- a/scripts/run-compose-stack.sh
+++ b/scripts/run-compose-stack.sh
@@ -313,7 +313,7 @@ wait_for_http() {
}
verify_api_proxy() {
- local url="http://127.0.0.1:8080/api/v1/auth/login"
+ local url="http://127.0.0.1:80/api/v1/auth/login"
local status
log "Checking nginx API proxy: ${url}"
@@ -365,8 +365,8 @@ configure_ports() {
fail "Host port 8000 is already used. docker-compose.yml hardcodes backend port 8000, so stop the conflicting container before clean startup."
fi
- if docker_published_port_in_use 8080; then
- fail "Host port 8080 is already used. docker-compose.yml hardcodes frontend port 8080, so stop the conflicting container before clean startup."
+ if docker_published_port_in_use 80; then
+ fail "Host port 80 is already used. docker-compose.yml hardcodes frontend port 80, so stop the conflicting container before clean startup."
fi
return
@@ -410,7 +410,7 @@ start_stack() {
fi
printf 'Clean project teardown: %s down --volumes --remove-orphans\n' "$(printf '%q ' "${COMPOSE_CMD[@]}" "${COMPOSE_ENV_ARGS[@]}" "${COMPOSE_PROJECT_ARGS[@]}")" >&2
else
- printf '\nIf port 3306, 6379, 8000, or 8080 is already used by an old webapi container, stop it first.\n' >&2
+ printf '\nIf port 3306, 6379, 8000, or 80 is already used by an old webapi container, stop it first.\n' >&2
printf 'Manual backend workflow cleanup example: docker rm -f webapi-mariadb webapi-redis webapi-backend\n' >&2
fi
if [[ -n "${BUILD_FLAG}" ]]; then
@@ -442,7 +442,7 @@ start_stack
wait_for_health mariadb "${TIMEOUT_SECONDS}"
wait_for_health redis "${TIMEOUT_SECONDS}"
wait_for_health backend "${TIMEOUT_SECONDS}"
-wait_for_http "frontend" "http://127.0.0.1:8080/" "${TIMEOUT_SECONDS}"
+wait_for_http "frontend" "http://127.0.0.1:80/" "${TIMEOUT_SECONDS}"
wait_for_http "backend direct endpoint" "http://127.0.0.1:8000/" "${TIMEOUT_SECONDS}"
verify_api_proxy
verify_database
@@ -450,9 +450,9 @@ verify_redis
log "Stack is ready."
printf '\nURLs:\n'
-printf ' Frontend: http://127.0.0.1:8080\n'
+printf ' Frontend: http://127.0.0.1:80\n'
printf ' Backend direct: http://127.0.0.1:8000/docs\n'
-printf ' Backend through nginx: http://127.0.0.1:8080/api/v1/...\n'
+printf ' Backend through nginx: http://127.0.0.1:80/api/v1/...\n'
printf ' MariaDB host port: 127.0.0.1:%s\n' "${MARIADB_HOST_PORT:-3306}"
printf ' Redis host port: 127.0.0.1:%s\n' "${REDIS_HOST_PORT:-6379}"
printf '\nUseful commands:\n'