Initial commit
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
import ErrorPage from '@/components/layout/ErrorPage';
|
||||
|
||||
export default function Forbidden() {
|
||||
const t = useTranslations('forbidden');
|
||||
|
||||
return (
|
||||
<ErrorPage
|
||||
title={t('title')}
|
||||
description={t('description')}
|
||||
linkLabel={t('link')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export default function CatchAllPage() {
|
||||
notFound();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1>About</h1>
|
||||
<p>This is an example</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import '../../globals.css';
|
||||
import Navbar from '@/components/layout/Navbar';
|
||||
import Footer from '@/components/layout/Footer';
|
||||
import { hasLocale } from 'next-intl';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { routing } from '@/i18n/routing';
|
||||
import { getMessages } from 'next-intl/server';
|
||||
import { Providers } from '../providers';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Iceberg template',
|
||||
description: 'Template for Iceberg websites',
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/icons/favicon-32x32.png', sizes: '32x32' },
|
||||
{ url: '/icons/favicon-16x16.png', sizes: '16x16' },
|
||||
],
|
||||
apple: '/icons/apple-touch-icon.png',
|
||||
},
|
||||
manifest: '/manifest.json',
|
||||
};
|
||||
|
||||
export default async function RootLayout(props: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await props.params;
|
||||
|
||||
const theme = (await cookies()).get('theme')?.value ?? 'light';
|
||||
|
||||
if (!hasLocale(routing.locales, locale)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const messages = await getMessages();
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
return (
|
||||
<html lang={locale} className={theme === 'dark' ? 'dark' : 'light-mode'}>
|
||||
<body className="min-h-screen flex flex-col bg-primary text-primary transition-theme">
|
||||
<Providers locale={locale} messages={messages} session={session}>
|
||||
<Navbar />
|
||||
|
||||
<main className="flex-1 pt-20 flex flex-col">{props.children}</main>
|
||||
|
||||
<Footer />
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
import ErrorPage from '@/components/layout/ErrorPage';
|
||||
|
||||
export default function NotFoundPage() {
|
||||
const t = useTranslations('notFound');
|
||||
|
||||
return (
|
||||
<ErrorPage
|
||||
title={t('title')}
|
||||
description={t('description')}
|
||||
linkLabel={t('link')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export default function Home() {
|
||||
const t = useTranslations('home');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center">
|
||||
<h1 className="text-5xl font-bold text-cyan-400">{t('name')}</h1>
|
||||
|
||||
<p className="mt-4 text-gray-400">{t('welcome')}</p>
|
||||
|
||||
<button className="mt-6 px-6 py-3 bg-cyan-500 hover:bg-cyan-600 rounded-xl">
|
||||
{t('start')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import NextAuth from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
import { Session } from 'next-auth';
|
||||
import { NextIntlClientProvider, AbstractIntlMessages } from 'next-intl';
|
||||
|
||||
export function Providers({
|
||||
children,
|
||||
session,
|
||||
locale,
|
||||
messages,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
session?: Session | null;
|
||||
locale: string;
|
||||
messages: AbstractIntlMessages;
|
||||
}) {
|
||||
return (
|
||||
<SessionProvider session={session}>
|
||||
<NextIntlClientProvider
|
||||
locale={locale}
|
||||
messages={messages}
|
||||
timeZone="Europe/Paris"
|
||||
>
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* =========================
|
||||
GLOBAL BASE STYLES
|
||||
========================= */
|
||||
|
||||
html,
|
||||
body {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: theme('fontFamily.sans');
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
THEME VARIABLES (DARK DEFAULT)
|
||||
========================= */
|
||||
|
||||
:root {
|
||||
--bg-primary: #000000;
|
||||
|
||||
--text-primary: #ffffff;
|
||||
|
||||
--accent: #00d4ff;
|
||||
|
||||
--color-base: #000000;
|
||||
--color-inverse: #ffffff;
|
||||
|
||||
--transition-fast: 200ms ease-in-out;
|
||||
--transition-normal: 300ms ease-in-out;
|
||||
--transition-slow: 500ms ease-in-out;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
LIGHT MODE OVERRIDE
|
||||
========================= */
|
||||
|
||||
.light-mode {
|
||||
--bg-primary: #e0e0e0;
|
||||
|
||||
--text-primary: #111111;
|
||||
|
||||
--accent: #0077ff;
|
||||
|
||||
--color-base: #ffffff;
|
||||
--color-inverse: #000000;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
UTILITY CLASSES (USE THESE INSTEAD OF bg-black etc)
|
||||
========================= */
|
||||
|
||||
.bg-primary {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.text-accent {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* =========================
|
||||
TRANSITIONS
|
||||
========================= */
|
||||
|
||||
@layer utilities {
|
||||
.transition-theme {
|
||||
transition:
|
||||
color 500ms ease,
|
||||
background-color 500ms ease,
|
||||
border-color 500ms ease;
|
||||
}
|
||||
}
|
||||
|
||||
/* =========================
|
||||
LOGO
|
||||
========================= */
|
||||
|
||||
.bg-logo-light {
|
||||
background: var(--color-base);
|
||||
}
|
||||
|
||||
.bg-logo-dark {
|
||||
background: var(--color-inverse);
|
||||
}
|
||||
|
||||
.text-logo-light {
|
||||
color: var(--color-base);
|
||||
}
|
||||
|
||||
.text-logo-dark {
|
||||
color: var(--color-inverse);
|
||||
}
|
||||
|
||||
/* =========================
|
||||
SCROLLBAR (OPTIONAL NICE TOUCH)
|
||||
========================= */
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--accent);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #0099cc;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
function getCookie(name: string) {
|
||||
if (typeof document === 'undefined') return null;
|
||||
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
|
||||
if (parts.length === 2) return parts.pop()?.split(';').shift();
|
||||
return null;
|
||||
}
|
||||
|
||||
function setCookie(value: Theme) {
|
||||
document.cookie = `theme=${value}; path=/; max-age=31536000`;
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useState<Theme>('dark');
|
||||
|
||||
useEffect(() => {
|
||||
const cookieTheme = getCookie('theme') as Theme | null;
|
||||
const rootTheme = document.documentElement.classList.contains('dark')
|
||||
? 'dark'
|
||||
: 'light';
|
||||
|
||||
const initial = cookieTheme || rootTheme || 'dark';
|
||||
|
||||
setTheme(initial);
|
||||
|
||||
document.documentElement.classList.toggle('dark', initial === 'dark');
|
||||
document.documentElement.classList.toggle(
|
||||
'light-mode',
|
||||
initial === 'light',
|
||||
);
|
||||
}, []);
|
||||
|
||||
function applyTheme(t: Theme) {
|
||||
document.documentElement.classList.toggle('dark', t === 'dark');
|
||||
document.documentElement.classList.toggle('light-mode', t === 'light');
|
||||
|
||||
setCookie(t);
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
setTheme((prev) => {
|
||||
const next = prev === 'dark' ? 'light' : 'dark';
|
||||
applyTheme(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
theme,
|
||||
dark: theme === 'dark',
|
||||
toggleTheme,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createNavigation } from 'next-intl/navigation';
|
||||
import { routing } from './routing';
|
||||
|
||||
export const { Link, redirect, usePathname, useRouter, getPathname } =
|
||||
createNavigation(routing);
|
||||
@@ -0,0 +1,15 @@
|
||||
import { getRequestConfig } from 'next-intl/server';
|
||||
import { hasLocale } from 'next-intl';
|
||||
import { routing } from './routing';
|
||||
|
||||
export default getRequestConfig(async ({ requestLocale }) => {
|
||||
const requested = await requestLocale;
|
||||
const locale = hasLocale(routing.locales, requested)
|
||||
? requested
|
||||
: routing.defaultLocale;
|
||||
|
||||
return {
|
||||
locale,
|
||||
messages: (await import(`../../messages/${locale}.json`)).default,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineRouting } from 'next-intl/routing';
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales: ['en', 'fr'],
|
||||
defaultLocale: 'en',
|
||||
});
|
||||
|
||||
const localeLabels = {
|
||||
en: 'English',
|
||||
fr: 'Français',
|
||||
} as const;
|
||||
|
||||
export const locales = routing.locales.map((code) => ({
|
||||
code,
|
||||
label: localeLabels[code],
|
||||
}));
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
import { NextAuthOptions } from 'next-auth';
|
||||
import KeycloakProvider from 'next-auth/providers/keycloak';
|
||||
import { JWT } from 'next-auth/jwt';
|
||||
|
||||
declare module 'next-auth' {
|
||||
interface Session {
|
||||
accessToken?: string;
|
||||
roles?: string[];
|
||||
error?: string;
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'next-auth/jwt' {
|
||||
interface JWT {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
accessTokenExpires?: number;
|
||||
error?: string;
|
||||
roles?: string[];
|
||||
}
|
||||
}
|
||||
|
||||
function decode(token: string) {
|
||||
try {
|
||||
const base64 = token.split('.')[1];
|
||||
|
||||
return JSON.parse(Buffer.from(base64, 'base64').toString('utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAccessToken(token: JWT): Promise<JWT> {
|
||||
try {
|
||||
if (!token.refreshToken) {
|
||||
throw new Error('No refresh token');
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: process.env.KEYCLOAK_CLIENT_ID!,
|
||||
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: token.refreshToken,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Refresh failed');
|
||||
}
|
||||
|
||||
const refreshed = await response.json();
|
||||
|
||||
const decoded = decode(refreshed.access_token);
|
||||
|
||||
return {
|
||||
...token,
|
||||
accessToken: refreshed.access_token,
|
||||
refreshToken: refreshed.refresh_token ?? token.refreshToken,
|
||||
accessTokenExpires: decoded?.exp
|
||||
? decoded.exp * 1000
|
||||
: Date.now() + 5 * 60 * 1000,
|
||||
error: undefined,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
...token,
|
||||
error: 'RefreshAccessTokenError',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
providers: [
|
||||
KeycloakProvider({
|
||||
clientId: process.env.KEYCLOAK_CLIENT_ID!,
|
||||
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
|
||||
issuer: process.env.KEYCLOAK_ISSUER!,
|
||||
}),
|
||||
],
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
callbacks: {
|
||||
async jwt({ token, account }) {
|
||||
if (token.error === 'RefreshAccessTokenError') return token;
|
||||
// initial login
|
||||
if (account) {
|
||||
const decoded = decode(account.access_token!);
|
||||
|
||||
const clientId = process.env.KEYCLOAK_CLIENT_ID!;
|
||||
|
||||
const realmRoles = decoded?.realm_access?.roles ?? [];
|
||||
const clientRoles = decoded?.resource_access?.[clientId]?.roles ?? [];
|
||||
|
||||
token.roles = [...new Set([...realmRoles, ...clientRoles])];
|
||||
|
||||
token.accessToken = account.access_token;
|
||||
token.refreshToken = account.refresh_token;
|
||||
token.accessTokenExpires = decoded?.exp
|
||||
? decoded.exp * 1000
|
||||
: Date.now() + 5 * 60 * 1000;
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
// stop loop if refresh already failed
|
||||
if (
|
||||
token.accessTokenExpires &&
|
||||
Date.now() > token.accessTokenExpires - 60_000
|
||||
) {
|
||||
return { ...token, error: 'RefreshAccessTokenError' };
|
||||
}
|
||||
|
||||
// still valid
|
||||
if (token.accessTokenExpires && Date.now() < token.accessTokenExpires) {
|
||||
return token;
|
||||
}
|
||||
|
||||
// refresh expired token
|
||||
return await refreshAccessToken(token);
|
||||
},
|
||||
|
||||
async session({ session, token }) {
|
||||
session.accessToken = token.accessToken;
|
||||
session.error = token.error;
|
||||
|
||||
if (!token.accessToken) {
|
||||
session.roles = [];
|
||||
return session;
|
||||
}
|
||||
|
||||
const payload = decode(token.accessToken);
|
||||
const clientId = process.env.KEYCLOAK_CLIENT_ID!;
|
||||
|
||||
const realmRoles = payload?.realm_access?.roles ?? [];
|
||||
const clientRoles = payload?.resource_access?.[clientId]?.roles ?? [];
|
||||
|
||||
session.roles = [...new Set([...realmRoles, ...clientRoles])];
|
||||
|
||||
return session;
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import createIntlMiddleware from 'next-intl/middleware';
|
||||
import { routing } from './i18n/routing';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getToken } from 'next-auth/jwt';
|
||||
|
||||
const protectedRoutes: Record<string, string[]> = {
|
||||
'/example': ['ad'],
|
||||
'/dashboard': [],
|
||||
};
|
||||
|
||||
const intlMiddleware = createIntlMiddleware(routing);
|
||||
|
||||
export default async function middleware(req: NextRequest) {
|
||||
const pathname = req.nextUrl.pathname;
|
||||
const segments = pathname.split('/');
|
||||
const locale = segments[1];
|
||||
const pathWithoutLocale = '/' + segments.slice(2).join('/');
|
||||
|
||||
const matchedRoute = Object.keys(protectedRoutes).find((route) => {
|
||||
return (
|
||||
pathWithoutLocale === route || pathWithoutLocale.startsWith(route + '/')
|
||||
);
|
||||
});
|
||||
|
||||
// Short-circuit: skip token fetch entirely for public routes
|
||||
if (!matchedRoute) {
|
||||
return intlMiddleware(req);
|
||||
}
|
||||
|
||||
const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET });
|
||||
|
||||
if (!token) {
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = '/api/auth/signin';
|
||||
url.searchParams.set('callbackUrl', pathname);
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
const requiredRoles = protectedRoutes[matchedRoute];
|
||||
if (requiredRoles.length > 0) {
|
||||
const userRoles = token.roles ?? [];
|
||||
const hasRole = requiredRoles.some((role) => userRoles.includes(role));
|
||||
if (!hasRole) {
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = `/${locale}/403`;
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
}
|
||||
|
||||
return intlMiddleware(req);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!api|trpc|_next|_vercel|.*\\..*).*)'],
|
||||
};
|
||||
Reference in New Issue
Block a user