From 4ef2b639b603e3b91820a17671ca60af109e3d0f Mon Sep 17 00:00:00 2001 From: Sonny Date: Thu, 21 Aug 2025 02:27:51 +0200 Subject: [PATCH] feat: add multiple way to show collections and links --- .../display_preferences_controller.ts | 26 +++ app/user/lib/index.ts | 12 ++ app/user/models/user.ts | 13 ++ app/user/routes/routes.ts | 1 + .../routes/user_display_preferences_route.ts | 10 + .../validators/update_display_preferences.ts | 26 +++ ..._persist_data_display_preferences_table.ts | 17 ++ .../common/combo_list/combo_list.tsx | 66 ++++++ .../common/combo_list/combo_list_item.tsx | 16 ++ .../floating_navbar/floating_navbar.tsx | 8 +- .../floating_navbar/user_dropdown.module.css | 4 - .../common/floating_navbar/user_dropdown.tsx | 20 +- .../user_preferences/user_preferences.tsx | 43 ++++ .../dashboard/collection/collection_list.tsx | 29 +++ .../collection/collection_list_selector.tsx | 20 ++ .../collection/inline_collection_list.tsx | 58 +++++ .../item/collection_favorite_item.tsx | 29 +++ .../collection/item/collection_item.tsx | 13 +- .../collection/mobile_collection_list.tsx | 50 +++++ .../shared_collection_copy_link.tsx | 32 +++ .../dashboard/link/item/link_item.tsx | 8 +- .../dashboard/link/list/link_list.tsx | 42 +++- inertia/components/form/form_collection.tsx | 4 +- inertia/components/share/share_collection.tsx | 4 +- .../use_collection_list_selector.tsx | 31 --- inertia/hooks/use_app_url.tsx | 7 + inertia/hooks/use_disable_overflow.ts | 9 - inertia/hooks/use_display_preferences.tsx | 26 +++ inertia/hooks/use_is_mobile.tsx | 3 + inertia/i18n/locales/en/common.json | 12 +- inertia/i18n/locales/fr/common.json | 12 +- inertia/layouts/_base_layout.tsx | 5 +- inertia/layouts/default_layout.tsx | 2 +- inertia/lib/display_preferences.tsx | 34 +++ inertia/pages/dashboard.tsx | 199 ++++++++++-------- inertia/pages/shared.tsx | 10 +- inertia/types/app.ts | 3 + package.json | 1 + pnpm-lock.yaml | 18 ++ shared/lib/display_preferences.ts | 18 ++ shared/types/index.ts | 8 + 41 files changed, 785 insertions(+), 164 deletions(-) create mode 100644 app/user/controllers/display_preferences_controller.ts create mode 100644 app/user/lib/index.ts create mode 100644 app/user/routes/user_display_preferences_route.ts create mode 100644 app/user/validators/update_display_preferences.ts create mode 100644 database/migrations/1755640100586_persist_data_display_preferences_table.ts create mode 100644 inertia/components/common/combo_list/combo_list.tsx create mode 100644 inertia/components/common/combo_list/combo_list_item.tsx create mode 100644 inertia/components/common/user_preferences/user_preferences.tsx create mode 100644 inertia/components/dashboard/collection/collection_list.tsx create mode 100644 inertia/components/dashboard/collection/collection_list_selector.tsx create mode 100644 inertia/components/dashboard/collection/inline_collection_list.tsx create mode 100644 inertia/components/dashboard/collection/item/collection_favorite_item.tsx create mode 100644 inertia/components/dashboard/collection/mobile_collection_list.tsx create mode 100644 inertia/components/dashboard/collection/shared_collection_copy_link.tsx delete mode 100644 inertia/hooks/collections/use_collection_list_selector.tsx create mode 100644 inertia/hooks/use_app_url.tsx delete mode 100644 inertia/hooks/use_disable_overflow.ts create mode 100644 inertia/hooks/use_display_preferences.tsx create mode 100644 inertia/hooks/use_is_mobile.tsx create mode 100644 inertia/lib/display_preferences.tsx create mode 100644 shared/lib/display_preferences.ts diff --git a/app/user/controllers/display_preferences_controller.ts b/app/user/controllers/display_preferences_controller.ts new file mode 100644 index 0000000..347cd35 --- /dev/null +++ b/app/user/controllers/display_preferences_controller.ts @@ -0,0 +1,26 @@ +import { getDisplayPreferences } from '#shared/lib/display_preferences'; +import { updateDisplayPreferencesValidator } from '#user/validators/update_display_preferences'; +import { HttpContext } from '@adonisjs/core/http'; + +export default class DisplayPreferencesController { + async update({ request, response, auth }: HttpContext) { + const { displayPreferences } = await request.validateUsing( + updateDisplayPreferencesValidator + ); + const userPrefs = auth.user!.displayPreferences ?? {}; + const mergedPrefs = { + linkListDisplay: + displayPreferences.linkListDisplay ?? + userPrefs.linkListDisplay ?? + getDisplayPreferences().linkListDisplay, + collectionListDisplay: + displayPreferences.collectionListDisplay ?? + userPrefs.collectionListDisplay ?? + getDisplayPreferences().collectionListDisplay, + }; + auth.user!.displayPreferences = mergedPrefs; + console.log(auth.user!.displayPreferences); + await auth.user!.save(); + return response.redirect().withQs().back(); + } +} diff --git a/app/user/lib/index.ts b/app/user/lib/index.ts new file mode 100644 index 0000000..469d304 --- /dev/null +++ b/app/user/lib/index.ts @@ -0,0 +1,12 @@ +import { type DisplayPreferences } from '#shared/types/index'; +import User from '#user/models/user'; + +export function ensureDisplayPreferences(user: User): DisplayPreferences { + const defaults: DisplayPreferences = { + linkListDisplay: 'grid', + collectionListDisplay: 'list', + }; + + user.displayPreferences = { ...defaults, ...user.displayPreferences }; + return user.displayPreferences; +} diff --git a/app/user/models/user.ts b/app/user/models/user.ts index 1443385..03c5d8e 100644 --- a/app/user/models/user.ts +++ b/app/user/models/user.ts @@ -1,6 +1,8 @@ import Collection from '#collections/models/collection'; import AppBaseModel from '#core/models/app_base_model'; import Link from '#links/models/link'; +import { type DisplayPreferences } from '#shared/types/index'; +import { ensureDisplayPreferences } from '#user/lib/index'; import type { GoogleToken } from '@adonisjs/ally/types'; import { column, computed, hasMany } from '@adonisjs/lucid/orm'; import type { HasMany } from '@adonisjs/lucid/types/relations'; @@ -51,4 +53,15 @@ export default class User extends AppBaseModel { autoUpdate: true, }) declare lastSeenAt: DateTime; + + @column({ + serialize: (value) => { + if (typeof value === 'string') { + return ensureDisplayPreferences(JSON.parse(value)); + } + return value; + }, + prepare: (value) => JSON.stringify(value), + }) + declare displayPreferences: DisplayPreferences; } diff --git a/app/user/routes/routes.ts b/app/user/routes/routes.ts index 1dcf892..2a3e4df 100644 --- a/app/user/routes/routes.ts +++ b/app/user/routes/routes.ts @@ -1 +1,2 @@ +import './user_display_preferences_route.js'; import './user_theme_route.js'; diff --git a/app/user/routes/user_display_preferences_route.ts b/app/user/routes/user_display_preferences_route.ts new file mode 100644 index 0000000..3d67479 --- /dev/null +++ b/app/user/routes/user_display_preferences_route.ts @@ -0,0 +1,10 @@ +import { middleware } from '#start/kernel'; +import router from '@adonisjs/core/services/router'; + +const DisplayPreferencesController = () => + import('#user/controllers/display_preferences_controller'); + +router + .post('/user/display-preferences', [DisplayPreferencesController, 'update']) + .as('user.update-display-preferences') + .middleware([middleware.auth()]); diff --git a/app/user/validators/update_display_preferences.ts b/app/user/validators/update_display_preferences.ts new file mode 100644 index 0000000..cee2485 --- /dev/null +++ b/app/user/validators/update_display_preferences.ts @@ -0,0 +1,26 @@ +import { + COLLECTION_LIST_DISPLAYS, + DEFAULT_LIST_DISPLAY_PREFERENCES, + LINK_LIST_DISPLAYS, +} from '#shared/lib/display_preferences'; +import vine from '@vinejs/vine'; + +export const updateDisplayPreferencesValidator = vine.compile( + vine.object({ + displayPreferences: vine.object({ + linkListDisplay: vine + .enum(LINK_LIST_DISPLAYS) + .optional() + .transform( + (value) => value ?? DEFAULT_LIST_DISPLAY_PREFERENCES.linkListDisplay + ), + collectionListDisplay: vine + .enum(COLLECTION_LIST_DISPLAYS) + .optional() + .transform( + (value) => + value ?? DEFAULT_LIST_DISPLAY_PREFERENCES.collectionListDisplay + ), + }), + }) +); diff --git a/database/migrations/1755640100586_persist_data_display_preferences_table.ts b/database/migrations/1755640100586_persist_data_display_preferences_table.ts new file mode 100644 index 0000000..a2bd3a6 --- /dev/null +++ b/database/migrations/1755640100586_persist_data_display_preferences_table.ts @@ -0,0 +1,17 @@ +import { BaseSchema } from '@adonisjs/lucid/schema'; + +export default class extends BaseSchema { + protected tableName = 'users'; + + async up() { + this.schema.alterTable(this.tableName, (table) => { + table.jsonb('display_preferences').defaultTo('{}'); + }); + } + + async down() { + this.schema.alterTable(this.tableName, (table) => { + table.dropColumn('display_preferences'); + }); + } +} diff --git a/inertia/components/common/combo_list/combo_list.tsx b/inertia/components/common/combo_list/combo_list.tsx new file mode 100644 index 0000000..0e4cf1f --- /dev/null +++ b/inertia/components/common/combo_list/combo_list.tsx @@ -0,0 +1,66 @@ +import { Combobox, Input, InputBase, useCombobox } from '@mantine/core'; +import { ComboListItem } from '~/components/common/combo_list/combo_list_item'; + +export type ValueWithIcon = { + label: string; + value: string; + icon: React.ReactNode; +}; + +export function ComboList({ + selectedValue, + values, + setValue, +}: { + selectedValue: string; + values: ValueWithIcon[]; + setValue: (value: string) => void; +}) { + const combobox = useCombobox({ + onDropdownClose: () => combobox.resetSelectedOption(), + }); + + const selectedOption = values.find((item) => item.value === selectedValue); + + const options = values.map((item) => ( + + + + )); + + return ( + { + setValue(val as string); + combobox.closeDropdown(); + }} + > + + } + onClick={() => combobox.toggleDropdown()} + rightSectionPointerEvents="none" + multiline + > + {selectedOption ? ( + + ) : ( + Pick value + )} + + + + + {options} + + + ); +} diff --git a/inertia/components/common/combo_list/combo_list_item.tsx b/inertia/components/common/combo_list/combo_list_item.tsx new file mode 100644 index 0000000..9765aba --- /dev/null +++ b/inertia/components/common/combo_list/combo_list_item.tsx @@ -0,0 +1,16 @@ +import { Group, Text } from '@mantine/core'; + +export const ComboListItem = ({ + emoji, + label, +}: { + emoji: React.ReactNode; + label: string; +}) => ( + + {emoji} + + {label} + + +); diff --git a/inertia/components/common/floating_navbar/floating_navbar.tsx b/inertia/components/common/floating_navbar/floating_navbar.tsx index f5180f9..6eaa525 100644 --- a/inertia/components/common/floating_navbar/floating_navbar.tsx +++ b/inertia/components/common/floating_navbar/floating_navbar.tsx @@ -19,6 +19,8 @@ import { useEffect } from 'react'; import { UserDropdown } from '~/components/common/floating_navbar/user_dropdown'; import { ExternalLinkUnstyled } from '~/components/common/links/external_link_unstyled'; import { InternalLink } from '~/components/common/links/internal_link'; +import { LocaleSwitcher } from '~/components/common/locale_switcher'; +import { ThemeSwitcher } from '~/components/common/theme_switcher'; import { useAuth } from '~/hooks/use_auth'; import classes from './floating_navbar.module.css'; @@ -74,8 +76,8 @@ export function FloatingNavbar({ width }: FloatingNavbarProps) { {!isMobile && {links}} - {isMobile && } {auth.isAuthenticated && } + {isMobile && } {!auth.isAuthenticated && ( + + + ); +} diff --git a/inertia/components/dashboard/collection/shared_collection_copy_link.tsx b/inertia/components/dashboard/collection/shared_collection_copy_link.tsx new file mode 100644 index 0000000..ac8e8b9 --- /dev/null +++ b/inertia/components/dashboard/collection/shared_collection_copy_link.tsx @@ -0,0 +1,32 @@ +import { Badge, CopyButton } from '@mantine/core'; +import { t } from 'i18next'; +import { TbCopy } from 'react-icons/tb'; +import { useActiveCollection } from '~/hooks/collections/use_active_collection'; +import { useAppUrl } from '~/hooks/use_app_url'; + +const COPY_TIMEOUT = 3_000; + +export function SharedCollectionCopyLink() { + const appUrl = useAppUrl(); + const activeCollection = useActiveCollection(); + + if (!activeCollection) { + return null; + } + + const copyUrl = `${appUrl}/shared/${activeCollection.id}`; + return ( + + {({ copied, copy }) => ( + + {copied ? t('success-copy') : t('visibility.public')} + {!copied && } + + )} + + ); +} diff --git a/inertia/components/dashboard/link/item/link_item.tsx b/inertia/components/dashboard/link/item/link_item.tsx index 4c7edd6..64991e9 100644 --- a/inertia/components/dashboard/link/item/link_item.tsx +++ b/inertia/components/dashboard/link/item/link_item.tsx @@ -28,7 +28,13 @@ export function LinkItem({ link, hideMenu: hideMenu = false }: LinkItemProps) { {!hideMenu && } {description && ( - + {description} )} diff --git a/inertia/components/dashboard/link/list/link_list.tsx b/inertia/components/dashboard/link/list/link_list.tsx index 3195a76..2e96d60 100644 --- a/inertia/components/dashboard/link/list/link_list.tsx +++ b/inertia/components/dashboard/link/list/link_list.tsx @@ -1,24 +1,52 @@ -import { Stack } from '@mantine/core'; +import { DisplayPreferences } from '#shared/types/index'; +import { SimpleGrid, Stack, StyleProp, useCombobox } from '@mantine/core'; import { LinkItem } from '~/components/dashboard/link/item/link_item'; import { NoLink } from '~/components/dashboard/link/no_link/no_link'; -import { useActiveCollection } from '~/stores/collection_store'; +import { useActiveCollection } from '~/hooks/collections/use_active_collection'; +import { useFavoriteLinks } from '~/hooks/collections/use_favorite_links'; +import { useDisplayPreferences } from '~/hooks/use_display_preferences'; export interface LinkListProps { hideMenu?: boolean; } export function LinkList({ hideMenu = false }: LinkListProps) { - const { activeCollection } = useActiveCollection(); + const activeCollection = useActiveCollection(); + const favoriteLinks = useFavoriteLinks(); + const { displayPreferences } = useDisplayPreferences(); - if (!activeCollection?.links || activeCollection.links.length === 0) { + const combobox = useCombobox({ + onDropdownClose: () => combobox.resetSelectedOption(), + }); + + const links = activeCollection?.links || favoriteLinks; + + if (activeCollection?.links.length === 0) { return ; } return ( - {activeCollection?.links.map((link) => ( - - ))} + + {links.map((link) => ( + + ))} + ); } + +function getColsByView( + displayPreferences: DisplayPreferences +): StyleProp { + const { linkListDisplay } = displayPreferences; + + if (linkListDisplay === 'grid') { + return { + sm: 1, + md: 2, + lg: 3, + }; + } + return 1; +} diff --git a/inertia/components/form/form_collection.tsx b/inertia/components/form/form_collection.tsx index 8637495..339e1ec 100644 --- a/inertia/components/form/form_collection.tsx +++ b/inertia/components/form/form_collection.tsx @@ -2,6 +2,7 @@ import { Box, Group, SegmentedControl, Text, TextInput } from '@mantine/core'; import { FormEvent } from 'react'; import { useTranslation } from 'react-i18next'; import BackToDashboard from '~/components/common/navigation/back_to_dashboard'; +import useSearchParam from '~/hooks/use_search_param'; import { FormLayout, FormLayoutProps } from '~/layouts/form_layout'; import { Visibility } from '~/types/app'; @@ -29,6 +30,7 @@ export default function MantineFormCollection({ ...props }: FormCollectionProps) { const { t } = useTranslation('common'); + const collectionId = Number(useSearchParam('collectionId')); const onSubmit = (event: FormEvent) => { event.preventDefault(); @@ -36,7 +38,7 @@ export default function MantineFormCollection({ }; return ( - + , - }, - { - label: 'List', - value: 'list', - icon: , - }, -]; -type ListDisplay = (typeof listDisplayOptions)[number]['value']; - -export const useCollectionListSelector = () => { - const [listDisplay, setListDisplay] = usePersisted( - 'inline', - 'list' - ); - - return { - listDisplay, - listDisplayOptions, - setListDisplay, - }; -}; diff --git a/inertia/hooks/use_app_url.tsx b/inertia/hooks/use_app_url.tsx new file mode 100644 index 0000000..9bb117d --- /dev/null +++ b/inertia/hooks/use_app_url.tsx @@ -0,0 +1,7 @@ +import { PageProps } from '@adonisjs/inertia/types'; +import { usePage } from '@inertiajs/react'; + +export function useAppUrl() { + const { props } = usePage(); + return props.appUrl; +} diff --git a/inertia/hooks/use_disable_overflow.ts b/inertia/hooks/use_disable_overflow.ts deleted file mode 100644 index 6fc7270..0000000 --- a/inertia/hooks/use_disable_overflow.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { useEffect } from 'react'; - -export const useDisableOverflow = () => - useEffect(() => { - document.body.style.overflow = 'hidden'; - return () => { - document.body.style.overflow = 'auto'; - }; - }, []); diff --git a/inertia/hooks/use_display_preferences.tsx b/inertia/hooks/use_display_preferences.tsx new file mode 100644 index 0000000..aec7931 --- /dev/null +++ b/inertia/hooks/use_display_preferences.tsx @@ -0,0 +1,26 @@ +import { getDisplayPreferences } from '#shared/lib/display_preferences'; +import { DisplayPreferences } from '#shared/types/index'; +import { router } from '@inertiajs/react'; +import { route } from '@izzyjs/route/client'; +import { useAuth } from '~/hooks/use_auth'; + +export const useDisplayPreferences = () => { + const { user } = useAuth(); + const displayPreferences = getDisplayPreferences(user?.displayPreferences); + + const handleUpdateDisplayPreferences = ( + displayPreferences: Partial + ) => { + router.visit(route('user.update-display-preferences').path, { + method: 'post', + data: { + displayPreferences, + }, + }); + }; + + return { + displayPreferences, + handleUpdateDisplayPreferences, + }; +}; diff --git a/inertia/hooks/use_is_mobile.tsx b/inertia/hooks/use_is_mobile.tsx new file mode 100644 index 0000000..d5d3dfe --- /dev/null +++ b/inertia/hooks/use_is_mobile.tsx @@ -0,0 +1,3 @@ +import { useMediaQuery } from '@mantine/hooks'; + +export const useIsMobile = () => useMediaQuery('(max-width: 768px)'); diff --git a/inertia/i18n/locales/en/common.json b/inertia/i18n/locales/en/common.json index 043d9c5..6d7edee 100644 --- a/inertia/i18n/locales/en/common.json +++ b/inertia/i18n/locales/en/common.json @@ -56,7 +56,7 @@ "go-to-collection": "Go to collection", "no-item-found": "No item found", "admin": "Admin", - "manage_users": "Manage users", + "manage-users": "Manage users", "user": "User", "search": "Search", "search-with": "Search with", @@ -83,5 +83,13 @@ "loading": "Loading...", "no-results": "No results found", "click-to-copy": "Click on the following link to copy the shareable url", - "success-copy": "Link copied to clipboard" + "success-copy": "Link copied to clipboard", + "user-preferences": "User preferences", + "preferences": "Preferences", + "preferences-description": "Display preferences do not apply on mobile", + "display-preferences": { + "collection-list-display": "Collection list display", + "link-list-display": "Link list display" + }, + "coming-soon": "Under development" } diff --git a/inertia/i18n/locales/fr/common.json b/inertia/i18n/locales/fr/common.json index d585ac3..d998637 100644 --- a/inertia/i18n/locales/fr/common.json +++ b/inertia/i18n/locales/fr/common.json @@ -56,7 +56,7 @@ "go-to-collection": "Voir la collection", "no-item-found": "Aucun élément trouvé", "admin": "Admin", - "manage_users": "Gestion utilisateurs", + "manage-users": "Gestion des utilisateurs", "user": "Utilisateur", "search": "Rechercher", "search-with": "Rechercher avec", @@ -83,5 +83,13 @@ "loading": "Chargement...", "no-results": "Aucun résultat trouvé", "click-to-copy": "Cliquez sur le lien suivant pour copier l'URL partageable", - "success-copy": "Link copié dans le presse-papiers" + "success-copy": "Link copié dans le presse-papiers", + "user-preferences": "Préférences utilisateur", + "preferences": "Préférences", + "preferences-description": "Les préférences d'affichage ne s'appliquent pas sur mobile", + "display-preferences": { + "collection-list-display": "Affichage de la liste des collections", + "link-list-display": "Affichage de la liste des liens" + }, + "coming-soon": "En cours de développement" } diff --git a/inertia/layouts/_base_layout.tsx b/inertia/layouts/_base_layout.tsx index 9bffb84..fc04159 100644 --- a/inertia/layouts/_base_layout.tsx +++ b/inertia/layouts/_base_layout.tsx @@ -8,6 +8,7 @@ import { rem, } from '@mantine/core'; import '@mantine/core/styles.css'; +import { ModalsProvider } from '@mantine/modals'; import '@mantine/spotlight/styles.css'; import { createTuyau } from '@tuyau/client'; import { TuyauProvider } from '@tuyau/inertia/react'; @@ -119,7 +120,9 @@ export function BaseLayout({ children }: { children: ReactNode }) { return ( - {children} + + {children} + ); } diff --git a/inertia/layouts/default_layout.tsx b/inertia/layouts/default_layout.tsx index 04758bd..88892d3 100644 --- a/inertia/layouts/default_layout.tsx +++ b/inertia/layouts/default_layout.tsx @@ -31,7 +31,7 @@ const Layout = ({ children }: PropsWithChildren) => ( maxWidth: '100%', width: LAYOUT_WIDTH, marginInline: 'auto', - marginBlock: rem(60), + marginBlock: rem(30), }} > {children} diff --git a/inertia/lib/display_preferences.tsx b/inertia/lib/display_preferences.tsx new file mode 100644 index 0000000..109148b --- /dev/null +++ b/inertia/lib/display_preferences.tsx @@ -0,0 +1,34 @@ +import { + COLLECTION_LIST_DISPLAYS, + LINK_LIST_DISPLAYS, +} from '#shared/lib/display_preferences'; +import { AiOutlineFolder } from 'react-icons/ai'; +import { IoGridOutline } from 'react-icons/io5'; +import { TbList } from 'react-icons/tb'; +import { ValueWithIcon } from '~/components/common/combo_list/combo_list'; + +const collectionListDisplayIcons = { + list: , + inline: , +} as const; + +export function getCollectionListDisplayOptions(): ValueWithIcon[] { + return COLLECTION_LIST_DISPLAYS.map((display) => ({ + label: display, + value: display, + icon: collectionListDisplayIcons[display], + })); +} + +const linkListDisplayIcons = { + list: , + grid: , +} as const; + +export function getLinkListDisplayOptions(): ValueWithIcon[] { + return LINK_LIST_DISPLAYS.map((display) => ({ + label: display, + value: display, + icon: linkListDisplayIcons[display], + })); +} diff --git a/inertia/pages/dashboard.tsx b/inertia/pages/dashboard.tsx index 8e4d5f2..4241651 100644 --- a/inertia/pages/dashboard.tsx +++ b/inertia/pages/dashboard.tsx @@ -1,98 +1,117 @@ -import { AppShell, ScrollArea } from '@mantine/core'; -import { useDisclosure } from '@mantine/hooks'; -import { useEffect } from 'react'; -import { DashboardAside } from '~/components/dashboard/dashboard_aside'; -import { DashboardHeader } from '~/components/dashboard/dashboard_header'; -import { DashboardNavbar } from '~/components/dashboard/dashboard_navbar'; -import { LinkList } from '~/components/dashboard/link/list/link_list'; -import { MantineFooter } from '~/components/footer/footer'; -import { useDisableOverflow } from '~/hooks/use_disable_overflow'; -import useShortcut from '~/hooks/use_shortcut'; -import { DashboardLayout } from '~/layouts/dashboard_layout'; +import { Link } from '@inertiajs/react'; +import { route } from '@izzyjs/route/client'; import { - useActiveCollection, - useCollectionsSetter, -} from '~/stores/collection_store'; -import { CollectionWithLinks } from '~/types/app'; -import classes from './dashboard.module.css'; + Box, + Button, + Divider, + Group, + Input, + Stack, + Text, + Tooltip, +} from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { CollectionList } from '~/components/dashboard/collection/collection_list'; +import { InlineCollectionList } from '~/components/dashboard/collection/inline_collection_list'; +import { MobileCollectionList } from '~/components/dashboard/collection/mobile_collection_list'; +import { SharedCollectionCopyLink } from '~/components/dashboard/collection/shared_collection_copy_link'; +import { LinkList } from '~/components/dashboard/link/list/link_list'; +import { useActiveCollection } from '~/hooks/collections/use_active_collection'; +import { useDisplayPreferences } from '~/hooks/use_display_preferences'; +import { useIsMobile } from '~/hooks/use_is_mobile'; +import { appendCollectionId } from '~/lib/navigation'; +import { Visibility } from '~/types/app'; -interface DashboardPageProps { - collections: CollectionWithLinks[]; - activeCollection: CollectionWithLinks; -} +export default function Dashboard() { + const { t } = useTranslation(); + const { displayPreferences } = useDisplayPreferences(); + const activeCollection = useActiveCollection(); -const HEADER_SIZE_WITH_DESCRIPTION = 60; -const HEADER_SIZE_WITHOUT_DESCRIPTION = 50; - -export default function MantineDashboard(props: Readonly) { - const [openedNavbar, { toggle: toggleNavbar, close: closeNavbar }] = - useDisclosure(); - const [openedAside, { toggle: toggleAside, close: closeAside }] = - useDisclosure(); - - const { activeCollection } = useActiveCollection(); - const { _setCollections, setActiveCollection } = useCollectionsSetter(); - - useShortcut('ESCAPE_KEY', () => { - closeNavbar(); - closeAside(); - }); - - useDisableOverflow(); - - useEffect(() => { - _setCollections(props.collections); - setActiveCollection(props.activeCollection); - }, []); - - const headerHeight = !!activeCollection?.description - ? HEADER_SIZE_WITH_DESCRIPTION - : HEADER_SIZE_WITHOUT_DESCRIPTION; - const footerHeight = 45; + const isMobile = useIsMobile(); + const isFavorite = !activeCollection?.id; return ( - -
- - + + + - - - + {!isFavorite && ( + + {activeCollection?.visibility === Visibility.PUBLIC && ( + + )} + + +
-
+ {t('collection.create')} + + + + + + + )} + + {displayPreferences.collectionListDisplay === 'inline' && !isMobile && ( + + )} + + + {activeCollection?.description && ( + + {activeCollection.description} + + )} + + + {displayPreferences.collectionListDisplay === 'list' && !isMobile && ( + + )} + + {isMobile && } + ); } diff --git a/inertia/pages/shared.tsx b/inertia/pages/shared.tsx index d16a0c0..ad8c48b 100644 --- a/inertia/pages/shared.tsx +++ b/inertia/pages/shared.tsx @@ -4,17 +4,17 @@ import { LinkList } from '~/components/dashboard/link/list/link_list'; import type { CollectionWithLinks, PublicUser } from '~/types/app'; interface SharedPageProps { - collection: CollectionWithLinks & { author: PublicUser }; + activeCollection: CollectionWithLinks & { author: PublicUser }; } -export default function SharedPage({ collection }: SharedPageProps) { +export default function SharedPage({ activeCollection }: SharedPageProps) { const { t } = useTranslation('common'); return ( <> - {collection.name} + {activeCollection.name} - {collection.description} + {activeCollection.description} diff --git a/inertia/types/app.ts b/inertia/types/app.ts index 80ec57f..764bba9 100644 --- a/inertia/types/app.ts +++ b/inertia/types/app.ts @@ -1,3 +1,5 @@ +import { DisplayPreferences } from '#shared/types/index'; + type CommonBase = { id: number; createdAt: string; @@ -10,6 +12,7 @@ export type User = CommonBase & { avatarUrl: string; isAdmin: boolean; lastSeenAt: string; + displayPreferences: DisplayPreferences; }; export type PublicUser = Omit; diff --git a/package.json b/package.json index 86c736d..40f26ff 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "@izzyjs/route": "^1.2.0", "@mantine/core": "^8.2.3", "@mantine/hooks": "^8.2.3", + "@mantine/modals": "^8.2.5", "@mantine/spotlight": "^8.2.3", "@tuyau/client": "^0.2.10", "@tuyau/core": "^0.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55f5bc7..785c72b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: '@mantine/hooks': specifier: ^8.2.3 version: 8.2.3(react@19.1.1) + '@mantine/modals': + specifier: ^8.2.5 + version: 8.2.5(@mantine/core@8.2.3(@mantine/hooks@8.2.3(react@19.1.1))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(@mantine/hooks@8.2.3(react@19.1.1))(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@mantine/spotlight': specifier: ^8.2.3 version: 8.2.3(@mantine/core@8.2.3(@mantine/hooks@8.2.3(react@19.1.1))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(@mantine/hooks@8.2.3(react@19.1.1))(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -1451,6 +1454,14 @@ packages: peerDependencies: react: ^18.x || ^19.x + '@mantine/modals@8.2.5': + resolution: {integrity: sha512-IUv/3kbnR8XCG5lFkgWtVbYJ+PsmGRHGSQs6AKgA8YrSKsFKYitXmhWbLzs9wRi/rWbYlUxkt9b3caCPAZe9IA==} + peerDependencies: + '@mantine/core': 8.2.5 + '@mantine/hooks': 8.2.5 + react: ^18.x || ^19.x + react-dom: ^18.x || ^19.x + '@mantine/spotlight@8.2.3': resolution: {integrity: sha512-jorSsWwHDtEMOnylpQG91xcKMeYQM5GVPz5Wbif7hpQgWguRq/elEhF6DTpoPwLlMnNrAC27CV6WPa+p0Gc1WA==} peerDependencies: @@ -6834,6 +6845,13 @@ snapshots: dependencies: react: 19.1.1 + '@mantine/modals@8.2.5(@mantine/core@8.2.3(@mantine/hooks@8.2.3(react@19.1.1))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(@mantine/hooks@8.2.3(react@19.1.1))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@mantine/core': 8.2.3(@mantine/hooks@8.2.3(react@19.1.1))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@mantine/hooks': 8.2.3(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + '@mantine/spotlight@8.2.3(@mantine/core@8.2.3(@mantine/hooks@8.2.3(react@19.1.1))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(@mantine/hooks@8.2.3(react@19.1.1))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: '@mantine/core': 8.2.3(@mantine/hooks@8.2.3(react@19.1.1))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) diff --git a/shared/lib/display_preferences.ts b/shared/lib/display_preferences.ts new file mode 100644 index 0000000..121a182 --- /dev/null +++ b/shared/lib/display_preferences.ts @@ -0,0 +1,18 @@ +import { DisplayPreferences } from '#shared/types/index'; + +export const COLLECTION_LIST_DISPLAYS = ['list', 'inline'] as const; +export const LINK_LIST_DISPLAYS = ['list', 'grid'] as const; + +export const DEFAULT_LIST_DISPLAY_PREFERENCES: DisplayPreferences = { + linkListDisplay: LINK_LIST_DISPLAYS[0], + collectionListDisplay: COLLECTION_LIST_DISPLAYS[0], +} as const; + +export function getDisplayPreferences( + displayPreferences: Partial = DEFAULT_LIST_DISPLAY_PREFERENCES +): DisplayPreferences { + return { + ...DEFAULT_LIST_DISPLAY_PREFERENCES, + ...displayPreferences, + } as const; +} diff --git a/shared/types/index.ts b/shared/types/index.ts index 7a3a7a4..e0cc8b3 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -1,3 +1,11 @@ import { api } from '#adonis/api'; export type ApiRouteName = (typeof api.routes)[number]['name']; + +export type CollectionListDisplay = 'list' | 'inline'; +export type LinkListDisplay = 'list' | 'grid'; + +export type DisplayPreferences = { + linkListDisplay: LinkListDisplay; + collectionListDisplay: CollectionListDisplay; +};