Skip to content
Open
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
88 changes: 88 additions & 0 deletions app/(admin)/dashboard/boards/competitions/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { getActiveCompetition, getCompetitionCategories, getCompetitionEntries } from '@/lib/server/competition-public'

export default async function CompetitionsPage() {
const competition = await getActiveCompetition()

if (!competition) {
return (
<div className="rounded-xl border border-dashed border-border p-8 text-center text-muted-foreground">
Belum ada kompetisi aktif yang bisa dikelola.
</div>
)
}

const [categories, entriesResult] = await Promise.all([
getCompetitionCategories(competition.id),
getCompetitionEntries({
competitionId: competition.id,
sort: 'top',
}),
])

const entries = entriesResult?.entries ?? []

return (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader>
<CardTitle>Kompetisi aktif</CardTitle>
</CardHeader>
<CardContent className="space-y-1">
<p className="font-semibold text-lg">{competition.title}</p>
<p className="text-sm text-muted-foreground">{competition.tagline}</p>
</CardContent>
</Card>

<Card>
<CardHeader>
<CardTitle>Total entry publik</CardTitle>
</CardHeader>
<CardContent>
<p className="font-semibold text-3xl">{entries.length}</p>
</CardContent>
</Card>

<Card>
<CardHeader>
<CardTitle>Kategori aktif</CardTitle>
</CardHeader>
<CardContent>
<p className="font-semibold text-3xl">{categories.length}</p>
</CardContent>
</Card>
</div>

<Card>
<CardHeader>
<CardTitle>Top entries sementara</CardTitle>
</CardHeader>
<CardContent>
{entries.length === 0 ? (
<p className="text-sm text-muted-foreground">Belum ada entry yang tayang.</p>
) : (
<div className="space-y-3">
{entries.slice(0, 10).map((entry) => (
<div
key={entry.id}
className="flex flex-col gap-2 rounded-xl border border-border p-4 md:flex-row md:items-center md:justify-between"
>
<div>
<p className="font-medium">{entry.title}</p>
<p className="text-sm text-muted-foreground">
{entry.author?.displayName || 'Peserta'} • {entry.category?.label || 'Umum'}
</p>
</div>
<div className="text-sm text-muted-foreground">
{entry.voteCount} vote • {entry.commentCount} komentar
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}
15 changes: 15 additions & 0 deletions app/(admin)/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import {
IconNews,
IconNotification,
IconSettings2,
IconTrophy,
IconUsers,
} from '@tabler/icons-react'
import { Header } from '@/components/admin-panel/header'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import Analytics from './boards/analytics'
import BlogPage from './boards/blog/page'
import CommentsPage from './boards/comments/page'
import CompetitionsPage from './boards/competitions/page'
import EventsApproval from './boards/events-approval/page'
import Overview from './boards/overview'
import ProjectsPage from './boards/projects/page'
Expand Down Expand Up @@ -112,6 +114,13 @@ export default async function Dashboard1Page({ searchParams }: { searchParams: P
<IconUsers size={16} />
Users
</TabsTrigger>
<TabsTrigger
value="competitions"
className="flex items-center gap-2"
>
<IconTrophy size={16} />
Competitions
</TabsTrigger>
<TabsTrigger
value="comments"
className="flex items-center gap-2"
Expand Down Expand Up @@ -157,6 +166,12 @@ export default async function Dashboard1Page({ searchParams }: { searchParams: P
>
<UsersPage searchParams={searchParams} />
</TabsContent>
<TabsContent
value="competitions"
className="space-y-4"
>
<CompetitionsPage />
</TabsContent>
<TabsContent
value="comments"
className="space-y-4"
Expand Down
241 changes: 241 additions & 0 deletions app/competition/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
import { ExternalLink, MessageCircle, Video } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { CompetitionVoteButton } from '@/components/competition/competition-vote-button'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { CommentSection } from '@/components/ui/comment-section'
import { Footer } from '@/components/ui/footer'
import { Navbar } from '@/components/ui/navbar'
import { OptimizedAvatar } from '@/components/ui/optimized-avatar'
import { UserDisplayName } from '@/components/ui/user-display-name'
import { getComments } from '@/lib/actions/comments'
import { getCurrentUser } from '@/lib/server/auth'
import { getCompetitionEntryBySlug } from '@/lib/server/competition-public'

function isCompetitionOpen(startsAt: string, endsAt: string) {
const now = new Date()
return now >= new Date(startsAt) && now < new Date(endsAt)
}

export default async function CompetitionEntryPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const currentUser = await getCurrentUser()
const entry = await getCompetitionEntryBySlug(slug, currentUser?.id)

if (!entry) {
notFound()
}

const { comments: initialComments } = await getComments('competition', entry.id)
const user = currentUser
? {
name: currentUser.name,
email: currentUser.email,
avatar: currentUser.avatar,
username: currentUser.username,
role: currentUser.role,
}
: null

const competitionOpen = isCompetitionOpen(entry.competition.startsAt, entry.competition.endsAt)

return (
<div className="min-h-screen bg-background">
<div className="relative min-h-screen bg-grid-pattern">
<div className="absolute inset-0 bg-gradient-to-b from-background/80 via-background/60 to-background/80" />

<Navbar
showNavigation={true}
showBackButton={true}
isLoggedIn={Boolean(currentUser)}
user={user ?? undefined}
/>

<main className="relative mx-auto grid max-w-6xl gap-8 px-4 pt-24 pb-12 sm:px-6 lg:grid-cols-[minmax(0,1fr)_320px] lg:px-8">
<div className="space-y-8">
<div className="overflow-hidden rounded-3xl border border-border">
<Image
src={entry.thumbnailUrl}
alt={entry.title}
width={1440}
height={810}
className="h-full w-full object-cover"
/>
</div>

<div className="space-y-5">
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<span className="rounded-full bg-primary/10 px-3 py-1 text-primary">
{entry.category?.label || 'Umum'}
</span>
<span>{new Date(entry.submittedAt).toLocaleDateString('id-ID')}</span>
<span className="inline-flex items-center gap-1">
<MessageCircle className="h-4 w-4" />
{initialComments.length} komentar
</span>
</div>

<div className="space-y-2">
<h1 className="font-bold text-4xl tracking-tight">{entry.title}</h1>
<p className="text-lg text-muted-foreground">{entry.tagline}</p>
</div>

{entry.author ? (
<Link
href={`/${entry.author.username}`}
className="inline-flex items-center gap-3"
>
<OptimizedAvatar
src={entry.author.avatarUrl}
alt={entry.author.displayName}
size="md"
showSkeleton={false}
/>
<div className="space-y-1">
<UserDisplayName
name={entry.author.displayName}
role={entry.author.role}
/>
<p className="text-sm text-muted-foreground">@{entry.author.username}</p>
</div>
</Link>
) : null}
</div>

<Card>
<CardContent className="space-y-6 py-6">
<section className="space-y-2">
<h2 className="font-semibold text-xl">Tentang entry ini</h2>
<div className="whitespace-pre-line text-sm leading-7 text-muted-foreground">{entry.description}</div>
</section>

<section className="space-y-2">
<h2 className="font-semibold text-xl">Proses vibe coding</h2>
<div className="whitespace-pre-line text-sm leading-7 text-muted-foreground">
{entry.processSummary}
</div>
</section>

<div className="grid gap-6 md:grid-cols-2">
<section className="space-y-2">
<h2 className="font-semibold text-xl">AI tools</h2>
<p className="text-sm text-muted-foreground">{entry.aiToolsUsed.join(', ')}</p>
</section>

<section className="space-y-2">
<h2 className="font-semibold text-xl">Tech stack</h2>
<p className="text-sm text-muted-foreground">{entry.techStacks.join(', ')}</p>
</section>
</div>

{entry.galleryUrls.length > 0 ? (
<section className="space-y-3">
<h2 className="font-semibold text-xl">Galeri</h2>
<div className="grid gap-4 sm:grid-cols-2">
{entry.galleryUrls.map((url) => (
<div
key={url}
className="overflow-hidden rounded-2xl border border-border"
>
<Image
src={url}
alt={entry.title}
width={1280}
height={720}
className="h-full w-full object-cover"
/>
</div>
))}
</div>
</section>
) : null}
</CardContent>
</Card>

<CommentSection
entityType="competition"
entityId={entry.id}
initialComments={initialComments}
isLoggedIn={Boolean(currentUser)}
currentUser={
currentUser
? {
id: currentUser.id,
name: currentUser.name,
avatar: currentUser.avatar,
}
: null
}
allowGuest={false}
/>
</div>

<div className="space-y-6">
<Card>
<CardContent className="space-y-4 py-6">
<CompetitionVoteButton
entryId={entry.id}
initialVoteCount={entry.voteCount}
initialHasVoted={entry.hasVoted}
isLoggedIn={Boolean(currentUser)}
isOwner={currentUser?.id === entry.userId}
isCompetitionOpen={competitionOpen}
/>

<Button
asChild
className="w-full"
>
<a
href={entry.demoUrl}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="h-4 w-4" />
Buka demo
</a>
</Button>

<Button
asChild
variant="outline"
className="w-full"
>
<a
href={entry.repoUrl}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="h-4 w-4" />
Lihat repo
</a>
</Button>

{entry.videoUrl ? (
<Button
asChild
variant="outline"
className="w-full"
>
<a
href={entry.videoUrl}
target="_blank"
rel="noopener noreferrer"
>
<Video className="h-4 w-4" />
Tonton video
</a>
</Button>
) : null}
</CardContent>
</Card>
</div>
</main>

<Footer />
</div>
</div>
)
}
Loading
Loading