Initial commit

This commit is contained in:
Iceberg
2026-04-20 17:59:24 +00:00
commit a43f8de3e4
51 changed files with 9695 additions and 0 deletions
@@ -0,0 +1,91 @@
import React from 'react';
import { useTranslations } from 'next-intl';
type Props = {
authenticated: boolean;
profileOpen: boolean;
setProfileOpen: React.Dispatch<React.SetStateAction<boolean>>;
profileRef: React.RefObject<HTMLDivElement | null>;
signIn: (provider: string) => void;
signOut: () => void;
toggleProfile: () => void;
};
export default function AuthProfileButton({
authenticated,
profileOpen,
setProfileOpen,
profileRef,
signIn,
signOut,
toggleProfile,
}: Props) {
const t = useTranslations('auth');
if (!authenticated) {
return (
<button
onClick={() => signIn('keycloak')}
className="px-3 py-1 rounded-md hover:opacity-80 transition-theme text-xs sm:text-sm md:text-base"
>
{t('login')}
</button>
);
}
return (
<div className="relative" ref={profileRef}>
{/* Avatar button */}
<button
onClick={toggleProfile}
className="w-9 h-9 flex items-center justify-center hover:opacity-80 transition-theme"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="var(--accent)"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className="w-7 h-7 transition-theme"
>
{/* outer circle */}
<circle cx="12" cy="12" r="10" />
{/* head */}
<circle cx="12" cy="10" r="3" />
{/* shoulders */}
<path d="M7.5 18c1.5-2.5 7.5-2.5 9 0" />
</svg>
</button>
{/* Dropdown */}
<div
ref={profileRef}
className={`
fixed bg-primary text-primary top-[75px] left-0 z-40
w-full md:w-64 md:left-auto md:right-6 md:rounded-xl md:shadow-lg
origin-top transition-all duration-100 border px-6
${profileOpen ? 'opacity-100 scale-y-100' : 'opacity-0 scale-y-0 pointer-events-none'}
`}
>
<a
href="https://auth.iceberg.black/realms/iceberg/account"
className="hover:opacity-80 transition-theme py-4 block"
>
{t('myaccount')}
</a>
<button
onClick={() => {
signOut();
setProfileOpen(false);
}}
className="w-full text-left hover:opacity-80 transition-theme pb-4"
>
{t('logout')}
</button>
</div>
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import Link from 'next/link';
export default function ErrorPage({
title,
description,
linkLabel,
}: {
title: string;
description: string;
linkLabel: string;
}) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-center p-8 bg-primary transition-theme">
<h1 className="text-6xl md:text-8xl font-bold text-accent transition-theme">
{title}
</h1>
<p className="mt-4 text-secondary transition-theme">{description}</p>
<Link
href="/"
className="mt-6 text-accent hover:opacity-70 underline underline-offset-4 transition-theme"
>
{linkLabel}
</Link>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
export default function Footer() {
const year = new Date().getFullYear();
return (
<footer className="bg-primary border-t border-border py-6 transition-theme">
<div className="max-w-6xl mx-auto px-6 flex flex-col sm:flex-row justify-between items-center gap-3 transition-theme">
<div className="flex items-center gap-1 font-bold text-accent text-sm transition-theme">
<span className="bg-logo-light text-logo-dark px-1.5 py-0.5 rounded-l-md transition-theme">
iceberg.
</span>
<span className="bg-logo-dark text-logo-light px-1.5 py-0.5 rounded-r-md transition-theme">
black
</span>
</div>
<p className="text-secondary text-sm text-center transition-theme">
© {year} Iceberg Server
</p>
</div>
</footer>
);
}
+37
View File
@@ -0,0 +1,37 @@
type Props = {
open: boolean;
burgerRef: React.RefObject<HTMLButtonElement | null>;
toggleBurger: () => void;
};
export default function HamburgerButton({
open,
toggleBurger,
burgerRef,
}: Props) {
return (
<button
ref={burgerRef}
onClick={toggleBurger}
className="flex flex-col justify-center items-center w-10 h-10 hover:opacity-80"
>
<span
className={`w-6 h-[2px] bg-current block transition-transform duration-300 ${
open ? 'rotate-45 translate-y-[6px]' : ''
}`}
/>
<span
className={`w-6 h-[2px] bg-current block my-1 transition-all duration-300 ${
open ? 'opacity-0 scale-x-0' : ''
}`}
/>
<span
className={`w-6 h-[2px] bg-current block transition-transform duration-300 ${
open ? '-rotate-45 -translate-y-[6px]' : ''
}`}
/>
</button>
);
}
+41
View File
@@ -0,0 +1,41 @@
'use client';
import { useState } from 'react';
import { useLocale } from 'next-intl';
import { useRouter, usePathname } from '@/i18n/navigation';
import { locales } from '@/i18n/routing';
export function LocaleSwitcher() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const [currentLocale, setCurrentLocale] = useState(locale);
function switchLocale(next: string) {
setCurrentLocale(next);
router.replace(pathname, { locale: next });
}
return (
<div className="flex items-center gap-2 w-full py-2 hover:opacity-80">
<img
src={`/flags/${currentLocale}.svg`}
alt={currentLocale}
className="w-5 h-5 rounded-sm object-cover"
/>
<select
value={currentLocale}
onChange={(e) => switchLocale(e.target.value)}
className="bg-transparent text-current cursor-pointer outline-none w-full"
>
{locales.map(({ code, label }) => (
<option value={code} key={code}>
{label}
</option>
))}
</select>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import Image from 'next/image';
export default function Logo() {
return (
<div className="flex items-center gap-1 sm:gap-2 font-bold text-accent text-xs sm:text-base md:text-lg">
<Image
src="/logo/logo-nobg.png"
alt="Iceberg Logo"
width={36}
height={36}
className="h-6 sm:h-8 md:h-9 w-auto flex-shrink-0"
priority
/>
<span className="bg-logo-light text-logo-dark px-1.5 sm:px-2 py-0.5 sm:py-1 rounded-l-md transition-theme">
iceberg.
</span>
<span className="bg-logo-dark text-logo-light px-1.5 sm:px-2 py-0.5 sm:py-1 rounded-r-md transition-theme">
black
</span>
</div>
);
}
+159
View File
@@ -0,0 +1,159 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { useSession, signIn, signOut } from 'next-auth/react';
import { LocaleSwitcher } from '@/components/layout/LocaleSwitcher';
import { ThemeSwitcher } from '@/components/layout/ThemeSwitcher';
import HamburgerButton from '@/components/layout/HamburgerButton';
import AuthProfileButton from '@/components/layout/AuthProfileButton';
import ScrollProgressBar from '@/components/layout/ScrollProgressBar';
import Logo from '@/components/layout/Logo';
export default function Navbar() {
const t = useTranslations('nav');
const [open, setOpen] = useState(false);
const [profileOpen, setProfileOpen] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
const burgerRef = useRef<HTMLButtonElement | null>(null);
const profileRef = useRef<HTMLDivElement | null>(null);
const { data: session, status } = useSession();
const authenticated =
status === 'authenticated' && session?.error !== 'RefreshAccessTokenError';
// const username = session?.user?.name || session?.user?.email;
// const roles = (session as any)?.roles || [];
const links = [
{ to: '/', label: t('label1') },
{ to: '/example', label: t('label2') },
{ to: '/3', label: t('label3') },
];
useEffect(() => {
if (session?.error === 'RefreshAccessTokenError') {
signOut({ callbackUrl: '/' });
}
}, [session]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
const clickedBurger = burgerRef.current?.contains(e.target as Node);
const clickedProfile = profileRef.current?.contains(e.target as Node);
const clickedMenu = menuRef.current?.contains(e.target as Node);
const clickedInsideUI = clickedBurger || clickedProfile || clickedMenu;
if (!clickedInsideUI) {
setOpen(false);
setProfileOpen(false);
}
};
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}, [open, profileOpen]);
const toggleProfile = () => {
setProfileOpen((v) => {
const next = !v;
if (next) setOpen(false);
return next;
});
};
const toggleBurger = () => {
setOpen((v) => {
const next = !v;
if (next) setProfileOpen(false);
return next;
});
};
return (
<>
<nav className="fixed top-0 w-full z-50 bg-primary text-primary transition-theme">
<div className="w-full flex justify-between items-center px-4 sm:px-6 py-4 transition-theme">
{/* Logo */}
<Link
href="/"
className="flex items-center gap-1 sm:gap-2 font-bold text-accent text-sm sm:text-base md:text-lg"
>
<Logo />
</Link>
{/* Desktop Links */}
<ul className="hidden md:flex gap-8 transition-theme">
{links.map((link) => (
<li key={link.to}>
<Link href={link.to} className="hover:opacity-80">
{link.label}
</Link>
</li>
))}
</ul>
{/* Right actions */}
<div className="flex items-center">
<AuthProfileButton
authenticated={authenticated}
profileOpen={profileOpen}
setProfileOpen={setProfileOpen}
profileRef={profileRef}
signIn={signIn}
signOut={signOut}
toggleProfile={toggleProfile}
/>
{/* Hamburger */}
<HamburgerButton
open={open}
toggleBurger={toggleBurger}
burgerRef={burgerRef}
/>
</div>
</div>
<ScrollProgressBar />
</nav>
{/* Dropdown menu */}
<div
ref={menuRef}
className={`
fixed top-[75px] left-0 z-40
w-full md:w-64 md:left-auto md:right-6
origin-top transition-all duration-100
${open ? 'opacity-100 scale-y-100' : 'opacity-0 scale-y-0 pointer-events-none'}
`}
>
<div className="overflow-hidden bg-primary border border-border md:rounded-xl md:shadow-lg transition-theme">
<ul className="flex flex-col px-6 py-4 gap-4 transition-theme">
{links.map((link) => (
<li key={link.to}>
<Link
className="hover:text-accent hover:opacity-80"
href={link.to}
onClick={() => setOpen(false)}
>
{link.label}
</Link>
</li>
))}
{/* Theme + Language switch */}
<li className="pt-2 border-t border-border">
<LocaleSwitcher />
<ThemeSwitcher />
</li>
</ul>
</div>
</div>
</>
);
}
@@ -0,0 +1,40 @@
'use client';
import { useEffect, useState } from 'react';
export default function ScrollProgressBar() {
const [scroll, setScroll] = useState(0);
useEffect(() => {
const handleScroll = () => {
const totalHeight =
document.documentElement.scrollHeight -
document.documentElement.clientHeight;
if (totalHeight <= 0) {
setScroll(100);
return;
}
const percent = (window.scrollY / totalHeight) * 100;
setScroll(percent);
};
handleScroll(); // initial run
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return (
<div className="top-0 left-0 w-full h-[3px] z-10">
<div
className="h-full transition-[width] duration-100 ease-out"
style={{
width: `${scroll}%`,
background: 'linear-gradient(to right, var(--accent), #7b2ff7)',
}}
/>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
'use client';
import { useTheme } from '@/hooks/useTheme';
import { useTranslations } from 'next-intl';
export function ThemeSwitcher() {
const { dark, toggleTheme } = useTheme();
const t = useTranslations('theme');
return (
<button
onClick={toggleTheme}
className="flex items-center gap-2 w-full py-2 hover:opacity-80 cursor-pointer hover:text-accent"
>
<span className="text-lg">{dark ? '🌙' : '☀️'}</span>
<span>{dark ? t('dark') : t('light')}</span>
</button>
);
}