From 2cf8c5ae02d7c768d6c9c719c74b31c439139211 Mon Sep 17 00:00:00 2001 From: Sonny Date: Fri, 3 May 2024 02:10:15 +0200 Subject: [PATCH] feat: recreate dashboard page from previous version --- Dockerfile | 7 +- app/constants/keys.ts | 19 ++ app/controllers/collections_controller.ts | 8 +- app/middleware/log_request.ts | 10 +- docker-compose.yml | 1 + .../common/navigation/bask_to_dashboard.tsx | 18 ++ .../collection/collection_controls.tsx | 34 +++ .../collection/collection_description.tsx | 18 ++ .../collection/collection_header.tsx | 47 ++++ .../dashboard/link/link_favicon.tsx | 72 ++++++ .../dashboard/link_list/link_item.tsx | 197 ++++++++++++++++ .../dashboard/link_list/link_list.tsx | 93 ++++++++ .../dashboard/link_list/no_item.tsx | 59 +++++ .../quick_action/_quick_action_style.tsx | 22 ++ .../dashboard/quick_action/quick_action.tsx | 55 +++++ .../quick_action/quick_favorite_link.tsx | 25 ++ .../side_nav/collection/collection_item.tsx | 223 ++++++++++++++++++ .../side_nav/collection/collection_list.tsx | 99 ++++++++ .../side_nav/favorites/favorite_item.tsx | 67 ++++++ .../side_nav/favorites/favorite_list.tsx | 44 ++++ .../navigation_links/navigation_links.tsx | 46 ++++ .../dashboard/side_nav/side_navigation.tsx | 70 ++++++ .../side_nav/user_card/user_card.tsx | 73 ++++++ .../side_nav/visibilty/visibilty.tsx | 30 +++ .../components/dashboard/swiper_handler.tsx | 10 + .../components/layouts/dashboard_layout.tsx | 2 +- inertia/components/layouts/form_layout.tsx | 2 +- .../contexts/active_collection_context.tsx | 15 ++ inertia/contexts/collections_context.ts | 18 ++ inertia/contexts/favorites_context.ts | 16 ++ inertia/contexts/global_hotkeys_context.ts | 17 ++ inertia/hooks/use_active_collection.tsx | 5 + inertia/hooks/use_auto_focus.tsx | 9 + inertia/hooks/use_collections.tsx | 5 + inertia/hooks/use_favorites.tsx | 5 + inertia/hooks/use_global_hotkeys.tsx | 5 + inertia/hooks/use_is_mobile.tsx | 8 + inertia/hooks/use_local_storage.tsx | 30 +++ inertia/hooks/use_media_query.tsx | 24 ++ inertia/hooks/use_modal.tsx | 18 ++ inertia/hooks/use_search_item.tsx | 43 ++++ inertia/hooks/use_search_param.tsx | 12 + inertia/i18n/locales/en/about.json | 8 +- inertia/i18n/locales/en/common.json | 20 +- inertia/i18n/locales/en/home.json | 4 +- inertia/i18n/locales/en/privacy.json | 14 +- inertia/i18n/locales/fr/about.json | 8 +- inertia/i18n/locales/fr/common.json | 21 +- inertia/i18n/locales/fr/home.json | 4 +- inertia/i18n/locales/fr/privacy.json | 6 +- inertia/lib/array.ts | 39 +++ inertia/lib/collection.ts | 28 +++ inertia/lib/image.ts | 6 + inertia/lib/navigation.tsx | 9 + inertia/lib/request.ts | 24 ++ inertia/lib/search.tsx | 53 +++++ .../{collection => collections}/create.tsx | 47 ++-- inertia/pages/dashboard.tsx | 146 +++++++++++- inertia/styles/reset.ts | 12 + inertia/styles/theme.ts | 4 + inertia/types/emotion.d.ts | 2 + inertia/types/favicon.d.ts | 6 + inertia/types/search.d.ts | 17 ++ package-lock.json | 104 +++++++- package.json | 5 + vite.config.ts | 5 +- 66 files changed, 2087 insertions(+), 86 deletions(-) create mode 100644 app/constants/keys.ts create mode 100644 inertia/components/common/navigation/bask_to_dashboard.tsx create mode 100644 inertia/components/dashboard/collection/collection_controls.tsx create mode 100644 inertia/components/dashboard/collection/collection_description.tsx create mode 100644 inertia/components/dashboard/collection/collection_header.tsx create mode 100644 inertia/components/dashboard/link/link_favicon.tsx create mode 100644 inertia/components/dashboard/link_list/link_item.tsx create mode 100644 inertia/components/dashboard/link_list/link_list.tsx create mode 100644 inertia/components/dashboard/link_list/no_item.tsx create mode 100644 inertia/components/dashboard/quick_action/_quick_action_style.tsx create mode 100644 inertia/components/dashboard/quick_action/quick_action.tsx create mode 100644 inertia/components/dashboard/quick_action/quick_favorite_link.tsx create mode 100644 inertia/components/dashboard/side_nav/collection/collection_item.tsx create mode 100644 inertia/components/dashboard/side_nav/collection/collection_list.tsx create mode 100644 inertia/components/dashboard/side_nav/favorites/favorite_item.tsx create mode 100644 inertia/components/dashboard/side_nav/favorites/favorite_list.tsx create mode 100644 inertia/components/dashboard/side_nav/navigation_links/navigation_links.tsx create mode 100644 inertia/components/dashboard/side_nav/side_navigation.tsx create mode 100644 inertia/components/dashboard/side_nav/user_card/user_card.tsx create mode 100644 inertia/components/dashboard/side_nav/visibilty/visibilty.tsx create mode 100644 inertia/components/dashboard/swiper_handler.tsx create mode 100644 inertia/contexts/active_collection_context.tsx create mode 100644 inertia/contexts/collections_context.ts create mode 100644 inertia/contexts/favorites_context.ts create mode 100644 inertia/contexts/global_hotkeys_context.ts create mode 100644 inertia/hooks/use_active_collection.tsx create mode 100644 inertia/hooks/use_auto_focus.tsx create mode 100644 inertia/hooks/use_collections.tsx create mode 100644 inertia/hooks/use_favorites.tsx create mode 100644 inertia/hooks/use_global_hotkeys.tsx create mode 100644 inertia/hooks/use_is_mobile.tsx create mode 100644 inertia/hooks/use_local_storage.tsx create mode 100644 inertia/hooks/use_media_query.tsx create mode 100644 inertia/hooks/use_modal.tsx create mode 100644 inertia/hooks/use_search_item.tsx create mode 100644 inertia/hooks/use_search_param.tsx create mode 100644 inertia/lib/array.ts create mode 100644 inertia/lib/collection.ts create mode 100644 inertia/lib/image.ts create mode 100644 inertia/lib/navigation.tsx create mode 100644 inertia/lib/request.ts create mode 100644 inertia/lib/search.tsx rename inertia/pages/{collection => collections}/create.tsx (56%) create mode 100644 inertia/types/favicon.d.ts create mode 100644 inertia/types/search.d.ts diff --git a/Dockerfile b/Dockerfile index 264bde6..9799354 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,14 +7,15 @@ WORKDIR /usr/src/app # Install dependencies COPY package*.json ./ -RUN npm ci --omit="dev" +RUN npm ci --omit="dev" --ignore-scripts # Copy app source code COPY . . # Build app -RUN npm install +RUN npm install --ignore-scripts RUN npm run build --omit="dev" +RUN npx vite build COPY ./.env ./build @@ -30,4 +31,4 @@ ENV SESSION_DRIVER=cookie EXPOSE $PORT # Start app -CMD node build/bin/console.js migration:run --force && node build/bin/server.js +CMD node build/bin/console.js migration:run && node build/bin/server.js diff --git a/app/constants/keys.ts b/app/constants/keys.ts new file mode 100644 index 0000000..9becb69 --- /dev/null +++ b/app/constants/keys.ts @@ -0,0 +1,19 @@ +const OPEN_SEARCH_KEY = 's'; +const ESCAPE_KEY = 'escape'; + +const OPEN_CREATE_LINK_KEY = 'l'; +const OPEN_CREATE_COLLECTION_KEY = 'c'; + +const ARROW_UP = 'ArrowUp'; +const ARROW_DOWN = 'ArrowDown'; + +const KEYS = { + ARROW_DOWN, + ARROW_UP, + ESCAPE_KEY, + OPEN_CREATE_COLLECTION_KEY, + OPEN_CREATE_LINK_KEY, + OPEN_SEARCH_KEY, +}; + +export default KEYS; diff --git a/app/controllers/collections_controller.ts b/app/controllers/collections_controller.ts index 6ccd480..2dbb191 100644 --- a/app/controllers/collections_controller.ts +++ b/app/controllers/collections_controller.ts @@ -8,8 +8,8 @@ export default class CollectionsController { const collections = await Collection.findManyBy('author_id', auth.user!.id); const collectionsWithLinks = await Promise.all( - collections.map((collection) => { - collection.load('links'); + collections.map(async (collection) => { + await collection.load('links'); return collection; }) ); @@ -18,7 +18,7 @@ export default class CollectionsController { } async showCreatePage({ inertia }: HttpContext) { - return inertia.render('collection/create'); + return inertia.render('collections/create'); } async store({ request, response, auth }: HttpContext) { @@ -34,6 +34,6 @@ export default class CollectionsController { response: HttpContext['response'], collectionId: Collection['id'] ) { - return response.redirect(`${PATHS.DASHBOARD}?categoryId=${collectionId}`); + return response.redirect(`${PATHS.DASHBOARD}?collectionId=${collectionId}`); } } diff --git a/app/middleware/log_request.ts b/app/middleware/log_request.ts index 444e37e..f109cf4 100644 --- a/app/middleware/log_request.ts +++ b/app/middleware/log_request.ts @@ -3,7 +3,15 @@ import logger from '@adonisjs/core/services/logger'; export default class LogRequest { async handle({ request }: HttpContext, next: () => Promise) { - logger.info(`-> ${request.method()}: ${request.url()}`); + if ( + !request.url().startsWith('/node_modules') && + !request.url().startsWith('/inertia') && + !request.url().startsWith('/@vite') && + !request.url().startsWith('/@react-refresh') && + !request.url().includes('.ts') + ) { + logger.info(`-> ${request.method()}: ${request.url()}`); + } await next(); } } diff --git a/docker-compose.yml b/docker-compose.yml index e8617b0..7d8efc0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,7 @@ services: environment: - DB_HOST=postgres - HOST=0.0.0.0 + - NODE_ENV=production env_file: - .env depends_on: diff --git a/inertia/components/common/navigation/bask_to_dashboard.tsx b/inertia/components/common/navigation/bask_to_dashboard.tsx new file mode 100644 index 0000000..40f3261 --- /dev/null +++ b/inertia/components/common/navigation/bask_to_dashboard.tsx @@ -0,0 +1,18 @@ +import KEYS from '#constants/keys'; +import PATHS from '#constants/paths'; +import { router } from '@inertiajs/react'; +import { ReactNode } from 'react'; +import { useHotkeys } from 'react-hotkeys-hook'; +import useGlobalHotkeys from '~/hooks/use_global_hotkeys'; + +export default function BackToDashboard({ children }: { children: ReactNode }) { + const { globalHotkeysEnabled } = useGlobalHotkeys(); + useHotkeys( + KEYS.ESCAPE_KEY, + () => { + router.visit(PATHS.DASHBOARD); + }, + { enabled: globalHotkeysEnabled, enableOnFormTags: ['INPUT'] } + ); + return <>{children}; +} diff --git a/inertia/components/dashboard/collection/collection_controls.tsx b/inertia/components/dashboard/collection/collection_controls.tsx new file mode 100644 index 0000000..80db94a --- /dev/null +++ b/inertia/components/dashboard/collection/collection_controls.tsx @@ -0,0 +1,34 @@ +import styled from '@emotion/styled'; +import QuickResourceAction from '~/components/dashboard/quick_action/quick_action'; +import useActiveCollection from '~/hooks/use_active_collection'; + +const CollectionControlsStyle = styled.span({ + display: 'flex', + gap: '0.5em', + alignItems: 'center', +}); + +export default function CollectionControls() { + const { activeCollection } = useActiveCollection(); + return ( + activeCollection && ( + + + + + + ) + ); +} diff --git a/inertia/components/dashboard/collection/collection_description.tsx b/inertia/components/dashboard/collection/collection_description.tsx new file mode 100644 index 0000000..86ec273 --- /dev/null +++ b/inertia/components/dashboard/collection/collection_description.tsx @@ -0,0 +1,18 @@ +import styled from '@emotion/styled'; +import useActiveCollection from '~/hooks/use_active_collection'; + +const CollectionDescriptionStyle = styled.p({ + fontSize: '0.85em', + marginBottom: '0.5em', +}); + +export default function CollectionDescription() { + const { activeCollection } = useActiveCollection(); + return ( + activeCollection && ( + + {activeCollection?.description} + + ) + ); +} diff --git a/inertia/components/dashboard/collection/collection_header.tsx b/inertia/components/dashboard/collection/collection_header.tsx new file mode 100644 index 0000000..2e32614 --- /dev/null +++ b/inertia/components/dashboard/collection/collection_header.tsx @@ -0,0 +1,47 @@ +import styled from '@emotion/styled'; +import { Fragment } from 'react'; +import { useTranslation } from 'react-i18next'; +import VisibilityBadge from '~/components/dashboard/side_nav/visibilty/visibilty'; +import useActiveCollection from '~/hooks/use_active_collection'; + +const CollectionNameWrapper = styled.div({ + minWidth: 0, + width: '100%', + display: 'flex', + gap: '0.35em', + flex: 1, + alignItems: 'center', +}); + +const CollectionName = styled.div({ + minWidth: 0, + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + overflow: 'hidden', +}); + +const LinksCount = styled.div(({ theme }) => ({ + minWidth: 'fit-content', + fontWeight: 300, + fontSize: '0.8em', + color: theme.colors.grey, +})); + +export default function CollectionHeader() { + const { t } = useTranslation('common'); + const { activeCollection } = useActiveCollection(); + + if (!activeCollection) return ; + + const { name, links, visibility } = activeCollection; + return ( + + {name} + {links.length > 0 && — {links.length}} + + + ); +} diff --git a/inertia/components/dashboard/link/link_favicon.tsx b/inertia/components/dashboard/link/link_favicon.tsx new file mode 100644 index 0000000..5a6e53c --- /dev/null +++ b/inertia/components/dashboard/link/link_favicon.tsx @@ -0,0 +1,72 @@ +import styled from '@emotion/styled'; +import { useState } from 'react'; +import { TbLoader3 } from 'react-icons/tb'; +import { TfiWorld } from 'react-icons/tfi'; + +interface LinkFaviconProps { + url: string; + size?: number; + noMargin?: boolean; +} + +const Favicon = styled.div({ + position: 'relative', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', +}); +const FaviconLoader = styled.div(({ theme }) => ({ + position: 'absolute', + top: 0, + left: 0, + backgroundColor: theme.colors.white, + + '& > *': { + animation: 'rotate 1s both reverse infinite linear', + }, +})); + +// The Favicon API should always return an image, so it's not really useful to keep the loader nor placeholder icon, +// but for slow connections and other random stuff, I'll keep this +export default function LinkFavicon({ + url, + size = 32, + noMargin = false, +}: LinkFaviconProps) { + const [isFailed, setFailed] = useState(false); + const [isLoading, setLoading] = useState(true); + const baseUrlApi = + (process.env.NEXT_PUBLIC_SITE_URL || + (typeof window !== 'undefined' && window?.location?.origin)) + '/api'; + if (!baseUrlApi) { + console.warn('Missing API URL'); + } + + const setFallbackFavicon = () => setFailed(true); + const handleStopLoading = () => setLoading(false); + + return ( + + {!isFailed && baseUrlApi ? ( + { + setFallbackFavicon(); + handleStopLoading(); + }} + onLoad={handleStopLoading} + height={size} + width={size} + alt="icon" + /> + ) : ( + + )} + {isLoading && ( + + + + )} + + ); +} diff --git a/inertia/components/dashboard/link_list/link_item.tsx b/inertia/components/dashboard/link_list/link_item.tsx new file mode 100644 index 0000000..fb6ade9 --- /dev/null +++ b/inertia/components/dashboard/link_list/link_item.tsx @@ -0,0 +1,197 @@ +import PATHS from '#constants/paths'; +import type Link from '#models/link'; +import styled from '@emotion/styled'; +import { useCallback } from 'react'; +import { AiFillStar } from 'react-icons/ai'; +import ExternalLink from '~/components/common/external_link'; +import LinkFavicon from '~/components/dashboard/link/link_favicon'; +import QuickResourceAction from '~/components/dashboard/quick_action/quick_action'; +import QuickLinkFavorite from '~/components/dashboard/quick_action/quick_favorite_link'; +import useCollections from '~/hooks/use_collections'; +import { makeRequest } from '~/lib/request'; +import { theme as globalTheme } from '~/styles/theme'; + +const LinkWrapper = styled.li(({ theme }) => ({ + userSelect: 'none', + cursor: 'pointer', + height: 'fit-content', + width: '100%', + color: theme.colors.primary, + backgroundColor: theme.colors.white, + padding: '0.75em 1em', + border: `1px solid ${theme.colors.lightestGrey}`, + borderRadius: theme.border.radius, + outline: '3px solid transparent', +})); + +const LinkHeader = styled.div(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + + '& > a': { + height: '100%', + maxWidth: 'calc(100% - 125px)', // TODO: fix this, it is ugly af :( + textDecoration: 'none', + display: 'flex', + flex: 1, + flexDirection: 'column', + transition: theme.transition.delay, + + '&, &:hover': { + border: 0, + }, + }, +})); + +const LinkName = styled.div({ + width: '100%', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + overflow: 'hidden', +}); + +const LinkControls = styled.div({ + display: 'none', + alignItems: 'center', + justifyContent: 'center', + gap: '10px', + + '& svg': { + height: '20px', + width: '20px', + }, + + '&:hover *': { + transform: 'scale(1.3)', + }, +}); + +const LinkDescription = styled.div(({ theme }) => ({ + marginTop: '0.5em', + color: theme.colors.font, + fontSize: '0.8em', + wordWrap: 'break-word', +})); + +const LinkUrl = styled.span(({ theme }) => ({ + width: '100%', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + overflow: 'hidden', + color: theme.colors.grey, + fontSize: '0.8em', +})); + +const LinkUrlPathname = styled.span({ + opacity: 0, +}); + +export default function LinkItem({ + link, + showUserControls = false, +}: { + link: Link; + showUserControls: boolean; +}) { + const { id, name, url, description, favorite } = link; + const { collections, setCollections } = useCollections(); + + const toggleFavorite = useCallback( + (linkId: Link['id']) => { + let linkIndex = 0; + const collectionIndex = collections.findIndex(({ links }) => { + const lIndex = links.findIndex((l) => l.id === linkId); + if (lIndex !== -1) { + linkIndex = lIndex; + } + return lIndex !== -1; + }); + + const collectionLink = collections[collectionIndex].links[linkIndex]; + const collectionsCopy = [...collections]; + collectionsCopy[collectionIndex].links[linkIndex] = { + ...collectionLink, + favorite: !collectionLink.favorite, + }; + + setCollections(collectionsCopy); + }, + [collections, setCollections] + ); + + const onFavorite = () => { + makeRequest({ + url: `${PATHS.API.LINK}/${link.id}`, + method: 'PUT', + body: { + name, + url, + favorite: !favorite, + collectionId: link.collectionId, + }, + }) + .then(() => toggleFavorite(link.id)) + .catch(console.error); + }; + + return ( + + + + + + {name}{' '} + {showUserControls && favorite && ( + + )} + + + + {showUserControls && ( + + + + + + )} + + {description && {description}} + + ); +} + +function LinkItemURL({ url }: { url: Link['url'] }) { + try { + const { origin, pathname, search } = new URL(url); + let text = ''; + + if (pathname !== '/') { + text += pathname; + } + + if (search !== '') { + if (text === '') { + text += '/'; + } + text += search; + } + + return ( + + {origin} + {text} + + ); + } catch (error) { + console.error('error', error); + return {url}; + } +} diff --git a/inertia/components/dashboard/link_list/link_list.tsx b/inertia/components/dashboard/link_list/link_list.tsx new file mode 100644 index 0000000..6802007 --- /dev/null +++ b/inertia/components/dashboard/link_list/link_list.tsx @@ -0,0 +1,93 @@ +import styled from '@emotion/styled'; +import { Link } from '@inertiajs/react'; +import { RxHamburgerMenu } from 'react-icons/rx'; +import CollectionControls from '~/components/dashboard/collection/collection_controls'; +import CollectionDescription from '~/components/dashboard/collection/collection_description'; +import CollectionHeader from '~/components/dashboard/collection/collection_header'; +import LinkItem from '~/components/dashboard/link_list/link_item'; +import { NoCollection, NoLink } from '~/components/dashboard/link_list/no_item'; +import Footer from '~/components/footer/footer'; +import useActiveCollection from '~/hooks/use_active_collection'; + +const LinksWrapper = styled.div({ + height: '100%', + minWidth: 0, + padding: '0.5em 0.5em 0', + display: 'flex', + flex: 1, + flexDirection: 'column', +}); + +const CollectionHeaderWrapper = styled.h2(({ theme }) => ({ + color: theme.colors.blue, + fontWeight: 500, + display: 'flex', + gap: '0.4em', + alignItems: 'center', + justifyContent: 'space-between', + + '& > svg': { + display: 'flex', + }, +})); + +interface LinksProps { + isMobile: boolean; + openSideMenu: () => void; +} + +export default function Links({ + isMobile, + openSideMenu, +}: Readonly) { + const { activeCollection } = useActiveCollection(); + + if (activeCollection === null) { + return ; + } + + return ( + + + {isMobile && ( + { + event.preventDefault(); + openSideMenu(); + }} + title="Open side nav bar" + > + + + )} + + + + + {activeCollection?.links.length !== 0 ? ( + + {activeCollection?.links.map((link) => ( + + ))} + + ) : ( + + )} +