Init
This commit is contained in:
commit
f0d178101a
|
@ -0,0 +1,17 @@
|
|||
*.pyc
|
||||
.vscode/
|
||||
.history
|
||||
.env
|
||||
.venv
|
||||
db.sqlite3
|
||||
node_modules
|
||||
.DS_Store
|
||||
/staticfiles
|
||||
!/assets/staticfiles
|
||||
medias/
|
||||
media/
|
||||
*.mo
|
||||
efundburo/production.py
|
||||
*.ipynb
|
||||
alpine
|
||||
data.json
|
|
@ -0,0 +1,49 @@
|
|||
# This file is a template, and might need editing before it works on your project.
|
||||
# Official framework image. Look for the different tagged releases at:
|
||||
# https://hub.docker.com/r/library/python
|
||||
image: python:latest
|
||||
|
||||
# Pick zero or more services to be used on all builds.
|
||||
# Only needed when using a docker container to run your tests in.
|
||||
# Check out: http://docs.gitlab.com/ce/ci/docker/using_docker_images.html#what-is-a-service
|
||||
services:
|
||||
- postgres:latest
|
||||
|
||||
variables:
|
||||
POSTGRES_DB: database_name
|
||||
|
||||
# This folder is cached between builds
|
||||
# http://docs.gitlab.com/ce/ci/yaml/README.html#cache
|
||||
cache:
|
||||
paths:
|
||||
- ~/.cache/pip/
|
||||
|
||||
# This is a basic example for a gem or script which doesn't use
|
||||
# services such as redis or postgres
|
||||
before_script:
|
||||
- python -V # Print out python version for debugging
|
||||
# Uncomment next line if your Django app needs a JS runtime:
|
||||
# - apt-get update -q && apt-get install nodejs -yqq
|
||||
- pip install -r requirements.txt
|
||||
|
||||
# To get Django tests to work you may need to create a settings file using
|
||||
# the following DATABASES:
|
||||
#
|
||||
# DATABASES = {
|
||||
# 'default': {
|
||||
# 'ENGINE': 'django.db.backends.postgresql_psycopg2',
|
||||
# 'NAME': 'ci',
|
||||
# 'USER': 'postgres',
|
||||
# 'PASSWORD': 'postgres',
|
||||
# 'HOST': 'postgres',
|
||||
# 'PORT': '5432',
|
||||
# },
|
||||
# }
|
||||
#
|
||||
# and then adding `--settings app.settings.ci` (or similar) to the test command
|
||||
|
||||
test:
|
||||
variables:
|
||||
DATABASE_URL: "postgresql://postgres:postgres@postgres:5432/$POSTGRES_DB"
|
||||
script:
|
||||
- python manage.py test
|
|
@ -0,0 +1,8 @@
|
|||
printWidth: 100
|
||||
tabWidth: 4
|
||||
useTabs: true
|
||||
semi: true
|
||||
singleQuote: true
|
||||
jsxSingleQuote: true
|
||||
trailingComma: "none"
|
||||
arrowParens: "avoid"
|
|
@ -0,0 +1,63 @@
|
|||
# eFundBüro
|
||||
|
||||
## How to install
|
||||
|
||||
- clone repository
|
||||
- Tested with python version 3.6
|
||||
- create virtualenv and `pip install -r requirements.txt`
|
||||
- Run `python manage.py makemigrations`
|
||||
- create db with `python manage.py migrate`
|
||||
- create superuser with `python manage.py createsuperuser`
|
||||
- import catalog from xlsx `python manage.py import_catalog ~/ownCloud/Project_eFundbuero/Datenbank-Zeugs/Fundbüro\ Muster\ Export\ aus\ MuseumPlus.xlsx`
|
||||
|
||||
## Sample uwsgi config file
|
||||
|
||||
```
|
||||
[uwsgi]
|
||||
socket = /home/app/app/uwsgi.sock
|
||||
chdir = /home/app/app
|
||||
venv = /home/app/pyvenv
|
||||
wsgi-file = efundburo/wsgi.py
|
||||
processes = 4
|
||||
threads = 2
|
||||
chmod-socket = 666
|
||||
vacuum = true
|
||||
plugins = python3
|
||||
uid = app
|
||||
gid = app
|
||||
env = DJANGO_SETTINGS_MODULE=efundburo.production
|
||||
env = DEBUG=True
|
||||
wsgi-disable-file-wrapper = true
|
||||
```
|
||||
|
||||
## Production setup
|
||||
|
||||
### Endpoints
|
||||
|
||||
#### API
|
||||
|
||||
* `GET /search/?query={keyword}` allow to retreive item matching the keyword in the
|
||||
item title, description or inventory_number.
|
||||
* `GET /item/{item_pk}/` return the details of an item
|
||||
* `GET /item/{item_pk}/comments/` return the validated comments of an item.
|
||||
* `POST /item/{item_pk}/comments/` allow you to create a new comment. The endpoint
|
||||
expect a JSON dict with the following keys :
|
||||
* `comment`: the content of the comment as a string
|
||||
* `author`: the author of the comment as a string
|
||||
* *optional* `field_specific`: the field to comment on. Can be any of the
|
||||
field of the Item model
|
||||
|
||||
### Application
|
||||
|
||||
Application is running as `efundbuero` user in `/home/efundbuero/efundbuero`. To deploy:
|
||||
|
||||
```
|
||||
# SSH to server
|
||||
sudo su - efundbuero
|
||||
workon efundbuero
|
||||
cd efundbuero
|
||||
git pull
|
||||
# Run migrations, collect staticfiles etc if needed
|
||||
exit
|
||||
sudo systemctl restart apache2
|
||||
```
|
|
@ -0,0 +1,54 @@
|
|||
import React from 'react';
|
||||
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
|
||||
import AppContentContainer from './AppContent/AppContentContainer';
|
||||
import AppMenuContainer from './AppMenu/AppMenuContainer';
|
||||
import CIDetailViewContainer from './AppContent/CIDetailView/CIDetailViewContainer';
|
||||
import reduxLang from '../middleware/lang';
|
||||
import StartScreenContainer from './StartScreenContainer';
|
||||
import { useSelector } from 'react-redux'
|
||||
import { getLocaleFromURL, defineLocale, shouldDisplayDynamicComponent, getDjangoView, routePathForDynamicComponent } from '../utils';
|
||||
import BurgerBtnContainer from './AppMenu/BurgerBtnContainer';
|
||||
|
||||
const App = ({ pending, menuOpen, setLocale, isPreLaunch }) => {
|
||||
// set initial language
|
||||
setLocale(defineLocale(isPreLaunch));
|
||||
let localeFromURL = getLocaleFromURL(window.location.pathname);
|
||||
if (localeFromURL === undefined) {
|
||||
localeFromURL = ''
|
||||
}
|
||||
const basename = '/' + localeFromURL + useSelector(state => state.urls.site);
|
||||
|
||||
const activeSite = useSelector(state => state.urls.site);
|
||||
const urls = useSelector(state => state.urls.allSites[activeSite].urls);
|
||||
const DynamicComponent = React.memo(() => (
|
||||
<div className="AppContent">
|
||||
<BurgerBtnContainer />
|
||||
<div dangerouslySetInnerHTML={{ __html: getDjangoView() }} />
|
||||
</div>
|
||||
));
|
||||
return (
|
||||
<div
|
||||
className={`App ${isTouchable() ? 'touch' : ''} ${pending ? 'loading' : ''} ${
|
||||
menuOpen ? 'menuOpen' : ''
|
||||
}`}
|
||||
>
|
||||
<Router basename={basename}>
|
||||
<AppMenuContainer />
|
||||
<Switch>
|
||||
{ shouldDisplayDynamicComponent(urls) ? <Route path={routePathForDynamicComponent(basename)} component={DynamicComponent} /> : null }
|
||||
<Route path='/' component={AppContentContainer} />
|
||||
</Switch>
|
||||
<Route path='/catalog/:itemId' component={CIDetailViewContainer} />
|
||||
<Route path='/start' component={StartScreenContainer} />
|
||||
</Router>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default reduxLang()(App);
|
||||
|
||||
|
||||
|
||||
const isTouchable = () => {
|
||||
return 'ontouchstart' in window || navigator.msMaxTouchPoints > 0;
|
||||
};
|
|
@ -0,0 +1,14 @@
|
|||
import { connect } from 'react-redux';
|
||||
import App from './App';
|
||||
|
||||
const mapStateToProps = state => {
|
||||
return {
|
||||
pending: state.ui.pending,
|
||||
menuOpen: state.ui.menuOpen,
|
||||
isPreLaunch: state.ui.isPreLaunch
|
||||
};
|
||||
};
|
||||
|
||||
const AppContainer = connect(mapStateToProps)(App);
|
||||
|
||||
export default AppContainer;
|
|
@ -0,0 +1,31 @@
|
|||
import React, { Component } from 'react';
|
||||
import ContentHeader from './ContentHeader';
|
||||
import ContentBodyContainer from './ContentBodyContainer';
|
||||
import ContentOverlayContainer from './ContentOverlayContainer';
|
||||
import PreLaunchPage from './PreLaunchPage';
|
||||
import animateScrollTo from 'animated-scroll-to';
|
||||
|
||||
export default class AppContent extends Component {
|
||||
componentDidMount() {
|
||||
const { isPreLaunch, getMode } = this.props;
|
||||
|
||||
animateScrollTo(document.getElementById('root'));
|
||||
|
||||
if (isPreLaunch === undefined) {
|
||||
getMode();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isPreLaunch } = this.props;
|
||||
return (
|
||||
<div className={`AppContent${isPreLaunch ? ' is-pre-launch' : ''}`}>
|
||||
<ContentHeader isPreLaunch={isPreLaunch} />
|
||||
{!isPreLaunch && <ContentBodyContainer />}
|
||||
{!isPreLaunch && <ContentOverlayContainer />}
|
||||
{isPreLaunch && <PreLaunchPage />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { getMode } from '../../redux/actions/mode';
|
||||
import AppContent from './AppContent';
|
||||
|
||||
const mapStateToProps = state => {
|
||||
return {
|
||||
catalogFetched: !!state.catalog.length,
|
||||
isPreLaunch: state.ui.isPreLaunch
|
||||
};
|
||||
};
|
||||
|
||||
const AppContentContainer = connect(mapStateToProps, { getMode })(AppContent);
|
||||
|
||||
export default AppContentContainer;
|
|
@ -0,0 +1,89 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import DVHeader from './DVHeader';
|
||||
import DVBody from './DVBody';
|
||||
import DVFooter from './DVFooter';
|
||||
import Spinner from '../../Spinner';
|
||||
import Div100vh from 'react-div-100vh';
|
||||
import { useSelector } from 'react-redux'
|
||||
|
||||
const CIDetailView = ({
|
||||
activeItem,
|
||||
isFirst,
|
||||
isLast,
|
||||
close,
|
||||
catalog,
|
||||
pending,
|
||||
openDetail,
|
||||
showNextItem,
|
||||
showPrevItem,
|
||||
match,
|
||||
participated,
|
||||
sendParticipation,
|
||||
toggleParticipated,
|
||||
color
|
||||
}) => {
|
||||
if (activeItem === null) {
|
||||
// we have the catalog, but this view was not opened through an action
|
||||
// but by it's URL. Now we need to dispatch an action that,
|
||||
// depending on the current route, will tell which id the ID of the item to display.
|
||||
if (catalog.length) {
|
||||
catalog.forEach((item, i) => {
|
||||
if (item.inventory_number === match.params.itemId.replace('__', ' ')) {
|
||||
openDetail(i);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// we don't know yet what data to display.
|
||||
// Most certainly the catalog is being fetched.
|
||||
return (
|
||||
<div className='CIDetailView__loading AppContent'>
|
||||
<DVHeader close={close} />
|
||||
{pending && <Spinner />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (activeItem.inventory_number !== match.params.itemId.replace('__', ' ')) {
|
||||
window.history.pushState(null, null, activeItem.inventory_number.replace(' ', '__'));
|
||||
}
|
||||
const doYouKnowMoreURL = (
|
||||
useSelector(state => state.urls.site) + "do-you-know-more/?object_id=" + activeItem.inventory_number
|
||||
);
|
||||
return (
|
||||
<Div100vh>
|
||||
<div className='CIDetailView AppContent' id='CIDetailView'>
|
||||
<div id='CIDetailView-top-element'></div>
|
||||
<DVHeader close={close} />
|
||||
<DVBody
|
||||
item={activeItem}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
showNextItem={showNextItem}
|
||||
showPrevItem={showPrevItem}
|
||||
/>
|
||||
<DVFooter
|
||||
item={activeItem}
|
||||
participated={participated}
|
||||
sendParticipation={sendParticipation}
|
||||
toggleParticipated={toggleParticipated}
|
||||
doYouKnowMoreURL={doYouKnowMoreURL}
|
||||
color={color}
|
||||
/>
|
||||
</div>
|
||||
</Div100vh>
|
||||
);
|
||||
};
|
||||
|
||||
CIDetailView.propTypes = {
|
||||
activeItem: PropTypes.object,
|
||||
isFirst: PropTypes.bool,
|
||||
isLast: PropTypes.bool,
|
||||
close: PropTypes.func.isRequired,
|
||||
catalog: PropTypes.array,
|
||||
openDetail: PropTypes.func.isRequired,
|
||||
showNextItem: PropTypes.func.isRequired,
|
||||
showPrevItem: PropTypes.func.isRequired,
|
||||
match: PropTypes.object.isRequired
|
||||
};
|
||||
export default CIDetailView;
|
|
@ -0,0 +1,49 @@
|
|||
import { connect } from 'react-redux';
|
||||
import {
|
||||
openDetail,
|
||||
showNextItem,
|
||||
showPrevItem,
|
||||
toggleParticipated,
|
||||
closeDetail
|
||||
} from '../../../redux/actions/ui';
|
||||
import CIDetailView from './CIDetailView';
|
||||
import { sendParticipation } from '../../../redux/actions/participate';
|
||||
|
||||
const mapStateToProps = state => {
|
||||
if (state.ui.activeItem === null) {
|
||||
return {
|
||||
catalog: state.catalog,
|
||||
pending: state.ui.pending,
|
||||
activeItem: null
|
||||
};
|
||||
}
|
||||
const activeItemData = state.catalog[state.ui.activeItem];
|
||||
const isFirst = state.ui.activeItem === 0;
|
||||
const isLast = state.ui.activeItem === state.catalog.length - 1;
|
||||
console.log("????????????????????????????");
|
||||
return {
|
||||
activeItem: activeItemData,
|
||||
isFirst,
|
||||
isLast,
|
||||
participated: state.ui.participated,
|
||||
color: state.urls.color
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
close: () => dispatch(closeDetail()),
|
||||
openDetail: item => dispatch(openDetail(item)),
|
||||
showNextItem: () => dispatch(showNextItem()),
|
||||
showPrevItem: () => dispatch(showPrevItem()),
|
||||
sendParticipation: data => dispatch(sendParticipation(data)),
|
||||
toggleParticipated: () => dispatch(toggleParticipated())
|
||||
};
|
||||
};
|
||||
|
||||
const CIDetailViewContainer = connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(CIDetailView);
|
||||
|
||||
export default CIDetailViewContainer;
|
|
@ -0,0 +1,183 @@
|
|||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import reduxLang from '../../../middleware/lang';
|
||||
import DVLabel from './DVLabel';
|
||||
import DetailViewShare from './DetailViewShare';
|
||||
import { DefaultPlayer as Video } from 'react-html5video';
|
||||
import { useSwipeable } from 'react-swipeable';
|
||||
import Div100vh from 'react-div-100vh';
|
||||
|
||||
const DVBody = ({ item, isFirst, isLast, showNextItem, showPrevItem, t }) => (
|
||||
<div className='DVBody'>
|
||||
<DetailMediaContainer
|
||||
item={item}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
showNextItem={showNextItem}
|
||||
showPrevItem={showPrevItem}
|
||||
/>
|
||||
<DetailViewShare title={item.title} />
|
||||
<DetailViewTags tags={item.tags} label={t('category')} />
|
||||
<DetailAttributeContainer
|
||||
name={t('inventory_number')}
|
||||
content={item['inventory_number']}
|
||||
phone={true}
|
||||
/>
|
||||
<DetailAttributeContainer name={t('title')} content={item.title} />
|
||||
<DetailAttributeContainer name={t('date')} content={item.date} />
|
||||
<DetailAttributeContainer name={t('owner')} content={item.participant} />
|
||||
<DetailAttributeContainer name={t('special')} content={item.description} />
|
||||
{item.history && <DetailAttributeContainer name={t('story')} content={item.history} />}
|
||||
</div>
|
||||
);
|
||||
DVBody.propTypes = {
|
||||
item: PropTypes.object.isRequired,
|
||||
isFirst: PropTypes.bool.isRequired,
|
||||
isLast: PropTypes.bool.isRequired,
|
||||
t: PropTypes.func.isRequired
|
||||
};
|
||||
export default reduxLang('DVBody')(DVBody);
|
||||
|
||||
const DetailMediaContainer = ({ item, isFirst, isLast, showNextItem, showPrevItem }) => {
|
||||
const handlers = useSwipeable({
|
||||
onSwipedLeft: !isLast ? showNextItem : undefined,
|
||||
onSwipedRight: !isFirst ? showPrevItem : undefined
|
||||
});
|
||||
return (
|
||||
<div className={`DetailMediaContainer${item.youtube ? ' youtube' : ''}`} {...handlers}>
|
||||
{!isFirst && <LeftNavButton handler={showPrevItem} />}
|
||||
<DVLabel text={item.inventory_number} />
|
||||
{item.video || item.youtube ? (
|
||||
<DIVideo video={item.video} youtube={item.youtube} alt={item.title} />
|
||||
) : (
|
||||
<DIImage item={item} alt={item.title} />
|
||||
)}
|
||||
{!isLast && <RightNavButton handler={showNextItem} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
DetailMediaContainer.propTypes = {
|
||||
item: PropTypes.object.isRequired,
|
||||
isFirst: PropTypes.bool.isRequired,
|
||||
isLast: PropTypes.bool.isRequired,
|
||||
showNextItem: PropTypes.func.isRequired,
|
||||
showPrevItem: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
const LeftNavButton = ({ handler }) => (
|
||||
<button className='LeftNavButton' onClick={handler}>
|
||||
<img src='/static/gfx/alps_links.svg' alt='left' />
|
||||
</button>
|
||||
);
|
||||
LeftNavButton.propTypes = {
|
||||
handler: PropTypes.func.isRequired
|
||||
};
|
||||
const RightNavButton = ({ handler }) => (
|
||||
<button className='RightNavButton' onClick={handler}>
|
||||
<img src='/static/gfx/alps_rechts.svg' alt='right' />
|
||||
</button>
|
||||
);
|
||||
RightNavButton.propTypes = {
|
||||
handler: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
function youtube_parser(url) {
|
||||
var regExp = /^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#\&\?]*).*/;
|
||||
var match = url.match(regExp);
|
||||
return match && match[7].length == 11 ? match[7] : false;
|
||||
}
|
||||
const DIVideo = ({ video, youtube }) => (
|
||||
<div className='DIVideo'>
|
||||
{!youtube ? (
|
||||
<Video controls={['PlayPause', 'Seek', 'Time', 'Volume', 'Fullscreen']}>
|
||||
<source src={video} type='video/mp4' />
|
||||
</Video>
|
||||
) : (
|
||||
<iframe
|
||||
width='100%'
|
||||
src={`https://www.youtube.com/embed/${youtube_parser(youtube)}`}
|
||||
frameBorder='0'
|
||||
allow='accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture'
|
||||
allowFullScreen
|
||||
></iframe>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
DIVideo.propTypes = {
|
||||
video: PropTypes.string,
|
||||
youtube: PropTypes.string
|
||||
};
|
||||
|
||||
const DIImage = ({ item, alt = '' }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const openFullScreen = () => {
|
||||
setOpen(true);
|
||||
|
||||
// fix cut off part on Safari
|
||||
document.getElementById('CIDetailView').style.overflow = 'visible';
|
||||
};
|
||||
|
||||
const closeFullScreen = () => {
|
||||
setOpen(false);
|
||||
|
||||
// revert Safari fix don when opening
|
||||
document.getElementById('CIDetailView').style.overflow = '';
|
||||
};
|
||||
|
||||
if (open) {
|
||||
return <FullScreenImage image={item.image} close={closeFullScreen} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='DIImage' onClick={openFullScreen}>
|
||||
<img src={item.image} alt={alt} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
DIImage.propTypes = {
|
||||
item: PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
const FullScreenImage = ({ image, close }) => (
|
||||
<Div100vh>
|
||||
<div
|
||||
className='FullScreenImage'
|
||||
style={{ backgroundImage: `url('${image}')` }}
|
||||
onClick={close}
|
||||
></div>
|
||||
</Div100vh>
|
||||
);
|
||||
|
||||
const DetailViewTags = ({ tags, label }) => (
|
||||
<div className='DetailViewTags'>
|
||||
<DVLabel text={label} />
|
||||
<div>
|
||||
{tags.map((tag, i) => (
|
||||
<span className='Tag' key={i}>
|
||||
<button style={{border: '0px solid black'}}>{tag.name}</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DetailAttributeContainer = ({ name, content, phone = false }) => (
|
||||
<div className={`DetailAttributeContainer${phone ? ' phone-invetory-number' : ''}`}>
|
||||
<DVLabel text={name} />
|
||||
<DAContent content={content} />
|
||||
</div>
|
||||
);
|
||||
DetailAttributeContainer.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
content: PropTypes.any.isRequired
|
||||
};
|
||||
|
||||
const DAContent = ({ content }) => (
|
||||
<div className='DAContent'>
|
||||
<p className='medium-text'>{content}</p>
|
||||
</div>
|
||||
);
|
||||
DAContent.propTypes = {
|
||||
content: PropTypes.any.isRequired
|
||||
};
|
|
@ -0,0 +1,180 @@
|
|||
import React, { Component } from 'react';
|
||||
import reduxLang from '../../../middleware/lang';
|
||||
import CVDescInput from './../../ContribView/CVDescInput';
|
||||
import CVUploadInput from './../../ContribView/CVUploadInput';
|
||||
import CVPersonalInfosInputs from '../../ContribView/CVPersonalInfosInputs';
|
||||
import CVSubmit from './../../ContribView/CVSubmit';
|
||||
import autobind from 'autobind-decorator';
|
||||
import { Notification } from 'react-notification';
|
||||
import animateScrollTo from 'animated-scroll-to';
|
||||
|
||||
@reduxLang('ContribView')
|
||||
@autobind
|
||||
export default class DVFooter extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isToggleOn: false,
|
||||
desc: '',
|
||||
file: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
birth_year: '',
|
||||
address: '',
|
||||
post_code: '',
|
||||
city: '',
|
||||
phone: '',
|
||||
mail: '',
|
||||
agb: false,
|
||||
notification: ''
|
||||
};
|
||||
|
||||
this.props.toggleParticipated(false);
|
||||
}
|
||||
|
||||
handleClick() {
|
||||
const { isToggleOn } = this.state;
|
||||
if (isToggleOn) {
|
||||
this.props.toggleParticipated(false);
|
||||
animateScrollTo(document.getElementById('CIDetailView-top-element'), {
|
||||
elementToScroll: document.getElementById('CIDetailView')
|
||||
}).then(hasScrolledToPosition => {
|
||||
// scroll animation is finished
|
||||
|
||||
// "hasScrolledToPosition" indicates if page/element
|
||||
// was scrolled to a desired position
|
||||
// or if animation got interrupted
|
||||
if (hasScrolledToPosition) {
|
||||
// page is scrolled to a desired position
|
||||
|
||||
this.setState({
|
||||
isToggleOn: !isToggleOn
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
isToggleOn: !isToggleOn
|
||||
});
|
||||
this.props.toggleParticipated(false);
|
||||
|
||||
// if not executed 2 time, the scroll would not be complete
|
||||
setImmediate(() =>
|
||||
animateScrollTo(document.getElementById('detail-view-footer-button'), {
|
||||
elementToScroll: document.getElementById('CIDetailView')
|
||||
}).then(() =>
|
||||
animateScrollTo(document.getElementById('detail-view-footer-button'), {
|
||||
elementToScroll: document.getElementById('CIDetailView')
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onObjectTypeChange(event) {
|
||||
this.setState({ object_type: event.target.value });
|
||||
}
|
||||
onDescChange(event) {
|
||||
this.setState({ desc: event.target.value });
|
||||
}
|
||||
onFileChange(value) {
|
||||
this.setState({ file: value });
|
||||
}
|
||||
onPersonalInfosChange(event) {
|
||||
this.setState({ [event.target.name]: event.target.value });
|
||||
}
|
||||
onKeepChange(event) {
|
||||
this.setState({ keep: event.target.value });
|
||||
}
|
||||
onAGBChange(event) {
|
||||
this.setState({ agb: event.target.checked });
|
||||
}
|
||||
onSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (this.validForm()) {
|
||||
const data = { ...this.state, object_id: this.props.item.inventory_number };
|
||||
delete data.abg;
|
||||
delete data.notification;
|
||||
delete data.isToggleOn;
|
||||
|
||||
this.props.sendParticipation(data);
|
||||
}
|
||||
}
|
||||
|
||||
validForm() {
|
||||
const { desc, first_name, last_name, mail } = this.state;
|
||||
const { t } = this.props;
|
||||
|
||||
if (!desc) {
|
||||
this.setState({ notification: t('noti_desc') });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!last_name) {
|
||||
this.setState({ notification: t('noti_last_name') });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!first_name) {
|
||||
this.setState({ notification: t('noti_first_name') });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!mail) {
|
||||
this.setState({ notification: t('noti_mail') });
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
onNotificationDismiss() {
|
||||
this.setState({ notification: '' });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isToggleOn, notification } = this.state;
|
||||
const { t, participated, doYouKnowMoreURL, color } = this.props;
|
||||
return (
|
||||
<div className='DVFooter' style={{backgroundColor: color}}>
|
||||
<div>
|
||||
<div className='DVFooterTop'>
|
||||
<a id='detail-view-footer-button' target="_blank" href={doYouKnowMoreURL}>
|
||||
<p className='medium-text'>{t('know_more')}</p>
|
||||
</a>
|
||||
</div>
|
||||
{isToggleOn && (
|
||||
<div className='DVDropdown'>
|
||||
<div className='DVText'>
|
||||
<p className='medium-text'>{/* TODO */}</p>
|
||||
</div>
|
||||
{participated && (
|
||||
<div className='CVParticipatedMessage medium-text'>
|
||||
{t('thank_you')}
|
||||
</div>
|
||||
)}
|
||||
<Notification
|
||||
isActive={!!notification}
|
||||
message={notification}
|
||||
action={'schliessen'}
|
||||
onClick={this.onNotificationDismiss}
|
||||
/>
|
||||
<form
|
||||
onSubmit={this.onSubmit}
|
||||
style={participated ? { opacity: 0, pointerEvents: 'none' } : {}}
|
||||
>
|
||||
<CVDescInput onDescChange={this.onDescChange} />
|
||||
<CVUploadInput onFileChange={this.onFileChange} />
|
||||
<CVPersonalInfosInputs
|
||||
onPersonalInfosChange={this.onPersonalInfosChange}
|
||||
/>
|
||||
<CVSubmit />
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const DVHeader = ({ close }) => (
|
||||
<div className='DVHeader'>
|
||||
<Link to='/' onClick={close}>
|
||||
<img src='/static/gfx/alps_x.svg' alt='close' />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
DVHeader.propTypes = {
|
||||
close: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default DVHeader;
|
|
@ -0,0 +1,9 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const DVLabel = ({ text }) => <div className='DVLabel small-text'>{text}</div>;
|
||||
DVLabel.propTypes = {
|
||||
text: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default DVLabel;
|
|
@ -0,0 +1,42 @@
|
|||
import React from 'react';
|
||||
import DVLabel from './DVLabel';
|
||||
import reduxLang from '../../../middleware/lang';
|
||||
|
||||
const DetailViewShare = ({ title, t }) => (
|
||||
<div className='DetailViewShare'>
|
||||
<DVLabel text={t('share')} />
|
||||
<div className='DetailViewShare--items'>
|
||||
<ShareItem
|
||||
icon={<img src='/static/gfx/alps_fb.svg' alt='fb' />}
|
||||
href={`http://www.facebook.com/sharer.php?u=${window.location.href}`}
|
||||
/>
|
||||
|
||||
<ShareItem
|
||||
icon={<img src='/static/gfx/alps_twitter.svg' alt='tw' />}
|
||||
href={`https://twitter.com/share?url=${window.location.href}&text=${encodeURIComponent(
|
||||
title
|
||||
)}&hashtags=alpinesmueum`}
|
||||
/>
|
||||
|
||||
<ShareItem
|
||||
icon={<img src='/static/gfx/alps_mail.svg' alt='email' />}
|
||||
href={`mailto:?subject=${title}&body=${window.location.href}`}
|
||||
newTab={false}
|
||||
/>
|
||||
|
||||
<ShareItem
|
||||
icon={<img src='/static/gfx/whatsapp.svg' alt='whatsapp' />}
|
||||
href={`whatsapp://send?text=${window.location.href}`}
|
||||
newTab={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default reduxLang('DVBody')(DetailViewShare);
|
||||
|
||||
const ShareItem = ({ icon, href, newTab = true}) => (
|
||||
<a className='ShareItem' href={href} target={newTab ? '_blank' : ''}>
|
||||
{icon}
|
||||
</a>
|
||||
);
|
|
@ -0,0 +1,80 @@
|
|||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
import reduxLang from '../../middleware/lang';
|
||||
import GridImg from './GridImg';
|
||||
|
||||
@reduxLang('AppContent')
|
||||
export default class CatalogItem extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { locale: props.locale };
|
||||
}
|
||||
|
||||
handleMitmachenClick (event) {
|
||||
event.stopPropagation();
|
||||
let elem = ReactDOM.findDOMNode(event.target).parentNode.parentNode;
|
||||
window.location.href = elem.getAttribute("href");
|
||||
return false;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { item, openDetail, index, locale, isInternal } = this.props;
|
||||
|
||||
if (isInternal && (item.youtube || item.video)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (item.youtube && !item.image_width && !item.image_height) {
|
||||
item.image_width = 480;
|
||||
item.image_height = 360;
|
||||
}
|
||||
return (
|
||||
<div className='CatalogItem grid-item'>
|
||||
<div className='grid-gap'>
|
||||
<Link
|
||||
to={
|
||||
item.inventory_number
|
||||
? `/catalog/${item.inventory_number.replace(' ', '__')}`
|
||||
: '/mitmachen' // this case is when it is an ad
|
||||
}
|
||||
onClick={item.inventory_number ? () => openDetail(index) : this.handleMitmachenClick} // only when not an add
|
||||
>
|
||||
<div className='CatalogItem__body'>
|
||||
<GridImg
|
||||
src={
|
||||
item.youtube // case of youtube media
|
||||
? `https://img.youtube.com/vi/${parse_video_id(
|
||||
item.youtube
|
||||
)}/0.jpg`
|
||||
: item.inventory_number // other cases
|
||||
? item.thumbnail // case of image or video
|
||||
: item[locale] // case of bubble
|
||||
}
|
||||
item={item}
|
||||
/>
|
||||
{(item.video || item.youtube) && (
|
||||
<img
|
||||
className='CatalogItem__videoIcon'
|
||||
src='/static/gfx/alps_play.svg'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
CatalogItem.propTypes = {
|
||||
item: PropTypes.object.isRequired,
|
||||
openDetail: PropTypes.func.isRequired,
|
||||
index: PropTypes.number.isRequired
|
||||
};
|
||||
|
||||
function parse_video_id(url) {
|
||||
var regExp = /^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#\&\?]*).*/;
|
||||
var match = url.match(regExp);
|
||||
return match && match[7].length == 11 ? match[7] : false;
|
||||
}
|
|
@ -0,0 +1,103 @@
|
|||
import React, { Component, createRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import CatalogItem from './CatalogItem';
|
||||
import shuffleSeed from 'shuffle-seed';
|
||||
import reduxLang from '../../middleware/lang';
|
||||
import autobind from 'autobind-decorator';
|
||||
import 'intersection-observer';
|
||||
|
||||
const rand = () => Math.floor(Math.random() * 1000000) + 1;
|
||||
|
||||
@autobind
|
||||
class _CatalogList extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.bottomElement = createRef();
|
||||
this.observer = undefined;
|
||||
this.state = {
|
||||
pageNumber: 1
|
||||
};
|
||||
}
|
||||
componentDidMount() {
|
||||
this.props.rebuildLayout();
|
||||
if (this.props.items.length) {
|
||||
// Add an observer to the intersection with the bottom element
|
||||
// (to know when the user scrolled to the end)
|
||||
setImmediate(this.observeIntersection);
|
||||
}
|
||||
}
|
||||
|
||||
observeIntersection() {
|
||||
// if there is an old obeserver
|
||||
if (this.observer) {
|
||||
// we don't need it anymore so we disconnect itfl
|
||||
this.observer.disconnect();
|
||||
}
|
||||
if (!this.bottomElement.current) {
|
||||
// we don't need the observer if there is no "load more" button
|
||||
// (due to the fact that there are not a lot of elements)
|
||||
return;
|
||||
}
|
||||
this.observer = new IntersectionObserver(entries => {
|
||||
let isIntersecting = false;
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
isIntersecting = true;
|
||||
}
|
||||
});
|
||||
if (isIntersecting) {
|
||||
this.loadMore();
|
||||
}
|
||||
});
|
||||
this.observer.observe(this.bottomElement.current);
|
||||
}
|
||||
|
||||
loadMore() {
|
||||
this.setState({ pageNumber: this.state.pageNumber + 1 });
|
||||
setImmediate(this.props.rebuildLayout);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { items, ads, openDetail, showingSearchResults, t, isInternal } = this.props;
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
let displayed_items = [];
|
||||
|
||||
displayed_items = items.slice(0, this.state.pageNumber * ITEMS_PER_PAGE);
|
||||
|
||||
let hasMore = true;
|
||||
if (displayed_items.length === items.length) {
|
||||
hasMore = false;
|
||||
}
|
||||
|
||||
if (showingSearchResults && !displayed_items.length) {
|
||||
return <div className='CatalogList--noResults medium-text'>{t('nothing_found')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='grid-container'>
|
||||
{displayed_items.map((item, i) => (
|
||||
<CatalogItem item={item} key={i} index={i} openDetail={openDetail} isInternal={isInternal} />
|
||||
))}
|
||||
</div>
|
||||
{hasMore && displayed_items.length && (
|
||||
<button
|
||||
className='load-more-btn medium-text'
|
||||
ref={this.bottomElement}
|
||||
onClick={this.loadMore}
|
||||
>
|
||||
{t('load_more')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
_CatalogList.propTypes = {
|
||||
items: PropTypes.array.isRequired,
|
||||
openDetail: PropTypes.func.isRequired,
|
||||
showingSearchResults: PropTypes.bool.isRequired,
|
||||
t: PropTypes.func.isRequired
|
||||
};
|
||||
const CatalogList = reduxLang('AppContent')(_CatalogList);
|
||||
export default CatalogList;
|
|
@ -0,0 +1,24 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { openDetail, rebuildLayout } from '../../redux/actions/ui';
|
||||
import CatalogList from './CatalogList';
|
||||
|
||||
const mapStateToProps = state => {
|
||||
return {
|
||||
items: state.catalog,
|
||||
ads: state.ads,
|
||||
showingSearchResults: state.ui.showingSearchResults,
|
||||
magnetInstance: state.ui.magnetInstance,
|
||||
isInternal: state.ui.isInternal
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
openDetail: item => dispatch(openDetail(item)),
|
||||
rebuildLayout: shuffle => dispatch(rebuildLayout(shuffle))
|
||||
};
|
||||
};
|
||||
|
||||
const CatalogListContainer = connect(mapStateToProps, mapDispatchToProps)(CatalogList);
|
||||
|
||||
export default CatalogListContainer;
|
|
@ -0,0 +1,27 @@
|
|||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import CatalogListContainer from './CatalogListContainer';
|
||||
import Spinner from '../Spinner';
|
||||
|
||||
export default class ContentBody extends Component {
|
||||
componentDidMount() {
|
||||
const { catalogFetched, getCatalog } = this.props;
|
||||
// this fetches the entire catalog, no matter if the list of a single item is opened
|
||||
if (!catalogFetched) {
|
||||
getCatalog();
|
||||
}
|
||||
}
|
||||
render() {
|
||||
const { pending } = this.props;
|
||||
return (
|
||||
<div className={`ContentBody${pending ? ' loading' : ''}`}>
|
||||
{pending ? <Spinner /> : <CatalogListContainer />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
ContentBody.propTypes = {
|
||||
pending: PropTypes.bool.isRequired,
|
||||
catalogFetched: PropTypes.bool.isRequired,
|
||||
getCatalog: PropTypes.func.isRequired
|
||||
};
|
|
@ -0,0 +1,34 @@
|
|||
import { connect } from 'react-redux';
|
||||
import ContentBody from './ContentBody';
|
||||
import { getCatalog } from '../../redux/actions/catalog';
|
||||
|
||||
const mapStateToProps = state => {
|
||||
let pending = state.ui.pending;
|
||||
|
||||
// NOTE: do this in a middleware didn't work
|
||||
if (!pending) {
|
||||
// case where we have no items (but not because there are no search results)
|
||||
if (!state.ui.showingSearchResults && state.catalog.length === 0) {
|
||||
pending = true;
|
||||
}
|
||||
// case where we only have ads and are still pending catalog items
|
||||
if (!state.ui.showingSearchResults && state.catalog.every(item => !item.inventory_number)) {
|
||||
pending = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pending,
|
||||
catalogFetched: !!state.catalog.length
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
getCatalog: () => dispatch(getCatalog())
|
||||
};
|
||||
};
|
||||
|
||||
const ContentBodyContainer = connect(mapStateToProps, mapDispatchToProps)(ContentBody);
|
||||
|
||||
export default ContentBodyContainer;
|
|
@ -0,0 +1,92 @@
|
|||
import React, { Component } from 'react';
|
||||
import PropTypes, { func } from 'prop-types';
|
||||
import autobind from 'autobind-decorator';
|
||||
import HeaderSearchFieldContainer from './HeaderSearchFieldContainer';
|
||||
import reduxLang from '../../middleware/lang';
|
||||
import BurgerBtnContainer from '../AppMenu/BurgerBtnContainer';
|
||||
import { useSelector } from 'react-redux'
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
@autobind
|
||||
class _ContentHeader extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
searching: false
|
||||
};
|
||||
}
|
||||
|
||||
toggleSearching() {
|
||||
this.setState({ searching: !this.state.searching });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { searching } = this.state;
|
||||
const { isPreLaunch, color, scrollingText } = this.props;
|
||||
return searching ? (
|
||||
<div className='ContentHeader'>
|
||||
<HeaderSearchFieldContainer closeFunc={this.toggleSearching} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="marquee" style={{backgroundColor: color}}>
|
||||
<div className="marquee__inner" aria-hidden="true">
|
||||
<span>{scrollingText}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='ContentHeader'>
|
||||
<HeaderIcon />
|
||||
<BurgerBtnContainer />
|
||||
<HeaderTitle />
|
||||
{!isPreLaunch && <HeaderSearchBtn handler={this.toggleSearching} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = state => {
|
||||
return {
|
||||
color: state.urls.color,
|
||||
scrollingText: state.urls.scrollingText
|
||||
};
|
||||
};
|
||||
const ContentHeader = connect(mapStateToProps)(_ContentHeader);
|
||||
export default ContentHeader;
|
||||
|
||||
function HeaderIcon() {
|
||||
const currentSite = useSelector(state => state.urls.site);
|
||||
const allSites = Object.getOwnPropertyNames(useSelector(state => state.urls.allSites))
|
||||
const siteID = allSites.findIndex((siteName) => siteName === currentSite) + 1;
|
||||
return <h1 className='HeaderIcon big-text'>№ {siteID}</h1>;
|
||||
}
|
||||
|
||||
|
||||
const getTitle = t => {
|
||||
if ('ontouchstart' in window || navigator.msMaxTouchPoints > 0) {
|
||||
return 'Fundbüro № 1';
|
||||
} else {
|
||||
return t('skiing');
|
||||
}
|
||||
};
|
||||
|
||||
function _HeaderTitle({ t }) {
|
||||
const siteName = useSelector(state => state.urls.name);
|
||||
return <h1 className='HeaderTitle big-text'>{siteName}</h1>;
|
||||
}
|
||||
_HeaderTitle.propTypes = {
|
||||
t: PropTypes.func.isRequired
|
||||
};
|
||||
const HeaderTitle = reduxLang('HeaderTitle')(_HeaderTitle);
|
||||
|
||||
const HeaderSearchBtn = ({ handler }) => (
|
||||
<div className='HeaderSearchBtn'>
|
||||
<button onClick={handler}>
|
||||
<img src='/static/gfx/alps_lupe.svg' alt='search' />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
HeaderSearchBtn.propTypes = {
|
||||
handler: PropTypes.func.isRequired
|
||||
};
|
|
@ -0,0 +1,16 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const ContentOverlay = ({ shuffle }) => (
|
||||
<div className='ContentOverlay'>
|
||||
<button onClick={shuffle}>
|
||||
<img src='/static/gfx/alps_random.svg' alt='shuffle'/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
ContentOverlay.propTypes = {
|
||||
shuffle: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default ContentOverlay;
|
|
@ -0,0 +1,11 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { shuffle } from '../../redux/actions/ui';
|
||||
import ContentOverlay from './ContentOverlay';
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return { shuffle: () => dispatch(shuffle()) };
|
||||
};
|
||||
|
||||
const ContentOverlayContainer = connect(undefined, mapDispatchToProps)(ContentOverlay);
|
||||
|
||||
export default ContentOverlayContainer;
|
|
@ -0,0 +1,20 @@
|
|||
import React from 'react';
|
||||
|
||||
const GridImg = ({ src, item }) => {
|
||||
let ratio = 1;
|
||||
if (item.thumbnail_height && item.thumbnail_width) {
|
||||
ratio = item.thumbnail_height / item.thumbnail_width;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundImage: `url('${src}')`,
|
||||
paddingBottom: `${ratio * 100}%`,
|
||||
width: '100%',
|
||||
backgroundPosition: 'center',
|
||||
backgroundSize: 'cover'
|
||||
}}
|
||||
></div>
|
||||
);
|
||||
};
|
||||
export default GridImg;
|
|
@ -0,0 +1,7 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { imageLoaded } from '../../redux/actions/ui';
|
||||
import GridImg from './GridImg';
|
||||
|
||||
const GridImgContainer = connect(undefined, { imageLoaded })(GridImg);
|
||||
|
||||
export default GridImgContainer;
|
|
@ -0,0 +1,71 @@
|
|||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import autobind from 'autobind-decorator';
|
||||
import reduxLang from '../../middleware/lang';
|
||||
|
||||
@autobind
|
||||
class HeaderSearchField extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.searchInput = React.createRef();
|
||||
|
||||
this.state = {
|
||||
search: ''
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.searchInput.current.focus();
|
||||
}
|
||||
|
||||
handleSearchInput(event) {
|
||||
this.setState({ search: event.target.value });
|
||||
}
|
||||
|
||||
handleSearch(event) {
|
||||
event.preventDefault();
|
||||
this.props.search(this.state.search);
|
||||
}
|
||||
|
||||
handleClose() {
|
||||
const { closeFunc, getCatalog, showingSearchResults } = this.props;
|
||||
if (showingSearchResults) {
|
||||
getCatalog();
|
||||
}
|
||||
|
||||
{closeFunc && closeFunc() };
|
||||
}
|
||||
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
const { search } = this.state;
|
||||
const { showClose, getCatalog, showingSearchResults } = this.props;
|
||||
return (
|
||||
<div className='HeaderSearchField'>
|
||||
<form onSubmit={this.handleSearch}>
|
||||
<input
|
||||
className='medium-text'
|
||||
type='text'
|
||||
placeholder={t('search')}
|
||||
ref={this.searchInput}
|
||||
value={search}
|
||||
onChange={this.handleSearchInput}
|
||||
/>
|
||||
</form>
|
||||
{showClose && <button className='HeaderSearchField_closeBtn' onClick={this.handleClose}>
|
||||
<img src='/static/gfx/alps_x.svg' alt='close' />
|
||||
</button>
|
||||
}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
HeaderSearchField.propTypes = {
|
||||
t: PropTypes.func.isRequired,
|
||||
search: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default reduxLang('HeaderSearchField')(HeaderSearchField);
|
|
@ -0,0 +1,13 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { search, getCatalog } from '../../redux/actions/catalog';
|
||||
import HeaderSearchField from './HeaderSearchField';
|
||||
|
||||
const mapStateToProps = state => {
|
||||
return { showingSearchResults: state.ui.showingSearchResults };
|
||||
};
|
||||
|
||||
const HeaderSearchFieldContainer = connect(mapStateToProps, { search, getCatalog })(
|
||||
HeaderSearchField
|
||||
);
|
||||
|
||||
export default HeaderSearchFieldContainer;
|
|
@ -0,0 +1,182 @@
|
|||
import React, { Component } from 'react';
|
||||
import autobind from 'autobind-decorator';
|
||||
import { Notification } from 'react-notification';
|
||||
import Spinner from '../Spinner';
|
||||
import CVDescInput from '../ContribView/CVDescInput';
|
||||
import CVUploadInput from '../ContribView/CVUploadInput';
|
||||
import CVPersonalInfosInputs from '../ContribView/CVPersonalInfosInputs';
|
||||
import CVAGBInput from '../ContribView/CVAGBInput';
|
||||
import CVSubmit from '../ContribView/CVSubmit';
|
||||
import CVObjectTypeRadio from '../ContribView/CVObjectTypeRadio';
|
||||
import CVKeepInput from '../ContribView/CVKeepInput';
|
||||
import reduxLang from '../../middleware/lang';
|
||||
import animateScrollTo from 'animated-scroll-to';
|
||||
|
||||
@reduxLang('ContribView')
|
||||
@autobind
|
||||
export default class PreLaunchForm extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
desc: '',
|
||||
file: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
birth_year: '',
|
||||
address: '',
|
||||
post_code: '',
|
||||
city: '',
|
||||
phone: '',
|
||||
mail: '',
|
||||
agb: false,
|
||||
notification: '',
|
||||
uploadeBtn: true
|
||||
};
|
||||
|
||||
this.props.toggleParticipated(false);
|
||||
}
|
||||
|
||||
onObjectTypeChange(event) {
|
||||
this.setState({ object_type: event.target.value });
|
||||
}
|
||||
onDescChange(event) {
|
||||
this.setState({ desc: event.target.value });
|
||||
}
|
||||
onFileChange(value) {
|
||||
this.setState({ file: value });
|
||||
}
|
||||
onPersonalInfosChange(event) {
|
||||
this.setState({ [event.target.name]: event.target.value });
|
||||
}
|
||||
onKeepChange(event) {
|
||||
this.setState({ keep: event.target.value });
|
||||
}
|
||||
onAGBChange(event) {
|
||||
this.setState({ agb: event.target.checked });
|
||||
}
|
||||
onSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (this.validForm()) {
|
||||
const data = { ...this.state };
|
||||
delete data.abg;
|
||||
delete data.notification;
|
||||
|
||||
this.props.sendParticipation(data);
|
||||
}
|
||||
}
|
||||
|
||||
validForm() {
|
||||
const {
|
||||
object_type,
|
||||
desc,
|
||||
first_name,
|
||||
last_name,
|
||||
mail,
|
||||
keep,
|
||||
agb
|
||||
} = this.state;
|
||||
const { t } = this.props;
|
||||
|
||||
if (!object_type) {
|
||||
this.setState({ notification: t('noti_object_type') });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!desc) {
|
||||
this.setState({ notification: t('noti_desc') });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!first_name) {
|
||||
this.setState({ notification: t('noti_first_name') });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!last_name) {
|
||||
this.setState({ notification: t('noti_last_name') });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!mail) {
|
||||
this.setState({ notification: t('noti_mail') });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!keep) {
|
||||
this.setState({ notification: t('noti_keep') });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!agb) {
|
||||
this.setState({ notification: t('noti_agb') });
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
onNotificationDismiss() {
|
||||
this.setState({ notification: '' });
|
||||
}
|
||||
|
||||
resetUploadBtn() {
|
||||
this.setState({ uploadeBtn: false });
|
||||
setImmediate(() => this.setState({ uploadeBtn: true }));
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.participated) {
|
||||
animateScrollTo(document.getElementById('pre-launch-contribute-form'));
|
||||
}
|
||||
|
||||
if (prevProps.locale !== this.props.locale) {
|
||||
this.resetUploadBtn();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { notification, uploadeBtn } = this.state;
|
||||
const { pending, participated, t } = this.props;
|
||||
if (pending) {
|
||||
return <Spinner />;
|
||||
}
|
||||
return (
|
||||
<div className={`PreLaunchForm ContribView${participated ? ' participated' : ''}`}>
|
||||
<h1 className='big-text'>{t('we_search')}</h1>
|
||||
<div className='CVText'>
|
||||
<p
|
||||
className='medium-text'
|
||||
dangerouslySetInnerHTML={{ __html: t('intro_text') }}
|
||||
></p>
|
||||
</div>
|
||||
<div className='CVBody'>
|
||||
<Notification
|
||||
isActive={!!notification}
|
||||
message={notification}
|
||||
action={t('noti_close')}
|
||||
onClick={this.onNotificationDismiss}
|
||||
/>
|
||||
<form onSubmit={this.onSubmit}>
|
||||
<CVObjectTypeRadio onObjectTypeChange={this.onObjectTypeChange} />
|
||||
<CVDescInput onDescChange={this.onDescChange} />
|
||||
{uploadeBtn && (
|
||||
<CVUploadInput
|
||||
onFileChange={this.onFileChange}
|
||||
reset={this.resetUploadBtn}
|
||||
/>
|
||||
)}
|
||||
<CVPersonalInfosInputs onPersonalInfosChange={this.onPersonalInfosChange} />
|
||||
<CVKeepInput onKeepChange={this.onKeepChange} />
|
||||
<CVAGBInput onAGBChange={this.onAGBChange} />
|
||||
<CVSubmit />
|
||||
</form>
|
||||
</div>
|
||||
{participated && (
|
||||
<div className='CVParticipatedMessage medium-text'>{t('thank_you')}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { sendParticipation } from '../../redux/actions/participate';
|
||||
import { toggleParticipated } from '../../redux/actions/ui';
|
||||
import PreLaunchForm from './PreLaunchForm';
|
||||
|
||||
const mapStateToProps = state => {
|
||||
return { pending: state.ui.pending, participated: state.ui.participated };
|
||||
};
|
||||
|
||||
const PreLaunchFormContainer = connect(
|
||||
mapStateToProps,
|
||||
{ sendParticipation, toggleParticipated }
|
||||
)(PreLaunchForm);
|
||||
|
||||
export default PreLaunchFormContainer;
|
|
@ -0,0 +1,57 @@
|
|||
import React from 'react';
|
||||
import PreLaunchFormContainer from './PreLaunchFormContainer';
|
||||
import reduxLang from '../../middleware/lang';
|
||||
import Newsletter from '../InfoView/Newsletter';
|
||||
import ImageWithLegend from '../InfoView/ImageWithLegend';
|
||||
import Sponsors from '../InfoView/Sponsors';
|
||||
import InfoViewFooter from '../InfoView/InfoViewFooter';
|
||||
|
||||
const PreLaunchPage = ({ t, locale }) => (
|
||||
<div className='PreLaunchPage'>
|
||||
<div
|
||||
className='PreLaunchPage-hereo'
|
||||
style={{
|
||||
backgroundImage: `url(/static/gfx/2019-12-09-Banner-OnePage-${locale}.jpg)`
|
||||
}}
|
||||
></div>
|
||||
<div className='PreLaunchPage-video'>
|
||||
<iframe
|
||||
width='100%'
|
||||
height='100%'
|
||||
src='https://www.youtube.com/embed/kjpw4LunEEA'
|
||||
frameBorder='0'
|
||||
allow='accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture'
|
||||
allowFullScreen
|
||||
></iframe>
|
||||
</div>
|
||||
<div className='PreLaunchPage-form'>
|
||||
<a id='pre-launch-contribute-form' />
|
||||
<PreLaunchFormContainer />
|
||||
</div>
|
||||
<div className='PreLaunchPage-info'>
|
||||
<a id='pre-launch-info' />
|
||||
<div className='InfoView'>
|
||||
<h1 style={{ paddingTop: 0 }} className='big-text'>
|
||||
{t('welcome_title')}
|
||||
</h1>
|
||||
<div className='InfoContent'>
|
||||
<p
|
||||
className='medium-text'
|
||||
dangerouslySetInnerHTML={{ __html: t('welcome_text') }}
|
||||
></p>
|
||||
</div>
|
||||
<div className='ImagesList'>
|
||||
<ImageWithLegend
|
||||
image='/static/gfx/alps_bruegger_A0080_NAm015_edited_06.12.19-optmimized.jpg'
|
||||
legend={t('image_legend')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Newsletter />
|
||||
<Sponsors />
|
||||
<InfoViewFooter />
|
||||
</div>
|
||||
);
|
||||
export default reduxLang('InfoView')(PreLaunchPage);
|
|
@ -0,0 +1,44 @@
|
|||
import React from 'react';
|
||||
|
||||
import MenuHeader from './MenuHeader';
|
||||
import MenuBodyContainer from './MenuBodyContainer';
|
||||
import MenuFooter from './MenuFooter';
|
||||
import Redirecter from './Redirecter';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { getSiteDomain, mostStrictSiteMatch } from '../../utils';
|
||||
|
||||
|
||||
const AppMenu = ({ menuOpen, toggleMenu, getCatalog, isPreLaunch, isInternal, deselectTags }) => {
|
||||
const allSites = useSelector(state => state.urls.allSites);
|
||||
const color = useSelector(state => state.urls.color);
|
||||
return (
|
||||
<div className={`AppMenu ${menuOpen ? 'open' : ''}`}>
|
||||
{isInternal && <Redirecter />}
|
||||
<div className='AppMenu__container'>
|
||||
<div className="SitesTabGroup">
|
||||
{
|
||||
Object.getOwnPropertyNames(allSites).map((site, i) => {
|
||||
const isActive = site === mostStrictSiteMatch(allSites, getSiteDomain());
|
||||
if (isActive) {
|
||||
return <a className="SitesTab active" key={i} style={{backgroundColor: color}} href={site}>№ {i + 1}</a>
|
||||
} else {
|
||||
return <a className="SitesTab" key={i} href={site}>№ {i + 1}</a>
|
||||
}
|
||||
})
|
||||
}
|
||||
</div>
|
||||
|
||||
<MenuHeader
|
||||
toggleMenu={toggleMenu}
|
||||
getCatalog={getCatalog}
|
||||
isPreLaunch={isPreLaunch}
|
||||
deselectTags={deselectTags}
|
||||
/>
|
||||
<MenuBodyContainer />
|
||||
<MenuFooter isPreLaunch={isPreLaunch} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppMenu;
|
|
@ -0,0 +1,19 @@
|
|||
import { connect } from 'react-redux';
|
||||
import AppMenu from './AppMenu';
|
||||
import { toggleMenu } from '../../redux/actions/ui';
|
||||
import { getCatalog } from '../../redux/actions/catalog';
|
||||
import { deselectTags } from '../../redux/actions/tags';
|
||||
|
||||
const mapStateToProps = state => {
|
||||
return {
|
||||
menuOpen: state.ui.menuOpen,
|
||||
isPreLaunch: state.ui.isPreLaunch,
|
||||
isInternal: state.ui.isInternal
|
||||
};
|
||||
};
|
||||
|
||||
const AppMenuContainer = connect(mapStateToProps, { toggleMenu, getCatalog, deselectTags })(
|
||||
AppMenu
|
||||
);
|
||||
|
||||
export default AppMenuContainer;
|
|
@ -0,0 +1,25 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faBars } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
const BurgerBtn = ({ menuOpen, toggleMenu }) => (
|
||||
<button className='HeaderBurger' onClick={toggleMenu}>
|
||||
{menuOpen && (
|
||||
<div
|
||||
className='openMenuOverlay'
|
||||
onClick={event => {
|
||||
event.stopPropagation(); // prevent the execution of the onClick event of the button
|
||||
toggleMenu();
|
||||
}}
|
||||
></div>
|
||||
)}
|
||||
<FontAwesomeIcon icon={faBars} />
|
||||
</button>
|
||||
);
|
||||
BurgerBtn.propTypes = {
|
||||
menuOpen: PropTypes.bool.isRequired,
|
||||
toggleMenu: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default BurgerBtn;
|
|
@ -0,0 +1,14 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { toggleMenu } from '../../redux/actions/ui';
|
||||
import BurgerBtn from './BurgerBtn';
|
||||
|
||||
const mapStateToProps = state => {
|
||||
return { menuOpen: state.ui.menuOpen };
|
||||
};
|
||||
|
||||
const BurgerBtnContainer = connect(
|
||||
mapStateToProps,
|
||||
{ toggleMenu }
|
||||
)(BurgerBtn);
|
||||
|
||||
export default BurgerBtnContainer;
|
|
@ -0,0 +1,30 @@
|
|||
import React from 'react';
|
||||
import TagGroup from './TagGroup';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
const MenuBody = ({ tagGroups, getTags, selectTag, isPreLaunch }) => {
|
||||
const [isTagsRequested, setIsTagsRequested] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTagsRequested) {
|
||||
getTags();
|
||||
setIsTagsRequested(true);
|
||||
}
|
||||
})
|
||||
if (isPreLaunch || Object.keys(tagGroups).length === 0) {
|
||||
return null;
|
||||
}
|
||||
else {
|
||||
return (
|
||||
<div className='MenuBody'>
|
||||
<TagGroup tags={tagGroups.misc} category={'misc'} selectTag={selectTag} />
|
||||
<TagGroup tags={tagGroups.where} category={'where'} selectTag={selectTag} />
|
||||
<TagGroup tags={tagGroups.when} category={'when'} selectTag={selectTag} />
|
||||
<TagGroup tags={tagGroups.object_type} category={'object_type'} selectTag={selectTag} />
|
||||
<TagGroup tags={tagGroups.who} category={'who'} selectTag={selectTag} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default MenuBody;
|
|
@ -0,0 +1,11 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { getTags, selectTag } from '../../redux/actions/tags';
|
||||
import MenuBody from './MenuBody';
|
||||
|
||||
const mapStateToProps = state => {
|
||||
return { tagGroups: state.tags, isPreLaunch: state.ui.isPreLaunch };
|
||||
};
|
||||
|
||||
const MenuBodyContainer = connect(mapStateToProps, { getTags, selectTag })(MenuBody);
|
||||
|
||||
export default MenuBodyContainer;
|
|
@ -0,0 +1,64 @@
|
|||
import React from 'react';
|
||||
import reduxLang from '../../middleware/lang';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { updateURLS, isDjangoContentPresent, urlWithoutLocale } from '../../utils';
|
||||
|
||||
const MenuFooter = ({ t, isPreLaunch }) => (
|
||||
<div className='MenuFooter'>
|
||||
<a
|
||||
href='http://www.alpinesmuseum.ch'
|
||||
target='_blank'
|
||||
className='MenuFooter-museum-link small-text'
|
||||
>
|
||||
<img src='/static/gfx/alsp_logo.svg' alt='alpines museum logo' />
|
||||
{t('footer_phrase')}
|
||||
</a>
|
||||
<hr />
|
||||
<LangSelector isPreLaunch={isPreLaunch} />
|
||||
</div>
|
||||
);
|
||||
|
||||
function onClickHandler(locale, _setLocale, dispatch) {
|
||||
_setLocale(locale);
|
||||
updateURLS(locale, dispatch);
|
||||
window.location.replace("/" + locale + urlWithoutLocale(window.location.pathname));
|
||||
}
|
||||
|
||||
export default reduxLang('AppMenu')(MenuFooter);
|
||||
|
||||
const _LangSelector = ({ locale, setLocale, isPreLaunch }) => {
|
||||
const _setLocale = locale => {
|
||||
localStorage.setItem('userLang', locale);
|
||||
setLocale(locale);
|
||||
};
|
||||
const dispatch = useDispatch();
|
||||
|
||||
return (
|
||||
<div className='LangSelector small-text'>
|
||||
<span onClick={() => onClickHandler('de', _setLocale, dispatch)} className={locale === 'de' ? 'activeLang' : ''}>
|
||||
DE
|
||||
</span>
|
||||
<span onClick={() => onClickHandler('fr', _setLocale, dispatch)} className={locale === 'fr' ? 'activeLang' : ''}>
|
||||
FR
|
||||
</span>
|
||||
{!isPreLaunch && (
|
||||
<span
|
||||
onClick={() => onClickHandler('it', _setLocale, dispatch)}
|
||||
className={locale === 'it' ? 'activeLang' : ''}
|
||||
>
|
||||
IT
|
||||
</span>
|
||||
)}
|
||||
{!isPreLaunch && (
|
||||
<span
|
||||
onClick={() => onClickHandler('en', _setLocale, dispatch)}
|
||||
className={locale === 'en' ? 'activeLang' : ''}
|
||||
>
|
||||
EN
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const LangSelector = reduxLang()(_LangSelector);
|
|
@ -0,0 +1,30 @@
|
|||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import MenuNav from './MenuNav';
|
||||
import reduxLang from '../../middleware/lang';
|
||||
import animateScrollTo from 'animated-scroll-to';
|
||||
|
||||
const MenuHeader = ({ toggleMenu, getCatalog, isPreLaunch, deselectTags }) => (
|
||||
<div className='MenuHeader'>
|
||||
<a href="/" onClick={() => {
|
||||
toggleMenu();
|
||||
deselectTags();
|
||||
getCatalog(true);
|
||||
animateScrollTo(document.getElementById('root'));
|
||||
}}><MenuTitle /></a>
|
||||
<MenuNav toggleMenu={toggleMenu} isPreLaunch={isPreLaunch} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default MenuHeader;
|
||||
|
||||
const _MenuTitle = ({ t, locale }) => (
|
||||
<div className={`MenuTitle${locale === 'de' || locale === 'en' ? ' reverted' : ''}`}>
|
||||
<h2 className='MenuTitle--small medium-text'>{t('title_small')}</h2>
|
||||
<div className='MenuTitle--big'>
|
||||
<h1 className='big-text'>{t('title_big_line1')}</h1>
|
||||
<h1 className='big-text'>{t('title_big_line2')}</h1>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const MenuTitle = reduxLang('AppMenu')(_MenuTitle);
|
|
@ -0,0 +1,30 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux'
|
||||
import MenuNavItem from './MenuNavItem';
|
||||
|
||||
function MenuNav({ toggleMenu, isPreLaunch, navigationItems }) {
|
||||
return (
|
||||
<div className='MenNav'>
|
||||
{Object.getOwnPropertyNames(navigationItems).map((navitem, i) => (
|
||||
<MenuNavItem
|
||||
key={i}
|
||||
navitem={navitem}
|
||||
toggleMenu={toggleMenu}
|
||||
isPreLaunch={isPreLaunch}
|
||||
to={navigationItems[navitem][0]}
|
||||
name={navitem}
|
||||
isEnabled={navigationItems[navitem][1]}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function mapStateToProps(state, ownProps) {
|
||||
return {
|
||||
navigationItems: state.urls.allSites[state.urls.site].urls
|
||||
};
|
||||
}
|
||||
export default connect(mapStateToProps)(MenuNav);
|
|
@ -0,0 +1,43 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Link, withRouter } from 'react-router-dom';
|
||||
import reduxLang from '../../middleware/lang';
|
||||
import animateScrollTo from 'animated-scroll-to';
|
||||
|
||||
|
||||
const MenuNavItem = ({ to, name, isEnabled, navitem, toggleMenu, t, location, isPreLaunch }) => {
|
||||
if (isPreLaunch) {
|
||||
const navitems_urls = { contribute: '/mitmachen', about: '/info' };
|
||||
|
||||
navitems_urls.contribute = 'pre-launch-contribute-form';
|
||||
navitems_urls.about = 'pre-launch-info';
|
||||
|
||||
return (
|
||||
<div className='MenuNavItem medium-text'>
|
||||
<a
|
||||
href={`#${navitems_urls[navitem]}`}
|
||||
className={location.pathname.includes(navitems_urls[navitem]) ? 'active' : ''}
|
||||
onClick={() => {
|
||||
animateScrollTo(document.getElementById(navitems_urls[navitem]));
|
||||
toggleMenu();
|
||||
}}
|
||||
>
|
||||
{t(navitem)}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return isEnabled ? (
|
||||
<div className='MenuNavItem medium-text'>
|
||||
<a href={to} className={location.pathname.includes(to) ? 'active' : ''} onClick={toggleMenu}>{name}</a>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
};
|
||||
MenuNavItem.propTypes = {
|
||||
navitem: PropTypes.string.isRequired,
|
||||
t: PropTypes.func.isRequired,
|
||||
location: PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
export default reduxLang('AppMenu')(withRouter(MenuNavItem));
|
|
@ -0,0 +1,73 @@
|
|||
import React, { Component } from 'react';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import animateScrollTo from 'animated-scroll-to';
|
||||
import autobind from 'autobind-decorator';
|
||||
|
||||
const user_events = [
|
||||
'mousemove',
|
||||
'mousedown',
|
||||
'keydown',
|
||||
'scroll',
|
||||
'touchstart',
|
||||
'resize',
|
||||
'visibilitychange'
|
||||
];
|
||||
const idle_timeout = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
const inactivityTime = (callback, idleTimeout) => {
|
||||
var time;
|
||||
|
||||
user_events.forEach(function(name) {
|
||||
document.addEventListener(name, resetTimer, true);
|
||||
});
|
||||
|
||||
function resetTimer(destroy) {
|
||||
clearTimeout(time);
|
||||
if (destroy === true) {
|
||||
user_events.forEach(function(name) {
|
||||
document.removeEventListener(name, resetTimer, true);
|
||||
});
|
||||
} else {
|
||||
time = setTimeout(callback, idleTimeout);
|
||||
}
|
||||
}
|
||||
resetTimer();
|
||||
return resetTimer;
|
||||
};
|
||||
|
||||
@autobind
|
||||
export default class Redirecter extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
goBackHome: false
|
||||
};
|
||||
}
|
||||
|
||||
setNewTimer() {
|
||||
if (this.resetTimer) {
|
||||
// destroy the current timer
|
||||
this.resetTimer(true);
|
||||
}
|
||||
this.resetTimer = inactivityTime(() => {
|
||||
this.setState({ goBackHome: true });
|
||||
this.setState({ goBackHome: false });
|
||||
}, idle_timeout);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.setNewTimer();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.setNewTimer();
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.goBackHome) {
|
||||
animateScrollTo(document.getElementById('root'));
|
||||
return <Redirect to='/' />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
const TagGroup = ({ tags, category, selectTag }) => (
|
||||
<div className='TagGroup'>
|
||||
{tags.map((tag, i) => (
|
||||
<Tag
|
||||
key={i}
|
||||
tag={tag}
|
||||
selectTag={() => {
|
||||
selectTag({ category, slug: tag.slug });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const Tag = ({ tag, selectTag }) => {
|
||||
const color = useSelector(state => state.urls.color);
|
||||
return (
|
||||
<div className='Tag small-text'>
|
||||
<Link to='/'>
|
||||
<button onClick={selectTag} onMouseOut={(e) => e.target.style.backgroundColor = 'transparent'} onMouseOver={(e) => e.target.style.backgroundColor = color} className={tag.selected ? ' selected' : ''}>
|
||||
{tag.name}
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default TagGroup;
|
|
@ -0,0 +1,49 @@
|
|||
import React, { Fragment } from 'react';
|
||||
import { ModalContainer, Modal } from 'minimal-react-modal';
|
||||
import reduxLang from '../../middleware/lang';
|
||||
|
||||
const CVAGBInput = ({ onAGBChange, t }) => (
|
||||
<ModalContainer>
|
||||
{(openModal, closeModal, isActive) => (
|
||||
<div className='CVForm CVAGBInput'>
|
||||
<div className='medium-text'>
|
||||
<input
|
||||
type='checkbox'
|
||||
name='AGB'
|
||||
value='AGB'
|
||||
className='checkboxAGB'
|
||||
onChange={onAGBChange}
|
||||
/>
|
||||
{t('accept_rules_part1')}{' '}
|
||||
<a onClick={openModal}>{t('accept_rules_part2')}</a>{t('accept_rules_part3')}
|
||||
</div>
|
||||
<Modal
|
||||
isActive={isActive} // required
|
||||
closeModal={closeModal} // required
|
||||
>
|
||||
<AGB closeModal={closeModal} />
|
||||
</Modal>
|
||||
</div>
|
||||
)}
|
||||
</ModalContainer>
|
||||
);
|
||||
|
||||
export default reduxLang('ContribView')(CVAGBInput);
|
||||
|
||||
const _AGB = ({ closeModal, t }) => (
|
||||
<div className='Modal--container'>
|
||||
<div className='Modal--body'>
|
||||
<button onClick={closeModal}>
|
||||
<img src='/static/gfx/alps_x.svg' alt='close' />
|
||||
</button>
|
||||
<h1 className='big-text'>{t('title')}</h1>
|
||||
{[...Array(6).keys()].map(key => (
|
||||
<Fragment key={key}>
|
||||
<h4 className='medium-text'>{t(`rule${key + 1}_title`)}</h4>
|
||||
<p className='small-text'>{t(`rule${key + 1}_text`)}</p>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const AGB = reduxLang('Rules')(_AGB);
|
|
@ -0,0 +1,158 @@
|
|||
import React, { Component } from 'react';
|
||||
|
||||
import CVObjectTypeRadio from './CVObjectTypeRadio';
|
||||
import CVDescInput from './CVDescInput';
|
||||
import CVUploadInput from './CVUploadInput';
|
||||
import CVPersonalInfosInputs from './CVPersonalInfosInputs';
|
||||
import CVAGBInput from './CVAGBInput';
|
||||
import CVKeepInput from './CVKeepInput';
|
||||
import CVSubmit from './CVSubmit';
|
||||
import autobind from 'autobind-decorator';
|
||||
import { Notification } from 'react-notification';
|
||||
import Spinner from '../Spinner';
|
||||
import reduxLang from '../../middleware/lang';
|
||||
|
||||
@reduxLang('ContribView')
|
||||
@autobind
|
||||
export default class CVBody extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
object_type: '',
|
||||
desc: '',
|
||||
file: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
birth_year: '',
|
||||
address: '',
|
||||
post_code: '',
|
||||
city: '',
|
||||
phone: '',
|
||||
mail: '',
|
||||
keep: '',
|
||||
agb: false,
|
||||
notification: ''
|
||||
};
|
||||
|
||||
this.props.toggleParticipated(false);
|
||||
}
|
||||
|
||||
onObjectTypeChange(event) {
|
||||
this.setState({ object_type: event.target.value });
|
||||
}
|
||||
onDescChange(event) {
|
||||
this.setState({ desc: event.target.value });
|
||||
}
|
||||
onFileChange(value) {
|
||||
this.setState({ file: value });
|
||||
}
|
||||
onPersonalInfosChange(event) {
|
||||
this.setState({ [event.target.name]: event.target.value });
|
||||
}
|
||||
onKeepChange(event) {
|
||||
this.setState({ keep: event.target.value });
|
||||
}
|
||||
onAGBChange(event) {
|
||||
this.setState({ agb: event.target.checked });
|
||||
}
|
||||
onSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (this.validForm()) {
|
||||
const data = { ...this.state };
|
||||
delete data.abg;
|
||||
delete data.notification;
|
||||
|
||||
this.props.sendParticipation(data);
|
||||
}
|
||||
}
|
||||
|
||||
validForm() {
|
||||
const {
|
||||
object_type,
|
||||
desc,
|
||||
first_name,
|
||||
last_name,
|
||||
mail,
|
||||
keep,
|
||||
agb
|
||||
} = this.state;
|
||||
const { t } = this.props;
|
||||
|
||||
if (!object_type) {
|
||||
this.setState({ notification: t('noti_object_type') });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!desc) {
|
||||
this.setState({ notification: t('noti_desc') });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!last_name) {
|
||||
this.setState({ notification: t('noti_last_name') });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!first_name) {
|
||||
this.setState({ notification: t('noti_first_name') });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!mail) {
|
||||
this.setState({ notification: t('noti_mail') });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!keep) {
|
||||
this.setState({ notification: t('noti_keep') });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!agb) {
|
||||
this.setState({ notification: t('noti_agb') });
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
onNotificationDismiss() {
|
||||
this.setState({ notification: '' });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { notification } = this.state;
|
||||
const { pending, participated, t } = this.props;
|
||||
if (pending) {
|
||||
return <Spinner />;
|
||||
}
|
||||
if (participated) {
|
||||
return (
|
||||
<div className='CVBody participated'>
|
||||
<div className='CVParticipatedMessage medium-text'>{t('thank_you')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className='CVBody'>
|
||||
<Notification
|
||||
isActive={!!notification}
|
||||
message={notification}
|
||||
action={t('noti_close')}
|
||||
onClick={this.onNotificationDismiss}
|
||||
/>
|
||||
<form onSubmit={this.onSubmit}>
|
||||
<CVObjectTypeRadio onObjectTypeChange={this.onObjectTypeChange} />
|
||||
<CVDescInput onDescChange={this.onDescChange} />
|
||||
<CVUploadInput onFileChange={this.onFileChange} />
|
||||
<CVPersonalInfosInputs onPersonalInfosChange={this.onPersonalInfosChange} />
|
||||
<CVKeepInput onKeepChange={this.onKeepChange} />
|
||||
<CVAGBInput onAGBChange={this.onAGBChange} />
|
||||
<CVSubmit />
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { sendParticipation } from '../../redux/actions/participate';
|
||||
import { toggleParticipated } from '../../redux/actions/ui';
|
||||
import CVBody from './CVBody';
|
||||
|
||||
const mapStateToProps = state => {
|
||||
return { pending: state.ui.pending, participated: state.ui.participated };
|
||||
};
|
||||
|
||||
const CVBodyContainer = connect(
|
||||
mapStateToProps,
|
||||
{ sendParticipation, toggleParticipated }
|
||||
)(CVBody);
|
||||
|
||||
export default CVBodyContainer;
|
|
@ -0,0 +1,16 @@
|
|||
import React from 'react';
|
||||
import reduxLang from '../../middleware/lang';
|
||||
|
||||
const CVDescInput = ({ onDescChange, t }) => (
|
||||
<div className='CVForm CVDescInput'>
|
||||
<label className='small-text'>{t('description')}</label>
|
||||
<textarea
|
||||
name='Beschreibung'
|
||||
rows='5'
|
||||
className='textarea medium-text'
|
||||
onChange={onDescChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default reduxLang('ContribView')(CVDescInput);
|
|
@ -0,0 +1,23 @@
|
|||
import React from 'react';
|
||||
import reduxLang from '../../middleware/lang';
|
||||
|
||||
const CVKeepInput = ({ onKeepChange, t }) => (
|
||||
<div className='CVForm CVKeepInput'>
|
||||
<p />
|
||||
<div className='medium-text'>
|
||||
<div className='Auswahltext'>{t('keep_text')}</div>
|
||||
<label>
|
||||
<input type='radio' name='keep' value='forever' onChange={onKeepChange} />
|
||||
<span> {t('give_forever')}</span>
|
||||
</label>
|
||||
<br />
|
||||
<label>
|
||||
<input type='radio' name='keep' value='until_2021' onChange={onKeepChange} />
|
||||
<span> {t('until_2012')}</span>
|
||||
</label>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default reduxLang('ContribView')(CVKeepInput);
|
|
@ -0,0 +1,53 @@
|
|||
import React from 'react';
|
||||
import reduxLang from '../../middleware/lang';
|
||||
|
||||
const CVObjectTypeRadio = ({ onObjectTypeChange, t }) => (
|
||||
<div className='CVForm CVObjectTypeRadio'>
|
||||
<label className='small-text'>{t('i_offer')}</label>
|
||||
<div className='medium-text'>
|
||||
<label>
|
||||
<input
|
||||
type='radio'
|
||||
name='object_type'
|
||||
value='objekt'
|
||||
onChange={onObjectTypeChange}
|
||||
/>
|
||||
<span> {t('offer_object')}</span>
|
||||
</label>
|
||||
<br />
|
||||
<label>
|
||||
<input type='radio' name='object_type' value='foto' onChange={onObjectTypeChange} />
|
||||
<span> {t('offer_photo')}</span>
|
||||
</label>
|
||||
<br />
|
||||
<label>
|
||||
<input type='radio' name='object_type' value='film' onChange={onObjectTypeChange} />
|
||||
<span> {t('offer_film')}</span>
|
||||
</label>
|
||||
<br />
|
||||
<label>
|
||||
<input
|
||||
type='radio'
|
||||
name='object_type'
|
||||
value='skigeschichte'
|
||||
onChange={onObjectTypeChange}
|
||||
/>
|
||||
<span> {t('offer_story')}</span>
|
||||
</label>
|
||||
<br />
|
||||
|
||||
<label>
|
||||
<input
|
||||
type='radio'
|
||||
name='object_type'
|
||||
value='spezialwissen'
|
||||
onChange={onObjectTypeChange}
|
||||
/>
|
||||
<span> {t('offer_knowledge')}</span>
|
||||
</label>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default reduxLang('ContribView')(CVObjectTypeRadio);
|
|
@ -0,0 +1,81 @@
|
|||
import React from 'react';
|
||||
import reduxLang from '../../middleware/lang';
|
||||
|
||||
const CVPersonalInfosInputs = ({ onPersonalInfosChange, t }) => (
|
||||
<div className='CVPersonalInfosInputs'>
|
||||
<div className='CVForm'>
|
||||
<label className='small-text'>{t('first_name')}</label>
|
||||
<input
|
||||
type='text'
|
||||
name='last_name'
|
||||
className='medium-text'
|
||||
onChange={onPersonalInfosChange}
|
||||
/>
|
||||
</div>
|
||||
<div className='CVForm'>
|
||||
<label className='small-text'>{t('last_name')}</label>
|
||||
<input
|
||||
type='text'
|
||||
name='first_name'
|
||||
className='medium-text'
|
||||
onChange={onPersonalInfosChange}
|
||||
/>
|
||||
</div>
|
||||
<div className='CVForm'>
|
||||
<label className='small-text'>{t('birth_year')}</label>
|
||||
<input
|
||||
type='text'
|
||||
name='birth_year'
|
||||
className='medium-text'
|
||||
onChange={onPersonalInfosChange}
|
||||
/>
|
||||
</div>
|
||||
<div className='CVForm'>
|
||||
<label className='small-text'>{t('address')}</label>
|
||||
<input
|
||||
type='text'
|
||||
name='address'
|
||||
className='medium-text'
|
||||
onChange={onPersonalInfosChange}
|
||||
/>
|
||||
</div>
|
||||
<div className='CVForm'>
|
||||
<label className='small-text'>{t('post_code')}</label>
|
||||
<input
|
||||
type='text'
|
||||
name='post_code'
|
||||
className='medium-text'
|
||||
onChange={onPersonalInfosChange}
|
||||
/>
|
||||
</div>
|
||||
<div className='CVForm'>
|
||||
<label className='small-text'>{t('city')}</label>
|
||||
<input
|
||||
type='text'
|
||||
name='city'
|
||||
className='medium-text'
|
||||
onChange={onPersonalInfosChange}
|
||||
/>
|
||||
</div>
|
||||
<div className='CVForm'>
|
||||
<label className='small-text'>{t('phone')}</label>
|
||||
<input
|
||||
type='tel'
|
||||
name='phone'
|
||||
className='medium-text'
|
||||
onChange={onPersonalInfosChange}
|
||||
/>
|
||||
</div>
|
||||
<div className='CVForm'>
|
||||
<label className='small-text'>{t('mail')}</label>
|
||||
<input
|
||||
type='email'
|
||||
name='mail'
|
||||
className='medium-text'
|
||||
onChange={onPersonalInfosChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default reduxLang('ContribView')(CVPersonalInfosInputs);
|
|
@ -0,0 +1,10 @@
|
|||
import React from 'react';
|
||||
import reduxLang from '../../middleware/lang';
|
||||
|
||||
const CVSubmit = ({ t }) => (
|
||||
<div className='CVForm CVSubmit'>
|
||||
<input type='submit' value={t('send_now')} className='submitbtn big-text' />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default reduxLang('ContribView')(CVSubmit);
|
|
@ -0,0 +1,21 @@
|
|||
import React from 'react';
|
||||
import reduxLang from '../../middleware/lang';
|
||||
|
||||
const CVUploadInput = ({ onFileChange, t, locale, reset }) => {
|
||||
|
||||
const onImageChange = event => {
|
||||
if (event.target.files && event.target.files[0]) {
|
||||
let img = event.target.files[0];
|
||||
onFileChange(img);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='CVForm CVUploadInput' id='upload-wetransfer-container'>
|
||||
<label className='small-text'>{t('upload')}</label>
|
||||
<input type="file" id="img" name="img" accept="image/*" onChange={onImageChange} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default reduxLang('ContribView')(CVUploadInput);
|
|
@ -0,0 +1,33 @@
|
|||
import React, { Component } from 'react';
|
||||
import CVBodyContainer from './CVBodyContainer';
|
||||
import BurgerBtnContainer from '../AppMenu/BurgerBtnContainer';
|
||||
import reduxLang from '../../middleware/lang';
|
||||
import animateScrollTo from 'animated-scroll-to';
|
||||
|
||||
class ContribView extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
animateScrollTo(document.getElementById('root'), { speed: 1 });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
return (
|
||||
<div className='ContribView AppContent'>
|
||||
<BurgerBtnContainer />
|
||||
<div className='ContribViewContent '>
|
||||
<h1 className='big-text'>{t('we_search')}</h1>
|
||||
<div className='CVText'>
|
||||
<p
|
||||
className='medium-text'
|
||||
dangerouslySetInnerHTML={{ __html: t('intro_text') }}
|
||||
></p>
|
||||
</div>
|
||||
<CVBodyContainer />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default reduxLang('ContribView')(ContribView);
|
|
@ -0,0 +1,100 @@
|
|||
import React from 'react';
|
||||
import { ModalContainer, Modal } from 'minimal-react-modal';
|
||||
|
||||
const DatenschutzerklarungBtn = ({ label }) => (
|
||||
<ModalContainer>
|
||||
{(openModal, closeModal, isActive) => (
|
||||
<>
|
||||
<a onClick={openModal}>{label}</a>
|
||||
|
||||
<Modal isActive={isActive} closeModal={closeModal}>
|
||||
<Datenschutzerklarung closeModal={closeModal} />
|
||||
</Modal>
|
||||
</>
|
||||
)}
|
||||
</ModalContainer>
|
||||
);
|
||||
export default DatenschutzerklarungBtn;
|
||||
|
||||
const Datenschutzerklarung = ({ closeModal }) => (
|
||||
<div className='Modal--container'>
|
||||
<div className='Modal--body'>
|
||||
<button onClick={closeModal}>
|
||||
<img src='/static/gfx/alps_x.svg' alt='close' />
|
||||
</button>
|
||||
<h1 className='big-text'>Datenschutzerklärung</h1>
|
||||
<p className='small-text'>
|
||||
<br />
|
||||
Beim Zugriff auf unsere Website werden keine persönlichen Daten gespeichert.
|
||||
</p>
|
||||
<h4 className='medium-text'>Hosting</h4>
|
||||
<p className='small-text'>
|
||||
Das Hosting der Daten erfolgt in der Schweiz und wird von der Firma 89grad GmbH
|
||||
betrieben.
|
||||
</p>
|
||||
<h4 className='medium-text'>Kontaktformular</h4>
|
||||
<p className='small-text'>
|
||||
Wenn Sie sich per Kontaktformular bei uns melden, werden Ihre Angaben und
|
||||
Kontaktdaten aus dem Formular zwecks Sicherstellung der Bearbeitung bei uns
|
||||
gespeichert. Diese Daten werden nicht an Dritte weitergegeben.
|
||||
</p>
|
||||
<p className='small-text'>
|
||||
<br />
|
||||
Das Kontaktformular dient ausschliesslich dem Zweck, im Rahmen des Projekts
|
||||
„Fundbüro für Erinnerungen“ mit Ihnen Kontakt aufnehmen zu können. Die von Ihnen
|
||||
eingegebenen Daten verbleiben bei uns, bis Sie uns zur Löschung auffordern, Ihre
|
||||
Einwilligung zur Speicherung widerrufen oder der Zweck für die Datenspeicherung
|
||||
entfällt (z.B. nach abgeschlossener Bearbeitung).
|
||||
</p>
|
||||
<h4 className='medium-text'>Newsletter</h4>
|
||||
<p className='small-text'>
|
||||
Der Versand der Newsletter erfolgt mittels „MailChimp“, einer
|
||||
Newsletterversandplattform des US-Anbieters Rocket Science Group, LLC, 675 Ponce De
|
||||
Leon Ave NE #5000, Atlanta, GA 30308, USA.
|
||||
</p>
|
||||
<p className='small-text'>
|
||||
<br />
|
||||
Die E-Mail-Adressen unserer Newsletterempfänger, als auch deren weitere, im Rahmen
|
||||
dieser Hinweise beschriebenen Daten, werden auf den Servern von MailChimp in den USA
|
||||
gespeichert. MailChimp verwendet diese Informationen zum Versand und zur Auswertung
|
||||
der Newsletter in unserem Auftrag. Des Weiteren kann MailChimp nach eigenen
|
||||
Informationen diese Daten zur Optimierung oder Verbesserung der eigenen Services
|
||||
nutzen, z.B. zur technischen Optimierung des Versandes und der Darstellung der
|
||||
Newsletter oder für wirtschaftliche Zwecke, um zu bestimmen aus welchen Ländern die
|
||||
Empfänger kommen. MailChimp nutzt die Daten unserer Newsletterempfänger jedoch
|
||||
nicht, um diese selbst anzuschreiben oder an Dritte weiterzugeben.
|
||||
</p>
|
||||
<p className='small-text'>
|
||||
<br />
|
||||
Wir vertrauen auf die Zuverlässigkeit und die IT- sowie Datensicherheit von
|
||||
MailChimp. MailChimp ist unter dem US-EU-Datenschutzabkommen „Privacy Shield“
|
||||
zertifiziert und verpflichtet sich damit die EU-Datenschutzvorgaben einzuhalten. Des
|
||||
Weiteren haben wir mit MailChimp ein „Data-Processing-Agreement“ abgeschlossen.
|
||||
Dabei handelt es sich um einen Vertrag, in dem sich MailChimp dazu verpflichtet, die
|
||||
Daten unserer Nutzer zu schützen, entsprechend dessen Datenschutzbestimmungen in
|
||||
unserem Auftrag zu verarbeiten und insbesondere nicht an Dritte weiter zu geben. Die
|
||||
Datenschutzbestimmungen von MailChimp können Sie unter folgendem Link einsehen:{' '}
|
||||
<a href='https://mailchimp.com/legal/privacy/' target='_blank'>
|
||||
https://mailchimp.com/legal/privacy/
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<h4 className='medium-text'>Datenübertragung</h4>
|
||||
<p className='small-text'>
|
||||
Auf unserer Internetseite besteht die Möglichkeit, uns über den Übertragungsdienst
|
||||
WeTransfer gesichert Daten zuzusenden. Dabei erfolgt die Anmeldung und Übertragung
|
||||
direkt über den Dienstleister WeTransfer B.V., Oostelijke Handelskade 751, 1019 BW
|
||||
Amsterdam, the Netherlands.
|
||||
<br />
|
||||
Wir möchten Sie gerne ausdrücklich darauf hinweisen, dass wir als Betreiber der
|
||||
Website keine Kenntnis davon haben, in welchem Umfang WeTransfer diese Daten nutzt.
|
||||
WeTransfer stellt diesbezüglich selbst Informationen zur Verfügung. Diese können Sie
|
||||
in der offiziellen Datenschutzerklärung abrufbar unter{' '}
|
||||
<a href='https://wetransfer.com/legal/privacy' target='_blank'>
|
||||
https://wetransfer.com/legal/privacy
|
||||
</a>{' '}
|
||||
einsehen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
|
@ -0,0 +1,9 @@
|
|||
import React from 'react';
|
||||
|
||||
const ImageWithLegend = ({ image, legend }) => (
|
||||
<div className='ImageWithLegend'>
|
||||
<img src={image} />
|
||||
{legend && <label>{legend}</label>}
|
||||
</div>
|
||||
);
|
||||
export default ImageWithLegend;
|
|
@ -0,0 +1,89 @@
|
|||
import React from 'react';
|
||||
import { ModalContainer, Modal } from 'minimal-react-modal';
|
||||
|
||||
const ImpressumBtn = ({ label }) => (
|
||||
<ModalContainer>
|
||||
{(openModal, closeModal, isActive) => (
|
||||
<>
|
||||
<a onClick={openModal}>{label}</a>
|
||||
|
||||
<Modal isActive={isActive} closeModal={closeModal}>
|
||||
<Impressum closeModal={closeModal} />
|
||||
</Modal>
|
||||
</>
|
||||
)}
|
||||
</ModalContainer>
|
||||
);
|
||||
export default ImpressumBtn;
|
||||
|
||||
const Impressum = ({ closeModal }) => (
|
||||
<div className='Modal--container'>
|
||||
<div className='Modal--body'>
|
||||
<button onClick={closeModal}>
|
||||
<img src='/static/gfx/alps_x.svg' alt='close' />
|
||||
</button>
|
||||
<h1 className='big-text'>Impressum</h1>
|
||||
<h4 className='medium-text'>Kontakt-Adresse</h4>
|
||||
<p className='small-text'>
|
||||
Alpines Museum der Schweiz
|
||||
<br />
|
||||
Helvetiaplatz 4<br />
|
||||
3005 Bern
|
||||
<br />
|
||||
Schweiz
|
||||
</p>
|
||||
<p className='small-text'>
|
||||
E-Mail:
|
||||
<br />
|
||||
info@alpinesmuseum.ch
|
||||
</p>
|
||||
<h4 className='medium-text'>Vertretungsberechtigte Person(en)</h4>
|
||||
<p className='small-text'>Michael Fässler, Projektleiter</p>
|
||||
<h4 className='medium-text'>Handelsregister-Eintrag</h4>
|
||||
<p className='small-text'>
|
||||
Eingetragener Firmenname: Alpines Museum der Schweiz
|
||||
<br />
|
||||
Handelsregister Nr: CHE-107.817.066
|
||||
</p>
|
||||
<h4 className='medium-text'>Mehrwertsteuer-Nummer</h4>
|
||||
<p className='small-text'>CHE-107.817.066</p>
|
||||
<h4 className='medium-text'>Haftungsausschluss</h4>
|
||||
<p className='small-text'>
|
||||
Der Autor übernimmt keinerlei Gewähr hinsichtlich der inhaltlichen Richtigkeit,
|
||||
Genauigkeit, Aktualität, Zuverlässigkeit und Vollständigkeit der Informationen.
|
||||
<br />
|
||||
Haftungsansprüche gegen den Autor wegen Schäden materieller oder immaterieller Art,
|
||||
welche aus dem Zugriff oder der Nutzung bzw. Nichtnutzung der veröffentlichten
|
||||
Informationen, durch Missbrauch der Verbindung oder durch technische Störungen
|
||||
entstanden sind, werden ausgeschlossen.
|
||||
</p>
|
||||
<p className='small-text'>
|
||||
<br />
|
||||
Alle Angebote sind unverbindlich. Der Autor behält es sich ausdrücklich vor, Teile
|
||||
der Seiten oder das gesamte Angebot ohne besondere Ankündigung zu verändern, zu
|
||||
ergänzen, zu löschen oder die Veröffentlichung zeitweise oder endgültig
|
||||
einzustellen.
|
||||
</p>
|
||||
<h4 className='medium-text'>Haftungsausschluss für Links</h4>
|
||||
<p className='small-text'>
|
||||
Verweise und Links auf Webseiten Dritter liegen ausserhalb unseres
|
||||
Verantwortungsbereichs. Es wird jegliche Verantwortung für solche Webseiten
|
||||
abgelehnt. Der Zugriff und die Nutzung solcher Webseiten erfolgen auf eigene Gefahr
|
||||
des jeweiligen Nutzers.
|
||||
</p>
|
||||
<h4 className='medium-text'>Urheberrechte</h4>
|
||||
<p className='small-text'>
|
||||
Die Urheber- und alle anderen Rechte an Inhalten, Bildern, Fotos oder anderen
|
||||
Dateien auf dieser Website, gehören ausschliesslich{' '}
|
||||
<b>der Stiftung Alpines Museum der Schweiz</b> oder den speziell genannten
|
||||
Rechteinhabern. Für die Reproduktion jeglicher Elemente ist die schriftliche
|
||||
Zustimmung des Urheberrechtsträgers im Voraus einzuholen.
|
||||
</p>
|
||||
<br />
|
||||
Quelle:{' '}
|
||||
<a href='https://www.swissanwalt.ch/' target='_blank'>
|
||||
SwissAnwalt
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
|
@ -0,0 +1,71 @@
|
|||
import React, { Component } from 'react';
|
||||
import BurgerBtnContainer from '../AppMenu/BurgerBtnContainer';
|
||||
import reduxLang from '../../middleware/lang';
|
||||
import ImageWithLegend from './ImageWithLegend';
|
||||
import Newsletter from './Newsletter';
|
||||
import Sponsors from './Sponsors';
|
||||
import InfoViewFooter from './InfoViewFooter';
|
||||
import animateScrollTo from 'animated-scroll-to';
|
||||
|
||||
class InfoView extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
animateScrollTo(document.getElementById('root'), { speed: 1 });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
return (
|
||||
<div className='AppContent'>
|
||||
<div className='InfoView'>
|
||||
<BurgerBtnContainer />
|
||||
<h1 style={{ paddingTop: 0 }} className='big-text'>
|
||||
{t('welcome_title')}
|
||||
</h1>
|
||||
<div className='InfoContent'>
|
||||
<p className='medium-text'>{t('exhibition_date')}</p>
|
||||
<br />
|
||||
<br />
|
||||
<div className='embedded-youtube-container'>
|
||||
<iframe
|
||||
width='100%'
|
||||
height='100%'
|
||||
src='https://www.youtube.com/embed/kjpw4LunEEA'
|
||||
frameBorder='0'
|
||||
allow='accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture'
|
||||
allowFullScreen
|
||||
></iframe>
|
||||
</div>
|
||||
<p
|
||||
className='medium-text'
|
||||
dangerouslySetInnerHTML={{ __html: t('welcome_text') }}
|
||||
></p>
|
||||
</div>
|
||||
<div className='ImagesList'>
|
||||
<ImageWithLegend image='/static/gfx/Fundbueuro_Sliderbild_1.jpg' />
|
||||
<ImageWithLegend image='/static/gfx/Fundbueuro_Sliderbild_2.jpg' />
|
||||
<ImageWithLegend image='/static/gfx/Fundbueuro_Sliderbild_3.jpg' />
|
||||
<ImageWithLegend image='/static/gfx/Fundbueuro_Sliderbild_4.jpg' />
|
||||
</div>
|
||||
|
||||
<h1 className='big-text'>{t('about_title')}</h1>
|
||||
<div className='InfoContent'>
|
||||
<p
|
||||
className='medium-text'
|
||||
dangerouslySetInnerHTML={{ __html: t('about_text') }}
|
||||
></p>
|
||||
<a href='http://www.alpinesmuseum.ch' target='_blank'>
|
||||
<img src='/static/gfx/alsp_logo.svg' alt='alpines museum logo' />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Newsletter />
|
||||
<Sponsors />
|
||||
<InfoViewFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default reduxLang('InfoView')(InfoView);
|
|
@ -0,0 +1,12 @@
|
|||
import React from 'react';
|
||||
import ImpressumBtn from './ImpressumBtn';
|
||||
import DatenschutzerklarungBtn from './DatenschutzerklarungBtn';
|
||||
|
||||
const InfoViewFooter = () => (
|
||||
<div className='InfoView-footer'>
|
||||
<ImpressumBtn label='Impressum' />
|
||||
-
|
||||
<DatenschutzerklarungBtn label='Datenschutzerklärung' />
|
||||
</div>
|
||||
);
|
||||
export default InfoViewFooter;
|
|
@ -0,0 +1,17 @@
|
|||
import React from 'react';
|
||||
import reduxLang from '../../middleware/lang';
|
||||
|
||||
const Newsletter = ({ t }) => {
|
||||
return (
|
||||
<div className='InfoView-newsletter'>
|
||||
<a
|
||||
className='medium-text'
|
||||
href={`https://www.alpinesmuseum.ch${t('url_path')}`}
|
||||
target='_blank'
|
||||
>
|
||||
{t('title')}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default reduxLang('Newsletter')(Newsletter);
|
|
@ -0,0 +1,39 @@
|
|||
import React from 'react';
|
||||
import reduxLang from '../../middleware/lang';
|
||||
|
||||
const sponsors = [
|
||||
'01_Lotteriefonds_BE.svg',
|
||||
'02_Kulturstiftung.svg',
|
||||
'03_Kanton_Bern.svg',
|
||||
'04_Burgergemeinde_Bern.svg',
|
||||
'05_SAC.svg',
|
||||
'06_Binding_Stiftung.svg',
|
||||
'07_Mobiliar.svg',
|
||||
'08_Pro_Patria.svg',
|
||||
'09_Swiss_Ski.svg'
|
||||
];
|
||||
|
||||
const Sponsors = ({ t }) => (
|
||||
<div className='InfoView-sponsors'>
|
||||
<div className='CVForm innovationspartner'>
|
||||
<label>{t('innovation_partner')}</label>
|
||||
<div className='InfoView-sponsors--items'>
|
||||
<div className='InfoView-sponsors--item'>
|
||||
<img src='/static/gfx/sponsors/Engagement_Migros.svg' />
|
||||
</div>
|
||||
</div>
|
||||
<p className='small-text' dangerouslySetInnerHTML={{ __html: t('migros_text') }}></p>
|
||||
</div>
|
||||
<div className='CVForm'>
|
||||
<label>{t('project_partner')}</label>
|
||||
<div className='InfoView-sponsors--items'>
|
||||
{sponsors.map((sponsor, i) => (
|
||||
<div className='InfoView-sponsors--item' key={i}>
|
||||
<img src={`/static/gfx/sponsors/${sponsor}`} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default reduxLang('Sponsors')(Sponsors);
|
|
@ -0,0 +1,8 @@
|
|||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
const Spinner = () => {
|
||||
const color = useSelector(state => state.urls.color);
|
||||
return <div className='spinner' style={{borderTopColor: color}} />;
|
||||
}
|
||||
export default Spinner;
|
|
@ -0,0 +1,22 @@
|
|||
import AppContainer from './AppContainer';
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import React, { useEffect } from 'react';
|
||||
import { defineLocale, updateURLS } from "../utils";
|
||||
|
||||
export default function SplashScreen(props) {
|
||||
const apiUrl = useSelector(state => state.urls.site);
|
||||
const dispatch = useDispatch();
|
||||
const locale = defineLocale(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
updateURLS(locale, dispatch);
|
||||
});
|
||||
if (!apiUrl) {
|
||||
return (
|
||||
<div style={{fontSize: "48px", fontWeight: "600", margin: "auto"}}>
|
||||
Loading...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <AppContainer />
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import reduxLang from '../middleware/lang';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
|
||||
const _StartScreen = ({ t, search }) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searched, setSearched] = useState(false);
|
||||
|
||||
const handleSearchInput = event => {
|
||||
setSearchQuery(event.target.value);
|
||||
};
|
||||
|
||||
const handleSearch = event => {
|
||||
event.preventDefault();
|
||||
search(searchQuery);
|
||||
setSearched(true);
|
||||
};
|
||||
|
||||
if (searched) {
|
||||
return <Redirect to='/' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='StartScreen'>
|
||||
<Link to='/'>
|
||||
<img src='/static/gfx/alps_x.svg' alt='close' />
|
||||
</Link>
|
||||
<form onSubmit={handleSearch}>
|
||||
<input
|
||||
className='medium-text'
|
||||
type='text'
|
||||
id='start-screen-search-field'
|
||||
placeholder={t('placeholder')}
|
||||
value={searchQuery}
|
||||
onChange={handleSearchInput}
|
||||
/>
|
||||
<img src='/static/gfx/alps_lupe.svg' alt='search' />
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const StartScreen = reduxLang('StartScreen')(_StartScreen);
|
||||
export default StartScreen;
|
|
@ -0,0 +1,10 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { search } from '../redux/actions/catalog';
|
||||
import StartScreen from './StartScreen';
|
||||
|
||||
const StartScreenContainer = connect(
|
||||
undefined,
|
||||
{ search }
|
||||
)(StartScreen);
|
||||
|
||||
export default StartScreenContainer;
|
|
@ -0,0 +1,42 @@
|
|||
import React from 'react';
|
||||
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
|
||||
import AppContentContainer from './AppContentContainer';
|
||||
import CIDetailViewContainer from './CIDetailViewContainer';
|
||||
import reduxLang from '../../middleware/lang';
|
||||
import StartScreenContainer from './../StartScreenContainer';
|
||||
import { useSelector } from 'react-redux'
|
||||
import { getLocaleFromURL, defineLocale, shouldDisplayDynamicComponent, getDjangoView, routePathForDynamicComponent } from '../../utils';
|
||||
|
||||
const App = ({ pending, menuOpen, setLocale, isPreLaunch }) => {
|
||||
// set initial language
|
||||
setLocale(defineLocale(isPreLaunch));
|
||||
let localeFromURL = getLocaleFromURL(window.location.pathname);
|
||||
if (localeFromURL === undefined) {
|
||||
localeFromURL = ''
|
||||
}
|
||||
const basename = '/' + localeFromURL + useSelector(state => state.urls.site);
|
||||
|
||||
const activeSite = useSelector(state => state.urls.site);
|
||||
const urls = useSelector(state => state.urls.allSites[activeSite].urls);
|
||||
const DynamicComponent = React.memo(() => (
|
||||
<div className="AppContent">
|
||||
<div dangerouslySetInnerHTML={{ __html: getDjangoView() }} />
|
||||
</div>
|
||||
));
|
||||
return (
|
||||
<div
|
||||
className={`App touch ${pending ? 'loading' : ''}`}
|
||||
>
|
||||
<Router basename={basename}>
|
||||
<Switch>
|
||||
{ shouldDisplayDynamicComponent(urls) ? <Route path={routePathForDynamicComponent(basename)} component={DynamicComponent} /> : null }
|
||||
<Route path='/' component={AppContentContainer} />
|
||||
</Switch>
|
||||
<Route path='/catalog/:itemId' component={CIDetailViewContainer} />
|
||||
<Route path='/start' component={StartScreenContainer} />
|
||||
</Router>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default reduxLang()(App);
|
|
@ -0,0 +1,13 @@
|
|||
import { connect } from 'react-redux';
|
||||
import App from './App';
|
||||
|
||||
const mapStateToProps = state => {
|
||||
return {
|
||||
pending: state.ui.pending,
|
||||
isPreLaunch: state.ui.isPreLaunch
|
||||
};
|
||||
};
|
||||
|
||||
const AppContainer = connect(mapStateToProps)(App);
|
||||
|
||||
export default AppContainer;
|
|
@ -0,0 +1,31 @@
|
|||
import React, { Component } from 'react';
|
||||
import ContentHeader from './ContentHeader';
|
||||
import ContentBodyContainer from './../AppContent/ContentBodyContainer';
|
||||
import ContentOverlayContainer from './../AppContent/ContentOverlayContainer';
|
||||
import PreLaunchPage from './../AppContent/PreLaunchPage';
|
||||
import animateScrollTo from 'animated-scroll-to';
|
||||
|
||||
export default class AppContent extends Component {
|
||||
componentDidMount() {
|
||||
const { isPreLaunch, getMode } = this.props;
|
||||
|
||||
animateScrollTo(document.getElementById('root'));
|
||||
|
||||
if (isPreLaunch === undefined) {
|
||||
getMode();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isPreLaunch } = this.props;
|
||||
return (
|
||||
<div className={`AppContent${isPreLaunch ? ' is-pre-launch' : ''}`}>
|
||||
<ContentHeader isPreLaunch={isPreLaunch} />
|
||||
{!isPreLaunch && <ContentBodyContainer />}
|
||||
{!isPreLaunch && <ContentOverlayContainer />}
|
||||
{isPreLaunch && <PreLaunchPage />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { getMode } from '../../redux/actions/mode';
|
||||
import AppContent from './AppContent';
|
||||
|
||||
const mapStateToProps = state => {
|
||||
return {
|
||||
catalogFetched: !!state.catalog.length,
|
||||
isPreLaunch: state.ui.isPreLaunch
|
||||
};
|
||||
};
|
||||
|
||||
const AppContentContainer = connect(mapStateToProps, { getMode })(AppContent);
|
||||
|
||||
export default AppContentContainer;
|
|
@ -0,0 +1,87 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import DVHeader from './../AppContent/CIDetailView/DVHeader';
|
||||
import DVBody from './DVBody';
|
||||
import Spinner from './../Spinner';
|
||||
import Div100vh from 'react-div-100vh';
|
||||
import { useSelector } from 'react-redux'
|
||||
|
||||
const CIDetailView = ({
|
||||
activeItem,
|
||||
isFirst,
|
||||
isLast,
|
||||
close,
|
||||
catalog,
|
||||
pending,
|
||||
openDetail,
|
||||
showNextItem,
|
||||
showPrevItem,
|
||||
match,
|
||||
participated,
|
||||
sendParticipation,
|
||||
toggleParticipated,
|
||||
color
|
||||
}) => {
|
||||
if (activeItem === null) {
|
||||
// we have the catalog, but this view was not opened through an action
|
||||
// but by it's URL. Now we need to dispatch an action that,
|
||||
// depending on the current route, will tell which id the ID of the item to display.
|
||||
if (catalog.length) {
|
||||
catalog.forEach((item, i) => {
|
||||
if (item.inventory_number === match.params.itemId.replace('__', ' ')) {
|
||||
openDetail(i);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// we don't know yet what data to display.
|
||||
// Most certainly the catalog is being fetched.
|
||||
return (
|
||||
<div className='CIDetailView__loading AppContent'>
|
||||
<DVHeader close={close} />
|
||||
{pending && <Spinner />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (activeItem.inventory_number !== match.params.itemId.replace('__', ' ')) {
|
||||
window.history.pushState(null, null, activeItem.inventory_number.replace(' ', '__'));
|
||||
}
|
||||
|
||||
const doYouKnowMoreURL = (
|
||||
useSelector(state => state.urls.site) + "do-you-know-more/?object_id=" + activeItem.inventory_number
|
||||
);
|
||||
|
||||
return (
|
||||
<Div100vh>
|
||||
<div className='CIDetailView AppContent' id='CIDetailView'>
|
||||
<div id='CIDetailView-top-element'></div>
|
||||
<DVHeader close={close} />
|
||||
<DVBody
|
||||
item={activeItem}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
showNextItem={showNextItem}
|
||||
showPrevItem={showPrevItem}
|
||||
participated={participated}
|
||||
sendParticipation={sendParticipation}
|
||||
toggleParticipated={toggleParticipated}
|
||||
doYouKnowMoreURL={doYouKnowMoreURL}
|
||||
color={color}
|
||||
/>
|
||||
</div>
|
||||
</Div100vh>
|
||||
);
|
||||
};
|
||||
|
||||
CIDetailView.propTypes = {
|
||||
activeItem: PropTypes.object,
|
||||
isFirst: PropTypes.bool,
|
||||
isLast: PropTypes.bool,
|
||||
close: PropTypes.func.isRequired,
|
||||
catalog: PropTypes.array,
|
||||
openDetail: PropTypes.func.isRequired,
|
||||
showNextItem: PropTypes.func.isRequired,
|
||||
showPrevItem: PropTypes.func.isRequired,
|
||||
match: PropTypes.object.isRequired
|
||||
};
|
||||
export default CIDetailView;
|
|
@ -0,0 +1,54 @@
|
|||
import { connect } from 'react-redux';
|
||||
import {
|
||||
openDetail,
|
||||
showNextItem,
|
||||
showPrevItem,
|
||||
toggleParticipated,
|
||||
closeDetail
|
||||
} from '../../redux/actions/ui';
|
||||
import CIDetailView from './CIDetailView';
|
||||
import { sendParticipation } from '../../redux/actions/participate';
|
||||
|
||||
const mapStateToProps = state => {
|
||||
if (state.ui.activeItem === null) {
|
||||
return {
|
||||
catalog: state.catalog,
|
||||
pending: state.ui.pending,
|
||||
activeItem: null
|
||||
};
|
||||
}
|
||||
|
||||
let activeItemData = state.catalog[state.ui.activeItem];
|
||||
if (activeItemData.inventory_number == undefined) {
|
||||
activeItemData = state.catalog[state.ui.activeItem + 1];
|
||||
}
|
||||
|
||||
const isFirst = state.ui.activeItem === 0;
|
||||
const isLast = state.ui.activeItem === state.catalog.length - 1;
|
||||
|
||||
return {
|
||||
activeItem: activeItemData,
|
||||
isFirst,
|
||||
isLast,
|
||||
participated: state.ui.participated,
|
||||
color: state.urls.color
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
close: () => dispatch(closeDetail()),
|
||||
openDetail: item => dispatch(openDetail(item)),
|
||||
showNextItem: () => dispatch(showNextItem()),
|
||||
showPrevItem: () => dispatch(showPrevItem()),
|
||||
sendParticipation: data => dispatch(sendParticipation(data)),
|
||||
toggleParticipated: () => dispatch(toggleParticipated())
|
||||
};
|
||||
};
|
||||
|
||||
const CIDetailViewContainer = connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(CIDetailView);
|
||||
|
||||
export default CIDetailViewContainer;
|
|
@ -0,0 +1,51 @@
|
|||
import React, { Component } from 'react';
|
||||
import PropTypes, { func } from 'prop-types';
|
||||
import autobind from 'autobind-decorator';
|
||||
import HeaderSearchFieldContainer from './../AppContent/HeaderSearchFieldContainer';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { connect } from 'react-redux';
|
||||
import LangDropMenu from './LangDropMenu';
|
||||
@autobind
|
||||
class _ContentHeader extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
searching: true
|
||||
};
|
||||
}
|
||||
render() {
|
||||
return <div className='ContentHeader'>
|
||||
<HeaderSearchFieldContainer showClose={false} />
|
||||
<LangDropMenu />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = state => {
|
||||
return {
|
||||
color: state.urls.color,
|
||||
scrollingText: state.urls.scrollingText
|
||||
};
|
||||
};
|
||||
const ContentHeader = connect(mapStateToProps)(_ContentHeader);
|
||||
export default ContentHeader;
|
||||
|
||||
function _HeaderTitle({ t }) {
|
||||
const siteName = useSelector(state => state.urls.name);
|
||||
return <h1 className='HeaderTitle big-text'>{siteName}</h1>;
|
||||
}
|
||||
_HeaderTitle.propTypes = {
|
||||
t: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
const HeaderSearchBtn = ({ handler }) => (
|
||||
<div className='HeaderSearchBtn'>
|
||||
<button onClick={handler}>
|
||||
<img src='/static/gfx/alps_lupe.svg' alt='search' />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
HeaderSearchBtn.propTypes = {
|
||||
handler: PropTypes.func.isRequired
|
||||
};
|
|
@ -0,0 +1,193 @@
|
|||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import reduxLang from '../../middleware/lang';
|
||||
import DVKownMore from './DVKownMore';
|
||||
import DVLabel from './../AppContent/CIDetailView/DVLabel';
|
||||
import { DefaultPlayer as Video } from 'react-html5video';
|
||||
import { useSwipeable } from 'react-swipeable';
|
||||
import Div100vh from 'react-div-100vh';
|
||||
|
||||
|
||||
const DVBody = ({ item, isFirst, isLast, showNextItem, showPrevItem, participated,
|
||||
sendParticipation, toggleParticipated, doYouKnowMoreURL, color, t }) => {
|
||||
return <div className='DVBody'>
|
||||
<DetailMediaContainer
|
||||
item={item}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
showNextItem={showNextItem}
|
||||
showPrevItem={showPrevItem}
|
||||
/>
|
||||
<DVKownMore
|
||||
item={item}
|
||||
participated={participated}
|
||||
sendParticipation={sendParticipation}
|
||||
toggleParticipated={toggleParticipated}
|
||||
doYouKnowMoreURL={doYouKnowMoreURL}
|
||||
color={color}
|
||||
/>
|
||||
<DetailViewTags tags={item.tags} label={t('category')} />
|
||||
<DetailAttributeContainer
|
||||
name={t('inventory_number')}
|
||||
content={item['inventory_number']}
|
||||
phone={true}
|
||||
/>
|
||||
<DetailAttributeContainer name={t('title')} content={item.title} />
|
||||
<DetailAttributeContainer name={t('date')} content={item.date} />
|
||||
<DetailAttributeContainer name={t('owner')} content={item.participant} />
|
||||
<DetailAttributeContainer name={t('special')} content={item.description} />
|
||||
{item.history && <DetailAttributeContainer name={t('story')} content={item.history} />}
|
||||
</div>
|
||||
};
|
||||
|
||||
DVBody.propTypes = {
|
||||
item: PropTypes.object.isRequired,
|
||||
isFirst: PropTypes.bool.isRequired,
|
||||
isLast: PropTypes.bool.isRequired,
|
||||
t: PropTypes.func.isRequired
|
||||
};
|
||||
export default reduxLang('DVBody')(DVBody);
|
||||
|
||||
const DetailMediaContainer = ({ item, isFirst, isLast, showNextItem, showPrevItem }) => {
|
||||
const handlers = useSwipeable({
|
||||
onSwipedLeft: !isLast ? showNextItem : undefined,
|
||||
onSwipedRight: !isFirst ? showPrevItem : undefined
|
||||
});
|
||||
return (
|
||||
<div className={`DetailMediaContainer${item.youtube ? ' youtube' : ''}`} {...handlers}>
|
||||
{!isFirst && <LeftNavButton handler={showPrevItem} />}
|
||||
<DVLabel text={item.inventory_number} />
|
||||
{item.video || item.youtube ? (
|
||||
<DIVideo video={item.video} youtube={item.youtube} alt={item.title} />
|
||||
) : (
|
||||
<DIImage item={item} alt={item.title} />
|
||||
)}
|
||||
{!isLast && <RightNavButton handler={showNextItem} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
DetailMediaContainer.propTypes = {
|
||||
item: PropTypes.object.isRequired,
|
||||
isFirst: PropTypes.bool.isRequired,
|
||||
isLast: PropTypes.bool.isRequired,
|
||||
showNextItem: PropTypes.func.isRequired,
|
||||
showPrevItem: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
const LeftNavButton = ({ handler }) => (
|
||||
<button className='LeftNavButton' onClick={handler}>
|
||||
<img src='/static/gfx/alps_links.svg' alt='left' />
|
||||
</button>
|
||||
);
|
||||
LeftNavButton.propTypes = {
|
||||
handler: PropTypes.func.isRequired
|
||||
};
|
||||
const RightNavButton = ({ handler }) => (
|
||||
<button className='RightNavButton' onClick={handler}>
|
||||
<img src='/static/gfx/alps_rechts.svg' alt='right' />
|
||||
</button>
|
||||
);
|
||||
RightNavButton.propTypes = {
|
||||
handler: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
function youtube_parser(url) {
|
||||
var regExp = /^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#\&\?]*).*/;
|
||||
var match = url.match(regExp);
|
||||
return match && match[7].length == 11 ? match[7] : false;
|
||||
}
|
||||
const DIVideo = ({ video, youtube }) => (
|
||||
<div className='DIVideo'>
|
||||
{!youtube ? (
|
||||
<Video controls={['PlayPause', 'Seek', 'Time', 'Volume', 'Fullscreen']}>
|
||||
<source src={video} type='video/mp4' />
|
||||
</Video>
|
||||
) : (
|
||||
<iframe
|
||||
width='100%'
|
||||
src={`https://www.youtube.com/embed/${youtube_parser(youtube)}`}
|
||||
frameBorder='0'
|
||||
allow='accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture'
|
||||
allowFullScreen
|
||||
></iframe>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
DIVideo.propTypes = {
|
||||
video: PropTypes.string,
|
||||
youtube: PropTypes.string
|
||||
};
|
||||
|
||||
const DIImage = ({ item, alt = '' }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const openFullScreen = () => {
|
||||
setOpen(true);
|
||||
|
||||
// fix cut off part on Safari
|
||||
document.getElementById('CIDetailView').style.overflow = 'visible';
|
||||
};
|
||||
|
||||
const closeFullScreen = () => {
|
||||
setOpen(false);
|
||||
|
||||
// revert Safari fix don when opening
|
||||
document.getElementById('CIDetailView').style.overflow = '';
|
||||
};
|
||||
|
||||
if (open) {
|
||||
return <FullScreenImage image={item.image} close={closeFullScreen} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='DIImage' onClick={openFullScreen}>
|
||||
<img src={item.image} alt={alt} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
DIImage.propTypes = {
|
||||
item: PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
const FullScreenImage = ({ image, close }) => (
|
||||
<Div100vh>
|
||||
<div
|
||||
className='FullScreenImage'
|
||||
style={{ backgroundImage: `url('${image}')` }}
|
||||
onClick={close}
|
||||
></div>
|
||||
</Div100vh>
|
||||
);
|
||||
|
||||
const DetailViewTags = ({ tags, label }) => (
|
||||
<div className='DetailViewTags'>
|
||||
<DVLabel text={label} />
|
||||
<div>
|
||||
{tags.map((tag, i) => (
|
||||
<span className='Tag' key={i}>
|
||||
<button style={{border: '0px solid black'}}>{tag.name}</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DetailAttributeContainer = ({ name, content, phone = false }) => (
|
||||
<div className={`DetailAttributeContainer${phone ? ' phone-invetory-number' : ''}`}>
|
||||
<DVLabel text={name} />
|
||||
<DAContent content={content} />
|
||||
</div>
|
||||
);
|
||||
DetailAttributeContainer.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
content: PropTypes.any.isRequired
|
||||
};
|
||||
|
||||
const DAContent = ({ content }) => (
|
||||
<div className='DAContent'>
|
||||
<p className='medium-text'>{content}</p>
|
||||
</div>
|
||||
);
|
||||
DAContent.propTypes = {
|
||||
content: PropTypes.any.isRequired
|
||||
};
|
|
@ -0,0 +1,174 @@
|
|||
import React, { Component } from 'react';
|
||||
import reduxLang from '../../middleware/lang';
|
||||
import CVDescInput from './../ContribView/CVDescInput';
|
||||
import CVPersonalInfosInputs from './../ContribView/CVPersonalInfosInputs';
|
||||
import CVSubmit from './../ContribView/CVSubmit';
|
||||
import autobind from 'autobind-decorator';
|
||||
import { Notification } from 'react-notification';
|
||||
import animateScrollTo from 'animated-scroll-to';
|
||||
|
||||
@reduxLang('ContribView')
|
||||
@autobind
|
||||
export default class DVKownMore extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isToggleOn: false,
|
||||
desc: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
birth_year: '',
|
||||
address: '',
|
||||
post_code: '',
|
||||
city: '',
|
||||
phone: '',
|
||||
mail: '',
|
||||
agb: false,
|
||||
notification: ''
|
||||
};
|
||||
|
||||
this.props.toggleParticipated(false);
|
||||
}
|
||||
|
||||
handleClick() {
|
||||
const { isToggleOn } = this.state;
|
||||
if (isToggleOn) {
|
||||
this.props.toggleParticipated(false);
|
||||
animateScrollTo(document.getElementById('CIDetailView-top-element'), {
|
||||
elementToScroll: document.getElementById('CIDetailView')
|
||||
}).then(hasScrolledToPosition => {
|
||||
// scroll animation is finished
|
||||
|
||||
// "hasScrolledToPosition" indicates if page/element
|
||||
// was scrolled to a desired position
|
||||
// or if animation got interrupted
|
||||
if (hasScrolledToPosition) {
|
||||
// page is scrolled to a desired position
|
||||
|
||||
this.setState({
|
||||
isToggleOn: !isToggleOn
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
isToggleOn: !isToggleOn
|
||||
});
|
||||
this.props.toggleParticipated(false);
|
||||
|
||||
// if not executed 2 time, the scroll would not be complete
|
||||
setImmediate(() =>
|
||||
animateScrollTo(document.getElementById('detail-view-footer-button'), {
|
||||
elementToScroll: document.getElementById('CIDetailView')
|
||||
}).then(() =>
|
||||
animateScrollTo(document.getElementById('detail-view-footer-button'), {
|
||||
elementToScroll: document.getElementById('CIDetailView')
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onObjectTypeChange(event) {
|
||||
this.setState({ object_type: event.target.value });
|
||||
}
|
||||
onDescChange(event) {
|
||||
this.setState({ desc: event.target.value });
|
||||
}
|
||||
onPersonalInfosChange(event) {
|
||||
this.setState({ [event.target.name]: event.target.value });
|
||||
}
|
||||
onKeepChange(event) {
|
||||
this.setState({ keep: event.target.value });
|
||||
}
|
||||
onAGBChange(event) {
|
||||
this.setState({ agb: event.target.checked });
|
||||
}
|
||||
onSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (this.validForm()) {
|
||||
const data = { ...this.state, object_id: this.props.item.inventory_number };
|
||||
delete data.abg;
|
||||
delete data.notification;
|
||||
delete data.isToggleOn;
|
||||
|
||||
this.props.sendParticipation(data);
|
||||
}
|
||||
}
|
||||
|
||||
validForm() {
|
||||
const { desc, first_name, last_name, mail } = this.state;
|
||||
const { t } = this.props;
|
||||
|
||||
if (!desc) {
|
||||
this.setState({ notification: t('noti_desc') });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!last_name) {
|
||||
this.setState({ notification: t('noti_last_name') });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!first_name) {
|
||||
this.setState({ notification: t('noti_first_name') });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!mail) {
|
||||
this.setState({ notification: t('noti_mail') });
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
onNotificationDismiss() {
|
||||
this.setState({ notification: '' });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isToggleOn, notification } = this.state;
|
||||
const { t, participated, doYouKnowMoreURL, color } = this.props;
|
||||
return (
|
||||
<div className='DVFooter' style={{backgroundColor: color}}>
|
||||
<div>
|
||||
<div className='DVFooterTop'>
|
||||
<a id='detail-view-footer-button' href={doYouKnowMoreURL}>
|
||||
<p className='medium-text'>{t('know_more')}</p>
|
||||
</a>
|
||||
</div>
|
||||
{isToggleOn && (
|
||||
<div className='DVDropdown'>
|
||||
<div className='DVText'>
|
||||
<p className='medium-text'>{/* TODO */}</p>
|
||||
</div>
|
||||
{participated && (
|
||||
<div className='CVParticipatedMessage medium-text'>
|
||||
{t('thank_you')}
|
||||
</div>
|
||||
)}
|
||||
<Notification
|
||||
isActive={!!notification}
|
||||
message={notification}
|
||||
action={'schliessen'}
|
||||
onClick={this.onNotificationDismiss}
|
||||
/>
|
||||
<form
|
||||
onSubmit={this.onSubmit}
|
||||
style={participated ? { opacity: 0, pointerEvents: 'none' } : {}}
|
||||
>
|
||||
<CVDescInput onDescChange={this.onDescChange} />
|
||||
<CVPersonalInfosInputs
|
||||
onPersonalInfosChange={this.onPersonalInfosChange}
|
||||
/>
|
||||
<CVSubmit />
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,89 @@
|
|||
import React, { Component } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import reduxLang from '../../middleware/lang';
|
||||
import { updateURLS, urlWithoutLocale } from '../../utils';
|
||||
|
||||
@reduxLang('AppContent')
|
||||
class LangDropMenu extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.dropdownLinks = [
|
||||
{ linkText: 'EN', val: 'en'},
|
||||
{ linkText: 'DE', val: 'de'},
|
||||
{ linkText: 'FR', val: 'fr'},
|
||||
{ linkText: 'IT', val: 'it'}
|
||||
];
|
||||
|
||||
this.state = {
|
||||
showMenu: false,
|
||||
selectedLanguage: this.dropdownLinks.filter(lang => {
|
||||
return lang.val == this.props.locale;
|
||||
})[0]
|
||||
};
|
||||
|
||||
this.showMenu = this.showMenu.bind(this);
|
||||
this.setLanguage = this.setLanguage.bind(this);
|
||||
this.closeMenu = this.closeMenu.bind(this);
|
||||
|
||||
}
|
||||
|
||||
showMenu(event) {
|
||||
event.preventDefault();
|
||||
this.setState({ showMenu: true }, () => {
|
||||
document.addEventListener('click', this.closeMenu);
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
setLanguage(language) {
|
||||
console.log(language);
|
||||
this.setState({selectedLanguage: language});
|
||||
localStorage.setItem('userLang', language.val);
|
||||
|
||||
this.props.setLocale(language.val);
|
||||
updateURLS(language.val, this.props.dispatch);
|
||||
window.location.replace("/" + language.val + urlWithoutLocale(window.location.pathname));
|
||||
}
|
||||
|
||||
closeMenu(event) {
|
||||
if (!this.dropdownMenu.contains(event.target) || this.dropdownMenu.contains(event.target)) {
|
||||
|
||||
this.setState({ showMenu: false }, () => {
|
||||
document.removeEventListener('click', this.closeMenu);
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let dropdownMenuShow = null;
|
||||
if (this.state.showMenu) {
|
||||
dropdownMenuShow = <ul
|
||||
className="dropdown-menu"
|
||||
ref={(element) => {
|
||||
this.dropdownMenu = element;
|
||||
}}>
|
||||
{this.dropdownLinks.map((dropdownLink, index) => {
|
||||
return (
|
||||
<li key={index}>
|
||||
<NavLink to="#" onClick={() => this.setLanguage(dropdownLink)} className="dropdown-item small-text" exact>
|
||||
<span>{dropdownLink.linkText}</span>
|
||||
</NavLink>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
}
|
||||
return (
|
||||
<div className="dropdown HeaderBurger lang-dropdown">
|
||||
<NavLink to='#' className='lang-links small-text' onClick={this.showMenu}>
|
||||
<span>{this.state.selectedLanguage.linkText}</span>
|
||||
<i className='fas fa-caret-down' />
|
||||
</NavLink>
|
||||
{dropdownMenuShow}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
export default LangDropMenu;
|
|
@ -0,0 +1,22 @@
|
|||
import AppContainer from './AppContainer';
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import React, { useEffect } from 'react';
|
||||
import { defineLocale, updateURLS } from "../../utils";
|
||||
|
||||
export default function SplashScreen(props) {
|
||||
const apiUrl = useSelector(state => state.urls.site);
|
||||
const dispatch = useDispatch();
|
||||
const locale = defineLocale(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
updateURLS(locale, dispatch);
|
||||
});
|
||||
if (!apiUrl) {
|
||||
return (
|
||||
<div style={{fontSize: "48px", fontWeight: "600", margin: "auto"}}>
|
||||
Loading...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <AppContainer />
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
import '@babel/polyfill';
|
||||
import 'whatwg-fetch';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import store from './redux/store';
|
||||
import '../less/index.less';
|
||||
import SplashScreen from "./components/SplashScreen";
|
||||
|
||||
const app = (
|
||||
<Provider store={store}>
|
||||
<SplashScreen />
|
||||
</Provider>
|
||||
);
|
||||
ReactDOM.render(app, document.getElementById('root'));
|
|
@ -0,0 +1,15 @@
|
|||
import '@babel/polyfill';
|
||||
import 'whatwg-fetch';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import store from './redux/store';
|
||||
import '../less/index.less';
|
||||
import SplashScreen from "./components/Tablet/SplashScreen";
|
||||
|
||||
const app = (
|
||||
<Provider store={store}>
|
||||
<SplashScreen />
|
||||
</Provider>
|
||||
);
|
||||
ReactDOM.render(app, document.getElementById('root'));
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
|
@ -0,0 +1 @@
|
|||
export const REDUX_LANG_SET_LOCALE = 'REDUX_LANG_SET_LOCALE'
|
|
@ -0,0 +1,3 @@
|
|||
import { REDUX_LANG_SET_LOCALE } from './actionTypes'
|
||||
|
||||
export const setLocale = value => ({ type: REDUX_LANG_SET_LOCALE, value })
|
|
@ -0,0 +1,19 @@
|
|||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import * as actions from './actions'
|
||||
import { getString } from './helpers'
|
||||
|
||||
const defaultConfig = {
|
||||
reducerKey: 'locale'
|
||||
}
|
||||
|
||||
export const createLang = (dictionary, config = defaultConfig) => {
|
||||
const getStringFromDictionary = getString(dictionary)
|
||||
return screenKey => component => {
|
||||
const { reducerKey } = config
|
||||
const mstp = ({ [reducerKey]: locale }) =>
|
||||
({ [reducerKey]: locale, t: getStringFromDictionary(locale)(screenKey) })
|
||||
const mdtp = dispatch => bindActionCreators(actions, dispatch)
|
||||
return connect(mstp, mdtp)(component)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
import { vsprintf } from 'sprintf-js'
|
||||
|
||||
export const applyReplacements = (obj, replacements = []) => {
|
||||
return (typeof obj === 'string' && replacements.length > 0)
|
||||
? vsprintf(obj, replacements)
|
||||
: obj
|
||||
}
|
||||
|
||||
export const getString = dictionary => localeKey => screenKey => (stringKey, replacements = []) => {
|
||||
return [localeKey, screenKey, stringKey].reduce((acc, key) => {
|
||||
if (acc[key]) return applyReplacements(acc[key], replacements)
|
||||
console.warn(`ReduxLang: ${localeKey} / ${screenKey} / ${stringKey} does not exist!`)
|
||||
return key
|
||||
}, dictionary)
|
||||
}
|
||||
|
||||
export const square = number => number * number
|
|
@ -0,0 +1,9 @@
|
|||
import { createLang } from './createLang'
|
||||
import { setLocale } from './actions'
|
||||
import langReducer from './reducer'
|
||||
|
||||
export {
|
||||
createLang,
|
||||
setLocale,
|
||||
langReducer
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
import * as types from './actionTypes'
|
||||
|
||||
export default (initialState) => {
|
||||
return (state = initialState, action = {}) => {
|
||||
switch (action.type) {
|
||||
|
||||
case types.REDUX_LANG_SET_LOCALE:
|
||||
return action.value ? action.value : state
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
import { createLang } from '../lib/redux-lang';
|
||||
import dictionary from '../../lang';
|
||||
const reduxLang = createLang(dictionary);
|
||||
export default reduxLang;
|
|
@ -0,0 +1,8 @@
|
|||
export const FETCH_ADS_SUCCESS = '[ads] GET';
|
||||
export const FETCH_ADS_ERROR = '[ads] Fetch success';
|
||||
export const UPDATE_ADS = '[ads] Update';
|
||||
|
||||
export const updateAds = data => ({
|
||||
type: UPDATE_ADS,
|
||||
payload: data
|
||||
});
|
|
@ -0,0 +1,7 @@
|
|||
export const API_REQUEST = '[app] API Request';
|
||||
|
||||
export const apiRequest = (method, url, body, onSuccess, onError) => ({
|
||||
type: API_REQUEST,
|
||||
payload: body,
|
||||
meta: { method, url, onSuccess, onError }
|
||||
});
|
|
@ -0,0 +1,33 @@
|
|||
export const GET_CATALOG = '[catalog] GET';
|
||||
export const FETCH_CATALOG_SUCCESS = '[catalog] Fetch success';
|
||||
export const FETCH_CATALOG_ERROR = '[catalog] Fetch error';
|
||||
export const UPDATE_CATALOG = '[catalog] Update';
|
||||
export const SEARCH_CATALOG = '[catalog] Search for items';
|
||||
export const SEARCH_CATALOG_SUCCESS = '[catalog] Search success';
|
||||
export const FILTER_CATALOG = '[catalog] Filter items by tags';
|
||||
export const FILTER_CATALOG_SUCCESS = '[catalog] Filter success';
|
||||
|
||||
export const getCatalog = (clear = false) => ({
|
||||
type: GET_CATALOG,
|
||||
payload: clear
|
||||
});
|
||||
|
||||
export const updateCatalog = data => ({
|
||||
type: UPDATE_CATALOG,
|
||||
payload: data
|
||||
});
|
||||
|
||||
export const search = query => {
|
||||
return {
|
||||
type: SEARCH_CATALOG,
|
||||
payload: query
|
||||
};
|
||||
};
|
||||
|
||||
export const filter = tags => {
|
||||
return {
|
||||
type: FILTER_CATALOG,
|
||||
payload: tags
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export const GET_MODE = '[mode] GET';
|
||||
export const FETCH_MODE_SUCCESS = '[mode] Fetch mode SUCCESS';
|
||||
export const FETCH_MODE_ERROR = '[mode] Fetch mode ERROR';
|
||||
|
||||
export const getMode = () => ({
|
||||
type: GET_MODE
|
||||
});
|
|
@ -0,0 +1,5 @@
|
|||
export const SEND_PARTICIPATION = '[participate] Send participation';
|
||||
export const SEND_PARTICIPATION_SUCCESS = '[participate] Send participation sucess';
|
||||
export const SEND_PARTICIPATION_ERROR = '[participate] Send participation error';
|
||||
|
||||
export const sendParticipation = data => ({ type: SEND_PARTICIPATION, payload: data });
|
|
@ -0,0 +1,24 @@
|
|||
export const GET_TAGS = '[tags] GET';
|
||||
export const FETCH_TAGS_SUCCESS = '[tags] Fetch Success';
|
||||
export const FETCH_TAGS_ERROR = '[tags] Fetch Error';
|
||||
export const UPDATE_TAGS = '[tags] Update';
|
||||
export const SELECT_TAG = '[tags] Select a tag';
|
||||
export const DESELECT_TAGS = '[tags] Deselect all tags';
|
||||
|
||||
export const getTags = () => ({
|
||||
type: GET_TAGS
|
||||
});
|
||||
|
||||
export const updateTags = data => ({
|
||||
type: UPDATE_TAGS,
|
||||
payload: data
|
||||
});
|
||||
|
||||
export const selectTag = ({ category, slug }) => ({
|
||||
type: SELECT_TAG,
|
||||
payload: { category, slug }
|
||||
});
|
||||
|
||||
export const deselectTags = () => ({
|
||||
type: DESELECT_TAGS
|
||||
});
|
|
@ -0,0 +1,92 @@
|
|||
/* SPINNER */
|
||||
|
||||
export const SHOW_SPINNER = '[ui] show spinner';
|
||||
export const HIDE_SPINNER = '[ui] hide spinner';
|
||||
|
||||
export const showSpinner = () => ({
|
||||
type: SHOW_SPINNER
|
||||
});
|
||||
|
||||
export const hideSpinner = () => ({
|
||||
type: HIDE_SPINNER
|
||||
});
|
||||
|
||||
/* DETAIL VIEW */
|
||||
|
||||
export const OPEN_DETAIL = '[ui] Open detail view';
|
||||
export const CLOSE_DETAIL = '[ui] Close detail view';
|
||||
export const SHOW_NEXT_ITEM = '[ui] Show the next item';
|
||||
export const SHOW_PREV_ITEM = '[ui] Show the previous item';
|
||||
export const SET_MAGNET_INSTANCE = '[ui] Set Magnet instance';
|
||||
|
||||
export const openDetail = item => {
|
||||
return {
|
||||
type: OPEN_DETAIL,
|
||||
item
|
||||
};
|
||||
};
|
||||
|
||||
export const showNextItem = () => ({
|
||||
type: SHOW_NEXT_ITEM
|
||||
});
|
||||
|
||||
export const showPrevItem = () => ({
|
||||
type: SHOW_PREV_ITEM
|
||||
});
|
||||
|
||||
export const closeDetail = () => ({
|
||||
type: CLOSE_DETAIL
|
||||
});
|
||||
|
||||
/* OTHER */
|
||||
|
||||
export const TOGGEL_MENU = '[ui] toggle app menu';
|
||||
export const TOGGEL_SHOWING_SEARCH_RESULTS = '[ui] toggle showing search results';
|
||||
export const TOGGLE_PARTICIPATED = '[ui] toogle participated';
|
||||
export const REBUILD_LAYOUT = '[ui] Rebuild the grid layout';
|
||||
export const SHUFFLE_CATALOG = '[ui] Shuffle items';
|
||||
export const SET_PRE_LAUNCH = '[ui] Set the pre-launch flag';
|
||||
export const SET_INTERNAL = '[ui] Set the internal flag';
|
||||
|
||||
export const toggleMenu = () => ({
|
||||
type: TOGGEL_MENU
|
||||
});
|
||||
|
||||
export const toggleShowingSearchResults = show => ({
|
||||
type: TOGGEL_SHOWING_SEARCH_RESULTS,
|
||||
payload: show
|
||||
});
|
||||
|
||||
export const toggleParticipated = participated => ({
|
||||
type: TOGGLE_PARTICIPATED,
|
||||
payload: participated
|
||||
});
|
||||
|
||||
export const setMagnetInstance = instance => ({
|
||||
type: SET_MAGNET_INSTANCE,
|
||||
payload: instance
|
||||
});
|
||||
|
||||
export const rebuildLayout = () => ({
|
||||
type: REBUILD_LAYOUT
|
||||
});
|
||||
|
||||
export const shuffle = () => {
|
||||
return {
|
||||
type: SHUFFLE_CATALOG
|
||||
};
|
||||
};
|
||||
|
||||
export const setPreLaunch = isPreLaunch => {
|
||||
return {
|
||||
type: SET_PRE_LAUNCH,
|
||||
payload: isPreLaunch
|
||||
};
|
||||
};
|
||||
|
||||
export const setInternal = isInternal => {
|
||||
return {
|
||||
type: SET_INTERNAL,
|
||||
payload: isInternal
|
||||
};
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export const UPDATE_URLS = 'update urls'
|
|
@ -0,0 +1,26 @@
|
|||
import { GET_CATALOG } from '../actions/catalog';
|
||||
import { FETCH_ADS_SUCCESS, FETCH_ADS_ERROR, updateAds } from '../actions/ads';
|
||||
import { apiRequest } from '../actions/api';
|
||||
import { showSpinner, hideSpinner } from '../actions/ui';
|
||||
|
||||
export const getAdsFlow = store => next => action => {
|
||||
next(action);
|
||||
|
||||
const state = store.getState();
|
||||
if (action.type === GET_CATALOG && !state.ui.isPreLaunch) {
|
||||
store.dispatch(apiRequest('GET', state.urls.ads, null, FETCH_ADS_SUCCESS, FETCH_ADS_ERROR));
|
||||
store.dispatch(showSpinner());
|
||||
}
|
||||
};
|
||||
|
||||
// on successful fetch, process the ads data
|
||||
export const processAdsCollection = ({ dispatch }) => next => action => {
|
||||
next(action);
|
||||
|
||||
if (action.type === FETCH_ADS_SUCCESS) {
|
||||
dispatch(updateAds(action.payload.results));
|
||||
dispatch(hideSpinner());
|
||||
}
|
||||
};
|
||||
|
||||
export const adsMdl = [getAdsFlow, processAdsCollection];
|
|
@ -0,0 +1,20 @@
|
|||
import { API_REQUEST } from '../actions/api';
|
||||
|
||||
// this middleware care only for API calls
|
||||
export const api = ({ dispatch }) => next => action => {
|
||||
if (action.type === API_REQUEST) {
|
||||
const { method, url, onSuccess, onError } = action.meta;
|
||||
|
||||
fetch(url, {
|
||||
method,
|
||||
...(method === 'POST' ? { body: JSON.stringify(action.payload) } : {}),
|
||||
headers: {
|
||||
...(method === 'POST' ? { 'Content-Type': 'application/json' } : {})
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => dispatch({ type: onSuccess, payload: data }))
|
||||
.catch(error => dispatch({ type: onError, payload: error }));
|
||||
}
|
||||
return next(action);
|
||||
};
|
|
@ -0,0 +1,176 @@
|
|||
import {
|
||||
FETCH_CATALOG_SUCCESS,
|
||||
FETCH_CATALOG_ERROR,
|
||||
GET_CATALOG,
|
||||
updateCatalog,
|
||||
SEARCH_CATALOG,
|
||||
SEARCH_CATALOG_SUCCESS,
|
||||
FILTER_CATALOG,
|
||||
FILTER_CATALOG_SUCCESS,
|
||||
UPDATE_CATALOG
|
||||
} from '../actions/catalog';
|
||||
import {
|
||||
showSpinner,
|
||||
toggleShowingSearchResults,
|
||||
hideSpinner,
|
||||
setMagnetInstance
|
||||
} from '../actions/ui';
|
||||
import { apiRequest } from '../actions/api';
|
||||
import shuffleSeed from 'shuffle-seed';
|
||||
import { UPDATE_ADS } from '../actions/ads';
|
||||
|
||||
const rand = () => Math.floor(Math.random() * 1000000) + 1;
|
||||
|
||||
// this middleware only care about the getCatalog action
|
||||
export const getCatalogFlow = store => next => action => {
|
||||
next(action);
|
||||
|
||||
const state = store.getState();
|
||||
if (action.type === GET_CATALOG && !state.ui.isPreLaunch) {
|
||||
store.dispatch(showSpinner());
|
||||
if (action.payload) {
|
||||
// if we want to clear the list
|
||||
store.dispatch(updateCatalog([]));
|
||||
}
|
||||
store.dispatch(apiRequest('GET', state.urls.items, null, FETCH_CATALOG_SUCCESS, FETCH_CATALOG_ERROR));
|
||||
store.dispatch(setMagnetInstance());
|
||||
}
|
||||
};
|
||||
|
||||
// on successful fetch, process the catalog data
|
||||
export const processCatalogCollection = ({ dispatch }) => next => action => {
|
||||
next(action);
|
||||
|
||||
if (action.type === FETCH_CATALOG_SUCCESS) {
|
||||
dispatch(toggleShowingSearchResults(false));
|
||||
dispatch(updateCatalog(action.payload.catalog));
|
||||
dispatch(hideSpinner());
|
||||
}
|
||||
};
|
||||
|
||||
export const searchCatalogFlow = (store) => next => action => {
|
||||
next(action);
|
||||
const state = store.getState();
|
||||
const url = new URL(state.urls.items, window.location.origin);
|
||||
url.searchParams.set("query", encodeURIComponent(action.payload));
|
||||
|
||||
if (action.type === SEARCH_CATALOG) {
|
||||
store.dispatch(
|
||||
apiRequest(
|
||||
'GET',
|
||||
url.pathname + url.search,
|
||||
null,
|
||||
SEARCH_CATALOG_SUCCESS,
|
||||
FETCH_CATALOG_ERROR
|
||||
)
|
||||
);
|
||||
store.dispatch(showSpinner());
|
||||
}
|
||||
};
|
||||
|
||||
export const updateCatalogFlow = store => next => action => {
|
||||
if (action.type === UPDATE_CATALOG || action.type === UPDATE_ADS) {
|
||||
const state = store.getState();
|
||||
if (action.type === UPDATE_ADS && state.catalog.length === 0) {
|
||||
next(action);
|
||||
return;
|
||||
}
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
let ads = state.ads;
|
||||
if (action.type === UPDATE_ADS) {
|
||||
ads = action.payload
|
||||
}
|
||||
let items = state.catalog;
|
||||
if (action.type === UPDATE_CATALOG) {
|
||||
items = action.payload;
|
||||
}
|
||||
if (!state.ui.showingSearchResults && state.ui.isInternal) {
|
||||
items = items.concat(ads.filter(ad => {
|
||||
return !ad.show_per_page;
|
||||
}));
|
||||
}
|
||||
if (!state.ui.showingSearchResults && !state.ui.isInternal) {
|
||||
items = items.concat(ads);
|
||||
}
|
||||
const shuffled = shuffleSeed.shuffle(items, rand());
|
||||
const prio = shuffled.filter(item => {
|
||||
return item.prio;
|
||||
});
|
||||
const not_prio = shuffled.filter(item => {
|
||||
return !item.prio;
|
||||
});
|
||||
const all_items = prio.concat(not_prio);
|
||||
if(!state.ui.showingSearchResults && state.ui.isInternal) {
|
||||
const perPageAds = ads.filter(ad => {
|
||||
return ad.show_per_page;
|
||||
});
|
||||
const pages_count = parseInt((all_items.length - 1) / ITEMS_PER_PAGE);
|
||||
for (let step = 1; step < pages_count; step++) {
|
||||
perPageAds.map((ad, i) => {
|
||||
let indx = (step) * ITEMS_PER_PAGE;
|
||||
all_items.splice(indx, 0, ad);
|
||||
});
|
||||
}
|
||||
console.log(all_items);
|
||||
}
|
||||
if (action.type === UPDATE_ADS) {
|
||||
store.dispatch(updateCatalog(all_items));
|
||||
} else {
|
||||
action.payload = all_items;
|
||||
}
|
||||
}
|
||||
next(action);
|
||||
};
|
||||
|
||||
// on successful search, process the catalog data
|
||||
export const processCatalogSearchCollection = ({ dispatch }) => next => action => {
|
||||
next(action);
|
||||
|
||||
if (action.type === SEARCH_CATALOG_SUCCESS) {
|
||||
dispatch(toggleShowingSearchResults(true));
|
||||
dispatch(updateCatalog(action.payload.catalog));
|
||||
dispatch(hideSpinner());
|
||||
}
|
||||
};
|
||||
|
||||
export const filterCatalogFlow = (store) => next => action => {
|
||||
next(action);
|
||||
|
||||
const state = store.getState();
|
||||
if (action.type === FILTER_CATALOG) {
|
||||
const tagsStr = action.payload.join(',');
|
||||
const url = new URL(state.urls.items, window.location.origin);
|
||||
url.searchParams.set("tags", encodeURIComponent(tagsStr));
|
||||
store.dispatch(
|
||||
apiRequest(
|
||||
'GET',
|
||||
url.pathname + url.search,
|
||||
null,
|
||||
FILTER_CATALOG_SUCCESS,
|
||||
FETCH_CATALOG_ERROR
|
||||
)
|
||||
);
|
||||
store.dispatch(showSpinner());
|
||||
}
|
||||
};
|
||||
|
||||
// on successful filter, process the catalog data
|
||||
export const processCatalogFilterCollection = ({ dispatch }) => next => action => {
|
||||
next(action);
|
||||
|
||||
if (action.type === FILTER_CATALOG_SUCCESS) {
|
||||
dispatch(toggleShowingSearchResults(true));
|
||||
dispatch(updateCatalog(action.payload.catalog));
|
||||
dispatch(hideSpinner());
|
||||
}
|
||||
};
|
||||
|
||||
export const catalogMdl = [
|
||||
getCatalogFlow,
|
||||
searchCatalogFlow,
|
||||
processCatalogCollection,
|
||||
updateCatalogFlow,
|
||||
processCatalogSearchCollection,
|
||||
filterCatalogFlow,
|
||||
processCatalogFilterCollection
|
||||
];
|
|
@ -0,0 +1,24 @@
|
|||
import { GET_MODE, FETCH_MODE_SUCCESS, FETCH_MODE_ERROR } from '../actions/mode';
|
||||
import { showSpinner, hideSpinner, setPreLaunch, setInternal } from '../actions/ui';
|
||||
import { apiRequest } from '../actions/api';
|
||||
|
||||
export const getModeFlow = ({ dispatch }) => next => action => {
|
||||
next(action);
|
||||
|
||||
if (action.type === GET_MODE) {
|
||||
dispatch(apiRequest('GET', '/mode/', null, FETCH_MODE_SUCCESS, FETCH_MODE_ERROR));
|
||||
dispatch(showSpinner());
|
||||
}
|
||||
};
|
||||
|
||||
export const processModeCollection = ({ dispatch }) => next => action => {
|
||||
next(action);
|
||||
|
||||
if (action.type === FETCH_MODE_SUCCESS) {
|
||||
dispatch(setPreLaunch(action.payload.mode === 'minimal'));
|
||||
dispatch(setInternal(action.payload.mode === 'internal'));
|
||||
dispatch(hideSpinner());
|
||||
}
|
||||
};
|
||||
|
||||
export const modeMdl = [getModeFlow, processModeCollection];
|
|
@ -0,0 +1,38 @@
|
|||
import {
|
||||
SEND_PARTICIPATION,
|
||||
SEND_PARTICIPATION_SUCCESS,
|
||||
SEND_PARTICIPATION_ERROR
|
||||
} from '../actions/participate';
|
||||
import { showSpinner, hideSpinner, toggleParticipated } from '../actions/ui';
|
||||
|
||||
|
||||
export const sendParticipationFlow = ({ dispatch }) => next => action => {
|
||||
next(action);
|
||||
|
||||
if (action.type === SEND_PARTICIPATION) {
|
||||
const formData = new FormData();
|
||||
for (let key of Object.getOwnPropertyNames(action.payload)) {
|
||||
formData.append(key, action.payload[key])
|
||||
}
|
||||
fetch('/participate/',
|
||||
{ method: "POST", body: formData }
|
||||
)
|
||||
.then(response => response.json())
|
||||
.then(data => dispatch({ type: SEND_PARTICIPATION_SUCCESS, payload: data }))
|
||||
.catch(error => dispatch({ type: SEND_PARTICIPATION_ERROR, payload: error }))
|
||||
|
||||
dispatch(showSpinner());
|
||||
dispatch(toggleParticipated(false));
|
||||
}
|
||||
};
|
||||
|
||||
export const successfulParticipationFlow = ({ dispatch }) => next => action => {
|
||||
next(action);
|
||||
|
||||
if (action.type === SEND_PARTICIPATION_SUCCESS) {
|
||||
dispatch(hideSpinner());
|
||||
dispatch(toggleParticipated(true));
|
||||
}
|
||||
};
|
||||
|
||||
export const participateMdl = [sendParticipationFlow, successfulParticipationFlow];
|
|
@ -0,0 +1,48 @@
|
|||
import {
|
||||
FETCH_TAGS_SUCCESS,
|
||||
FETCH_TAGS_ERROR,
|
||||
GET_TAGS,
|
||||
updateTags,
|
||||
SELECT_TAG
|
||||
} from '../actions/tags';
|
||||
import { showSpinner, hideSpinner } from '../actions/ui';
|
||||
import { apiRequest } from '../actions/api';
|
||||
import { filter } from '../actions/catalog';
|
||||
|
||||
// this middleware only care about the getTags action
|
||||
export const getTagsFlow = (store) => next => action => {
|
||||
next(action);
|
||||
const state = store.getState();
|
||||
if (action.type === GET_TAGS) {
|
||||
store.dispatch(apiRequest('GET', state.urls.tags, null, FETCH_TAGS_SUCCESS, FETCH_TAGS_ERROR));
|
||||
store.dispatch(showSpinner());
|
||||
}
|
||||
};
|
||||
|
||||
// on successful fetch, process the tags data
|
||||
export const processTagsCollection = ({ dispatch }) => next => action => {
|
||||
next(action);
|
||||
|
||||
if (action.type === FETCH_TAGS_SUCCESS) {
|
||||
dispatch(updateTags(action.payload));
|
||||
dispatch(hideSpinner());
|
||||
}
|
||||
};
|
||||
|
||||
export const selectTagFlow = store => next => action => {
|
||||
next(action);
|
||||
if (action.type === SELECT_TAG) {
|
||||
const tags = [];
|
||||
const state = store.getState();
|
||||
Object.keys(state.tags).forEach(key => {
|
||||
state.tags[key].forEach(tag => {
|
||||
if (tag.selected) {
|
||||
tags.push(tag.slug);
|
||||
}
|
||||
});
|
||||
});
|
||||
store.dispatch(filter(tags));
|
||||
}
|
||||
};
|
||||
|
||||
export const tagsMdl = [getTagsFlow, processTagsCollection, selectTagFlow];
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue