精简后的项目:仅保留 api/ui 源码、文档、构建依赖
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
@@ -0,0 +1,59 @@
|
||||
/* global __dirname */
|
||||
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
env: {
|
||||
browser: true,
|
||||
es6: true,
|
||||
},
|
||||
ignorePatterns: ['node_modules', 'dist'],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:@typescript-eslint/eslint-recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:@typescript-eslint/recommended-requiring-type-checking',
|
||||
'prettier',
|
||||
],
|
||||
globals: {
|
||||
Atomics: 'readonly',
|
||||
SharedArrayBuffer: 'readonly',
|
||||
process: 'readonly',
|
||||
module: 'readonly',
|
||||
require: 'readonly',
|
||||
},
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: ['./tsconfig.json'],
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
ecmaVersion: 2018,
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['react', 'react-hooks', '@typescript-eslint'],
|
||||
rules: {
|
||||
'no-unused-vars': 'off',
|
||||
'react/display-name': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'warn',
|
||||
'@typescript-eslint/no-var-requires': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx'],
|
||||
rules: {
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'off',
|
||||
'@typescript-eslint/no-misused-promises': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// apollo.config.js
|
||||
const { readdirSync, readFileSync, writeFileSync, mkdirSync } = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const schemasFolder = path.join(__dirname, '..', 'api', 'graphql', 'resolvers');
|
||||
|
||||
let completeSchema;
|
||||
try {
|
||||
completeSchema = readdirSync(schemasFolder)
|
||||
.filter(x => x.endsWith('.graphql'))
|
||||
.map(x => {
|
||||
const filePath = path.join(schemasFolder, x);
|
||||
return readFileSync(filePath, 'utf-8');
|
||||
})
|
||||
.join('\n\n');
|
||||
} catch (error) {
|
||||
console.error('Failed to generate schema:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const outputPath = path.join(__dirname, '.cache', 'schema.graphql');
|
||||
|
||||
try {
|
||||
mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
writeFileSync(outputPath, completeSchema, { mode: 0o644 });
|
||||
} catch (error) {
|
||||
console.error('Failed to write schema file:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
client: {
|
||||
service: {
|
||||
name: 'photoview',
|
||||
localSchemaFile: outputPath,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
REACT_APP_API_ENDPOINT=http://localhost:4001/
|
||||
@@ -0,0 +1,29 @@
|
||||
module.exports = {
|
||||
defaultValue: function (locale, namespace, key, value) {
|
||||
if (locale != 'en') {
|
||||
return '';
|
||||
}
|
||||
return value || key;
|
||||
},
|
||||
sort: true,
|
||||
locales: [
|
||||
'da',
|
||||
'de',
|
||||
'en',
|
||||
'es',
|
||||
'fr',
|
||||
'it',
|
||||
'ja',
|
||||
'nl',
|
||||
'pl',
|
||||
'pt',
|
||||
'ru',
|
||||
'sv',
|
||||
'uk',
|
||||
'zh-CN',
|
||||
'zh-HK',
|
||||
'zh-TW',
|
||||
],
|
||||
input: 'src/**/*.{js,ts,jsx,tsx}',
|
||||
output: 'src/extractedTranslations/$LOCALE/$NAMESPACE.json',
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="/photoview-logo.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<meta name="apple-mobile-web-app-title" content="Gallery.FuKun.Net" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run Gallery.FuKun.Net.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"watch": ["./**"],
|
||||
"exec": "npm start",
|
||||
"ext": "ts,json,tsx,js,html,css",
|
||||
"ignore": ["./dist/"],
|
||||
"execMap": {
|
||||
"ts": "ts-node-esm"
|
||||
}
|
||||
}
|
||||
Generated
+26430
File diff suppressed because it is too large
Load Diff
+119
@@ -0,0 +1,119 @@
|
||||
{
|
||||
"name": "photoview-ui",
|
||||
"version": "0.0.1",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/photoview/photoview.git",
|
||||
"directory": "ui"
|
||||
},
|
||||
"license": "GPL-3.0",
|
||||
"description": "UI app for Photoview",
|
||||
"scripts": {
|
||||
"start": "vite --host=0.0.0.0",
|
||||
"mon": "nodemon",
|
||||
"build": "vite build",
|
||||
"build:dev": "vite build --mode development --sourcemap --minify=false",
|
||||
"lint": "eslint ./src --max-warnings 0 --config .eslintrc.js",
|
||||
"lint:ci": "eslint ./src --config .eslintrc.js --no-color --fix-dry-run > eslint-report.txt",
|
||||
"lint:staged": "lint-staged",
|
||||
"format:check": "prettier --check .",
|
||||
"test": "vitest",
|
||||
"test:ci": "CI=true vitest --reporter=junit --reporter=verbose --run --coverage",
|
||||
"genSchemaTypes": "apollo client:codegen --target=typescript --globalTypesFile=src/__generated__/globalTypes.ts --passthroughCustomScalars && prettier --write */**/__generated__/*.ts",
|
||||
"extractTranslations": "i18next -c i18next-parser.config.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.6.9",
|
||||
"@babel/preset-typescript": "^7.18.6",
|
||||
"@headlessui/react": "^1.6.6",
|
||||
"@types/geojson": "^7946.0.8",
|
||||
"@types/jest": "^28.1.4",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/luxon": "^3.7.1",
|
||||
"@types/mapbox-gl": "^2.7.3",
|
||||
"@types/react": "^18.0.15",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@types/react-helmet": "^6.1.5",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/styled-components": "^5.1.25",
|
||||
"@types/url-join": "^4.0.1",
|
||||
"@vitejs/plugin-react": "^1.3.2",
|
||||
"autoprefixer": "^10.4.7",
|
||||
"blurhash": "^1.1.5",
|
||||
"classnames": "^2.3.1",
|
||||
"connect-history-api-fallback": "^2.0.0",
|
||||
"copy-to-clipboard": "^3.3.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"i18next": "^21.8.13",
|
||||
"js-cookie": "^3.0.5",
|
||||
"luxon": "^3.7.2",
|
||||
"mapbox-gl": "^2.9.1",
|
||||
"postcss": "^8.4.39",
|
||||
"prettier": "^2.7.1",
|
||||
"react": "^18.2.0",
|
||||
"react-blurhash": "^0.1.3",
|
||||
"react-datepicker": "^9.0.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-hook-form": "^7.33.1",
|
||||
"react-i18next": "^11.18.0",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"react-swipeable": "^7.0.0",
|
||||
"react-test-renderer": "^18.2.0",
|
||||
"styled-components": "^5.3.5",
|
||||
"subscriptions-transport-ws": "^0.11.0",
|
||||
"tailwind-override": "^0.6.1",
|
||||
"tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.2.17",
|
||||
"url-join": "^5.0.0",
|
||||
"vite": "^2.9.13",
|
||||
"vite-plugin-svgr": "^2.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^5.16.4",
|
||||
"@testing-library/react": "^13.3.0",
|
||||
"@testing-library/user-event": "^14.2.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.30.6",
|
||||
"@typescript-eslint/parser": "^5.30.6",
|
||||
"@vitest/ui": "^0.17.1",
|
||||
"apollo": "2.34.0",
|
||||
"apollo-language-server": "1.26.9",
|
||||
"c8": "^7.11.3",
|
||||
"eslint": "^8.19.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-react": "^7.37.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"i18next-parser": "^8.13.0",
|
||||
"jsdom": "^20.0.0",
|
||||
"lint-staged": "^13.0.3",
|
||||
"nodemon": "^3.1.4",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.5.3",
|
||||
"vitest": "^0.17.1"
|
||||
},
|
||||
"overrides": {
|
||||
"graphql": "^15.5.0"
|
||||
},
|
||||
"prettier": {
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"arrowParens": "avoid"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,js,json,css,md,graphql}": "prettier --write",
|
||||
"*.{js,ts,tsx}": "eslint --cache --fix --max-warnings 0"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
"defaults",
|
||||
"not ie 11",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "Photoview",
|
||||
"short_name": "Photoview",
|
||||
"description": "Photo gallery for self-hosted personal servers",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"theme_color": "black",
|
||||
"background_color": "white",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/assets/logo192.png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "/assets/logo512.png",
|
||||
"sizes": "512x512"
|
||||
},
|
||||
{
|
||||
"src": "/assets/photoview-logo.svg",
|
||||
"sizes": "150x150"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200">
|
||||
<defs>
|
||||
<!-- Ink bleed and brush texture -->
|
||||
<filter id="xuan" x="-10%" y="-10%" width="120%" height="120%">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.04" numOctaves="6" seed="2" result="noise"/>
|
||||
<feDisplacementMap in="SourceGraphic" in2="noise" scale="3.5" xChannelSelector="R" yChannelSelector="G" result="rough"/>
|
||||
<feGaussianBlur in="rough" stdDeviation="0.3" result="blur"/>
|
||||
<feComponentTransfer in="blur">
|
||||
<feFuncA type="linear" slope="1.2"/>
|
||||
</feComponentTransfer>
|
||||
</filter>
|
||||
|
||||
<!-- Paper texture -->
|
||||
<filter id="paper" x="0%" y="0%" width="100%" height="100%">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="3" result="n"/>
|
||||
<feColorMatrix type="saturate" values="0" in="n" result="g"/>
|
||||
<feBlend in="SourceGraphic" in2="g" mode="multiply"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Rice paper background -->
|
||||
<rect x="4" y="4" width="192" height="192" rx="44" ry="44" fill="#000"/>
|
||||
|
||||
<!-- F — 行书风格,一笔写成 -->
|
||||
<g filter="url(#xuan)">
|
||||
<!-- Main stroke group: heavy vertical downstroke -->
|
||||
<path d="M 60 22 C 56 60, 57 100, 56 178" fill="none" stroke="#f8f8f8" stroke-width="13" stroke-linecap="round"/>
|
||||
<!-- Lighter overlay for brush texture -->
|
||||
<path d="M 60 22 C 56 60, 57 100, 56 178" fill="none" stroke="#fff" stroke-width="8" stroke-linecap="round" opacity="0.7"/>
|
||||
<!-- Top bar — 横画,起笔重收笔轻 -->
|
||||
<path d="M 56 28 C 72 24, 100 24, 118 30" fill="none" stroke="#f8f8f8" stroke-width="12" stroke-linecap="round"/>
|
||||
<path d="M 56 28 C 72 24, 100 24, 118 30" fill="none" stroke="#fff" stroke-width="7" stroke-linecap="round" opacity="0.6"/>
|
||||
<!-- Mid bar — 短横 -->
|
||||
<path d="M 57 94 C 70 90, 90 90, 100 96" fill="none" stroke="#f8f8f8" stroke-width="10" stroke-linecap="round"/>
|
||||
<path d="M 57 94 C 70 90, 90 90, 100 96" fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round" opacity="0.5"/>
|
||||
<!-- 飞白 干笔效果 — dry brush streaks at top -->
|
||||
<path d="M 60 22 C 58 30, 61 38, 59 44" fill="none" stroke="#fff" stroke-width="2" opacity="0.3"/>
|
||||
<path d="M 64 26 C 62 34, 65 40, 63 46" fill="none" stroke="#fff" stroke-width="1.5" opacity="0.2"/>
|
||||
</g>
|
||||
|
||||
<!-- K — 行书风格 -->
|
||||
<g filter="url(#xuan)">
|
||||
<!-- Vertical downstroke -->
|
||||
<path d="M 120 22 C 117 60, 118 100, 117 178" fill="none" stroke="#f8f8f8" stroke-width="13" stroke-linecap="round"/>
|
||||
<path d="M 120 22 C 117 60, 118 100, 117 178" fill="none" stroke="#fff" stroke-width="8" stroke-linecap="round" opacity="0.7"/>
|
||||
<!-- Upper diagonal — 从右上撇向左下,起笔重收笔轻 -->
|
||||
<path d="M 170 24 C 160 32, 140 56, 118 96" fill="none" stroke="#f8f8f8" stroke-width="11" stroke-linecap="round"/>
|
||||
<path d="M 170 24 C 160 32, 140 56, 118 96" fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" opacity="0.6"/>
|
||||
<!-- Lower diagonal — 从左上捺向右下 -->
|
||||
<path d="M 117 100 C 130 120, 148 144, 168 176" fill="none" stroke="#f8f8f8" stroke-width="11" stroke-linecap="round"/>
|
||||
<path d="M 117 100 C 130 120, 148 144, 168 176" fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" opacity="0.6"/>
|
||||
<!-- 飞白 -->
|
||||
<path d="M 168 28 C 158 38, 142 58, 122 90" fill="none" stroke="#fff" stroke-width="2" opacity="0.25"/>
|
||||
<path d="M 120 104 C 132 120, 146 140, 162 168" fill="none" stroke="#fff" stroke-width="2" opacity="0.25"/>
|
||||
</g>
|
||||
|
||||
<!-- Paper grain overlay -->
|
||||
<rect x="4" y="4" width="192" height="192" rx="44" ry="44" fill="none" filter="url(#paper)" opacity="0.05"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
declare module '*.svg' {
|
||||
const ReactComponent: React.FC<React.SVGProps<SVGSVGElement>>
|
||||
// const content: string
|
||||
|
||||
export { ReactComponent }
|
||||
// export default content
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly REACT_APP_BUILD_VERSION: string | undefined
|
||||
readonly REACT_APP_BUILD_DATE: string | undefined
|
||||
readonly REACT_APP_BUILD_COMMIT_SHA: string | undefined
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
type Time = string
|
||||
type Any = object
|
||||
@@ -0,0 +1,38 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import { Helmet } from 'react-helmet'
|
||||
import Routes from './components/routes/Routes'
|
||||
import Messages from './components/messages/Messages'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { loadTranslations } from './localization'
|
||||
import { useLocation } from 'react-router'
|
||||
|
||||
const App = () => {
|
||||
const { t } = useTranslation()
|
||||
const { pathname } = useLocation()
|
||||
|
||||
loadTranslations()
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0)
|
||||
if (document.activeElement != document.body)
|
||||
(document.activeElement as HTMLInputElement).blur()
|
||||
}, [pathname])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<meta
|
||||
name="description"
|
||||
content={t(
|
||||
'meta.description',
|
||||
'Simple and User-friendly Photo Gallery for Personal Servers'
|
||||
)}
|
||||
/>
|
||||
</Helmet>
|
||||
<Routes />
|
||||
<Messages />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -0,0 +1,24 @@
|
||||
import { MockedProvider } from '@apollo/client/testing'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||
import AlbumPage from './AlbumPage'
|
||||
|
||||
vi.mock('../../hooks/useScrollPagination')
|
||||
|
||||
test('AlbumPage renders', () => {
|
||||
render(
|
||||
<MockedProvider mocks={[]}>
|
||||
<MemoryRouter initialEntries={['/album/1']}>
|
||||
<Routes>
|
||||
<Route path="/album/:id" element={<AlbumPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</MockedProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Sort')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Sort direction')).toBeInTheDocument()
|
||||
|
||||
screen.debug()
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { useQuery, gql } from '@apollo/client'
|
||||
import AlbumGallery, {
|
||||
ALBUM_GALLERY_FRAGMENT,
|
||||
} from '../../components/albumGallery/AlbumGallery'
|
||||
import Layout from '../../components/layout/Layout'
|
||||
import useURLParameters from '../../hooks/useURLParameters'
|
||||
import useScrollPagination from '../../hooks/useScrollPagination'
|
||||
import PaginateLoader from '../../components/PaginateLoader'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { albumQuery, albumQueryVariables } from './__generated__/albumQuery'
|
||||
import useOrderingParams from '../../hooks/useOrderingParams'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { isNil } from '../../helpers/utils'
|
||||
|
||||
const ALBUM_QUERY = gql`
|
||||
${ALBUM_GALLERY_FRAGMENT}
|
||||
|
||||
query albumQuery(
|
||||
$id: ID!
|
||||
$onlyFavorites: Boolean
|
||||
$mediaOrderBy: String
|
||||
$orderDirection: OrderDirection
|
||||
$limit: Int
|
||||
$offset: Int
|
||||
) {
|
||||
album(id: $id) {
|
||||
...AlbumGalleryFields
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
let refetchNeededAll = false
|
||||
let refetchNeededFavorites = false
|
||||
|
||||
function AlbumPage() {
|
||||
const { id: albumId } = useParams()
|
||||
if (isNil(albumId))
|
||||
throw new Error('Expected parameter `id` to be defined for AlbumPage')
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const urlParams = useURLParameters()
|
||||
const orderParams = useOrderingParams(urlParams)
|
||||
|
||||
const onlyFavorites = urlParams.getParam('favorites') == '1' ? true : false
|
||||
const setOnlyFavorites = (favorites: boolean) =>
|
||||
urlParams.setParam('favorites', favorites ? '1' : '0')
|
||||
|
||||
const { loading, error, data, refetch, fetchMore } = useQuery<
|
||||
albumQuery,
|
||||
albumQueryVariables
|
||||
>(ALBUM_QUERY, {
|
||||
variables: {
|
||||
id: albumId,
|
||||
onlyFavorites,
|
||||
mediaOrderBy: orderParams.orderBy,
|
||||
orderDirection: orderParams.orderDirection,
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
},
|
||||
})
|
||||
|
||||
const { containerElem, finished: finishedLoadingMore } =
|
||||
useScrollPagination<albumQuery>({
|
||||
loading,
|
||||
fetchMore,
|
||||
data,
|
||||
getItems: data => data.album.media,
|
||||
})
|
||||
|
||||
const toggleFavorites = useCallback(
|
||||
(onlyFavorites: boolean) => {
|
||||
if (
|
||||
(refetchNeededAll && !onlyFavorites) ||
|
||||
(refetchNeededFavorites && onlyFavorites)
|
||||
) {
|
||||
refetch({ id: albumId, onlyFavorites: onlyFavorites }).then(() => {
|
||||
if (onlyFavorites) {
|
||||
refetchNeededFavorites = false
|
||||
} else {
|
||||
refetchNeededAll = false
|
||||
}
|
||||
setOnlyFavorites(onlyFavorites)
|
||||
})
|
||||
} else {
|
||||
setOnlyFavorites(onlyFavorites)
|
||||
}
|
||||
},
|
||||
[setOnlyFavorites, refetch]
|
||||
)
|
||||
|
||||
if (error) return <div>Error</div>
|
||||
|
||||
return (
|
||||
<Layout
|
||||
title={
|
||||
data ? data.album.title : t('title.loading_album', 'Loading album')
|
||||
}
|
||||
>
|
||||
<AlbumGallery
|
||||
ref={containerElem}
|
||||
album={data && data.album}
|
||||
loading={loading}
|
||||
setOnlyFavorites={toggleFavorites}
|
||||
onlyFavorites={onlyFavorites}
|
||||
onFavorite={() => (refetchNeededAll = refetchNeededFavorites = true)}
|
||||
showFilter
|
||||
setOrdering={orderParams.setOrdering}
|
||||
ordering={orderParams}
|
||||
/>
|
||||
<PaginateLoader
|
||||
active={!finishedLoadingMore && !loading}
|
||||
text={t('general.loading.paginate.media', 'Loading more media')}
|
||||
/>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default AlbumPage
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
import { OrderDirection, MediaType } from "./../../../__generated__/globalTypes";
|
||||
|
||||
// ====================================================
|
||||
// GraphQL query operation: albumQuery
|
||||
// ====================================================
|
||||
|
||||
export interface albumQuery_album_subAlbums_thumbnail_thumbnail {
|
||||
__typename: "MediaURL";
|
||||
/**
|
||||
* URL for previewing the image
|
||||
*/
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface albumQuery_album_subAlbums_thumbnail {
|
||||
__typename: "Media";
|
||||
id: string;
|
||||
/**
|
||||
* URL to display the media in a smaller resolution
|
||||
*/
|
||||
thumbnail: albumQuery_album_subAlbums_thumbnail_thumbnail | null;
|
||||
}
|
||||
|
||||
export interface albumQuery_album_subAlbums {
|
||||
__typename: "Album";
|
||||
id: string;
|
||||
title: string;
|
||||
/**
|
||||
* An image in this album used for previewing this album
|
||||
*/
|
||||
thumbnail: albumQuery_album_subAlbums_thumbnail | null;
|
||||
}
|
||||
|
||||
export interface albumQuery_album_media_thumbnail {
|
||||
__typename: "MediaURL";
|
||||
/**
|
||||
* URL for previewing the image
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* Width of the image in pixels
|
||||
*/
|
||||
width: number;
|
||||
/**
|
||||
* Height of the image in pixels
|
||||
*/
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface albumQuery_album_media_highRes {
|
||||
__typename: "MediaURL";
|
||||
/**
|
||||
* URL for previewing the image
|
||||
*/
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface albumQuery_album_media_videoWeb {
|
||||
__typename: "MediaURL";
|
||||
/**
|
||||
* URL for previewing the image
|
||||
*/
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface albumQuery_album_media {
|
||||
__typename: "Media";
|
||||
id: string;
|
||||
type: MediaType;
|
||||
/**
|
||||
* A short string that can be used to generate a blured version of the media, to show while the original is loading
|
||||
*/
|
||||
blurhash: string | null;
|
||||
/**
|
||||
* URL to display the media in a smaller resolution
|
||||
*/
|
||||
thumbnail: albumQuery_album_media_thumbnail | null;
|
||||
/**
|
||||
* URL to display the photo in full resolution, will be null for videos
|
||||
*/
|
||||
highRes: albumQuery_album_media_highRes | null;
|
||||
/**
|
||||
* URL to get the video in a web format that can be played in the browser, will be null for photos
|
||||
*/
|
||||
videoWeb: albumQuery_album_media_videoWeb | null;
|
||||
favorite: boolean;
|
||||
}
|
||||
|
||||
export interface albumQuery_album {
|
||||
__typename: "Album";
|
||||
id: string;
|
||||
title: string;
|
||||
/**
|
||||
* The albums contained in this album
|
||||
*/
|
||||
subAlbums: albumQuery_album_subAlbums[];
|
||||
/**
|
||||
* The media inside this album
|
||||
*/
|
||||
media: albumQuery_album_media[];
|
||||
}
|
||||
|
||||
export interface albumQuery {
|
||||
/**
|
||||
* Get album by id, user must own the album or be admin
|
||||
* If valid tokenCredentials are provided, the album may be retrived without further authentication
|
||||
*/
|
||||
album: albumQuery_album;
|
||||
}
|
||||
|
||||
export interface albumQueryVariables {
|
||||
id: string;
|
||||
onlyFavorites?: boolean | null;
|
||||
mediaOrderBy?: string | null;
|
||||
orderDirection?: OrderDirection | null;
|
||||
limit?: number | null;
|
||||
offset?: number | null;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AlbumBoxes from '../../components/albumGallery/AlbumBoxes'
|
||||
import Layout from '../../components/layout/Layout'
|
||||
import { useQuery, gql } from '@apollo/client'
|
||||
import { getMyAlbums, getMyAlbumsVariables } from './__generated__/getMyAlbums'
|
||||
import useURLParameters from '../../hooks/useURLParameters'
|
||||
import useOrderingParams from '../../hooks/useOrderingParams'
|
||||
import AlbumFilter from '../../components/album/AlbumFilter'
|
||||
|
||||
const getAlbumsQuery = gql`
|
||||
query getMyAlbums($orderBy: String, $orderDirection: OrderDirection) {
|
||||
myAlbums(
|
||||
order: { order_by: $orderBy, order_direction: $orderDirection }
|
||||
onlyRoot: true
|
||||
showEmpty: true
|
||||
) {
|
||||
id
|
||||
title
|
||||
thumbnail {
|
||||
id
|
||||
thumbnail {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const AlbumsPage = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const urlParams = useURLParameters()
|
||||
const orderParams = useOrderingParams(urlParams, 'updated_at')
|
||||
|
||||
const { error, data } = useQuery<getMyAlbums, getMyAlbumsVariables>(
|
||||
getAlbumsQuery,
|
||||
{
|
||||
variables: {
|
||||
orderBy: orderParams.orderBy,
|
||||
orderDirection: orderParams.orderDirection,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const sortingOptions = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
value: 'updated_at' as const,
|
||||
label: t('album_filter.sorting_options.date_imported', 'Date imported'),
|
||||
},
|
||||
{
|
||||
value: 'title' as const,
|
||||
label: t('album_filter.sorting_options.title', 'Title'),
|
||||
},
|
||||
],
|
||||
[t]
|
||||
)
|
||||
|
||||
return (
|
||||
<Layout title="Albums">
|
||||
<AlbumFilter
|
||||
onlyFavorites={false}
|
||||
ordering={orderParams}
|
||||
setOrdering={orderParams.setOrdering}
|
||||
sortingOptions={sortingOptions}
|
||||
/>
|
||||
<AlbumBoxes error={error} albums={data?.myAlbums} />
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default AlbumsPage
|
||||
@@ -0,0 +1,49 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
import { OrderDirection } from "./../../../__generated__/globalTypes";
|
||||
|
||||
// ====================================================
|
||||
// GraphQL query operation: getMyAlbums
|
||||
// ====================================================
|
||||
|
||||
export interface getMyAlbums_myAlbums_thumbnail_thumbnail {
|
||||
__typename: "MediaURL";
|
||||
/**
|
||||
* URL for previewing the image
|
||||
*/
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface getMyAlbums_myAlbums_thumbnail {
|
||||
__typename: "Media";
|
||||
id: string;
|
||||
/**
|
||||
* URL to display the media in a smaller resolution
|
||||
*/
|
||||
thumbnail: getMyAlbums_myAlbums_thumbnail_thumbnail | null;
|
||||
}
|
||||
|
||||
export interface getMyAlbums_myAlbums {
|
||||
__typename: "Album";
|
||||
id: string;
|
||||
title: string;
|
||||
/**
|
||||
* An image in this album used for previewing this album
|
||||
*/
|
||||
thumbnail: getMyAlbums_myAlbums_thumbnail | null;
|
||||
}
|
||||
|
||||
export interface getMyAlbums {
|
||||
/**
|
||||
* List of albums owned by the logged in user.
|
||||
*/
|
||||
myAlbums: getMyAlbums_myAlbums[];
|
||||
}
|
||||
|
||||
export interface getMyAlbumsVariables {
|
||||
orderBy?: string | null;
|
||||
orderDirection?: OrderDirection | null;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import React from 'react'
|
||||
import { MockedProvider } from '@apollo/client/testing'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { unstable_HistoryRouter as HistoryRouter } from 'react-router-dom'
|
||||
import { createMemoryHistory } from 'history'
|
||||
import * as authentication from '../../helpers/authentication'
|
||||
import InitialSetupPage from './InitialSetupPage'
|
||||
import { mockInitialSetupGraphql } from './loginTestHelpers'
|
||||
|
||||
vi.mock('../../helpers/authentication.ts')
|
||||
|
||||
const authToken = vi.mocked(authentication.authToken)
|
||||
|
||||
describe('Initial setup page', () => {
|
||||
test('Render initial setup form', () => {
|
||||
authToken.mockImplementation(() => null)
|
||||
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/initialSetup'],
|
||||
})
|
||||
|
||||
render(
|
||||
<MockedProvider mocks={[mockInitialSetupGraphql(true)]}>
|
||||
<HistoryRouter history={history}>
|
||||
<InitialSetupPage />
|
||||
</HistoryRouter>
|
||||
</MockedProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByLabelText('Username')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Password')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Photo path')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Setup Photoview')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('Redirect if auth token is present', async () => {
|
||||
authToken.mockImplementation(() => 'some-token')
|
||||
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/initialSetup'],
|
||||
})
|
||||
|
||||
render(
|
||||
<MockedProvider mocks={[mockInitialSetupGraphql(true)]}>
|
||||
<HistoryRouter history={history}>
|
||||
<InitialSetupPage />
|
||||
</HistoryRouter>
|
||||
</MockedProvider>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(history.location.pathname).toBe('/')
|
||||
})
|
||||
})
|
||||
|
||||
test('Redirect if not initial setup', async () => {
|
||||
authToken.mockImplementation(() => null)
|
||||
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/initialSetup'],
|
||||
})
|
||||
|
||||
render(
|
||||
<MockedProvider mocks={[mockInitialSetupGraphql(false)]}>
|
||||
<HistoryRouter history={history}>
|
||||
<InitialSetupPage />
|
||||
</HistoryRouter>
|
||||
</MockedProvider>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(history.location.pathname).toBe('/')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import { gql, useQuery, useMutation } from '@apollo/client'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Container } from './loginUtilities'
|
||||
|
||||
import { INITIAL_SETUP_QUERY, login } from './loginUtilities'
|
||||
import { authToken } from '../../helpers/authentication'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CheckInitialSetup } from './__generated__/CheckInitialSetup'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Submit, TextField } from '../../primitives/form/Input'
|
||||
import MessageBox from '../../primitives/form/MessageBox'
|
||||
import {
|
||||
InitialSetup,
|
||||
InitialSetupVariables,
|
||||
} from './__generated__/InitialSetup'
|
||||
|
||||
const initialSetupMutation = gql`
|
||||
mutation InitialSetup(
|
||||
$username: String!
|
||||
$password: String!
|
||||
$rootPath: String!
|
||||
) {
|
||||
initialSetupWizard(
|
||||
username: $username
|
||||
password: $password
|
||||
rootPath: $rootPath
|
||||
) {
|
||||
success
|
||||
status
|
||||
token
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
type InitialSetupFormData = {
|
||||
username: string
|
||||
password: string
|
||||
rootPath: string
|
||||
}
|
||||
|
||||
const InitialSetupPage = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors: formErrors },
|
||||
} = useForm<InitialSetupFormData>()
|
||||
|
||||
useEffect(() => {
|
||||
if (authToken()) navigate('/')
|
||||
}, [])
|
||||
|
||||
const { data: initialSetupData } =
|
||||
useQuery<CheckInitialSetup>(INITIAL_SETUP_QUERY)
|
||||
|
||||
const notInitialSetup = initialSetupData?.siteInfo?.initialSetup === false
|
||||
|
||||
useEffect(() => {
|
||||
if (notInitialSetup) navigate('/')
|
||||
}, [notInitialSetup])
|
||||
|
||||
const [authorize, { loading: authorizeLoading, data: authorizationData }] =
|
||||
useMutation<InitialSetup, InitialSetupVariables>(initialSetupMutation, {
|
||||
onCompleted: data => {
|
||||
if (!data.initialSetupWizard) return
|
||||
|
||||
const { success, token } = data.initialSetupWizard
|
||||
if (success && token) login(token)
|
||||
},
|
||||
})
|
||||
|
||||
const signIn = handleSubmit(data => {
|
||||
authorize({
|
||||
variables: {
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
rootPath: data.rootPath,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
if (authToken() || notInitialSetup) {
|
||||
return null
|
||||
}
|
||||
|
||||
let errorMessage = null
|
||||
if (authorizationData && !authorizationData?.initialSetupWizard?.success) {
|
||||
errorMessage = authorizationData?.initialSetupWizard?.status
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Container>
|
||||
<h1 className="text-center text-xl">
|
||||
{t('login_page.initial_setup.title', 'Initial Setup')}
|
||||
</h1>
|
||||
<form onSubmit={signIn} className="max-w-[500px] mx-auto">
|
||||
<TextField
|
||||
wrapperClassName="my-4"
|
||||
fullWidth
|
||||
{...register('username', { required: true })}
|
||||
label={t('login_page.field.username', 'Username')}
|
||||
error={
|
||||
formErrors.username?.type == 'required'
|
||||
? 'Please enter a username'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
wrapperClassName="my-4"
|
||||
fullWidth
|
||||
{...register('password', { required: true })}
|
||||
label={t('login_page.field.password', 'Password')}
|
||||
error={
|
||||
formErrors.password?.type == 'required'
|
||||
? 'Please enter a password'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
wrapperClassName="my-4"
|
||||
fullWidth
|
||||
{...register('rootPath', { required: true })}
|
||||
label={t(
|
||||
'login_page.initial_setup.field.photo_path.label',
|
||||
'Photo path'
|
||||
)}
|
||||
placeholder={t(
|
||||
'login_page.initial_setup.field.photo_path.placeholder',
|
||||
'/path/to/photos'
|
||||
)}
|
||||
error={
|
||||
formErrors.password?.type == 'required'
|
||||
? 'Please enter a photo path'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<MessageBox
|
||||
type="negative"
|
||||
message={errorMessage}
|
||||
show={!!errorMessage}
|
||||
/>
|
||||
<Submit className="mt-2" disabled={authorizeLoading}>
|
||||
{t('login_page.initial_setup.field.submit', 'Setup Photoview')}
|
||||
</Submit>
|
||||
</form>
|
||||
</Container>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InitialSetupPage
|
||||
@@ -0,0 +1,76 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import LoginPage from './LoginPage'
|
||||
import * as authentication from '../../helpers/authentication'
|
||||
import { unstable_HistoryRouter as HistoryRouter } from 'react-router-dom'
|
||||
import { createMemoryHistory } from 'history'
|
||||
import { MockedProvider } from '@apollo/client/testing'
|
||||
import { mockInitialSetupGraphql } from './loginTestHelpers'
|
||||
|
||||
vi.mock('../../helpers/authentication.ts')
|
||||
|
||||
const authToken = vi.mocked(authentication.authToken)
|
||||
|
||||
describe('Login page redirects', () => {
|
||||
test('Auth token redirect', async () => {
|
||||
authToken.mockImplementation(() => 'some-token')
|
||||
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/login'],
|
||||
})
|
||||
|
||||
render(
|
||||
<MockedProvider mocks={[]}>
|
||||
<HistoryRouter history={history}>
|
||||
<LoginPage />
|
||||
</HistoryRouter>
|
||||
</MockedProvider>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(history.location.pathname).toBe('/')
|
||||
})
|
||||
})
|
||||
|
||||
test('Initial setup redirect', async () => {
|
||||
authToken.mockImplementation(() => null)
|
||||
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/login'],
|
||||
})
|
||||
|
||||
render(
|
||||
<MockedProvider mocks={[mockInitialSetupGraphql(true)]}>
|
||||
<HistoryRouter history={history}>
|
||||
<LoginPage />
|
||||
</HistoryRouter>
|
||||
</MockedProvider>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(history.location.pathname).toBe('/initialSetup')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Login page', () => {
|
||||
test('Render login form', () => {
|
||||
authToken.mockImplementation(() => null)
|
||||
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/login'],
|
||||
})
|
||||
|
||||
render(
|
||||
<MockedProvider mocks={[mockInitialSetupGraphql(false)]}>
|
||||
<HistoryRouter history={history}>
|
||||
<LoginPage />
|
||||
</HistoryRouter>
|
||||
</MockedProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByLabelText('Username')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Password')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Sign in')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import { useQuery, gql, useMutation } from '@apollo/client'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { INITIAL_SETUP_QUERY, login } from './loginUtilities'
|
||||
import { authToken } from '../../helpers/authentication'
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Helmet } from 'react-helmet'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { TextField } from '../../primitives/form/Input'
|
||||
import MessageBox from '../../primitives/form/MessageBox'
|
||||
import { CheckInitialSetup } from './__generated__/CheckInitialSetup'
|
||||
import { Authorize, AuthorizeVariables } from './__generated__/Authorize'
|
||||
|
||||
const authorizeMutation = gql`
|
||||
mutation Authorize($username: String!, $password: String!) {
|
||||
authorizeUser(username: $username, password: $password) {
|
||||
success
|
||||
status
|
||||
token
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const LogoHeader = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex justify-center flex-col mb-14 mt-20">
|
||||
<img
|
||||
className="h-24"
|
||||
src={import.meta.env.BASE_URL + 'photoview-logo.svg'}
|
||||
alt="photoview logo"
|
||||
/>
|
||||
<h1 className="text-3xl text-center mt-4">
|
||||
{t('login_page.welcome', 'Welcome to Photoview')}
|
||||
</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const LoginForm = () => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors: formErrors },
|
||||
} = useForm<LoginInputs>()
|
||||
|
||||
const [authorize, { loading, data }] = useMutation<
|
||||
Authorize,
|
||||
AuthorizeVariables
|
||||
>(authorizeMutation, {
|
||||
onCompleted: data => {
|
||||
const { success, token } = data.authorizeUser
|
||||
|
||||
if (success && token) {
|
||||
login(token)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = (data: LoginInputs) => {
|
||||
authorize({
|
||||
variables: {
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
data && !data.authorizeUser.success ? data.authorizeUser.status : null
|
||||
|
||||
return (
|
||||
<form
|
||||
className="mx-auto max-w-[500px] px-4"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
// loading={loading || (data && data.authorizeUser.success)}
|
||||
>
|
||||
<TextField
|
||||
sizeVariant="big"
|
||||
wrapperClassName="my-6"
|
||||
className="w-full"
|
||||
label={t('login_page.field.username', 'Username')}
|
||||
{...register('username', { required: true })}
|
||||
error={
|
||||
formErrors.username?.type == 'required'
|
||||
? 'Please enter a username'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
sizeVariant="big"
|
||||
wrapperClassName="my-6"
|
||||
className="w-full"
|
||||
type="password"
|
||||
label={t('login_page.field.password', 'Password')}
|
||||
{...register('password')}
|
||||
/>
|
||||
<input
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
value={t('login_page.field.submit', 'Sign in')}
|
||||
className="rounded-md px-8 py-2 mt-2 focus:outline-none cursor-pointer bg-gradient-to-bl from-[#FF8246] to-[#D6264D] text-white font-semibold focus:ring-2 focus:ring-red-200 disabled:cursor-default disabled:opacity-80"
|
||||
/>
|
||||
<MessageBox
|
||||
message={errorMessage}
|
||||
show={!!errorMessage}
|
||||
type="negative"
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
type LoginInputs = {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
const LoginPage = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { data: initialSetupData } = useQuery<CheckInitialSetup>(
|
||||
INITIAL_SETUP_QUERY,
|
||||
{ variables: {} }
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (authToken()) navigate('/')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (initialSetupData?.siteInfo?.initialSetup) navigate('/initialSetup')
|
||||
}, [initialSetupData?.siteInfo?.initialSetup])
|
||||
|
||||
if (authToken() || initialSetupData?.siteInfo?.initialSetup) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t('title.login', 'Login')} - Photoview</title>
|
||||
</Helmet>
|
||||
<div>
|
||||
<LogoHeader />
|
||||
<LoginForm />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginPage
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
// ====================================================
|
||||
// GraphQL mutation operation: Authorize
|
||||
// ====================================================
|
||||
|
||||
export interface Authorize_authorizeUser {
|
||||
__typename: "AuthorizeResult";
|
||||
success: boolean;
|
||||
/**
|
||||
* A textual status message describing the result, can be used to show an error message when `success` is false
|
||||
*/
|
||||
status: string;
|
||||
/**
|
||||
* An access token used to authenticate new API requests as the newly authorized user. Is present when success is true
|
||||
*/
|
||||
token: string | null;
|
||||
}
|
||||
|
||||
export interface Authorize {
|
||||
/**
|
||||
* Authorizes a user and returns a token used to identify the new session
|
||||
*/
|
||||
authorizeUser: Authorize_authorizeUser;
|
||||
}
|
||||
|
||||
export interface AuthorizeVariables {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
// ====================================================
|
||||
// GraphQL query operation: CheckInitialSetup
|
||||
// ====================================================
|
||||
|
||||
export interface CheckInitialSetup_siteInfo {
|
||||
__typename: "SiteInfo";
|
||||
/**
|
||||
* Whether or not the initial setup wizard should be shown
|
||||
*/
|
||||
initialSetup: boolean;
|
||||
}
|
||||
|
||||
export interface CheckInitialSetup {
|
||||
siteInfo: CheckInitialSetup_siteInfo;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
// ====================================================
|
||||
// GraphQL mutation operation: InitialSetup
|
||||
// ====================================================
|
||||
|
||||
export interface InitialSetup_initialSetupWizard {
|
||||
__typename: "AuthorizeResult";
|
||||
success: boolean;
|
||||
/**
|
||||
* A textual status message describing the result, can be used to show an error message when `success` is false
|
||||
*/
|
||||
status: string;
|
||||
/**
|
||||
* An access token used to authenticate new API requests as the newly authorized user. Is present when success is true
|
||||
*/
|
||||
token: string | null;
|
||||
}
|
||||
|
||||
export interface InitialSetup {
|
||||
/**
|
||||
* Registers the initial user, can only be called if initialSetup from SiteInfo is true
|
||||
*/
|
||||
initialSetupWizard: InitialSetup_initialSetupWizard | null;
|
||||
}
|
||||
|
||||
export interface InitialSetupVariables {
|
||||
username: string;
|
||||
password: string;
|
||||
rootPath: string;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { INITIAL_SETUP_QUERY } from './loginUtilities'
|
||||
|
||||
export const mockInitialSetupGraphql = (initial: boolean) => ({
|
||||
request: {
|
||||
query: INITIAL_SETUP_QUERY,
|
||||
variables: {},
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
siteInfo: {
|
||||
initialSetup: initial,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import { gql } from '@apollo/client'
|
||||
import { saveTokenCookie } from '../../helpers/authentication'
|
||||
import styled from 'styled-components'
|
||||
|
||||
export const INITIAL_SETUP_QUERY = gql`
|
||||
query CheckInitialSetup {
|
||||
siteInfo {
|
||||
initialSetup
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export function login(token: string) {
|
||||
saveTokenCookie(token)
|
||||
window.location.href = `${import.meta.env.BASE_URL}`
|
||||
}
|
||||
|
||||
export const Container = styled.div.attrs({ className: 'mt-20' })``
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import FaceCircleImage from './FaceCircleImage'
|
||||
import { myFaces_myFaceGroups_imageFaces } from './__generated__/myFaces'
|
||||
|
||||
test('face circle image', () => {
|
||||
const imageFace: myFaces_myFaceGroups_imageFaces = {
|
||||
__typename: 'ImageFace',
|
||||
id: '3',
|
||||
media: {
|
||||
id: '1',
|
||||
__typename: 'Media',
|
||||
title: 'my_image.jpg',
|
||||
thumbnail: {
|
||||
__typename: 'MediaURL',
|
||||
url: '/photo/thumbnail_my_image_jpg_p9x8dLWr.jpg',
|
||||
width: 1024,
|
||||
height: 641,
|
||||
},
|
||||
},
|
||||
rectangle: {
|
||||
__typename: 'FaceRectangle',
|
||||
minX: 0.27,
|
||||
maxX: 0.34,
|
||||
minY: 0.76,
|
||||
maxY: 0.88,
|
||||
},
|
||||
}
|
||||
|
||||
render(<FaceCircleImage imageFace={imageFace} selectable={true} />)
|
||||
|
||||
expect(screen.getByRole('img')).toBeInTheDocument()
|
||||
expect(screen.getByRole('img')).toHaveAttribute(
|
||||
'src',
|
||||
'http://localhost:3000/photo/thumbnail_my_image_jpg_p9x8dLWr.jpg'
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import {
|
||||
ProtectedImage,
|
||||
ProtectedImageProps,
|
||||
} from '../../components/photoGallery/ProtectedMedia'
|
||||
import {
|
||||
myFaces_myFaceGroups_imageFaces_media,
|
||||
myFaces_myFaceGroups_imageFaces_rectangle,
|
||||
} from './__generated__/myFaces'
|
||||
|
||||
type FaceImageProps = ProtectedImageProps & {
|
||||
origin: { x: number; y: number }
|
||||
selectable: boolean
|
||||
scale: number
|
||||
}
|
||||
|
||||
const FaceImage = styled(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
({ origin, selectable, scale, ...rest }: FaceImageProps) => (
|
||||
<ProtectedImage {...rest} />
|
||||
)
|
||||
)`
|
||||
position: absolute;
|
||||
transform-origin: ${({ origin }) => `${origin.x * 100}% ${origin.y * 100}%`};
|
||||
object-fit: cover;
|
||||
|
||||
transition: transform 250ms ease-out;
|
||||
`
|
||||
|
||||
const FaceImagePortrait = styled(FaceImage)`
|
||||
width: 100%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%)
|
||||
${({ origin, scale }) =>
|
||||
`translate(${(0.5 - origin.x) * 100}%, ${
|
||||
(0.5 - origin.y) * 100
|
||||
}%) scale(${Math.max(scale * 0.8, 1)})`};
|
||||
|
||||
${({ selectable, origin, scale }) =>
|
||||
selectable
|
||||
? `
|
||||
&:hover {
|
||||
transform: translateY(-50%) translate(${(0.5 - origin.x) * 100}%, ${
|
||||
(0.5 - origin.y) * 100
|
||||
}%) scale(${Math.max(scale * 0.85, 1)})
|
||||
`
|
||||
: ''}
|
||||
`
|
||||
|
||||
const FaceImageLandscape = styled(FaceImage)`
|
||||
height: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%)
|
||||
${({ origin, scale }) =>
|
||||
`translate(${(0.5 - origin.x) * 100}%, ${
|
||||
(0.5 - origin.y) * 100
|
||||
}%) scale(${Math.max(scale * 0.8, 1)})`};
|
||||
|
||||
${({ selectable, origin, scale }) =>
|
||||
selectable
|
||||
? `
|
||||
&:hover {
|
||||
transform: translateX(-50%) translate(${(0.5 - origin.x) * 100}%, ${
|
||||
(0.5 - origin.y) * 100
|
||||
}%) scale(${Math.max(scale * 0.85, 1)})
|
||||
`
|
||||
: ''}
|
||||
`
|
||||
|
||||
const CircleImageWrapper = styled.div<{ size: string }>`
|
||||
background-color: #eee;
|
||||
position: relative;
|
||||
border-radius: 50%;
|
||||
width: ${({ size }) => size};
|
||||
height: ${({ size }) => size};
|
||||
object-fit: fill;
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
type FaceCircleImageFace = {
|
||||
__typename: 'ImageFace'
|
||||
id: string
|
||||
rectangle: myFaces_myFaceGroups_imageFaces_rectangle
|
||||
media: myFaces_myFaceGroups_imageFaces_media
|
||||
}
|
||||
|
||||
type FaceCircleImageProps = {
|
||||
imageFace: FaceCircleImageFace
|
||||
selectable: boolean
|
||||
size?: string
|
||||
}
|
||||
|
||||
const FaceCircleImage = ({
|
||||
imageFace,
|
||||
selectable,
|
||||
size = '150px',
|
||||
}: FaceCircleImageProps) => {
|
||||
if (!imageFace) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rect = imageFace.rectangle
|
||||
|
||||
const scale = Math.min(
|
||||
1 / (rect.maxX - rect.minX),
|
||||
1 / (rect.maxY - rect.minY)
|
||||
)
|
||||
|
||||
const origin = {
|
||||
x: (rect.minX + rect.maxX) / 2,
|
||||
y: (rect.minY + rect.maxY) / 2,
|
||||
}
|
||||
|
||||
let SpecificFaceImage: typeof FaceImageLandscape | typeof FaceImagePortrait =
|
||||
FaceImageLandscape
|
||||
if (imageFace.media.thumbnail) {
|
||||
SpecificFaceImage =
|
||||
imageFace.media.thumbnail.width > imageFace.media.thumbnail.height
|
||||
? FaceImageLandscape
|
||||
: FaceImagePortrait
|
||||
}
|
||||
|
||||
return (
|
||||
<CircleImageWrapper size={size}>
|
||||
<SpecificFaceImage
|
||||
selectable={selectable}
|
||||
scale={scale}
|
||||
origin={origin}
|
||||
src={imageFace.media.thumbnail?.url}
|
||||
/>
|
||||
</CircleImageWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export default FaceCircleImage
|
||||
@@ -0,0 +1,246 @@
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import {
|
||||
PeoplePage,
|
||||
FaceDetails,
|
||||
FaceGroup,
|
||||
MY_FACES_QUERY,
|
||||
SET_GROUP_LABEL_MUTATION,
|
||||
} from './PeoplePage'
|
||||
import { MockedProvider } from '@apollo/client/testing'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { myFaces_myFaceGroups } from './__generated__/myFaces'
|
||||
|
||||
vi.mock('../../hooks/useScrollPagination')
|
||||
|
||||
describe('PeoplePage component', () => {
|
||||
const graphqlMocks = [
|
||||
{
|
||||
request: {
|
||||
query: MY_FACES_QUERY,
|
||||
variables: {
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
},
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
myFaceGroups: [
|
||||
{
|
||||
__typename: 'FaceGroup',
|
||||
id: '3',
|
||||
label: 'Person A',
|
||||
imageFaceCount: 2,
|
||||
imageFaces: [
|
||||
{
|
||||
__typename: 'ImageFace',
|
||||
id: '3',
|
||||
rectangle: {
|
||||
__typename: 'FaceRectangle',
|
||||
minX: 0.2705079913139343,
|
||||
maxX: 0.3408200144767761,
|
||||
minY: 0.7691109776496887,
|
||||
maxY: 0.881434977054596,
|
||||
},
|
||||
media: {
|
||||
__typename: 'Media',
|
||||
id: '63',
|
||||
title: 'image.jpg',
|
||||
thumbnail: {
|
||||
__typename: 'MediaURL',
|
||||
url: '/photo/thumbnail_image_jpg_p9x8dLWr.jpg',
|
||||
width: 1024,
|
||||
height: 641,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
__typename: 'FaceGroup',
|
||||
id: '1',
|
||||
label: 'Person B',
|
||||
imageFaceCount: 1,
|
||||
imageFaces: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
test('people page', async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/people']}>
|
||||
<MockedProvider mocks={graphqlMocks} addTypename={false}>
|
||||
<PeoplePage />
|
||||
</MockedProvider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('Layout')).toBeInTheDocument()
|
||||
expect(screen.getByText('Recognize unlabeled faces')).toBeInTheDocument()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Person A')).toBeInTheDocument()
|
||||
expect(screen.getByText('Person B')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getAllByRole('link')
|
||||
.some(x => x.getAttribute('href') == '/people/1')
|
||||
).toBeTruthy()
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getAllByRole('link')
|
||||
.some(x => x.getAttribute('href') == '/people/3')
|
||||
).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('FaceDetails component', () => {
|
||||
const faceGroup: myFaces_myFaceGroups = {
|
||||
id: '3',
|
||||
label: null,
|
||||
imageFaceCount: 2,
|
||||
imageFaces: [
|
||||
{
|
||||
id: '3',
|
||||
rectangle: {
|
||||
minX: 0.2705079913139343,
|
||||
maxX: 0.3408200144767761,
|
||||
minY: 0.7691109776496887,
|
||||
maxY: 0.881434977054596,
|
||||
__typename: 'FaceRectangle',
|
||||
},
|
||||
media: {
|
||||
id: '63',
|
||||
title: 'image.jpg',
|
||||
thumbnail: {
|
||||
url: '/photo/thumbnail_image_jpg_p9x8dLWr.jpg',
|
||||
width: 1024,
|
||||
height: 641,
|
||||
__typename: 'MediaURL',
|
||||
},
|
||||
__typename: 'Media',
|
||||
},
|
||||
__typename: 'ImageFace',
|
||||
},
|
||||
],
|
||||
__typename: 'FaceGroup',
|
||||
}
|
||||
|
||||
test('unlabeled, no images', () => {
|
||||
const emptyFaceGroup: myFaces_myFaceGroups = {
|
||||
...faceGroup,
|
||||
imageFaces: [],
|
||||
}
|
||||
|
||||
render(
|
||||
<MockedProvider mocks={[]} addTypename={false}>
|
||||
<FaceDetails
|
||||
editLabel={false}
|
||||
setEditLabel={vi.fn()}
|
||||
group={emptyFaceGroup}
|
||||
/>
|
||||
</MockedProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Unlabeled')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('labeled, with thumbnail', () => {
|
||||
const labeledFaceGroup: myFaces_myFaceGroups = {
|
||||
...faceGroup,
|
||||
label: 'Some label',
|
||||
}
|
||||
|
||||
render(
|
||||
<MockedProvider mocks={[]} addTypename={false}>
|
||||
<FaceDetails
|
||||
editLabel={false}
|
||||
setEditLabel={vi.fn()}
|
||||
group={labeledFaceGroup}
|
||||
/>
|
||||
</MockedProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByText(labeledFaceGroup.label!)).toBeInTheDocument()
|
||||
expect(screen.queryByText('Unlabeled')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('add label to face group', async () => {
|
||||
const graphqlMocks = [
|
||||
{
|
||||
request: {
|
||||
query: SET_GROUP_LABEL_MUTATION,
|
||||
variables: {
|
||||
groupID: '3',
|
||||
label: 'John Doe',
|
||||
},
|
||||
},
|
||||
newData: vi.fn(() => ({
|
||||
data: {
|
||||
setFaceGroupLabel: {
|
||||
__typename: 'FaceGroup',
|
||||
id: '3',
|
||||
label: 'John Doe',
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
]
|
||||
render(
|
||||
<MockedProvider mocks={graphqlMocks} addTypename={false}>
|
||||
<MemoryRouter>
|
||||
<FaceGroup group={faceGroup} />
|
||||
</MemoryRouter>
|
||||
</MockedProvider>
|
||||
)
|
||||
|
||||
const btn = screen.getByRole('button')
|
||||
expect(btn).toBeInTheDocument()
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(btn)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(input).toHaveValue('')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'John Doe' } })
|
||||
fireEvent.keyUp(input, { key: 'Enter', code: 'Enter' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(graphqlMocks[0].newData).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
test('cancel add label to face group', () => {
|
||||
render(
|
||||
<MockedProvider mocks={[]} addTypename={false}>
|
||||
<MemoryRouter>
|
||||
<FaceGroup group={faceGroup} />
|
||||
</MemoryRouter>
|
||||
</MockedProvider>
|
||||
)
|
||||
|
||||
const btn = screen.getByRole('button')
|
||||
expect(btn).toBeInTheDocument()
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Unlabeled')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(btn)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(input).toHaveValue('')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'John Doe' } })
|
||||
fireEvent.keyDown(input, { key: 'Escape', code: 'Escape' })
|
||||
|
||||
expect(screen.queryByText('Unlabeled')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,352 @@
|
||||
import React, { createRef, useEffect, useState } from 'react'
|
||||
import { gql, useMutation, useQuery } from '@apollo/client'
|
||||
import Layout from '../../components/layout/Layout'
|
||||
import styled from 'styled-components'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import SingleFaceGroup from './SingleFaceGroup/SingleFaceGroup'
|
||||
import { Button, TextField } from '../../primitives/form/Input'
|
||||
import FaceCircleImage from './FaceCircleImage'
|
||||
import useScrollPagination from '../../hooks/useScrollPagination'
|
||||
import PaginateLoader from '../../components/PaginateLoader'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
setGroupLabel,
|
||||
setGroupLabelVariables,
|
||||
} from './__generated__/setGroupLabel'
|
||||
import {
|
||||
myFaces,
|
||||
myFacesVariables,
|
||||
myFaces_myFaceGroups,
|
||||
} from './__generated__/myFaces'
|
||||
import { recognizeUnlabeledFaces } from './__generated__/recognizeUnlabeledFaces'
|
||||
import { isNil, tailwindClassNames } from '../../helpers/utils'
|
||||
import classNames from 'classnames'
|
||||
import MergeFaceGroupsModal, {
|
||||
MergeFaceGroupsModalState,
|
||||
} from './SingleFaceGroup/MergeFaceGroupsModal'
|
||||
|
||||
export const MY_FACES_QUERY = gql`
|
||||
query myFaces($limit: Int, $offset: Int) {
|
||||
myFaceGroups(paginate: { limit: $limit, offset: $offset }) {
|
||||
id
|
||||
label
|
||||
imageFaceCount
|
||||
imageFaces(paginate: { limit: 1 }) {
|
||||
id
|
||||
rectangle {
|
||||
minX
|
||||
maxX
|
||||
minY
|
||||
maxY
|
||||
}
|
||||
media {
|
||||
id
|
||||
title
|
||||
thumbnail {
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const SET_GROUP_LABEL_MUTATION = gql`
|
||||
mutation setGroupLabel($groupID: ID!, $label: String) {
|
||||
setFaceGroupLabel(faceGroupID: $groupID, label: $label) {
|
||||
id
|
||||
label
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const RECOGNIZE_UNLABELED_FACES_MUTATION = gql`
|
||||
mutation recognizeUnlabeledFaces {
|
||||
recognizeUnlabeledFaces {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
type FaceDetailsWrapperProps = {
|
||||
labeled: boolean
|
||||
className?: string
|
||||
} & React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLSpanElement>,
|
||||
HTMLSpanElement
|
||||
>
|
||||
|
||||
const FaceDetailsWrapperInner = ({
|
||||
labeled,
|
||||
children,
|
||||
className,
|
||||
...otherProps
|
||||
}: FaceDetailsWrapperProps) => (
|
||||
<span
|
||||
{...otherProps}
|
||||
className={classNames(
|
||||
className,
|
||||
`${labeled ? '' : 'text-gray-400 dark:text-gray-500'}`
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
|
||||
const FaceDetailsWrapper = styled(FaceDetailsWrapperInner)`
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: #2683ca;
|
||||
}
|
||||
`
|
||||
|
||||
type FaceDetailsProps = {
|
||||
group: {
|
||||
__typename: 'FaceGroup'
|
||||
id: string
|
||||
label: string | null
|
||||
imageFaceCount: number
|
||||
}
|
||||
className?: string
|
||||
textFieldClassName?: string
|
||||
editLabel: boolean
|
||||
setEditLabel: React.Dispatch<React.SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
export const FaceDetails = ({
|
||||
group,
|
||||
className,
|
||||
textFieldClassName,
|
||||
editLabel,
|
||||
setEditLabel,
|
||||
}: FaceDetailsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [inputValue, setInputValue] = useState(group.label ?? '')
|
||||
const inputRef = createRef<HTMLInputElement>()
|
||||
|
||||
const [setGroupLabel, { loading }] = useMutation<
|
||||
setGroupLabel,
|
||||
setGroupLabelVariables
|
||||
>(SET_GROUP_LABEL_MUTATION, {
|
||||
variables: {
|
||||
groupID: group.id,
|
||||
},
|
||||
})
|
||||
|
||||
const resetLabel = () => {
|
||||
setInputValue(group.label ?? '')
|
||||
setEditLabel(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [inputRef])
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
resetLabel()
|
||||
}
|
||||
}, [loading])
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key == 'Escape') {
|
||||
resetLabel()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let label
|
||||
if (!editLabel) {
|
||||
label = (
|
||||
<FaceDetailsWrapper
|
||||
className={tailwindClassNames(
|
||||
className,
|
||||
'whitespace-nowrap inline-block overflow-hidden overflow-clip'
|
||||
)}
|
||||
labeled={!!group.label}
|
||||
onClick={() => setEditLabel(true)}
|
||||
>
|
||||
<FaceImagesCount>{group.imageFaceCount}</FaceImagesCount>
|
||||
<button className="">
|
||||
{group.label ?? t('people_page.face_group.unlabeled', 'Unlabeled')}
|
||||
</button>
|
||||
{/* <EditIcon name="pencil" /> */}
|
||||
</FaceDetailsWrapper>
|
||||
)
|
||||
} else {
|
||||
label = (
|
||||
<FaceDetailsWrapper className={className} labeled={!!group.label}>
|
||||
<TextField
|
||||
className={textFieldClassName}
|
||||
loading={loading}
|
||||
ref={inputRef}
|
||||
// size="mini"
|
||||
placeholder={t('people_page.face_group.label_placeholder', 'Label')}
|
||||
// icon="arrow right"
|
||||
value={inputValue}
|
||||
action={() =>
|
||||
setGroupLabel({
|
||||
variables: {
|
||||
groupID: group.id,
|
||||
label: inputValue == '' ? null : inputValue,
|
||||
},
|
||||
})
|
||||
}
|
||||
onKeyDown={onKeyDown}
|
||||
onChange={e => setInputValue(e.target.value)}
|
||||
onBlur={() => {
|
||||
resetLabel()
|
||||
}}
|
||||
/>
|
||||
</FaceDetailsWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
return label
|
||||
}
|
||||
|
||||
const FaceImagesCount = styled.span.attrs({
|
||||
className:
|
||||
'bg-gray-100 text-gray-900 dark:bg-gray-400 dark:text-black text-sm px-1 mr-2 rounded-md',
|
||||
})``
|
||||
|
||||
type FaceGroupProps = {
|
||||
group: myFaces_myFaceGroups
|
||||
}
|
||||
|
||||
export const FaceGroup = ({ group }: FaceGroupProps) => {
|
||||
const previewFace = group.imageFaces[0]
|
||||
const [editLabel, setEditLabel] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="m-3">
|
||||
<Link to={`/people/${group.id}`}>
|
||||
<FaceCircleImage imageFace={previewFace} selectable />
|
||||
</Link>
|
||||
<FaceDetails
|
||||
className="block cursor-pointer text-center w-full mt-3"
|
||||
textFieldClassName="w-[140px]"
|
||||
group={group}
|
||||
editLabel={editLabel}
|
||||
setEditLabel={setEditLabel}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FaceGroupsWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 24px;
|
||||
`
|
||||
|
||||
/**
|
||||
* Renders a page that shows a gallery of all people
|
||||
*/
|
||||
export const PeoplePage = () => {
|
||||
const { t } = useTranslation()
|
||||
const { data, error, loading, fetchMore } = useQuery<
|
||||
myFaces,
|
||||
myFacesVariables
|
||||
>(MY_FACES_QUERY, {
|
||||
variables: {
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const [mergeModalState, setMergeModalState] = useState(
|
||||
MergeFaceGroupsModalState.Closed
|
||||
)
|
||||
|
||||
const [recognizeUnlabeled, { loading: recognizeUnlabeledLoading }] =
|
||||
useMutation<recognizeUnlabeledFaces>(RECOGNIZE_UNLABELED_FACES_MUTATION)
|
||||
|
||||
const { containerElem, finished: finishedLoadingMore } =
|
||||
useScrollPagination<myFaces>({
|
||||
loading,
|
||||
fetchMore,
|
||||
data,
|
||||
getItems: data => data.myFaceGroups,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
return <div>{error.message}</div>
|
||||
}
|
||||
|
||||
let faces: JSX.Element[] | null = null
|
||||
if (data) {
|
||||
faces = data.myFaceGroups.map(faceGroup => (
|
||||
<FaceGroup key={faceGroup.id} group={faceGroup} />
|
||||
))
|
||||
}
|
||||
|
||||
let modals = null
|
||||
modals = (
|
||||
<MergeFaceGroupsModal
|
||||
state={mergeModalState}
|
||||
setState={setMergeModalState}
|
||||
refetchQueries={[
|
||||
{
|
||||
query: MY_FACES_QUERY,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<Layout title={t('title.people', 'People')}>
|
||||
<ul className="flex gap-2 flex-wrap mb-6">
|
||||
<li>
|
||||
<Button
|
||||
aria-label='Recognize unlabled faces'
|
||||
disabled={recognizeUnlabeledLoading}
|
||||
onClick={() => {
|
||||
recognizeUnlabeled()
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
'people_page.recognize_unlabeled_faces_button',
|
||||
'Recognize unlabeled faces'
|
||||
)}
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button
|
||||
aria-label='Merge people'
|
||||
onClick={() => {
|
||||
setMergeModalState(MergeFaceGroupsModalState.SelectDestination)
|
||||
}}
|
||||
>
|
||||
{t('people_page.action_label.merge_people', 'Merge people')}
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<FaceGroupsWrapper ref={containerElem}>{faces}</FaceGroupsWrapper>
|
||||
<PaginateLoader
|
||||
active={!finishedLoadingMore && !loading}
|
||||
text={t('general.loading.paginate.faces', 'Loading more people')}
|
||||
/>
|
||||
{modals}
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a page for an individual person
|
||||
*/
|
||||
export const PersonPage = () => {
|
||||
const { t } = useTranslation()
|
||||
const { person } = useParams()
|
||||
if (isNil(person))
|
||||
throw new Error('Expected `person` parameter to be defined')
|
||||
|
||||
return (
|
||||
<Layout title={t('title.people', 'People')}>
|
||||
<SingleFaceGroup faceGroupID={person} />
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { BaseMutationOptions, gql, useMutation } from '@apollo/client'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { isNil } from '../../../helpers/utils'
|
||||
import Modal from '../../../primitives/Modal'
|
||||
import { MY_FACES_QUERY } from '../PeoplePage'
|
||||
import {
|
||||
myFaces_myFaceGroups,
|
||||
myFaces_myFaceGroups_imageFaces,
|
||||
} from '../__generated__/myFaces'
|
||||
import SelectImageFacesTable from './SelectImageFacesTable'
|
||||
import {
|
||||
detachImageFaces,
|
||||
detachImageFacesVariables,
|
||||
} from './__generated__/detachImageFaces'
|
||||
import {
|
||||
singleFaceGroup_faceGroup,
|
||||
singleFaceGroup_faceGroup_imageFaces,
|
||||
} from './__generated__/singleFaceGroup'
|
||||
|
||||
const DETACH_IMAGE_FACES_MUTATION = gql`
|
||||
mutation detachImageFaces($faceIDs: [ID!]!) {
|
||||
detachImageFaces(imageFaceIDs: $faceIDs) {
|
||||
id
|
||||
label
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const useDetachImageFaces = (
|
||||
mutationOptions: BaseMutationOptions<
|
||||
detachImageFaces,
|
||||
detachImageFacesVariables
|
||||
>
|
||||
) => {
|
||||
const [detachImageFacesMutation] = useMutation<
|
||||
detachImageFaces,
|
||||
detachImageFacesVariables
|
||||
>(DETACH_IMAGE_FACES_MUTATION, mutationOptions)
|
||||
|
||||
return async (
|
||||
selectedImageFaces: (
|
||||
| myFaces_myFaceGroups_imageFaces
|
||||
| singleFaceGroup_faceGroup_imageFaces
|
||||
)[]
|
||||
) => {
|
||||
const faceIDs = selectedImageFaces.map(face => face.id)
|
||||
|
||||
const result = await detachImageFacesMutation({
|
||||
variables: {
|
||||
faceIDs,
|
||||
},
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
type DetachImageFacesModalProps = {
|
||||
open: boolean
|
||||
setOpen(open: boolean): void
|
||||
faceGroup: myFaces_myFaceGroups | singleFaceGroup_faceGroup
|
||||
selectedImageFaces?: (
|
||||
| myFaces_myFaceGroups_imageFaces
|
||||
| singleFaceGroup_faceGroup_imageFaces
|
||||
)[]
|
||||
}
|
||||
|
||||
const DetachImageFacesModal = ({
|
||||
open,
|
||||
setOpen,
|
||||
faceGroup,
|
||||
selectedImageFaces: selectedImageFacesProp,
|
||||
}: DetachImageFacesModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [selectedImageFaces, setSelectedImageFaces] = useState<
|
||||
(myFaces_myFaceGroups_imageFaces | singleFaceGroup_faceGroup_imageFaces)[]
|
||||
>([])
|
||||
const navigate = useNavigate()
|
||||
|
||||
const detachImageFacesMutation = useDetachImageFaces({
|
||||
refetchQueries: [
|
||||
{
|
||||
query: MY_FACES_QUERY,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const detachImageFaces = () => {
|
||||
detachImageFacesMutation(selectedImageFaces).then(({ data }) => {
|
||||
if (isNil(data)) throw new Error('Expected data not to be null')
|
||||
setOpen(false)
|
||||
navigate(`/people/${data.detachImageFaces.id}`)
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isNil(selectedImageFacesProp)) return
|
||||
setSelectedImageFaces(selectedImageFacesProp)
|
||||
}, [selectedImageFacesProp])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSelectedImageFaces([])
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (open == false) return null
|
||||
|
||||
const imageFaces = faceGroup?.imageFaces ?? []
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t(
|
||||
'people_page.modal.detach_image_faces.title',
|
||||
'Detach Image Faces'
|
||||
)}
|
||||
description={t(
|
||||
'people_page.modal.detach_image_faces.description',
|
||||
'Detach selected images of this face group and move them to a new face groups'
|
||||
)}
|
||||
actions={[
|
||||
{
|
||||
key: 'cancel',
|
||||
label: t('general.action.cancel', 'Cancel'),
|
||||
onClick: () => setOpen(false),
|
||||
},
|
||||
{
|
||||
key: 'detach',
|
||||
label: t(
|
||||
'people_page.modal.detach_image_faces.action.detach',
|
||||
'Detach image faces'
|
||||
),
|
||||
variant: 'positive',
|
||||
onClick: () => detachImageFaces(),
|
||||
},
|
||||
]}
|
||||
onClose={() => setOpen(false)}
|
||||
open={open}
|
||||
>
|
||||
<SelectImageFacesTable
|
||||
imageFaces={imageFaces}
|
||||
selectedImageFaces={selectedImageFaces}
|
||||
setSelectedImageFaces={setSelectedImageFaces}
|
||||
title={t(
|
||||
'people_page.modal.detach_image_faces.action.select_images',
|
||||
'Select images to detach'
|
||||
)}
|
||||
/>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default DetachImageFacesModal
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useMutation } from '@apollo/client'
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
createRef,
|
||||
KeyboardEventHandler,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { isNil } from '../../../helpers/utils'
|
||||
import { Button, TextField } from '../../../primitives/form/Input'
|
||||
import { MY_FACES_QUERY, SET_GROUP_LABEL_MUTATION } from '../PeoplePage'
|
||||
import {
|
||||
setGroupLabel,
|
||||
setGroupLabelVariables,
|
||||
} from '../__generated__/setGroupLabel'
|
||||
import DetachImageFacesModal from './DetachImageFacesModal'
|
||||
import MergeFaceGroupsModal, {
|
||||
MergeFaceGroupsModalState,
|
||||
} from './MergeFaceGroupsModal'
|
||||
import MoveImageFacesModal from './MoveImageFacesModal'
|
||||
import { singleFaceGroup_faceGroup } from './__generated__/singleFaceGroup'
|
||||
|
||||
type FaceGroupTitleProps = {
|
||||
faceGroup?: singleFaceGroup_faceGroup
|
||||
}
|
||||
|
||||
const FaceGroupTitle = ({ faceGroup }: FaceGroupTitleProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [editLabel, setEditLabel] = useState(false)
|
||||
const [inputValue, setInputValue] = useState(faceGroup?.label ?? '')
|
||||
const inputRef = createRef<HTMLInputElement>()
|
||||
const [mergeModalState, setMergeModalState] = useState(
|
||||
MergeFaceGroupsModalState.Closed
|
||||
)
|
||||
const [moveModalOpen, setMoveModalOpen] = useState(false)
|
||||
const [detachModalOpen, setDetachModalOpen] = useState(false)
|
||||
|
||||
const [setGroupLabel, { loading: setLabelLoading }] = useMutation<
|
||||
setGroupLabel,
|
||||
setGroupLabelVariables
|
||||
>(SET_GROUP_LABEL_MUTATION)
|
||||
|
||||
const resetLabel = () => {
|
||||
setInputValue(faceGroup?.label ?? '')
|
||||
setEditLabel(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [inputRef])
|
||||
|
||||
useEffect(() => {
|
||||
if (!setLabelLoading) {
|
||||
resetLabel()
|
||||
}
|
||||
}, [setLabelLoading])
|
||||
|
||||
const onKeyDown: KeyboardEventHandler<HTMLInputElement> = e => {
|
||||
if (e.key == 'Escape') {
|
||||
resetLabel()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let title
|
||||
if (!editLabel) {
|
||||
title = (
|
||||
<>
|
||||
<h1
|
||||
className={`text-2xl font-semibold ${
|
||||
faceGroup?.label ? '' : 'text-gray-600 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{faceGroup?.label ??
|
||||
t('people_page.face_group.unlabeled_person', 'Unlabeled person')}
|
||||
</h1>
|
||||
</>
|
||||
)
|
||||
} else {
|
||||
title = (
|
||||
<>
|
||||
<TextField
|
||||
loading={setLabelLoading}
|
||||
ref={inputRef}
|
||||
placeholder={t('people_page.face_group.label_placeholder', 'Label')}
|
||||
action={() => {
|
||||
if (isNil(faceGroup))
|
||||
throw new Error('Expected faceGroup to be defined')
|
||||
|
||||
setGroupLabel({
|
||||
variables: {
|
||||
groupID: faceGroup.id,
|
||||
label: inputValue ? inputValue : null,
|
||||
},
|
||||
})
|
||||
}}
|
||||
value={inputValue}
|
||||
onKeyDown={onKeyDown}
|
||||
onChange={e => setInputValue(e.target.value)}
|
||||
onBlur={() => {
|
||||
resetLabel()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
let modals = null
|
||||
if (faceGroup) {
|
||||
modals = (
|
||||
<>
|
||||
<MergeFaceGroupsModal
|
||||
state={mergeModalState}
|
||||
setState={setMergeModalState}
|
||||
preselectedDestinationFaceGroup={faceGroup}
|
||||
refetchQueries={[
|
||||
{
|
||||
query: MY_FACES_QUERY,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<MoveImageFacesModal
|
||||
open={moveModalOpen}
|
||||
setOpen={setMoveModalOpen}
|
||||
faceGroup={faceGroup}
|
||||
/>
|
||||
<DetachImageFacesModal
|
||||
open={detachModalOpen}
|
||||
setOpen={setDetachModalOpen}
|
||||
faceGroup={faceGroup}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="mb-2">{title}</div>
|
||||
<ul className="flex gap-2 flex-wrap mb-6">
|
||||
<li>
|
||||
<Button onClick={() => setEditLabel(true)}>
|
||||
{t('people_page.action_label.change_label', 'Change label')}
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button
|
||||
onClick={() =>
|
||||
setMergeModalState(MergeFaceGroupsModalState.SelectDestination)
|
||||
}
|
||||
>
|
||||
{t('people_page.action_label.merge_face', 'Merge face')}
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button onClick={() => setDetachModalOpen(true)}>
|
||||
{t('people_page.action_label.detach_images', 'Detach images')}
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button onClick={() => setMoveModalOpen(true)}>
|
||||
{t('people_page.action_label.move_faces', 'Move faces')}
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{modals}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FaceGroupTitle
|
||||
@@ -0,0 +1,144 @@
|
||||
import { vi } from 'vitest'
|
||||
import { render, fireEvent, waitFor } from '@testing-library/react'
|
||||
import MergeFaceGroupsModal, { MergeFaceGroupsModalState } from './MergeFaceGroupsModal'
|
||||
import { MockedProvider } from '@apollo/client/testing'
|
||||
import { MY_FACES_QUERY } from '../PeoplePage'
|
||||
import { COMBINE_FACES_MUTATION } from './MergeFaceGroupsModal'
|
||||
|
||||
// Mock useNavigate before any imports that use it
|
||||
const navigate = vi.fn()
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual: object = await vi.importActual('react-router-dom')
|
||||
return { ...actual, useNavigate: () => navigate }
|
||||
})
|
||||
|
||||
// Mock IntersectionObserver for tests
|
||||
beforeAll(() => {
|
||||
global.IntersectionObserver = class {
|
||||
constructor() {}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as any
|
||||
})
|
||||
|
||||
// Helper function to convert face group ID to test ID
|
||||
function idToTestId(id: string): string {
|
||||
return `facegroup-${id}`
|
||||
}
|
||||
|
||||
// Mock SelectFaceGroupTable to simplify selection logic
|
||||
vi.mock('./SelectFaceGroupTable', () => ({
|
||||
__esModule: true,
|
||||
default: ({ faceGroups, selectedFaceGroups, toggleSelectedFaceGroup, title }: any) => (
|
||||
<div>
|
||||
<div>{title}</div>
|
||||
{faceGroups.map((fg: any) => (
|
||||
<button
|
||||
key={fg.id}
|
||||
data-testid={idToTestId(fg.id)}
|
||||
onClick={() => toggleSelectedFaceGroup(fg)}
|
||||
style={{ fontWeight: selectedFaceGroups.has(fg) ? 'bold' : 'normal' }}
|
||||
>
|
||||
{fg.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const mockFaceGroups = ["Alice", "Bob", "Charlie", "David", "Felix"].map((name, index) => { return { __typename: 'FaceGroup', id: index.toString(), label: name, imageFaceCount: 0, imageFaces: [] }})
|
||||
|
||||
const myFacesMock = {
|
||||
request: { query: MY_FACES_QUERY },
|
||||
result: { data: { myFaceGroups: mockFaceGroups } },
|
||||
}
|
||||
|
||||
function getCombineFacesMock(destinationID: string, sourceIDs: string[]) {
|
||||
return {
|
||||
request: {
|
||||
query: COMBINE_FACES_MUTATION,
|
||||
variables: {
|
||||
destID: destinationID,
|
||||
srcIDs: sourceIDs,
|
||||
},
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
combineFaceGroups: {
|
||||
id: destinationID,
|
||||
__typename: 'FaceGroup',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Tests the merging of source face groups into a destination face group
|
||||
// Logic extracted to be used in several tests
|
||||
async function testMerge(destinationID: string, sourceIDs: string[]) {
|
||||
const setState = vi.fn()
|
||||
|
||||
const destinationTestID: string = idToTestId(destinationID)
|
||||
const sourceTestIDs: string[] = sourceIDs.map(idToTestId)
|
||||
|
||||
const combineFacesMock = getCombineFacesMock(destinationID, sourceIDs)
|
||||
|
||||
// Render modal in SelectDestination state
|
||||
const { getByText, getByTestId, getByRole, rerender } = render(
|
||||
<MockedProvider mocks={[myFacesMock, combineFacesMock]} addTypename={false}>
|
||||
<MergeFaceGroupsModal
|
||||
state={MergeFaceGroupsModalState.SelectDestination}
|
||||
setState={setState}
|
||||
refetchQueries={[]}
|
||||
/>
|
||||
</MockedProvider>
|
||||
)
|
||||
|
||||
// Wait for face groups to load and select destination
|
||||
await waitFor(() => getByTestId(destinationTestID))
|
||||
fireEvent.click(getByTestId(destinationTestID))
|
||||
|
||||
// Click "Next" to go to SelectSources
|
||||
fireEvent.click(getByText(/Next/i))
|
||||
expect(setState).toHaveBeenCalledWith(MergeFaceGroupsModalState.SelectSources)
|
||||
|
||||
// Rerender in SelectSources state
|
||||
rerender(
|
||||
<MockedProvider mocks={[myFacesMock, combineFacesMock]} addTypename={false}>
|
||||
<MergeFaceGroupsModal
|
||||
state={MergeFaceGroupsModalState.SelectSources}
|
||||
setState={setState}
|
||||
refetchQueries={[]}
|
||||
/>
|
||||
</MockedProvider>
|
||||
)
|
||||
|
||||
// Wait for source face groups to load
|
||||
await waitFor(() => getByTestId(sourceTestIDs[0]))
|
||||
|
||||
// Select multiple source face groups
|
||||
for (const testID of sourceTestIDs)
|
||||
fireEvent.click(getByTestId(testID))
|
||||
|
||||
// Click the Merge button
|
||||
fireEvent.click(getByRole('button', { name: /Merge/i }))
|
||||
|
||||
// Check that the modal closes and redirects to the destination face group
|
||||
await waitFor(() => {
|
||||
expect(setState).toHaveBeenCalledWith(MergeFaceGroupsModalState.Closed)
|
||||
expect(navigate).toHaveBeenCalledWith(`/people/${destinationID}`)
|
||||
})
|
||||
}
|
||||
|
||||
test('merges a single source face group into a destination face group', async () => {
|
||||
testMerge(mockFaceGroups[0].id, [mockFaceGroups[1].id])
|
||||
})
|
||||
|
||||
test('merges all source face groups into a destination face group', async () => {
|
||||
testMerge(mockFaceGroups[0].id, mockFaceGroups.slice(1).map(fg => fg.id))
|
||||
})
|
||||
|
||||
test('merges multiple source face groups into a destination face group', async () => {
|
||||
testMerge(mockFaceGroups[0].id, [mockFaceGroups[1], mockFaceGroups[2]].map(fg => fg.id))
|
||||
})
|
||||
@@ -0,0 +1,262 @@
|
||||
import { gql, PureQueryOptions, useMutation, useQuery } from '@apollo/client'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { isNil } from '../../../helpers/utils'
|
||||
import Modal, { ModalAction, ModalProps } from '../../../primitives/Modal'
|
||||
import { MY_FACES_QUERY } from '../PeoplePage'
|
||||
import {
|
||||
myFaces,
|
||||
myFacesVariables,
|
||||
myFaces_myFaceGroups,
|
||||
} from '../__generated__/myFaces'
|
||||
import SelectFaceGroupTable from './SelectFaceGroupTable'
|
||||
import {
|
||||
combineFaces,
|
||||
combineFacesVariables,
|
||||
} from './__generated__/combineFaces'
|
||||
import { singleFaceGroup_faceGroup } from './__generated__/singleFaceGroup'
|
||||
|
||||
export const COMBINE_FACES_MUTATION = gql`
|
||||
mutation combineFaces($destID: ID!, $srcIDs: [ID!]!) {
|
||||
combineFaceGroups(
|
||||
destinationFaceGroupID: $destID
|
||||
sourceFaceGroupIDs: $srcIDs
|
||||
) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export enum MergeFaceGroupsModalState {
|
||||
Closed = 'closed',
|
||||
SelectDestination = 'select_destination',
|
||||
SelectSources = 'select_sources',
|
||||
}
|
||||
|
||||
type MergeFaceGroupsModalProps = {
|
||||
state: MergeFaceGroupsModalState
|
||||
setState(state: MergeFaceGroupsModalState): void
|
||||
preselectedDestinationFaceGroup?: {
|
||||
__typename: 'FaceGroup'
|
||||
id: string
|
||||
}
|
||||
refetchQueries: PureQueryOptions[]
|
||||
}
|
||||
|
||||
type StateContent = {
|
||||
props: ModalProps
|
||||
searchTitle: string
|
||||
}
|
||||
|
||||
const MergeFaceGroupsModal = ({
|
||||
state,
|
||||
setState,
|
||||
preselectedDestinationFaceGroup,
|
||||
refetchQueries,
|
||||
}: MergeFaceGroupsModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const navigate = useNavigate()
|
||||
const { data } = useQuery<myFaces, myFacesVariables>(MY_FACES_QUERY)
|
||||
const [combineFacesMutation] = useMutation<
|
||||
combineFaces,
|
||||
combineFacesVariables
|
||||
>(COMBINE_FACES_MUTATION, {
|
||||
refetchQueries: refetchQueries,
|
||||
})
|
||||
|
||||
// The destination face group
|
||||
const [selectedDestinationFaceGroup, setSelectedDestinationFaceGroup] =
|
||||
useState<myFaces_myFaceGroups | singleFaceGroup_faceGroup | null>(null)
|
||||
|
||||
// The set of currently selected face groups, on the modal page
|
||||
const [selectedFaceGroups, setSelectedFaceGroups] = useState<
|
||||
Set<myFaces_myFaceGroups | singleFaceGroup_faceGroup | null>
|
||||
>(new Set())
|
||||
|
||||
const addSelectedFaceGroup = (
|
||||
faceGroup: myFaces_myFaceGroups | singleFaceGroup_faceGroup | null
|
||||
) => setSelectedFaceGroups(prev => new Set(prev).add(faceGroup))
|
||||
const removeSelectedFaceGroup = (
|
||||
faceGroup: myFaces_myFaceGroups | singleFaceGroup_faceGroup | null
|
||||
) => {
|
||||
setSelectedFaceGroups(prev => {
|
||||
const s = new Set(prev)
|
||||
s.delete(faceGroup)
|
||||
return s
|
||||
})
|
||||
}
|
||||
|
||||
const setDestinationFaceGroup = (
|
||||
faceGroup: myFaces_myFaceGroups | singleFaceGroup_faceGroup | null
|
||||
) => {
|
||||
if (isNil(faceGroup)) {
|
||||
setSelectedFaceGroups(new Set())
|
||||
setSelectedDestinationFaceGroup(null)
|
||||
return
|
||||
}
|
||||
|
||||
// Overwrite the selected face groups with a set containing only the selected group
|
||||
setSelectedFaceGroups(
|
||||
new Set<myFaces_myFaceGroups | singleFaceGroup_faceGroup | null>().add(
|
||||
faceGroup
|
||||
)
|
||||
)
|
||||
|
||||
setSelectedDestinationFaceGroup(faceGroup)
|
||||
}
|
||||
|
||||
// Go straight to the sources page if a destination face group is preselected, using the preselection as the destination
|
||||
useEffect(() => {
|
||||
if (isNil(preselectedDestinationFaceGroup)) return
|
||||
if (state != MergeFaceGroupsModalState.SelectDestination) return
|
||||
|
||||
const destinationFaceGroup = data?.myFaceGroups.find(
|
||||
x => x.id == preselectedDestinationFaceGroup?.id
|
||||
)
|
||||
if (isNil(destinationFaceGroup)) return
|
||||
|
||||
setDestinationFaceGroup(destinationFaceGroup)
|
||||
}, [state, preselectedDestinationFaceGroup])
|
||||
|
||||
function handleFaceGroupToggled(newValue: myFaces_myFaceGroups | singleFaceGroup_faceGroup) {
|
||||
switch (state) {
|
||||
case MergeFaceGroupsModalState.SelectDestination:
|
||||
setSelectedDestinationFaceGroup(newValue)
|
||||
setSelectedFaceGroups(new Set([newValue]))
|
||||
break
|
||||
case MergeFaceGroupsModalState.SelectSources:
|
||||
if (selectedFaceGroups.has(newValue))
|
||||
removeSelectedFaceGroup(newValue)
|
||||
else addSelectedFaceGroup(newValue)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Show all face groups on the destination page, but filter out the destination group on the source page
|
||||
const filteredFaceGroups =
|
||||
data?.myFaceGroups.filter(
|
||||
x =>
|
||||
state === MergeFaceGroupsModalState.SelectDestination ||
|
||||
x.id != selectedDestinationFaceGroup?.id
|
||||
) ?? []
|
||||
|
||||
const goNext = () => {
|
||||
if (isNil(selectedDestinationFaceGroup))
|
||||
throw new Error('No selected face group')
|
||||
|
||||
setState(MergeFaceGroupsModalState.SelectSources)
|
||||
setSelectedFaceGroups(new Set())
|
||||
}
|
||||
|
||||
const mergeFaceGroups = () => {
|
||||
if (isNil(selectedDestinationFaceGroup))
|
||||
throw new Error('No selected destination face group')
|
||||
|
||||
const sourceGroupIDs: string[] = [...selectedFaceGroups].filter(fc => fc !== null).map(fc => fc.id)
|
||||
|
||||
if(sourceGroupIDs.length < 1)
|
||||
throw new Error('No selected source face groups')
|
||||
|
||||
combineFacesMutation({
|
||||
variables: {
|
||||
srcIDs: sourceGroupIDs,
|
||||
destID: selectedDestinationFaceGroup.id,
|
||||
},
|
||||
}).then(() => {
|
||||
setState(MergeFaceGroupsModalState.Closed)
|
||||
navigate(`/people/${selectedDestinationFaceGroup.id}`)
|
||||
})
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
setState(MergeFaceGroupsModalState.Closed)
|
||||
}
|
||||
|
||||
const isOpen: boolean = state !== MergeFaceGroupsModalState.Closed
|
||||
|
||||
const cancelAction: ModalAction = {
|
||||
key: 'cancel',
|
||||
label: t('general.action.cancel', 'Cancel'),
|
||||
onClick: () => setState(MergeFaceGroupsModalState.Closed),
|
||||
}
|
||||
|
||||
const nextAction: ModalAction = {
|
||||
key: 'next',
|
||||
label: t('people_page.modal.action.next', 'Next'),
|
||||
onClick: () => goNext(),
|
||||
variant: 'positive',
|
||||
}
|
||||
|
||||
const mergeAction: ModalAction = {
|
||||
key: 'merge',
|
||||
label: t('people_page.modal.action.merge', 'Merge'),
|
||||
onClick: () => mergeFaceGroups(),
|
||||
variant: 'positive',
|
||||
}
|
||||
|
||||
const modalTitle: string = t(
|
||||
'people_page.modal.merge_face_groups.title',
|
||||
'Merge Face Groups'
|
||||
)
|
||||
|
||||
const selectDestinationProps: StateContent = {
|
||||
props: {
|
||||
title: modalTitle,
|
||||
description: t(
|
||||
'people_page.modal.merge_face_groups.destination_description',
|
||||
'Select the face group that other groups should be merged into.'
|
||||
),
|
||||
actions: [cancelAction, nextAction],
|
||||
onClose: closeModal,
|
||||
open: isOpen,
|
||||
},
|
||||
searchTitle: t(
|
||||
'people_page.modal.merge_face_groups.destination_table.title',
|
||||
'Select the destination face'
|
||||
),
|
||||
}
|
||||
|
||||
const selectSourcesProps: StateContent = {
|
||||
props: {
|
||||
title: modalTitle,
|
||||
description: t(
|
||||
'people_page.modal.merge_face_groups.sources_description',
|
||||
'Select all face groups that will be merged into the destination group.'
|
||||
),
|
||||
actions: [cancelAction, mergeAction],
|
||||
onClose: closeModal,
|
||||
open: isOpen,
|
||||
},
|
||||
searchTitle:
|
||||
t(
|
||||
'people_page.modal.merge_face_groups.sources_table.title',
|
||||
'Select one or more source faces to merge into:'
|
||||
) +
|
||||
` ${
|
||||
selectedDestinationFaceGroup?.label ??
|
||||
t('people_page.face_group.unlabeled', 'Unlabeled') ??
|
||||
'Unlabeled'
|
||||
}`,
|
||||
}
|
||||
|
||||
const modalContent: StateContent =
|
||||
state === MergeFaceGroupsModalState.SelectDestination
|
||||
? selectDestinationProps
|
||||
: selectSourcesProps
|
||||
|
||||
return (
|
||||
<Modal {...modalContent.props}>
|
||||
<SelectFaceGroupTable
|
||||
title={modalContent.searchTitle}
|
||||
frozen={state === MergeFaceGroupsModalState.SelectDestination && preselectedDestinationFaceGroup !== undefined}
|
||||
faceGroups={filteredFaceGroups}
|
||||
selectedFaceGroups={selectedFaceGroups}
|
||||
toggleSelectedFaceGroup={(face) => { if(face === null) return; handleFaceGroupToggled(face) }}
|
||||
/>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default MergeFaceGroupsModal
|
||||
@@ -0,0 +1,204 @@
|
||||
import { gql, useLazyQuery, useMutation } from '@apollo/client'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import SelectFaceGroupTable from './SelectFaceGroupTable'
|
||||
import SelectImageFacesTable from './SelectImageFacesTable'
|
||||
import { MY_FACES_QUERY } from '../PeoplePage'
|
||||
import {
|
||||
singleFaceGroup_faceGroup,
|
||||
singleFaceGroup_faceGroup_imageFaces,
|
||||
} from './__generated__/singleFaceGroup'
|
||||
import {
|
||||
myFaces,
|
||||
myFacesVariables,
|
||||
myFaces_myFaceGroups,
|
||||
myFaces_myFaceGroups_imageFaces,
|
||||
} from '../__generated__/myFaces'
|
||||
import { isNil } from '../../../helpers/utils'
|
||||
import {
|
||||
moveImageFaces,
|
||||
moveImageFacesVariables,
|
||||
} from './__generated__/moveImageFaces'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Modal, { ModalAction } from '../../../primitives/Modal'
|
||||
|
||||
const MOVE_IMAGE_FACES_MUTATION = gql`
|
||||
mutation moveImageFaces($faceIDs: [ID!]!, $destFaceGroupID: ID!) {
|
||||
moveImageFaces(
|
||||
imageFaceIDs: $faceIDs
|
||||
destinationFaceGroupID: $destFaceGroupID
|
||||
) {
|
||||
id
|
||||
imageFaces {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
type MoveImageFacesModalProps = {
|
||||
open: boolean
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
faceGroup: singleFaceGroup_faceGroup
|
||||
preselectedImageFaces?: (
|
||||
| singleFaceGroup_faceGroup_imageFaces
|
||||
| myFaces_myFaceGroups_imageFaces
|
||||
)[]
|
||||
}
|
||||
|
||||
const MoveImageFacesModal = ({
|
||||
open,
|
||||
setOpen,
|
||||
faceGroup,
|
||||
preselectedImageFaces,
|
||||
}: MoveImageFacesModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [selectedImageFaces, setSelectedImageFaces] = useState<
|
||||
(singleFaceGroup_faceGroup_imageFaces | myFaces_myFaceGroups_imageFaces)[]
|
||||
>([])
|
||||
const [selectedFaceGroup, setSelectedFaceGroup] = useState<
|
||||
myFaces_myFaceGroups | singleFaceGroup_faceGroup | null
|
||||
>(null)
|
||||
const [imagesSelected, setImagesSelected] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [moveImageFacesMutation] = useMutation<
|
||||
moveImageFaces,
|
||||
moveImageFacesVariables
|
||||
>(MOVE_IMAGE_FACES_MUTATION, {
|
||||
refetchQueries: [
|
||||
{
|
||||
query: MY_FACES_QUERY,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const [loadFaceGroups, { data: faceGroupsData }] = useLazyQuery<
|
||||
myFaces,
|
||||
myFacesVariables
|
||||
>(MY_FACES_QUERY)
|
||||
|
||||
useEffect(() => {
|
||||
if (isNil(preselectedImageFaces)) return
|
||||
setSelectedImageFaces(preselectedImageFaces)
|
||||
setImagesSelected(true)
|
||||
}, [preselectedImageFaces])
|
||||
|
||||
useEffect(() => {
|
||||
if (imagesSelected) {
|
||||
loadFaceGroups()
|
||||
}
|
||||
}, [imagesSelected])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setImagesSelected(false)
|
||||
setSelectedImageFaces([])
|
||||
setSelectedFaceGroup(null)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (open == false) return null
|
||||
|
||||
const moveImageFaces = () => {
|
||||
const faceIDs = selectedImageFaces.map(face => face.id)
|
||||
|
||||
if (isNil(selectedFaceGroup)) {
|
||||
throw new Error('Expected selectedFaceGroup not to be null')
|
||||
}
|
||||
|
||||
moveImageFacesMutation({
|
||||
variables: {
|
||||
faceIDs,
|
||||
destFaceGroupID: selectedFaceGroup.id,
|
||||
},
|
||||
}).then(() => {
|
||||
setOpen(false)
|
||||
navigate(`/people/${selectedFaceGroup.id}`)
|
||||
})
|
||||
}
|
||||
|
||||
const imageFaces = faceGroup.imageFaces
|
||||
|
||||
let table = null
|
||||
if (!imagesSelected) {
|
||||
table = (
|
||||
<SelectImageFacesTable
|
||||
imageFaces={imageFaces}
|
||||
selectedImageFaces={selectedImageFaces}
|
||||
setSelectedImageFaces={setSelectedImageFaces}
|
||||
title={t(
|
||||
'people_page.modal.move_image_faces.image_select_table.title',
|
||||
'Select images to move'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
if (faceGroupsData && faceGroup) {
|
||||
const filteredFaceGroups = faceGroupsData.myFaceGroups.filter(
|
||||
x => x.id != faceGroup.id
|
||||
)
|
||||
table = (
|
||||
<SelectFaceGroupTable
|
||||
title={t(
|
||||
'people_page.modal.move_image_faces.destination_face_group_table.title',
|
||||
'Select destination face group'
|
||||
)}
|
||||
faceGroups={filteredFaceGroups}
|
||||
selectedFaceGroup={selectedFaceGroup}
|
||||
setSelectedFaceGroup={setSelectedFaceGroup}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
table = <div>{t('general.loading.default', 'Loading...')}</div>
|
||||
}
|
||||
}
|
||||
|
||||
let positiveButton: ModalAction
|
||||
if (!imagesSelected) {
|
||||
positiveButton = {
|
||||
key: 'next',
|
||||
label: t(
|
||||
'people_page.modal.move_image_faces.image_select_table.next_action',
|
||||
'Next'
|
||||
),
|
||||
onClick: () => setImagesSelected(true),
|
||||
variant: 'positive',
|
||||
}
|
||||
} else {
|
||||
positiveButton = {
|
||||
key: 'move',
|
||||
label: t(
|
||||
'people_page.modal.move_image_faces.destination_face_group_table.move_action',
|
||||
'Move image faces'
|
||||
),
|
||||
onClick: () => moveImageFaces(),
|
||||
variant: 'positive',
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('people_page.modal.move_image_faces.title', 'Move Image Faces')}
|
||||
description={t(
|
||||
'people_page.modal.move_image_faces.description',
|
||||
'Move selected images of this face group to another face group'
|
||||
)}
|
||||
onClose={() => setOpen(false)}
|
||||
open={open}
|
||||
actions={[
|
||||
{
|
||||
key: 'cancel',
|
||||
label: t('general.action.cancel', 'Cancel'),
|
||||
onClick: () => setOpen(false),
|
||||
},
|
||||
positiveButton,
|
||||
]}
|
||||
>
|
||||
{table}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default MoveImageFacesModal
|
||||
@@ -0,0 +1,142 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
import { TextField } from '../../../primitives/form/Input'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from '../../../primitives/Table'
|
||||
import FaceCircleImage from '../FaceCircleImage'
|
||||
import { myFaces_myFaceGroups } from '../__generated__/myFaces'
|
||||
import { singleFaceGroup_faceGroup } from './__generated__/singleFaceGroup'
|
||||
|
||||
const FaceCircleWrapper = styled.div<{ $selected: boolean }>`
|
||||
display: inline-block;
|
||||
border-radius: 50%;
|
||||
border: 2px solid
|
||||
${({ $selected }) => ($selected ? `#2185c9` : 'rgba(255,255,255,0)')};
|
||||
`
|
||||
|
||||
const FlexCell = styled(TableCell)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`
|
||||
|
||||
export const RowLabel = styled.span<{ $selected: boolean }>`
|
||||
${({ $selected }) => $selected && `font-weight: bold;`}
|
||||
margin-left: 12px;
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
type FaceGroupRowProps = {
|
||||
faceGroup: myFaces_myFaceGroups
|
||||
faceSelected: boolean
|
||||
selectable: boolean
|
||||
toggleFaceSelected(): void
|
||||
}
|
||||
|
||||
const FaceGroupRow = ({
|
||||
faceGroup,
|
||||
faceSelected,
|
||||
selectable,
|
||||
toggleFaceSelected,
|
||||
}: FaceGroupRowProps) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<TableRow key={faceGroup.id} onClick={toggleFaceSelected} className={selectable ? 'cursor-pointer' : 'cursor-not-allowed'}>
|
||||
<FlexCell className={faceSelected ? 'brightness-110' : ''}>
|
||||
<FaceCircleWrapper $selected={faceSelected}>
|
||||
<FaceCircleImage
|
||||
imageFace={faceGroup.imageFaces[0]}
|
||||
size="50px"
|
||||
selectable={false}
|
||||
/>
|
||||
</FaceCircleWrapper>
|
||||
<span
|
||||
className={`ml-3 ${
|
||||
faceSelected ? 'font-semibold text-slate-100' : 'text-gray-400'
|
||||
} ${!faceSelected && !faceGroup.label ? 'text-gray-600 italic' : ''}`}
|
||||
>
|
||||
{faceGroup.label ??
|
||||
t('people_page.face_group.unlabeled', 'Unlabeled')}
|
||||
</span>
|
||||
</FlexCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
type SelectFaceGroupTableProps = {
|
||||
faceGroups: myFaces_myFaceGroups[]
|
||||
selectedFaceGroups: Set<
|
||||
singleFaceGroup_faceGroup | myFaces_myFaceGroups | null
|
||||
>
|
||||
toggleSelectedFaceGroup: React.Dispatch<
|
||||
singleFaceGroup_faceGroup | myFaces_myFaceGroups | null
|
||||
>
|
||||
title: string,
|
||||
frozen: boolean
|
||||
}
|
||||
|
||||
const SelectFaceGroupTable = ({
|
||||
faceGroups,
|
||||
selectedFaceGroups,
|
||||
toggleSelectedFaceGroup,
|
||||
title,
|
||||
frozen
|
||||
}: SelectFaceGroupTableProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
|
||||
const rows = faceGroups
|
||||
.filter(
|
||||
x =>
|
||||
searchValue == '' ||
|
||||
(x.label && x.label.toLowerCase().includes(searchValue.toLowerCase()))
|
||||
)
|
||||
.map(face => (
|
||||
<FaceGroupRow
|
||||
key={face.id}
|
||||
faceGroup={face}
|
||||
faceSelected={[...selectedFaceGroups].some(val => val?.id === face.id)}
|
||||
selectable={!frozen}
|
||||
toggleFaceSelected={() => !frozen && toggleSelectedFaceGroup(face)}
|
||||
/>
|
||||
))
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table className="w-full">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>{title}</TableHeaderCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHeaderCell>
|
||||
<TextField
|
||||
fullWidth
|
||||
value={searchValue}
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
placeholder={t(
|
||||
'people_page.tableselect_face_group.search_faces_placeholder',
|
||||
'Search faces...'
|
||||
)}
|
||||
/>
|
||||
</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
</Table>
|
||||
<div className="overflow-auto max-h-[500px] mt-2">
|
||||
<Table className="w-full">
|
||||
<TableBody>{rows}</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SelectFaceGroupTable
|
||||
@@ -0,0 +1,147 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
import { ProtectedImage } from '../../../components/photoGallery/ProtectedMedia'
|
||||
import Checkbox from '../../../primitives/form/Checkbox'
|
||||
import { TextField } from '../../../primitives/form/Input'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from '../../../primitives/Table'
|
||||
import { myFaces_myFaceGroups_imageFaces } from '../__generated__/myFaces'
|
||||
import { singleFaceGroup_faceGroup_imageFaces } from './__generated__/singleFaceGroup'
|
||||
|
||||
const SelectImagePreview = styled(ProtectedImage)`
|
||||
max-width: 120px;
|
||||
max-height: 80px;
|
||||
`
|
||||
|
||||
type ImageFaceRowProps = {
|
||||
imageFace:
|
||||
| myFaces_myFaceGroups_imageFaces
|
||||
| singleFaceGroup_faceGroup_imageFaces
|
||||
faceSelected: boolean
|
||||
setFaceSelected(): void
|
||||
}
|
||||
|
||||
const ImageFaceRow = ({
|
||||
imageFace,
|
||||
faceSelected,
|
||||
setFaceSelected,
|
||||
}: ImageFaceRowProps) => {
|
||||
return (
|
||||
<TableRow key={imageFace.id}>
|
||||
<TableCell>
|
||||
<SelectImagePreview
|
||||
src={imageFace.media.thumbnail?.url}
|
||||
onClick={setFaceSelected}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="min-w-64 w-full">
|
||||
<Checkbox
|
||||
label={imageFace.media.title}
|
||||
checked={faceSelected}
|
||||
onChange={setFaceSelected}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
type SelectImageFacesTable = {
|
||||
imageFaces: (
|
||||
| myFaces_myFaceGroups_imageFaces
|
||||
| singleFaceGroup_faceGroup_imageFaces
|
||||
)[]
|
||||
selectedImageFaces: (
|
||||
| myFaces_myFaceGroups_imageFaces
|
||||
| singleFaceGroup_faceGroup_imageFaces
|
||||
)[]
|
||||
setSelectedImageFaces: React.Dispatch<
|
||||
React.SetStateAction<
|
||||
(myFaces_myFaceGroups_imageFaces | singleFaceGroup_faceGroup_imageFaces)[]
|
||||
>
|
||||
>
|
||||
title: string
|
||||
}
|
||||
|
||||
const SelectImageFacesTable = ({
|
||||
imageFaces,
|
||||
selectedImageFaces,
|
||||
setSelectedImageFaces,
|
||||
title,
|
||||
}: SelectImageFacesTable) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// const PAGE_SIZE = 6
|
||||
|
||||
// const [page, setPage] = useState(0)
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
|
||||
// useEffect(() => {
|
||||
// setPage(0)
|
||||
// }, [searchValue])
|
||||
|
||||
const rows = imageFaces
|
||||
.filter(
|
||||
face =>
|
||||
searchValue == '' ||
|
||||
face.media.title.toLowerCase().includes(searchValue.toLowerCase())
|
||||
)
|
||||
.map(face => (
|
||||
<ImageFaceRow
|
||||
key={face.id}
|
||||
imageFace={face}
|
||||
faceSelected={selectedImageFaces.includes(face)}
|
||||
setFaceSelected={() =>
|
||||
setSelectedImageFaces(faces => {
|
||||
if (faces.includes(face)) {
|
||||
return faces.filter(x => x != face)
|
||||
} else {
|
||||
return [...faces, face]
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
))
|
||||
|
||||
// const pageRows = rows.filter(
|
||||
// (_, i) => i >= page * PAGE_SIZE && i < (page + 1) * PAGE_SIZE
|
||||
// )
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table className="w-full">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell colSpan={2}>{title}</TableHeaderCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHeaderCell colSpan={2}>
|
||||
<TextField
|
||||
value={searchValue}
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
placeholder={t(
|
||||
'people_page.tableselect_image_faces.search_images_placeholder',
|
||||
'Search images...'
|
||||
)}
|
||||
fullWidth
|
||||
/>
|
||||
</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
</Table>
|
||||
<div className="overflow-auto max-h-[500px] mt-2">
|
||||
<Table>
|
||||
<TableBody>{rows}</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SelectImageFacesTable
|
||||
@@ -0,0 +1,97 @@
|
||||
import React from 'react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { MockedProvider } from '@apollo/client/testing'
|
||||
import SingleFaceGroup, { SINGLE_FACE_GROUP } from './SingleFaceGroup'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
|
||||
vi.mock('../../../hooks/useScrollPagination')
|
||||
|
||||
test('single face group', async () => {
|
||||
const graphqlMocks = [
|
||||
{
|
||||
request: {
|
||||
query: SINGLE_FACE_GROUP,
|
||||
variables: { limit: 200, offset: 0, id: '123' },
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
faceGroup: {
|
||||
__typename: 'FaceGroup',
|
||||
id: '2',
|
||||
label: 'Face Group Name',
|
||||
imageFaces: [
|
||||
{
|
||||
__typename: 'ImageFace',
|
||||
id: '1',
|
||||
rectangle: {
|
||||
__typename: 'FaceRectangle',
|
||||
minX: 0.4912109971046448,
|
||||
maxX: 0.5927730202674866,
|
||||
minY: 0.2998049855232239,
|
||||
maxY: 0.4013670086860657,
|
||||
},
|
||||
media: {
|
||||
__typename: 'Media',
|
||||
id: '10',
|
||||
type: 'Photo',
|
||||
title: '122A2785-2.jpg',
|
||||
thumbnail: {
|
||||
__typename: 'MediaURL',
|
||||
url: '/photo/thumbnail_122A2785-2_jpg_lFmZcaN5.jpg',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
highRes: {
|
||||
__typename: 'MediaURL',
|
||||
url: '/photo/122A2785-2_e4nCeMHU.jpg',
|
||||
},
|
||||
favorite: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
__typename: 'ImageFace',
|
||||
id: '2',
|
||||
rectangle: {
|
||||
__typename: 'FaceRectangle',
|
||||
minX: 0.265625,
|
||||
maxX: 0.3876950144767761,
|
||||
minY: 0.1917019933462143,
|
||||
maxY: 0.3705289959907532,
|
||||
},
|
||||
media: {
|
||||
__typename: 'Media',
|
||||
id: '52',
|
||||
type: 'Photo',
|
||||
title: 'image.png',
|
||||
thumbnail: {
|
||||
__typename: 'MediaURL',
|
||||
url: '/photo/thumbnail_image_png_OwTDG5fM.jpg',
|
||||
width: 1024,
|
||||
height: 699,
|
||||
},
|
||||
highRes: {
|
||||
__typename: 'MediaURL',
|
||||
url: '/photo/image_A2YB0x3z.png',
|
||||
},
|
||||
favorite: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/person/123']}>
|
||||
<MockedProvider mocks={graphqlMocks}>
|
||||
<SingleFaceGroup faceGroupID="123" />
|
||||
</MockedProvider>
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('img')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import { gql, useQuery } from '@apollo/client'
|
||||
import React, { useEffect, useReducer } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import PaginateLoader from '../../../components/PaginateLoader'
|
||||
import MediaGallery from '../../../components/photoGallery/MediaGallery'
|
||||
import { mediaGalleryReducer } from '../../../components/photoGallery/mediaGalleryReducer'
|
||||
import useScrollPagination from '../../../hooks/useScrollPagination'
|
||||
import FaceGroupTitle from './FaceGroupTitle'
|
||||
import {
|
||||
singleFaceGroup,
|
||||
singleFaceGroupVariables,
|
||||
} from './__generated__/singleFaceGroup'
|
||||
|
||||
export const SINGLE_FACE_GROUP = gql`
|
||||
query singleFaceGroup($id: ID!, $limit: Int!, $offset: Int!) {
|
||||
faceGroup(id: $id) {
|
||||
id
|
||||
label
|
||||
imageFaces(paginate: { limit: $limit, offset: $offset }) {
|
||||
id
|
||||
rectangle {
|
||||
minX
|
||||
maxX
|
||||
minY
|
||||
maxY
|
||||
}
|
||||
media {
|
||||
id
|
||||
type
|
||||
title
|
||||
blurhash
|
||||
thumbnail {
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
highRes {
|
||||
url
|
||||
}
|
||||
favorite
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
type SingleFaceGroupProps = {
|
||||
faceGroupID: string
|
||||
}
|
||||
|
||||
const SingleFaceGroup = ({ faceGroupID }: SingleFaceGroupProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { data, error, loading, fetchMore } = useQuery<
|
||||
singleFaceGroup,
|
||||
singleFaceGroupVariables
|
||||
>(SINGLE_FACE_GROUP, {
|
||||
variables: {
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
id: faceGroupID,
|
||||
},
|
||||
})
|
||||
|
||||
const [mediaState, dispatchMedia] = useReducer(mediaGalleryReducer, {
|
||||
presenting: false,
|
||||
activeIndex: -1,
|
||||
media: [],
|
||||
})
|
||||
|
||||
const { containerElem, finished: finishedLoadingMore } =
|
||||
useScrollPagination<singleFaceGroup>({
|
||||
loading,
|
||||
fetchMore,
|
||||
data,
|
||||
getItems: data => data.faceGroup.imageFaces,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const media = data?.faceGroup.imageFaces.map(x => x.media) || []
|
||||
dispatchMedia({ type: 'replaceMedia', media })
|
||||
}, [data])
|
||||
|
||||
const faceGroup = data?.faceGroup
|
||||
|
||||
if (error) {
|
||||
return <div>{error.message}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerElem}>
|
||||
<FaceGroupTitle faceGroup={faceGroup} />
|
||||
<div>
|
||||
<MediaGallery
|
||||
loading={loading}
|
||||
dispatchMedia={dispatchMedia}
|
||||
mediaState={mediaState}
|
||||
/>
|
||||
<PaginateLoader
|
||||
active={!finishedLoadingMore && !loading}
|
||||
text={t('general.loading.paginate.media', 'Loading more media')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SingleFaceGroup
|
||||
@@ -0,0 +1,25 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
// ====================================================
|
||||
// GraphQL mutation operation: combineFaces
|
||||
// ====================================================
|
||||
|
||||
export interface combineFaces_combineFaceGroups {
|
||||
__typename: "FaceGroup";
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface combineFaces {
|
||||
/**
|
||||
* Merge two face groups into a single one, all ImageFaces from source will be moved to destination
|
||||
*/
|
||||
combineFaceGroups: combineFaces_combineFaceGroups;
|
||||
}
|
||||
|
||||
export interface combineFacesVariables {
|
||||
destID: string;
|
||||
srcIDs: string[];
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
// ====================================================
|
||||
// GraphQL mutation operation: detachImageFaces
|
||||
// ====================================================
|
||||
|
||||
export interface detachImageFaces_detachImageFaces {
|
||||
__typename: "FaceGroup";
|
||||
id: string;
|
||||
/**
|
||||
* The name of the person
|
||||
*/
|
||||
label: string | null;
|
||||
}
|
||||
|
||||
export interface detachImageFaces {
|
||||
/**
|
||||
* Move a list of ImageFaces to a new face group
|
||||
*/
|
||||
detachImageFaces: detachImageFaces_detachImageFaces;
|
||||
}
|
||||
|
||||
export interface detachImageFacesVariables {
|
||||
faceIDs: string[];
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
// ====================================================
|
||||
// GraphQL mutation operation: moveImageFaces
|
||||
// ====================================================
|
||||
|
||||
export interface moveImageFaces_moveImageFaces_imageFaces {
|
||||
__typename: "ImageFace";
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface moveImageFaces_moveImageFaces {
|
||||
__typename: "FaceGroup";
|
||||
id: string;
|
||||
imageFaces: moveImageFaces_moveImageFaces_imageFaces[];
|
||||
}
|
||||
|
||||
export interface moveImageFaces {
|
||||
/**
|
||||
* Move a list of ImageFaces to another face group
|
||||
*/
|
||||
moveImageFaces: moveImageFaces_moveImageFaces;
|
||||
}
|
||||
|
||||
export interface moveImageFacesVariables {
|
||||
faceIDs: string[];
|
||||
destFaceGroupID: string;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
import { MediaType } from "./../../../../__generated__/globalTypes";
|
||||
|
||||
// ====================================================
|
||||
// GraphQL query operation: singleFaceGroup
|
||||
// ====================================================
|
||||
|
||||
export interface singleFaceGroup_faceGroup_imageFaces_rectangle {
|
||||
__typename: "FaceRectangle";
|
||||
minX: number;
|
||||
maxX: number;
|
||||
minY: number;
|
||||
maxY: number;
|
||||
}
|
||||
|
||||
export interface singleFaceGroup_faceGroup_imageFaces_media_thumbnail {
|
||||
__typename: "MediaURL";
|
||||
/**
|
||||
* URL for previewing the image
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* Width of the image in pixels
|
||||
*/
|
||||
width: number;
|
||||
/**
|
||||
* Height of the image in pixels
|
||||
*/
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface singleFaceGroup_faceGroup_imageFaces_media_highRes {
|
||||
__typename: "MediaURL";
|
||||
/**
|
||||
* URL for previewing the image
|
||||
*/
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface singleFaceGroup_faceGroup_imageFaces_media {
|
||||
__typename: "Media";
|
||||
id: string;
|
||||
type: MediaType;
|
||||
title: string;
|
||||
/**
|
||||
* A short string that can be used to generate a blured version of the media, to show while the original is loading
|
||||
*/
|
||||
blurhash: string | null;
|
||||
/**
|
||||
* URL to display the media in a smaller resolution
|
||||
*/
|
||||
thumbnail: singleFaceGroup_faceGroup_imageFaces_media_thumbnail | null;
|
||||
/**
|
||||
* URL to display the photo in full resolution, will be null for videos
|
||||
*/
|
||||
highRes: singleFaceGroup_faceGroup_imageFaces_media_highRes | null;
|
||||
favorite: boolean;
|
||||
}
|
||||
|
||||
export interface singleFaceGroup_faceGroup_imageFaces {
|
||||
__typename: "ImageFace";
|
||||
id: string;
|
||||
/**
|
||||
* A bounding box of where on the image the face is present
|
||||
*/
|
||||
rectangle: singleFaceGroup_faceGroup_imageFaces_rectangle;
|
||||
/**
|
||||
* A reference to the image the face appears on
|
||||
*/
|
||||
media: singleFaceGroup_faceGroup_imageFaces_media;
|
||||
}
|
||||
|
||||
export interface singleFaceGroup_faceGroup {
|
||||
__typename: "FaceGroup";
|
||||
id: string;
|
||||
/**
|
||||
* The name of the person
|
||||
*/
|
||||
label: string | null;
|
||||
imageFaces: singleFaceGroup_faceGroup_imageFaces[];
|
||||
}
|
||||
|
||||
export interface singleFaceGroup {
|
||||
/**
|
||||
* Get a particular `FaceGroup` specified by its ID
|
||||
*/
|
||||
faceGroup: singleFaceGroup_faceGroup;
|
||||
}
|
||||
|
||||
export interface singleFaceGroupVariables {
|
||||
id: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
// ====================================================
|
||||
// GraphQL query operation: myFaces
|
||||
// ====================================================
|
||||
|
||||
export interface myFaces_myFaceGroups_imageFaces_rectangle {
|
||||
__typename: "FaceRectangle";
|
||||
minX: number;
|
||||
maxX: number;
|
||||
minY: number;
|
||||
maxY: number;
|
||||
}
|
||||
|
||||
export interface myFaces_myFaceGroups_imageFaces_media_thumbnail {
|
||||
__typename: "MediaURL";
|
||||
/**
|
||||
* URL for previewing the image
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* Width of the image in pixels
|
||||
*/
|
||||
width: number;
|
||||
/**
|
||||
* Height of the image in pixels
|
||||
*/
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface myFaces_myFaceGroups_imageFaces_media {
|
||||
__typename: "Media";
|
||||
id: string;
|
||||
title: string;
|
||||
/**
|
||||
* URL to display the media in a smaller resolution
|
||||
*/
|
||||
thumbnail: myFaces_myFaceGroups_imageFaces_media_thumbnail | null;
|
||||
}
|
||||
|
||||
export interface myFaces_myFaceGroups_imageFaces {
|
||||
__typename: "ImageFace";
|
||||
id: string;
|
||||
/**
|
||||
* A bounding box of where on the image the face is present
|
||||
*/
|
||||
rectangle: myFaces_myFaceGroups_imageFaces_rectangle;
|
||||
/**
|
||||
* A reference to the image the face appears on
|
||||
*/
|
||||
media: myFaces_myFaceGroups_imageFaces_media;
|
||||
}
|
||||
|
||||
export interface myFaces_myFaceGroups {
|
||||
__typename: "FaceGroup";
|
||||
id: string;
|
||||
/**
|
||||
* The name of the person
|
||||
*/
|
||||
label: string | null;
|
||||
/**
|
||||
* The total number of images in this collection
|
||||
*/
|
||||
imageFaceCount: number;
|
||||
imageFaces: myFaces_myFaceGroups_imageFaces[];
|
||||
}
|
||||
|
||||
export interface myFaces {
|
||||
/**
|
||||
* Get a list of `FaceGroup`s for the logged in user
|
||||
*/
|
||||
myFaceGroups: myFaces_myFaceGroups[];
|
||||
}
|
||||
|
||||
export interface myFacesVariables {
|
||||
limit?: number | null;
|
||||
offset?: number | null;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
// ====================================================
|
||||
// GraphQL mutation operation: recognizeUnlabeledFaces
|
||||
// ====================================================
|
||||
|
||||
export interface recognizeUnlabeledFaces_recognizeUnlabeledFaces {
|
||||
__typename: "ImageFace";
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface recognizeUnlabeledFaces {
|
||||
/**
|
||||
* Check all unlabeled faces to see if they match a labeled FaceGroup, and move them if they match
|
||||
*/
|
||||
recognizeUnlabeledFaces: recognizeUnlabeledFaces_recognizeUnlabeledFaces[];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
// ====================================================
|
||||
// GraphQL mutation operation: setGroupLabel
|
||||
// ====================================================
|
||||
|
||||
export interface setGroupLabel_setFaceGroupLabel {
|
||||
__typename: "FaceGroup";
|
||||
id: string;
|
||||
/**
|
||||
* The name of the person
|
||||
*/
|
||||
label: string | null;
|
||||
}
|
||||
|
||||
export interface setGroupLabel {
|
||||
/**
|
||||
* Assign a label to a face group, set label to null to remove the current one
|
||||
*/
|
||||
setFaceGroupLabel: setGroupLabel_setFaceGroupLabel;
|
||||
}
|
||||
|
||||
export interface setGroupLabelVariables {
|
||||
groupID: string;
|
||||
label?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import imagePopupSrc from './image-popup.svg'
|
||||
import { MediaMarker } from './MapPresentMarker'
|
||||
import { PlacesAction } from './placesReducer'
|
||||
|
||||
const Wrapper = styled.div`
|
||||
width: 56px;
|
||||
height: 68px;
|
||||
position: relative;
|
||||
margin-top: -54px;
|
||||
cursor: pointer;
|
||||
`
|
||||
|
||||
const ThumbnailImage = styled.img`
|
||||
position: absolute;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
border-radius: 2px;
|
||||
object-fit: cover;
|
||||
`
|
||||
|
||||
const PopupImage = styled.img`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`
|
||||
|
||||
const PointCountCircle = styled.div`
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
right: -10px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-color: #00b3dc;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding-top: 2px;
|
||||
`
|
||||
|
||||
type MapClusterMarkerProps = {
|
||||
dispatchMarkerMedia: React.Dispatch<PlacesAction>
|
||||
marker: MediaMarker
|
||||
}
|
||||
|
||||
const MapClusterMarker = ({
|
||||
marker,
|
||||
dispatchMarkerMedia,
|
||||
}: MapClusterMarkerProps) => {
|
||||
const thumbnail = JSON.parse(marker.thumbnail) as { url: string }
|
||||
|
||||
const presentMedia = () => {
|
||||
dispatchMarkerMedia({
|
||||
type: 'replacePresentMarker',
|
||||
marker: {
|
||||
cluster: !!marker.cluster,
|
||||
id: marker.cluster ? marker.cluster_id : marker.media_id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper onClick={presentMedia}>
|
||||
<PopupImage src={imagePopupSrc} />
|
||||
<ThumbnailImage src={thumbnail.url} />
|
||||
{marker.cluster && (
|
||||
<PointCountCircle>{marker.point_count_abbreviated}</PointCountCircle>
|
||||
)}
|
||||
</Wrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export default MapClusterMarker
|
||||
@@ -0,0 +1,142 @@
|
||||
import { gql } from '@apollo/client'
|
||||
import React, { useEffect } from 'react'
|
||||
import { useLazyQuery } from '@apollo/client'
|
||||
import PresentView from '../../components/photoGallery/presentView/PresentView'
|
||||
import type mapboxgl from 'mapbox-gl'
|
||||
import { PresentMarker } from './PlacesPage'
|
||||
import {
|
||||
placePageQueryMedia,
|
||||
placePageQueryMediaVariables,
|
||||
} from './__generated__/placePageQueryMedia'
|
||||
import { PlacesAction, PlacesState } from './placesReducer'
|
||||
|
||||
const QUERY_MEDIA = gql`
|
||||
query placePageQueryMedia($mediaIDs: [ID!]!) {
|
||||
mediaList(ids: $mediaIDs) {
|
||||
id
|
||||
title
|
||||
blurhash
|
||||
thumbnail {
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
highRes {
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
videoWeb {
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
type
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const getMediaFromMarker = (map: mapboxgl.Map, presentMarker: PresentMarker) =>
|
||||
new Promise<MediaMarker[]>((resolve, reject) => {
|
||||
const { cluster, id } = presentMarker
|
||||
|
||||
if (cluster) {
|
||||
const mediaSource = map.getSource('media') as mapboxgl.GeoJSONSource
|
||||
|
||||
mediaSource.getClusterLeaves(id as number, 1000, 0, (error, features) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
|
||||
const media = features.map(feat => feat.properties) as MediaMarker[]
|
||||
resolve(media)
|
||||
})
|
||||
} else {
|
||||
const features = map.querySourceFeatures('media')
|
||||
const media = features.find(f => f.properties?.media_id == id)
|
||||
?.properties as MediaMarker | undefined
|
||||
|
||||
if (media === undefined) {
|
||||
reject('ERROR: media is undefined')
|
||||
return
|
||||
}
|
||||
|
||||
resolve([media])
|
||||
}
|
||||
})
|
||||
|
||||
export interface MediaMarker {
|
||||
id: number
|
||||
thumbnail: string
|
||||
cluster: boolean
|
||||
point_count_abbreviated: number
|
||||
cluster_id: string
|
||||
media_id: string
|
||||
}
|
||||
|
||||
type MapPresetMarkerProps = {
|
||||
map: mapboxgl.Map | null
|
||||
markerMediaState: PlacesState
|
||||
dispatchMarkerMedia: React.Dispatch<PlacesAction>
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen present-view that works with PlacesState
|
||||
*/
|
||||
const MapPresentMarker = ({
|
||||
map,
|
||||
markerMediaState,
|
||||
dispatchMarkerMedia,
|
||||
}: MapPresetMarkerProps) => {
|
||||
const [loadMedia, { data: loadedMedia }] = useLazyQuery<
|
||||
placePageQueryMedia,
|
||||
placePageQueryMediaVariables
|
||||
>(QUERY_MEDIA)
|
||||
|
||||
useEffect(() => {
|
||||
const presentMarker = markerMediaState.presentMarker
|
||||
if (presentMarker == null || map == null) {
|
||||
dispatchMarkerMedia({
|
||||
type: 'closePresentMode',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
getMediaFromMarker(map, presentMarker).then(mediaMarkers => {
|
||||
loadMedia({
|
||||
variables: {
|
||||
mediaIDs: mediaMarkers.map(x => x.media_id),
|
||||
},
|
||||
})
|
||||
})
|
||||
}, [markerMediaState.presentMarker])
|
||||
|
||||
useEffect(() => {
|
||||
const mediaList = loadedMedia?.mediaList || []
|
||||
dispatchMarkerMedia({
|
||||
type: 'replaceMedia',
|
||||
media: mediaList,
|
||||
})
|
||||
if (mediaList.length > 0) {
|
||||
dispatchMarkerMedia({
|
||||
type: 'openPresentMode',
|
||||
activeIndex: 0,
|
||||
})
|
||||
}
|
||||
}, [loadedMedia])
|
||||
|
||||
if (markerMediaState.presenting) {
|
||||
return (
|
||||
<PresentView
|
||||
activeMedia={markerMediaState.media[markerMediaState.activeIndex]}
|
||||
dispatchMedia={dispatchMarkerMedia}
|
||||
disableSaveCloseInHistory={true}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default MapPresentMarker
|
||||
@@ -0,0 +1,140 @@
|
||||
import { gql, useQuery } from '@apollo/client'
|
||||
import type mapboxgl from 'mapbox-gl'
|
||||
import React, { useReducer } from 'react'
|
||||
import { Helmet } from 'react-helmet'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
import Layout from '../../components/layout/Layout'
|
||||
import { registerMediaMarkers } from '../../components/mapbox/mapboxHelperFunctions'
|
||||
import useMapboxMap from '../../components/mapbox/MapboxMap'
|
||||
import { urlPresentModeSetupHook } from '../../components/photoGallery/mediaGalleryReducer'
|
||||
import MapPresentMarker from './MapPresentMarker'
|
||||
import { PlacesAction, placesReducer } from './placesReducer'
|
||||
import { mediaGeoJson } from './__generated__/mediaGeoJson'
|
||||
|
||||
const MapWrapper = styled.div`
|
||||
width: 100%;
|
||||
height: calc(100vh - 120px);
|
||||
`
|
||||
|
||||
const MAPBOX_DATA_QUERY = gql`
|
||||
query mediaGeoJson {
|
||||
myMediaGeoJson
|
||||
}
|
||||
`
|
||||
|
||||
export type PresentMarker = {
|
||||
id: number | string
|
||||
cluster: boolean
|
||||
}
|
||||
|
||||
const MapPage = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { data: mapboxData } = useQuery<mediaGeoJson>(MAPBOX_DATA_QUERY, {
|
||||
fetchPolicy: 'cache-first',
|
||||
})
|
||||
|
||||
const [markerMediaState, dispatchMarkerMedia] = useReducer(placesReducer, {
|
||||
presenting: false,
|
||||
activeIndex: -1,
|
||||
media: [],
|
||||
})
|
||||
|
||||
const { mapContainer, mapboxMap, mapboxToken } = useMapboxMap({
|
||||
configureMapbox: configureMapbox({ mapboxData, dispatchMarkerMedia }),
|
||||
mapboxOptions: {
|
||||
projection: {
|
||||
name: 'globe',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
urlPresentModeSetupHook({
|
||||
dispatchMedia: dispatchMarkerMedia,
|
||||
openPresentMode: event => {
|
||||
dispatchMarkerMedia({
|
||||
type: 'openPresentMode',
|
||||
activeIndex: event.state.activeIndex,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
if (mapboxData && mapboxToken == null) {
|
||||
return (
|
||||
<Layout title={t('places_page.title', 'Places')}>
|
||||
<h1>Mapbox token is not set</h1>
|
||||
<p>
|
||||
To use map related features a mapbox token is needed.
|
||||
<br /> A mapbox token can be created for free at{' '}
|
||||
<a href="https://account.mapbox.com/access-tokens/">mapbox.com</a>.
|
||||
</p>
|
||||
<p>
|
||||
Make sure the access token is added as the MAPBOX_TOKEN environment
|
||||
variable.
|
||||
</p>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout title="Places">
|
||||
<Helmet>
|
||||
{/* <link rel="stylesheet" href="/mapbox-gl.css" /> */}
|
||||
{/* <style type="text/css">{mapboxStyles}</style> */}
|
||||
</Helmet>
|
||||
<MapWrapper>{mapContainer}</MapWrapper>
|
||||
<MapPresentMarker
|
||||
map={mapboxMap}
|
||||
markerMediaState={markerMediaState}
|
||||
dispatchMarkerMedia={dispatchMarkerMedia}
|
||||
/>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
const configureMapbox =
|
||||
({
|
||||
mapboxData,
|
||||
dispatchMarkerMedia,
|
||||
}: {
|
||||
mapboxData?: mediaGeoJson
|
||||
dispatchMarkerMedia: React.Dispatch<PlacesAction>
|
||||
}) =>
|
||||
(map: mapboxgl.Map, mapboxLibrary: typeof mapboxgl) => {
|
||||
// Add map navigation control
|
||||
map.addControl(new mapboxLibrary.NavigationControl())
|
||||
|
||||
map.on('load', () => {
|
||||
if (map == null) {
|
||||
console.error('ERROR: map is null')
|
||||
return
|
||||
}
|
||||
|
||||
map.addSource('media', {
|
||||
type: 'geojson',
|
||||
data: mapboxData?.myMediaGeoJson as never,
|
||||
cluster: true,
|
||||
clusterRadius: 50,
|
||||
clusterProperties: {
|
||||
thumbnail: ['coalesce', ['get', 'thumbnail'], false],
|
||||
},
|
||||
})
|
||||
|
||||
// Add dummy layer for features to be queryable
|
||||
map.addLayer({
|
||||
id: 'media-points',
|
||||
type: 'circle',
|
||||
source: 'media',
|
||||
filter: ['!', true],
|
||||
})
|
||||
|
||||
registerMediaMarkers({
|
||||
map: map,
|
||||
mapboxLibrary,
|
||||
dispatchMarkerMedia,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export default MapPage
|
||||
@@ -0,0 +1,15 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
// ====================================================
|
||||
// GraphQL query operation: mediaGeoJson
|
||||
// ====================================================
|
||||
|
||||
export interface mediaGeoJson {
|
||||
/**
|
||||
* Get media owned by the logged in user, returned in GeoJson format
|
||||
*/
|
||||
myMediaGeoJson: Any;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
// ====================================================
|
||||
// GraphQL query operation: placePageMapboxToken
|
||||
// ====================================================
|
||||
|
||||
export interface placePageMapboxToken {
|
||||
/**
|
||||
* Get the mapbox api token, returns null if mapbox is not enabled
|
||||
*/
|
||||
mapboxToken: string | null
|
||||
/**
|
||||
* Get media owned by the logged in user, returned in GeoJson format
|
||||
*/
|
||||
myMediaGeoJson: any
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
import { MediaType } from "./../../../__generated__/globalTypes";
|
||||
|
||||
// ====================================================
|
||||
// GraphQL query operation: placePageQueryMedia
|
||||
// ====================================================
|
||||
|
||||
export interface placePageQueryMedia_mediaList_thumbnail {
|
||||
__typename: "MediaURL";
|
||||
/**
|
||||
* URL for previewing the image
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* Width of the image in pixels
|
||||
*/
|
||||
width: number;
|
||||
/**
|
||||
* Height of the image in pixels
|
||||
*/
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface placePageQueryMedia_mediaList_highRes {
|
||||
__typename: "MediaURL";
|
||||
/**
|
||||
* URL for previewing the image
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* Width of the image in pixels
|
||||
*/
|
||||
width: number;
|
||||
/**
|
||||
* Height of the image in pixels
|
||||
*/
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface placePageQueryMedia_mediaList_videoWeb {
|
||||
__typename: "MediaURL";
|
||||
/**
|
||||
* URL for previewing the image
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* Width of the image in pixels
|
||||
*/
|
||||
width: number;
|
||||
/**
|
||||
* Height of the image in pixels
|
||||
*/
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface placePageQueryMedia_mediaList {
|
||||
__typename: "Media";
|
||||
id: string;
|
||||
title: string;
|
||||
/**
|
||||
* A short string that can be used to generate a blured version of the media, to show while the original is loading
|
||||
*/
|
||||
blurhash: string | null;
|
||||
/**
|
||||
* URL to display the media in a smaller resolution
|
||||
*/
|
||||
thumbnail: placePageQueryMedia_mediaList_thumbnail | null;
|
||||
/**
|
||||
* URL to display the photo in full resolution, will be null for videos
|
||||
*/
|
||||
highRes: placePageQueryMedia_mediaList_highRes | null;
|
||||
/**
|
||||
* URL to get the video in a web format that can be played in the browser, will be null for photos
|
||||
*/
|
||||
videoWeb: placePageQueryMedia_mediaList_videoWeb | null;
|
||||
type: MediaType;
|
||||
}
|
||||
|
||||
export interface placePageQueryMedia {
|
||||
/**
|
||||
* Get a list of media by their ids, user must own the media or be admin
|
||||
*/
|
||||
mediaList: placePageQueryMedia_mediaList[];
|
||||
}
|
||||
|
||||
export interface placePageQueryMediaVariables {
|
||||
mediaIDs: string[];
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 56 68" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g transform="matrix(0.875,0,0,1.0625,0,0)">
|
||||
<g transform="matrix(1,0,0,0.970588,0,0)">
|
||||
<g transform="matrix(0.857143,0,0,1.55496,8,-35.483)">
|
||||
<g style="filter:url(#_Effect1);">
|
||||
<ellipse cx="28" cy="61.172" rx="8" ry="0.828" style="fill-opacity:0.5;"/>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="matrix(1.14286,0,0,0.969697,0,0)">
|
||||
<path d="M4,54C3.47,54 2.961,53.789 2.586,53.414C2.211,53.039 2,52.53 2,52C2,43.732 2,12.268 2,4C2,3.47 2.211,2.961 2.586,2.586C2.961,2.211 3.47,2 4,2L52,2C52.53,2 53.039,2.211 53.414,2.586C53.789,2.961 54,3.47 54,4L54,52C54,52.53 53.789,53.039 53.414,53.414C53.039,53.789 52.53,54 52,54C48.077,54 39.521,54 36.828,54C36.298,54 35.789,54.211 35.414,54.586C34.161,55.839 31.192,58.808 29.414,60.586C29.039,60.961 28.53,61.172 28,61.172C27.47,61.172 26.961,60.961 26.586,60.586C24.808,58.808 21.839,55.839 20.586,54.586C20.211,54.211 19.702,54 19.172,54C16.479,54 7.923,54 4,54Z" style="fill:white;"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="_Effect1" filterUnits="userSpaceOnUse" x="8" y="54.7306" width="40" height="12.8819">
|
||||
<feGaussianBlur in="SourceGraphic" stdDeviation="1.1983"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,41 @@
|
||||
import { PresentMarker } from './PlacesPage'
|
||||
import {
|
||||
MediaGalleryState,
|
||||
PhotoGalleryAction,
|
||||
mediaGalleryReducer,
|
||||
} from '../../components/photoGallery/mediaGalleryReducer'
|
||||
|
||||
export interface PlacesState extends MediaGalleryState {
|
||||
presentMarker?: PresentMarker
|
||||
}
|
||||
|
||||
export type PlacesAction =
|
||||
| PhotoGalleryAction
|
||||
| { type: 'replacePresentMarker'; marker?: PresentMarker }
|
||||
|
||||
export function placesReducer(
|
||||
state: PlacesState,
|
||||
action: PlacesAction
|
||||
): PlacesState {
|
||||
switch (action.type) {
|
||||
case 'replacePresentMarker':
|
||||
if (
|
||||
state.presentMarker &&
|
||||
action.marker &&
|
||||
state.presentMarker.cluster === action.marker.cluster &&
|
||||
state.presentMarker.id === action.marker.id
|
||||
) {
|
||||
return {
|
||||
...state,
|
||||
presenting: true,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
...state,
|
||||
presentMarker: action.marker,
|
||||
}
|
||||
}
|
||||
default:
|
||||
return mediaGalleryReducer(state, action)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { MockedProvider } from '@apollo/client/testing'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import PeriodicScanner, {
|
||||
SCAN_INTERVAL_MUTATION,
|
||||
SCAN_INTERVAL_QUERY,
|
||||
} from './PeriodicScanner'
|
||||
|
||||
test('Enable periodic scanner', async () => {
|
||||
const graphqlMocks = [
|
||||
{
|
||||
request: {
|
||||
query: SCAN_INTERVAL_QUERY,
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
siteInfo: { periodicScanInterval: 7380, __typename: 'SiteInfo' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
request: {
|
||||
query: SCAN_INTERVAL_MUTATION,
|
||||
variables: { interval: 0 },
|
||||
},
|
||||
result: { data: { setPeriodicScanInterval: 0 } },
|
||||
},
|
||||
{
|
||||
request: {
|
||||
query: SCAN_INTERVAL_MUTATION,
|
||||
variables: { interval: 123 * 60 },
|
||||
},
|
||||
result: { data: { setPeriodicScanInterval: 123 * 60 } },
|
||||
},
|
||||
]
|
||||
|
||||
render(
|
||||
<MockedProvider mocks={graphqlMocks} addTypename={true}>
|
||||
<PeriodicScanner />
|
||||
</MockedProvider>
|
||||
)
|
||||
|
||||
const enableCheckbox = screen.getByLabelText('Enable periodic scanner')
|
||||
const inputField = screen.getByLabelText('Interval value')
|
||||
const unitDropdown = screen.getByLabelText('Interval unit')
|
||||
|
||||
expect(inputField).toBeDisabled()
|
||||
expect(unitDropdown).toBeDisabled()
|
||||
|
||||
fireEvent.click(enableCheckbox)
|
||||
|
||||
expect(inputField).toBeEnabled()
|
||||
expect(unitDropdown).toBeEnabled()
|
||||
|
||||
fireEvent.change(unitDropdown, { target: { value: 'minute' } })
|
||||
fireEvent.change(inputField, { target: { value: '123' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.keyUp(inputField, { key: 'Enter' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,256 @@
|
||||
import { gql } from '@apollo/client'
|
||||
import React, { useRef, useState } from 'react'
|
||||
import { useMutation, useQuery } from '@apollo/client'
|
||||
import { InputLabelDescription, InputLabelTitle } from './SettingsPage'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { scanIntervalQuery } from './__generated__/scanIntervalQuery'
|
||||
import {
|
||||
changeScanIntervalMutation,
|
||||
changeScanIntervalMutationVariables,
|
||||
} from './__generated__/changeScanIntervalMutation'
|
||||
import Checkbox from '../../primitives/form/Checkbox'
|
||||
import { TextField } from '../../primitives/form/Input'
|
||||
import Dropdown, { DropdownItem } from '../../primitives/form/Dropdown'
|
||||
import Loader from '../../primitives/Loader'
|
||||
|
||||
export const SCAN_INTERVAL_QUERY = gql`
|
||||
query scanIntervalQuery {
|
||||
siteInfo {
|
||||
periodicScanInterval
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const SCAN_INTERVAL_MUTATION = gql`
|
||||
mutation changeScanIntervalMutation($interval: Int!) {
|
||||
setPeriodicScanInterval(interval: $interval)
|
||||
}
|
||||
`
|
||||
|
||||
enum TimeUnit {
|
||||
Second = 'second',
|
||||
Minute = 'minute',
|
||||
Hour = 'hour',
|
||||
Day = 'day',
|
||||
Month = 'month',
|
||||
}
|
||||
|
||||
const timeUnits = [
|
||||
{
|
||||
value: TimeUnit.Second,
|
||||
multiplier: 1,
|
||||
},
|
||||
{
|
||||
value: TimeUnit.Minute,
|
||||
multiplier: 60,
|
||||
},
|
||||
{
|
||||
value: TimeUnit.Hour,
|
||||
multiplier: 60 * 60,
|
||||
},
|
||||
{
|
||||
value: TimeUnit.Day,
|
||||
multiplier: 60 * 60 * 24,
|
||||
},
|
||||
{
|
||||
value: TimeUnit.Month,
|
||||
multiplier: 60 * 60 * 24 * 30,
|
||||
},
|
||||
]
|
||||
|
||||
type TimeValue = {
|
||||
value: number
|
||||
unit: TimeUnit
|
||||
}
|
||||
|
||||
const convertToSeconds = ({ value, unit }: TimeValue) => {
|
||||
return value * (timeUnits.find(x => x.value == unit)?.multiplier as number)
|
||||
}
|
||||
|
||||
const convertToAppropriateUnit = ({ value, unit }: TimeValue): TimeValue => {
|
||||
if (value == 0) {
|
||||
return {
|
||||
unit: TimeUnit.Second,
|
||||
value: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const seconds = convertToSeconds({ value, unit })
|
||||
|
||||
let resultingUnit = timeUnits[0]
|
||||
for (const unit of timeUnits) {
|
||||
if (
|
||||
seconds / unit.multiplier >= 1 &&
|
||||
(seconds / unit.multiplier) % 1 == 0
|
||||
) {
|
||||
resultingUnit = unit
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
unit: resultingUnit.value,
|
||||
value: seconds / resultingUnit.multiplier,
|
||||
}
|
||||
}
|
||||
|
||||
const PeriodicScanner = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [enablePeriodicScanner, setEnablePeriodicScanner] = useState(false)
|
||||
const [scanInterval, setScanInterval] = useState<TimeValue>({
|
||||
value: 0,
|
||||
unit: TimeUnit.Second,
|
||||
})
|
||||
|
||||
const scanIntervalServerValue = useRef<number | null>(null)
|
||||
|
||||
const scanIntervalQuery = useQuery<scanIntervalQuery>(SCAN_INTERVAL_QUERY, {
|
||||
onCompleted(data) {
|
||||
const queryScanInterval = data.siteInfo.periodicScanInterval
|
||||
|
||||
if (queryScanInterval == 0) {
|
||||
setScanInterval({
|
||||
unit: TimeUnit.Second,
|
||||
value: 0,
|
||||
})
|
||||
} else {
|
||||
setScanInterval(
|
||||
convertToAppropriateUnit({
|
||||
unit: TimeUnit.Second,
|
||||
value: queryScanInterval,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
setEnablePeriodicScanner(queryScanInterval > 0)
|
||||
},
|
||||
})
|
||||
|
||||
const [setScanIntervalMutation, { loading: scanIntervalMutationLoading }] =
|
||||
useMutation<
|
||||
changeScanIntervalMutation,
|
||||
changeScanIntervalMutationVariables
|
||||
>(SCAN_INTERVAL_MUTATION)
|
||||
|
||||
const onScanIntervalCheckboxChange = (checked: boolean) => {
|
||||
setEnablePeriodicScanner(checked)
|
||||
|
||||
onScanIntervalUpdate(
|
||||
checked ? scanInterval : { value: 0, unit: TimeUnit.Second }
|
||||
)
|
||||
}
|
||||
|
||||
const onScanIntervalUpdate = (scanInterval: TimeValue) => {
|
||||
const seconds = convertToSeconds(scanInterval)
|
||||
|
||||
if (scanIntervalServerValue.current != seconds) {
|
||||
setScanIntervalMutation({
|
||||
variables: {
|
||||
interval: seconds,
|
||||
},
|
||||
})
|
||||
scanIntervalServerValue.current = seconds
|
||||
}
|
||||
}
|
||||
|
||||
const scanIntervalUnits: DropdownItem[] = [
|
||||
{
|
||||
label: t('settings.periodic_scanner.interval_unit.seconds', 'Seconds'),
|
||||
value: TimeUnit.Second,
|
||||
},
|
||||
{
|
||||
label: t('settings.periodic_scanner.interval_unit.minutes', 'Minutes'),
|
||||
value: TimeUnit.Minute,
|
||||
},
|
||||
{
|
||||
label: t('settings.periodic_scanner.interval_unit.hour', 'Hour'),
|
||||
value: TimeUnit.Hour,
|
||||
},
|
||||
{
|
||||
label: t('settings.periodic_scanner.interval_unit.days', 'Days'),
|
||||
value: TimeUnit.Day,
|
||||
},
|
||||
{
|
||||
label: t('settings.periodic_scanner.interval_unit.months', 'Months'),
|
||||
value: TimeUnit.Month,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className="font-semibold text-lg mt-4 mb-2">
|
||||
{t('settings.periodic_scanner.title', 'Periodic scanner')}
|
||||
</h3>
|
||||
|
||||
<Checkbox
|
||||
label={t(
|
||||
'settings.periodic_scanner.checkbox_label',
|
||||
'Enable periodic scanner'
|
||||
)}
|
||||
disabled={scanIntervalQuery.loading}
|
||||
checked={enablePeriodicScanner}
|
||||
onChange={event =>
|
||||
onScanIntervalCheckboxChange(event.target.checked || false)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<label htmlFor="periodic_scan_field">
|
||||
<InputLabelTitle>
|
||||
{t(
|
||||
'settings.periodic_scanner.field.label',
|
||||
'Periodic scan interval'
|
||||
)}
|
||||
</InputLabelTitle>
|
||||
<InputLabelDescription>
|
||||
{t(
|
||||
'settings.periodic_scanner.field.description',
|
||||
'How often the scanner should perform automatic scans of all users'
|
||||
)}
|
||||
</InputLabelDescription>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<TextField
|
||||
id="periodic_scan_field"
|
||||
aria-label="Interval value"
|
||||
disabled={!enablePeriodicScanner}
|
||||
value={scanInterval.value}
|
||||
onChange={e => {
|
||||
setScanInterval(x => ({
|
||||
value: Number(e.target.value),
|
||||
unit: x.unit,
|
||||
}))
|
||||
}}
|
||||
action={() => {
|
||||
onScanIntervalUpdate(scanInterval)
|
||||
}}
|
||||
/>
|
||||
<Dropdown
|
||||
aria-label="Interval unit"
|
||||
disabled={!enablePeriodicScanner}
|
||||
items={scanIntervalUnits}
|
||||
selected={scanInterval.unit}
|
||||
setSelected={value => {
|
||||
const newScanInterval: TimeValue = {
|
||||
...scanInterval,
|
||||
unit: value as TimeUnit,
|
||||
}
|
||||
|
||||
setScanInterval(newScanInterval)
|
||||
onScanIntervalUpdate(newScanInterval)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Loader
|
||||
active={scanIntervalQuery.loading || scanIntervalMutationLoading}
|
||||
size="small"
|
||||
style={{ marginLeft: 16 }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default PeriodicScanner
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react'
|
||||
import { MockedProvider } from '@apollo/client/testing'
|
||||
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
import {
|
||||
CONCURRENT_WORKERS_QUERY,
|
||||
SET_CONCURRENT_WORKERS_MUTATION,
|
||||
ScannerConcurrentWorkers,
|
||||
} from './ScannerConcurrentWorkers'
|
||||
|
||||
test('load ScannerConcurrentWorkers', () => {
|
||||
const graphqlMocks = [
|
||||
{
|
||||
request: {
|
||||
query: CONCURRENT_WORKERS_QUERY,
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
siteInfo: { concurrentWorkers: 3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
request: {
|
||||
query: SET_CONCURRENT_WORKERS_MUTATION,
|
||||
variables: {
|
||||
workers: '1',
|
||||
},
|
||||
},
|
||||
result: {
|
||||
data: {},
|
||||
},
|
||||
},
|
||||
]
|
||||
render(
|
||||
<MockedProvider
|
||||
mocks={graphqlMocks}
|
||||
addTypename={false}
|
||||
defaultOptions={{
|
||||
// disable cache, required to make fragments work
|
||||
watchQuery: { fetchPolicy: 'no-cache' },
|
||||
query: { fetchPolicy: 'no-cache' },
|
||||
}}
|
||||
>
|
||||
<ScannerConcurrentWorkers />
|
||||
</MockedProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Scanner concurrent workers')).toBeInTheDocument()
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, { useRef, useState } from 'react'
|
||||
import { useQuery, useMutation, gql } from '@apollo/client'
|
||||
import { InputLabelTitle, InputLabelDescription } from './SettingsPage'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { concurrentWorkersQuery } from './__generated__/concurrentWorkersQuery'
|
||||
import {
|
||||
setConcurrentWorkers,
|
||||
setConcurrentWorkersVariables,
|
||||
} from './__generated__/setConcurrentWorkers'
|
||||
import { TextField } from '../../primitives/form/Input'
|
||||
|
||||
export const CONCURRENT_WORKERS_QUERY = gql`
|
||||
query concurrentWorkersQuery {
|
||||
siteInfo {
|
||||
concurrentWorkers
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const SET_CONCURRENT_WORKERS_MUTATION = gql`
|
||||
mutation setConcurrentWorkers($workers: Int!) {
|
||||
setScannerConcurrentWorkers(workers: $workers)
|
||||
}
|
||||
`
|
||||
|
||||
export const ScannerConcurrentWorkers = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const workerAmountServerValue = useRef<null | number>(null)
|
||||
const [workerAmount, setWorkerAmount] = useState(0)
|
||||
|
||||
const workerAmountQuery = useQuery<concurrentWorkersQuery>(
|
||||
CONCURRENT_WORKERS_QUERY,
|
||||
{
|
||||
onCompleted(data) {
|
||||
setWorkerAmount(data.siteInfo.concurrentWorkers)
|
||||
workerAmountServerValue.current = data.siteInfo.concurrentWorkers
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const [setWorkersMutation, workersMutationData] = useMutation<
|
||||
setConcurrentWorkers,
|
||||
setConcurrentWorkersVariables
|
||||
>(SET_CONCURRENT_WORKERS_MUTATION)
|
||||
|
||||
const updateWorkerAmount = (workerAmount: number) => {
|
||||
if (workerAmountServerValue.current != workerAmount) {
|
||||
workerAmountServerValue.current = workerAmount
|
||||
setWorkersMutation({
|
||||
variables: {
|
||||
workers: workerAmount,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="scanner_concurrent_workers_field">
|
||||
<InputLabelTitle>
|
||||
{t('settings.concurrent_workers.title', 'Scanner concurrent workers')}
|
||||
</InputLabelTitle>
|
||||
<InputLabelDescription>
|
||||
{t(
|
||||
'settings.concurrent_workers.description',
|
||||
'The maximum amount of scanner jobs that is allowed to run at once'
|
||||
)}
|
||||
</InputLabelDescription>
|
||||
</label>
|
||||
<TextField
|
||||
disabled={workerAmountQuery.loading || workersMutationData.loading}
|
||||
type="number"
|
||||
min="1"
|
||||
max="24"
|
||||
id="scanner_concurrent_workers_field"
|
||||
value={workerAmount}
|
||||
onChange={event => {
|
||||
setWorkerAmount(parseInt(event.target.value))
|
||||
}}
|
||||
onBlur={() => updateWorkerAmount(workerAmount)}
|
||||
onKeyDown={event =>
|
||||
event.key == 'Enter' && updateWorkerAmount(workerAmount)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react'
|
||||
import { useMutation, gql } from '@apollo/client'
|
||||
import PeriodicScanner from './PeriodicScanner'
|
||||
import { ScannerConcurrentWorkers } from './ScannerConcurrentWorkers'
|
||||
import { SectionTitle, InputLabelDescription } from './SettingsPage'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { scanAllMutation } from './__generated__/scanAllMutation'
|
||||
import { Button } from '../../primitives/form/Input'
|
||||
|
||||
const SCAN_MUTATION = gql`
|
||||
mutation scanAllMutation {
|
||||
scanAll {
|
||||
success
|
||||
message
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const ScannerSection = () => {
|
||||
const { t } = useTranslation()
|
||||
const [startScanner, { called }] = useMutation<scanAllMutation>(SCAN_MUTATION)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionTitle nospace>
|
||||
{t('settings.scanner.title', 'Scanner')}
|
||||
</SectionTitle>
|
||||
<InputLabelDescription>
|
||||
{t(
|
||||
'settings.scanner.description',
|
||||
'Will scan all users for new or updated media'
|
||||
)}
|
||||
</InputLabelDescription>
|
||||
<Button
|
||||
onClick={() => {
|
||||
startScanner()
|
||||
}}
|
||||
disabled={called}
|
||||
>
|
||||
{t('settings.scanner.scan_all_users', 'Scan all users')}
|
||||
</Button>
|
||||
<PeriodicScanner />
|
||||
<ScannerConcurrentWorkers />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ScannerSection
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
import { useIsAdmin } from '../../components/routes/AuthorizedRoute'
|
||||
import Layout from '../../components/layout/Layout'
|
||||
import ScannerSection from './ScannerSection'
|
||||
import UserPreferences from './UserPreferences'
|
||||
import UsersTable from './Users/UsersTable'
|
||||
import VersionInfo from './VersionInfo'
|
||||
import classNames from 'classnames'
|
||||
|
||||
type SectionTitleProps = {
|
||||
children: string
|
||||
nospace?: boolean
|
||||
}
|
||||
|
||||
export const SectionTitle = ({ children, nospace }: SectionTitleProps) => {
|
||||
return (
|
||||
<h2
|
||||
className={classNames(
|
||||
'pb-1 border-b border-gray-200 dark:border-dark-border text-xl mb-5',
|
||||
!nospace && 'mt-6'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
)
|
||||
}
|
||||
|
||||
export const InputLabelTitle = styled.h3.attrs({
|
||||
className: 'font-semibold mt-4',
|
||||
})``
|
||||
|
||||
export const InputLabelDescription = styled.p.attrs({
|
||||
className: 'text-sm mb-2',
|
||||
})``
|
||||
|
||||
const SettingsPage = () => {
|
||||
const { t } = useTranslation()
|
||||
const isAdmin = useIsAdmin()
|
||||
|
||||
return (
|
||||
<Layout title={t('title.settings', 'Settings')}>
|
||||
<UserPreferences />
|
||||
{isAdmin && (
|
||||
<>
|
||||
<ScannerSection />
|
||||
<UsersTable />
|
||||
</>
|
||||
)}
|
||||
<VersionInfo />
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsPage
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useMutation, useQuery } from '@apollo/client'
|
||||
import gql from 'graphql-tag'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
import { LanguageTranslation } from '../../__generated__/globalTypes'
|
||||
import Dropdown from '../../primitives/form/Dropdown'
|
||||
import { Button } from '../../primitives/form/Input'
|
||||
import {
|
||||
InputLabelDescription,
|
||||
InputLabelTitle,
|
||||
SectionTitle,
|
||||
} from './SettingsPage'
|
||||
import {
|
||||
changeUserPreferences,
|
||||
changeUserPreferencesVariables,
|
||||
} from './__generated__/changeUserPreferences'
|
||||
import { myUserPreferences } from './__generated__/myUserPreferences'
|
||||
import { TranslationFn } from '../../localization'
|
||||
import { changeTheme, getTheme } from '../../theme'
|
||||
|
||||
const languagePreferences = [
|
||||
{ key: 1, label: 'English', value: LanguageTranslation.English },
|
||||
{ key: 2, label: 'Français', value: LanguageTranslation.French },
|
||||
{ key: 3, label: 'Svenska', value: LanguageTranslation.Swedish },
|
||||
{ key: 4, label: 'Dansk', value: LanguageTranslation.Danish },
|
||||
{ key: 5, label: 'Español', value: LanguageTranslation.Spanish },
|
||||
{ key: 6, label: 'Polski', value: LanguageTranslation.Polish },
|
||||
{ key: 7, label: 'Italiano', value: LanguageTranslation.Italian },
|
||||
{ key: 8, label: 'Deutsch', value: LanguageTranslation.German },
|
||||
{ key: 9, label: 'Русский', value: LanguageTranslation.Russian },
|
||||
{ key: 10, label: '繁體中文 (香港)', value: LanguageTranslation.TraditionalChineseHK },
|
||||
{ key: 16, label: '繁體中文 (台灣)', value: LanguageTranslation.TraditionalChineseTW },
|
||||
{ key: 11, label: '简体中文', value: LanguageTranslation.SimplifiedChinese },
|
||||
{ key: 12, label: 'Português', value: LanguageTranslation.Portuguese },
|
||||
{ key: 13, label: 'Euskara', value: LanguageTranslation.Basque },
|
||||
{ key: 14, label: 'Türkçe', value: LanguageTranslation.Turkish },
|
||||
{ key: 15, label: 'Українська', value: LanguageTranslation.Ukrainian },
|
||||
{ key: 17, label: '日本語', value: LanguageTranslation.Japanese },
|
||||
{ key: 18, label: 'Nederlands', value: LanguageTranslation.Dutch },
|
||||
]
|
||||
|
||||
const themePreferences = (t: TranslationFn) => [
|
||||
{
|
||||
key: 1,
|
||||
label: t('settings.user_preferences.theme.auto.label', 'Same as system'),
|
||||
value: 'auto',
|
||||
},
|
||||
{
|
||||
key: 2,
|
||||
label: t('settings.user_preferences.theme.light.label', 'Light'),
|
||||
value: 'light',
|
||||
},
|
||||
{
|
||||
key: 2,
|
||||
label: t('settings.user_preferences.theme.dark.label', 'Dark'),
|
||||
value: 'dark',
|
||||
},
|
||||
]
|
||||
|
||||
const CHANGE_USER_PREFERENCES = gql`
|
||||
mutation changeUserPreferences($language: String) {
|
||||
changeUserPreferences(language: $language) {
|
||||
id
|
||||
language
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const MY_USER_PREFERENCES = gql`
|
||||
query myUserPreferences {
|
||||
myUserPreferences {
|
||||
id
|
||||
language
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const LogoutButton = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="mb-4"
|
||||
onClick={() => {
|
||||
location.href = '/logout'
|
||||
}}
|
||||
>
|
||||
{t('settings.logout', 'Log out')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const UserPreferencesWrapper = styled.div`
|
||||
margin-bottom: 24px;
|
||||
`
|
||||
|
||||
const UserPreferences = () => {
|
||||
const { t } = useTranslation()
|
||||
const [theme, setTheme] = useState(getTheme())
|
||||
|
||||
const changeStateTheme = (value: string) => {
|
||||
changeTheme(value)
|
||||
setTheme(value)
|
||||
}
|
||||
|
||||
const { data } = useQuery<myUserPreferences>(MY_USER_PREFERENCES)
|
||||
|
||||
const [changePrefs, { loading: loadingPrefs, error }] = useMutation<
|
||||
changeUserPreferences,
|
||||
changeUserPreferencesVariables
|
||||
>(CHANGE_USER_PREFERENCES)
|
||||
|
||||
const sortedLanguagePrefs = useMemo(
|
||||
() => [...languagePreferences].sort((a, b) => a.label.localeCompare(b.label)),
|
||||
[]
|
||||
)
|
||||
|
||||
if (error) {
|
||||
return <div>{error.message}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<UserPreferencesWrapper>
|
||||
<SectionTitle nospace>
|
||||
{t('settings.user_preferences.title', 'User preferences')}
|
||||
</SectionTitle>
|
||||
<LogoutButton />
|
||||
<label htmlFor="user_pref_change_language_field">
|
||||
<InputLabelTitle>
|
||||
{t(
|
||||
'settings.user_preferences.change_language.label',
|
||||
'Website language'
|
||||
)}
|
||||
</InputLabelTitle>
|
||||
<InputLabelDescription>
|
||||
{t(
|
||||
'settings.user_preferences.change_language.description',
|
||||
'Change website language specific for this user'
|
||||
)}
|
||||
</InputLabelDescription>
|
||||
</label>
|
||||
<Dropdown
|
||||
id="user_pref_change_language_field"
|
||||
placeholder={t(
|
||||
'settings.user_preferences.language_selector.placeholder',
|
||||
'Select language'
|
||||
)}
|
||||
items={sortedLanguagePrefs}
|
||||
setSelected={language => {
|
||||
changePrefs({
|
||||
variables: {
|
||||
language: language as LanguageTranslation,
|
||||
},
|
||||
})
|
||||
}}
|
||||
selected={data?.myUserPreferences.language || undefined}
|
||||
disabled={loadingPrefs}
|
||||
/>
|
||||
<label htmlFor="user_pref_change_theme_field">
|
||||
<InputLabelTitle>
|
||||
{t('settings.user_preferences.theme.title', 'Theme preferences')}
|
||||
</InputLabelTitle>
|
||||
<InputLabelDescription>
|
||||
{t(
|
||||
'settings.user_preferences.theme.description',
|
||||
'Change the appearance of the website'
|
||||
)}
|
||||
</InputLabelDescription>
|
||||
</label>
|
||||
<Dropdown
|
||||
id="user_pref_change_theme_field"
|
||||
items={themePreferences(t)}
|
||||
setSelected={changeStateTheme}
|
||||
selected={theme}
|
||||
/>
|
||||
</UserPreferencesWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserPreferences
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import AddUserRow, {
|
||||
CREATE_USER_MUTATION,
|
||||
USER_ADD_ROOT_PATH_MUTATION,
|
||||
} from './AddUserRow'
|
||||
import { MockedProvider } from '@apollo/client/testing'
|
||||
|
||||
const gqlMock = [
|
||||
{
|
||||
request: {
|
||||
query: CREATE_USER_MUTATION,
|
||||
variables: { username: 'testuser', admin: false },
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
createUser: {
|
||||
id: '123',
|
||||
username: 'testuser',
|
||||
admin: false,
|
||||
__typename: 'User',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
request: {
|
||||
query: USER_ADD_ROOT_PATH_MUTATION,
|
||||
variables: { id: '123', rootPath: '/tmp' },
|
||||
},
|
||||
result: { data: { userAddRootPath: { id: '567', __typename: 'Album' } } },
|
||||
},
|
||||
]
|
||||
|
||||
test('Add user with username and path', async () => {
|
||||
const userAdded = vi.fn()
|
||||
const setShow = vi.fn()
|
||||
|
||||
render(
|
||||
<MockedProvider addTypename={true} mocks={gqlMock}>
|
||||
<table>
|
||||
<tbody>
|
||||
<AddUserRow onUserAdded={userAdded} setShow={setShow} show={true} />
|
||||
</tbody>
|
||||
</table>
|
||||
</MockedProvider>
|
||||
)
|
||||
|
||||
const usernameInput = screen.getByPlaceholderText('Username')
|
||||
const pathInput = screen.getByPlaceholderText('/path/to/photos')
|
||||
const addUserBtn = screen.getByText('Add user')
|
||||
|
||||
fireEvent.change(usernameInput, { target: { value: 'testuser' } })
|
||||
fireEvent.change(pathInput, { target: { value: '/tmp' } })
|
||||
fireEvent.click(addUserBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(userAdded).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
expect(setShow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('Add user with only username', async () => {
|
||||
const userAdded = vi.fn()
|
||||
const setShow = vi.fn()
|
||||
|
||||
render(
|
||||
<MockedProvider addTypename={true} mocks={gqlMock}>
|
||||
<table>
|
||||
<tbody>
|
||||
<AddUserRow onUserAdded={userAdded} setShow={setShow} show={true} />
|
||||
</tbody>
|
||||
</table>
|
||||
</MockedProvider>
|
||||
)
|
||||
|
||||
const usernameInput = screen.getByPlaceholderText('Username')
|
||||
const addUserBtn = screen.getByText('Add user')
|
||||
|
||||
// don't set path
|
||||
// const pathInput = screen.getByPlaceholderText('/path/to/photos')
|
||||
// fireEvent.change(pathInput, { target: { value: '/tmp' } })
|
||||
|
||||
fireEvent.change(usernameInput, { target: { value: 'testuser' } })
|
||||
fireEvent.click(addUserBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(userAdded).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
expect(setShow).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
import { gql, useMutation } from '@apollo/client'
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Checkbox from '../../../primitives/form/Checkbox'
|
||||
import { TextField, Button, ButtonGroup } from '../../../primitives/form/Input'
|
||||
import { TableRow, TableCell } from '../../../primitives/Table'
|
||||
import { createUser, createUserVariables } from './__generated__/createUser'
|
||||
import {
|
||||
userAddRootPath,
|
||||
userAddRootPathVariables,
|
||||
} from './__generated__/userAddRootPath'
|
||||
|
||||
export const CREATE_USER_MUTATION = gql`
|
||||
mutation createUser($username: String!, $admin: Boolean!) {
|
||||
createUser(username: $username, admin: $admin) {
|
||||
id
|
||||
username
|
||||
admin
|
||||
__typename
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const USER_ADD_ROOT_PATH_MUTATION = gql`
|
||||
mutation userAddRootPath($id: ID!, $rootPath: String!) {
|
||||
userAddRootPath(id: $id, rootPath: $rootPath) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const initialState = {
|
||||
username: '',
|
||||
rootPath: '',
|
||||
admin: false,
|
||||
userAdded: false,
|
||||
}
|
||||
|
||||
type AddUserRowProps = {
|
||||
setShow: React.Dispatch<React.SetStateAction<boolean>>
|
||||
show: boolean
|
||||
onUserAdded(): void
|
||||
}
|
||||
|
||||
const AddUserRow = ({ setShow, show, onUserAdded }: AddUserRowProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [state, setState] = useState(initialState)
|
||||
|
||||
const finished = () => {
|
||||
setState(initialState)
|
||||
onUserAdded()
|
||||
}
|
||||
|
||||
const [addRootPath, { loading: addRootPathLoading }] = useMutation<
|
||||
userAddRootPath,
|
||||
userAddRootPathVariables
|
||||
>(USER_ADD_ROOT_PATH_MUTATION, {
|
||||
onCompleted: () => {
|
||||
finished()
|
||||
},
|
||||
onError: () => {
|
||||
finished()
|
||||
},
|
||||
})
|
||||
|
||||
const [createUser, { loading: createUserLoading }] = useMutation<
|
||||
createUser,
|
||||
createUserVariables
|
||||
>(CREATE_USER_MUTATION, {
|
||||
onCompleted: ({ createUser: { id } }) => {
|
||||
if (state.rootPath) {
|
||||
addRootPath({
|
||||
variables: {
|
||||
id: id,
|
||||
rootPath: state.rootPath,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
finished()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const loading = addRootPathLoading || createUserLoading
|
||||
|
||||
function updateInput(
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
key: string
|
||||
) {
|
||||
setState({
|
||||
...state,
|
||||
[key]: event.target.value,
|
||||
})
|
||||
}
|
||||
|
||||
if (!show) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<TextField
|
||||
placeholder={t('login_page.field.username', 'Username')}
|
||||
value={state.username}
|
||||
onChange={e => updateInput(e, 'username')}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextField
|
||||
placeholder={t(
|
||||
'login_page.initial_setup.field.photo_path.placeholder',
|
||||
'/path/to/photos'
|
||||
)}
|
||||
value={state.rootPath}
|
||||
onChange={e => updateInput(e, 'rootPath')}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
label="Admin"
|
||||
checked={state.admin}
|
||||
onChange={e => {
|
||||
setState({
|
||||
...state,
|
||||
admin: e.target.checked || false,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ButtonGroup>
|
||||
<Button variant="negative" onClick={() => setShow(false)}>
|
||||
{t('general.action.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
variant="positive"
|
||||
onClick={() => {
|
||||
createUser({
|
||||
variables: {
|
||||
username: state.username,
|
||||
admin: state.admin,
|
||||
},
|
||||
})
|
||||
}}
|
||||
>
|
||||
{t('settings.users.add_user.submit', 'Add user')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddUserRow
|
||||
@@ -0,0 +1,88 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { EditRootPaths } from './EditUserRowRootPaths'
|
||||
import { UserRowChildProps } from './UserRow'
|
||||
import { TableRow, TableCell } from '../../../primitives/Table'
|
||||
import { TextField } from '../../../primitives/form/Input'
|
||||
import { Button, ButtonGroup } from '../../../primitives/form/Input'
|
||||
import Checkbox from '../../../primitives/form/Checkbox'
|
||||
|
||||
const EditUserRow = ({
|
||||
user,
|
||||
state,
|
||||
setState,
|
||||
updateUser,
|
||||
updateUserLoading,
|
||||
}: UserRowChildProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
function updateInput(
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
key: string
|
||||
) {
|
||||
setState(state => ({
|
||||
...state,
|
||||
[key]: event.target.value,
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<TextField
|
||||
style={{ width: '100%' }}
|
||||
placeholder={user.username}
|
||||
value={state.username}
|
||||
onChange={e => updateInput(e, 'username')}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<EditRootPaths user={user} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
label="Admin"
|
||||
checked={state.admin}
|
||||
onChange={e => {
|
||||
setState(state => ({
|
||||
...state,
|
||||
admin: e.target.checked || false,
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
variant="negative"
|
||||
onClick={() =>
|
||||
setState(state => ({
|
||||
...state,
|
||||
...state.oldState,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{t('general.action.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={updateUserLoading}
|
||||
variant="positive"
|
||||
onClick={() =>
|
||||
updateUser({
|
||||
variables: {
|
||||
id: user.id,
|
||||
username: state.username,
|
||||
admin: state.admin,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('general.action.save', 'Save')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditUserRow
|
||||
@@ -0,0 +1,125 @@
|
||||
import React, { useState } from 'react'
|
||||
import { gql, useMutation } from '@apollo/client'
|
||||
import { USERS_QUERY } from './UsersTable'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { USER_ADD_ROOT_PATH_MUTATION } from './AddUserRow'
|
||||
import {
|
||||
userRemoveAlbumPathMutation,
|
||||
userRemoveAlbumPathMutationVariables,
|
||||
} from './__generated__/userRemoveAlbumPathMutation'
|
||||
import {
|
||||
settingsUsersQuery_user,
|
||||
settingsUsersQuery_user_rootAlbums,
|
||||
} from './__generated__/settingsUsersQuery'
|
||||
import { userAddRootPath } from './__generated__/userAddRootPath'
|
||||
import { Button, TextField } from '../../../primitives/form/Input'
|
||||
|
||||
const USER_REMOVE_ALBUM_PATH_MUTATION = gql`
|
||||
mutation userRemoveAlbumPathMutation($userId: ID!, $albumId: ID!) {
|
||||
userRemoveRootAlbum(userId: $userId, albumId: $albumId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
type EditRootPathProps = {
|
||||
album: settingsUsersQuery_user_rootAlbums
|
||||
user: settingsUsersQuery_user
|
||||
}
|
||||
|
||||
const EditRootPath = ({ album, user }: EditRootPathProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [removeAlbumPath, { loading }] = useMutation<
|
||||
userRemoveAlbumPathMutation,
|
||||
userRemoveAlbumPathMutationVariables
|
||||
>(USER_REMOVE_ALBUM_PATH_MUTATION, {
|
||||
refetchQueries: [
|
||||
{
|
||||
query: USERS_QUERY,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
return (
|
||||
<li className="flex justify-between">
|
||||
<span>{album.filePath}</span>
|
||||
<Button
|
||||
variant="negative"
|
||||
disabled={loading}
|
||||
onClick={() =>
|
||||
removeAlbumPath({
|
||||
variables: {
|
||||
userId: user.id,
|
||||
albumId: album.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('general.action.remove', 'Remove')}
|
||||
</Button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
type EditNewRootPathProps = {
|
||||
userID: string
|
||||
}
|
||||
|
||||
const EditNewRootPath = ({ userID }: EditNewRootPathProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [value, setValue] = useState('')
|
||||
const [addRootPath, { loading }] = useMutation<userAddRootPath>(
|
||||
USER_ADD_ROOT_PATH_MUTATION,
|
||||
{
|
||||
refetchQueries: [
|
||||
{
|
||||
query: USERS_QUERY,
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<li className="flex gap-1 mt-2">
|
||||
<TextField
|
||||
value={value}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setValue(e.target.value)
|
||||
}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
variant="positive"
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setValue('')
|
||||
addRootPath({
|
||||
variables: {
|
||||
id: userID,
|
||||
rootPath: value,
|
||||
},
|
||||
})
|
||||
}}
|
||||
>
|
||||
{t('general.action.add', 'Add')}
|
||||
</Button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
type EditRootPathsProps = {
|
||||
user: settingsUsersQuery_user
|
||||
}
|
||||
|
||||
export const EditRootPaths = ({ user }: EditRootPathsProps) => {
|
||||
const editRows = user.rootAlbums.map(album => (
|
||||
<EditRootPath key={album.id} album={album} user={user} />
|
||||
))
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{editRows}
|
||||
<EditNewRootPath userID={user.id} />
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import React, { useState } from 'react'
|
||||
import { gql, useMutation } from '@apollo/client'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { settingsUsersQuery_user } from './__generated__/settingsUsersQuery'
|
||||
import Modal from '../../../primitives/Modal'
|
||||
import { TextField } from '../../../primitives/form/Input'
|
||||
|
||||
const changeUserPasswordMutation = gql`
|
||||
mutation changeUserPassword($userId: ID!, $password: String!) {
|
||||
updateUser(id: $userId, password: $password) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
interface ChangePasswordModalProps {
|
||||
onClose(): void
|
||||
open: boolean
|
||||
user: settingsUsersQuery_user
|
||||
}
|
||||
|
||||
const ChangePasswordModal = ({
|
||||
onClose,
|
||||
user,
|
||||
open,
|
||||
}: ChangePasswordModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [passwordInput, setPasswordInput] = useState('')
|
||||
|
||||
const [changePassword] = useMutation(changeUserPasswordMutation, {
|
||||
onCompleted: () => {
|
||||
onClose && onClose()
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t('settings.users.password_reset.title', 'Change password')}
|
||||
description={
|
||||
<Trans t={t} i18nKey="settings.users.password_reset.description">
|
||||
Change password for <b>{user.username}</b>
|
||||
</Trans>
|
||||
}
|
||||
actions={[
|
||||
{
|
||||
key: 'cancel',
|
||||
label: t('general.action.cancel', 'Cancel'),
|
||||
onClick: () => onClose && onClose(),
|
||||
},
|
||||
{
|
||||
key: 'change_password',
|
||||
label: t(
|
||||
'settings.users.password_reset.form.submit',
|
||||
'Change password'
|
||||
),
|
||||
variant: 'positive',
|
||||
onClick: () => {
|
||||
changePassword({
|
||||
variables: {
|
||||
userId: user.id,
|
||||
password: passwordInput,
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<div className="w-[360px]">
|
||||
<TextField
|
||||
label={t('settings.users.password_reset.form.label', 'New password')}
|
||||
placeholder={t(
|
||||
'settings.users.password_reset.form.placeholder',
|
||||
'password'
|
||||
)}
|
||||
onChange={e => setPasswordInput(e.target.value)}
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChangePasswordModal
|
||||
@@ -0,0 +1,137 @@
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
FetchResult,
|
||||
gql,
|
||||
MutationFunctionOptions,
|
||||
useMutation,
|
||||
} from '@apollo/client'
|
||||
import EditUserRow from './EditUserRow'
|
||||
import ViewUserRow from './ViewUserRow'
|
||||
import { settingsUsersQuery_user } from './__generated__/settingsUsersQuery'
|
||||
import { scanUser, scanUserVariables } from './__generated__/scanUser'
|
||||
import { updateUser, updateUserVariables } from './__generated__/updateUser'
|
||||
import { deleteUser, deleteUserVariables } from './__generated__/deleteUser'
|
||||
|
||||
const updateUserMutation = gql`
|
||||
mutation updateUser($id: ID!, $username: String, $admin: Boolean) {
|
||||
updateUser(id: $id, username: $username, admin: $admin) {
|
||||
id
|
||||
username
|
||||
admin
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const deleteUserMutation = gql`
|
||||
mutation deleteUser($id: ID!) {
|
||||
deleteUser(id: $id) {
|
||||
id
|
||||
username
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const scanUserMutation = gql`
|
||||
mutation scanUser($userId: ID!) {
|
||||
scanUser(userId: $userId) {
|
||||
success
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
interface UserRowState extends settingsUsersQuery_user {
|
||||
editing: boolean
|
||||
newRootPath: string
|
||||
oldState?: Omit<UserRowState, 'oldState'>
|
||||
}
|
||||
|
||||
type ApolloMutationFn<MutationType, VariablesType> = (
|
||||
options?: MutationFunctionOptions<MutationType, VariablesType>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) => Promise<FetchResult<MutationType, any, any>>
|
||||
|
||||
export type UserRowChildProps = {
|
||||
user: settingsUsersQuery_user
|
||||
state: UserRowState
|
||||
setState: React.Dispatch<React.SetStateAction<UserRowState>>
|
||||
scanUser: ApolloMutationFn<scanUser, scanUserVariables>
|
||||
updateUser: ApolloMutationFn<updateUser, updateUserVariables>
|
||||
updateUserLoading: boolean
|
||||
deleteUser: ApolloMutationFn<deleteUser, deleteUserVariables>
|
||||
setChangePassword: React.Dispatch<React.SetStateAction<boolean>>
|
||||
setConfirmDelete: React.Dispatch<React.SetStateAction<boolean>>
|
||||
scanUserCalled: boolean
|
||||
showChangePassword: boolean
|
||||
showConfirmDelete: boolean
|
||||
}
|
||||
|
||||
export type UserRowProps = {
|
||||
user: settingsUsersQuery_user
|
||||
refetchUsers: () => void
|
||||
}
|
||||
|
||||
const UserRow = ({ user, refetchUsers }: UserRowProps) => {
|
||||
const [state, setState] = useState<UserRowState>({
|
||||
...user,
|
||||
editing: false,
|
||||
newRootPath: '',
|
||||
})
|
||||
|
||||
const [showConfirmDelete, setConfirmDelete] = useState(false)
|
||||
const [showChangePassword, setChangePassword] = useState(false)
|
||||
|
||||
const [updateUser, { loading: updateUserLoading }] = useMutation<
|
||||
updateUser,
|
||||
updateUserVariables
|
||||
>(updateUserMutation, {
|
||||
onCompleted: data => {
|
||||
setState(state => ({
|
||||
...state,
|
||||
...data.updateUser,
|
||||
editing: false,
|
||||
}))
|
||||
refetchUsers()
|
||||
},
|
||||
})
|
||||
|
||||
const [deleteUser] = useMutation<deleteUser, deleteUserVariables>(
|
||||
deleteUserMutation,
|
||||
{
|
||||
onCompleted: () => {
|
||||
refetchUsers()
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const [scanUser, { called: scanUserCalled }] = useMutation<
|
||||
scanUser,
|
||||
scanUserVariables
|
||||
>(scanUserMutation, {
|
||||
onCompleted: () => {
|
||||
refetchUsers()
|
||||
},
|
||||
})
|
||||
|
||||
const props: UserRowChildProps = {
|
||||
user,
|
||||
state,
|
||||
setState,
|
||||
scanUser,
|
||||
updateUser,
|
||||
updateUserLoading,
|
||||
deleteUser,
|
||||
setChangePassword,
|
||||
setConfirmDelete,
|
||||
scanUserCalled,
|
||||
showChangePassword,
|
||||
showConfirmDelete,
|
||||
}
|
||||
|
||||
if (state.editing) {
|
||||
return <EditUserRow {...props} />
|
||||
}
|
||||
|
||||
return <ViewUserRow {...props} />
|
||||
}
|
||||
|
||||
export default UserRow
|
||||
@@ -0,0 +1,113 @@
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableScrollWrapper,
|
||||
} from '../../../primitives/Table'
|
||||
import { useQuery, gql } from '@apollo/client'
|
||||
import UserRow from './UserRow'
|
||||
import AddUserRow from './AddUserRow'
|
||||
import { SectionTitle } from '../SettingsPage'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { settingsUsersQuery } from './__generated__/settingsUsersQuery'
|
||||
import { Button } from '../../../primitives/form/Input'
|
||||
import Loader from '../../../primitives/Loader'
|
||||
|
||||
export const USERS_QUERY = gql`
|
||||
query settingsUsersQuery {
|
||||
user {
|
||||
id
|
||||
username
|
||||
admin
|
||||
rootAlbums {
|
||||
id
|
||||
filePath
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const UsersTable = () => {
|
||||
const { t } = useTranslation()
|
||||
const [showAddUser, setShowAddUser] = useState(false)
|
||||
|
||||
const { loading, error, data, refetch } =
|
||||
useQuery<settingsUsersQuery>(USERS_QUERY)
|
||||
|
||||
if (error) {
|
||||
return <div>{`Users table error: ${error.message}`}</div>
|
||||
}
|
||||
|
||||
let userRows: JSX.Element[] = []
|
||||
if (data?.user) {
|
||||
userRows = data.user.map(user => (
|
||||
<UserRow user={user} refetchUsers={refetch} key={user.id} />
|
||||
))
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionTitle>{t('settings.users.title', 'Users')}</SectionTitle>
|
||||
<Loader active={loading} />
|
||||
<TableScrollWrapper>
|
||||
<Table className="w-full max-w-6xl">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>
|
||||
{t('settings.users.table.column_names.username', 'Username')}
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>
|
||||
{t(
|
||||
'settings.users.table.column_names.photo_path',
|
||||
'Photo path'
|
||||
)}
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>
|
||||
{t(
|
||||
'settings.users.table.column_names.capabilities',
|
||||
'Capabilities'
|
||||
)}
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="w-0 whitespace-nowrap">
|
||||
{t('settings.users.table.column_names.action', 'Action')}
|
||||
</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{userRows}
|
||||
<AddUserRow
|
||||
show={showAddUser}
|
||||
setShow={setShowAddUser}
|
||||
onUserAdded={() => {
|
||||
setShowAddUser(false)
|
||||
refetch()
|
||||
}}
|
||||
/>
|
||||
</TableBody>
|
||||
|
||||
<TableFooter>
|
||||
<TableRow>
|
||||
<TableHeaderCell colSpan={4} className="text-right">
|
||||
<Button
|
||||
variant="positive"
|
||||
background="white"
|
||||
disabled={showAddUser}
|
||||
onClick={() => setShowAddUser(true)}
|
||||
>
|
||||
{t('settings.users.table.new_user', 'New user')}
|
||||
</Button>
|
||||
</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</TableScrollWrapper>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UsersTable
|
||||
@@ -0,0 +1,123 @@
|
||||
import React from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import Checkbox from '../../../primitives/form/Checkbox'
|
||||
import { Button } from '../../../primitives/form/Input'
|
||||
import Modal from '../../../primitives/Modal'
|
||||
import { TableCell, TableRow } from '../../../primitives/Table'
|
||||
import ChangePasswordModal from './UserChangePassword'
|
||||
import { UserRowChildProps } from './UserRow'
|
||||
|
||||
const ViewUserRow = ({
|
||||
user,
|
||||
// state,
|
||||
setState,
|
||||
scanUser,
|
||||
deleteUser,
|
||||
setChangePassword,
|
||||
setConfirmDelete,
|
||||
scanUserCalled,
|
||||
showChangePassword,
|
||||
showConfirmDelete,
|
||||
}: UserRowChildProps) => {
|
||||
const { t } = useTranslation()
|
||||
const paths = (
|
||||
<ul>
|
||||
{user.rootAlbums.map(album => (
|
||||
<li key={album.id}>{album.filePath}</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>{user.username}</TableCell>
|
||||
<TableCell>{paths}</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox label="Admin" disabled checked={user.admin} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setState(state => {
|
||||
const oldState = { ...state }
|
||||
delete oldState.oldState
|
||||
return { ...state, editing: true, oldState }
|
||||
})
|
||||
}}
|
||||
>
|
||||
{t('settings.users.table.row.action.edit', 'Edit')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={scanUserCalled}
|
||||
onClick={() => scanUser({ variables: { userId: user.id } })}
|
||||
>
|
||||
{t('settings.users.table.row.action.scan', 'Scan')}
|
||||
</Button>
|
||||
<Button onClick={() => setChangePassword(true)}>
|
||||
{t(
|
||||
'settings.users.table.row.action.change_password',
|
||||
'Change password'
|
||||
)}
|
||||
</Button>
|
||||
<ChangePasswordModal
|
||||
user={user}
|
||||
open={showChangePassword}
|
||||
onClose={() => setChangePassword(false)}
|
||||
/>
|
||||
<Button
|
||||
variant="negative"
|
||||
onClick={() => {
|
||||
setConfirmDelete(true)
|
||||
}}
|
||||
>
|
||||
{t('settings.users.table.row.action.delete', 'Delete')}
|
||||
</Button>
|
||||
<Modal
|
||||
open={showConfirmDelete}
|
||||
onClose={() => setConfirmDelete(false)}
|
||||
title={t('settings.users.confirm_delete_user.title', 'Delete user')}
|
||||
actions={[
|
||||
{
|
||||
key: 'cancel',
|
||||
label: t('general.action.cancel', 'Cancel'),
|
||||
onClick: () => setConfirmDelete(false),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: t(
|
||||
'settings.users.confirm_delete_user.action',
|
||||
'Delete {{user}}',
|
||||
{ user: user.username }
|
||||
),
|
||||
onClick: () => {
|
||||
setConfirmDelete(false)
|
||||
deleteUser({
|
||||
variables: {
|
||||
id: user.id,
|
||||
},
|
||||
})
|
||||
},
|
||||
variant: 'negative',
|
||||
},
|
||||
]}
|
||||
description={
|
||||
<Trans
|
||||
t={t}
|
||||
i18nKey="settings.users.confirm_delete_user.description"
|
||||
>
|
||||
<p>
|
||||
{`Are you sure, you want to delete `}
|
||||
<b>{user.username}</b>?
|
||||
</p>
|
||||
<p>{`This action cannot be undone`}</p>
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
export default ViewUserRow
|
||||
@@ -0,0 +1,25 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
// ====================================================
|
||||
// GraphQL mutation operation: changeUserPassword
|
||||
// ====================================================
|
||||
|
||||
export interface changeUserPassword_updateUser {
|
||||
__typename: "User";
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface changeUserPassword {
|
||||
/**
|
||||
* Update a user, fields left as `null` will not be changed
|
||||
*/
|
||||
updateUser: changeUserPassword_updateUser;
|
||||
}
|
||||
|
||||
export interface changeUserPasswordVariables {
|
||||
userId: string;
|
||||
password: string;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
// ====================================================
|
||||
// GraphQL mutation operation: createUser
|
||||
// ====================================================
|
||||
|
||||
export interface createUser_createUser {
|
||||
__typename: "User";
|
||||
id: string;
|
||||
username: string;
|
||||
/**
|
||||
* Whether or not the user has admin privileges
|
||||
*/
|
||||
admin: boolean;
|
||||
}
|
||||
|
||||
export interface createUser {
|
||||
/**
|
||||
* Create a new user
|
||||
*/
|
||||
createUser: createUser_createUser;
|
||||
}
|
||||
|
||||
export interface createUserVariables {
|
||||
username: string;
|
||||
admin: boolean;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
// ====================================================
|
||||
// GraphQL mutation operation: deleteUser
|
||||
// ====================================================
|
||||
|
||||
export interface deleteUser_deleteUser {
|
||||
__typename: "User";
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface deleteUser {
|
||||
/**
|
||||
* Delete an existing user
|
||||
*/
|
||||
deleteUser: deleteUser_deleteUser;
|
||||
}
|
||||
|
||||
export interface deleteUserVariables {
|
||||
id: string;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
// ====================================================
|
||||
// GraphQL mutation operation: scanUser
|
||||
// ====================================================
|
||||
|
||||
export interface scanUser_scanUser {
|
||||
__typename: "ScannerResult";
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface scanUser {
|
||||
/**
|
||||
* Scan a single user for new media
|
||||
*/
|
||||
scanUser: scanUser_scanUser;
|
||||
}
|
||||
|
||||
export interface scanUserVariables {
|
||||
userId: string;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
// ====================================================
|
||||
// GraphQL query operation: settingsUsersQuery
|
||||
// ====================================================
|
||||
|
||||
export interface settingsUsersQuery_user_rootAlbums {
|
||||
__typename: "Album";
|
||||
id: string;
|
||||
/**
|
||||
* The path on the filesystem of the server, where this album is located
|
||||
*/
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
export interface settingsUsersQuery_user {
|
||||
__typename: "User";
|
||||
id: string;
|
||||
username: string;
|
||||
/**
|
||||
* Whether or not the user has admin privileges
|
||||
*/
|
||||
admin: boolean;
|
||||
/**
|
||||
* Top level albums owned by this user
|
||||
*/
|
||||
rootAlbums: settingsUsersQuery_user_rootAlbums[];
|
||||
}
|
||||
|
||||
export interface settingsUsersQuery {
|
||||
/**
|
||||
* List of registered users, must be admin to call
|
||||
*/
|
||||
user: settingsUsersQuery_user[];
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
// ====================================================
|
||||
// GraphQL mutation operation: updateUser
|
||||
// ====================================================
|
||||
|
||||
export interface updateUser_updateUser {
|
||||
__typename: "User";
|
||||
id: string;
|
||||
username: string;
|
||||
/**
|
||||
* Whether or not the user has admin privileges
|
||||
*/
|
||||
admin: boolean;
|
||||
}
|
||||
|
||||
export interface updateUser {
|
||||
/**
|
||||
* Update a user, fields left as `null` will not be changed
|
||||
*/
|
||||
updateUser: updateUser_updateUser;
|
||||
}
|
||||
|
||||
export interface updateUserVariables {
|
||||
id: string;
|
||||
username?: string | null;
|
||||
admin?: boolean | null;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
// ====================================================
|
||||
// GraphQL mutation operation: userAddRootPath
|
||||
// ====================================================
|
||||
|
||||
export interface userAddRootPath_userAddRootPath {
|
||||
__typename: "Album";
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface userAddRootPath {
|
||||
/**
|
||||
* Add a root path from where to look for media for the given user, specified by their user id.
|
||||
*/
|
||||
userAddRootPath: userAddRootPath_userAddRootPath | null;
|
||||
}
|
||||
|
||||
export interface userAddRootPathVariables {
|
||||
id: string;
|
||||
rootPath: string;
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
// ====================================================
|
||||
// GraphQL mutation operation: userRemoveAlbumPathMutation
|
||||
// ====================================================
|
||||
|
||||
export interface userRemoveAlbumPathMutation_userRemoveRootAlbum {
|
||||
__typename: "Album";
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface userRemoveAlbumPathMutation {
|
||||
/**
|
||||
* Remove a root path from a user, specified by the id of the user and the top album representing the root path.
|
||||
* This album was returned when creating the path using `userAddRootPath`.
|
||||
* A list of root paths for a particular user can be retrived from the `User.rootAlbums` path.
|
||||
*/
|
||||
userRemoveRootAlbum: userRemoveAlbumPathMutation_userRemoveRootAlbum | null;
|
||||
}
|
||||
|
||||
export interface userRemoveAlbumPathMutationVariables {
|
||||
userId: string;
|
||||
albumId: string;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import React, { ReactElement } from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
InputLabelDescription,
|
||||
InputLabelTitle,
|
||||
SectionTitle,
|
||||
} from './SettingsPage'
|
||||
|
||||
const VERSION = import.meta.env.REACT_APP_BUILD_VERSION ?? 'undefined'
|
||||
const BUILD_DATE = import.meta.env.REACT_APP_BUILD_DATE ?? 'undefined'
|
||||
|
||||
const COMMIT_SHA = import.meta.env.REACT_APP_BUILD_COMMIT_SHA
|
||||
let commitLink: ReactElement
|
||||
|
||||
if (COMMIT_SHA) {
|
||||
commitLink = React.createElement(
|
||||
'a',
|
||||
{
|
||||
href: 'https://github.com/photoview/photoview/commit/' + COMMIT_SHA,
|
||||
title: COMMIT_SHA,
|
||||
},
|
||||
COMMIT_SHA.substring(0, 8)
|
||||
)
|
||||
}
|
||||
|
||||
const VersionInfoWrapper = styled.div`
|
||||
margin-bottom: 24px;
|
||||
`
|
||||
|
||||
const VersionInfo = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<VersionInfoWrapper>
|
||||
<SectionTitle>
|
||||
{t('settings.version_info.title', 'Photoview Version')}
|
||||
</SectionTitle>
|
||||
<InputLabelTitle>
|
||||
{t('settings.version_info.version_title', 'Release Version')}
|
||||
</InputLabelTitle>
|
||||
<InputLabelDescription>
|
||||
{VERSION} ({commitLink})
|
||||
</InputLabelDescription>
|
||||
<InputLabelTitle>
|
||||
{t('settings.version_info.build_date_title', 'Build date')}
|
||||
</InputLabelTitle>
|
||||
<InputLabelDescription>{BUILD_DATE}</InputLabelDescription>
|
||||
</VersionInfoWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export default VersionInfo
|
||||
@@ -0,0 +1,20 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
// ====================================================
|
||||
// GraphQL mutation operation: changeScanIntervalMutation
|
||||
// ====================================================
|
||||
|
||||
export interface changeScanIntervalMutation {
|
||||
/**
|
||||
* Set how often, in seconds, the server should automatically scan for new media,
|
||||
* a value of 0 will disable periodic scans
|
||||
*/
|
||||
setPeriodicScanInterval: number;
|
||||
}
|
||||
|
||||
export interface changeScanIntervalMutationVariables {
|
||||
interval: number;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
import { LanguageTranslation } from "./../../../__generated__/globalTypes";
|
||||
|
||||
// ====================================================
|
||||
// GraphQL mutation operation: changeUserPreferences
|
||||
// ====================================================
|
||||
|
||||
export interface changeUserPreferences_changeUserPreferences {
|
||||
__typename: "UserPreferences";
|
||||
id: string;
|
||||
language: LanguageTranslation | null;
|
||||
}
|
||||
|
||||
export interface changeUserPreferences {
|
||||
/**
|
||||
* Change user preferences for the logged in user
|
||||
*/
|
||||
changeUserPreferences: changeUserPreferences_changeUserPreferences;
|
||||
}
|
||||
|
||||
export interface changeUserPreferencesVariables {
|
||||
language?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
// ====================================================
|
||||
// GraphQL query operation: concurrentWorkersQuery
|
||||
// ====================================================
|
||||
|
||||
export interface concurrentWorkersQuery_siteInfo {
|
||||
__typename: "SiteInfo";
|
||||
/**
|
||||
* How many max concurrent scanner jobs that should run at once
|
||||
*/
|
||||
concurrentWorkers: number;
|
||||
}
|
||||
|
||||
export interface concurrentWorkersQuery {
|
||||
siteInfo: concurrentWorkersQuery_siteInfo;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
import { LanguageTranslation } from "./../../../__generated__/globalTypes";
|
||||
|
||||
// ====================================================
|
||||
// GraphQL query operation: myUserPreferences
|
||||
// ====================================================
|
||||
|
||||
export interface myUserPreferences_myUserPreferences {
|
||||
__typename: "UserPreferences";
|
||||
id: string;
|
||||
language: LanguageTranslation | null;
|
||||
}
|
||||
|
||||
export interface myUserPreferences {
|
||||
/**
|
||||
* User preferences for the logged in user
|
||||
*/
|
||||
myUserPreferences: myUserPreferences_myUserPreferences;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
// ====================================================
|
||||
// GraphQL mutation operation: scanAllMutation
|
||||
// ====================================================
|
||||
|
||||
export interface scanAllMutation_scanAll {
|
||||
__typename: "ScannerResult";
|
||||
success: boolean;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export interface scanAllMutation {
|
||||
/**
|
||||
* Scan all users for new media
|
||||
*/
|
||||
scanAll: scanAllMutation_scanAll;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
// ====================================================
|
||||
// GraphQL query operation: scanIntervalQuery
|
||||
// ====================================================
|
||||
|
||||
export interface scanIntervalQuery_siteInfo {
|
||||
__typename: "SiteInfo";
|
||||
/**
|
||||
* How often automatic scans should be initiated in seconds
|
||||
*/
|
||||
periodicScanInterval: number;
|
||||
}
|
||||
|
||||
export interface scanIntervalQuery {
|
||||
siteInfo: scanIntervalQuery_siteInfo;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
// ====================================================
|
||||
// GraphQL mutation operation: setConcurrentWorkers
|
||||
// ====================================================
|
||||
|
||||
export interface setConcurrentWorkers {
|
||||
/**
|
||||
* Set max number of concurrent scanner jobs running at once
|
||||
*/
|
||||
setScannerConcurrentWorkers: number;
|
||||
}
|
||||
|
||||
export interface setConcurrentWorkersVariables {
|
||||
workers: number;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import React from 'react'
|
||||
import Layout from '../../components/layout/Layout'
|
||||
import AlbumGallery from '../../components/albumGallery/AlbumGallery'
|
||||
import styled from 'styled-components'
|
||||
import { gql, useQuery } from '@apollo/client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useURLParameters from '../../hooks/useURLParameters'
|
||||
import useOrderingParams from '../../hooks/useOrderingParams'
|
||||
import { shareAlbumQuery } from './__generated__/shareAlbumQuery'
|
||||
import useScrollPagination from '../../hooks/useScrollPagination'
|
||||
import PaginateLoader from '../../components/PaginateLoader'
|
||||
|
||||
export const SHARE_ALBUM_QUERY = gql`
|
||||
query shareAlbumQuery(
|
||||
$id: ID!
|
||||
$token: String!
|
||||
$password: String
|
||||
$mediaOrderBy: String
|
||||
$mediaOrderDirection: OrderDirection
|
||||
$limit: Int
|
||||
$offset: Int
|
||||
) {
|
||||
album(id: $id, tokenCredentials: { token: $token, password: $password }) {
|
||||
id
|
||||
title
|
||||
subAlbums(order: { order_by: "title" }) {
|
||||
id
|
||||
title
|
||||
thumbnail {
|
||||
id
|
||||
thumbnail {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
media(
|
||||
paginate: { limit: $limit, offset: $offset }
|
||||
order: {
|
||||
order_by: $mediaOrderBy
|
||||
order_direction: $mediaOrderDirection
|
||||
}
|
||||
) {
|
||||
id
|
||||
title
|
||||
type
|
||||
blurhash
|
||||
thumbnail {
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
downloads {
|
||||
title
|
||||
mediaUrl {
|
||||
url
|
||||
width
|
||||
height
|
||||
fileSize
|
||||
}
|
||||
}
|
||||
highRes {
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
videoWeb {
|
||||
url
|
||||
}
|
||||
exif {
|
||||
id
|
||||
description
|
||||
camera
|
||||
maker
|
||||
lens
|
||||
dateShot
|
||||
exposure
|
||||
aperture
|
||||
iso
|
||||
focalLength
|
||||
flash
|
||||
exposureProgram
|
||||
coordinates {
|
||||
latitude
|
||||
longitude
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const AlbumSharePageWrapper = styled.div`
|
||||
height: 100%;
|
||||
`
|
||||
|
||||
type AlbumSharePageProps = {
|
||||
albumID: string
|
||||
token: string
|
||||
password: string | null
|
||||
}
|
||||
|
||||
const AlbumSharePage = ({ albumID, token, password }: AlbumSharePageProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const urlParams = useURLParameters()
|
||||
const orderParams = useOrderingParams(urlParams)
|
||||
|
||||
const { data, error, loading, fetchMore } = useQuery<shareAlbumQuery>(
|
||||
SHARE_ALBUM_QUERY,
|
||||
{
|
||||
variables: {
|
||||
id: albumID,
|
||||
token,
|
||||
password,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
mediaOrderBy: orderParams.orderBy,
|
||||
mediaOrderDirection: orderParams.orderDirection,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const { containerElem, finished: finishedLoadingMore } =
|
||||
useScrollPagination<shareAlbumQuery>({
|
||||
loading,
|
||||
fetchMore,
|
||||
data,
|
||||
getItems: data => data.album.media,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
return <div>{error.message}</div>
|
||||
}
|
||||
|
||||
const album = data?.album
|
||||
|
||||
return (
|
||||
<AlbumSharePageWrapper data-testid="AlbumSharePage">
|
||||
<Layout
|
||||
title={
|
||||
album ? album.title : t('general.loading.album', 'Loading album')
|
||||
}
|
||||
>
|
||||
<AlbumGallery
|
||||
ref={containerElem}
|
||||
album={album}
|
||||
customAlbumLink={albumId => `/share/${token}/${albumId}`}
|
||||
showFilter={false}
|
||||
setOrdering={orderParams.setOrdering}
|
||||
ordering={orderParams}
|
||||
/>
|
||||
<PaginateLoader
|
||||
active={!finishedLoadingMore && !loading}
|
||||
text={t('general.loading.paginate.media', 'Loading more media')}
|
||||
/>
|
||||
</Layout>
|
||||
</AlbumSharePageWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export default AlbumSharePage
|
||||
@@ -0,0 +1,64 @@
|
||||
import React, { useContext, useEffect } from 'react'
|
||||
import styled from 'styled-components'
|
||||
import Layout from '../../components/layout/Layout'
|
||||
import {
|
||||
ProtectedImage,
|
||||
ProtectedVideo,
|
||||
} from '../../components/photoGallery/ProtectedMedia'
|
||||
import { SidebarContext } from '../../components/sidebar/Sidebar'
|
||||
import MediaSidebar from '../../components/sidebar/MediaSidebar/MediaSidebar'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SharePageToken_shareToken_media } from './__generated__/SharePageToken'
|
||||
import { MediaType } from '../../__generated__/globalTypes'
|
||||
import { exhaustiveCheck } from '../../helpers/utils'
|
||||
|
||||
const DisplayPhoto = styled(ProtectedImage)`
|
||||
/* width: 100%; */
|
||||
max-height: calc(80vh);
|
||||
object-fit: contain;
|
||||
`
|
||||
|
||||
const DisplayVideo = styled(ProtectedVideo)`
|
||||
/* width: 100%; */
|
||||
max-height: calc(80vh);
|
||||
`
|
||||
|
||||
type MediaViewProps = {
|
||||
media: SharePageToken_shareToken_media
|
||||
}
|
||||
|
||||
const MediaView = ({ media }: MediaViewProps) => {
|
||||
const { updateSidebar } = useContext(SidebarContext)
|
||||
|
||||
useEffect(() => {
|
||||
updateSidebar(<MediaSidebar media={media} hidePreview />)
|
||||
}, [media])
|
||||
|
||||
switch (media.type) {
|
||||
case MediaType.Photo:
|
||||
return <DisplayPhoto src={media.highRes?.url} />
|
||||
case MediaType.Video:
|
||||
return <DisplayVideo media={media} />
|
||||
}
|
||||
|
||||
exhaustiveCheck(media.type)
|
||||
}
|
||||
|
||||
type MediaSharePageType = {
|
||||
media: SharePageToken_shareToken_media
|
||||
}
|
||||
|
||||
const MediaSharePage = ({ media }: MediaSharePageType) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Layout title={t('share_page.media.title', 'Shared media')}>
|
||||
<div data-testid="MediaSharePage">
|
||||
<h1 className="font-semibold text-xl mb-4">{media.title}</h1>
|
||||
<MediaView media={media} />
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default MediaSharePage
|
||||
@@ -0,0 +1,71 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { TextField } from '../../primitives/form/Input'
|
||||
import { MessageContainer } from './SharePage'
|
||||
|
||||
type ProtectedTokenEnterPasswordProps = {
|
||||
refetchWithPassword(password: string): void
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const PasswordProtectedShare = ({
|
||||
refetchWithPassword,
|
||||
loading = false,
|
||||
}: ProtectedTokenEnterPasswordProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
register,
|
||||
watch,
|
||||
formState: { errors },
|
||||
handleSubmit,
|
||||
} = useForm()
|
||||
|
||||
const [invalidPassword, setInvalidPassword] = useState(false)
|
||||
|
||||
const onSubmit = () => {
|
||||
refetchWithPassword(watch('password') as string)
|
||||
setInvalidPassword(true)
|
||||
}
|
||||
|
||||
let errorMessage = undefined
|
||||
if (invalidPassword && !loading) {
|
||||
errorMessage = t(
|
||||
'share_page.wrong_password',
|
||||
'Wrong password, please try again.'
|
||||
)
|
||||
} else if (errors.password) {
|
||||
errorMessage = t(
|
||||
'share_page.protected_share.password_required_error',
|
||||
'Password is required'
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<MessageContainer>
|
||||
<h1 className="text-xl">
|
||||
{t('share_page.protected_share.title', 'Protected share')}
|
||||
</h1>
|
||||
<p className="mb-4">
|
||||
{t(
|
||||
'share_page.protected_share.description',
|
||||
'This share is protected with a password.'
|
||||
)}
|
||||
</p>
|
||||
<TextField
|
||||
{...register('password', { required: true })}
|
||||
label={t('login_page.field.password', 'Password')}
|
||||
type="password"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
action={handleSubmit(onSubmit)}
|
||||
error={errorMessage}
|
||||
fullWidth={true}
|
||||
sizeVariant="big"
|
||||
/>
|
||||
</MessageContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default PasswordProtectedShare
|
||||
@@ -0,0 +1,377 @@
|
||||
import React from 'react'
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||
import { MockedProvider } from '@apollo/client/testing'
|
||||
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
waitForElementToBeRemoved,
|
||||
} from '@testing-library/react'
|
||||
|
||||
import {clearSharePassword, saveSharePassword} from '../../helpers/authentication'
|
||||
|
||||
import {
|
||||
SHARE_TOKEN_QUERY,
|
||||
TokenRoute,
|
||||
VALIDATE_TOKEN_PASSWORD_QUERY,
|
||||
} from './SharePage'
|
||||
|
||||
import { SIDEBAR_DOWNLOAD_QUERY } from '../../components/sidebar/SidebarDownloadMedia'
|
||||
import { SHARE_ALBUM_QUERY } from './AlbumSharePage'
|
||||
|
||||
|
||||
vi.mock('../../hooks/useScrollPagination')
|
||||
|
||||
describe('load correct share page, based on graphql query', () => {
|
||||
const token = 'TOKEN123'
|
||||
const password = 'PASSWORD-123_@456\\'
|
||||
|
||||
const historyMock = [{ pathname: `/share/${token}` }]
|
||||
|
||||
const graphqlMocks = [
|
||||
{
|
||||
request: {
|
||||
query: VALIDATE_TOKEN_PASSWORD_QUERY,
|
||||
variables: {
|
||||
token,
|
||||
password,
|
||||
},
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
shareTokenValidatePassword: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
request: {
|
||||
query: SIDEBAR_DOWNLOAD_QUERY,
|
||||
variables: {
|
||||
mediaId: '1',
|
||||
},
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
media: {
|
||||
id: '1',
|
||||
downloads: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
beforeAll(() => {
|
||||
saveSharePassword(token, password)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
clearSharePassword(token)
|
||||
})
|
||||
|
||||
test('load media share page', async () => {
|
||||
const mediaPageMock = {
|
||||
request: {
|
||||
query: SHARE_TOKEN_QUERY,
|
||||
variables: {
|
||||
token,
|
||||
password,
|
||||
},
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
shareToken: {
|
||||
token: token,
|
||||
album: null,
|
||||
media: {
|
||||
id: '1',
|
||||
title: 'shared_image.jpg',
|
||||
type: 'Photo',
|
||||
highRes: {
|
||||
url: 'https://example.com/shared_image.jpg',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
render(
|
||||
<MockedProvider
|
||||
mocks={[...graphqlMocks, mediaPageMock]}
|
||||
addTypename={false}
|
||||
defaultOptions={{
|
||||
// disable cache, required to make fragments work
|
||||
watchQuery: { fetchPolicy: 'no-cache' },
|
||||
query: { fetchPolicy: 'no-cache' },
|
||||
}}
|
||||
>
|
||||
<MemoryRouter initialEntries={historyMock}>
|
||||
<Routes>
|
||||
<Route path="/share/:token/*" element={<TokenRoute />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</MockedProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.queryByText('Loading...'))
|
||||
|
||||
expect(screen.getByTestId('Layout')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('MediaSharePage')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('load album share page', async () => {
|
||||
const albumPageMock = [
|
||||
{
|
||||
request: {
|
||||
query: SHARE_TOKEN_QUERY,
|
||||
variables: {
|
||||
token,
|
||||
password,
|
||||
},
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
shareToken: {
|
||||
token: token,
|
||||
album: {
|
||||
id: '1',
|
||||
},
|
||||
media: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
request: {
|
||||
query: SHARE_ALBUM_QUERY,
|
||||
variables: {
|
||||
id: '1',
|
||||
token,
|
||||
password,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
mediaOrderBy: 'date_shot',
|
||||
mediaOrderDirection: 'ASC',
|
||||
},
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
album: {
|
||||
id: '1',
|
||||
title: 'album_title',
|
||||
subAlbums: [],
|
||||
thumbnail: {
|
||||
url: 'https://photoview.example.com/album_thumbnail.jpg',
|
||||
},
|
||||
media: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
render(
|
||||
<MockedProvider
|
||||
mocks={[...graphqlMocks, ...albumPageMock]}
|
||||
addTypename={false}
|
||||
defaultOptions={{
|
||||
// disable cache, required to make fragments work
|
||||
watchQuery: { fetchPolicy: 'no-cache' },
|
||||
query: { fetchPolicy: 'no-cache' },
|
||||
}}
|
||||
>
|
||||
<MemoryRouter initialEntries={historyMock}>
|
||||
<Routes>
|
||||
<Route path="/share/:token/*" element={<TokenRoute />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</MockedProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
||||
await waitForElementToBeRemoved(() => screen.getByText('Loading...'))
|
||||
|
||||
expect(screen.getByTestId('Layout')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('AlbumSharePage')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('load subalbum of a shared album', async () => {
|
||||
const subalbumID = '456'
|
||||
const subalbumHistoryMock = [{ pathname: `/share/${token}/${subalbumID}` }]
|
||||
|
||||
const subalbumPageMocks = [
|
||||
{
|
||||
request: {
|
||||
query: SHARE_TOKEN_QUERY,
|
||||
variables: {
|
||||
token,
|
||||
password,
|
||||
},
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
shareToken: {
|
||||
token: token,
|
||||
album: {
|
||||
id: subalbumID,
|
||||
},
|
||||
media: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
request: {
|
||||
query: SHARE_ALBUM_QUERY,
|
||||
variables: {
|
||||
id: subalbumID,
|
||||
token,
|
||||
password,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
mediaOrderBy: 'date_shot',
|
||||
mediaOrderDirection: 'ASC',
|
||||
},
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
album: {
|
||||
id: '1',
|
||||
title: 'album_title',
|
||||
subAlbums: [],
|
||||
thumbnail: {
|
||||
url: 'https://photoview.example.com/album_thumbnail.jpg',
|
||||
},
|
||||
media: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
render(
|
||||
<MockedProvider
|
||||
mocks={[...graphqlMocks, ...subalbumPageMocks]}
|
||||
addTypename={false}
|
||||
defaultOptions={{
|
||||
// disable cache, required to make fragments work
|
||||
watchQuery: { fetchPolicy: 'no-cache' },
|
||||
query: { fetchPolicy: 'no-cache' },
|
||||
}}
|
||||
>
|
||||
<MemoryRouter initialEntries={subalbumHistoryMock}>
|
||||
<Routes>
|
||||
<Route path="/share/:token/*" element={<TokenRoute />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</MockedProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
||||
await waitForElementToBeRemoved(() => screen.getByText('Loading...'))
|
||||
|
||||
expect(screen.getByTestId('Layout')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('AlbumSharePage')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test(`test the expired`, async () => {
|
||||
const expiredValidationMock = {
|
||||
request: {
|
||||
query: VALIDATE_TOKEN_PASSWORD_QUERY,
|
||||
variables: { token: 'TOKEN123', password: "PASSWORD-123_@456\\" },
|
||||
},
|
||||
error: new Error('share expired'),
|
||||
};
|
||||
|
||||
render(
|
||||
<MockedProvider
|
||||
mocks={[expiredValidationMock]}
|
||||
addTypename={false}
|
||||
defaultOptions={{
|
||||
watchQuery: { fetchPolicy: 'no-cache' },
|
||||
query: { fetchPolicy: 'no-cache' },
|
||||
}}
|
||||
>
|
||||
<MemoryRouter initialEntries={historyMock}>
|
||||
<Routes>
|
||||
<Route path="/share/:token/*" element={<TokenRoute />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</MockedProvider>
|
||||
)
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.queryByText('Loading...'))
|
||||
|
||||
expect(screen.getByText(/share expired/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test(`test the normal`, async () => {
|
||||
|
||||
const shareTokenMock = {
|
||||
request: {
|
||||
query: SHARE_TOKEN_QUERY,
|
||||
variables: { token: 'TOKEN123', password: "PASSWORD-123_@456\\" },
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
shareToken: {
|
||||
token: 'TOKEN123',
|
||||
album: { id: '1' },
|
||||
media: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const albumDataMock = {
|
||||
request: {
|
||||
query: SHARE_ALBUM_QUERY,
|
||||
variables: {
|
||||
id: '1',
|
||||
token: 'TOKEN123',
|
||||
password: "PASSWORD-123_@456\\",
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
mediaOrderBy: 'date_shot',
|
||||
mediaOrderDirection: 'ASC',
|
||||
},
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
album: {
|
||||
id: '1',
|
||||
title: 'normal_album',
|
||||
subAlbums: [],
|
||||
thumbnail: { url: '...' },
|
||||
media: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<MockedProvider
|
||||
mocks={[...graphqlMocks, shareTokenMock, albumDataMock]}
|
||||
addTypename={false}
|
||||
>
|
||||
<MemoryRouter initialEntries={historyMock}>
|
||||
<Routes>
|
||||
<Route path="/share/:token/*" element={<TokenRoute />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</MockedProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
||||
await waitForElementToBeRemoved(() => screen.queryByText('Loading...'))
|
||||
|
||||
expect(screen.getByTestId('Layout')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('AlbumSharePage')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
})
|
||||
@@ -0,0 +1,202 @@
|
||||
import React from 'react'
|
||||
import { useQuery, gql } from '@apollo/client'
|
||||
import { Route, Routes, useParams } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
import {
|
||||
getSharePassword,
|
||||
saveSharePassword,
|
||||
} from '../../helpers/authentication'
|
||||
import AlbumSharePage from './AlbumSharePage'
|
||||
import MediaSharePage from './MediaSharePage'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import PasswordProtectedShare from './PasswordProtectedShare'
|
||||
import { isNil } from '../../helpers/utils'
|
||||
import {
|
||||
SharePageToken,
|
||||
SharePageTokenVariables,
|
||||
} from './__generated__/SharePageToken'
|
||||
import {
|
||||
ShareTokenValidatePassword,
|
||||
ShareTokenValidatePasswordVariables,
|
||||
} from './__generated__/ShareTokenValidatePassword'
|
||||
|
||||
export const SHARE_TOKEN_QUERY = gql`
|
||||
query SharePageToken($token: String!, $password: String) {
|
||||
shareToken(credentials: { token: $token, password: $password }) {
|
||||
token
|
||||
album {
|
||||
id
|
||||
}
|
||||
media {
|
||||
id
|
||||
title
|
||||
type
|
||||
thumbnail {
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
downloads {
|
||||
title
|
||||
mediaUrl {
|
||||
url
|
||||
width
|
||||
height
|
||||
fileSize
|
||||
}
|
||||
}
|
||||
highRes {
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
videoWeb {
|
||||
url
|
||||
width
|
||||
height
|
||||
}
|
||||
exif {
|
||||
id
|
||||
description
|
||||
camera
|
||||
maker
|
||||
lens
|
||||
dateShot
|
||||
exposure
|
||||
aperture
|
||||
iso
|
||||
focalLength
|
||||
flash
|
||||
exposureProgram
|
||||
coordinates {
|
||||
longitude
|
||||
latitude
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const VALIDATE_TOKEN_PASSWORD_QUERY = gql`
|
||||
query ShareTokenValidatePassword($token: String!, $password: String) {
|
||||
shareTokenValidatePassword(
|
||||
credentials: { token: $token, password: $password }
|
||||
)
|
||||
}
|
||||
`
|
||||
|
||||
const tokenFromParams = () => {
|
||||
const { token } = useParams()
|
||||
if (isNil(token)) throw new Error('Expected `token` param to be defined')
|
||||
return token
|
||||
}
|
||||
|
||||
const AuthorizedTokenRoute = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const token = tokenFromParams()
|
||||
const password = getSharePassword(token)
|
||||
|
||||
const { loading, error, data } = useQuery<
|
||||
SharePageToken,
|
||||
SharePageTokenVariables
|
||||
>(SHARE_TOKEN_QUERY, {
|
||||
variables: {
|
||||
token,
|
||||
password,
|
||||
},
|
||||
})
|
||||
|
||||
if (!isNil(error)) return <div>{error.message}</div>
|
||||
if (loading) return <div>{t('general.loading.default', 'Loading...')}</div>
|
||||
|
||||
if (data?.shareToken.album) {
|
||||
const SharedSubAlbumPage = () => {
|
||||
const { subAlbum } = useParams()
|
||||
if (isNil(subAlbum))
|
||||
throw new Error('Expected `subAlbum` param to be defined')
|
||||
|
||||
return (
|
||||
<AlbumSharePage albumID={subAlbum} token={token} password={password} />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path=":subAlbum" element={<SharedSubAlbumPage />} />
|
||||
<Route
|
||||
index
|
||||
element={
|
||||
<AlbumSharePage
|
||||
albumID={data.shareToken.album.id}
|
||||
token={token}
|
||||
password={password}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
if (data?.shareToken.media) {
|
||||
return <MediaSharePage media={data.shareToken.media} />
|
||||
}
|
||||
|
||||
return <h1>{t('share_page.share_not_found', 'Share not found')}</h1>
|
||||
}
|
||||
|
||||
export const MessageContainer = styled.div`
|
||||
max-width: 400px;
|
||||
margin: 100px auto 0;
|
||||
`
|
||||
|
||||
export const TokenRoute = () => {
|
||||
const { t } = useTranslation()
|
||||
const token = tokenFromParams()
|
||||
|
||||
const { loading, error, data, refetch } = useQuery<
|
||||
ShareTokenValidatePassword,
|
||||
ShareTokenValidatePasswordVariables
|
||||
>(VALIDATE_TOKEN_PASSWORD_QUERY, {
|
||||
notifyOnNetworkStatusChange: true,
|
||||
variables: {
|
||||
token: token,
|
||||
password: getSharePassword(token),
|
||||
},
|
||||
})
|
||||
|
||||
if (error) {
|
||||
if (error.message == 'GraphQL error: share not found') {
|
||||
return (
|
||||
<MessageContainer>
|
||||
<h1>{t('share_page.share_not_found', 'Share not found')}</h1>
|
||||
<p>
|
||||
{t(
|
||||
'share_page.share_not_found_description',
|
||||
'Maybe the share has expired or has been deleted.'
|
||||
)}
|
||||
</p>
|
||||
</MessageContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return <div>{error.message}</div>
|
||||
}
|
||||
|
||||
if (data && data.shareTokenValidatePassword == false) {
|
||||
return (
|
||||
<PasswordProtectedShare
|
||||
refetchWithPassword={password => {
|
||||
saveSharePassword(token, password)
|
||||
refetch({ token, password })
|
||||
}}
|
||||
loading={loading}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) return <div>{t('general.loading.default', 'Loading...')}</div>
|
||||
|
||||
return <AuthorizedTokenRoute />
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user