1
Fork 0

Continue with typescript migration

This commit is contained in:
viktorstrate 2021-04-12 16:06:06 +02:00
parent 264ee54e7f
commit c5d2f3dc8b
No known key found for this signature in database
GPG Key ID: 3F855605109C1E8A
23 changed files with 882 additions and 435 deletions

View File

@ -1,10 +1,18 @@
module.exports = { module.exports = {
root: true,
parser: '@typescript-eslint/parser',
env: { env: {
browser: true, browser: true,
es6: true, es6: true,
}, },
ignorePatterns: ['**/*.ts', '**/*.tsx'], ignorePatterns: ['node_modules', 'dist'],
extends: ['eslint:recommended', 'plugin:react/recommended'], extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
'prettier',
],
globals: { globals: {
Atomics: 'readonly', Atomics: 'readonly',
SharedArrayBuffer: 'readonly', SharedArrayBuffer: 'readonly',
@ -19,17 +27,20 @@ module.exports = {
ecmaVersion: 2018, ecmaVersion: 2018,
sourceType: 'module', sourceType: 'module',
}, },
plugins: ['react', 'react-hooks'], plugins: ['react', 'react-hooks', '@typescript-eslint'],
rules: { rules: {
'no-unused-vars': 'warn', 'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': 'warn',
'react/display-name': 'off', 'react/display-name': 'off',
'@typescript-eslint/no-var-requires': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
}, },
settings: { settings: {
react: { react: {
version: 'detect', version: 'detect',
}, },
}, },
parser: 'babel-eslint', // parser: 'babel-eslint',
overrides: [ overrides: [
Object.assign(require('eslint-plugin-jest').configs.recommended, { Object.assign(require('eslint-plugin-jest').configs.recommended, {
files: ['**/*.test.js'], files: ['**/*.test.js'],
@ -43,5 +54,11 @@ module.exports = {
} }
), ),
}), }),
{
files: ['**/*.js'],
rules: {
'@typescript-eslint/explicit-module-boundary-types': 'off',
},
},
], ],
} }

View File

@ -2,7 +2,7 @@ module.exports = function (api) {
const isTest = api.env('test') const isTest = api.env('test')
const isProduction = api.env('NODE_ENV') == 'production' const isProduction = api.env('NODE_ENV') == 'production'
let presets = ['@babel/preset-react'] let presets = ['@babel/preset-typescript', '@babel/preset-react']
let plugins = [] let plugins = []
if (isTest) { if (isTest) {

View File

@ -23,7 +23,7 @@ const esbuildOptions = {
entryPoints: ['src/index.tsx'], entryPoints: ['src/index.tsx'],
plugins: [ plugins: [
babel({ babel({
filter: /photoview\/ui\/src\/.*\.js$/, filter: /photoview\/ui\/src\/.*\.(js|tsx?)$/,
}), }),
], ],
publicPath: process.env.UI_PUBLIC_URL || '/', publicPath: process.env.UI_PUBLIC_URL || '/',
@ -66,25 +66,25 @@ if (watchMode) {
open: false, open: false,
}) })
bs.watch('src/**/*.js').on('change', async args => { bs.watch('src/**/*.@(js|tsx|ts)').on('change', async args => {
console.log('reloading', args) console.log('reloading', args)
builderPromise = (await builderPromise).rebuild() builderPromise = (await builderPromise).rebuild()
bs.reload(args) bs.reload(args)
}) })
} else { } else {
const esbuildPromise = esbuild const build = async () => {
.build(esbuildOptions) await esbuild.build(esbuildOptions)
.then(() => console.log('esbuild done'))
const workboxPromise = workboxBuild console.log('esbuild done')
.generateSW({
await workboxBuild.generateSW({
globDirectory: 'dist/', globDirectory: 'dist/',
globPatterns: ['**/*.{png,svg,woff2,ttf,eot,woff,js,ico,html,json,css}'], globPatterns: ['**/*.{png,svg,woff2,ttf,eot,woff,js,ico,html,json,css}'],
swDest: 'dist/service-worker.js', swDest: 'dist/service-worker.js',
}) })
.then(() => console.log('workbox done'))
Promise.all([esbuildPromise, workboxPromise]).then(() => console.log('workbox done')
console.log('build complete') console.log('build complete')
) }
build()
} }

864
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -15,6 +15,7 @@
"@babel/plugin-transform-runtime": "^7.13.15", "@babel/plugin-transform-runtime": "^7.13.15",
"@babel/preset-env": "^7.13.15", "@babel/preset-env": "^7.13.15",
"@babel/preset-react": "^7.13.13", "@babel/preset-react": "^7.13.13",
"@babel/preset-typescript": "^7.13.0",
"babel-eslint": "^10.1.0", "babel-eslint": "^10.1.0",
"babel-jest": "^26.6.3", "babel-jest": "^26.6.3",
"babel-plugin-graphql-tag": "^3.2.0", "babel-plugin-graphql-tag": "^3.2.0",
@ -56,7 +57,7 @@
"start": "node --experimental-modules build.mjs watch", "start": "node --experimental-modules build.mjs watch",
"build": "NODE_ENV=production node --experimental-modules build.mjs", "build": "NODE_ENV=production node --experimental-modules build.mjs",
"test": "npm run lint && npm run jest", "test": "npm run lint && npm run jest",
"lint": "eslint ./src --max-warnings 0 --cache", "lint": "eslint ./src --max-warnings 0 --cache --config .eslintrc.js",
"jest": "jest", "jest": "jest",
"genSchemaTypes": "npx apollo client:codegen --target=typescript", "genSchemaTypes": "npx apollo client:codegen --target=typescript",
"prepare": "(cd .. && npx husky install)" "prepare": "(cd .. && npx husky install)"
@ -69,6 +70,10 @@
"@types/react-helmet": "^6.1.1", "@types/react-helmet": "^6.1.1",
"@types/react-router-dom": "^5.1.7", "@types/react-router-dom": "^5.1.7",
"@types/styled-components": "^5.1.9", "@types/styled-components": "^5.1.9",
"@types/url-join": "^4.0.0",
"@typescript-eslint/eslint-plugin": "^4.21.0",
"@typescript-eslint/parser": "^4.21.0",
"eslint-config-prettier": "^8.1.0",
"husky": "^6.0.0", "husky": "^6.0.0",
"jest": "^26.6.3", "jest": "^26.6.3",
"lint-staged": "^10.5.4", "lint-staged": "^10.5.4",

4
ui/src/@types/index.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
declare module '*.svg' {
const src: string
export default src
}

View File

@ -80,7 +80,7 @@ const SideButtonLink = styled(NavLink)`
` `
type SideButtonProps = { type SideButtonProps = {
children: any children: ReactChild | ReactChild[]
to: string to: string
exact: boolean exact: boolean
} }

View File

@ -4,6 +4,8 @@ import {
split, split,
ApolloLink, ApolloLink,
HttpLink, HttpLink,
ServerError,
FieldMergeFunction,
} from '@apollo/client' } from '@apollo/client'
import { getMainDefinition } from '@apollo/client/utilities' import { getMainDefinition } from '@apollo/client/utilities'
import { onError } from '@apollo/client/link/error' import { onError } from '@apollo/client/link/error'
@ -12,6 +14,7 @@ import { WebSocketLink } from '@apollo/client/link/ws'
import urlJoin from 'url-join' import urlJoin from 'url-join'
import { clearTokenCookie } from './helpers/authentication' import { clearTokenCookie } from './helpers/authentication'
import { MessageState } from './components/messages/Messages' import { MessageState } from './components/messages/Messages'
import { Message } from './components/messages/SubscriptionsHook'
export const GRAPHQL_ENDPOINT = process.env.PHOTOVIEW_API_ENDPOINT export const GRAPHQL_ENDPOINT = process.env.PHOTOVIEW_API_ENDPOINT
? urlJoin(process.env.PHOTOVIEW_API_ENDPOINT, '/graphql') ? urlJoin(process.env.PHOTOVIEW_API_ENDPOINT, '/graphql')
@ -26,12 +29,12 @@ console.log('GRAPHQL ENDPOINT', GRAPHQL_ENDPOINT)
const apiProtocol = new URL(GRAPHQL_ENDPOINT).protocol const apiProtocol = new URL(GRAPHQL_ENDPOINT).protocol
let websocketUri = new URL(GRAPHQL_ENDPOINT) const websocketUri = new URL(GRAPHQL_ENDPOINT)
websocketUri.protocol = apiProtocol === 'https:' ? 'wss:' : 'ws:' websocketUri.protocol = apiProtocol === 'https:' ? 'wss:' : 'ws:'
const wsLink = new WebSocketLink({ const wsLink = new WebSocketLink({
uri: websocketUri, uri: websocketUri.toString(),
credentials: 'include', // credentials: 'include',
}) })
const link = split( const link = split(
@ -48,7 +51,7 @@ const link = split(
) )
const linkError = onError(({ graphQLErrors, networkError }) => { const linkError = onError(({ graphQLErrors, networkError }) => {
let errorMessages = [] const errorMessages = []
if (graphQLErrors) { if (graphQLErrors) {
graphQLErrors.map(({ message, locations, path }) => graphQLErrors.map(({ message, locations, path }) =>
@ -82,7 +85,7 @@ const linkError = onError(({ graphQLErrors, networkError }) => {
console.log(`[Network error]: ${JSON.stringify(networkError)}`) console.log(`[Network error]: ${JSON.stringify(networkError)}`)
clearTokenCookie() clearTokenCookie()
const errors = networkError.result.errors const errors = (networkError as ServerError).result.errors
if (errors.length == 1) { if (errors.length == 1) {
errorMessages.push({ errorMessages.push({
@ -92,7 +95,9 @@ const linkError = onError(({ graphQLErrors, networkError }) => {
} else if (errors.length > 1) { } else if (errors.length > 1) {
errorMessages.push({ errorMessages.push({
header: 'Multiple server errors', header: 'Multiple server errors',
content: `Received ${graphQLErrors.length} errors from the server. You are being logged out in an attempt to recover.`, content: `Received ${
graphQLErrors?.length || 0
} errors from the server. You are being logged out in an attempt to recover.`,
}) })
} }
} }
@ -106,26 +111,32 @@ const linkError = onError(({ graphQLErrors, networkError }) => {
...msg, ...msg,
}, },
})) }))
MessageState.set(messages => [...messages, ...newMessages]) MessageState.set((messages: Message[]) => [...messages, ...newMessages])
} }
}) })
type PaginateCacheType = {
keyArgs: string[]
merge: FieldMergeFunction
}
// Modified version of Apollo's offsetLimitPagination() // Modified version of Apollo's offsetLimitPagination()
const paginateCache = keyArgs => ({ const paginateCache = (keyArgs: string[]) =>
keyArgs, ({
merge(existing, incoming, { args, fieldName }) { keyArgs,
const merged = existing ? existing.slice(0) : [] merge(existing, incoming, { args, fieldName }) {
if (args?.paginate) { const merged = existing ? existing.slice(0) : []
const { offset = 0 } = args.paginate if (args?.paginate) {
for (let i = 0; i < incoming.length; ++i) { const { offset = 0 } = args.paginate
merged[offset + i] = incoming[i] for (let i = 0; i < incoming.length; ++i) {
merged[offset + i] = incoming[i]
}
} else {
throw new Error(`Paginate argument is missing for query: ${fieldName}`)
} }
} else { return merged
throw new Error(`Paginate argument is missing for query: ${fieldName}`) },
} } as PaginateCacheType)
return merged
},
})
const memoryCache = new InMemoryCache({ const memoryCache = new InMemoryCache({
typePolicies: { typePolicies: {

View File

@ -1,6 +1,6 @@
import React, { useEffect, useContext } from 'react' import React, { useEffect, useContext } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { Breadcrumb } from 'semantic-ui-react' import { Breadcrumb, IconProps } from 'semantic-ui-react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import styled from 'styled-components' import styled from 'styled-components'
import { Icon } from 'semantic-ui-react' import { Icon } from 'semantic-ui-react'
@ -8,6 +8,7 @@ import { SidebarContext } from './sidebar/Sidebar'
import AlbumSidebar from './sidebar/AlbumSidebar' import AlbumSidebar from './sidebar/AlbumSidebar'
import { useLazyQuery, gql } from '@apollo/client' import { useLazyQuery, gql } from '@apollo/client'
import { authToken } from '../helpers/authentication' import { authToken } from '../helpers/authentication'
import { albumPathQuery } from './__generated__/albumPathQuery'
const Header = styled.h1` const Header = styled.h1`
margin: 24px 0 8px 0 !important; margin: 24px 0 8px 0 !important;
@ -32,7 +33,7 @@ const StyledIcon = styled(Icon)`
} }
` `
const SettingsIcon = props => { const SettingsIcon = (props: IconProps) => {
return <StyledIcon name="settings" size="small" {...props} /> return <StyledIcon name="settings" size="small" {...props} />
} }
@ -48,8 +49,18 @@ const ALBUM_PATH_QUERY = gql`
} }
` `
const AlbumTitle = ({ album, disableLink = false }) => { type AlbumTitleProps = {
const [fetchPath, { data: pathData }] = useLazyQuery(ALBUM_PATH_QUERY) album: {
id: string
title: string
}
disableLink: boolean
}
const AlbumTitle = ({ album, disableLink = false }: AlbumTitleProps) => {
const [fetchPath, { data: pathData }] = useLazyQuery<albumPathQuery>(
ALBUM_PATH_QUERY
)
const { updateSidebar } = useContext(SidebarContext) const { updateSidebar } = useContext(SidebarContext)
useEffect(() => { useEffect(() => {
@ -68,10 +79,7 @@ const AlbumTitle = ({ album, disableLink = false }) => {
let title = <span>{album.title}</span> let title = <span>{album.title}</span>
let path = [] const path = pathData?.album.path || []
if (pathData) {
path = pathData.album.path
}
const breadcrumbSections = path const breadcrumbSections = path
.slice() .slice()

View File

@ -1,8 +1,12 @@
import React from 'react' import React from 'react'
import PropTypes from 'prop-types'
import { Loader } from 'semantic-ui-react' import { Loader } from 'semantic-ui-react'
const PaginateLoader = ({ active, text }) => ( type PaginateLoaderProps = {
active: boolean
text: string
}
const PaginateLoader = ({ active, text }: PaginateLoaderProps) => (
<Loader <Loader
style={{ margin: '42px 0 24px 0', opacity: active ? '1' : '0' }} style={{ margin: '42px 0 24px 0', opacity: active ? '1' : '0' }}
inline="centered" inline="centered"
@ -12,9 +16,4 @@ const PaginateLoader = ({ active, text }) => (
</Loader> </Loader>
) )
PaginateLoader.propTypes = {
active: PropTypes.bool,
text: PropTypes.string,
}
export default PaginateLoader export default PaginateLoader

View File

@ -1,11 +1,15 @@
import React, { useState, useRef, useEffect } from 'react' import React, { useState, useRef, useEffect } from 'react'
import PropTypes from 'prop-types'
import styled from 'styled-components' import styled from 'styled-components'
import { useLazyQuery, gql } from '@apollo/client' import { useLazyQuery, gql } from '@apollo/client'
import { debounce } from '../../helpers/utils' import { debounce, DebouncedFn } from '../../helpers/utils'
import { ProtectedImage } from '../photoGallery/ProtectedMedia' import { ProtectedImage } from '../photoGallery/ProtectedMedia'
import { NavLink } from 'react-router-dom' import { NavLink } from 'react-router-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import {
searchQuery,
searchQuery_search_albums,
searchQuery_search_media,
} from './__generated__/searchQuery'
const Container = styled.div` const Container = styled.div`
height: 60px; height: 60px;
@ -30,7 +34,7 @@ const SearchField = styled.input`
} }
` `
const Results = styled.div` const Results = styled.div<{ show: boolean }>`
display: ${({ show }) => (show ? 'block' : 'none')}; display: ${({ show }) => (show ? 'block' : 'none')};
position: absolute; position: absolute;
width: 100%; width: 100%;
@ -79,27 +83,29 @@ const SEARCH_QUERY = gql`
const SearchBar = () => { const SearchBar = () => {
const { t } = useTranslation() const { t } = useTranslation()
const [fetchSearches, fetchResult] = useLazyQuery(SEARCH_QUERY) const [fetchSearches, fetchResult] = useLazyQuery<searchQuery>(SEARCH_QUERY)
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [fetched, setFetched] = useState(false) const [fetched, setFetched] = useState(false)
let debouncedFetch = useRef(null) type QueryFn = (query: string) => void
const debouncedFetch = useRef<null | DebouncedFn<QueryFn>>(null)
useEffect(() => { useEffect(() => {
debouncedFetch.current = debounce(query => { debouncedFetch.current = debounce<QueryFn>(query => {
fetchSearches({ variables: { query } }) fetchSearches({ variables: { query } })
setFetched(true) setFetched(true)
}, 250) }, 250)
return () => { return () => {
debouncedFetch.current.cancel() debouncedFetch.current?.cancel()
} }
}, []) }, [])
const fetchEvent = e => { const fetchEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
e.persist() e.persist()
setQuery(e.target.value) setQuery(e.target.value)
if (e.target.value.trim() != '') { if (e.target.value.trim() != '' && debouncedFetch.current) {
debouncedFetch.current(e.target.value.trim()) debouncedFetch.current(e.target.value.trim())
} else { } else {
setFetched(false) setFetched(false)
@ -108,7 +114,12 @@ const SearchBar = () => {
let results = null let results = null
if (query.trim().length > 0 && fetched) { if (query.trim().length > 0 && fetched) {
results = <SearchResults result={fetchResult} /> results = (
<SearchResults
searchData={fetchResult.data}
loading={fetchResult.loading}
/>
)
} }
return ( return (
@ -128,17 +139,21 @@ const ResultTitle = styled.h1`
margin: 12px 0 0.25rem; margin: 12px 0 0.25rem;
` `
const SearchResults = ({ result }) => { type SearchResultsProps = {
const { t } = useTranslation() searchData?: searchQuery
const { data, loading } = result loading: boolean
const query = data && data.search.query }
const media = (data && data.search.media) || [] const SearchResults = ({ searchData, loading }: SearchResultsProps) => {
const albums = (data && data.search.albums) || [] const { t } = useTranslation()
const query = searchData?.search.query || ''
const media = searchData?.search.media || []
const albums = searchData?.search.albums || []
let message = null let message = null
if (loading) message = t('header.search.loading', 'Loading results...') if (loading) message = t('header.search.loading', 'Loading results...')
else if (data && media.length == 0 && albums.length == 0) else if (searchData && media.length == 0 && albums.length == 0)
message = t('header.search.no_results', 'No results found') message = t('header.search.no_results', 'No results found')
const albumElements = albums.map(album => ( const albumElements = albums.map(album => (
@ -155,7 +170,7 @@ const SearchResults = ({ result }) => {
// Prevent input blur event // Prevent input blur event
e.preventDefault() e.preventDefault()
}} }}
show={data} show={!!searchData}
> >
{message} {message}
{albumElements.length > 0 && ( {albumElements.length > 0 && (
@ -174,10 +189,6 @@ const SearchResults = ({ result }) => {
) )
} }
SearchResults.propTypes = {
result: PropTypes.object,
}
const RowLink = styled(NavLink)` const RowLink = styled(NavLink)`
display: flex; display: flex;
align-items: center; align-items: center;
@ -205,31 +216,31 @@ const RowTitle = styled.span`
padding-left: 8px; padding-left: 8px;
` `
const PhotoRow = ({ query, media }) => ( type PhotoRowArgs = {
query: string
media: searchQuery_search_media
}
const PhotoRow = ({ query, media }: PhotoRowArgs) => (
<RowLink to={`/album/${media.album.id}`}> <RowLink to={`/album/${media.album.id}`}>
<PhotoSearchThumbnail src={media?.thumbnail?.url} /> <PhotoSearchThumbnail src={media?.thumbnail?.url} />
<RowTitle>{searchHighlighted(query, media.title)}</RowTitle> <RowTitle>{searchHighlighted(query, media.title)}</RowTitle>
</RowLink> </RowLink>
) )
PhotoRow.propTypes = { type AlbumRowArgs = {
query: PropTypes.string.isRequired, query: string
media: PropTypes.object.isRequired, album: searchQuery_search_albums
} }
const AlbumRow = ({ query, album }) => ( const AlbumRow = ({ query, album }: AlbumRowArgs) => (
<RowLink to={`/album/${album.id}`}> <RowLink to={`/album/${album.id}`}>
<AlbumSearchThumbnail src={album?.thumbnail?.thumbnail?.url} /> <AlbumSearchThumbnail src={album?.thumbnail?.thumbnail?.url} />
<RowTitle>{searchHighlighted(query, album.title)}</RowTitle> <RowTitle>{searchHighlighted(query, album.title)}</RowTitle>
</RowLink> </RowLink>
) )
AlbumRow.propTypes = { const searchHighlighted = (query: string, text: string) => {
query: PropTypes.string.isRequired,
album: PropTypes.object.isRequired,
}
const searchHighlighted = (query, text) => {
const i = text.toLowerCase().indexOf(query.toLowerCase()) const i = text.toLowerCase().indexOf(query.toLowerCase())
if (i == -1) { if (i == -1) {

View File

@ -17,9 +17,11 @@ const Container = styled.div`
} }
` `
export let MessageState = { export const MessageState = {
set: null, set: fn => {
get: null, console.warn('set function is not defined yet, called with', fn)
},
get: [],
add: message => { add: message => {
MessageState.set(messages => { MessageState.set(messages => {
const newMessages = messages.filter(msg => msg.key != message.key) const newMessages = messages.filter(msg => msg.key != message.key)

View File

@ -1,7 +1,9 @@
import { notificationSubscription } from './__generated__/notificationSubscription'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { useEffect } from 'react' import { useEffect } from 'react'
import { useSubscription, gql } from '@apollo/client' import { useSubscription, gql } from '@apollo/client'
import { authToken } from '../../helpers/authentication' import { authToken } from '../../helpers/authentication'
import { NotificationType } from '../../../__generated__/globalTypes'
const notificationSubscription = gql` const notificationSubscription = gql`
subscription notificationSubscription { subscription notificationSubscription {
@ -18,14 +20,37 @@ const notificationSubscription = gql`
} }
` `
let messageTimeoutHandles = new Map() const messageTimeoutHandles = new Map()
const SubscriptionsHook = ({ messages, setMessages }) => { export interface Message {
key: string
type: NotificationType
timeout?: number
props: {
header: string
content: string
negative?: boolean
positive?: boolean
percent?: number
}
}
type SubscriptionHookProps = {
messages: Message[]
setMessages: React.Dispatch<React.SetStateAction<Message[]>>
}
const SubscriptionsHook = ({
messages,
setMessages,
}: SubscriptionHookProps) => {
if (!authToken()) { if (!authToken()) {
return null return null
} }
const { data, error } = useSubscription(notificationSubscription) const { data, error } = useSubscription<notificationSubscription>(
notificationSubscription
)
useEffect(() => { useEffect(() => {
if (error) { if (error) {
@ -33,7 +58,7 @@ const SubscriptionsHook = ({ messages, setMessages }) => {
...state, ...state,
{ {
key: Math.random().toString(26), key: Math.random().toString(26),
type: 'message', type: NotificationType.Message,
props: { props: {
header: 'Network error', header: 'Network error',
content: error.message, content: error.message,
@ -54,16 +79,16 @@ const SubscriptionsHook = ({ messages, setMessages }) => {
return return
} }
const newNotification = { const newNotification: Message = {
key: msg.key, key: msg.key,
type: msg.type.toLowerCase(), type: msg.type,
timeout: msg.timeout, timeout: msg.timeout || undefined,
props: { props: {
header: msg.header, header: msg.header,
content: msg.content, content: msg.content,
negative: msg.negative, negative: msg.negative,
positive: msg.positive, positive: msg.positive,
percent: msg.progress, percent: msg.progress || undefined,
}, },
} }

View File

@ -1,5 +1,5 @@
import React, { useEffect } from 'react' import React, { ReactChild, useEffect } from 'react'
import PropTypes from 'prop-types' import PropTypes, { ReactComponentLike } from 'prop-types'
import { Route, Redirect } from 'react-router-dom' import { Route, Redirect } from 'react-router-dom'
import { useLazyQuery } from '@apollo/client' import { useLazyQuery } from '@apollo/client'
import { authToken } from '../../helpers/authentication' import { authToken } from '../../helpers/authentication'
@ -21,22 +21,31 @@ export const useIsAdmin = (enabled = true) => {
return data?.myUser?.admin return data?.myUser?.admin
} }
export const Authorized = ({ children }) => { export const Authorized = ({ children }: { children: JSX.Element }) => {
const token = authToken() const token = authToken()
return token ? children : null return token ? children : null
} }
const AuthorizedRoute = ({ component: Component, admin = false, ...props }) => { type AuthorizedRouteProps = {
component: ReactComponentLike
admin: boolean
}
const AuthorizedRoute = ({
component: Component,
admin = false,
...props
}: AuthorizedRouteProps) => {
const token = authToken() const token = authToken()
const isAdmin = useIsAdmin(admin) const isAdmin = useIsAdmin(admin)
let unauthorizedRedirect = null let unauthorizedRedirect: null | ReactChild = null
if (!token) { if (!token) {
unauthorizedRedirect = <Redirect to="/login" /> unauthorizedRedirect = <Redirect to="/login" />
} }
let adminRedirect = null let adminRedirect: null | ReactChild = null
if (token && admin) { if (token && admin) {
if (isAdmin === false) { if (isAdmin === false) {
adminRedirect = <Redirect to="/" /> adminRedirect = <Redirect to="/" />

View File

@ -1,4 +1,6 @@
class LazyLoad { class LazyLoad {
observer: null | IntersectionObserver
constructor() { constructor() {
this.observe = this.observe.bind(this) this.observe = this.observe.bind(this)
this.loadImages = this.loadImages.bind(this) this.loadImages = this.loadImages.bind(this)
@ -6,22 +8,22 @@ class LazyLoad {
this.observer = null this.observer = null
} }
observe(images) { observe(images: Element[]) {
if (!this.observer) { if (!this.observer) {
this.observer = new IntersectionObserver(entries => { this.observer = new IntersectionObserver(entries => {
entries.forEach(entry => { entries.forEach(entry => {
if (entry.isIntersecting || entry.intersectionRatio > 0) { if (entry.isIntersecting || entry.intersectionRatio > 0) {
const element = entry.target const element = entry.target
this.setSrcAttribute(element) this.setSrcAttribute(element)
this.observer.unobserve(element) this.observer?.unobserve(element)
} }
}) })
}) })
} }
Array.from(images).forEach(image => this.observer.observe(image)) Array.from(images).forEach(image => this.observer?.observe(image))
} }
loadImages(elements) { loadImages(elements: Element[]) {
const images = Array.from(elements) const images = Array.from(elements)
if (images.length) { if (images.length) {
if ('IntersectionObserver' in window) { if ('IntersectionObserver' in window) {
@ -36,11 +38,18 @@ class LazyLoad {
this.observer && this.observer.disconnect() this.observer && this.observer.disconnect()
} }
setSrcAttribute(element) { setSrcAttribute(element: Element) {
if (element.hasAttribute('data-src')) { if (element.hasAttribute('data-src')) {
const src = element.getAttribute('data-src') const src = element.getAttribute('data-src')
element.removeAttribute('data-src') if (src) {
element.setAttribute('src', src) element.removeAttribute('data-src')
element.setAttribute('src', src)
} else {
console.warn(
'WARN: expected element to have `data-src` property',
element
)
}
} }
} }
} }

View File

@ -1,4 +1,4 @@
export function saveTokenCookie(token) { export function saveTokenCookie(token: string) {
const maxAge = 14 * 24 * 60 * 60 const maxAge = 14 * 24 * 60 * 60
document.cookie = `auth-token=${token} ;max-age=${maxAge} ;path=/ ;sameSite=Lax` document.cookie = `auth-token=${token} ;max-age=${maxAge} ;path=/ ;sameSite=Lax`
@ -13,11 +13,11 @@ export function authToken() {
return match && match[1] return match && match[1]
} }
export function saveSharePassword(shareToken, password) { export function saveSharePassword(shareToken: string, password: string) {
document.cookie = `share-token-pw-${shareToken}=${password} ;path=/ ;sameSite=Lax` document.cookie = `share-token-pw-${shareToken}=${password} ;path=/ ;sameSite=Lax`
} }
export function getSharePassword(shareToken) { export function getSharePassword(shareToken: string) {
const match = document.cookie.match( const match = document.cookie.match(
`share-token-pw-${shareToken}=([\\d\\w]+)` `share-token-pw-${shareToken}=([\\d\\w]+)`
) )

View File

@ -1,28 +0,0 @@
export function debounce(func, wait, triggerRising) {
let timeout = null
const debounced = (...args) => {
if (timeout) {
clearTimeout(timeout)
timeout = null
} else if (triggerRising) {
func(...args)
}
timeout = setTimeout(() => {
timeout = null
func(...args)
}, wait)
}
debounced.cancel = () => {
clearTimeout(timeout)
timeout = null
}
return debounced
}
export function isNil(value) {
return value === undefined || value === null
}

39
ui/src/helpers/utils.ts Normal file
View File

@ -0,0 +1,39 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export interface DebouncedFn<F extends (...args: any[]) => any> {
(...args: Parameters<F>): void
cancel(): void
}
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number,
triggerRising?: boolean
): DebouncedFn<T> {
let timeout: number | undefined = undefined
const debounced = (...args: Parameters<T>) => {
if (timeout) {
clearTimeout(timeout)
timeout = undefined
} else if (triggerRising) {
func(...args)
}
timeout = window.setTimeout(() => {
timeout = undefined
func(...args)
}, wait)
}
debounced.cancel = () => {
clearTimeout(timeout)
timeout = undefined
}
return debounced
}
export function isNil(value: any) {
return value === undefined || value === null
}

View File

@ -1,26 +1,44 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
const useScrollPagination = ({ loading, fetchMore, data, getItems }) => { interface ScrollPaginationArgs<D> {
const observer = useRef(null) loading: boolean
const observerElem = useRef(null) data: D
fetchMore(args: { variables: { offset: number } }): Promise<{ data: D }>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getItems(data: D): any[]
}
type ScrollPaginationResult = {
finished: boolean
containerElem(node: null | Element): void
}
function useScrollPagination<D>({
loading,
fetchMore,
data,
getItems,
}: ScrollPaginationArgs<D>): ScrollPaginationResult {
const observer = useRef<IntersectionObserver | null>(null)
const observerElem = useRef<Element | null>(null)
const [finished, setFinished] = useState(false) const [finished, setFinished] = useState(false)
const reconfigureIntersectionObserver = () => { const reconfigureIntersectionObserver = () => {
var options = { const options = {
root: null, root: null,
rootMargin: '-100% 0px 0px 0px', rootMargin: '-100% 0px 0px 0px',
threshold: 0, threshold: 0,
} }
// delete old observer // delete old observer
if (observer.current) observer.current.disconnect() observer.current?.disconnect()
if (finished) return if (finished) return
// configure new observer // configure new observer
observer.current = new IntersectionObserver(entities => { observer.current = new IntersectionObserver(entities => {
if (entities.find(x => x.isIntersecting == false)) { if (entities.find(x => x.isIntersecting == false)) {
let itemCount = getItems(data).length const itemCount = getItems(data).length
fetchMore({ fetchMore({
variables: { variables: {
offset: itemCount, offset: itemCount,
@ -40,7 +58,7 @@ const useScrollPagination = ({ loading, fetchMore, data, getItems }) => {
} }
} }
const containerElem = useCallback(node => { const containerElem = useCallback((node: null | Element): void => {
observerElem.current = node observerElem.current = node
// cleanup // cleanup
@ -55,7 +73,7 @@ const useScrollPagination = ({ loading, fetchMore, data, getItems }) => {
// only observe when not loading // only observe when not loading
useEffect(() => { useEffect(() => {
if (observer.current != null) { if (observer.current && observerElem.current) {
if (loading) { if (loading) {
observer.current.unobserve(observerElem.current) observer.current.unobserve(observerElem.current)
} else { } else {

View File

@ -6,7 +6,7 @@ function useURLParameters() {
const url = new URL(urlString) const url = new URL(urlString)
const params = new URLSearchParams(url.search) const params = new URLSearchParams(url.search)
const getParam = (key, defaultValue = null) => { const getParam = (key: string, defaultValue = null) => {
return params.has(key) ? params.get(key) : defaultValue return params.has(key) ? params.get(key) : defaultValue
} }
@ -15,12 +15,12 @@ function useURLParameters() {
setUrlString(document.location.href) setUrlString(document.location.href)
} }
const setParam = (key, value) => { const setParam = (key: string, value: string) => {
params.set(key, value) params.set(key, value)
updateParams() updateParams()
} }
const setParams = pairs => { const setParams = (pairs: { key: string; value: string }[]) => {
for (const pair of pairs) { for (const pair of pairs) {
params.set(pair.key, pair.value) params.set(pair.key, pair.value)
} }

View File

@ -1,7 +1,7 @@
import i18n from 'i18next' import i18n from 'i18next'
import { initReactI18next } from 'react-i18next' import { initReactI18next } from 'react-i18next'
export default function setupLocalization() { export default function setupLocalization(): void {
i18n.use(initReactI18next).init({ i18n.use(initReactI18next).init({
resources: { resources: {
en: { en: {

View File

@ -4,9 +4,12 @@
/* Basic Options */ /* Basic Options */
// "incremental": true, /* Enable incremental compilation */ // "incremental": true, /* Enable incremental compilation */
// "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */,
// "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */,
// "lib": [], /* Specify library files to be included in the compilation. */ "lib": [
"es2015",
"dom"
] /* Specify library files to be included in the compilation. */,
"allowJs": true /* Allow javascript files to be compiled. */, "allowJs": true /* Allow javascript files to be compiled. */,
// "checkJs": true, /* Report errors in .js files. */ // "checkJs": true, /* Report errors in .js files. */
"jsx": "preserve" /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */, "jsx": "preserve" /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */,
@ -48,7 +51,9 @@
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */ "typeRoots": [
"./src/@types/"
] /* List of folders to include type definitions from. */,
// "types": [], /* Type declaration files to be included in compilation. */ // "types": [], /* Type declaration files to be included in compilation. */
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
@ -68,5 +73,6 @@
/* Advanced Options */ /* Advanced Options */
"skipLibCheck": true /* Skip type checking of declaration files. */, "skipLibCheck": true /* Skip type checking of declaration files. */,
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
} },
"exclude": ["node_modules/", "dist/"]
} }