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 (
- + Trunk-Player NG logo -

Trunk Player

+

{tApp("nameDisplay")}

- - - - - - - Trunk Player User - - - - Logout - - - + + +
); 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 ( + + + + + {logoutProgressMessageText} + + +
+ +
+ + +
+
+
+
+
+
+ ); +}; + +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 ( - - - Trunk-Player NG logo - - - ); -}; - -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({
- + Trunk-Player NG logo

{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() + ); +};