diff --git a/app/[locale]/(loggedIn)/components/layout/Header.tsx b/app/[locale]/(loggedIn)/components/layout/Header.tsx
index 02f5948..13c9c64 100644
--- a/app/[locale]/(loggedIn)/components/layout/Header.tsx
+++ b/app/[locale]/(loggedIn)/components/layout/Header.tsx
@@ -1,25 +1,28 @@
-import Link from "next/link";
+import Image from "next/image";
+import { useTranslations } from "next-intl";
import TopNavigationMenu from "./TopNavigationMenu";
+import LogoutButton from "./userMenu/LogoutButton";
-import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { HomeIcon, HamburgerMenuIcon } from "@radix-ui/react-icons";
+import { HamburgerMenuIcon } from "@radix-ui/react-icons";
+import UserMenu from "./userMenu/UserMenu";
const Header = () => {
+ const t = useTranslations("header");
+ const tApp = useTranslations("app");
+
return (
);
diff --git a/app/[locale]/(loggedIn)/components/layout/userMenu/LogoutButton.tsx b/app/[locale]/(loggedIn)/components/layout/userMenu/LogoutButton.tsx
new file mode 100644
index 0000000..60384dd
--- /dev/null
+++ b/app/[locale]/(loggedIn)/components/layout/userMenu/LogoutButton.tsx
@@ -0,0 +1,40 @@
+"use client";
+
+import { useState } from "react";
+import { signOut } from "next-auth/react";
+import LogoutDialog from "./LogoutDialog";
+
+export interface LogoutButtonProps {
+ logoutText: string;
+ logoutProgressMessageText: string;
+}
+
+const LogoutButton = ({
+ logoutText,
+ logoutProgressMessageText,
+}: LogoutButtonProps) => {
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
+
+ const handleSignOut = async () => {
+ setIsDialogOpen(true);
+ await signOut();
+ setIsDialogOpen(false);
+ };
+
+ return (
+ <>
+
+
+ >
+ );
+};
+
+export default LogoutButton;
diff --git a/app/[locale]/(loggedIn)/components/layout/userMenu/LogoutDialog.tsx b/app/[locale]/(loggedIn)/components/layout/userMenu/LogoutDialog.tsx
new file mode 100644
index 0000000..3e68fb2
--- /dev/null
+++ b/app/[locale]/(loggedIn)/components/layout/userMenu/LogoutDialog.tsx
@@ -0,0 +1,44 @@
+import { Skeleton } from "@/components/ui/skeleton";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+
+export interface LogoutDialogProps {
+ isOpen: boolean;
+ logoutProgressMessageText: string;
+}
+
+const LogoutDialog = ({
+ isOpen,
+ logoutProgressMessageText,
+}: LogoutDialogProps) => {
+ return (
+
+ );
+};
+
+export default LogoutDialog;
diff --git a/app/[locale]/(loggedIn)/components/layout/userMenu/UserMenu.tsx b/app/[locale]/(loggedIn)/components/layout/userMenu/UserMenu.tsx
new file mode 100644
index 0000000..6535c77
--- /dev/null
+++ b/app/[locale]/(loggedIn)/components/layout/userMenu/UserMenu.tsx
@@ -0,0 +1,52 @@
+"use client";
+import { useSession } from "next-auth/react";
+import { useMemo, type ReactNode } from "react";
+import { getUserInitials } from "@/utils/userSessionUtils";
+
+import { Button } from "@/components/ui/button";
+
+import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+
+export interface UserMenuProps {
+ children?: ReactNode;
+}
+
+const UserMenu = ({ children }: UserMenuProps) => {
+ const { data: session } = useSession();
+
+ const userName = session?.user?.name;
+ const userInitials = useMemo(() => getUserInitials(userName), [userName]);
+
+ return (
+
+
+
+
+
+
+ {userName}
+
+
+ {children}
+
+
+ );
+};
+
+export default UserMenu;
diff --git a/app/[locale]/(loggedIn)/layoutComponents/sidebar/LogoImage.tsx b/app/[locale]/(loggedIn)/layoutComponents/sidebar/LogoImage.tsx
deleted file mode 100644
index 27a1060..0000000
--- a/app/[locale]/(loggedIn)/layoutComponents/sidebar/LogoImage.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-interface LogoImageProps {
- className?: string;
-}
-
-const LogoImage = ({ className }: LogoImageProps) => {
- return (
-
-
-
-
-
- );
-};
-
-export default LogoImage;
diff --git a/app/[locale]/globals.css b/app/[locale]/globals.css
index b5c61c9..8d34e5d 100644
--- a/app/[locale]/globals.css
+++ b/app/[locale]/globals.css
@@ -1,3 +1,43 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+
+.DialogOverlay {
+ background: rgba(0 0 0 / 0.5);
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ display: grid;
+ place-items: center;
+ overflow-y: auto;
+ z-index: 9998;
+}
+
+.DialogContent {
+ min-width: 300px;
+ width: calc(100% - 60px);
+ position: relative;
+ background: white;
+ margin-top: 30px;
+ margin-right: 30px;
+ margin-bottom: 30px;
+ margin-left: 30px;
+ padding: 30px;
+ border-radius: 4px;
+ z-index: 9999;
+}
+
+.DialogContentFit {
+ min-width: 300px;
+ position: relative;
+ background: white;
+ margin-top: 30px;
+ margin-right: 30px;
+ margin-bottom: 30px;
+ margin-left: 30px;
+ padding: 30px;
+ border-radius: 4px;
+ z-index: 9999;
+}
diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx
index f2474de..63cdd86 100644
--- a/app/[locale]/login/page.tsx
+++ b/app/[locale]/login/page.tsx
@@ -1,30 +1,14 @@
-import { createTranslator } from "next-intl";
-import { notFound } from "next/navigation";
+import { useTranslations } from "next-intl";
+import Image from "next/image";
-import LogoImage from "../(loggedIn)/layoutComponents/sidebar/LogoImage";
import Form from "./components/Form";
-async function getMessages(locale: string = "en") {
- try {
- return (await import(`@/messages/${locale}.json`)).default;
- } catch (error) {
- notFound();
- }
-}
-
-export default async function LoginPage({
- params: { locale },
+export default function LoginPage({
searchParams,
}: {
- params: { [key: string]: string | undefined };
searchParams: { [key: string]: string | string[] | undefined };
}) {
- const messages = await getMessages(locale);
- const t = createTranslator({
- locale: locale ?? "en",
- messages,
- namespace: "loginPage",
- });
+ const t = useTranslations("loginPage");
const callbackUrl = Array.isArray(searchParams.callbackUrl)
? searchParams.callbackUrl[0]
@@ -35,7 +19,13 @@ export default async function LoginPage({
-
+
{t("header")}
diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts
index 42f65bd..9bbe104 100644
--- a/app/api/auth/[...nextauth]/route.ts
+++ b/app/api/auth/[...nextauth]/route.ts
@@ -104,6 +104,7 @@ export const OPTIONS: AuthOptions = {
if (user) {
token.access_token = user.access_token;
token.apiUser = user.apiUser;
+ token.name = user.name;
}
return token;
diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx
new file mode 100644
index 0000000..5616a32
--- /dev/null
+++ b/components/ui/dialog.tsx
@@ -0,0 +1,152 @@
+"use client";
+
+import * as React from "react";
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { Cross2Icon } from "@radix-ui/react-icons";
+import { cn } from "@/lib/utils";
+
+import type { DismissableLayerProps } from "@radix-ui/react-dialog";
+
+export type DialogOnEscapeKeyDown = DismissableLayerProps["onEscapeKeyDown"];
+export type DialogOnPointerDown = DismissableLayerProps["onPointerDown"];
+export type DialogOnInteractOutside =
+ DismissableLayerProps["onInteractOutside"];
+
+const Dialog = DialogPrimitive.Root;
+
+const DialogTrigger = DialogPrimitive.Trigger;
+
+const DialogPortal = DialogPrimitive.Portal;
+
+const DialogClose = DialogPrimitive.Close;
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef
,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
+
+const DialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ closeButtonText?: string;
+ hasCloseButton?: boolean;
+ overlayClassName?: string;
+ contentClassName?: string;
+ }
+>(
+ (
+ {
+ className = "DialogContent",
+ closeButtonText = "Close",
+ hasCloseButton = true,
+ overlayClassName = "DialogOverlay",
+ contentClassName,
+ children,
+ ...props
+ },
+ ref
+ ) => {
+ return (
+
+
+
+ {children}
+ {hasCloseButton && (
+
+
+ {closeButtonText}
+
+ )}
+
+
+
+ );
+ }
+);
+DialogContent.displayName = DialogPrimitive.Content.displayName;
+
+const DialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DialogHeader.displayName = "DialogHeader";
+
+const DialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DialogFooter.displayName = "DialogFooter";
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogTitle.displayName = DialogPrimitive.Title.displayName;
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogDescription.displayName = DialogPrimitive.Description.displayName;
+
+export {
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogTrigger,
+ DialogClose,
+ DialogContent,
+ DialogHeader,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription,
+};
diff --git a/components/ui/skeleton.tsx b/components/ui/skeleton.tsx
new file mode 100644
index 0000000..d104b85
--- /dev/null
+++ b/components/ui/skeleton.tsx
@@ -0,0 +1,18 @@
+import { cn } from "@/lib/utils";
+
+function Skeleton({
+ className,
+ ...props
+}: React.HTMLAttributes) {
+ return (
+
+ );
+}
+
+export { Skeleton };
diff --git a/global.d.ts b/global.d.ts
new file mode 100644
index 0000000..3f71924
--- /dev/null
+++ b/global.d.ts
@@ -0,0 +1,4 @@
+/* eslint-disable no-unused-vars */
+// Use type safe message keys with `next-intl`
+type Messages = typeof import("./messages/en.json");
+declare interface IntlMessages extends Messages {}
diff --git a/i18n.ts b/i18n.ts
index b1b57de..3f6283a 100644
--- a/i18n.ts
+++ b/i18n.ts
@@ -1,5 +1,11 @@
+import deepmerge from "deepmerge";
import { getRequestConfig } from "next-intl/server";
-export default getRequestConfig(async ({ locale }) => ({
- messages: (await import(`./messages/${locale}.json`)).default,
-}));
+export default getRequestConfig(async ({ locale }) => {
+ const userMessages = (await import(`./messages/${locale}.json`)).default;
+ const defaultMessages = (await import(`./messages/en.json`)).default;
+
+ return {
+ messages: deepmerge(defaultMessages, userMessages),
+ };
+});
diff --git a/messages/en.json b/messages/en.json
index 1f106ba..7c33726 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1,12 +1,25 @@
{
"app": {
"name": "TrunkPlayer-NG",
+ "nameDisplay": "Trunk Player",
"description": "A project to play back recorded radio transmissions used on site."
},
"notImplemented": {
"page": "This page has not been implemented yet!",
"component": "This component has not been implemented yet!"
},
+ "temp": {
+ "user": {
+ "name": "Trunk Player User",
+ "initials": "TU"
+ }
+ },
+ "header": {
+ "logout": {
+ "logout": "Logout",
+ "logoutProgressMessage": "Please wait while we log you out..."
+ }
+ },
"loginPage": {
"header": "Login to Trunk-Player",
"emailAddress": "Email address",
diff --git a/middleware.ts b/middleware.ts
index 90ab119..df21bca 100644
--- a/middleware.ts
+++ b/middleware.ts
@@ -8,6 +8,7 @@ const publicPages = ["/login", "/register"];
const intlMiddleware = createIntlMiddleware({
locales,
+ localePrefix: "as-needed",
defaultLocale: "en",
});
diff --git a/package-lock.json b/package-lock.json
index 303cc50..da5b772 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
"@headlessui/tailwindcss": "^0.2.0",
"@heroicons/react": "^2.1.1",
"@radix-ui/react-avatar": "^1.0.4",
+ "@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-icons": "^1.3.0",
@@ -4113,6 +4114,42 @@
}
}
},
+ "node_modules/@radix-ui/react-dialog": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz",
+ "integrity": "sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==",
+ "dependencies": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/primitive": "1.0.1",
+ "@radix-ui/react-compose-refs": "1.0.1",
+ "@radix-ui/react-context": "1.0.1",
+ "@radix-ui/react-dismissable-layer": "1.0.5",
+ "@radix-ui/react-focus-guards": "1.0.1",
+ "@radix-ui/react-focus-scope": "1.0.4",
+ "@radix-ui/react-id": "1.0.1",
+ "@radix-ui/react-portal": "1.0.4",
+ "@radix-ui/react-presence": "1.0.1",
+ "@radix-ui/react-primitive": "1.0.3",
+ "@radix-ui/react-slot": "1.0.2",
+ "@radix-ui/react-use-controllable-state": "1.0.1",
+ "aria-hidden": "^1.1.1",
+ "react-remove-scroll": "2.5.5"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0",
+ "react-dom": "^16.8 || ^17.0 || ^18.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-direction": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.0.1.tgz",
diff --git a/package.json b/package.json
index e7a919c..ded653c 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
"@headlessui/tailwindcss": "^0.2.0",
"@heroicons/react": "^2.1.1",
"@radix-ui/react-avatar": "^1.0.4",
+ "@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-icons": "^1.3.0",
diff --git a/utils/userSessionUtils.ts b/utils/userSessionUtils.ts
new file mode 100644
index 0000000..a28e39d
--- /dev/null
+++ b/utils/userSessionUtils.ts
@@ -0,0 +1,12 @@
+export const getUserInitials = (name: string | null | undefined) => {
+ if (!name) {
+ return undefined;
+ }
+
+ const names = name.split(" ");
+
+ return (
+ names[0].charAt(0).toUpperCase() +
+ names[names.length - 1].charAt(0).toUpperCase()
+ );
+};