diff --git a/bun.lockb b/bun.lockb index 7b8d364..523264d 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/messages/en.json b/messages/en.json index 4ae4606..316e4ba 100644 --- a/messages/en.json +++ b/messages/en.json @@ -16,6 +16,7 @@ "navigationMenu": "Navigation menu", "news": "News", "events": "Events", + "storage": "Storage", "about": "About", "changeLocale": "Change language", "toggleTheme": "Toggle theme", @@ -48,5 +49,31 @@ "newArticle": "New article", "readTime": "{count, plural, =0 {less than a minute} one {# minute} other {# minutes}} read", "views": "Views" + }, + "storage": { + "title": "Storage", + "card": { + "quantityInfo": "{quantity} units", + "addToCart": "Add to cart" + }, + "select": { + "filters": "Filters", + "defaultPlaceholder": "Sort results", + "popularity": "Popularity", + "sortDescending": "Inventory (descending)", + "sortAscending": "Inventory (ascending)", + "name": "Name (in alphabetical order)" + }, + "combobox": { + "defaultDescription": "Choose category...", + "defaultPlaceholder": "Search category...", + "cables": "Cables", + "sensors": "Sensors", + "peripherals": "PC peripherals", + "miniPC": "Mini PC" + }, + "tooltips": { + "viewShoppingCart": "View shopping cart" + } } } diff --git a/messages/no.json b/messages/no.json index 440eef5..79286cb 100644 --- a/messages/no.json +++ b/messages/no.json @@ -16,6 +16,7 @@ "navigationMenu": "Navigasjonsmeny", "news": "Nyheter", "events": "Hendelser", + "storage": "Lager", "about": "Om oss", "changeLocale": "Bytt språk", "toggleTheme": "Bytt tema", @@ -48,5 +49,31 @@ "newArticle": "Ny artikkel", "readTime": "{count, plural, =0 {mindre enn ett minutt} one {# minutt} other {# minutter}} lesing", "views": "Visninger" + }, + "storage": { + "title": "Lager", + "card": { + "quantityInfo": "{quantity} stk.", + "addToCart": "Legg i handlekurven" + }, + "select": { + "filters": "Filtre", + "defaultPlaceholder": "Sorter resultater", + "popularity": "Popularitet", + "sortDescending": "Lagerbeholdning (synkende)", + "sortAscending": "Lagerbeholdning (stigende)", + "name": "Navn (alfabetisk)" + }, + "combobox": { + "defaultDescription": "Velg kategori...", + "defaultPlaceholder": "Søk etter kategori...", + "cables": "Kabler", + "sensors": "Sensorer", + "peripherals": "PC-tilbehør", + "miniPC": "Mini-PC" + }, + "tooltips": { + "viewShoppingCart": "Vis handlekurv" + } } } diff --git a/package.json b/package.json index ccc2215..ff97bbe 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", + "@radix-ui/react-popover": "^1.1.1", + "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.1", @@ -26,10 +28,11 @@ "@trpc/react-query": "^10.45.2", "@trpc/server": "^10.45.2", "autoprefixer": "^10.4.19", + "cmdk": "1.0.0", "country-flag-icons": "^1.5.12", "cva": "^1.0.0-beta.1", "drizzle-orm": "^0.31.2", - "lucide-react": "^0.396.0", + "lucide-react": "^0.429.0", "next": "^14.2.4", "next-intl": "^3.15.2", "next-sitemap": "^4.2.3", @@ -40,7 +43,7 @@ "reading-time": "^1.5.0", "server-only": "^0.0.1", "sharp": "^0.33.4", - "tailwind-merge": "^2.3.0", + "tailwind-merge": "^2.5.2", "zod": "^3.23.8" }, "devDependencies": { diff --git a/public/unknown.png b/public/unknown.png new file mode 100644 index 0000000..8d33d4d Binary files /dev/null and b/public/unknown.png differ diff --git a/src/app/[locale]/(default)/storage/layout.tsx b/src/app/[locale]/(default)/storage/layout.tsx new file mode 100644 index 0000000..c941e8f --- /dev/null +++ b/src/app/[locale]/(default)/storage/layout.tsx @@ -0,0 +1,40 @@ +import { useTranslations } from 'next-intl'; + +import { Button } from '@/components/ui/Button'; + +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/Tooltip'; +import { ShoppingCart } from 'lucide-react'; + +export default function StorageLayout({ + children, +}: { + children: React.ReactNode; +}) { + const t = useTranslations('storage'); + + return ( + <> +
+

{t('title')}

+ + + + + + +

{t('tooltips.viewShoppingCart')}

+
+
+
+
+ {children} + + ); +} diff --git a/src/app/[locale]/(default)/storage/loading.tsx b/src/app/[locale]/(default)/storage/loading.tsx new file mode 100644 index 0000000..f88b271 --- /dev/null +++ b/src/app/[locale]/(default)/storage/loading.tsx @@ -0,0 +1,24 @@ +import { SkeletonCard } from '@/components/storage/SkeletonCard'; +import { Skeleton } from '@/components/ui/Skeleton'; + +export default function StorageSkeleton() { + return ( + <> +
+ + + +
+
+ + + + + + + + +
+ + ); +} diff --git a/src/app/[locale]/(default)/storage/page.tsx b/src/app/[locale]/(default)/storage/page.tsx new file mode 100644 index 0000000..26128df --- /dev/null +++ b/src/app/[locale]/(default)/storage/page.tsx @@ -0,0 +1,158 @@ +import { items } from '@/mock-data/items'; +import { useTranslations } from 'next-intl'; +import { getTranslations, unstable_setRequestLocale } from 'next-intl/server'; +import Image from 'next/image'; +import { createSearchParamsCache, parseAsInteger } from 'nuqs/parsers'; + +import { PaginationCarousel } from '@/components/layout/PaginationCarousel'; +import { Button } from '@/components/ui/Button'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/Card'; +import { Combobox } from '@/components/ui/Combobox'; +import { SearchBar } from '@/components/ui/SearchBar'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/Select'; + +export async function generateMetadata({ + params: { locale }, +}: { + params: { locale: string }; +}) { + const t = await getTranslations({ locale, namespace: 'layout' }); + + return { + title: t('storage'), + }; +} + +export default function StoragePage({ + params: { locale }, + searchParams, +}: { + params: { locale: string }; + searchParams: Record; +}) { + unstable_setRequestLocale(locale); + const t = useTranslations('storage'); + const t_ui = useTranslations('ui'); + + const itemsPerPage = 12; + + const searchParamsCache = createSearchParamsCache({ + [t_ui('page')]: parseAsInteger.withDefault(1), + }); + + const { [t_ui('page')]: page = 1 } = searchParamsCache.parse(searchParams); + + // TODO: Implement filters and category selection + const categories = [ + { + value: 'cables', + label: t('combobox.cables'), + }, + { + value: 'sensors', + label: t('combobox.sensors'), + }, + { + value: 'peripherals', + label: t('combobox.peripherals'), + }, + { + value: 'miniPC', + label: t('combobox.miniPC'), + }, + ]; + + const filters = [ + 'select.popularity', + 'select.sortDescending', + 'select.sortAscending', + 'select.name', + ] as const; + + return ( + <> +
+ + + + +
+
+ {items + .slice((page - 1) * itemsPerPage, page * itemsPerPage) + .map((item) => ( + + +
+ {`Photo +
+ {item.name} + + {item.location} + +
+ + + {t('card.quantityInfo', { quantity: item.quantity })} + + + +
+ ))} +
+ + + ); +} diff --git a/src/components/storage/SkeletonCard.tsx b/src/components/storage/SkeletonCard.tsx new file mode 100644 index 0000000..8c3cafb --- /dev/null +++ b/src/components/storage/SkeletonCard.tsx @@ -0,0 +1,17 @@ +import { Skeleton } from '@/components/ui/Skeleton'; + +export function SkeletonCard() { + return ( +
+
+ + + +
+
+ + +
+
+ ); +} diff --git a/src/components/ui/Combobox.tsx b/src/components/ui/Combobox.tsx new file mode 100644 index 0000000..9fe99fe --- /dev/null +++ b/src/components/ui/Combobox.tsx @@ -0,0 +1,91 @@ +'use client'; + +import { Check, ChevronsUpDown } from 'lucide-react'; +import * as React from 'react'; + +import { cx } from '@/lib/utils'; + +import { Button } from '@/components/ui/Button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/Command'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/Popover'; + +type ComboboxProps = { + choices: { + value: string; + label: string; + }[]; + defaultDescription: string; + defaultPlaceholder: string; + buttonClassName?: string; + contentClassName?: string; +}; + +function Combobox({ + choices, + defaultDescription, + defaultPlaceholder, + buttonClassName, + contentClassName, +}: ComboboxProps) { + const [open, setOpen] = React.useState(false); + const [value, setValue] = React.useState(''); + + return ( + + + + + + + + + Ingen valg funnet. + + {choices.map((choice) => ( + { + setValue(currentValue === value ? '' : currentValue); + setOpen(false); + }} + > + + {choice.label} + + ))} + + + + + + ); +} + +export { Combobox }; diff --git a/src/components/ui/Command.tsx b/src/components/ui/Command.tsx new file mode 100644 index 0000000..5f651e8 --- /dev/null +++ b/src/components/ui/Command.tsx @@ -0,0 +1,156 @@ +'use client'; + +import type { DialogProps } from '@radix-ui/react-dialog'; +import { Command as CommandPrimitive } from 'cmdk'; +import { Search } from 'lucide-react'; +import * as React from 'react'; + +import { cx } from '@/lib/utils'; + +import { Dialog, DialogContent } from '@/components/ui/Dialog'; + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Command.displayName = CommandPrimitive.displayName; + +type CommandDialogProps = DialogProps; + +const CommandDialog = ({ children, ...props }: CommandDialogProps) => { + return ( + + + + {children} + + + + ); +}; + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)); + +CommandInput.displayName = CommandPrimitive.Input.displayName; + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandList.displayName = CommandPrimitive.List.displayName; + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)); + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName; + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandGroup.displayName = CommandPrimitive.Group.displayName; + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandSeparator.displayName = CommandPrimitive.Separator.displayName; + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandItem.displayName = CommandPrimitive.Item.displayName; + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +CommandShortcut.displayName = 'CommandShortcut'; + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +}; diff --git a/src/components/ui/Dialog.tsx b/src/components/ui/Dialog.tsx new file mode 100644 index 0000000..bbbab19 --- /dev/null +++ b/src/components/ui/Dialog.tsx @@ -0,0 +1,122 @@ +'use client'; + +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { X } from 'lucide-react'; +import * as React from 'react'; + +import { cx } from '@/lib/utils'; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = 'DialogHeader'; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = 'DialogFooter'; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/src/components/ui/Input.tsx b/src/components/ui/Input.tsx new file mode 100644 index 0000000..a16957e --- /dev/null +++ b/src/components/ui/Input.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; + +import { cx } from '@/lib/utils'; + +export type InputProps = React.InputHTMLAttributes; + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ); + }, +); +Input.displayName = 'Input'; + +export { Input }; diff --git a/src/components/ui/Popover.tsx b/src/components/ui/Popover.tsx new file mode 100644 index 0000000..4970f8e --- /dev/null +++ b/src/components/ui/Popover.tsx @@ -0,0 +1,31 @@ +'use client'; + +import * as PopoverPrimitive from '@radix-ui/react-popover'; +import * as React from 'react'; + +import { cx } from '@/lib/utils'; + +const Popover = PopoverPrimitive.Root; + +const PopoverTrigger = PopoverPrimitive.Trigger; + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( + + + +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverTrigger, PopoverContent }; diff --git a/src/components/ui/SearchBar.tsx b/src/components/ui/SearchBar.tsx new file mode 100644 index 0000000..f4a6cdc --- /dev/null +++ b/src/components/ui/SearchBar.tsx @@ -0,0 +1,37 @@ +import { Search } from 'lucide-react'; +import * as React from 'react'; + +import { cx } from '@/lib/utils'; + +type SearchBarProps = React.InputHTMLAttributes; + +/** + * This component creates a full search bar with an icon. + * The ref, if used, is passed onto the input element, not the wrapper for the component. + */ +const SearchBar = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( +
+ + +
+ ); + }, +); +SearchBar.displayName = 'SearchBar'; + +export { SearchBar }; diff --git a/src/components/ui/Select.tsx b/src/components/ui/Select.tsx new file mode 100644 index 0000000..7c33ff2 --- /dev/null +++ b/src/components/ui/Select.tsx @@ -0,0 +1,160 @@ +'use client'; + +import * as SelectPrimitive from '@radix-ui/react-select'; +import { Check, ChevronDown, ChevronUp } from 'lucide-react'; +import * as React from 'react'; + +import { cx } from '@/lib/utils'; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1', + className, + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = 'popper', ...props }, ref) => ( + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/src/lib/config.ts b/src/lib/config.ts index b839efa..1ea67ef 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -26,6 +26,10 @@ const pathnames = { en: '/news/[article]', no: '/nyheter/[article]', }, + '/storage': { + en: '/storage', + no: '/lager', + }, '/about': { en: '/about', no: '/om-oss', diff --git a/src/mock-data/items.ts b/src/mock-data/items.ts new file mode 100644 index 0000000..ce106f9 --- /dev/null +++ b/src/mock-data/items.ts @@ -0,0 +1,144 @@ +const items = [ + { + name: 'Laptop', + photo_url: 'https://example.com/photos/laptop.jpg', + status: 'Operational', + quantity: 15, + location: 'Storage Room A', + }, + { + name: 'Desktop PC', + photo_url: 'https://example.com/photos/desktop_pc.jpg', + status: 'Operational', + quantity: 10, + location: 'Workstation Area 1', + }, + { + name: 'Monitor', + photo_url: 'https://example.com/photos/monitor.jpg', + status: 'Operational', + quantity: 20, + location: 'Storage Room B', + }, + { + name: 'Keyboard', + photo_url: 'https://example.com/photos/keyboard.jpg', + status: 'Operational', + quantity: 50, + location: 'Storage Room A', + }, + { + name: 'Mouse', + photo_url: 'https://example.com/photos/mouse.jpg', + status: 'Operational', + quantity: 50, + location: 'Storage Room A', + }, + { + name: 'Router', + photo_url: 'https://example.com/photos/router.jpg', + status: 'Operational', + quantity: 5, + location: 'Networking Room', + }, + { + name: 'Ethernet Cable', + photo_url: 'https://example.com/photos/ethernet_cable.jpg', + status: 'Operational', + quantity: 100, + location: 'Networking Room', + }, + { + name: 'External Hard Drive', + photo_url: 'https://example.com/photos/external_hard_drive.jpg', + status: 'Operational', + quantity: 25, + location: 'Storage Room B', + }, + { + name: 'USB Flash Drive', + photo_url: 'https://example.com/photos/usb_flash_drive.jpg', + status: 'Operational', + quantity: 75, + location: 'Storage Room B', + }, + { + name: 'Power Supply Unit (PSU)', + photo_url: 'https://example.com/photos/psu.jpg', + status: 'Operational', + quantity: 30, + location: 'Storage Room C', + }, + { + name: 'Graphics Card', + photo_url: 'https://example.com/photos/graphics_card.jpg', + status: 'Operational', + quantity: 12, + location: 'Storage Room C', + }, + { + name: 'RAM Module', + photo_url: 'https://example.com/photos/ram_module.jpg', + status: 'Operational', + quantity: 40, + location: 'Storage Room C', + }, + { + name: 'Motherboard', + photo_url: 'https://example.com/photos/motherboard.jpg', + status: 'Operational', + quantity: 10, + location: 'Storage Room C', + }, + { + name: 'CPU', + photo_url: 'https://example.com/photos/cpu.jpg', + status: 'Operational', + quantity: 10, + location: 'Storage Room C', + }, + { + name: 'SSD', + photo_url: 'https://example.com/photos/ssd.jpg', + status: 'Operational', + quantity: 20, + location: 'Storage Room C', + }, + { + name: 'Network Switch', + photo_url: 'https://example.com/photos/network_switch.jpg', + status: 'Operational', + quantity: 5, + location: 'Networking Room', + }, + { + name: 'Soldering Iron', + photo_url: 'https://example.com/photos/soldering_iron.jpg', + status: 'Operational', + quantity: 8, + location: 'Repair Station', + }, + { + name: 'Multimeter', + photo_url: 'https://example.com/photos/multimeter.jpg', + status: 'Operational', + quantity: 10, + location: 'Repair Station', + }, + { + name: 'Screwdriver Set', + photo_url: 'https://example.com/photos/screwdriver_set.jpg', + status: 'Operational', + quantity: 20, + location: 'Toolbox 1', + }, + { + name: 'Anti-static Wrist Strap', + photo_url: 'https://example.com/photos/anti_static_wrist_strap.jpg', + status: 'Operational', + quantity: 15, + location: 'Toolbox 2', + }, +]; + +export { items };