forked from UKSOURCE/ipv6
feat: memberships page
This commit is contained in:
@@ -18,7 +18,7 @@ type EventItem = {
|
||||
|
||||
export default function GalleryPageClient() {
|
||||
const { gallery, lang } = useTranslation();
|
||||
|
||||
|
||||
// Active Event Index (0: latest event, 7: oldest event)
|
||||
const [activeEventIndex, setActiveEventIndex] = useState(0);
|
||||
|
||||
@@ -128,21 +128,21 @@ export default function GalleryPageClient() {
|
||||
|
||||
{/* Gallery Section */}
|
||||
<section className="py-12 lg:py-20 px-[var(--spacing-gutter)] max-w-hd mx-auto">
|
||||
|
||||
|
||||
{/* Step Timeline Navigation */}
|
||||
<div className="mb-10 max-w-4xl mx-auto px-4">
|
||||
<div className="relative flex justify-between items-center">
|
||||
{/* Timeline track behind steps */}
|
||||
<div className="absolute left-0 right-0 h-[2px] bg-white/10 -z-10" />
|
||||
<div
|
||||
<div
|
||||
className="absolute left-0 h-[2px] bg-primary transition-all duration-500 -z-10"
|
||||
style={{ width: `${(activeEventIndex / (gallery.events.length - 1)) * 100}%` }}
|
||||
/>
|
||||
|
||||
|
||||
{gallery.events.map((event: EventItem, idx: number) => {
|
||||
const isActive = idx === activeEventIndex;
|
||||
const isPassed = idx < activeEventIndex;
|
||||
|
||||
|
||||
return (
|
||||
<button
|
||||
key={event.id}
|
||||
@@ -152,13 +152,13 @@ export default function GalleryPageClient() {
|
||||
title={event.title}
|
||||
>
|
||||
{/* Step circle */}
|
||||
<div
|
||||
<div
|
||||
className={[
|
||||
"w-8 h-8 rounded-full flex items-center justify-center font-[var(--font-label-caps)] text-xs font-bold transition-all duration-300 border-2 z-10",
|
||||
isActive
|
||||
? "bg-background border-primary text-primary status-led shadow-[0_0_12px_rgba(197,160,89,0.6)] scale-110"
|
||||
: isPassed
|
||||
? "bg-primary border-primary text-on-primary"
|
||||
isActive
|
||||
? "bg-background border-primary text-primary status-led shadow-[0_0_12px_rgba(197,160,89,0.6)] scale-110"
|
||||
: isPassed
|
||||
? "bg-primary border-primary text-on-primary"
|
||||
: "bg-surface-container-high border-white/10 text-on-surface-variant/40 hover:border-white/30 hover:text-on-surface"
|
||||
].join(" ")}
|
||||
>
|
||||
@@ -173,7 +173,7 @@ export default function GalleryPageClient() {
|
||||
{/* Active Event Grid Container */}
|
||||
{activeEvent && (
|
||||
<div className="glass-panel p-6 sm:p-10 lg:p-16 rounded-[2rem] lg:rounded-[3rem] border border-primary/20 hover:border-primary/30 shadow-2xl transition-all duration-300">
|
||||
|
||||
|
||||
{/* Top Navigation Control Header */}
|
||||
<div className="flex justify-between items-center mb-10 border-b border-white/5 pb-6">
|
||||
<button
|
||||
@@ -187,8 +187,8 @@ export default function GalleryPageClient() {
|
||||
</button>
|
||||
|
||||
<span className="font-[var(--font-label-caps)] text-xs sm:text-sm text-primary tracking-widest bg-primary/5 border border-primary/20 px-4 py-2 rounded-xl">
|
||||
{lang === "vi"
|
||||
? `SỰ KIỆN ${activeEventIndex + 1} / ${gallery.events.length}`
|
||||
{lang === "vi"
|
||||
? `SỰ KIỆN ${activeEventIndex + 1} / ${gallery.events.length}`
|
||||
: `EVENT ${activeEventIndex + 1} OF ${gallery.events.length}`
|
||||
}
|
||||
</span>
|
||||
@@ -209,13 +209,13 @@ export default function GalleryPageClient() {
|
||||
<h2 className="text-2xl sm:text-3xl lg:text-4xl font-[var(--font-display-lg)] text-primary mb-4 leading-tight">
|
||||
{activeEvent.title}
|
||||
</h2>
|
||||
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs uppercase tracking-wider text-on-surface-variant/50 font-[var(--font-label-caps)] mb-6">
|
||||
<div className="flex items-center gap-1.5 bg-surface-container-high px-3.5 py-2 rounded-xl border border-white/5">
|
||||
<span className="material-symbols-outlined text-base text-primary">calendar_today</span>
|
||||
<span>{activeEvent.date}</span>
|
||||
</div>
|
||||
|
||||
|
||||
{activeEvent.googleDriveUrl && activeEvent.googleDriveUrl.trim() !== "" && (
|
||||
<a
|
||||
href={activeEvent.googleDriveUrl}
|
||||
@@ -235,8 +235,8 @@ export default function GalleryPageClient() {
|
||||
<div className="flex flex-col items-center justify-center py-20 px-6 border border-white/5 bg-white/[0.01] rounded-2xl">
|
||||
<span className="material-symbols-outlined text-5xl text-primary/45 mb-4">image</span>
|
||||
<p className="text-on-surface-variant/60 font-sans text-sm sm:text-base text-center max-w-md">
|
||||
{lang === "vi"
|
||||
? "Hình ảnh nổi bật cho sự kiện này hiện đang được cập nhật."
|
||||
{lang === "vi"
|
||||
? "Hình ảnh nổi bật cho sự kiện này hiện đang được cập nhật."
|
||||
: "Highlight images for this event are currently being updated."
|
||||
}
|
||||
</p>
|
||||
@@ -244,7 +244,7 @@ export default function GalleryPageClient() {
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
|
||||
{activeEvent.images.map((imgUrl, i) => (
|
||||
<div
|
||||
<div
|
||||
key={i}
|
||||
className={[
|
||||
getMosaicClasses(i, activeEvent.images.length),
|
||||
@@ -253,9 +253,9 @@ export default function GalleryPageClient() {
|
||||
onClick={() => openLightbox(i)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getImgSrc(imgUrl)}
|
||||
alt={`${activeEvent.title} Highlight ${i + 1}`}
|
||||
<img
|
||||
src={getImgSrc(imgUrl)}
|
||||
alt={`${activeEvent.title} Highlight ${i + 1}`}
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
@@ -278,13 +278,13 @@ export default function GalleryPageClient() {
|
||||
|
||||
{/* Lightbox Overlay */}
|
||||
{lightboxImageIndex !== null && activeEvent && activeImage && (
|
||||
<div
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex flex-col justify-center items-center bg-black/95 backdrop-blur-md transition-opacity duration-300"
|
||||
onClick={closeLightbox}
|
||||
>
|
||||
{/* Close button */}
|
||||
<button
|
||||
type="button"
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-6 right-6 text-white/70 hover:text-white transition-colors p-2 z-[110] focus:outline-none"
|
||||
onClick={closeLightbox}
|
||||
aria-label="Close Lightbox"
|
||||
@@ -293,12 +293,12 @@ export default function GalleryPageClient() {
|
||||
</button>
|
||||
|
||||
{/* Lightbox content container */}
|
||||
<div
|
||||
<div
|
||||
className="relative flex items-center justify-between w-full max-w-6xl px-4 md:px-12 h-[60vh] sm:h-[70vh] md:h-[80vh]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Prev arrow */}
|
||||
<button
|
||||
<button
|
||||
type="button"
|
||||
className="w-12 h-12 md:w-16 md:h-16 rounded-full bg-white/5 hover:bg-white/10 border border-white/10 text-white flex items-center justify-center transition-colors mr-2 active:scale-95 focus:outline-none"
|
||||
onClick={() => setLightboxImageIndex((prev) => (prev! - 1 + activeEvent.images.length) % activeEvent.images.length)}
|
||||
@@ -310,15 +310,15 @@ export default function GalleryPageClient() {
|
||||
{/* Active Image */}
|
||||
<div className="flex-1 flex items-center justify-center h-full relative overflow-hidden select-none px-4">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getImgSrc(activeImage)}
|
||||
<img
|
||||
src={getImgSrc(activeImage)}
|
||||
alt={`${activeEvent.title} Full Highlight`}
|
||||
className="max-w-full max-h-full object-contain rounded-lg border border-white/5 shadow-2xl transition-all duration-300 animate-in fade-in zoom-in-95 duration-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Next arrow */}
|
||||
<button
|
||||
<button
|
||||
type="button"
|
||||
className="w-12 h-12 md:w-16 md:h-16 rounded-full bg-white/5 hover:bg-white/10 border border-white/10 text-white flex items-center justify-center transition-colors ml-2 active:scale-95 focus:outline-none"
|
||||
onClick={() => setLightboxImageIndex((prev) => (prev! + 1) % activeEvent.images.length)}
|
||||
@@ -329,7 +329,7 @@ export default function GalleryPageClient() {
|
||||
</div>
|
||||
|
||||
{/* Info bar below image */}
|
||||
<div
|
||||
<div
|
||||
className="text-center mt-6 px-6 max-w-2xl select-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -337,7 +337,7 @@ export default function GalleryPageClient() {
|
||||
{activeEvent.title}
|
||||
</h3>
|
||||
<p className="text-on-surface-variant/60 text-xs sm:text-sm uppercase tracking-wider font-[var(--font-label-caps)]">
|
||||
{lang === "vi"
|
||||
{lang === "vi"
|
||||
? `Hình ảnh ${lightboxImageIndex + 1} trên ${activeEvent.images.length}`
|
||||
: `Image ${lightboxImageIndex + 1} of ${activeEvent.images.length}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "@/app/hooks/useTranslation";
|
||||
import ReadyToJoin from "@/app/components/gallery/ReadyToJoin";
|
||||
|
||||
const BASE = process.env.NEXT_PUBLIC_BASE_PATH || "";
|
||||
|
||||
type MemberItem = {
|
||||
name: string;
|
||||
logo: string;
|
||||
subtitle: string;
|
||||
};
|
||||
|
||||
type Category = {
|
||||
title: string;
|
||||
items: MemberItem[];
|
||||
};
|
||||
|
||||
type IndividualMember = {
|
||||
name: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
/* Stateful Logo Card component to dynamically adjust padding & scaling based on image aspect ratio */
|
||||
const LogoCard = ({ item }: { item: MemberItem }) => {
|
||||
const [isSquareOrTall, setIsSquareOrTall] = useState(false);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const img = imgRef.current;
|
||||
if (!img) return;
|
||||
|
||||
// Reset aspect ratio state when logo path changes
|
||||
setIsSquareOrTall(false);
|
||||
|
||||
const handleLoad = () => {
|
||||
const { naturalWidth, naturalHeight } = img;
|
||||
if (naturalHeight && naturalWidth) {
|
||||
// If height-to-width ratio is close to square or taller (>= 0.75)
|
||||
setIsSquareOrTall(naturalHeight / naturalWidth >= 0.75);
|
||||
}
|
||||
};
|
||||
|
||||
if (img.complete) {
|
||||
handleLoad();
|
||||
} else {
|
||||
img.addEventListener("load", handleLoad);
|
||||
return () => img.removeEventListener("load", handleLoad);
|
||||
}
|
||||
}, [item.logo]);
|
||||
|
||||
const hasLogo = item.logo && item.logo.trim() !== "";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center group w-full">
|
||||
{/* Card containing the logo image or fallback placeholder */}
|
||||
<div
|
||||
className={`w-full h-28 flex items-center justify-center bg-white/95 rounded-2xl border border-white/5 group-hover:scale-[1.03] transition-all duration-300 shadow-md ${
|
||||
hasLogo && isSquareOrTall ? "p-2.5" : "p-4.5"
|
||||
}`}
|
||||
>
|
||||
{hasLogo ? (
|
||||
/* Logo Container with background fill for high contrast */
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={`${BASE}${item.logo}`}
|
||||
alt={item.name}
|
||||
className={`w-auto h-auto object-contain filter group-hover:scale-105 transition-all duration-300 ${
|
||||
isSquareOrTall
|
||||
? "max-h-[82%] max-w-[82%]"
|
||||
: "max-h-[55%] max-w-[85%]"
|
||||
}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* Fallback avatar badge */
|
||||
<div className="w-12 h-12 rounded-full bg-primary/10 border border-primary/20 flex items-center justify-center">
|
||||
<span className="font-[var(--font-display-lg)] text-sm text-primary font-bold">
|
||||
{item.name.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Text details (outside on the dark background, larger text) */}
|
||||
<div className="mt-3.5 flex flex-col items-center text-center w-full">
|
||||
<span className="font-[var(--font-display-lg)] text-sm lg:text-base text-on-surface group-hover:text-primary transition-colors duration-300 tracking-wider uppercase leading-tight px-1">
|
||||
{item.name}
|
||||
</span>
|
||||
<span className="text-[10px] lg:text-[11px] text-on-surface-variant/50 leading-tight mt-1.5 max-w-full px-2 truncate block uppercase tracking-widest font-[var(--font-body-base)] select-none">
|
||||
{item.subtitle}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function MembersPageClient() {
|
||||
const { members } = useTranslation();
|
||||
|
||||
const categories = members.organizationalMembers.categories;
|
||||
|
||||
return (
|
||||
<main className="pt-16 lg:pt-20 min-h-screen bg-background text-on-surface">
|
||||
{/* Hero Header Section */}
|
||||
<section className="hero-glow py-20 lg:py-32 px-[var(--spacing-gutter)] text-center border-b border-white/5">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<span className="font-[var(--font-label-caps)] text-xs lg:text-sm text-primary block mb-4 lg:mb-6 tracking-[0.25em] uppercase animate-pulse">
|
||||
{members.hero.eyebrow}
|
||||
</span>
|
||||
<h1 className="font-[var(--font-display-lg)] text-on-surface text-[clamp(2.5rem,6vw+1rem,5.5rem)] leading-none tracking-tight">
|
||||
{members.hero.title}
|
||||
</h1>
|
||||
<p className="font-[var(--font-body-base)] text-on-surface-variant text-base lg:text-[20px] max-w-3xl mx-auto opacity-70 leading-relaxed mt-6">
|
||||
{members.hero.description}
|
||||
</p>
|
||||
|
||||
{/* Jump Anchors Navigation Menu */}
|
||||
<div className="flex flex-wrap justify-center gap-4 sm:gap-6 mt-10">
|
||||
<a
|
||||
href="#org-members"
|
||||
className="inline-flex items-center gap-2.5 px-6 py-3 rounded-full border border-primary/25 bg-primary/5 text-primary text-xs lg:text-sm font-bold uppercase tracking-widest hover:bg-primary/10 hover:border-primary/50 transition-all duration-300"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
|
||||
{members.organizationalMembers.title}
|
||||
</a>
|
||||
<a
|
||||
href="#contrib-members"
|
||||
className="inline-flex items-center gap-2.5 px-6 py-3 rounded-full border border-primary/25 bg-primary/5 text-primary text-xs lg:text-sm font-bold uppercase tracking-widest hover:bg-primary/10 hover:border-primary/50 transition-all duration-300"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
|
||||
{members.contributingMembers.title}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Organizational Members Section */}
|
||||
<section className="py-16 lg:py-24 px-[var(--spacing-gutter)] max-w-hd mx-auto" id="org-members">
|
||||
<div className="mb-12 lg:mb-20 text-center max-w-3xl mx-auto">
|
||||
<h2 className="font-[var(--font-display-lg)] text-on-surface uppercase tracking-tight text-[clamp(1.75rem,3vw+1rem,3.5rem)] mb-4">
|
||||
{members.organizationalMembers.title}
|
||||
</h2>
|
||||
<p className="text-on-surface-variant/60 text-base lg:text-lg">
|
||||
{members.organizationalMembers.description}
|
||||
</p>
|
||||
<div className="h-[2px] w-24 bg-primary/50 mx-auto rounded-full mt-6" />
|
||||
</div>
|
||||
|
||||
{/* Subcategories */}
|
||||
<div className="space-y-20 lg:space-y-28">
|
||||
{Object.entries(categories).map(([key, cat]) => {
|
||||
const category = cat as Category;
|
||||
return (
|
||||
<div key={key} className="space-y-8 lg:space-y-12">
|
||||
{/* Subcategory title */}
|
||||
<h3 className="font-[var(--font-display-lg)] text-base lg:text-lg text-primary uppercase tracking-widest border-l-4 border-primary pl-4 leading-none">
|
||||
{category.title}
|
||||
</h3>
|
||||
{/* Logos Grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-x-6 gap-y-12">
|
||||
{category.items.map((item) => (
|
||||
<LogoCard key={item.name} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Contributing Members Section */}
|
||||
<section className="py-16 lg:py-24 px-[var(--spacing-gutter)] max-w-hd mx-auto border-t border-white/5 bg-white/[0.01]" id="contrib-members">
|
||||
<div className="mb-12 lg:mb-20 text-center max-w-3xl mx-auto">
|
||||
<h2 className="font-[var(--font-display-lg)] text-on-surface uppercase tracking-tight text-[clamp(1.75rem,3vw+1rem,3.5rem)] mb-4">
|
||||
{members.contributingMembers.title}
|
||||
</h2>
|
||||
<p className="text-on-surface-variant/60 text-base lg:text-lg">
|
||||
{members.contributingMembers.description}
|
||||
</p>
|
||||
<div className="h-[2px] w-24 bg-primary/50 mx-auto rounded-full mt-6" />
|
||||
</div>
|
||||
|
||||
{/* Individual Contributors Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
|
||||
{members.contributingMembers.items.map((item: IndividualMember) => (
|
||||
<div
|
||||
key={item.name}
|
||||
className="glass-panel p-5 rounded-2xl flex items-center gap-4 hover:border-primary/50 hover:scale-[1.02] transition-all duration-300 shadow-md group"
|
||||
>
|
||||
<div className="w-12 h-12 rounded-full bg-primary/10 border border-primary/20 flex items-center justify-center shrink-0 group-hover:bg-primary/20 transition-colors duration-300">
|
||||
<span className="material-symbols-outlined text-primary text-2xl" aria-hidden="true">
|
||||
person
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h4 className="font-[var(--font-display-lg)] text-sm lg:text-base text-on-surface leading-tight truncate mb-1 group-hover:text-primary transition-colors duration-300">
|
||||
{item.name}
|
||||
</h4>
|
||||
<p className="text-xs text-on-surface-variant/70 leading-normal truncate">
|
||||
{item.title}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Ready To Join CTA Section (Reusing Visual Gallery CTA design) */}
|
||||
<ReadyToJoin data={members.join} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user