精简后的项目:仅保留 api/ui 源码、文档、构建依赖

This commit is contained in:
gallery
2026-09-11 16:11:09 +08:00
commit 490e85634e
457 changed files with 86862 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules
dist
coverage
+59
View File
@@ -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',
},
},
],
}
+38
View File
@@ -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,
},
},
}
+1
View File
@@ -0,0 +1 @@
REACT_APP_API_ENDPOINT=http://localhost:4001/
+29
View File
@@ -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',
}
+21
View File
@@ -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>
+9
View File
@@ -0,0 +1,9 @@
{
"watch": ["./**"],
"exec": "npm start",
"ext": "ts,json,tsx,js,html,css",
"ignore": ["./dist/"],
"execMap": {
"ts": "ts-node-esm"
}
}
+26430
View File
File diff suppressed because it is too large Load Diff
+119
View File
@@ -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"
]
}
}
+6
View File
@@ -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

+23
View File
@@ -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"
}
]
}
+59
View File
@@ -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

+20
View File
@@ -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
+38
View File
@@ -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
+24
View File
@@ -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()
})
+120
View File
@@ -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
View File
@@ -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;
}
+73
View File
@@ -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
+49
View File
@@ -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('/')
})
})
})
+155
View File
@@ -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
+76
View File
@@ -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()
})
})
+155
View File
@@ -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
View File
@@ -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;
}
+34
View File
@@ -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,
},
},
},
})
+18
View File
@@ -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'
)
})
+136
View File
@@ -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
+246
View File
@@ -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()
})
})
+352
View File
@@ -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
View File
@@ -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[];
}
+29
View File
@@ -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
+140
View File
@@ -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
+15
View File
@@ -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[];
}
+21
View File
@@ -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

+41
View File
@@ -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
+137
View File
@@ -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;
}
@@ -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;
}
+53
View File
@@ -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;
}
+161
View File
@@ -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
+64
View File
@@ -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
+377
View File
@@ -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()
})
})
+202
View File
@@ -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