Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 25 additions & 6 deletions frontend/app/[locale]/q&a/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Suspense } from 'react';
import { getTranslations } from 'next-intl/server';
import QaSection from '@/components/q&a/QaSection';
import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground';

export async function generateMetadata({
params,
Expand All @@ -16,12 +17,30 @@ export async function generateMetadata({
};
}

export default function QAPage() {
export default async function QAPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'qa' });

return (
<main className="mx-auto max-w-7xl px-4 py-10 sm:px-6 lg:px-8">
<Suspense fallback={<>...</>}>
<QaSection />
</Suspense>
</main>
<DynamicGridBackground className="bg-gray-50 transition-colors duration-300 dark:bg-transparent py-10">
<main className="relative z-10 mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="mb-8">
<p className="text-sm text-[var(--accent-primary)] font-semibold">
{t('pretitle')}
</p>
<h1 className="text-3xl font-bold">{t('title')}</h1>
<p className="text-gray-600 dark:text-gray-400">
{t('subtitle')}
</p>
</div>
<Suspense fallback={<>...</>}>
<QaSection />
</Suspense>
</main>
</DynamicGridBackground>
);
}
47 changes: 5 additions & 42 deletions frontend/components/about/HeroSection.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,12 @@
"use client"

import { useRef } from "react"
import { motion, useMotionTemplate, useMotionValue } from "framer-motion"
import { motion } from "framer-motion"
import { CheckCircle, Users, Star, Linkedin, ArrowDown } from "lucide-react"
import { InteractiveGame } from "./InteractiveGame"
import type { PlatformStats } from "@/lib/about/stats"
import { DynamicGridBackground } from "@/components/shared/DynamicGridBackground"

export function HeroSection({ stats }: { stats?: PlatformStats }) {
const containerRef = useRef<HTMLElement>(null)

const mouseX = useMotionValue(0)
const mouseY = useMotionValue(0)

function handleMouseMove({ currentTarget, clientX, clientY }: React.MouseEvent) {
const { left, top } = currentTarget.getBoundingClientRect()
mouseX.set(clientX - left)
mouseY.set(clientY - top)
}

const data = stats || {
questionsSolved: "850+",
githubStars: "120+",
Expand All @@ -26,33 +15,7 @@ export function HeroSection({ stats }: { stats?: PlatformStats }) {
}

return (
<section
ref={containerRef}
onMouseMove={handleMouseMove}
className="relative flex min-h-[calc(100svh)] items-center justify-center overflow-hidden bg-gray-50 transition-colors duration-300 dark:bg-transparent pt-20 pb-10 group"
>

<div className="pointer-events-none absolute inset-0">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#8080800a_1px,transparent_1px),linear-gradient(to_bottom,#8080800a_1px,transparent_1px)] bg-[size:40px_40px] [mask-image:radial-gradient(ellipse_80%_80%_at_50%_50%,#000_70%,transparent_100%)]" />
</div>

<motion.div
className="pointer-events-none absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300"
style={{
maskImage: useMotionTemplate`radial-gradient(300px circle at ${mouseX}px ${mouseY}px, black, transparent)`,
WebkitMaskImage: useMotionTemplate`radial-gradient(300px circle at ${mouseX}px ${mouseY}px, black, transparent)`,
}}
>
<div className="absolute inset-0 bg-[linear-gradient(to_right,#1e5eff_1px,transparent_1px),linear-gradient(to_bottom,#1e5eff_1px,transparent_1px)] dark:bg-[linear-gradient(to_right,#ff2d55_1px,transparent_1px),linear-gradient(to_bottom,#ff2d55_1px,transparent_1px)] bg-[size:40px_40px] opacity-20 dark:opacity-30" />
</motion.div>

<div className="pointer-events-none absolute inset-0">
<div className="absolute left-1/2 top-1/2 h-[70svh] w-[70svw] min-h-[500px] min-w-[500px] md:h-[900px] md:w-[900px] -translate-x-1/2 -translate-y-1/2 rounded-full
bg-[#1e5eff]/10 blur-[120px] mix-blend-multiply
dark:bg-[#ff2d55]/10 dark:mix-blend-screen transition-all duration-500"
/>
</div>

<DynamicGridBackground className="flex min-h-[calc(100svh)] items-center justify-center bg-gray-50 transition-colors duration-300 dark:bg-transparent pt-20 pb-10">
<div className="relative z-10 grid w-full max-w-[1600px] grid-cols-1 items-center px-4 sm:px-6 lg:px-8 xl:grid-cols-12 xl:gap-8 h-full">

<div className="hidden h-full flex-col justify-center gap-24 xl:col-span-3 xl:flex">
Expand Down Expand Up @@ -121,7 +84,7 @@ export function HeroSection({ stats }: { stats?: PlatformStats }) {
</div>

</div>
</section>
</DynamicGridBackground>
)
}

Expand Down Expand Up @@ -154,4 +117,4 @@ function MobileStatItem({ icon: Icon, color, bg, label, value }: any) {
</div>
</div>
)
}
}
65 changes: 52 additions & 13 deletions frontend/components/q&a/Pagination.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,49 @@
'use client';

import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { cn } from '@/lib/utils';

interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
accentColor: string;
}

function hexToRgba(hex: string, alpha: number): string {
const normalized = hex.replace('#', '');
if (normalized.length !== 6) return `rgba(0, 0, 0, ${alpha})`;
const r = parseInt(normalized.slice(0, 2), 16);
const g = parseInt(normalized.slice(2, 4), 16);
const b = parseInt(normalized.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}

export function Pagination({
currentPage,
totalPages,
onPageChange,
accentColor,
}: PaginationProps) {
const t = useTranslations('qa.pagination');
const accentSoft = hexToRgba(accentColor, 0.16);
const accentGlow = hexToRgba(accentColor, 0.22);
const [isMobile, setIsMobile] = useState(false);

useEffect(() => {
const media = window.matchMedia('(max-width: 640px)');
const update = () => setIsMobile(media.matches);
update();
media.addEventListener('change', update);
return () => media.removeEventListener('change', update);
}, []);

if (totalPages <= 1) return null;

const getPageNumbers = (): (number | 'ellipsis')[] => {
const pages: (number | 'ellipsis')[] = [];
const maxVisible = 5;
const maxVisible = isMobile ? 3 : 5;

if (totalPages <= maxVisible + 2) {
for (let i = 1; i <= totalPages; i++) {
Expand Down Expand Up @@ -59,25 +82,32 @@ export function Pagination({

return (
<nav
className="flex items-center justify-center gap-1 mt-8"
className="flex items-center justify-center gap-1 mt-8 sm:gap-2"
style={
{
'--qa-accent': accentColor,
'--qa-accent-soft': accentSoft,
'--qa-accent-glow': accentGlow,
} as React.CSSProperties
}
aria-label={t('label')}
>
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className={cn(
'px-3 py-2 text-sm font-medium rounded-lg transition-colors',
'px-2 py-2 text-sm font-medium rounded-lg transition-colors sm:px-3',
'border border-gray-300 dark:border-gray-700',
currentPage === 1
? 'text-gray-400 dark:text-gray-600 cursor-not-allowed'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
: 'text-gray-700 dark:text-gray-300 hover:bg-[var(--qa-accent-soft)]'
)}
aria-label={t('previousPage')}
>
← {t('previous')}
<span className="hidden sm:inline">{t('previous')}</span>
</button>

<div className="flex items-center gap-1 mx-2">
<div className="flex items-center gap-1 mx-1 sm:mx-2">
{pages.map((page, index) =>
page === 'ellipsis' ? (
<span
Expand All @@ -92,11 +122,20 @@ export function Pagination({
onClick={() => onPageChange(page)}
disabled={page === currentPage}
className={cn(
'min-w-[40px] px-3 py-2 text-sm font-medium rounded-lg transition-colors',
'min-w-[40px] px-3 py-2 text-sm font-medium rounded-lg transition-colors border border-transparent overflow-hidden',
page === currentPage
? 'bg-blue-600 text-white'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
? 'shadow-sm text-gray-700 dark:text-gray-300'
: 'text-gray-700 dark:text-gray-300 hover:bg-[var(--qa-accent-soft)]'
)}
style={
page === currentPage
? {
backgroundColor: accentSoft,
borderColor: accentColor,
boxShadow: `inset 0 0 18px ${accentGlow}`,
}
: undefined
}
aria-label={t('page', { page })}
aria-current={page === currentPage ? 'page' : undefined}
>
Expand All @@ -110,16 +149,16 @@ export function Pagination({
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className={cn(
'px-3 py-2 text-sm font-medium rounded-lg transition-colors',
'px-2 py-2 text-sm font-medium rounded-lg transition-colors sm:px-3',
'border border-gray-300 dark:border-gray-700',
currentPage === totalPages
? 'text-gray-400 dark:text-gray-600 cursor-not-allowed'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
: 'text-gray-700 dark:text-gray-300 hover:bg-[var(--qa-accent-soft)]'
)}
aria-label={t('nextPage')}
>
{t('next')} →
<span className="hidden sm:inline">{t('next')}</span>
</button>
</nav>
);
}
}
87 changes: 39 additions & 48 deletions frontend/components/q&a/QaSection.tsx
Original file line number Diff line number Diff line change
@@ -1,84 +1,74 @@
'use client';

import { useTranslations } from 'next-intl';
import { Search, X } from 'lucide-react';

import AccordionList from '@/components/q&a/AccordionList';
import { Pagination } from '@/components/q&a/Pagination';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Tabs, TabsList, TabsContent } from '@/components/ui/tabs';
import { categoryData } from '@/data/category';
import { useQaTabs } from '@/components/q&a/useQaTabs';
import { QaTabButton } from '@/components/q&a/QaTabButton';
import { qaTabStyles } from '@/data/qaTabs';
import { cn } from '@/lib/utils';
import type { CategorySlug } from '@/components/q&a/types';

export default function TabsSection() {
const t = useTranslations('qa');
const {
active,
currentPage,
debouncedSearch,
handleCategoryChange,
handlePageChange,
isLoading,
items,
localeKey,
searchQuery,
setSearchQuery,
totalPages,
clearSearch,
} = useQaTabs();

return (
<div className="w-full">
<div className="relative mb-6">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />

<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder={t('searchPlaceholder')}
className="w-full pl-10 pr-10 py-3 border rounded-lg"
/>

{searchQuery && (
<button
onClick={clearSearch}
className="absolute right-3 top-1/2 -translate-y-1/2"
>
<X className="h-5 w-5" />
</button>
)}
</div>

<Tabs value={active} onValueChange={handleCategoryChange}>
<TabsList className="!bg-transparent !p-0 !h-auto !w-full grid grid-cols-3 sm:grid-cols-5 lg:grid-cols-10 mb-6 gap-2">
{categoryData.map(category => (
<TabsTrigger
key={category.slug}
value={category.slug}
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg px-3 py-2 text-sm font-medium transition-colors"
>
{category.translations[localeKey] ??
<TabsList className="!bg-transparent !p-0 !h-auto !w-full flex flex-wrap items-stretch justify-start gap-3 mb-6">
{categoryData.map(category => {
const slug = category.slug as keyof typeof qaTabStyles;
const value = slug as CategorySlug;
return (
<QaTabButton
key={slug}
value={value}
label={
category.translations[localeKey] ??
category.translations.en ??
category.slug}
</TabsTrigger>
))}
value
}
style={qaTabStyles[slug]}
isActive={active === value}
/>
);
})}
</TabsList>

{categoryData.map(category => (
<TabsContent key={category.slug} value={category.slug}>
{isLoading ? (
{isLoading && (
<div className="flex justify-center py-12">
<div className="animate-spin h-8 w-8 border-b-2" />
</div>
) : items.length ? (
<AccordionList items={items} />
) : (
<p className="text-center py-12">
{debouncedSearch
? t('noResults', { query: debouncedSearch })
: t('noQuestions')}
</p>
)}
<div
className={cn(
'transition-opacity duration-300',
isLoading ? 'opacity-0' : 'opacity-100'
)}
aria-busy={isLoading}
>
{items.length ? (
<AccordionList items={items} />
) : (
<p className="text-center py-12">
{t('noQuestions')}
</p>
)}
</div>
</TabsContent>
))}
</Tabs>
Expand All @@ -88,6 +78,7 @@ export default function TabsSection() {
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
accentColor={qaTabStyles[active as keyof typeof qaTabStyles].accent}
/>
)}
</div>
Expand Down
Loading