Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
FROM node:22 as build
ARG NODE_ENV=production
ARG NODE_OPTIONS=--max_old_space_size=700
ENV OCL_ONLINE=${OCL_ONLINE}
ENV BRIDGE_MATCH_URL=${BRIDGE_MATCH_URL:-''}
ENV NPM_CONFIG_LOGLEVEL warn
ENV WEB_PORT=${WEB_PORT:-4004}
Expand All @@ -16,6 +15,7 @@ ENV OIDC_RP_CLIENT_ID=${OIDC_RP_CLIENT_ID}
ENV OIDC_SERVER_URL=${OIDC_SERVER_URL}
ENV OIDC_REALM=${OIDC_REALM}
ENV OCL_ONLINE_API_URL=${OCL_ONLINE_API_URL}
ENV TERMBROWSER_URL=${TERMBROWSER_URL}
ENV SCISPACY_LOINC_API_URL=${SCISPACY_LOINC_API_URL}
ENV REPO_SUGGESTIONS=${REPO_SUGGESTIONS}
RUN mkdir /app
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ services:
- ERRBIT_URL
- ERRBIT_KEY
- HOTJAR_ID
- OCL_ONLINE=${OCL_ONLINE-false}
- BRIDGE_MATCH_URL
- AI_ASSISTANT_API_URL
- SCISPACY_LOINC_API_URL
- OCL_ONLINE_API_URL
- TERMBROWSER_URL=${TERMBROWSER_URL-}
- REPO_SUGGESTIONS=${REPO_SUGGESTIONS}
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@
"react-is": "^18.3.1"
},
"scripts": {
"start": "./node_modules/webpack-dev-server/bin/webpack-dev-server.js --progress --host 0.0.0.0 --port ${WEB_PORT} --env API_URL=${API_URL} --env NODE_ENV=${NODE_ENV} --env RECAPTCHA_SITE_KEY=${RECAPTCHA_SITE_KEY} --env GA_ACCOUNT_ID=${GA_ACCOUNT_ID} --env HOTJAR_ID=${HOTJAR_ID} --env ERRBIT_URL=${ERRBIT_URL} --env ERRBIT_KEY=${ERRBIT_KEY} --env LOGIN_REDIRECT_URL=${LOGIN_REDIRECT_URL} --env OIDC_RP_CLIENT_ID=${OIDC_RP_CLIENT_ID} --env OIDC_SERVER_URL=${OIDC_SERVER_URL} --env OIDC_REALM=${OIDC_REALM} --env OCL_ONLINE=${OCL_ONLINE} --env BRIDGE_MATCH_URL=${BRIDGE_MATCH_URL} --env AI_ASSISTANT_API_URL=${AI_ASSISTANT_API_URL} --env SCISPACY_LOINC_API_URL=${SCISPACY_LOINC_API_URL} --env OCL_ONLINE_API_URL=${OCL_ONLINE_API_URL} --env REPO_SUGGESTIONS=${REPO_SUGGESTIONS} --mode ${NODE_ENV} --hot",
"build": "node --max-old-space-size=1536 ./node_modules/webpack/bin/webpack.js --progress --env API_URL=${API_URL} --env NODE_ENV=${NODE_ENV} --env RECAPTCHA_SITE_KEY=${RECAPTCHA_SITE_KEY} --env GA_ACCOUNT_ID=${GA_ACCOUNT_ID} --env HOTJAR_ID=${HOTJAR_ID} --env ERRBIT_URL=${ERRBIT_URL} --env ERRBIT_KEY=${ERRBIT_KEY} --env LOGIN_REDIRECT_URL=${LOGIN_REDIRECT_URL} --env OIDC_RP_CLIENT_ID=${OIDC_RP_CLIENT_ID} --env OIDC_SERVER_URL=${OIDC_SERVER_URL} --env OIDC_REALM=${OIDC_REALM} --env OCL_ONLINE=${OCL_ONLINE} --env BRIDGE_MATCH_URL=${BRIDGE_MATCH_URL} --env AI_ASSISTANT_API_URL=${AI_ASSISTANT_API_URL} --env SCISPACY_LOINC_API_URL=${SCISPACY_LOINC_API_URL} --env OCL_ONLINE_API_URL=${OCL_ONLINE_API_URL} --env REPO_SUGGESTIONS=${REPO_SUGGESTIONS} --mode ${NODE_ENV}",
"start": "./node_modules/webpack-dev-server/bin/webpack-dev-server.js --progress --host 0.0.0.0 --port ${WEB_PORT} --env API_URL=${API_URL} --env NODE_ENV=${NODE_ENV} --env RECAPTCHA_SITE_KEY=${RECAPTCHA_SITE_KEY} --env GA_ACCOUNT_ID=${GA_ACCOUNT_ID} --env HOTJAR_ID=${HOTJAR_ID} --env ERRBIT_URL=${ERRBIT_URL} --env ERRBIT_KEY=${ERRBIT_KEY} --env LOGIN_REDIRECT_URL=${LOGIN_REDIRECT_URL} --env OIDC_RP_CLIENT_ID=${OIDC_RP_CLIENT_ID} --env OIDC_SERVER_URL=${OIDC_SERVER_URL} --env OIDC_REALM=${OIDC_REALM} --env BRIDGE_MATCH_URL=${BRIDGE_MATCH_URL} --env AI_ASSISTANT_API_URL=${AI_ASSISTANT_API_URL} --env SCISPACY_LOINC_API_URL=${SCISPACY_LOINC_API_URL} --env OCL_ONLINE_API_URL=${OCL_ONLINE_API_URL} --env TERMBROWSER_URL=${TERMBROWSER_URL} --env REPO_SUGGESTIONS=${REPO_SUGGESTIONS} --mode ${NODE_ENV} --hot",
"build": "node --max-old-space-size=1536 ./node_modules/webpack/bin/webpack.js --progress --env API_URL=${API_URL} --env NODE_ENV=${NODE_ENV} --env RECAPTCHA_SITE_KEY=${RECAPTCHA_SITE_KEY} --env GA_ACCOUNT_ID=${GA_ACCOUNT_ID} --env HOTJAR_ID=${HOTJAR_ID} --env ERRBIT_URL=${ERRBIT_URL} --env ERRBIT_KEY=${ERRBIT_KEY} --env LOGIN_REDIRECT_URL=${LOGIN_REDIRECT_URL} --env OIDC_RP_CLIENT_ID=${OIDC_RP_CLIENT_ID} --env OIDC_SERVER_URL=${OIDC_SERVER_URL} --env OIDC_REALM=${OIDC_REALM} --env BRIDGE_MATCH_URL=${BRIDGE_MATCH_URL} --env AI_ASSISTANT_API_URL=${AI_ASSISTANT_API_URL} --env SCISPACY_LOINC_API_URL=${SCISPACY_LOINC_API_URL} --env OCL_ONLINE_API_URL=${OCL_ONLINE_API_URL} --env TERMBROWSER_URL=${TERMBROWSER_URL} --env REPO_SUGGESTIONS=${REPO_SUGGESTIONS} --mode ${NODE_ENV}",
"eslint": "./node_modules/.bin/eslint ./src --ext .jsx,.js",
"test": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test 'src/**/__tests__/**/*.test.js'",
"test:integration": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --import ./test-integration-setup.js --test 'src/**/__integration__/**/*.test.js'",
Expand Down
32 changes: 32 additions & 0 deletions src/common/__tests__/appUrls.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import test from 'node:test'
import assert from 'node:assert/strict'

const { resolveV3URL, resolveIntegratedV3URL, PRODUCTION_TERMBROWSER_URL } = await import('../appUrls.js')

test('configured URL wins and loses its trailing slash', () => {
assert.equal(resolveV3URL('https://tb.example.org/', 'https://map.qa.openconceptlab.org', 'map.qa.openconceptlab.org'), 'https://tb.example.org')
})

test('localhost points to the local TermBrowser', () => {
assert.equal(resolveV3URL('', 'http://localhost:4004', 'localhost:4004'), 'http://localhost:4002')
})

test('official Mapper hosts map to the matching TermBrowser environment', () => {
assert.equal(resolveV3URL('', 'https://map.openconceptlab.org', 'map.openconceptlab.org'), 'https://app.v3.openconceptlab.org')
assert.equal(resolveV3URL('', 'https://map.qa.openconceptlab.org', 'map.qa.openconceptlab.org'), 'https://app.v3.qa.openconceptlab.org')
assert.equal(resolveV3URL(undefined, 'https://map.staging.who.openconceptlab.org', 'map.staging.who.openconceptlab.org'), 'https://app.v3.staging.who.openconceptlab.org')
})

test('custom hosts without configuration point to production', () => {
assert.equal(resolveV3URL('', 'https://mapper.example.org', 'mapper.example.org'), PRODUCTION_TERMBROWSER_URL)
assert.equal(resolveV3URL('', undefined, undefined), PRODUCTION_TERMBROWSER_URL)
})

test('an integrated TermBrowser needs configuration or an official Mapper host', () => {
assert.equal(resolveIntegratedV3URL('https://tb.example.org/', 'https://mapper.example.org', 'mapper.example.org'), 'https://tb.example.org')
assert.equal(resolveIntegratedV3URL('http://localhost:4002', 'http://localhost:4004', 'localhost:4004'), 'http://localhost:4002')
assert.equal(resolveIntegratedV3URL('', 'http://localhost:4004', 'localhost:4004'), '', 'a standalone local Mapper keeps its own pages')
assert.equal(resolveIntegratedV3URL('', 'https://map.qa.openconceptlab.org', 'map.qa.openconceptlab.org'), 'https://app.v3.qa.openconceptlab.org')
assert.equal(resolveIntegratedV3URL('', 'https://mapper.example.org', 'mapper.example.org'), '')
assert.equal(resolveIntegratedV3URL('', undefined, undefined), '')
})
20 changes: 20 additions & 0 deletions src/common/__tests__/breadcrumb.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import test from 'node:test'
import assert from 'node:assert/strict'

const { projectCrumbs } = await import('../breadcrumb.js')

const toV3URL = path => `https://tb.example.org/#${path}`

test('integrated deployments link both crumbs to TermBrowser', () => {
assert.deepEqual(projectCrumbs('/users/test/', true, toV3URL).map(c => c.href), [
'https://tb.example.org/#/users/test/',
'https://tb.example.org/#/users/test/map-projects'
])
})

test('standalone deployments keep the owner as text and the projects crumb local', () => {
assert.deepEqual(projectCrumbs('/users/test/', false, () => { throw new Error('no TermBrowser link') }).map(c => c.href), [
null,
'#/map-projects'
])
})
9 changes: 9 additions & 0 deletions src/common/__tests__/mapProjects.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import test from 'node:test'
import assert from 'node:assert/strict'

const { projectCount } = await import('../mapProjects.js')

test('projectCount sums num_found across user and organization lists', () => {
assert.equal(projectCount({headers: {num_found: '4'}}, {headers: {num_found: '3'}}), 7)
assert.equal(projectCount({headers: {}}, undefined), 0)
})
41 changes: 41 additions & 0 deletions src/common/__tests__/navigation.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import test from 'node:test'
import assert from 'node:assert/strict'

const { trayItems, createItems } = await import('../navigation.js')

const user = {url: '/users/test/'}

test('tray lists dashboard, repositories and mapping projects', () => {
assert.deepEqual(trayItems(user, true).map(i => [i.key, i.path]), [
['dashboard', '/'],
['repos', '/users/test/repos'],
['map-projects', '/users/test/map-projects'],
])
})

test('tray omits mapping projects without a mapper', () => {
assert.deepEqual(trayItems(user, false).map(i => i.key), ['dashboard', 'repos'])
})

test('create menu sends the mapping project to the mapper app', () => {
const items = createItems(user, true)
assert.equal(items.find(i => i.key === 'map-project').app, 'mapper')
assert.equal(createItems(user, false).some(i => i.key === 'map-project'), false)
assert.equal(items.find(i => i.key === 'import').divider, true)
})

const { isTrayItemSelected } = await import('../navigation.js')

test('mapping projects stays selected on a project page', () => {
const item = trayItems(user, true).find(i => i.key === 'map-projects')
assert.equal(isTrayItemSelected(item, '/users/test/map-projects'), true)
assert.equal(isTrayItemSelected(item, '/users/test/map-projects/'), true)
assert.equal(isTrayItemSelected(item, '/users/test/map-projects/123/'), true)
assert.equal(isTrayItemSelected(item, '/users/test/repos'), false)
})

test('dashboard is selected only on the root path', () => {
const item = trayItems(user, true).find(i => i.key === 'dashboard')
assert.equal(isTrayItemSelected(item, '/'), true)
assert.equal(isTrayItemSelected(item, '/users/test/map-projects'), false)
})
17 changes: 17 additions & 0 deletions src/common/appUrls.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export const PRODUCTION_TERMBROWSER_URL = 'https://app.v3.openconceptlab.org'

const OFFICIAL_MAPPER_ORIGIN = /^https:\/\/map\.([a-z]+\.)*openconceptlab\.org$/

// A TermBrowser counts as part of this installation only when it's configured
// or this is an official Mapper host; a standalone Mapper keeps its own pages.
export const resolveIntegratedV3URL = (configured, origin) => {
const url = (configured || '').trim().replace(/\/+$/, '')
if(url)
return url
if(OFFICIAL_MAPPER_ORIGIN.test(origin || ''))
return origin.replace('//map.', '//app.v3.')
return ''
}

export const resolveV3URL = (configured, origin, host) =>
resolveIntegratedV3URL(configured, origin) || (host?.includes('localhost') ? 'http://localhost:4002' : PRODUCTION_TERMBROWSER_URL)
4 changes: 4 additions & 0 deletions src/common/breadcrumb.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const projectCrumbs = (ownerURL, integrated, toV3URL) => [
{key: 'owner', href: integrated ? toV3URL(ownerURL) : null},
{key: 'projects', href: integrated ? toV3URL(`${ownerURL}map-projects`) : '#/map-projects'}
]
1 change: 1 addition & 0 deletions src/common/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,4 @@ export const MAPPER_ANNOUNCEMENT_LETTER_URL = 'https://openconceptlab.org/blog/l
export const MAPPER_ANNOUNCEMENT_PRICING_URL = 'https://openconceptlab.org/pricing?utm_source=oclmap&utm_medium=banner&utm_campaign=subscription_dates'
export const TERMS_OF_USE_URL = 'https://openconceptlab.org/terms-of-use'
export const PRIVACY_POLICY_URL = 'https://openconceptlab.org/privacy-policy'
export const COMMUNITY_SITE_URL = 'https://openconceptlab.org'
4 changes: 4 additions & 0 deletions src/common/mapProjects.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const projectCount = (...responses) => responses.reduce(
(total, response) => total + (parseInt(response?.headers?.num_found) || 0),
0
)
21 changes: 21 additions & 0 deletions src/common/navigation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export const trayItems = (user, hasMapper) => [
{key: 'dashboard', app: 'v3', path: '/', labelKey: 'dashboard.my'},
{key: 'repos', app: 'v3', path: `${user?.url}repos`, labelKey: 'user.my_repositories'},
...(hasMapper ? [{key: 'map-projects', app: 'v3', path: `${user?.url}map-projects`, labelKey: 'user.my_mapping_projects'}] : [])
]

export const createItems = (user, hasMapper) => [
{key: 'org', app: 'v3', path: '/orgs/new'},
{key: 'repo', app: 'v3', path: `${user?.url}repos/new`},
...(hasMapper ? [{key: 'map-project', app: 'mapper', path: '/map-projects/new'}] : []),
{key: 'import', app: 'v3', path: '/imports', divider: true}
]

export const isTrayItemSelected = (item, pathname) => {
if(!pathname)
return false
if(item.key === 'dashboard')
return pathname === item.path
const base = item.path.replace(/\/+$/, '')
return pathname === base || pathname === `${base}/` || pathname.startsWith(`${base}/`)
}
17 changes: 7 additions & 10 deletions src/common/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import GAService from '../services/GAService';
import { ensureIdToken, prepareSignOut, settleForcedSignOut, renewSessionSilently } from './ssoSession';
import { SERVER_CONFIGS } from './serverConfigs';
import { legacyHashRoute, isSameSitePath } from './history';
import { resolveV3URL, resolveIntegratedV3URL } from './appUrls';

export const currentPath = () => window.location.pathname;

Expand Down Expand Up @@ -245,7 +246,7 @@ export const arrayToCSV = objArray => {
}

export const refreshCurrentUserCache = (callback, onFailure) => {
APIService.user().get(null, null, {includeAuthGroups: true, includeSubscribedOrgs: true, includeCapabilities: true}).then(response => {
APIService.user().get(null, null, {includeAuthGroups: true, includeSubscribedOrgs: true, includeFollowing: true, includeCapabilities: true}).then(response => {
if(response?.status === 200) {
localStorage.setItem('user', JSON.stringify(response.data));
if(callback) callback(response);
Expand Down Expand Up @@ -1093,20 +1094,16 @@ export const highlightTexts = (items, texts, unmark=false, additionalTexts) => {

export const pluralize = (count, singular, plural) => `${count?.toLocaleString()} ${count === 1 ? singular : plural}`;

export const toV3URL = path => {
let url = 'https://app.v3.openconceptlab.org'
if(window.location.host?.includes('localhost'))
url = 'http://localhost:4002'
if(['map.dev.openconceptlab.org', 'map.demo.openconceptlab.org'].includes(window.location.host))
url = 'https://app.v3.qa.openconceptlab.org'
if(window.location.host.match('map.*.openconceptlab.org'))
url = window.location.origin.replace('//map.', '//app.v3.')
export const getV3URL = () => resolveV3URL(window.TERMBROWSER_URL || process.env.TERMBROWSER_URL, window.location.origin, window.location.host)

export const hasTermBrowser = () => Boolean(resolveIntegratedV3URL(window.TERMBROWSER_URL || process.env.TERMBROWSER_URL, window.location.origin, window.location.host))

export const toV3URL = path => {
let referrerParams = `referrer=${window.location.href}`
if(isLoggedIn())
referrerParams += '?auth=true'

return `${url}/#${path || '/'}?${referrerParams}`
return `${getV3URL()}/#${path || '/'}?${referrerParams}`
}

export const isV3URL = url => {
Expand Down
6 changes: 3 additions & 3 deletions src/components/app/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import OIDLoginCallback from '../users/OIDLoginCallback';
import { OperationsContext } from './LayoutContext';
import Alert from '../common/Alert';
import MapProjectRoute from '../map-projects/MapProjectRoute'
import MapProjects from '../map-projects/MapProjects'
import MapProjectsRedirect from '../map-projects/MapProjectsRedirect'
import { useTranslation } from 'react-i18next';

const AuthenticationRequiredRoute = ({component: Component, ...rest}) => {
Expand Down Expand Up @@ -164,8 +164,8 @@ const App = props => {
<Switch>
<Route exact path="/signin" component={SigninRedirect} />
<Route exact path="/oidc/login" component={OIDLoginCallback} />
<AuthenticationRequiredRoute exact path='/' component={MapProjects} />
<AuthenticationRequiredRoute exact path='/map-projects' component={MapProjects} />
<AuthenticationRequiredRoute exact path='/' component={MapProjectsRedirect} />
<AuthenticationRequiredRoute exact path='/map-projects' component={MapProjectsRedirect} />
<AuthenticationRequiredRoute exact path='/map-projects/new' component={MapProjectRoute} />
<AuthenticationRequiredRoute exact path='/:ownerType/:owner/map-projects/:projectId' component={MapProjectRoute} />
<Route exact path='/403' component={Error403} />
Expand Down
2 changes: 1 addition & 1 deletion src/components/app/DocumentTitle.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { getSiteTitle } from '../../common/utils';

const SITE_TITLE = getSiteTitle()

export const getAppTitle = () => `${getSiteTitle()} Mapper`
export const getAppTitle = () => getSiteTitle()

/*eslint no-useless-escape: 0*/
const TITLES = {
Expand Down
4 changes: 3 additions & 1 deletion src/components/app/Footer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { get } from 'lodash';
import {
getAppliedServerConfig
} from '../../common/utils';
import { TERMS_OF_USE_URL, PRIVACY_POLICY_URL } from '../../common/constants';
import { COMMUNITY_SITE_URL, TERMS_OF_USE_URL, PRIVACY_POLICY_URL } from '../../common/constants';
import AppVersions from './AppVersions';
import './Footer.scss';

Expand Down Expand Up @@ -44,6 +44,8 @@ const Footer = () => {
<footer ref={ref} className='col-sm-12 footer-container flex-vertical-center' style={{textAlign: 'right'}}>
{/* On every page, signed in or not: using the Mapper means accepting the Terms of Use */}
<div className='footer-legal flex-vertical-center'>
<Link href={COMMUNITY_SITE_URL} target='_blank' rel='noopener noreferrer' underline='hover' sx={LEGAL_LINK_SX}>{t('common.community_website')}</Link>
<Divider orientation='vertical' className='footer-divider-vertical' />
<Link href={TERMS_OF_USE_URL} target='_blank' rel='noopener noreferrer' underline='hover' sx={LEGAL_LINK_SX}>{t('common.terms_of_use')}</Link>
<Divider orientation='vertical' className='footer-divider-vertical' />
<Link href={PRIVACY_POLICY_URL} target='_blank' rel='noopener noreferrer' underline='hover' sx={LEGAL_LINK_SX}>{t('common.privacy_policy')}</Link>
Expand Down
22 changes: 21 additions & 1 deletion src/components/app/HeaderControls.jsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,34 @@
import React from 'react';
import IconButton from '@mui/material/IconButton'
import AddIcon from '@mui/icons-material/Add'
import UserMenu from '../users/UserMenu';
import UserProfileButton from '../users/UserProfileButton';
import AddMenuList from '../users/AddMenuList'
import HeaderSearch from './HeaderSearch'
import { isLoggedIn } from '../../common/utils';


const HeaderControls = () => {
const [userMenu, setUserMenu] = React.useState(false)
const [addMenuAnchorEl, setAddMenuAnchorEl] = React.useState(null)

const authenticated = isLoggedIn()

return (
<div className='col-xs-3 padding-0' style={{textAlign: 'right'}}>
<div className='col-xs-4 padding-0' style={{textAlign: 'right'}}>
{authenticated && <HeaderSearch />}
{
authenticated &&
<IconButton sx={{marginRight: '8px'}} aria-label='create' onClick={event => setAddMenuAnchorEl(event.currentTarget)}>
<AddIcon />
</IconButton>
}
<UserProfileButton onClick={() => setUserMenu(true)} />
<UserMenu isOpen={userMenu} onClose={() => setUserMenu(false)} />
{
authenticated &&
<AddMenuList open={Boolean(addMenuAnchorEl)} onClose={() => setAddMenuAnchorEl(null)} anchorEl={addMenuAnchorEl} />
}
</div>
)
}
Expand Down
Loading
Loading