Compare commits
3
Commits
| 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 NAVIGATIONS from '@/data/navigations.json';
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
import { Button } from '@components';
|
||||
import { cn } from '@utils';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
@@ -13,6 +14,7 @@ import { LuMenu, LuX } from 'react-icons/lu';
|
||||
export function Header() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -30,6 +32,13 @@ export function Header() {
|
||||
setMobileMenuOpen(false);
|
||||
}, [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 (
|
||||
<header className="sticky top-0 w-full z-50 bg-background/70">
|
||||
<div className="container flex h-20 items-center justify-between">
|
||||
@@ -62,19 +71,31 @@ export function Header() {
|
||||
</nav>
|
||||
|
||||
<div className="hidden md:flex items-center gap-x-3">
|
||||
<Button
|
||||
onClick={() => router.push('/signin')}
|
||||
className="px-5 py-2 text-sm font-medium"
|
||||
>
|
||||
Masuk
|
||||
</Button>
|
||||
<Button
|
||||
variant="bordered"
|
||||
onClick={() => router.push('/signup')}
|
||||
className="px-5 py-2 text-sm font-medium shadow-lg shadow-primary/20 hover:shadow-primary/30"
|
||||
>
|
||||
Daftar
|
||||
</Button>
|
||||
{isAuthenticated ? (
|
||||
<Button
|
||||
onClick={handleLogout}
|
||||
variant="bordered"
|
||||
className="px-5 py-2 text-sm font-medium"
|
||||
>
|
||||
Keluar
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => router.push('/signin')}
|
||||
className="px-5 py-2 text-sm font-medium"
|
||||
>
|
||||
Masuk
|
||||
</Button>
|
||||
<Button
|
||||
variant="bordered"
|
||||
onClick={() => router.push('/signup')}
|
||||
className="px-5 py-2 text-sm font-medium shadow-lg shadow-primary/20 hover:shadow-primary/30"
|
||||
>
|
||||
Daftar
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -144,25 +165,40 @@ export function Header() {
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setMobileMenuOpen(false);
|
||||
router.push('/signin');
|
||||
}}
|
||||
className="w-full py-4 text-base"
|
||||
>
|
||||
Masuk
|
||||
</Button>
|
||||
<Button
|
||||
variant="bordered"
|
||||
onClick={() => {
|
||||
setMobileMenuOpen(false);
|
||||
router.push('/signup');
|
||||
}}
|
||||
className="w-full py-4 text-base shadow-lg shadow-primary/20"
|
||||
>
|
||||
Daftar
|
||||
</Button>
|
||||
{isAuthenticated ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setMobileMenuOpen(false);
|
||||
handleLogout();
|
||||
}}
|
||||
variant="bordered"
|
||||
className="w-full py-4 text-base"
|
||||
>
|
||||
Keluar
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setMobileMenuOpen(false);
|
||||
router.push('/signin');
|
||||
}}
|
||||
className="w-full py-4 text-base"
|
||||
>
|
||||
Masuk
|
||||
</Button>
|
||||
<Button
|
||||
variant="bordered"
|
||||
onClick={() => {
|
||||
setMobileMenuOpen(false);
|
||||
router.push('/signup');
|
||||
}}
|
||||
className="w-full py-4 text-base shadow-lg shadow-primary/20"
|
||||
>
|
||||
Daftar
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</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 { buttonVariants } from '@components';
|
||||
import { cn } from '@utils';
|
||||
import Image from 'next/image';
|
||||
import { HiOutlineCode } from 'react-icons/hi';
|
||||
import React from 'react';
|
||||
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';
|
||||
|
||||
export default function HackathonsPage() {
|
||||
const sortedHackathons = [...hackathons];
|
||||
|
||||
if (sortedHackathons.length === 0) {
|
||||
const HackathonTags: React.FC<{ tags?: string[] }> = ({ tags }) => {
|
||||
if (!tags || tags.length === 0) return null;
|
||||
return (
|
||||
<section className="min-h-screen bg-background container py-10 flex items-center justify-center">
|
||||
<p className="text-muted-foreground text-lg">No hackathon projects available yet.</p>
|
||||
</section>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="min-h-screen bg-background container py-10">
|
||||
<motion.div
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={{
|
||||
visible: { transition: { staggerChildren: 0.12 } },
|
||||
}}
|
||||
>
|
||||
{sortedHackathons.map((hackathon, idx) => (
|
||||
<motion.div
|
||||
key={hackathon.project_title}
|
||||
className="rounded-xl shadow-sm hover:shadow-lg transition-all duration-300 bg-card group"
|
||||
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)' }}
|
||||
>
|
||||
<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>
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</section>
|
||||
);
|
||||
<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() {
|
||||
const [filteredItems, setFilteredItems] = React.useState<HackathonSummary[]>(hackathonSummaries);
|
||||
const [searchTerm, setSearchTerm] = React.useState('');
|
||||
const [statusFilter, setStatusFilter] = React.useState<string>('all');
|
||||
|
||||
|
||||
React.useEffect(() => {
|
||||
let filtered = [...hackathonSummaries];
|
||||
if (searchTerm) {
|
||||
filtered = filterHackathons(filtered, { search: searchTerm });
|
||||
}
|
||||
|
||||
if (statusFilter !== 'all') {
|
||||
filtered = filterHackathons(filtered, { status: [statusFilter] });
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={{
|
||||
visible: { transition: { staggerChildren: 0.12 } },
|
||||
}}
|
||||
>
|
||||
{filteredItems.map((hackathon, idx) => (
|
||||
<HackathonCard key={hackathon.slug || hackathon.name} hackathon={hackathon} idx={idx} />
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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!
|
||||
@@ -0,0 +1,246 @@
|
||||
{
|
||||
"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.",
|
||||
"theme": "AI Agent untuk Kemerdekaan Indonesia",
|
||||
"status": "ended",
|
||||
"prize": "Rp. 5.000.000",
|
||||
"prizes": [
|
||||
{
|
||||
"position": "Juara 1",
|
||||
"amount": "Rp. 5.000.000",
|
||||
"description": "Hadiah utama untuk tim terbaik"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"AI Agent"
|
||||
],
|
||||
"difficulty": "intermediate",
|
||||
"submissionWindow": {
|
||||
"start": "2025-08-15T00:00:00.000Z",
|
||||
"end": "2025-08-22T23:59:59.999Z"
|
||||
},
|
||||
"minTeamSize": 1,
|
||||
"maxTeamSize": 3,
|
||||
"partnersCount": 2,
|
||||
"submissionsCount": 22,
|
||||
"partners": [
|
||||
{
|
||||
"name": "Lunos.tech",
|
||||
"logo": "https://lunos.tech/favicon.ico",
|
||||
"link": "https://lunos.tech"
|
||||
},
|
||||
{
|
||||
"name": "Mailry.co",
|
||||
"logo": "https://mailry.co/favicon.ico",
|
||||
"link": "https://mailry.co"
|
||||
},
|
||||
{
|
||||
"name": "Unli.dev",
|
||||
"logo": "https://unli.dev/favicon.ico",
|
||||
"link": "https://unli.dev"
|
||||
}
|
||||
],
|
||||
"requirements": [
|
||||
{
|
||||
"id": "lunos-tech",
|
||||
"name": "lunos.tech",
|
||||
"description": "Wajib menggunakan platform lunos.tech dalam proyek",
|
||||
"mandatory": true
|
||||
},
|
||||
{
|
||||
"id": "mailry-co",
|
||||
"name": "mailry.co",
|
||||
"description": "Wajib menggunakan platform mailry.co dalam proyek",
|
||||
"mandatory": true
|
||||
},
|
||||
{
|
||||
"id": "unli-dev",
|
||||
"name": "unli.dev",
|
||||
"description": "Wajib menggunakan platform unli.dev dalam proyek",
|
||||
"mandatory": true
|
||||
}
|
||||
],
|
||||
"seoTitle": "AI Agent Hackathon 2025 - IMPHNEN",
|
||||
"seoDescription": "Hackathon AI Agent untuk Kemerdekaan Indonesia. Bergabunglah dengan pengembang dari seluruh Indonesia untuk membangun solusi AI yang inovatif.",
|
||||
"socialImage": "https://cdn.andka.my.id/imphnen/534798944_122241336080178516_4647521272813012181_n.jpg",
|
||||
"submissions": [
|
||||
{
|
||||
"team_name": "Lineproject",
|
||||
"project_title": "LaporMerdeka",
|
||||
"description": "Platform pelaporan infrastruktur publik Indonesia yang memungkinkan warga melaporkan masalah dengan cepat dan mudah untuk Indonesia yang lebih baik.",
|
||||
"repo_link": "https://github.com/MANFIT7/lapormerdeka",
|
||||
"screenshot": "https://drive.google.com/open?id=1tbOJKacQGsfldr5TsWtzNKL65iCpADz2",
|
||||
"file_name": "Screenshot 2025-08-22 062036 - Fafnir.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Aliansi switch",
|
||||
"project_title": "News-ai",
|
||||
"description": "ai agent untuk memilah beritah hoax dengan asli",
|
||||
"repo_link": "https://github.com/7FIl/News-AI",
|
||||
"screenshot": "https://drive.google.com/open?id=1IYoOB1zqL70tpxaeopdS6VtuL8hoqpPn",
|
||||
"file_name": "Screenshot 2025-08-22 223626 - 7Fil.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Sodev Sedap",
|
||||
"project_title": "Sejarah Alternatif ID",
|
||||
"description": "Website AI Agent yang dapat memberikan user pov bagaimana jika user ada di situasi tersebut menggunakan reka adegan dengan pendekatan teks dengan gaya novel",
|
||||
"repo_link": "https://github.com/rizalkr/sejarah-alternatif-id/tree/main",
|
||||
"screenshot": "https://drive.google.com/open?id=1TMUgayvtI45gU79I-0SokQnPJP6LHQaC",
|
||||
"file_name": "Screenshot 2025-08-23 115137 - Rizal Kurnia.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Muhammad Harafsan Alhad",
|
||||
"project_title": "Elysia AI Kemerdekaan Indonesia",
|
||||
"description": "“Sebuah chatbot AI interaktif yang menampilkan Elysia (dari Honkai Impact) yang menjawab pertanyaan tentang Kemerdekaan Indonesia dengan gaya khas Elysia, lengkap dengan fitur kuis interaktif.",
|
||||
"repo_link": "https://github.com/rafsanalhad/elysia-ai-kemerdekaan",
|
||||
"screenshot": "https://drive.google.com/open?id=1_QE59lgHNbJfuVikJ5KT0_85tbJc6pMo",
|
||||
"file_name": "Screenshot 2025-08-23 131756 - Ralhad Alhad.png"
|
||||
},
|
||||
{
|
||||
"team_name": "RaflanGT",
|
||||
"project_title": "Ecobot",
|
||||
"description": "EcoBot adalah AI Agent yang hadir untuk menjawab tantangan pengelolaan sampah dan keterbatasan digitalisasi di masyarakat. Melalui WhatsApp yang akrab bagi warga, EcoBot memandu pemilahan sampah dengan analisis gambar berbasis AI sekaligus menumbuhkan kesadaran lingkungan. Kemerdekaan bukan hanya bebas dari penjajahan, tetapi juga kesadaran kolektif untuk mengelola hal-hal sederhana yang berdampak besar. Dengan langkah kecil seperti ini, desa dan masyarakat dapat mandiri secara digital, menjaga lingkungan, dan bersama-sama membawa Indonesia terus maju.",
|
||||
"repo_link": "https://github.com/mycoderisyad/raflangt-ecobot",
|
||||
"screenshot": "https://drive.google.com/open?id=1lj5DMJfxSrCwyNSLIlq-GQohJHCUDU1-",
|
||||
"file_name": "Screenshot 2025-08-23 223413 - MRisyad Raflan.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Tchh Tidak Akan",
|
||||
"project_title": "Merdeka Quiziz",
|
||||
"description": "Merdeka Quiziz merupakan web kuis yang menggunakan tema Kemerdekaan Indonesia dengan fitur gamifikasi yang membuat kuis menjadi menyenangkan, dimana setiap kuis dibuat oleh Mera (AI) dan dipersonalisasi untuk pengguna. Selain itu di Merdeka Quiziz pengguna juga dapat membahas sejarah Indonesia bersama Mera (AI).",
|
||||
"repo_link": "https://gitlab.com/personal-projects9094234/merdeka-quiziz",
|
||||
"screenshot": "https://drive.google.com/open?id=1U_UMaYABLjbO38GA9DopXebDWznFKQcI",
|
||||
"file_name": "Screenshot 2025-08-24 at 09.15.06 - Khen Cahyo.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Pengen Ikut tapi Bingung Mau Buat Apa",
|
||||
"project_title": "IMMPHNEN (Ingin Menjadi Mesin Pencari Handal Namun Enggan Ngecrawl)",
|
||||
"description": "Mesin pencari yang didesain untuk memerdekakan para pencari informasi dari tracker-tracker yang berlebihan (lelah bukan habis mencari A, nongol iklan A dimana-mana?). Memiliki fitur ringkasan pencarian, serta filter negatif penelusuran (judi & pornografi). Dibuat dengan LangSearch dan Lunos(ChatGPT 5.0).",
|
||||
"repo_link": "https://gitlab.com/myracledev/py-search-engine",
|
||||
"screenshot": "https://drive.google.com/open?id=1xNFtvGSLfWwdA4Mx46S7bx2nmwpt5CXA",
|
||||
"file_name": "{CBBB6849-BC8E-4435-9C6A-8C88C83287DF} - Mohamad Yusuf Rizaldi.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Ayam Geprek",
|
||||
"project_title": "SURA AI (Suara Rakyat)",
|
||||
"description": "SIngkatnya ini itu AI yang jadi mewakili hati rakyat Indonesia (bukan dpr). Dia bukan sekadar asisten digital, kenapa? ya karena dia kritis, cerdas, dan punya selera sinis yang bikin narasi kekuasaan gampang dibongkar. Gayanya penuh satir, dan sering pakai perumpamaan yang sangat panas. Sura AI hadir untuk menantang pemikiran, membakar semangat, dan memberikan perspektif yang ngga takut ngomong jujur tentang realita sosial dan politik.",
|
||||
"repo_link": "https://github.com/Roti18/sura-ai",
|
||||
"screenshot": "https://drive.google.com/open?id=1uUWGu08NimQ1qV9E-xu0h39HyoAYrclS",
|
||||
"file_name": "Screenshot 2025-08-24 204538 - Roti 1.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Fae",
|
||||
"project_title": "Daily Commit",
|
||||
"description": "Daily Commit adalah semacam alarm commit yang bakal ngingetin kamu kalau seharian nggak ada commit di GitHub. Tapi kalau rajin, dia juga bisa jadi cheerleader digital yang muji-muji kamu.",
|
||||
"repo_link": "https://github.com/far-id/send-mail-mailry.git",
|
||||
"screenshot": "https://drive.google.com/open?id=1GAvN4RxW_gAvOfew7LbbHANEx2F3lC5y",
|
||||
"file_name": "GITHUB~1.PNG"
|
||||
},
|
||||
{
|
||||
"team_name": "garudaStack",
|
||||
"project_title": "Tani AI",
|
||||
"description": "Tani AI adalah AI agent andalan anda untuk membantu dalam perkembangan, produktifitas serta analisis untuk komoditas pertanian anda.",
|
||||
"repo_link": "FE : https://github.com/Jazaniest/garuda-ai-frontend.git BE : https://github.com/Rifaldy1292/be-hackaton.git",
|
||||
"screenshot": "https://drive.google.com/open?id=173dS3v9sAJXMOxs7uY_SG-TUW9wIo_WM",
|
||||
"file_name": "Tani AI - M Abdillah Aljazani.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Roki Miftah Kamaludin",
|
||||
"project_title": "Mengenang Pahlawan",
|
||||
"description": "Mengenang Pahlawan adalah platform digital untuk mengenang dan mempelajari kisah pahlawan nasional Indonesia. Aplikasi ini menyajikan biografi, foto, serta informasi resmi terkait penetapan gelar pahlawan.\n\nSelain sebagai ensiklopedia digital, platform ini juga dilengkapi fitur interaktif seperti kuis edukatif, pencarian, dan poin penghargaan.",
|
||||
"repo_link": "https://github.com/rokimiftah/mengenang-pahlawan",
|
||||
"screenshot": "https://drive.google.com/open?id=140c-FyndYCAOCtKChbRaENc9fgWvQwU2",
|
||||
"file_name": "mengenang-pahlawan - Roki Miftah Kamaludin.png"
|
||||
},
|
||||
{
|
||||
"team_name": "LokerHunter",
|
||||
"project_title": "LokerKerja",
|
||||
"description": "Sebuah platform job matching yang memanfaatkan analisis CV atau portofolio untuk mengidentifikasi keahlian utama pengguna dan melakukan inferensi otomatis terhadap posisi pekerjaan yang paling sesuai.\n\nHasil analisis ini digunakan untuk memberikan rekomendasi daftar lowongan yang relevan dengan profil keterampilan pengguna. Selain itu, pengguna dapat berlangganan newsletter agar selalu mendapatkan informasi lowongan terbaru yang sesuai dengan hasil analisis CV mereka, yang kemudian akan dikirimkan langsung melalui email.\n\nMapping ke Sponsor\nUNLI = Digunakan untuk vision & reasoning engine dalam analisis CV/portofolio (misalnya parsing teks dari PDF/gambar, lalu inferensi posisi kerja yang cocok).\nLunos = Digunakan untuk parsing terstruktur (PDF ke JSON), normalisasi data, dan orkestrasi pipeline analisis.\nMailry = Digunakan untuk layanan email newsletter, agar pengguna bisa berlangganan update lowongan yang sesuai dengan profil keterampilannya.",
|
||||
"repo_link": "https://github.com/iegl3/LokerKerja",
|
||||
"screenshot": "https://drive.google.com/open?id=1qludNU5DnNPFtzowSnB_mYkPB00Ll4Rz",
|
||||
"file_name": "demo - Eagle.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Kami Gila Roblox",
|
||||
"project_title": "Pitara: Pintu Sejarah Nusantara",
|
||||
"description": "Pitara adalah platform yang bertujuan untuk meningkatkan literasi sejarah dan melawan hoaks di Indonesia. Platform ini menyediakan fitur chat AI untuk belajar sejarah, AI fact-checker untuk memverifikasi berita, forum diskusi, dan fitur pembuatan artikel otomatis. Pitara juga menjaga retensi pengguna melalui newsletter mingguan.",
|
||||
"repo_link": "https://github.com/JackBerck/pitara",
|
||||
"screenshot": "https://drive.google.com/open?id=1GIXZkRrRn21hpNMcip-QdGayLr8m2qCG",
|
||||
"file_name": "screencapture-127-0-0-1-8000-2025-08-24-22_56_54 - Zaki Dzulfikar.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Hidup Jokowi",
|
||||
"project_title": "Historia",
|
||||
"description": "Historia, sebuah platform revolusioner yang menjembatani masa lalu dengan masa kini. kami memanfaatkan kekuatan kecerdasan buatan (AI) untuk menganalisis dan memberikan narasi pada foto-foto dan dokumen bersejarah Indonesia. cukup unggah sebuah gambar, dan biarkan teknologi kami mengungkap cerita, tokoh, serta konteks di balik momen beku tersebut. mari jelajahi kembali perjuangan bangsa dengan cara yang belum pernah ada sebelumnya.",
|
||||
"repo_link": "https://github.com/mybday123/historia",
|
||||
"screenshot": "https://drive.google.com/open?id=1RaMiswa7Fy5m3V1xsoODvKabEwTspu2y",
|
||||
"file_name": "Historia_-_Preview - Julian Mifta Yama Fauzan.png"
|
||||
},
|
||||
{
|
||||
"team_name": "CORTEZA FAMILY",
|
||||
"project_title": "Garuda Shield - Criminal Website Detector",
|
||||
"description": "Garuda Shield - Criminal Website Detector: Adalah Web analysis berbasis Crawling yang memanfaatkan AI Untuk mendeteksi anomali pada suatu web menggunakan: LunosTech, Mailry, Unli.Dev serta Crawler Tools",
|
||||
"repo_link": "https://github.com/c0rt3z4/hackathon-imphnen",
|
||||
"screenshot": "https://drive.google.com/open?id=1AKJ7pN7zUEAoJEcZFAxGVtSE582r2Hw5",
|
||||
"file_name": "Capture - Calm.PNG"
|
||||
},
|
||||
{
|
||||
"team_name": "Oziral",
|
||||
"project_title": "Kerja Merdeka - AI Agent Pendamping Pelamar Kerja",
|
||||
"description": "Kerja Merdeka – AI Agent Pendamping Pelamar Kerja adalah platform berbasis kecerdasan buatan yang membantu pencari kerja menyusun CV dan Cover Letter yang relevan, berlatih interview secara interaktif, hingga mengirimkan lamaran dalam satu alur terpadu.",
|
||||
"repo_link": "frontend : https://github.com/lakhatekno/imphnen-frontend, backend: https://github.com/Contsol-dev/kerja-merdeka-be",
|
||||
"screenshot": "https://drive.google.com/open?id=1l7IOCTj1tRJTtFgq0Wq8CJ8NaDYZ-DS1",
|
||||
"file_name": "Screenshot 2025-08-24 230728 - Muhammad Iqbal Ghozy.png"
|
||||
},
|
||||
{
|
||||
"team_name": "ak mw heketon",
|
||||
"project_title": "MerdekAI",
|
||||
"description": "Kita sedang mengembangkan sebuah chatbot AI versi low budget yang tetap powerful dan fungsional. Meskipun budget pembuatan murah bahkan gratis dibanding ChatGPT, fitur-fiturnya gak kalah lengkap. Chatbot ini mendukung:\n\nChat Completion (percakapan interaktif seperti ChatGPT)\n\nText-to-Voice (mengubah teks menjadi suara)\n\nImage Generation (membuat gambar dari prompt)\n\nImage Recognition (mengidentifikasi dan mendeskripsikan gambar)\n\nJadi, meskipun gak ada dana keluar, project ini dirancang supaya tetap memberikan pengalaman mirip ChatGPT dengan fitur-fitur AI kekinian ygy.",
|
||||
"repo_link": "https://github.com/kevinalvarel/merdekai",
|
||||
"screenshot": "https://drive.google.com/open?id=1U24L8_4c2nM088olI7V8LaqUGcOfg1YH",
|
||||
"file_name": "merdekai.my.id_ - Muhammad Kevin Alvarel.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Er Project",
|
||||
"project_title": "Agentic Merdeka",
|
||||
"description": "Multi-modal AI Chat interface, dengan kombinasi beberapa capability. Diantaranya:\n\nConversation, Image Analisis, Generate Embeddings Vector, Generate voice, Dan yang terakhir Generate Gambar, bisa build character ai sendiri, select persona dll\n\nFramework:\nNextjs 15+ (app router)\n\nDatabseses:\nFirebase untuk penyimpanan chat history dan login\n\nDilengkapi proteksi CSRF, Next Middleware dan Authentikasi menggunakan mailry\n\nSEMUA ITU DAPAT DI AKSES melalui satu web interface. Ini sudah malas, JANGAN ANGGAP PROYEK INI RAJIN🗿",
|
||||
"repo_link": "https://github.com/ErRickow/ai-agent-hackathon",
|
||||
"screenshot": "https://drive.google.com/open?id=1EkVWezUIXc_F_9IeM3LeX47TFDl2j2Va",
|
||||
"file_name": "download - Er Rickow.png"
|
||||
},
|
||||
{
|
||||
"team_name": "NamamuCore",
|
||||
"project_title": "Namamu - Startup Name Generator",
|
||||
"description": "Namamu.web.id merupakan situs generator nama sederhana yang memudahkan brainstorming ide platform, dengan tambahan fitur pengiriman hasil ke email.",
|
||||
"repo_link": "https://github.com/nooradn/namamu-name-gen",
|
||||
"screenshot": "https://drive.google.com/open?id=1EW6wBuujpHkhT1tW-_j6TklSgqvZt7wa",
|
||||
"file_name": "preview - Noor Adn.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Tim GakTau.Dev",
|
||||
"project_title": "Quiz Kemerdekaan",
|
||||
"description": "Sebuah aplikasi kuis interaktif berbasis AI untuk membantu pelajar dan penggemar sejarah Indonesia memahami peristiwa kemerdekaan dengan cara yang menyenangkan",
|
||||
"repo_link": "https://github.com/RAYDENFLY/Quiz-Merdeka/tree/main",
|
||||
"screenshot": "https://drive.google.com/open?id=1c0A6ijx_pNCmh87g_EvlyUAAV7zAT8Li",
|
||||
"file_name": "Gambar WhatsApp 2025-08-24 pukul 21.36.26_53cb7a14 - RAYDENFLY.jpg"
|
||||
},
|
||||
{
|
||||
"team_name": "Icikiwir semilir",
|
||||
"project_title": "Chef AI",
|
||||
"description": "chat bot untuk mendapatkan resep dari AI",
|
||||
"repo_link": "https://github.com/ranggacey/chef",
|
||||
"screenshot": "https://drive.google.com/open?id=1JCz7pEfM_nF--ZsAZvQSlUonJCjTZkh6",
|
||||
"file_name": "Screenshot 2025-08-24 235059 - Diablo volfir.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Greatvitech Team",
|
||||
"project_title": "Patriotisme Quiz",
|
||||
"description": "Sebuah aplikasi quiz bertema patriotisme, pengguna bisa menjawab soal - soal yang berkaitan dengan patriotisme, serta soal digenerate langsung oleh ai",
|
||||
"repo_link": "frontend: https://github.com/farhanangwa12/patriot-frontend backend: https://github.com/farhanangwa12/patriot-backend",
|
||||
"screenshot": "https://drive.google.com/open?id=1IAKk_ShPo51_CmqaClgv1ulXKrlf2fHA",
|
||||
"file_name": "Screenshot 2025-08-24 221129 - farhan hokado.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
@@ -1,178 +1,29 @@
|
||||
[
|
||||
{
|
||||
"team_name": "Lineproject",
|
||||
"project_title": "LaporMerdeka",
|
||||
"description": "Platform pelaporan infrastruktur publik Indonesia yang memungkinkan warga melaporkan masalah dengan cepat dan mudah untuk Indonesia yang lebih baik.",
|
||||
"repo_link": "https://github.com/MANFIT7/lapormerdeka",
|
||||
"screenshot": "https://drive.google.com/open?id=1tbOJKacQGsfldr5TsWtzNKL65iCpADz2",
|
||||
"file_name": "Screenshot 2025-08-22 062036 - Fafnir.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Aliansi switch",
|
||||
"project_title": "News-ai",
|
||||
"description": "ai agent untuk memilah beritah hoax dengan asli",
|
||||
"repo_link": "https://github.com/7FIl/News-AI",
|
||||
"screenshot": "https://drive.google.com/open?id=1IYoOB1zqL70tpxaeopdS6VtuL8hoqpPn",
|
||||
"file_name": "Screenshot 2025-08-22 223626 - 7Fil.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Sodev Sedap",
|
||||
"project_title": "Sejarah Alternatif ID",
|
||||
"description": "Website AI Agent yang dapat memberikan user pov bagaimana jika user ada di situasi tersebut menggunakan reka adegan dengan pendekatan teks dengan gaya novel",
|
||||
"repo_link": "https://github.com/rizalkr/sejarah-alternatif-id/tree/main",
|
||||
"screenshot": "https://drive.google.com/open?id=1TMUgayvtI45gU79I-0SokQnPJP6LHQaC",
|
||||
"file_name": "Screenshot 2025-08-23 115137 - Rizal Kurnia.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Muhammad Harafsan Alhad",
|
||||
"project_title": "Elysia AI Kemerdekaan Indonesia",
|
||||
"description": "“Sebuah chatbot AI interaktif yang menampilkan Elysia (dari Honkai Impact) yang menjawab pertanyaan tentang Kemerdekaan Indonesia dengan gaya khas Elysia, lengkap dengan fitur kuis interaktif.",
|
||||
"repo_link": "https://github.com/rafsanalhad/elysia-ai-kemerdekaan",
|
||||
"screenshot": "https://drive.google.com/open?id=1_QE59lgHNbJfuVikJ5KT0_85tbJc6pMo",
|
||||
"file_name": "Screenshot 2025-08-23 131756 - Ralhad Alhad.png"
|
||||
},
|
||||
{
|
||||
"team_name": "RaflanGT",
|
||||
"project_title": "Ecobot",
|
||||
"description": "EcoBot adalah AI Agent yang hadir untuk menjawab tantangan pengelolaan sampah dan keterbatasan digitalisasi di masyarakat. Melalui WhatsApp yang akrab bagi warga, EcoBot memandu pemilahan sampah dengan analisis gambar berbasis AI sekaligus menumbuhkan kesadaran lingkungan. Kemerdekaan bukan hanya bebas dari penjajahan, tetapi juga kesadaran kolektif untuk mengelola hal-hal sederhana yang berdampak besar. Dengan langkah kecil seperti ini, desa dan masyarakat dapat mandiri secara digital, menjaga lingkungan, dan bersama-sama membawa Indonesia terus maju.",
|
||||
"repo_link": "https://github.com/mycoderisyad/raflangt-ecobot",
|
||||
"screenshot": "https://drive.google.com/open?id=1lj5DMJfxSrCwyNSLIlq-GQohJHCUDU1-",
|
||||
"file_name": "Screenshot 2025-08-23 223413 - MRisyad Raflan.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Tchh Tidak Akan",
|
||||
"project_title": "Merdeka Quiziz",
|
||||
"description": "Merdeka Quiziz merupakan web kuis yang menggunakan tema Kemerdekaan Indonesia dengan fitur gamifikasi yang membuat kuis menjadi menyenangkan, dimana setiap kuis dibuat oleh Mera (AI) dan dipersonalisasi untuk pengguna. Selain itu di Merdeka Quiziz pengguna juga dapat membahas sejarah Indonesia bersama Mera (AI).",
|
||||
"repo_link": "https://gitlab.com/personal-projects9094234/merdeka-quiziz",
|
||||
"screenshot": "https://drive.google.com/open?id=1U_UMaYABLjbO38GA9DopXebDWznFKQcI",
|
||||
"file_name": "Screenshot 2025-08-24 at 09.15.06 - Khen Cahyo.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Pengen Ikut tapi Bingung Mau Buat Apa",
|
||||
"project_title": "IMMPHNEN (Ingin Menjadi Mesin Pencari Handal Namun Enggan Ngecrawl)",
|
||||
"description": "Mesin pencari yang didesain untuk memerdekakan para pencari informasi dari tracker-tracker yang berlebihan (lelah bukan habis mencari A, nongol iklan A dimana-mana?). Memiliki fitur ringkasan pencarian, serta filter negatif penelusuran (judi & pornografi). Dibuat dengan LangSearch dan Lunos(ChatGPT 5.0).",
|
||||
"repo_link": "https://gitlab.com/myracledev/py-search-engine",
|
||||
"screenshot": "https://drive.google.com/open?id=1xNFtvGSLfWwdA4Mx46S7bx2nmwpt5CXA",
|
||||
"file_name": "{CBBB6849-BC8E-4435-9C6A-8C88C83287DF} - Mohamad Yusuf Rizaldi.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Ayam Geprek",
|
||||
"project_title": "SURA AI (Suara Rakyat)",
|
||||
"description": "SIngkatnya ini itu AI yang jadi mewakili hati rakyat Indonesia (bukan dpr). Dia bukan sekadar asisten digital, kenapa? ya karena dia kritis, cerdas, dan punya selera sinis yang bikin narasi kekuasaan gampang dibongkar. Gayanya penuh satir, dan sering pakai perumpamaan yang sangat panas. Sura AI hadir untuk menantang pemikiran, membakar semangat, dan memberikan perspektif yang ngga takut ngomong jujur tentang realita sosial dan politik.",
|
||||
"repo_link": "https://github.com/Roti18/sura-ai",
|
||||
"screenshot": "https://drive.google.com/open?id=1uUWGu08NimQ1qV9E-xu0h39HyoAYrclS",
|
||||
"file_name": "Screenshot 2025-08-24 204538 - Roti 1.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Fae",
|
||||
"project_title": "Daily Commit",
|
||||
"description": "Daily Commit adalah semacam alarm commit yang bakal ngingetin kamu kalau seharian nggak ada commit di GitHub. Tapi kalau rajin, dia juga bisa jadi cheerleader digital yang muji-muji kamu.",
|
||||
"repo_link": "https://github.com/far-id/send-mail-mailry.git",
|
||||
"screenshot": "https://drive.google.com/open?id=1GAvN4RxW_gAvOfew7LbbHANEx2F3lC5y",
|
||||
"file_name": "GITHUB~1.PNG"
|
||||
},
|
||||
{
|
||||
"team_name": "garudaStack",
|
||||
"project_title": "Tani AI",
|
||||
"description": "Tani AI adalah AI agent andalan anda untuk membantu dalam perkembangan, produktifitas serta analisis untuk komoditas pertanian anda.",
|
||||
"repo_link": "FE : https://github.com/Jazaniest/garuda-ai-frontend.git BE : https://github.com/Rifaldy1292/be-hackaton.git",
|
||||
"screenshot": "https://drive.google.com/open?id=173dS3v9sAJXMOxs7uY_SG-TUW9wIo_WM",
|
||||
"file_name": "Tani AI - M Abdillah Aljazani.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Roki Miftah Kamaludin",
|
||||
"project_title": "Mengenang Pahlawan",
|
||||
"description": "Mengenang Pahlawan adalah platform digital untuk mengenang dan mempelajari kisah pahlawan nasional Indonesia. Aplikasi ini menyajikan biografi, foto, serta informasi resmi terkait penetapan gelar pahlawan.\n\nSelain sebagai ensiklopedia digital, platform ini juga dilengkapi fitur interaktif seperti kuis edukatif, pencarian, dan poin penghargaan.",
|
||||
"repo_link": "https://github.com/rokimiftah/mengenang-pahlawan",
|
||||
"screenshot": "https://drive.google.com/open?id=140c-FyndYCAOCtKChbRaENc9fgWvQwU2",
|
||||
"file_name": "mengenang-pahlawan - Roki Miftah Kamaludin.png"
|
||||
},
|
||||
{
|
||||
"team_name": "LokerHunter",
|
||||
"project_title": "LokerKerja",
|
||||
"description": "Sebuah platform job matching yang memanfaatkan analisis CV atau portofolio untuk mengidentifikasi keahlian utama pengguna dan melakukan inferensi otomatis terhadap posisi pekerjaan yang paling sesuai.\n\nHasil analisis ini digunakan untuk memberikan rekomendasi daftar lowongan yang relevan dengan profil keterampilan pengguna. Selain itu, pengguna dapat berlangganan newsletter agar selalu mendapatkan informasi lowongan terbaru yang sesuai dengan hasil analisis CV mereka, yang kemudian akan dikirimkan langsung melalui email.\n\nMapping ke Sponsor\nUNLI = Digunakan untuk vision & reasoning engine dalam analisis CV/portofolio (misalnya parsing teks dari PDF/gambar, lalu inferensi posisi kerja yang cocok).\nLunos = Digunakan untuk parsing terstruktur (PDF ke JSON), normalisasi data, dan orkestrasi pipeline analisis.\nMailry = Digunakan untuk layanan email newsletter, agar pengguna bisa berlangganan update lowongan yang sesuai dengan profil keterampilannya.",
|
||||
"repo_link": "https://github.com/iegl3/LokerKerja",
|
||||
"screenshot": "https://drive.google.com/open?id=1qludNU5DnNPFtzowSnB_mYkPB00Ll4Rz",
|
||||
"file_name": "demo - Eagle.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Kami Gila Roblox",
|
||||
"project_title": "Pitara: Pintu Sejarah Nusantara",
|
||||
"description": "Pitara adalah platform yang bertujuan untuk meningkatkan literasi sejarah dan melawan hoaks di Indonesia. Platform ini menyediakan fitur chat AI untuk belajar sejarah, AI fact-checker untuk memverifikasi berita, forum diskusi, dan fitur pembuatan artikel otomatis. Pitara juga menjaga retensi pengguna melalui newsletter mingguan.",
|
||||
"repo_link": "https://github.com/JackBerck/pitara",
|
||||
"screenshot": "https://drive.google.com/open?id=1GIXZkRrRn21hpNMcip-QdGayLr8m2qCG",
|
||||
"file_name": "screencapture-127-0-0-1-8000-2025-08-24-22_56_54 - Zaki Dzulfikar.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Hidup Jokowi",
|
||||
"project_title": "Historia",
|
||||
"description": "Historia, sebuah platform revolusioner yang menjembatani masa lalu dengan masa kini. kami memanfaatkan kekuatan kecerdasan buatan (AI) untuk menganalisis dan memberikan narasi pada foto-foto dan dokumen bersejarah Indonesia. cukup unggah sebuah gambar, dan biarkan teknologi kami mengungkap cerita, tokoh, serta konteks di balik momen beku tersebut. mari jelajahi kembali perjuangan bangsa dengan cara yang belum pernah ada sebelumnya.",
|
||||
"repo_link": "https://github.com/mybday123/historia",
|
||||
"screenshot": "https://drive.google.com/open?id=1RaMiswa7Fy5m3V1xsoODvKabEwTspu2y",
|
||||
"file_name": "Historia_-_Preview - Julian Mifta Yama Fauzan.png"
|
||||
},
|
||||
{
|
||||
"team_name": "CORTEZA FAMILY",
|
||||
"project_title": "Garuda Shield - Criminal Website Detector",
|
||||
"description": "Garuda Shield - Criminal Website Detector: Adalah Web analysis berbasis Crawling yang memanfaatkan AI Untuk mendeteksi anomali pada suatu web menggunakan: LunosTech, Mailry, Unli.Dev serta Crawler Tools",
|
||||
"repo_link": "https://github.com/c0rt3z4/hackathon-imphnen",
|
||||
"screenshot": "https://drive.google.com/open?id=1AKJ7pN7zUEAoJEcZFAxGVtSE582r2Hw5",
|
||||
"file_name": "Capture - Calm.PNG"
|
||||
},
|
||||
{
|
||||
"team_name": "Oziral",
|
||||
"project_title": "Kerja Merdeka - AI Agent Pendamping Pelamar Kerja",
|
||||
"description": "Kerja Merdeka – AI Agent Pendamping Pelamar Kerja adalah platform berbasis kecerdasan buatan yang membantu pencari kerja menyusun CV dan Cover Letter yang relevan, berlatih interview secara interaktif, hingga mengirimkan lamaran dalam satu alur terpadu.",
|
||||
"repo_link": "frontend : https://github.com/lakhatekno/imphnen-frontend, backend: https://github.com/Contsol-dev/kerja-merdeka-be",
|
||||
"screenshot": "https://drive.google.com/open?id=1l7IOCTj1tRJTtFgq0Wq8CJ8NaDYZ-DS1",
|
||||
"file_name": "Screenshot 2025-08-24 230728 - Muhammad Iqbal Ghozy.png"
|
||||
},
|
||||
{
|
||||
"team_name": "ak mw heketon",
|
||||
"project_title": "MerdekAI",
|
||||
"description": "Kita sedang mengembangkan sebuah chatbot AI versi low budget yang tetap powerful dan fungsional. Meskipun budget pembuatan murah bahkan gratis dibanding ChatGPT, fitur-fiturnya gak kalah lengkap. Chatbot ini mendukung:\n\nChat Completion (percakapan interaktif seperti ChatGPT)\n\nText-to-Voice (mengubah teks menjadi suara)\n\nImage Generation (membuat gambar dari prompt)\n\nImage Recognition (mengidentifikasi dan mendeskripsikan gambar)\n\nJadi, meskipun gak ada dana keluar, project ini dirancang supaya tetap memberikan pengalaman mirip ChatGPT dengan fitur-fitur AI kekinian ygy.",
|
||||
"repo_link": "https://github.com/kevinalvarel/merdekai",
|
||||
"screenshot": "https://drive.google.com/open?id=1U24L8_4c2nM088olI7V8LaqUGcOfg1YH",
|
||||
"file_name": "merdekai.my.id_ - Muhammad Kevin Alvarel.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Er Project",
|
||||
"project_title": "Agentic Merdeka",
|
||||
"description": "Multi-modal AI Chat interface, dengan kombinasi beberapa capability. Diantaranya:\n\nConversation, Image Analisis, Generate Embeddings Vector, Generate voice, Dan yang terakhir Generate Gambar, bisa build character ai sendiri, select persona dll\n\nFramework:\nNextjs 15+ (app router)\n\nDatabseses:\nFirebase untuk penyimpanan chat history dan login\n\nDilengkapi proteksi CSRF, Next Middleware dan Authentikasi menggunakan mailry\n\nSEMUA ITU DAPAT DI AKSES melalui satu web interface. Ini sudah malas, JANGAN ANGGAP PROYEK INI RAJIN🗿",
|
||||
"repo_link": "https://github.com/ErRickow/ai-agent-hackathon",
|
||||
"screenshot": "https://drive.google.com/open?id=1EkVWezUIXc_F_9IeM3LeX47TFDl2j2Va",
|
||||
"file_name": "download - Er Rickow.png"
|
||||
},
|
||||
{
|
||||
"team_name": "NamamuCore",
|
||||
"project_title": "Namamu - Startup Name Generator",
|
||||
"description": "Namamu.web.id merupakan situs generator nama sederhana yang memudahkan brainstorming ide platform, dengan tambahan fitur pengiriman hasil ke email.",
|
||||
"repo_link": "https://github.com/nooradn/namamu-name-gen",
|
||||
"screenshot": "https://drive.google.com/open?id=1EW6wBuujpHkhT1tW-_j6TklSgqvZt7wa",
|
||||
"file_name": "preview - Noor Adn.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Tim GakTau.Dev",
|
||||
"project_title": "Quiz Kemerdekaan",
|
||||
"description": "Sebuah aplikasi kuis interaktif berbasis AI untuk membantu pelajar dan penggemar sejarah Indonesia memahami peristiwa kemerdekaan dengan cara yang menyenangkan",
|
||||
"repo_link": "https://github.com/RAYDENFLY/Quiz-Merdeka/tree/main",
|
||||
"screenshot": "https://drive.google.com/open?id=1c0A6ijx_pNCmh87g_EvlyUAAV7zAT8Li",
|
||||
"file_name": "Gambar WhatsApp 2025-08-24 pukul 21.36.26_53cb7a14 - RAYDENFLY.jpg"
|
||||
},
|
||||
{
|
||||
"team_name": "Icikiwir semilir",
|
||||
"project_title": "Chef AI",
|
||||
"description": "chat bot untuk mendapatkan resep dari AI",
|
||||
"repo_link": "https://github.com/ranggacey/chef",
|
||||
"screenshot": "https://drive.google.com/open?id=1JCz7pEfM_nF--ZsAZvQSlUonJCjTZkh6",
|
||||
"file_name": "Screenshot 2025-08-24 235059 - Diablo volfir.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Greatvitech Team",
|
||||
"project_title": "Patriotisme Quiz",
|
||||
"description": "Sebuah aplikasi quiz bertema patriotisme, pengguna bisa menjawab soal - soal yang berkaitan dengan patriotisme, serta soal digenerate langsung oleh ai",
|
||||
"repo_link": "frontend: https://github.com/farhanangwa12/patriot-frontend backend: https://github.com/farhanangwa12/patriot-backend",
|
||||
"screenshot": "https://drive.google.com/open?id=1IAKk_ShPo51_CmqaClgv1ulXKrlf2fHA",
|
||||
"file_name": "Screenshot 2025-08-24 221129 - farhan hokado.png"
|
||||
"slug": "ai-agent-hackathon-2025",
|
||||
"name": "AI Agent Hackathon",
|
||||
"prize": "Rp. 5.000.000",
|
||||
"tags": ["AI Agent"],
|
||||
"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.",
|
||||
"details": "",
|
||||
"theme": "AI Agent untuk Kemerdekaan Indonesia",
|
||||
"cover": "https://cdn.andka.my.id/imphnen/534798944_122241336080178516_4647521272813012181_n.jpg",
|
||||
"status": "ended",
|
||||
"submissionWindow": {
|
||||
"start": "2025-08-15T00:00:00.000Z",
|
||||
"end": "2025-09-20T23:59:59.999Z"
|
||||
},
|
||||
"partners": [
|
||||
{
|
||||
"name": "GitHub",
|
||||
"logo": "https://example.com/github-logo.png",
|
||||
"link": "https://github.com"
|
||||
},
|
||||
{
|
||||
"name": "Microsoft",
|
||||
"logo": "https://example.com/microsoft-logo.png",
|
||||
"link": "https://microsoft.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
@@ -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 "../../../../libs/shadcn-ui/src/index.css";
|
||||
@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",
|
||||
"framer-motion": "^12.9.2",
|
||||
"graphql": "^16.11.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"js-cookie": "^3.0.5",
|
||||
"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",
|
||||
@@ -90,6 +93,7 @@
|
||||
"@swc/core": "~1.5.7",
|
||||
"@swc/helpers": "~0.5.11",
|
||||
"@tailwindcss/postcss": "^4.0.13",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@testing-library/dom": "10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
|
||||
Reference in New Issue
Block a user