Merge remote-tracking branch 'origin/main' into synonym-button-in-species-table

This commit is contained in:
Elias Junno
2025-06-18 10:04:38 +03:00
48 changed files with 926 additions and 268 deletions
+1
View File
@@ -14,6 +14,7 @@ export const existingPerson: Omit<PersonDetailsType, 'user'> = {
used_morph: null,
used_now: null,
used_gene: null,
now_user_group: 'eu',
}
export const editedPerson = {
+2
View File
@@ -2,6 +2,7 @@ import 'express-async-errors'
import express from 'express'
import cors from 'cors'
import crossSearchRouter from './routes/crossSearch'
import statisticsRouter from './routes/statistics'
import localityRouter from './routes/locality'
import museumRouter from './routes/museum'
import referenceRouter from './routes/reference'
@@ -52,6 +53,7 @@ app.use('/crosssearch', crossSearchRouter)
app.use('/locality', localityRouter)
app.use('/locality-species', localitySpeciesRouter)
app.use('/species', speciesRouter)
app.use('/statistics', statisticsRouter)
app.use('/reference', referenceRouter)
app.use('/time-unit', timeUnitRouter)
app.use('/time-bound', requireOneOf([Role.Admin, Role.EditUnrestricted]), timeBoundRouter)
+34 -1
View File
@@ -1,5 +1,9 @@
import { Router } from 'express'
import { getCrossSearchRawSql, parseAndValidateCrossSearchRouteParameters } from '../services/crossSearch'
import {
getCrossSearchRawSql,
getCrossSearchLocalitiesRawSql,
parseAndValidateCrossSearchRouteParameters,
} from '../services/crossSearch'
import { fixBigInt } from '../utils/common'
import { format } from '@fast-csv/format'
import { pipeline } from 'stream'
@@ -41,6 +45,35 @@ router.get(`/all/:limit/:offset/:columnfilters/:sorting`, async (req, res) => {
}
})
router.get(`/localities/:columnfilters/:sorting`, async (req, res) => {
let validatedValues
try {
const { validationErrors, ...values } = parseAndValidateCrossSearchRouteParameters({
columnFilters: req.params.columnfilters,
sorting: req.params.sorting,
})
validatedValues = values
if (validationErrors.length > 0) {
return res.status(403).send(validationErrors)
}
} catch (error) {
if (error instanceof Error) return res.status(403).send({ error: error.message })
return res.status(403).send('Unknown error')
}
try {
const result = await getCrossSearchLocalitiesRawSql(
req.user,
validatedValues.validatedColumnFilters,
validatedValues.validatedSorting
)
return res.status(200).send(fixBigInt(result))
} catch (error) {
if (error instanceof Error) return res.status(403).send({ error: error.message })
return res.status(403).send('Unknown error')
}
})
router.get(`/export/:columnfilters/:sorting`, async (req, res) => {
let validatedValues
try {
+12
View File
@@ -3,6 +3,7 @@ import { getAllPersons, getPersonDetails, validateEntirePerson } from '../servic
import { Role, PersonDetailsType, EditDataType, EditMetaData } from '../../../frontend/src/shared/types'
import { requireOneOf } from '../middlewares/authorizer'
import { writePerson } from '../services/write/person'
import { UserGroup, validUserGroups, writeUserGroup } from '../services/write/user'
const router = Router()
@@ -53,6 +54,17 @@ router.put(
return res.status(403).send(validationErrors)
}
const initials = await writePerson(editedPerson)
/* Update user group (admin-only) */
if (
req.user.role === Role.Admin &&
editedPerson.user_id &&
editedPerson.now_user_group &&
validUserGroups.includes(editedPerson.now_user_group)
) {
await writeUserGroup(editedPerson.user_id, editedPerson.now_user_group as UserGroup)
}
return res.status(200).send({ initials })
}
)
+22
View File
@@ -0,0 +1,22 @@
import { Router } from 'express'
import { getLocalityStatistics, getSpeciesStatistics, getStatistics } from '../services/statistics'
import { fixBigInt } from '../utils/common'
const router = Router()
router.get('/', async (_req, res) => {
const statistics = await getStatistics()
return res.status(200).send(fixBigInt(statistics))
})
router.get('/locality', async (_req, res) => {
const statistics = await getLocalityStatistics()
return res.status(200).send(fixBigInt(statistics))
})
router.get('/species', async (_req, res) => {
const statistics = await getSpeciesStatistics()
return res.status(200).send(fixBigInt(statistics))
})
export default router
+3 -6
View File
@@ -8,6 +8,7 @@ import { getRole } from '../middlewares/authenticator'
import { AccessError } from '../middlewares/authorizer'
import { Role } from '../../../frontend/src/shared/types'
import md5 from 'md5'
import { validatePassword } from '../utils/validatePassword'
const router = Router()
@@ -127,12 +128,8 @@ router.put('/password', async (req, res) => {
if (!passwordMatches) return res.status(403).send({ error: 'Old password does not match your current password.' })
if (newPassword.length < 8) return res.status(400).send({ error: 'New password must be at least 8 characters long.' })
if (!/^[0-9A-Za-z$%&~]+/.test(newPassword))
return res
.status(400)
.send({ error: 'Use only alphanumeric characters a-z, A-Z and 0-9 and symbols ^?$%&~ in the new password.' })
const validationResult = validatePassword(newPassword)
if (!validationResult.isValid) return res.status(400).send({ error: validationResult.error })
const passwordHash = await createPasswordHash(newPassword)
+47 -1
View File
@@ -6,9 +6,10 @@ import {
ParsedCrossSearchRouteParameters,
SortingState,
ColumnFilter,
SimplifiedLocality,
} from '../../../frontend/src/shared/types'
import { getCrossSearchFields, getFieldsOfTables, nowDb } from '../utils/db'
import { generateCrossSearchSql } from './queries/crossSearchQuery'
import { generateCrossSearchLocalitiesSql, generateCrossSearchSql } from './queries/crossSearchQuery'
import { ValidationObject } from '../../../frontend/src/shared/validators/validator'
import { validateCrossSearchRouteParams } from '../../../frontend/src/shared/validators/crossSearch'
@@ -126,6 +127,51 @@ export const getCrossSearchRawSql = async (
return result
}
export const getCrossSearchLocalitiesRawSql = async (
user: User | undefined,
columnFilters?: ColumnFilter[],
sorting?: SortingState[]
) => {
let orderBy: string | undefined
let descendingOrder: boolean = false
if (!sorting || sorting.length === 0) {
orderBy = undefined
} else {
orderBy = sorting[0].id
descendingOrder = sorting[0].desc
}
let convertedOrderBy = undefined
if (orderBy) {
convertedOrderBy = convertFilterIdToFieldName(orderBy)
if (!getCrossSearchFields().includes(convertedOrderBy)) throw new Error('orderBy was not a valid column id.')
}
const convertedColumnFilters = []
if (columnFilters) {
const allowedColumns = getCrossSearchFields()
for (const filter of columnFilters) {
const convertedId = convertFilterIdToFieldName(filter.id)
if (!allowedColumns.includes(convertedId)) throw new Error('columnFilters has an invalid column id.')
convertedColumnFilters.push({ id: convertedId, value: filter.value })
}
}
const showAll = user ? [Role.Admin, Role.EditUnrestricted].includes(user.role) : false
const allowedLocalities = user ? await getAllowedLocalities(user) : []
const sql = generateCrossSearchLocalitiesSql(
showAll,
allowedLocalities,
convertedColumnFilters,
convertedOrderBy,
descendingOrder
)
const result: Partial<SimplifiedLocality>[] = await nowDb.$queryRaw(sql)
return result
}
export const parseAndValidateCrossSearchRouteParameters = (parameters: CrossSearchRouteParameters) => {
const { limit, offset, columnFilters, sorting } = parameters
let parsedLimit
+1 -1
View File
@@ -27,7 +27,7 @@ export const getPersonDetails = async (id: string) => {
select: { user_id: true, user_name: true, last_login: true, now_user_group: true },
})
return { ...person, user }
return { ...person, user, now_user_group: user?.now_user_group }
}
export const validateEntirePerson = (editedFields: EditDataType<Prisma.com_people>) => {
@@ -15,7 +15,7 @@ const generateWhereClause = (showAll: boolean, allowedLocalities: Array<number>,
else {
const conditions: Prisma.Sql[] = []
for (const filter of columnFilters) {
const newQuery = Prisma.sql`${Prisma.raw(filter.id)} = ${filter.value}`
const newQuery = Prisma.sql`${Prisma.raw(filter.id)} LIKE ${'%' + filter.value + '%'}`
conditions.push(newQuery)
}
@@ -256,3 +256,29 @@ export const generateCrossSearchSql = (
${offset ? Prisma.sql`OFFSET ${offset}` : Prisma.empty}
`
}
export const generateCrossSearchLocalitiesSql = (
showAll: boolean,
allowedLocalities: Array<number>,
columnFilters: ColumnFilter[],
orderBy: string | undefined,
descendingOrder: boolean
) => {
const whereClause = generateWhereClause(showAll, allowedLocalities, columnFilters)
return Prisma.sql`
SELECT
-- now_loc fields
now_loc.lid,
now_loc.loc_name,
now_loc.dec_lat,
now_loc.dec_long
FROM com_species
INNER JOIN now_ls ON com_species.species_id = now_ls.species_id
INNER JOIN now_loc ON now_ls.lid = now_loc.lid
${whereClause}
GROUP BY now_loc.lid
ORDER BY
${orderBy ? Prisma.raw(orderBy) : Prisma.sql`now_loc.lid`} ${descendingOrder ? Prisma.sql`DESC` : Prisma.empty}
`
}
+48
View File
@@ -0,0 +1,48 @@
import { ActivityStatistic, Statistics } from '../../../frontend/src/shared/types'
import { Prisma } from '../../prisma/generated/now_test_client'
import { nowDb } from '../utils/db'
export const getStatistics = async (): Promise<Statistics> => {
const localityAggregate = await nowDb.now_loc.aggregate({
_count: {
lid: true,
},
})
const localityCount = localityAggregate._count.lid
const speciesAggregate = await nowDb.com_species.aggregate({
_count: {
species_id: true,
},
})
const speciesCount = speciesAggregate._count.species_id
const localitySpeciesQuery = Prisma.sql`
SELECT COUNT(now_ls.species_id) as count
FROM now_loc
LEFT JOIN now_ls ON now_ls.lid = now_loc.lid
`
const localitySpeciesAggregate: { count: number }[] = await nowDb.$queryRaw(localitySpeciesQuery)
let localitySpeciesCount = 0
if (localitySpeciesAggregate.length > 0) {
localitySpeciesCount = localitySpeciesAggregate[0].count
}
return {
localityCount,
speciesCount,
localitySpeciesCount,
}
}
export const getLocalityStatistics = async (): Promise<ActivityStatistic[]> => {
const query = Prisma.sql`SELECT * FROM now_v_locality_statistics LIMIT 10`
return await nowDb.$queryRaw(query)
}
export const getSpeciesStatistics = async (): Promise<ActivityStatistic[]> => {
const query = Prisma.sql`SELECT * FROM now_v_species_statistics LIMIT 10`
return await nowDb.$queryRaw(query)
}
+11
View File
@@ -0,0 +1,11 @@
import { nowDb } from '../../utils/db'
export const validUserGroups = ['su', 'eu', 'er', 'pl', 'plp', 'no', 'ro']
export type UserGroup = 'su' | 'eu' | 'er' | 'pl' | 'plp' | 'no' | 'ro'
export const writeUserGroup = async (user_id: number, group: UserGroup) => {
await nowDb.com_users.update({
where: { user_id: user_id },
data: { now_user_group: group },
})
}
@@ -0,0 +1,26 @@
import { describe, it, expect } from '@jest/globals'
import { validatePassword } from '../utils/validatePassword'
describe('Password validation utils', () => {
it('Accepts correct password', () => {
expect(validatePassword('thisisavalidpassword').isValid).toBeTruthy()
})
it('Accepts correct password with symbols', () => {
expect(validatePassword('thisisav~!%ali&dpas$sword').isValid).toBeTruthy()
})
it('Rejects invalid password with unsupported symbols in the middle', () => {
expect(validatePassword('thisisav~%a€li&dpas$sword').isValid).toBeFalsy()
})
it('Rejects invalid password with unsupported symbols in the beginning or end', () => {
expect(validatePassword('thisisav~%ali&dpas$sword€').isValid).toBeFalsy()
expect(validatePassword('€thisisav~%ali&dpas$sword').isValid).toBeFalsy()
})
it('Rejects password that is under 8 characters', () => {
expect(validatePassword('aaaaaaa').isValid).toBeFalsy()
expect(validatePassword('aaaaa').isValid).toBeFalsy()
})
it('Accepts password that is 8 characters or more', () => {
expect(validatePassword('aaaaaaaa').isValid).toBeTruthy()
expect(validatePassword('aaaaaaaaaaaa').isValid).toBeTruthy()
})
})
+15
View File
@@ -0,0 +1,15 @@
export interface PasswordValidationResult {
error?: string
isValid: boolean
}
export const validatePassword = (password: string): PasswordValidationResult => {
if (password.length < 8) return { isValid: false, error: 'Password must be at least 8 characters long' }
if (!/^[0-9A-Za-z$%&~!]+$/.test(password))
return {
isValid: false,
error: 'Password must only contain characters a-z, A-Z, 0-9 and ^?$%&~!',
}
return { isValid: true }
}
-5
View File
@@ -38,9 +38,4 @@ describe('Open each page, table view and detail view, and check at least some co
cy.contains('Sequence')
cy.contains('gcss')
})
it('Map works', () => {
cy.visit('/locality')
cy.contains('map').click()
})
})
+15 -3
View File
@@ -30,6 +30,18 @@ describe('Editing a person', () => {
cy.contains('test.email@provider.com')
})
it('user rights editing works', () => {
cy.visit(`/person/AD?tab=0`)
cy.get('[id=edit-button]').click()
cy.contains('Choose user group').parent().type('n')
cy.contains('no').click()
cy.get('[id=write-button]').click()
cy.contains('no')
})
it('with any missing fields does not work', () => {
cy.visit(`/person/AD?tab=0`)
cy.contains('ads')
@@ -55,9 +67,9 @@ describe('Editing a person', () => {
cy.get('[id=organization-textfield]').type('test organization')
cy.get('[id=Country-multiselect]').click()
cy.get('[data-testid=CloseIcon]').click() // clears the country dropdown
cy.contains('Country: This field is required')
cy.get('[id=write-button]').should('be.disabled')
// cy.get('[id=Country-multiselect]').children('[data-testid=CloseIcon]').click() // clears the country dropdown
// cy.contains('Country: This field is required')
// cy.get('[id=write-button]').should('be.disabled')
cy.get('[id=Country-multiselect]').type('Andorra')
cy.contains('.MuiAutocomplete-option', 'Andorra').click() // this finds the Andorra inside the Autocomplete component
-5
View File
@@ -57,11 +57,6 @@ describe('Button Tests', () => {
cy.contains('Species').should('be.visible')
})
it('Map button works', () => {
cy.visit('/locality')
cy.contains('map').click()
})
it('Link to species details from species tab', () => {
cy.visit('/species')
// Click the button with the SVG icon
+3 -3
View File
@@ -20,7 +20,7 @@ describe('Change password form validates passwords correctly and shows errors to
cy.contains('Please fill all fields.')
cy.get('[id=new-password-verification-textfield]').type('test2')
cy.get('[id=change-password-button]').click()
cy.contains('New password must be at least 8 characters long.')
cy.contains('Password must be at least 8 characters long')
})
it('Incorrect old password', () => {
@@ -40,14 +40,14 @@ describe('Change password form validates passwords correctly and shows errors to
cy.get('[id=new-password-textfield]').type('short')
cy.get('[id=new-password-verification-textfield]').type('short')
cy.get('[id=change-password-button]').click()
cy.contains('New password must be at least 8 characters long.')
cy.contains('Password must be at least 8 characters long')
cy.get('[id=new-password-textfield]').clear()
cy.get('[id=new-password-textfield]').type('€uroisnotallowed')
cy.get('[id=new-password-verification-textfield]').clear()
cy.get('[id=new-password-verification-textfield]').type('€uroisnotallowed')
cy.get('[id=change-password-button]').click()
cy.contains('Use only alphanumeric characters a-z, A-Z and 0-9 and symbols ^?$%&~ in the new password.')
cy.contains('Password must only contain characters a-z, A-Z, 0-9 and ^?$%&~!')
})
it('New password and verification do not match', () => {
+5
View File
@@ -0,0 +1,5 @@
# Architecture picture, stack
To do any updates to this picture, update documentation/images/architecture.drawio.svg in drawio/diagrams.net
![Architecture Diagram](./images/architecture.drawio.svg)
@@ -1,6 +1,6 @@
# Map features
See component-specific docs at [CoordinateSelectionMap](./CoordinateSelectionMap.md) and [LocalityMap](./LocalityMap.md)
See component-specific docs at [CoordinateSelectionMap](./CoordinateSelectionMap.md) and [LocalitiesMap](./LocalitiesMap.md)
The map features use the React Leaflet package and OpenStreetMap.
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 119 KiB

+28 -24
View File
@@ -1,10 +1,10 @@
import { Container, Grid } from '@mui/material'
import { FrontPage } from './components/FrontPage'
import { NavBar } from './components/NavBar'
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import { Login } from './components/Login'
import {
crossSearchPage,
frontPage,
localityPage,
personPage,
projectPage,
@@ -16,36 +16,40 @@ import {
} from './components/pages'
import { Notification, NotificationContextProvider } from './components/Notification'
import { EmailPage } from './components/EmailPage'
import { Footer } from './Footer'
const App = () => {
return (
<BrowserRouter>
<NotificationContextProvider>
<Notification />
<Grid container>
<Grid item xs={12}>
<NavBar />
</Grid>
<Container maxWidth="xl" fixed style={{ marginTop: '2em', marginBottom: '2em' }}>
<Grid item>
<Routes>
<Route element={crossSearchPage} path="/crosssearch/:id?" />
<Route element={localityPage} path="/locality/:id?" />
<Route element={speciesPage} path="/species/:id?" />
<Route element={referencePage} path="/reference/:id?" />
<Route element={timeUnitPage} path="/time-unit/:id?" />
<Route element={timeBoundPage} path="/time-bound/:id?" />
<Route element={regionPage} path="/region/:id?" />
<Route element={personPage} path="/person/:id?" />
<Route element={projectPage} path="/project/:id?" />
<Route element={<EmailPage />} path="/email/" />
<Route element={<Login />} path="/login" />
<Route element={<FrontPage />} path="/" />
<Route element={<div>Page not found.</div>} path="*" />
</Routes>
<div id="wrapper">
<Grid container>
<Grid item xs={12}>
<NavBar />
</Grid>
</Container>
</Grid>
<Container maxWidth="xl" fixed style={{ marginTop: '2em', marginBottom: '2em' }}>
<Grid item>
<Routes>
<Route element={crossSearchPage} path="/crosssearch/:id?" />
<Route element={localityPage} path="/locality/:id?" />
<Route element={speciesPage} path="/species/:id?" />
<Route element={referencePage} path="/reference/:id?" />
<Route element={timeUnitPage} path="/time-unit/:id?" />
<Route element={timeBoundPage} path="/time-bound/:id?" />
<Route element={regionPage} path="/region/:id?" />
<Route element={personPage} path="/person/:id?" />
<Route element={projectPage} path="/project/:id?" />
<Route element={<EmailPage />} path="/email/" />
<Route element={<Login />} path="/login" />
<Route element={frontPage} path="/" />
<Route element={<div>Page not found.</div>} path="*" />
</Routes>
</Grid>
</Container>
</Grid>
<Footer />
</div>
</NotificationContextProvider>
</BrowserRouter>
)
+5
View File
@@ -0,0 +1,5 @@
import './styles/Footer.css'
export const Footer = () => {
return <footer>© The NOW Database 2023</footer>
}
@@ -2,8 +2,9 @@ import { useMemo } from 'react'
import { type MRT_ColumnDef } from 'material-react-table'
import { CrossSearch } from '@/shared/types'
import { TableView } from '../TableView/TableView'
import { useGetAllCrossSearchQuery } from '@/redux/crossSearchReducer'
import { useGetAllCrossSearchQuery, useGetAllCrossSearchLocalitiesQuery } from '@/redux/crossSearchReducer'
import { usePageContext } from '../Page'
import { LocalitiesMap } from '../Map/LocalitiesMap'
export const CrossSearchTable = ({ selectorFn }: { selectorFn?: (newObject: CrossSearch) => void }) => {
const { sqlLimit, sqlOffset, sqlColumnFilters, sqlOrderBy } = usePageContext()
@@ -14,6 +15,11 @@ export const CrossSearchTable = ({ selectorFn }: { selectorFn?: (newObject: Cros
sorting: sqlOrderBy,
})
const { data: localitiesData, isFetching: localitiesFetching } = useGetAllCrossSearchLocalitiesQuery({
columnFilters: sqlColumnFilters,
sorting: sqlOrderBy,
})
// Show/Hide columns in the following order
const columns = useMemo<MRT_ColumnDef<CrossSearch>[]>(
() => [
@@ -800,19 +806,22 @@ export const CrossSearchTable = ({ selectorFn }: { selectorFn?: (newObject: Cros
}
return (
<TableView<CrossSearch>
title="Locality-Species-Cross-Search"
selectorFn={selectorFn}
checkRowRestriction={checkRowRestriction}
idFieldName="lid_now_loc"
columns={columns}
isFetching={isFetching}
visibleColumns={visibleColumns}
data={crossSearchQueryData}
url="crosssearch"
enableColumnFilterModes={true}
serverSidePagination={true}
isCrossSearchTable={true}
/>
<>
<LocalitiesMap localities={localitiesData} isFetching={localitiesFetching || isFetching} />
<TableView<CrossSearch>
title="Locality-Species-Cross-Search"
selectorFn={selectorFn}
checkRowRestriction={checkRowRestriction}
idFieldName="lid_now_loc"
columns={columns}
isFetching={isFetching}
visibleColumns={visibleColumns}
data={crossSearchQueryData}
url="crosssearch"
enableColumnFilterModes={true}
serverSidePagination={true}
isCrossSearchTable={true}
/>
</>
)
}
@@ -71,7 +71,8 @@ export type DetailContextType<T> = {
field: keyof EditDataType<T>,
options: Array<DropdownOption | string>,
name: string,
disabled?: boolean
disabled?: boolean,
label?: string
) => JSX.Element
radioSelection: (
field: keyof EditDataType<T>,
@@ -114,8 +114,9 @@ export const DetailView = <T extends object>({
field: keyof EditDataType<T>,
options: Array<DropdownOption | string>,
name: string,
disabled?: boolean
) => <DropdownSelectorWithSearch field={field} options={options} name={name} disabled={disabled} />
disabled?: boolean,
label?: string
) => <DropdownSelectorWithSearch field={field} options={options} name={name} disabled={disabled} label={label} />
const onDelete = () => {
if (!window.confirm('Are you sure you want to delete this item? This operation cannot be undone.')) return
@@ -98,11 +98,13 @@ export const DropdownSelectorWithSearch = <T extends object>({
name,
field,
disabled,
label,
}: {
options: Array<DropdownOption | string>
name: string
field: keyof EditDataType<T>
disabled?: boolean
label?: string
}) => {
const { setEditData, editData, validator, fieldsWithErrors, setFieldsWithErrors } = useDetailContext<T>()
const errorObject = validator(editData, field)
@@ -139,7 +141,7 @@ export const DropdownSelectorWithSearch = <T extends object>({
setInputValue(newInputValue)
}}
sx={{ width: fieldWidth, backgroundColor: disabled ? 'grey' : '' }}
renderInput={params => <TextField {...params} label="Choose a country" error={!!error} />}
renderInput={params => <TextField {...params} label={label ?? 'Choose a country'} error={!!error} />}
/>
{error && <FormHelperText>{error}</FormHelperText>}
+133 -64
View File
@@ -1,30 +1,35 @@
import {
useGetLocalityStatisticsQuery,
useGetSpeciesStatisticsQuery,
useGetStatisticsQuery,
} from '../redux/statisticsReducer'
import { ENV } from '@/util/config'
import { Divider } from '@mui/material'
import Box from '@mui/material/Box'
import { useState, useEffect } from 'react'
import { useEffect, useRef } from 'react'
const dateWhenStarted = new Date(Date.now())
import mapSvg from '../resource/map.svg'
import logo from '../resource/nowlogo.jpg'
import '../styles/FrontPage.css'
export const FrontPage = () => {
const [dateString, setDateString] = useState('')
const mapContainerRef = useRef<HTMLDivElement | null>(null)
const { data: statisticsQueryData } = useGetStatisticsQuery()
const { data: speciesStatisticsQueryData, isFetching: speciesStatisticsQueryIsFetching } =
useGetSpeciesStatisticsQuery()
const { data: localityStatisticsQueryData, isFetching: localityStatisticsQueryIsFetching } =
useGetLocalityStatisticsQuery()
// Map hover effect
useEffect(() => {
const currentLocalStorage = localStorage.getItem('dateWhenStarted')
if (!currentLocalStorage) {
localStorage.setItem('dateWhenStarted', dateWhenStarted.toString())
return
}
if (!mapContainerRef.current) return
mapContainerRef.current.onmousemove = (e: MouseEvent) => {
if (!mapContainerRef.current) return
const rect = mapContainerRef.current.getBoundingClientRect()
const height = rect.bottom - rect.top
const width = rect.right - rect.left
const currentLocalStorageDate = new Date(currentLocalStorage)
const optionsForDateFormatting: Intl.DateTimeFormatOptions = {
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
year: 'numeric',
month: 'long',
day: 'numeric',
mapContainerRef.current?.style.setProperty('--x', `${((e.clientX - rect.left) / width) * 100}%`)
mapContainerRef.current?.style.setProperty('--y', `${((e.clientY - rect.top) / height) * 100}%`)
}
setDateString(currentLocalStorageDate.toLocaleDateString('fi-FI', optionsForDateFormatting))
}, [])
if (ENV === 'dev') {
@@ -32,51 +37,115 @@ export const FrontPage = () => {
} else {
document.title = 'NOW Database'
}
return (
<Box sx={{ fontSize: 20, maxWidth: '40em' }}>
<h1>Welcome</h1>
{ENV !== 'prod' && (
<p>
<b style={{ backgroundColor: 'orange' }}>
This version is running on test data. May contain incorrect and outdated information.
</b>
</p>
)}
<p>
This is a new version of the NOW (New And Old Worlds) Fossil Mammal Database. It displays tables and details of
localities, species, time units and references.
</p>
<p>
<b>To navigate this application, use the bar at the top of the website.</b> Click on the links there to view
different data tables, such as localities or species. Then, to view details of an individual item, click the
magnifying glass icon on the left side of the row.
</p>
<p>
To log in, click the Login-link at the top-right corner of the screen. A logged in user will see more links in
the navigation bar, depending on their user rights, as well as the User-link that will take the user to their
own user-page, where you can view your own information and change your password.
</p>
<p style={{ fontSize: 26 }}>
<b>
<a href="https://forms.gle/Aj392QWov5QXBJfo7">Click here to provide feedback!</a>
</b>
</p>
<Divider />
<h2>External links</h2>
<ul>
<li>
Information about the database and NOW community:{' '}
<a href="https://nowdatabase.org/">https://nowdatabase.org/</a>
</li>
<li>
The old application: <a href="https://nowdatabase.luomus.fi/">https://nowdatabase.luomus.fi/</a>
</li>
<li>
Code repository of the project:{' '}
<a href="https://github.com/nowcommunity/nowdatabase">https://github.com/nowcommunity/nowdatabase</a>
</li>
</ul>
{ENV !== 'prod' ? <p style={{ opacity: 0.25 }}>{dateString}</p> : ''}
</Box>
<main id="front-page">
<header className="top-info">
<div className="left">
<h1>Welcome to NOW database</h1>
<p>
The NOW (New and Old Worlds) fossil mammal database contains information about Cenozoic land mammal taxa and
localities. The emphasis of the database has been on the European Miocene and Pliocene but North American
localities, covering the whole Cenozoic, were added to the public database in 2016. African localities are
currently being added and updated, and the temporal scale is also becoming wider for both Eurasia and
Africa, with Pleistocene and Paleogene localities being added into the database. The NOW database is
maintained and coordinated by <a href="mailto:indre-dot-zliobaite-at-helsinki-dot-fi">Indrė Žliobaitė</a> in
collaboration with associate coordinators, a steering group and an international{' '}
<a href="https://nowdatabase.org/now/board/">advisory board</a>.
</p>
<p>
<a href="https://nowdatabase.org/">See more information at nowdatabase.org</a>
</p>
</div>
<div className="image-container">
<img src={logo} alt="NOW database logo" />
<span className="copyright">© Noira Martiskainen</span>
</div>
</header>
<div className="stats">
<span>
<b>Localities</b> {statisticsQueryData && statisticsQueryData.localityCount}
</span>
<span>
<b>Species</b> {statisticsQueryData && statisticsQueryData.speciesCount}
</span>
<span>
<b>Locality-species</b> {statisticsQueryData && statisticsQueryData.localitySpeciesCount}
</span>
</div>
<div className="map-container" ref={mapContainerRef}>
<div className="map">
<img src={mapSvg} />
</div>
</div>
<section className="additional-info">
<div>
<h2>Statistics</h2>
<div className="activity-stats">
<div className="stat-table">
<h3>Most localities</h3>
<table>
<thead>
<tr>
<th>Year</th>
<th>Month</th>
<th>Surname</th>
</tr>
</thead>
<tbody>
{!localityStatisticsQueryIsFetching &&
localityStatisticsQueryData &&
localityStatisticsQueryData.map((stat, i) => (
<tr key={i}>
<td>{stat.year}</td>
<td>{stat.month}</td>
<td>{stat.surname}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="stat-table">
<h3>Most species</h3>
<table>
<thead>
<tr>
<th>Year</th>
<th>Month</th>
<th>Surname</th>
</tr>
</thead>
<tbody>
{!speciesStatisticsQueryIsFetching &&
speciesStatisticsQueryData &&
speciesStatisticsQueryData.map((stat, i) => (
<tr key={i}>
<td>{stat.year}</td>
<td>{stat.month}</td>
<td>{stat.surname}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
<div>
<h2>Citation</h2>
<h3>Minimum required attribution</h3>
<p>
Data (<a href="https://nowdatabase.org/now/database/">https://nowdatabase.org/now/database/</a>) by The NOW
Community / CC BY 4.0.
</p>
<h3>Suggested citation or attribution</h3>
<p>
The NOW Community [year]. New and Old Worlds Database of Fossil Mammals (NOW). Licensed under CC BY 4.0.
Retrieved [download date] from{' '}
</p>
<a href="https://nowdatabase.org/now/database/">https://nowdatabase.org/now/database/</a>.<h3>DOI</h3>{' '}
<a href="http://doi.org/10.5281/zenodo.4268068">doi:10.5281/zenodo.4268068</a>.
</div>
</section>
</main>
)
}
@@ -1,12 +1,13 @@
import { useMemo } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { MRT_TableInstance, type MRT_ColumnDef, MRT_RowData } from 'material-react-table'
import { useGetAllLocalitiesQuery, useGetLocalitySpeciesListMutation } from '../../redux/localityReducer'
import { Locality } from '@/shared/types'
import { Locality, SimplifiedLocality } from '@/shared/types'
import { TableView } from '../TableView/TableView'
import { useNotify } from '@/hooks/notification'
import { LocalitiesMap } from '../Map/LocalitiesMap'
import { generateKml } from '@/util/kml'
import { generateSvg } from '../Map/generateSvg'
import { usePageContext } from '../Page'
const decimalCount = (num: number) => {
const numAsString = num.toString()
@@ -19,7 +20,20 @@ const decimalCount = (num: number) => {
export const LocalityTable = ({ selectorFn }: { selectorFn?: (newObject: Locality) => void }) => {
const { data: localitiesQueryData, isFetching: localitiesQueryIsFetching } = useGetAllLocalitiesQuery()
const [getLocalitySpeciesList, { isLoading }] = useGetLocalitySpeciesListMutation()
const [filteredLocalities, setFilteredLocalities] = useState<SimplifiedLocality[]>()
const columnFilters = usePageContext()
const notify = useNotify()
useEffect(() => {
// Filter localities for the map component
const localityIds = columnFilters.idList as unknown as number[]
const validIds = localityIds.filter(id => typeof id === 'number')
if (validIds.length > 0)
setFilteredLocalities(localitiesQueryData?.filter(locality => validIds.includes(locality.lid)))
else setFilteredLocalities(localitiesQueryData as SimplifiedLocality[])
}, [columnFilters, localitiesQueryData])
const columns = useMemo<MRT_ColumnDef<Locality>[]>(
() => [
{
@@ -415,7 +429,7 @@ export const LocalityTable = ({ selectorFn }: { selectorFn?: (newObject: Localit
return (
<>
<LocalitiesMap localitiesQueryData={localitiesQueryData} localitiesQueryIsFetching={localitiesQueryIsFetching} />
<LocalitiesMap localities={filteredLocalities} isFetching={localitiesQueryIsFetching} />
<TableView<Locality>
title="Localities"
selectorFn={selectorFn}
@@ -11,7 +11,7 @@ import { CoordinateSelectionMap } from '@/components/Map/CoordinateSelectionMap'
import { useState } from 'react'
import { convertDmsToDec, convertDecToDms } from '@/util/coordinateConversion'
import { validCountries } from '@/shared/validators/countryList'
import { LocalityMap } from '@/components/Map/LocalityMap'
import { SingleLocalityMap } from '@/components/Map/SingleLocalityMap'
export const LocalityTab = () => {
const { textField, radioSelection, dropdown, dropdownWithSearch, mode, bigTextField } =
@@ -183,10 +183,16 @@ export const LocalityTab = () => {
</HalfFrames>
<Grouped>
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 2, justifyContent: 'center' }}>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
gap: 2,
}}
>
<ArrayFrame array={latlong} title="Latitude & Longitude" />
<Box sx={{ minWidth: 250, minHeight: 250 }}>
{locality && <LocalityMap dec_lat={editData.dec_lat} dec_long={editData.dec_long} />}
<Box sx={{ width: '50%' }}>
{locality && <SingleLocalityMap dec_lat={editData.dec_lat} dec_long={editData.dec_long} />}
</Box>
</Box>
{!mode.read && coordinateButton}
+35 -51
View File
@@ -1,41 +1,38 @@
import { useGetLocalityDetailsQuery } from '../../redux/localityReducer'
import { useState, useEffect, useRef } from 'react'
import 'leaflet/dist/leaflet.css'
import L, { LatLngExpression } from 'leaflet'
import { borders } from './country_borders_WGS84'
import { Button } from '@mui/material'
import MapIcon from '@mui/icons-material/Map'
import { Locality } from '@/shared/types/data.js'
import { usePageContext } from '../Page'
import L, { LatLngExpression } from 'leaflet'
import { SimplifiedLocality } from '@/shared/types/data.js'
import { skipToken } from '@reduxjs/toolkit/query'
import './leaflet.markercluster.js'
import './MarkerCluster.css'
import './MarkerCluster.Default.css'
import { SlidingModal } from './SlidingModal.tsx'
import { LocalityInfo } from './LocalityDetailsPanel.tsx'
import '../../styles/LocalityMap.css'
import '../../styles/LocalitiesMap.css'
import northarrow from './images/north-arrow.png'
interface Props {
localitiesQueryData?: Locality[]
localitiesQueryIsFetching: boolean
localities?: SimplifiedLocality[]
isFetching: boolean
}
type CustomCircleMarkerOptions = L.CircleMarkerOptions & { localityId: number }
type CustomCircleMarker = L.CircleMarker & { options: CustomCircleMarkerOptions }
export const LocalitiesMap = ({ localitiesQueryData, localitiesQueryIsFetching }: Props) => {
export const LocalitiesMap = ({ localities, isFetching }: Props) => {
const [selectedLocality, setSelectedLocality] = useState<string | null>(null)
const mapRef = useRef<HTMLDivElement | null>(null)
const [map, setMap] = useState<L.Map | null>(null)
const [isOpen, setIsOpen] = useState(false)
const [localityDetailsIsOpen, setLocalityDetailsIsOpen] = useState(false)
const columnFilters = usePageContext()
const [cluster, setCluster] = useState(true)
const [clusteringEnabled, setClusteringEnabled] = useState(true)
// Use skipToken to skip query when state is null
const { data: localityDetailsQueryData, isFetching: detailsLoading } = useGetLocalityDetailsQuery(
selectedLocality ?? skipToken
) // use skipToken to skip query when state is null
)
useEffect(() => {
if (!mapRef.current) return
@@ -44,28 +41,30 @@ export const LocalitiesMap = ({ localitiesQueryData, localitiesQueryIsFetching }
setMap(mapInstance)
const coords: LatLngExpression = [15, 13]
mapInstance.setView(coords, 2)
mapInstance.setView(coords, 3)
// BASE MAPS
//// OpenTopoMap
// ---- Base maps ----
// OpenTopoMap
const topomap = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
attribution: 'Map data: © <a href="https://opentopomap.org">OpenTopoMap</a> (CC-BY-SA)',
noWrap: true,
}).addTo(mapInstance)
//// OpenStreetMap
// OpenStreetMap
const osm = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
noWrap: true,
})
// LAYERS ON TOP OF BASE MAPS
//// Add borders to the map
// ---- Layers on top of base maps ----
// Add borders to the map
borders.forEach(poly => {
L.polygon(poly as LatLngExpression[], { color: 'gray', weight: 1 }).addTo(mapInstance)
})
// create a polygon layer for the country borders that is in layer control panel..
// Create a polygon layer for the country borders that is in layer control panel.
const borderLayer = L.layerGroup()
borders.forEach(country_border => {
const polygon = L.polygon(country_border as LatLngExpression[], {
@@ -82,13 +81,13 @@ export const LocalitiesMap = ({ localitiesQueryData, localitiesQueryIsFetching }
Countries: borderLayer,
}
// Add a scale bar to the map
// Scale bar
L.control.scale({ position: 'bottomright' }).addTo(mapInstance)
//Add a layer control to the map
// Layer control
L.control.layers(baseMaps, {}, { position: 'topright' }).addTo(mapInstance)
// Add north-arrow to the map
// North-arrow
const northArrowControl = L.Control.extend({
onAdd: function () {
const img = L.DomUtil.create('img')
@@ -97,7 +96,6 @@ export const LocalitiesMap = ({ localitiesQueryData, localitiesQueryIsFetching }
return img
},
})
const northArrow = new northArrowControl({ position: 'bottomright' })
northArrow.addTo(mapInstance)
@@ -107,7 +105,7 @@ export const LocalitiesMap = ({ localitiesQueryData, localitiesQueryIsFetching }
}, [])
useEffect(() => {
if (!map || localitiesQueryIsFetching) return
if (!map || isFetching) return
// To prevent eslint from complaining about that 'markers' variable below:
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
@@ -117,18 +115,9 @@ export const LocalitiesMap = ({ localitiesQueryData, localitiesQueryIsFetching }
// @ts-expect-error The marker cluster library is a plain javascript library
// with no module exports that extends 'L' when imported
const newMarkers: Layer = cluster ? L.markerClusterGroup() : L.layerGroup()
const newMarkers: Layer = clusteringEnabled ? L.markerClusterGroup() : L.layerGroup()
const localityIds = columnFilters.idList as unknown as number[]
const filterApplied = localityIds.length > 0 && localityIds.every(id => id === null)
const validIds = localityIds.filter(id => typeof id === 'number')
const filteredLocalities = !filterApplied
? localitiesQueryData?.filter(locality => validIds.includes(locality.lid))
: localitiesQueryData
filteredLocalities?.forEach(locality => {
localities?.forEach(locality => {
const options: CustomCircleMarkerOptions = {
localityId: locality.lid,
radius: 4,
@@ -159,28 +148,23 @@ export const LocalitiesMap = ({ localitiesQueryData, localitiesQueryIsFetching }
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
map.removeLayer(newMarkers)
}
}, [localitiesQueryData, localitiesQueryIsFetching, columnFilters, map, cluster])
}, [localities, isFetching, map, clusteringEnabled])
document.title = 'Map'
return (
<>
<article id="localities-map">
<div id="map-container" className={isOpen ? 'open' : ''}>
<div ref={mapRef} style={{ flex: 1 }} />
{isOpen && (
<button className="cluster-btn" onClick={() => setCluster(cluster => !cluster)}>
{cluster ? 'Individual' : 'Cluster'}
</button>
)}
<article id="localities-map" className={isOpen ? 'open' : ''}>
<div className="map-container">
<div className="map" ref={mapRef} />
<button className="cluster" onClick={() => setClusteringEnabled(cluster => !cluster)}>
{clusteringEnabled ? 'Show individual' : 'Show cluster'}
</button>
<div className="gradient" onClick={() => setIsOpen(v => !v)}></div>
</div>
<div className="open-button" onClick={() => setIsOpen(v => !v)} title="Close map">
<span className="downarrow"></span>
</div>
{!localitiesQueryIsFetching && localitiesQueryData && (
<div className="button-row">
<Button variant="contained" startIcon={<MapIcon />} onClick={() => setIsOpen(v => !v)}>
{isOpen ? 'Close' : 'Open'} map
</Button>
</div>
)}
</article>
<SlidingModal isOpen={localityDetailsIsOpen} onClose={() => setLocalityDetailsIsOpen(false)}>
<LocalityInfo
@@ -52,7 +52,7 @@ export const LocalityInfo = ({ localityDetailsQueryData, selectedLocality, detai
{`${localityDetailsQueryData.dec_lat}, ${localityDetailsQueryData.dec_long}`}
</p>
<p>
<strong>Taxa:</strong> {data.length - 1}
<strong>Taxa:</strong> {data.length - 1 < 0 ? 0 : data.length - 1}
</p>
</div>
)}
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import 'leaflet/dist/leaflet.css'
import { borders } from './country_borders_WGS84'
import L, { LatLngExpression } from 'leaflet'
import '../../styles/LocalityMap.css'
import '../../styles/SingleLocalityMap.css'
import northarrow from './images/north-arrow.png'
interface Props {
@@ -10,7 +10,7 @@ interface Props {
dec_long: number | undefined
}
export const LocalityMap = ({ dec_lat, dec_long }: Props) => {
export const SingleLocalityMap = ({ dec_lat, dec_long }: Props) => {
const mapRef = useRef<HTMLDivElement | null>(null)
const [map, setMap] = useState<L.Map | null>(null)
@@ -111,7 +111,7 @@ export const LocalityMap = ({ dec_lat, dec_long }: Props) => {
document.title = 'Map'
return (
<article id="localities-map">
<article id="locality-map">
<div className={'map-locality-container'}>
<div ref={mapRef}></div>
</div>
File diff suppressed because one or more lines are too long
+6 -10
View File
@@ -5,12 +5,13 @@ import { clearUser } from '../redux/userReducer'
import { api } from '../redux/api'
import { Role } from '@/shared/types'
import { useUser } from '@/hooks/user'
import { ENV } from '@/util/config'
import { useNotify } from '@/hooks/notification'
import PersonIcon from '@mui/icons-material/Person'
import '../styles/NavBar.css'
import { LinkDefinition, NavBarLink } from './NavBarLink'
import logo from '../resource/nowlogo.jpg'
export const NavBar = () => {
const user = useUser()
const navigate = useNavigate()
@@ -53,16 +54,11 @@ export const NavBar = () => {
</MenuItem>
)
const getModeText = () => {
if (ENV === 'staging') return '(test)'
if (ENV === 'dev') return '(dev)'
return ''
}
return (
<AppBar position="static" id="main-navbar">
<Stack spacing={10} direction="row" marginLeft={2} justifyContent="flex-start">
<MenuItem component={Link} to="/">
<Typography fontSize={'1.4em'}>NOW Database {getModeText()}</Typography>
<div className="container">
<MenuItem component={Link} to="/" className="logo-link">
<img src={logo} title="Logo © Noira Martiskainen" />
</MenuItem>
<MenuList className="menu-list">
{pages
@@ -86,7 +82,7 @@ export const NavBar = () => {
</Stack>
)}
</Box>
</Stack>
</div>
</AppBar>
)
}
@@ -22,6 +22,7 @@ export const PersonTab = () => {
}, [currentUser])
const countryOptions = ['', ...validCountries]
const userGroupOptions = ['su', 'eu', 'er', 'pl', 'plp', 'no', 'ro']
const person = [
['Initials', textField('initials', { type: 'text', disabled: true })],
@@ -37,7 +38,10 @@ export const PersonTab = () => {
? [
['User Name', data.user?.user_name ?? ''],
['Last log in', lastLogin ? formatLastLoginDate(lastLogin) : 'No data'],
['User Group', data.user?.now_user_group ?? ''],
[
'User Group',
dropdownWithSearch('now_user_group', userGroupOptions, 'User Group', false, 'Choose user group'),
],
]
: [['Not a user']]
+3
View File
@@ -28,6 +28,7 @@ import { ProjectDetails } from './Project/ProjectDetails'
import { RegionTable } from './Region/RegionTable'
import { RegionDetails } from './Region/RegionDetails'
import { UserState } from '@/redux/userReducer'
import { FrontPage } from './FrontPage'
const noRights: EditRights = {}
const fullRights: EditRights = { new: true, edit: true, delete: true }
@@ -51,6 +52,8 @@ export const localityPage = (
/>
)
export const frontPage = <FrontPage />
export const crossSearchPage = (
<Page
tableView={<CrossSearchTable />}
+11 -2
View File
@@ -1,6 +1,6 @@
import { MRT_ColumnFiltersState, MRT_SortingState } from 'material-react-table'
import { api } from './api'
import { CrossSearch } from '@/shared/types'
import { CrossSearch, SimplifiedLocality } from '@/shared/types'
const crossSearchApi = api.injectEndpoints({
endpoints: builder => ({
@@ -13,7 +13,16 @@ const crossSearchApi = api.injectEndpoints({
}),
providesTags: result => (result ? [{ type: 'localities' }] : []),
}),
getAllCrossSearchLocalities: builder.query<
SimplifiedLocality[],
{ columnFilters: MRT_ColumnFiltersState; sorting: MRT_SortingState }
>({
query: ({ columnFilters, sorting }) => ({
url: `/crosssearch/localities/${JSON.stringify(columnFilters)}/${JSON.stringify(sorting)}`,
}),
providesTags: result => (result ? [{ type: 'localities' }] : []),
}),
}),
})
export const { useGetAllCrossSearchQuery } = crossSearchApi
export const { useGetAllCrossSearchQuery, useGetAllCrossSearchLocalitiesQuery } = crossSearchApi
+24
View File
@@ -0,0 +1,24 @@
import { api } from './api'
import { ActivityStatistic, Statistics } from '@/shared/types'
const statisticsApi = api.injectEndpoints({
endpoints: builder => ({
getStatistics: builder.query<Statistics, void>({
query: () => ({
url: `/statistics`,
}),
}),
getLocalityStatistics: builder.query<ActivityStatistic[], void>({
query: () => ({
url: `/statistics/locality`,
}),
}),
getSpeciesStatistics: builder.query<ActivityStatistic[], void>({
query: () => ({
url: `/statistics/species`,
}),
}),
}),
})
export const { useGetStatisticsQuery, useGetLocalityStatisticsQuery, useGetSpeciesStatisticsQuery } = statisticsApi
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+12 -1
View File
@@ -5,6 +5,8 @@ import * as LogPrisma from '../../../../backend/prisma/generated/now_log_test_cl
import { Editable, FixBigInt, RowState } from './util'
import { Decimal } from '../../../../backend/prisma/generated/now_test_client/runtime/library'
export type Statistics = { localityCount: number; speciesCount: number; localitySpeciesCount: number }
export type ActivityStatistic = { year: number; month: number; surname: string }
export type SpeciesType = FixBigInt<Prisma.com_species>
export type SedimentaryStructureValues = Prisma.now_ss_values
export type CollectingMethod = Prisma.now_coll_meth
@@ -15,7 +17,9 @@ export type SpeciesLocality = FixBigInt<Prisma.now_ls> & { now_loc: Prisma.now_l
export type LocalityUpdate = Prisma.now_lau & { now_lr: LocalityReference[] } & { updates: UpdateLog[] }
export type SpeciesUpdate = Prisma.now_sau & { now_sr: SpeciesReference[] } & { updates: UpdateLog[] }
export type Museum = Prisma.com_mlist
export type PersonDetailsType = Prisma.com_people & { user: Omit<Prisma.com_users, 'password, newpassword'> | null }
export type PersonDetailsType = Prisma.com_people & { user: Omit<Prisma.com_users, 'password, newpassword'> | null } & {
now_user_group: string
}
export type ProjectPeople = Prisma.now_proj_people
export type ProjectDetailsType = Prisma.now_proj & { now_proj_people: Array<ProjectPeople> }
export type Project = Prisma.now_proj
@@ -471,3 +475,10 @@ export type GeonamesJSON = {
}
export type ParsedGeoname = Pick<Geoname, 'name' | 'countryName' | 'fclName' | 'adminName1' | 'lat' | 'lng'>
export type SimplifiedLocality = {
lid: number
dec_lat: number
dec_long: number
loc_name: string
}
+15
View File
@@ -0,0 +1,15 @@
#wrapper {
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: space-between;
}
footer {
background-color: #2e7fab;
color: white;
padding: 0.6em;
padding-left: 1em;
padding-top: 0.7em;
font-weight: 500;
}
+156
View File
@@ -0,0 +1,156 @@
#front-page .top-info {
display: flex;
justify-content: space-between;
}
#front-page a {
color: #2e40aa;
}
#front-page h3 {
font-size: 1rem;
}
#front-page .top-info .left {
margin-left: 1em;
}
#front-page .top-info .image-container {
/* aspect-ratio: initial; */
flex: 1;
margin-left: 3em;
display: flex;
gap: 0.4em;
flex-direction: column;
justify-content: flex-end;
align-items: flex-end;
}
#front-page .top-info .image-container img {
width: 100%;
max-width: 16em;
min-width: 10em;
}
#front-page p {
font-size: 1.05rem;
}
#front-page p,
#front-page h1 {
max-width: 40em;
}
#front-page .map-container {
overflow: hidden;
position: relative;
margin-top: 0em;
user-select: none;
}
#front-page .stats {
display: flex;
gap: 1.4em;
font-size: 1.1rem;
padding: 1em;
padding-bottom: 0;
top: -1px;
left: 0;
right: 0;
z-index: 2;
}
#front-page .stats span b {
margin-right: 0.2em;
}
#front-page .map-container .map {
--margin-top: 18%;
margin-top: calc(var(--margin-top) * -1);
width: 100%;
clip-path: polygon(0% 23%, 100% var(--margin-top), 97% 62%, 10% 70%);
background-color: #2e7fab;
margin-bottom: -23%;
}
#front-page .map-container .map img {
width: 100%;
}
#front-page .map-container:hover .map img {
transform: translateX(calc(var(--x) * (-1 / 1.5))) translateY(calc(var(--y) * (-1 / 1.5))) scale(1.5);
transform-origin: 0 0;
}
#front-page .additional-info {
display: flex;
justify-content: stretch;
flex-wrap: wrap;
margin-bottom: 3em;
}
#front-page .additional-info p {
white-space: break-word;
word-wrap: break-word;
text-wrap: wrap;
overflow-x: hidden;
max-width: 100vw;
}
#front-page .additional-info > div {
flex: 1;
min-width: 24em;
}
#front-page .additional-info .activity-stats {
display: flex;
flex-wrap: wrap;
}
#front-page .additional-info .activity-stats > div {
width: fit-content;
}
#front-page .stat-table {
padding-right: 2em;
}
#front-page .stat-table table {
width: fit-content;
border-collapse: collapse;
}
#front-page .stat-table table * {
white-space: nowrap;
}
#front-page .stat-table table td,
#front-page .stat-table table th {
border: 1px solid rgb(177, 177, 177);
border-width: 0 1px 1px 0;
text-align: left;
padding: 0.1em 0.4em;
}
#front-page .stat-table table th {
border-width: 0 1px 2px 0;
}
#front-page .stat-table table th:last-child,
#front-page .stat-table table td:last-child {
border-right: none;
}
#front-page .stat-table table tr:last-child td {
border-bottom: none;
}
@media screen and (max-width: 900px) {
#front-page .stats {
padding-bottom: 1em;
}
#front-page .top-info .image-container {
display: none;
}
}
+122
View File
@@ -0,0 +1,122 @@
#localities-map .map-container {
position: relative;
--map-height: 70vh;
overflow: hidden;
height: calc(var(--map-height) * 0.15);
transition: height 0.2s ease-out;
margin-bottom: 0.8em;
border-radius: 0.25em;
}
#localities-map .gradient {
z-index: 9999;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
opacity: 1;
transition: opacity 0.2s ease-out;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0) 40%, rgb(255, 255, 255) 100%);
cursor: pointer;
}
#localities-map.open .gradient {
opacity: 0;
pointer-events: none;
}
#localities-map.open .map-container {
height: var(--map-height);
position: relative;
}
#localities-map .map-container .map {
height: var(--map-height);
width: 100%;
}
#localities-map:not(.open) .map-container .map {
pointer-events: none !important;
user-select: none;
}
#localities-map:not(.open) .map-container .map * {
pointer-events: none;
}
#localities-map:not(.open) button.cluster {
pointer-events: none;
}
#localities-map button.cluster {
user-select: none;
cursor: pointer;
background-color: #fdfeff;
position: absolute;
padding: 0.3em 0.6em;
z-index: 1000;
left: 4.2em;
top: 0.90em;
border-radius: 0.25em;
border: none;
box-shadow: 0 0 0 0.15em rgba(0, 0, 0, 0.22);
}
#localities-map button.cluster:hover {
background-color: #f4f4f4;
}
#localities-map .open-button {
width: 100%;
display: flex;
justify-content: center;
margin-bottom: 1rem;
height: 4em;
cursor: pointer;
}
#localities-map .downarrow {
--line-width: 4px;
--radius: 3px;
--angle: 130deg;
width: 2em;
height: 2em;
display: flex;
align-items: flex-end;
transform: translateY(-0.4em) rotate(-45deg);
transition: transform 0.3s ease-out;
}
#localities-map.open .downarrow {
transform: scaleY(-1) translateY(-1.8em) rotate(-45deg);
}
#localities-map .downarrow::before,
#localities-map .downarrow::after {
content: "";
display: block;
background-color: #a5a5a5;
transition: background-color 0.2s ease-out;
}
#localities-map .open-button:hover .downarrow::before,
#localities-map .open-button:hover .downarrow::after {
background-color: #606060;
}
#localities-map .downarrow::before {
height: 100%;
width: var(--line-width);
border-radius: var(--radius);
transform: rotate(calc(45deg - var(--angle) * 0.5));
transform-origin: 50% 100%;
}
#localities-map .downarrow::after {
width: 100%;
height: var(--line-width);
border-radius: var(--radius);
transform: rotate(calc(-1 * (45deg - var(--angle) * 0.5)));
transform-origin: 0% 50%;
margin-left: calc(-1 * var(--line-width));
}
-54
View File
@@ -1,54 +0,0 @@
#map-container {
--map-height: 70vh;
overflow: hidden;
height: 0;
transition: height 0.2s ease-out;
margin-bottom: 0.8em;
border-radius: 0.25em;
}
#localities-map button {
background-color: #ab742e;
box-shadow: inset 0 -3px 3px #00000011;
border-radius: 0.25em 0.25em 0 0;
margin-left: 0.25em;
}
#map-container.open {
height: var(--map-height);
display: flex;
}
#map-container > div {
height: var(--map-height);
opacity: 0;
transition: opacity 0.2s ease-out;
}
#map-container.open > div {
opacity: 1;
}
.map-locality-container > div {
height: 250px;
margin-top: 2rem;
border-radius: 0.25em;
}
#localities-map .button-row {
display: flex;
justify-content: flex-end;
}
#localities-map .cluster-btn {
background-color: #fdfeff;
height: 2em;
width: 5.5em;
position: absolute;
z-index: 1000;
margin-left: 4.2em;
margin-top: 0.95em;
border-radius: 0.25em;
border: none;
box-shadow: 0 0 0 0.15em rgba(0, 0, 0, 0.22);
}
+21 -2
View File
@@ -1,6 +1,8 @@
#main-navbar {
background-color: #2e7fab;
--color-hl: #3896c9;
padding-left: 0;
margin-left: 0;
}
#main-navbar > * {
@@ -25,6 +27,7 @@
#main-navbar .username-box {
display: flex;
align-items: center;
border: 2px solid var(--color-hl);
border-radius: 0.25rem;
padding: 0.4em 1em 0.3em 0.5em;
@@ -34,11 +37,17 @@
font-weight: 500;
}
#main-navbar .container {
display: flex;
align-items: stretch;
}
#main-navbar .menu-list {
margin-left: 1.4em;
margin-left: 2em;
padding: 0.6em 0;
display: flex;
flex-wrap: wrap;
align-items: center;
align-items: stretch;
}
#main-navbar .dropdown-link {
@@ -77,3 +86,13 @@
#main-navbar .username-box:hover {
background-color: var(--color-hl);
}
#main-navbar .logo-link {
padding: 0;
}
#main-navbar .logo-link img {
height: 4em;
transform-origin: 0 80%;
transform: scale(1.03);
}
@@ -0,0 +1,6 @@
.map-locality-container > div {
height: 260px;
margin-top: 1rem;
margin-right: 0.4rem;
border-radius: 0.25em;
}
+2 -2
View File
@@ -12,9 +12,9 @@
background-color: white;
padding: 2em 2em;
width: calc(100% - 4em);
height: calc(100% - 2em);
max-height: calc(100% - 2em);
height: fit-content;
max-width: 50em;
max-height: fit-content;
border-radius: 0.25rem;
overflow-y: auto;
}