diff --git a/app/(admin)/dashboard/boards/competitions/page.tsx b/app/(admin)/dashboard/boards/competitions/page.tsx new file mode 100644 index 0000000..cd15f1f --- /dev/null +++ b/app/(admin)/dashboard/boards/competitions/page.tsx @@ -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 ( +
+ Belum ada kompetisi aktif yang bisa dikelola. +
+ ) + } + + const [categories, entriesResult] = await Promise.all([ + getCompetitionCategories(competition.id), + getCompetitionEntries({ + competitionId: competition.id, + sort: 'top', + }), + ]) + + const entries = entriesResult?.entries ?? [] + + return ( +
+
+ + + Kompetisi aktif + + +

{competition.title}

+

{competition.tagline}

+
+
+ + + + Total entry publik + + +

{entries.length}

+
+
+ + + + Kategori aktif + + +

{categories.length}

+
+
+
+ + + + Top entries sementara + + + {entries.length === 0 ? ( +

Belum ada entry yang tayang.

+ ) : ( +
+ {entries.slice(0, 10).map((entry) => ( +
+
+

{entry.title}

+

+ {entry.author?.displayName || 'Peserta'} • {entry.category?.label || 'Umum'} +

+
+
+ {entry.voteCount} vote • {entry.commentCount} komentar +
+
+ ))} +
+ )} +
+
+
+ ) +} diff --git a/app/(admin)/dashboard/page.tsx b/app/(admin)/dashboard/page.tsx index c6734bc..74cf565 100644 --- a/app/(admin)/dashboard/page.tsx +++ b/app/(admin)/dashboard/page.tsx @@ -7,6 +7,7 @@ import { IconNews, IconNotification, IconSettings2, + IconTrophy, IconUsers, } from '@tabler/icons-react' import { Header } from '@/components/admin-panel/header' @@ -14,6 +15,7 @@ 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' @@ -112,6 +114,13 @@ export default async function Dashboard1Page({ searchParams }: { searchParams: P Users + + + Competitions + + + + = 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 ( +
+
+
+ + + +
+
+
+ {entry.title} +
+ +
+
+ + {entry.category?.label || 'Umum'} + + {new Date(entry.submittedAt).toLocaleDateString('id-ID')} + + + {initialComments.length} komentar + +
+ +
+

{entry.title}

+

{entry.tagline}

+
+ + {entry.author ? ( + + +
+ +

@{entry.author.username}

+
+ + ) : null} +
+ + + +
+

Tentang entry ini

+
{entry.description}
+
+ +
+

Proses vibe coding

+
+ {entry.processSummary} +
+
+ +
+
+

AI tools

+

{entry.aiToolsUsed.join(', ')}

+
+ +
+

Tech stack

+

{entry.techStacks.join(', ')}

+
+
+ + {entry.galleryUrls.length > 0 ? ( +
+

Galeri

+
+ {entry.galleryUrls.map((url) => ( +
+ {entry.title} +
+ ))} +
+
+ ) : null} +
+
+ + +
+ +
+ + + + + + + + + {entry.videoUrl ? ( + + ) : null} + + +
+
+ +
+
+
+ ) +} diff --git a/app/competition/list/competition-list-client.tsx b/app/competition/list/competition-list-client.tsx new file mode 100644 index 0000000..c402858 --- /dev/null +++ b/app/competition/list/competition-list-client.tsx @@ -0,0 +1,45 @@ +'use client' + +import { CompetitionEntryFeed } from '@/components/competition/competition-entry-feed' +import { CompetitionSortControls } from '@/components/competition/competition-sort-controls' +import type { CompetitionEntrySummary, CompetitionSort } from '@/types/competition' + +interface CompetitionListClientProps { + entries: CompetitionEntrySummary[] + sort: CompetitionSort + isLoggedIn: boolean + currentUserId?: string + voteState: Record + isCompetitionOpen: boolean +} + +export function CompetitionListClient({ + entries, + sort, + isLoggedIn, + currentUserId, + voteState, + isCompetitionOpen, +}: CompetitionListClientProps) { + return ( +
+
+
+

Feed entry publik

+

+ Urutan “Teratas” memakai vote_count desc lalu submitted_at asc. +

+
+ +
+ + +
+ ) +} diff --git a/app/competition/list/page.tsx b/app/competition/list/page.tsx new file mode 100644 index 0000000..4bba3d4 --- /dev/null +++ b/app/competition/list/page.tsx @@ -0,0 +1,96 @@ +import { notFound } from 'next/navigation' +import { Footer } from '@/components/ui/footer' +import { Navbar } from '@/components/ui/navbar' +import { getCurrentUser } from '@/lib/server/auth' +import { + getActiveCompetition, + getCompetitionEntries, + getCompetitionEntryVoteState, +} from '@/lib/server/competition-public' +import type { CompetitionSort } from '@/types/competition' +import { CompetitionListClient } from './competition-list-client' + +type SearchParams = Promise<{ sort?: string }> + +function normalizeSort(sort?: string): CompetitionSort { + return sort === 'oldest' || sort === 'top' ? sort : 'newest' +} + +function isCompetitionOpen(startsAt: string, endsAt: string) { + const now = new Date() + return now >= new Date(startsAt) && now < new Date(endsAt) +} + +export default async function CompetitionListPage({ searchParams }: { searchParams: SearchParams }) { + const [{ sort }, competition, currentUser] = await Promise.all([ + searchParams, + getActiveCompetition(), + getCurrentUser(), + ]) + + if (!competition) { + notFound() + } + + const normalizedSort = normalizeSort(sort) + const entriesResult = await getCompetitionEntries({ + competitionId: competition.id, + sort: normalizedSort, + userId: currentUser?.id, + }) + + if (!entriesResult) { + notFound() + } + + const voteState = currentUser + ? await getCompetitionEntryVoteState( + entriesResult.entries.map((entry) => entry.id), + currentUser.id, + ) + : {} + + const user = currentUser + ? { + name: currentUser.name, + email: currentUser.email, + avatar: currentUser.avatar, + username: currentUser.username, + role: currentUser.role, + } + : null + + return ( +
+
+
+ + + +
+
+

Mini Vibeathon Entries

+

+ Jelajahi semua entry publik dari kompetisi aktif dan dukung karya favoritmu dengan vote. +

+
+ + +
+ +
+
+
+ ) +} diff --git a/app/competition/page.tsx b/app/competition/page.tsx new file mode 100644 index 0000000..5028f5f --- /dev/null +++ b/app/competition/page.tsx @@ -0,0 +1,90 @@ +import { notFound } from 'next/navigation' +import { CompetitionFaq } from '@/components/competition/competition-faq' +import { CompetitionHero } from '@/components/competition/competition-hero' +import { CompetitionRules } from '@/components/competition/competition-rules' +import { CompetitionTimeline } from '@/components/competition/competition-timeline' +import { Footer } from '@/components/ui/footer' +import { Navbar } from '@/components/ui/navbar' +import { getCurrentUser } from '@/lib/server/auth' +import { getActiveCompetition, getCompetitionCategories } 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 CompetitionPage() { + const [competition, currentUser] = await Promise.all([getActiveCompetition(), getCurrentUser()]) + + if (!competition) { + notFound() + } + + const categories = await getCompetitionCategories(competition.id) + const user = currentUser + ? { + name: currentUser.name, + email: currentUser.email, + avatar: currentUser.avatar, + username: currentUser.username, + role: currentUser.role, + } + : null + + const competitionOpen = isCompetitionOpen(competition.startsAt, competition.endsAt) + + return ( +
+
+
+ + + +
+ + + {categories.length > 0 ? ( +
+

Kategori kompetisi

+
+ {categories.map((category) => ( + + {category.label} + + ))} +
+
+ ) : null} + +
+ + +
+ +
+ + +
+
+ +
+
+
+ ) +} diff --git a/app/competition/submit/page.tsx b/app/competition/submit/page.tsx new file mode 100644 index 0000000..4068af2 --- /dev/null +++ b/app/competition/submit/page.tsx @@ -0,0 +1,63 @@ +import { redirect } from 'next/navigation' +import { CompetitionSubmitForm } from '@/components/competition/competition-submit-form' +import { Card, CardContent } from '@/components/ui/card' +import { getActiveCompetition, getCompetitionCategories } from '@/lib/server/competition-public' +import { createClient } from '@/lib/supabase/server' + +function isCompetitionOpen(startsAt: string, endsAt: string) { + const now = new Date() + return now >= new Date(startsAt) && now < new Date(endsAt) +} + +export default async function CompetitionSubmitPage() { + const supabase = await createClient() + const redirectTo = '/competition/submit' + const { + data: { user }, + error, + } = await supabase.auth.getUser() + + if (error || !user) { + redirect(`/user/auth?redirectTo=${redirectTo}`) + } + + const competition = await getActiveCompetition() + if (!competition) { + redirect('/competition') + } + + const categories = await getCompetitionCategories(competition.id) + const competitionOpen = isCompetitionOpen(competition.startsAt, competition.endsAt) + + return ( +
+
+ +
+
+
+

Kirim entry Mini Vibeathon

+

+ Submission akan langsung tayang di feed publik dan tidak bisa diedit setelah dikirim. +

+
+ + {!competitionOpen ? ( + + +

+ Submission belum dibuka atau sudah ditutup. Pantau halaman kompetisi untuk timeline resminya. +

+
+
+ ) : ( + + )} +
+
+
+ ) +} diff --git a/app/sitemap.ts b/app/sitemap.ts index 053e6d8..7957f78 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -6,6 +6,9 @@ export default async function sitemap(): Promise { // Static core routes. Add dynamic routes (projects/users) here if desired. const routes = [ '', + '/competition', + '/competition/list', + '/competition/submit', '/project/list', '/project/submit', '/user/auth', diff --git a/components/competition/competition-entry-card.tsx b/components/competition/competition-entry-card.tsx new file mode 100644 index 0000000..09eb435 --- /dev/null +++ b/components/competition/competition-entry-card.tsx @@ -0,0 +1,105 @@ +import { MessageCircle } from 'lucide-react' +import Image from 'next/image' +import Link from 'next/link' +import { CompetitionVoteButton } from '@/components/competition/competition-vote-button' +import { Card, CardContent } from '@/components/ui/card' +import { OptimizedAvatar } from '@/components/ui/optimized-avatar' +import { UserDisplayName } from '@/components/ui/user-display-name' +import type { CompetitionEntrySummary } from '@/types/competition' + +interface CompetitionEntryCardProps { + entry: CompetitionEntrySummary + rank?: number + isLoggedIn: boolean + currentUserId?: string + hasVoted: boolean + isCompetitionOpen: boolean +} + +export function CompetitionEntryCard({ + entry, + rank, + isLoggedIn, + currentUserId, + hasVoted, + isCompetitionOpen, +}: CompetitionEntryCardProps) { + const isOwner = currentUserId === entry.userId + + return ( + + +
+ +
+ #{rank ?? 0} +
+ {entry.title} + + +
+
+ +

{entry.title}

+ +

{entry.tagline}

+
+ +
+ + {entry.category?.label || 'Umum'} + + {new Date(entry.submittedAt).toLocaleDateString('id-ID')} + + + {entry.commentCount} + +
+ + {entry.author ? ( + + + + + ) : null} +
+ +
+ +
+
+
+
+ ) +} diff --git a/components/competition/competition-entry-feed.tsx b/components/competition/competition-entry-feed.tsx new file mode 100644 index 0000000..e254a0b --- /dev/null +++ b/components/competition/competition-entry-feed.tsx @@ -0,0 +1,42 @@ +import { CompetitionEntryCard } from '@/components/competition/competition-entry-card' +import type { CompetitionEntrySummary } from '@/types/competition' + +interface CompetitionEntryFeedProps { + entries: CompetitionEntrySummary[] + isLoggedIn: boolean + currentUserId?: string + voteState: Record + isCompetitionOpen: boolean +} + +export function CompetitionEntryFeed({ + entries, + isLoggedIn, + currentUserId, + voteState, + isCompetitionOpen, +}: CompetitionEntryFeedProps) { + if (entries.length === 0) { + return ( +
+ Belum ada entry yang tayang untuk kompetisi ini. +
+ ) + } + + return ( +
+ {entries.map((entry, index) => ( + + ))} +
+ ) +} diff --git a/components/competition/competition-faq.tsx b/components/competition/competition-faq.tsx new file mode 100644 index 0000000..346d1ce --- /dev/null +++ b/components/competition/competition-faq.tsx @@ -0,0 +1,31 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import type { CompetitionFaqItem } from '@/types/competition' + +interface CompetitionFaqProps { + items: CompetitionFaqItem[] +} + +export function CompetitionFaq({ items }: CompetitionFaqProps) { + if (items.length === 0) { + return null + } + + return ( + + + FAQ + + + {items.map((item) => ( +
+

{item.question}

+

{item.answer}

+
+ ))} +
+
+ ) +} diff --git a/components/competition/competition-hero.tsx b/components/competition/competition-hero.tsx new file mode 100644 index 0000000..cad8abb --- /dev/null +++ b/components/competition/competition-hero.tsx @@ -0,0 +1,45 @@ +import Link from 'next/link' +import { Button } from '@/components/ui/button' +import type { Competition } from '@/types/competition' + +interface CompetitionHeroProps { + competition: Competition + isOpen: boolean +} + +export function CompetitionHero({ competition, isOpen }: CompetitionHeroProps) { + return ( +
+
+ + {competition.title} + +
+

{competition.tagline}

+

{competition.description}

+
+
+ Hadiah: {competition.prizeText} + + + {new Date(competition.startsAt).toLocaleDateString('id-ID')} -{' '} + {new Date(competition.endsAt).toLocaleDateString('id-ID')} + + + {isOpen ? 'Submission & voting sedang berjalan' : 'Kompetisi belum dibuka atau sudah selesai'} +
+
+ + +
+
+
+ ) +} diff --git a/components/competition/competition-rules.tsx b/components/competition/competition-rules.tsx new file mode 100644 index 0000000..1361762 --- /dev/null +++ b/components/competition/competition-rules.tsx @@ -0,0 +1,23 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' + +interface CompetitionRulesProps { + title: string + content: string +} + +export function CompetitionRules({ title, content }: CompetitionRulesProps) { + if (!content.trim()) { + return null + } + + return ( + + + {title} + + +
{content}
+
+
+ ) +} diff --git a/components/competition/competition-sort-controls.tsx b/components/competition/competition-sort-controls.tsx new file mode 100644 index 0000000..8b4bbbc --- /dev/null +++ b/components/competition/competition-sort-controls.tsx @@ -0,0 +1,44 @@ +'use client' + +import { usePathname, useRouter, useSearchParams } from 'next/navigation' +import type { CompetitionSort } from '@/types/competition' + +interface CompetitionSortControlsProps { + value: CompetitionSort +} + +const SORT_LABELS: Record = { + newest: 'Terbaru', + oldest: 'Terlama', + top: 'Teratas', +} + +export function CompetitionSortControls({ value }: CompetitionSortControlsProps) { + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + + return ( + + ) +} diff --git a/components/competition/competition-submit-form.tsx b/components/competition/competition-submit-form.tsx new file mode 100644 index 0000000..0965134 --- /dev/null +++ b/components/competition/competition-submit-form.tsx @@ -0,0 +1,580 @@ +'use client' + +import { Loader2, Trash2, Upload } from 'lucide-react' +import Image from 'next/image' +import { useRouter } from 'next/navigation' +import { useMemo, useState } from 'react' +import { toast } from 'sonner' +import { + type CompetitionDraftSubmission, + CompetitionSubmitPreview, +} from '@/components/competition/competition-submit-preview' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { submitCompetitionEntry } from '@/lib/actions/competition' +import { UploadButton } from '@/lib/uploadthing-client' +import type { CompetitionCategory } from '@/types/competition' + +interface CompetitionSubmitFormProps { + competitionId: string + categories: CompetitionCategory[] +} + +interface FormState { + categoryId: string + title: string + tagline: string + description: string + processSummary: string + aiToolsInput: string + techStacksInput: string + demoUrl: string + repoUrl: string + thumbnailUrl: string + thumbnailKey: string + galleryUrls: string[] + galleryKeys: string[] + videoUrl: string +} + +const INITIAL_STATE: FormState = { + categoryId: '', + title: '', + tagline: '', + description: '', + processSummary: '', + aiToolsInput: '', + techStacksInput: '', + demoUrl: '', + repoUrl: '', + thumbnailUrl: '', + thumbnailKey: '', + galleryUrls: [], + galleryKeys: [], + videoUrl: '', +} + +function parseListInput(value: string): string[] { + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean) +} + +function buildPreviewDraft( + formState: FormState, + categories: CompetitionCategory[], + competitionId: string, +): CompetitionDraftSubmission { + return { + competitionId, + categoryId: formState.categoryId, + categoryLabel: categories.find((category) => category.id === formState.categoryId)?.label || 'Belum dipilih', + title: formState.title.trim(), + tagline: formState.tagline.trim(), + description: formState.description.trim(), + processSummary: formState.processSummary.trim(), + aiToolsUsed: parseListInput(formState.aiToolsInput), + techStacks: parseListInput(formState.techStacksInput), + demoUrl: formState.demoUrl.trim(), + repoUrl: formState.repoUrl.trim(), + thumbnailUrl: formState.thumbnailUrl, + thumbnailKey: formState.thumbnailKey, + galleryUrls: formState.galleryUrls, + galleryKeys: formState.galleryKeys, + videoUrl: formState.videoUrl.trim(), + } +} + +export function CompetitionSubmitForm({ competitionId, categories }: CompetitionSubmitFormProps) { + const router = useRouter() + const [formState, setFormState] = useState(INITIAL_STATE) + const [fieldErrors, setFieldErrors] = useState>({}) + const [isPreviewing, setIsPreviewing] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + + const previewDraft = useMemo( + () => buildPreviewDraft(formState, categories, competitionId), + [competitionId, categories, formState], + ) + + const validateBeforePreview = () => { + const errors: Record = {} + + if (!previewDraft.categoryId) errors.category_id = ['Pilih kategori terlebih dahulu.'] + if (!previewDraft.title) errors.title = ['Judul wajib diisi.'] + if (!previewDraft.tagline) errors.tagline = ['Tagline wajib diisi.'] + if (!previewDraft.description) errors.description = ['Deskripsi wajib diisi.'] + if (!previewDraft.processSummary) errors.process_summary = ['Ringkasan proses wajib diisi.'] + if (!previewDraft.demoUrl) errors.demo_url = ['Demo URL wajib diisi.'] + if (!previewDraft.repoUrl) errors.repo_url = ['Repo URL wajib diisi.'] + if (!previewDraft.thumbnailUrl) errors.thumbnail_url = ['Thumbnail wajib diupload.'] + if (previewDraft.aiToolsUsed.length === 0) errors.ai_tools_used = ['Isi minimal satu AI tool.'] + if (previewDraft.techStacks.length === 0) errors.tech_stacks = ['Isi minimal satu tech stack.'] + if (previewDraft.galleryUrls.length === 0 && !previewDraft.videoUrl) { + errors.gallery_urls = ['Tambahkan minimal satu gambar galeri atau video demo.'] + } + + setFieldErrors(errors) + + if (Object.keys(errors).length > 0) { + toast.error('Lengkapi data wajib sebelum lanjut ke preview.') + return false + } + + return true + } + + const submitDraft = async () => { + setIsSubmitting(true) + setFieldErrors({}) + + const formData = new FormData() + formData.set('competition_id', competitionId) + formData.set('category_id', previewDraft.categoryId) + formData.set('title', previewDraft.title) + formData.set('tagline', previewDraft.tagline) + formData.set('description', previewDraft.description) + formData.set('process_summary', previewDraft.processSummary) + formData.set('ai_tools_used', JSON.stringify(previewDraft.aiToolsUsed)) + formData.set('tech_stacks', JSON.stringify(previewDraft.techStacks)) + formData.set('demo_url', previewDraft.demoUrl) + formData.set('repo_url', previewDraft.repoUrl) + formData.set('thumbnail_url', previewDraft.thumbnailUrl) + formData.set('thumbnail_key', previewDraft.thumbnailKey) + formData.set('gallery_urls', JSON.stringify(previewDraft.galleryUrls)) + formData.set('gallery_keys', JSON.stringify(previewDraft.galleryKeys)) + formData.set('video_url', previewDraft.videoUrl) + + const result = await submitCompetitionEntry(formData) + + if (!result.success) { + setFieldErrors((result.fieldErrors ?? {}) as Record) + toast.error(result.error ?? 'Gagal menyimpan submission.') + setIsSubmitting(false) + setIsPreviewing(false) + return + } + + toast.success('Entry kompetisi berhasil dipublikasikan.') + router.push(`/competition/${result.slug}`) + router.refresh() + } + + if (isPreviewing) { + return ( + setIsPreviewing(false)} + onSubmit={() => { + void submitDraft() + }} + /> + ) + } + + return ( + + + Form submission + + +
+
+ + + {fieldErrors.category_id?.map((error) => ( +

+ {error} +

+ ))} +
+ +
+ + setFormState((current) => ({ ...current, title: event.target.value }))} + placeholder="Nama proyek atau eksperimenmu" + /> + {fieldErrors.title?.map((error) => ( +

+ {error} +

+ ))} +
+
+ +
+ + setFormState((current) => ({ ...current, tagline: event.target.value }))} + placeholder="Satu kalimat pendek yang menjelaskan value utamamu" + /> + {fieldErrors.tagline?.map((error) => ( +

+ {error} +

+ ))} +
+ +
+ +