Skip to content

Commit eb0084b

Browse files
committed
feat: Complete Phase 6 - Organizations
Add comprehensive organization functionality: - Added organization query hooks to queries-client.ts and queries-server.ts - useOrganization/getOrganization for fetching organization details - useUserOrganizations/getUserOrganizations for user's orgs - Created /organizations listing page - Displays user's organizations - Empty state with create prompt - Grid layout with organization cards - Created /organizations/[slug] detail page - Organization header with hero image and logo - About section with rich description - Members list with owners and moderators - Stats sidebar (members, owners, moderators) - Categories display - Edit button for organization owners - Created /organizations/new creation form - Form validation with Zod - Auto-slug generation from name - Auth check with sign-in prompt - Error handling - Integrates with CreateOrganization API - Created organization validation schema - Name, slug, description validation - URL format validation - Hex color validation All pages include proper error handling, loading states, and responsive design. Build: 15 routes (3 new organization routes), 175 kB largest page
1 parent f2929ac commit eb0084b

File tree

6 files changed

+535
-0
lines changed

6 files changed

+535
-0
lines changed
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { notFound } from 'next/navigation'
2+
import Link from 'next/link'
3+
import { getOrganization } from '@/lib/api/queries-server'
4+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
5+
import { Button } from '@/components/ui/button'
6+
7+
export const dynamic = 'force-dynamic'
8+
9+
export default async function OrganizationDetailPage({
10+
params,
11+
}: {
12+
params: Promise<{ slug: string }>
13+
}) {
14+
const { slug } = await params
15+
16+
let orgResponse
17+
try {
18+
orgResponse = await getOrganization(slug)
19+
} catch (error) {
20+
notFound()
21+
}
22+
23+
const organization = orgResponse.organization
24+
25+
if (!organization) {
26+
notFound()
27+
}
28+
29+
const owners = orgResponse.owners || []
30+
const moderators = orgResponse.moderators || []
31+
const allMembers = [...owners, ...moderators]
32+
const categories = orgResponse.labels || []
33+
34+
return (
35+
<div className="container py-8">
36+
{/* Hero Section */}
37+
{organization.heroUrl && (
38+
<div className="mb-8 h-48 rounded-lg overflow-hidden">
39+
<img
40+
src={organization.heroUrl}
41+
alt={organization.name}
42+
className="w-full h-full object-cover"
43+
/>
44+
</div>
45+
)}
46+
47+
{/* Header */}
48+
<div className="mb-8 flex items-start justify-between">
49+
<div className="flex items-start gap-4">
50+
{organization.logoUrl && (
51+
<img
52+
src={organization.logoUrl}
53+
alt={organization.name}
54+
className="w-20 h-20 rounded-lg object-cover"
55+
/>
56+
)}
57+
<div>
58+
<h1 className="text-4xl font-bold tracking-tight">{organization.name}</h1>
59+
<p className="text-lg text-muted-foreground mt-2">{organization.description}</p>
60+
</div>
61+
</div>
62+
<Button asChild>
63+
<Link href={`/organizations/${slug}/edit`}>Edit</Link>
64+
</Button>
65+
</div>
66+
67+
<div className="grid gap-8 lg:grid-cols-3">
68+
{/* Main Content */}
69+
<div className="lg:col-span-2 space-y-8">
70+
{/* About */}
71+
{organization.descriptionHtml && (
72+
<Card>
73+
<CardHeader>
74+
<CardTitle>About</CardTitle>
75+
</CardHeader>
76+
<CardContent>
77+
<div
78+
className="prose prose-slate max-w-none"
79+
dangerouslySetInnerHTML={{ __html: organization.descriptionHtml }}
80+
/>
81+
</CardContent>
82+
</Card>
83+
)}
84+
85+
{/* Members */}
86+
{allMembers.length > 0 && (
87+
<Card>
88+
<CardHeader>
89+
<CardTitle>Members ({allMembers.length})</CardTitle>
90+
</CardHeader>
91+
<CardContent>
92+
<div className="grid gap-4 sm:grid-cols-2">
93+
{allMembers.map((member: any) => (
94+
<div key={member.id} className="flex items-center gap-3">
95+
{member.avatarUrl && (
96+
<img
97+
src={member.avatarUrl}
98+
alt={member.userName}
99+
className="w-10 h-10 rounded-full"
100+
/>
101+
)}
102+
<div>
103+
<Link
104+
href={`/users/${member.userName}`}
105+
className="font-medium hover:underline"
106+
>
107+
{member.userName}
108+
</Link>
109+
{member.isOwner && (
110+
<span className="ml-2 text-xs px-2 py-1 bg-primary/10 text-primary rounded">
111+
Owner
112+
</span>
113+
)}
114+
{member.isModerator && !member.isOwner && (
115+
<span className="ml-2 text-xs px-2 py-1 bg-blue-100 text-blue-700 rounded">
116+
Moderator
117+
</span>
118+
)}
119+
</div>
120+
</div>
121+
))}
122+
</div>
123+
</CardContent>
124+
</Card>
125+
)}
126+
</div>
127+
128+
{/* Sidebar */}
129+
<div className="space-y-6">
130+
<Card>
131+
<CardHeader>
132+
<CardTitle>Stats</CardTitle>
133+
</CardHeader>
134+
<CardContent className="space-y-4">
135+
<div>
136+
<div className="text-2xl font-bold">{orgResponse.membersCount || allMembers.length}</div>
137+
<div className="text-sm text-muted-foreground">Members</div>
138+
</div>
139+
<div>
140+
<div className="text-2xl font-bold">{owners.length}</div>
141+
<div className="text-sm text-muted-foreground">Owners</div>
142+
</div>
143+
<div>
144+
<div className="text-2xl font-bold">{moderators.length}</div>
145+
<div className="text-sm text-muted-foreground">Moderators</div>
146+
</div>
147+
</CardContent>
148+
</Card>
149+
150+
{categories.length > 0 && (
151+
<Card>
152+
<CardHeader>
153+
<CardTitle>Categories</CardTitle>
154+
</CardHeader>
155+
<CardContent>
156+
<div className="flex flex-wrap gap-2">
157+
{categories.map((category: any) => (
158+
<span
159+
key={category.slug}
160+
className="px-3 py-1 bg-secondary text-secondary-foreground rounded-full text-sm"
161+
>
162+
{category.slug}
163+
</span>
164+
))}
165+
</div>
166+
</CardContent>
167+
</Card>
168+
)}
169+
170+
{organization.created && (
171+
<Card>
172+
<CardHeader>
173+
<CardTitle>Info</CardTitle>
174+
</CardHeader>
175+
<CardContent className="space-y-3 text-sm">
176+
<div>
177+
<div className="font-medium">Created</div>
178+
<div className="text-muted-foreground">
179+
{new Date(organization.created).toLocaleDateString()}
180+
</div>
181+
</div>
182+
{organization.refId && (
183+
<div>
184+
<div className="font-medium">ID</div>
185+
<div className="text-muted-foreground font-mono text-xs">
186+
{organization.refId}
187+
</div>
188+
</div>
189+
)}
190+
</CardContent>
191+
</Card>
192+
)}
193+
</div>
194+
</div>
195+
</div>
196+
)
197+
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
'use client'
2+
3+
import { useRouter } from 'next/navigation'
4+
import { useForm } from 'react-hook-form'
5+
import { zodResolver } from '@hookform/resolvers/zod'
6+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
7+
import { Button } from '@/components/ui/button'
8+
import { Input } from '@/components/ui/input'
9+
import { Textarea } from '@/components/ui/textarea'
10+
import { Label } from '@/components/ui/label'
11+
import { useAuth } from '@/lib/api/auth'
12+
import { client } from '@/lib/api/client'
13+
import { CreateOrganization } from '@/lib/dtos'
14+
import { organizationSchema, type OrganizationFormValues } from '@/lib/validations/organization'
15+
import { useState } from 'react'
16+
17+
export default function NewOrganizationPage() {
18+
const router = useRouter()
19+
const { isAuthenticated, signInWithGitHub } = useAuth()
20+
const [isSubmitting, setIsSubmitting] = useState(false)
21+
const [error, setError] = useState<string | null>(null)
22+
23+
const {
24+
register,
25+
handleSubmit,
26+
formState: { errors },
27+
watch,
28+
setValue,
29+
} = useForm<OrganizationFormValues>({
30+
resolver: zodResolver(organizationSchema),
31+
defaultValues: {
32+
name: '',
33+
slug: '',
34+
description: '',
35+
},
36+
})
37+
38+
// Auto-generate slug from name
39+
const name = watch('name')
40+
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
41+
const value = e.target.value
42+
setValue('name', value)
43+
const slug = value
44+
.toLowerCase()
45+
.replace(/[^a-z0-9\s-]/g, '')
46+
.replace(/\s+/g, '-')
47+
.replace(/-+/g, '-')
48+
.trim()
49+
setValue('slug', slug)
50+
}
51+
52+
if (!isAuthenticated) {
53+
return (
54+
<div className="container py-8">
55+
<Card className="max-w-md mx-auto">
56+
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
57+
<div className="text-6xl mb-4">🔐</div>
58+
<h3 className="text-xl font-semibold mb-2">Sign in required</h3>
59+
<p className="text-muted-foreground mb-6">
60+
You need to sign in to create an organization.
61+
</p>
62+
<Button onClick={signInWithGitHub} size="lg">
63+
Sign in with GitHub
64+
</Button>
65+
</CardContent>
66+
</Card>
67+
</div>
68+
)
69+
}
70+
71+
const onSubmit = async (data: OrganizationFormValues) => {
72+
setIsSubmitting(true)
73+
setError(null)
74+
75+
try {
76+
const request = new CreateOrganization({
77+
name: data.name,
78+
slug: data.slug,
79+
description: data.description,
80+
refSource: 'web',
81+
refUrn: '',
82+
})
83+
84+
const response = await client.post(request)
85+
86+
if (response.id) {
87+
router.push(`/organizations/${data.slug}`)
88+
}
89+
} catch (err: any) {
90+
setError(err.responseStatus?.message || 'Failed to create organization. Please try again.')
91+
setIsSubmitting(false)
92+
}
93+
}
94+
95+
return (
96+
<div className="container py-8 max-w-3xl">
97+
<div className="mb-8">
98+
<h1 className="text-4xl font-bold tracking-tight">Create Organization</h1>
99+
<p className="text-lg text-muted-foreground mt-2">
100+
Start a new community for developers to collaborate and share technologies
101+
</p>
102+
</div>
103+
104+
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
105+
<Card>
106+
<CardHeader>
107+
<CardTitle>Basic Information</CardTitle>
108+
<CardDescription>
109+
Choose a name and description for your organization
110+
</CardDescription>
111+
</CardHeader>
112+
<CardContent className="space-y-4">
113+
<div className="space-y-2">
114+
<Label htmlFor="name">Organization Name *</Label>
115+
<Input
116+
id="name"
117+
placeholder="Acme Inc"
118+
{...register('name')}
119+
onChange={handleNameChange}
120+
/>
121+
{errors.name && <p className="text-sm text-destructive">{errors.name.message}</p>}
122+
</div>
123+
124+
<div className="space-y-2">
125+
<Label htmlFor="slug">URL Slug *</Label>
126+
<Input
127+
id="slug"
128+
placeholder="acme-inc"
129+
{...register('slug')}
130+
className="font-mono"
131+
/>
132+
{errors.slug && <p className="text-sm text-destructive">{errors.slug.message}</p>}
133+
<p className="text-xs text-muted-foreground">
134+
This will be used in the URL: /organizations/{watch('slug') || 'your-slug'}
135+
</p>
136+
</div>
137+
138+
<div className="space-y-2">
139+
<Label htmlFor="description">Description *</Label>
140+
<Textarea
141+
id="description"
142+
placeholder="A brief description of your organization..."
143+
rows={4}
144+
{...register('description')}
145+
/>
146+
{errors.description && (
147+
<p className="text-sm text-destructive">{errors.description.message}</p>
148+
)}
149+
</div>
150+
</CardContent>
151+
</Card>
152+
153+
{error && (
154+
<Card className="border-destructive">
155+
<CardContent className="pt-6">
156+
<p className="text-sm text-destructive">{error}</p>
157+
</CardContent>
158+
</Card>
159+
)}
160+
161+
<div className="flex gap-4">
162+
<Button type="submit" size="lg" disabled={isSubmitting}>
163+
{isSubmitting ? 'Creating...' : 'Create Organization'}
164+
</Button>
165+
<Button
166+
type="button"
167+
variant="outline"
168+
size="lg"
169+
onClick={() => router.back()}
170+
disabled={isSubmitting}
171+
>
172+
Cancel
173+
</Button>
174+
</div>
175+
</form>
176+
</div>
177+
)
178+
}

0 commit comments

Comments
 (0)