diff --git a/packages/web/codegen.ts b/packages/web/codegen.ts index 8768366..6fe1b15 100644 --- a/packages/web/codegen.ts +++ b/packages/web/codegen.ts @@ -4,6 +4,7 @@ export default { overwrite: true, schema: '../api/src/schema.gql', documents: ['src/**/*.tsx'], + errorsOnly: true, generates: { 'src/@generated/': { preset: 'client', diff --git a/packages/web/package.json b/packages/web/package.json index b3db00c..f25d4c4 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -13,7 +13,7 @@ "build": "tsc --noEmit && rm -rf ./dist/* && vavite build && tsup && rm -rf ./dist/server", "generate": "graphql-codegen --config codegen.ts", "start": "node ./dist/index.js", - "watch": "concurrently \"vavite serve\" \"pnpm generate --watch --errors-only\"" + "watch": "concurrently \"vavite serve\" \"pnpm generate --watch\"" }, "devDependencies": { "@0no-co/graphqlsp": "^1.3.0", diff --git a/packages/web/src/components/button.tsx b/packages/web/src/components/button.tsx index bf524e5..1a3756a 100644 --- a/packages/web/src/components/button.tsx +++ b/packages/web/src/components/button.tsx @@ -19,6 +19,9 @@ export enum ButtonStyle { Disabled = 'bg-dark-300 hover:bg-dark-400 cursor-not-allowed', } +export const BASE_BUTTON_CLASSES = + 'flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium transition rounded truncate max-h-[2.65em]'; + export const Button = forwardRef( ( { @@ -38,11 +41,7 @@ export const Button = forwardRef( if (disabled) style = ButtonStyle.Disabled; const onClickWrap = disabled || loading ? undefined : onClick; const onKeyDownWrap = disabled || loading ? undefined : onKeyDown; - const classes = clsx( - 'flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium transition rounded truncate max-h-[2.65em]', - className, - style, - ); + const classes = clsx(BASE_BUTTON_CLASSES, className, style); return ( ({ const formik = useContext>(FormikContext); const errorMessage = !!(formik && id && formik.touched[id]) && (formik.errors[id] as string); if (errorMessage) style = InputStyle.Error; - const childClasses = clsx( - 'w-full h-full px-3 py-2 rounded outline-none border transition duration-75', - maxHeight && 'max-h-[calc(2.65em-2px)]', - style, - className - ); + const childClasses = clsx(BASE_INPUT_CLASSES, maxHeight && BASE_INPUT_MAX_HEIGHT, className, style); if (formik) { if (!id) { @@ -74,3 +69,6 @@ export function InputContainer({ ); } + +export const BASE_INPUT_CLASSES = 'w-full h-full px-3 py-2 rounded outline-none border transition duration-75'; +export const BASE_INPUT_MAX_HEIGHT = 'max-h-[calc(2.65em-2px)]'; diff --git a/packages/web/src/components/skeleton.tsx b/packages/web/src/components/skeleton.tsx new file mode 100644 index 0000000..854b78e --- /dev/null +++ b/packages/web/src/components/skeleton.tsx @@ -0,0 +1,59 @@ +import clsx from 'clsx'; +import { FC, Fragment, ReactNode, memo } from 'react'; +import { BASE_BUTTON_CLASSES } from './button'; +import { BASE_INPUT_CLASSES, BASE_INPUT_MAX_HEIGHT } from './input/container'; + +interface SkeletonProps { + className?: string; +} + +export const Skeleton = memo(({ className }) => { + const hasHeight = !className?.includes(' h-'); + return
; +}); + +interface SkeletonListProps { + count: number; + children: ReactNode; + className?: string; + as?: FC | FC<{ className?: string }>; +} + +export const SkeletonList = memo(({ count, children, className, as }) => { + const Component = as || 'div'; + return ( + + {Array.from({ length: count }).map((_, i) => ( + {children} + ))} + + ); +}); + +interface InputSkeletonProps { + maxHeight?: boolean; + className?: string; +} + +export const InputSkeleton = memo(({ maxHeight = true, className }) => { + const classes = clsx(BASE_INPUT_CLASSES, maxHeight && BASE_INPUT_MAX_HEIGHT, className, 'border-transparent'); + return ; +}); + +export const ButtonSkeleton = memo(({ className }) => { + return ; +}); + +export const SkeletonWrap = memo<{ + show: boolean; + children: ReactNode; +}>(({ show, children }) => { + if (!show) return children; + + return ( + + + {children} + + ); +}); diff --git a/packages/web/src/containers/config-generator/config-generator.tsx b/packages/web/src/containers/config-generator/config-generator.tsx index cc6bba8..e383838 100644 --- a/packages/web/src/containers/config-generator/config-generator.tsx +++ b/packages/web/src/containers/config-generator/config-generator.tsx @@ -4,7 +4,7 @@ import { FiDownload } from 'react-icons/fi'; import { RegularUserFragment } from '../../@generated/graphql'; import { Container } from '../../components/container'; import { Section } from '../../components/section'; -import { Spinner } from '../../components/spinner'; +import { Skeleton, SkeletonList, SkeletonWrap } from '../../components/skeleton'; import { Toggle } from '../../components/toggle'; import { downloadFile } from '../../helpers/download.helper'; import { generateConfig } from '../../helpers/generate-config.helper'; @@ -12,7 +12,7 @@ import { useConfig } from '../../hooks/useConfig'; import { CustomisationOption } from './customisation-option'; export interface ConfigGeneratorProps { - user: RegularUserFragment & { token: string }; + user?: RegularUserFragment & { token: string }; } export const ConfigGenerator: FC = ({ user }) => { @@ -23,7 +23,7 @@ export const ConfigGenerator: FC = ({ user }) => { const downloadable = !!selectedHosts[0]; const download = () => { - if (!downloadable) return; + if (!downloadable || !user) return; const { name, content } = generateConfig({ direct: !embedded, hosts: selectedHosts, @@ -39,19 +39,16 @@ export const ConfigGenerator: FC = ({ user }) => {
- {!config.data && ( -
- -
- )} - {config.data && ( - + +
Config Generator

Pick the hosts you want with the options you think will suit you best. These options are saved in the config file and are not persisted between sessions. Changing them will not affect existing config files.

-
+ +
+ = ({ user }) => { ]} /> + + = ({ user }) => { ]} /> -
-
-
- {config.data.hosts.map((host) => { + +
+
+
+ {config.data && + config.data.hosts.map((host) => { const isSelected = selectedHosts.includes(host.normalised); const classes = clsx( 'rounded px-2 py-1 truncate transition border border-transparent', @@ -116,14 +117,18 @@ export const ConfigGenerator: FC = ({ user }) => { } }} > - {host.normalised.replace('{{username}}', user.username)} + {user ? host.normalised.replace('{{username}}', user.username) : host.normalised} ); })} -
+ {!config.data && ( + + + + )}
- - )} +
+
diff --git a/packages/web/src/containers/file-list/cards/file-card.tsx b/packages/web/src/containers/file-list/cards/file-card.tsx index 9665701..f9a63e3 100644 --- a/packages/web/src/containers/file-list/cards/file-card.tsx +++ b/packages/web/src/containers/file-list/cards/file-card.tsx @@ -3,6 +3,7 @@ import { FiFileMinus, FiTrash } from 'react-icons/fi'; import { graphql } from '../../../@generated'; import { FileCardFragment } from '../../../@generated/graphql'; import { Link } from '../../../components/link'; +import { Skeleton } from '../../../components/skeleton'; import { useConfig } from '../../../hooks/useConfig'; import { MissingPreview } from '../missing-preview'; @@ -78,3 +79,5 @@ export const FileCard = memo<{ file: FileCardFragment }>(({ file }) => { ); }); + +export const FileCardSkeleton = () => ; diff --git a/packages/web/src/containers/file-list/file-list.tsx b/packages/web/src/containers/file-list/file-list.tsx index bbb0608..447a82c 100644 --- a/packages/web/src/containers/file-list/file-list.tsx +++ b/packages/web/src/containers/file-list/file-list.tsx @@ -5,14 +5,12 @@ import { graphql } from '../../@generated'; import { Breadcrumbs } from '../../components/breadcrumbs'; import { Card } from '../../components/card'; import { Error } from '../../components/error'; -import { PageLoader } from '../../components/page-loader'; +import { SkeletonList } from '../../components/skeleton'; import { Toggle } from '../../components/toggle'; import { useQueryState } from '../../hooks/useQueryState'; -import { FileCard } from './cards/file-card'; +import { FileCard, FileCardSkeleton } from './cards/file-card'; import { PasteCard } from './cards/paste-card'; -const PER_PAGE = 24; - const GetFilesQuery = graphql(` query GetFiles($after: String) { user { @@ -89,7 +87,11 @@ export const FileList: FC = () => {
- {!source.data && } + {!source.data && ( + + + + )} {filter === 'files' && (
{files.data?.user.files.edges.map(({ node }) => )} diff --git a/packages/web/src/hooks/useUser.tsx b/packages/web/src/hooks/useUser.tsx index 4953d22..5a47d0a 100644 --- a/packages/web/src/hooks/useUser.tsx +++ b/packages/web/src/hooks/useUser.tsx @@ -69,23 +69,22 @@ export const useLogoutUser = () => { }; export const useUserRedirect = ( - data: RegularUserFragment | null | undefined, - loading: boolean, + query: { data: { user: RegularUserFragment } | null | undefined; loading: boolean; called: boolean }, redirect: boolean | undefined, ) => { useEffect(() => { - if (!data && !loading && redirect) { + if (!query.data && !query.loading && query.called && redirect) { navigate(`/login?to=${window.location.href}`); } - }, [redirect, data, loading]); + }, [redirect, query.data, query.loading, query.called]); }; export const useUser = >(redirect?: boolean, query?: T) => { const { login, otpRequired } = useLoginUser(); const { logout } = useLogoutUser(); - const { data, loading, error } = useQuery((query || UserQuery) as T); + const { data, loading, called, error } = useQuery((query || UserQuery) as T); - useUserRedirect(data?.user, loading, redirect); + useUserRedirect({ data, loading, called }, redirect); return { data: data?.user as RegularUserFragment | null | undefined, diff --git a/packages/web/src/pages/dashboard/preferences/+Page.tsx b/packages/web/src/pages/dashboard/preferences/+Page.tsx index b166d86..a30ac65 100644 --- a/packages/web/src/pages/dashboard/preferences/+Page.tsx +++ b/packages/web/src/pages/dashboard/preferences/+Page.tsx @@ -1,5 +1,5 @@ import { useMutation, useQuery } from '@apollo/client'; -import { FC } from 'react'; +import { FC, Fragment } from 'react'; import { graphql } from '../../../@generated'; import { GetUserDocument } from '../../../@generated/graphql'; import { Breadcrumbs } from '../../../components/breadcrumbs'; @@ -7,12 +7,11 @@ import { Button } from '../../../components/button'; import { Container } from '../../../components/container'; import { Input } from '../../../components/input/input'; import { OtpInput } from '../../../components/input/otp'; -import { PageLoader } from '../../../components/page-loader'; +import { ButtonSkeleton, InputSkeleton, Skeleton } from '../../../components/skeleton'; import { Title } from '../../../components/title'; import { ConfigGenerator } from '../../../containers/config-generator/config-generator'; import { navigate } from '../../../helpers/routing'; import { useAsync } from '../../../hooks/useAsync'; -import { useConfig } from '../../../hooks/useConfig'; import { useLogoutUser, useUserRedirect } from '../../../hooks/useUser'; const RefreshToken = graphql(` @@ -40,9 +39,8 @@ const UserQueryWithToken = graphql(` `); export const Page: FC = () => { - const { logout } = useLogoutUser(); const user = useQuery(UserQueryWithToken); - const config = useConfig(); + const { logout } = useLogoutUser(); const [refreshMutation] = useMutation(RefreshToken); const [refresh, refreshing] = useAsync(async () => { // eslint-disable-next-line no-alert @@ -52,16 +50,12 @@ export const Page: FC = () => { await logout(); }); - useUserRedirect(user.data?.user, user.loading, true); + useUserRedirect(user, true); const [disableOTP, disableOTPMut] = useMutation(DisableOtp, { refetchQueries: [{ query: GetUserDocument }], }); - if (!user.data || !config.data) { - return ; - } - return ( Preferences @@ -70,38 +64,66 @@ export const Page: FC = () => {
-
Upload Token
-

- This token is used when uploading files.{' '} - {' '} - to reset your token and invalidate all existing ShareX configurations. -

+ {user.data && ( + +
Upload Token
+

+ This token is used when uploading files.{' '} + {' '} + to reset your token and invalidate all existing ShareX configurations. +

+
+ )} + {!user.data && ( + + + + + )}
- { - event.target.select(); - }} - /> + {user.data && ( + { + event.target.select(); + }} + /> + )} + {!user.data && }
- +
-
2-factor Authentication
-

- 2-factor authentication is currently {user.data.user.otpEnabled ? 'enabled' : 'disabled'}.{' '} - {user.data.user.otpEnabled ? `Enter an authenticator code to disable it.` : 'Click to setup.'} -

+ {user.data && ( + +
2-factor Authentication
+

+ 2-factor authentication is currently {user.data.user.otpEnabled ? 'enabled' : 'disabled'}.{' '} + {user.data.user.otpEnabled ? `Enter an authenticator code to disable it.` : 'Click to setup.'} +

+
+ )} + {!user.data && ( + + + + + )}
- {user.data.user.otpEnabled && ( + {user.data && user.data.user.otpEnabled && ( { @@ -111,11 +133,12 @@ export const Page: FC = () => { }} /> )} - {!user.data.user.otpEnabled && ( + {user.data && !user.data.user.otpEnabled && ( )} + {!user.data && }
diff --git a/packages/web/src/pages/file/@fileId/+Page.tsx b/packages/web/src/pages/file/@fileId/+Page.tsx index ddb680d..8eec40f 100644 --- a/packages/web/src/pages/file/@fileId/+Page.tsx +++ b/packages/web/src/pages/file/@fileId/+Page.tsx @@ -2,13 +2,13 @@ import { useMutation, useQuery } from '@apollo/client'; import clsx from 'clsx'; import copyToClipboard from 'copy-to-clipboard'; import type { FC, ReactNode } from 'react'; -import { useState } from 'react'; +import { Fragment, useState } from 'react'; import { FiDownload, FiShare, FiTrash } from 'react-icons/fi'; import { graphql } from '../../../@generated'; import { Container } from '../../../components/container'; import { Embed } from '../../../components/embed/embed'; import { Error } from '../../../components/error'; -import { PageLoader } from '../../../components/page-loader'; +import { Skeleton, SkeletonList } from '../../../components/skeleton'; import { Spinner } from '../../../components/spinner'; import { Title } from '../../../components/title'; import { useToasts } from '../../../components/toast'; @@ -114,46 +114,59 @@ export const Page: FC = ({ routeParams }) => { return ; } - if (!file.data) { - return ; - } - - const canDelete = file.data.file.isOwner || deleteKey; + const canDelete = file.data?.file.isOwner || deleteKey; return ( - {file.data.file.displayName} + {file.data && {file.data.file.displayName}}
-

{file.data.file.displayName}

- {file.data.file.sizeFormatted} + {file.data && ( + +

{file.data.file.displayName}

+ {file.data.file.sizeFormatted} +
+ )} + {!file.data && }
- + {file.data && ( + + )} + {!file.data && }
- - Copy link - - - Download - - {canDelete && ( - - - {deletingFile ? : confirm ? 'Are you sure?' : 'Delete'} - + {file.data && ( + + + Copy link + + + Download + + {canDelete && ( + + + {deletingFile ? : confirm ? 'Are you sure?' : 'Delete'} + + )} + + )} + {!file.data && ( + + + )}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f06545a..6b7b127 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1434,7 +1434,7 @@ packages: '@fastify/reply-from': 9.7.0 fast-querystring: 1.1.2 fastify-plugin: 4.5.1 - ws: 8.16.0(utf-8-validate@6.0.3) + ws: 8.16.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -1836,7 +1836,7 @@ packages: graphql-ws: 5.14.3(graphql@16.8.1) isomorphic-ws: 5.0.0(ws@8.16.0) tslib: 2.6.2 - ws: 8.16.0(utf-8-validate@6.0.3) + ws: 8.16.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -1871,7 +1871,7 @@ packages: graphql: 16.8.1 isomorphic-ws: 5.0.0(ws@8.16.0) tslib: 2.6.2 - ws: 8.16.0(utf-8-validate@6.0.3) + ws: 8.16.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -2123,7 +2123,7 @@ packages: isomorphic-ws: 5.0.0(ws@8.16.0) tslib: 2.6.2 value-or-promise: 1.0.12 - ws: 8.16.0(utf-8-validate@6.0.3) + ws: 8.16.0 transitivePeerDependencies: - '@types/node' - bufferutil @@ -7333,7 +7333,7 @@ packages: peerDependencies: ws: '*' dependencies: - ws: 8.16.0(utf-8-validate@6.0.3) + ws: 8.16.0 dev: true /istextorbinary@9.5.0: @@ -8476,6 +8476,7 @@ packages: /node-gyp-build@4.8.0: resolution: {integrity: sha512-u6fs2AEUljNho3EYTJNBfImO5QTo/J/1Etd+NVdCj7qWKUSN/bSLkZwhDv7I+w/MSC6qJ4cknepkAYykDdK8og==} hasBin: true + dev: false /node-html-parser@6.1.12: resolution: {integrity: sha512-/bT/Ncmv+fbMGX96XG9g05vFt43m/+SYKIs9oAemQVYyVcZmDAI2Xq/SbNcpOA35eF0Zk2av3Ksf+Xk8Vt8abA==} @@ -10952,6 +10953,7 @@ packages: requiresBuild: true dependencies: node-gyp-build: 4.8.0 + dev: false /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -11353,6 +11355,19 @@ packages: utf-8-validate: 6.0.3 dev: false + /ws@8.16.0: + resolution: {integrity: sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: true + /ws@8.16.0(utf-8-validate@6.0.3): resolution: {integrity: sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==} engines: {node: '>=10.0.0'} @@ -11366,6 +11381,7 @@ packages: optional: true dependencies: utf-8-validate: 6.0.3 + dev: false /xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}