1
Fork 0

Merge pull request #382 from photoview/redesign

Redesign UI and remove SemanticUI dependency
This commit is contained in:
Viktor Strate Kløvedal 2021-07-18 16:33:56 +02:00 committed by GitHub
commit 0587a7b06f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
149 changed files with 28776 additions and 14004 deletions

View File

@ -9,7 +9,7 @@ photos_path
screenshots
ui/node_modules/
ui/dist/
ui/build/
ui/.cache/
api/photo_cache

View File

@ -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

View File

@ -55,6 +55,11 @@ module.exports = {
'jest/valid-title': 'off',
}
),
settings: {
jest: {
version: 26,
},
},
}),
{
files: ['**/*.js'],

View File

@ -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,
}
}

View File

@ -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()
}

7
ui/craco.config.js Normal file
View File

@ -0,0 +1,7 @@
module.exports = {
style: {
postcss: {
plugins: [require('tailwindcss'), require('autoprefixer')],
},
},
}

View File

@ -1 +1 @@
PHOTOVIEW_API_ENDPOINT=http://localhost:4001/
REACT_APP_API_ENDPOINT=http://localhost:4001/

36482
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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"
]
}
}

43
ui/public/index.html Normal file
View File

@ -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>

View File

@ -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
}

View File

@ -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 />
</>

View File

@ -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

View File

@ -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,15 +96,13 @@ function AlbumPage({ match }: AlbumPageProps) {
},
})
const {
containerElem,
finished: finishedLoadingMore,
} = useScrollPagination<albumQuery>({
loading,
fetchMore,
data,
getItems: data => data.album.media,
})
const { containerElem, finished: finishedLoadingMore } =
useScrollPagination<albumQuery>({
loading,
fetchMore,
data,
getItems: data => data.album.media,
})
const toggleFavorites = useCallback(
onlyFavorites => {

View File

@ -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
}

View File

@ -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>
)
}

View File

@ -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,41 +56,26 @@ const InitialSetupPage = () => {
<Redirect to="/" />
)
const [
authorize,
{ loading: authorizeLoading, data: authorizationData },
] = useMutation(initialSetupMutation, {
onCompleted: data => {
const { success, token } = data.initialSetupWizard
const [authorize, { loading: authorizeLoading, data: authorizationData }] =
useMutation(initialSetupMutation, {
onCompleted: data => {
const { success, token } = data.initialSetupWizard
if (success) {
login(token)
}
},
})
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()
authorize({
variables: {
username: state.username,
password: state.password,
rootPath: state.rootPath,
if (success) {
login(token)
}
},
})
}
const signIn = handleSubmit(data => {
authorize({
variables: {
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
}
>
<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(
'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')}
/>
</Form.Field>
<Message error content={errorMessage} />
<Button type="submit">
</h1>
<form onSubmit={signIn} className="max-w-[500px] mx-auto">
<TextField
wrapperClassName="my-4"
fullWidth
{...register('username', { required: true })}
label={t('login_page.field.username', 'Username')}
error={
formErrors.username?.type == 'required'
? 'Please enter a username'
: undefined
}
/>
<TextField
wrapperClassName="my-4"
fullWidth
{...register('password', { required: true })}
label={t('login_page.field.password', 'Password')}
error={
formErrors.password?.type == 'required'
? 'Please enter a password'
: undefined
}
/>
<TextField
wrapperClassName="my-4"
fullWidth
{...register('rootPath', { required: true })}
label={t(
'login_page.initial_setup.field.photo_path.label',
'Photo path'
)}
placeholder={t(
'login_page.initial_setup.field.photo_path.placeholder',
'/path/to/photos'
)}
error={
formErrors.password?.type == 'required'
? 'Please enter a photo path'
: undefined
}
/>
<MessageBox
type="negative"
message={errorMessage}
show={errorMessage}
/>
<Submit className="mt-2" disabled={authorizeLoading}>
{t('login_page.initial_setup.field.submit', 'Setup Photoview')}
</Button>
</Form>
</Submit>
</form>
</Container>
</div>
)

View File

@ -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>
{initialSetupData?.siteInfo?.initialSetup && (
<Redirect to="/initialSetup" />
)}
<div>
<LogoHeader />
<LoginForm />
</div>
</>
)
}

View File

@ -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' })``

View File

@ -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'

View File

@ -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,46 +83,42 @@ 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(
'people_page.modal.detach_image_faces.description',
'Detach selected images of this face group and move them to a new face groups'
)}
</p>
<SelectImageFacesTable
imageFaces={imageFaces}
selectedImageFaces={selectedImageFaces}
setSelectedImageFaces={setSelectedImageFaces}
title={t(
'people_page.modal.detach_image_faces.action.select_images',
'Select images to detach'
)}
/>
</Modal.Description>
</Modal.Content>
<Modal.Actions>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button
disabled={selectedImageFaces.length == 0}
content={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'
)}
actions={[
{
key: 'cancel',
label: 'Cancel',
onClick: () => setOpen(false),
},
{
key: 'detach',
label: t(
'people_page.modal.detach_image_faces.action.detach',
'Detach image faces'
)}
labelPosition="right"
icon="checkmark"
onClick={() => detachImageFaces()}
positive
/>
</Modal.Actions>
),
variant: 'positive',
onClick: () => detachImageFaces(),
},
]}
onClose={() => setOpen(false)}
open={open}
>
<SelectImageFacesTable
imageFaces={imageFaces}
selectedImageFaces={selectedImageFaces}
setSelectedImageFaces={setSelectedImageFaces}
title={t(
'people_page.modal.detach_image_faces.action.select_images',
'Select images to detach'
)}
/>
</Modal>
)
}

View File

@ -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}
</>
)

View File

@ -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,45 +78,39 @@ const MergeFaceGroupsModal = ({
return (
<Modal
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.'
)}
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)}
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(
'people_page.modal.merge_face_groups.description',
'All images within this face group will be merged into the selected face group.'
)}
</p>
<SelectFaceGroupTable
title={t(
'people_page.modal.merge_face_groups.destination_table.title',
'Select the destination face'
)}
faceGroups={filteredFaceGroups}
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>
<SelectFaceGroupTable
title={t(
'people_page.modal.merge_face_groups.destination_table.title',
'Select the destination face'
)}
faceGroups={filteredFaceGroups}
selectedFaceGroup={selectedFaceGroup}
setSelectedFaceGroup={setSelectedFaceGroup}
/>
</Modal>
)
}

View File

@ -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(
'people_page.modal.move_image_faces.image_select_table.next_action',
'Next'
)}
labelPosition="right"
icon="arrow right"
onClick={() => setImagesSelected(true)}
positive
/>
)
positiveButton = {
key: 'next',
label: t(
'people_page.modal.move_image_faces.image_select_table.next_action',
'Next'
),
onClick: () => setImagesSelected(true),
variant: 'positive',
}
} else {
positiveButton = (
<Button
disabled={!selectedFaceGroup}
content={t(
'people_page.modal.move_image_faces.destination_face_group_table.move_action',
'Move image faces'
)}
labelPosition="right"
icon="checkmark"
onClick={() => moveImageFaces()}
positive
/>
)
positiveButton = {
key: 'move',
label: t(
'people_page.modal.move_image_faces.destination_face_group_table.move_action',
'Move image faces'
),
onClick: () => moveImageFaces(),
variant: 'positive',
}
}
return (
<Modal
title={t('people_page.modal.move_image_faces.title', 'Move Image Faces')}
description={t(
'people_page.modal.move_image_faces.description',
'Move selected images of this face group to another face group'
)}
onClose={() => setOpen(false)}
onOpen={() => setOpen(true)}
open={open}
actions={[
{
key: 'cancel',
label: t('general.action.cancel', 'Cancel'),
onClick: () => setOpen(false),
},
positiveButton,
]}
>
<Modal.Header>
{t('people_page.modal.move_image_faces.title', 'Move Image Faces')}
</Modal.Header>
<Modal.Content scrolling>
<Modal.Description>
<p>
{t(
'people_page.modal.move_image_faces.description',
'Move selected images of this face group to another face group'
)}
</p>
{table}
</Modal.Description>
</Modal.Content>
<Modal.Actions>
<Button onClick={() => setOpen(false)}>
{t('general.action.cancel', 'Cancel')}
</Button>
{positiveButton}
</Modal.Actions>
{table}
</Modal>
)
}

View File

@ -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
value={searchValue}
onChange={e => setSearchValue(e.target.value)}
icon="search"
placeholder={t(
'people_page.table.select_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>
</Table>
<>
<Table className="w-full">
<TableHeader>
<TableRow>
<TableHeaderCell>{title}</TableHeaderCell>
</TableRow>
<TableRow>
<TableHeaderCell>
<TextField
fullWidth
value={searchValue}
onChange={e => setSearchValue(e.target.value)}
placeholder={t(
'people_page.tableselect_face_group.search_faces_placeholder',
'Search faces...'
)}
/>
</TableHeaderCell>
</TableRow>
</TableHeader>
</Table>
<div className="overflow-auto max-h-[500px] mt-2">
<Table className="w-full">
<TableBody>{rows}</TableBody>
</Table>
</div>
</>
)
}

View File

@ -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
value={searchValue}
onChange={e => setSearchValue(e.target.value)}
icon="search"
placeholder={t(
'people_page.table.select_image_faces.search_images_placeholder',
'Search images...'
)}
fluid
/>
</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>
</Table>
<>
<Table className="w-full">
<TableHeader>
<TableRow>
<TableHeaderCell colSpan={2}>{title}</TableHeaderCell>
</TableRow>
<TableRow>
<TableHeaderCell colSpan={2}>
<TextField
value={searchValue}
onChange={e => setSearchValue(e.target.value)}
placeholder={t(
'people_page.tableselect_image_faces.search_images_placeholder',
'Search images...'
)}
fullWidth
/>
</TableHeaderCell>
</TableRow>
</TableHeader>
</Table>
<div className="overflow-auto max-h-[500px] mt-2">
<Table>
<TableBody>{rows}</TableBody>
</Table>
</div>
</>
)
}

View File

@ -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)
})

View File

@ -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

View File

@ -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'

View File

@ -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>

View File

@ -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[]
}

View File

@ -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,13 +128,11 @@ const PeriodicScanner = () => {
},
})
const [
setScanIntervalMutation,
{ loading: scanIntervalMutationLoading },
] = useMutation<
changeScanIntervalMutation,
changeScanIntervalMutationVariables
>(SCAN_INTERVAL_MUTATION)
const [setScanIntervalMutation, { loading: scanIntervalMutationLoading }] =
useMutation<
changeScanIntervalMutation,
changeScanIntervalMutationVariables
>(SCAN_INTERVAL_MUTATION)
const onScanIntervalCheckboxChange = (checked: boolean) => {
setEnablePeriodicScanner(checked)
@ -154,111 +155,85 @@ 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',
'Enable periodic scanner'
)}
disabled={scanIntervalQuery.loading}
checked={enablePeriodicScanner}
onChange={(_, { checked }) =>
onScanIntervalCheckboxChange(checked || false)
}
/>
</div>
<Checkbox
label={t(
'settings.periodic_scanner.checkbox_label',
'Enable periodic scanner'
)}
disabled={scanIntervalQuery.loading}
checked={enablePeriodicScanner}
onChange={event =>
onScanIntervalCheckboxChange(event.target.checked || false)
}
/>
{enablePeriodicScanner && (
<>
<label htmlFor="periodic_scan_field">
<InputLabelTitle>
{t(
'settings.periodic_scanner.field.label',
'Periodic scan interval'
)}
</InputLabelTitle>
<InputLabelDescription>
{t(
'settings.periodic_scanner.field.description',
'How often the scanner should perform automatic scans of all users'
)}
</InputLabelDescription>
</label>
<Input
label={
<Dropdown
onChange={(_, { value }) => {
const newScanInterval: TimeValue = {
...scanInterval,
unit: value as TimeUnit,
}
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 }}
<div className="mt-4">
<label htmlFor="periodic_scan_field">
<h4 className="font-semibold">
{t(
'settings.periodic_scanner.field.label',
'Periodic scan interval'
)}
</h4>
<InputLabelDescription>
{t(
'settings.periodic_scanner.field.description',
'How often the scanner should perform automatic scans of all users'
)}
</InputLabelDescription>
</label>
<div className="flex gap-2">
<TextField
id="periodic_scan_field"
value={scanInterval.value}
onChange={(_, { value }) => {
setScanInterval(x => ({
...x,
value: parseInt(value),
}))
disabled={!enablePeriodicScanner}
/>
<Dropdown
disabled={!enablePeriodicScanner}
items={scanIntervalUnits}
selected={scanInterval.unit}
setSelected={value => {
const newScanInterval: TimeValue = {
...scanInterval,
unit: value as TimeUnit,
}
setScanInterval(newScanInterval)
onScanIntervalUpdate(newScanInterval)
}}
/>
</>
)}
</div>
</div>
<Loader
active={scanIntervalQuery.loading || scanIntervalMutationLoading}
inline
size="small"
style={{ marginLeft: 16 }}
/>

View File

@ -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>
)
}

View File

@ -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 />

View File

@ -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>
)

View File

@ -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>

View File

@ -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>
)
}

View File

@ -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

View File

@ -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,39 +80,33 @@ 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: () => {
setValue('')
addRootPath({
variables: {
id: userID,
rootPath: value,
},
})
},
}}
/>
<Button
variant="positive"
disabled={loading}
onClick={() => {
setValue('')
addRootPath({
variables: {
id: userID,
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>
)
}

View File

@ -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,50 +34,50 @@ const ChangePasswordModal = ({
})
return (
<Modal open={open} {...props}>
<Modal.Header>
{t('settings.users.password_reset.title', 'Change password')}
</Modal.Header>
<Modal.Content>
<p>
<Trans t={t} i18nKey="settings.users.password_reset.description">
Change password for <b>{user.username}</b>
</Trans>
</p>
<Form>
<Form.Field>
<label>
{t('settings.users.password_reset.form.label', 'New password')}
</label>
<Input
placeholder={t(
'settings.users.password_reset.form.placeholder',
'password'
)}
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={() => {
<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>{{ username: user.username }}</b>
</Trans>
}
actions={[
{
key: 'cancel',
label: t('general.action.cancel', 'Cancel'),
onClick: () => onClose && onClose(),
},
{
key: 'change_password',
label: t(
'settings.users.password_reset.form.submit',
'Change password'
),
variant: 'positive',
onClick: () => {
changePassword({
variables: {
userId: user.id,
password: passwordInput,
},
})
}}
>
{t('settings.users.password_reset.form.submit', 'Change password')}
</Button>
</Modal.Actions>
},
},
]}
>
<div className="w-[360px]">
<TextField
label={t('settings.users.password_reset.form.label', 'New password')}
placeholder={t(
'settings.users.password_reset.form.placeholder',
'password'
)}
onChange={e => setPasswordInput(e.target.value)}
type="password"
/>
</div>
</Modal>
)
}

View File

@ -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
}

View File

@ -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,52 +53,59 @@ const UsersTable = () => {
<div>
<SectionTitle>{t('settings.users.title', 'Users')}</SectionTitle>
<Loader active={loading} />
<Table celled>
<Table.Header>
<Table.Row>
<Table.HeaderCell>
{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>
{t('settings.users.table.column_names.action', 'Action')}
</Table.HeaderCell>
</Table.Row>
</Table.Header>
<TableScrollWrapper>
<Table className="w-full max-w-6xl">
<TableHeader>
<TableRow>
<TableHeaderCell>
{t('settings.users.table.column_names.username', 'Username')}
</TableHeaderCell>
<TableHeaderCell>
{t(
'settings.users.table.column_names.photo_path',
'Photo path'
)}
</TableHeaderCell>
<TableHeaderCell>
{t(
'settings.users.table.column_names.capabilities',
'Capabilities'
)}
</TableHeaderCell>
<TableHeaderCell className="w-0 whitespace-nowrap">
{t('settings.users.table.column_names.action', 'Action')}
</TableHeaderCell>
</TableRow>
</TableHeader>
<Table.Body>
{userRows}
<AddUserRow
show={showAddUser}
setShow={setShowAddUser}
onUserAdded={() => {
setShowAddUser(false)
refetch()
}}
/>
</Table.Body>
<TableBody>
{userRows}
<AddUserRow
show={showAddUser}
setShow={setShowAddUser}
onUserAdded={() => {
setShowAddUser(false)
refetch()
}}
/>
</TableBody>
<Table.Footer>
<Table.Row>
<Table.HeaderCell colSpan="4">
<Button
positive
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>
</Table>
<TableFooter>
<TableRow>
<TableHeaderCell colSpan={4} className="text-right">
<Button
variant="positive"
background="white"
disabled={showAddUser}
onClick={() => setShowAddUser(true)}
>
{t('settings.users.table.new_user', 'New user')}
</Button>
</TableHeaderCell>
</TableRow>
</TableFooter>
</Table>
</TableScrollWrapper>
</div>
)
}

View File

@ -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

View File

@ -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) {

View File

@ -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
}

View File

@ -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
}

View File

@ -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'

View File

@ -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>

View File

@ -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

View File

@ -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 })

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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) {
const wrapperClasses =
'inline-block text-center text-gray-900 mx-3 my-2 xs:h-60'
if (album) {
return (
<AlbumBoxLink {...props} to="#">
<AlbumBoxImage />
</AlbumBoxLink>
<Link
to={customLink || `/album/${album.id}`}
className={wrapperClasses}
{...props}
>
<AlbumBoxImage src={album.thumbnail?.thumbnail?.url} />
<p>{album.title}</p>
</Link>
)
}
const thumbnail = album.thumbnail?.thumbnail?.url
return (
<AlbumBoxLink {...props} to={customLink || `/album/${album.id}`}>
<AlbumBoxImage src={thumbnail} />
<p>{album.title}</p>
</AlbumBoxLink>
<div className={wrapperClasses} {...props}>
<AlbumBoxImage />
</div>
)
}

View File

@ -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

View File

@ -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}

View File

@ -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'

View File

@ -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>
{authToken() ? <SearchBar /> : null}
</Container>
)
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}
</div>
)
}
export default Header

View File

@ -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>
<>
<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')}
</ResultTitle>
<>
<ResultTitle>
{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>
)
}

View File

@ -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()
})

View File

@ -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

View File

@ -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()

File diff suppressed because one or more lines are too long

View File

@ -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
}

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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({
opacity: 1,
height: `${refMap.get(item).offsetHeight + 10}px`,
})
enter: {
opacity: 1,
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}>

View File

@ -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

View File

@ -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
}

View File

@ -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

View File

@ -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;

View File

@ -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',

View File

@ -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>
</>
)
}

View File

@ -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

View File

@ -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 = {

View File

@ -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', () => {

View File

@ -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,

View File

@ -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')
})
})

View File

@ -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)

View File

@ -10,14 +10,14 @@ import {
} from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
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 () => {
jest.mock('../../Pages/LoginPage/LoginPage.tsx', () => () => (
<div>mocked login page</div>
))
render(
<MemoryRouter initialEntries={['/']}>
<Routes />

View File

@ -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>
}
>

View File

@ -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>
)

Some files were not shown because too many files have changed in this diff Show More