From 4620af0e5296a98e815876cdd2b2d7b2932bcfec Mon Sep 17 00:00:00 2001 From: Fredrik Monsen <31658585+fredrikmonsen@users.noreply.github.com> Date: Fri, 13 Dec 2024 15:13:00 +0100 Subject: [PATCH] refactor/fix: split into components, fix imports and fix item loading bug after login (#12) (TT-1779) * remove unecessary useEffect in UserDetails * refactor: create context for theme * refactor: use react import, use dynamic time to refresh from token expiry variable * refactor: more usage of context for theme * refactor: split item registration page into multiple components to better follow 'thinking in react' * fix: wait to load items until user is authenticated to prevent items not being fetched * Move theme toggler to its own component * useAuth directly in userdetails component * Apply suggestion --- src/app/[id]/page.tsx | 238 +----------------- src/app/layout.tsx | 16 +- src/app/page.tsx | 9 +- src/app/providers.tsx | 10 +- src/components/Header.tsx | 68 ----- src/components/layouts/ThemeLayout.tsx | 16 ++ src/components/ui/Header.tsx | 41 +++ src/components/ui/ImageContainer.tsx | 10 + src/components/{ => ui}/ItemThumbnail.tsx | 8 +- src/components/{ => ui}/Logo.tsx | 7 +- src/components/{ => ui}/LogoutButton.tsx | 2 +- src/components/ui/ThemeToggleButton.tsx | 16 ++ src/components/ui/TitleSearchAutocomplete.tsx | 73 ++++++ src/components/{ => ui}/UserDetails.tsx | 18 +- src/features/metadata-form.tsx | 179 +++++++++++++ src/{app => providers}/AuthProvider.tsx | 12 +- src/providers/ThemeProvider.tsx | 42 ++++ 17 files changed, 435 insertions(+), 330 deletions(-) delete mode 100644 src/components/Header.tsx create mode 100644 src/components/layouts/ThemeLayout.tsx create mode 100644 src/components/ui/Header.tsx create mode 100644 src/components/ui/ImageContainer.tsx rename src/components/{ => ui}/ItemThumbnail.tsx (82%) rename src/components/{ => ui}/Logo.tsx (92%) rename src/components/{ => ui}/LogoutButton.tsx (94%) create mode 100644 src/components/ui/ThemeToggleButton.tsx create mode 100644 src/components/ui/TitleSearchAutocomplete.tsx rename src/components/{ => ui}/UserDetails.tsx (58%) create mode 100644 src/features/metadata-form.tsx rename src/{app => providers}/AuthProvider.tsx (89%) create mode 100644 src/providers/ThemeProvider.tsx diff --git a/src/app/[id]/page.tsx b/src/app/[id]/page.tsx index 89c98eb..8bc1854 100644 --- a/src/app/[id]/page.tsx +++ b/src/app/[id]/page.tsx @@ -1,66 +1,15 @@ 'use client'; -import {Key, useEffect, useState} from 'react'; -import {approveItem, deleteLock, getItemImage, getItemMetadata} from '@/services/item.data'; +import {useEffect, useState} from 'react'; import {Spinner} from '@nextui-org/spinner'; -import NextImage from 'next/image'; -import {CalendarDate, DatePicker, Image} from '@nextui-org/react'; -import {Controller, SubmitHandler, useForm} from 'react-hook-form'; -import {Input} from '@nextui-org/input'; -import {Button} from '@nextui-org/button'; import {NewspaperMetadata} from '@/models/NewspaperMetadata'; -import {parseDate, today} from '@internationalized/date'; -import {useRouter} from 'next/navigation'; -import {useAsyncList} from '@react-stately/data'; -import {CatalogTitle} from '@/models/CatalogTitle'; -import {searchNewspaperTitlesInCatalog} from '@/services/catalog.data'; -import {Autocomplete, AutocompleteItem} from '@nextui-org/autocomplete'; - -interface NewspaperFormInput { - title: string; - titleId: string; - date: CalendarDate; - editionNumber: string; - volume: string; -} +import {MetadataForm} from '@/features/metadata-form'; +import {getItemImage, getItemMetadata} from '@/services/item.data'; +import {ImageContainer} from '@/components/ui/ImageContainer'; export default function Page({params}: { params: { id: string } }) { - const { register, handleSubmit, control, setValue, formState: {errors}, } = useForm({ - mode: 'onChange' - }); const [imageSrc, setImageSrc] = useState(); const [extractedMetadata, setExtractedMetadata] = useState(); - const [loading, setLoading] = useState(true); - const [isSubmitting, setIsSubmitting] = useState(false); - const router = useRouter(); - - const onSubmit: SubmitHandler = data => { - setIsSubmitting(true); - const metadata: NewspaperMetadata = { - title: data.title, - titleId: data.titleId, - date: data.date.toString().substring(0, 10), - editionNumber: data.editionNumber, - volume: data.volume - }; - void approveItem(params.id, metadata).then(res => { - if (res.ok) { - router.push('/'); - } else { - throw new Error(`Noe gikk galt ved godkjenning: ${res.status}`); - } - }) - .then(async () => { - await handleDeleteLock(); - }) - .then(() => { - router.push('/'); - }).catch(e => { - if (e instanceof Error) { - alert(e.message); - } - }); - }; useEffect(() => { const getItem = async () => { @@ -71,188 +20,25 @@ export default function Page({params}: { params: { id: string } }) { await getItemMetadata(params.id).then(async res => { const data = await res.json() as NewspaperMetadata; setExtractedMetadata(data); - setValue('title', data.title); - setValue('titleId', data.titleId); - setLoading(false); }); }; void getItem(); - }, [params.id, setValue]); - - const titles = useAsyncList({ - async load({signal, filterText}) { - if (!filterText) { - return {items: []}; - } - const data = await searchNewspaperTitlesInCatalog(filterText, signal); - return { items: data }; - } - }); - - const dateValue = (date: Date): CalendarDate => { - return parseDate(date.toISOString()?.substring(0, 10)); - }; - - const onSelectionChange = (key: Key | null) => { - const selectedTitle = titles.items.find(title => title.catalogueId === key); - if (selectedTitle) { - setValue('title', selectedTitle.name); - setValue('titleId', selectedTitle.catalogueId); - } - }; - - const handleDeleteLock = async () => { - await deleteLock(params.id).then(res => { - if (res.ok) { - router.push('/'); - } else { - alert('Kunne ikke slette lås.'); - } - }); - }; + }, [params.id]); return (
- { loading ? + { !extractedMetadata ? : <> - { imageSrc && extractedMetadata && -
-
- Bilde -
-
-
- onSelectionChange(key)} - onInputChange={value => titles.setFilterText(value)} - allowsEmptyCollection={false} - allowsCustomValue={true} - > - { item => ( - - {item.catalogueId} -
- } - > -
- {item.name} -
- {item.startDate && ( - Fra {item.startDate} - )} - {item.endDate && ( - til {item.endDate} - )} -
-
- - )} - -
- -
void handleSubmit(onSubmit)()} className="flex flex-col gap-4"> -
- - - } - /> - - - } - /> -
- { - return value >= today('Europe/Oslo') - ? 'Datoen kan ikke være i fremtiden' - : true; - } - } - }} - defaultValue={dateValue(new Date(extractedMetadata.date))} - render={({field}) => ( - field.onChange(e)} - label="Utgivelsesdato" - variant={'bordered'} - isInvalid={!!errors.date} - errorMessage={errors.date?.message} - /> - )} - /> -
- - -
- - - + {imageSrc && +
+ +
-
} }
- ); + ) + ; } \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 0fd8e0f..97ede53 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,7 +1,10 @@ import type {Metadata} from 'next'; import './globals.css'; import {Providers} from '@/app/providers'; -import Header from '@/components/Header'; +import Header from '@/components/ui/Header'; +import {ReactNode} from 'react'; +import {ThemeLayout} from '@/components/layouts/ThemeLayout'; +import {ThemeProvider} from '@/providers/ThemeProvider'; export const metadata: Metadata = { title: 'AMMO', @@ -11,11 +14,11 @@ export const metadata: Metadata = { export default function RootLayout({ children, }: Readonly<{ - children: React.ReactNode; + children: ReactNode; }>) { return ( - - + +
@@ -29,7 +32,8 @@ export default function RootLayout({
- - +
+
+ ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 14c5dd7..2810152 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,11 +1,11 @@ 'use client'; -import ItemThumbnail from '@/components/ItemThumbnail'; +import ItemThumbnail from '@/components/ui/ItemThumbnail'; import {Spinner} from '@nextui-org/spinner'; import {useEffect, useState} from 'react'; import {ItemImage} from '@/models/ItemImage'; import {getAllItems, getAllLocks, lockItem} from '@/services/item.data'; -import {useAuth} from '@/app/AuthProvider'; +import {useAuth} from '@/providers/AuthProvider'; import {useRouter} from 'next/navigation'; import {ItemLock} from '@prisma/client'; @@ -40,8 +40,11 @@ export default function Home() { }; useEffect(() => { + if (!user) { + return; + } void getItems(); - }, []); + }, [user]); const handleItemClicked = async (id: string) => { if (!user) { diff --git a/src/app/providers.tsx b/src/app/providers.tsx index c2422e3..cedd09f 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -1,17 +1,17 @@ 'use client'; -import {AuthProvider} from '@/app/AuthProvider'; +import {AuthProvider} from '@/providers/AuthProvider'; import {NextUIProvider} from '@nextui-org/react'; -import React from 'react'; +import {ReactNode, StrictMode} from 'react'; -export function Providers({children}: { children: React.ReactNode }) { +export function Providers({children}: { children: ReactNode }) { return ( - + {children} - + ); } diff --git a/src/components/Header.tsx b/src/components/Header.tsx deleted file mode 100644 index ace7098..0000000 --- a/src/components/Header.tsx +++ /dev/null @@ -1,68 +0,0 @@ -'use client'; - -import {Link, Navbar, NavbarBrand, NavbarContent, NavbarItem} from '@nextui-org/react'; -import React, {useEffect, useState} from 'react'; -import {useRouter} from 'next/navigation'; -import LogoutButton from '@/components/LogoutButton'; -import {useAuth} from '@/app/AuthProvider'; -import {UserDetails} from '@/components/UserDetails'; -import {Switch} from '@nextui-org/switch'; -import {LuMoon, LuSun} from 'react-icons/lu'; -import Logo from '@/components/Logo'; - -export default function Header() { - const { authenticated , user } = useAuth(); - const router = useRouter(); - const [theme, setTheme] = useState<'light' | 'dark'>(); - - useEffect(() => { - const storedTheme = localStorage.getItem('theme'); - if (storedTheme) { - setTheme(storedTheme as 'light' | 'dark'); - document.documentElement.classList.toggle('dark', theme === 'dark'); - } - }, [theme]); - - const toggleTheme = () => { - setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light')); - localStorage.setItem('theme', theme === 'light' ? 'dark' : 'light'); - }; - - return ( - - - router.push('/')} - > - - - - - - - isSelected ? ( - - ) : ( - - ) - } - /> - { authenticated ? ( - <> - - - - ) : <>} - - - - ); -} diff --git a/src/components/layouts/ThemeLayout.tsx b/src/components/layouts/ThemeLayout.tsx new file mode 100644 index 0000000..6b16deb --- /dev/null +++ b/src/components/layouts/ThemeLayout.tsx @@ -0,0 +1,16 @@ +'use client'; + +import {ReactNode} from 'react'; +import {useTheme} from '@/providers/ThemeProvider'; + +export const ThemeLayout = ({ children }: { children: ReactNode }) => { + const { theme } = useTheme(); + + return ( + + + {children} + + + ); +}; \ No newline at end of file diff --git a/src/components/ui/Header.tsx b/src/components/ui/Header.tsx new file mode 100644 index 0000000..17d941d --- /dev/null +++ b/src/components/ui/Header.tsx @@ -0,0 +1,41 @@ +'use client'; + +import {Link, Navbar, NavbarBrand, NavbarContent, NavbarItem} from '@nextui-org/react'; +import React from 'react'; +import {useRouter} from 'next/navigation'; +import LogoutButton from '@/components/ui/LogoutButton'; +import {useAuth} from '@/providers/AuthProvider'; +import {UserDetails} from '@/components/ui/UserDetails'; +import Logo from '@/components/ui/Logo'; +import {ThemeToggleButton} from '@/components/ui/ThemeToggleButton'; + + +export default function Header() { + const { authenticated } = useAuth(); + const router = useRouter(); + + return ( + + + router.push('/')} + > + + + + + + + { authenticated && ( + <> + + + + )} + + + + ); +} diff --git a/src/components/ui/ImageContainer.tsx b/src/components/ui/ImageContainer.tsx new file mode 100644 index 0000000..824b22c --- /dev/null +++ b/src/components/ui/ImageContainer.tsx @@ -0,0 +1,10 @@ +import NextImage from 'next/image'; +import {Image} from '@nextui-org/react'; + +export const ImageContainer = (props: {src: string}) => { + return ( +
+ Bilde +
+ ); +}; \ No newline at end of file diff --git a/src/components/ItemThumbnail.tsx b/src/components/ui/ItemThumbnail.tsx similarity index 82% rename from src/components/ItemThumbnail.tsx rename to src/components/ui/ItemThumbnail.tsx index a537898..2c7a4ff 100644 --- a/src/components/ItemThumbnail.tsx +++ b/src/components/ui/ItemThumbnail.tsx @@ -2,7 +2,13 @@ import {ItemImage} from '@/models/ItemImage'; import Image from 'next/image'; import {LuLock} from 'react-icons/lu'; -export default function ItemThumbnail(props: {item: ItemImage; onItemClick: (id: string) => void; locked: boolean}) { +interface ItemThumbnailProps { + item: ItemImage; + onItemClick: (id: string) => void; + locked: boolean; +} + +export default function ItemThumbnail(props: ItemThumbnailProps) { return (
{ + const { theme, toggleTheme } = useTheme(); + return ( + + + + ); +}; \ No newline at end of file diff --git a/src/components/ui/TitleSearchAutocomplete.tsx b/src/components/ui/TitleSearchAutocomplete.tsx new file mode 100644 index 0000000..300cca3 --- /dev/null +++ b/src/components/ui/TitleSearchAutocomplete.tsx @@ -0,0 +1,73 @@ +import {Autocomplete, AutocompleteItem} from '@nextui-org/autocomplete'; +import {CatalogTitle} from '@/models/CatalogTitle'; +import {Key} from 'react'; +import {useAsyncList} from '@react-stately/data'; +import {searchNewspaperTitlesInCatalog} from '@/services/catalog.data'; + +interface TitleSearchAutocompleteProps { + onSelectionChange: (key: CatalogTitle | null) => void; +} + +export const TitleSearchAutocomplete = (props: TitleSearchAutocompleteProps) => { + + const titles = useAsyncList({ + async load({signal, filterText}) { + if (!filterText) { + return {items: []}; + } + const data = await searchNewspaperTitlesInCatalog(filterText, signal); + return { items: data }; + } + }); + + const handleSelectionChanged = (key: Key | null) => { + const selectedTitle = titles.items.find(title => title.catalogueId === key); + if (selectedTitle) { + props.onSelectionChange(selectedTitle); + } + }; + + return ( +
+ handleSelectionChanged(key)} + onInputChange={value => titles.setFilterText(value)} + allowsEmptyCollection={false} + allowsCustomValue={true} + > + {item => ( + + {item.catalogueId} +
+ } + > +
+ {item.name} +
+ {item.startDate && ( + Fra {item.startDate} + )} + {item.endDate && ( + til {item.endDate} + )} +
+
+ + )} + +
+ ); +}; \ No newline at end of file diff --git a/src/components/UserDetails.tsx b/src/components/ui/UserDetails.tsx similarity index 58% rename from src/components/UserDetails.tsx rename to src/components/ui/UserDetails.tsx index 514989f..5a5e066 100644 --- a/src/components/UserDetails.tsx +++ b/src/components/ui/UserDetails.tsx @@ -1,21 +1,17 @@ 'use client'; import {User} from '@nextui-org/user'; -import {useEffect, useState} from 'react'; +import {FC} from 'react'; import {Avatar} from '@nextui-org/avatar'; +import {useAuth} from '@/providers/AuthProvider'; interface UserDetailsProps { - name: string; className?: string; } -export const UserDetails: React.FC = ({ name, className }) => { - const [initials, setInitials] = useState(''); - - useEffect(() => { - const tempInitials = name.split(' ').map(n => n[0]?.toUpperCase()).join(''); - setInitials(tempInitials); - }, [name]); +export const UserDetails: FC = ({ className }) => { + const { user } = useAuth(); + const initials = user?.name.split(' ').map(n => n[0]?.toUpperCase()).join(''); return (
@@ -27,10 +23,10 @@ export const UserDetails: React.FC = ({ name, className }) =>
diff --git a/src/features/metadata-form.tsx b/src/features/metadata-form.tsx new file mode 100644 index 0000000..80f3774 --- /dev/null +++ b/src/features/metadata-form.tsx @@ -0,0 +1,179 @@ +import {CalendarDate, DatePicker} from '@nextui-org/react'; +import {Controller, SubmitHandler, useForm} from 'react-hook-form'; +import {Input} from '@nextui-org/input'; +import {parseDate, today} from '@internationalized/date'; +import {Button} from '@nextui-org/button'; +import {Spinner} from '@nextui-org/spinner'; +import {useEffect, useState} from 'react'; +import {NewspaperMetadata} from '@/models/NewspaperMetadata'; +import {useRouter} from 'next/navigation'; +import {approveItem, deleteLock} from '@/services/item.data'; +import {CatalogTitle} from '@/models/CatalogTitle'; +import {TitleSearchAutocomplete} from '@/components/ui/TitleSearchAutocomplete'; + +interface NewspaperFormInput { + title: string; + titleId: string; + date: CalendarDate; + editionNumber: string; + volume: string; +} + +interface MetadataFormProps { + id: string; + extractedMetadata: NewspaperMetadata; +} + +export const MetadataForm = (props: MetadataFormProps) => { + + const { register, handleSubmit, control, setValue, formState: {errors}, } = useForm({ + mode: 'onChange' + }); + const [isSubmitting, setIsSubmitting] = useState(false); + const router = useRouter(); + + const onSubmit: SubmitHandler = data => { + setIsSubmitting(true); + const metadata: NewspaperMetadata = { + title: data.title, + titleId: data.titleId, + date: data.date.toString().substring(0, 10), + editionNumber: data.editionNumber, + volume: data.volume + }; + void approveItem(props.id, metadata).then(res => { + if (res.ok) { + router.push('/'); + } else { + throw new Error(`Noe gikk galt ved godkjenning: ${res.status}`); + } + }) + .then(async () => { + await handleDeleteLock(); + }) + .then(() => { + router.push('/'); + }).catch(e => { + if (e instanceof Error) { + alert(e.message); + } + }); + }; + + useEffect(() => { + setValue('title', props.extractedMetadata.title); + setValue('titleId', props.extractedMetadata.titleId); + }, [props.extractedMetadata.title, props.extractedMetadata.titleId, setValue]); + + const dateValue = (date: Date): CalendarDate => { + return parseDate(date.toISOString()?.substring(0, 10)); + }; + + const onSelectionChange = (title: CatalogTitle | null) => { + if (title) { + setValue('title', title.name); + setValue('titleId', title.catalogueId); + } + }; + + const handleDeleteLock = async () => { + await deleteLock(props.id).then(res => { + if (res.ok) { + router.push('/'); + } else { + alert('Kunne ikke slette lås.'); + } + }); + }; + + return ( +
+ +
void handleSubmit(onSubmit)()} className="flex flex-col gap-4"> +
+ + + } + /> + + + } + /> +
+ { + return value >= today('Europe/Oslo') + ? 'Datoen kan ikke være i fremtiden' + : true; + } + } + }} + defaultValue={dateValue(new Date(props.extractedMetadata.date))} + render={({field}) => ( + field.onChange(e)} + label="Utgivelsesdato" + variant={'bordered'} + isInvalid={!!errors.date} + errorMessage={errors.date?.message} + /> + )} + /> +
+ + +
+ + + +
+ ) + ; +}; \ No newline at end of file diff --git a/src/app/AuthProvider.tsx b/src/providers/AuthProvider.tsx similarity index 89% rename from src/app/AuthProvider.tsx rename to src/providers/AuthProvider.tsx index 90b5244..3c0f214 100644 --- a/src/app/AuthProvider.tsx +++ b/src/providers/AuthProvider.tsx @@ -1,6 +1,6 @@ 'use client'; -import {createContext, useCallback, useContext, useEffect, useState} from 'react'; +import {createContext, ReactNode, useCallback, useContext, useEffect, useState} from 'react'; import {useRouter} from 'next/navigation'; import keycloakConfig from '@/lib/keycloak'; import {User} from '@/models/UserToken'; @@ -17,7 +17,7 @@ const AuthContext = createContext({ logout: () => {} }); -export const AuthProvider = ({children}: { children: React.ReactNode }) => { +export const AuthProvider = ({children}: { children: ReactNode }) => { const router = useRouter(); const [authenticated, setAuthenticated] = useState(false); @@ -71,12 +71,10 @@ export const AuthProvider = ({children}: { children: React.ReactNode }) => { return refresh(); }, []); - const setIntervalToRefreshAccessToken = useCallback(async () => { + const setIntervalToRefreshAccessToken = useCallback(() => { if (user?.expires && !intervalId) { const expiryTime = new Date(user?.expires).getTime() - Date.now(); - if (expiryTime < 1000 * 60 * 4.75) { - await refreshToken(); - } + setIntervalId(window.setInterval(() => { void refreshToken().then((newUser: User) => { handleIsAuthenticated(newUser); @@ -85,7 +83,7 @@ export const AuthProvider = ({children}: { children: React.ReactNode }) => { console.error('Failed to refresh token: ', e.message); handleNotAuthenticated(); }); - }, (1000 * 60 * 4.75))); // Refresh every 4.75 minutes (fifteen seconds before expiry) + }, (expiryTime - 15 * 1000))); // Refresh token 15 seconds before expiry } }, [handleNotAuthenticated, intervalId, refreshToken, user?.expires]); diff --git a/src/providers/ThemeProvider.tsx b/src/providers/ThemeProvider.tsx new file mode 100644 index 0000000..8360df9 --- /dev/null +++ b/src/providers/ThemeProvider.tsx @@ -0,0 +1,42 @@ +'use client'; + +import React, {createContext, ReactNode, useContext, useEffect, useState} from 'react'; + +export enum Theme { + Light = 'light', + Dark = 'dark' +} + +interface IThemeContext { + theme?: Theme; + toggleTheme: () => void; +} + +const ThemeContext = createContext({ + theme: Theme.Light, + toggleTheme: () => {} +}); + +export const ThemeProvider = ({ children }: {children: ReactNode}) => { + const [theme, setTheme] = useState(); + + useEffect(() => { + const storedTheme = localStorage?.getItem('theme') as Theme; + const parsedTheme = storedTheme === Theme.Light ? Theme.Light : Theme.Dark; + setTheme(parsedTheme); + }, []); + + const toggleTheme = () => { + const newTheme = theme === Theme.Light ? Theme.Dark : Theme.Light; + setTheme(newTheme); + localStorage.setItem('theme', newTheme); + }; + + return ( + + {children} + + ); +}; + +export const useTheme = () => useContext(ThemeContext);