Skip to content

Commit e45db3b

Browse files
committed
feat: Complete Phase 4 - Forms & Creation
Add comprehensive form functionality for creating technologies and stacks: - Created form UI components (Input, Textarea, Label, Select) - Created Zod validation schemas for technologies and stacks - Built /tech/new page with full technology creation form - React Hook Form integration with validation - Support for all TechnologyTier enum values - Vendor, product, and logo URL fields - Built /stacks/new page with stack creation form - Technology selection interface with search - Multiple technology selection support - Markdown details field - Created PageHeader component with conditional create buttons - Updated /tech and /stacks listing pages with PageHeader - All forms integrate with ServiceStack API using DTOs Build: 12 routes, 175 kB largest page (/tech/new)
1 parent 5470d7a commit e45db3b

File tree

11 files changed

+814
-12
lines changed

11 files changed

+814
-12
lines changed
Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
'use client'
2+
3+
import { useState, useEffect } from 'react'
4+
import { useRouter } from 'next/navigation'
5+
import { useForm } from 'react-hook-form'
6+
import { zodResolver } from '@hookform/resolvers/zod'
7+
import { stackSchema, type StackFormValues } from '@/lib/validations/stack'
8+
import { client } from '@/lib/api/client'
9+
import { CreateTechnologyStack, QueryTechnology } from '@/lib/dtos'
10+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
11+
import { Input } from '@/components/ui/input'
12+
import { Textarea } from '@/components/ui/textarea'
13+
import { Button } from '@/components/ui/button'
14+
import { Label } from '@/components/ui/label'
15+
import { useAuth } from '@/lib/api/auth'
16+
17+
export default function NewStackPage() {
18+
const router = useRouter()
19+
const { isAuthenticated } = useAuth()
20+
const [isSubmitting, setIsSubmitting] = useState(false)
21+
const [error, setError] = useState<string | null>(null)
22+
const [technologies, setTechnologies] = useState<Array<{ id: number; name: string }>>([])
23+
const [selectedTechIds, setSelectedTechIds] = useState<number[]>([])
24+
const [techSearch, setTechSearch] = useState('')
25+
26+
const {
27+
register,
28+
handleSubmit,
29+
formState: { errors },
30+
setValue,
31+
} = useForm<StackFormValues>({
32+
resolver: zodResolver(stackSchema),
33+
defaultValues: {
34+
technologyIds: [],
35+
},
36+
})
37+
38+
// Load technologies for selection
39+
useEffect(() => {
40+
const loadTechnologies = async () => {
41+
try {
42+
const request = new QueryTechnology({
43+
take: 100,
44+
orderBy: 'Name',
45+
})
46+
const response = await client.get(request)
47+
if (response.results) {
48+
setTechnologies(
49+
response.results.map((tech) => ({
50+
id: tech.id!,
51+
name: tech.name,
52+
}))
53+
)
54+
}
55+
} catch (err) {
56+
console.error('Failed to load technologies:', err)
57+
}
58+
}
59+
loadTechnologies()
60+
}, [])
61+
62+
// Redirect if not authenticated
63+
if (!isAuthenticated) {
64+
router.push('/auth/github')
65+
return null
66+
}
67+
68+
const toggleTechnology = (techId: number) => {
69+
const newSelection = selectedTechIds.includes(techId)
70+
? selectedTechIds.filter((id) => id !== techId)
71+
: [...selectedTechIds, techId]
72+
73+
setSelectedTechIds(newSelection)
74+
setValue('technologyIds', newSelection, { shouldValidate: true })
75+
}
76+
77+
const filteredTechnologies = techSearch
78+
? technologies.filter((tech) =>
79+
tech.name.toLowerCase().includes(techSearch.toLowerCase())
80+
)
81+
: technologies
82+
83+
const onSubmit = async (data: StackFormValues) => {
84+
setIsSubmitting(true)
85+
setError(null)
86+
87+
try {
88+
const request = new CreateTechnologyStack({
89+
...data,
90+
isLocked: false,
91+
technologyIds: selectedTechIds,
92+
})
93+
const response = await client.post(request)
94+
95+
if (response.result) {
96+
router.push(`/stacks/${response.result.slug}`)
97+
}
98+
} catch (err: any) {
99+
setError(err.responseStatus?.message || 'Failed to create stack')
100+
} finally {
101+
setIsSubmitting(false)
102+
}
103+
}
104+
105+
return (
106+
<div className="container max-w-4xl py-8">
107+
<div className="mb-8">
108+
<h1 className="text-4xl font-bold tracking-tight">Create Technology Stack</h1>
109+
<p className="text-lg text-muted-foreground mt-2">
110+
Share your technology stack with the community
111+
</p>
112+
</div>
113+
114+
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
115+
{error && (
116+
<div className="rounded-md bg-destructive/15 p-4 text-sm text-destructive">
117+
{error}
118+
</div>
119+
)}
120+
121+
<Card>
122+
<CardHeader>
123+
<CardTitle>Basic Information</CardTitle>
124+
<CardDescription>General details about your stack</CardDescription>
125+
</CardHeader>
126+
<CardContent className="space-y-6">
127+
<div className="space-y-2">
128+
<Label htmlFor="name">
129+
Stack Name <span className="text-destructive">*</span>
130+
</Label>
131+
<Input id="name" {...register('name')} placeholder="Modern Web Stack" />
132+
{errors.name && (
133+
<p className="text-sm text-destructive">{errors.name.message}</p>
134+
)}
135+
</div>
136+
137+
<div className="space-y-2">
138+
<Label htmlFor="slug">
139+
Slug <span className="text-destructive">*</span>
140+
</Label>
141+
<Input
142+
id="slug"
143+
{...register('slug')}
144+
placeholder="modern-web-stack"
145+
className="font-mono"
146+
/>
147+
{errors.slug && (
148+
<p className="text-sm text-destructive">{errors.slug.message}</p>
149+
)}
150+
<p className="text-xs text-muted-foreground">
151+
URL-friendly identifier (lowercase, numbers, hyphens only)
152+
</p>
153+
</div>
154+
155+
<div className="space-y-2">
156+
<Label htmlFor="description">
157+
Description <span className="text-destructive">*</span>
158+
</Label>
159+
<Textarea
160+
id="description"
161+
{...register('description')}
162+
placeholder="A comprehensive full-stack web development setup..."
163+
rows={3}
164+
/>
165+
{errors.description && (
166+
<p className="text-sm text-destructive">{errors.description.message}</p>
167+
)}
168+
</div>
169+
170+
<div className="grid gap-6 sm:grid-cols-2">
171+
<div className="space-y-2">
172+
<Label htmlFor="vendorName">Organization/Vendor Name</Label>
173+
<Input
174+
id="vendorName"
175+
{...register('vendorName')}
176+
placeholder="Acme Corp"
177+
/>
178+
{errors.vendorName && (
179+
<p className="text-sm text-destructive">{errors.vendorName.message}</p>
180+
)}
181+
</div>
182+
183+
<div className="space-y-2">
184+
<Label htmlFor="appUrl">Application URL</Label>
185+
<Input
186+
id="appUrl"
187+
{...register('appUrl')}
188+
placeholder="https://example.com"
189+
type="url"
190+
/>
191+
{errors.appUrl && (
192+
<p className="text-sm text-destructive">{errors.appUrl.message}</p>
193+
)}
194+
</div>
195+
</div>
196+
197+
<div className="space-y-2">
198+
<Label htmlFor="screenshotUrl">Screenshot URL</Label>
199+
<Input
200+
id="screenshotUrl"
201+
{...register('screenshotUrl')}
202+
placeholder="https://example.com/screenshot.png"
203+
type="url"
204+
/>
205+
{errors.screenshotUrl && (
206+
<p className="text-sm text-destructive">{errors.screenshotUrl.message}</p>
207+
)}
208+
</div>
209+
210+
<div className="space-y-2">
211+
<Label htmlFor="details">Additional Details</Label>
212+
<Textarea
213+
id="details"
214+
{...register('details')}
215+
placeholder="Detailed information about your stack, architecture, and design decisions..."
216+
rows={6}
217+
/>
218+
{errors.details && (
219+
<p className="text-sm text-destructive">{errors.details.message}</p>
220+
)}
221+
<p className="text-xs text-muted-foreground">
222+
You can use Markdown formatting here
223+
</p>
224+
</div>
225+
</CardContent>
226+
</Card>
227+
228+
<Card>
229+
<CardHeader>
230+
<CardTitle>Technologies</CardTitle>
231+
<CardDescription>
232+
Select the technologies used in your stack{' '}
233+
<span className="text-destructive">*</span>
234+
</CardDescription>
235+
</CardHeader>
236+
<CardContent className="space-y-4">
237+
<div className="space-y-2">
238+
<Label htmlFor="techSearch">Search Technologies</Label>
239+
<Input
240+
id="techSearch"
241+
value={techSearch}
242+
onChange={(e) => setTechSearch(e.target.value)}
243+
placeholder="Search by name..."
244+
/>
245+
</div>
246+
247+
{selectedTechIds.length > 0 && (
248+
<div className="rounded-md bg-muted p-4">
249+
<p className="text-sm font-medium mb-2">
250+
Selected: {selectedTechIds.length}{' '}
251+
{selectedTechIds.length === 1 ? 'technology' : 'technologies'}
252+
</p>
253+
<div className="flex flex-wrap gap-2">
254+
{selectedTechIds.map((id) => {
255+
const tech = technologies.find((t) => t.id === id)
256+
return (
257+
<button
258+
key={id}
259+
type="button"
260+
onClick={() => toggleTechnology(id)}
261+
className="inline-flex items-center gap-1 rounded-full bg-primary px-3 py-1 text-xs font-medium text-primary-foreground hover:bg-primary/90"
262+
>
263+
{tech?.name}
264+
<span>×</span>
265+
</button>
266+
)
267+
})}
268+
</div>
269+
</div>
270+
)}
271+
272+
<div className="max-h-96 overflow-y-auto rounded-md border">
273+
<div className="grid grid-cols-2 gap-2 p-4 sm:grid-cols-3">
274+
{filteredTechnologies.map((tech) => (
275+
<button
276+
key={tech.id}
277+
type="button"
278+
onClick={() => toggleTechnology(tech.id)}
279+
className={`rounded-md border px-3 py-2 text-sm transition-colors hover:bg-accent ${
280+
selectedTechIds.includes(tech.id)
281+
? 'border-primary bg-primary/10'
282+
: 'border-border'
283+
}`}
284+
>
285+
{tech.name}
286+
</button>
287+
))}
288+
</div>
289+
</div>
290+
291+
{errors.technologyIds && (
292+
<p className="text-sm text-destructive">{errors.technologyIds.message}</p>
293+
)}
294+
</CardContent>
295+
</Card>
296+
297+
<div className="flex gap-4">
298+
<Button type="submit" size="lg" disabled={isSubmitting}>
299+
{isSubmitting ? 'Creating...' : 'Create Stack'}
300+
</Button>
301+
<Button
302+
type="button"
303+
variant="outline"
304+
size="lg"
305+
onClick={() => router.back()}
306+
disabled={isSubmitting}
307+
>
308+
Cancel
309+
</Button>
310+
</div>
311+
</form>
312+
</div>
313+
)
314+
}

app-next/src/app/(browse)/stacks/page.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { getTechnologyStacks } from '@/lib/api/queries-server'
44
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
55
import { GridSkeleton } from '@/components/shared/loading-skeleton'
66
import { EmptyState } from '@/components/shared/error-state'
7+
import { PageHeader } from '@/components/browse/page-header'
78

89
export const dynamic = 'force-dynamic'
910

@@ -17,12 +18,12 @@ export default async function StacksPage({
1718

1819
return (
1920
<div className="container py-8">
20-
<div className="mb-8">
21-
<h1 className="text-4xl font-bold tracking-tight">Technology Stacks</h1>
22-
<p className="text-lg text-muted-foreground mt-2">
23-
Explore complete technology stacks from successful companies
24-
</p>
25-
</div>
21+
<PageHeader
22+
title="Technology Stacks"
23+
description="Explore complete technology stacks from successful companies"
24+
createHref="/stacks/new"
25+
createLabel="Create Stack"
26+
/>
2627

2728
<div className="mb-6">
2829
<form action="/stacks" method="get">

0 commit comments

Comments
 (0)