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) => ( + + ))} + + ) : ( + + )} +