mirror of
https://git.vectorsigma.ru/public/photoview.git
synced 2026-08-03 21:09:05 +00:00
General work towards new design
This commit is contained in:
@@ -6,6 +6,9 @@
|
|||||||
<!-- <link rel="manifest" href="/manifest.json" />
|
<!-- <link rel="manifest" href="/manifest.json" />
|
||||||
<link rel="stylesheet" href="/index.css" /> -->
|
<link rel="stylesheet" href="/index.css" /> -->
|
||||||
|
|
||||||
|
<link rel="icon" href="/src/favicon.ico" />
|
||||||
|
<link rel="icon" href="/src/favicon.svg" type="image/svg+xml" />
|
||||||
|
|
||||||
<!-- Apple touch devices -->
|
<!-- Apple touch devices -->
|
||||||
<link rel="apple-touch-icon" href="/src/assets/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/src/assets/apple-touch-icon.png" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Photoview" />
|
<meta name="apple-mobile-web-app-title" content="Photoview" />
|
||||||
|
|||||||
326
ui/package-lock.json
generated
326
ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,9 @@
|
|||||||
"@babel/core": "^7.14.0",
|
"@babel/core": "^7.14.0",
|
||||||
"@babel/preset-react": "^7.13.13",
|
"@babel/preset-react": "^7.13.13",
|
||||||
"@babel/preset-typescript": "^7.13.0",
|
"@babel/preset-typescript": "^7.13.0",
|
||||||
|
"@rollup/plugin-babel": "^5.3.0",
|
||||||
|
"@vitejs/plugin-react-refresh": "^1.3.3",
|
||||||
|
"autoprefixer": "^10.2.5",
|
||||||
"babel-eslint": "^10.1.0",
|
"babel-eslint": "^10.1.0",
|
||||||
"babel-jest": "^26.6.3",
|
"babel-jest": "^26.6.3",
|
||||||
"babel-plugin-graphql-tag": "^3.2.0",
|
"babel-plugin-graphql-tag": "^3.2.0",
|
||||||
@@ -31,6 +34,7 @@
|
|||||||
"fs-extra": "^10.0.0",
|
"fs-extra": "^10.0.0",
|
||||||
"i18next": "^20.2.2",
|
"i18next": "^20.2.2",
|
||||||
"mapbox-gl": "^2.2.0",
|
"mapbox-gl": "^2.2.0",
|
||||||
|
"postcss": "^8.3.0",
|
||||||
"prop-types": "^15.7.2",
|
"prop-types": "^15.7.2",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
@@ -44,8 +48,10 @@
|
|||||||
"semantic-ui-react": "^2.0.3",
|
"semantic-ui-react": "^2.0.3",
|
||||||
"styled-components": "^5.3.0",
|
"styled-components": "^5.3.0",
|
||||||
"subscriptions-transport-ws": "^0.9.18",
|
"subscriptions-transport-ws": "^0.9.18",
|
||||||
|
"tailwindcss": "^2.1.2",
|
||||||
"typescript": "^4.2.4",
|
"typescript": "^4.2.4",
|
||||||
"url-join": "^4.0.1"
|
"url-join": "^4.0.1",
|
||||||
|
"vite": "^2.3.3"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "vite",
|
"start": "vite",
|
||||||
@@ -78,17 +84,12 @@
|
|||||||
"@types/url-join": "^4.0.0",
|
"@types/url-join": "^4.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^4.23.0",
|
"@typescript-eslint/eslint-plugin": "^4.23.0",
|
||||||
"@typescript-eslint/parser": "^4.23.0",
|
"@typescript-eslint/parser": "^4.23.0",
|
||||||
"@vitejs/plugin-react-refresh": "^1.3.3",
|
|
||||||
"autoprefixer": "^10.2.5",
|
|
||||||
"eslint-config-prettier": "^8.3.0",
|
"eslint-config-prettier": "^8.3.0",
|
||||||
"husky": "^6.0.0",
|
"husky": "^6.0.0",
|
||||||
"jest": "^26.6.3",
|
"jest": "^26.6.3",
|
||||||
"lint-staged": "^11.0.0",
|
"lint-staged": "^11.0.0",
|
||||||
"postcss": "^8.3.0",
|
|
||||||
"prettier": "^2.3.0",
|
"prettier": "^2.3.0",
|
||||||
"tailwindcss": "^2.1.2",
|
"tsc-files": "^1.1.2"
|
||||||
"tsc-files": "^1.1.2",
|
|
||||||
"vite": "^2.3.3"
|
|
||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"trailingComma": "es5",
|
"trailingComma": "es5",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Layout from '../../components/layout/Layout'
|
|||||||
import { useQuery, gql } from '@apollo/client'
|
import { useQuery, gql } from '@apollo/client'
|
||||||
import LazyLoad from '../../helpers/LazyLoad'
|
import LazyLoad from '../../helpers/LazyLoad'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { getMyAlbums } from './__generated__/getMyAlbums'
|
||||||
|
|
||||||
const getAlbumsQuery = gql`
|
const getAlbumsQuery = gql`
|
||||||
query getMyAlbums {
|
query getMyAlbums {
|
||||||
@@ -20,8 +21,7 @@ const getAlbumsQuery = gql`
|
|||||||
`
|
`
|
||||||
|
|
||||||
const AlbumsPage = () => {
|
const AlbumsPage = () => {
|
||||||
const { t } = useTranslation()
|
const { loading, error, data } = useQuery<getMyAlbums>(getAlbumsQuery)
|
||||||
const { loading, error, data } = useQuery(getAlbumsQuery)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => LazyLoad.disconnect()
|
return () => LazyLoad.disconnect()
|
||||||
@@ -33,14 +33,7 @@ const AlbumsPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout title="Albums">
|
<Layout title="Albums">
|
||||||
<h1>{t('albums_page.title', 'Albums')}</h1>
|
<AlbumBoxes error={error} albums={data?.myAlbums} />
|
||||||
{!loading && (
|
|
||||||
<AlbumBoxes
|
|
||||||
loading={loading}
|
|
||||||
error={error}
|
|
||||||
albums={data && data.myAlbums}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ const LoginForm = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('errors', formErrors)
|
||||||
|
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
data && !data.authorizeUser.success ? data.authorizeUser.status : null
|
data && !data.authorizeUser.success ? data.authorizeUser.status : null
|
||||||
|
|
||||||
@@ -78,14 +80,23 @@ const LoginForm = () => {
|
|||||||
<TextField
|
<TextField
|
||||||
className="w-full"
|
className="w-full"
|
||||||
label={t('login_page.field.username', 'Username')}
|
label={t('login_page.field.username', 'Username')}
|
||||||
{...register('username')}
|
{...register('username', { required: true })}
|
||||||
error={formErrors.username?.message}
|
error={
|
||||||
|
formErrors.username?.type == 'required'
|
||||||
|
? 'Please enter a username'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
className="w-full"
|
className="w-full"
|
||||||
type="password"
|
type="password"
|
||||||
label={t('login_page.field.password', 'Password')}
|
label={t('login_page.field.password', 'Password')}
|
||||||
{...register('password')}
|
{...register('password', { required: true })}
|
||||||
|
error={
|
||||||
|
formErrors.password?.type == 'required'
|
||||||
|
? 'Please enter a password'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -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
|
|
||||||
104
ui/src/components/AlbumFilter.tsx
Normal file
104
ui/src/components/AlbumFilter.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
type FavoriteCheckboxProps = {
|
||||||
|
onlyFavorites: boolean
|
||||||
|
setOnlyFavorites(favorites: boolean): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FavoritesCheckbox = ({
|
||||||
|
onlyFavorites,
|
||||||
|
setOnlyFavorites,
|
||||||
|
}: FavoriteCheckboxProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={onlyFavorites}
|
||||||
|
onChange={e => setOnlyFavorites(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>{t('album_filter.only_favorites', 'Show only favorites')}</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlbumFilterProps = {
|
||||||
|
onlyFavorites: boolean
|
||||||
|
setOnlyFavorites?(favorites: boolean): void
|
||||||
|
ordering?: MediaOrdering
|
||||||
|
setOrdering?: SetOrderingFn
|
||||||
|
}
|
||||||
|
|
||||||
|
const AlbumFilter = ({
|
||||||
|
onlyFavorites,
|
||||||
|
setOnlyFavorites,
|
||||||
|
setOrdering,
|
||||||
|
ordering,
|
||||||
|
}: AlbumFilterProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const changeOrderDirection = () => {
|
||||||
|
if (setOrdering && ordering) {
|
||||||
|
setOrdering({
|
||||||
|
orderDirection:
|
||||||
|
ordering.orderDirection == OrderDirection.ASC
|
||||||
|
? OrderDirection.DESC
|
||||||
|
: OrderDirection.ASC,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeOrderBy = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
if (setOrdering) {
|
||||||
|
setOrdering({ orderBy: e.target.value })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortingOptions = [
|
||||||
|
{
|
||||||
|
value: 'date_shot',
|
||||||
|
text: t('album_filter.sorting_options.date_shot', 'Date shot'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'updated_at',
|
||||||
|
text: t('album_filter.sorting_options.date_imported', 'Date imported'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'title',
|
||||||
|
text: t('album_filter.sorting_options.title', 'Title'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'type',
|
||||||
|
text: t('album_filter.sorting_options.type', 'Kind'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{authToken() && setOnlyFavorites && (
|
||||||
|
<FavoritesCheckbox
|
||||||
|
onlyFavorites={onlyFavorites}
|
||||||
|
setOnlyFavorites={setOnlyFavorites}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span>{t('album_filter.sort_by', 'Sort by')}</span>
|
||||||
|
|
||||||
|
<select onChange={changeOrderBy} value={ordering?.orderBy || undefined}>
|
||||||
|
{sortingOptions.map(x => (
|
||||||
|
<option key={x.value} value={x.value}>
|
||||||
|
{x.text}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button onClick={changeOrderDirection}>{ordering?.orderDirection}</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AlbumFilter
|
||||||
@@ -1,42 +1,24 @@
|
|||||||
import React, { useEffect, useContext } from 'react'
|
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 { Link } from 'react-router-dom'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
import { Icon } from 'semantic-ui-react'
|
|
||||||
import { SidebarContext } from './sidebar/Sidebar'
|
import { SidebarContext } from './sidebar/Sidebar'
|
||||||
import AlbumSidebar from './sidebar/AlbumSidebar'
|
import AlbumSidebar from './sidebar/AlbumSidebar'
|
||||||
import { useLazyQuery, gql } from '@apollo/client'
|
import { useLazyQuery, gql } from '@apollo/client'
|
||||||
import { authToken } from '../helpers/authentication'
|
import { authToken } from '../helpers/authentication'
|
||||||
import { albumPathQuery } from './__generated__/albumPathQuery'
|
import { albumPathQuery } from './__generated__/albumPathQuery'
|
||||||
|
|
||||||
const Header = styled.h1`
|
const BreadcrumbList = styled.ol`
|
||||||
margin: 24px 0 8px 0 !important;
|
& li::after {
|
||||||
|
content: '';
|
||||||
& a {
|
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");
|
||||||
color: black;
|
width: 5px;
|
||||||
|
height: 6px;
|
||||||
&:hover {
|
display: inline-block;
|
||||||
text-decoration: underline;
|
margin: 6px;
|
||||||
}
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
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`
|
const ALBUM_PATH_QUERY = gql`
|
||||||
query albumPathQuery($id: ID!) {
|
query albumPathQuery($id: ID!) {
|
||||||
album(id: $id) {
|
album(id: $id) {
|
||||||
@@ -58,9 +40,8 @@ type AlbumTitleProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AlbumTitle = ({ album, disableLink = false }: AlbumTitleProps) => {
|
const AlbumTitle = ({ album, disableLink = false }: AlbumTitleProps) => {
|
||||||
const [fetchPath, { data: pathData }] = useLazyQuery<albumPathQuery>(
|
const [fetchPath, { data: pathData }] =
|
||||||
ALBUM_PATH_QUERY
|
useLazyQuery<albumPathQuery>(ALBUM_PATH_QUERY)
|
||||||
)
|
|
||||||
const { updateSidebar } = useContext(SidebarContext)
|
const { updateSidebar } = useContext(SidebarContext)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -85,12 +66,9 @@ const AlbumTitle = ({ album, disableLink = false }: AlbumTitleProps) => {
|
|||||||
.slice()
|
.slice()
|
||||||
.reverse()
|
.reverse()
|
||||||
.map(x => (
|
.map(x => (
|
||||||
<span key={x.id}>
|
<li key={x.id} className="inline-block hover:underline">
|
||||||
<Breadcrumb.Section as={Link} to={`/album/${x.id}`}>
|
<Link to={`/album/${x.id}`}>{x.title}</Link>
|
||||||
{x.title}
|
</li>
|
||||||
</Breadcrumb.Section>
|
|
||||||
<Breadcrumb.Divider icon="right angle" />
|
|
||||||
</span>
|
|
||||||
))
|
))
|
||||||
|
|
||||||
if (!disableLink) {
|
if (!disableLink) {
|
||||||
@@ -98,25 +76,24 @@ const AlbumTitle = ({ album, disableLink = false }: AlbumTitleProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex">
|
||||||
<Header>
|
<div>
|
||||||
<Breadcrumb>{breadcrumbSections}</Breadcrumb>
|
<nav aria-label="Album breadcrumb">
|
||||||
{title}
|
<BreadcrumbList className="">{breadcrumbSections}</BreadcrumbList>
|
||||||
{authToken() && (
|
</nav>
|
||||||
<SettingsIcon
|
<h1 className="text-2xl">{title}</h1>
|
||||||
onClick={() => {
|
</div>
|
||||||
updateSidebar(<AlbumSidebar albumId={album.id} />)
|
{authToken() && (
|
||||||
}}
|
<button
|
||||||
/>
|
onClick={() => {
|
||||||
)}
|
updateSidebar(<AlbumSidebar albumId={album.id} />)
|
||||||
</Header>
|
}}
|
||||||
</>
|
>
|
||||||
|
More
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
AlbumTitle.propTypes = {
|
|
||||||
album: PropTypes.object,
|
|
||||||
disableLink: PropTypes.bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AlbumTitle
|
export default AlbumTitle
|
||||||
|
|||||||
@@ -1,52 +1,8 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import styled from 'styled-components'
|
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { ProtectedImage } from '../photoGallery/ProtectedMedia'
|
import { ProtectedImage } from '../photoGallery/ProtectedMedia'
|
||||||
import { albumQuery_album_subAlbums } from '../../Pages/AlbumPage/__generated__/albumQuery'
|
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 {
|
interface AlbumBoxImageProps {
|
||||||
src?: string
|
src?: string
|
||||||
}
|
}
|
||||||
@@ -54,16 +10,31 @@ interface AlbumBoxImageProps {
|
|||||||
const AlbumBoxImage = ({ src, ...props }: AlbumBoxImageProps) => {
|
const AlbumBoxImage = ({ src, ...props }: AlbumBoxImageProps) => {
|
||||||
const [loaded, setLoaded] = useState(false)
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
|
||||||
|
let image = null
|
||||||
if (src) {
|
if (src) {
|
||||||
return (
|
image = (
|
||||||
<ImageWrapper>
|
<ProtectedImage
|
||||||
<Image {...props} onLoad={() => setLoaded(true)} src={src} />
|
className="object-cover object-center w-full h-full rounded-lg"
|
||||||
<Placeholder overlap loaded={loaded} />
|
{...props}
|
||||||
</ImageWrapper>
|
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="w-[220px] h-[220px] relative rounded-lg">
|
||||||
|
{image}
|
||||||
|
{placeholder}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type AlbumBoxProps = {
|
type AlbumBoxProps = {
|
||||||
@@ -72,20 +43,24 @@ type AlbumBoxProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const AlbumBox = ({ album, customLink, ...props }: AlbumBoxProps) => {
|
export const AlbumBox = ({ album, customLink, ...props }: AlbumBoxProps) => {
|
||||||
if (!album) {
|
const wrapperClasses = 'inline-block text-center text-gray-900 mx-3 my-2 h-60'
|
||||||
|
|
||||||
|
if (album) {
|
||||||
return (
|
return (
|
||||||
<AlbumBoxLink {...props} to="#">
|
<Link
|
||||||
<AlbumBoxImage />
|
to={customLink || `/album/${album.id}`}
|
||||||
</AlbumBoxLink>
|
className={wrapperClasses}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<AlbumBoxImage src={album.thumbnail?.thumbnail?.url} />
|
||||||
|
<p>{album.title}</p>
|
||||||
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const thumbnail = album.thumbnail?.thumbnail?.url
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlbumBoxLink {...props} to={customLink || `/album/${album.id}`}>
|
<div className={wrapperClasses} {...props}>
|
||||||
<AlbumBoxImage src={thumbnail} />
|
<AlbumBoxImage />
|
||||||
<p>{album.title}</p>
|
</div>
|
||||||
</AlbumBoxLink>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import styled from 'styled-components'
|
|
||||||
import { albumQuery_album_subAlbums } from '../../Pages/AlbumPage/__generated__/albumQuery'
|
import { albumQuery_album_subAlbums } from '../../Pages/AlbumPage/__generated__/albumQuery'
|
||||||
import { AlbumBox } from './AlbumBox'
|
import { AlbumBox } from './AlbumBox'
|
||||||
|
|
||||||
const Container = styled.div`
|
|
||||||
margin: 20px -10px;
|
|
||||||
position: relative;
|
|
||||||
`
|
|
||||||
|
|
||||||
type AlbumBoxesProps = {
|
type AlbumBoxesProps = {
|
||||||
loading: boolean
|
|
||||||
error?: Error
|
error?: Error
|
||||||
albums?: albumQuery_album_subAlbums[]
|
albums?: albumQuery_album_subAlbums[]
|
||||||
getCustomLink?(albumID: string): string
|
getCustomLink?(albumID: string): string
|
||||||
@@ -20,7 +13,7 @@ const AlbumBoxes = ({ error, albums, getCustomLink }: AlbumBoxesProps) => {
|
|||||||
|
|
||||||
let albumElements = []
|
let albumElements = []
|
||||||
|
|
||||||
if (albums) {
|
if (albums !== undefined) {
|
||||||
albumElements = albums.map(album => (
|
albumElements = albums.map(album => (
|
||||||
<AlbumBox
|
<AlbumBox
|
||||||
key={album.id}
|
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
|
export default AlbumBoxes
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import PhotoGallery from '../photoGallery/PhotoGallery'
|
|||||||
import AlbumBoxes from './AlbumBoxes'
|
import AlbumBoxes from './AlbumBoxes'
|
||||||
import AlbumFilter from '../AlbumFilter'
|
import AlbumFilter from '../AlbumFilter'
|
||||||
import { albumQuery_album } from '../../Pages/AlbumPage/__generated__/albumQuery'
|
import { albumQuery_album } from '../../Pages/AlbumPage/__generated__/albumQuery'
|
||||||
import { OrderDirection } from '../../../__generated__/globalTypes'
|
|
||||||
import {
|
import {
|
||||||
photoGalleryReducer,
|
photoGalleryReducer,
|
||||||
urlPresentModeSetupHook,
|
urlPresentModeSetupHook,
|
||||||
} from '../photoGallery/photoGalleryReducer'
|
} from '../photoGallery/photoGalleryReducer'
|
||||||
|
import { MediaOrdering, SetOrderingFn } from '../../hooks/useOrderingParams'
|
||||||
|
|
||||||
type AlbumGalleryProps = {
|
type AlbumGalleryProps = {
|
||||||
album?: albumQuery_album
|
album?: albumQuery_album
|
||||||
@@ -16,8 +16,8 @@ type AlbumGalleryProps = {
|
|||||||
customAlbumLink?(albumID: string): string
|
customAlbumLink?(albumID: string): string
|
||||||
showFilter?: boolean
|
showFilter?: boolean
|
||||||
setOnlyFavorites?(favorites: boolean): void
|
setOnlyFavorites?(favorites: boolean): void
|
||||||
setOrdering?(ordering: { orderBy: string }): void
|
setOrdering?: SetOrderingFn
|
||||||
ordering?: { orderBy: string | null; orderDirection: OrderDirection | null }
|
ordering?: MediaOrdering
|
||||||
onlyFavorites?: boolean
|
onlyFavorites?: boolean
|
||||||
onFavorite?(): void
|
onFavorite?(): void
|
||||||
}
|
}
|
||||||
@@ -61,19 +61,17 @@ const AlbumGallery = React.forwardRef(
|
|||||||
if (album.subAlbums.length > 0) {
|
if (album.subAlbums.length > 0) {
|
||||||
subAlbumElement = (
|
subAlbumElement = (
|
||||||
<AlbumBoxes
|
<AlbumBoxes
|
||||||
loading={loading}
|
|
||||||
albums={album.subAlbums}
|
albums={album.subAlbums}
|
||||||
getCustomLink={customAlbumLink}
|
getCustomLink={customAlbumLink}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
subAlbumElement = <AlbumBoxes loading={loading} />
|
subAlbumElement = <AlbumBoxes />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref}>
|
<div ref={ref}>
|
||||||
<AlbumTitle album={album} disableLink />
|
|
||||||
{showFilter && (
|
{showFilter && (
|
||||||
<AlbumFilter
|
<AlbumFilter
|
||||||
onlyFavorites={onlyFavorites}
|
onlyFavorites={onlyFavorites}
|
||||||
@@ -82,17 +80,8 @@ const AlbumGallery = React.forwardRef(
|
|||||||
ordering={ordering}
|
ordering={ordering}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<AlbumTitle album={album} disableLink />
|
||||||
{subAlbumElement}
|
{subAlbumElement}
|
||||||
{
|
|
||||||
<h2
|
|
||||||
style={{
|
|
||||||
opacity: loading ? 0 : 1,
|
|
||||||
display: album && album.subAlbums.length > 0 ? 'block' : 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Images
|
|
||||||
</h2>
|
|
||||||
}
|
|
||||||
<PhotoGallery
|
<PhotoGallery
|
||||||
loading={loading}
|
loading={loading}
|
||||||
mediaState={mediaState}
|
mediaState={mediaState}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export const MainMenu = () => {
|
|||||||
const mapboxEnabled = !!mapboxQuery?.data?.mapboxToken
|
const mapboxEnabled = !!mapboxQuery?.data?.mapboxToken
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute lg:relative w-full bottom-0 bg-white shadow-separator lg:shadow-none lg:w-[240px] lg:mx-8 lg:my-4">
|
<div className="absolute lg:relative w-full bottom-0 z-10 bg-white shadow-separator lg:shadow-none lg:w-[240px] lg:mx-8 lg:my-4">
|
||||||
<ul className="flex justify-around py-2 px-2 z-10 max-w-lg mx-auto lg:flex-col lg:p-0">
|
<ul className="flex justify-around py-2 px-2 z-10 max-w-lg mx-auto lg:flex-col lg:p-0">
|
||||||
<MenuButton
|
<MenuButton
|
||||||
to="/photos"
|
to="/photos"
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ export const MediaThumbnail = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PhotoThumbnail = styled.div`
|
export const MediaPlaceholder = styled.div`
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
height: 200px;
|
height: 200px;
|
||||||
width: 300px;
|
width: 300px;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useContext, useEffect } from 'react'
|
import React, { useContext, useEffect } from 'react'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
import { Loader } from 'semantic-ui-react'
|
import { MediaThumbnail, MediaPlaceholder } from './MediaThumbnail'
|
||||||
import { MediaThumbnail, PhotoThumbnail } from './MediaThumbnail'
|
|
||||||
import PresentView from './presentView/PresentView'
|
import PresentView from './presentView/PresentView'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { PresentMediaProps_Media } from './presentView/PresentMedia'
|
import { PresentMediaProps_Media } from './presentView/PresentMedia'
|
||||||
@@ -37,10 +36,6 @@ const PhotoFiller = styled.div`
|
|||||||
flex-grow: 999999;
|
flex-grow: 999999;
|
||||||
`
|
`
|
||||||
|
|
||||||
const ClearWrap = styled.div`
|
|
||||||
clear: both;
|
|
||||||
`
|
|
||||||
|
|
||||||
export interface PhotoGalleryProps_Media extends PresentMediaProps_Media {
|
export interface PhotoGalleryProps_Media extends PresentMediaProps_Media {
|
||||||
thumbnail: sidebarPhoto_media_thumbnail | null
|
thumbnail: sidebarPhoto_media_thumbnail | null
|
||||||
favorite?: boolean
|
favorite?: boolean
|
||||||
@@ -109,16 +104,13 @@ const PhotoGallery = ({
|
|||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
for (let i = 0; i < 6; i++) {
|
for (let i = 0; i < 6; i++) {
|
||||||
photoElements.push(<PhotoThumbnail key={i} />)
|
photoElements.push(<MediaPlaceholder key={i} />)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClearWrap>
|
<>
|
||||||
<Gallery data-testid="photo-gallery-wrapper">
|
<Gallery data-testid="photo-gallery-wrapper">
|
||||||
<Loader active={loading}>
|
|
||||||
{t('general.loading.media', 'Loading media')}
|
|
||||||
</Loader>
|
|
||||||
{photoElements}
|
{photoElements}
|
||||||
<PhotoFiller />
|
<PhotoFiller />
|
||||||
</Gallery>
|
</Gallery>
|
||||||
@@ -128,7 +120,7 @@ const PhotoGallery = ({
|
|||||||
dispatchMedia={dispatchMedia}
|
dispatchMedia={dispatchMedia}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ClearWrap>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -173,9 +173,6 @@ const TimelineGallery = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Loader active={loading}>
|
|
||||||
{t('general.loading.timeline', 'Loading timeline')}
|
|
||||||
</Loader>
|
|
||||||
<FavoritesCheckbox
|
<FavoritesCheckbox
|
||||||
onlyFavorites={onlyFavorites}
|
onlyFavorites={onlyFavorites}
|
||||||
setOnlyFavorites={setOnlyFavorites}
|
setOnlyFavorites={setOnlyFavorites}
|
||||||
|
|||||||
1
ui/src/favicon.svg
Normal file
1
ui/src/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 17 KiB |
@@ -2,18 +2,23 @@ import { useCallback } from 'react'
|
|||||||
import { OrderDirection } from '../../__generated__/globalTypes'
|
import { OrderDirection } from '../../__generated__/globalTypes'
|
||||||
import { UrlKeyValuePair, UrlParams } from './useURLParameters'
|
import { UrlKeyValuePair, UrlParams } from './useURLParameters'
|
||||||
|
|
||||||
|
export type MediaOrdering = {
|
||||||
|
orderBy: string | null
|
||||||
|
orderDirection: OrderDirection | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SetOrderingFn = (args: {
|
||||||
|
orderBy?: string
|
||||||
|
orderDirection?: OrderDirection
|
||||||
|
}) => void
|
||||||
|
|
||||||
function useOrderingParams({ getParam, setParams }: UrlParams) {
|
function useOrderingParams({ getParam, setParams }: UrlParams) {
|
||||||
const orderBy = getParam('orderBy', 'date_shot')
|
const orderBy = getParam('orderBy', 'date_shot')
|
||||||
|
|
||||||
const orderDirStr = getParam('orderDirection', 'ASC') || 'hello'
|
const orderDirStr = getParam('orderDirection', 'ASC') || 'hello'
|
||||||
const orderDirection = orderDirStr as OrderDirection
|
const orderDirection = orderDirStr as OrderDirection
|
||||||
|
|
||||||
type setOrderingFn = (args: {
|
const setOrdering: SetOrderingFn = useCallback(
|
||||||
orderBy?: string
|
|
||||||
orderDirection?: OrderDirection
|
|
||||||
}) => void
|
|
||||||
|
|
||||||
const setOrdering: setOrderingFn = useCallback(
|
|
||||||
({ orderBy, orderDirection }) => {
|
({ orderBy, orderDirection }) => {
|
||||||
const updatedParams: UrlKeyValuePair[] = []
|
const updatedParams: UrlKeyValuePair[] = []
|
||||||
if (orderBy !== undefined) {
|
if (orderBy !== undefined) {
|
||||||
|
|||||||
@@ -1,45 +1,51 @@
|
|||||||
import React from 'react'
|
import React, { forwardRef } from 'react'
|
||||||
|
|
||||||
type TextFieldProps = {
|
type TextFieldProps = {
|
||||||
label?: string
|
label?: string
|
||||||
error?: string
|
error?: string
|
||||||
} & React.InputHTMLAttributes<HTMLInputElement>
|
} & React.InputHTMLAttributes<HTMLInputElement>
|
||||||
|
|
||||||
export const TextField = ({
|
export const TextField = forwardRef(
|
||||||
label,
|
(
|
||||||
error,
|
{ label, error, className, ...inputProps }: TextFieldProps,
|
||||||
className,
|
ref: React.ForwardedRef<HTMLInputElement>
|
||||||
...inputProps
|
) => {
|
||||||
}: TextFieldProps) => {
|
let variant = 'bg-white border-gray-200 focus:border-blue-400'
|
||||||
const input = (
|
if (error)
|
||||||
<input
|
variant =
|
||||||
className="block bg-white border border-gray-200 rounded-md h-10 w-full focus:border-blue-400 focus:ring-2 focus:outline-none px-2"
|
'bg-red-50 border-red-200 focus:border-red-400 focus:ring-red-100'
|
||||||
{...inputProps}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
let errorElm = null
|
const input = (
|
||||||
if (error) errorElm = <div>{error}</div>
|
<input
|
||||||
|
className={`block border rounded-md h-10 w-full focus:ring-2 focus:outline-none px-2 ${variant}`}
|
||||||
|
{...inputProps}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
let errorElm = null
|
||||||
|
if (error) errorElm = <div className="text-red-800">{error}</div>
|
||||||
|
|
||||||
|
if (label) {
|
||||||
|
return (
|
||||||
|
<label className={`block my-4 ${className}`}>
|
||||||
|
<span className="block text-xs uppercase font-semibold mb-1">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{input}
|
||||||
|
{errorElm}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (label) {
|
|
||||||
return (
|
return (
|
||||||
<label className={`block my-4 ${className}`}>
|
<div className={className}>
|
||||||
<span className="block text-xs uppercase font-semibold mb-1">
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
{input}
|
{input}
|
||||||
{errorElm}
|
{errorElm}
|
||||||
</label>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
)
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
{input}
|
|
||||||
{errorElm}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Submit = (props: React.InputHTMLAttributes<HTMLInputElement>) => {
|
export const Submit = (props: React.InputHTMLAttributes<HTMLInputElement>) => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import reactRefresh from '@vitejs/plugin-react-refresh'
|
import reactRefresh from '@vitejs/plugin-react-refresh'
|
||||||
|
import { babel } from '@rollup/plugin-babel'
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [reactRefresh()],
|
plugins: [
|
||||||
|
reactRefresh(),
|
||||||
|
// babel({
|
||||||
|
// babelHelpers: 'bundled',
|
||||||
|
// exclude: 'node_modules/**',
|
||||||
|
// extensions: ['.js', '.jsx', '.ts', '.tsx'],
|
||||||
|
// }),
|
||||||
|
],
|
||||||
server: {
|
server: {
|
||||||
port: 1234,
|
port: 1234,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user