mirror of
https://github.com/nowcommunity/nowdatabase.git
synced 2026-10-12 19:13:52 +03:00
Merge remote-tracking branch 'origin/main' into synonym-button-in-species-table
This commit is contained in:
@@ -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,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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Architecture picture, stack
|
||||
|
||||
To do any updates to this picture, update documentation/images/architecture.drawio.svg in drawio/diagrams.net
|
||||
|
||||

|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: '© <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>
|
||||
)}
|
||||
|
||||
+3
-3
@@ -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
@@ -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']]
|
||||
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user