Compare commits
59 Commits
main
...
developmen
Author | SHA1 | Date | |
---|---|---|---|
90fd37fde1 | |||
0cc0d18558 | |||
52274f204c | |||
4606be56a0 | |||
a23b58e05c | |||
1a3bc63d2e | |||
cf9fd767f3 | |||
1e9d5c2742 | |||
2f304684f3 | |||
bb751c1476 | |||
c100c71fea | |||
3bb97abdf2 | |||
919630524b | |||
3ac29ae909 | |||
1c897648f7 | |||
5bfc969f6a | |||
b1f59249c0 | |||
b467ae704c | |||
fce459679c | |||
845381e84d | |||
b3c6ae2460 | |||
de8742437f | |||
dc858227fa | |||
9f2ae08f1a | |||
1ff5126e98 | |||
bf94e84c65 | |||
1684ac2743 | |||
da74d1bf82 | |||
16a191341b | |||
946bc68946 | |||
96a0c5c4b0 | |||
ff82486134 | |||
434414d99d | |||
d45f79a3d9 | |||
f0b498ca18 | |||
26e9462dac | |||
a9d4fbedcf | |||
ddd333e64a | |||
38fa50e0c8 | |||
096a10b9c1 | |||
e6f84ad95e | |||
b81a058a1c | |||
f54a8ccc0a | |||
85deb66a54 | |||
f9aef69cfa | |||
37a34dc004 | |||
c325305442 | |||
65cc556080 | |||
482b43eaf4 | |||
52ba69107e | |||
c45579221b | |||
e6d8a9ae9e | |||
b4d054de36 | |||
bc8216fbe2 | |||
|
c54d6b2ea8 | ||
|
40e002e998 | ||
|
bc880295c4 | ||
|
52fc2b5e07 | ||
|
76d05af0bb |
@ -9,4 +9,5 @@ OAUTH_ISSUER=
|
|||||||
OAUTH_AUTHORIZATION_URL=
|
OAUTH_AUTHORIZATION_URL=
|
||||||
OAUTH_TOKEN_URL=
|
OAUTH_TOKEN_URL=
|
||||||
OAUTH_USERINFO_URL=
|
OAUTH_USERINFO_URL=
|
||||||
OAUTH_JWKS_ENDPOINT=
|
OAUTH_JWKS_ENDPOINT=
|
||||||
|
OAUTH_SCOPES="openid email profile"
|
5
.prettierrc
Normal file
5
.prettierrc
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 4,
|
||||||
|
"useTabs": true,
|
||||||
|
"semi": true
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
# syntax=docker.io/docker/dockerfile:1
|
# syntax=docker.io/docker/dockerfile:1
|
||||||
|
|
||||||
FROM node:18-alpine AS base
|
FROM node:20-alpine AS base
|
||||||
|
|
||||||
# Install dependencies only when needed
|
# Install dependencies only when needed
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
|
@ -1,10 +1,5 @@
|
|||||||
services:
|
services:
|
||||||
webapp:
|
webapp:
|
||||||
image: toogether/webapp
|
image: toogether/webapp
|
||||||
ports:
|
ports:
|
||||||
- "80:3000"
|
- "80:3000"
|
||||||
build:
|
|
||||||
context: ./
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
environment:
|
|
||||||
- NEXT_PUBLIC_API_URL=http://api:3000
|
|
||||||
|
23
package.json
23
package.json
@ -1,21 +1,33 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "@toogether/webapp",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev -p 4000",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"build:docker": "docker build -t toogether/webapp .",
|
||||||
"lint": "next lint"
|
"start": "next start -p 4000",
|
||||||
|
"start:prod": "docker compose up --force-recreate -d",
|
||||||
|
"lint": "next lint",
|
||||||
|
"format": "prettier --write ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@internationalized/date": "^3.6.0",
|
||||||
|
"@nextui-org/navbar": "^2.2.7",
|
||||||
|
"@nextui-org/react": "^2.6.10",
|
||||||
|
"@nextui-org/system": "^2.4.5",
|
||||||
|
"@nextui-org/theme": "^2.4.4",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
|
"axios-hooks": "^5.1.0",
|
||||||
|
"framer-motion": "^11.15.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"moment": "^2.30.1",
|
"moment-timezone": "^0.5.46",
|
||||||
"next": "15.0.3",
|
"next": "15.0.3",
|
||||||
"next-auth": "^4.24.10",
|
"next-auth": "^4.24.10",
|
||||||
|
"next-themes": "^0.4.4",
|
||||||
"react": "19.0.0-rc-66855b96-20241106",
|
"react": "19.0.0-rc-66855b96-20241106",
|
||||||
"react-dom": "19.0.0-rc-66855b96-20241106",
|
"react-dom": "19.0.0-rc-66855b96-20241106",
|
||||||
|
"react-icons": "^5.4.0",
|
||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -26,6 +38,7 @@
|
|||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "15.0.3",
|
"eslint-config-next": "15.0.3",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
|
10
src/app/admin/classes/page.tsx
Normal file
10
src/app/admin/classes/page.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { ClassList } from "@/app/components/Class";
|
||||||
|
import { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Classes | Toogether",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function Page() {
|
||||||
|
return <ClassList />;
|
||||||
|
}
|
50
src/app/admin/layout.tsx
Normal file
50
src/app/admin/layout.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
"use client";
|
||||||
|
import { Sidebar } from "@/app/components/Sidebar";
|
||||||
|
import { Divider } from "@nextui-org/react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { FaDoorClosed, FaRegUser } from "react-icons/fa";
|
||||||
|
import { FiSettings } from "react-icons/fi";
|
||||||
|
import { IoMdStats } from "react-icons/io";
|
||||||
|
import { MdInbox } from "react-icons/md";
|
||||||
|
import { SidebarItemProps } from "../components/Sidebar/item";
|
||||||
|
|
||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathName = usePathname();
|
||||||
|
|
||||||
|
const sidebarItems = [
|
||||||
|
[
|
||||||
|
{ href: "/admin", title: "Dashboard", icon: <IoMdStats /> },
|
||||||
|
{ href: "/admin/users", title: "Users", icon: <FaRegUser /> },
|
||||||
|
{ href: "/admin/classes", title: "Classes", icon: <MdInbox /> },
|
||||||
|
{ href: "/admin/rooms", title: "Rooms", icon: <FaDoorClosed /> },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
href: "/admin/settings",
|
||||||
|
title: "Settings",
|
||||||
|
icon: <FiSettings />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
] as SidebarItemProps[][];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex">
|
||||||
|
<Sidebar items={sidebarItems} />
|
||||||
|
<main className="p-7 w-full">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-2xl font-semibold">
|
||||||
|
{
|
||||||
|
sidebarItems
|
||||||
|
.flat()
|
||||||
|
.find((item) => item.href === pathName)?.title
|
||||||
|
}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider className="mt-2 mb-5 bg-foreground-300" />
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
11
src/app/admin/page.tsx
Normal file
11
src/app/admin/page.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Admin | Toogether",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function Page() {
|
||||||
|
return (
|
||||||
|
<p>Hello world!</p>
|
||||||
|
);
|
||||||
|
}
|
11
src/app/admin/rooms/page.tsx
Normal file
11
src/app/admin/rooms/page.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Rooms | Toogether",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function Page() {
|
||||||
|
return (
|
||||||
|
<p>Hello world!</p>
|
||||||
|
);
|
||||||
|
}
|
11
src/app/admin/settings/page.tsx
Normal file
11
src/app/admin/settings/page.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Settings | Toogether",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function Page() {
|
||||||
|
return (
|
||||||
|
<p>Hello world!</p>
|
||||||
|
);
|
||||||
|
}
|
10
src/app/admin/users/page.tsx
Normal file
10
src/app/admin/users/page.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { UserList } from "@/app/components/Users";
|
||||||
|
import { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Users | Toogether",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function Page() {
|
||||||
|
return <UserList />;
|
||||||
|
}
|
@ -1,5 +1,5 @@
|
|||||||
import NextAuth from "next-auth";
|
import NextAuth from "next-auth";
|
||||||
import { authOptions } from "@/authOptions";
|
import { authOptions } from "@/authOptions";
|
||||||
|
|
||||||
const handler = NextAuth(authOptions);
|
const handler = NextAuth(authOptions);
|
||||||
export { handler as GET, handler as POST };
|
export { handler as GET, handler as POST };
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
"use client";
|
import { Metadata } from "next";
|
||||||
import { authOptions } from "@/authOptions";
|
import { ProvidersList } from "./providersList";
|
||||||
import { signIn } from "next-auth/react";
|
|
||||||
|
|
||||||
const LoginPage = () => {
|
export const metadata: Metadata = {
|
||||||
const provider = authOptions.providers[0];
|
title: "Toogether | Connexion",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen items-center justify-center bg-gradient-to-br from-green-700 to-green-950 bg-[length:200%_200%] animate-gradient-x text-white">
|
<div className="flex flex-col h-screen items-center justify-center bg-gradient-to-br from-green-700 to-green-950 bg-[length:200%_200%] animate-gradient-x text-white">
|
||||||
<div className="flex flex-col justify-center items-center gap-6 bg-black bg-opacity-40 rounded-md p-5 mx-5 w-2/3">
|
<div className="flex flex-col justify-center items-center gap-6 bg-black bg-opacity-40 rounded-md p-5 mx-5 w-2/3">
|
||||||
@ -25,19 +26,8 @@ const LoginPage = () => {
|
|||||||
<div className="w-full border-t border-white"></div>
|
<div className="w-full border-t border-white"></div>
|
||||||
|
|
||||||
<h3 className="font-bold text-xl">Via</h3>
|
<h3 className="font-bold text-xl">Via</h3>
|
||||||
<ul>
|
<ProvidersList />
|
||||||
<li key={provider.id}>
|
|
||||||
<button
|
|
||||||
className="bg-white text-black p-2 rounded-md"
|
|
||||||
onClick={() => signIn(provider.id)}
|
|
||||||
>
|
|
||||||
{provider.name}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default LoginPage;
|
|
||||||
|
22
src/app/auth/login/providersList.tsx
Normal file
22
src/app/auth/login/providersList.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { authOptions } from "@/authOptions";
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
|
|
||||||
|
export const ProvidersList = () => {
|
||||||
|
const providers = authOptions.providers;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{providers.map((provider) => (
|
||||||
|
<button
|
||||||
|
key={provider.id}
|
||||||
|
className="bg-white text-black p-2 rounded-md"
|
||||||
|
onClick={() => signIn(provider.id)}
|
||||||
|
>
|
||||||
|
{provider.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -1,18 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { signOut, useSession } from "next-auth/react";
|
import { signOut, useSession } from "next-auth/react";
|
||||||
|
|
||||||
const LogoutPage = () => {
|
const LogoutPage = () => {
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (session) {
|
if (session) {
|
||||||
signOut();
|
signOut();
|
||||||
}
|
}
|
||||||
}, [session]);
|
}, [session]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default LogoutPage;
|
export default LogoutPage;
|
||||||
|
24
src/app/components/AppWrapper.tsx
Normal file
24
src/app/components/AppWrapper.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { NextUIProvider } from "@nextui-org/react";
|
||||||
|
import { Session } from "next-auth";
|
||||||
|
import { SessionProvider } from "next-auth/react";
|
||||||
|
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||||
|
|
||||||
|
export default function AppWrapper({
|
||||||
|
session,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
session: Session | null;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SessionProvider session={session} refetchOnWindowFocus={false}>
|
||||||
|
<NextUIProvider>
|
||||||
|
<NextThemesProvider attribute="class" defaultTheme="dark">
|
||||||
|
{children}
|
||||||
|
</NextThemesProvider>
|
||||||
|
</NextUIProvider>
|
||||||
|
</SessionProvider>
|
||||||
|
);
|
||||||
|
}
|
71
src/app/components/Class/index.tsx
Normal file
71
src/app/components/Class/index.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
"use client";
|
||||||
|
import { Class } from "@/app/interface/class";
|
||||||
|
import { classesService } from "@/app/services/classes.service";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Skeleton,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableColumn,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@nextui-org/react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export const ClassList = () => {
|
||||||
|
const [classes, setClasses] = useState<Class[]>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
classesService.getAll().then((response) => {
|
||||||
|
setClasses(response.data);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!classes) {
|
||||||
|
return (
|
||||||
|
<Card className="w-full space-y-5 p-4" radius="lg">
|
||||||
|
<Skeleton className="rounded-lg">
|
||||||
|
<div className="h-10 rounded-lg bg-default-300" />
|
||||||
|
</Skeleton>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Skeleton className="w-3/5 rounded-lg">
|
||||||
|
<div className="h-3 w-3/5 rounded-lg bg-default-200" />
|
||||||
|
</Skeleton>
|
||||||
|
<Skeleton className="w-4/5 rounded-lg">
|
||||||
|
<div className="h-3 w-4/5 rounded-lg bg-default-200" />
|
||||||
|
</Skeleton>
|
||||||
|
<Skeleton className="w-2/5 rounded-lg">
|
||||||
|
<div className="h-3 w-2/5 rounded-lg bg-default-300" />
|
||||||
|
</Skeleton>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (classes.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="w-full p-4" radius="lg">
|
||||||
|
<p className="text-center text-lg text-gray-500 dark:text-gray-400">
|
||||||
|
No classes found
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table aria-label="List of users" className="w-full">
|
||||||
|
<TableHeader>
|
||||||
|
<TableColumn>NAME</TableColumn>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{classes.map((Class) => (
|
||||||
|
<TableRow key={Class.id.toString()}>
|
||||||
|
<TableCell>{Class.name}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
};
|
@ -1,29 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { axiosInstance } from "@/app/lib/axios";
|
|
||||||
import { useRef } from "react";
|
|
||||||
|
|
||||||
export const FetchApi = () => {
|
|
||||||
const listRef = useRef<HTMLUListElement>(null);
|
|
||||||
|
|
||||||
const handleFetch = async () => {
|
|
||||||
const response = await axiosInstance.get<{
|
|
||||||
message: string;
|
|
||||||
}>("/ping");
|
|
||||||
|
|
||||||
const li = document.createElement("li");
|
|
||||||
li.textContent = response.data.message;
|
|
||||||
listRef.current?.appendChild(li);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
className="text-white px-2 py-2 bg-green-500 rounded-md"
|
|
||||||
onClick={handleFetch}
|
|
||||||
>
|
|
||||||
Fetch API
|
|
||||||
</button>
|
|
||||||
<ul ref={listRef}></ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,28 +0,0 @@
|
|||||||
import { getServerSession, Session } from "next-auth";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
const Header = async () => {
|
|
||||||
const session = (await getServerSession()) as Session;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header className="bg-gray-900 text-white p-4">
|
|
||||||
<div className="container mx-auto flex justify-between items-center">
|
|
||||||
<h1 className="text-xl font-bold">Toogether</h1>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<p>
|
|
||||||
Logged in as <strong>{session.user.name}</strong>
|
|
||||||
</p>
|
|
||||||
<nav className="space-x-4">
|
|
||||||
<Link href="/auth/logout">
|
|
||||||
<button className="bg-gray-800 px-4 py-2 rounded hover:scale-105 transition duration-200">
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</Link>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Header;
|
|
123
src/app/components/Header/contents.tsx
Normal file
123
src/app/components/Header/contents.tsx
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
"use client";
|
||||||
|
import { useClassStore } from "@/app/stores/classStore";
|
||||||
|
import { useUserStore } from "@/app/stores/userStore";
|
||||||
|
import { User } from "@/app/types/next-auth";
|
||||||
|
import { getInitials } from "@/app/utils/initial";
|
||||||
|
import {
|
||||||
|
Autocomplete,
|
||||||
|
AutocompleteItem,
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
|
Dropdown,
|
||||||
|
DropdownItem,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownTrigger,
|
||||||
|
Navbar,
|
||||||
|
NavbarBrand,
|
||||||
|
NavbarContent,
|
||||||
|
NavbarItem,
|
||||||
|
} from "@nextui-org/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { ThemeSwitcher } from "../ThemeSwitcher/ThemeSwitcher";
|
||||||
|
|
||||||
|
export const HeaderContent = ({ user }: { user?: User }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { classes, fetchClass } = useClassStore();
|
||||||
|
const {
|
||||||
|
currentClassId: selectedClassId,
|
||||||
|
setCurrentClassId: setSelectedClassId,
|
||||||
|
} = useUserStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchClass();
|
||||||
|
}, [fetchClass]);
|
||||||
|
|
||||||
|
const selectedClass = classes.find((Class) => Class.id === selectedClassId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedClass && classes.length > 0)
|
||||||
|
setSelectedClassId(classes[0].id);
|
||||||
|
}, [selectedClass, classes, setSelectedClassId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Navbar className="mb-2">
|
||||||
|
<NavbarBrand>
|
||||||
|
<p className="font-bold text-inherit">Toogether</p>
|
||||||
|
</NavbarBrand>
|
||||||
|
<NavbarContent as="div" justify="end">
|
||||||
|
{user?.roles.includes("admin") ? (
|
||||||
|
<NavbarItem>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
className="min-w-0"
|
||||||
|
onPress={() => router.push("/admin")}
|
||||||
|
>
|
||||||
|
🔧
|
||||||
|
</Button>
|
||||||
|
</NavbarItem>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<NavbarItem>
|
||||||
|
<ThemeSwitcher />
|
||||||
|
</NavbarItem>
|
||||||
|
|
||||||
|
<Dropdown placement="bottom-end">
|
||||||
|
<DropdownTrigger>
|
||||||
|
<Avatar
|
||||||
|
isBordered
|
||||||
|
as="button"
|
||||||
|
className="transition-transform"
|
||||||
|
color="secondary"
|
||||||
|
name={user?.name ? getInitials(user.name) : ""}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</DropdownTrigger>
|
||||||
|
<DropdownMenu
|
||||||
|
aria-label="Profile Actions"
|
||||||
|
variant="flat"
|
||||||
|
closeOnSelect={false}
|
||||||
|
>
|
||||||
|
<DropdownItem key="profile" className="h-14 gap-2" showDivider>
|
||||||
|
<p>Signed in as</p>
|
||||||
|
<p className="font-semibold">{user?.name}</p>
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem key="class" className="h-14 gap-2">
|
||||||
|
<Autocomplete
|
||||||
|
size="sm"
|
||||||
|
label="Select an class"
|
||||||
|
value={selectedClass?.name}
|
||||||
|
selectedKey={selectedClass?.id}
|
||||||
|
onSelectionChange={(selectedId) => {
|
||||||
|
const inputSelectedClass = classes.find(
|
||||||
|
(Class) => Class.id === selectedId,
|
||||||
|
);
|
||||||
|
if (inputSelectedClass)
|
||||||
|
setSelectedClassId(
|
||||||
|
inputSelectedClass.id,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{classes.map((Class) => (
|
||||||
|
<AutocompleteItem key={Class.id}>
|
||||||
|
{Class.name}
|
||||||
|
</AutocompleteItem>
|
||||||
|
))}
|
||||||
|
</Autocomplete>
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem key="settings">Settings</DropdownItem>
|
||||||
|
<DropdownItem
|
||||||
|
key="logout"
|
||||||
|
color="danger"
|
||||||
|
href="/auth/logout"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</DropdownItem>
|
||||||
|
</DropdownMenu>
|
||||||
|
</Dropdown>
|
||||||
|
</NavbarContent>
|
||||||
|
</Navbar>
|
||||||
|
);
|
||||||
|
};
|
9
src/app/components/Header/index.tsx
Normal file
9
src/app/components/Header/index.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/authOptions";
|
||||||
|
import { HeaderContent } from "./contents";
|
||||||
|
|
||||||
|
export const Header = async () => {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
return <HeaderContent user={session?.user} />;
|
||||||
|
};
|
90
src/app/components/Room/Card.tsx
Normal file
90
src/app/components/Room/Card.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { parseDate, Time } from "@internationalized/date";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
CardHeader,
|
||||||
|
DateInput,
|
||||||
|
Divider,
|
||||||
|
TimeInput,
|
||||||
|
} from "@nextui-org/react";
|
||||||
|
import { Room } from "../../interface/room";
|
||||||
|
import moment from "moment";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
export const RoomCard = ({ id, name, date, Times, Presentator }: Room) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-[300px]">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-col min-h-20">
|
||||||
|
<p className="text-md">{name}</p>
|
||||||
|
<p className="text-small text-default-500">
|
||||||
|
{Presentator.username}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<Divider />
|
||||||
|
<CardBody>
|
||||||
|
<div className="flex flex-col gap-2" key={`times.${id}`}>
|
||||||
|
<DateInput
|
||||||
|
isReadOnly
|
||||||
|
label="Date"
|
||||||
|
value={parseDate(moment(date).format("YYYY-MM-DD"))}
|
||||||
|
/>
|
||||||
|
{Times.map((time) => (
|
||||||
|
<div key={`time.${time.id}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TimeInput
|
||||||
|
isReadOnly
|
||||||
|
label="Start"
|
||||||
|
hourCycle={24}
|
||||||
|
value={
|
||||||
|
new Time(
|
||||||
|
moment(time.startTime).hours(),
|
||||||
|
moment(time.startTime).minutes(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>-</span>
|
||||||
|
<TimeInput
|
||||||
|
isReadOnly
|
||||||
|
label="End"
|
||||||
|
hourCycle={24}
|
||||||
|
value={
|
||||||
|
new Time(
|
||||||
|
moment(time.endTime).hours(),
|
||||||
|
moment(time.endTime).minutes(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
{moment(date).dayOfYear() === moment().dayOfYear() && (
|
||||||
|
<div className="flex p-2">
|
||||||
|
<Button
|
||||||
|
isDisabled={moment(
|
||||||
|
moment(Times[0].startTime).subtract(5, "minutes"),
|
||||||
|
).isAfter(moment())}
|
||||||
|
color="primary"
|
||||||
|
radius="full"
|
||||||
|
size="sm"
|
||||||
|
variant={"flat"}
|
||||||
|
onPress={() => {
|
||||||
|
router.push(`/room/${id}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Join
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
87
src/app/components/Room/List.tsx
Normal file
87
src/app/components/Room/List.tsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { RoomCard } from "./Card";
|
||||||
|
import { Room } from "../../interface/room";
|
||||||
|
import { SkeletonRoomCard } from "./SkeletonRoomCard";
|
||||||
|
|
||||||
|
export const RoomList = ({ rooms }: { rooms: Room[] | null }) => {
|
||||||
|
const scrollContainerRef = useRef<HTMLUListElement>(null);
|
||||||
|
|
||||||
|
const handleWheel = (event: WheelEvent) => {
|
||||||
|
if (event.deltaY === 0) return;
|
||||||
|
if (event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||||
|
|
||||||
|
const scrollContainer = scrollContainerRef.current;
|
||||||
|
if (!scrollContainer) return;
|
||||||
|
|
||||||
|
const goLeft = event.deltaY < 0;
|
||||||
|
const isEnd = goLeft
|
||||||
|
? scrollContainer.scrollLeft === 0
|
||||||
|
: scrollContainer.scrollLeft + scrollContainer.clientWidth >=
|
||||||
|
scrollContainer.scrollWidth;
|
||||||
|
if (isEnd) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const scrollAmount = 10;
|
||||||
|
const direction = event.deltaY > 0 ? 1 : -1;
|
||||||
|
let scrollCount = 0;
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
scrollContainer.scrollLeft += scrollAmount * direction;
|
||||||
|
scrollCount += scrollAmount;
|
||||||
|
if (scrollCount >= 100) {
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const scrollContainer = scrollContainerRef.current;
|
||||||
|
if (!scrollContainer) return;
|
||||||
|
|
||||||
|
scrollContainer.addEventListener("wheel", handleWheel);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
scrollContainer.removeEventListener("wheel", handleWheel);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (rooms?.length == 0)
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 rounded-xl bg-default-100 p-4">
|
||||||
|
<p className="text-default-500 text-center w-full">
|
||||||
|
No rooms found
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
ref={scrollContainerRef}
|
||||||
|
className="flex gap-2 overflow-x-auto scrollbar-hide rounded-xl bg-default-100 p-1"
|
||||||
|
>
|
||||||
|
{rooms ? (
|
||||||
|
rooms.map((room) => (
|
||||||
|
<li key={room.id}>
|
||||||
|
<RoomCard
|
||||||
|
id={room.id}
|
||||||
|
name={room.name}
|
||||||
|
date={room.date}
|
||||||
|
Presentator={room.Presentator}
|
||||||
|
Times={room.Times}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<SkeletonRoomCard />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
30
src/app/components/Room/SkeletonRoomCard.tsx
Normal file
30
src/app/components/Room/SkeletonRoomCard.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
"use client";
|
||||||
|
import { Card, Skeleton, Divider } from "@nextui-org/react";
|
||||||
|
|
||||||
|
export const SkeletonRoomCard = () => {
|
||||||
|
return (
|
||||||
|
<Card className="w-[200px] space-y-5 p-4" radius="lg">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Skeleton className="w-4/5 rounded-lg">
|
||||||
|
<div className="h-3 w-4/5 rounded-lg bg-default-200" />
|
||||||
|
</Skeleton>
|
||||||
|
<Skeleton className="w-2/5 rounded-lg">
|
||||||
|
<div className="h-3 w-2/5 rounded-lg bg-default-300" />
|
||||||
|
</Skeleton>
|
||||||
|
</div>
|
||||||
|
<Divider />
|
||||||
|
<Skeleton className="rounded-lg">
|
||||||
|
<div className="h-12 rounded-lg bg-default-300" />
|
||||||
|
</Skeleton>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="rounded-lg w-1/2">
|
||||||
|
<div className="h-10 rounded-lg bg-default-300" />
|
||||||
|
</Skeleton>
|
||||||
|
<span>-</span>
|
||||||
|
<Skeleton className="rounded-lg w-1/2">
|
||||||
|
<div className="h-10 rounded-lg bg-default-300" />
|
||||||
|
</Skeleton>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
31
src/app/components/Room/Table.tsx
Normal file
31
src/app/components/Room/Table.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
import { useRoomStore } from "@/app/stores/roomStore";
|
||||||
|
import { useUserStore } from "@/app/stores/userStore";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { RoomList } from "./List";
|
||||||
|
|
||||||
|
export const RoomTable = () => {
|
||||||
|
const { fetchRooms, actual, future, past } = useRoomStore();
|
||||||
|
const { currentClassId: selectedClassId } = useUserStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedClassId) fetchRooms();
|
||||||
|
}, [fetchRooms, selectedClassId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<section className="flex flex-col gap-2">
|
||||||
|
<h2 className="font-semibold text-lg">Upcoming</h2>
|
||||||
|
<RoomList rooms={future} />
|
||||||
|
</section>
|
||||||
|
<section className="flex flex-col gap-2">
|
||||||
|
<h2 className="font-semibold text-lg">Today</h2>
|
||||||
|
<RoomList rooms={actual} />
|
||||||
|
</section>
|
||||||
|
<section className="flex flex-col gap-2">
|
||||||
|
<h2 className="font-semibold text-lg">Past</h2>
|
||||||
|
<RoomList rooms={past} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -1,11 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { SessionProvider } from "next-auth/react";
|
|
||||||
|
|
||||||
export default function SessionProviderWrapper({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return <SessionProvider>{children}</SessionProvider>;
|
|
||||||
}
|
|
129
src/app/components/Sidebar/index.tsx
Normal file
129
src/app/components/Sidebar/index.tsx
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import { Divider, Link } from "@nextui-org/react";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
BsLayoutSidebarInset,
|
||||||
|
BsReverseLayoutSidebarInsetReverse,
|
||||||
|
} from "react-icons/bs";
|
||||||
|
import { ThemeSwitcher } from "../ThemeSwitcher/ThemeSwitcher";
|
||||||
|
import { SidebarItem } from "./item";
|
||||||
|
import { useUserStore } from "@/app/stores/userStore";
|
||||||
|
|
||||||
|
export const Sidebar = ({
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
items: {
|
||||||
|
href: string;
|
||||||
|
title: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}[][];
|
||||||
|
}) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathName = usePathname();
|
||||||
|
|
||||||
|
// State to manage menu openness
|
||||||
|
const { setSidebarIsOpen, sidebarIsOpen } = useUserStore();
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
if (sidebarIsOpen) return;
|
||||||
|
if (window.innerWidth <= 1024 && !isMobile) {
|
||||||
|
setIsMobile(true);
|
||||||
|
} else if (window.innerWidth > 1024 && isMobile) {
|
||||||
|
setIsMobile(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleResize();
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMobile) {
|
||||||
|
setSidebarIsOpen(false);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isMobile]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={`
|
||||||
|
${sidebarIsOpen ? "w-96" : "w-20"} z-40 h-screen transition-all bg-foreground-100
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* items-center lg:items-start */}
|
||||||
|
<div
|
||||||
|
className={`flex flex-col h-full px-3 py-4 overflow-y-auto scrollbar-hide ${sidebarIsOpen ? "items-start" : "items-center"}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex flex-col gap-2 p-2 mb-4 w-full ${!sidebarIsOpen ? "items-center" : ""}`}
|
||||||
|
>
|
||||||
|
{sidebarIsOpen ? (
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<Link
|
||||||
|
className="text-2xl font-semibold text-gray-900 dark:text-white rounded-lg cursor-pointer"
|
||||||
|
onPress={() => router.push("/")}
|
||||||
|
>
|
||||||
|
Toogether
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarIsOpen(!sidebarIsOpen)}
|
||||||
|
>
|
||||||
|
<BsLayoutSidebarInset className="text-xl" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarIsOpen(!sidebarIsOpen)}
|
||||||
|
>
|
||||||
|
<BsReverseLayoutSidebarInsetReverse className="text-xl" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sidebarIsOpen && (
|
||||||
|
<p className="text-sm text-foreground-400 dark:text-gray-400">
|
||||||
|
Manage classes, rooms, and users
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{items.map((group, index) => (
|
||||||
|
<div key={index} className={sidebarIsOpen ? "w-full" : ""}>
|
||||||
|
<section className="flex flex-col">
|
||||||
|
<ul className={`flex flex-col gap-2 w-full`}>
|
||||||
|
{group.map((item, index) => (
|
||||||
|
<li
|
||||||
|
key={index}
|
||||||
|
className={`${
|
||||||
|
pathName === item.href
|
||||||
|
? "bg-foreground-300"
|
||||||
|
: "hover:bg-foreground-200"
|
||||||
|
} rounded-md cursor-pointer w-full flex `}
|
||||||
|
>
|
||||||
|
<SidebarItem
|
||||||
|
href={item.href}
|
||||||
|
title={item.title}
|
||||||
|
icon={item.icon}
|
||||||
|
setOpen={setSidebarIsOpen}
|
||||||
|
showTitle={sidebarIsOpen}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{index < items.length - 1 && (
|
||||||
|
<Divider className="bg-foreground-300 my-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<section
|
||||||
|
className={`mt-auto ${!sidebarIsOpen && "justify-center"}`}
|
||||||
|
>
|
||||||
|
<ThemeSwitcher />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
37
src/app/components/Sidebar/item.tsx
Normal file
37
src/app/components/Sidebar/item.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
import { Link } from "@nextui-org/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
export type SidebarItemProps = {
|
||||||
|
title: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
href: string;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
showTitle: boolean;
|
||||||
|
isMobile: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SidebarItem = ({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
href,
|
||||||
|
setOpen,
|
||||||
|
showTitle = true,
|
||||||
|
isMobile
|
||||||
|
}: SidebarItemProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
onPress={() => {
|
||||||
|
router.push(href);
|
||||||
|
if (isMobile) setOpen(false);
|
||||||
|
}}
|
||||||
|
color="foreground"
|
||||||
|
className="w-full p-2 gap-3 text-md"
|
||||||
|
>
|
||||||
|
<span className="text-xl">{icon}</span>
|
||||||
|
{showTitle && <span className="text-sm">{title}</span>}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
26
src/app/components/ThemeSwitcher/ThemeSwitcher.tsx
Normal file
26
src/app/components/ThemeSwitcher/ThemeSwitcher.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
import { Button } from "@nextui-org/react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export const ThemeSwitcher = () => {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
className="min-w-0"
|
||||||
|
onPress={() =>
|
||||||
|
mounted && setTheme(theme === "light" ? "dark" : "light")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{mounted && theme === "light" ? "🌑" : "☀️"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
81
src/app/components/Users/index.tsx
Normal file
81
src/app/components/Users/index.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
"use client";
|
||||||
|
import { User } from "@/app/interface/user";
|
||||||
|
import { usersService } from "@/app/services/users.service";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Skeleton,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableColumn,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@nextui-org/react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export const UserList = () => {
|
||||||
|
const [users, setUsers] = useState<User[]>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
usersService.getAll().then((response) => {
|
||||||
|
setUsers(response.data);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!users) {
|
||||||
|
return (
|
||||||
|
<Card className="w-full space-y-5 p-4" radius="lg">
|
||||||
|
<Skeleton className="rounded-lg">
|
||||||
|
<div className="h-10 rounded-lg bg-default-300" />
|
||||||
|
</Skeleton>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Skeleton className="w-3/5 rounded-lg">
|
||||||
|
<div className="h-3 w-3/5 rounded-lg bg-default-200" />
|
||||||
|
</Skeleton>
|
||||||
|
<Skeleton className="w-4/5 rounded-lg">
|
||||||
|
<div className="h-3 w-4/5 rounded-lg bg-default-200" />
|
||||||
|
</Skeleton>
|
||||||
|
<Skeleton className="w-2/5 rounded-lg">
|
||||||
|
<div className="h-3 w-2/5 rounded-lg bg-default-300" />
|
||||||
|
</Skeleton>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="w-full p-4" radius="lg">
|
||||||
|
<p className="text-center text-lg text-gray-500 dark:text-gray-400">
|
||||||
|
No users found
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table aria-label="List of users" className="w-full">
|
||||||
|
<TableHeader>
|
||||||
|
<TableColumn>USERNAME</TableColumn>
|
||||||
|
<TableColumn>CLASS</TableColumn>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{users.map((user) => (
|
||||||
|
<TableRow key={user.id.toString()}>
|
||||||
|
<TableCell>{user.username}</TableCell>
|
||||||
|
<TableCell className="flex flex-wrap gap-1">
|
||||||
|
{user.Class.map((c) => (
|
||||||
|
<span
|
||||||
|
className="px-2 rounded-md bg-foreground-100"
|
||||||
|
key={c.id.toString()}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
};
|
16
src/app/constants/apiUrl.constant.ts
Normal file
16
src/app/constants/apiUrl.constant.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
const NEXT_PUBLIC_API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||||
|
|
||||||
|
export const API_URLS = {
|
||||||
|
class: {
|
||||||
|
all: `${NEXT_PUBLIC_API_URL}/@me/class`,
|
||||||
|
},
|
||||||
|
room: {
|
||||||
|
all: (classId: string) =>
|
||||||
|
`${NEXT_PUBLIC_API_URL}/@me/class/${classId}/rooms`,
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
user: {
|
||||||
|
all: `${NEXT_PUBLIC_API_URL}/user`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
19
src/app/dashboard/layout.tsx
Normal file
19
src/app/dashboard/layout.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { authOptions } from "@/authOptions";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { HeaderContent } from "../components/Header/contents";
|
||||||
|
|
||||||
|
export default async function Layout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<HeaderContent user={session?.user} />
|
||||||
|
|
||||||
|
<main>{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
16
src/app/dashboard/page.tsx
Normal file
16
src/app/dashboard/page.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
import { RoomTable } from "../components/Room/Table";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Toogether | Home",
|
||||||
|
description:
|
||||||
|
"Toogether is a platform that allows you to create and join rooms to study together.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function Page() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8 p-4">
|
||||||
|
<RoomTable />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,3 +1,3 @@
|
|||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
5
src/app/interface/class.ts
Normal file
5
src/app/interface/class.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export interface Class {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
17
src/app/interface/room.ts
Normal file
17
src/app/interface/room.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
export interface Room {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
date: string;
|
||||||
|
Times: {
|
||||||
|
id: number;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
roomId: string;
|
||||||
|
}[];
|
||||||
|
Presentator: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
}
|
10
src/app/interface/user.ts
Normal file
10
src/app/interface/user.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
Class: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
}[];
|
||||||
|
}
|
@ -1,15 +1,18 @@
|
|||||||
import SessionProviderWrapper from "./components/SessionProviderWrapper";
|
import { getSession } from "next-auth/react";
|
||||||
|
import AppWrapper from "./components/AppWrapper";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export default function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const session = await getSession()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="fr">
|
<html lang="fr" suppressHydrationWarning>
|
||||||
<body>
|
<body>
|
||||||
<SessionProviderWrapper>{children}</SessionProviderWrapper>
|
<AppWrapper session={session}>{children}</AppWrapper>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
@ -1,28 +1,55 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import moment, { Moment } from "moment";
|
import moment, { Moment } from "moment";
|
||||||
import { getSession } from "next-auth/react";
|
import { getSession, signOut } from "next-auth/react";
|
||||||
|
|
||||||
let cachedAccessToken: string | null = null;
|
moment.locale("fr");
|
||||||
let tokenExpirationAt: Moment | null = null;
|
|
||||||
|
let cachedAccessToken: string | null = null;
|
||||||
export const axiosInstance = axios.create({
|
let tokenExpirationAt: Moment | null = null;
|
||||||
baseURL: process.env.NEXT_PUBLIC_API_URL,
|
|
||||||
});
|
export const axiosInstance = axios.create({
|
||||||
|
baseURL: process.env.NEXT_PUBLIC_API_URL,
|
||||||
axiosInstance.interceptors.request.use(async (config) => {
|
});
|
||||||
if (tokenExpirationAt && moment().isBefore(tokenExpirationAt)) {
|
|
||||||
config.headers.Authorization = `Bearer ${cachedAccessToken}`;
|
let isRefreshing = false;
|
||||||
return config;
|
let refreshQueue: Array<(token: string) => void> = [];
|
||||||
}
|
|
||||||
|
axiosInstance.interceptors.request.use(async (config) => {
|
||||||
const session = await getSession();
|
if (tokenExpirationAt && moment().isBefore(tokenExpirationAt)) {
|
||||||
if (!session) {
|
config.headers.Authorization = `Bearer ${cachedAccessToken}`;
|
||||||
throw new Error("User is not authenticated");
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
cachedAccessToken = session.accessToken;
|
if (isRefreshing) {
|
||||||
tokenExpirationAt = moment(session.accessTokenExpires);
|
// Add the request to the queue
|
||||||
|
return new Promise((resolve) => {
|
||||||
config.headers.Authorization = `Bearer ${cachedAccessToken}`;
|
refreshQueue.push((token: string) => {
|
||||||
return config;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
});
|
resolve(config);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isRefreshing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session || session.error) {
|
||||||
|
console.log("No session found, redirecting to login page");
|
||||||
|
await signOut({ callbackUrl: "/auth/login" });
|
||||||
|
// cancel the request
|
||||||
|
return new Promise(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedAccessToken = session.accessToken;
|
||||||
|
tokenExpirationAt = moment(session.accessTokenExpires);
|
||||||
|
|
||||||
|
// Execute the queue
|
||||||
|
refreshQueue.forEach((cb) => cb(cachedAccessToken!));
|
||||||
|
refreshQueue = [];
|
||||||
|
config.headers.Authorization = `Bearer ${cachedAccessToken}`;
|
||||||
|
return config;
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
@ -1,3 +0,0 @@
|
|||||||
export const UppercaseFirstLetter = (str: string) => {
|
|
||||||
return str.slice(0, 1).toLocaleUpperCase() + str.slice(1);
|
|
||||||
}
|
|
@ -1,13 +1,20 @@
|
|||||||
import Header from "./components/Header";
|
import { Metadata } from "next";
|
||||||
import { FetchApi } from "./components/FetchApi";
|
import { Header } from "./components/Header";
|
||||||
|
import { RoomTable } from "./components/Room/Table";
|
||||||
|
|
||||||
const HomePage = () => {
|
export const metadata: Metadata = {
|
||||||
|
title: "Toogether | Home",
|
||||||
|
description:
|
||||||
|
"Toogether is a platform that allows you to create and join rooms to study together.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function Page() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
<FetchApi />
|
<main className="flex flex-col gap-8 p-4">
|
||||||
|
<RoomTable />
|
||||||
|
</main>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default HomePage;
|
|
||||||
|
7
src/app/services/classes.service.ts
Normal file
7
src/app/services/classes.service.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { API_URLS } from "../constants/apiUrl.constant";
|
||||||
|
import { Class } from "../interface/class";
|
||||||
|
import { axiosInstance } from "../lib/axios";
|
||||||
|
|
||||||
|
const getAll = () => axiosInstance.get<Class[]>(API_URLS.class.all);
|
||||||
|
|
||||||
|
export const classesService = { getAll };
|
7
src/app/services/rooms.service.ts
Normal file
7
src/app/services/rooms.service.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { API_URLS } from "../constants/apiUrl.constant";
|
||||||
|
import { axiosInstance } from "../lib/axios";
|
||||||
|
import { Room } from "../interface/room";
|
||||||
|
|
||||||
|
const getAll = (classId: string) => axiosInstance.get<Room[]>(API_URLS.room.all(classId));
|
||||||
|
|
||||||
|
export const roomsService = { getAll };
|
7
src/app/services/users.service.ts
Normal file
7
src/app/services/users.service.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { API_URLS } from "../constants/apiUrl.constant";
|
||||||
|
import { User } from "../interface/user";
|
||||||
|
import { axiosInstance } from "../lib/axios";
|
||||||
|
|
||||||
|
const getAll = () => axiosInstance.get<User[]>(API_URLS.admin.user.all);
|
||||||
|
|
||||||
|
export const usersService = { getAll };
|
32
src/app/stores/classStore.ts
Normal file
32
src/app/stores/classStore.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { classesService } from "../services/classes.service";
|
||||||
|
import { Class } from "../interface/class";
|
||||||
|
|
||||||
|
type ClassStoreState = {
|
||||||
|
classes: Class[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ClassStoreActions = {
|
||||||
|
_setClass: (classes: Class[]) => void;
|
||||||
|
fetchClass: () => Promise<Class[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ClassStore = ClassStoreState & ClassStoreActions;
|
||||||
|
|
||||||
|
const defaultState: ClassStoreState = {
|
||||||
|
classes: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useClassStore = create<ClassStore>()((set) => ({
|
||||||
|
...defaultState,
|
||||||
|
_setClass: (classes: Class[]) => {
|
||||||
|
set(() => ({
|
||||||
|
classes: classes,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
fetchClass: async () => {
|
||||||
|
const classResponse = await classesService.getAll();
|
||||||
|
useClassStore.getState()._setClass(classResponse.data);
|
||||||
|
return classResponse.data;
|
||||||
|
},
|
||||||
|
}));
|
47
src/app/stores/roomStore.ts
Normal file
47
src/app/stores/roomStore.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import moment from "moment";
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { Room } from "../interface/room";
|
||||||
|
import { useUserStore } from "./userStore";
|
||||||
|
import { roomsService } from "../services/rooms.service";
|
||||||
|
|
||||||
|
type RoomStoreState = {
|
||||||
|
future: Room[] | null;
|
||||||
|
actual: Room[] | null;
|
||||||
|
past: Room[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RoomStoreActions = {
|
||||||
|
_setRooms: (rooms: Room[]) => void;
|
||||||
|
fetchRooms: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RoomStore = RoomStoreState & RoomStoreActions;
|
||||||
|
|
||||||
|
const defaultState: RoomStoreState = {
|
||||||
|
future: null,
|
||||||
|
actual: null,
|
||||||
|
past: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRoomStore = create<RoomStore>()((set) => ({
|
||||||
|
...defaultState,
|
||||||
|
_setRooms: (rooms) => {
|
||||||
|
const future = rooms.filter((room) =>
|
||||||
|
moment(room.date).isAfter(moment(), "day"),
|
||||||
|
);
|
||||||
|
const actual = rooms.filter((room) =>
|
||||||
|
moment(room.date).isSame(moment(), "day"),
|
||||||
|
);
|
||||||
|
const past = rooms.filter((room) =>
|
||||||
|
moment(room.date).isBefore(moment(), "day"),
|
||||||
|
);
|
||||||
|
set({ future, actual, past });
|
||||||
|
},
|
||||||
|
fetchRooms: async () => {
|
||||||
|
const selectedClassId = useUserStore.getState().currentClassId;
|
||||||
|
if (!selectedClassId) return;
|
||||||
|
|
||||||
|
const roomResponse = await roomsService.getAll(selectedClassId);
|
||||||
|
useRoomStore.getState()._setRooms(roomResponse.data);
|
||||||
|
},
|
||||||
|
}));
|
37
src/app/stores/userStore.ts
Normal file
37
src/app/stores/userStore.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { persist, createJSONStorage } from "zustand/middleware";
|
||||||
|
|
||||||
|
type UserStoreState = {
|
||||||
|
currentClassId: string | null;
|
||||||
|
sidebarIsOpen: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UserStoreActions = {
|
||||||
|
setCurrentClassId: (classId: string) => void;
|
||||||
|
setSidebarIsOpen: (isOpen: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UserStore = UserStoreState & UserStoreActions;
|
||||||
|
|
||||||
|
const defaultState: UserStoreState = {
|
||||||
|
currentClassId: null,
|
||||||
|
sidebarIsOpen: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUserStore = create<UserStore>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
...defaultState,
|
||||||
|
setCurrentClassId: (classId: string) => {
|
||||||
|
set({ currentClassId: classId });
|
||||||
|
},
|
||||||
|
setSidebarIsOpen: (isOpen: boolean) => {
|
||||||
|
set({ sidebarIsOpen: isOpen });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "userStore",
|
||||||
|
storage: createJSONStorage(() => localStorage),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
25
src/app/types/env.d.ts
vendored
25
src/app/types/env.d.ts
vendored
@ -1,12 +1,13 @@
|
|||||||
declare namespace NodeJS {
|
declare namespace NodeJS {
|
||||||
interface ProcessEnv {
|
interface ProcessEnv {
|
||||||
OAUTH_PROVIDER_NAME: string;
|
OAUTH_PROVIDER_NAME: string;
|
||||||
OAUTH_CLIENT_ID: string;
|
OAUTH_CLIENT_ID: string;
|
||||||
OAUTH_CLIENT_SECRET: string;
|
OAUTH_CLIENT_SECRET: string;
|
||||||
OAUTH_AUTHORIZATION_URL: string;
|
OAUTH_AUTHORIZATION_URL: string;
|
||||||
OAUTH_TOKEN_URL: string;
|
OAUTH_TOKEN_URL: string;
|
||||||
OAUTH_USERINFO_URL: string;
|
OAUTH_USERINFO_URL: string;
|
||||||
OAUTH_ISSUER: string;
|
OAUTH_ISSUER: string;
|
||||||
OAUTH_JWKS_ENDPOINT: string;
|
OAUTH_JWKS_ENDPOINT: string;
|
||||||
}
|
OAUTH_SCOPES: string;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
105
src/app/types/next-auth.d.ts
vendored
105
src/app/types/next-auth.d.ts
vendored
@ -1,42 +1,63 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
import { Moment } from "moment";
|
import { Moment } from "moment";
|
||||||
import NextAuth, { DefaultSession } from "next-auth";
|
import { DefaultSession } from "next-auth";
|
||||||
import { JWT } from "next-auth/jwt";
|
import "next-auth/jwt";
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
sub: string;
|
sub: string;
|
||||||
name: string;
|
name: string;
|
||||||
preferred_username: string;
|
preferred_username: string;
|
||||||
given_name: string;
|
given_name: string;
|
||||||
family_name: string;
|
family_name: string;
|
||||||
}
|
email: string;
|
||||||
|
roles: string[];
|
||||||
declare module "next-auth" {
|
}
|
||||||
interface Session extends DefaultSession {
|
|
||||||
accessToken: string;
|
export interface JWTDecoded {
|
||||||
refreshToken: string;
|
realm_access: {
|
||||||
accessTokenExpires: Moment;
|
roles: string[];
|
||||||
error?: Error;
|
};
|
||||||
user: User;
|
}
|
||||||
}
|
|
||||||
|
declare module "next-auth" {
|
||||||
interface Account {
|
interface Session extends DefaultSession {
|
||||||
expires_at: number;
|
accessToken: string;
|
||||||
access_token: string;
|
refreshToken: string;
|
||||||
refresh_token: string;
|
accessTokenExpires: Moment;
|
||||||
}
|
error?: string;
|
||||||
}
|
user: User;
|
||||||
|
}
|
||||||
declare module "next-auth/jwt" {
|
|
||||||
interface JWT {
|
interface Account {
|
||||||
accessToken: string;
|
provider: string;
|
||||||
accessTokenExpires: Moment;
|
type: string;
|
||||||
refreshToken: string;
|
providerAccountId: string;
|
||||||
error?: Error;
|
access_token: string;
|
||||||
user: User | AdapterUser;
|
expires_at: number;
|
||||||
iat: number;
|
refresh_expires_in: number;
|
||||||
exp: number;
|
refresh_token: string;
|
||||||
jti: string;
|
token_type: string;
|
||||||
}
|
id_token: string;
|
||||||
}
|
session_state: string;
|
||||||
|
scope: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "next-auth/jwt" {
|
||||||
|
interface JWT {
|
||||||
|
// Default properties
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
picture: string;
|
||||||
|
sub: string;
|
||||||
|
|
||||||
|
// Custom properties
|
||||||
|
accessToken: string;
|
||||||
|
accessTokenExpires: Moment;
|
||||||
|
refreshToken: string;
|
||||||
|
refreshTokenExpires: Moment | undefined;
|
||||||
|
error?: string;
|
||||||
|
user: User | AdapterUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
13
src/app/utils/initial.ts
Normal file
13
src/app/utils/initial.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
export const getInitials = (name: string) => {
|
||||||
|
if (!name) return "";
|
||||||
|
|
||||||
|
const nameParts = name.split(" ");
|
||||||
|
if (nameParts.length === 1) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstInitial = nameParts[0]?.[0] || "";
|
||||||
|
const secondInitial = nameParts[1]?.[0] || nameParts[0]?.[1] || "";
|
||||||
|
|
||||||
|
return firstInitial + secondInitial;
|
||||||
|
};
|
@ -1,104 +1,153 @@
|
|||||||
import axios from "axios";
|
import axios, { AxiosResponse } from "axios";
|
||||||
import moment from "moment";
|
import moment from "moment-timezone";
|
||||||
import { AuthOptions, Session } from "next-auth";
|
import { AuthOptions, Session } from "next-auth";
|
||||||
import { JWT } from "next-auth/jwt";
|
import { JWT } from "next-auth/jwt";
|
||||||
|
import jsonwebtoken from "jsonwebtoken";
|
||||||
export const authOptions: AuthOptions = {
|
import { JWTDecoded } from "./app/types/next-auth";
|
||||||
providers: [
|
|
||||||
{
|
moment.tz.setDefault("America/New_York");
|
||||||
id: "oauth",
|
|
||||||
name: process.env.OAUTH_PROVIDER_NAME,
|
export const authOptions: AuthOptions = {
|
||||||
type: "oauth",
|
providers: [
|
||||||
clientId: process.env.OAUTH_CLIENT_ID,
|
{
|
||||||
clientSecret: process.env.OAUTH_CLIENT_SECRET,
|
id: "oauth",
|
||||||
authorization: {
|
name: process.env.OAUTH_PROVIDER_NAME,
|
||||||
url: process.env.OAUTH_AUTHORIZATION_URL,
|
type: "oauth",
|
||||||
params: {
|
clientId: process.env.OAUTH_CLIENT_ID,
|
||||||
scope: "openid profile offline_access",
|
clientSecret: process.env.OAUTH_CLIENT_SECRET,
|
||||||
response_type: "code",
|
authorization: {
|
||||||
},
|
url: process.env.OAUTH_AUTHORIZATION_URL,
|
||||||
},
|
params: {
|
||||||
checks: ["pkce", "state"],
|
scope: process.env.OAUTH_SCOPES,
|
||||||
idToken: true,
|
response_type: "code",
|
||||||
token: process.env.OAUTH_TOKEN_URL,
|
},
|
||||||
userinfo: process.env.OAUTH_USERINFO_URL,
|
},
|
||||||
issuer: process.env.OAUTH_ISSUER,
|
checks: ["pkce", "state"],
|
||||||
jwks_endpoint: process.env.OAUTH_JWKS_ENDPOINT,
|
idToken: true,
|
||||||
profile(profile: Session["user"]) {
|
token: process.env.OAUTH_TOKEN_URL,
|
||||||
return {
|
userinfo: process.env.OAUTH_USERINFO_URL,
|
||||||
id: profile.sub || profile.id,
|
issuer: process.env.OAUTH_ISSUER,
|
||||||
name:
|
jwks_endpoint: process.env.OAUTH_JWKS_ENDPOINT,
|
||||||
profile.name ||
|
wellKnown: `${process.env.OAUTH_ISSUER}/.well-known/openid-configuration`,
|
||||||
profile.preferred_username ||
|
profile(profile: Session["user"]) {
|
||||||
`${profile.given_name} ${profile.family_name}`,
|
return {
|
||||||
};
|
id: profile.sub || profile.id,
|
||||||
},
|
name:
|
||||||
},
|
profile.name ||
|
||||||
],
|
profile.preferred_username ||
|
||||||
callbacks: {
|
`${profile.given_name} ${profile.family_name}`,
|
||||||
async jwt({ token, account, user }) {
|
email: profile.email,
|
||||||
if (account && user) {
|
};
|
||||||
token.accessToken = account.access_token;
|
},
|
||||||
token.accessTokenExpires = moment(account.expires_at * 1000).subtract(5, "s");
|
},
|
||||||
token.refreshToken = account.refresh_token;
|
],
|
||||||
token.user = user;
|
callbacks: {
|
||||||
return token;
|
async jwt({ token, account, user: profile }) {
|
||||||
}
|
// Initial sign in
|
||||||
|
if (account && profile) {
|
||||||
if (moment().isBefore(moment(token.accessTokenExpires))) {
|
token.accessToken = account.access_token;
|
||||||
return token;
|
token.refreshToken = account.refresh_token;
|
||||||
}
|
|
||||||
|
token.accessTokenExpires = moment.unix(account.expires_at);
|
||||||
return refreshAccessToken(token);
|
|
||||||
},
|
token.refreshTokenExpires =
|
||||||
async session({ session, token }) {
|
account.refresh_expires_in != 0
|
||||||
if (token) {
|
? moment().add(account.refresh_expires_in, "seconds")
|
||||||
session.user = token.user;
|
: undefined;
|
||||||
session.accessToken = token.accessToken;
|
|
||||||
session.accessTokenExpires = token.accessTokenExpires;
|
const accessTokenDecode = jsonwebtoken.decode(
|
||||||
session.error = token.error;
|
account.access_token,
|
||||||
}
|
) as JWTDecoded;
|
||||||
|
|
||||||
return session;
|
token.user = {
|
||||||
},
|
...profile,
|
||||||
},
|
roles: accessTokenDecode.realm_access.roles,
|
||||||
pages: {
|
};
|
||||||
signIn: "/auth/login",
|
|
||||||
signOut: "/auth/logout",
|
return token;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
if (
|
||||||
const refreshAccessToken = async (token: JWT): Promise<JWT> => {
|
token.refreshTokenExpires &&
|
||||||
const response = await axios.post<{
|
moment().isAfter(token.refreshTokenExpires)
|
||||||
access_token: string;
|
)
|
||||||
expires_in: number;
|
return {
|
||||||
refresh_token: string;
|
...token,
|
||||||
error_description?: string;
|
error: "Refresh token has expired",
|
||||||
}>(
|
};
|
||||||
process.env.OAUTH_TOKEN_URL,
|
|
||||||
{
|
// Return previous token if the access token has not expired yet
|
||||||
grant_type: "refresh_token",
|
if (moment().isBefore(moment(token.accessTokenExpires)))
|
||||||
refresh_token: token.refreshToken,
|
return token;
|
||||||
client_id: process.env.OAUTH_CLIENT_ID,
|
|
||||||
client_secret: process.env.OAUTH_CLIENT_SECRET,
|
// Access token has expired, try to refresh it
|
||||||
},
|
return refreshAccessToken(token);
|
||||||
{
|
},
|
||||||
headers: {
|
async session({ session, token }) {
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
if (token) {
|
||||||
},
|
session.user = token.user;
|
||||||
}
|
session.accessToken = token.accessToken;
|
||||||
);
|
session.accessTokenExpires = token.accessTokenExpires;
|
||||||
|
session.error = token.error;
|
||||||
if (response.status != 200) {
|
}
|
||||||
throw new Error(
|
|
||||||
response.data.error_description || "Failed to refresh access token"
|
return session;
|
||||||
);
|
},
|
||||||
}
|
},
|
||||||
|
pages: {
|
||||||
return {
|
signIn: "/auth/login",
|
||||||
...token,
|
signOut: "/auth/logout",
|
||||||
accessToken: response.data.access_token,
|
},
|
||||||
accessTokenExpires: moment().add(response.data.expires_in, "seconds").subtract(5, "s"),
|
};
|
||||||
refreshToken: response.data.refresh_token,
|
|
||||||
};
|
const refreshAccessToken = async (token: JWT): Promise<JWT> => {
|
||||||
};
|
try {
|
||||||
|
const response = await axios.post<{
|
||||||
|
access_token: string;
|
||||||
|
expires_in: number;
|
||||||
|
id_token: string;
|
||||||
|
"not-before-policy": number;
|
||||||
|
refresh_expires_in: number;
|
||||||
|
refresh_token: string;
|
||||||
|
scope: string;
|
||||||
|
session_state: string;
|
||||||
|
token_type: string;
|
||||||
|
|
||||||
|
error?: string;
|
||||||
|
error_description?: string;
|
||||||
|
}>(
|
||||||
|
process.env.OAUTH_TOKEN_URL,
|
||||||
|
{
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: token.refreshToken,
|
||||||
|
client_id: process.env.OAUTH_CLIENT_ID,
|
||||||
|
client_secret: process.env.OAUTH_CLIENT_SECRET,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.status !== 200) throw response.data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
accessToken: response.data.access_token,
|
||||||
|
accessTokenExpires: moment()
|
||||||
|
.add(response.data.expires_in, "seconds")
|
||||||
|
.subtract(5, "s"),
|
||||||
|
refreshToken: response.data.refresh_token,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
"Error refreshing access token",
|
||||||
|
(e as AxiosResponse).data,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
error: "RefreshAccessTokenError",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
@ -1,25 +1,33 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getToken } from "next-auth/jwt";
|
import { getToken } from "next-auth/jwt";
|
||||||
|
|
||||||
export async function middleware(req: NextRequest) {
|
export async function middleware(req: NextRequest) {
|
||||||
const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET });
|
const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET });
|
||||||
const isAuth = !!token;
|
const isAuth = !!token;
|
||||||
|
|
||||||
const url = req.nextUrl.clone();
|
const url = req.nextUrl.clone();
|
||||||
|
|
||||||
if (isAuth && url.pathname === "/auth/login") {
|
if (isAuth && url.pathname === "/auth/login") {
|
||||||
url.pathname = "/";
|
url.pathname = "/";
|
||||||
return NextResponse.redirect(url);
|
return NextResponse.redirect(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuth && url.pathname !== "/auth/login") {
|
if (!isAuth && url.pathname !== "/auth/login") {
|
||||||
url.pathname = "/auth/login";
|
url.pathname = "/auth/login";
|
||||||
return NextResponse.redirect(url);
|
return NextResponse.redirect(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.next();
|
if (
|
||||||
}
|
!token?.user.roles.includes("admin") &&
|
||||||
|
url.pathname.startsWith("/admin")
|
||||||
export const config = {
|
) {
|
||||||
matcher: ["/((?!api|_next|static|favicon.ico).*)"],
|
url.pathname = "/";
|
||||||
};
|
return NextResponse.redirect(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/((?!api|_next|static|favicon.ico).*)"],
|
||||||
|
};
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
import type { Config } from "tailwindcss";
|
import type { Config } from "tailwindcss";
|
||||||
|
import { nextui } from "@nextui-org/react";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
content: [
|
content: [
|
||||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}",
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
@ -12,17 +14,8 @@ export default {
|
|||||||
background: "var(--background)",
|
background: "var(--background)",
|
||||||
foreground: "var(--foreground)",
|
foreground: "var(--foreground)",
|
||||||
},
|
},
|
||||||
animation: {
|
|
||||||
"gradient-x": "gradient-x 5s ease infinite",
|
|
||||||
},
|
|
||||||
keyframes: {
|
|
||||||
"gradient-x": {
|
|
||||||
"0%": { "background-position": "0% 50%" },
|
|
||||||
"50%": { "background-position": "100% 50%" },
|
|
||||||
"100%": { "background-position": "0% 50%" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
darkMode: "class",
|
||||||
|
plugins: [nextui()],
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
Loading…
Reference in New Issue
Block a user