From 8324c50eb8562f488cec2d1cfbf280e77786c079 Mon Sep 17 00:00:00 2001 From: Azarchaniel Date: Mon, 4 May 2026 21:16:23 +0200 Subject: [PATCH 1/9] fix: enhance API and modal functionality, improve input handling, and address security concerns Co-authored-by: Copilot --- client/src/API.ts | 3 +- client/src/components/Modal.tsx | 17 ++++ client/src/components/inputs/Autocomplete.tsx | 84 +++++++++++++++---- client/src/components/inputs/Input.tsx | 17 ++-- client/src/pages/books/BookPage.tsx | 18 ++-- client/src/pages/quotes/QuotePage.tsx | 1 - client/src/styles/Autocomplete.scss | 4 + client/src/styles/table.scss | 5 +- client/src/utils/context/authContext.tsx | 25 +++++- server/src/controllers/booksC.ts | 11 +-- 10 files changed, 136 insertions(+), 49 deletions(-) diff --git a/client/src/API.ts b/client/src/API.ts index 0c5b3cb..4e3ed5b 100644 --- a/client/src/API.ts +++ b/client/src/API.ts @@ -183,7 +183,8 @@ export const getBooks = async (params?: any): Promise = ({ customKey, title, @@ -218,6 +220,21 @@ export const Modal: React.FC = ({ } }, [hasExplicitPosition, initialized]); + // Lock body scroll when backdrop is visible (not minimized) + useEffect(() => { + if (!minimized) { + openBackdropCount++; + document.body.style.overflow = 'hidden'; + return () => { + openBackdropCount--; + if (openBackdropCount <= 0) { + openBackdropCount = 0; + document.body.style.overflow = ''; + } + }; + } + }, [minimized]); + // Handle minimizing/maximizing the modal const toggleMinimize = useCallback(() => { // If external control is provided, use it diff --git a/client/src/components/inputs/Autocomplete.tsx b/client/src/components/inputs/Autocomplete.tsx index b744946..1189dad 100644 --- a/client/src/components/inputs/Autocomplete.tsx +++ b/client/src/components/inputs/Autocomplete.tsx @@ -83,6 +83,7 @@ export const LazyLoadMultiselect = React.memo(({ const [dropdownPosition, setDropdownPosition] = useState<'top' | 'bottom'>('bottom'); const [dropdownStyle, setDropdownStyle] = useState({}); const [isInputFocused, setIsInputFocused] = useState(false); // Track input focus + const [highlightedIndex, setHighlightedIndex] = useState(-1); // Helper function to get display text for an option (string or object) const getDisplayText = useCallback((option: OptionValue): string => { @@ -201,6 +202,7 @@ export const LazyLoadMultiselect = React.memo(({ setSearchQuery(''); setFilteredOptions([]); setCurrentPage(1); + setHighlightedIndex(-1); setIsOpen(false); if (inputRef.current) inputRef.current.focus(); }, [selectedValues, onChange, name, selectionLimit, areOptionsEqual, disabled]); @@ -254,16 +256,28 @@ export const LazyLoadMultiselect = React.memo(({ const handleKeyDown = (e: KeyboardEvent) => { if (disabled) return; if (e.key === 'Backspace' && inputValue === '' && selectedValues.length > 0) { - // Remove last chip when backspace is pressed on empty input handleRemove(selectedValues[selectedValues.length - 1]); } else if (e.key === 'Escape') { setIsOpen(false); - } else if (e.key === 'Enter') { - // On Enter: select first option if available, otherwise create new when allowed + setHighlightedIndex(-1); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + if (!isOpen) { + setIsOpen(true); + } + setHighlightedIndex(prev => + prev < filteredOptionsToDisplay.length - 1 ? prev + 1 : prev + ); + } else if (e.key === 'ArrowUp') { e.preventDefault(); - if (filteredOptionsToDisplay.length > 0) { - handleSelect(filteredOptionsToDisplay[0]); - } else if (onNew && inputValue.trim().length > 0) { + setHighlightedIndex(prev => (prev > 0 ? prev - 1 : 0)); + } else if (e.key === 'Enter') { + const idx = highlightedIndex >= 0 ? highlightedIndex : 0; + if (filteredOptionsToDisplay.length > 0 && isOpen) { + e.preventDefault(); + handleSelect(filteredOptionsToDisplay[idx]); + } else if (onNew && inputValue.trim().length > 0 && isOpen) { + e.preventDefault(); handleCreateNew(); } } @@ -282,6 +296,18 @@ export const LazyLoadMultiselect = React.memo(({ ); }, [filteredOptions, selectedValues, areOptionsEqual]); + // Reset highlight when options list changes + useEffect(() => { + setHighlightedIndex(-1); + }, [filteredOptionsToDisplay.length]); + + // Scroll highlighted item into view on keyboard navigation + useEffect(() => { + if (highlightedIndex < 0 || !menuRef.current) return; + const item = menuRef.current.querySelector(`[id$="-option-${highlightedIndex}"]`) as HTMLElement | null; + item?.scrollIntoView({ block: 'nearest' }); + }, [highlightedIndex]); + const handleInputClick = useCallback(() => { if (disabled) return; setIsOpen(true); @@ -291,6 +317,7 @@ export const LazyLoadMultiselect = React.memo(({ setFilteredOptions(getFilteredClientOptions("")); setLoadingStatus("noMore"); } else { + setLoadingStatus("loading"); debouncedSearch("", 1); } } @@ -312,20 +339,21 @@ export const LazyLoadMultiselect = React.memo(({ if (wrapperRef.current) { const rect = wrapperRef.current.getBoundingClientRect(); const windowHeight = window.innerHeight; + const menuHeight = menuRef.current?.offsetHeight || 240; const spaceBelow = windowHeight - rect.bottom; const spaceAbove = rect.top; const newStyle: React.CSSProperties = { - position: 'absolute', + position: 'fixed', width: rect.width, left: rect.left, zIndex: 9999, }; - if (spaceBelow < 200 && spaceAbove > 200) { // 200px is approx menu height + if (spaceBelow < menuHeight && spaceAbove > menuHeight) { setDropdownPosition('top'); - newStyle.top = rect.top - 200; // show above + newStyle.top = rect.top - menuHeight; } else { setDropdownPosition('bottom'); - newStyle.top = rect.bottom; // show below + newStyle.top = rect.bottom; } setDropdownStyle(newStyle); } @@ -349,11 +377,19 @@ export const LazyLoadMultiselect = React.memo(({ useEffect(() => { if (isOpen && menuRef.current) { - menuRef.current.addEventListener('scroll', handleScroll as any); - return () => menuRef?.current?.removeEventListener('scroll', handleScroll as any); + const el = menuRef.current; + el.addEventListener('scroll', handleScroll as any); + return () => el.removeEventListener('scroll', handleScroll as any); } }, [isOpen, handleScroll]); + useEffect(() => { + if (!isOpen) return; + const handleWindowScroll = () => adjustDropdownPosition(); + window.addEventListener('scroll', handleWindowScroll, true); + return () => window.removeEventListener('scroll', handleWindowScroll, true); + }, [isOpen, adjustDropdownPosition]); + return (
{resolvedPlaceholder && {getDisplayText(item)} - - × - + {!disabled && ×}
))} = 0 ? `${id ?? name}-option-${highlightedIndex}` : undefined} value={inputValue} onChange={handleInputChange} onKeyDown={handleKeyDown} @@ -412,6 +450,8 @@ export const LazyLoadMultiselect = React.memo(({ createPortal(
+ {/* loading */} + {loadingStatus === "loading" && filteredOptionsToDisplay.length === 0 && ( +
{t("loading.patience")}
+ )} {/* no data */} - {filteredOptionsToDisplay.length === 0 && ( + {filteredOptionsToDisplay.length === 0 && loadingStatus !== "loading" && (
{resolvedEmptyRecordMsg}
@@ -431,8 +475,12 @@ export const LazyLoadMultiselect = React.memo(({ {filteredOptionsToDisplay.map((option, index) => (
= selectionLimit ? 'disabled' : ''}`} + id={`${id ?? name}-option-${index}`} + role="option" + aria-selected={highlightedIndex === index} + className={`autocomplete-item${highlightedIndex === index ? ' highlighted' : ''}${selectionLimit && selectedValues?.length >= selectionLimit ? ' disabled' : ''}`} onMouseDown={disabled ? undefined : (e) => { e.preventDefault(); handleSelect(option); }} + onMouseEnter={() => setHighlightedIndex(index)} > {getDisplayText(option)}
@@ -446,7 +494,7 @@ export const LazyLoadMultiselect = React.memo(({ {searchQuery.length > 0 && onNew && (
+ data-tooltip-content={t("inputs.addRecord")}> {t("common.add")} "{searchQuery}"
)} diff --git a/client/src/components/inputs/Input.tsx b/client/src/components/inputs/Input.tsx index c1b834e..fe8b5dc 100644 --- a/client/src/components/inputs/Input.tsx +++ b/client/src/components/inputs/Input.tsx @@ -3,15 +3,16 @@ import React, { useEffect, useRef } from "react"; interface InputProps extends React.InputHTMLAttributes { customerror?: string; ref?: React.Ref; - class?: string; + innerClass?: string; } export const InputField = React.memo((props: InputProps) => { const inputRef = useRef(null); + const { innerClass, customerror, ...inputProps } = props; useEffect(() => { - if (props.customerror) { + if (customerror) { if (inputRef.current) { - (inputRef.current as any).setCustomValidity(props.customerror); + (inputRef.current as any).setCustomValidity(customerror); (inputRef.current as any).reportValidity(); } } else { @@ -19,7 +20,7 @@ export const InputField = React.memo((props: InputProps) => { (inputRef.current as any).reportValidity(); } - }, [props.customerror]); + }, [customerror]); useEffect(() => { (inputRef.current as any).blur(); // field Nazov is preselected on start, because it has error on start @@ -28,14 +29,14 @@ export const InputField = React.memo((props: InputProps) => { return (
- {props.placeholder && {props.placeholder}} + {inputProps.placeholder && {inputProps.placeholder}}
); }); \ No newline at end of file diff --git a/client/src/pages/books/BookPage.tsx b/client/src/pages/books/BookPage.tsx index 577f0b6..dc4322e 100644 --- a/client/src/pages/books/BookPage.tsx +++ b/client/src/pages/books/BookPage.tsx @@ -72,7 +72,7 @@ export default function BookPage() { const [saveBookSuccess, setSaveBookSuccess] = useState(undefined); const timeoutRef = useRef(null); const filterTimeoutRef = useRef(null); - const [controller, setController] = useState(null); + const controllerRef = useRef(null); const [selectedBooks, setSelectedBooks] = useState([]); const popRef = useRef(null); @@ -95,12 +95,11 @@ export default function BookPage() { setLoading(true); // Abort previous request - if (controller) { - controller.abort(); + if (controllerRef.current) { + controllerRef.current.abort(); } // Create new AbortController - const newController = new AbortController(); - setController(newController); + controllerRef.current = new AbortController(); // Check if data is up-to-date const { status } = await checkBooksUpdated(await getCachedTimestamp()); @@ -119,7 +118,7 @@ export default function BookPage() { } } - getBooks({ ...pagination }) + getBooks({ ...pagination, signal: controllerRef.current.signal }) .then(({ data: { books, count } }: IBook[] | any) => { setCountAll(count); const processedBooks = stringifyAutors(books); @@ -131,8 +130,10 @@ export default function BookPage() { saveFirstPageToCache(books, count, pagination); } }) - .catch(() => { - throw Error() + .catch((err: any) => { + if (err?.name === 'AbortError' || err?.message?.includes('AbortError')) return; + toast.error(err.response?.data?.error || t("books.loadError")); + console.error('Error fetching books:', err); }) .finally(() => setLoading(false)); } catch (err: any) { @@ -365,6 +366,7 @@ export default function BookPage() { setPagination((prevState) => ({ diff --git a/client/src/pages/quotes/QuotePage.tsx b/client/src/pages/quotes/QuotePage.tsx index 7f5ec91..857ef41 100644 --- a/client/src/pages/quotes/QuotePage.tsx +++ b/client/src/pages/quotes/QuotePage.tsx @@ -252,7 +252,6 @@ export default function QuotePage() {
{ setSearchText(e.target.value); diff --git a/client/src/styles/Autocomplete.scss b/client/src/styles/Autocomplete.scss index 4bcdf24..480238f 100644 --- a/client/src/styles/Autocomplete.scss +++ b/client/src/styles/Autocomplete.scss @@ -152,6 +152,10 @@ text-align: center; } + &.highlighted { + background-color: color-mix(in srgb, var(--anchor) 18%, transparent); + } + &.disabled { cursor: not-allowed !important; opacity: 0.5; diff --git a/client/src/styles/table.scss b/client/src/styles/table.scss index be72b0d..3c0fd55 100644 --- a/client/src/styles/table.scss +++ b/client/src/styles/table.scss @@ -480,7 +480,6 @@ $table-divider-color: var(--table-divider); .searchInput { width: 20rem; border-radius: 4px; - padding: 5px 4rem 5px 10px; box-shadow: none; height: 2.5rem; } @@ -509,6 +508,10 @@ $table-divider-color: var(--table-divider); } } +.searchInputInner { + padding-right: 4.5rem; +} + .filter-header { min-width: 10rem; } diff --git a/client/src/utils/context/authContext.tsx b/client/src/utils/context/authContext.tsx index f5f04fd..771ca7c 100644 --- a/client/src/utils/context/authContext.tsx +++ b/client/src/utils/context/authContext.tsx @@ -1,4 +1,4 @@ -import { createContext, useState, useContext, ReactNode, useEffect, useCallback } from 'react'; +import { createContext, useState, useContext, ReactNode, useEffect, useCallback, useMemo } from 'react'; import { IUser } from '../../type'; import { jwtDecode } from "jwt-decode"; @@ -19,7 +19,20 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { const [currentUser, setCurrentUser] = useState(null); const [isLoading, setIsLoading] = useState(true); // Start loading - // Function to check if token is valid + // Pure read-only validity check — no side effects, safe to call during render + const isTokenCurrentlyValid = useCallback((): boolean => { + const token = localStorage.getItem("token"); + if (!token) return false; + try { + const decoded = jwtDecode<{ exp?: number }>(token); + const now = Date.now() / 1000; + return !!(decoded.exp && decoded.exp >= now); + } catch { + return false; + } + }, []); + + // Full validity check with side effects (clears state & storage when expired) const checkTokenValidity = useCallback((): boolean => { const token = localStorage.getItem("token"); if (!token) { @@ -92,8 +105,12 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { // Potentially navigate to login page }, []); - // Only consider logged in if we have a user AND a valid token - const isLoggedIn = currentUser !== null && checkTokenValidity(); + // Only consider logged in if we have a user AND a valid token. + // Uses the pure (side-effect-free) check so no state setter is called during render. + const isLoggedIn = useMemo( + () => currentUser !== null && isTokenCurrentlyValid(), + [currentUser, isTokenCurrentlyValid] + ); const isGuest = currentUser?.role === 'guest'; const value = { diff --git a/server/src/controllers/booksC.ts b/server/src/controllers/booksC.ts index 976e96d..a88aa20 100644 --- a/server/src/controllers/booksC.ts +++ b/server/src/controllers/booksC.ts @@ -70,7 +70,7 @@ const normalizeBook = (data: any): IBook => { const getAllBooks = async (req: Request, res: Response): Promise => { try { - const { page = "1", pageSize = "10_000", search = "", sorting, filterUsers, filters = [] } = req.query; + const { page = "1", pageSize = "10000", search = "", sorting, filterUsers, filters = [] } = req.query; const searchFields = [ "autor", "editor", "ilustrator", "translator", "title", "subtitle", "content", "edition", "serie", "note", "published", "ISBN" @@ -97,7 +97,7 @@ const getAllBooks = async (req: Request, res: Response): Promise => { Book, { page: isNaN(parsedPage) || parsedPage < 1 ? 1 : parsedPage, - pageSize: isNaN(parsedPageSize) || parsedPageSize < 1 ? 10_000 : parsedPageSize, + pageSize: isNaN(parsedPageSize) || parsedPageSize < 1 ? 10000 : parsedPageSize, search: search as string, sorting: sorting as string, searchFields, @@ -196,7 +196,7 @@ const getBooksByIds = async (req: Request, res: Response): Promise => { const getPageByStartingLetter = async (req: Request, res: Response): Promise => { try { - let { pageSize = "10_000", filterUsers, letter = "", model } = req.query; + let { pageSize = "10000", filterUsers, letter = "", model } = req.query; if (!letter || letter.length !== 1) { res.status(500).json({ error: "Nevalidné písmeno." }); @@ -239,11 +239,6 @@ const getPageByStartingLetter = async (req: Request, res: Response): Promise 0 ? countBefore[0].count : 0; - if (position === 0) { - res.status(404).json({ error: `Nie je možné nájsť prvú pozíciu písmena "${letter}"!` }); - return; - } - // Calculate the page number const page = Math.ceil((position + 1) / parseInt(pageSize as string)); From 27bc5aeaf95320683485cc782bcb0e59ed49d9e8 Mon Sep 17 00:00:00 2001 From: Azarchaniel Date: Mon, 4 May 2026 21:28:41 +0200 Subject: [PATCH 2/9] feat: implement ErrorBoundary component, enhance error handling and user feedback fix: update localization files with load error messages fix: improve error handling in BookPage for various cancellation scenarios refactor: optimize getDB function for indexedDB with external deletion handling refactor: simplify addBook and deleteBook responses in booksC controller Co-authored-by: Copilot --- client/src/App.tsx | 17 ++++--- client/src/components/ErrorBoundary.tsx | 60 +++++++++++++++++++++++++ client/src/locales/cs.json | 3 +- client/src/locales/en.json | 3 +- client/src/locales/sk.json | 3 +- client/src/pages/books/BookPage.tsx | 8 +++- client/src/utils/indexDb.tsx | 25 +++++++++-- server/src/controllers/booksC.ts | 13 +----- 8 files changed, 106 insertions(+), 26 deletions(-) create mode 100644 client/src/components/ErrorBoundary.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index 88acf94..ffd9d3a 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -5,16 +5,19 @@ import { AuthProvider } from "@utils/context"; import { ModalProvider } from "@utils/context/ModalContext"; import Layout from "./Layout"; import { Outlet } from "react-router-dom"; +import { ErrorBoundary } from "@components/ErrorBoundary"; const App: React.FC = () => { return ( - - - - - - - + + + + + + + + + ) }; diff --git a/client/src/components/ErrorBoundary.tsx b/client/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..b109c95 --- /dev/null +++ b/client/src/components/ErrorBoundary.tsx @@ -0,0 +1,60 @@ +import React, { Component, ErrorInfo, ReactNode } from "react"; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends Component { + state: State = { hasError: false, error: null }; + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error("Uncaught error:", error, info.componentStack); + } + + render() { + if (this.state.hasError) { + return ( +
+

Something went wrong.

+

+ {this.state.error?.message} +

+ +
+ ); + } + + return this.props.children; + } +} diff --git a/client/src/locales/cs.json b/client/src/locales/cs.json index e6adebc..d2119cb 100644 --- a/client/src/locales/cs.json +++ b/client/src/locales/cs.json @@ -160,7 +160,8 @@ "scanIsbn": "Naskenuj ISBN", "searchByISBN": "Hledat podle ISBN", "searchPlaceholder": "Vyhledej knihu", - "showHideColumns": "Zobraz/skryj sloupce" + "showHideColumns": "Zobraz/skryj sloupce", + "loadError": "Chyba při načítání knih!" }, "common": { "add": "Přidat", diff --git a/client/src/locales/en.json b/client/src/locales/en.json index a7ffe4f..82bd487 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -160,7 +160,8 @@ "scanIsbn": "Scan ISBN", "searchByISBN": "Search by ISBN", "searchPlaceholder": "Search books", - "showHideColumns": "Show/hide columns" + "showHideColumns": "Show/hide columns", + "loadError": "Error loading books!" }, "common": { "add": "Add", diff --git a/client/src/locales/sk.json b/client/src/locales/sk.json index 244925c..e4a30ff 100644 --- a/client/src/locales/sk.json +++ b/client/src/locales/sk.json @@ -160,7 +160,8 @@ "scanIsbn": "Naskenuj ISBN", "searchByISBN": "Hľadať podľa ISBN", "searchPlaceholder": "Vyhľadaj knihu", - "showHideColumns": "Zobraz/skry stĺpce" + "showHideColumns": "Zobraz/skry stĺpce", + "loadError": "Chyba pri načítavaní kníh!" }, "common": { "add": "Pridať", diff --git a/client/src/pages/books/BookPage.tsx b/client/src/pages/books/BookPage.tsx index dc4322e..487bbb3 100644 --- a/client/src/pages/books/BookPage.tsx +++ b/client/src/pages/books/BookPage.tsx @@ -131,7 +131,13 @@ export default function BookPage() { } }) .catch((err: any) => { - if (err?.name === 'AbortError' || err?.message?.includes('AbortError')) return; + if ( + err?.name === 'AbortError' || + err?.name === 'CanceledError' || + err?.code === 'ERR_CANCELED' || + err?.message?.includes('AbortError') || + err?.message?.includes('canceled') + ) return; toast.error(err.response?.data?.error || t("books.loadError")); console.error('Error fetching books:', err); }) diff --git a/client/src/utils/indexDb.tsx b/client/src/utils/indexDb.tsx index 6a6e30c..21ec730 100644 --- a/client/src/utils/indexDb.tsx +++ b/client/src/utils/indexDb.tsx @@ -1,5 +1,5 @@ // src/utils/indexedDBService.ts -import { openDB, IDBPDatabase } from 'idb'; +import { openDB, deleteDB, IDBPDatabase } from 'idb'; import { IBook } from '../type'; // Define database schema @@ -24,7 +24,7 @@ let dbPromise: Promise> | null = null; /** * Initialize and get database instance */ -export const getDB = (): Promise> => { +export const getDB = async (): Promise> => { if (!dbPromise) { dbPromise = openDB(DB_NAME, DB_VERSION, { upgrade(db) { @@ -36,9 +36,28 @@ export const getDB = (): Promise> => { db.createObjectStore('metadata', { keyPath: 'key' }); } }, + terminated() { + // DB was externally deleted or connection was killed — force re-open on next access + dbPromise = null; + } + }).catch((err) => { + dbPromise = null; + throw err; }); } - return dbPromise; + + const db = await dbPromise; + + // Guard against external deletion: if the stores are gone but terminated() hasn't + // fired yet, close the stale connection, wipe the DB and re-open from scratch. + if (!db.objectStoreNames.contains('books') || !db.objectStoreNames.contains('metadata')) { + db.close(); + dbPromise = null; + await deleteDB(DB_NAME); + return getDB(); + } + + return db; }; /** diff --git a/server/src/controllers/booksC.ts b/server/src/controllers/booksC.ts index a88aa20..50e4809 100644 --- a/server/src/controllers/booksC.ts +++ b/server/src/controllers/booksC.ts @@ -272,12 +272,7 @@ const addBook = async (req: Request, res: Response): Promise => { const newBook: IBook = (await bookToSave.save()).toObject(); - const allBooks = await Book - .find(optionFetchAllExceptDeleted) - .populate(populateOptionsBook) - .exec(); - - res.status(200).json({ message: 'Book added', book: newBook, books: allBooks }) + res.status(200).json({ message: 'Book added', book: newBook }) } catch (error) { res.status(500).json({ error: "Chyba pri pridávaní knihy! " + error }); console.error("Error adding book:", error); @@ -322,15 +317,9 @@ const deleteBook = async (req: Request, res: Response): Promise => { deletedAt: new Date() } ) - const allBooks = await Book - .find(optionFetchAllExceptDeleted) - .populate(populateOptionsBook) - .exec(); - res.status(200).json({ message: 'Book deleted', book: deletedBook, - books: allBooks, }) } catch (error) { res.status(500).json({ error: "Chyba pri mazaní knihy! " + error }); From e42262630eba279620128f8fe2e34594ae393d18 Mon Sep 17 00:00:00 2001 From: Azarchaniel Date: Mon, 4 May 2026 21:38:46 +0200 Subject: [PATCH 3/9] fix: address various error handling issues and improve CORS configuration Co-authored-by: Copilot --- client/src/pages/books/BookPage.tsx | 7 ++--- client/src/utils/user.tsx | 3 --- server/src/app.ts | 10 ++++++-- server/src/controllers/booksC.ts | 40 ++++++++++++++--------------- server/src/controllers/lpC.ts | 2 +- server/src/controllers/usersC.ts | 4 +-- 6 files changed, 35 insertions(+), 31 deletions(-) diff --git a/client/src/pages/books/BookPage.tsx b/client/src/pages/books/BookPage.tsx index 487bbb3..6af0702 100644 --- a/client/src/pages/books/BookPage.tsx +++ b/client/src/pages/books/BookPage.tsx @@ -129,6 +129,7 @@ export default function BookPage() { if (checkIfFirstPage()) { saveFirstPageToCache(books, count, pagination); } + setLoading(false); }) .catch((err: any) => { if ( @@ -137,11 +138,11 @@ export default function BookPage() { err?.code === 'ERR_CANCELED' || err?.message?.includes('AbortError') || err?.message?.includes('canceled') - ) return; + ) return; // keep loading=true so the next request's result takes over toast.error(err.response?.data?.error || t("books.loadError")); console.error('Error fetching books:', err); - }) - .finally(() => setLoading(false)); + setLoading(false); + }); } catch (err: any) { toast.error(err.response?.data?.error); console.error('Error fetching books:', err); diff --git a/client/src/utils/user.tsx b/client/src/utils/user.tsx index 4deb0be..1a5bc65 100644 --- a/client/src/utils/user.tsx +++ b/client/src/utils/user.tsx @@ -111,9 +111,6 @@ export const logoutUser = async () => { localStorage.removeItem('refreshToken'); localStorage.removeItem('user'); - // Clean other data if needed - localStorage.clear(); - // Clear cached data await clearCache(); //clear IndexDB - cached Books data diff --git a/server/src/app.ts b/server/src/app.ts index e36732e..871f4a7 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -16,8 +16,14 @@ const allowedOrigins = ["http://localhost:3000", "https://webdbklp.onrender.com" app.use( cors({ origin: function (origin, callback) { - // Check if the incoming origin is in the whitelist (allow listed origins) - if (!origin || allowedOrigins.includes(origin)) { + // Block requests with no origin in production (e.g. curl, server-to-server) + if (!origin) { + if (process.env.NODE_ENV !== 'production') { + return callback(null, true); + } + return callback(new Error('CORS: missing origin')); + } + if (allowedOrigins.includes(origin)) { callback(null, true); } else { callback(new Error(`CORS not allowed for origin: ${origin}`)); diff --git a/server/src/controllers/booksC.ts b/server/src/controllers/booksC.ts index 50e4809..c6040dc 100644 --- a/server/src/controllers/booksC.ts +++ b/server/src/controllers/booksC.ts @@ -110,7 +110,7 @@ const getAllBooks = async (req: Request, res: Response): Promise => { res.status(200).json({ books: data, count }); } catch (error) { console.error("Error fetching books:", error); - res.status(500).json({ error: "Chyba pri získavaní kníh! " + error }); + res.status(500).json({ error: "Chyba pri získavaní kníh! "or }); } }; @@ -141,7 +141,7 @@ const checkBooksUpdated = async (req: Request, res: Response): Promise => res.status(200).json({ latestUpdate: latestUpdate?.updatedAt }); } catch (error) { console.error("Error checking book updates:", error); - res.status(500).json({ error: "Chyba pri získavaní informácií o dátume kníh! " + error }); + res.status(500).json({ error: "Chyba pri získavaní informácií o dátume kníh! "or }); } }; @@ -190,7 +190,7 @@ const getBooksByIds = async (req: Request, res: Response): Promise => { res.status(200).json({ books, count: totalCount }); } catch (error) { console.error("Error fetching books by ids:", error); - res.status(500).json({ error: "Chyba pri získavaní kníh! " + error }); + res.status(500).json({ error: "Chyba pri získavaní kníh! "or }); } }; @@ -245,7 +245,7 @@ const getPageByStartingLetter = async (req: Request, res: Response): Promise => { .exec(); res.status(200).json({ book: book }) } catch (err) { - res.status(500).json({ error: "Chyba pri získavaní knihy! " + err }); + res.status(500).json({ error: "Chyba pri získavaní knihy! " }); console.error("Error fetching book:", err); } } @@ -274,7 +274,7 @@ const addBook = async (req: Request, res: Response): Promise => { res.status(200).json({ message: 'Book added', book: newBook }) } catch (error) { - res.status(500).json({ error: "Chyba pri pridávaní knihy! " + error }); + res.status(500).json({ error: "Chyba pri pridávaní knihy! "or }); console.error("Error adding book:", error); } } @@ -299,7 +299,7 @@ const updateBook = async (req: Request, res: Response): Promise => { book: updateBook }) } catch (error) { - res.status(500).json({ error: "Chyba pri aktualizácii knihy! " + error }); + res.status(500).json({ error: "Chyba pri aktualizácii knihy! "or }); console.error("Error updating book:", error); } } @@ -322,7 +322,7 @@ const deleteBook = async (req: Request, res: Response): Promise => { book: deletedBook, }) } catch (error) { - res.status(500).json({ error: "Chyba pri mazaní knihy! " + error }); + res.status(500).json({ error: "Chyba pri mazaní knihy! "or }); console.error("Error deleting book:", error); } } @@ -338,11 +338,11 @@ const getInfoFromISBN = async (req: Request, res: Response): Promise => { if (bookInfo) { res.status(200).json(bookInfo); } else { - res.status(401).json({ error: "Kniha nebola nájdená." }); + res.status(404).json({ error: "Kniha nebola nájdená." }); } } catch (err: any) { - res.status(500).json({ error: "Chyba pri získavaní informácií o knihe! " + err.message }); - console.error("Problem at web scrapping: " + err); + res.status(500).json({ error: "Chyba pri získavaní informácií o knihe! " }); + console.error("Problem at web scrapping: "); } } @@ -556,7 +556,7 @@ const getUniqueFieldValues = async (_: Request, res: Response): Promise => } catch (error) { console.error("Error fetching unique field values:", error); - res.status(500).json({ error: "Chyba pri získavaní unikátnych hodnôt! " + error }); + res.status(500).json({ error: "Chyba pri získavaní unikátnych hodnôt! "or }); } }; @@ -822,7 +822,7 @@ const dashboard = { res.status(200).json(formattedResult); } catch (error: unknown) { console.error("Error while calculating statistics", error); - res.status(500).json({ error: "Chyba pri získavaní rozmerových štatistík! " + error }); + res.status(500).json({ error: "Chyba pri získavaní rozmerových štatistík! "or }); } }, getSizesGroups: async (_: Request, res: Response): Promise => { @@ -910,7 +910,7 @@ const dashboard = { }); } catch (err) { console.error("Error while getSizesGroups", err); - res.status(500).json({ error: "Chyba pri získavaní rozmerových skupín! " + err }); + res.status(500).json({ error: "Chyba pri získavaní rozmerových skupín! " }); } }, getLanguageStatistics: async (_: Request, res: Response): Promise => { @@ -965,7 +965,7 @@ const dashboard = { res.status(200).json(data); } catch (error) { console.error("Error while calculating language statistics:", error); - res.status(500).json({ error: "Chyba pri získavaní jazykových štatistík! " + error }); + res.status(500).json({ error: "Chyba pri získavaní jazykových štatistík! "or }); } }, countBooks: async (req: Request, res: Response): Promise => { @@ -1022,7 +1022,7 @@ const dashboard = { res.status(200).json(sortedData); } catch (error) { console.error("Error counting books:", error); - res.status(500).json({ error: "Chyba pri počítaní kníh! " + error }); + res.status(500).json({ error: "Chyba pri počítaní kníh! "or }); } }, getReadBy: async (_: Request, res: Response): Promise => { @@ -1071,7 +1071,7 @@ const dashboard = { res.status(200).json(sortedData); } catch (error) { console.error('Error calculating reading statistics:', error); - res.status(500).json({ error: "Chyba pri získavaní štatistík čítania! " + error }); + res.status(500).json({ error: "Chyba pri získavaní štatistík čítania! "or }); } }, getOldestBooks: async (_: Request, res: Response): Promise => { @@ -1112,7 +1112,7 @@ const dashboard = { res.status(200).json(Array.from(groupedByYear.values())); } catch (error) { console.error("Error fetching oldest books:", error); - res.status(500).json({ error: "Chyba pri získavaní najstarších kníh! " + error }); + res.status(500).json({ error: "Chyba pri získavaní najstarších kníh! "or }); } }, getNewestBooks: async (_: Request, res: Response): Promise => { @@ -1139,7 +1139,7 @@ const dashboard = { res.status(200).json(formattedBooks); } catch (error) { console.error("Error fetching newest books:", error); - res.status(500).json({ error: "Chyba pri získavaní najnovších kníh! " + error }); + res.status(500).json({ error: "Chyba pri získavaní najnovších kníh! "or }); } }, getBiggestBooks: async (req: Request, res: Response): Promise => { @@ -1245,7 +1245,7 @@ const dashboard = { res.status(200).json(formattedBooks); } catch (error) { console.error("Error fetching biggest books:", error); - res.status(500).json({ error: "Chyba pri získavaní najväčších kníh! " + error }); + res.status(500).json({ error: "Chyba pri získavaní najväčších kníh! "or }); } } } diff --git a/server/src/controllers/lpC.ts b/server/src/controllers/lpC.ts index 67fee66..7d37f98 100644 --- a/server/src/controllers/lpC.ts +++ b/server/src/controllers/lpC.ts @@ -113,7 +113,7 @@ const addLp = async (req: Request, res: Response): Promise => { }) } } catch (error) { - throw Error("Error while creating/editing LP \n" + error) + throw Error("Error while creating/editing LP \n"or) } } diff --git a/server/src/controllers/usersC.ts b/server/src/controllers/usersC.ts index 0876a67..fb1b31f 100644 --- a/server/src/controllers/usersC.ts +++ b/server/src/controllers/usersC.ts @@ -34,7 +34,7 @@ const loginUser = async (req: Request, res: Response): Promise => { const { email, password } = req.body.params; if (!email || !password) { - console.error(`All fields are required! Email: ${email}, password: ${password}`) + console.error(`All fields are required! Email: ${email}`); return res.status(403).json({ message: 'All fields are required' }) } @@ -75,7 +75,7 @@ const loginUser = async (req: Request, res: Response): Promise => { refreshTokensStore.add(refreshToken); res.cookie("token", token, { - httpOnly: false, + httpOnly: true, secure: true, sameSite: "none", }); From 6a99853bb6b62730b592c3642cb45a23280a56d3 Mon Sep 17 00:00:00 2001 From: Azarchaniel Date: Mon, 4 May 2026 21:45:31 +0200 Subject: [PATCH 4/9] fix: update refresh token handling to persist in DB and enhance security --- server/src/controllers/usersC.ts | 29 +++++++++++++---------------- server/src/models/user.ts | 1 + server/src/types/user.ts | 3 ++- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/server/src/controllers/usersC.ts b/server/src/controllers/usersC.ts index fb1b31f..ef733ee 100644 --- a/server/src/controllers/usersC.ts +++ b/server/src/controllers/usersC.ts @@ -6,9 +6,6 @@ import { sortByParam } from "../utils/utils"; import bcrypt from "bcrypt"; import jwt from 'jsonwebtoken'; -// In-memory store for refresh tokens (use DB for production) -const refreshTokensStore = new Set(); - const getAllUsers = async (_: Request, res: Response): Promise => { try { const users: IUser[] = await User.find(optionFetchAllExceptDeleted) @@ -71,8 +68,8 @@ const loginUser = async (req: Request, res: Response): Promise => { { expiresIn: '3d' } // Long-lived ); - // Store refresh token - refreshTokensStore.add(refreshToken); + // Persist refresh token in DB + await User.updateOne({ _id: user._id }, { $push: { refreshTokens: refreshToken } }); res.cookie("token", token, { httpOnly: true, @@ -97,15 +94,15 @@ const refreshToken = async (req: Request, res: Response): Promise => { return res.status(400).json({ message: 'Refresh token is required' }); } - // Check if refresh token is in store - if (!refreshTokensStore.has(refreshToken)) { - return res.status(401).json({ message: 'Invalid refresh token' }); - } - try { - // Verify the refresh token + // Verify the refresh token signature and expiry const decoded = jwt.verify(refreshToken, `${process.env.REFRESH_TOKEN_SECRET}`) as CustomJwtPayload; - if (!decoded) return res.status(401).json({ message: 'Invalid refresh token' }); + + // Check token is still stored (not revoked/logged out) + const user = await User.findOne({ _id: decoded.userId, refreshTokens: refreshToken }); + if (!user) { + return res.status(401).json({ message: 'Invalid refresh token' }); + } // Issue a new access token only (do not issue a new refresh token) const newAccessToken = jwt.sign( @@ -116,17 +113,17 @@ const refreshToken = async (req: Request, res: Response): Promise => { return res.status(200).json({ accessToken: newAccessToken }); } catch (error) { - // Remove invalid/expired token - refreshTokensStore.delete(refreshToken); + // Remove invalid/expired token from DB + await User.updateOne({ refreshTokens: refreshToken }, { $pull: { refreshTokens: refreshToken } }); console.error("Error while refreshing token", error); - return res.status(500).json({ message: 'Internal server error' }); + return res.status(401).json({ message: 'Invalid refresh token' }); } } const logoutUser = async (req: Request, res: Response): Promise => { const { refreshToken } = req.body; if (refreshToken) { - refreshTokensStore.delete(refreshToken); + await User.updateOne({ refreshTokens: refreshToken }, { $pull: { refreshTokens: refreshToken } }); } return res.status(200).json({ message: 'Logged out' }); } diff --git a/server/src/models/user.ts b/server/src/models/user.ts index a44ed1e..6302e92 100644 --- a/server/src/models/user.ts +++ b/server/src/models/user.ts @@ -8,6 +8,7 @@ const userSchema: Schema = new Schema({ hashedPassword: { type: String }, deletedAt: { type: Date, default: null, required: false }, role: { type: String, enum: ['user', 'guest'], default: 'user', required: false }, + refreshTokens: { type: [String], default: [] }, }, { timestamps: true }); export default model('User', userSchema); \ No newline at end of file diff --git a/server/src/types/user.ts b/server/src/types/user.ts index 1125241..60e80bd 100644 --- a/server/src/types/user.ts +++ b/server/src/types/user.ts @@ -7,7 +7,8 @@ export interface IUser extends Document { email: string, hashedPassword: string, deletedAt?: Date | null, - role?: 'user' | 'guest' + role?: 'user' | 'guest', + refreshTokens?: string[] } export interface CustomJwtPayload extends JwtPayload { From eb1052dbaf4ced2d4b8ea0200d7b40570db35717 Mon Sep 17 00:00:00 2001 From: Azarchaniel Date: Mon, 4 May 2026 22:13:10 +0200 Subject: [PATCH 5/9] fix: remove jwt-decode dependency and refactor token handling to use cookies for authentication Co-authored-by: Copilot --- client/package-lock.json | 10 -- client/package.json | 1 - client/src/API.ts | 122 ++++++---------------- client/src/utils/context/authContext.tsx | 42 +++----- client/src/utils/hooks/useClickOutside.ts | 4 +- client/src/utils/user.tsx | 77 +++++--------- server/package-lock.json | 42 ++++++-- server/package.json | 4 +- server/src/app.ts | 2 + server/src/controllers/booksC.ts | 32 +++--- server/src/controllers/lpC.ts | 2 +- server/src/controllers/usersC.ts | 69 ++++++++---- server/src/middleware/index.ts | 5 +- 13 files changed, 172 insertions(+), 240 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index fb74147..3ceab7f 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -33,7 +33,6 @@ "i18next": "^23.12.2", "i18next-browser-languagedetector": "^7.2.1", "idb": "^8.0.2", - "jwt-decode": "^4.0.0", "npm-check-updates": "^17.1.11", "react": "^18.0.0", "react-datepicker": "^7.5.0", @@ -15954,15 +15953,6 @@ "node": ">=4.0" } }, - "node_modules/jwt-decode": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", - "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", diff --git a/client/package.json b/client/package.json index 5413f25..be714d6 100644 --- a/client/package.json +++ b/client/package.json @@ -29,7 +29,6 @@ "i18next": "^23.12.2", "i18next-browser-languagedetector": "^7.2.1", "idb": "^8.0.2", - "jwt-decode": "^4.0.0", "npm-check-updates": "^17.1.11", "react": "^18.0.0", "react-datepicker": "^7.5.0", diff --git a/client/src/API.ts b/client/src/API.ts index 4e3ed5b..390ca0d 100644 --- a/client/src/API.ts +++ b/client/src/API.ts @@ -11,7 +11,6 @@ import { ILP, IUser } from "./type"; -import { jwtDecode } from "jwt-decode"; const baseUrl: string = process.env.REACT_APP_API_BASE_URL || "http://localhost:4000"; const BATCH_SIZE = 5; @@ -42,108 +41,51 @@ axiosInstance.interceptors.request.use( config.url?.includes(route) && config.method?.toLowerCase() === 'get' ); - // Skip authentication for public GET routes - if (isPublicRoute) { - config.headers = { - ...config.headers, - "Content-Type": "application/json" - }; - return config; - } - - let token = localStorage.getItem("token"); - const refreshToken = localStorage.getItem('refreshToken'); - - // If no refreshToken (e.g. guest), attach the token as-is and skip refresh - if (!refreshToken) { - if (token) { - config.headers = { - ...config.headers, - "Content-Type": "application/json", - Authorization: `Bearer ${token}` - }; - } - return config; - } - - if (token) { - config.headers = { - ...config.headers, - "Content-Type": "application/json", - Authorization: `Bearer ${token}` // Attach token to Authorization header - }; - - const { exp } = jwtDecode(token); - const now = Date.now() / 1000; - - if (!exp) { - console.error("Token has no expiration date!"); - return config; - } - - if (exp - now < 60) { - const response = await axios.post(baseUrl + '/refresh-token', { - refreshToken: localStorage.getItem('refreshToken'), - }); - // Update only the access token, do not update refresh token - token = response.data.accessToken; - localStorage.setItem('token', token!); - config.headers.Authorization = `Bearer ${token}`; + config.headers = { ...config.headers, "Content-Type": "application/json" }; + + // Skip proactive refresh for public routes or guests (no refresh token cookie) + const userStr = localStorage.getItem('user'); + const user = userStr ? JSON.parse(userStr) : null; + const isGuest = user?.role === 'guest'; + + if (!isPublicRoute && !isGuest) { + const tokenExpiresAt = localStorage.getItem('tokenExpiresAt'); + if (tokenExpiresAt) { + const expiresAt = parseInt(tokenExpiresAt, 10); + const now = Date.now() / 1000; + if (expiresAt - now < 60) { + try { + // Refresh access token — server reads refreshToken cookie and sets new token cookie + const response = await axios.post(baseUrl + '/refresh-token', {}, { withCredentials: true }); + if (response.data.tokenExpiresAt) { + localStorage.setItem('tokenExpiresAt', response.data.tokenExpiresAt.toString()); + } + } catch (e) { + // Refresh failed — let the request continue; the server will 401 if needed + console.warn('Proactive token refresh failed:', e); + } + } } } - if (token) { - config.headers!.Authorization = `Bearer ${token}`; - } - return config; }, (error) => { - // Handle request error console.error("Request error:", error); return Promise.reject(error); } ); -let lastLogTime = 0; - axiosInstance.interceptors.response.use( - (response: AxiosResponse) => { - // Just return the response if everything goes well - return response; - }, + (response: AxiosResponse) => response, (error) => { - // Handle unauthorized errors (token expired) if (error.response?.status === 401) { - console.warn("Unauthorized! Token may be expired."); - - // Clear auth data if unauthorized - const token = localStorage.getItem('token'); - if (token) { - try { - const { exp } = jwtDecode(token); - const now = Date.now() / 1000; - if (!exp || exp < now) { - // Clear all auth data if token is expired - localStorage.removeItem('token'); - localStorage.removeItem('refreshToken'); - localStorage.removeItem('user'); - - // Force page reload to update auth state - window.location.href = '/'; - return Promise.reject(new Error('Session expired. Please log in again.')); - } - } catch (e) { - console.error("Error decoding token:", e); - } - } + console.warn("Unauthorized! Session may have expired."); + localStorage.removeItem('user'); + localStorage.removeItem('tokenExpiresAt'); + window.location.href = '/'; + return Promise.reject(new Error('Session expired. Please log in again.')); } - - const now = Date.now(); - if (now - lastLogTime > 10000) { // Check if 10 seconds have passed since the last log - lastLogTime = now; // Update the last log time - } - return Promise.reject(error); } ); @@ -580,8 +522,8 @@ export const login = async ( } } -export const logout = async (refreshToken: string): Promise => { - return axiosInstance.post(baseUrl + "/logout", { refreshToken }); +export const logout = async (): Promise => { + return axiosInstance.post(baseUrl + "/logout"); }; export const loginGuest = async (): Promise> => { diff --git a/client/src/utils/context/authContext.tsx b/client/src/utils/context/authContext.tsx index 771ca7c..66dc4ca 100644 --- a/client/src/utils/context/authContext.tsx +++ b/client/src/utils/context/authContext.tsx @@ -1,6 +1,5 @@ import { createContext, useState, useContext, ReactNode, useEffect, useCallback, useMemo } from 'react'; import { IUser } from '../../type'; -import { jwtDecode } from "jwt-decode"; interface AuthContextType { currentUser: IUser | null; @@ -21,45 +20,28 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { // Pure read-only validity check — no side effects, safe to call during render const isTokenCurrentlyValid = useCallback((): boolean => { - const token = localStorage.getItem("token"); - if (!token) return false; - try { - const decoded = jwtDecode<{ exp?: number }>(token); - const now = Date.now() / 1000; - return !!(decoded.exp && decoded.exp >= now); - } catch { - return false; - } + const tokenExpiresAt = localStorage.getItem("tokenExpiresAt"); + if (!tokenExpiresAt) return false; + const now = Date.now() / 1000; + return parseInt(tokenExpiresAt, 10) >= now; }, []); // Full validity check with side effects (clears state & storage when expired) const checkTokenValidity = useCallback((): boolean => { - const token = localStorage.getItem("token"); - if (!token) { + const tokenExpiresAt = localStorage.getItem("tokenExpiresAt"); + if (!tokenExpiresAt) { + setCurrentUser(null); return false; } - try { - const decoded = jwtDecode<{ exp?: number }>(token); - const now = Date.now() / 1000; - - if (!decoded.exp || decoded.exp < now) { - // Token expired: clear user state and storage - localStorage.removeItem("token"); - localStorage.removeItem("refreshToken"); - localStorage.removeItem("user"); - setCurrentUser(null); - return false; - } - return true; - } catch (error) { - console.error("Error decoding token:", error); - localStorage.removeItem("token"); - localStorage.removeItem("refreshToken"); + const now = Date.now() / 1000; + if (parseInt(tokenExpiresAt, 10) < now) { + localStorage.removeItem("tokenExpiresAt"); localStorage.removeItem("user"); setCurrentUser(null); return false; } + return true; }, []); // Effect to load user from localStorage on initial mount and verify token @@ -100,9 +82,9 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { // Logout function clears state and localStorage const logout = useCallback(() => { + localStorage.removeItem('tokenExpiresAt'); localStorage.removeItem('user'); setCurrentUser(null); - // Potentially navigate to login page }, []); // Only consider logged in if we have a user AND a valid token. diff --git a/client/src/utils/hooks/useClickOutside.ts b/client/src/utils/hooks/useClickOutside.ts index e1aff85..8062ac7 100644 --- a/client/src/utils/hooks/useClickOutside.ts +++ b/client/src/utils/hooks/useClickOutside.ts @@ -1,4 +1,4 @@ -import {useEffect, RefObject} from 'react'; +import { useEffect, RefObject } from 'react'; type Handler = (event: MouseEvent) => void; @@ -21,11 +21,9 @@ export function useClickOutside( }; document.addEventListener('mousedown', listener); - //document.addEventListener('touchstart', listener); return () => { document.removeEventListener('mousedown', listener); - //document.removeEventListener('touchstart', listener); }; }, [ref, handler, exceptRef]); } diff --git a/client/src/utils/user.tsx b/client/src/utils/user.tsx index 1a5bc65..8da9e4d 100644 --- a/client/src/utils/user.tsx +++ b/client/src/utils/user.tsx @@ -1,56 +1,34 @@ -import { jwtDecode } from "jwt-decode"; import { login, loginGuest, logout as apiLogout } from "../API"; import { clearCache } from "./indexDb"; let lastLogTime = 0; export const isUserLoggedIn = () => { - const token = localStorage.getItem('token'); + const tokenExpiresAt = localStorage.getItem('tokenExpiresAt'); const user = localStorage.getItem('user'); - if (!token || !user) { + if (!tokenExpiresAt || !user) { const now = Date.now(); - if (now - lastLogTime > 10000) { // Check if 10 seconds have passed since the last log - console.error('No token or user found. Please login to continue.'); - lastLogTime = now; // Update the last log time + if (now - lastLogTime > 10000) { + console.error('No session found. Please login to continue.'); + lastLogTime = now; } // Clear any incomplete auth state - if (!token && user) { - localStorage.removeItem('user'); - } - - if (token && !user) { - localStorage.removeItem('token'); - localStorage.removeItem('refreshToken'); - } + if (!tokenExpiresAt && user) localStorage.removeItem('user'); + if (tokenExpiresAt && !user) localStorage.removeItem('tokenExpiresAt'); - return false; // No token or user, not logged in + return false; } - try { - const decoded = jwtDecode<{ exp?: number }>(token); - const now = Math.floor(Date.now() / 1000); - - // Check if token is expired - if (decoded.exp && now > decoded.exp) { - // Token expired, clear all auth data - localStorage.removeItem('token'); - localStorage.removeItem('refreshToken'); - localStorage.removeItem('user'); - return false; - } - - // Token exists and is valid - return true; - } catch (error) { - console.error("Error decoding token:", error); - // Invalid token, clear auth data - localStorage.removeItem('token'); - localStorage.removeItem('refreshToken'); + const now = Math.floor(Date.now() / 1000); + if (parseInt(tokenExpiresAt, 10) < now) { + localStorage.removeItem('tokenExpiresAt'); localStorage.removeItem('user'); return false; } + + return true; }; export const loginUser = async (loginForm: { @@ -58,15 +36,13 @@ export const loginUser = async (loginForm: { password: string }) => { try { - const res = await login(loginForm); // Make the API call (assumes `login` is defined elsewhere) + const res = await login(loginForm); if (res.status === 200) { // @ts-ignore - const { token, refreshToken, user } = res.data; + const { user, tokenExpiresAt } = res.data; - // Save token to localStorage or sessionStorage - localStorage.setItem('token', token); - localStorage.setItem('refreshToken', refreshToken); + localStorage.setItem('tokenExpiresAt', tokenExpiresAt.toString()); localStorage.setItem('user', JSON.stringify(user)); return user; } else { @@ -83,8 +59,8 @@ export const loginGuestUser = async () => { if (res.status === 200) { // @ts-ignore - const { token, user } = res.data; - localStorage.setItem('token', token); + const { user, tokenExpiresAt } = res.data; + localStorage.setItem('tokenExpiresAt', tokenExpiresAt.toString()); localStorage.setItem('user', JSON.stringify(user)); return user; } else { @@ -96,19 +72,14 @@ export const loginGuestUser = async () => { } export const logoutUser = async () => { - const refreshToken = localStorage.getItem('refreshToken'); - if (refreshToken) { - try { - await apiLogout(refreshToken); - } catch (e) { - console.warn("Error during API logout:", e); - // Ignore errors, proceed with local cleanup - } + try { + await apiLogout(); + } catch (e) { + console.warn("Error during API logout:", e); + // Ignore errors, proceed with local cleanup } - // Explicitly remove authentication items - localStorage.removeItem('token'); - localStorage.removeItem('refreshToken'); + localStorage.removeItem('tokenExpiresAt'); localStorage.removeItem('user'); // Clear cached data diff --git a/server/package-lock.json b/server/package-lock.json index 30bd004..6ad63f4 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -7,8 +7,10 @@ "": { "name": "WebDBKLP", "version": "1.0.0", + "hasInstallScript": true, "license": "ISC", "dependencies": { + "@types/cookie-parser": "^1.4.10", "@types/mongodb": "^4.0.7", "@types/mongoose": "^5.11.97", "@types/node": "^22.9.3", @@ -16,6 +18,7 @@ "axios": "^1.8.1", "bcrypt": "^6.0.0", "bson": "^6.10.3", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "diacritics": "^1.3.0", "express": "^4.18.2", @@ -1478,7 +1481,6 @@ "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, "license": "MIT", "dependencies": { "@types/connect": "*", @@ -1489,12 +1491,20 @@ "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -1516,7 +1526,6 @@ "version": "4.17.25", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", - "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", @@ -1529,7 +1538,6 @@ "version": "4.19.8", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -1542,7 +1550,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, "license": "MIT" }, "node_modules/@types/jsonwebtoken": { @@ -1560,7 +1567,6 @@ "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, "license": "MIT" }, "node_modules/@types/mongodb": { @@ -1603,21 +1609,18 @@ "version": "6.15.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", - "dev": true, "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -1627,7 +1630,6 @@ "version": "1.15.10", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", - "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", @@ -1639,7 +1641,6 @@ "version": "0.17.6", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", - "dev": true, "license": "MIT", "dependencies": { "@types/mime": "^1", @@ -2268,6 +2269,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", diff --git a/server/package.json b/server/package.json index 736d631..a82bd30 100644 --- a/server/package.json +++ b/server/package.json @@ -16,6 +16,7 @@ "author": "", "license": "ISC", "dependencies": { + "@types/cookie-parser": "^1.4.10", "@types/mongodb": "^4.0.7", "@types/mongoose": "^5.11.97", "@types/node": "^22.9.3", @@ -23,6 +24,7 @@ "axios": "^1.8.1", "bcrypt": "^6.0.0", "bson": "^6.10.3", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "diacritics": "^1.3.0", "express": "^4.18.2", @@ -42,4 +44,4 @@ "nodemon": "^3.1.7", "typescript": "^5.7.2" } -} \ No newline at end of file +} diff --git a/server/src/app.ts b/server/src/app.ts index 871f4a7..f34f13e 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -1,6 +1,7 @@ import express, { Express } from "express" import mongoose from "mongoose" import cors from "cors" +import cookieParser from "cookie-parser" import routes from "./routes" import path from "path"; @@ -34,6 +35,7 @@ app.use( ); +app.use(cookieParser()); app.use(express.json({ limit: "20mb" })); app.use(express.urlencoded({ limit: "20mb", extended: true })); app.use(routes) diff --git a/server/src/controllers/booksC.ts b/server/src/controllers/booksC.ts index c6040dc..fcaf484 100644 --- a/server/src/controllers/booksC.ts +++ b/server/src/controllers/booksC.ts @@ -110,7 +110,7 @@ const getAllBooks = async (req: Request, res: Response): Promise => { res.status(200).json({ books: data, count }); } catch (error) { console.error("Error fetching books:", error); - res.status(500).json({ error: "Chyba pri získavaní kníh! "or }); + res.status(500).json({ error: "Chyba pri získavaní kníh! " }); } }; @@ -141,7 +141,7 @@ const checkBooksUpdated = async (req: Request, res: Response): Promise => res.status(200).json({ latestUpdate: latestUpdate?.updatedAt }); } catch (error) { console.error("Error checking book updates:", error); - res.status(500).json({ error: "Chyba pri získavaní informácií o dátume kníh! "or }); + res.status(500).json({ error: "Chyba pri získavaní informácií o dátume kníh! " }); } }; @@ -190,7 +190,7 @@ const getBooksByIds = async (req: Request, res: Response): Promise => { res.status(200).json({ books, count: totalCount }); } catch (error) { console.error("Error fetching books by ids:", error); - res.status(500).json({ error: "Chyba pri získavaní kníh! "or }); + res.status(500).json({ error: "Chyba pri získavaní kníh! " }); } }; @@ -245,7 +245,7 @@ const getPageByStartingLetter = async (req: Request, res: Response): Promise => { res.status(200).json({ message: 'Book added', book: newBook }) } catch (error) { - res.status(500).json({ error: "Chyba pri pridávaní knihy! "or }); + res.status(500).json({ error: "Chyba pri pridávaní knihy! " }); console.error("Error adding book:", error); } } @@ -299,7 +299,7 @@ const updateBook = async (req: Request, res: Response): Promise => { book: updateBook }) } catch (error) { - res.status(500).json({ error: "Chyba pri aktualizácii knihy! "or }); + res.status(500).json({ error: "Chyba pri aktualizácii knihy! " }); console.error("Error updating book:", error); } } @@ -322,7 +322,7 @@ const deleteBook = async (req: Request, res: Response): Promise => { book: deletedBook, }) } catch (error) { - res.status(500).json({ error: "Chyba pri mazaní knihy! "or }); + res.status(500).json({ error: "Chyba pri mazaní knihy! " }); console.error("Error deleting book:", error); } } @@ -338,7 +338,7 @@ const getInfoFromISBN = async (req: Request, res: Response): Promise => { if (bookInfo) { res.status(200).json(bookInfo); } else { - res.status(404).json({ error: "Kniha nebola nájdená." }); + res.status(401).json({ error: "Kniha nebola nájdená." }); } } catch (err: any) { res.status(500).json({ error: "Chyba pri získavaní informácií o knihe! " }); @@ -556,7 +556,7 @@ const getUniqueFieldValues = async (_: Request, res: Response): Promise => } catch (error) { console.error("Error fetching unique field values:", error); - res.status(500).json({ error: "Chyba pri získavaní unikátnych hodnôt! "or }); + res.status(500).json({ error: "Chyba pri získavaní unikátnych hodnôt! " }); } }; @@ -822,7 +822,7 @@ const dashboard = { res.status(200).json(formattedResult); } catch (error: unknown) { console.error("Error while calculating statistics", error); - res.status(500).json({ error: "Chyba pri získavaní rozmerových štatistík! "or }); + res.status(500).json({ error: "Chyba pri získavaní rozmerových štatistík! " }); } }, getSizesGroups: async (_: Request, res: Response): Promise => { @@ -965,7 +965,7 @@ const dashboard = { res.status(200).json(data); } catch (error) { console.error("Error while calculating language statistics:", error); - res.status(500).json({ error: "Chyba pri získavaní jazykových štatistík! "or }); + res.status(500).json({ error: "Chyba pri získavaní jazykových štatistík! " }); } }, countBooks: async (req: Request, res: Response): Promise => { @@ -1022,7 +1022,7 @@ const dashboard = { res.status(200).json(sortedData); } catch (error) { console.error("Error counting books:", error); - res.status(500).json({ error: "Chyba pri počítaní kníh! "or }); + res.status(500).json({ error: "Chyba pri počítaní kníh! " }); } }, getReadBy: async (_: Request, res: Response): Promise => { @@ -1071,7 +1071,7 @@ const dashboard = { res.status(200).json(sortedData); } catch (error) { console.error('Error calculating reading statistics:', error); - res.status(500).json({ error: "Chyba pri získavaní štatistík čítania! "or }); + res.status(500).json({ error: "Chyba pri získavaní štatistík čítania! " }); } }, getOldestBooks: async (_: Request, res: Response): Promise => { @@ -1112,7 +1112,7 @@ const dashboard = { res.status(200).json(Array.from(groupedByYear.values())); } catch (error) { console.error("Error fetching oldest books:", error); - res.status(500).json({ error: "Chyba pri získavaní najstarších kníh! "or }); + res.status(500).json({ error: "Chyba pri získavaní najstarších kníh! " }); } }, getNewestBooks: async (_: Request, res: Response): Promise => { @@ -1139,7 +1139,7 @@ const dashboard = { res.status(200).json(formattedBooks); } catch (error) { console.error("Error fetching newest books:", error); - res.status(500).json({ error: "Chyba pri získavaní najnovších kníh! "or }); + res.status(500).json({ error: "Chyba pri získavaní najnovších kníh! " }); } }, getBiggestBooks: async (req: Request, res: Response): Promise => { @@ -1245,7 +1245,7 @@ const dashboard = { res.status(200).json(formattedBooks); } catch (error) { console.error("Error fetching biggest books:", error); - res.status(500).json({ error: "Chyba pri získavaní najväčších kníh! "or }); + res.status(500).json({ error: "Chyba pri získavaní najväčších kníh! " }); } } } diff --git a/server/src/controllers/lpC.ts b/server/src/controllers/lpC.ts index 7d37f98..4673256 100644 --- a/server/src/controllers/lpC.ts +++ b/server/src/controllers/lpC.ts @@ -113,7 +113,7 @@ const addLp = async (req: Request, res: Response): Promise => { }) } } catch (error) { - throw Error("Error while creating/editing LP \n"or) + throw Error("Error while creating/editing LP \n") } } diff --git a/server/src/controllers/usersC.ts b/server/src/controllers/usersC.ts index ef733ee..b63fc02 100644 --- a/server/src/controllers/usersC.ts +++ b/server/src/controllers/usersC.ts @@ -62,44 +62,47 @@ const loginUser = async (req: Request, res: Response): Promise => { expiresIn: '3h', }); - const refreshToken = jwt.sign( + const newRefreshToken = jwt.sign( { userId: user._id }, `${process.env.REFRESH_TOKEN_SECRET}`!, { expiresIn: '3d' } // Long-lived ); // Persist refresh token in DB - await User.updateOne({ _id: user._id }, { $push: { refreshTokens: refreshToken } }); + await User.updateOne({ _id: user._id }, { $push: { refreshTokens: newRefreshToken } }); + + const tokenExpiresAt = Math.floor(Date.now() / 1000) + 3 * 60 * 60; res.cookie("token", token, { httpOnly: true, secure: true, sameSite: "none", + maxAge: 3 * 60 * 60 * 1000, + }); + + res.cookie("refreshToken", newRefreshToken, { + httpOnly: true, + secure: true, + sameSite: "none", + maxAge: 3 * 24 * 60 * 60 * 1000, }); - return res.status(200).json({ token, refreshToken, user: user }); - //return res.status(200).json({ - // token, - // userId: user._id, - // email: user.email, - // roles: user.roles, // Example: ['user', 'admin'] - // expiresIn: 60 * 60, // Expiry in seconds - // }); + return res.status(200).json({ user, tokenExpiresAt }); } -const refreshToken = async (req: Request, res: Response): Promise => { - const { refreshToken } = req.body; +const handleRefreshToken = async (req: Request, res: Response): Promise => { + const refreshTokenValue: string | undefined = req.cookies?.refreshToken; - if (!refreshToken) { + if (!refreshTokenValue) { return res.status(400).json({ message: 'Refresh token is required' }); } try { // Verify the refresh token signature and expiry - const decoded = jwt.verify(refreshToken, `${process.env.REFRESH_TOKEN_SECRET}`) as CustomJwtPayload; + const decoded = jwt.verify(refreshTokenValue, `${process.env.REFRESH_TOKEN_SECRET}`) as CustomJwtPayload; // Check token is still stored (not revoked/logged out) - const user = await User.findOne({ _id: decoded.userId, refreshTokens: refreshToken }); + const user = await User.findOne({ _id: decoded.userId, refreshTokens: refreshTokenValue }); if (!user) { return res.status(401).json({ message: 'Invalid refresh token' }); } @@ -111,20 +114,33 @@ const refreshToken = async (req: Request, res: Response): Promise => { { expiresIn: '15m' } ); - return res.status(200).json({ accessToken: newAccessToken }); + const tokenExpiresAt = Math.floor(Date.now() / 1000) + 15 * 60; + + res.cookie("token", newAccessToken, { + httpOnly: true, + secure: true, + sameSite: "none", + maxAge: 15 * 60 * 1000, + }); + + return res.status(200).json({ tokenExpiresAt }); } catch (error) { // Remove invalid/expired token from DB - await User.updateOne({ refreshTokens: refreshToken }, { $pull: { refreshTokens: refreshToken } }); + await User.updateOne({ refreshTokens: refreshTokenValue }, { $pull: { refreshTokens: refreshTokenValue } }); + res.clearCookie("token", { httpOnly: true, secure: true, sameSite: "none" }); + res.clearCookie("refreshToken", { httpOnly: true, secure: true, sameSite: "none" }); console.error("Error while refreshing token", error); return res.status(401).json({ message: 'Invalid refresh token' }); } } const logoutUser = async (req: Request, res: Response): Promise => { - const { refreshToken } = req.body; - if (refreshToken) { - await User.updateOne({ refreshTokens: refreshToken }, { $pull: { refreshTokens: refreshToken } }); + const refreshTokenValue: string | undefined = req.cookies?.refreshToken; + if (refreshTokenValue) { + await User.updateOne({ refreshTokens: refreshTokenValue }, { $pull: { refreshTokens: refreshTokenValue } }); } + res.clearCookie("token", { httpOnly: true, secure: true, sameSite: "none" }); + res.clearCookie("refreshToken", { httpOnly: true, secure: true, sameSite: "none" }); return res.status(200).json({ message: 'Logged out' }); } @@ -140,9 +156,18 @@ const loginGuest = (_: Request, res: Response): Response => { { expiresIn: '24h' } ); + const tokenExpiresAt = Math.floor(Date.now() / 1000) + 24 * 60 * 60; + + res.cookie("token", token, { + httpOnly: true, + secure: true, + sameSite: "none", + maxAge: 24 * 60 * 60 * 1000, + }); + const guestUser = { _id: 'guest', firstName: 'Guest', lastName: '', email: 'guest', role: 'guest' }; - return res.status(200).json({ token, refreshToken: null, user: guestUser }); + return res.status(200).json({ tokenExpiresAt, user: guestUser }); } -export { getAllUsers, getUser, loginUser, loginGuest, refreshToken, logoutUser }; +export { getAllUsers, getUser, loginUser, loginGuest, handleRefreshToken as refreshToken, logoutUser }; diff --git a/server/src/middleware/index.ts b/server/src/middleware/index.ts index c5b2ecd..433b55a 100644 --- a/server/src/middleware/index.ts +++ b/server/src/middleware/index.ts @@ -9,7 +9,8 @@ interface CustomVerificationResponse extends Response { } const userVerification = (req: Request, res: Response, next: NextFunction) => { - const token = req?.headers?.authorization?.split(" ")[1]; + // Prefer httpOnly cookie; fall back to Authorization header (e.g. non-browser clients) + const token = req.cookies?.token ?? req?.headers?.authorization?.split(" ")[1]; if (!token) return res.status(401).send("Unauthorized"); @@ -22,7 +23,7 @@ const userVerification = (req: Request, res: Response, next: NextFunction) => { } const blockGuest = (req: Request, res: Response, next: NextFunction) => { - const token = req?.headers?.authorization?.split(" ")[1]; + const token = req.cookies?.token ?? req?.headers?.authorization?.split(" ")[1]; if (!token) return next(); try { const decoded = jwt.verify(token, `${process.env.SECRET_KEY}`) as JwtPayload; From e5857e5365c563a2d2fa7a507444820bfdc55e8c Mon Sep 17 00:00:00 2001 From: Azarchaniel Date: Mon, 4 May 2026 22:46:53 +0200 Subject: [PATCH 6/9] fix: update pagination limits to improve performance and consistency across API endpoints; various other fixes Co-authored-by: Copilot --- client/src/API.ts | 14 ++--- client/src/components/ConfirmDialog.tsx | 33 ++++++++--- client/src/components/LoginModal.tsx | 37 ++++++------ client/src/components/Sidebar.tsx | 13 +---- client/src/components/Toast.tsx | 4 +- client/src/components/table/TableSP.tsx | 10 ++-- client/src/pages/autors/AutorPage.tsx | 6 +- .../src/pages/boardGames/BoardGamesPage.tsx | 6 +- client/src/pages/books/BookPage.tsx | 4 +- client/src/pages/lps/LPPage.tsx | 6 +- client/src/pages/quotes/QuotePage.tsx | 4 +- client/src/utils/autor.tsx | 2 +- client/src/utils/context/ModalContext.tsx | 26 ++++----- client/src/utils/hooks/useNetworkStatus.ts | 2 +- server/src/app.ts | 12 +++- server/src/controllers/autorsC.ts | 4 +- server/src/controllers/boardGamesC.ts | 56 +++++++++---------- server/src/controllers/lpC.ts | 4 +- server/src/routes/index.ts | 2 +- server/src/utils/queryUtils.ts | 2 +- 20 files changed, 128 insertions(+), 119 deletions(-) diff --git a/client/src/API.ts b/client/src/API.ts index 390ca0d..826e2c0 100644 --- a/client/src/API.ts +++ b/client/src/API.ts @@ -120,7 +120,7 @@ export const getBooks = async (params?: any): Promise> => { try { - const deletedBook: AxiosResponse = await axiosInstance.post( - `${baseUrl}/delete-book/${_id}` + const deletedBook: AxiosResponse = await axiosInstance.delete( + `${baseUrl}/book/${_id}` ) return deletedBook } catch (error: any) { @@ -260,7 +260,7 @@ export const getAutors = async (params?: any): Promise baseUrl + "/lps", { params: { page: params?.page ?? 1, - pageSize: params?.pageSize ?? 10_000, + pageSize: params?.pageSize ?? 100, search: params?.search ?? "", sorting: params?.sorting ?? { id: "lastName", desc: false } } @@ -726,7 +726,7 @@ export const getBoardGames = async (params?: any): Promise> = baseUrl + "/boardgames", { params: { page: params?.page ?? 1, // API expects 1-based index - pageSize: params?.pageSize ?? 10_000, + pageSize: params?.pageSize ?? 100, search: params?.search ?? "", sorting: params?.sorting ?? [{ id: "title", desc: false }], filters: params?.filters ?? [] diff --git a/client/src/components/ConfirmDialog.tsx b/client/src/components/ConfirmDialog.tsx index 7eca2af..ce13fb5 100644 --- a/client/src/components/ConfirmDialog.tsx +++ b/client/src/components/ConfirmDialog.tsx @@ -1,7 +1,8 @@ import React, { FC, useCallback } from "react"; import { Modal } from "./Modal"; import { createRoot } from "react-dom/client"; -import { useTranslation } from "react-i18next"; +import { useTranslation, I18nextProvider } from "react-i18next"; +import i18n from "../i18n"; interface ConfirmDialogProps { text: string; @@ -65,6 +66,8 @@ const ConfirmDialog: FC = React.memo(({ const DialogManager = (() => { let rootElement: HTMLDivElement | null = null; let rootInstance: ReturnType | null = null; + let isShowing = false; + let queue: ConfirmDialogProps[] = []; const getRoot = () => { if (!rootElement) { @@ -76,14 +79,19 @@ const DialogManager = (() => { return rootInstance; }; - return { - show: (props: ConfirmDialogProps) => { - const root = getRoot(); - const handleClose = () => { - root?.render(null); - }; + const showNext = () => { + if (queue.length === 0) { + isShowing = false; + getRoot()?.render(null); + return; + } + isShowing = true; + const props = queue.shift()!; + const root = getRoot(); + const handleClose = () => showNext(); - root?.render( + root?.render( + { @@ -95,7 +103,14 @@ const DialogManager = (() => { handleClose(); }} /> - ); + + ); + }; + + return { + show: (props: ConfirmDialogProps) => { + queue.push(props); + if (!isShowing) showNext(); } }; })(); diff --git a/client/src/components/LoginModal.tsx b/client/src/components/LoginModal.tsx index 6ce63ce..12f7461 100644 --- a/client/src/components/LoginModal.tsx +++ b/client/src/components/LoginModal.tsx @@ -1,4 +1,4 @@ -import React, { ChangeEvent, useState, useEffect } from "react"; +import React, { ChangeEvent, useState } from "react"; import { Modal, showError } from "./Modal"; import { toast } from "react-toastify"; import { loginUser, loginGuestUser, logoutUser } from "@utils"; @@ -9,26 +9,14 @@ import { useTranslation } from "react-i18next"; const LoginModal: React.FC = () => { const { t } = useTranslation(); - const { login, isLoggedIn, isGuest, currentUser, checkTokenValidity } = useAuth(); + const { login, isLoggedIn, isGuest, currentUser } = useAuth(); const [showModal, setShowModal] = useState(false); const [form, setForm] = useState({ email: "", password: "" }); const [errorKey, setErrorKey] = useState("auth.enterEmail"); - - // Verify token validity on component render and periodically - useEffect(() => { - // Check token validity immediately - checkTokenValidity(); - - // Check token validity every minute - const tokenCheckInterval = setInterval(() => { - checkTokenValidity(); - }, 60000); - - return () => clearInterval(tokenCheckInterval); - }, [checkTokenValidity]); + const [isSubmitting, setIsSubmitting] = useState(false); const getErrMsg = (form: any) => { const { email, password } = form; @@ -56,20 +44,25 @@ const LoginModal: React.FC = () => { }; const sendLogin = async () => { + if (isSubmitting) return; + setIsSubmitting(true); loginUser(form) .then((user: IUser | undefined) => { setShowModal(false); login(user!); - toast.success(t("auth.loginSuccess")); + toast.success(t("auth.loginSuccess"), { autoClose: 3000 }); }) .catch(err => { console.error(err.message); toast.error(t("auth.loginFailed")); setErrorKey("auth.loginFailedRetry"); - }); + }) + .finally(() => setIsSubmitting(false)); } const sendGuestLogin = async () => { + if (isSubmitting) return; + setIsSubmitting(true); loginGuestUser() .then((user: IUser | undefined) => { setShowModal(false); @@ -79,11 +72,13 @@ const LoginModal: React.FC = () => { .catch(err => { console.error(err.message); toast.error(t("auth.loginFailed")); - }); + }) + .finally(() => setIsSubmitting(false)); } - const sendLogout = () => { - logoutUser(); + const sendLogout = async () => { + setShowModal(false); + await logoutUser(); } return ( @@ -138,7 +133,7 @@ const LoginModal: React.FC = () => { onClick={() => setShowModal(false)}>{t("common.cancel")} diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index f7331a5..de8b5e7 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -18,19 +18,10 @@ interface HamburgerToXProps { } const HamburgerToX = forwardRef(({ onClick, className, activeEl, label }, ref) => { - const [active, setActive] = useState(activeEl); - - useEffect(() => { - setActive(activeEl); - }, [activeEl]); - return (
-
{ - setActive(!active); - onClick() - }}> - {label} +
) diff --git a/client/src/components/Toast.tsx b/client/src/components/Toast.tsx index bf5e859..e1363e3 100644 --- a/client/src/components/Toast.tsx +++ b/client/src/components/Toast.tsx @@ -1,11 +1,11 @@ import React from "react"; -import {Slide, ToastContainer} from "react-toastify"; +import { Slide, ToastContainer } from "react-toastify"; const Toast: React.FC = () => { return ( = return () => { mounted = false; }; - }, [filtering]); + }, []); const getInputForColumn = (columnName: string) => { const locale = t("common.locale"); @@ -281,7 +281,7 @@ const ServerPaginationTable: FC = ); - case "number": + case "number": { const numFilter = (filtering as any[]).find((f) => f.id === columnName); const numValue = numFilter?.value || ''; const numOperator = numFilter?.operator || '='; @@ -362,7 +362,8 @@ const ServerPaginationTable: FC = />
); - case "date": + } + case "date": { const dateFilter = (filtering as any[]).find((f) => f.id === columnName); const dateValue = dateFilter?.value || ''; const dateOperator = dateFilter?.operator || '='; @@ -443,6 +444,7 @@ const ServerPaginationTable: FC = />
); + } default: return null; } @@ -483,7 +485,7 @@ const ServerPaginationTable: FC = return (
-

{title}

+

{title}

{actions} {filteringChange && diff --git a/client/src/pages/autors/AutorPage.tsx b/client/src/pages/autors/AutorPage.tsx index 0f37756..1538a43 100644 --- a/client/src/pages/autors/AutorPage.tsx +++ b/client/src/pages/autors/AutorPage.tsx @@ -107,7 +107,7 @@ export default function AutorPage() { action: !isNewAutor ? t("autors.actionSaved") : t("autors.actionAdded") }); } - toast.success(message); + toast.success(message, { autoClose: 3000 }); setSaveAutorSuccess(true) fetchAutors(); return { success: true, message }; @@ -214,7 +214,7 @@ export default function AutorPage() { if (status !== 200) { throw new Error("Error! Autor not deleted") } - toast.success(t("autors.deleteSuccessSingle", { name: autorToDelete?.fullName })); + toast.success(t("autors.deleteSuccessSingle", { name: autorToDelete?.fullName }), { autoClose: 3000 }); fetchAutors(); }) .catch((err) => { @@ -242,7 +242,7 @@ export default function AutorPage() { onOk: async () => { try { await Promise.all(idsToDelete.map(id => deleteAutor(id))); - toast.success(t("autors.deleteSuccessMany")); + toast.success(t("autors.deleteSuccessMany"), { autoClose: 3000 }); fetchAutors(); } catch (err) { toast.error(t("autors.deleteErrorMany")); diff --git a/client/src/pages/boardGames/BoardGamesPage.tsx b/client/src/pages/boardGames/BoardGamesPage.tsx index f44b567..7b46f7f 100644 --- a/client/src/pages/boardGames/BoardGamesPage.tsx +++ b/client/src/pages/boardGames/BoardGamesPage.tsx @@ -83,7 +83,7 @@ export default function BoardGamesPage() { return Promise.all(formData.map(bg => addBoardGame(bg))) .then((results) => { const message = t("boardGames.saveManySuccess", { count: results.length }); - toast.success(message); + toast.success(message, { autoClose: 3000 }); setSaveBoardGameSuccess(true); fetchBoardGames(); return { success: true, message }; @@ -112,7 +112,7 @@ export default function BoardGamesPage() { title: data?.title || "", action: !isNewBoardGame ? t("boardGames.actionEdited") : t("boardGames.actionAdded") }); - toast.success(message); + toast.success(message, { autoClose: 3000 }); setSaveBoardGameSuccess(true); fetchBoardGames(); return { success: true, message }; @@ -220,7 +220,7 @@ export default function BoardGamesPage() { successCount > 1 ? t("boardGames.deleteSuccessMany", { count: successCount }) : t("boardGames.deleteSuccessSingle", { title: games[0].title }) - ); + , { autoClose: 3000 }); fetchBoardGames(); }) .catch((err) => { diff --git a/client/src/pages/books/BookPage.tsx b/client/src/pages/books/BookPage.tsx index 6af0702..f052c4a 100644 --- a/client/src/pages/books/BookPage.tsx +++ b/client/src/pages/books/BookPage.tsx @@ -190,7 +190,7 @@ export default function BookPage() { action: !isNewBook ? t("books.actionSaved") : t("books.actionAdded") }); } - toast.success(message); + toast.success(message, { autoClose: 3000 }); setSaveBookSuccess(true); fetchBooks(); return { success: true, message }; @@ -292,7 +292,7 @@ export default function BookPage() { successCount > 1 ? t("books.deleteSuccessMany", { count: successCount }) : t("books.deleteSuccessSingle", { title: books[0].title }) - ); + , { autoClose: 3000 }); fetchBooks(); }) .catch((err) => { diff --git a/client/src/pages/lps/LPPage.tsx b/client/src/pages/lps/LPPage.tsx index 54921e6..7224541 100644 --- a/client/src/pages/lps/LPPage.tsx +++ b/client/src/pages/lps/LPPage.tsx @@ -82,7 +82,7 @@ export default function LPPage() { return Promise.all(formData.map(lp => addLP(lp))) .then((results) => { const message = t("lp.saveManySuccess", { count: results.length }); - toast.success(message); + toast.success(message, { autoClose: 3000 }); setSaveLpSuccess(true); fetchLPs(); return { success: true, message }; @@ -111,7 +111,7 @@ export default function LPPage() { title: result.data.lp?.title, action: !isNewLp ? t("lp.actionSaved") : t("lp.actionAdded") }); - toast.success(message); + toast.success(message, { autoClose: 3000 }); setSaveLpSuccess(true); setLPs(stringifyAutors(result.data.lps)); return { success: true, message }; @@ -214,7 +214,7 @@ export default function LPPage() { successCount > 1 ? t("lp.deleteSuccessMany", { count: successCount }) : t("lp.deleteSuccessSingle", { title: lps[0].title }) - ); + , { autoClose: 3000 }); fetchLPs(); }) .catch((err) => { diff --git a/client/src/pages/quotes/QuotePage.tsx b/client/src/pages/quotes/QuotePage.tsx index 857ef41..b30aa5f 100644 --- a/client/src/pages/quotes/QuotePage.tsx +++ b/client/src/pages/quotes/QuotePage.tsx @@ -184,7 +184,7 @@ export default function QuotePage() { doFetch(1, booksToFilter.map(b => b._id), debouncedSearch, activeUser, true); toast.success(t("quotes.saveSuccess", { action: !isNewQuote ? t("quotes.actionEdited") : t("quotes.actionAdded") - })); + }), { autoClose: 3000 }); }) .catch((err) => { toast.error(t("quotes.saveError", { @@ -205,7 +205,7 @@ export default function QuotePage() { if (res.status !== 200) { throw new Error("Error! Quote not deleted"); } - toast.success(t("quotes.deleteSuccess")); + toast.success(t("quotes.deleteSuccess"), { autoClose: 3000 }); setPage(1); doFetch(1, booksToFilter.map(b => b._id), debouncedSearch, activeUser, true); }) diff --git a/client/src/utils/autor.tsx b/client/src/utils/autor.tsx index 8ddd7e8..041bf8d 100644 --- a/client/src/utils/autor.tsx +++ b/client/src/utils/autor.tsx @@ -37,7 +37,7 @@ export const createNewAutor = async ( try { const res = await addAutor([{ firstName, lastName, role: [{ value: role }] }]); if (res.status === 201 && res.data?.autor?._id) { - toast.success(i18n.t("autors.createdSuccess")); + toast.success(i18n.t("autors.createdSuccess"), { autoClose: 3000 }); if (setFormData) { setFormData((prevData: any) => { // Check if prevData is an array (BookModal) or object (other forms) diff --git a/client/src/utils/context/ModalContext.tsx b/client/src/utils/context/ModalContext.tsx index 8002f25..065d9e8 100644 --- a/client/src/utils/context/ModalContext.tsx +++ b/client/src/utils/context/ModalContext.tsx @@ -18,6 +18,7 @@ interface ModalState { minimized: boolean; position: { x: number | null, y: null | number }; previousPosition?: { x: number | null, y: null | number }; + closedAt?: number; } interface ModalContextType { @@ -75,7 +76,7 @@ export const ModalProvider: React.FC<{ children: React.ReactNode }> = ({ childre window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); - }, [window.innerHeight, window.innerWidth]); + }, []); const showModal = useCallback((props: { customKey: string, @@ -121,31 +122,30 @@ export const ModalProvider: React.FC<{ children: React.ReactNode }> = ({ childre }, []); const hideModal = useCallback((key: string) => { - // Instead of filtering out the modal, mark it as closed and save its position + const closedAt = Date.now(); setModals(prevModals => prevModals.map(modal => modal.customKey === key ? { ...modal, isOpen: false, - // Store position so we can restore it when reopened + closedAt, previousPosition: modal.position } : modal ) ); - // Clean up any closed modals after some time to prevent state bloat - // This timeout can be adjusted based on UX needs + // Clean up closed modals after 5 seconds, but only those whose own closedAt + // is old enough — prevents concurrent-close races from removing other modals early. setTimeout(() => { - setModals(prevModals => { - // Only remove modals that have been closed for a while - // Keep recent ones in case they get reopened - return prevModals.filter(modal => - modal.isOpen || modal.customKey === key - ); - }); - }, 5000); // Keep closed modals for 5 seconds + const cutoff = Date.now() - 4500; // slightly under 5 s to account for timer drift + setModals(prevModals => + prevModals.filter(modal => + modal.isOpen || !modal.closedAt || modal.closedAt > cutoff + ) + ); + }, 5000); }, []); // Function to calculate position for minimized modals to appear side by side diff --git a/client/src/utils/hooks/useNetworkStatus.ts b/client/src/utils/hooks/useNetworkStatus.ts index 8a12a8d..3ac361f 100644 --- a/client/src/utils/hooks/useNetworkStatus.ts +++ b/client/src/utils/hooks/useNetworkStatus.ts @@ -12,7 +12,7 @@ const useNetworkStatus = () => { const handleOnline = () => { toast.dismiss("network-offline"); - toast.success(t("network.online"), { toastId: "network-online" }); + toast.success(t("network.online"), { toastId: "network-online", autoClose: 3000 }); }; window.addEventListener("offline", handleOffline); diff --git a/server/src/app.ts b/server/src/app.ts index f34f13e..9120ad7 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -1,4 +1,4 @@ -import express, { Express } from "express" +import express, { Express, Request, Response, NextFunction } from "express" import mongoose from "mongoose" import cors from "cors" import cookieParser from "cookie-parser" @@ -36,8 +36,8 @@ app.use( app.use(cookieParser()); -app.use(express.json({ limit: "20mb" })); -app.use(express.urlencoded({ limit: "20mb", extended: true })); +app.use(express.json({ limit: "2mb" })); +app.use(express.urlencoded({ limit: "2mb", extended: true })); app.use(routes) app.use(express.static(path.join(__dirname, "client/build"))); @@ -46,6 +46,12 @@ app.get("*", (req, res) => { res.sendFile(path.join(__dirname, "client/build", "index.html")); }); +// Global error handler — catches unhandled errors from async route handlers +app.use((err: any, _req: Request, res: Response, _next: NextFunction) => { + console.error(err); + res.status(500).json({ error: "Internal server error" }); +}); + const uri: string = `mongodb+srv://${process.env.MONGO_USER}:${process.env.MONGO_PASSWORD}@cluster0.og6qo.mongodb.net/${databaseName}?retryWrites=true&w=majority` mongoose.set("strictQuery", false); diff --git a/server/src/controllers/autorsC.ts b/server/src/controllers/autorsC.ts index d49b6a4..29d4aee 100644 --- a/server/src/controllers/autorsC.ts +++ b/server/src/controllers/autorsC.ts @@ -20,7 +20,7 @@ const normalizeAutor = (data: any): IAutor => { const getAllAutors = async (req: Request, res: Response): Promise => { try { - const { page = "1", pageSize = "10_000", search = "", sorting, dataFrom } = req.query; + const { page = "1", pageSize = "100", search = "", sorting, dataFrom } = req.query; const searchFields = ["firstName", "lastName"]; const parsedPage = parseInt(page as string, 10); @@ -30,7 +30,7 @@ const getAllAutors = async (req: Request, res: Response): Promise => { Autor, { page: isNaN(parsedPage) || parsedPage < 1 ? 1 : parsedPage, - pageSize: isNaN(parsedPageSize) || parsedPageSize < 1 ? 10_000 : parsedPageSize, + pageSize: isNaN(parsedPageSize) || parsedPageSize < 1 ? 100 : parsedPageSize, search: search as string, sorting: sorting as string, dataFrom: dataFrom as string, diff --git a/server/src/controllers/boardGamesC.ts b/server/src/controllers/boardGamesC.ts index 771683c..1ec8235 100644 --- a/server/src/controllers/boardGamesC.ts +++ b/server/src/controllers/boardGamesC.ts @@ -1,12 +1,12 @@ import BoardGame from "../models/boardGame"; -import {Response, Request} from 'express'; -import {fetchDataWithPagination} from "../utils/queryUtils"; -import {createLookupStage, getIdFromArray} from "../utils/utils"; -import {IBoardGame} from "../types"; +import { Response, Request } from 'express'; +import { fetchDataWithPagination } from "../utils/queryUtils"; +import { createLookupStage, getIdFromArray } from "../utils/utils"; +import { IBoardGame } from "../types"; const getAllBoardGames = async (req: Request, res: Response): Promise => { try { - const {page = "1", pageSize = "10_000", search = "", sorting, dataFrom} = req.query; + const { page = "1", pageSize = "100", search = "", sorting, dataFrom } = req.query; const searchFields = ["title", "autor", "published.publisher"]; const parsedPage = parseInt(page as string, 10); @@ -18,11 +18,11 @@ const getAllBoardGames = async (req: Request, res: Response): Promise => { createLookupStage("boardgames", "children", "children") ]; - const {data, count, latestUpdate} = await fetchDataWithPagination( + const { data, count, latestUpdate } = await fetchDataWithPagination( BoardGame, { page: isNaN(parsedPage) || parsedPage < 1 ? 1 : parsedPage, - pageSize: isNaN(parsedPageSize) || parsedPageSize < 1 ? 10_000 : parsedPageSize, + pageSize: isNaN(parsedPageSize) || parsedPageSize < 1 ? 100 : parsedPageSize, search: search as string, sorting: sorting as string, dataFrom: dataFrom as string, @@ -31,29 +31,29 @@ const getAllBoardGames = async (req: Request, res: Response): Promise => { lookupStages ); - res.status(200).json({boardGames: data, count: count, latestUpdate: latestUpdate}) + res.status(200).json({ boardGames: data, count: count, latestUpdate: latestUpdate }) } catch (error) { console.error("Error fetching board games:", error); - res.status(500).json({error: "Internal server error"}); + res.status(500).json({ error: "Internal server error" }); } } const getBoardGame = async (req: Request, res: Response): Promise => { try { - const {id} = req.params + const { id } = req.params const boardGame = await BoardGame.findById(id) .populate('autor') .populate("boardgames", "parent", "parent") .populate("boardgames", "children", "children"); if (!boardGame) { - res.status(404).json({error: "Board game not found"}) + res.status(404).json({ error: "Board game not found" }) return } res.status(200).json(boardGame) } catch (error) { console.error("Error fetching board game:", error); - res.status(500).json({error: "Internal server error"}); + res.status(500).json({ error: "Internal server error" }); } } @@ -84,7 +84,7 @@ const addBoardGame = async (req: Request, res: Response): Promise => { noPlayers, playTime, ageRecommendation, - published: {...published, country: countryPublished}, + published: { ...published, country: countryPublished }, note, autor: getIdFromArray(autor), parent: parent?.map((p: IBoardGame) => p._id), @@ -97,13 +97,13 @@ const addBoardGame = async (req: Request, res: Response): Promise => { res.status(201).json(newBoardGame) } catch (error) { console.error("Error creating board game:", error); - res.status(500).json({error: "Internal server error"}); + res.status(500).json({ error: "Internal server error" }); } } const updateBoardGame = async (req: Request, res: Response): Promise => { try { - const {id} = req.params + const { id } = req.params const { title, description, @@ -129,50 +129,50 @@ const updateBoardGame = async (req: Request, res: Response): Promise => { noPlayers, playTime, ageRecommendation, - published: {...published, country: countryPublished}, + published: { ...published, country: countryPublished }, autor: getIdFromArray(autor), parent: parent?.map((p: IBoardGame) => p._id), children: children?.map((c: IBoardGame) => c._id), note: note - }, {new: true}) + }, { new: true }) if (!updatedBoardGame) { - res.status(404).json({error: "Board game not found"}) + res.status(404).json({ error: "Board game not found" }) return } res.status(201).json(updatedBoardGame) } catch (error) { console.error("Error updating board game:", error); - res.status(500).json({error: "Internal server error"}); + res.status(500).json({ error: "Internal server error" }); } } const countBGchildren = async (req: Request, res: Response): Promise => { try { - const {id} = req.params + const { id } = req.params const boardGame = await BoardGame.findById(id).lean(); if (!boardGame) { - res.status(404).json({error: "Žiadna spoločenská hra s týmto ID neexistuje"}); + res.status(404).json({ error: "Žiadna spoločenská hra s týmto ID neexistuje" }); } - res.status(200).json({count: (boardGame as IBoardGame)?.children?.length}) + res.status(200).json({ count: (boardGame as IBoardGame)?.children?.length }) } catch (error) { console.error("Error counting children:", error); - res.status(500).json({error: "Internal server error"}); + res.status(500).json({ error: "Internal server error" }); } } const deleteBoardGame = async (req: Request, res: Response): Promise => { try { - const {id} = req.params - const deletedBoardGame = await BoardGame.findOneAndUpdate({_id: id}, {deletedAt: new Date()}) + const { id } = req.params + const deletedBoardGame = await BoardGame.findOneAndUpdate({ _id: id }, { deletedAt: new Date() }) if (!deletedBoardGame) { - res.status(404).json({error: "Board game not found"}) + res.status(404).json({ error: "Board game not found" }) return } res.status(200).json(deletedBoardGame) } catch (error) { console.error("Error deleting board game:", error); - res.status(500).json({error: "Internal server error"}); + res.status(500).json({ error: "Internal server error" }); } } -export {getAllBoardGames, getBoardGame, addBoardGame, updateBoardGame, countBGchildren, deleteBoardGame}; \ No newline at end of file +export { getAllBoardGames, getBoardGame, addBoardGame, updateBoardGame, countBGchildren, deleteBoardGame }; \ No newline at end of file diff --git a/server/src/controllers/lpC.ts b/server/src/controllers/lpC.ts index 4673256..a4aab96 100644 --- a/server/src/controllers/lpC.ts +++ b/server/src/controllers/lpC.ts @@ -7,7 +7,7 @@ import { fetchDataWithPagination } from "../utils/queryUtils"; const getAllLps = async (req: Request, res: Response): Promise => { try { - const { page = "1", pageSize = "10_000", search = "", sorting } = req.query; + const { page = "1", pageSize = "100", search = "", sorting } = req.query; const searchFields = [ "title", @@ -27,7 +27,7 @@ const getAllLps = async (req: Request, res: Response): Promise => { Lp, { page: isNaN(parsedPage) || parsedPage < 1 ? 1 : parsedPage, - pageSize: isNaN(parsedPageSize) || parsedPageSize < 1 ? 10_000 : parsedPageSize, + pageSize: isNaN(parsedPageSize) || parsedPageSize < 1 ? 100 : parsedPageSize, search: search as string, sorting: sorting as string, searchFields diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index dacedb2..3810691 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -79,7 +79,7 @@ router.get('/book/:id', getBook) router.get('/books', getAllBooks) router.post('/add-book', addBook) router.put('/edit-book/:id', updateBook) -router.post('/delete-book/:id', deleteBook) +router.delete('/book/:id', deleteBook) router.get('/get-book-info/:isbn', getInfoFromISBN) router.get('/books-by-ids', getBooksByIds) router.get('/books/check-updated', checkBooksUpdated) diff --git a/server/src/utils/queryUtils.ts b/server/src/utils/queryUtils.ts index 1812d50..f732199 100644 --- a/server/src/utils/queryUtils.ts +++ b/server/src/utils/queryUtils.ts @@ -196,7 +196,7 @@ export const fetchDataWithPagination = async ( lookupStages: PipelineStage[] = [], additionalQuery: Record = {}, ): Promise<{ data: any[], count: number, latestUpdate?: Date | undefined }> => { - const { page = "1", pageSize = "10_000", search = "", sorting, dataFrom, searchFields = [], filters = [] } = options; + const { page = "1", pageSize = "100", search = "", sorting, dataFrom, searchFields = [], filters = [] } = options; const latestUpdate: { updatedAt: Date | undefined From cc8a427b6675061b50c1428e49b46f60b90ac7b3 Mon Sep 17 00:00:00 2001 From: Azarchaniel Date: Mon, 4 May 2026 23:16:44 +0200 Subject: [PATCH 7/9] fix: update media query syntax for consistency across stylesheets Co-authored-by: Copilot --- client/src/index.scss | 17 ++++++- client/src/styles/Autocomplete.scss | 19 ++++++- client/src/styles/AutorPage.scss | 2 +- client/src/styles/BoardGamesPage.scss | 2 +- client/src/styles/BookPage.scss | 71 ++++++++++++++++++++++++++- client/src/styles/LpPage.scss | 4 +- client/src/styles/ModalStatus.scss | 2 +- client/src/styles/QuotePage.scss | 8 +-- client/src/styles/bookLoading.scss | 2 +- client/src/styles/header.scss | 2 +- client/src/styles/sidebar.scss | 2 +- client/src/styles/table.scss | 4 +- client/src/styles/toggleSwitch.scss | 6 +-- 13 files changed, 120 insertions(+), 21 deletions(-) diff --git a/client/src/index.scss b/client/src/index.scss index 5bf50c7..a13f602 100644 --- a/client/src/index.scss +++ b/client/src/index.scss @@ -600,7 +600,20 @@ textarea { display: none; } -@media screen and (width <=$mobile-width) { +@media (width <=$tablet-width) { + .customModal { + max-width: 95vw; + min-width: unset; + width: 95vw; + + &Body { + max-width: 95vw; + max-height: calc(85vh - 7rem); + } + } +} + +@media (width <=$mobile-width) { .desktop-table { display: none; } @@ -651,9 +664,11 @@ textarea { .customModal { max-width: 90vw; min-width: unset; + width: 90vw; &Body { max-width: 90vw; + max-height: calc(85vh - 7rem); } } } diff --git a/client/src/styles/Autocomplete.scss b/client/src/styles/Autocomplete.scss index 480238f..e90c93a 100644 --- a/client/src/styles/Autocomplete.scss +++ b/client/src/styles/Autocomplete.scss @@ -11,9 +11,16 @@ max-height: 80px; background: var(--input-bg); display: flex; - flex-wrap: wrap; + align-items: center; overflow-y: auto; + .input-wrapper { + display: flex; + align-items: center; + width: 100%; + min-width: 0; // allow flex children to shrink + } + &:focus-within { border: 2px solid var(--anchor) !important; box-shadow: none !important; @@ -36,7 +43,8 @@ flex-wrap: wrap; align-items: center; padding: 0; - width: 100%; + flex: 1 1 0; + min-width: 0; // allow it to shrink so chevron is never pushed out position: relative; } @@ -44,6 +52,7 @@ display: flex; align-items: center; justify-content: center; + flex-shrink: 0; // never shrink — always visible padding: 0 8px; cursor: pointer; } @@ -166,21 +175,27 @@ position: absolute; top: 20%; left: 1rem; + right: 2.5rem; // leave room for the chevron; acts as the overflow boundary z-index: 10; font-size: 1rem; margin-bottom: 0.5rem; color: gray; pointer-events: none; transition: all 0.2s ease; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; &.active { top: -0.1rem; left: 0.5rem; + right: auto; // raised label is 11 px — no need to constrain; lets it grow naturally line-height: 0; font-size: 11px; opacity: 1; background: var(--surface); padding: 0.2rem; + overflow: visible; } &.disabled { diff --git a/client/src/styles/AutorPage.scss b/client/src/styles/AutorPage.scss index 30ed9bc..965cc03 100644 --- a/client/src/styles/AutorPage.scss +++ b/client/src/styles/AutorPage.scss @@ -136,7 +136,7 @@ form { cursor: pointer; } -@media screen and (width <=$mobile-width) { +@media (width <=$mobile-width) { .autorDetail { flex-direction: column; diff --git a/client/src/styles/BoardGamesPage.scss b/client/src/styles/BoardGamesPage.scss index 61a9af7..6abee51 100644 --- a/client/src/styles/BoardGamesPage.scss +++ b/client/src/styles/BoardGamesPage.scss @@ -92,7 +92,7 @@ gap: 5px; } -@media screen and (width <=variables.$mobile-width) { +@media (width <=variables.$mobile-width) { .boardGameDetail { flex-direction: column; diff --git a/client/src/styles/BookPage.scss b/client/src/styles/BookPage.scss index c2695ed..b11f538 100644 --- a/client/src/styles/BookPage.scss +++ b/client/src/styles/BookPage.scss @@ -431,10 +431,79 @@ .b-Krajina { grid-area: 5 / 6 / 5 / 9; } + + /* Row 6 */ + .b-Mesto { + grid-area: 6 / 1 / 6 / 3; + } + + .b-Police { + grid-area: 6 / 3 / 6 / 6; + } + + .b-language { + grid-area: 6 / 6 / 6 / 9; + } + + /* Row 7 — dimensions: pair them up, two per column-group */ + .b-Vyska { + grid-area: 7 / 1 / 7 / 2; + } + + .b-Sirka { + grid-area: 7 / 2 / 7 / 4; + } + + .b-Hrubka { + grid-area: 7 / 4 / 7 / 6; + } + + .b-Hmotnost { + grid-area: 7 / 6 / 7 / 8; + } + + .b-Page-no { + grid-area: 7 / 8 / 7 / 9; + } + + /* Row 8 */ + .b-Obsah { + grid-area: 8 / 1 / 8 / 5; + } + + .b-Poznamka { + grid-area: 8 / 5 / 8 / 9; + } + + /* Row 9 */ + .b-Precitane { + grid-area: 9 / 1 / 9 / 4; + } + + .b-Vlastnik { + grid-area: 9 / 4 / 9 / 7; + } + + .b-Ex-Libris { + grid-area: 9 / 7 / 9 / 9; + } + + /* Row 10 */ + .b-pic { + grid-area: 10 / 1 / 10 / 3; + } + + .b-DK { + grid-area: 10 / 3 / 10 / 6; + } + + .b-GR { + grid-area: 10 / 6 / 10 / 9; + } } /* Mobile phone layout */ -@media screen and (max-width: $mobile-width) { +@media (max-width: $mobile-width) { .bookDetailRow { flex-direction: column; diff --git a/client/src/styles/LpPage.scss b/client/src/styles/LpPage.scss index 59c071e..60275f4 100644 --- a/client/src/styles/LpPage.scss +++ b/client/src/styles/LpPage.scss @@ -46,12 +46,12 @@ grid-area: 6 / 1 / 7 / 3; } -@media screen and (max-width: $mobile-width) { +@media (max-width: $mobile-width) { form { grid-template-columns: 1fr !important; } - form > div { + form>div { grid-column: 1; grid-area: unset !important; } diff --git a/client/src/styles/ModalStatus.scss b/client/src/styles/ModalStatus.scss index a0d682c..4eaa9d6 100644 --- a/client/src/styles/ModalStatus.scss +++ b/client/src/styles/ModalStatus.scss @@ -1,6 +1,6 @@ @import "variables"; -@media screen and (width <=$mobile-width) { +@media (width <=$mobile-width) { .modal-buttons-wrapper { flex-direction: column; align-items: stretch; diff --git a/client/src/styles/QuotePage.scss b/client/src/styles/QuotePage.scss index 2b7944f..1ac5450 100644 --- a/client/src/styles/QuotePage.scss +++ b/client/src/styles/QuotePage.scss @@ -149,7 +149,7 @@ form { } /* Media query for mobile layout */ -@media screen and (max-width: 768px) { +@media (max-width: 768px) { .quotesTitle { flex-direction: column; align-items: flex-start; @@ -173,19 +173,19 @@ form { } /* Responsive column count */ -@media screen and (max-width: 1200px) { +@media (max-width: 1200px) { .quote_container { column-count: 3; } } -@media screen and (max-width: $tablet-width) { +@media (max-width: $tablet-width) { .quote_container { column-count: 2; } } -@media screen and (max-width: $mobile-width) { +@media (max-width: $mobile-width) { .quote_container { column-count: 1; } diff --git a/client/src/styles/bookLoading.scss b/client/src/styles/bookLoading.scss index 53706d6..8254f21 100644 --- a/client/src/styles/bookLoading.scss +++ b/client/src/styles/bookLoading.scss @@ -9,7 +9,7 @@ $delay : $duration*0.16; background: radial-gradient($accent $dot, transparent 0) 0 -2.5px; } -@media screen and (max-width: $mobile-width) { +@media (max-width: $mobile-width) { .bookshelf_wrapper { right: 52.5% !important; } diff --git a/client/src/styles/header.scss b/client/src/styles/header.scss index 9f91934..d648d5b 100644 --- a/client/src/styles/header.scss +++ b/client/src/styles/header.scss @@ -65,7 +65,7 @@ } } -@media screen and (width <= $mobile-width) { +@media (width <=$mobile-width) { h1 { flex-grow: 1; } diff --git a/client/src/styles/sidebar.scss b/client/src/styles/sidebar.scss index 57bbdde..4c2a8dc 100644 --- a/client/src/styles/sidebar.scss +++ b/client/src/styles/sidebar.scss @@ -1,6 +1,6 @@ @use "variables" as *; -@media screen and (max-width: $mobile-width) { +@media (max-width: $mobile-width) { .sideBar { height: 3rem !important; position: fixed; diff --git a/client/src/styles/table.scss b/client/src/styles/table.scss index 3c0fd55..a0d7e16 100644 --- a/client/src/styles/table.scss +++ b/client/src/styles/table.scss @@ -5,7 +5,7 @@ $table-divider-color: var(--table-divider); // ============================================================================ // MOBILE STYLES // ============================================================================ -@media screen and (width <=$mobile-width) { +@media (width <=$mobile-width) { // Expanded row on mobile .expanded-mobile-row { @@ -584,7 +584,7 @@ $table-divider-color: var(--table-divider); // DARK MODE // ============================================================================ -@media screen and (width <=$mobile-width) { +@media (width <=$mobile-width) { :root[data-theme="dark"] .serverPaginationTable tbody { td { border-bottom: none; diff --git a/client/src/styles/toggleSwitch.scss b/client/src/styles/toggleSwitch.scss index 702ab31..eaeffb4 100644 --- a/client/src/styles/toggleSwitch.scss +++ b/client/src/styles/toggleSwitch.scss @@ -132,15 +132,15 @@ $white: #fff; } } - @media screen and (max-width: 991px) { + @media (max-width: 991px) { transform: scale(0.9); } - @media screen and (max-width: 767px) { + @media (max-width: 767px) { transform: scale(0.825); } - @media screen and (max-width: 575px) { + @media (max-width: 575px) { transform: scale(0.75); } } From b718f4f16ccff6d5aa322b182b62028f9c28026f Mon Sep 17 00:00:00 2001 From: Azarchaniel Date: Tue, 5 May 2026 20:46:16 +0200 Subject: [PATCH 8/9] fix: rename and refactor book retrieval functions for clarity and consistency; update related components and styles Co-authored-by: Copilot --- client/src/API.ts | 8 +- client/src/components/Tabs.tsx | 8 +- .../components/dashboard/TableNewestBooks.tsx | 77 ++++++++++++++----- client/src/index.scss | 28 ++++--- client/src/locales/cs.json | 6 ++ client/src/locales/en.json | 6 ++ client/src/locales/sk.json | 6 ++ client/src/pages/dashboard/DashboardPage.tsx | 18 ++--- server/src/controllers/booksC.ts | 16 ++-- server/src/routes/index.ts | 2 +- 10 files changed, 122 insertions(+), 53 deletions(-) diff --git a/client/src/API.ts b/client/src/API.ts index 826e2c0..a47cb38 100644 --- a/client/src/API.ts +++ b/client/src/API.ts @@ -694,12 +694,12 @@ export const getOldestBooks = async (): Promise => { } } -export const getNewestBooks = async (): Promise => { +export const getRecentlyUpdatedBooks = async (): Promise => { try { - const newestBooks: AxiosResponse = await axiosInstance.get( - `${baseUrl}/get-newest-books`, + const recentlyUpdatedBooks: AxiosResponse = await axiosInstance.get( + `${baseUrl}/get-recently-updated-books`, ) - return newestBooks; + return recentlyUpdatedBooks; } catch (error: any) { throw new Error(error) } diff --git a/client/src/components/Tabs.tsx b/client/src/components/Tabs.tsx index 705831a..ba7d210 100644 --- a/client/src/components/Tabs.tsx +++ b/client/src/components/Tabs.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useRef, useState } from "react"; interface TabsProps { children: any | any[]; className?: string; + onTabChange?: (label: string) => void; } interface TabProps { @@ -10,7 +11,7 @@ interface TabProps { label: string; } -const Tabs = ({ children, className = "" }: TabsProps) => { +const Tabs = ({ children, className = "", onTabChange }: TabsProps) => { if (!Array.isArray(children)) { children = [children]; } @@ -41,7 +42,10 @@ const Tabs = ({ children, className = "" }: TabsProps) => { diff --git a/client/src/components/dashboard/TableNewestBooks.tsx b/client/src/components/dashboard/TableNewestBooks.tsx index 545a2bf..05dd5ba 100644 --- a/client/src/components/dashboard/TableNewestBooks.tsx +++ b/client/src/components/dashboard/TableNewestBooks.tsx @@ -1,24 +1,29 @@ -import { ReactElement } from "react"; +import { ReactElement, useState } from "react"; import { Link } from "react-router-dom"; import { NoData } from "./NoData"; import { useTranslation } from "react-i18next"; import { TooltipedText } from "@utils"; +import { Tab, Tabs } from "@components/Tabs"; -interface NewestBook { +interface RecentBook { title: string; _id: string; createdAt: string; + updatedAt: string; + deletedAt?: string | null; picture?: string | null; author?: string; } interface Props { - newestBooks: NewestBook[] | undefined; + recentBooks: RecentBook[] | undefined; } -export const TableNewestBooks = ({ newestBooks }: Props): ReactElement => { +const CREATED_THRESHOLD_MS = 1000; + +export const TableNewestBooks = ({ recentBooks }: Props): ReactElement => { const { t, i18n } = useTranslation(); - if (!newestBooks || newestBooks?.length === 0) return ; + const [activeTab, setActiveTab] = useState<"added" | "updated" | "deleted">("added"); const locale = i18n.resolvedLanguage || i18n.language || "en"; @@ -32,18 +37,32 @@ export const TableNewestBooks = ({ newestBooks }: Props): ReactElement => { return new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit" }).format(date); }; - return ( -
-
{t("dashboard.newestBooks")}
+ const books = recentBooks ?? []; + + if (books.length === 0) return ; + + const addedBooks = books.filter(b => !b.deletedAt && Math.abs(new Date(b.updatedAt).getTime() - new Date(b.createdAt).getTime()) <= CREATED_THRESHOLD_MS); + const editedBooks = books.filter(b => !b.deletedAt && Math.abs(new Date(b.updatedAt).getTime() - new Date(b.createdAt).getTime()) > CREATED_THRESHOLD_MS); + const deletedBooks = books.filter(b => !!b.deletedAt); + + const tabAddedLabel = t("dashboard.tabAdded"); + const tabUpdatedLabel = t("dashboard.tabUpdated"); + const tabDeletedLabel = t("dashboard.tabDeleted"); + + const titleMap: Record = { + added: t("dashboard.recentlyAddedBooks"), + updated: t("dashboard.recentlyUpdatedBooks"), + deleted: t("dashboard.recentlyDeletedBooks"), + }; + + const renderBookList = (list: RecentBook[], dateKey: keyof RecentBook) => { + if (list.length === 0) return ; + return (
- {newestBooks.map((book) => ( + {list.map((book) => (
{book.picture ? ( - {book.title} + {book.title} ) : (
)} @@ -53,16 +72,38 @@ export const TableNewestBooks = ({ newestBooks }: Props): ReactElement => { {book.author && {book.author}}
- -
))}
+ ); + }; + + return ( +
+ {recentBooks && recentBooks.length > 0 &&
{titleMap[activeTab]}
} + { + if (label === tabUpdatedLabel) setActiveTab("updated"); + else if (label === tabDeletedLabel) setActiveTab("deleted"); + else setActiveTab("added"); + }} + > + + {renderBookList(addedBooks, "createdAt")} + + + {renderBookList(editedBooks, "updatedAt")} + + + {renderBookList(deletedBooks, "deletedAt")} + +
); }; + diff --git a/client/src/index.scss b/client/src/index.scss index a13f602..8a5e08a 100644 --- a/client/src/index.scss +++ b/client/src/index.scss @@ -496,21 +496,25 @@ textarea { font-size: 14px; color: var(--anchor); transition: background-color 0.2s ease, color 0.2s ease; -} -.tab-button:last-child { - border-right: none; -} + &:first-child.active { + border-left: 1px solid var(--input-border); + } -.tab-button:hover { - background-color: var(--input-bg); -} + &:last-child { + border-right: none; + } -.tab-button.active { - background-color: var(--background); - font-weight: bold; - color: var(--text); - cursor: default; + &:hover { + background-color: var(--input-bg); + } + + &.active { + background-color: var(--background); + font-weight: bold; + color: var(--text); + cursor: default; + } } /* Tab content styles */ diff --git a/client/src/locales/cs.json b/client/src/locales/cs.json index d2119cb..1d004db 100644 --- a/client/src/locales/cs.json +++ b/client/src/locales/cs.json @@ -222,6 +222,12 @@ "width": "Šířka", "oldestBooks": "Nejstarší knihy", "newestBooks": "Posledně přidané knihy", + "recentlyAddedBooks": "Posledně přidané knihy", + "recentlyUpdatedBooks": "Posledně upravené knihy", + "tabAdded": "Přidané", + "tabUpdated": "Upravené", + "tabDeleted": "Smazáné", + "recentlyDeletedBooks": "Posledně smazané knihy", "biggestBooks": "Největší knihy", "languageStats": "Jazykové statistiky", "readBy": "Přečteno", diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 82bd487..e823e9e 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -222,6 +222,12 @@ "width": "Width", "oldestBooks": "Oldest Books", "newestBooks": "Lastly Added Books", + "recentlyAddedBooks": "Recently Added Books", + "recentlyUpdatedBooks": "Recently Updated Books", + "tabAdded": "Added", + "tabUpdated": "Updated", + "tabDeleted": "Deleted", + "recentlyDeletedBooks": "Recently Deleted Books", "biggestBooks": "Biggest Books", "languageStats": "Language Statistics", "readBy": "Read By", diff --git a/client/src/locales/sk.json b/client/src/locales/sk.json index e4a30ff..f2f0c52 100644 --- a/client/src/locales/sk.json +++ b/client/src/locales/sk.json @@ -225,6 +225,12 @@ "width": "Šírka", "oldestBooks": "Najstaršie knihy", "newestBooks": "Naposledy pridané knihy", + "recentlyAddedBooks": "Naposledy pridané knihy", + "recentlyUpdatedBooks": "Naposledy upravené knihy", + "tabAdded": "Pridané", + "tabUpdated": "Upravené", + "tabDeleted": "Zmazané", + "recentlyDeletedBooks": "Naposledy zmazané knihy", "biggestBooks": "Najväčšie knihy", "languageStats": "Jazykové štatistiky", "readBy": "Prečítané", diff --git a/client/src/pages/dashboard/DashboardPage.tsx b/client/src/pages/dashboard/DashboardPage.tsx index cfcb64c..f991355 100644 --- a/client/src/pages/dashboard/DashboardPage.tsx +++ b/client/src/pages/dashboard/DashboardPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import '../../styles/DashboardPage.scss'; -import { checkBooksUpdated, countBooks, getDimensionsStatistics, getLanguageStatistics, getReadBy, getSizeGroups, getOldestBooks, getNewestBooks, getBiggestBooks } from "../../API"; +import { checkBooksUpdated, countBooks, getDimensionsStatistics, getLanguageStatistics, getReadBy, getSizeGroups, getOldestBooks, getRecentlyUpdatedBooks, getBiggestBooks } from "../../API"; import { getDashboardCachedTimestamp, loadDashboardFromCache, saveDashboardToCache } from "@utils"; import { DashboardPieChart } from "@components/dashboard/DashboardPieChart"; import { DashboardTableStats } from "@components/dashboard/TableDimensionStats"; @@ -44,7 +44,7 @@ export default function DashboardPage() { const [langStats, setLangStats] = useState(); const [readBy, setReadBy] = useState(); const [oldestBooks, setOldestBooks] = useState(); - const [newestBooks, setNewestBooks] = useState(); + const [recentlyUpdatedBooks, setRecentlyUpdatedBooks] = useState(); const [biggestBooks, setBiggestBooks] = useState(); const [isLoadingData, setIsLoadingData] = useState(true); @@ -56,7 +56,7 @@ export default function DashboardPage() { setLangStats(undefined); setReadBy(undefined); setOldestBooks(undefined); - setNewestBooks(undefined); + setRecentlyUpdatedBooks(undefined); setBiggestBooks(undefined); setIsLoadingData(false); return; @@ -79,21 +79,21 @@ export default function DashboardPage() { setLangStats(cached.langStats); setReadBy(cached.readBy); setOldestBooks(cached.oldestBooks); - setNewestBooks(cached.newestBooks); + setRecentlyUpdatedBooks(cached.recentlyUpdatedBooks); setBiggestBooks(cached.biggestBooks); setIsLoadingData(false); return; } } - const [countResult, dimResult, sizeResult, langResult, readByResult, oldestResult, newestResult, heightResult, widthResult, thicknessResult, weightResult, squareResult] = await Promise.all([ + const [countResult, dimResult, sizeResult, langResult, readByResult, oldestResult, recentlyUpdatedResult, heightResult, widthResult, thicknessResult, weightResult, squareResult] = await Promise.all([ countBooks(), getDimensionsStatistics(), getSizeGroups(), getLanguageStatistics(), getReadBy(), getOldestBooks(), - getNewestBooks(), + getRecentlyUpdatedBooks(), getBiggestBooks("height"), getBiggestBooks("width"), getBiggestBooks("thickness"), @@ -108,7 +108,7 @@ export default function DashboardPage() { langStats: langResult.data, readBy: readByResult.data, oldestBooks: oldestResult.data, - newestBooks: newestResult.data, + recentlyUpdatedBooks: recentlyUpdatedResult.data, biggestBooks: { height: heightResult.data, width: widthResult.data, @@ -124,7 +124,7 @@ export default function DashboardPage() { setLangStats(dashboardData.langStats); setReadBy(dashboardData.readBy); setOldestBooks(dashboardData.oldestBooks); - setNewestBooks(dashboardData.newestBooks); + setRecentlyUpdatedBooks(dashboardData.recentlyUpdatedBooks); setBiggestBooks(dashboardData.biggestBooks); saveDashboardToCache(dashboardData, currentUser._id); @@ -171,7 +171,7 @@ export default function DashboardPage() {
- +
diff --git a/server/src/controllers/booksC.ts b/server/src/controllers/booksC.ts index fcaf484..5a240f6 100644 --- a/server/src/controllers/booksC.ts +++ b/server/src/controllers/booksC.ts @@ -1115,13 +1115,13 @@ const dashboard = { res.status(500).json({ error: "Chyba pri získavaní najstarších kníh! " }); } }, - getNewestBooks: async (_: Request, res: Response): Promise => { + getRecentlyUpdatedBooks: async (_: Request, res: Response): Promise => { try { const books = await Book - .find({ deletedAt: undefined }) - .sort({ createdAt: -1 }) - .limit(10) - .select("title createdAt picture autor") + .find({}) + .sort({ updatedAt: -1 }) + .limit(30) + .select("title createdAt updatedAt deletedAt picture autor") .populate({ path: "autor", select: "firstName lastName" }) .lean(); @@ -1131,6 +1131,8 @@ const dashboard = { _id: book._id, title: book.title, createdAt: book.createdAt, + updatedAt: book.updatedAt, + deletedAt: book.deletedAt ?? null, picture: book.picture ?? null, author: authorDoc ? stringifyName(authorDoc) : "" }; @@ -1138,8 +1140,8 @@ const dashboard = { res.status(200).json(formattedBooks); } catch (error) { - console.error("Error fetching newest books:", error); - res.status(500).json({ error: "Chyba pri získavaní najnovších kníh! " }); + console.error("Error fetching recently updated books:", error); + res.status(500).json({ error: "Chyba pri získavaní naposledy upravených kníh!" }); } }, getBiggestBooks: async (req: Request, res: Response): Promise => { diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 3810691..d388595 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -119,7 +119,7 @@ router.get('/get-language-statistics', dashboard.getLanguageStatistics) router.get('/get-size-groups', dashboard.getSizesGroups) router.get('/get-read-by', dashboard.getReadBy) router.get('/get-oldest-books', dashboard.getOldestBooks) -router.get('/get-newest-books', dashboard.getNewestBooks) +router.get('/get-recently-updated-books', dashboard.getRecentlyUpdatedBooks) router.get('/get-biggest-books', dashboard.getBiggestBooks) // ### BOARD GAMES ### From e946400d3b5f6dd532152a5b1fb85ddeef2f26f2 Mon Sep 17 00:00:00 2001 From: Azarchaniel Date: Tue, 5 May 2026 21:54:52 +0200 Subject: [PATCH 9/9] fix: update localization files for consistency and add new fields; enhance book detail rendering with edition and series information Co-authored-by: Copilot --- client/src/locales/cs.json | 17 ++++++-- client/src/locales/en.json | 17 ++++++-- client/src/locales/sk.json | 17 ++++++-- client/src/pages/autors/AutorPage.tsx | 2 +- client/src/pages/books/BookDetail.tsx | 40 +++++++++++++++---- client/src/utils/tableColumns.tsx | 57 ++++++++++++++++++++------- server/src/controllers/lpC.ts | 2 +- server/src/controllers/quotesC.ts | 7 +--- server/src/utils/queryUtils.ts | 12 +++++- 9 files changed, 132 insertions(+), 39 deletions(-) diff --git a/client/src/locales/cs.json b/client/src/locales/cs.json index 1d004db..a0b56b3 100644 --- a/client/src/locales/cs.json +++ b/client/src/locales/cs.json @@ -59,7 +59,11 @@ "warningLps_few": "a {{count}} LP", "warningLps_many": "a {{count}} LP", "warningLps_one": "a {{count}} LP", - "warningLps_other": "a {{count}} LP" + "warningLps_other": "a {{count}} LP", + "warningLpsOnly_few": "Tento autor má u sebe přiřazené {{count}} LP", + "warningLpsOnly_many": "Tento autor má u sebe přiřazených {{count}} LP", + "warningLpsOnly_one": "Tento autor má u sebe přiřazené {{count}} LP", + "warningLpsOnly_other": "Tento autor má u sebe přiřazených {{count}} LP" }, "barcode": { "notFound": "Nebyl nalezen žádný čárový kód!", @@ -104,8 +108,10 @@ "authors_few": "Autoři", "authors_one": "Autor", "authors_other": "Autoři", + "content": "Obsah", "coverAlt": "obal", "dimensions": "Rozměry", + "edition": "Edice", "editors_few": "Editoři", "editors_one": "Editor", "editors_other": "Editoři", @@ -121,6 +127,7 @@ "pages": "Počet stran", "publisher": "Vydavatel", "readBy": "Přečteno", + "serie": "Série", "thickness": "Tloušťka", "translators_few": "Překladatelé", "translators_one": "Překladatel", @@ -389,8 +396,10 @@ "players": "Počet hráčů", "playTime": "Čas hraní (min)", "published": "Vydáno", + "publisher": "Vydavatel", "title": "Název", - "updatedAt": "Datum úpravy" + "updatedAt": "Datum úpravy", + "year": "Rok" }, "books": { "author": "Autor", @@ -434,10 +443,12 @@ "createdAt": "Datum přidání", "language": "Jazyk", "published": "Vydáno", + "publisher": "Vydavatel", "speed": "Rychlost", "subtitle": "Podnázev", "title": "Název", - "updatedAt": "Datum úpravy" + "updatedAt": "Datum úpravy", + "year": "Rok" }, "selection": { "none": "Nevybráno", diff --git a/client/src/locales/en.json b/client/src/locales/en.json index e823e9e..05fc0a8 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -59,7 +59,11 @@ "warningLps_few": "and {{count}} LP", "warningLps_many": "and {{count}} LP", "warningLps_one": "and {{count}} LP", - "warningLps_other": "and {{count}} LP" + "warningLps_other": "and {{count}} LP", + "warningLpsOnly_few": "This author has {{count}} LP assigned", + "warningLpsOnly_many": "This author has {{count}} LP assigned", + "warningLpsOnly_one": "This author has {{count}} LP assigned", + "warningLpsOnly_other": "This author has {{count}} LP assigned" }, "barcode": { "notFound": "No barcode was found!", @@ -104,8 +108,10 @@ "authors_few": "Authors", "authors_one": "Author", "authors_other": "Authors", + "content": "Contents", "coverAlt": "cover", "dimensions": "Dimensions", + "edition": "Edition", "editors_few": "Editors", "editors_one": "Editor", "editors_other": "Editors", @@ -121,6 +127,7 @@ "pages": "Pages", "publisher": "Publisher", "readBy": "Read by", + "serie": "Series", "thickness": "Thickness", "translators_few": "Translators", "translators_one": "Translator", @@ -389,8 +396,10 @@ "players": "Players", "playTime": "Play time (min)", "published": "Published", + "publisher": "Publisher", "title": "Title", - "updatedAt": "Updated" + "updatedAt": "Updated", + "year": "Year" }, "books": { "author": "Author", @@ -434,10 +443,12 @@ "createdAt": "Created", "language": "Language", "published": "Published", + "publisher": "Publisher", "speed": "Speed", "subtitle": "Subtitle", "title": "Title", - "updatedAt": "Updated" + "updatedAt": "Updated", + "year": "Year" }, "selection": { "none": "None selected", diff --git a/client/src/locales/sk.json b/client/src/locales/sk.json index f2f0c52..daad70f 100644 --- a/client/src/locales/sk.json +++ b/client/src/locales/sk.json @@ -59,7 +59,11 @@ "warningLps_few": "a {{count}} LP", "warningLps_many": "a {{count}} LP", "warningLps_one": "a {{count}} LP", - "warningLps_other": "a {{count}} LP" + "warningLps_other": "a {{count}} LP", + "warningLpsOnly_few": "Tento autor má k sebe priradené {{count}} LP", + "warningLpsOnly_many": "Tento autor má k sebe priradených {{count}} LP", + "warningLpsOnly_one": "Tento autor má k sebe priradené {{count}} LP", + "warningLpsOnly_other": "Tento autor má k sebe priradených {{count}} LP" }, "barcode": { "notFound": "Nebol nájdený žiaden čiarový kód!", @@ -104,8 +108,10 @@ "authors_few": "Autori", "authors_one": "Autor", "authors_other": "Autori", + "content": "Obsah", "coverAlt": "titulka", "dimensions": "Rozmery", + "edition": "Edícia", "editors_few": "Editori", "editors_one": "Editor", "editors_other": "Editori", @@ -121,6 +127,7 @@ "pages": "Počet strán", "publisher": "Vydavateľ", "readBy": "Prečítané", + "serie": "Séria", "thickness": "Hrúbka", "translators_few": "Prekladatelia", "translators_one": "Prekladateľ", @@ -394,8 +401,10 @@ "players": "Počet hráčov", "playTime": "Čas hrania (min)", "published": "Vydané", + "publisher": "Vydavateľ", "title": "Názov", - "updatedAt": "Dátum úpravy" + "updatedAt": "Dátum úpravy", + "year": "Rok" }, "books": { "author": "Autor", @@ -439,10 +448,12 @@ "createdAt": "Dátum pridania", "language": "Jazyk", "published": "Vydané", + "publisher": "Vydavateľ", "speed": "Rýchlosť", "subtitle": "Podnázov", "title": "Názov", - "updatedAt": "Dátum úpravy" + "updatedAt": "Dátum úpravy", + "year": "Rok" }, "selection": { "none": "Nevybrané", diff --git a/client/src/pages/autors/AutorPage.tsx b/client/src/pages/autors/AutorPage.tsx index 1538a43..965981a 100644 --- a/client/src/pages/autors/AutorPage.tsx +++ b/client/src/pages/autors/AutorPage.tsx @@ -200,7 +200,7 @@ export default function AutorPage() { } if (lps.length > 0) { - warningText += " " + t("autors.warningLps", { count: lps.length }); + warningText += (books.length > 0 ? " " : "") + t(books.length > 0 ? "autors.warningLps" : "autors.warningLpsOnly", { count: lps.length }); } if (books.length > 0 || lps.length > 0) warningText += "!"; diff --git a/client/src/pages/books/BookDetail.tsx b/client/src/pages/books/BookDetail.tsx index f7e08c9..7857380 100644 --- a/client/src/pages/books/BookDetail.tsx +++ b/client/src/pages/books/BookDetail.tsx @@ -160,6 +160,30 @@ const BookDetail: React.FC = React.memo(({ data }) => { return {label}: {value}; } + const renderEdition = (): string | undefined => { + const { title, no } = data.edition ?? {}; + if (!title && !no) return; + const parts = [title, no ? `#${no}` : undefined].filter(Boolean); + return `${t("bookDetail.edition")}: ${parts.join(" ")}`; + }; + + const renderSerie = (): string | undefined => { + const { title, no } = data.serie ?? {}; + if (!title && !no) return; + const parts = [title, no ? `#${no}` : undefined].filter(Boolean); + return `${t("bookDetail.serie")}: ${parts.join(" ")}`; + }; + + const renderContent = (): React.ReactNode => { + if (!data.content || data.content.length === 0) return null; + return ( + + {t("bookDetail.content")}: + {(data.content as string[]).join(", ")} + + ); + }; + // Main render return (
@@ -175,19 +199,21 @@ const BookDetail: React.FC = React.memo(({ data }) => { {renderContributorLinks("autor", "bookDetail.authors")} + {renderTableRow(`ISBN: ${data.ISBN}`)} + {renderContributorLinks("translator", "bookDetail.translators")} {renderContributorLinks("editor", "bookDetail.editors")} {renderContributorLinks("ilustrator", "bookDetail.illustrators")} - {renderContributorLinks("translator", "bookDetail.translators")} - {renderTableRow(renderLanguage())} - {renderTableRow(`ISBN: ${data.ISBN}`)} - {data.numberOfPages && renderTableRow(`${t("bookDetail.pages")}: ${formatNumberLocale(data.numberOfPages, t('common.locale'), 0)}`)} + {renderTableRow(renderEdition())} + {renderTableRow(renderSerie())} {renderTableRow(renderPublisherInfo())} {renderTableRow(renderLocation())} - {renderTableRow(renderPeopleList(data.owner, t("bookDetail.owner")))} - {renderTableRow(renderPeopleList(data.readBy, t("bookDetail.readBy")))} + {renderTableRow(renderLanguage())} {renderDimensions()} + {data.numberOfPages && renderTableRow(`${t("bookDetail.pages")}: ${formatNumberLocale(data.numberOfPages, t('common.locale'), 0)}`)} + {renderContent()} {data.note && renderTableRow(`${t("bookDetail.note")}: ${data.note}`)} - + {renderTableRow(renderPeopleList(data.readBy, t("bookDetail.readBy")))} + {renderTableRow(renderPeopleList(data.owner, t("bookDetail.owner")))} {renderTableRow(`Ex Libris: ${data.exLibris ? "✔" : "✘"}`)}
diff --git a/client/src/utils/tableColumns.tsx b/client/src/utils/tableColumns.tsx index e8bacc6..917973e 100644 --- a/client/src/utils/tableColumns.tsx +++ b/client/src/utils/tableColumns.tsx @@ -307,14 +307,28 @@ export const getLPTableColumns = (t: TFunction): ColumnDef[] => [ accessorKey: 'speed', header: t("table.lp.speed") }, - { - accessorKey: 'published', - header: t("table.lp.published"), - cell: ({ cell }: { cell: any }) => { - const published = cell.getValue() as IPublished; - return published ? `${published?.publisher ?? ""} (${published?.year ?? "-"})` : null; + columnHelper.group({ + id: "published", + header: () => t("table.lp.published"), + meta: { + headerStyle: { + backgroundColor: TABLE_HEADER_COLOR + } }, - }, + columns: [ + columnHelper.accessor(row => row.published?.publisher, { + id: "published.publisher", + header: t("table.lp.publisher"), + sortingFn: "alphanumeric" + }), + columnHelper.accessor(row => row.published?.year, { + id: "published.year", + header: t("table.lp.year"), + cell: info => info.getValue() ?? "?", + sortingFn: "alphanumeric" + }), + ] + }), { accessorKey: 'createdAt', header: t("table.lp.createdAt"), @@ -369,15 +383,28 @@ export const getBoardGameTableColumns = (t: TFunction): ColumnDef[] => return value ? formatBoardGameRange(value, t("units.years")) : ""; }, }, - { - accessorKey: 'published', - header: t("table.boardGames.published"), - cell: ({ cell }: { cell: any }) => { - const published = cell.getValue() as IPublished; - return published ? `${published?.publisher ?? "?"} (${published?.year ?? "?"})` : ""; + columnHelper.group({ + id: "published", + header: () => t("table.boardGames.published"), + meta: { + headerStyle: { + backgroundColor: TABLE_HEADER_COLOR + } }, - sortingFn: "datetime", - }, + columns: [ + columnHelper.accessor(row => row.published?.publisher, { + id: "published.publisher", + header: t("table.boardGames.publisher"), + sortingFn: "alphanumeric" + }), + columnHelper.accessor(row => row.published?.year, { + id: "published.year", + header: t("table.boardGames.year"), + cell: info => info.getValue() ?? "?", + sortingFn: "alphanumeric" + }), + ] + }), { accessorKey: 'createdAt', header: t("table.boardGames.createdAt"), diff --git a/server/src/controllers/lpC.ts b/server/src/controllers/lpC.ts index a4aab96..5c8d831 100644 --- a/server/src/controllers/lpC.ts +++ b/server/src/controllers/lpC.ts @@ -102,7 +102,7 @@ const addLp = async (req: Request, res: Response): Promise => { published: { ...published, country: publishedCountryNormalized } } ) - const allLps: ILp[] = await Lp.find().populate([ + const allLps: ILp[] = await Lp.find(optionFetchAllExceptDeleted).populate([ { path: 'autor', model: 'Autor' }, ]).exec() diff --git a/server/src/controllers/quotesC.ts b/server/src/controllers/quotesC.ts index b1181b4..6fc4fbe 100644 --- a/server/src/controllers/quotesC.ts +++ b/server/src/controllers/quotesC.ts @@ -2,7 +2,7 @@ import { Request, Response } from "express"; import { IPopulateOptions, IQuote, IBook } from "../types"; import Quote from "../models/quote" import { optionFetchAllExceptDeleted } from "../utils/constants"; -import diacritics from "diacritics"; +import { buildSearchQuery } from "../utils/queryUtils"; const populateOptions: IPopulateOptions[] = [ { @@ -32,10 +32,7 @@ const getAllQuotes = async (req: Request, res: Response): Promise => { query.fromBook = { $in: filterByBook }; } - if (search && typeof search === 'string' && search.trim()) { - const normalizedSearch = diacritics.remove(search.trim()); - query['normalizedSearchField.text'] = { $regex: normalizedSearch, $options: 'i' }; - } + Object.assign(query, buildSearchQuery(search as string, ['text'])); const [quotes, count] = await Promise.all([ Quote.find(query) diff --git a/server/src/utils/queryUtils.ts b/server/src/utils/queryUtils.ts index f732199..8d9fc82 100644 --- a/server/src/utils/queryUtils.ts +++ b/server/src/utils/queryUtils.ts @@ -72,9 +72,19 @@ export const buildPaginationPipeline = (page: number, pageSize: number, sortOpti export const buildSearchQuery = (search: string, searchFields: string[]): Record => { if (!search) return {}; + // Normalize: remove diacritics and strip non-alphanumeric chars — must match normalizeFieldValue + // in utils.ts which also removes (not replaces) these chars when storing normalizedSearchField. + // Use \s+ in the regex to handle any whitespace left behind after stripping. + const normalized = diacritics.remove(search) + .replace(/[^a-zA-Z0-9\s]/g, '') + .trim() + .replace(/\s+/g, '\\s+'); + + if (!normalized) return {}; + const conditions: Record[] = searchFields.map(field => ({ [`normalizedSearchField.${field}`]: { - $regex: diacritics.remove(search ?? "")?.replace(/-/g, ""), + $regex: normalized, $options: "i" } }));