forked from UKSOURCE/ipv6
init
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import Header from "@/app/components/layout/Header/Header";
|
||||
import Footer from "@/app/components/layout/Footer/Footer";
|
||||
|
||||
export default function SiteLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
{children}
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import home from "@/app/data/home.json";
|
||||
import HeroSection from "@/app/components/home/HeroSection";
|
||||
import VisionSection from "@/app/components/home/VisionSection";
|
||||
import StatsSection from "@/app/components/home/StatsSection";
|
||||
import AgendaSection from "@/app/components/home/AgendaSection";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main className="pt-24 lg:pt-32">
|
||||
<HeroSection data={home.hero} />
|
||||
<VisionSection data={home.vision} />
|
||||
<StatsSection data={home.stats} />
|
||||
<AgendaSection data={home.agenda} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import Link from "next/link";
|
||||
import RegistrationForm from "@/app/components/forms/RegistrationForm";
|
||||
import data from "@/app/data/registration.json";
|
||||
|
||||
export default function RegistrationPage() {
|
||||
return (
|
||||
<main className="pt-[160px] pb-[80px] px-[var(--spacing-gutter)] flex justify-center">
|
||||
<div className="w-full max-w-[1280px]">
|
||||
<div className="max-w-[800px] mx-auto">
|
||||
<div className="mb-[32px]">
|
||||
<Link
|
||||
className="group flex items-center gap-2 text-outline hover:text-primary transition-colors"
|
||||
href="/"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px] transition-transform group-hover:-translate-x-1">
|
||||
arrow_back
|
||||
</span>
|
||||
Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mb-[32px]">
|
||||
<h1 className="font-[var(--font-display-lg)] text-[clamp(2.25rem,3vw+1rem,4.5rem)] text-on-surface mb-2 leading-[1.1]">
|
||||
{data.title}
|
||||
</h1>
|
||||
<p className="text-on-surface-variant text-lg">{data.subtitle}</p>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel p-[32px] md:p-12 rounded-xl shadow-2xl relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-full h-[1px] bg-gradient-to-r from-transparent via-primary/20 to-transparent" />
|
||||
<RegistrationForm data={data} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
type IndustryOption = { value: string; label: string };
|
||||
|
||||
export default function RegistrationForm({
|
||||
data,
|
||||
}: {
|
||||
data: {
|
||||
sections: {
|
||||
personal: { title: string; fullName: string; phone: string; email: string };
|
||||
professional: { title: string; company: string; jobTitle: string };
|
||||
industry: { title: string; label: string; placeholder: string; options: IndustryOption[] };
|
||||
additional: { title: string; label: string; placeholder: string };
|
||||
};
|
||||
submit: { label: string; disclaimer: string };
|
||||
};
|
||||
}) {
|
||||
const industries = useMemo(() => data.sections.industry.options, [data.sections.industry.options]);
|
||||
const [form, setForm] = useState({
|
||||
fullName: "",
|
||||
phone: "",
|
||||
email: "",
|
||||
company: "",
|
||||
jobTitle: "",
|
||||
industry: "",
|
||||
notes: "",
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-[32px]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
// frontend-only scope
|
||||
console.log("[registration]", form);
|
||||
alert("Saved locally (frontend-only).");
|
||||
}}
|
||||
>
|
||||
<div className="space-y-[16px]">
|
||||
<h2 className="font-[var(--font-display-lg)] text-[20px] text-primary border-l-2 border-primary pl-3">
|
||||
{data.sections.personal.title}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-[16px]">
|
||||
<div className="space-y-1">
|
||||
<label className="text-outline uppercase tracking-wider">{data.sections.personal.fullName}</label>
|
||||
<input
|
||||
className="w-full bg-surface-container-low border border-white/10 rounded-lg px-4 py-3 text-on-surface placeholder:text-outline/40 transition-all"
|
||||
placeholder="John Doe"
|
||||
value={form.fullName}
|
||||
onChange={(e) => setForm((s) => ({ ...s, fullName: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-outline uppercase tracking-wider">{data.sections.personal.phone}</label>
|
||||
<input
|
||||
className="w-full bg-surface-container-low border border-white/10 rounded-lg px-4 py-3 text-on-surface placeholder:text-outline/40 transition-all"
|
||||
placeholder="+84 ..."
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm((s) => ({ ...s, phone: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2 space-y-1">
|
||||
<label className="text-outline uppercase tracking-wider">{data.sections.personal.email}</label>
|
||||
<input
|
||||
className="w-full bg-surface-container-low border border-white/10 rounded-lg px-4 py-3 text-on-surface placeholder:text-outline/40 transition-all"
|
||||
placeholder="john.doe@company.com"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm((s) => ({ ...s, email: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-white/5" />
|
||||
|
||||
<div className="space-y-[16px]">
|
||||
<h2 className="font-[var(--font-display-lg)] text-[20px] text-primary border-l-2 border-primary pl-3">
|
||||
{data.sections.professional.title}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-[16px]">
|
||||
<div className="space-y-1">
|
||||
<label className="text-outline uppercase tracking-wider">{data.sections.professional.company}</label>
|
||||
<input
|
||||
className="w-full bg-surface-container-low border border-white/10 rounded-lg px-4 py-3 text-on-surface placeholder:text-outline/40 transition-all"
|
||||
placeholder="Company"
|
||||
value={form.company}
|
||||
onChange={(e) => setForm((s) => ({ ...s, company: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-outline uppercase tracking-wider">{data.sections.professional.jobTitle}</label>
|
||||
<input
|
||||
className="w-full bg-surface-container-low border border-white/10 rounded-lg px-4 py-3 text-on-surface placeholder:text-outline/40 transition-all"
|
||||
placeholder="Network Architect"
|
||||
value={form.jobTitle}
|
||||
onChange={(e) => setForm((s) => ({ ...s, jobTitle: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-white/5" />
|
||||
|
||||
<div className="space-y-[16px]">
|
||||
<h2 className="font-[var(--font-display-lg)] text-[20px] text-primary border-l-2 border-primary pl-3">
|
||||
{data.sections.industry.title}
|
||||
</h2>
|
||||
<div className="space-y-1">
|
||||
<label className="text-outline uppercase tracking-wider">{data.sections.industry.label}</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
className="w-full bg-surface-container-low border border-white/10 rounded-lg px-4 py-3 text-on-surface appearance-none transition-all"
|
||||
value={form.industry}
|
||||
onChange={(e) => setForm((s) => ({ ...s, industry: e.target.value }))}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{data.sections.industry.placeholder}
|
||||
</option>
|
||||
{industries.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="material-symbols-outlined absolute right-4 top-1/2 -translate-y-1/2 text-outline pointer-events-none">
|
||||
expand_more
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-white/5" />
|
||||
|
||||
<div className="space-y-[16px]">
|
||||
<h2 className="font-[var(--font-display-lg)] text-[20px] text-primary border-l-2 border-primary pl-3">
|
||||
{data.sections.additional.title}
|
||||
</h2>
|
||||
<div className="space-y-1">
|
||||
<label className="text-outline uppercase tracking-wider">{data.sections.additional.label}</label>
|
||||
<textarea
|
||||
className="w-full bg-surface-container-low border border-white/10 rounded-lg px-4 py-3 text-on-surface placeholder:text-outline/40 transition-all resize-none"
|
||||
placeholder={data.sections.additional.placeholder}
|
||||
rows={4}
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm((s) => ({ ...s, notes: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-[16px]">
|
||||
<button
|
||||
className="w-full gold-gradient-bg py-5 rounded-lg text-on-primary font-[var(--font-display-lg)] text-[18px] font-bold shadow-[0_10px_30px_rgba(197,160,89,0.15)] hover:shadow-[0_15px_40px_rgba(197,160,89,0.25)] hover:-translate-y-0.5 active:translate-y-0 active:scale-[0.98] transition-all"
|
||||
type="submit"
|
||||
>
|
||||
{data.submit.label}
|
||||
</button>
|
||||
<p className="mt-[16px] text-center text-outline text-[11px] tracking-widest opacity-60 uppercase">
|
||||
{data.submit.disclaimer}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
export default function AgendaSection({
|
||||
data,
|
||||
}: {
|
||||
data: {
|
||||
id: string;
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
items: { order: string; time: string; title: string; description: string }[];
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<section className="py-16 lg:py-32 max-w-hd mx-auto px-[var(--spacing-gutter)]" id={data.id}>
|
||||
<div className="flex flex-col xl:flex-row justify-between xl:items-end mb-12 lg:mb-24 gap-8">
|
||||
<div className="max-w-3xl">
|
||||
<span className="font-[var(--font-label-caps)] text-xs lg:text-sm text-primary block mb-4 lg:mb-8 tracking-[0.25em]">
|
||||
{data.eyebrow}
|
||||
</span>
|
||||
<h2 className="font-[var(--font-display-lg)] text-on-surface uppercase tracking-tight text-[clamp(2rem,4vw+1rem,4.5rem)]">
|
||||
{data.title}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-on-surface-variant/60 text-base lg:text-[20px] max-w-md xl:text-right">
|
||||
{data.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6 lg:gap-10">
|
||||
{data.items.map((item) => (
|
||||
<div
|
||||
key={item.order}
|
||||
className="glass-panel p-8 lg:p-12 rounded-3xl relative overflow-hidden group hover:border-primary/60 transition-colors"
|
||||
>
|
||||
<div className="absolute top-0 right-0 p-6 lg:p-8">
|
||||
<span className="font-[var(--font-display-lg)] text-2xl lg:text-[40px] text-primary/20 group-hover:text-primary transition-colors">
|
||||
{item.order}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-6 lg:mb-10">
|
||||
<span className="font-[var(--font-display-lg)] text-2xl lg:text-[36px] text-primary">
|
||||
{item.time}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-[var(--font-display-lg)] text-2xl lg:text-[32px] text-on-surface mb-4 lg:mb-6">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-sm lg:text-[18px] text-on-surface-variant/80 leading-relaxed mb-6 lg:mb-8">
|
||||
{item.description}
|
||||
</p>
|
||||
<div className="w-full h-1 bg-primary/10 group-hover:bg-primary transition-colors duration-500" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function HeroSection({
|
||||
data,
|
||||
}: {
|
||||
data: {
|
||||
badgeIcon: string;
|
||||
badgeText: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
primaryCta: { label: string; href: string };
|
||||
secondaryCta: { label: string; href: string };
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<section className="relative min-h-[90vh] lg:min-h-screen flex items-center overflow-hidden bg-background hero-glow px-[var(--spacing-gutter)]">
|
||||
<div className="relative z-10 max-w-hd mx-auto w-full">
|
||||
<div className="max-w-7xl">
|
||||
<div className="inline-flex items-center gap-3 lg:gap-4 px-4 lg:px-8 py-2 lg:py-3 rounded-full bg-surface-container-high border border-primary/30 mb-8 lg:mb-16">
|
||||
<span className="material-symbols-outlined text-primary text-[20px] lg:text-[26px]">
|
||||
{data.badgeIcon}
|
||||
</span>
|
||||
<span className="font-[var(--font-label-caps)] text-xs lg:text-[16px] text-on-surface">
|
||||
{data.badgeText}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="font-[var(--font-display-lg)] text-on-surface mb-6 lg:mb-10 leading-[1.1] text-[clamp(2.25rem,5vw+1rem,6.875rem)]">
|
||||
{data.title}
|
||||
</h1>
|
||||
<p className="text-on-surface-variant text-lg lg:text-[28px] mb-10 lg:mb-20 max-w-4xl leading-relaxed opacity-90">
|
||||
{data.subtitle}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 lg:gap-10">
|
||||
<Link
|
||||
href={data.primaryCta.href}
|
||||
className="gold-gradient-bg text-on-primary px-8 lg:px-16 py-5 lg:py-7 font-[var(--font-display-lg)] text-sm lg:text-[20px] font-bold uppercase tracking-widest rounded-xl hover:shadow-[0_0_50px_rgba(197,160,89,0.4)] transition-all text-center"
|
||||
>
|
||||
{data.primaryCta.label}
|
||||
</Link>
|
||||
<Link
|
||||
href={data.secondaryCta.href}
|
||||
className="bg-surface-container-high border border-primary/40 text-on-surface px-8 lg:px-16 py-5 lg:py-7 font-[var(--font-display-lg)] text-sm lg:text-[20px] font-bold uppercase tracking-widest rounded-xl hover:bg-surface-container-highest transition-all text-center"
|
||||
>
|
||||
{data.secondaryCta.label}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
export default function StatsSection({
|
||||
data,
|
||||
}: {
|
||||
data: { id: string; items: { value: string; label: string }[] };
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className="py-16 lg:py-32 bg-surface-container-low border-y border-primary/10"
|
||||
id={data.id}
|
||||
>
|
||||
<div className="max-w-hd mx-auto px-[var(--spacing-gutter)]">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-12 lg:gap-32 text-center">
|
||||
{data.items.map((x) => (
|
||||
<div key={x.label} className="space-y-3 lg:space-y-6">
|
||||
<div className="font-[var(--font-display-lg)] text-5xl lg:text-[96px] text-primary">
|
||||
{x.value}
|
||||
</div>
|
||||
<div className="font-[var(--font-label-caps)] text-sm lg:text-[18px] text-on-surface/80 tracking-widest uppercase">
|
||||
{x.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
export default function VisionSection({
|
||||
data,
|
||||
}: {
|
||||
data: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
paragraphs: string[];
|
||||
cards: { icon: string; title: string; description: string }[];
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<section className="py-16 lg:py-32 max-w-hd mx-auto px-[var(--spacing-gutter)]">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-32 items-start">
|
||||
<div className="lg:col-span-5">
|
||||
<span className="font-[var(--font-label-caps)] text-xs lg:text-sm text-primary mb-6 lg:mb-10 block tracking-[0.25em]">
|
||||
{data.eyebrow}
|
||||
</span>
|
||||
<h2 className="font-[var(--font-display-lg)] text-on-surface mb-8 lg:mb-12 leading-tight text-[clamp(2rem,4vw+1rem,4.5rem)]">
|
||||
{data.title}
|
||||
</h2>
|
||||
<div className="space-y-6 lg:space-y-10 text-on-surface-variant text-lg lg:text-[22px] leading-relaxed">
|
||||
{data.paragraphs.map((p) => (
|
||||
<p key={p}>{p}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:col-span-7 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 lg:gap-10">
|
||||
{data.cards.map((c) => (
|
||||
<div
|
||||
key={c.title}
|
||||
className="glass-panel p-8 lg:p-12 rounded-2xl group transition-all duration-500 hover:lg:-translate-y-4 hover:shadow-2xl hover:shadow-primary/5"
|
||||
>
|
||||
<span className="material-symbols-outlined text-primary text-[40px] lg:text-[56px] mb-8 lg:mb-12 block">
|
||||
{c.icon}
|
||||
</span>
|
||||
<h3 className="font-[var(--font-display-lg)] text-xl lg:text-[28px] text-on-surface mb-4 lg:mb-6">
|
||||
{c.title}
|
||||
</h3>
|
||||
<p className="text-sm lg:text-[18px] text-on-surface-variant/80 leading-relaxed">
|
||||
{c.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import Link from "next/link";
|
||||
import siteData from "@/app/data/site.json";
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="w-full bg-surface-container-low border-t border-white/5 py-12 lg:py-20">
|
||||
<div className="flex flex-col xl:flex-row justify-between items-center px-[var(--spacing-gutter)] gap-12 w-full max-w-hd mx-auto">
|
||||
<div className="font-[var(--font-label-caps)] text-xl lg:text-[24px] text-primary tracking-[0.4em] font-bold uppercase">
|
||||
{siteData.brand.name}
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-8 lg:gap-16">
|
||||
{siteData.footer.links.map((l) => (
|
||||
<Link
|
||||
key={l.label}
|
||||
className="text-on-surface-variant font-[var(--font-label-caps)] text-xs lg:text-sm hover:text-primary transition-colors"
|
||||
href={l.href}
|
||||
>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-[14px] lg:text-[16px] text-on-surface-variant/40 text-center xl:text-right max-w-sm leading-relaxed uppercase tracking-widest">
|
||||
{siteData.footer.copyright}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import HeaderClient from "./HeaderClient";
|
||||
import siteData from "@/app/data/site.json";
|
||||
|
||||
export default function Header() {
|
||||
return <HeaderClient brand={siteData.brand} nav={siteData.nav} />;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
type NavItem = { label: string; href: string };
|
||||
|
||||
export default function HeaderClient({
|
||||
brand,
|
||||
nav,
|
||||
}: {
|
||||
brand: { name: string };
|
||||
nav: NavItem[];
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
const isHome = pathname === "/";
|
||||
const navItems = useMemo(() => nav.filter((x) => x.href !== "/"), [nav]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mobileOpen) return;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [mobileOpen]);
|
||||
|
||||
return (
|
||||
<header className="fixed top-0 w-full z-50 bg-surface/80 backdrop-blur-xl border-b border-white/5">
|
||||
<div className="flex justify-between items-center px-[var(--spacing-gutter)] py-4 lg:py-8 w-full max-w-hd mx-auto">
|
||||
<Link
|
||||
href="/"
|
||||
className="font-[var(--font-display-lg)] text-[20px] sm:text-[22px] lg:text-[36px] text-primary tracking-tighter uppercase font-bold"
|
||||
>
|
||||
{brand.name}
|
||||
</Link>
|
||||
|
||||
<nav className="hidden xl:flex items-center gap-10 2xl:gap-16">
|
||||
<Link
|
||||
className={`font-[var(--font-label-caps)] text-sm ${
|
||||
isHome ? "text-primary border-b-2 border-primary pb-1" : "text-on-surface-variant hover:text-primary"
|
||||
} transition-colors duration-300`}
|
||||
href="/"
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
className="text-on-surface-variant font-[var(--font-label-caps)] text-sm hover:text-primary transition-colors duration-300"
|
||||
href={item.href}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-4 lg:gap-10">
|
||||
<span className="hidden sm:block text-on-surface-variant/60 font-[var(--font-label-caps)] text-xs">
|
||||
EN
|
||||
</span>
|
||||
<Link
|
||||
href="/registration"
|
||||
className="hidden sm:inline-flex gold-gradient-bg text-on-primary px-6 lg:px-10 py-3 lg:py-4 font-[var(--font-body-base)] text-sm lg:text-[18px] font-bold uppercase tracking-wider rounded-lg active:scale-95 transition-transform shadow-lg shadow-primary/10"
|
||||
>
|
||||
Register
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="xl:hidden text-primary p-2"
|
||||
aria-label="Toggle menu"
|
||||
aria-expanded={mobileOpen}
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
>
|
||||
<span className="material-symbols-outlined text-3xl">{mobileOpen ? "close" : "menu"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`fixed inset-0 top-[72px] lg:top-[104px] bg-surface z-40 flex-col p-[var(--spacing-gutter)] gap-8 xl:hidden border-t border-white/5 ${
|
||||
mobileOpen ? "flex" : "hidden"
|
||||
}`}
|
||||
>
|
||||
<Link className="text-2xl font-[var(--font-display-lg)] text-on-surface" href="/" onClick={() => setMobileOpen(false)}>
|
||||
Home
|
||||
</Link>
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
className="text-2xl font-[var(--font-display-lg)] text-on-surface"
|
||||
href={item.href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
<div className="mt-auto flex flex-col gap-6">
|
||||
<div className="text-on-surface-variant/60 font-[var(--font-label-caps)] text-sm">Language: EN</div>
|
||||
<Link
|
||||
href="/registration"
|
||||
className="gold-gradient-bg text-on-primary w-full py-5 font-[var(--font-body-base)] text-lg font-bold uppercase tracking-wider rounded-lg text-center"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
Register Now
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"hero": {
|
||||
"badgeIcon": "calendar_today",
|
||||
"badgeText": "June 2026 | Ho Chi Minh City, Vietnam",
|
||||
"title": "IPv6 for AI & Data Centre Summit 2026",
|
||||
"subtitle": "Unlocking the Potential of Next-Gen Infrastructure. Join global leaders in redefining ASEAN's digital connectivity for the age of AI.",
|
||||
"primaryCta": { "label": "Register", "href": "/registration" },
|
||||
"secondaryCta": { "label": "View Agenda", "href": "#agenda" }
|
||||
},
|
||||
"vision": {
|
||||
"eyebrow": "VISION & GOALS",
|
||||
"title": "ASEAN's Digital Future",
|
||||
"paragraphs": [
|
||||
"Vietnam and the broader ASEAN region are at a critical juncture in digital transformation. The transition to IPv6 is the fundamental infrastructure for AI growth and massive data centre scaling.",
|
||||
"IPv6 is the backbone for scalable cloud, secure connectivity, and AI-ready networking across the region."
|
||||
],
|
||||
"cards": [
|
||||
{
|
||||
"icon": "hub",
|
||||
"title": "Networking",
|
||||
"description": "Connect with C-level executives, policymakers, and technical leaders across the ecosystem."
|
||||
},
|
||||
{
|
||||
"icon": "insights",
|
||||
"title": "Tech Insights",
|
||||
"description": "Deep dives into AI workloads, hyperscale infrastructure, and next-gen IPv6 operations."
|
||||
},
|
||||
{
|
||||
"icon": "gavel",
|
||||
"title": "Policy",
|
||||
"description": "A forum for national infrastructure standards, governance, and cross-border collaboration."
|
||||
}
|
||||
]
|
||||
},
|
||||
"stats": {
|
||||
"id": "stats",
|
||||
"items": [
|
||||
{ "value": "130+", "label": "Global Speakers" },
|
||||
{ "value": "14", "label": "ASEAN Nations" },
|
||||
{ "value": "50+", "label": "Tech Partners" }
|
||||
]
|
||||
},
|
||||
"agenda": {
|
||||
"id": "agenda",
|
||||
"eyebrow": "SCHEDULE",
|
||||
"title": "Event Agenda",
|
||||
"description": "A strategic overview of the technical and strategic sessions.",
|
||||
"items": [
|
||||
{
|
||||
"order": "01",
|
||||
"time": "08:30",
|
||||
"title": "Opening Ceremony",
|
||||
"description": "Welcome address by MIC Vietnam and regional digital economy leaders."
|
||||
},
|
||||
{
|
||||
"order": "02",
|
||||
"time": "10:00",
|
||||
"title": "AI in Data Centres",
|
||||
"description": "How IPv6 enables hyperscale processing and low-latency fabric."
|
||||
},
|
||||
{
|
||||
"order": "03",
|
||||
"time": "14:00",
|
||||
"title": "Forum: Policy & Scale",
|
||||
"description": "Closed-door discussion for regulators and industry leaders."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"title": "Summit Registration",
|
||||
"subtitle": "Secure your presence at the forefront of IPv6 innovation for AI and Data Centres.",
|
||||
"sections": {
|
||||
"personal": { "title": "Personal Info", "fullName": "Full Name", "phone": "Phone Number", "email": "Business Email" },
|
||||
"professional": { "title": "Professional Info", "company": "Company Name", "jobTitle": "Job Title" },
|
||||
"industry": {
|
||||
"title": "Industry",
|
||||
"label": "Select Industry",
|
||||
"placeholder": "Please choose...",
|
||||
"options": [
|
||||
{ "value": "government", "label": "Government" },
|
||||
{ "value": "telecom", "label": "Telecom" },
|
||||
{ "value": "cloud", "label": "Cloud / Data Centre" },
|
||||
{ "value": "ai", "label": "AI / Software" },
|
||||
{ "value": "others", "label": "Others" }
|
||||
]
|
||||
},
|
||||
"additional": {
|
||||
"title": "Additional Details",
|
||||
"label": "Special requirements or dietary restrictions",
|
||||
"placeholder": "Vegetarian options, accessibility needs, etc."
|
||||
}
|
||||
},
|
||||
"submit": { "label": "Submit Registration", "disclaimer": "By registering, you agree to our Privacy Policy and Terms of Service." }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"brand": {
|
||||
"name": "IPv6 SUMMIT 2026"
|
||||
},
|
||||
"nav": [
|
||||
{ "label": "Home", "href": "/" },
|
||||
{ "label": "Registration", "href": "/registration" },
|
||||
{ "label": "Feedback", "href": "/feedback" },
|
||||
{ "label": "Sponsor", "href": "/sponsor" }
|
||||
],
|
||||
"footer": {
|
||||
"links": [
|
||||
{ "label": "Privacy", "href": "#" },
|
||||
{ "label": "Terms", "href": "#" },
|
||||
{ "label": "Press", "href": "#" },
|
||||
{ "label": "Contact", "href": "#" }
|
||||
],
|
||||
"copyright":
|
||||
"© 2026 IPv6 for AI & Data Centre Summit. Infrastructure for the Future."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
/* IPV6 Summit design tokens */
|
||||
--color-primary: #c5a059;
|
||||
--color-secondary: #8e6d3d;
|
||||
--color-on-primary: #261900;
|
||||
|
||||
--color-background: #0f0f0f;
|
||||
--color-surface: #0f0f0f;
|
||||
--color-surface-container-low: #141414;
|
||||
--color-surface-container-high: #1f1f1f;
|
||||
--color-surface-container-highest: #292929;
|
||||
|
||||
--color-on-surface: #ffffff;
|
||||
--color-on-surface-variant: #e0e0e0;
|
||||
--color-outline: #c5a059;
|
||||
|
||||
--font-body-base: "Inter", system-ui, sans-serif;
|
||||
--font-display-lg: "Space Grotesk", system-ui, sans-serif;
|
||||
--font-label-caps: "Geist", system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
|
||||
"Courier New", monospace;
|
||||
|
||||
--spacing-gutter: clamp(1rem, 4vw, 3.75rem);
|
||||
--spacing-container-max: 1800px;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-on-surface-variant);
|
||||
font-family: var(--font-body-base);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: rgba(224, 224, 224, 0.4);
|
||||
}
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0, "opsz" 24;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.max-w-hd {
|
||||
max-width: 1920px;
|
||||
}
|
||||
|
||||
.glass-panel {
|
||||
background: rgba(15, 15, 15, 0.7);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(197, 160, 89, 0.2);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.glass-panel {
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(197, 160, 89, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.gold-gradient-bg {
|
||||
background: linear-gradient(135deg, #c5a059 0%, #8e6d3d 100%);
|
||||
}
|
||||
|
||||
.hero-glow {
|
||||
background: radial-gradient(circle at 50% 40%, rgba(197, 160, 89, 0.08) 0%, rgba(15, 15, 15, 0) 70%);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.hero-glow {
|
||||
background: radial-gradient(circle at 50% 50%, rgba(197, 160, 89, 0.12) 0%, rgba(15, 15, 15, 0) 80%);
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"hero": {
|
||||
"badge": "Top Ranked Online Education",
|
||||
"title": "Advance Your Career Without Boundaries.",
|
||||
"description": "Access world-class education from anywhere. Flexible schedules, accredited programs, and affordable tuition designed for the modern professional.",
|
||||
"searchPlaceholder": "e.g. Business Administration",
|
||||
"buttonLabel": "Find Program",
|
||||
"image": "https://storage.googleapis.com/uxpilot-auth.appspot.com/d9383b2d27-55d93496971925dee83d.png",
|
||||
"imageAlt": "diverse group of modern adult students studying online with laptops, professional lighting, cinematic, high quality",
|
||||
"floatingBadge": {
|
||||
"icon": "fa-users",
|
||||
"value": "50,000+",
|
||||
"label": "Active Students"
|
||||
}
|
||||
},
|
||||
"quickLinks": [
|
||||
{
|
||||
"icon": "fa-trophy",
|
||||
"title": "Milestones",
|
||||
"description": "Explore our history of academic excellence and major achievements since our founding.",
|
||||
"linkText": "Explore History",
|
||||
"href": "/about/history"
|
||||
},
|
||||
{
|
||||
"icon": "fa-certificate",
|
||||
"title": "Accreditations",
|
||||
"description": "Review our globally recognized credentials ensuring the highest quality of education.",
|
||||
"linkText": "View Credentials",
|
||||
"href": "/about/accreditation"
|
||||
},
|
||||
{
|
||||
"icon": "fa-handshake",
|
||||
"title": "Partnerships",
|
||||
"description": "Discover our network of industry leaders providing career pathways for graduates.",
|
||||
"linkText": "See Partners",
|
||||
"href": "/about/partnerships"
|
||||
},
|
||||
{
|
||||
"icon": "fa-newspaper",
|
||||
"title": "Latest Blog",
|
||||
"description": "Read insights, student success stories, and updates from our academic community.",
|
||||
"linkText": "Read Articles",
|
||||
"href": "/blog"
|
||||
}
|
||||
],
|
||||
"valueProp": {
|
||||
"badge": "Why Choose Us",
|
||||
"title": "Education Engineered for the Modern World",
|
||||
"description": "We believe that quality education should be accessible to everyone, regardless of location or schedule. Our platform delivers an immersive learning experience backed by industry-leading technology.",
|
||||
"features": [
|
||||
{
|
||||
"icon": "fa-laptop-code",
|
||||
"title": "100% Online Flexibility",
|
||||
"description": "Study on your own time with asynchronous classes designed to fit around your work and life commitments."
|
||||
},
|
||||
{
|
||||
"icon": "fa-piggy-bank",
|
||||
"title": "Affordable Tuition",
|
||||
"description": "Graduate with less debt. Our transparent pricing and financial aid options make your degree attainable."
|
||||
},
|
||||
{
|
||||
"icon": "fa-briefcase",
|
||||
"title": "Career-Focused Curriculum",
|
||||
"description": "Programs developed in partnership with industry leaders to ensure you learn the skills employers actually want."
|
||||
}
|
||||
],
|
||||
"stats": [
|
||||
{
|
||||
"value": "94%",
|
||||
"label": "Employment rate within 6 months of graduation",
|
||||
"image": "https://storage.googleapis.com/uxpilot-auth.appspot.com/b1e2e6a5a4-7c815d7e47c7e7471c5e.png",
|
||||
"imageAlt": "student studying late at night looking focused"
|
||||
},
|
||||
{
|
||||
"value": "200+",
|
||||
"label": "Accredited degree and certificate programs",
|
||||
"image": "https://storage.googleapis.com/uxpilot-auth.appspot.com/8e7cd5934f-dc6192e3a13ea24a5312.png",
|
||||
"imageAlt": "graduation cap and diploma abstract professional setup"
|
||||
}
|
||||
]
|
||||
},
|
||||
"programs": {
|
||||
"heading": "Featured Programs",
|
||||
"description": "Discover our most popular career-focused degrees and certificates designed for the modern job market.",
|
||||
"items": [
|
||||
{
|
||||
"category": "Tech & Software",
|
||||
"image": "https://storage.googleapis.com/uxpilot-auth.appspot.com/f6c491e508-064cc521a89dee9776d1.png",
|
||||
"duration": "12-18 Months",
|
||||
"rating": "4.9",
|
||||
"title": "B.S. Computer Science",
|
||||
"description": "Master full-stack development, algorithms, and system design with hands-on projects.",
|
||||
"studentCount": "+1k",
|
||||
"href": "/programmes/bs-cs"
|
||||
},
|
||||
{
|
||||
"category": "Data Analysis",
|
||||
"image": "https://storage.googleapis.com/uxpilot-auth.appspot.com/e17be2a4e6-91247c5d9a4eed4d28a9.png",
|
||||
"duration": "8-12 Months",
|
||||
"rating": "4.8",
|
||||
"title": "Data Analytics Certificate",
|
||||
"description": "Learn SQL, Python, and Tableau to transform complex data into actionable business insights.",
|
||||
"studentCount": "+800",
|
||||
"href": "/programmes/cert-da"
|
||||
},
|
||||
{
|
||||
"category": "Business",
|
||||
"image": "https://storage.googleapis.com/uxpilot-auth.appspot.com/c53c1f7b37-66e95dbe58513f7bce4b.png",
|
||||
"duration": "18-24 Months",
|
||||
"rating": "4.9",
|
||||
"title": "MBA in Leadership",
|
||||
"description": "Develop strategic management skills and leadership qualities for the modern corporate world.",
|
||||
"studentCount": "+2k",
|
||||
"href": "/programmes/mba-db"
|
||||
}
|
||||
]
|
||||
},
|
||||
"requestInfo": {
|
||||
"heading": "Take the Next Step in Your Career.",
|
||||
"description": "Request more information about our programs, tuition, and admissions process. Our advisors are ready to help you plan your future.",
|
||||
"phone": "12345678-GLOBAL-U",
|
||||
"email": " info@lams.ac",
|
||||
"programs": [
|
||||
"Computer Science",
|
||||
"Business Administration",
|
||||
"Data Analytics",
|
||||
"Nursing"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Metadata } from "next";
|
||||
import "../public/assets/css/all.min.css";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"),
|
||||
title: "IPv6 Summit 2026 | Infrastructure for AI",
|
||||
description:
|
||||
"IPv6 for AI & Data Centre Summit 2026 — a premier event for next-gen infrastructure in ASEAN.",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
{/* Fonts */}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600;700&family=Geist:wght@400;600&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
{/* Icons */}
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
|
||||
/>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
{/* Favicon */}
|
||||
<link
|
||||
rel="shortcut icon"
|
||||
href="/assets/img/favicon.png"
|
||||
type="image/x-icon"
|
||||
/>
|
||||
</head>
|
||||
<body suppressHydrationWarning>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-[60vh] flex flex-col items-center justify-center py-24 px-6 md:px-12 lg:px-24 bg-uni-grey">
|
||||
<div className="max-w-2xl w-full text-center bg-white p-10 md:p-16 rounded-[32px] ref-card-shadow border border-slate-100">
|
||||
|
||||
{/* Icon */}
|
||||
<div className="w-20 h-20 mx-auto rounded-full bg-uni-orange/10 flex items-center justify-center text-uni-orange mb-8">
|
||||
<i className="fa-solid fa-triangle-exclamation text-4xl"></i>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<h1 className="font-display text-5xl md:text-6xl font-bold text-uni-dark mb-6">404</h1>
|
||||
<h2 className="font-display text-2xl md:text-3xl font-bold text-uni-dark mb-4">Page Not Found</h2>
|
||||
<p className="text-slate-600 text-lg mb-10 leading-relaxed">
|
||||
The page you're looking for could not be found. It may have been moved or deleted.
|
||||
Please check the URL or return to our home page.
|
||||
</p>
|
||||
|
||||
{/* Action Button */}
|
||||
<div className="flex justify-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="px-8 py-4 bg-uni-dark hover:bg-slate-800 text-white rounded-full font-semibold text-lg transition-all flex items-center justify-center gap-2 shadow-lg"
|
||||
>
|
||||
Back to Home <i className="fa-solid fa-arrow-right text-sm"></i>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Converts an EditorJS JSON output (string or object) into an HTML string.
|
||||
* Supports all block types used in the CMS blog editor.
|
||||
*/
|
||||
export function editorjsToHtml(raw: string | object | null | undefined): string {
|
||||
if (!raw) return "";
|
||||
|
||||
let data: { blocks?: Array<{ type: string; data: Record<string, unknown> }> };
|
||||
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch {
|
||||
// Not JSON — treat as plain HTML string (legacy content)
|
||||
return raw;
|
||||
}
|
||||
} else {
|
||||
data = raw as typeof data;
|
||||
}
|
||||
|
||||
if (!data || !Array.isArray(data.blocks) || data.blocks.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return data.blocks.map(renderBlock).join("\n");
|
||||
}
|
||||
|
||||
function esc(text: unknown): string {
|
||||
return String(text ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function renderBlock(block: { type: string; data: Record<string, unknown> }): string {
|
||||
const { type, data } = block;
|
||||
|
||||
switch (type) {
|
||||
case "paragraph": {
|
||||
const text = data.text as string ?? "";
|
||||
return `<p>${text}</p>`;
|
||||
}
|
||||
|
||||
case "header": {
|
||||
const level = Number(data.level) || 2;
|
||||
const tag = `h${Math.min(Math.max(level, 1), 6)}`;
|
||||
return `<${tag}>${data.text}</${tag}>`;
|
||||
}
|
||||
|
||||
case "list": {
|
||||
const style = data.style === "ordered" ? "ol" : "ul";
|
||||
const items = (data.items as string[]) ?? [];
|
||||
const lis = items.map((item) => `<li>${item}</li>`).join("\n");
|
||||
return `<${style}>\n${lis}\n</${style}>`;
|
||||
}
|
||||
|
||||
case "checklist": {
|
||||
const items = (data.items as Array<{ text: string; checked: boolean }>) ?? [];
|
||||
const lis = items
|
||||
.map(
|
||||
(item) =>
|
||||
`<li class="editorjs-checklist-item${item.checked ? " checked" : ""}">` +
|
||||
`<span class="editorjs-checkbox">${item.checked ? "✓" : "○"}</span> ${item.text}` +
|
||||
`</li>`
|
||||
)
|
||||
.join("\n");
|
||||
return `<ul class="editorjs-checklist">\n${lis}\n</ul>`;
|
||||
}
|
||||
|
||||
case "image": {
|
||||
const file = (data.file as { url?: string }) ?? {};
|
||||
const url = file.url ?? (data.url as string) ?? "";
|
||||
const caption = (data.caption as string) ?? "";
|
||||
const stretched = data.stretched ? ' class="editorjs-image-stretched"' : "";
|
||||
const withBg = data.withBackground ? ' style="background:#f5f5f5; padding:1rem;"' : "";
|
||||
return (
|
||||
`<figure${withBg}>` +
|
||||
`<img src="${esc(url)}" alt="${esc(caption)}"${stretched} loading="lazy" />` +
|
||||
(caption ? `<figcaption>${caption}</figcaption>` : "") +
|
||||
`</figure>`
|
||||
);
|
||||
}
|
||||
|
||||
case "quote": {
|
||||
const text = data.text as string ?? "";
|
||||
const caption = data.caption as string ?? "";
|
||||
return (
|
||||
`<blockquote class="editorjs-quote">` +
|
||||
`<p>${text}</p>` +
|
||||
(caption ? `<cite>${caption}</cite>` : "") +
|
||||
`</blockquote>`
|
||||
);
|
||||
}
|
||||
|
||||
case "code": {
|
||||
return `<pre><code>${esc(data.code)}</code></pre>`;
|
||||
}
|
||||
|
||||
case "delimiter": {
|
||||
return `<hr class="editorjs-delimiter" />`;
|
||||
}
|
||||
|
||||
case "table": {
|
||||
const content = (data.content as string[][]) ?? [];
|
||||
const withHeadings = data.withHeadings as boolean;
|
||||
const rows = content.map((row, rowIdx) => {
|
||||
const tag = withHeadings && rowIdx === 0 ? "th" : "td";
|
||||
const cells = row.map((cell) => `<${tag}>${cell}</${tag}>`).join("");
|
||||
return `<tr>${cells}</tr>`;
|
||||
});
|
||||
return `<table class="editorjs-table">\n${rows.join("\n")}\n</table>`;
|
||||
}
|
||||
|
||||
case "embed": {
|
||||
const service = (data.service as string ?? "").toLowerCase();
|
||||
const embedUrl = (data.embed as string) ?? "";
|
||||
const caption = (data.caption as string) ?? "";
|
||||
const width = (data.width as number) ?? 560;
|
||||
const height = (data.height as number) ?? 315;
|
||||
|
||||
// EditorJS Embed stores the ready-to-use iframe src in data.embed
|
||||
let iframeSrc = esc(embedUrl);
|
||||
|
||||
// Fallback: if data.embed is the watch URL, convert it
|
||||
if (service === "youtube" && iframeSrc.includes("watch?v=")) {
|
||||
iframeSrc = iframeSrc.replace("watch?v=", "embed/").split("&")[0];
|
||||
} else if (service === "vimeo" && !iframeSrc.includes("player.vimeo.com")) {
|
||||
const vimeoId = iframeSrc.split("/").pop() ?? "";
|
||||
iframeSrc = `https://player.vimeo.com/video/${vimeoId}`;
|
||||
}
|
||||
|
||||
return (
|
||||
`<figure class="editorjs-embed">` +
|
||||
`<iframe src="${iframeSrc}" width="${width}" height="${height}" frameborder="0" allowfullscreen loading="lazy"></iframe>` +
|
||||
(caption ? `<figcaption>${caption}</figcaption>` : "") +
|
||||
`</figure>`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
default:
|
||||
// Unknown block — skip silently
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export const imageUrl = (path?: string) => {
|
||||
// Không có ảnh → ảnh mặc định
|
||||
if (!path) return "/_images/default.jpg";
|
||||
|
||||
// Đã là full URL
|
||||
if (path.startsWith("http")) return path;
|
||||
|
||||
const base = (
|
||||
process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001"
|
||||
).replace(/\/$/, "");
|
||||
|
||||
return `${base}/${path.replace(/^\//, "")}`;
|
||||
};
|
||||
Reference in New Issue
Block a user