Merge pull request #382 from photoview/redesign
Redesign UI and remove SemanticUI dependency
|
@ -9,7 +9,7 @@ photos_path
|
|||
screenshots
|
||||
|
||||
ui/node_modules/
|
||||
ui/dist/
|
||||
ui/build/
|
||||
ui/.cache/
|
||||
|
||||
api/photo_cache
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
### Build UI ###
|
||||
FROM --platform=${BUILDPLATFORM:-linux/amd64} node:15 as ui
|
||||
|
||||
ARG PHOTOVIEW_API_ENDPOINT
|
||||
ENV PHOTOVIEW_API_ENDPOINT=${PHOTOVIEW_API_ENDPOINT}
|
||||
ARG REACT_APP_API_ENDPOINT
|
||||
ENV REACT_APP_API_ENDPOINT=${REACT_APP_API_ENDPOINT}
|
||||
|
||||
# Set environment variable UI_PUBLIC_URL from build args, uses "/" as default
|
||||
ARG UI_PUBLIC_URL
|
||||
|
@ -10,12 +10,15 @@ ENV UI_PUBLIC_URL=${UI_PUBLIC_URL:-/}
|
|||
|
||||
ARG VERSION
|
||||
ENV VERSION=${VERSION:-undefined}
|
||||
ENV REACT_APP_VERSION=${VERSION:-undefined}
|
||||
|
||||
ARG BUILD_DATE
|
||||
ENV BUILD_DATE=${BUILD_DATE:-undefined}
|
||||
ENV REACT_APP_BUILD_DATE=${BUILD_DATE:-undefined}
|
||||
|
||||
ARG COMMIT_SHA
|
||||
ENV COMMIT_SHA=${COMMIT_SHA:-}
|
||||
ENV REACT_APP_COMMIT_SHA=${COMMIT_SHA:-}
|
||||
|
||||
RUN mkdir -p /app
|
||||
WORKDIR /app
|
||||
|
@ -84,7 +87,7 @@ RUN apt-get purge -y curl gpg \
|
|||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=ui /app/dist /ui
|
||||
COPY --from=ui /app/build /ui
|
||||
COPY --from=api /app/photoview /app/photoview
|
||||
|
||||
ENV PHOTOVIEW_LISTEN_IP 127.0.0.1
|
||||
|
|
|
@ -55,6 +55,11 @@ module.exports = {
|
|||
'jest/valid-title': 'off',
|
||||
}
|
||||
),
|
||||
settings: {
|
||||
jest: {
|
||||
version: 26,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
files: ['**/*.js'],
|
||||
|
|
|
@ -1,30 +0,0 @@
|
|||
module.exports = function (api) {
|
||||
const isTest = api.env('test')
|
||||
const isProduction = api.env('NODE_ENV') == 'production'
|
||||
|
||||
let presets = ['@babel/preset-react', '@babel/preset-typescript']
|
||||
let plugins = []
|
||||
|
||||
if (isTest) {
|
||||
presets.push('@babel/preset-env')
|
||||
plugins.push('@babel/plugin-transform-runtime')
|
||||
} else {
|
||||
if (!isProduction) {
|
||||
plugins.push([
|
||||
'i18next-extract',
|
||||
{
|
||||
locales: ['en', 'da', 'fr', 'sv', 'es', 'it', 'pl', 'de'],
|
||||
defaultValue: null,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
plugins.push(['styled-components', { pure: true }])
|
||||
plugins.push('graphql-tag')
|
||||
}
|
||||
|
||||
return {
|
||||
presets: presets,
|
||||
plugins: plugins,
|
||||
}
|
||||
}
|
100
ui/build.mjs
|
@ -1,100 +0,0 @@
|
|||
import fs from 'fs-extra'
|
||||
import esbuild from 'esbuild'
|
||||
import babel from 'esbuild-plugin-babel'
|
||||
import browserSync from 'browser-sync'
|
||||
import historyApiFallback from 'connect-history-api-fallback'
|
||||
import dotenv from 'dotenv'
|
||||
import workboxBuild from 'workbox-build'
|
||||
|
||||
dotenv.config()
|
||||
const bs = browserSync.create()
|
||||
|
||||
const production = process.env.NODE_ENV == 'production'
|
||||
const watchMode = process.argv[2] == 'watch'
|
||||
|
||||
const ENVIRONMENT_VARIABLES = [
|
||||
'NODE_ENV',
|
||||
'PHOTOVIEW_API_ENDPOINT',
|
||||
'VERSION',
|
||||
'BUILD_DATE',
|
||||
'COMMIT_SHA',
|
||||
]
|
||||
|
||||
const defineEnv = ENVIRONMENT_VARIABLES.reduce((acc, key) => {
|
||||
acc[`process.env.${key}`] = process.env[key] ? `"${process.env[key]}"` : null
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const esbuildOptions = {
|
||||
entryPoints: ['src/index.tsx', 'mapbox-gl/dist/mapbox-gl.css'],
|
||||
entryNames: '[name]',
|
||||
plugins: [
|
||||
babel({
|
||||
filter: /photoview\/ui\/src\/.*\.(js|tsx?)$/,
|
||||
}),
|
||||
],
|
||||
publicPath: process.env.UI_PUBLIC_URL || '/',
|
||||
outdir: 'dist',
|
||||
format: 'esm',
|
||||
bundle: true,
|
||||
platform: 'browser',
|
||||
splitting: true,
|
||||
minify: production,
|
||||
sourcemap: !production,
|
||||
loader: {
|
||||
'.js': 'jsx',
|
||||
'.svg': 'file',
|
||||
'.woff': 'file',
|
||||
'.woff2': 'file',
|
||||
'.ttf': 'file',
|
||||
'.eot': 'file',
|
||||
'.png': 'file',
|
||||
},
|
||||
define: defineEnv,
|
||||
}
|
||||
|
||||
if (watchMode) {
|
||||
esbuildOptions.incremental = true
|
||||
esbuildOptions.watch = {
|
||||
onRebuild(err) {
|
||||
if (err == null) {
|
||||
bs.reload()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fs.emptyDirSync('dist/')
|
||||
fs.copyFileSync('src/index.html', 'dist/index.html')
|
||||
fs.copyFileSync('src/manifest.webmanifest', 'dist/manifest.json')
|
||||
fs.copyFileSync('src/favicon.ico', 'dist/favicon.ico')
|
||||
fs.copySync('src/assets/', 'dist/assets/')
|
||||
|
||||
if (watchMode) {
|
||||
esbuild.build(esbuildOptions)
|
||||
|
||||
bs.init({
|
||||
server: {
|
||||
baseDir: './dist',
|
||||
middleware: [historyApiFallback()],
|
||||
},
|
||||
port: 1234,
|
||||
open: false,
|
||||
})
|
||||
} else {
|
||||
const build = async () => {
|
||||
await esbuild.build(esbuildOptions)
|
||||
|
||||
console.log('esbuild done')
|
||||
|
||||
await workboxBuild.generateSW({
|
||||
globDirectory: 'dist/',
|
||||
globPatterns: ['**/*.{png,svg,woff2,ttf,eot,woff,js,ico,html,json,css}'],
|
||||
swDest: 'dist/service-worker.js',
|
||||
})
|
||||
|
||||
console.log('workbox done')
|
||||
console.log('build complete')
|
||||
}
|
||||
build()
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
module.exports = {
|
||||
style: {
|
||||
postcss: {
|
||||
plugins: [require('tailwindcss'), require('autoprefixer')],
|
||||
},
|
||||
},
|
||||
}
|
|
@ -1 +1 @@
|
|||
PHOTOVIEW_API_ENDPOINT=http://localhost:4001/
|
||||
REACT_APP_API_ENDPOINT=http://localhost:4001/
|
126
ui/package.json
|
@ -9,87 +9,71 @@
|
|||
"license": "GPL-3.0",
|
||||
"description": "UI app for Photoview",
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.3.17",
|
||||
"@babel/core": "^7.14.0",
|
||||
"@babel/preset-react": "^7.13.13",
|
||||
"@babel/preset-typescript": "^7.13.0",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"babel-jest": "^26.6.3",
|
||||
"babel-plugin-graphql-tag": "^3.2.0",
|
||||
"babel-plugin-i18next-extract": "^0.8.3",
|
||||
"babel-plugin-styled-components": "^1.12.0",
|
||||
"babel-plugin-transform-semantic-ui-react-imports": "^1.4.1",
|
||||
"browser-sync": "^2.26.14",
|
||||
"@apollo/client": "^3.3.21",
|
||||
"@babel/preset-typescript": "^7.14.5",
|
||||
"@craco/craco": "^6.2.0",
|
||||
"@headlessui/react": "^1.3.0",
|
||||
"@react-aria/focus": "^3.4.0",
|
||||
"@rollup/plugin-babel": "^5.3.0",
|
||||
"@types/geojson": "^7946.0.8",
|
||||
"@types/jest": "^26.0.24",
|
||||
"@types/mapbox-gl": "^2.3.3",
|
||||
"@types/react": "^17.0.14",
|
||||
"@types/react-dom": "^17.0.9",
|
||||
"@types/react-helmet": "^6.1.2",
|
||||
"@types/react-router-dom": "^5.1.8",
|
||||
"@types/styled-components": "^5.1.11",
|
||||
"@types/url-join": "^4.0.1",
|
||||
"autoprefixer": "^9.8.6",
|
||||
"babel-plugin-graphql-tag": "^3.3.0",
|
||||
"classnames": "^2.3.1",
|
||||
"connect-history-api-fallback": "^1.6.0",
|
||||
"copy-to-clipboard": "^3.3.1",
|
||||
"dotenv": "^9.0.2",
|
||||
"esbuild": "^0.11.20",
|
||||
"esbuild-plugin-babel": "^0.2.3",
|
||||
"eslint": "^7.26.0",
|
||||
"eslint-plugin-jest": "^24.3.6",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-jest-dom": "^3.9.0",
|
||||
"eslint-plugin-react": "^7.23.2",
|
||||
"eslint-plugin-react-hooks": "^4.2.0",
|
||||
"fs-extra": "^10.0.0",
|
||||
"i18next": "^20.2.2",
|
||||
"mapbox-gl": "^2.2.0",
|
||||
"i18next": "^20.3.2",
|
||||
"mapbox-gl": "^2.3.1",
|
||||
"postcss": "^7.0.36",
|
||||
"prettier": "^2.3.2",
|
||||
"prop-types": "^15.7.2",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-i18next": "^11.8.15",
|
||||
"react-hook-form": "^7.11.0",
|
||||
"react-i18next": "^11.11.1",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-router-prop-types": "^1.0.5",
|
||||
"react-scripts": "^4.0.3",
|
||||
"react-spring": "^8.0.27",
|
||||
"react-test-renderer": "^17.0.2",
|
||||
"semantic-ui-css": "^2.4.1",
|
||||
"semantic-ui-react": "^2.0.3",
|
||||
"styled-components": "^5.3.0",
|
||||
"subscriptions-transport-ws": "^0.9.18",
|
||||
"typescript": "^4.2.4",
|
||||
"url-join": "^4.0.1",
|
||||
"workbox-build": "^6.1.5"
|
||||
"subscriptions-transport-ws": "^0.9.19",
|
||||
"tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.2.4",
|
||||
"typescript": "^4.3.5",
|
||||
"url-join": "^4.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node --experimental-modules build.mjs watch",
|
||||
"build": "NODE_ENV=production node --experimental-modules build.mjs",
|
||||
"test": "npm run lint && npm run jest",
|
||||
"start": "BROWSER=none craco start",
|
||||
"build": "craco build",
|
||||
"test": "npm run lint && npm run jest -- --watchAll=false",
|
||||
"test:ci": "npm run lint && npm run jest:ci",
|
||||
"lint": "npm run lint:types & npm run lint:eslint",
|
||||
"lint:eslint": "eslint ./src --max-warnings 0 --cache --config .eslintrc.js",
|
||||
"lint:types": "tsc --noemit",
|
||||
"jest": "jest --verbose",
|
||||
"jest:ci": "jest --verbose --ci --coverage",
|
||||
"genSchemaTypes": "npx apollo client:codegen --target=typescript",
|
||||
"jest": "craco test",
|
||||
"jest:ci": "CI=true craco test --verbose --ci --coverage",
|
||||
"genSchemaTypes": "npx apollo client:codegen --target=typescript --globalTypesFile=src/__generated__/globalTypes.ts",
|
||||
"prepare": "(cd .. && npx husky install)"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/plugin-transform-runtime": "^7.13.15",
|
||||
"@babel/preset-env": "^7.14.1",
|
||||
"@testing-library/jest-dom": "^5.12.0",
|
||||
"@testing-library/react": "^11.2.6",
|
||||
"@testing-library/user-event": "^13.1.8",
|
||||
"@types/geojson": "^7946.0.7",
|
||||
"@types/jest": "^26.0.23",
|
||||
"@types/mapbox-gl": "^2.1.2",
|
||||
"@types/react": "^17.0.5",
|
||||
"@types/react-dom": "^17.0.4",
|
||||
"@types/react-helmet": "^6.1.1",
|
||||
"@types/react-router-dom": "^5.1.7",
|
||||
"@types/styled-components": "^5.1.9",
|
||||
"@types/url-join": "^4.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^4.23.0",
|
||||
"@typescript-eslint/parser": "^4.23.0",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"@testing-library/jest-dom": "^5.14.1",
|
||||
"@testing-library/react": "^12.0.0",
|
||||
"@testing-library/user-event": "^13.1.9",
|
||||
"husky": "^6.0.0",
|
||||
"jest": "^26.6.3",
|
||||
"lint-staged": "^11.0.0",
|
||||
"prettier": "^2.3.0",
|
||||
"lint-staged": "^11.0.1",
|
||||
"tsc-files": "^1.1.2"
|
||||
},
|
||||
"cache": {
|
||||
"swDest": "service-worker.js"
|
||||
},
|
||||
"prettier": {
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
|
@ -97,27 +81,21 @@
|
|||
"singleQuote": true,
|
||||
"arrowParens": "avoid"
|
||||
},
|
||||
"jest": {
|
||||
"testMatch": [
|
||||
"**/__tests__/**/*.+(ts|tsx|js)",
|
||||
"**/?(*.)+(spec|test).+(ts|tsx|js)"
|
||||
],
|
||||
"transformIgnorePatterns": [
|
||||
"^.+\\.css$"
|
||||
],
|
||||
"transform": {
|
||||
"^.+\\.(js|ts|tsx)$": "babel-jest",
|
||||
"^.+\\.svg$": "<rootDir>/testing/transform-svg.js"
|
||||
},
|
||||
"collectCoverage": true,
|
||||
"coverageReporters": [
|
||||
"json",
|
||||
"html"
|
||||
]
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,js,json,css,md,graphql}": "prettier --write",
|
||||
"*.{js,ts,tsx}": "eslint --cache --fix --max-warnings 0",
|
||||
"*.{ts,tsx}": "tsc-files --noEmit"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
|
||||
<!--
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
-->
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<!--
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
-->
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run Photoview.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
|
@ -1,4 +1,7 @@
|
|||
declare module '*.svg' {
|
||||
const src: string
|
||||
export default src
|
||||
const ReactComponent: React.FC<React.SVGProps<SVGSVGElement>>
|
||||
// const content: string
|
||||
|
||||
export { ReactComponent }
|
||||
// export default content
|
||||
}
|
||||
|
|
|
@ -1,39 +1,23 @@
|
|||
import React from 'react'
|
||||
import { createGlobalStyle } from 'styled-components'
|
||||
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'
|
||||
|
||||
const GlobalStyle = createGlobalStyle`
|
||||
* {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
#root, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
/* Make dimmer lighter */
|
||||
.ui.dimmer {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
`
|
||||
|
||||
import 'semantic-ui-css/semantic.min.css'
|
||||
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>
|
||||
|
@ -45,7 +29,6 @@ const App = () => {
|
|||
)}
|
||||
/>
|
||||
</Helmet>
|
||||
<GlobalStyle />
|
||||
<Routes />
|
||||
<Messages />
|
||||
</>
|
||||
|
|
|
@ -1,175 +0,0 @@
|
|||
import React, { ReactChild } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import styled from 'styled-components'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { Icon } from 'semantic-ui-react'
|
||||
import Sidebar from './components/sidebar/Sidebar'
|
||||
import { useQuery, gql } from '@apollo/client'
|
||||
import { Authorized } from './components/routes/AuthorizedRoute'
|
||||
import { Helmet } from 'react-helmet'
|
||||
import Header from './components/header/Header'
|
||||
import { authToken } from './helpers/authentication'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const ADMIN_QUERY = gql`
|
||||
query adminQuery {
|
||||
myUser {
|
||||
admin
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const MAPBOX_QUERY = gql`
|
||||
query mapboxEnabledQuery {
|
||||
mapboxToken
|
||||
}
|
||||
`
|
||||
|
||||
const Container = styled.div`
|
||||
height: 100%;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
`
|
||||
|
||||
const SideMenuContainer = styled.div`
|
||||
height: 100%;
|
||||
width: 150px;
|
||||
left: 0;
|
||||
padding-top: 70px;
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
position: fixed;
|
||||
background: white;
|
||||
z-index: 10;
|
||||
padding-top: 0;
|
||||
display: flex;
|
||||
bottom: 0;
|
||||
box-shadow: 0 0 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
`
|
||||
|
||||
const Content = styled.div`
|
||||
margin-top: 70px;
|
||||
padding: 10px 12px 0;
|
||||
width: 100%;
|
||||
overflow-y: scroll;
|
||||
`
|
||||
|
||||
const SideButtonLink = styled(NavLink)`
|
||||
text-align: center;
|
||||
padding-top: 8px;
|
||||
padding-left: 10px;
|
||||
display: block;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
margin: 10px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
font-size: 28px;
|
||||
|
||||
color: #888;
|
||||
|
||||
transition: transform 200ms, box-shadow 200ms;
|
||||
|
||||
:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
`
|
||||
|
||||
type SideButtonProps = {
|
||||
children: ReactChild | ReactChild[]
|
||||
to: string
|
||||
exact: boolean
|
||||
}
|
||||
|
||||
const SideButton = ({
|
||||
children,
|
||||
to,
|
||||
exact,
|
||||
...otherProps
|
||||
}: SideButtonProps) => {
|
||||
return (
|
||||
<SideButtonLink
|
||||
{...otherProps}
|
||||
to={to}
|
||||
exact={exact}
|
||||
activeStyle={{ color: '#4183c4' }}
|
||||
>
|
||||
{children}
|
||||
</SideButtonLink>
|
||||
)
|
||||
}
|
||||
|
||||
const SideButtonLabel = styled.div`
|
||||
font-size: 14px;
|
||||
`
|
||||
|
||||
export const SideMenu = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const mapboxQuery = authToken() ? useQuery(MAPBOX_QUERY) : null
|
||||
|
||||
const mapboxEnabled = !!mapboxQuery?.data?.mapboxToken
|
||||
|
||||
return (
|
||||
<SideMenuContainer>
|
||||
<SideButton to="/photos" exact>
|
||||
<Icon name="image" />
|
||||
<SideButtonLabel>{t('sidemenu.photos', 'Photos')}</SideButtonLabel>
|
||||
</SideButton>
|
||||
<SideButton to="/albums" exact>
|
||||
<Icon name="images" />
|
||||
<SideButtonLabel>{t('sidemenu.albums', 'Albums')}</SideButtonLabel>
|
||||
</SideButton>
|
||||
{mapboxEnabled ? (
|
||||
<SideButton to="/places" exact>
|
||||
<Icon name="map" />
|
||||
<SideButtonLabel>{t('sidemenu.places', 'Places')}</SideButtonLabel>
|
||||
</SideButton>
|
||||
) : null}
|
||||
<SideButton to="/people" exact>
|
||||
<Icon name="user" />
|
||||
<SideButtonLabel>{t('sidemenu.people', 'People')}</SideButtonLabel>
|
||||
</SideButton>
|
||||
<SideButton to="/settings" exact>
|
||||
<Icon name="settings" />
|
||||
<SideButtonLabel>{t('sidemenu.settings', 'Settings')}</SideButtonLabel>
|
||||
</SideButton>
|
||||
</SideMenuContainer>
|
||||
)
|
||||
}
|
||||
|
||||
type LayoutProps = {
|
||||
children: React.ReactNode
|
||||
title: string
|
||||
}
|
||||
|
||||
const Layout = ({ children, title, ...otherProps }: LayoutProps) => {
|
||||
return (
|
||||
<Container {...otherProps} data-testid="Layout">
|
||||
<Helmet>
|
||||
<title>{title ? `${title} - Photoview` : `Photoview`}</title>
|
||||
</Helmet>
|
||||
<Authorized>
|
||||
<SideMenu />
|
||||
</Authorized>
|
||||
<Sidebar>
|
||||
<Content id="layout-content">
|
||||
{children}
|
||||
<div style={{ height: 24 }}></div>
|
||||
</Content>
|
||||
</Sidebar>
|
||||
<Header />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
Layout.propTypes = {
|
||||
children: PropTypes.any.isRequired,
|
||||
title: PropTypes.string,
|
||||
}
|
||||
|
||||
export default Layout
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useCallback, useEffect } from 'react'
|
||||
import { useQuery, gql } from '@apollo/client'
|
||||
import AlbumGallery from '../../components/albumGallery/AlbumGallery'
|
||||
import Layout from '../../Layout'
|
||||
import Layout from '../../components/layout/Layout'
|
||||
import useURLParameters from '../../hooks/useURLParameters'
|
||||
import useScrollPagination from '../../hooks/useScrollPagination'
|
||||
import PaginateLoader from '../../components/PaginateLoader'
|
||||
|
@ -96,10 +96,8 @@ function AlbumPage({ match }: AlbumPageProps) {
|
|||
},
|
||||
})
|
||||
|
||||
const {
|
||||
containerElem,
|
||||
finished: finishedLoadingMore,
|
||||
} = useScrollPagination<albumQuery>({
|
||||
const { containerElem, finished: finishedLoadingMore } =
|
||||
useScrollPagination<albumQuery>({
|
||||
loading,
|
||||
fetchMore,
|
||||
data,
|
||||
|
|
|
@ -3,101 +3,101 @@
|
|||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
import { OrderDirection, MediaType } from "./../../../../__generated__/globalTypes";
|
||||
import { OrderDirection, MediaType } from './../../../__generated__/globalTypes'
|
||||
|
||||
// ====================================================
|
||||
// GraphQL query operation: albumQuery
|
||||
// ====================================================
|
||||
|
||||
export interface albumQuery_album_subAlbums_thumbnail_thumbnail {
|
||||
__typename: "MediaURL";
|
||||
__typename: 'MediaURL'
|
||||
/**
|
||||
* URL for previewing the image
|
||||
*/
|
||||
url: string;
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface albumQuery_album_subAlbums_thumbnail {
|
||||
__typename: "Media";
|
||||
__typename: 'Media'
|
||||
/**
|
||||
* URL to display the media in a smaller resolution
|
||||
*/
|
||||
thumbnail: albumQuery_album_subAlbums_thumbnail_thumbnail | null;
|
||||
thumbnail: albumQuery_album_subAlbums_thumbnail_thumbnail | null
|
||||
}
|
||||
|
||||
export interface albumQuery_album_subAlbums {
|
||||
__typename: "Album";
|
||||
id: string;
|
||||
title: string;
|
||||
__typename: 'Album'
|
||||
id: string
|
||||
title: string
|
||||
/**
|
||||
* An image in this album used for previewing this album
|
||||
*/
|
||||
thumbnail: albumQuery_album_subAlbums_thumbnail | null;
|
||||
thumbnail: albumQuery_album_subAlbums_thumbnail | null
|
||||
}
|
||||
|
||||
export interface albumQuery_album_media_thumbnail {
|
||||
__typename: "MediaURL";
|
||||
__typename: 'MediaURL'
|
||||
/**
|
||||
* URL for previewing the image
|
||||
*/
|
||||
url: string;
|
||||
url: string
|
||||
/**
|
||||
* Width of the image in pixels
|
||||
*/
|
||||
width: number;
|
||||
width: number
|
||||
/**
|
||||
* Height of the image in pixels
|
||||
*/
|
||||
height: number;
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface albumQuery_album_media_highRes {
|
||||
__typename: "MediaURL";
|
||||
__typename: 'MediaURL'
|
||||
/**
|
||||
* URL for previewing the image
|
||||
*/
|
||||
url: string;
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface albumQuery_album_media_videoWeb {
|
||||
__typename: "MediaURL";
|
||||
__typename: 'MediaURL'
|
||||
/**
|
||||
* URL for previewing the image
|
||||
*/
|
||||
url: string;
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface albumQuery_album_media {
|
||||
__typename: "Media";
|
||||
id: string;
|
||||
type: MediaType;
|
||||
__typename: 'Media'
|
||||
id: string
|
||||
type: MediaType
|
||||
/**
|
||||
* URL to display the media in a smaller resolution
|
||||
*/
|
||||
thumbnail: albumQuery_album_media_thumbnail | null;
|
||||
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;
|
||||
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;
|
||||
videoWeb: albumQuery_album_media_videoWeb | null
|
||||
favorite: boolean
|
||||
}
|
||||
|
||||
export interface albumQuery_album {
|
||||
__typename: "Album";
|
||||
id: string;
|
||||
title: string;
|
||||
__typename: 'Album'
|
||||
id: string
|
||||
title: string
|
||||
/**
|
||||
* The albums contained in this album
|
||||
*/
|
||||
subAlbums: albumQuery_album_subAlbums[];
|
||||
subAlbums: albumQuery_album_subAlbums[]
|
||||
/**
|
||||
* The media inside this album
|
||||
*/
|
||||
media: albumQuery_album_media[];
|
||||
media: albumQuery_album_media[]
|
||||
}
|
||||
|
||||
export interface albumQuery {
|
||||
|
@ -105,14 +105,14 @@ 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;
|
||||
album: albumQuery_album
|
||||
}
|
||||
|
||||
export interface albumQueryVariables {
|
||||
id: string;
|
||||
onlyFavorites?: boolean | null;
|
||||
mediaOrderBy?: string | null;
|
||||
mediaOrderDirection?: OrderDirection | null;
|
||||
limit?: number | null;
|
||||
offset?: number | null;
|
||||
id: string
|
||||
onlyFavorites?: boolean | null
|
||||
mediaOrderBy?: string | null
|
||||
mediaOrderDirection?: OrderDirection | null
|
||||
limit?: number | null
|
||||
offset?: number | null
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import React, { useEffect } from 'react'
|
||||
import AlbumBoxes from '../../components/albumGallery/AlbumBoxes'
|
||||
import Layout from '../../Layout'
|
||||
import Layout from '../../components/layout/Layout'
|
||||
import { useQuery, gql } from '@apollo/client'
|
||||
import LazyLoad from '../../helpers/LazyLoad'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getMyAlbums } from './__generated__/getMyAlbums'
|
||||
|
||||
const getAlbumsQuery = gql`
|
||||
query getMyAlbums {
|
||||
|
@ -20,8 +20,7 @@ const getAlbumsQuery = gql`
|
|||
`
|
||||
|
||||
const AlbumsPage = () => {
|
||||
const { t } = useTranslation()
|
||||
const { loading, error, data } = useQuery(getAlbumsQuery)
|
||||
const { loading, error, data } = useQuery<getMyAlbums>(getAlbumsQuery)
|
||||
|
||||
useEffect(() => {
|
||||
return () => LazyLoad.disconnect()
|
||||
|
@ -33,14 +32,7 @@ const AlbumsPage = () => {
|
|||
|
||||
return (
|
||||
<Layout title="Albums">
|
||||
<h1>{t('albums_page.title', 'Albums')}</h1>
|
||||
{!loading && (
|
||||
<AlbumBoxes
|
||||
loading={loading}
|
||||
error={error}
|
||||
albums={data && data.myAlbums}
|
||||
/>
|
||||
)}
|
||||
<AlbumBoxes error={error} albums={data?.myAlbums} />
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
import React, { useState } from 'react'
|
||||
import React from 'react'
|
||||
import { gql, useQuery, useMutation } from '@apollo/client'
|
||||
import { Redirect } from 'react-router-dom'
|
||||
import { Button, Form, Message, Header } from 'semantic-ui-react'
|
||||
import { Container } from './loginUtilities'
|
||||
|
||||
import { checkInitialSetupQuery, 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'
|
||||
|
||||
const initialSetupMutation = gql`
|
||||
mutation InitialSetup(
|
||||
|
@ -27,14 +29,20 @@ const initialSetupMutation = gql`
|
|||
}
|
||||
`
|
||||
|
||||
type InitialSetupFormData = {
|
||||
username: string
|
||||
password: string
|
||||
rootPath: string
|
||||
}
|
||||
|
||||
const InitialSetupPage = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [state, setState] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
rootPath: '',
|
||||
})
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors: formErrors },
|
||||
} = useForm<InitialSetupFormData>()
|
||||
|
||||
if (authToken()) {
|
||||
return <Redirect to="/" />
|
||||
|
@ -48,10 +56,8 @@ const InitialSetupPage = () => {
|
|||
<Redirect to="/" />
|
||||
)
|
||||
|
||||
const [
|
||||
authorize,
|
||||
{ loading: authorizeLoading, data: authorizationData },
|
||||
] = useMutation(initialSetupMutation, {
|
||||
const [authorize, { loading: authorizeLoading, data: authorizationData }] =
|
||||
useMutation(initialSetupMutation, {
|
||||
onCompleted: data => {
|
||||
const { success, token } = data.initialSetupWizard
|
||||
|
||||
|
@ -61,28 +67,15 @@ const InitialSetupPage = () => {
|
|||
},
|
||||
})
|
||||
|
||||
const handleChange = (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
key: string
|
||||
) => {
|
||||
const value = event.target.value
|
||||
setState(prevState => ({
|
||||
...prevState,
|
||||
[key]: value,
|
||||
}))
|
||||
}
|
||||
|
||||
const signIn = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
|
||||
const signIn = handleSubmit(data => {
|
||||
authorize({
|
||||
variables: {
|
||||
username: state.username,
|
||||
password: state.password,
|
||||
rootPath: state.rootPath,
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
rootPath: data.rootPath,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
let errorMessage = null
|
||||
if (authorizationData && !authorizationData.initialSetupWizard.success) {
|
||||
|
@ -93,49 +86,59 @@ const InitialSetupPage = () => {
|
|||
<div>
|
||||
{initialSetupRedirect}
|
||||
<Container>
|
||||
<Header as="h1" textAlign="center">
|
||||
<h1 className="text-center text-xl">
|
||||
{t('login_page.initial_setup.title', 'Initial Setup')}
|
||||
</Header>
|
||||
<Form
|
||||
style={{ width: 500, margin: 'auto' }}
|
||||
error={!!errorMessage}
|
||||
onSubmit={signIn}
|
||||
loading={
|
||||
authorizeLoading || authorizationData?.initialSetupWizard?.success
|
||||
</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
|
||||
}
|
||||
>
|
||||
<Form.Field>
|
||||
<label>{t('login_page.field.username', 'Username')}</label>
|
||||
<input onChange={e => handleChange(e, 'username')} />
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label>{t('login_page.field.password', 'Password')}</label>
|
||||
<input
|
||||
type="password"
|
||||
onChange={e => handleChange(e, 'password')}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label>
|
||||
{t(
|
||||
<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'
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
placeholder={t(
|
||||
'login_page.initial_setup.field.photo_path.placeholder',
|
||||
'/path/to/photos'
|
||||
)}
|
||||
type="text"
|
||||
onChange={e => handleChange(e, 'rootPath')}
|
||||
error={
|
||||
formErrors.password?.type == 'required'
|
||||
? 'Please enter a photo path'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Message error content={errorMessage} />
|
||||
<Button type="submit">
|
||||
<MessageBox
|
||||
type="negative"
|
||||
message={errorMessage}
|
||||
show={errorMessage}
|
||||
/>
|
||||
<Submit className="mt-2" disabled={authorizeLoading}>
|
||||
{t('login_page.initial_setup.field.submit', 'Setup Photoview')}
|
||||
</Button>
|
||||
</Form>
|
||||
</Submit>
|
||||
</form>
|
||||
</Container>
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -1,14 +1,17 @@
|
|||
import React, { useState, useCallback } from 'react'
|
||||
import React from 'react'
|
||||
import { useQuery, gql, useMutation } from '@apollo/client'
|
||||
import { Redirect } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
import { Button, Form, Message, Header, HeaderProps } from 'semantic-ui-react'
|
||||
import { checkInitialSetupQuery, login, Container } from './loginUtilities'
|
||||
import { useForm, SubmitHandler } from 'react-hook-form'
|
||||
import { checkInitialSetupQuery, login } from './loginUtilities'
|
||||
import { authToken } from '../../helpers/authentication'
|
||||
|
||||
import logoPath from '../../assets/photoview-logo.svg'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Helmet } from 'react-helmet'
|
||||
import { Redirect } 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!) {
|
||||
|
@ -20,123 +23,129 @@ const authorizeMutation = gql`
|
|||
}
|
||||
`
|
||||
|
||||
const StyledLogo = styled.img`
|
||||
max-height: 128px;
|
||||
`
|
||||
|
||||
const LogoHeader = (props: HeaderProps) => {
|
||||
const LogoHeader = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Header {...props} as="h1" textAlign="center">
|
||||
<StyledLogo src={logoPath} alt="photoview logo" />
|
||||
<p style={{ fontWeight: 400 }}>
|
||||
<div className="flex justify-center flex-col mb-14 mt-20">
|
||||
<img className="h-24" src={logoPath} alt="photoview logo" />
|
||||
<h1 className="text-3xl text-center mt-4">
|
||||
{t('login_page.welcome', 'Welcome to Photoview')}
|
||||
</p>
|
||||
</Header>
|
||||
</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const LogoHeaderStyled = styled(LogoHeader)`
|
||||
margin-bottom: 72px !important;
|
||||
`
|
||||
|
||||
const LoginPage = () => {
|
||||
const LoginForm = () => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors: formErrors },
|
||||
} = useForm()
|
||||
|
||||
const [credentials, setCredentials] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
const handleChange = useCallback(
|
||||
(event, key) => {
|
||||
const value = event.target.value
|
||||
setCredentials(credentials => {
|
||||
return {
|
||||
...credentials,
|
||||
[key]: value,
|
||||
}
|
||||
})
|
||||
},
|
||||
[setCredentials]
|
||||
)
|
||||
|
||||
const signIn = useCallback(
|
||||
(event, authorize) => {
|
||||
event.preventDefault()
|
||||
|
||||
authorize({
|
||||
variables: {
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
},
|
||||
})
|
||||
},
|
||||
[credentials]
|
||||
)
|
||||
|
||||
const { data: initialSetupData } = useQuery(checkInitialSetupQuery)
|
||||
|
||||
const [authorize, { loading, data }] = useMutation(authorizeMutation, {
|
||||
const [authorize, { loading, data }] = useMutation<
|
||||
Authorize,
|
||||
AuthorizeVariables
|
||||
>(authorizeMutation, {
|
||||
onCompleted: data => {
|
||||
const { success, token } = data.authorizeUser
|
||||
|
||||
if (success) {
|
||||
if (success && token) {
|
||||
login(token)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<LoginInputs> = data => {
|
||||
authorize({
|
||||
variables: {
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
console.log('errors', formErrors)
|
||||
|
||||
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', { required: true })}
|
||||
error={
|
||||
formErrors.password?.type == 'required'
|
||||
? 'Please enter a password'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
value={t('login_page.field.submit', 'Sign in') as string}
|
||||
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 { data: initialSetupData } = useQuery<CheckInitialSetup>(
|
||||
checkInitialSetupQuery
|
||||
)
|
||||
|
||||
if (authToken()) {
|
||||
return <Redirect to="/" />
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t('title.login', 'Login')} - Photoview</title>
|
||||
</Helmet>
|
||||
<Container>
|
||||
<LogoHeaderStyled />
|
||||
{initialSetupData?.siteInfo?.initialSetup && (
|
||||
<Redirect to="/initialSetup" />
|
||||
)}
|
||||
<Form
|
||||
style={{ width: 500, margin: 'auto' }}
|
||||
error={!!errorMessage}
|
||||
onSubmit={e => signIn(e, authorize)}
|
||||
loading={loading || (data && data.authorizeUser.success)}
|
||||
>
|
||||
<Form.Field>
|
||||
<label htmlFor="username_field">
|
||||
{t('login_page.field.username', 'Username')}
|
||||
</label>
|
||||
<input
|
||||
id="username_field"
|
||||
onChange={e => handleChange(e, 'username')}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label htmlFor="password_field">
|
||||
{t('login_page.field.password', 'Password')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password_field"
|
||||
onChange={e => handleChange(e, 'password')}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Message error content={errorMessage} />
|
||||
<Button type="submit">
|
||||
{t('login_page.field.submit', 'Sign in')}
|
||||
</Button>
|
||||
</Form>
|
||||
</Container>
|
||||
<div>
|
||||
<LogoHeader />
|
||||
<LoginForm />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { gql } from '@apollo/client'
|
||||
import { saveTokenCookie } from '../../helpers/authentication'
|
||||
import styled from 'styled-components'
|
||||
import { Container as SemanticContainer } from 'semantic-ui-react'
|
||||
|
||||
export const checkInitialSetupQuery = gql`
|
||||
query CheckInitialSetup {
|
||||
|
@ -16,6 +15,4 @@ export function login(token: string) {
|
|||
window.location.href = '/'
|
||||
}
|
||||
|
||||
export const Container = styled(SemanticContainer)`
|
||||
margin-top: 80px;
|
||||
`
|
||||
export const Container = styled.div.attrs({ className: 'mt-20' })``
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import React, { createRef, useEffect, useState } from 'react'
|
||||
import { gql, useMutation, useQuery } from '@apollo/client'
|
||||
import Layout from '../../Layout'
|
||||
import Layout from '../../components/layout/Layout'
|
||||
import styled from 'styled-components'
|
||||
import { Link } from 'react-router-dom'
|
||||
import SingleFaceGroup from './SingleFaceGroup/SingleFaceGroup'
|
||||
import { Button, Icon, Input } from 'semantic-ui-react'
|
||||
import { Button, TextField } from '../../primitives/form/Input'
|
||||
import FaceCircleImage from './FaceCircleImage'
|
||||
import useScrollPagination from '../../hooks/useScrollPagination'
|
||||
import PaginateLoader from '../../components/PaginateLoader'
|
||||
|
@ -65,7 +65,7 @@ const RECOGNIZE_UNLABELED_FACES_MUTATION = gql`
|
|||
}
|
||||
`
|
||||
|
||||
const FaceDetailsButton = styled.button<{ labeled: boolean }>`
|
||||
const FaceDetailsWrapper = styled.div<{ labeled: boolean }>`
|
||||
color: ${({ labeled }) => (labeled ? 'black' : '#aaa')};
|
||||
width: 150px;
|
||||
margin: 12px auto 24px;
|
||||
|
@ -81,8 +81,6 @@ const FaceDetailsButton = styled.button<{ labeled: boolean }>`
|
|||
}
|
||||
`
|
||||
|
||||
const FaceLabel = styled.span``
|
||||
|
||||
type FaceDetailsProps = {
|
||||
group: myFaces_myFaceGroups
|
||||
}
|
||||
|
@ -91,7 +89,7 @@ export const FaceDetails = ({ group }: FaceDetailsProps) => {
|
|||
const { t } = useTranslation()
|
||||
const [editLabel, setEditLabel] = useState(false)
|
||||
const [inputValue, setInputValue] = useState(group.label ?? '')
|
||||
const inputRef = createRef<Input>()
|
||||
const inputRef = createRef<HTMLInputElement>()
|
||||
|
||||
const [setGroupLabel, { loading }] = useMutation<
|
||||
setGroupLabel,
|
||||
|
@ -117,54 +115,54 @@ export const FaceDetails = ({ group }: FaceDetailsProps) => {
|
|||
}
|
||||
}, [loading])
|
||||
|
||||
const onKeyUp = (e: React.ChangeEvent<HTMLInputElement> & KeyboardEvent) => {
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key == 'Escape') {
|
||||
resetLabel()
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key == 'Enter') {
|
||||
setGroupLabel({
|
||||
variables: {
|
||||
groupID: group.id,
|
||||
label: e.target.value == '' ? null : e.target.value,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let label
|
||||
if (!editLabel) {
|
||||
label = (
|
||||
<FaceDetailsButton
|
||||
<FaceDetailsWrapper
|
||||
labeled={!!group.label}
|
||||
onClick={() => setEditLabel(true)}
|
||||
>
|
||||
<FaceImagesCount>{group.imageFaceCount}</FaceImagesCount>
|
||||
<FaceLabel>
|
||||
<button>
|
||||
{group.label ?? t('people_page.face_group.unlabeled', 'Unlabeled')}
|
||||
</FaceLabel>
|
||||
<EditIcon name="pencil" />
|
||||
</FaceDetailsButton>
|
||||
</button>
|
||||
{/* <EditIcon name="pencil" /> */}
|
||||
</FaceDetailsWrapper>
|
||||
)
|
||||
} else {
|
||||
label = (
|
||||
<FaceDetailsButton labeled={!!group.label}>
|
||||
<Input
|
||||
<FaceDetailsWrapper labeled={!!group.label}>
|
||||
<TextField
|
||||
className="w-[160px]"
|
||||
loading={loading}
|
||||
ref={inputRef}
|
||||
size="mini"
|
||||
// size="mini"
|
||||
placeholder={t('people_page.face_group.label_placeholder', 'Label')}
|
||||
icon="arrow right"
|
||||
// icon="arrow right"
|
||||
value={inputValue}
|
||||
onKeyUp={onKeyUp}
|
||||
action={() =>
|
||||
setGroupLabel({
|
||||
variables: {
|
||||
groupID: group.id,
|
||||
label: inputValue == '' ? null : inputValue,
|
||||
},
|
||||
})
|
||||
}
|
||||
onKeyDown={onKeyDown}
|
||||
onChange={e => setInputValue(e.target.value)}
|
||||
onBlur={() => {
|
||||
onBlur={e => {
|
||||
console.log(e)
|
||||
resetLabel()
|
||||
}}
|
||||
/>
|
||||
</FaceDetailsButton>
|
||||
</FaceDetailsWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -180,17 +178,6 @@ const FaceImagesCount = styled.span`
|
|||
border-radius: 4px;
|
||||
`
|
||||
|
||||
const EditIcon = styled(Icon)`
|
||||
margin-left: 6px !important;
|
||||
opacity: 0 !important;
|
||||
|
||||
transition: opacity 100ms;
|
||||
|
||||
${FaceDetailsButton}:hover &, ${FaceDetailsButton}:focus-visible & {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
`
|
||||
|
||||
type FaceGroupProps = {
|
||||
group: myFaces_myFaceGroups
|
||||
}
|
||||
|
@ -251,13 +238,11 @@ const PeopleGallery = () => {
|
|||
return (
|
||||
<Layout title={t('title.people', 'People')}>
|
||||
<Button
|
||||
loading={recognizeUnlabeledLoading}
|
||||
disabled={recognizeUnlabeledLoading}
|
||||
onClick={() => {
|
||||
recognizeUnlabeled()
|
||||
}}
|
||||
>
|
||||
<Icon name="sync" />
|
||||
{t(
|
||||
'people_page.recognize_unlabeled_faces_button',
|
||||
'Recognize unlabeled faces'
|
||||
|
|
|
@ -2,8 +2,8 @@ import { gql, useMutation } from '@apollo/client'
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
import { Button, Modal } from 'semantic-ui-react'
|
||||
import { isNil } from '../../../helpers/utils'
|
||||
import Modal from '../../../primitives/Modal'
|
||||
import { MY_FACES_QUERY } from '../PeoplePage'
|
||||
import {
|
||||
myFaces_myFaceGroups,
|
||||
|
@ -83,21 +83,33 @@ const DetachImageFacesModal = ({
|
|||
|
||||
return (
|
||||
<Modal
|
||||
onClose={() => setOpen(false)}
|
||||
onOpen={() => setOpen(true)}
|
||||
open={open}
|
||||
>
|
||||
<Modal.Header>
|
||||
{t('people_page.modal.detach_image_faces.title', 'Detach Image Faces')}
|
||||
</Modal.Header>
|
||||
<Modal.Content scrolling>
|
||||
<Modal.Description>
|
||||
<p>
|
||||
{t(
|
||||
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'
|
||||
)}
|
||||
</p>
|
||||
actions={[
|
||||
{
|
||||
key: 'cancel',
|
||||
label: '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}
|
||||
|
@ -107,22 +119,6 @@ const DetachImageFacesModal = ({
|
|||
'Select images to detach'
|
||||
)}
|
||||
/>
|
||||
</Modal.Description>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
disabled={selectedImageFaces.length == 0}
|
||||
content={t(
|
||||
'people_page.modal.detach_image_faces.action.detach',
|
||||
'Detach image faces'
|
||||
)}
|
||||
labelPosition="right"
|
||||
icon="checkmark"
|
||||
onClick={() => detachImageFaces()}
|
||||
positive
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,9 +1,13 @@
|
|||
import { useMutation } from '@apollo/client'
|
||||
import React, { useState, useEffect, createRef } from 'react'
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
createRef,
|
||||
KeyboardEventHandler,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Dropdown, Input } from 'semantic-ui-react'
|
||||
import styled from 'styled-components'
|
||||
import { isNil } from '../../../helpers/utils'
|
||||
import { Button, TextField } from '../../../primitives/form/Input'
|
||||
import { SET_GROUP_LABEL_MUTATION } from '../PeoplePage'
|
||||
import {
|
||||
setGroupLabel,
|
||||
|
@ -14,26 +18,6 @@ import MergeFaceGroupsModal from './MergeFaceGroupsModal'
|
|||
import MoveImageFacesModal from './MoveImageFacesModal'
|
||||
import { singleFaceGroup_faceGroup } from './__generated__/singleFaceGroup'
|
||||
|
||||
const TitleWrapper = styled.div`
|
||||
min-height: 3.5em;
|
||||
`
|
||||
|
||||
const TitleLabel = styled.h1<{ labeled: boolean }>`
|
||||
display: inline-block;
|
||||
color: ${({ labeled }) => (labeled ? 'black' : '#888')};
|
||||
margin-right: 12px;
|
||||
`
|
||||
|
||||
const TitleDropdown = styled(Dropdown)`
|
||||
vertical-align: middle;
|
||||
margin-top: -10px;
|
||||
color: #888;
|
||||
|
||||
&:hover {
|
||||
color: #1e70bf;
|
||||
}
|
||||
`
|
||||
|
||||
type FaceGroupTitleProps = {
|
||||
faceGroup?: singleFaceGroup_faceGroup
|
||||
}
|
||||
|
@ -43,7 +27,7 @@ const FaceGroupTitle = ({ faceGroup }: FaceGroupTitleProps) => {
|
|||
|
||||
const [editLabel, setEditLabel] = useState(false)
|
||||
const [inputValue, setInputValue] = useState(faceGroup?.label ?? '')
|
||||
const inputRef = createRef<Input>()
|
||||
const inputRef = createRef<HTMLInputElement>()
|
||||
const [mergeModalOpen, setMergeModalOpen] = useState(false)
|
||||
const [moveModalOpen, setMoveModalOpen] = useState(false)
|
||||
const [detachModalOpen, setDetachModalOpen] = useState(false)
|
||||
|
@ -70,90 +54,53 @@ const FaceGroupTitle = ({ faceGroup }: FaceGroupTitleProps) => {
|
|||
}
|
||||
}, [setLabelLoading])
|
||||
|
||||
const onKeyUp = (e: KeyboardEvent & React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (isNil(faceGroup)) throw new Error('Expected faceGroup to be defined')
|
||||
|
||||
const onKeyDown: KeyboardEventHandler<HTMLInputElement> = e => {
|
||||
if (e.key == 'Escape') {
|
||||
resetLabel()
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key == 'Enter') {
|
||||
setGroupLabel({
|
||||
variables: {
|
||||
groupID: faceGroup.id,
|
||||
label: e.target.value == '' ? null : e.target.value,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let title
|
||||
if (!editLabel) {
|
||||
title = (
|
||||
<TitleWrapper>
|
||||
<TitleLabel labeled={!!faceGroup?.label}>
|
||||
<>
|
||||
<h1
|
||||
className={`text-2xl font-semibold ${
|
||||
faceGroup?.label ? 'text-black' : 'text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{faceGroup?.label ??
|
||||
t('people_page.face_group.unlabeled_person', 'Unlabeled person')}
|
||||
</TitleLabel>
|
||||
<TitleDropdown
|
||||
icon={{
|
||||
name: 'settings',
|
||||
size: 'large',
|
||||
}}
|
||||
>
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item
|
||||
icon="pencil"
|
||||
text={
|
||||
faceGroup?.label
|
||||
? t(
|
||||
'people_page.face_group.action.change_label',
|
||||
'Change Label'
|
||||
)
|
||||
: t('people_page.face_group.action.add_label', 'Add Label')
|
||||
}
|
||||
onClick={() => setEditLabel(true)}
|
||||
/>
|
||||
<Dropdown.Item
|
||||
icon="object group"
|
||||
text={t('people_page.face_group.action.merge_face', 'Merge Face')}
|
||||
onClick={() => setMergeModalOpen(true)}
|
||||
/>
|
||||
<Dropdown.Item
|
||||
icon="object ungroup"
|
||||
text={t(
|
||||
'people_page.face_group.action.detach_face',
|
||||
'Detach Face'
|
||||
)}
|
||||
onClick={() => setDetachModalOpen(true)}
|
||||
/>
|
||||
<Dropdown.Item
|
||||
icon="clone"
|
||||
text={t('people_page.face_group.action.move_faces', 'Move Faces')}
|
||||
onClick={() => setMoveModalOpen(true)}
|
||||
/>
|
||||
</Dropdown.Menu>
|
||||
</TitleDropdown>
|
||||
</TitleWrapper>
|
||||
</h1>
|
||||
</>
|
||||
)
|
||||
} else {
|
||||
title = (
|
||||
<TitleWrapper>
|
||||
<Input
|
||||
<>
|
||||
<TextField
|
||||
loading={setLabelLoading}
|
||||
ref={inputRef}
|
||||
placeholder={t('people_page.face_group.label_placeholder', 'Label')}
|
||||
icon="arrow right"
|
||||
action={() => {
|
||||
if (isNil(faceGroup))
|
||||
throw new Error('Expected faceGroup to be defined')
|
||||
|
||||
setGroupLabel({
|
||||
variables: {
|
||||
groupID: faceGroup.id,
|
||||
label: inputValue ? inputValue : null,
|
||||
},
|
||||
})
|
||||
}}
|
||||
value={inputValue}
|
||||
onKeyUp={onKeyUp}
|
||||
onKeyDown={onKeyDown}
|
||||
onChange={e => setInputValue(e.target.value)}
|
||||
onBlur={() => {
|
||||
resetLabel()
|
||||
}}
|
||||
/>
|
||||
</TitleWrapper>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -182,7 +129,25 @@ const FaceGroupTitle = ({ faceGroup }: FaceGroupTitleProps) => {
|
|||
|
||||
return (
|
||||
<>
|
||||
{title}
|
||||
<div>
|
||||
<div className="mb-2">{title}</div>
|
||||
<ul className="flex gap-2 flex-wrap mb-6">
|
||||
<li>
|
||||
<Button onClick={() => setEditLabel(true)}>Change label</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button onClick={() => setMergeModalOpen(true)}>Merge face</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button onClick={() => setDetachModalOpen(true)}>
|
||||
Detach face
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button onClick={() => setMoveModalOpen(true)}>Move faces</Button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{modals}
|
||||
</>
|
||||
)
|
||||
|
|
|
@ -2,8 +2,8 @@ import { gql, useMutation, useQuery } from '@apollo/client'
|
|||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
import { Button, Modal } from 'semantic-ui-react'
|
||||
import { isNil } from '../../../helpers/utils'
|
||||
import Modal from '../../../primitives/Modal'
|
||||
import { MY_FACES_QUERY } from '../PeoplePage'
|
||||
import {
|
||||
myFaces,
|
||||
|
@ -78,21 +78,30 @@ const MergeFaceGroupsModal = ({
|
|||
|
||||
return (
|
||||
<Modal
|
||||
onClose={() => setOpen(false)}
|
||||
onOpen={() => setOpen(true)}
|
||||
open={open}
|
||||
>
|
||||
<Modal.Header>
|
||||
{t('people_page.modal.merge_face_groups.title', 'Merge Face Groups')}
|
||||
</Modal.Header>
|
||||
<Modal.Content scrolling>
|
||||
<Modal.Description>
|
||||
<p>
|
||||
{t(
|
||||
title={t(
|
||||
'people_page.modal.merge_face_groups.title',
|
||||
'Merge Face Groups'
|
||||
)}
|
||||
description={t(
|
||||
'people_page.modal.merge_face_groups.description',
|
||||
'All images within this face group will be merged into the selected face group.'
|
||||
)}
|
||||
</p>
|
||||
actions={[
|
||||
{
|
||||
key: 'cancel',
|
||||
label: t('general.action.cancel', 'Cancel'),
|
||||
onClick: () => setOpen(false),
|
||||
},
|
||||
{
|
||||
key: 'merge',
|
||||
label: t('people_page.modal.action.merge', 'Merge'),
|
||||
onClick: () => mergeFaceGroups(),
|
||||
variant: 'positive',
|
||||
},
|
||||
]}
|
||||
onClose={() => setOpen(false)}
|
||||
open={open}
|
||||
>
|
||||
<SelectFaceGroupTable
|
||||
title={t(
|
||||
'people_page.modal.merge_face_groups.destination_table.title',
|
||||
|
@ -102,21 +111,6 @@ const MergeFaceGroupsModal = ({
|
|||
selectedFaceGroup={selectedFaceGroup}
|
||||
setSelectedFaceGroup={setSelectedFaceGroup}
|
||||
/>
|
||||
</Modal.Description>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button onClick={() => setOpen(false)}>
|
||||
{t('general.action.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={selectedFaceGroup == null}
|
||||
content={t('people_page.modal.action.merge', 'Merge')}
|
||||
labelPosition="right"
|
||||
icon="checkmark"
|
||||
onClick={() => mergeFaceGroups()}
|
||||
positive
|
||||
/>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { gql, useLazyQuery, useMutation } from '@apollo/client'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
import { Button, Modal } from 'semantic-ui-react'
|
||||
import SelectFaceGroupTable from './SelectFaceGroupTable'
|
||||
import SelectImageFacesTable from './SelectImageFacesTable'
|
||||
import { MY_FACES_QUERY } from '../PeoplePage'
|
||||
|
@ -21,6 +20,7 @@ import {
|
|||
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!) {
|
||||
|
@ -141,63 +141,48 @@ const MoveImageFacesModal = ({
|
|||
}
|
||||
}
|
||||
|
||||
let positiveButton = null
|
||||
let positiveButton: ModalAction
|
||||
if (!imagesSelected) {
|
||||
positiveButton = (
|
||||
<Button
|
||||
disabled={selectedImageFaces.length == 0}
|
||||
content={t(
|
||||
positiveButton = {
|
||||
key: 'next',
|
||||
label: t(
|
||||
'people_page.modal.move_image_faces.image_select_table.next_action',
|
||||
'Next'
|
||||
)}
|
||||
labelPosition="right"
|
||||
icon="arrow right"
|
||||
onClick={() => setImagesSelected(true)}
|
||||
positive
|
||||
/>
|
||||
)
|
||||
),
|
||||
onClick: () => setImagesSelected(true),
|
||||
variant: 'positive',
|
||||
}
|
||||
} else {
|
||||
positiveButton = (
|
||||
<Button
|
||||
disabled={!selectedFaceGroup}
|
||||
content={t(
|
||||
positiveButton = {
|
||||
key: 'move',
|
||||
label: t(
|
||||
'people_page.modal.move_image_faces.destination_face_group_table.move_action',
|
||||
'Move image faces'
|
||||
)}
|
||||
labelPosition="right"
|
||||
icon="checkmark"
|
||||
onClick={() => moveImageFaces()}
|
||||
positive
|
||||
/>
|
||||
)
|
||||
),
|
||||
onClick: () => moveImageFaces(),
|
||||
variant: 'positive',
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onClose={() => setOpen(false)}
|
||||
onOpen={() => setOpen(true)}
|
||||
open={open}
|
||||
>
|
||||
<Modal.Header>
|
||||
{t('people_page.modal.move_image_faces.title', 'Move Image Faces')}
|
||||
</Modal.Header>
|
||||
<Modal.Content scrolling>
|
||||
<Modal.Description>
|
||||
<p>
|
||||
{t(
|
||||
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'
|
||||
)}
|
||||
</p>
|
||||
onClose={() => setOpen(false)}
|
||||
open={open}
|
||||
actions={[
|
||||
{
|
||||
key: 'cancel',
|
||||
label: t('general.action.cancel', 'Cancel'),
|
||||
onClick: () => setOpen(false),
|
||||
},
|
||||
positiveButton,
|
||||
]}
|
||||
>
|
||||
{table}
|
||||
</Modal.Description>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button onClick={() => setOpen(false)}>
|
||||
{t('general.action.cancel', 'Cancel')}
|
||||
</Button>
|
||||
{positiveButton}
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,7 +1,15 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Input, Pagination, Table } from 'semantic-ui-react'
|
||||
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'
|
||||
|
@ -13,7 +21,7 @@ const FaceCircleWrapper = styled.div<{ $selected: boolean }>`
|
|||
${({ $selected }) => ($selected ? `#2185c9` : 'rgba(255,255,255,0)')};
|
||||
`
|
||||
|
||||
const FlexCell = styled(Table.Cell)`
|
||||
const FlexCell = styled(TableCell)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`
|
||||
|
@ -21,6 +29,7 @@ const FlexCell = styled(Table.Cell)`
|
|||
export const RowLabel = styled.span<{ $selected: boolean }>`
|
||||
${({ $selected }) => $selected && `font-weight: bold;`}
|
||||
margin-left: 12px;
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
type FaceGroupRowProps = {
|
||||
|
@ -35,7 +44,7 @@ const FaceGroupRow = ({
|
|||
setFaceSelected,
|
||||
}: FaceGroupRowProps) => {
|
||||
return (
|
||||
<Table.Row key={faceGroup.id} onClick={setFaceSelected}>
|
||||
<TableRow key={faceGroup.id} onClick={setFaceSelected}>
|
||||
<FlexCell>
|
||||
<FaceCircleWrapper $selected={faceSelected}>
|
||||
<FaceCircleImage
|
||||
|
@ -44,9 +53,15 @@ const FaceGroupRow = ({
|
|||
selectable={false}
|
||||
/>
|
||||
</FaceCircleWrapper>
|
||||
<RowLabel $selected={faceSelected}>{faceGroup.label}</RowLabel>
|
||||
<span
|
||||
className={`ml-3 ${faceSelected ? 'font-semibold' : ''} ${
|
||||
!faceGroup.label ? 'text-gray-500 italic' : ''
|
||||
}`}
|
||||
>
|
||||
{faceGroup.label ?? 'Unlabeled'}
|
||||
</span>
|
||||
</FlexCell>
|
||||
</Table.Row>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -69,15 +84,8 @@ const SelectFaceGroupTable = ({
|
|||
}: SelectFaceGroupTableProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const PAGE_SIZE = 6
|
||||
|
||||
const [page, setPage] = useState(0)
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setPage(0)
|
||||
}, [searchValue])
|
||||
|
||||
const rows = faceGroups
|
||||
.filter(
|
||||
x =>
|
||||
|
@ -93,55 +101,34 @@ const SelectFaceGroupTable = ({
|
|||
/>
|
||||
))
|
||||
|
||||
const pageRows = rows.filter(
|
||||
(_, i) => i >= page * PAGE_SIZE && i < (page + 1) * PAGE_SIZE
|
||||
)
|
||||
|
||||
return (
|
||||
<Table selectable>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>{title}</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>
|
||||
<Input
|
||||
<>
|
||||
<Table className="w-full">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>{title}</TableHeaderCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHeaderCell>
|
||||
<TextField
|
||||
fullWidth
|
||||
value={searchValue}
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
icon="search"
|
||||
placeholder={t(
|
||||
'people_page.table.select_face_group.search_faces_placeholder',
|
||||
'people_page.tableselect_face_group.search_faces_placeholder',
|
||||
'Search faces...'
|
||||
)}
|
||||
fluid
|
||||
/>
|
||||
</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>{pageRows}</Table.Body>
|
||||
<Table.Footer>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>
|
||||
<Pagination
|
||||
floated="right"
|
||||
firstItem={null}
|
||||
lastItem={null}
|
||||
// nextItem={null}
|
||||
// prevItem={null}
|
||||
activePage={page + 1}
|
||||
totalPages={Math.ceil(rows.length / PAGE_SIZE)}
|
||||
onPageChange={(_, { activePage }) => {
|
||||
if (activePage) {
|
||||
setPage(Math.ceil(activePage as number) - 1)
|
||||
} else {
|
||||
setPage(0)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Footer>
|
||||
</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
</Table>
|
||||
<div className="overflow-auto max-h-[500px] mt-2">
|
||||
<Table className="w-full">
|
||||
<TableBody>{rows}</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,10 +1,18 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Checkbox, Input, Pagination, Table } from 'semantic-ui-react'
|
||||
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 { RowLabel } from './SelectFaceGroupTable'
|
||||
import { singleFaceGroup_faceGroup_imageFaces } from './__generated__/singleFaceGroup'
|
||||
|
||||
const SelectImagePreview = styled(ProtectedImage)`
|
||||
|
@ -26,22 +34,21 @@ const ImageFaceRow = ({
|
|||
setFaceSelected,
|
||||
}: ImageFaceRowProps) => {
|
||||
return (
|
||||
<Table.Row key={imageFace.id}>
|
||||
<Table.Cell>
|
||||
<Checkbox checked={faceSelected} onChange={setFaceSelected} />
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<TableRow key={imageFace.id}>
|
||||
<TableCell>
|
||||
<SelectImagePreview
|
||||
src={imageFace.media.thumbnail?.url}
|
||||
onClick={setFaceSelected}
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell width={16}>
|
||||
<RowLabel $selected={faceSelected} onClick={setFaceSelected}>
|
||||
{imageFace.media.title}
|
||||
</RowLabel>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
</TableCell>
|
||||
<TableCell className="min-w-64 w-full">
|
||||
<Checkbox
|
||||
label={imageFace.media.title}
|
||||
checked={faceSelected}
|
||||
onChange={setFaceSelected}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -70,14 +77,14 @@ const SelectImageFacesTable = ({
|
|||
}: SelectImageFacesTable) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const PAGE_SIZE = 6
|
||||
// const PAGE_SIZE = 6
|
||||
|
||||
const [page, setPage] = useState(0)
|
||||
// const [page, setPage] = useState(0)
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setPage(0)
|
||||
}, [searchValue])
|
||||
// useEffect(() => {
|
||||
// setPage(0)
|
||||
// }, [searchValue])
|
||||
|
||||
const rows = imageFaces
|
||||
.filter(
|
||||
|
@ -102,55 +109,38 @@ const SelectImageFacesTable = ({
|
|||
/>
|
||||
))
|
||||
|
||||
const pageRows = rows.filter(
|
||||
(_, i) => i >= page * PAGE_SIZE && i < (page + 1) * PAGE_SIZE
|
||||
)
|
||||
// const pageRows = rows.filter(
|
||||
// (_, i) => i >= page * PAGE_SIZE && i < (page + 1) * PAGE_SIZE
|
||||
// )
|
||||
|
||||
return (
|
||||
<Table selectable>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell colSpan={3}>{title}</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell colSpan={3}>
|
||||
<Input
|
||||
<>
|
||||
<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)}
|
||||
icon="search"
|
||||
placeholder={t(
|
||||
'people_page.table.select_image_faces.search_images_placeholder',
|
||||
'people_page.tableselect_image_faces.search_images_placeholder',
|
||||
'Search images...'
|
||||
)}
|
||||
fluid
|
||||
fullWidth
|
||||
/>
|
||||
</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>{pageRows}</Table.Body>
|
||||
<Table.Footer>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell colSpan={3}>
|
||||
<Pagination
|
||||
floated="right"
|
||||
firstItem={null}
|
||||
lastItem={null}
|
||||
// nextItem={null}
|
||||
// prevItem={null}
|
||||
activePage={page + 1}
|
||||
totalPages={Math.ceil(rows.length / PAGE_SIZE)}
|
||||
onPageChange={(_, { activePage }) => {
|
||||
if (activePage) {
|
||||
setPage(Math.ceil(activePage as number) - 1)
|
||||
} else {
|
||||
setPage(0)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Footer>
|
||||
</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
</Table>
|
||||
<div className="overflow-auto max-h-[500px] mt-2">
|
||||
<Table>
|
||||
<TableBody>{rows}</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -93,9 +93,9 @@ test('single face group', async () => {
|
|||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading media')).not.toHaveClass('active')
|
||||
// expect(screen.queryByText('Loading more media')).not.toHaveClass('active')
|
||||
expect(screen.queryByText('Face Group Name')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByText('Face Group Name')).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('img')).toHaveLength(2)
|
||||
})
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
import { MediaType } from './../../../../../__generated__/globalTypes'
|
||||
import { MediaType } from './../../../../__generated__/globalTypes'
|
||||
|
||||
// ====================================================
|
||||
// GraphQL query operation: singleFaceGroup
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react'
|
||||
import Layout from '../../Layout'
|
||||
import Layout from '../../components/layout/Layout'
|
||||
import TimelineGallery from '../../components/timelineGallery/TimelineGallery'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
|
|
|
@ -4,15 +4,17 @@ import React, { useEffect, useReducer, useRef, useState } from 'react'
|
|||
import { Helmet } from 'react-helmet'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
import Layout from '../../Layout'
|
||||
import Layout from '../../components/layout/Layout'
|
||||
import { makeUpdateMarkers } from './mapboxHelperFunctions'
|
||||
import MapPresentMarker from './MapPresentMarker'
|
||||
import { urlPresentModeSetupHook } from '../../components/photoGallery/photoGalleryReducer'
|
||||
import { placesReducer } from './placesReducer'
|
||||
|
||||
import 'mapbox-gl/dist/mapbox-gl.css'
|
||||
|
||||
const MapWrapper = styled.div`
|
||||
width: 100%;
|
||||
height: calc(100% - 24px);
|
||||
height: calc(100vh - 120px);
|
||||
`
|
||||
|
||||
const MapContainer = styled.div`
|
||||
|
@ -145,7 +147,8 @@ const MapPage = () => {
|
|||
return (
|
||||
<Layout title="Places">
|
||||
<Helmet>
|
||||
<link rel="stylesheet" href="/mapbox-gl.css" />
|
||||
{/* <link rel="stylesheet" href="/mapbox-gl.css" /> */}
|
||||
{/* <style type="text/css">{mapboxStyles}</style> */}
|
||||
</Helmet>
|
||||
<MapWrapper>
|
||||
<MapContainer ref={mapContainer}></MapContainer>
|
||||
|
|
|
@ -3,86 +3,86 @@
|
|||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
import { MediaType } from "./../../../../__generated__/globalTypes";
|
||||
import { MediaType } from './../../../__generated__/globalTypes'
|
||||
|
||||
// ====================================================
|
||||
// GraphQL query operation: placePageQueryMedia
|
||||
// ====================================================
|
||||
|
||||
export interface placePageQueryMedia_mediaList_thumbnail {
|
||||
__typename: "MediaURL";
|
||||
__typename: 'MediaURL'
|
||||
/**
|
||||
* URL for previewing the image
|
||||
*/
|
||||
url: string;
|
||||
url: string
|
||||
/**
|
||||
* Width of the image in pixels
|
||||
*/
|
||||
width: number;
|
||||
width: number
|
||||
/**
|
||||
* Height of the image in pixels
|
||||
*/
|
||||
height: number;
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface placePageQueryMedia_mediaList_highRes {
|
||||
__typename: "MediaURL";
|
||||
__typename: 'MediaURL'
|
||||
/**
|
||||
* URL for previewing the image
|
||||
*/
|
||||
url: string;
|
||||
url: string
|
||||
/**
|
||||
* Width of the image in pixels
|
||||
*/
|
||||
width: number;
|
||||
width: number
|
||||
/**
|
||||
* Height of the image in pixels
|
||||
*/
|
||||
height: number;
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface placePageQueryMedia_mediaList_videoWeb {
|
||||
__typename: "MediaURL";
|
||||
__typename: 'MediaURL'
|
||||
/**
|
||||
* URL for previewing the image
|
||||
*/
|
||||
url: string;
|
||||
url: string
|
||||
/**
|
||||
* Width of the image in pixels
|
||||
*/
|
||||
width: number;
|
||||
width: number
|
||||
/**
|
||||
* Height of the image in pixels
|
||||
*/
|
||||
height: number;
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface placePageQueryMedia_mediaList {
|
||||
__typename: "Media";
|
||||
id: string;
|
||||
title: string;
|
||||
__typename: 'Media'
|
||||
id: string
|
||||
title: string
|
||||
/**
|
||||
* URL to display the media in a smaller resolution
|
||||
*/
|
||||
thumbnail: placePageQueryMedia_mediaList_thumbnail | null;
|
||||
thumbnail: placePageQueryMedia_mediaList_thumbnail | null
|
||||
/**
|
||||
* URL to display the photo in full resolution, will be null for videos
|
||||
*/
|
||||
highRes: placePageQueryMedia_mediaList_highRes | null;
|
||||
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;
|
||||
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[];
|
||||
mediaList: placePageQueryMedia_mediaList[]
|
||||
}
|
||||
|
||||
export interface placePageQueryMediaVariables {
|
||||
mediaIDs: string[];
|
||||
mediaIDs: string[]
|
||||
}
|
||||
|
|
|
@ -1,14 +1,17 @@
|
|||
import { gql } from '@apollo/client'
|
||||
import React, { useRef, useState } from 'react'
|
||||
import { useMutation, useQuery } from '@apollo/client'
|
||||
import { Checkbox, Dropdown, Input, Loader } from 'semantic-ui-react'
|
||||
import { InputLabelDescription, InputLabelTitle } from './SettingsPage'
|
||||
import { InputLabelDescription } 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'
|
||||
|
||||
const SCAN_INTERVAL_QUERY = gql`
|
||||
query scanIntervalQuery {
|
||||
|
@ -125,10 +128,8 @@ const PeriodicScanner = () => {
|
|||
},
|
||||
})
|
||||
|
||||
const [
|
||||
setScanIntervalMutation,
|
||||
{ loading: scanIntervalMutationLoading },
|
||||
] = useMutation<
|
||||
const [setScanIntervalMutation, { loading: scanIntervalMutationLoading }] =
|
||||
useMutation<
|
||||
changeScanIntervalMutation,
|
||||
changeScanIntervalMutationVariables
|
||||
>(SCAN_INTERVAL_MUTATION)
|
||||
|
@ -154,45 +155,35 @@ const PeriodicScanner = () => {
|
|||
}
|
||||
}
|
||||
|
||||
type scanIntervalUnitType = {
|
||||
key: TimeUnit
|
||||
text: string
|
||||
value: TimeUnit
|
||||
}
|
||||
|
||||
const scanIntervalUnits: scanIntervalUnitType[] = [
|
||||
const scanIntervalUnits: DropdownItem[] = [
|
||||
{
|
||||
key: TimeUnit.Second,
|
||||
text: t('settings.periodic_scanner.interval_unit.seconds', 'Seconds'),
|
||||
label: t('settings.periodic_scanner.interval_unit.seconds', 'Seconds'),
|
||||
value: TimeUnit.Second,
|
||||
},
|
||||
{
|
||||
key: TimeUnit.Minute,
|
||||
text: t('settings.periodic_scanner.interval_unit.minutes', 'Minutes'),
|
||||
label: t('settings.periodic_scanner.interval_unit.minutes', 'Minutes'),
|
||||
value: TimeUnit.Minute,
|
||||
},
|
||||
{
|
||||
key: TimeUnit.Hour,
|
||||
text: t('settings.periodic_scanner.interval_unit.hour', 'Hour'),
|
||||
label: t('settings.periodic_scanner.interval_unit.hour', 'Hour'),
|
||||
value: TimeUnit.Hour,
|
||||
},
|
||||
{
|
||||
key: TimeUnit.Day,
|
||||
text: t('settings.periodic_scanner.interval_unit.days', 'Days'),
|
||||
label: t('settings.periodic_scanner.interval_unit.days', 'Days'),
|
||||
value: TimeUnit.Day,
|
||||
},
|
||||
{
|
||||
key: TimeUnit.Month,
|
||||
text: t('settings.periodic_scanner.interval_unit.months', 'Months'),
|
||||
label: t('settings.periodic_scanner.interval_unit.months', 'Months'),
|
||||
value: TimeUnit.Month,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3>{t('settings.periodic_scanner.title', 'Periodic scanner')}</h3>
|
||||
<h3 className="font-semibold text-lg mt-4 mb-2">
|
||||
{t('settings.periodic_scanner.title', 'Periodic scanner')}
|
||||
</h3>
|
||||
|
||||
<div style={{ margin: '12px 0' }}>
|
||||
<Checkbox
|
||||
label={t(
|
||||
'settings.periodic_scanner.checkbox_label',
|
||||
|
@ -200,21 +191,19 @@ const PeriodicScanner = () => {
|
|||
)}
|
||||
disabled={scanIntervalQuery.loading}
|
||||
checked={enablePeriodicScanner}
|
||||
onChange={(_, { checked }) =>
|
||||
onScanIntervalCheckboxChange(checked || false)
|
||||
onChange={event =>
|
||||
onScanIntervalCheckboxChange(event.target.checked || false)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{enablePeriodicScanner && (
|
||||
<>
|
||||
<div className="mt-4">
|
||||
<label htmlFor="periodic_scan_field">
|
||||
<InputLabelTitle>
|
||||
<h4 className="font-semibold">
|
||||
{t(
|
||||
'settings.periodic_scanner.field.label',
|
||||
'Periodic scan interval'
|
||||
)}
|
||||
</InputLabelTitle>
|
||||
</h4>
|
||||
<InputLabelDescription>
|
||||
{t(
|
||||
'settings.periodic_scanner.field.description',
|
||||
|
@ -222,10 +211,16 @@ const PeriodicScanner = () => {
|
|||
)}
|
||||
</InputLabelDescription>
|
||||
</label>
|
||||
<Input
|
||||
label={
|
||||
<div className="flex gap-2">
|
||||
<TextField
|
||||
id="periodic_scan_field"
|
||||
disabled={!enablePeriodicScanner}
|
||||
/>
|
||||
<Dropdown
|
||||
onChange={(_, { value }) => {
|
||||
disabled={!enablePeriodicScanner}
|
||||
items={scanIntervalUnits}
|
||||
selected={scanInterval.unit}
|
||||
setSelected={value => {
|
||||
const newScanInterval: TimeValue = {
|
||||
...scanInterval,
|
||||
unit: value as TimeUnit,
|
||||
|
@ -234,31 +229,11 @@ const PeriodicScanner = () => {
|
|||
setScanInterval(newScanInterval)
|
||||
onScanIntervalUpdate(newScanInterval)
|
||||
}}
|
||||
value={scanInterval.unit}
|
||||
options={scanIntervalUnits}
|
||||
/>
|
||||
}
|
||||
onBlur={() => onScanIntervalUpdate(scanInterval)}
|
||||
onKeyDown={({ key }: KeyboardEvent) =>
|
||||
key == 'Enter' && onScanIntervalUpdate(scanInterval)
|
||||
}
|
||||
loading={scanIntervalQuery.loading}
|
||||
labelPosition="right"
|
||||
style={{ maxWidth: 300 }}
|
||||
id="periodic_scan_field"
|
||||
value={scanInterval.value}
|
||||
onChange={(_, { value }) => {
|
||||
setScanInterval(x => ({
|
||||
...x,
|
||||
value: parseInt(value),
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Loader
|
||||
active={scanIntervalQuery.loading || scanIntervalMutationLoading}
|
||||
inline
|
||||
size="small"
|
||||
style={{ marginLeft: 16 }}
|
||||
/>
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import React, { useRef, useState } from 'react'
|
||||
import { useQuery, useMutation, gql } from '@apollo/client'
|
||||
import { Input, Loader } from 'semantic-ui-react'
|
||||
import { InputLabelTitle, InputLabelDescription } from './SettingsPage'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { concurrentWorkersQuery } from './__generated__/concurrentWorkersQuery'
|
||||
|
@ -8,6 +7,7 @@ import {
|
|||
setConcurrentWorkers,
|
||||
setConcurrentWorkersVariables,
|
||||
} from './__generated__/setConcurrentWorkers'
|
||||
import { TextField } from '../../primitives/form/Input'
|
||||
|
||||
const CONCURRENT_WORKERS_QUERY = gql`
|
||||
query concurrentWorkersQuery {
|
||||
|
@ -56,7 +56,7 @@ const ScannerConcurrentWorkers = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 32 }}>
|
||||
<div>
|
||||
<label htmlFor="scanner_concurrent_workers_field">
|
||||
<InputLabelTitle>
|
||||
{t('settings.concurrent_workers.title', 'Scanner concurrent workers')}
|
||||
|
@ -68,27 +68,21 @@ const ScannerConcurrentWorkers = () => {
|
|||
)}
|
||||
</InputLabelDescription>
|
||||
</label>
|
||||
<Input
|
||||
<TextField
|
||||
disabled={workerAmountQuery.loading || workersMutationData.loading}
|
||||
type="number"
|
||||
min="1"
|
||||
max="24"
|
||||
id="scanner_concurrent_workers_field"
|
||||
value={workerAmount}
|
||||
onChange={(_, { value }) => {
|
||||
setWorkerAmount(parseInt(value))
|
||||
onChange={event => {
|
||||
setWorkerAmount(parseInt(event.target.value))
|
||||
}}
|
||||
onBlur={() => updateWorkerAmount(workerAmount)}
|
||||
onKeyDown={({ key }: KeyboardEvent) =>
|
||||
key == 'Enter' && updateWorkerAmount(workerAmount)
|
||||
onKeyDown={event =>
|
||||
event.key == 'Enter' && updateWorkerAmount(workerAmount)
|
||||
}
|
||||
/>
|
||||
<Loader
|
||||
active={workerAmountQuery.loading || workersMutationData.loading}
|
||||
inline
|
||||
size="small"
|
||||
style={{ marginLeft: 16 }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import React from 'react'
|
||||
import { useMutation, gql } from '@apollo/client'
|
||||
import { Button, Icon } from 'semantic-ui-react'
|
||||
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 {
|
||||
|
@ -32,14 +32,11 @@ const ScannerSection = () => {
|
|||
)}
|
||||
</InputLabelDescription>
|
||||
<Button
|
||||
icon
|
||||
labelPosition="left"
|
||||
onClick={() => {
|
||||
startScanner()
|
||||
}}
|
||||
disabled={called}
|
||||
>
|
||||
<Icon name="sync" />
|
||||
{t('settings.scanner.scan_all_users', 'Scan all users')}
|
||||
</Button>
|
||||
<PeriodicScanner />
|
||||
|
|
|
@ -1,32 +1,39 @@
|
|||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from 'semantic-ui-react'
|
||||
import styled from 'styled-components'
|
||||
import { useIsAdmin } from '../../components/routes/AuthorizedRoute'
|
||||
|
||||
import Layout from '../../Layout'
|
||||
|
||||
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'
|
||||
|
||||
export const SectionTitle = styled.h2<{ nospace?: boolean }>`
|
||||
margin-top: ${({ nospace }) => (nospace ? '0' : '1.4em')} !important;
|
||||
padding-bottom: 0.3em;
|
||||
border-bottom: 1px solid #ddd;
|
||||
`
|
||||
type SectionTitleProps = {
|
||||
children: string
|
||||
nospace?: boolean
|
||||
}
|
||||
|
||||
export const InputLabelTitle = styled.p`
|
||||
font-size: 1.1em;
|
||||
font-weight: 600;
|
||||
margin: 1em 0 0 !important;
|
||||
`
|
||||
export const SectionTitle = ({ children, nospace }: SectionTitleProps) => {
|
||||
return (
|
||||
<h2
|
||||
className={classNames(
|
||||
'pb-1 border-b border-gray-200 text-xl mb-5',
|
||||
!nospace && 'mt-6'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
)
|
||||
}
|
||||
|
||||
export const InputLabelDescription = styled.p`
|
||||
font-size: 0.9em;
|
||||
margin: 0 0 0.5em !important;
|
||||
`
|
||||
export const InputLabelTitle = styled.h3.attrs({
|
||||
className: 'font-semibold',
|
||||
})``
|
||||
|
||||
export const InputLabelDescription = styled.p.attrs({
|
||||
className: 'text-sm mb-2',
|
||||
})``
|
||||
|
||||
const SettingsPage = () => {
|
||||
const { t } = useTranslation()
|
||||
|
@ -41,14 +48,6 @@ const SettingsPage = () => {
|
|||
<UsersTable />
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
style={{ marginTop: 24 }}
|
||||
onClick={() => {
|
||||
location.href = '/logout'
|
||||
}}
|
||||
>
|
||||
{t('settings.logout', 'Log out')}
|
||||
</Button>
|
||||
<VersionInfo />
|
||||
</Layout>
|
||||
)
|
||||
|
|
|
@ -2,9 +2,10 @@ import { useMutation, useQuery } from '@apollo/client'
|
|||
import gql from 'graphql-tag'
|
||||
import React, { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Dropdown } from 'semantic-ui-react'
|
||||
import styled from 'styled-components'
|
||||
import { LanguageTranslation } from '../../../__generated__/globalTypes'
|
||||
import { LanguageTranslation } from '../../__generated__/globalTypes'
|
||||
import Dropdown from '../../primitives/form/Dropdown'
|
||||
import { Button } from '../../primitives/form/Input'
|
||||
import {
|
||||
InputLabelDescription,
|
||||
InputLabelTitle,
|
||||
|
@ -17,14 +18,14 @@ import {
|
|||
import { myUserPreferences } from './__generated__/myUserPreferences'
|
||||
|
||||
const languagePreferences = [
|
||||
{ key: 1, text: 'English', flag: 'uk', value: LanguageTranslation.English },
|
||||
{ key: 2, text: 'Français', flag: 'fr', value: LanguageTranslation.French },
|
||||
{ key: 3, text: 'Svenska', flag: 'se', value: LanguageTranslation.Swedish },
|
||||
{ key: 4, text: 'Dansk', flag: 'dk', value: LanguageTranslation.Danish },
|
||||
{ key: 5, text: 'Español', flag: 'es', value: LanguageTranslation.Spanish },
|
||||
{ key: 6, text: 'polski', flag: 'pl', value: LanguageTranslation.Polish },
|
||||
{ key: 7, text: 'Italiano', flag: 'it', value: LanguageTranslation.Italian },
|
||||
{ key: 8, text: 'Deutsch', flag: 'de', value: LanguageTranslation.German },
|
||||
{ key: 1, label: 'English', flag: 'uk', value: LanguageTranslation.English },
|
||||
{ key: 2, label: 'Français', flag: 'fr', value: LanguageTranslation.French },
|
||||
{ key: 3, label: 'Svenska', flag: 'se', value: LanguageTranslation.Swedish },
|
||||
{ key: 4, label: 'Dansk', flag: 'dk', value: LanguageTranslation.Danish },
|
||||
{ key: 5, label: 'Español', flag: 'es', value: LanguageTranslation.Spanish },
|
||||
{ key: 6, label: 'polski', flag: 'pl', value: LanguageTranslation.Polish },
|
||||
{ key: 7, label: 'Italiano', flag: 'it', value: LanguageTranslation.Italian },
|
||||
{ key: 8, label: 'Deutsch', flag: 'de', value: LanguageTranslation.German },
|
||||
]
|
||||
|
||||
const CHANGE_USER_PREFERENCES = gql`
|
||||
|
@ -45,6 +46,21 @@ const MY_USER_PREFERENCES = gql`
|
|||
}
|
||||
`
|
||||
|
||||
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;
|
||||
`
|
||||
|
@ -60,7 +76,7 @@ const UserPreferences = () => {
|
|||
>(CHANGE_USER_PREFERENCES)
|
||||
|
||||
const sortedLanguagePrefs = useMemo(
|
||||
() => languagePreferences.sort((a, b) => a.text.localeCompare(b.text)),
|
||||
() => languagePreferences.sort((a, b) => a.label.localeCompare(b.label)),
|
||||
[]
|
||||
)
|
||||
|
||||
|
@ -73,6 +89,7 @@ const UserPreferences = () => {
|
|||
<SectionTitle nospace>
|
||||
{t('settings.user_preferences.title', 'User preferences')}
|
||||
</SectionTitle>
|
||||
<LogoutButton />
|
||||
<label id="user_pref_change_language_field">
|
||||
<InputLabelTitle>
|
||||
{t(
|
||||
|
@ -93,19 +110,15 @@ const UserPreferences = () => {
|
|||
'settings.user_preferences.language_selector.placeholder',
|
||||
'Select language'
|
||||
)}
|
||||
clearable
|
||||
options={sortedLanguagePrefs}
|
||||
onChange={(event, { value: language }) => {
|
||||
items={sortedLanguagePrefs}
|
||||
setSelected={language => {
|
||||
changePrefs({
|
||||
variables: {
|
||||
language: language as LanguageTranslation,
|
||||
},
|
||||
})
|
||||
}}
|
||||
selection
|
||||
search
|
||||
value={data?.myUserPreferences.language || undefined}
|
||||
loading={loadingPrefs}
|
||||
selected={data?.myUserPreferences.language || undefined}
|
||||
disabled={loadingPrefs}
|
||||
/>
|
||||
</UserPreferencesWrapper>
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import { gql, useMutation } from '@apollo/client'
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button, Checkbox, Input, Table } from 'semantic-ui-react'
|
||||
import Checkbox from '../../../primitives/form/Checkbox'
|
||||
import { TextField, Button, ButtonGroup } from '../../../primitives/form/Input'
|
||||
import { TableRow, TableCell } from '../../../primitives/Table'
|
||||
|
||||
const CREATE_USER_MUTATION = gql`
|
||||
mutation createUser($username: String!, $admin: Boolean!) {
|
||||
|
@ -88,16 +90,16 @@ const AddUserRow = ({ setShow, show, onUserAdded }: AddUserRowProps) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
<Input
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<TextField
|
||||
placeholder={t('login_page.field.username', 'Username')}
|
||||
value={state.username}
|
||||
onChange={e => updateInput(e, 'username')}
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Input
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextField
|
||||
placeholder={t(
|
||||
'login_page.initial_setup.field.photo_path.placeholder',
|
||||
'/path/to/photos'
|
||||
|
@ -105,29 +107,28 @@ const AddUserRow = ({ setShow, show, onUserAdded }: AddUserRowProps) => {
|
|||
value={state.rootPath}
|
||||
onChange={e => updateInput(e, 'rootPath')}
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
toggle
|
||||
label="Admin"
|
||||
checked={state.admin}
|
||||
onChange={(e, data) => {
|
||||
onChange={e => {
|
||||
setState({
|
||||
...state,
|
||||
admin: data.checked || false,
|
||||
admin: e.target.checked || false,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Button.Group>
|
||||
<Button negative onClick={() => setShow(false)}>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ButtonGroup>
|
||||
<Button variant="negative" onClick={() => setShow(false)}>
|
||||
{t('general.action.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
positive
|
||||
variant="positive"
|
||||
onClick={() => {
|
||||
createUser({
|
||||
variables: {
|
||||
|
@ -139,9 +140,9 @@ const AddUserRow = ({ setShow, show, onUserAdded }: AddUserRowProps) => {
|
|||
>
|
||||
{t('settings.users.add_user.submit', 'Add user')}
|
||||
</Button>
|
||||
</Button.Group>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
</ButtonGroup>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button, Checkbox, Input, Table } from 'semantic-ui-react'
|
||||
import { EditRootPaths } from './EditUserRowRootPaths'
|
||||
import { UserRowProps, UserRowChildProps } from './UserRow'
|
||||
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,
|
||||
|
@ -24,34 +27,34 @@ const EditUserRow = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
<Input
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<TextField
|
||||
style={{ width: '100%' }}
|
||||
placeholder={user.username}
|
||||
value={state.username}
|
||||
onChange={e => updateInput(e, 'username')}
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<EditRootPaths user={user} />
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
toggle
|
||||
label="Admin"
|
||||
checked={state.admin}
|
||||
onChange={(_, data) => {
|
||||
onChange={e => {
|
||||
setState(state => ({
|
||||
...state,
|
||||
admin: data.checked || false,
|
||||
admin: e.target.checked || false,
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Button.Group>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
negative
|
||||
variant="negative"
|
||||
onClick={() =>
|
||||
setState(state => ({
|
||||
...state,
|
||||
|
@ -62,9 +65,8 @@ const EditUserRow = ({
|
|||
{t('general.action.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
loading={updateUserLoading}
|
||||
disabled={updateUserLoading}
|
||||
positive
|
||||
variant="positive"
|
||||
onClick={() =>
|
||||
updateUser({
|
||||
variables: {
|
||||
|
@ -77,12 +79,10 @@ const EditUserRow = ({
|
|||
>
|
||||
{t('general.action.save', 'Save')}
|
||||
</Button>
|
||||
</Button.Group>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
</ButtonGroup>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
EditUserRow.propTypes = UserRowProps
|
||||
|
||||
export default EditUserRow
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
import React, { useState } from 'react'
|
||||
import { gql, useMutation } from '@apollo/client'
|
||||
import { Button, Icon, Input } from 'semantic-ui-react'
|
||||
import styled from 'styled-components'
|
||||
import { USERS_QUERY } from './UsersTable'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { USER_ADD_ROOT_PATH_MUTATION } from './AddUserRow'
|
||||
|
@ -14,6 +12,7 @@ import {
|
|||
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!) {
|
||||
|
@ -23,12 +22,6 @@ const USER_REMOVE_ALBUM_PATH_MUTATION = gql`
|
|||
}
|
||||
`
|
||||
|
||||
const RootPathListItem = styled.li`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
`
|
||||
|
||||
type EditRootPathProps = {
|
||||
album: settingsUsersQuery_user_rootAlbums
|
||||
user: settingsUsersQuery_user
|
||||
|
@ -48,10 +41,10 @@ const EditRootPath = ({ album, user }: EditRootPathProps) => {
|
|||
})
|
||||
|
||||
return (
|
||||
<RootPathListItem>
|
||||
<li className="flex justify-between">
|
||||
<span>{album.filePath}</span>
|
||||
<Button
|
||||
negative
|
||||
variant="negative"
|
||||
disabled={loading}
|
||||
onClick={() =>
|
||||
removeAlbumPath({
|
||||
|
@ -62,18 +55,12 @@ const EditRootPath = ({ album, user }: EditRootPathProps) => {
|
|||
})
|
||||
}
|
||||
>
|
||||
<Icon name="remove" />
|
||||
{t('general.action.remove', 'Remove')}
|
||||
</Button>
|
||||
</RootPathListItem>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
const NewRootPathInput = styled(Input)`
|
||||
width: 100%;
|
||||
margin-top: 24px;
|
||||
`
|
||||
|
||||
type EditNewRootPathProps = {
|
||||
userID: string
|
||||
}
|
||||
|
@ -93,19 +80,18 @@ const EditNewRootPath = ({ userID }: EditNewRootPathProps) => {
|
|||
)
|
||||
|
||||
return (
|
||||
<li>
|
||||
<NewRootPathInput
|
||||
style={{ width: '100%' }}
|
||||
<li className="flex gap-1 mt-2">
|
||||
<TextField
|
||||
value={value}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setValue(e.target.value)
|
||||
}
|
||||
disabled={loading}
|
||||
action={{
|
||||
positive: true,
|
||||
icon: 'add',
|
||||
content: t('general.action.add', 'Add'),
|
||||
onClick: () => {
|
||||
/>
|
||||
<Button
|
||||
variant="positive"
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setValue('')
|
||||
addRootPath({
|
||||
variables: {
|
||||
|
@ -113,19 +99,14 @@ const EditNewRootPath = ({ userID }: EditNewRootPathProps) => {
|
|||
rootPath: value,
|
||||
},
|
||||
})
|
||||
},
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{t('general.action.add', 'Add')}
|
||||
</Button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
const RootPathList = styled.ul`
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
`
|
||||
|
||||
type EditRootPathsProps = {
|
||||
user: settingsUsersQuery_user
|
||||
}
|
||||
|
@ -136,9 +117,9 @@ export const EditRootPaths = ({ user }: EditRootPathsProps) => {
|
|||
))
|
||||
|
||||
return (
|
||||
<RootPathList>
|
||||
<ul>
|
||||
{editRows}
|
||||
<EditNewRootPath userID={user.id} />
|
||||
</RootPathList>
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import React, { useState } from 'react'
|
||||
import { gql, useMutation } from '@apollo/client'
|
||||
import { Button, Form, Input, Modal, ModalProps } from 'semantic-ui-react'
|
||||
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!) {
|
||||
|
@ -12,7 +13,7 @@ const changeUserPasswordMutation = gql`
|
|||
}
|
||||
`
|
||||
|
||||
interface ChangePasswordModalProps extends ModalProps {
|
||||
interface ChangePasswordModalProps {
|
||||
onClose(): void
|
||||
open: boolean
|
||||
user: settingsUsersQuery_user
|
||||
|
@ -22,7 +23,6 @@ const ChangePasswordModal = ({
|
|||
onClose,
|
||||
user,
|
||||
open,
|
||||
...props
|
||||
}: ChangePasswordModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [passwordInput, setPasswordInput] = useState('')
|
||||
|
@ -34,22 +34,42 @@ const ChangePasswordModal = ({
|
|||
})
|
||||
|
||||
return (
|
||||
<Modal open={open} {...props}>
|
||||
<Modal.Header>
|
||||
{t('settings.users.password_reset.title', 'Change password')}
|
||||
</Modal.Header>
|
||||
<Modal.Content>
|
||||
<p>
|
||||
<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>
|
||||
Change password for <b>{{ username: user.username }}</b>
|
||||
</Trans>
|
||||
</p>
|
||||
<Form>
|
||||
<Form.Field>
|
||||
<label>
|
||||
{t('settings.users.password_reset.form.label', 'New password')}
|
||||
</label>
|
||||
<Input
|
||||
}
|
||||
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'
|
||||
|
@ -57,27 +77,7 @@ const ChangePasswordModal = ({
|
|||
onChange={e => setPasswordInput(e.target.value)}
|
||||
type="password"
|
||||
/>
|
||||
</Form.Field>
|
||||
</Form>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button onClick={() => onClose && onClose()}>
|
||||
{t('general.action.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
positive
|
||||
onClick={() => {
|
||||
changePassword({
|
||||
variables: {
|
||||
userId: user.id,
|
||||
password: passwordInput,
|
||||
},
|
||||
})
|
||||
}}
|
||||
>
|
||||
{t('settings.users.password_reset.form.submit', 'Change password')}
|
||||
</Button>
|
||||
</Modal.Actions>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import PropTypes from 'prop-types'
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
FetchResult,
|
||||
|
@ -40,21 +39,6 @@ const scanUserMutation = gql`
|
|||
}
|
||||
`
|
||||
|
||||
export const UserRowProps = {
|
||||
user: PropTypes.object.isRequired,
|
||||
state: PropTypes.object.isRequired,
|
||||
setState: PropTypes.func.isRequired,
|
||||
scanUser: PropTypes.func.isRequired,
|
||||
updateUser: PropTypes.func.isRequired,
|
||||
updateUserLoading: PropTypes.bool.isRequired,
|
||||
deleteUser: PropTypes.func.isRequired,
|
||||
setChangePassword: PropTypes.func.isRequired,
|
||||
setConfirmDelete: PropTypes.func.isRequired,
|
||||
scanUserCalled: PropTypes.func.isRequired,
|
||||
showChangePassword: PropTypes.func.isRequired,
|
||||
showConfirmDelete: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
interface UserRowState extends settingsUsersQuery_user {
|
||||
editing: boolean
|
||||
newRootPath: string
|
||||
|
@ -81,7 +65,7 @@ export type UserRowChildProps = {
|
|||
showConfirmDelete: boolean
|
||||
}
|
||||
|
||||
type UserRowProps = {
|
||||
export type UserRowProps = {
|
||||
user: settingsUsersQuery_user
|
||||
refetchUsers(): void
|
||||
}
|
||||
|
|
|
@ -1,12 +1,21 @@
|
|||
import React, { useState } from 'react'
|
||||
|
||||
import { Table, Loader, Button, Icon } from 'semantic-ui-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 {
|
||||
|
@ -26,9 +35,8 @@ const UsersTable = () => {
|
|||
const { t } = useTranslation()
|
||||
const [showAddUser, setShowAddUser] = useState(false)
|
||||
|
||||
const { loading, error, data, refetch } = useQuery<settingsUsersQuery>(
|
||||
USERS_QUERY
|
||||
)
|
||||
const { loading, error, data, refetch } =
|
||||
useQuery<settingsUsersQuery>(USERS_QUERY)
|
||||
|
||||
if (error) {
|
||||
return <div>{`Users table error: ${error.message}`}</div>
|
||||
|
@ -45,25 +53,32 @@ const UsersTable = () => {
|
|||
<div>
|
||||
<SectionTitle>{t('settings.users.title', 'Users')}</SectionTitle>
|
||||
<Loader active={loading} />
|
||||
<Table celled>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>
|
||||
<TableScrollWrapper>
|
||||
<Table className="w-full max-w-6xl">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>
|
||||
{t('settings.users.table.column_names.username', 'Username')}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>
|
||||
{t('settings.users.table.column_names.photo_path', 'Photo path')}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>
|
||||
{t('settings.users.table.column_names.admin', 'Admin')}
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>
|
||||
</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')}
|
||||
</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<Table.Body>
|
||||
<TableBody>
|
||||
{userRows}
|
||||
<AddUserRow
|
||||
show={showAddUser}
|
||||
|
@ -73,24 +88,24 @@ const UsersTable = () => {
|
|||
refetch()
|
||||
}}
|
||||
/>
|
||||
</Table.Body>
|
||||
</TableBody>
|
||||
|
||||
<Table.Footer>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell colSpan="4">
|
||||
<TableFooter>
|
||||
<TableRow>
|
||||
<TableHeaderCell colSpan={4} className="text-right">
|
||||
<Button
|
||||
positive
|
||||
variant="positive"
|
||||
background="white"
|
||||
disabled={showAddUser}
|
||||
floated="right"
|
||||
onClick={() => setShowAddUser(true)}
|
||||
>
|
||||
<Icon name="add" />
|
||||
{t('settings.users.table.new_user', 'New user')}
|
||||
</Button>
|
||||
</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Footer>
|
||||
</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</TableScrollWrapper>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,15 +1,11 @@
|
|||
import React from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { Button, Icon, Table, Modal } from 'semantic-ui-react'
|
||||
import styled from 'styled-components'
|
||||
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, UserRowProps } from './UserRow'
|
||||
|
||||
const PathList = styled.ul`
|
||||
margin: 0;
|
||||
padding: 0 0 0 12px;
|
||||
list-style: none;
|
||||
`
|
||||
import { UserRowChildProps } from './UserRow'
|
||||
|
||||
const ViewUserRow = ({
|
||||
user,
|
||||
|
@ -25,22 +21,22 @@ const ViewUserRow = ({
|
|||
}: UserRowChildProps) => {
|
||||
const { t } = useTranslation()
|
||||
const paths = (
|
||||
<PathList>
|
||||
<ul>
|
||||
{user.rootAlbums.map(album => (
|
||||
<li key={album.id}>{album.filePath}</li>
|
||||
))}
|
||||
</PathList>
|
||||
</ul>
|
||||
)
|
||||
|
||||
return (
|
||||
<Table.Row>
|
||||
<Table.Cell>{user.username}</Table.Cell>
|
||||
<Table.Cell>{paths}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{user.admin ? <Icon name="checkmark" size="large" /> : null}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Button.Group>
|
||||
<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 => {
|
||||
|
@ -50,18 +46,15 @@ const ViewUserRow = ({
|
|||
})
|
||||
}}
|
||||
>
|
||||
<Icon name="edit" />
|
||||
{t('settings.users.table.row.action.edit', 'Edit')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={scanUserCalled}
|
||||
onClick={() => scanUser({ variables: { userId: user.id } })}
|
||||
>
|
||||
<Icon name="sync" />
|
||||
{t('settings.users.table.row.action.scan', 'Scan')}
|
||||
</Button>
|
||||
<Button onClick={() => setChangePassword(true)}>
|
||||
<Icon name="key" />
|
||||
{t(
|
||||
'settings.users.table.row.action.change_password',
|
||||
'Change password'
|
||||
|
@ -73,19 +66,42 @@ const ViewUserRow = ({
|
|||
onClose={() => setChangePassword(false)}
|
||||
/>
|
||||
<Button
|
||||
negative
|
||||
variant="negative"
|
||||
onClick={() => {
|
||||
setConfirmDelete(true)
|
||||
}}
|
||||
>
|
||||
<Icon name="delete" />
|
||||
{t('settings.users.table.row.action.delete', 'Delete')}
|
||||
</Button>
|
||||
<Modal open={showConfirmDelete}>
|
||||
<Modal.Header>
|
||||
{t('settings.users.confirm_delete_user.title', 'Delete user')}
|
||||
</Modal.Header>
|
||||
<Modal.Content>
|
||||
<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"
|
||||
|
@ -96,36 +112,12 @@ const ViewUserRow = ({
|
|||
</p>
|
||||
<p>{`This action cannot be undone`}</p>
|
||||
</Trans>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button onClick={() => setConfirmDelete(false)}>
|
||||
{t('general.action.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
negative
|
||||
onClick={() => {
|
||||
setConfirmDelete(false)
|
||||
deleteUser({
|
||||
variables: {
|
||||
id: user.id,
|
||||
},
|
||||
})
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
'settings.users.confirm_delete_user.action',
|
||||
'Delete {{user}}',
|
||||
{ user: user.username }
|
||||
)}
|
||||
</Button>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
</Button.Group>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
ViewUserRow.propTypes = UserRowProps
|
||||
|
||||
export default ViewUserRow
|
||||
|
|
|
@ -7,10 +7,10 @@ import {
|
|||
SectionTitle,
|
||||
} from './SettingsPage'
|
||||
|
||||
const VERSION = process.env.VERSION ? process.env.VERSION : 'undefined'
|
||||
const BUILD_DATE = process.env.BUILD_DATE ? process.env.BUILD_DATE : 'undefined'
|
||||
const VERSION = process.env.REACT_APP_BUILD_VERSION ?? 'undefined'
|
||||
const BUILD_DATE = process.env.REACT_APP_BUILD_DATE ?? 'undefined'
|
||||
|
||||
const COMMIT_SHA = process.env.COMMIT_SHA
|
||||
const COMMIT_SHA = process.env.REACT_APP_BUILD_COMMIT_SHA as string | undefined
|
||||
let commitLink: ReactElement
|
||||
|
||||
if (COMMIT_SHA) {
|
||||
|
|
|
@ -3,22 +3,22 @@
|
|||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
import { LanguageTranslation } from "./../../../../__generated__/globalTypes";
|
||||
import { LanguageTranslation } from './../../../__generated__/globalTypes'
|
||||
|
||||
// ====================================================
|
||||
// GraphQL mutation operation: changeUserPreferences
|
||||
// ====================================================
|
||||
|
||||
export interface changeUserPreferences_changeUserPreferences {
|
||||
__typename: "UserPreferences";
|
||||
id: string;
|
||||
language: LanguageTranslation | null;
|
||||
__typename: 'UserPreferences'
|
||||
id: string
|
||||
language: LanguageTranslation | null
|
||||
}
|
||||
|
||||
export interface changeUserPreferences {
|
||||
changeUserPreferences: changeUserPreferences_changeUserPreferences;
|
||||
changeUserPreferences: changeUserPreferences_changeUserPreferences
|
||||
}
|
||||
|
||||
export interface changeUserPreferencesVariables {
|
||||
language?: string | null;
|
||||
language?: string | null
|
||||
}
|
||||
|
|
|
@ -3,18 +3,18 @@
|
|||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
import { LanguageTranslation } from "./../../../../__generated__/globalTypes";
|
||||
import { LanguageTranslation } from './../../../__generated__/globalTypes'
|
||||
|
||||
// ====================================================
|
||||
// GraphQL query operation: myUserPreferences
|
||||
// ====================================================
|
||||
|
||||
export interface myUserPreferences_myUserPreferences {
|
||||
__typename: "UserPreferences";
|
||||
id: string;
|
||||
language: LanguageTranslation | null;
|
||||
__typename: 'UserPreferences'
|
||||
id: string
|
||||
language: LanguageTranslation | null
|
||||
}
|
||||
|
||||
export interface myUserPreferences {
|
||||
myUserPreferences: myUserPreferences_myUserPreferences;
|
||||
myUserPreferences: myUserPreferences_myUserPreferences
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react'
|
||||
import Layout from '../../Layout'
|
||||
import Layout from '../../components/layout/Layout'
|
||||
import AlbumGallery from '../../components/albumGallery/AlbumGallery'
|
||||
import styled from 'styled-components'
|
||||
import { gql, useQuery } from '@apollo/client'
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useContext, useEffect } from 'react'
|
||||
import styled from 'styled-components'
|
||||
import Layout from '../../Layout'
|
||||
import Layout from '../../components/layout/Layout'
|
||||
import {
|
||||
ProtectedImage,
|
||||
ProtectedVideo,
|
||||
|
@ -9,17 +9,17 @@ import { SidebarContext } from '../../components/sidebar/Sidebar'
|
|||
import MediaSidebar from '../../components/sidebar/MediaSidebar'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SharePageToken_shareToken_media } from './__generated__/SharePageToken'
|
||||
import { MediaType } from '../../../__generated__/globalTypes'
|
||||
import { MediaType } from '../../__generated__/globalTypes'
|
||||
import { exhaustiveCheck } from '../../helpers/utils'
|
||||
|
||||
const DisplayPhoto = styled(ProtectedImage)`
|
||||
width: 100%;
|
||||
/* width: 100%; */
|
||||
max-height: calc(80vh);
|
||||
object-fit: contain;
|
||||
`
|
||||
|
||||
const DisplayVideo = styled(ProtectedVideo)`
|
||||
width: 100%;
|
||||
/* width: 100%; */
|
||||
max-height: calc(80vh);
|
||||
`
|
||||
|
||||
|
@ -54,7 +54,7 @@ const MediaSharePage = ({ media }: MediaSharePageType) => {
|
|||
return (
|
||||
<Layout title={t('share_page.media.title', 'Shared media')}>
|
||||
<div data-testid="MediaSharePage">
|
||||
<h1>{media.title}</h1>
|
||||
<h1 className="font-semibold text-xl mb-4">{media.title}</h1>
|
||||
<MediaView media={media} />
|
||||
</div>
|
||||
</Layout>
|
||||
|
|
|
@ -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'))
|
||||
setInvalidPassword(true)
|
||||
}
|
||||
|
||||
let errorMessage = undefined
|
||||
if (invalidPassword && !loading) {
|
||||
errorMessage = t(
|
||||
'share_page.wrong_password',
|
||||
'Wrong password, please try again.'
|
||||
)
|
||||
} else if (errors.password?.type === 'required') {
|
||||
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
|
|
@ -1,8 +1,7 @@
|
|||
import PropTypes from 'prop-types'
|
||||
import React, { useState } from 'react'
|
||||
import React from 'react'
|
||||
import { useQuery, gql } from '@apollo/client'
|
||||
import { match as MatchType, Route, Switch } from 'react-router-dom'
|
||||
import { Form, Header, Icon, Input, Message } from 'semantic-ui-react'
|
||||
import styled from 'styled-components'
|
||||
import {
|
||||
getSharePassword,
|
||||
|
@ -11,6 +10,7 @@ import {
|
|||
import AlbumSharePage from './AlbumSharePage'
|
||||
import MediaSharePage from './MediaSharePage'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import PasswordProtectedShare from './PasswordProtectedShare'
|
||||
|
||||
export const SHARE_TOKEN_QUERY = gql`
|
||||
query SharePageToken($token: String!, $password: String) {
|
||||
|
@ -133,73 +133,11 @@ AuthorizedTokenRoute.propTypes = {
|
|||
match: PropTypes.object.isRequired,
|
||||
}
|
||||
|
||||
const MessageContainer = styled.div`
|
||||
export const MessageContainer = styled.div`
|
||||
max-width: 400px;
|
||||
margin: 100px auto 0;
|
||||
`
|
||||
|
||||
type ProtectedTokenEnterPasswordProps = {
|
||||
refetchWithPassword(password: string): void
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const ProtectedTokenEnterPassword = ({
|
||||
refetchWithPassword,
|
||||
loading = false,
|
||||
}: ProtectedTokenEnterPasswordProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [passwordValue, setPasswordValue] = useState('')
|
||||
const [invalidPassword, setInvalidPassword] = useState(false)
|
||||
|
||||
const onSubmit = () => {
|
||||
refetchWithPassword(passwordValue)
|
||||
setInvalidPassword(true)
|
||||
}
|
||||
|
||||
let errorMessage = null
|
||||
if (invalidPassword && !loading) {
|
||||
errorMessage = (
|
||||
<Message negative>
|
||||
<Message.Content>
|
||||
{t('share_page.wrong_password', 'Wrong password, please try again.')}
|
||||
</Message.Content>
|
||||
</Message>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<MessageContainer>
|
||||
<Header as="h1" style={{ fontWeight: 400 }}>
|
||||
{t('share_page.protected_share.title', 'Protected share')}
|
||||
</Header>
|
||||
<p>
|
||||
{t(
|
||||
'share_page.protected_share.description',
|
||||
'This share is protected with a password.'
|
||||
)}
|
||||
</p>
|
||||
<Form>
|
||||
<Form.Field>
|
||||
<label>{t('login_page.field.password', 'Password')}</label>
|
||||
<Input
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onKeyUp={(event: KeyboardEvent) =>
|
||||
event.key == 'Enter' && onSubmit()
|
||||
}
|
||||
onChange={e => setPasswordValue(e.target.value)}
|
||||
placeholder={t('login_page.field.password', 'Password')}
|
||||
type="password"
|
||||
icon={<Icon onClick={onSubmit} link name="arrow right" />}
|
||||
/>
|
||||
</Form.Field>
|
||||
{errorMessage}
|
||||
</Form>
|
||||
</MessageContainer>
|
||||
)
|
||||
}
|
||||
|
||||
interface TokenRouteMatch {
|
||||
token: string
|
||||
}
|
||||
|
@ -248,7 +186,7 @@ const TokenRoute = ({ match }: MatchProps<TokenRouteMatch>) => {
|
|||
|
||||
if (data && data.shareTokenValidatePassword == false) {
|
||||
return (
|
||||
<ProtectedTokenEnterPassword
|
||||
<PasswordProtectedShare
|
||||
refetchWithPassword={password => {
|
||||
saveSharePassword(token, password)
|
||||
refetch({ token, password })
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
import { MediaType } from './../../../../__generated__/globalTypes'
|
||||
import { MediaType } from './../../../__generated__/globalTypes'
|
||||
|
||||
// ====================================================
|
||||
// GraphQL query operation: SharePageToken
|
||||
|
|
|
@ -3,10 +3,7 @@
|
|||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
import {
|
||||
OrderDirection,
|
||||
MediaType,
|
||||
} from './../../../../__generated__/globalTypes'
|
||||
import { OrderDirection, MediaType } from './../../../__generated__/globalTypes'
|
||||
|
||||
// ====================================================
|
||||
// GraphQL query operation: shareAlbumQuery
|
||||
|
|
|
@ -3,18 +3,18 @@
|
|||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
import { LanguageTranslation } from "./../../__generated__/globalTypes";
|
||||
import { LanguageTranslation } from './globalTypes'
|
||||
|
||||
// ====================================================
|
||||
// GraphQL query operation: siteTranslation
|
||||
// ====================================================
|
||||
|
||||
export interface siteTranslation_myUserPreferences {
|
||||
__typename: "UserPreferences";
|
||||
id: string;
|
||||
language: LanguageTranslation | null;
|
||||
__typename: 'UserPreferences'
|
||||
id: string
|
||||
language: LanguageTranslation | null
|
||||
}
|
||||
|
||||
export interface siteTranslation {
|
||||
myUserPreferences: siteTranslation_myUserPreferences;
|
||||
myUserPreferences: siteTranslation_myUserPreferences
|
||||
}
|
||||
|
|
|
@ -15,9 +15,10 @@ import urlJoin from 'url-join'
|
|||
import { clearTokenCookie } from './helpers/authentication'
|
||||
import { MessageState } from './components/messages/Messages'
|
||||
import { Message } from './components/messages/SubscriptionsHook'
|
||||
import { NotificationType } from './__generated__/globalTypes'
|
||||
|
||||
export const GRAPHQL_ENDPOINT = process.env.PHOTOVIEW_API_ENDPOINT
|
||||
? urlJoin(process.env.PHOTOVIEW_API_ENDPOINT, '/graphql')
|
||||
export const GRAPHQL_ENDPOINT = process.env.REACT_APP_API_ENDPOINT
|
||||
? urlJoin(process.env.REACT_APP_API_ENDPOINT as string, '/graphql')
|
||||
: urlJoin(location.origin, '/api/graphql')
|
||||
|
||||
const httpLink = new HttpLink({
|
||||
|
@ -85,7 +86,7 @@ const linkError = onError(({ graphQLErrors, networkError }) => {
|
|||
console.log(`[Network error]: ${JSON.stringify(networkError)}`)
|
||||
clearTokenCookie()
|
||||
|
||||
const errors = (networkError as ServerError).result.errors
|
||||
const errors = (networkError as ServerError)?.result.errors || []
|
||||
|
||||
if (errors.length == 1) {
|
||||
errorMessages.push({
|
||||
|
@ -103,9 +104,9 @@ const linkError = onError(({ graphQLErrors, networkError }) => {
|
|||
}
|
||||
|
||||
if (errorMessages.length > 0) {
|
||||
const newMessages = errorMessages.map(msg => ({
|
||||
const newMessages: Message[] = errorMessages.map(msg => ({
|
||||
key: Math.random().toString(26),
|
||||
type: 'message',
|
||||
type: NotificationType.Message,
|
||||
props: {
|
||||
negative: true,
|
||||
...msg,
|
||||
|
|
|
@ -1,136 +0,0 @@
|
|||
import React from 'react'
|
||||
import { authToken } from '../helpers/authentication'
|
||||
import { Checkbox, Dropdown, Button, Icon } from 'semantic-ui-react'
|
||||
import styled from 'styled-components'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const FavoritesCheckboxStyle = styled(Checkbox)`
|
||||
margin-bottom: 16px;
|
||||
margin-right: 10px;
|
||||
|
||||
&.ui.toggle.checkbox label {
|
||||
padding-left: 0;
|
||||
padding-right: 4em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&.ui.checkbox input,
|
||||
&.ui.toggle.checkbox label:before {
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&.ui.toggle.checkbox label:after {
|
||||
left: auto;
|
||||
right: 1.75em;
|
||||
transition: background 0.3s ease 0s, right 0.3s ease 0s;
|
||||
}
|
||||
|
||||
&.ui.toggle.checkbox input:checked + label:after {
|
||||
left: auto;
|
||||
right: 0.08em;
|
||||
transition: background 0.3s ease 0s, right 0.3s ease 0s;
|
||||
}
|
||||
`
|
||||
|
||||
export const FavoritesCheckbox = ({ onlyFavorites, setOnlyFavorites }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<FavoritesCheckboxStyle
|
||||
toggle
|
||||
label={t('album_filter.only_favorites', 'Show only favorites')}
|
||||
checked={onlyFavorites}
|
||||
onChange={(e, result) => setOnlyFavorites(result.checked)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
FavoritesCheckbox.propTypes = {
|
||||
onlyFavorites: PropTypes.bool.isRequired,
|
||||
setOnlyFavorites: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
const OrderDirectionButton = styled(Button)`
|
||||
padding: 0.88em;
|
||||
margin-left: 10px !important;
|
||||
`
|
||||
|
||||
const SortByLabel = styled.strong`
|
||||
margin-left: 4px;
|
||||
margin-right: 6px;
|
||||
`
|
||||
|
||||
const AlbumFilter = ({
|
||||
onlyFavorites,
|
||||
setOnlyFavorites,
|
||||
setOrdering,
|
||||
ordering,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const onChangeOrderDirection = (e, data) => {
|
||||
const direction = data.children.props.name === 'arrow up' ? 'DESC' : 'ASC'
|
||||
setOrdering({ orderDirection: direction })
|
||||
}
|
||||
|
||||
const sortingOptions = [
|
||||
{
|
||||
key: 'date_shot',
|
||||
value: 'date_shot',
|
||||
text: t('album_filter.sorting_options.date_shot', 'Date shot'),
|
||||
},
|
||||
{
|
||||
key: 'updated_at',
|
||||
value: 'updated_at',
|
||||
text: t('album_filter.sorting_options.date_imported', 'Date imported'),
|
||||
},
|
||||
{
|
||||
key: 'title',
|
||||
value: 'title',
|
||||
text: t('album_filter.sorting_options.title', 'Title'),
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
value: 'type',
|
||||
text: t('album_filter.sorting_options.type', 'Kind'),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
{authToken() && setOnlyFavorites && (
|
||||
<FavoritesCheckbox
|
||||
onlyFavorites={onlyFavorites}
|
||||
setOnlyFavorites={setOnlyFavorites}
|
||||
/>
|
||||
)}
|
||||
<SortByLabel>{t('album_filter.sort_by', 'Sort by')}</SortByLabel>
|
||||
<Dropdown
|
||||
selection
|
||||
options={sortingOptions}
|
||||
defaultValue={
|
||||
sortingOptions.find(e => e.value === ordering.orderBy)?.value ||
|
||||
sortingOptions[0].value
|
||||
}
|
||||
onChange={(e, data) => {
|
||||
setOrdering({ orderBy: data.value })
|
||||
}}
|
||||
/>
|
||||
<OrderDirectionButton icon basic onClick={onChangeOrderDirection}>
|
||||
<Icon
|
||||
name={'arrow ' + (ordering.orderDirection === 'ASC' ? 'up' : 'down')}
|
||||
/>
|
||||
</OrderDirectionButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
AlbumFilter.propTypes = {
|
||||
onlyFavorites: PropTypes.bool,
|
||||
setOnlyFavorites: PropTypes.func,
|
||||
setOrdering: PropTypes.func,
|
||||
ordering: PropTypes.object,
|
||||
}
|
||||
|
||||
export default AlbumFilter
|
|
@ -1,122 +0,0 @@
|
|||
import React, { useEffect, useContext } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Breadcrumb, IconProps } from 'semantic-ui-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
import { Icon } from 'semantic-ui-react'
|
||||
import { SidebarContext } from './sidebar/Sidebar'
|
||||
import AlbumSidebar from './sidebar/AlbumSidebar'
|
||||
import { useLazyQuery, gql } from '@apollo/client'
|
||||
import { authToken } from '../helpers/authentication'
|
||||
import { albumPathQuery } from './__generated__/albumPathQuery'
|
||||
|
||||
const Header = styled.h1`
|
||||
margin: 24px 0 8px 0 !important;
|
||||
|
||||
& a {
|
||||
color: black;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const StyledIcon = styled(Icon)`
|
||||
margin-left: 8px !important;
|
||||
display: inline-block;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #1e70bf;
|
||||
}
|
||||
`
|
||||
|
||||
const SettingsIcon = (props: IconProps) => {
|
||||
return <StyledIcon name="settings" size="small" {...props} />
|
||||
}
|
||||
|
||||
const ALBUM_PATH_QUERY = gql`
|
||||
query albumPathQuery($id: ID!) {
|
||||
album(id: $id) {
|
||||
id
|
||||
path {
|
||||
id
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
type AlbumTitleProps = {
|
||||
album?: {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
disableLink: boolean
|
||||
}
|
||||
|
||||
const AlbumTitle = ({ album, disableLink = false }: AlbumTitleProps) => {
|
||||
const [fetchPath, { data: pathData }] = useLazyQuery<albumPathQuery>(
|
||||
ALBUM_PATH_QUERY
|
||||
)
|
||||
const { updateSidebar } = useContext(SidebarContext)
|
||||
|
||||
useEffect(() => {
|
||||
if (!album) return
|
||||
|
||||
if (authToken() && disableLink == true) {
|
||||
fetchPath({
|
||||
variables: {
|
||||
id: album.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [album])
|
||||
|
||||
if (!album) return <div style={{ height: 36 }}></div>
|
||||
|
||||
let title = <span>{album.title}</span>
|
||||
|
||||
const path = pathData?.album.path || []
|
||||
|
||||
const breadcrumbSections = path
|
||||
.slice()
|
||||
.reverse()
|
||||
.map(x => (
|
||||
<span key={x.id}>
|
||||
<Breadcrumb.Section as={Link} to={`/album/${x.id}`}>
|
||||
{x.title}
|
||||
</Breadcrumb.Section>
|
||||
<Breadcrumb.Divider icon="right angle" />
|
||||
</span>
|
||||
))
|
||||
|
||||
if (!disableLink) {
|
||||
title = <Link to={`/album/${album.id}`}>{title}</Link>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
<Breadcrumb>{breadcrumbSections}</Breadcrumb>
|
||||
{title}
|
||||
{authToken() && (
|
||||
<SettingsIcon
|
||||
onClick={() => {
|
||||
updateSidebar(<AlbumSidebar albumId={album.id} />)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Header>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
AlbumTitle.propTypes = {
|
||||
album: PropTypes.object,
|
||||
disableLink: PropTypes.bool,
|
||||
}
|
||||
|
||||
export default AlbumTitle
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react'
|
||||
import { Loader } from 'semantic-ui-react'
|
||||
import Loader from '../primitives/Loader'
|
||||
|
||||
type PaginateLoaderProps = {
|
||||
active: boolean
|
||||
|
@ -8,12 +8,10 @@ type PaginateLoaderProps = {
|
|||
|
||||
const PaginateLoader = ({ active, text }: PaginateLoaderProps) => (
|
||||
<Loader
|
||||
style={{ margin: '42px 0 24px 0', opacity: active ? '1' : '0' }}
|
||||
inline="centered"
|
||||
active={true}
|
||||
>
|
||||
{text}
|
||||
</Loader>
|
||||
active
|
||||
message={text}
|
||||
className={active ? 'opacity-100' : 'opacity-0'}
|
||||
/>
|
||||
)
|
||||
|
||||
export default PaginateLoader
|
||||
|
|
|
@ -0,0 +1,140 @@
|
|||
import React from 'react'
|
||||
import { authToken } from '../../helpers/authentication'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { OrderDirection } from '../../__generated__/globalTypes'
|
||||
import { MediaOrdering, SetOrderingFn } from '../../hooks/useOrderingParams'
|
||||
import Checkbox from '../../primitives/form/Checkbox'
|
||||
|
||||
import { ReactComponent as SortingIcon } from './icons/sorting.svg'
|
||||
import { ReactComponent as DirectionIcon } from './icons/direction-arrow.svg'
|
||||
|
||||
import Dropdown from '../../primitives/form/Dropdown'
|
||||
|
||||
type FavoriteCheckboxProps = {
|
||||
onlyFavorites: boolean
|
||||
setOnlyFavorites(favorites: boolean): void
|
||||
}
|
||||
|
||||
export const FavoritesCheckbox = ({
|
||||
onlyFavorites,
|
||||
setOnlyFavorites,
|
||||
}: FavoriteCheckboxProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
className="mb-1"
|
||||
label={t('album_filter.only_favorites', 'Show only favorites')}
|
||||
checked={onlyFavorites}
|
||||
onChange={e => setOnlyFavorites(e.target.checked)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type SortingOptionsProps = {
|
||||
ordering?: MediaOrdering
|
||||
setOrdering?: SetOrderingFn
|
||||
}
|
||||
|
||||
const SortingOptions = ({ setOrdering, ordering }: SortingOptionsProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const changeOrderDirection = () => {
|
||||
if (setOrdering && ordering) {
|
||||
setOrdering({
|
||||
orderDirection:
|
||||
ordering.orderDirection == OrderDirection.ASC
|
||||
? OrderDirection.DESC
|
||||
: OrderDirection.ASC,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const changeOrderBy = (value: string) => {
|
||||
if (setOrdering) {
|
||||
setOrdering({ orderBy: value })
|
||||
}
|
||||
}
|
||||
|
||||
const sortingOptions = [
|
||||
{
|
||||
value: 'date_shot',
|
||||
label: t('album_filter.sorting_options.date_shot', 'Date shot'),
|
||||
},
|
||||
{
|
||||
value: 'updated_at',
|
||||
label: t('album_filter.sorting_options.date_imported', 'Date imported'),
|
||||
},
|
||||
{
|
||||
value: 'title',
|
||||
label: t('album_filter.sorting_options.title', 'Title'),
|
||||
},
|
||||
{
|
||||
value: 'type',
|
||||
label: t('album_filter.sorting_options.type', 'Kind'),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend id="filter_group_sort-label" className="inline-block mb-1">
|
||||
<SortingIcon
|
||||
className="inline-block align-baseline mr-1"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{t('album_filter.sort', 'Sort')}</span>
|
||||
</legend>
|
||||
<div>
|
||||
<Dropdown
|
||||
aria-labelledby="filter_group_sort-label"
|
||||
setSelected={changeOrderBy}
|
||||
value={ordering?.orderBy || undefined}
|
||||
items={sortingOptions}
|
||||
/>
|
||||
<button
|
||||
title="Sort direction"
|
||||
aria-label="Sort direction"
|
||||
className={`bg-gray-50 h-[30px] align-top px-2 py-1 rounded ml-2 border border-gray-200 focus:outline-none focus:border-blue-300 text-[#8b8b8b] hover:bg-gray-100 hover:text-[#777] ${
|
||||
ordering?.orderDirection == OrderDirection.ASC ? 'flip-y' : null
|
||||
}`}
|
||||
onClick={changeOrderDirection}
|
||||
>
|
||||
<DirectionIcon />
|
||||
<span className="sr-only">
|
||||
{ordering?.orderDirection == OrderDirection.ASC
|
||||
? 'ascending'
|
||||
: 'descending'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
|
||||
type AlbumFilterProps = {
|
||||
onlyFavorites: boolean
|
||||
setOnlyFavorites?(favorites: boolean): void
|
||||
ordering?: MediaOrdering
|
||||
setOrdering?: SetOrderingFn
|
||||
}
|
||||
|
||||
const AlbumFilter = ({
|
||||
onlyFavorites,
|
||||
setOnlyFavorites,
|
||||
setOrdering,
|
||||
ordering,
|
||||
}: AlbumFilterProps) => {
|
||||
return (
|
||||
<div className="flex items-end gap-4 flex-wrap mb-4">
|
||||
<SortingOptions ordering={ordering} setOrdering={setOrdering} />
|
||||
{authToken() && setOnlyFavorites && (
|
||||
<FavoritesCheckbox
|
||||
onlyFavorites={onlyFavorites}
|
||||
setOnlyFavorites={setOnlyFavorites}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AlbumFilter
|
|
@ -0,0 +1,119 @@
|
|||
import React, { useEffect, useContext } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
import { SidebarContext } from '../sidebar/Sidebar'
|
||||
import AlbumSidebar from '../sidebar/AlbumSidebar'
|
||||
import { useLazyQuery, gql } from '@apollo/client'
|
||||
import { authToken } from '../../helpers/authentication'
|
||||
import { albumPathQuery } from './__generated__/albumPathQuery'
|
||||
import useDelay from '../../hooks/useDelay'
|
||||
|
||||
import { ReactComponent as GearIcon } from './icons/gear.svg'
|
||||
|
||||
const BreadcrumbList = styled.ol`
|
||||
& li::after {
|
||||
content: '';
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='5px' height='6px' viewBox='0 0 5 6'%3E%3Cpolyline fill='none' stroke='%23979797' points='0.74 0.167710644 3.57228936 3 0.74 5.83228936' /%3E%3C/svg%3E");
|
||||
width: 5px;
|
||||
height: 6px;
|
||||
display: inline-block;
|
||||
margin: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
`
|
||||
|
||||
const ALBUM_PATH_QUERY = gql`
|
||||
query albumPathQuery($id: ID!) {
|
||||
album(id: $id) {
|
||||
id
|
||||
path {
|
||||
id
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
type AlbumTitleProps = {
|
||||
album?: {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
disableLink: boolean
|
||||
}
|
||||
|
||||
const AlbumTitle = ({ album, disableLink = false }: AlbumTitleProps) => {
|
||||
const [fetchPath, { data: pathData }] =
|
||||
useLazyQuery<albumPathQuery>(ALBUM_PATH_QUERY)
|
||||
const { updateSidebar } = useContext(SidebarContext)
|
||||
|
||||
useEffect(() => {
|
||||
if (!album) return
|
||||
|
||||
if (authToken() && disableLink == true) {
|
||||
fetchPath({
|
||||
variables: {
|
||||
id: album.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [album])
|
||||
|
||||
const delay = useDelay(200, [album])
|
||||
console.log('delay', delay)
|
||||
|
||||
if (!album) {
|
||||
return (
|
||||
<div
|
||||
className={`flex mb-6 flex-col h-14 transition-opacity animate-pulse ${
|
||||
delay ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className="w-32 h-4 bg-gray-100 mb-2 mt-1"></div>
|
||||
<div className="w-72 h-6 bg-gray-100"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
let title = <span>{album.title}</span>
|
||||
|
||||
const path = pathData?.album.path || []
|
||||
|
||||
const breadcrumbSections = path
|
||||
.slice()
|
||||
.reverse()
|
||||
.map(x => (
|
||||
<li key={x.id} className="inline-block hover:underline">
|
||||
<Link to={`/album/${x.id}`}>{x.title}</Link>
|
||||
</li>
|
||||
))
|
||||
|
||||
if (!disableLink) {
|
||||
title = <Link to={`/album/${album.id}`}>{title}</Link>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex mb-6 items-end h-14">
|
||||
<div className="min-w-0">
|
||||
<nav aria-label="Album breadcrumb">
|
||||
<BreadcrumbList>{breadcrumbSections}</BreadcrumbList>
|
||||
</nav>
|
||||
<h1 className="text-2xl truncate min-w-0">{title}</h1>
|
||||
</div>
|
||||
{authToken() && (
|
||||
<button
|
||||
title="Album options"
|
||||
aria-label="Album options"
|
||||
className="bg-gray-50 p-2 rounded ml-2 border border-gray-200 focus:outline-none focus:border-blue-300 text-[#8b8b8b] hover:bg-gray-100 hover:text-[#777]"
|
||||
onClick={() => {
|
||||
updateSidebar(<AlbumSidebar albumId={album.id} />)
|
||||
}}
|
||||
>
|
||||
<GearIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AlbumTitle
|
|
@ -0,0 +1,32 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
// ====================================================
|
||||
// GraphQL query operation: albumPathQuery
|
||||
// ====================================================
|
||||
|
||||
export interface albumPathQuery_album_path {
|
||||
__typename: 'Album'
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface albumPathQuery_album {
|
||||
__typename: 'Album'
|
||||
id: string
|
||||
path: albumPathQuery_album_path[]
|
||||
}
|
||||
|
||||
export interface albumPathQuery {
|
||||
/**
|
||||
* 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: albumPathQuery_album
|
||||
}
|
||||
|
||||
export interface albumPathQueryVariables {
|
||||
id: string
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="13px" height="8px" viewBox="0 0 13 8" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<polyline fill="none" stroke="currentColor" stroke-width="1.5" points="1 7 6.5 1 12 7"></polyline>
|
||||
</svg>
|
After Width: | Height: | Size: 289 B |
|
@ -0,0 +1,7 @@
|
|||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
width="15px"
|
||||
height="15px"
|
||||
fill="currentColor">
|
||||
<path d="M7.33333333,0 C8.069713,0 8.66666667,0.596953667 8.66666667,1.33333333 L8.66666667,1.39333333 C8.66841527,1.83176195 8.93035293,2.22728781 9.33333333,2.4 C9.74472671,2.5815643 10.2252088,2.49444392 10.5466667,2.18 L10.5866667,2.14 C10.8367577,1.88963061 11.176121,1.74895112 11.53,1.74895112 C11.883879,1.74895112 12.2232423,1.88963061 12.4733333,2.14 C12.7237027,2.39009101 12.8643822,2.72945434 12.8643822,3.08333333 C12.8643822,3.43721233 12.7237027,3.77657566 12.4733333,4.02666667 L12.4333333,4.06666667 C12.1188894,4.38812449 12.031769,4.86860662 12.2133333,5.28 L12.2133333,5.33333333 C12.3860455,5.73631374 12.7815714,5.9982514 13.22,6 L13.3333333,6 C14.069713,6 14.6666667,6.59695367 14.6666667,7.33333333 C14.6666667,8.069713 14.069713,8.66666667 13.3333333,8.66666667 L13.2733333,8.66666667 C12.8349047,8.66841527 12.4393789,8.93035293 12.2666667,9.33333333 C12.0851024,9.74472671 12.1722227,10.2252088 12.4866667,10.5466667 L12.5266667,10.5866667 C12.7770361,10.8367577 12.9177155,11.176121 12.9177155,11.53 C12.9177155,11.883879 12.7770361,12.2232423 12.5266667,12.4733333 C12.2765757,12.7237027 11.9372123,12.8643822 11.5833333,12.8643822 C11.2294543,12.8643822 10.890091,12.7237027 10.64,12.4733333 L10.6,12.4333333 C10.2785422,12.1188894 9.79806005,12.031769 9.38666667,12.2133333 C8.98368626,12.3860455 8.7217486,12.7815714 8.72,13.22 L8.72,13.3333333 C8.72,14.069713 8.12304633,14.6666667 7.38666667,14.6666667 C6.650287,14.6666667 6.05333333,14.069713 6.05333333,13.3333333 L6.05333333,13.2733333 C6.04277107,12.8217805 5.75724785,12.4225768 5.33333333,12.2666667 C4.92193995,12.0851024 4.44145782,12.1722227 4.12,12.4866667 L4.08,12.5266667 C3.82990899,12.7770361 3.49054566,12.9177155 3.13666667,12.9177155 C2.78278767,12.9177155 2.44342434,12.7770361 2.19333333,12.5266667 C1.94296394,12.2765757 1.80228446,11.9372123 1.80228446,11.5833333 C1.80228446,11.2294543 1.94296394,10.890091 2.19333333,10.64 L2.23333333,10.6 C2.54777725,10.2785422 2.63489764,9.79806005 2.45333333,9.38666667 C2.28062114,8.98368626 1.88509528,8.7217486 1.44666667,8.72 L1.33333333,8.72 C0.596953667,8.72 0,8.12304633 0,7.38666667 C0,6.650287 0.596953667,6.05333333 1.33333333,6.05333333 L1.39333333,6.05333333 C1.84488612,6.04277107 2.24408988,5.75724785 2.4,5.33333333 C2.5815643,4.92193995 2.49444392,4.44145782 2.18,4.12 L2.14,4.08 C1.88963061,3.82990899 1.74895112,3.49054566 1.74895112,3.13666667 C1.74895112,2.78278767 1.88963061,2.44342434 2.14,2.19333333 C2.39009101,1.94296394 2.72945434,1.80228446 3.08333333,1.80228446 C3.43721233,1.80228446 3.77657566,1.94296394 4.02666667,2.19333333 L4.06666667,2.23333333 C4.38812449,2.54777725 4.86860662,2.63489764 5.28,2.45333333 L5.33333333,2.45333333 C5.73631374,2.28062114 5.9982514,1.88509528 6,1.44666667 L6,1.33333333 C6,0.596953667 6.59695367,0 7.33333333,0 Z M7.33333333,5.33333333 C6.22876383,5.33333333 5.33333333,6.22876383 5.33333333,7.33333333 C5.33333333,8.43790283 6.22876383,9.33333333 7.33333333,9.33333333 C8.43790283,9.33333333 9.33333333,8.43790283 9.33333333,7.33333333 C9.33333333,6.22876383 8.43790283,5.33333333 7.33333333,5.33333333 Z" />
|
||||
</svg>
|
After Width: | Height: | Size: 3.1 KiB |
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="15px" height="13px" viewBox="0 0 15 13" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<path fill="currentColor" d="M4,3.55271368e-14 L4.0278401,0.000770874105 C4.04128052,0.00151580687 4.05469661,0.00280063794 4.06805308,0.0046253673 L4,3.55271368e-14 C4.04183471,3.55271368e-14 4.08246942,0.00513782494 4.12130755,0.0148169071 C4.13326871,0.0178373169 4.14523456,0.0212920805 4.15708315,0.0252017355 C4.16997104,0.0294299553 4.18277427,0.034259294 4.19531518,0.0395860503 C4.20839103,0.0451160612 4.22150919,0.0513529918 4.23439118,0.0581928845 C4.23503401,0.0585723692 4.23566876,0.058911632 4.23630269,0.0592522225 L4.30644972,0.104852306 L4.30644972,0.104852306 L4.35355339,0.146446609 L7.85355339,3.64644661 C8.04881554,3.84170876 8.04881554,4.15829124 7.85355339,4.35355339 C7.67331141,4.53379537 7.38969588,4.54766014 7.19355028,4.39514769 L7.14644661,4.35355339 L4.5,1.707 L4.5,12 C4.5,12.2761424 4.27614237,12.5 4,12.5 C3.74358208,12.5 3.53224642,12.3069799 3.50336387,12.0583106 L3.5,12 L3.5,1.707 L0.853553391,4.35355339 C0.67331141,4.53379537 0.389695882,4.54766014 0.193550278,4.39514769 L0.146446609,4.35355339 C-0.0337953714,4.17331141 -0.0476601392,3.88969588 0.104852306,3.69355028 L0.146446609,3.64644661 L3.64644661,0.146446609 L3.65767597,0.135561002 C3.67134412,0.122717283 3.68573494,0.110633776 3.70078091,0.0993780001 L3.64644661,0.146446609 C3.67320128,0.11969194 3.70223365,0.0966031755 3.73291954,0.0771803148 C3.74617456,0.0688441438 3.75977662,0.0610999945 3.77375753,0.0539938991 C3.78609769,0.0476919126 3.79888567,0.0418687101 3.81184952,0.0366116524 C3.8234432,0.0319023624 3.83504722,0.0276848515 3.84684229,0.0238934918 C3.86557052,0.0179217879 3.88445336,0.0130756914 3.90351142,0.00934593934 C3.91016306,0.00799330119 3.91744282,0.00672777028 3.92477476,0.00562145489 C3.94767442,0.00222086491 3.97013713,0.000384038311 3.99261784,5.41592491e-05 L4,3.55271368e-14 Z M11,0 C11.2564179,0 11.4677536,0.193020095 11.4966361,0.441689437 L11.5,0.5 L11.499,10.792 L14.1464466,8.14644661 C14.3266886,7.96620463 14.6103041,7.95233986 14.8064497,8.10485231 L14.8535534,8.14644661 C15.0337954,8.32668859 15.0476601,8.61030412 14.8951477,8.80644972 L14.8535534,8.85355339 L11.3535534,12.3535534 L11.342324,12.364439 C11.3286559,12.3772827 11.3142651,12.3893662 11.2992191,12.400622 L11.3535534,12.3535534 C11.3267987,12.3803081 11.2977664,12.4033968 11.2670805,12.4228197 C11.2538254,12.4311559 11.2402234,12.4389 11.2262425,12.4460061 C11.2139023,12.4523081 11.2011143,12.4581313 11.1881505,12.4633883 C11.1765568,12.4680976 11.1649528,12.4723151 11.1531577,12.4761065 C11.1344295,12.4820782 11.1155466,12.4869243 11.0964886,12.4906541 C11.08887,12.4921969 11.0806073,12.4936082 11.0722781,12.4948142 C11.0526434,12.4976054 11.0335482,12.4992446 11.0144276,12.4997931 L11,12.5 L11,12.5 L10.9590982,12.4983345 C10.9507048,12.4976497 10.9423254,12.4967538 10.9339686,12.4956467 L11,12.5 C10.9581653,12.5 10.9175306,12.4948622 10.8786924,12.4851831 C10.8667313,12.4821627 10.8547654,12.4787079 10.8429168,12.4747983 C10.828989,12.4702266 10.8151853,12.4649672 10.8016896,12.4591301 C10.7896647,12.4539556 10.7775355,12.4481398 10.7656088,12.4418071 C10.74145,12.4289242 10.7184764,12.4141602 10.6968848,12.3976777 L10.6935503,12.3951477 L10.6464466,12.3535534 L7.14644661,8.85355339 C6.95118446,8.65829124 6.95118446,8.34170876 7.14644661,8.14644661 C7.32668859,7.96620463 7.61030412,7.95233986 7.80644972,8.10485231 L7.85355339,8.14644661 L10.499,10.792 L10.5,0.5 C10.5,0.223857625 10.7238576,0 11,0 Z"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 3.6 KiB |
|
@ -1,52 +1,8 @@
|
|||
import React, { useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ProtectedImage } from '../photoGallery/ProtectedMedia'
|
||||
import { albumQuery_album_subAlbums } from '../../Pages/AlbumPage/__generated__/albumQuery'
|
||||
|
||||
const AlbumBoxLink = styled(Link)`
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
color: #222;
|
||||
`
|
||||
|
||||
const ImageWrapper = styled.div`
|
||||
width: 240px;
|
||||
height: 220px;
|
||||
padding: 0 10px;
|
||||
position: relative;
|
||||
`
|
||||
|
||||
const Image = styled(ProtectedImage)`
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
margin: auto;
|
||||
border-radius: 4%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
`
|
||||
|
||||
const Placeholder = styled.div<{ overlap?: boolean; loaded?: boolean }>`
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
border-radius: 4%;
|
||||
margin: auto;
|
||||
background: linear-gradient(#f7f7f7 0%, #eee 100%);
|
||||
|
||||
${({ overlap, loaded }) =>
|
||||
overlap &&
|
||||
`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 10px;
|
||||
opacity: ${loaded ? 0 : 1};
|
||||
|
||||
transition: opacity 200ms;
|
||||
`}
|
||||
`
|
||||
|
||||
interface AlbumBoxImageProps {
|
||||
src?: string
|
||||
}
|
||||
|
@ -54,16 +10,31 @@ interface AlbumBoxImageProps {
|
|||
const AlbumBoxImage = ({ src, ...props }: AlbumBoxImageProps) => {
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
let image = null
|
||||
if (src) {
|
||||
return (
|
||||
<ImageWrapper>
|
||||
<Image {...props} onLoad={() => setLoaded(true)} src={src} />
|
||||
<Placeholder overlap loaded={loaded} />
|
||||
</ImageWrapper>
|
||||
image = (
|
||||
<ProtectedImage
|
||||
className="object-cover object-center w-full h-full rounded-lg"
|
||||
{...props}
|
||||
onLoad={() => setLoaded(true)}
|
||||
src={src}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <Placeholder />
|
||||
let placeholder = null
|
||||
if (!loaded) {
|
||||
placeholder = (
|
||||
<div className="bg-gray-100 animate-pulse w-full h-full rounded-lg absolute"></div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xs:w-[220px] xs:h-[220px] relative rounded-lg">
|
||||
{image}
|
||||
{placeholder}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type AlbumBoxProps = {
|
||||
|
@ -72,20 +43,25 @@ type AlbumBoxProps = {
|
|||
}
|
||||
|
||||
export const AlbumBox = ({ album, customLink, ...props }: AlbumBoxProps) => {
|
||||
if (!album) {
|
||||
return (
|
||||
<AlbumBoxLink {...props} to="#">
|
||||
<AlbumBoxImage />
|
||||
</AlbumBoxLink>
|
||||
)
|
||||
}
|
||||
|
||||
const thumbnail = album.thumbnail?.thumbnail?.url
|
||||
const wrapperClasses =
|
||||
'inline-block text-center text-gray-900 mx-3 my-2 xs:h-60'
|
||||
|
||||
if (album) {
|
||||
return (
|
||||
<AlbumBoxLink {...props} to={customLink || `/album/${album.id}`}>
|
||||
<AlbumBoxImage src={thumbnail} />
|
||||
<Link
|
||||
to={customLink || `/album/${album.id}`}
|
||||
className={wrapperClasses}
|
||||
{...props}
|
||||
>
|
||||
<AlbumBoxImage src={album.thumbnail?.thumbnail?.url} />
|
||||
<p>{album.title}</p>
|
||||
</AlbumBoxLink>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={wrapperClasses} {...props}>
|
||||
<AlbumBoxImage />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,15 +1,8 @@
|
|||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { albumQuery_album_subAlbums } from '../../Pages/AlbumPage/__generated__/albumQuery'
|
||||
import { AlbumBox } from './AlbumBox'
|
||||
|
||||
const Container = styled.div`
|
||||
margin: 20px -10px;
|
||||
position: relative;
|
||||
`
|
||||
|
||||
type AlbumBoxesProps = {
|
||||
loading: boolean
|
||||
error?: Error
|
||||
albums?: albumQuery_album_subAlbums[]
|
||||
getCustomLink?(albumID: string): string
|
||||
|
@ -20,7 +13,7 @@ const AlbumBoxes = ({ error, albums, getCustomLink }: AlbumBoxesProps) => {
|
|||
|
||||
let albumElements = []
|
||||
|
||||
if (albums) {
|
||||
if (albums !== undefined) {
|
||||
albumElements = albums.map(album => (
|
||||
<AlbumBox
|
||||
key={album.id}
|
||||
|
@ -34,7 +27,7 @@ const AlbumBoxes = ({ error, albums, getCustomLink }: AlbumBoxesProps) => {
|
|||
}
|
||||
}
|
||||
|
||||
return <Container>{albumElements}</Container>
|
||||
return <div className="-mx-3 my-6">{albumElements}</div>
|
||||
}
|
||||
|
||||
export default AlbumBoxes
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import React, { useEffect, useReducer } from 'react'
|
||||
import AlbumTitle from '../AlbumTitle'
|
||||
import AlbumTitle from '../album/AlbumTitle'
|
||||
import PhotoGallery from '../photoGallery/PhotoGallery'
|
||||
import AlbumBoxes from './AlbumBoxes'
|
||||
import AlbumFilter from '../AlbumFilter'
|
||||
import AlbumFilter from '../album/AlbumFilter'
|
||||
import { albumQuery_album } from '../../Pages/AlbumPage/__generated__/albumQuery'
|
||||
import { OrderDirection } from '../../../__generated__/globalTypes'
|
||||
import {
|
||||
photoGalleryReducer,
|
||||
urlPresentModeSetupHook,
|
||||
} from '../photoGallery/photoGalleryReducer'
|
||||
import { MediaOrdering, SetOrderingFn } from '../../hooks/useOrderingParams'
|
||||
|
||||
type AlbumGalleryProps = {
|
||||
album?: albumQuery_album
|
||||
|
@ -16,8 +16,8 @@ type AlbumGalleryProps = {
|
|||
customAlbumLink?(albumID: string): string
|
||||
showFilter?: boolean
|
||||
setOnlyFavorites?(favorites: boolean): void
|
||||
setOrdering?(ordering: { orderBy: string }): void
|
||||
ordering?: { orderBy: string | null; orderDirection: OrderDirection | null }
|
||||
setOrdering?: SetOrderingFn
|
||||
ordering?: MediaOrdering
|
||||
onlyFavorites?: boolean
|
||||
onFavorite?(): void
|
||||
}
|
||||
|
@ -61,19 +61,17 @@ const AlbumGallery = React.forwardRef(
|
|||
if (album.subAlbums.length > 0) {
|
||||
subAlbumElement = (
|
||||
<AlbumBoxes
|
||||
loading={loading}
|
||||
albums={album.subAlbums}
|
||||
getCustomLink={customAlbumLink}
|
||||
/>
|
||||
)
|
||||
}
|
||||
} else {
|
||||
subAlbumElement = <AlbumBoxes loading={loading} />
|
||||
subAlbumElement = <AlbumBoxes />
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<AlbumTitle album={album} disableLink />
|
||||
{showFilter && (
|
||||
<AlbumFilter
|
||||
onlyFavorites={onlyFavorites}
|
||||
|
@ -82,17 +80,8 @@ const AlbumGallery = React.forwardRef(
|
|||
ordering={ordering}
|
||||
/>
|
||||
)}
|
||||
<AlbumTitle album={album} disableLink />
|
||||
{subAlbumElement}
|
||||
{
|
||||
<h2
|
||||
style={{
|
||||
opacity: loading ? 0 : 1,
|
||||
display: album && album.subAlbums.length > 0 ? 'block' : 'none',
|
||||
}}
|
||||
>
|
||||
Images
|
||||
</h2>
|
||||
}
|
||||
<PhotoGallery
|
||||
loading={loading}
|
||||
mediaState={mediaState}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
import { MediaType } from '../../../__generated__/globalTypes'
|
||||
import { MediaType } from '../../__generated__/globalTypes'
|
||||
import { MediaSidebarMedia } from '../sidebar/MediaSidebar'
|
||||
import { sidebarPhoto_media_faces } from '../sidebar/__generated__/sidebarPhoto'
|
||||
|
||||
|
|
|
@ -1,57 +1,30 @@
|
|||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import React, { useContext } from 'react'
|
||||
import SearchBar from './Searchbar'
|
||||
|
||||
import logoPath from '../../assets/photoview-logo.svg'
|
||||
import { authToken } from '../../helpers/authentication'
|
||||
import { SidebarContext } from '../sidebar/Sidebar'
|
||||
import classNames from 'classnames'
|
||||
|
||||
const Container = styled.div`
|
||||
height: 60px;
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
position: fixed;
|
||||
background: white;
|
||||
top: 0;
|
||||
/* border-bottom: 1px solid rgba(0, 0, 0, 0.1); */
|
||||
box-shadow: 0 0 2px rgba(0, 0, 0, 0.3);
|
||||
`
|
||||
const Header = () => {
|
||||
const { pinned } = useContext(SidebarContext)
|
||||
|
||||
const Title = styled.h1`
|
||||
font-size: 36px;
|
||||
font-weight: 400;
|
||||
padding: 2px 12px;
|
||||
flex-grow: 1;
|
||||
min-width: 245px;
|
||||
|
||||
@media (max-width: 400px) {
|
||||
min-width: auto;
|
||||
|
||||
& span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const Logo = styled.img`
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-right: 8px;
|
||||
`
|
||||
|
||||
const LogoText = styled.span`
|
||||
vertical-align: middle;
|
||||
`
|
||||
|
||||
const Header = () => (
|
||||
<Container>
|
||||
<Title>
|
||||
<Logo src={logoPath} alt="logo" />
|
||||
<LogoText>Photoview</LogoText>
|
||||
</Title>
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'sticky top-0 z-10 bg-white flex items-center justify-between py-3 px-4 lg:px-8 lg:pt-4 shadow-separator lg:shadow-none',
|
||||
{ 'mr-[404px]': pinned }
|
||||
)}
|
||||
>
|
||||
<h1 className="mr-4 lg:mr-8 flex-shrink-0 flex items-center">
|
||||
<img className="h-12 lg:h-10" src={logoPath} alt="logo" />
|
||||
<span className="hidden lg:block ml-2 text-2xl font-light">
|
||||
Photoview
|
||||
</span>
|
||||
</h1>
|
||||
{authToken() ? <SearchBar /> : null}
|
||||
</Container>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Header
|
||||
|
|
|
@ -3,56 +3,14 @@ import styled from 'styled-components'
|
|||
import { useLazyQuery, gql } from '@apollo/client'
|
||||
import { debounce, DebouncedFn } from '../../helpers/utils'
|
||||
import { ProtectedImage } from '../photoGallery/ProtectedMedia'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { NavLink, useHistory, useRouteMatch } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
searchQuery,
|
||||
searchQuery_search_albums,
|
||||
searchQuery_search_media,
|
||||
} from './__generated__/searchQuery'
|
||||
|
||||
const Container = styled.div`
|
||||
height: 60px;
|
||||
width: 350px;
|
||||
margin: 0 12px;
|
||||
padding: 12px 0;
|
||||
position: relative;
|
||||
`
|
||||
|
||||
const SearchField = styled.input`
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 4px;
|
||||
padding: 0 8px;
|
||||
font-size: 1rem;
|
||||
font-family: Lato, 'Helvetica Neue', Arial, Helvetica, sans-serif;
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 4px #eee;
|
||||
border-color: #3d82c6;
|
||||
}
|
||||
`
|
||||
|
||||
const Results = styled.div<{ show: boolean }>`
|
||||
display: ${({ show }) => (show ? 'block' : 'none')};
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
max-height: calc(100vh - 100px);
|
||||
overflow-y: scroll;
|
||||
padding: 28px 4px 32px;
|
||||
background-color: white;
|
||||
box-shadow: 0 0 4px #eee;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
top: 50%;
|
||||
z-index: -1;
|
||||
|
||||
${SearchField}:not(:focus) ~ & {
|
||||
display: none;
|
||||
}
|
||||
`
|
||||
import classNames from 'classnames'
|
||||
|
||||
const SEARCH_QUERY = gql`
|
||||
query searchQuery($query: String!) {
|
||||
|
@ -81,11 +39,17 @@ const SEARCH_QUERY = gql`
|
|||
}
|
||||
`
|
||||
|
||||
const SearchWrapper = styled.div.attrs({
|
||||
className: 'w-full max-w-xs lg:relative',
|
||||
})``
|
||||
|
||||
const SearchBar = () => {
|
||||
const { t } = useTranslation()
|
||||
const [fetchSearches, fetchResult] = useLazyQuery<searchQuery>(SEARCH_QUERY)
|
||||
const [query, setQuery] = useState('')
|
||||
const [fetched, setFetched] = useState(false)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const inputEl = useRef<HTMLInputElement>(null)
|
||||
|
||||
type QueryFn = (query: string) => void
|
||||
|
||||
|
@ -94,6 +58,7 @@ const SearchBar = () => {
|
|||
debouncedFetch.current = debounce<QueryFn>(query => {
|
||||
fetchSearches({ variables: { query } })
|
||||
setFetched(true)
|
||||
setExpanded(true)
|
||||
}, 250)
|
||||
|
||||
return () => {
|
||||
|
@ -112,132 +77,308 @@ const SearchBar = () => {
|
|||
}
|
||||
}
|
||||
|
||||
const routerMatch = useRouteMatch()
|
||||
useEffect(() => {
|
||||
setExpanded(false)
|
||||
setQuery('')
|
||||
}, [routerMatch])
|
||||
|
||||
const [selectedItem, setSelectedItem] = useState<number | null>(null)
|
||||
|
||||
const searchData = fetchResult.data
|
||||
let media = searchData?.search.media || []
|
||||
let albums = searchData?.search.albums || []
|
||||
|
||||
albums = albums.slice(0, 5)
|
||||
media = media.slice(0, 5)
|
||||
|
||||
const selectedItemId =
|
||||
selectedItem !== null
|
||||
? [...albums.map(x => x.id), ...media.map(x => x.id)][selectedItem]
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
const elem = inputEl.current
|
||||
if (!elem) return
|
||||
|
||||
const focusEvent = () => {
|
||||
setExpanded(true)
|
||||
}
|
||||
|
||||
const blurEvent = () => {
|
||||
setExpanded(false)
|
||||
}
|
||||
|
||||
elem.addEventListener('focus', focusEvent)
|
||||
elem.addEventListener('blur', blurEvent)
|
||||
|
||||
return () => {
|
||||
elem.removeEventListener('focus', focusEvent)
|
||||
elem.removeEventListener('blur', blurEvent)
|
||||
}
|
||||
}, [inputEl])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedItem(null)
|
||||
}, [searchData])
|
||||
|
||||
useEffect(() => {
|
||||
const totalItems = albums.length + media.length
|
||||
|
||||
const keydownEvent = (event: KeyboardEvent) => {
|
||||
if (!expanded) return
|
||||
|
||||
console.log(event.key)
|
||||
if (event.key == 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
setSelectedItem(i => (i === null ? 0 : Math.min(totalItems - 1, i + 1)))
|
||||
} else if (event.key == 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
setSelectedItem(i => (i === null ? 0 : Math.max(0, i - 1)))
|
||||
} else if (event.key == 'Escape') {
|
||||
// setExpanded(false)
|
||||
inputEl.current?.blur()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', keydownEvent)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', keydownEvent)
|
||||
}
|
||||
}, [searchData])
|
||||
|
||||
let results = null
|
||||
if (query.trim().length > 0 && fetched) {
|
||||
results = (
|
||||
<SearchResults
|
||||
searchData={fetchResult.data}
|
||||
albums={albums}
|
||||
media={media}
|
||||
query={fetchResult.data?.search.query || ''}
|
||||
selectedItem={selectedItem}
|
||||
setSelectedItem={setSelectedItem}
|
||||
loading={fetchResult.loading}
|
||||
expanded={expanded}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<SearchField
|
||||
<SearchWrapper>
|
||||
<input
|
||||
ref={inputEl}
|
||||
autoComplete="off"
|
||||
aria-controls="search-results"
|
||||
aria-haspopup="listbox"
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={
|
||||
selectedItemId ? `search-item-${selectedItemId}` : ''
|
||||
}
|
||||
aria-expanded={expanded}
|
||||
className="w-full py-2 px-3 z-10 relative rounded-md bg-gray-50 focus:bg-white border border-gray-50 focus:border-blue-400 outline-none focus:ring-2 focus:ring-blue-400 focus:ring-opacity-50"
|
||||
type="search"
|
||||
placeholder={t('header.search.placeholder', 'Search')}
|
||||
onChange={fetchEvent}
|
||||
value={query}
|
||||
/>
|
||||
{results}
|
||||
</Container>
|
||||
</SearchWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const ResultTitle = styled.h1`
|
||||
font-size: 1.25rem;
|
||||
margin: 12px 0 0.25rem;
|
||||
`
|
||||
const ResultTitle = styled.h1.attrs({
|
||||
className: 'uppercase text-gray-700 text-sm font-semibold mt-4 mb-2 mx-1',
|
||||
})``
|
||||
|
||||
type SearchResultsProps = {
|
||||
searchData?: searchQuery
|
||||
albums: searchQuery_search_albums[]
|
||||
media: searchQuery_search_media[]
|
||||
loading: boolean
|
||||
selectedItem: number | null
|
||||
setSelectedItem: React.Dispatch<React.SetStateAction<number | null>>
|
||||
query: string
|
||||
expanded: boolean
|
||||
}
|
||||
|
||||
const SearchResults = ({ searchData, loading }: SearchResultsProps) => {
|
||||
const SearchResults = ({
|
||||
albums,
|
||||
media,
|
||||
loading,
|
||||
selectedItem,
|
||||
setSelectedItem,
|
||||
query,
|
||||
expanded,
|
||||
}: SearchResultsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const query = searchData?.search.query || ''
|
||||
|
||||
const media = searchData?.search.media || []
|
||||
const albums = searchData?.search.albums || []
|
||||
const albumElements = albums.map((album, i) => (
|
||||
<AlbumRow
|
||||
key={album.id}
|
||||
query={query}
|
||||
album={album}
|
||||
selected={selectedItem == i}
|
||||
setSelected={() => setSelectedItem(i)}
|
||||
/>
|
||||
))
|
||||
|
||||
const mediaElements = media.map((media, i) => (
|
||||
<PhotoRow
|
||||
key={media.id}
|
||||
query={query}
|
||||
media={media}
|
||||
selected={selectedItem == i + albumElements.length}
|
||||
setSelected={() => setSelectedItem(i + albumElements.length)}
|
||||
/>
|
||||
))
|
||||
|
||||
let message = null
|
||||
if (loading) message = t('header.search.loading', 'Loading results...')
|
||||
else if (searchData && media.length == 0 && albums.length == 0)
|
||||
else if (media.length == 0 && albums.length == 0)
|
||||
message = t('header.search.no_results', 'No results found')
|
||||
|
||||
const albumElements = albums.map(album => (
|
||||
<AlbumRow key={album.id} query={query} album={album} />
|
||||
))
|
||||
|
||||
const mediaElements = media.map(media => (
|
||||
<PhotoRow key={media.id} query={query} media={media} />
|
||||
))
|
||||
if (message) message = <div className="mt-8 text-center">{message}</div>
|
||||
|
||||
return (
|
||||
<Results
|
||||
<div
|
||||
id="search-results"
|
||||
role="listbox"
|
||||
className={classNames(
|
||||
'absolute bg-white left-0 right-0 top-[72px] overflow-y-auto h-[calc(100vh-152px)] border px-4 z-0',
|
||||
'lg:top-[40px] lg:shadow-md lg:rounded-b lg:max-h-[560px]',
|
||||
{ hidden: !expanded }
|
||||
)}
|
||||
tabIndex={-1}
|
||||
onMouseDown={e => {
|
||||
// Prevent input blur event
|
||||
e.preventDefault()
|
||||
}}
|
||||
show={!!searchData}
|
||||
>
|
||||
{message}
|
||||
{albumElements.length > 0 && (
|
||||
<>
|
||||
<ResultTitle>
|
||||
{t('header.search.result_type.albums', 'Albums')}
|
||||
</ResultTitle>
|
||||
<ul aria-label="albums">{albumElements}</ul>
|
||||
</>
|
||||
)}
|
||||
{albumElements}
|
||||
{mediaElements.length > 0 && (
|
||||
<>
|
||||
<ResultTitle>
|
||||
{t('header.search.result_type.photos', 'Photos')}
|
||||
{t('header.search.result_type.media', 'Media')}
|
||||
</ResultTitle>
|
||||
<ul aria-label="media">{mediaElements}</ul>
|
||||
</>
|
||||
)}
|
||||
{mediaElements}
|
||||
</Results>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const RowLink = styled(NavLink)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: black;
|
||||
`
|
||||
type SearchRowProps = {
|
||||
id: string
|
||||
link: string
|
||||
preview: React.ReactNode
|
||||
label: React.ReactNode
|
||||
selected: boolean
|
||||
setSelected(): void
|
||||
}
|
||||
|
||||
const PhotoSearchThumbnail = styled(ProtectedImage)`
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
margin: 2px 0;
|
||||
object-fit: contain;
|
||||
`
|
||||
const SearchRow = ({
|
||||
id,
|
||||
link,
|
||||
preview,
|
||||
label,
|
||||
selected,
|
||||
setSelected,
|
||||
}: SearchRowProps) => {
|
||||
const rowEl = useRef<HTMLLIElement>(null)
|
||||
const history = useHistory()
|
||||
|
||||
const AlbumSearchThumbnail = styled(ProtectedImage)`
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
margin: 4px 0;
|
||||
border-radius: 4px;
|
||||
/* border: 1px solid #888; */
|
||||
object-fit: cover;
|
||||
`
|
||||
useEffect(() => {
|
||||
const keydownEvent = (event: KeyboardEvent) => {
|
||||
if (event.key == 'Enter') {
|
||||
history.push(link)
|
||||
}
|
||||
}
|
||||
|
||||
const RowTitle = styled.span`
|
||||
flex-grow: 1;
|
||||
padding-left: 8px;
|
||||
`
|
||||
document.addEventListener('keydown', keydownEvent)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', keydownEvent)
|
||||
}
|
||||
})
|
||||
|
||||
if (selected) {
|
||||
rowEl.current?.scrollIntoView({
|
||||
block: 'nearest',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
id={`search-item-${id}`}
|
||||
ref={rowEl}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
onMouseOver={() => setSelected()}
|
||||
className={classNames('rounded p-1 mt-1', {
|
||||
'bg-gray-100': selected,
|
||||
})}
|
||||
>
|
||||
<NavLink to={link} className="flex items-center" tabIndex={-1}>
|
||||
{preview}
|
||||
<span className="flex-grow pl-2 text-sm">{label}</span>
|
||||
</NavLink>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
type PhotoRowArgs = {
|
||||
query: string
|
||||
media: searchQuery_search_media
|
||||
selected: boolean
|
||||
setSelected(): void
|
||||
}
|
||||
|
||||
const PhotoRow = ({ query, media }: PhotoRowArgs) => (
|
||||
<RowLink to={`/album/${media.album.id}`}>
|
||||
<PhotoSearchThumbnail src={media?.thumbnail?.url} />
|
||||
<RowTitle>{searchHighlighted(query, media.title)}</RowTitle>
|
||||
</RowLink>
|
||||
const PhotoRow = ({ query, media, selected, setSelected }: PhotoRowArgs) => (
|
||||
<SearchRow
|
||||
key={media.id}
|
||||
id={media.id}
|
||||
link={`/album/${media.album.id}`}
|
||||
preview={
|
||||
<ProtectedImage
|
||||
src={media?.thumbnail?.url}
|
||||
className="w-14 h-14 object-cover"
|
||||
/>
|
||||
}
|
||||
label={searchHighlighted(query, media.title)}
|
||||
selected={selected}
|
||||
setSelected={setSelected}
|
||||
/>
|
||||
)
|
||||
|
||||
type AlbumRowArgs = {
|
||||
query: string
|
||||
album: searchQuery_search_albums
|
||||
selected: boolean
|
||||
setSelected(): void
|
||||
}
|
||||
|
||||
const AlbumRow = ({ query, album }: AlbumRowArgs) => (
|
||||
<RowLink to={`/album/${album.id}`}>
|
||||
<AlbumSearchThumbnail src={album?.thumbnail?.thumbnail?.url} />
|
||||
<RowTitle>{searchHighlighted(query, album.title)}</RowTitle>
|
||||
</RowLink>
|
||||
const AlbumRow = ({ query, album, selected, setSelected }: AlbumRowArgs) => (
|
||||
<SearchRow
|
||||
key={album.id}
|
||||
id={album.id}
|
||||
link={`/album/${album.id}`}
|
||||
preview={
|
||||
<ProtectedImage
|
||||
src={album?.thumbnail?.thumbnail?.url}
|
||||
className="w-14 h-14 rounded object-cover"
|
||||
/>
|
||||
}
|
||||
label={searchHighlighted(query, album.title)}
|
||||
selected={selected}
|
||||
setSelected={setSelected}
|
||||
/>
|
||||
)
|
||||
|
||||
const searchHighlighted = (query: string, text: string) => {
|
||||
|
@ -252,11 +393,11 @@ const searchHighlighted = (query: string, text: string) => {
|
|||
const end = text.substring(i + query.length)
|
||||
|
||||
return (
|
||||
<>
|
||||
<span>
|
||||
{start}
|
||||
<b>{middle}</b>
|
||||
<span className="font-semibold whitespace-pre">{middle}</span>
|
||||
{end}
|
||||
</>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
import '@testing-library/jest-dom'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import Layout from './Layout'
|
||||
|
||||
require('../../localization').setupLocalization()
|
||||
|
||||
test('Layout component', async () => {
|
||||
render(
|
||||
<Layout>
|
||||
<p>layout_content</p>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('Layout')).toBeInTheDocument()
|
||||
expect(screen.getByText('layout_content')).toBeInTheDocument()
|
||||
})
|
|
@ -0,0 +1,58 @@
|
|||
import { gql } from '@apollo/client'
|
||||
import PropTypes from 'prop-types'
|
||||
import React, { useContext } from 'react'
|
||||
import { Helmet } from 'react-helmet'
|
||||
import Header from '../header/Header'
|
||||
import { Authorized } from '../routes/AuthorizedRoute'
|
||||
import { Sidebar, SidebarContext } from '../sidebar/Sidebar'
|
||||
import MainMenu from './MainMenu'
|
||||
|
||||
export const ADMIN_QUERY = gql`
|
||||
query adminQuery {
|
||||
myUser {
|
||||
admin
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
type LayoutProps = {
|
||||
children: React.ReactNode
|
||||
title: string
|
||||
}
|
||||
|
||||
const Layout = ({ children, title, ...otherProps }: LayoutProps) => {
|
||||
const { pinned, content: sidebarContent } = useContext(SidebarContext)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{title ? `${title} - Photoview` : `Photoview`}</title>
|
||||
</Helmet>
|
||||
<div className="relative" {...otherProps} data-testid="Layout">
|
||||
<Header />
|
||||
<div className="">
|
||||
<Authorized>
|
||||
<MainMenu />
|
||||
</Authorized>
|
||||
<div
|
||||
className={`mx-3 my-3 lg:mt-5 lg:mr-8 lg:ml-[292px] ${
|
||||
pinned && sidebarContent ? 'lg:pr-[420px]' : ''
|
||||
}`}
|
||||
id="layout-content"
|
||||
>
|
||||
{children}
|
||||
{/* <div className="h-6"></div> */}
|
||||
</div>
|
||||
</div>
|
||||
<Sidebar />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Layout.propTypes = {
|
||||
children: PropTypes.any.isRequired,
|
||||
title: PropTypes.string,
|
||||
}
|
||||
|
||||
export default Layout
|
|
@ -4,32 +4,25 @@ import React from 'react'
|
|||
import { MockedProvider } from '@apollo/client/testing'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
import Layout, { ADMIN_QUERY, MAPBOX_QUERY, SideMenu } from './Layout'
|
||||
import * as authentication from '../../helpers/authentication'
|
||||
import { ADMIN_QUERY } from './Layout'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import MainMenu, { MAPBOX_QUERY } from './MainMenu'
|
||||
|
||||
import * as authentication from './helpers/authentication'
|
||||
require('../../localization').setupLocalization()
|
||||
|
||||
require('./localization').setupLocalization()
|
||||
jest.mock('../../helpers/authentication.ts')
|
||||
|
||||
jest.mock('./helpers/authentication.ts')
|
||||
|
||||
test('Layout component', async () => {
|
||||
render(
|
||||
<Layout>
|
||||
<p>layout_content</p>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('Layout')).toBeInTheDocument()
|
||||
expect(screen.getByText('layout_content')).toBeInTheDocument()
|
||||
})
|
||||
const authTokenMock = authentication.authToken as jest.MockedFunction<
|
||||
typeof authentication.authToken
|
||||
>
|
||||
|
||||
afterEach(() => {
|
||||
authentication.authToken.mockClear()
|
||||
authTokenMock.mockClear()
|
||||
})
|
||||
|
||||
test('Layout sidebar component', async () => {
|
||||
authentication.authToken.mockImplementation(() => true)
|
||||
authTokenMock.mockImplementation(() => 'test-token')
|
||||
|
||||
const mockedGraphql = [
|
||||
{
|
||||
|
@ -59,12 +52,12 @@ test('Layout sidebar component', async () => {
|
|||
render(
|
||||
<MockedProvider mocks={mockedGraphql} addTypename={false}>
|
||||
<MemoryRouter>
|
||||
<SideMenu />
|
||||
<MainMenu />
|
||||
</MemoryRouter>
|
||||
</MockedProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Photos')).toBeInTheDocument()
|
||||
expect(screen.getByText('Timeline')).toBeInTheDocument()
|
||||
expect(screen.getByText('Albums')).toBeInTheDocument()
|
||||
|
||||
expect(await screen.findByText('Settings')).toBeInTheDocument()
|
|
@ -0,0 +1,20 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
// ====================================================
|
||||
// GraphQL query operation: adminQuery
|
||||
// ====================================================
|
||||
|
||||
export interface adminQuery_myUser {
|
||||
__typename: 'User'
|
||||
admin: boolean
|
||||
}
|
||||
|
||||
export interface adminQuery {
|
||||
/**
|
||||
* Information about the currently logged in user
|
||||
*/
|
||||
myUser: adminQuery_myUser
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
// ====================================================
|
||||
// GraphQL query operation: mapboxEnabledQuery
|
||||
// ====================================================
|
||||
|
||||
export interface mapboxEnabledQuery {
|
||||
/**
|
||||
* Get the mapbox api token, returns null if mapbox is not enabled
|
||||
*/
|
||||
mapboxToken: string | null
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
<svg viewBox="0 0 24 24" fill="white">
|
||||
<path
|
||||
d="M6,16 L19,16 C19.5522847,16 20,16.4477153 20,17 L20,21 C20,21.5522847 19.5522847,22 19,22 L6,22 C4.8954305,22 4,21.1045695 4,20 L4,18 C4,16.8954305 4.8954305,16 6,16 Z" fillOpacity="0.75" />
|
||||
<path d="M19,2 C19.5522847,2 20,2.44771525 20,3 L20,19 C20,19.5522847 19.5522847,20 19,20 L6,20 C4.8954305,20 4,19.1045695 4,18 L4,4 C4,2.8954305 4.8954305,2 6,2 L19,2 Z M14.4676845,9 L11.5079767,12.9536523 L9.50029382,10.8745763 L7,14 L18,14 L14.4676845,9 Z M10.75,9 C10.3357864,9 10,9.33578644 10,9.75 C10,10.1642136 10.3357864,10.5 10.75,10.5 C11.1642136,10.5 11.5,10.1642136 11.5,9.75 C11.5,9.33578644 11.1642136,9 10.75,9 Z" />
|
||||
</svg>
|
After Width: | Height: | Size: 686 B |
|
@ -0,0 +1,7 @@
|
|||
<svg viewBox="0 0 24 24" fill="white">
|
||||
<path
|
||||
d="M12,15 C15.1826579,15 18.0180525,16.4868108 19.8494955,18.8037439 L20,19 C20,20.6568542 18.6568542,22 17,22 L7,22 C5.34314575,22 4,20.6568542 4,19 L4.15050454,18.8037439 C5.9819475,16.4868108 8.81734212,15 12,15 Z"
|
||||
fillOpacity="0.75"
|
||||
></path>
|
||||
<circle cx="12" cy="11" r="6"></circle>
|
||||
</svg>
|
After Width: | Height: | Size: 351 B |
|
@ -0,0 +1,4 @@
|
|||
<svg viewBox="0 0 24 24" fill="white">
|
||||
<path d="M15.6971052,10 L23.9367603,21.526562 C23.6878671,22.9323278 22.4600272,24 20.982819,24 L5.851819,24 L15.6971052,10 Z" />
|
||||
<path d="M5.59307375,14 L15.562,24 L2.982819,24 C1.43507633,24 0.161084327,22.8279341 -3.56225466e-14,21.3229592 L5.59307375,14 Z" fillOpacity="0.75"/>
|
||||
</svg>
|
After Width: | Height: | Size: 331 B |
|
@ -0,0 +1,8 @@
|
|||
<svg viewBox="0 0 24 24" fill="white">
|
||||
<path d="M3.4,3.34740684 C3.47896999,3.34740684 3.55617307,3.37078205 3.62188008,3.41458672 L9,7 L9,21 L3.4452998,17.2968665 C3.16710114,17.1114008 3,16.7991694 3,16.4648162 L3,3.74740684 C3,3.52649294 3.1790861,3.34740684 3.4,3.34740684 Z M15,3 L21.4961389,6.71207939 C21.8077139,6.89012225 22,7.22146569 22,7.58032254 L22,20.3107281 C22,20.531642 21.8209139,20.7107281 21.6,20.7107281 C21.5303892,20.7107281 21.4619835,20.692562 21.4015444,20.6580254 L15,17 L15,3 Z" />
|
||||
<polygon
|
||||
fillOpacity="0.75"
|
||||
transform="translate(12, 12) scale(1, -1) translate(-12, -12)"
|
||||
points="9 3 15 7 15 21 9 17"
|
||||
/>
|
||||
</svg>
|
After Width: | Height: | Size: 658 B |
|
@ -0,0 +1,7 @@
|
|||
<svg viewBox="0 0 24 24" fill="white">
|
||||
<path
|
||||
d="M13.4691754,16.7806702 L13,21 L11,21 L10.5318353,16.7809803 C10.9960818,16.9233714 11.4890921,17 12,17 C12.5112786,17 13.0046337,16.9232601 13.4691754,16.7806702 Z M16.4192861,14.3409153 L19.0710678,17.6568542 L17.6568542,19.0710678 L14.3409153,16.4192861 C15.2243208,15.9503691 15.9503691,15.2243208 16.4192861,14.3409153 Z M9.65908474,16.4192861 L6.34314575,19.0710678 L4.92893219,17.6568542 L7.5807139,14.3409153 C8.04963086,15.2243208 8.77567918,15.9503691 9.65908474,16.4192861 Z M7,12 C7,12.5112786 7.07673988,13.0046337 7.21932976,13.4691754 L3,13 L3,11 L7.21901966,10.5318353 C7.07662862,10.9960818 7,11.4890921 7,12 Z M16.7809803,10.5318353 L21,11 L21,13 L16.7806702,13.4691754 C16.9232601,13.0046337 17,12.5112786 17,12 C17,11.4890921 16.9233714,10.9960818 16.7809803,10.5318353 Z M6.34314575,4.92893219 L9.65908474,7.5807139 C8.77567918,8.04963086 8.04963086,8.77567918 7.5807139,9.65908474 L4.92893219,6.34314575 L6.34314575,4.92893219 Z M17.6568542,4.92893219 L19.0710678,6.34314575 L16.4192861,9.65908474 C15.9503691,8.77567918 15.2243208,8.04963086 14.3409153,7.5807139 L17.6568542,4.92893219 Z M13,3 L13.4691754,7.21932976 C13.0046337,7.07673988 12.5112786,7 12,7 C11.4890921,7 10.9960818,7.07662862 10.5318353,7.21901966 L11,3 L13,3 Z"
|
||||
fillOpacity="0.76"
|
||||
/>
|
||||
<path d="M12,5 C15.8659932,5 19,8.13400675 19,12 C19,15.8659932 15.8659932,19 12,19 C8.13400675,19 5,15.8659932 5,12 C5,8.13400675 8.13400675,5 12,5 Z M12,8 C9.790861,8 8,9.790861 8,12 C8,14.209139 9.790861,16 12,16 C14.209139,16 16,14.209139 16,12 C16,9.790861 14.209139,8 12,8 Z" />
|
||||
</svg>
|
After Width: | Height: | Size: 1.6 KiB |
|
@ -0,0 +1,33 @@
|
|||
import React from 'react'
|
||||
import { forwardRef } from 'react'
|
||||
import { ReactComponent as DismissIcon } from './icons/dismissIcon.svg'
|
||||
|
||||
export type MessageProps = {
|
||||
header: string
|
||||
content?: string
|
||||
children?: React.ReactNode
|
||||
onDismiss?(): void
|
||||
}
|
||||
|
||||
const Message = forwardRef(
|
||||
(
|
||||
{ onDismiss, header, children, content }: MessageProps,
|
||||
ref: React.ForwardedRef<HTMLDivElement>
|
||||
) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="bg-white shadow-md border rounded p-2 h-[84px] relative"
|
||||
>
|
||||
<button onClick={onDismiss} className="absolute top-3 right-2">
|
||||
<DismissIcon className="w-[10px] h-[10px] text-gray-700" />
|
||||
</button>
|
||||
<h1 className="font-semibold text-sm">{header}</h1>
|
||||
<div className="text-sm">{content}</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export default Message
|
|
@ -1,36 +0,0 @@
|
|||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import styled from 'styled-components'
|
||||
import { Message, Progress } from 'semantic-ui-react'
|
||||
|
||||
const StyledProgress = styled(Progress)`
|
||||
position: absolute !important;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
const MessageProgress = ({ header, content, percent = 0, ...props }) => {
|
||||
return (
|
||||
<Message floating {...props}>
|
||||
<Message.Content>
|
||||
<Message.Header>{header}</Message.Header>
|
||||
{content}
|
||||
<StyledProgress
|
||||
percent={percent}
|
||||
size="tiny"
|
||||
attached="bottom"
|
||||
indicating
|
||||
/>
|
||||
</Message.Content>
|
||||
</Message>
|
||||
)
|
||||
}
|
||||
|
||||
MessageProgress.propTypes = {
|
||||
header: PropTypes.string,
|
||||
content: PropTypes.any,
|
||||
percent: PropTypes.number,
|
||||
}
|
||||
|
||||
export default MessageProgress
|
|
@ -0,0 +1,30 @@
|
|||
import React, { forwardRef } from 'react'
|
||||
import MessagePlain, { MessageProps } from './Message'
|
||||
|
||||
type MessageProgressProps = MessageProps & {
|
||||
percent?: number
|
||||
}
|
||||
|
||||
const MessageProgress = forwardRef(
|
||||
(
|
||||
{ header, content, percent = 0, ...props }: MessageProgressProps,
|
||||
ref: React.ForwardedRef<HTMLDivElement>
|
||||
) => {
|
||||
let color = '#dc2625'
|
||||
if (percent > 33) color = '#fbbf24'
|
||||
if (percent > 66) color = '#56e263'
|
||||
|
||||
return (
|
||||
<MessagePlain header={header} content={content} {...props} ref={ref}>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-[3px] rounded-b overflow-hidden">
|
||||
<div
|
||||
className="h-full transition-all duration-200"
|
||||
style={{ width: `${percent}%`, backgroundColor: color }}
|
||||
></div>
|
||||
</div>
|
||||
</MessagePlain>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export default MessageProgress
|
|
@ -1,10 +1,11 @@
|
|||
import React, { useState } from 'react'
|
||||
import { animated, useTransition } from 'react-spring'
|
||||
import { Message } from 'semantic-ui-react'
|
||||
import styled from 'styled-components'
|
||||
import { authToken } from '../../helpers/authentication'
|
||||
import MessageProgress from './MessageProgress'
|
||||
import SubscriptionsHook from './SubscriptionsHook'
|
||||
import MessagePlain from './Message'
|
||||
import SubscriptionsHook, { Message } from './SubscriptionsHook'
|
||||
import { NotificationType } from '../../__generated__/globalTypes'
|
||||
|
||||
const Container = styled.div`
|
||||
position: fixed;
|
||||
|
@ -17,7 +18,14 @@ const Container = styled.div`
|
|||
}
|
||||
`
|
||||
|
||||
export const MessageState = {
|
||||
type MessageStateType = {
|
||||
set: React.Dispatch<React.SetStateAction<Message[]>>
|
||||
get: Message[]
|
||||
add(message: Message): void
|
||||
removeKey(key: string): void
|
||||
}
|
||||
|
||||
export const MessageState: MessageStateType = {
|
||||
set: fn => {
|
||||
console.warn('set function is not defined yet, called with', fn)
|
||||
},
|
||||
|
@ -39,37 +47,30 @@ export const MessageState = {
|
|||
}
|
||||
|
||||
const Messages = () => {
|
||||
const [messages, setMessages] = useState([])
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
MessageState.set = setMessages
|
||||
MessageState.get = messages
|
||||
|
||||
const [refMap] = useState(() => new WeakMap())
|
||||
|
||||
const getMessageElement = (message, ref) => {
|
||||
const dismissMessage = message => {
|
||||
const getMessageElement = (message: Message): React.FunctionComponent => {
|
||||
const dismissMessage = (message: Message) => {
|
||||
message.onDismiss && message.onDismiss()
|
||||
setMessages(messages => messages.filter(msg => msg.key != message.key))
|
||||
}
|
||||
|
||||
const RefDiv = props => <div {...props} ref={x => x && ref(x)} />
|
||||
|
||||
switch (message.type.toLowerCase()) {
|
||||
case 'message':
|
||||
switch (message.type) {
|
||||
case NotificationType.Message:
|
||||
return props => (
|
||||
<Message
|
||||
as={RefDiv}
|
||||
<MessagePlain
|
||||
onDismiss={() => {
|
||||
dismissMessage(message)
|
||||
}}
|
||||
floating
|
||||
{...message.props}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
case 'progress':
|
||||
case NotificationType.Progress:
|
||||
return props => (
|
||||
<MessageProgress
|
||||
as={RefDiv}
|
||||
onDismiss={() => {
|
||||
dismissMessage(message)
|
||||
}}
|
||||
|
@ -77,36 +78,19 @@ const Messages = () => {
|
|||
{...props}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
throw new Error(`Invalid message type: ${message.type}`)
|
||||
}
|
||||
}
|
||||
|
||||
let refHooks = new Map()
|
||||
messages.forEach(message => {
|
||||
let resolveFunc = null
|
||||
|
||||
const waitPromise = new Promise(resolve => {
|
||||
resolveFunc = resolve
|
||||
})
|
||||
|
||||
refHooks.set(message.key, {
|
||||
done: resolveFunc,
|
||||
promise: waitPromise,
|
||||
})
|
||||
})
|
||||
|
||||
const transitions = useTransition(messages.slice().reverse(), x => x.key, {
|
||||
from: {
|
||||
opacity: 0,
|
||||
height: '0px',
|
||||
},
|
||||
enter: item => async next => {
|
||||
const refPromise = refHooks.get(item.key).promise
|
||||
await refPromise
|
||||
|
||||
await next({
|
||||
enter: {
|
||||
opacity: 1,
|
||||
height: `${refMap.get(item).offsetHeight + 10}px`,
|
||||
})
|
||||
height: `100px`,
|
||||
},
|
||||
leave: { opacity: 0, height: '0px' },
|
||||
})
|
||||
|
@ -114,15 +98,7 @@ const Messages = () => {
|
|||
return (
|
||||
<Container>
|
||||
{transitions.map(({ item, props: style, key }) => {
|
||||
const getRef = ref => {
|
||||
refMap.set(item, ref)
|
||||
if (refHooks.has(item.key)) {
|
||||
refHooks.get(item.key).done()
|
||||
}
|
||||
}
|
||||
const MessageElement = getMessageElement(item, getRef)
|
||||
|
||||
style.padding = 0
|
||||
const MessageElement = getMessageElement(item)
|
||||
|
||||
return (
|
||||
<animated.div key={key} style={style}>
|
|
@ -3,7 +3,7 @@ import PropTypes from 'prop-types'
|
|||
import { useEffect } from 'react'
|
||||
import { useSubscription, gql } from '@apollo/client'
|
||||
import { authToken } from '../../helpers/authentication'
|
||||
import { NotificationType } from '../../../__generated__/globalTypes'
|
||||
import { NotificationType } from '../../__generated__/globalTypes'
|
||||
|
||||
const NOTIFICATION_SUBSCRIPTION = gql`
|
||||
subscription notificationSubscription {
|
||||
|
@ -26,6 +26,7 @@ export interface Message {
|
|||
key: string
|
||||
type: NotificationType
|
||||
timeout?: number
|
||||
onDismiss?: () => void
|
||||
props: {
|
||||
header: string
|
||||
content: string
|
||||
|
|
|
@ -3,27 +3,27 @@
|
|||
// @generated
|
||||
// This file was automatically generated and should not be edited.
|
||||
|
||||
import { NotificationType } from "./../../../../__generated__/globalTypes";
|
||||
import { NotificationType } from './../../../__generated__/globalTypes'
|
||||
|
||||
// ====================================================
|
||||
// GraphQL subscription operation: notificationSubscription
|
||||
// ====================================================
|
||||
|
||||
export interface notificationSubscription_notification {
|
||||
__typename: "Notification";
|
||||
key: string;
|
||||
type: NotificationType;
|
||||
header: string;
|
||||
content: string;
|
||||
progress: number | null;
|
||||
positive: boolean;
|
||||
negative: boolean;
|
||||
__typename: 'Notification'
|
||||
key: string
|
||||
type: NotificationType
|
||||
header: string
|
||||
content: string
|
||||
progress: number | null
|
||||
positive: boolean
|
||||
negative: boolean
|
||||
/**
|
||||
* Time in milliseconds before the notification will close
|
||||
*/
|
||||
timeout: number | null;
|
||||
timeout: number | null
|
||||
}
|
||||
|
||||
export interface notificationSubscription {
|
||||
notification: notificationSubscription_notification;
|
||||
notification: notificationSubscription_notification
|
||||
}
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
<svg viewBox="0 0 20 20"><g stroke="currentColor" stroke-width="3.5" fill="none" fill-rule="evenodd"><path d="m1.515 1.515 16.97 16.97M18.485 1.515l-16.97 16.97"/></g></svg>
|
After Width: | Height: | Size: 173 B |
|
@ -1,8 +1,8 @@
|
|||
import React, { useCallback, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { Icon } from 'semantic-ui-react'
|
||||
import { ProtectedImage } from './ProtectedMedia'
|
||||
import { MediaType } from '../../../__generated__/globalTypes'
|
||||
import { MediaType } from '../../__generated__/globalTypes'
|
||||
import { ReactComponent as VideoThumbnailIconSVG } from './icons/videoThumbnailIcon.svg'
|
||||
|
||||
const MediaContainer = styled.div`
|
||||
flex-grow: 1;
|
||||
|
@ -54,47 +54,92 @@ const PhotoOverlay = styled.div<{ active: boolean }>`
|
|||
`}
|
||||
`
|
||||
|
||||
const HoverIcon = styled(Icon)`
|
||||
font-size: 1.5em !important;
|
||||
margin: 160px 10px 0 10px !important;
|
||||
color: white !important;
|
||||
const HoverIcon = styled.button`
|
||||
font-size: 1.5em;
|
||||
margin: 160px 10px 0 10px;
|
||||
color: white;
|
||||
text-shadow: 0 0 4px black;
|
||||
opacity: 0 !important;
|
||||
opacity: 0;
|
||||
position: relative;
|
||||
|
||||
border-radius: 50%;
|
||||
width: 34px !important;
|
||||
height: 34px !important;
|
||||
padding-top: 7px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
|
||||
${MediaContainer}:hover & {
|
||||
${MediaContainer}:hover &, ${MediaContainer}:focus-within & {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.4);
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
transition: opacity 100ms, background-color 100ms;
|
||||
`
|
||||
|
||||
const FavoriteIcon = styled(HoverIcon)`
|
||||
float: right;
|
||||
opacity: ${({ favorite }) => (favorite ? '0.8' : '0.2')} !important;
|
||||
`
|
||||
type FavoriteIconProps = {
|
||||
favorite: boolean
|
||||
onClick(e: React.MouseEvent<HTMLButtonElement, MouseEvent>): void
|
||||
}
|
||||
|
||||
const SidebarIcon = styled(HoverIcon)`
|
||||
const FavoriteIcon = ({ favorite, onClick }: FavoriteIconProps) => {
|
||||
return (
|
||||
<HoverIcon
|
||||
onClick={onClick}
|
||||
style={{ opacity: favorite ? '0.75' : undefined }}
|
||||
>
|
||||
<svg
|
||||
className="text-white m-auto mt-1"
|
||||
width="19px"
|
||||
height="17px"
|
||||
viewBox="0 0 19 17"
|
||||
version="1.1"
|
||||
>
|
||||
<path
|
||||
d="M13.999086,1 C15.0573371,1 16.0710089,1.43342987 16.8190212,2.20112483 C17.5765039,2.97781012 18,4.03198704 18,5.13009709 C18,6.22820714 17.5765039,7.28238406 16.8188574,8.05923734 L16.8188574,8.05923734 L15.8553647,9.04761889 L9.49975689,15.5674041 L3.14414912,9.04761889 L2.18065643,8.05923735 C1.39216493,7.2503776 0.999999992,6.18971057 1,5.13009711 C1.00000001,4.07048366 1.39216496,3.00981663 2.18065647,2.20095689 C2.95931483,1.40218431 3.97927681,1.00049878 5.00042783,1.00049878 C6.02157882,1.00049878 7.04154078,1.4021843 7.82019912,2.20095684 L7.82019912,2.20095684 L9.4997569,3.92390079 L11.1794784,2.20078881 C11.9271631,1.43342987 12.9408349,1 13.999086,1 L13.999086,1 Z"
|
||||
fill={favorite ? 'currentColor' : 'none'}
|
||||
stroke="currentColor"
|
||||
strokeWidth={favorite ? '0' : '2'}
|
||||
></path>
|
||||
</svg>
|
||||
</HoverIcon>
|
||||
)
|
||||
}
|
||||
|
||||
type SidebarIconProps = {
|
||||
onClick(e: React.MouseEvent<HTMLButtonElement, MouseEvent>): void
|
||||
}
|
||||
|
||||
const SidebarIcon = ({ onClick }: SidebarIconProps) => (
|
||||
<SidebarIconWrapper onClick={onClick}>
|
||||
<svg
|
||||
width="20px"
|
||||
height="20px"
|
||||
viewBox="0 0 20 20"
|
||||
version="1.1"
|
||||
className="m-auto"
|
||||
>
|
||||
<path
|
||||
d="M10,0 C15.5228475,0 20,4.4771525 20,10 C20,15.5228475 15.5228475,20 10,20 C4.4771525,20 0,15.5228475 0,10 C0,4.4771525 4.4771525,0 10,0 Z M10,9 C9.44771525,9 9,9.44771525 9,10 L9,10 L9,14 L9.00672773,14.1166211 C9.06449284,14.6139598 9.48716416,15 10,15 C10.5522847,15 11,14.5522847 11,14 L11,14 L11,10 L10.9932723,9.88337887 C10.9355072,9.38604019 10.5128358,9 10,9 Z M10.01,5 L9.88337887,5.00672773 C9.38604019,5.06449284 9,5.48716416 9,6 C9,6.55228475 9.44771525,7 10,7 L10,7 L10.1266211,6.99327227 C10.6239598,6.93550716 11.01,6.51283584 11.01,6 C11.01,5.44771525 10.5622847,5 10.01,5 L10.01,5 Z"
|
||||
fill="#FFFFFF"
|
||||
></path>
|
||||
</svg>
|
||||
</SidebarIconWrapper>
|
||||
)
|
||||
|
||||
const SidebarIconWrapper = styled(HoverIcon)`
|
||||
margin: 10px !important;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
`
|
||||
|
||||
const VideoThumbnailIcon = styled(Icon)`
|
||||
const VideoThumbnailIcon = styled(VideoThumbnailIconSVG)`
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
position: absolute;
|
||||
left: calc(50% - 16px);
|
||||
top: calc(50% - 13px);
|
||||
left: calc(50% - 17.5px);
|
||||
top: calc(50% - 22px);
|
||||
`
|
||||
|
||||
type MediaThumbnailProps = {
|
||||
|
@ -125,10 +170,9 @@ export const MediaThumbnail = ({
|
|||
if (media.favorite !== undefined) {
|
||||
heartIcon = (
|
||||
<FavoriteIcon
|
||||
favorite={media.favorite.toString()}
|
||||
name={media.favorite ? 'heart' : 'heart outline'}
|
||||
onClick={(event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
favorite={media.favorite}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
clickFavorite()
|
||||
}}
|
||||
/>
|
||||
|
@ -137,7 +181,7 @@ export const MediaThumbnail = ({
|
|||
|
||||
let videoIcon = null
|
||||
if (media.type == MediaType.Video) {
|
||||
videoIcon = <VideoThumbnailIcon name="play" size="big" />
|
||||
videoIcon = <VideoThumbnailIcon />
|
||||
}
|
||||
|
||||
let minWidth = 100
|
||||
|
@ -160,6 +204,7 @@ export const MediaThumbnail = ({
|
|||
>
|
||||
<div
|
||||
style={{
|
||||
// minWidth: `min(${minWidth}px, 100%)`,
|
||||
minWidth: `${minWidth}px`,
|
||||
height: `200px`,
|
||||
}}
|
||||
|
@ -169,8 +214,7 @@ export const MediaThumbnail = ({
|
|||
<PhotoOverlay active={active}>
|
||||
{videoIcon}
|
||||
<SidebarIcon
|
||||
name="info"
|
||||
onClick={(e: MouseEvent) => {
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
selectImage()
|
||||
}}
|
||||
|
@ -181,7 +225,7 @@ export const MediaThumbnail = ({
|
|||
)
|
||||
}
|
||||
|
||||
export const PhotoThumbnail = styled.div`
|
||||
export const MediaPlaceholder = styled.div`
|
||||
flex-grow: 1;
|
||||
height: 200px;
|
||||
width: 300px;
|
||||
|
|
|
@ -2,7 +2,7 @@ import '@testing-library/jest-dom'
|
|||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
import React from 'react'
|
||||
import { MediaType } from '../../../__generated__/globalTypes'
|
||||
import { MediaType } from '../../__generated__/globalTypes'
|
||||
import PhotoGallery from './PhotoGallery'
|
||||
import { PhotoGalleryState } from './photoGalleryReducer'
|
||||
|
||||
|
@ -22,8 +22,7 @@ test('photo gallery with media', () => {
|
|||
id: '165',
|
||||
type: MediaType.Photo,
|
||||
thumbnail: {
|
||||
url:
|
||||
'http://localhost:4001/photo/thumbnail_3666760020_jpg_x76GG5pS.jpg',
|
||||
url: 'http://localhost:4001/photo/thumbnail_3666760020_jpg_x76GG5pS.jpg',
|
||||
width: 768,
|
||||
height: 1024,
|
||||
__typename: 'MediaURL',
|
||||
|
@ -97,8 +96,7 @@ describe('photo gallery presenting', () => {
|
|||
id: '165',
|
||||
type: MediaType.Photo,
|
||||
thumbnail: {
|
||||
url:
|
||||
'http://localhost:4001/photo/thumbnail_3666760020_jpg_x76GG5pS.jpg',
|
||||
url: 'http://localhost:4001/photo/thumbnail_3666760020_jpg_x76GG5pS.jpg',
|
||||
width: 768,
|
||||
height: 1024,
|
||||
__typename: 'MediaURL',
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
import React, { useContext, useEffect } from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { Loader } from 'semantic-ui-react'
|
||||
import { MediaThumbnail, PhotoThumbnail } from './MediaThumbnail'
|
||||
import { MediaThumbnail, MediaPlaceholder } from './MediaThumbnail'
|
||||
import PresentView from './presentView/PresentView'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PresentMediaProps_Media } from './presentView/PresentMedia'
|
||||
import { sidebarPhoto_media_thumbnail } from '../sidebar/__generated__/sidebarPhoto'
|
||||
import {
|
||||
|
@ -37,10 +35,6 @@ const PhotoFiller = styled.div`
|
|||
flex-grow: 999999;
|
||||
`
|
||||
|
||||
const ClearWrap = styled.div`
|
||||
clear: both;
|
||||
`
|
||||
|
||||
export interface PhotoGalleryProps_Media extends PresentMediaProps_Media {
|
||||
thumbnail: sidebarPhoto_media_thumbnail | null
|
||||
favorite?: boolean
|
||||
|
@ -52,13 +46,7 @@ type PhotoGalleryProps = {
|
|||
dispatchMedia: React.Dispatch<PhotoGalleryAction>
|
||||
}
|
||||
|
||||
const PhotoGallery = ({
|
||||
mediaState,
|
||||
loading,
|
||||
dispatchMedia,
|
||||
}: PhotoGalleryProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const PhotoGallery = ({ mediaState, dispatchMedia }: PhotoGalleryProps) => {
|
||||
const [markFavorite] = useMarkFavoriteMutation()
|
||||
|
||||
const { media, activeIndex, presenting } = mediaState
|
||||
|
@ -94,11 +82,6 @@ const PhotoGallery = ({
|
|||
toggleFavoriteAction({
|
||||
media,
|
||||
markFavorite,
|
||||
}).then(() => {
|
||||
dispatchMedia({
|
||||
type: 'selectImage',
|
||||
index,
|
||||
})
|
||||
})
|
||||
}}
|
||||
clickPresent={() => {
|
||||
|
@ -109,16 +92,13 @@ const PhotoGallery = ({
|
|||
})
|
||||
} else {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
photoElements.push(<PhotoThumbnail key={i} />)
|
||||
photoElements.push(<MediaPlaceholder key={i} />)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ClearWrap>
|
||||
<>
|
||||
<Gallery data-testid="photo-gallery-wrapper">
|
||||
<Loader active={loading}>
|
||||
{t('general.loading.media', 'Loading media')}
|
||||
</Loader>
|
||||
{photoElements}
|
||||
<PhotoFiller />
|
||||
</Gallery>
|
||||
|
@ -128,7 +108,7 @@ const PhotoGallery = ({
|
|||
dispatchMedia={dispatchMedia}
|
||||
/>
|
||||
)}
|
||||
</ClearWrap>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="35px" height="44px" viewBox="0 0 35 44" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<path d="M3.07677855,0.965719632 L33.3619943,20.3146075 C34.2928109,20.9092959 34.5652967,22.145962 33.9706083,23.0767786 C33.814312,23.3214163 33.606632,23.5290962 33.3619943,23.6853925 L3.07677855,43.0342804 C2.14596197,43.6289687 0.909295855,43.356483 0.31460748,42.4256664 C0.10916386,42.1041025 4.6731219e-17,41.7304772 0,41.3488878 L0,2.65111215 C-1.3527075e-16,1.54654265 0.8954305,0.651112151 2,0.651112151 C2.38158936,0.651112151 2.75521463,0.760276012 3.07677855,0.965719632 Z" fill="currentColor"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 708 B |
|
@ -1,5 +1,5 @@
|
|||
import { photoGalleryReducer, PhotoGalleryState } from './photoGalleryReducer'
|
||||
import { MediaType } from '../../../__generated__/globalTypes'
|
||||
import { MediaType } from '../../__generated__/globalTypes'
|
||||
|
||||
describe('photo gallery reducer', () => {
|
||||
const defaultState: PhotoGalleryState = {
|
||||
|
|
|
@ -2,7 +2,7 @@ import '@testing-library/jest-dom'
|
|||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
import React from 'react'
|
||||
import { MediaType } from '../../../../__generated__/globalTypes'
|
||||
import { MediaType } from '../../../__generated__/globalTypes'
|
||||
import PresentMedia, { PresentMediaProps_Media } from './PresentMedia'
|
||||
|
||||
test('render present image', () => {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { MediaType } from '../../../../__generated__/globalTypes'
|
||||
import { MediaType } from '../../../__generated__/globalTypes'
|
||||
import { exhaustiveCheck } from '../../../helpers/utils'
|
||||
import {
|
||||
ProtectedImage,
|
||||
|
|
|
@ -5,7 +5,7 @@ import React from 'react'
|
|||
import PresentNavigationOverlay from './PresentNavigationOverlay'
|
||||
import { fireEvent, render, screen, act } from '@testing-library/react'
|
||||
|
||||
jest.useFakeTimers()
|
||||
jest.useFakeTimers('modern')
|
||||
|
||||
describe('PresentNavigationOverlay component', () => {
|
||||
test('simple render', () => {
|
||||
|
@ -44,6 +44,7 @@ describe('PresentNavigationOverlay component', () => {
|
|||
act(() => {
|
||||
jest.advanceTimersByTime(3000)
|
||||
})
|
||||
|
||||
expect(screen.getByLabelText('Next image')).toHaveClass('hide')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -3,7 +3,7 @@ import PropTypes, { ReactComponentLike } from 'prop-types'
|
|||
import { Route, Redirect, RouteProps } from 'react-router-dom'
|
||||
import { useLazyQuery } from '@apollo/client'
|
||||
import { authToken } from '../../helpers/authentication'
|
||||
import { ADMIN_QUERY } from '../../Layout'
|
||||
import { ADMIN_QUERY } from '../layout/Layout'
|
||||
|
||||
export const useIsAdmin = (enabled = true) => {
|
||||
const [fetchAdminQuery, { data, called }] = useLazyQuery(ADMIN_QUERY)
|
||||
|
|
|
@ -10,14 +10,14 @@ import {
|
|||
} from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
|
||||
require('../../localization').setupLocalization()
|
||||
|
||||
describe('routes', () => {
|
||||
test('unauthorized root path should navigate to login page', async () => {
|
||||
jest.mock('../../Pages/LoginPage/LoginPage.tsx', () => () => (
|
||||
<div>mocked login page</div>
|
||||
))
|
||||
|
||||
require('../../localization').setupLocalization()
|
||||
|
||||
describe('routes', () => {
|
||||
test('unauthorized root path should navigate to login page', async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<Routes />
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import React from 'react'
|
||||
import { Route, Switch, Redirect } from 'react-router-dom'
|
||||
|
||||
import { Loader } from 'semantic-ui-react'
|
||||
import Layout from '../../Layout'
|
||||
import Layout from '../layout/Layout'
|
||||
import { clearTokenCookie } from '../../helpers/authentication'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loader from '../../primitives/Loader'
|
||||
|
||||
const AuthorizedRoute = React.lazy(() => import('./AuthorizedRoute'))
|
||||
|
||||
|
@ -33,7 +33,7 @@ const Routes = () => {
|
|||
<React.Suspense
|
||||
fallback={
|
||||
<Layout title={t('general.loading.page', 'Loading page')}>
|
||||
<Loader active>{t('general.loading.page', 'Loading page')}</Loader>
|
||||
<Loader message={t('general.loading.page', 'Loading page')} active />
|
||||
</Layout>
|
||||
}
|
||||
>
|
||||
|
|
|
@ -2,6 +2,11 @@ import React from 'react'
|
|||
import { useQuery, gql } from '@apollo/client'
|
||||
import { SidebarAlbumShare } from './Sharing'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import SidebarHeader from './SidebarHeader'
|
||||
import {
|
||||
getAlbumSidebar,
|
||||
getAlbumSidebarVariables,
|
||||
} from './__generated__/getAlbumSidebar'
|
||||
|
||||
const albumQuery = gql`
|
||||
query getAlbumSidebar($id: ID!) {
|
||||
|
@ -18,7 +23,10 @@ type AlbumSidebarProps = {
|
|||
|
||||
const AlbumSidebar = ({ albumId }: AlbumSidebarProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { loading, error, data } = useQuery(albumQuery, {
|
||||
const { loading, error, data } = useQuery<
|
||||
getAlbumSidebar,
|
||||
getAlbumSidebarVariables
|
||||
>(albumQuery, {
|
||||
variables: { id: albumId },
|
||||
})
|
||||
|
||||
|
@ -27,10 +35,16 @@ const AlbumSidebar = ({ albumId }: AlbumSidebarProps) => {
|
|||
|
||||
return (
|
||||
<div>
|
||||
<p>{t('sidebar.album.title', 'Album options')}</p>
|
||||
<div>
|
||||
<h1>{data.album.title}</h1>
|
||||
<SidebarAlbumShare id={data.album.id} />
|
||||
{/* <p>{t('sidebar.album.title', 'Album options')}</p> */}
|
||||
<SidebarHeader
|
||||
title={
|
||||
data?.album.title ??
|
||||
t('sidebar.album.title_placeholder', 'Album title')
|
||||
}
|
||||
/>
|
||||
<div className="mt-8">
|
||||
{/* <h1 className="text-3xl font-semibold">{data.album.title}</h1> */}
|
||||
<SidebarAlbumShare id={albumId} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|