Compare commits
3
Commits
a281f279f8
...
4d4b92b357
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d4b92b357 | ||
|
|
8e7c6e6c21 | ||
|
|
6960b17c41 |
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm run typecheck:*)"
|
||||||
|
],
|
||||||
|
"deny": [],
|
||||||
|
"ask": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,3 +22,4 @@ export async function fetchPostSignin({
|
|||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { CommunitySection } from '../_components/community-section';
|
||||||
|
import { CTASection } from '../_components/cta-section';
|
||||||
|
import { HeroSection } from '../_components/hero-section';
|
||||||
|
import { TestimonialSection } from '../_components/testimonial-section';
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HeroSection />
|
||||||
|
<CommunitySection />
|
||||||
|
<TestimonialSection />
|
||||||
|
<CTASection />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { CommunitySection } from './_components/community-section';
|
|
||||||
import { CTASection } from './_components/cta-section';
|
|
||||||
import { HeroSection } from './_components/hero-section';
|
|
||||||
import { TestimonialSection } from './_components/testimonial-section';
|
|
||||||
export default function Page() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<HeroSection />
|
|
||||||
<CommunitySection />
|
|
||||||
<TestimonialSection />
|
|
||||||
<CTASection />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { LogoSimple } from '@/app/_components/logo';
|
import { LogoSimple } from '@/app/_components/logo';
|
||||||
import NAVIGATIONS from '@/data/navigations.json';
|
import NAVIGATIONS from '@/data/navigations.json';
|
||||||
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
import { Button } from '@components';
|
import { Button } from '@components';
|
||||||
import { cn } from '@utils';
|
import { cn } from '@utils';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
@@ -13,6 +14,7 @@ import { LuMenu, LuX } from 'react-icons/lu';
|
|||||||
export function Header() {
|
export function Header() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -30,6 +32,13 @@ export function Header() {
|
|||||||
setMobileMenuOpen(false);
|
setMobileMenuOpen(false);
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
document.cookie = '__imphnen_access_token__=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||||
|
document.cookie = '__imphnen_refresh_token__=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||||
|
router.push('/');
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 w-full z-50 bg-background/70">
|
<header className="sticky top-0 w-full z-50 bg-background/70">
|
||||||
<div className="container flex h-20 items-center justify-between">
|
<div className="container flex h-20 items-center justify-between">
|
||||||
@@ -62,6 +71,16 @@ export function Header() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="hidden md:flex items-center gap-x-3">
|
<div className="hidden md:flex items-center gap-x-3">
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<Button
|
||||||
|
onClick={handleLogout}
|
||||||
|
variant="bordered"
|
||||||
|
className="px-5 py-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
Keluar
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => router.push('/signin')}
|
onClick={() => router.push('/signin')}
|
||||||
className="px-5 py-2 text-sm font-medium"
|
className="px-5 py-2 text-sm font-medium"
|
||||||
@@ -75,6 +94,8 @@ export function Header() {
|
|||||||
>
|
>
|
||||||
Daftar
|
Daftar
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -144,6 +165,19 @@ export function Header() {
|
|||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
transition={{ delay: 0.2 }}
|
transition={{ delay: 0.2 }}
|
||||||
>
|
>
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
handleLogout();
|
||||||
|
}}
|
||||||
|
variant="bordered"
|
||||||
|
className="w-full py-4 text-base"
|
||||||
|
>
|
||||||
|
Keluar
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMobileMenuOpen(false);
|
setMobileMenuOpen(false);
|
||||||
@@ -163,6 +197,8 @@ export function Header() {
|
|||||||
>
|
>
|
||||||
Daftar
|
Daftar
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { Metadata } from 'next';
|
||||||
|
import { getHackathonBySlug, getAllHackathonSlugs } from '@/content/hackathons/content';
|
||||||
|
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: {
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate static params for all hackathons
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
const slugs = await getAllHackathonSlugs();
|
||||||
|
return slugs.map((slug) => ({
|
||||||
|
slug,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate metadata for each hackathon
|
||||||
|
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',
|
||||||
|
description: 'The requested hackathon could not be found.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { metadata } = hackathon;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: metadata.seoTitle || `${metadata.name} - IMPHNEN`,
|
||||||
|
description: metadata.seoDescription || metadata.description,
|
||||||
|
keywords: metadata.tags?.join(', '),
|
||||||
|
openGraph: {
|
||||||
|
title: metadata.name,
|
||||||
|
description: metadata.description,
|
||||||
|
images: metadata.socialImage ? [metadata.socialImage] : [metadata.cover],
|
||||||
|
type: 'website',
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: metadata.name,
|
||||||
|
description: metadata.description,
|
||||||
|
images: metadata.socialImage ? [metadata.socialImage] : [metadata.cover],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function HackathonPage({ params }: Props) {
|
||||||
|
const resolvedParams = await params;
|
||||||
|
const hackathon = await getHackathonBySlug(resolvedParams.slug);
|
||||||
|
|
||||||
|
if (!hackathon) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,79 +1,173 @@
|
|||||||
'use client';
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
"use client";
|
||||||
|
|
||||||
import hackathons from '@/data/hackathons.json';
|
import React from 'react';
|
||||||
import { buttonVariants } from '@components';
|
|
||||||
import { cn } from '@utils';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import { HiOutlineCode } from 'react-icons/hi';
|
|
||||||
import { motion } from 'framer-motion';
|
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
|
||||||
|
} from '@/content/hackathons/utils';
|
||||||
|
|
||||||
|
const HackathonTags: React.FC<{ tags?: string[] }> = ({ tags }) => {
|
||||||
|
if (!tags || tags.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2 mb-3 text-xs">
|
||||||
|
{tags.slice(0, 3).map((t) => (
|
||||||
|
<div
|
||||||
|
key={t}
|
||||||
|
className="px-3 py-[2px] rounded-md border border-primary-500/50 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<div className="w-2 h-2 bg-primary-500 rounded-full" />
|
||||||
|
{t}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RegistrationProgress: React.FC<{ hackathon: HackathonSummary }> = ({ hackathon }) => {
|
||||||
|
const status = hackathon.status?.toLowerCase().trim();
|
||||||
|
// Hide the progress bar if the status is explicitly "ended"
|
||||||
|
if (status === 'ended') return null;
|
||||||
|
|
||||||
|
const percent = getProgressPercent(hackathon);
|
||||||
|
const label = getDaysLeftText(hackathon);
|
||||||
|
if (percent === null && !label) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-6 mt-2 mb-3 flex gap-2 items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="w-full h-[0.6rem] rounded-full border border-primary-500/50 overflow-hidden"
|
||||||
|
aria-label="Registration progress"
|
||||||
|
>
|
||||||
|
<div className="h-full bg-primary rounded-full" style={{ width: `${percent ?? 0}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-primary-700 shrink-0">{label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const HackathonCard: React.FC<{ hackathon: HackathonSummary; idx: number }> = ({ hackathon, idx }) => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="rounded-lg overflow-hidden shadow-sm hover:shadow-lg transition-all duration-300 bg-card group"
|
||||||
|
initial={{ opacity: 0, y: 40 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, delay: idx * 0.08, type: 'spring', stiffness: 60 }}
|
||||||
|
whileHover={{ scale: 1.03, boxShadow: '0 8px 32px rgba(0,0,0,0.10)' }}
|
||||||
|
>
|
||||||
|
<Link href={`/hackathon/${hackathon.slug}`} className="block focus:outline-none relative">
|
||||||
|
<div className="h-48 bg-muted overflow-hidden">
|
||||||
|
<div className='w-full h-48 overflow-hidden object-cover'>
|
||||||
|
{/* using img tag since it simpler to control */}
|
||||||
|
<img
|
||||||
|
src={hackathon.cover}
|
||||||
|
alt={hackathon.name}
|
||||||
|
className="object-cover object-center w-full h-full group-hover:scale-105 transition-transform duration-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='relative -mt-4 bg-card rounded-lg border-2 border-white hover:border-muted transition-all duration-300'>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h3 className="text-lg font-medium mb-2 text-foreground line-clamp-2">
|
||||||
|
{hackathon.name}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-sm mb-2 line-clamp-3">
|
||||||
|
{hackathon.description}
|
||||||
|
</p>
|
||||||
|
<HackathonTags tags={hackathon.tags} />
|
||||||
|
</div>
|
||||||
|
<RegistrationProgress hackathon={hackathon} />
|
||||||
|
{hackathon.prize && (
|
||||||
|
<div className="px-6 py-3 border-t font-medium text-lg">
|
||||||
|
<h4 className='text-muted-foreground'>Hadiah</h4>
|
||||||
|
<p>{hackathon.prize ?? '—'}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default function HackathonsPage() {
|
export default function HackathonsPage() {
|
||||||
const sortedHackathons = [...hackathons];
|
const [filteredItems, setFilteredItems] = React.useState<HackathonSummary[]>(hackathonSummaries);
|
||||||
|
const [searchTerm, setSearchTerm] = React.useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = React.useState<string>('all');
|
||||||
|
|
||||||
if (sortedHackathons.length === 0) {
|
|
||||||
return (
|
React.useEffect(() => {
|
||||||
<section className="min-h-screen bg-background container py-10 flex items-center justify-center">
|
let filtered = [...hackathonSummaries];
|
||||||
<p className="text-muted-foreground text-lg">No hackathon projects available yet.</p>
|
if (searchTerm) {
|
||||||
</section>
|
filtered = filterHackathons(filtered, { search: searchTerm });
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (statusFilter !== 'all') {
|
||||||
|
filtered = filterHackathons(filtered, { status: [statusFilter] });
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered = sortHackathons(filtered, 'registrationStart', 'desc');
|
||||||
|
setFilteredItems(filtered);
|
||||||
|
}, [searchTerm, statusFilter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="min-h-screen bg-background container py-10">
|
<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
|
||||||
|
type="text"
|
||||||
|
placeholder="Search hackathons..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
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}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="px-4 py-2 border border-muted rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
>
|
||||||
|
<option value="all">All Status</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="upcoming">Upcoming</option>
|
||||||
|
<option value="ended">Ended</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div> */}
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8"
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
animate="visible"
|
animate="visible"
|
||||||
variants={{
|
variants={{
|
||||||
visible: { transition: { staggerChildren: 0.12 } },
|
visible: { transition: { staggerChildren: 0.12 } },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{sortedHackathons.map((hackathon, idx) => (
|
{filteredItems.map((hackathon, idx) => (
|
||||||
<motion.div
|
<HackathonCard key={hackathon.slug || hackathon.name} hackathon={hackathon} idx={idx} />
|
||||||
key={hackathon.project_title}
|
|
||||||
className="rounded-xl shadow-sm hover:shadow-lg transition-all duration-300 bg-card group"
|
|
||||||
initial={{ opacity: 0, y: 40 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.6, delay: idx * 0.08, type: 'spring', stiffness: 60 }}
|
|
||||||
whileHover={{ scale: 1.03, boxShadow: '0 8px 32px rgba(0,0,0,0.10)' }}
|
|
||||||
>
|
|
||||||
<motion.div className="h-48 bg-muted relative overflow-hidden rounded-t-xl">
|
|
||||||
<Image
|
|
||||||
src={`https://cdn.asepharyana.tech/imphnen/hackatons/${hackathon.file_name}`}
|
|
||||||
alt={hackathon.project_title}
|
|
||||||
fill
|
|
||||||
className="object-cover object-top group-hover:scale-105 transition-transform duration-500"
|
|
||||||
sizes="(max-width: 768px) 100vw, 33vw"
|
|
||||||
unoptimized
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
<div className="p-6">
|
|
||||||
<h3 className="text-lg font-semibold mb-3 text-foreground">
|
|
||||||
{hackathon.project_title}
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-2 mb-4 text-sm text-muted-foreground">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<HiOutlineCode className="w-4 h-4" />
|
|
||||||
<span>{hackathon.team_name}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
|
|
||||||
{hackathon.description}
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href={hackathon.repo_link}
|
|
||||||
target="_blank"
|
|
||||||
className={cn(
|
|
||||||
buttonVariants({ variant: 'bordered' }),
|
|
||||||
'w-full text-sm'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Lihat Proyek
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
))}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
{/* No results message */}
|
||||||
|
{filteredItems.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<h3 className="text-lg font-medium text-muted-foreground mb-2">
|
||||||
|
No hackathons found
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Try adjusting your search or filter criteria
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { poppinsFont } from '@/lib/fonts';
|
|||||||
import '@/styles/globals.css';
|
import '@/styles/globals.css';
|
||||||
import { cn } from '@utils';
|
import { cn } from '@utils';
|
||||||
import { type Metadata } from 'next';
|
import { type Metadata } from 'next';
|
||||||
|
import NextTopLoader from 'nextjs-toploader';
|
||||||
import { Providers } from './_components/providers';
|
import { Providers } from './_components/providers';
|
||||||
import { Toaster } from './_components/toaster';
|
import { Toaster } from './_components/toaster';
|
||||||
|
|
||||||
@@ -18,6 +19,17 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="id" suppressHydrationWarning>
|
<html lang="id" suppressHydrationWarning>
|
||||||
<body className={cn(poppinsFont.className, 'antialiased')}>
|
<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
|
<Providers
|
||||||
attribute="class"
|
attribute="class"
|
||||||
defaultTheme="system"
|
defaultTheme="system"
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
title: "AI Agent Hackathon 2025"
|
||||||
|
description: "Hackathon AI Agent untuk Kemerdekaan Indonesia"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tentang Acara
|
||||||
|
|
||||||
|
Halo semuanya 👋, IMPHNEN dengan bangga mempersembahkan **Hackathon perdana** bertajuk **AI Agent Hackathon 2025**.
|
||||||
|
Event ini dirancang untuk menjadi ruang eksplorasi dan kolaborasi bagi para developer, mahasiswa, maupun kreator teknologi yang ingin membangun **AI Agent inovatif** dengan semangat **Kemerdekaan Indonesia** 🇮🇩.
|
||||||
|
|
||||||
|
Selama satu minggu penuh, para peserta akan bekerja dalam tim untuk menciptakan solusi berbasis AI yang memanfaatkan platform teknologi terbaru.
|
||||||
|
|
||||||
|
## 🌟 Tema Hackathon
|
||||||
|
|
||||||
|
**"AI Agent for Kemerdekaan Indonesia"**
|
||||||
|
Peserta diajak merancang agen AI yang mampu memberikan dampak positif dalam memperingati dan mengaktualisasi nilai kemerdekaan Indonesia di era digital.
|
||||||
|
|
||||||
|
## 📜 Ketentuan Peserta
|
||||||
|
|
||||||
|
1. Hackathon ini bersifat **tim-based**, dengan minimal **1 orang** dan maksimal **3 orang** per tim.
|
||||||
|
2. Peserta **wajib menggunakan ketiga platform berikut** sebagai komponen utama proyek:
|
||||||
|
- [lunos.tech](https://lunos.tech)
|
||||||
|
- [mailry.co](https://mailry.co)
|
||||||
|
- [unli.dev](https://unli.dev)
|
||||||
|
3. Semua ide dan kode yang disubmit **harus orisinal** serta dikembangkan selama periode hackathon.
|
||||||
|
4. Penggunaan API atau library pihak ketiga diperbolehkan, selama tidak melanggar hak cipta atau lisensi.
|
||||||
|
5. Plagiarisme dalam bentuk apa pun akan menyebabkan diskualifikasi.
|
||||||
|
6. Penyelenggara berhak melakukan perubahan jadwal maupun aturan dan akan mengumumkannya kepada peserta.
|
||||||
|
7. Informasi detail dapat dilihat pada **formulir pendaftaran**.
|
||||||
|
|
||||||
|
## 🏆 Hadiah
|
||||||
|
|
||||||
|
Hadiah akan diberikan kepada **3 tim terbaik**, dengan detail lebih lanjut diumumkan pada saat acara.
|
||||||
|
*(Catatan: pajak hadiah ditanggung oleh pemenang).*
|
||||||
|
|
||||||
|
## 📅 Timeline
|
||||||
|
|
||||||
|
* **Pendaftaran dibuka:** segera setelah pengumuman
|
||||||
|
* **Masa pengerjaan:** 18–24 Agustus 2025
|
||||||
|
* **Deadline submission:** Kamis, 21 Agustus 2025
|
||||||
|
* **Pengumuman pemenang:** setelah tahap penjurian selesai
|
||||||
|
|
||||||
|
## Cara Ikut (Arsip)
|
||||||
|
|
||||||
|
1. Daftar melalui tautan yang tersedia (QR code atau kolom komentar).
|
||||||
|
2. Bentuk timmu.
|
||||||
|
3. Mulai ngoding dan kembangkan ide terbaikmu.
|
||||||
|
4. Submit proyek sesuai jadwal.
|
||||||
|
|
||||||
|
## Catatan
|
||||||
|
|
||||||
|
* Event ini terbuka bagi siapa saja yang berkomitmen untuk membangun solusi kreatif.
|
||||||
|
* Jangan sia-siakan kesempatan ini untuk berkolaborasi, belajar, dan menantang dirimu.
|
||||||
|
* **Status:** Hackathon telah berakhir. Terima kasih untuk semua partisipan!
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,268 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import { HackathonMetadata, HackathonContent, HackathonSummary } from './types';
|
||||||
|
import { readMDXFile } from '../shared/mdx';
|
||||||
|
import { validateHackathon } from './validation';
|
||||||
|
import { toHackathonSummary, calculateHackathonStatus, sortHackathons, filterHackathons } from './utils';
|
||||||
|
|
||||||
|
const HACKATHONS_DIR = path.join(process.cwd(), 'src/content/hackathons');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load metadata from a hackathon's metadata.json file
|
||||||
|
*/
|
||||||
|
async function loadHackathonMetadata(hackathonDir: string): Promise<HackathonMetadata | null> {
|
||||||
|
const metadataPath = path.join(hackathonDir, 'metadata.json');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(metadataPath)) {
|
||||||
|
console.warn(`No metadata.json found in ${hackathonDir}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and parse JSON file
|
||||||
|
const metadataContent = fs.readFileSync(metadataPath, 'utf-8');
|
||||||
|
const metadata = JSON.parse(metadataContent) as HackathonMetadata;
|
||||||
|
|
||||||
|
// Validate the metadata
|
||||||
|
const validation = validateHackathon(metadata);
|
||||||
|
if (!validation.isValid) {
|
||||||
|
console.error(`Invalid metadata in ${hackathonDir}:`, validation.errors);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate status if not explicitly set
|
||||||
|
const finalMetadata: HackathonMetadata = {
|
||||||
|
...metadata,
|
||||||
|
status: metadata.status || calculateHackathonStatus(metadata),
|
||||||
|
contentPath: path.relative(HACKATHONS_DIR, hackathonDir),
|
||||||
|
lastModified: fs.statSync(metadataPath).mtime.toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
return finalMetadata;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error loading metadata from ${metadataPath}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load content from a hackathon's content.mdx file
|
||||||
|
*/
|
||||||
|
async function loadHackathonContent(hackathonDir: string): Promise<string | null> {
|
||||||
|
const contentPath = path.join(hackathonDir, 'content.mdx');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(contentPath)) {
|
||||||
|
console.warn(`No content.mdx found in ${hackathonDir}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mdxContent = await readMDXFile(contentPath);
|
||||||
|
return mdxContent?.content || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error loading content from ${contentPath}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all hackathon directories
|
||||||
|
*/
|
||||||
|
function getHackathonDirectories(): string[] {
|
||||||
|
if (!fs.existsSync(HACKATHONS_DIR)) {
|
||||||
|
console.warn(`Hackathons directory not found: ${HACKATHONS_DIR}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return fs.readdirSync(HACKATHONS_DIR, { withFileTypes: true })
|
||||||
|
.filter(dirent => dirent.isDirectory())
|
||||||
|
.map(dirent => path.join(HACKATHONS_DIR, dirent.name))
|
||||||
|
.filter(dir => {
|
||||||
|
// Only include directories that have either metadata.json or content.mdx
|
||||||
|
const hasMetadata = fs.existsSync(path.join(dir, 'metadata.json'));
|
||||||
|
const hasContent = fs.existsSync(path.join(dir, 'content.mdx'));
|
||||||
|
return hasMetadata || hasContent;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all hackathons with full content (build-time)
|
||||||
|
*/
|
||||||
|
export async function getAllHackathons(options: {
|
||||||
|
includeDrafts?: boolean;
|
||||||
|
sortBy?: 'name' | 'registrationStart' | 'status';
|
||||||
|
sortDirection?: 'asc' | 'desc';
|
||||||
|
} = {}): Promise<HackathonContent[]> {
|
||||||
|
const hackathonDirs = getHackathonDirectories();
|
||||||
|
const hackathons: HackathonContent[] = [];
|
||||||
|
|
||||||
|
for (const dir of hackathonDirs) {
|
||||||
|
const metadata = await loadHackathonMetadata(dir);
|
||||||
|
if (!metadata) continue;
|
||||||
|
|
||||||
|
// Skip drafts unless explicitly included
|
||||||
|
if (!options.includeDrafts && metadata.status === 'draft') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await loadHackathonContent(dir);
|
||||||
|
|
||||||
|
hackathons.push({
|
||||||
|
metadata,
|
||||||
|
content: content || ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort if requested
|
||||||
|
if (options.sortBy) {
|
||||||
|
const summaries = hackathons.map(h => toHackathonSummary(h.metadata));
|
||||||
|
const sortedSummaries = sortHackathons(summaries, options.sortBy, options.sortDirection);
|
||||||
|
|
||||||
|
// Reorder hackathons based on sorted summaries
|
||||||
|
return sortedSummaries.map(summary => {
|
||||||
|
const hackathon = hackathons.find(h => h.metadata.slug === summary.slug);
|
||||||
|
return hackathon;
|
||||||
|
}).filter((hackathon): hackathon is HackathonContent => hackathon !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hackathons;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get hackathon summaries for listing pages (build-time)
|
||||||
|
*/
|
||||||
|
export async function getHackathonSummaries(options: {
|
||||||
|
includeDrafts?: boolean;
|
||||||
|
sortBy?: 'name' | 'registrationStart' | 'status';
|
||||||
|
sortDirection?: 'asc' | 'desc';
|
||||||
|
filters?: {
|
||||||
|
status?: string[];
|
||||||
|
tags?: string[];
|
||||||
|
search?: string;
|
||||||
|
};
|
||||||
|
} = {}): Promise<HackathonSummary[]> {
|
||||||
|
const hackathons = await getAllHackathons({
|
||||||
|
includeDrafts: options.includeDrafts,
|
||||||
|
sortBy: options.sortBy,
|
||||||
|
sortDirection: options.sortDirection
|
||||||
|
});
|
||||||
|
|
||||||
|
let summaries = hackathons.map(h => toHackathonSummary(h.metadata));
|
||||||
|
|
||||||
|
// Apply filters if provided
|
||||||
|
if (options.filters) {
|
||||||
|
summaries = filterHackathons(summaries, options.filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
return summaries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single hackathon by slug (build-time)
|
||||||
|
*/
|
||||||
|
export async function getHackathonBySlug(slug: string): Promise<HackathonContent | null> {
|
||||||
|
console.log('Fetching hackathon by slug:', slug);
|
||||||
|
const hackathonDir = path.join(HACKATHONS_DIR, slug);
|
||||||
|
console.log('Resolved hackathon directory:', hackathonDir);
|
||||||
|
|
||||||
|
if (!fs.existsSync(hackathonDir)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = await loadHackathonMetadata(hackathonDir);
|
||||||
|
if (!metadata) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await loadHackathonContent(hackathonDir);
|
||||||
|
|
||||||
|
return {
|
||||||
|
metadata,
|
||||||
|
content: content || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all hackathon slugs (for static generation)
|
||||||
|
*/
|
||||||
|
export async function getAllHackathonSlugs(): Promise<string[]> {
|
||||||
|
const hackathons = await getAllHackathons({ includeDrafts: false });
|
||||||
|
return hackathons.map(h => h.metadata.slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate static index file for runtime use
|
||||||
|
*/
|
||||||
|
export async function generateHackathonIndex(): Promise<void> {
|
||||||
|
const summaries = await getHackathonSummaries({
|
||||||
|
includeDrafts: false,
|
||||||
|
sortBy: 'registrationStart',
|
||||||
|
sortDirection: 'desc'
|
||||||
|
});
|
||||||
|
|
||||||
|
const indexContent = `// Auto-generated file - do not edit manually
|
||||||
|
// Generated on: ${new Date().toISOString()}
|
||||||
|
|
||||||
|
import { HackathonSummary } from './types';
|
||||||
|
|
||||||
|
export const hackathonSummaries: HackathonSummary[] = ${JSON.stringify(summaries, null, 2)};
|
||||||
|
|
||||||
|
export const hackathonSlugs = ${JSON.stringify(summaries.map(s => s.slug), null, 2)};
|
||||||
|
|
||||||
|
export function getHackathonSummaryBySlug(slug: string): HackathonSummary | undefined {
|
||||||
|
return hackathonSummaries.find(h => h.slug === slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveHackathons(): HackathonSummary[] {
|
||||||
|
return hackathonSummaries.filter(h => h.status === 'active');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUpcomingHackathons(): HackathonSummary[] {
|
||||||
|
return hackathonSummaries.filter(h => h.status === 'upcoming');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFeaturedHackathons(): HackathonSummary[] {
|
||||||
|
return hackathonSummaries.filter(h => (h as any).featured === true);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const indexPath = path.join(HACKATHONS_DIR, 'index.ts');
|
||||||
|
fs.writeFileSync(indexPath, indexContent, 'utf-8');
|
||||||
|
|
||||||
|
console.log(`Generated hackathon index with ${summaries.length} hackathons`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Development helper - watch for changes and regenerate index
|
||||||
|
*/
|
||||||
|
export function watchHackathonChanges(): void {
|
||||||
|
if (process.env.NODE_ENV !== 'development') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.watch(HACKATHONS_DIR, { recursive: true }, (eventType, filename) => {
|
||||||
|
if (filename && (filename.includes('metadata.json') || filename.includes('content.mdx'))) {
|
||||||
|
console.log(`Hackathon content changed: ${filename}`);
|
||||||
|
generateHackathonIndex().catch(console.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build-time optimization: precompile all hackathon content
|
||||||
|
*/
|
||||||
|
export async function precompileHackathons(): Promise<void> {
|
||||||
|
console.log('Precompiling hackathon content...');
|
||||||
|
|
||||||
|
const hackathons = await getAllHackathons({ includeDrafts: false });
|
||||||
|
|
||||||
|
// Generate the main index
|
||||||
|
await generateHackathonIndex();
|
||||||
|
|
||||||
|
// Could add more optimizations here like:
|
||||||
|
// - Image optimization
|
||||||
|
// - Content minification
|
||||||
|
// - Search index generation
|
||||||
|
|
||||||
|
console.log(`Precompiled ${hackathons.length} hackathons`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Auto-generated file - do not edit manually
|
||||||
|
// This file will be regenerated by the build process
|
||||||
|
// Generated on: 2025-09-17T00:00:00.000Z
|
||||||
|
|
||||||
|
import { HackathonSummary } from './types';
|
||||||
|
|
||||||
|
export const hackathonSummaries: HackathonSummary[] = [
|
||||||
|
{
|
||||||
|
slug: 'ai-agent-hackathon-2025',
|
||||||
|
name: 'AI Agent Hackathon',
|
||||||
|
cover: 'https://cdn.andka.my.id/imphnen/534798944_122241336080178516_4647521272813012181_n.jpg',
|
||||||
|
description: 'Hackathon dalam rangka AI Agent Kemerdekaan Indonesia. Selama satu minggu, para peserta akan diminta untuk membuat proyek mereka sendiri secara online dengan bantuan lunos.tech, mailry.co, dan unli.dev.',
|
||||||
|
prize: 'Rp. 5.000.000',
|
||||||
|
tags: ['AI Agent'],
|
||||||
|
theme: 'AI Agent untuk Kemerdekaan Indonesia',
|
||||||
|
status: 'ended',
|
||||||
|
partnersCount: 2,
|
||||||
|
submissionsCount: 22,
|
||||||
|
submissionWindow: {
|
||||||
|
start: '2025-08-15T00:00:00.000Z',
|
||||||
|
end: '2025-09-20T23:59:59.999Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const hackathonSlugs = ['ai-agent-hackathon-2025'];
|
||||||
|
|
||||||
|
export function getHackathonSummaryBySlug(slug: string): HackathonSummary | undefined {
|
||||||
|
return hackathonSummaries.find(h => h.slug === slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveHackathons(): HackathonSummary[] {
|
||||||
|
return hackathonSummaries.filter(h => h.status === 'active');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUpcomingHackathons(): HackathonSummary[] {
|
||||||
|
return hackathonSummaries.filter(h => h.status === 'upcoming');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFeaturedHackathons(): HackathonSummary[] {
|
||||||
|
return hackathonSummaries.filter(h => (h as Record<string, unknown>).featured === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEndedHackathons(): HackathonSummary[] {
|
||||||
|
return hackathonSummaries.filter(h => h.status === 'ended');
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
export interface HackathonSubmission {
|
||||||
|
team_name: string;
|
||||||
|
project_title: string;
|
||||||
|
description: string;
|
||||||
|
repo_link: string;
|
||||||
|
screenshot: string;
|
||||||
|
file_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HackathonPartner {
|
||||||
|
name: string;
|
||||||
|
logo: string;
|
||||||
|
link: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HackathonPrize {
|
||||||
|
position: string;
|
||||||
|
amount: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HackathonTimeWindow {
|
||||||
|
start: string; // ISO date string
|
||||||
|
end: string; // ISO date string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HackathonJudge {
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
company?: string;
|
||||||
|
avatar?: string;
|
||||||
|
bio?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HackathonSponsor {
|
||||||
|
name: string;
|
||||||
|
logo: string;
|
||||||
|
link: string;
|
||||||
|
tier: 'title' | 'platinum' | 'gold' | 'silver' | 'bronze';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HackathonRequirement {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
mandatory: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HackathonMetadata {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
cover: string;
|
||||||
|
description?: string;
|
||||||
|
theme?: string;
|
||||||
|
status: 'draft' | 'upcoming' | 'active' | 'ended';
|
||||||
|
|
||||||
|
// Prizes and competition details
|
||||||
|
prize?: string; // Main prize display text
|
||||||
|
prizes?: HackathonPrize[];
|
||||||
|
|
||||||
|
// Tags and categorization
|
||||||
|
tags?: string[];
|
||||||
|
difficulty?: 'beginner' | 'intermediate' | 'advanced';
|
||||||
|
|
||||||
|
// Time windows
|
||||||
|
registrationStart?: string;
|
||||||
|
registrationEnd?: string;
|
||||||
|
submissionWindow?: HackathonTimeWindow;
|
||||||
|
judgingWindow?: HackathonTimeWindow;
|
||||||
|
|
||||||
|
// Participation
|
||||||
|
partnersCount?: number;
|
||||||
|
submissionsCount?: number;
|
||||||
|
maxTeamSize?: number;
|
||||||
|
minTeamSize?: number;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
partners?: HackathonPartner[];
|
||||||
|
submissions?: HackathonSubmission[];
|
||||||
|
judges?: HackathonJudge[];
|
||||||
|
sponsors?: HackathonSponsor[];
|
||||||
|
requirements?: HackathonRequirement[];
|
||||||
|
|
||||||
|
// Content metadata
|
||||||
|
contentPath?: string;
|
||||||
|
lastModified?: string;
|
||||||
|
featured?: boolean;
|
||||||
|
|
||||||
|
// SEO and social
|
||||||
|
seoTitle?: string;
|
||||||
|
seoDescription?: string;
|
||||||
|
socialImage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HackathonContent {
|
||||||
|
metadata: HackathonMetadata;
|
||||||
|
content: string; // MDX content as string
|
||||||
|
compiledContent?: React.ComponentType; // Compiled MDX component
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HackathonSummary {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
cover: string;
|
||||||
|
description?: string;
|
||||||
|
prize?: string;
|
||||||
|
tags?: string[];
|
||||||
|
theme?: string;
|
||||||
|
status?: string;
|
||||||
|
partnersCount?: number;
|
||||||
|
submissionsCount?: number;
|
||||||
|
registrationStart?: string;
|
||||||
|
registrationEnd?: string;
|
||||||
|
submissionWindow?: HackathonTimeWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HackathonApiResponse {
|
||||||
|
data: HackathonSummary[];
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HackathonFilterOptions {
|
||||||
|
status?: string[];
|
||||||
|
tags?: string[];
|
||||||
|
difficulty?: string[];
|
||||||
|
featured?: boolean;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HackathonSortOptions {
|
||||||
|
field: 'name' | 'registrationStart' | 'registrationEnd' | 'status' | 'featured';
|
||||||
|
direction: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HackathonStatus = 'draft' | 'upcoming' | 'active' | 'ended';
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { HackathonMetadata, HackathonSummary, HackathonTimeWindow, HackathonStatus } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the registration window dates for a hackathon
|
||||||
|
*/
|
||||||
|
export const getRegistrationWindow = (hackathon: HackathonMetadata | HackathonSummary) => {
|
||||||
|
const start = hackathon.submissionWindow?.start || hackathon.registrationStart || undefined;
|
||||||
|
const end = hackathon.submissionWindow?.end || hackathon.registrationEnd || undefined;
|
||||||
|
return {
|
||||||
|
start: start ? new Date(start) : undefined,
|
||||||
|
end: end ? new Date(end) : undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate days left for registration
|
||||||
|
*/
|
||||||
|
export const getDaysLeftText = (hackathon: HackathonMetadata | HackathonSummary): string | null => {
|
||||||
|
const { end } = getRegistrationWindow(hackathon);
|
||||||
|
if (!end) return null;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const ms = end.getTime() - now.getTime();
|
||||||
|
const days = Math.ceil(ms / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (days < 0) return 'Registration ended';
|
||||||
|
if (days === 0) return 'Ends today';
|
||||||
|
return `${days} days left`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate registration progress percentage
|
||||||
|
*/
|
||||||
|
export const getProgressPercent = (hackathon: HackathonMetadata | HackathonSummary): number | null => {
|
||||||
|
const { start, end } = getRegistrationWindow(hackathon);
|
||||||
|
if (!start || !end) return null;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const s = start.getTime();
|
||||||
|
const e = end.getTime();
|
||||||
|
|
||||||
|
if (now <= s) return 0;
|
||||||
|
if (now >= e) return 100;
|
||||||
|
|
||||||
|
return Math.min(100, Math.max(0, ((now - s) / (e - s)) * 100));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine hackathon status based on dates
|
||||||
|
*/
|
||||||
|
export const calculateHackathonStatus = (hackathon: HackathonMetadata): HackathonStatus => {
|
||||||
|
// If status is explicitly set to draft, keep it
|
||||||
|
if (hackathon.status === 'draft') return 'draft';
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const { start, end } = getRegistrationWindow(hackathon);
|
||||||
|
|
||||||
|
if (!start || !end) {
|
||||||
|
return hackathon.status || 'upcoming';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now < start) return 'upcoming';
|
||||||
|
if (now >= start && now <= end) return 'active';
|
||||||
|
return 'ended';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if hackathon is currently accepting registrations
|
||||||
|
*/
|
||||||
|
export const isRegistrationOpen = (hackathon: HackathonMetadata | HackathonSummary): boolean => {
|
||||||
|
const status = typeof hackathon.status === 'string'
|
||||||
|
? hackathon.status.toLowerCase().trim()
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (status === 'ended' || status === 'draft') return false;
|
||||||
|
|
||||||
|
const { start, end } = getRegistrationWindow(hackathon);
|
||||||
|
if (!start || !end) return false;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
return now >= start && now <= end;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date to readable string
|
||||||
|
*/
|
||||||
|
export const formatHackathonDate = (dateString: string): string => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('id-ID', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date range
|
||||||
|
*/
|
||||||
|
export const formatDateRange = (window: HackathonTimeWindow): string => {
|
||||||
|
const start = formatHackathonDate(window.start);
|
||||||
|
const end = formatHackathonDate(window.end);
|
||||||
|
return `${start} - ${end}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate slug from hackathon name
|
||||||
|
*/
|
||||||
|
export const generateSlug = (name: string): string => {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate hackathon metadata
|
||||||
|
*/
|
||||||
|
export const validateHackathonMetadata = (metadata: Partial<HackathonMetadata>): string[] => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
if (!metadata.slug) errors.push('Slug is required');
|
||||||
|
if (!metadata.name) errors.push('Name is required');
|
||||||
|
if (!metadata.cover) errors.push('Cover image is required');
|
||||||
|
|
||||||
|
if (metadata.submissionWindow) {
|
||||||
|
const start = new Date(metadata.submissionWindow.start);
|
||||||
|
const end = new Date(metadata.submissionWindow.end);
|
||||||
|
|
||||||
|
if (start >= end) {
|
||||||
|
errors.push('Submission end date must be after start date');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.maxTeamSize && metadata.minTeamSize) {
|
||||||
|
if (metadata.maxTeamSize < metadata.minTeamSize) {
|
||||||
|
errors.push('Max team size must be greater than or equal to min team size');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert HackathonMetadata to HackathonSummary
|
||||||
|
*/
|
||||||
|
export const toHackathonSummary = (metadata: HackathonMetadata): HackathonSummary => {
|
||||||
|
return {
|
||||||
|
slug: metadata.slug,
|
||||||
|
name: metadata.name,
|
||||||
|
cover: metadata.cover,
|
||||||
|
description: metadata.description,
|
||||||
|
prize: metadata.prize,
|
||||||
|
tags: metadata.tags,
|
||||||
|
theme: metadata.theme,
|
||||||
|
status: metadata.status,
|
||||||
|
partnersCount: metadata.partnersCount,
|
||||||
|
submissionsCount: metadata.submissionsCount,
|
||||||
|
registrationStart: metadata.registrationStart,
|
||||||
|
registrationEnd: metadata.registrationEnd,
|
||||||
|
submissionWindow: metadata.submissionWindow,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter hackathons based on criteria
|
||||||
|
*/
|
||||||
|
export const filterHackathons = (
|
||||||
|
hackathons: HackathonSummary[],
|
||||||
|
filters: {
|
||||||
|
status?: string[];
|
||||||
|
tags?: string[];
|
||||||
|
search?: string;
|
||||||
|
featured?: boolean;
|
||||||
|
}
|
||||||
|
): HackathonSummary[] => {
|
||||||
|
return hackathons.filter(hackathon => {
|
||||||
|
// Status filter
|
||||||
|
if (filters.status && filters.status.length > 0) {
|
||||||
|
const currentStatus = calculateHackathonStatus(hackathon as HackathonMetadata);
|
||||||
|
if (!filters.status.includes(currentStatus)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags filter
|
||||||
|
if (filters.tags && filters.tags.length > 0) {
|
||||||
|
if (!hackathon.tags || !filters.tags.some(tag => hackathon.tags?.includes(tag))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search filter
|
||||||
|
if (filters.search) {
|
||||||
|
const searchTerm = filters.search.toLowerCase();
|
||||||
|
const searchableText = [
|
||||||
|
hackathon.name,
|
||||||
|
hackathon.description,
|
||||||
|
hackathon.theme,
|
||||||
|
...(hackathon.tags || [])
|
||||||
|
].join(' ').toLowerCase();
|
||||||
|
|
||||||
|
if (!searchableText.includes(searchTerm)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort hackathons
|
||||||
|
*/
|
||||||
|
export const sortHackathons = (
|
||||||
|
hackathons: HackathonSummary[],
|
||||||
|
sortBy: 'name' | 'registrationStart' | 'status' | 'featured' = 'registrationStart',
|
||||||
|
direction: 'asc' | 'desc' = 'desc'
|
||||||
|
): HackathonSummary[] => {
|
||||||
|
return [...hackathons].sort((a, b) => {
|
||||||
|
let comparison = 0;
|
||||||
|
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'name':
|
||||||
|
comparison = a.name.localeCompare(b.name);
|
||||||
|
break;
|
||||||
|
case 'registrationStart': {
|
||||||
|
const aDate = getRegistrationWindow(a).start?.getTime() || 0;
|
||||||
|
const bDate = getRegistrationWindow(b).start?.getTime() || 0;
|
||||||
|
comparison = aDate - bDate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'status': {
|
||||||
|
const statusOrder = { 'active': 0, 'upcoming': 1, 'ended': 2, 'draft': 3 };
|
||||||
|
const aStatus = calculateHackathonStatus(a as HackathonMetadata);
|
||||||
|
const bStatus = calculateHackathonStatus(b as HackathonMetadata);
|
||||||
|
comparison = (statusOrder[aStatus] || 99) - (statusOrder[bStatus] || 99);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
comparison = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return direction === 'desc' ? -comparison : comparison;
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import { HackathonMetadata } from './types';
|
||||||
|
|
||||||
|
export interface ValidationResult {
|
||||||
|
isValid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidationRule<T> {
|
||||||
|
name: string;
|
||||||
|
validate: (value: T) => ValidationResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate required fields
|
||||||
|
*/
|
||||||
|
export const validateRequiredFields = (metadata: Partial<HackathonMetadata>): ValidationResult => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const requiredFields = [
|
||||||
|
{ key: 'slug', name: 'Slug' },
|
||||||
|
{ key: 'name', name: 'Name' },
|
||||||
|
{ key: 'cover', name: 'Cover image' },
|
||||||
|
{ key: 'status', name: 'Status' }
|
||||||
|
];
|
||||||
|
|
||||||
|
requiredFields.forEach(({ key, name }) => {
|
||||||
|
if (!metadata[key as keyof HackathonMetadata]) {
|
||||||
|
errors.push(`${name} is required`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
warnings: []
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate slug format
|
||||||
|
*/
|
||||||
|
export const validateSlug = (slug: string): ValidationResult => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
if (!slug) {
|
||||||
|
errors.push('Slug cannot be empty');
|
||||||
|
} else {
|
||||||
|
// Check slug format
|
||||||
|
const slugRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||||
|
if (!slugRegex.test(slug)) {
|
||||||
|
errors.push('Slug must contain only lowercase letters, numbers, and hyphens');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check length
|
||||||
|
if (slug.length < 3) {
|
||||||
|
errors.push('Slug must be at least 3 characters long');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slug.length > 100) {
|
||||||
|
errors.push('Slug must be less than 100 characters');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for consecutive hyphens
|
||||||
|
if (slug.includes('--')) {
|
||||||
|
errors.push('Slug cannot contain consecutive hyphens');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check start/end
|
||||||
|
if (slug.startsWith('-') || slug.endsWith('-')) {
|
||||||
|
errors.push('Slug cannot start or end with a hyphen');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
warnings
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate dates
|
||||||
|
*/
|
||||||
|
export const validateDates = (metadata: Partial<HackathonMetadata>): ValidationResult => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
// Validate submission window
|
||||||
|
if (metadata.submissionWindow) {
|
||||||
|
const start = new Date(metadata.submissionWindow.start);
|
||||||
|
const end = new Date(metadata.submissionWindow.end);
|
||||||
|
|
||||||
|
if (isNaN(start.getTime())) {
|
||||||
|
errors.push('Invalid submission start date');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNaN(end.getTime())) {
|
||||||
|
errors.push('Invalid submission end date');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start.getTime() >= end.getTime()) {
|
||||||
|
errors.push('Submission end date must be after start date');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if dates are in the past
|
||||||
|
const now = new Date();
|
||||||
|
if (end.getTime() < now.getTime()) {
|
||||||
|
warnings.push('Submission end date is in the past');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check reasonable duration
|
||||||
|
const duration = end.getTime() - start.getTime();
|
||||||
|
const durationDays = duration / (1000 * 60 * 60 * 24);
|
||||||
|
|
||||||
|
if (durationDays < 1) {
|
||||||
|
warnings.push('Submission window is less than 1 day');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (durationDays > 365) {
|
||||||
|
warnings.push('Submission window is longer than 1 year');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate judging window
|
||||||
|
if (metadata.judgingWindow) {
|
||||||
|
const start = new Date(metadata.judgingWindow.start);
|
||||||
|
const end = new Date(metadata.judgingWindow.end);
|
||||||
|
|
||||||
|
if (isNaN(start.getTime())) {
|
||||||
|
errors.push('Invalid judging start date');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNaN(end.getTime())) {
|
||||||
|
errors.push('Invalid judging end date');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start.getTime() >= end.getTime()) {
|
||||||
|
errors.push('Judging end date must be after start date');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check judging starts after submission ends
|
||||||
|
if (metadata.submissionWindow) {
|
||||||
|
const submissionEnd = new Date(metadata.submissionWindow.end);
|
||||||
|
if (start.getTime() < submissionEnd.getTime()) {
|
||||||
|
warnings.push('Judging should start after submission window ends');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
warnings
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate team size constraints
|
||||||
|
*/
|
||||||
|
export const validateTeamSize = (metadata: Partial<HackathonMetadata>): ValidationResult => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
if (metadata.minTeamSize !== undefined && metadata.maxTeamSize !== undefined) {
|
||||||
|
if (metadata.minTeamSize < 1) {
|
||||||
|
errors.push('Minimum team size must be at least 1');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.maxTeamSize < 1) {
|
||||||
|
errors.push('Maximum team size must be at least 1');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.maxTeamSize < metadata.minTeamSize) {
|
||||||
|
errors.push('Maximum team size must be greater than or equal to minimum team size');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.maxTeamSize > 20) {
|
||||||
|
warnings.push('Maximum team size is unusually large (>20)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
warnings
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate URLs
|
||||||
|
*/
|
||||||
|
export const validateUrls = (metadata: Partial<HackathonMetadata>): ValidationResult => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
// Validate cover image URL
|
||||||
|
if (metadata.cover) {
|
||||||
|
try {
|
||||||
|
new URL(metadata.cover);
|
||||||
|
} catch {
|
||||||
|
errors.push('Cover image must be a valid URL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate social image URL
|
||||||
|
if (metadata.socialImage) {
|
||||||
|
try {
|
||||||
|
new URL(metadata.socialImage);
|
||||||
|
} catch {
|
||||||
|
errors.push('Social image must be a valid URL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate partner URLs
|
||||||
|
if (metadata.partners) {
|
||||||
|
metadata.partners.forEach((partner, index) => {
|
||||||
|
try {
|
||||||
|
new URL(partner.link);
|
||||||
|
} catch {
|
||||||
|
errors.push(`Partner ${index + 1} link must be a valid URL`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
new URL(partner.logo);
|
||||||
|
} catch {
|
||||||
|
errors.push(`Partner ${index + 1} logo must be a valid URL`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate sponsor URLs
|
||||||
|
if (metadata.sponsors) {
|
||||||
|
metadata.sponsors.forEach((sponsor, index) => {
|
||||||
|
try {
|
||||||
|
new URL(sponsor.link);
|
||||||
|
} catch {
|
||||||
|
errors.push(`Sponsor ${index + 1} link must be a valid URL`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
new URL(sponsor.logo);
|
||||||
|
} catch {
|
||||||
|
errors.push(`Sponsor ${index + 1} logo must be a valid URL`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
warnings
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate status
|
||||||
|
*/
|
||||||
|
export const validateStatus = (status: string): ValidationResult => {
|
||||||
|
const validStatuses = ['draft', 'upcoming', 'active', 'ended'];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
if (!validStatuses.includes(status)) {
|
||||||
|
errors.push(`Status must be one of: ${validStatuses.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
warnings: []
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comprehensive validation
|
||||||
|
*/
|
||||||
|
export const validateHackathon = (metadata: Partial<HackathonMetadata>): ValidationResult => {
|
||||||
|
const validations = [
|
||||||
|
validateRequiredFields(metadata),
|
||||||
|
metadata.slug ? validateSlug(metadata.slug) : { isValid: true, errors: [], warnings: [] },
|
||||||
|
validateDates(metadata),
|
||||||
|
validateTeamSize(metadata),
|
||||||
|
validateUrls(metadata),
|
||||||
|
metadata.status ? validateStatus(metadata.status) : { isValid: true, errors: [], warnings: [] }
|
||||||
|
];
|
||||||
|
|
||||||
|
const allErrors = validations.flatMap(v => v.errors);
|
||||||
|
const allWarnings = validations.flatMap(v => v.warnings);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: allErrors.length === 0,
|
||||||
|
errors: allErrors,
|
||||||
|
warnings: allWarnings
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate hackathon content file structure
|
||||||
|
*/
|
||||||
|
export const validateHackathonStructure = (directoryPath: string): ValidationResult => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
// This would be implemented to check file system structure
|
||||||
|
// For now, just return a placeholder
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
warnings
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import matter from 'gray-matter';
|
||||||
|
|
||||||
|
export interface MDXContent {
|
||||||
|
content: string;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
slug: string;
|
||||||
|
filePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read and parse MDX file
|
||||||
|
*/
|
||||||
|
export async function readMDXFile(filePath: string): Promise<MDXContent | null> {
|
||||||
|
try {
|
||||||
|
const fileContent = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
const { data: metadata, content } = matter(fileContent);
|
||||||
|
|
||||||
|
const fileName = path.basename(filePath, path.extname(filePath));
|
||||||
|
const slug = fileName === 'content' ? path.basename(path.dirname(filePath)) : fileName;
|
||||||
|
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
metadata,
|
||||||
|
slug,
|
||||||
|
filePath
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error reading MDX file ${filePath}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all MDX files in a directory
|
||||||
|
*/
|
||||||
|
export async function getMDXFiles(directoryPath: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(directoryPath)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = fs.readdirSync(directoryPath, { withFileTypes: true });
|
||||||
|
const mdxFiles: string[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const fullPath = path.join(directoryPath, file.name);
|
||||||
|
|
||||||
|
if (file.isDirectory()) {
|
||||||
|
// Look for content.mdx in subdirectories
|
||||||
|
const contentPath = path.join(fullPath, 'content.mdx');
|
||||||
|
if (fs.existsSync(contentPath)) {
|
||||||
|
mdxFiles.push(contentPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also look for any .mdx files directly in subdirectories
|
||||||
|
const subFiles = await getMDXFiles(fullPath);
|
||||||
|
mdxFiles.push(...subFiles);
|
||||||
|
} else if (file.name.endsWith('.mdx')) {
|
||||||
|
mdxFiles.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mdxFiles;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error reading directory ${directoryPath}:`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract metadata from content directory structure
|
||||||
|
*/
|
||||||
|
export function extractMetadataFromPath(filePath: string, baseDir: string): Record<string, unknown> {
|
||||||
|
const relativePath = path.relative(baseDir, filePath);
|
||||||
|
const pathParts = relativePath.split(path.sep);
|
||||||
|
|
||||||
|
// If file is in a subdirectory, use directory name as slug
|
||||||
|
if (pathParts.length > 1) {
|
||||||
|
const directoryName = pathParts[pathParts.length - 2];
|
||||||
|
return {
|
||||||
|
slug: directoryName,
|
||||||
|
contentPath: relativePath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If file is directly in the base directory, use filename as slug
|
||||||
|
const fileName = path.basename(filePath, path.extname(filePath));
|
||||||
|
return {
|
||||||
|
slug: fileName,
|
||||||
|
contentPath: relativePath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate MDX frontmatter
|
||||||
|
*/
|
||||||
|
export function validateMDXFrontmatter(
|
||||||
|
metadata: Record<string, unknown>,
|
||||||
|
requiredFields: string[] = []
|
||||||
|
): { isValid: boolean; errors: string[] } {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (const field of requiredFields) {
|
||||||
|
if (!(field in metadata) || metadata[field] === undefined || metadata[field] === null) {
|
||||||
|
errors.push(`Missing required field: ${field}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process content images and assets
|
||||||
|
*/
|
||||||
|
export function processContentAssets(content: string, assetsBasePath: string): string {
|
||||||
|
// Replace relative image paths with absolute paths
|
||||||
|
return content.replace(
|
||||||
|
/!\[([^\]]*)\]\((?!https?:\/\/)([^)]+)\)/g,
|
||||||
|
(match, alt, src) => {
|
||||||
|
// Convert relative paths to absolute paths
|
||||||
|
const absolutePath = path.posix.join(assetsBasePath, src);
|
||||||
|
return ``;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate content index for build-time optimization
|
||||||
|
*/
|
||||||
|
export async function generateContentIndex<T>(
|
||||||
|
contentDir: string,
|
||||||
|
metadataParser: (mdxContent: MDXContent) => T | null
|
||||||
|
): Promise<T[]> {
|
||||||
|
const mdxFiles = await getMDXFiles(contentDir);
|
||||||
|
const contentItems: T[] = [];
|
||||||
|
|
||||||
|
for (const filePath of mdxFiles) {
|
||||||
|
const mdxContent = await readMDXFile(filePath);
|
||||||
|
if (mdxContent) {
|
||||||
|
const parsedMetadata = metadataParser(mdxContent);
|
||||||
|
if (parsedMetadata) {
|
||||||
|
contentItems.push(parsedMetadata);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return contentItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create content manifest for client-side use
|
||||||
|
*/
|
||||||
|
export function createContentManifest<T>(
|
||||||
|
items: T[],
|
||||||
|
options: {
|
||||||
|
sortBy?: keyof T;
|
||||||
|
sortDirection?: 'asc' | 'desc';
|
||||||
|
filterDrafts?: boolean;
|
||||||
|
} = {}
|
||||||
|
): {
|
||||||
|
items: T[];
|
||||||
|
total: number;
|
||||||
|
lastUpdated: string;
|
||||||
|
} {
|
||||||
|
let processedItems = [...items];
|
||||||
|
|
||||||
|
// Filter drafts if requested
|
||||||
|
if (options.filterDrafts) {
|
||||||
|
processedItems = processedItems.filter(
|
||||||
|
item => (item as Record<string, unknown>).status !== 'draft'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort if requested
|
||||||
|
if (options.sortBy) {
|
||||||
|
processedItems.sort((a, b) => {
|
||||||
|
const sortBy = options.sortBy;
|
||||||
|
if (!sortBy) return 0;
|
||||||
|
|
||||||
|
const aValue = a[sortBy];
|
||||||
|
const bValue = b[sortBy];
|
||||||
|
|
||||||
|
let comparison = 0;
|
||||||
|
if (aValue < bValue) comparison = -1;
|
||||||
|
if (aValue > bValue) comparison = 1;
|
||||||
|
|
||||||
|
return options.sortDirection === 'desc' ? -comparison : comparison;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: processedItems,
|
||||||
|
total: processedItems.length,
|
||||||
|
lastUpdated: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,28 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
||||||
|
const [cookie, setCookie] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkAuth = () => {
|
||||||
|
const accessToken = document.cookie
|
||||||
|
.split('; ')
|
||||||
|
.find(row => row.startsWith('__imphnen_access_token__='))
|
||||||
|
?.split('=')[1];
|
||||||
|
|
||||||
|
setIsAuthenticated(!!accessToken);
|
||||||
|
setCookie(accessToken || null)
|
||||||
|
};
|
||||||
|
|
||||||
|
checkAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function getToken (){
|
||||||
|
if(isAuthenticated) return cookie
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isAuthenticated, getToken };
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
@import "../../../../libs/shadcn-ui/src/index.css";
|
@import "../../../../libs/shadcn-ui/src/index.css";
|
||||||
@source "../../../../libs/shadcn-ui/src/atoms/**/*.{ts,tsx}";
|
@source "../../../../libs/shadcn-ui/src/atoms/**/*.{ts,tsx}";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
Generated
+1853
-526
File diff suppressed because it is too large
Load Diff
@@ -41,9 +41,12 @@
|
|||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"framer-motion": "^12.9.2",
|
"framer-motion": "^12.9.2",
|
||||||
"graphql": "^16.11.0",
|
"graphql": "^16.11.0",
|
||||||
|
"gray-matter": "^4.0.3",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"next": "~15.2.4",
|
"next": "~15.2.4",
|
||||||
|
"next-mdx-remote": "^5.0.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"nextjs-toploader": "^3.9.17",
|
||||||
"openapi-fetch": "^0.14.0",
|
"openapi-fetch": "^0.14.0",
|
||||||
"openapi-react-query": "^0.5.0",
|
"openapi-react-query": "^0.5.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
@@ -90,6 +93,7 @@
|
|||||||
"@swc/core": "~1.5.7",
|
"@swc/core": "~1.5.7",
|
||||||
"@swc/helpers": "~0.5.11",
|
"@swc/helpers": "~0.5.11",
|
||||||
"@tailwindcss/postcss": "^4.0.13",
|
"@tailwindcss/postcss": "^4.0.13",
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@testing-library/dom": "10.4.0",
|
"@testing-library/dom": "10.4.0",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.1.0",
|
"@testing-library/react": "^16.1.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user