feat: update hackathon data and enhance components
- Removed existing submissions data from hackathons.json. - Added new components for Hackathon details including HackathonContent, HackathonHeader, HackathonHero, HackathonPartners, HackathonQuickInfo, HackathonRequirements, HackathonSidebar, HackathonStatusBar, HackathonSubmissions, HackathonTabs, and ServerHackathonContent. - Integrated framer-motion for animations in Hackathon components. - Updated package.json and package-lock.json to include nextjs-toploader dependency.
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||
|
||||
interface HackathonContentProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function HackathonContent({ content }: HackathonContentProps) {
|
||||
return (
|
||||
<div className="prose w-full max-w-none">
|
||||
<MDXRemote source={content} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||
import { HackathonContent } from '@/content/hackathons/types';
|
||||
import {
|
||||
getDaysLeftText,
|
||||
getProgressPercent,
|
||||
formatDateRange
|
||||
} from '@/content/hackathons/utils';
|
||||
|
||||
interface Props {
|
||||
hackathon: HackathonContent;
|
||||
}
|
||||
|
||||
export function HackathonDetailPage({ hackathon }: Props) {
|
||||
const { metadata, content } = hackathon;
|
||||
const progressPercent = getProgressPercent(metadata);
|
||||
const daysLeftText = getDaysLeftText(metadata);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Hero Section */}
|
||||
<section className="relative h-96 overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
style={{ backgroundImage: `url(${metadata.cover})` }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/50" />
|
||||
</div>
|
||||
<div className="relative z-10 container mx-auto px-4 h-full flex items-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="text-white max-w-2xl"
|
||||
>
|
||||
<h1 className="text-4xl md:text-6xl font-bold mb-4">
|
||||
{metadata.name}
|
||||
</h1>
|
||||
{metadata.theme && (
|
||||
<p className="text-xl md:text-2xl mb-6 text-gray-200">
|
||||
{metadata.theme}
|
||||
</p>
|
||||
)}
|
||||
{metadata.description && (
|
||||
<p className="text-lg text-gray-300">
|
||||
{metadata.description}
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Status Bar */}
|
||||
{(progressPercent !== null && daysLeftText && parseInt(daysLeftText) > -5) && (
|
||||
<section className="bg-muted/50 py-4">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||
metadata.status === 'active' ? 'bg-green-100 text-green-800' :
|
||||
metadata.status === 'upcoming' ? 'bg-blue-100 text-blue-800' :
|
||||
metadata.status === 'ended' ? 'bg-gray-100 text-gray-800' :
|
||||
'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{metadata.status?.charAt(0).toUpperCase() + metadata.status?.slice(1)}
|
||||
</span>
|
||||
{metadata.submissionWindow && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatDateRange(metadata.submissionWindow)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{daysLeftText && (
|
||||
<span className="text-sm font-medium">
|
||||
{daysLeftText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{progressPercent !== null && (
|
||||
<div className="mt-3">
|
||||
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12">
|
||||
{/* Main Content */}
|
||||
<div className="lg:col-span-2">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="prose max-w-none"
|
||||
>
|
||||
<MDXRemote source={content} />
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="lg:col-span-1">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.3 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Quick Info */}
|
||||
<div className="bg-card rounded-lg p-6 border">
|
||||
<h3 className="text-lg font-semibold mb-4">Quick Info</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
{metadata.prize && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Prize Pool:</span>
|
||||
<span className="font-medium">{metadata.prize}</span>
|
||||
</div>
|
||||
)}
|
||||
{metadata.minTeamSize && metadata.maxTeamSize && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Team Size:</span>
|
||||
<span className="font-medium">
|
||||
{metadata.minTeamSize === metadata.maxTeamSize
|
||||
? `${metadata.minTeamSize} person${metadata.minTeamSize > 1 ? 's' : ''}`
|
||||
: `${metadata.minTeamSize}-${metadata.maxTeamSize} people`
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{metadata.submissionsCount && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Submissions:</span>
|
||||
<span className="font-medium">{metadata.submissionsCount}</span>
|
||||
</div>
|
||||
)}
|
||||
{metadata.partnersCount && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Partners:</span>
|
||||
<span className="font-medium">{metadata.partnersCount}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{metadata.tags && metadata.tags.length > 0 && (
|
||||
<div className="bg-card rounded-lg p-6 border">
|
||||
<h3 className="text-lg font-semibold mb-4">Tags</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{metadata.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-3 py-1 bg-primary/10 text-primary rounded-full text-sm"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Requirements */}
|
||||
{metadata.requirements && metadata.requirements.length > 0 && (
|
||||
<div className="bg-card rounded-lg p-6 border">
|
||||
<h3 className="text-lg font-semibold mb-4">Requirements</h3>
|
||||
<div className="space-y-3">
|
||||
{metadata.requirements.map((req) => (
|
||||
<div key={req.id} className="flex items-start gap-3">
|
||||
<div className={`w-2 h-2 rounded-full mt-2 ${
|
||||
req.mandatory ? 'bg-red-500' : 'bg-blue-500'
|
||||
}`} />
|
||||
<div>
|
||||
<div className="font-medium text-sm">{req.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{req.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Partners */}
|
||||
{metadata.partners && metadata.partners.length > 0 && (
|
||||
<div className="bg-card rounded-lg p-6 border">
|
||||
<h3 className="text-lg font-semibold mb-4">Partners</h3>
|
||||
<div className="space-y-3">
|
||||
{metadata.partners.map((partner, idx) => (
|
||||
<a
|
||||
key={idx}
|
||||
href={partner.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-2 rounded-lg hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={partner.logo}
|
||||
alt={partner.name}
|
||||
className="w-8 h-8 object-contain"
|
||||
/>
|
||||
<span className="text-sm font-medium">{partner.name}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { HackathonContent } from '@/content/hackathons/types';
|
||||
|
||||
interface HackathonHeaderProps {
|
||||
metadata: HackathonContent['metadata'];
|
||||
}
|
||||
|
||||
export function HackathonHeader({ metadata }: HackathonHeaderProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="max-w-2xl space-y-4 mb-8"
|
||||
>
|
||||
<div className='space-y-2'>
|
||||
<h1 className="text-2xl md:text-3xl font-bold">
|
||||
{metadata.name}
|
||||
</h1>
|
||||
{metadata.theme && (
|
||||
<p>
|
||||
{metadata.theme}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{metadata.description && (
|
||||
<p>
|
||||
{metadata.description}
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface HackathonHeroProps {
|
||||
coverImage: string;
|
||||
hackathonName: string;
|
||||
}
|
||||
|
||||
export function HackathonHero({ coverImage, hackathonName }: HackathonHeroProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className="relative h-64 overflow-hidden"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<Image
|
||||
unoptimized
|
||||
fill
|
||||
src={coverImage}
|
||||
alt={hackathonName}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import { HackathonContent } from '@/content/hackathons/types';
|
||||
import { BiBuilding } from 'react-icons/bi';
|
||||
|
||||
interface HackathonPartnersProps {
|
||||
partners: HackathonContent['metadata']['partners'];
|
||||
}
|
||||
|
||||
export function HackathonPartners({ partners }: HackathonPartnersProps) {
|
||||
if (!partners || partners.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="mx-auto w-24 h-24 bg-muted rounded-full flex items-center justify-center mb-4">
|
||||
<BiBuilding className="w-12 h-12 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No Sponsors Yet</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Sponsor information will appear here once partnerships are announced.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Event Sponsors</h3>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{partners.length} sponsor{partners.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<p>
|
||||
Kami berterima kasih kepada sponsor-sponsor berikut yang telah mendukung hackathon ini, tanpa dukungan mereka, acara ini tidak akan mungkin terlaksana.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{partners.map((partner, idx) => (
|
||||
<a
|
||||
key={idx}
|
||||
href={partner.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 p-4 bg-card rounded-lg border hover:shadow-md transition-all hover:border-primary/50"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={partner.logo}
|
||||
alt={partner.name}
|
||||
className="w-12 h-12 object-contain flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="font-medium text-sm block truncate">{partner.name}</span>
|
||||
<span className="text-xs text-muted-foreground">Sponsor</span>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { HackathonContent } from '@/content/hackathons/types';
|
||||
|
||||
interface HackathonQuickInfoProps {
|
||||
metadata: HackathonContent['metadata'];
|
||||
}
|
||||
|
||||
export function HackathonQuickInfo({ metadata }: HackathonQuickInfoProps) {
|
||||
return (
|
||||
<div className="bg-card rounded-lg p-6 border">
|
||||
<h3 className="text-lg font-semibold mb-4">Quick Info</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
{metadata.prize && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Prize Pool:</span>
|
||||
<span className="font-medium">{metadata.prize}</span>
|
||||
</div>
|
||||
)}
|
||||
{metadata.minTeamSize && metadata.maxTeamSize && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Team Size:</span>
|
||||
<span className="font-medium">
|
||||
{metadata.minTeamSize === metadata.maxTeamSize
|
||||
? `${metadata.minTeamSize} person${metadata.minTeamSize > 1 ? 's' : ''}`
|
||||
: `${metadata.minTeamSize}-${metadata.maxTeamSize} people`
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{metadata.submissionsCount && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Submissions:</span>
|
||||
<span className="font-medium">{metadata.submissionsCount}</span>
|
||||
</div>
|
||||
)}
|
||||
{metadata.partnersCount && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Partners:</span>
|
||||
<span className="font-medium">{metadata.partnersCount}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { HackathonContent } from '@/content/hackathons/types';
|
||||
|
||||
interface HackathonRequirementsProps {
|
||||
requirements: HackathonContent['metadata']['requirements'];
|
||||
}
|
||||
|
||||
export function HackathonRequirements({ requirements }: HackathonRequirementsProps) {
|
||||
if (!requirements || requirements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-lg p-6 border">
|
||||
<h3 className="text-lg font-semibold mb-4">Requirements</h3>
|
||||
<div className="space-y-3">
|
||||
{requirements.map((req) => (
|
||||
<div key={req.id} className="flex items-start gap-3">
|
||||
<div className={`w-2 h-2 rounded-full mt-2 ${
|
||||
req.mandatory ? 'bg-red-500' : 'bg-blue-500'
|
||||
}`} />
|
||||
<div>
|
||||
<div className="font-medium text-sm">{req.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{req.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { HackathonContent } from '@/content/hackathons/types';
|
||||
import { HackathonHeader } from './HackathonHeader';
|
||||
import { HackathonQuickInfo } from './HackathonQuickInfo';
|
||||
import { HackathonRequirements } from './HackathonRequirements';
|
||||
|
||||
interface HackathonSidebarProps {
|
||||
metadata: HackathonContent['metadata'];
|
||||
}
|
||||
|
||||
export function HackathonSidebar({ metadata }: HackathonSidebarProps) {
|
||||
return (
|
||||
<div className="lg:col-span-1">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
>
|
||||
<HackathonHeader metadata={metadata} />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.3 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<HackathonQuickInfo metadata={metadata} />
|
||||
<HackathonRequirements requirements={metadata.requirements} />
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { HackathonContent } from '@/content/hackathons/types';
|
||||
import { formatDateRange } from '@/content/hackathons/utils';
|
||||
|
||||
interface HackathonStatusBarProps {
|
||||
metadata: HackathonContent['metadata'];
|
||||
progressPercent: number | null;
|
||||
daysLeftText: string | null;
|
||||
}
|
||||
|
||||
export function HackathonStatusBar({ metadata, progressPercent, daysLeftText }: HackathonStatusBarProps) {
|
||||
// Only show if we have progress and days left, and it's not too far in the past
|
||||
if (!(progressPercent !== null && daysLeftText && parseInt(daysLeftText) > -5)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-muted/50 py-4">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||
metadata.status === 'active' ? 'bg-green-100 text-green-800' :
|
||||
metadata.status === 'upcoming' ? 'bg-blue-100 text-blue-800' :
|
||||
metadata.status === 'ended' ? 'bg-gray-100 text-gray-800' :
|
||||
'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{metadata.status?.charAt(0).toUpperCase() + metadata.status?.slice(1)}
|
||||
</span>
|
||||
{metadata.submissionWindow && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatDateRange(metadata.submissionWindow)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{daysLeftText && (
|
||||
<span className="text-sm font-medium">
|
||||
{daysLeftText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{progressPercent !== null && (
|
||||
<div className="mt-3">
|
||||
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { BiLinkExternal, BiLogoGithub, BiImage, BiGroup } from 'react-icons/bi';
|
||||
|
||||
interface Submission {
|
||||
team_name: string;
|
||||
project_title: string;
|
||||
description: string;
|
||||
repo_link: string;
|
||||
screenshot?: string;
|
||||
file_name?: string;
|
||||
}
|
||||
|
||||
interface HackathonSubmissionsProps {
|
||||
submissions?: Submission[];
|
||||
submissionsCount?: number;
|
||||
}
|
||||
|
||||
export function HackathonSubmissions({ submissions, submissionsCount }: HackathonSubmissionsProps) {
|
||||
if (!submissions || submissions.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="mx-auto w-24 h-24 bg-muted rounded-full flex items-center justify-center mb-4">
|
||||
<BiGroup className="w-12 h-12 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No Submissions Yet</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Submissions will appear here once participants start submitting their projects.
|
||||
</p>
|
||||
{submissionsCount && (
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Expected submissions: {submissionsCount}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Project Submissions</h3>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{submissions.length} project{submissions.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||
{submissions.map((submission, index) => (
|
||||
<div key={index} className="bg-card rounded-lg border hover:shadow-md transition-shadow">
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="w-full h-40 bg-muted rounded-lg rounded-b-none overflow-hidden flex items-center justify-center">
|
||||
{submission.screenshot ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={submission.screenshot}
|
||||
alt={submission.project_title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
|
||||
No Screenshot Available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 flex flex-col h-full">
|
||||
<div>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="font-semibold text-base">{submission.project_title}</h4>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<BiGroup className="w-4 h-4" />
|
||||
<span>{submission.team_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-muted-foreground leading-relaxed grow line-clamp-6">
|
||||
{submission.description}
|
||||
</p>
|
||||
|
||||
{/* Links */}
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{submission.repo_link && (
|
||||
<a
|
||||
href={submission.repo_link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-3 py-1 bg-primary/10 text-primary rounded-full text-xs hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<BiLogoGithub className="w-3 h-3" />
|
||||
Repository
|
||||
<BiLinkExternal className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { HackathonContent } from '@/content/hackathons/types';
|
||||
import { HackathonPartners } from './HackathonPartners';
|
||||
import { HackathonSubmissions } from './HackathonSubmissions';
|
||||
import { BiGroup, BiFile, BiInfoCircle } from 'react-icons/bi';
|
||||
|
||||
interface Submission {
|
||||
team_name: string;
|
||||
project_title: string;
|
||||
description: string;
|
||||
repo_link: string;
|
||||
screenshot?: string;
|
||||
file_name?: string;
|
||||
}
|
||||
|
||||
interface HackathonTabsProps {
|
||||
metadata: HackathonContent['metadata'];
|
||||
submissions?: Submission[];
|
||||
contentElement: React.ReactNode;
|
||||
}
|
||||
|
||||
export function HackathonTabs({ metadata, submissions, contentElement }: HackathonTabsProps) {
|
||||
const [activeTab, setActiveTab] = useState('content');
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: 'content',
|
||||
label: 'Details',
|
||||
icon: BiInfoCircle,
|
||||
count: 0 // No count for content tab
|
||||
},
|
||||
{
|
||||
id: 'sponsors',
|
||||
label: 'Sponsors',
|
||||
icon: BiGroup,
|
||||
count: metadata.partners?.length || 0
|
||||
},
|
||||
{
|
||||
id: 'submissions',
|
||||
label: 'Submissions',
|
||||
icon: BiFile,
|
||||
count: submissions?.length || metadata.submissionsCount || 0
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* Tab Navigation */}
|
||||
<div className="border-b border-border">
|
||||
<nav className="flex space-x-8" aria-label="Tabs">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`relative py-4 px-1 cursor-pointer font-medium text-sm flex items-center gap-2 transition-colors`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span>{tab.label}</span>
|
||||
{tab.count > 0 && (
|
||||
<span className="ml-2 bg-muted text-muted-foreground px-2 py-1 rounded-full text-xs">
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
{activeTab === tab.id && (
|
||||
<motion.div
|
||||
className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary"
|
||||
layoutId="activeTab"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="mt-6">
|
||||
<motion.div
|
||||
key={activeTab}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{activeTab === 'content' && contentElement}
|
||||
{activeTab === 'sponsors' && (
|
||||
<HackathonPartners partners={metadata.partners} />
|
||||
)}
|
||||
{activeTab === 'submissions' && (
|
||||
<HackathonSubmissions
|
||||
submissions={submissions}
|
||||
submissionsCount={metadata.submissionsCount}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||
|
||||
interface ServerHackathonContentProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export async function ServerHackathonContent({ content }: ServerHackathonContentProps) {
|
||||
return (
|
||||
<div className="prose w-full max-w-none">
|
||||
<MDXRemote source={content} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,15 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { Metadata } from 'next';
|
||||
import { getHackathonBySlug, getAllHackathonSlugs } from '@/content/hackathons/content';
|
||||
import { HackathonDetailPage } from '@/app/(public)/hackathon/[slug]/_components/HackathonDetailPage';
|
||||
import { getDaysLeftText, getProgressPercent } from '@/content/hackathons/utils';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@components';
|
||||
import { BsArrowLeft } from 'react-icons/bs';
|
||||
import { HackathonHero } from './_components/HackathonHero';
|
||||
import { HackathonStatusBar } from './_components/HackathonStatusBar';
|
||||
import { HackathonSidebar } from './_components/HackathonSidebar';
|
||||
import { HackathonTabs } from './_components/HackathonTabs';
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||
|
||||
interface Props {
|
||||
params: {
|
||||
@@ -21,7 +29,7 @@ export async function generateStaticParams() {
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const resolvedParams = await params;
|
||||
const hackathon = await getHackathonBySlug(resolvedParams.slug);
|
||||
|
||||
|
||||
if (!hackathon) {
|
||||
return {
|
||||
title: 'Hackathon Not Found',
|
||||
@@ -30,7 +38,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
}
|
||||
|
||||
const { metadata } = hackathon;
|
||||
|
||||
|
||||
return {
|
||||
title: metadata.seoTitle || `${metadata.name} - IMPHNEN`,
|
||||
description: metadata.seoDescription || metadata.description,
|
||||
@@ -58,5 +66,56 @@ export default async function HackathonPage({ params }: Props) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <HackathonDetailPage hackathon={hackathon} />;
|
||||
const { metadata, content } = hackathon;
|
||||
|
||||
// Return 404 if no content is found
|
||||
if (!content || content.trim() === '') {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const progressPercent = getProgressPercent(metadata);
|
||||
const daysLeftText = getDaysLeftText(metadata);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen container mx-auto px-4 py-8 bg-background">
|
||||
<Link href="/hackathon">
|
||||
<Button variant={'bordered'} className='mb-6 font-normal gap-2'>
|
||||
<BsArrowLeft /> <span>Back to Hackathons</span>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<HackathonHero
|
||||
coverImage={metadata.cover}
|
||||
hackathonName={metadata.name}
|
||||
/>
|
||||
|
||||
<HackathonStatusBar
|
||||
metadata={metadata}
|
||||
progressPercent={progressPercent}
|
||||
daysLeftText={daysLeftText}
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-4 py-12 flex gap-8">
|
||||
{/* Sidebar only */}
|
||||
<div className="max-w-xs w-full shrink-0 mx-auto lg:mx-0 lg:col-span-2">
|
||||
<HackathonSidebar metadata={metadata} />
|
||||
</div>
|
||||
|
||||
{/* Tabs section */}
|
||||
<div className="mb-12 col-span-8 lg:col-span-8">
|
||||
<HackathonTabs
|
||||
metadata={metadata}
|
||||
submissions={metadata.submissions}
|
||||
contentElement={
|
||||
<div className="prose w-full max-w-none">
|
||||
<MDXRemote source={content} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,11 +6,11 @@ import { motion } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import { HackathonSummary } from '@/content/hackathons/types';
|
||||
import { hackathonSummaries } from '@/content/hackathons/index';
|
||||
import {
|
||||
getDaysLeftText,
|
||||
getProgressPercent,
|
||||
filterHackathons,
|
||||
sortHackathons
|
||||
import {
|
||||
getDaysLeftText,
|
||||
getProgressPercent,
|
||||
filterHackathons,
|
||||
sortHackathons
|
||||
} from '@/content/hackathons/utils';
|
||||
|
||||
const HackathonTags: React.FC<{ tags?: string[] }> = ({ tags }) => {
|
||||
@@ -98,34 +98,29 @@ const HackathonCard: React.FC<{ hackathon: HackathonSummary; idx: number }> = ({
|
||||
};
|
||||
|
||||
export default function HackathonsPage() {
|
||||
// Use static data instead of API calls
|
||||
const [filteredItems, setFilteredItems] = React.useState<HackathonSummary[]>(hackathonSummaries);
|
||||
const [searchTerm, setSearchTerm] = React.useState('');
|
||||
const [statusFilter, setStatusFilter] = React.useState<string>('all');
|
||||
|
||||
// Filter and sort hackathons
|
||||
|
||||
React.useEffect(() => {
|
||||
let filtered = [...hackathonSummaries];
|
||||
|
||||
// Apply search filter
|
||||
if (searchTerm) {
|
||||
filtered = filterHackathons(filtered, { search: searchTerm });
|
||||
}
|
||||
|
||||
// Apply status filter
|
||||
if (statusFilter !== 'all') {
|
||||
filtered = filterHackathons(filtered, { status: [statusFilter] });
|
||||
}
|
||||
|
||||
// Sort by registration start date (newest first)
|
||||
|
||||
filtered = sortHackathons(filtered, 'registrationStart', 'desc');
|
||||
|
||||
setFilteredItems(filtered);
|
||||
}, [searchTerm, statusFilter]);
|
||||
|
||||
return (
|
||||
<section className="min-h-screen bg-background container py-10">
|
||||
{/* Search and Filter Controls */}
|
||||
{/*
|
||||
<div className="mb-8 space-y-4">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<input
|
||||
@@ -133,7 +128,7 @@ export default function HackathonsPage() {
|
||||
placeholder="Search hackathons..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="flex-1 px-4 py-2 border border-muted rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
className="flex-1 px-4 py-2 border border-gray-400 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<select
|
||||
value={statusFilter}
|
||||
@@ -146,12 +141,8 @@ export default function HackathonsPage() {
|
||||
<option value="ended">Ended</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Results summary */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing {filteredItems.length} of {hackathonSummaries.length} hackathons
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div> */}
|
||||
|
||||
<motion.div
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8"
|
||||
@@ -165,7 +156,7 @@ export default function HackathonsPage() {
|
||||
<HackathonCard key={hackathon.slug || hackathon.name} hackathon={hackathon} idx={idx} />
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
|
||||
{/* No results message */}
|
||||
{filteredItems.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { poppinsFont } from '@/lib/fonts';
|
||||
import '@/styles/globals.css';
|
||||
import { cn } from '@utils';
|
||||
import { type Metadata } from 'next';
|
||||
import NextTopLoader from 'nextjs-toploader';
|
||||
import { Providers } from './_components/providers';
|
||||
import { Toaster } from './_components/toaster';
|
||||
|
||||
@@ -18,6 +19,17 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="id" suppressHydrationWarning>
|
||||
<body className={cn(poppinsFont.className, 'antialiased')}>
|
||||
<NextTopLoader
|
||||
color="#6366f1"
|
||||
initialPosition={0.08}
|
||||
crawlSpeed={200}
|
||||
height={3}
|
||||
crawl={true}
|
||||
showSpinner={true}
|
||||
easing="ease"
|
||||
speed={200}
|
||||
shadow="0 0 10px #6366f1,0 0 5px #6366f1"
|
||||
/>
|
||||
<Providers
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Generated
+25
-4
@@ -30,6 +30,7 @@
|
||||
"next": "~15.2.4",
|
||||
"next-mdx-remote": "^5.0.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"nextjs-toploader": "^3.9.17",
|
||||
"openapi-fetch": "^0.14.0",
|
||||
"openapi-react-query": "^0.5.0",
|
||||
"react": "^19.1.0",
|
||||
@@ -25231,7 +25232,6 @@
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
@@ -27204,6 +27204,24 @@
|
||||
"@img/sharp-win32-x64": "0.33.5"
|
||||
}
|
||||
},
|
||||
"node_modules/nextjs-toploader": {
|
||||
"version": "3.9.17",
|
||||
"resolved": "https://registry.npmjs.org/nextjs-toploader/-/nextjs-toploader-3.9.17.tgz",
|
||||
"integrity": "sha512-9OF0KSSLtoSAuNg2LZ3aTl4hR9mBDj5L9s9DZiFCbMlXehyICGjkIz5dVGzuATU2bheJZoBdFgq9w07AKSuQQw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nprogress": "^0.2.0",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://buymeacoffee.com/thesgj"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"next": ">= 6.0.0",
|
||||
"react": ">= 16.0.0",
|
||||
"react-dom": ">= 16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/no-case": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
|
||||
@@ -27417,6 +27435,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/nprogress": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/nprogress/-/nprogress-0.2.0.tgz",
|
||||
"integrity": "sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nth-check": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||
@@ -27978,7 +28002,6 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -29887,7 +29910,6 @@
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
@@ -29899,7 +29921,6 @@
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/property-information": {
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"next": "~15.2.4",
|
||||
"next-mdx-remote": "^5.0.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"nextjs-toploader": "^3.9.17",
|
||||
"openapi-fetch": "^0.14.0",
|
||||
"openapi-react-query": "^0.5.0",
|
||||
"react": "^19.1.0",
|
||||
|
||||
Reference in New Issue
Block a user