Compare commits
48 Commits
139c313a84
...
f49c7287fd
Author | SHA1 | Date | |
---|---|---|---|
|
f49c7287fd | ||
|
f2bdeb0004 | ||
|
9420764e9b | ||
|
ccbe88a532 | ||
|
ceaa13bf2a | ||
|
dcd3962220 | ||
|
c541cf1a11 | ||
|
ec479e5d77 | ||
|
3abfa6dac3 | ||
|
bb81077d1c | ||
|
4e86e127d8 | ||
|
7b7b59ce23 | ||
|
0c5ffbb567 | ||
|
af6a2a0756 | ||
|
2659acdc58 | ||
|
03058f251b | ||
|
4b9a9c54d8 | ||
|
057a7d6d01 | ||
|
984cb37c8f | ||
|
7a54569820 | ||
|
c781ca3dbc | ||
|
7d0e141d4e | ||
|
720327424a | ||
|
db30d69cdb | ||
|
f3a6c3e8eb | ||
|
9506881792 | ||
|
b609b81ddf | ||
|
5703816498 | ||
|
129a4dc588 | ||
|
7fa5124577 | ||
|
e8a31f60d1 | ||
|
42ee6e091d | ||
|
f4e3ee6f55 | ||
|
9d0e4fdb2a | ||
|
c7dc90bfc5 | ||
|
d72fcf777f | ||
|
fd4bcc9c70 | ||
|
d7fb3032b7 | ||
|
16882ee06f | ||
|
8ebf5ac137 | ||
|
749fd773b6 | ||
|
d6850773b6 | ||
|
8d5bdc061d | ||
|
0fc0dd07e4 | ||
|
493c3852d8 | ||
|
449efd7536 | ||
|
f2f7b19380 | ||
|
0895b412d2 |
@ -2,7 +2,7 @@
|
||||
# Copy this file to .env file for development, create environment variables when deploying to production
|
||||
# https://symfony.com/doc/current/best_practices/configuration.html#infrastructure-related-configuration
|
||||
|
||||
GOOGLE_ANALYTICS=
|
||||
GTM_TAG=
|
||||
|
||||
###> symfony/framework-bundle ###
|
||||
APP_ENV=dev
|
||||
|
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "kadet/czydojade",
|
||||
"name": "kadet/cojedzie",
|
||||
"type": "project",
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
|
@ -1,4 +1,8 @@
|
||||
jms_serializer:
|
||||
default_context:
|
||||
serialization:
|
||||
serialize_null: true
|
||||
|
||||
visitors:
|
||||
xml_serialization:
|
||||
format_output: '%kernel.debug%'
|
||||
|
@ -1,7 +1,7 @@
|
||||
nelmio_api_doc:
|
||||
documentation:
|
||||
info:
|
||||
title: Czy Dojadę?
|
||||
title: Co Jedzie?
|
||||
version: 0.1.0
|
||||
parameters:
|
||||
provider:
|
||||
|
@ -3,4 +3,4 @@ twig:
|
||||
debug: '%kernel.debug%'
|
||||
strict_variables: '%kernel.debug%'
|
||||
globals:
|
||||
ga_tracking: "%env(GOOGLE_ANALYTICS)%"
|
||||
gtm_tracking: "%env(GTM_TAG)%"
|
||||
|
@ -2,3 +2,8 @@ api_v1:
|
||||
resource: ../src/Controller/Api/v1
|
||||
type: annotation
|
||||
prefix: /{provider}/api/v1
|
||||
|
||||
api_v1_providers:
|
||||
path: /api/v1/providers
|
||||
defaults:
|
||||
_controller: '\App\Controller\Api\v1\ProviderController::index'
|
||||
|
@ -19,11 +19,14 @@ services:
|
||||
App\Provider\Provider:
|
||||
tags: [ app.provider ]
|
||||
|
||||
App\Service\Converter:
|
||||
tags: [ app.converter ]
|
||||
|
||||
# makes classes in src/ available to be used as services
|
||||
# this creates a service per class whose id is the fully-qualified class name
|
||||
App\:
|
||||
resource: '../src/*'
|
||||
exclude: '../src/{DependencyInjection,Entity,Model,Migrations,Tests,Functions,Kernel.php}'
|
||||
exclude: '../src/{DependencyInjection,Exception,Modifier,Entity,Model,Migrations,Tests,Functions,Handler,Kernel.php}'
|
||||
|
||||
# controllers are imported separately to make sure services can be injected
|
||||
# as action arguments even if you don't extend any base controller class
|
||||
@ -35,6 +38,10 @@ services:
|
||||
resource: '../src/Provider'
|
||||
public: true
|
||||
|
||||
App\Handler\:
|
||||
resource: '../src/Handler'
|
||||
tags: [ app.handler ]
|
||||
|
||||
# add more service definitions when explicit configuration is needed
|
||||
# please note that last definitions always *replace* previous ones
|
||||
|
||||
@ -68,19 +75,14 @@ services:
|
||||
|
||||
ProxyManager\Configuration: '@proxy.config'
|
||||
|
||||
|
||||
# converter
|
||||
App\Service\AggregateConverter:
|
||||
arguments:
|
||||
- !tagged_iterator app.converter
|
||||
- !tagged_iterator app.converter
|
||||
|
||||
App\Service\Converter: '@App\Service\AggregateConverter'
|
||||
|
||||
App\Service\EntityConverter:
|
||||
tags: ['app.converter']
|
||||
|
||||
App\Service\ScheduledStopConverter:
|
||||
tags: ['app.converter']
|
||||
|
||||
# serializer configuration
|
||||
App\Service\SerializerContextFactory:
|
||||
arguments:
|
||||
@ -90,3 +92,7 @@ services:
|
||||
# other servces
|
||||
App\Service\ProviderResolver:
|
||||
arguments: [!tagged app.provider, '%kernel.debug%']
|
||||
|
||||
App\Service\HandlerProvider:
|
||||
arguments: [!tagged_locator app.handler]
|
||||
shared: false
|
||||
|
@ -1,4 +1,4 @@
|
||||
version: '2'
|
||||
version: '3.4'
|
||||
|
||||
services:
|
||||
nginx:
|
||||
@ -11,9 +11,17 @@ services:
|
||||
|
||||
php:
|
||||
build: docker/php
|
||||
mem_limit: 2g
|
||||
env_file:
|
||||
- ./docker/php/.env
|
||||
volumes:
|
||||
- ./:/var/www:cached
|
||||
- ./docker/php/log.conf:/usr/local/etc/php-fpm.d/zz-log.conf
|
||||
|
||||
|
||||
|
||||
blackfire:
|
||||
image: blackfire/blackfire
|
||||
ports: ["8707"]
|
||||
environment:
|
||||
- BLACKFIRE_SERVER_ID
|
||||
- BLACKFIRE_SERVER_TOKEN
|
||||
|
@ -21,7 +21,7 @@ server {
|
||||
|
||||
fastcgi_param APP_ENV "dev";
|
||||
fastcgi_param DATABASE_URL "sqlite:///%kernel.project_dir%/var/app.db";
|
||||
fastcgi_param GOOGLE_ANALYTICS "UA-00000-00";
|
||||
fastcgi_param GTM_TAG "GTM-00000";
|
||||
|
||||
internal;
|
||||
}
|
||||
|
@ -1,2 +1 @@
|
||||
XDEBUG_CONFIG=remote_host=172.17.0.1 remote_port=9001
|
||||
PHP_IDE_CONFIG=serverName=czydojade
|
||||
|
@ -1,22 +1,36 @@
|
||||
FROM php:7.3-fpm
|
||||
|
||||
ARG XDEBUG_REMOTE_HOST="172.17.0.1"
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends git zip libzip-dev
|
||||
|
||||
RUN docker-php-ext-install zip
|
||||
|
||||
# XDebug
|
||||
RUN pecl install xdebug-2.9.0 && docker-php-ext-enable xdebug
|
||||
RUN echo "xdebug.remote_enable = 1" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
|
||||
echo "xdebug.remote_host = ${XDEBUG_REMOTE_HOST}" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini;
|
||||
|
||||
RUN echo "xdebug.remote_enable = 1" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini;
|
||||
RUN echo "date.timezone = Europe/Warsaw" >> /usr/local/etc/php/conf.d/datetime.ini;
|
||||
# Blackfire
|
||||
RUN version=$(php -r "echo PHP_MAJOR_VERSION.PHP_MINOR_VERSION;") \
|
||||
&& curl -A "Docker" -o /tmp/blackfire-probe.tar.gz -D - -L -s https://blackfire.io/api/v1/releases/probe/php/linux/amd64/$version \
|
||||
&& mkdir -p /tmp/blackfire \
|
||||
&& tar zxpf /tmp/blackfire-probe.tar.gz -C /tmp/blackfire \
|
||||
&& mv /tmp/blackfire/blackfire-*.so $(php -r "echo ini_get ('extension_dir');")/blackfire.so \
|
||||
&& printf "extension=blackfire.so\nblackfire.agent_socket=tcp://blackfire:8707\n" > $PHP_INI_DIR/conf.d/blackfire.ini \
|
||||
&& rm -rf /tmp/blackfire /tmp/blackfire-probe.tar.gz
|
||||
|
||||
RUN php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
|
||||
RUN php composer-setup.php
|
||||
RUN php -r "unlink('composer-setup.php');"
|
||||
#Composer
|
||||
RUN php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" \
|
||||
&& php composer-setup.php \
|
||||
&& php -r "unlink('composer-setup.php');" \
|
||||
&& mv composer.phar /usr/local/bin/composer \
|
||||
&& chmod +x /usr/local/bin/composer
|
||||
|
||||
RUN mv composer.phar /usr/local/bin/composer
|
||||
RUN chmod +x /usr/local/bin/composer
|
||||
# Timezone
|
||||
RUN ln -snf /usr/share/zoneinfo/Europe/Warsaw /etc/localtime
|
||||
RUN echo "date.timezone = Europe/Warsaw" >> /usr/local/etc/php/conf.d/datetime.ini;
|
||||
|
||||
WORKDIR /var/www
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "czydojade",
|
||||
"name": "co-jedzie",
|
||||
"version": "1.0.0",
|
||||
"author": "Kacper Donat <kadet1090@gmail.com>",
|
||||
"license": "MIT",
|
||||
@ -9,11 +9,11 @@
|
||||
"@fortawesome/pro-regular-svg-icons": "^5.3.1",
|
||||
"@fortawesome/pro-solid-svg-icons": "^5.3.1",
|
||||
"@fortawesome/vue-fontawesome": "^0.1.1",
|
||||
"@types/bootstrap": "^4.1.2",
|
||||
"@types/bootstrap": "^4.3.1",
|
||||
"@types/jquery": "^3.3.6",
|
||||
"@types/moment": "^2.13.0",
|
||||
"@types/popper.js": "^1.11.0",
|
||||
"bootstrap": "^4.1.3",
|
||||
"bootstrap": "^4.3.1",
|
||||
"css-loader": "^1.0.0",
|
||||
"file-loader": "^2.0.0",
|
||||
"jquery": "^3.3.1",
|
||||
@ -36,6 +36,7 @@
|
||||
"dependencies": {
|
||||
"@types/mapbox-gl-leaflet": "^0.0.1",
|
||||
"@types/uuid": "^3.4.6",
|
||||
"@types/vue-moment": "^4.0.0",
|
||||
"@types/workbox-window": "^4.3.3",
|
||||
"clean-webpack-plugin": "^3.0.0",
|
||||
"copy-webpack-plugin": "^4.5.2",
|
||||
@ -46,6 +47,7 @@
|
||||
"portal-vue": "^2.1.7",
|
||||
"vue-dragscroll": "^1.10.2",
|
||||
"vue-fragment": "^1.5.1",
|
||||
"vue-moment": "^4.1.0",
|
||||
"vue-removed-hook-mixin": "^0.1.1",
|
||||
"vue2-leaflet": "^1.0.2",
|
||||
"vuex": "^3.0.1",
|
||||
|
@ -1,30 +0,0 @@
|
||||
{
|
||||
"name": "Czy Dojadę?",
|
||||
"short_name": "Czy Dojadę?",
|
||||
"orientation": "portrait",
|
||||
"lang": "pl_PL",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#005ea8",
|
||||
"theme_color": "#005ea8",
|
||||
"description": "Odpowiedź na odwieczne pytanie ludzkości - czy tramwaje jeżdżą?",
|
||||
"icons": [{
|
||||
"src": "images/icon-256.png",
|
||||
"sizes": "256x256"
|
||||
},{
|
||||
"src": "images/icon-512.png",
|
||||
"sizes": "512x512"
|
||||
},{
|
||||
"src": "images/icon-64.png",
|
||||
"sizes": "64x64"
|
||||
},{
|
||||
"src": "images/icon-128.png",
|
||||
"sizes": "128x128"
|
||||
},{
|
||||
"src": "images/icon-192.png",
|
||||
"sizes": "192x192"
|
||||
},{
|
||||
"src": "images/icon-96.png",
|
||||
"sizes": "96x96"
|
||||
}]
|
||||
}
|
@ -2,8 +2,4 @@
|
||||
<ul class="departures__list list-underlined">
|
||||
<departure :departure="departure" :key="departure.key" v-for="departure in departures"/>
|
||||
</ul>
|
||||
<div class="alert alert-info" v-if="stops.length === 0">
|
||||
<fa :icon="['fal', 'info-circle']"/>
|
||||
Wybierz przystanki korzystając z wyszukiwarki poniżej, aby zobaczyć listę odjazdów.
|
||||
</div>
|
||||
</div>
|
||||
|
@ -6,31 +6,35 @@
|
||||
</div>
|
||||
|
||||
<div class="departure__time">
|
||||
<fa-layers v-if="!departure.estimated" class="mr-1">
|
||||
<template v-if="!departure.estimated">
|
||||
<tooltip placement="top-end">Czas rozkładowy, nieuwzględniający aktualnej sytuacji komunikacyjnej.</tooltip>
|
||||
<fa :icon="['far', 'clock']"/>
|
||||
<fa :icon="['fas', 'exclamation-triangle']" transform="shrink-5 down-4 right-6"/>
|
||||
</fa-layers>
|
||||
<ui-icon icon="departure-warning" class="mr-1"/>
|
||||
</template>
|
||||
|
||||
<span :class="[ 'departure__time', 'departure__time--delayed']" v-if="timeDiffers">
|
||||
{{ departure.scheduled.format('HH:mm') }}
|
||||
</span>
|
||||
<span class="badge" :class="[departure.delay < 0 ? 'badge-danger' : 'badge-warning']"
|
||||
v-if="departure.delay < 0 || departure.delay > 30">
|
||||
<template v-if="!relativeTimes">
|
||||
<span :class="[ 'departure__time', 'departure__time--delayed']" v-if="timeDiffers">
|
||||
{{ departure.scheduled|moment('HH:mm') }}
|
||||
</span>
|
||||
<span class="badge" :class="[departure.delay < 0 ? 'badge-danger' : 'badge-warning']"
|
||||
v-if="departure.delay < 0 || departure.delay > 30">
|
||||
{{ departure.delay|signed }}s
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span class="departure__time">{{ time.format('HH:mm') }}</span>
|
||||
<span class="departure__time">{{ time|moment('HH:mm') }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ timeLeft|duration('humanize', true) }}
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="departure__stop">
|
||||
<fa :icon="['fal', 'sign']" fixed-width class="mr-1 flex-shrink-0"/>
|
||||
<ui-icon icon="stop" fixed-width class="mr-1 flex-shrink-0"/>
|
||||
<stop :stop="departure.stop"/>
|
||||
|
||||
<div class="stop__actions flex-space-left">
|
||||
<button class="btn btn-action" @click="showTrip = !showTrip">
|
||||
<tooltip>pokaż/ukryj trasę</tooltip>
|
||||
<fa :icon="['far', 'code-commit']" />
|
||||
<ui-icon icon="track" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -38,7 +42,7 @@
|
||||
<fold :visible="showTrip">
|
||||
<trip :schedule="trip.schedule" :current="departure.stop" v-if="trip" :class="[ `trip--${departure.line.type}` ]"/>
|
||||
<div v-else class="text-center">
|
||||
<fa icon="spinner-third" pulse></fa>
|
||||
<ui-icon icon="spinner"/>
|
||||
</div>
|
||||
</fold>
|
||||
</li>
|
||||
|
@ -3,7 +3,7 @@
|
||||
<li v-for="favourite in favourites" class="favourite">
|
||||
<button @click="choose(favourite)" class="favourite__entry">
|
||||
<div class="icon">
|
||||
<fa :icon="['fal', 'star']"/>
|
||||
<ui-icon icon="favourite"/>
|
||||
</div>
|
||||
<div class="overflow-hidden">
|
||||
<span class="text flex-grow-1">{{ favourite.name }}</span>
|
||||
@ -14,12 +14,12 @@
|
||||
</button>
|
||||
<button class="btn btn-action" @click="remove(favourite)">
|
||||
<tooltip placement="left">usuń</tooltip>
|
||||
<fa :icon="['fal', 'trash-alt']"></fa>
|
||||
<ui-icon icon="delete"/>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="alert alert-info" v-else>
|
||||
<fa :icon="['fal', 'info-circle']"></fa>
|
||||
<ui-icon icon="info"/>
|
||||
Brak zapisanych zespołów przystanków
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,13 +1,10 @@
|
||||
<form class="favourite-add-form" @submit="save">
|
||||
<form class="favourite-add-form" @submit.prevent="save">
|
||||
<div class="form-group">
|
||||
<label for="favourite_add_name">Nazwa</label>
|
||||
<div class="input-group">
|
||||
<input class="form-control form-control-sm" placeholder="np. Z pracy"
|
||||
:class="{ 'is-invalid': errors.name.length > 0 }" id="favourite_add_name"
|
||||
v-model="name" v-autofocus/>
|
||||
<button class="btn btn-sm btn-dark" type="submit">
|
||||
<fa :icon="['fal', 'check']"></fa>
|
||||
</button>
|
||||
<div v-if="errors.name.length > 0" class="invalid-feedback">
|
||||
<p v-for="error in errors.name">{{ error }}</p>
|
||||
</div>
|
||||
@ -18,4 +15,20 @@
|
||||
<stop :stop="stop"/>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="favourite-add-form__actions">
|
||||
<template v-if="confirmation">
|
||||
<button class="btn btn-xs btn-danger" type="submit">
|
||||
nadpisz
|
||||
</button>
|
||||
<button class="btn btn-xs btn-action" @click="$emit('close')">
|
||||
anuluj
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="btn btn-xs btn-primary" type="submit">
|
||||
<ui-icon icon="add" />
|
||||
zapisz
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -1,12 +1,12 @@
|
||||
<div class="finder">
|
||||
<input class="form-control" :value="filter" @input="filter = $event.target.value" placeholder="Zacznij pisać nazwę aby szukać..."/>
|
||||
<input class="form-control form-control--framed" :value="filter" @input="filter = $event.target.value" placeholder="Zacznij pisać nazwę aby szukać..."/>
|
||||
|
||||
<div v-if="filter.length < 3" class="mt-2">
|
||||
<favourites />
|
||||
</div>
|
||||
|
||||
<div v-if="state === 'fetching'" class="text-center p-4">
|
||||
<fa icon="spinner-third" pulse/>
|
||||
<ui-icon icon="spinner"/>
|
||||
</div>
|
||||
<div class="finder__stops" v-else-if="filter.length > 2 && Object.keys(filtered).length > 0">
|
||||
<div class="stop-group" v-for="(group, name) in filtered">
|
||||
@ -16,7 +16,7 @@
|
||||
<div class="actions flex-space-left">
|
||||
<button class="btn btn-action" @click="select(group)">
|
||||
<tooltip>wybierz wszystkie</tooltip>
|
||||
<fa :icon="['fal', 'check-double']"></fa>
|
||||
<ui-icon icon="add-all"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -26,7 +26,7 @@
|
||||
<template v-slot:primary-action>
|
||||
<button @click="select(stop, $event)" class="btn btn-action">
|
||||
<tooltip>dodaj przystanek</tooltip>
|
||||
<fa :icon="['fal', 'check']" />
|
||||
<ui-icon icon="add" />
|
||||
</button>
|
||||
</template>
|
||||
</picker-stop>
|
||||
@ -35,7 +35,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-warning" v-else-if="filter.length > 2">
|
||||
<fa :icon="['far', 'exclamation-triangle']"></fa>
|
||||
<ui-icon icon="warning"/>
|
||||
Nie znaleziono więcej przystanków, spełniających te kryteria.
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,12 +1,16 @@
|
||||
<span class="line__symbol flex" :class="{ [`line--${line.type}`]: true, 'line--night': line.night, 'line--fast': line.fast }">
|
||||
<span class="flex align-items-stretch">
|
||||
<span class="icon">
|
||||
<fa :icon="['fac', line.type]" fixed-width/>
|
||||
</span>
|
||||
<span class="badge badge-dark flex">
|
||||
<fa :icon="['fas', 'walking']" fixed-width v-if="line.fast"/>
|
||||
<fa :icon="['fal', 'moon']" fixed-width v-if="line.night"/>
|
||||
{{ line.symbol }}
|
||||
</span>
|
||||
<slot name="icon" v-if="!simple">
|
||||
<span class="icon">
|
||||
<ui-icon :icon="`line-${line.type}`" fixed-width/>
|
||||
</span>
|
||||
</slot>
|
||||
<slot name="badge">
|
||||
<span class="badge badge-dark flex">
|
||||
<ui-icon icon="night" fixed-width v-if="line.night && !simple"/>
|
||||
{{ line.symbol }}
|
||||
<ui-icon icon="fast" v-if="line.fast"/>
|
||||
</span>
|
||||
</slot>
|
||||
</span>
|
||||
</span>
|
@ -1,15 +1,29 @@
|
||||
<ul class="messages list-unstyled">
|
||||
<li class="message alert" :class="`alert-${type(message)}`" v-for="message in messages">
|
||||
<fa :icon="icon(message)" fixed-width></fa>
|
||||
{{ message.message }}
|
||||
<div class="messages mb-2">
|
||||
<ul class="list-unstyled mb-0">
|
||||
<li class="message alert" :class="`alert-${type(message)}`" v-for="message in messages">
|
||||
<ui-icon :icon="`message-${message.type}`" fixed-width/>
|
||||
{{ message.message }}
|
||||
|
||||
<div class="message__info">
|
||||
<small class="message__date">
|
||||
Komunikat ważny od
|
||||
{{ message.validFrom.format('HH:mm') }}
|
||||
do
|
||||
{{ message.validTo.format('HH:mm') }}
|
||||
</small>
|
||||
<div class="message__info">
|
||||
<small class="message__date">
|
||||
Komunikat ważny od
|
||||
{{ message.validFrom.format('HH:mm') }}
|
||||
do
|
||||
{{ message.validTo.format('HH:mm') }}
|
||||
</small>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<template v-if="nonDisplayedCount > 0">
|
||||
<div class="flex">
|
||||
<button class="btn btn-action btn-sm flex-space-left" @click="showAll = !showAll">
|
||||
<template v-if="showAll">
|
||||
<ui-icon icon="chevron-up"/> {{ nonDisplayedCount }} mniej
|
||||
</template>
|
||||
<template v-else>
|
||||
<ui-icon icon="chevron-down"/> {{ nonDisplayedCount }} więcej
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
|
42
resources/components/page/providers.html
Normal file
@ -0,0 +1,42 @@
|
||||
<main class="d-flex">
|
||||
<div style="width: 100%">
|
||||
<l-map :center="{ lat: 52.0194, lon: 19.1451 }" :zoom=7 :options="{ zoomControl: false }" class="map">
|
||||
<l-vector-layer url="https://api.maptiler.com/maps/bright/style.json?key=8GX5FRUNgk4lB83GZT8Q"
|
||||
token="not-needed"
|
||||
attribution='<a href="https://www.maptiler.com/copyright/" target="_blank">© MapTiler</a> <a href="https://www.openstreetmap.org/copyright" target="_blank">© OpenStreetMap contributors</a>'
|
||||
/>
|
||||
|
||||
<div class="provider-picker">
|
||||
<h2 class="provider-picker__heading">Wybierz lokalizację</h2>
|
||||
<ul class="provider-picker__providers">
|
||||
<li v-for="provider in providers" :key="provider.id" class="provider-picker__provider">
|
||||
<a :href="`/${provider.id}`" class="provider">
|
||||
<ui-icon icon="line-bus" size="2x" />
|
||||
<div>
|
||||
<div class="provider__short-name">{{ provider.shortName }}</div>
|
||||
<div class="provider__name">{{ provider.name }}</div>
|
||||
</div>
|
||||
<tooltip v-if="provider.lastUpdate != null">Ostatnia akutalizacja: {{ provider.lastUpdate|moment('YYYY-MM-DD HH:mm') }}</tooltip>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<l-marker :lat-lng="provider.location" v-for="provider in providers" :options="{ keyboard: false }" :key="provider.id">
|
||||
<l-icon>
|
||||
<div class="map__label-box" tabindex="0">
|
||||
<a :href="`/${provider.id}`" class="provider">
|
||||
<ui-icon icon="line-bus" class="map__icon" />
|
||||
<div>
|
||||
<div class="provider__short-name">{{ provider.shortName }}</div>
|
||||
<div class="provider__name">{{ provider.name }}</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</l-icon>
|
||||
</l-marker>
|
||||
</l-map>
|
||||
</div>
|
||||
|
||||
<portal-target name="popups" multiple/>
|
||||
</main>
|
@ -2,11 +2,17 @@
|
||||
<div class="d-flex">
|
||||
<slot name="primary-action" />
|
||||
<div class="overflow-hidden align-self-center">
|
||||
<stop :stop="stop" class="my-1"/>
|
||||
<div class="stop__destinations" v-if="stop.destinations && stop.destinations.length > 0">
|
||||
<fa :icon="['far', 'chevron-right']" />
|
||||
<stop :stop="stop" />
|
||||
<div class="stop__destinations" v-if="destinations && destinations.length > 0">
|
||||
<ul class="ml-1">
|
||||
<li class="stop__destination" v-for="destination in stop.destinations" :key="destination.id">{{ destination.name }}</li>
|
||||
<li class="stop__destination destination" v-for="destination in destinations" :key="destination.stop.id">
|
||||
<ul class="destination__lines">
|
||||
<li v-for="line in destination.lines">
|
||||
<line-symbol :line="line" :key="line.symbol" simple/>
|
||||
</li>
|
||||
</ul>
|
||||
<span class="destination__name ml-1">{{ destination.stop.name }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@ -15,22 +21,27 @@
|
||||
<slot name="actions">
|
||||
<button class="btn btn-action" ref="action-info" @click="details = !details">
|
||||
<tooltip>dodatkowe informacje</tooltip>
|
||||
<fa :icon="['fal', details ? 'chevron-circle-up' : 'info-circle']"/>
|
||||
<ui-icon icon="info"/>
|
||||
</button>
|
||||
|
||||
<button class="btn btn-action" ref="action-map" v-hover:map>
|
||||
<fa :icon="['fal', 'map-marker-alt']"/>
|
||||
<ui-icon icon="map"/>
|
||||
</button>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
<fold :visible="details" class="stop__details-fold" lazy>
|
||||
<stop-details :stop="stop"/>
|
||||
</fold>
|
||||
|
||||
<keep-alive>
|
||||
<popper reference="action-map" v-if="showMap" arrow class="popper--no-padding" style="width: 500px;" placement="right-start" v-hover:inMap>
|
||||
<portal to="popups">
|
||||
<ui-dialog v-if="details" @leave="details = false" behaviour="modal" class="ui-modal--medium" title="Szczegóły przystanku">
|
||||
<stop-details :stop="stop"/>
|
||||
</ui-dialog>
|
||||
</portal>
|
||||
</keep-alive>
|
||||
<keep-alive>
|
||||
<!-- FIXME: This should be in portal but it's not possible due to information loss, maybe in vue3 it will be better?-->
|
||||
<ui-dialog reference="action-map" v-if="showMap" arrow class="ui-popup--no-padding" style="width: 500px;" placement="right-start" v-hover:inMap>
|
||||
<stop-map :stop="stop" style="height: 300px"/>
|
||||
</popper>
|
||||
</ui-dialog>
|
||||
</keep-alive>
|
||||
</div>
|
||||
|
@ -1,4 +0,0 @@
|
||||
<div :class="[ 'popper', arrow && 'popper--arrow' ]" v-on="$listeners">
|
||||
<div class="popper__arrow" ref="arrow" v-if="arrow"></div>
|
||||
<slot />
|
||||
</div>
|
38
resources/components/settings/departures.html
Normal file
@ -0,0 +1,38 @@
|
||||
<fragment>
|
||||
<div class="form-group">
|
||||
<div class="flex">
|
||||
<label class="text" for="departures-auto-refresh-interval">
|
||||
<ui-icon icon="refresh" fixed-width/>
|
||||
autoodświeżanie
|
||||
</label>
|
||||
<ui-switch id="departures-auto-refresh" :value="autorefresh" @input="update({ autorefresh: $event })" class="flex-space-left"/>
|
||||
</div>
|
||||
<div class="flex " v-if="autorefresh">
|
||||
<label for="departures-auto-refresh-interval" class="text">
|
||||
<span class="sr-only">częstotliwość odświeżania</span>
|
||||
co
|
||||
</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" class="form-control form-control-sm form-control-simple" id="departures-auto-refresh-interval"
|
||||
:value="autorefreshInterval" @input="update({ autorefreshInterval: Number.parseInt($event.target.value) })" />
|
||||
<div class="input-group-append">
|
||||
<span class="input-group-text" aria-label="sekund">s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="text" for="departures-count">
|
||||
<ui-icon icon="line-bus" fixed-width/>
|
||||
liczba wpisów
|
||||
</label>
|
||||
<ui-numeric-input id="departures-count" :value="displayedEntriesCount" @input="update({ displayedEntriesCount: $event })" :min="1" :max="20"/>
|
||||
</div>
|
||||
<div class="form-group flex">
|
||||
<label class="text" for="departures-relative-times">
|
||||
<ui-icon icon="relative-time" fixed-width/>
|
||||
czas do odjazdu
|
||||
</label>
|
||||
<ui-switch id="departures-relative-times" :value="relativeTimes" @input="update({ relativeTimes: $event })" class="flex-space-left"/>
|
||||
</div>
|
||||
</fragment>
|
31
resources/components/settings/messages.html
Normal file
@ -0,0 +1,31 @@
|
||||
<fragment>
|
||||
<div class="form-group">
|
||||
<div class="flex">
|
||||
<label class="text" for="departures-auto-refresh-interval">
|
||||
<ui-icon icon="refresh" fixed-width/>
|
||||
autoodświeżanie
|
||||
</label>
|
||||
<ui-switch id="departures-auto-refresh" :value="autorefresh" @input="update({ autorefresh: $event })" class="flex-space-left"/>
|
||||
</div>
|
||||
<div class="flex " v-if="autorefresh">
|
||||
<label for="departures-auto-refresh-interval" class="text">
|
||||
<span class="sr-only">częstotliwość odświeżania</span>
|
||||
co
|
||||
</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" class="form-control form-control-sm form-control-simple" id="departures-auto-refresh-interval"
|
||||
:value="autorefreshInterval" @input="update({ autorefreshInterval: Number.parseInt($event.target.value) })" />
|
||||
<div class="input-group-append">
|
||||
<span class="input-group-text" aria-label="sekund">s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="text" for="departures-count">
|
||||
<ui-icon icon="messages" fixed-width/>
|
||||
wyświetlanych komunikatów
|
||||
</label>
|
||||
<ui-numeric-input id="departures-count" :value="displayedEntriesCount" @input="update({ displayedEntriesCount: $event })" :min="1" />
|
||||
</div>
|
||||
</fragment>
|
@ -16,21 +16,13 @@
|
||||
<div class="track__description">
|
||||
{{ track.description }}
|
||||
</div>
|
||||
<span class="badge badge-pill badge-light track__order">#{{ order }}</span>
|
||||
<span class="badge badge-pill badge-light track__order">
|
||||
#{{ order }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<strong>Na mapie:</strong>
|
||||
<div style="height: 350px" tabindex="-1">
|
||||
<l-map :center="stop.location" :zoom=17>
|
||||
<l-tile-layer url="//{s}.tile.osm.org/{z}/{x}/{y}.png" attribution='© <a href="//osm.org/copyright">OpenStreetMap</a> contributors'></l-tile-layer>
|
||||
<l-marker :lat-lng="stop.location"></l-marker>
|
||||
</l-map>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div v-else class="text-center">
|
||||
<fa icon="spinner-third" pulse></fa>
|
||||
<ui-icon icon="spinner"/>
|
||||
</div>
|
@ -1,9 +1,9 @@
|
||||
<fragment>
|
||||
<portal to="popups">
|
||||
<transition name="tooltip">
|
||||
<popper class="popper--tooltip" aria-hidden="true" arrow :reference="root" :placement="placement" v-if="show" :responsive="false">
|
||||
<ui-dialog class="ui-popup--tooltip" aria-hidden="true" arrow :reference="root" :placement="placement" v-if="show" :responsive="false">
|
||||
<slot />
|
||||
</popper>
|
||||
</ui-dialog>
|
||||
</transition>
|
||||
</portal>
|
||||
<span ref="root" class="sr-only"><slot /></span>
|
||||
|
31
resources/components/ui/dialog.html
Normal file
@ -0,0 +1,31 @@
|
||||
<div class="ui-backdrop" @click="handleBackdropClick" v-if="currentBehaviour === 'modal'" role="dialog">
|
||||
<div class="ui-modal" v-bind="attrs" v-on="$listeners">
|
||||
<div class="ui-modal__top-bar">
|
||||
<div class="ui-modal__header">
|
||||
<slot name="header">
|
||||
<div class="ui-modal__title" role="heading"><slot name="title">{{ title }}</slot></div>
|
||||
</slot>
|
||||
</div>
|
||||
<button class="btn btn-action ui-modal__close" @click.prevent="handleCloseClick">
|
||||
<ui-icon icon="close"/>
|
||||
</button>
|
||||
</div>
|
||||
<slot />
|
||||
<div class="ui-modal__footer" v-if="hasFooter">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="[ 'ui-popup', arrow && 'ui-popup--arrow' ]" v-bind="attrs" :style="{ zIndex: zIndex }" v-on="$listeners" role="dialog" v-else>
|
||||
<div class="ui-popup__arrow" ref="arrow" v-if="arrow"></div>
|
||||
<div class="ui-popup__header" v-if="hasHeader || title">
|
||||
<slot name="header">
|
||||
<div class="ui-popup__heading" role="heading"><slot name="title">{{ title }}</slot></div>
|
||||
</slot>
|
||||
</div>
|
||||
<slot />
|
||||
<div class="ui-popup__footer" v-if="hasFooter">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
|
4
resources/components/ui/icon.html
Normal file
@ -0,0 +1,4 @@
|
||||
<fa v-bind="attrs" v-if="type === 'simple'"/>
|
||||
<fa-layers v-else-if="type === 'stacked'">
|
||||
<fa :icon="props.icon" v-bind="props" v-for="(props, index) in definition.icons" :key="index"/>
|
||||
</fa-layers>
|
11
resources/components/ui/numeric.html
Normal file
@ -0,0 +1,11 @@
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" class="form-control form-control-sm" :id="id" inputmode="numeric" v-bind="$attrs" :value="value" @blur="update"/>
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-addon" type="button" @click="increment" :disabled="!canIncrement">
|
||||
<ui-icon icon="increment"/>
|
||||
</button>
|
||||
<button class="btn btn-addon" type="button" @click="decrement" :disabled="!canDecrement">
|
||||
<ui-icon icon="decrement"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
4
resources/components/ui/switch.html
Normal file
@ -0,0 +1,4 @@
|
||||
<div class="ui-switch" :class="[ value && 'ui-switch--checked' ]" v-bind="$attrs" @click="update">
|
||||
<div class="ui-switch__track"><div class="ui-switch__thumb"></div></div>
|
||||
<input type="checkbox" class="ui-switch__checkbox" :id="id" :checked="value" @input="update"/>
|
||||
</div>
|
BIN
resources/images/background-2x.png
Executable file
After Width: | Height: | Size: 45 KiB |
BIN
resources/images/background.png
Executable file
After Width: | Height: | Size: 19 KiB |
BIN
resources/images/icon-maskable.png
Normal file
After Width: | Height: | Size: 27 KiB |
BIN
resources/images/icon-monochrome.png
Normal file
After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 26 KiB |
Before Width: | Height: | Size: 12 KiB |
1313
resources/images/logo-cojedzie.ai
Executable file
Before Width: | Height: | Size: 24 KiB |
BIN
resources/images/logo-superhi.png
Executable file
After Width: | Height: | Size: 107 KiB |
1
resources/images/logo-vector.svg
Executable file
After Width: | Height: | Size: 5.7 KiB |
BIN
resources/images/logo.png
Normal file → Executable file
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 43 KiB |
@ -38,7 +38,6 @@
|
||||
|
||||
transition: height 250ms ease;
|
||||
will-change: height;
|
||||
box-sizing: padding-box;
|
||||
}
|
||||
|
||||
.flex {
|
||||
@ -61,7 +60,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
$section-safe-margin: 0.5rem;
|
||||
|
||||
.section {
|
||||
padding: $section-safe-margin;
|
||||
margin: -$section-safe-margin;
|
||||
|
||||
background: rgba(white, 0.85);
|
||||
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.section__title {
|
||||
@ -94,10 +100,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
svg.svg-inline--fa {
|
||||
//transform: rotate(360deg)
|
||||
}
|
||||
|
||||
.btn-unstyled {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
@ -13,9 +13,35 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-xs {
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
border-radius: 1.5px;
|
||||
|
||||
display: inline-block;
|
||||
|
||||
&.btn-outline-action {
|
||||
@extend .btn-outline-dark;
|
||||
}
|
||||
|
||||
&.btn-primary {
|
||||
background: $primary-gradient;
|
||||
border-color: transparent;
|
||||
|
||||
&:hover {
|
||||
border: 1px $primary solid;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-danger {
|
||||
background: $danger-gradient;
|
||||
border-color: transparent;
|
||||
|
||||
&:hover {
|
||||
border: 1px $danger solid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +1,48 @@
|
||||
label {
|
||||
font-weight: bold;
|
||||
font-size: .8rem;
|
||||
margin-bottom: 0;
|
||||
margin-top: -0.2rem;
|
||||
display: block;
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
.label-sm {
|
||||
font-size: .6rem;
|
||||
}
|
||||
|
||||
.form-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-control, .input-group-text, .btn-addon {
|
||||
background: rgba($dark, .06);
|
||||
border: none;
|
||||
border-bottom: 2px solid $dark;
|
||||
|
||||
&:focus {
|
||||
background: rgba($dark, .06);
|
||||
}
|
||||
}
|
||||
|
||||
.form-control--framed {
|
||||
background: transparent;
|
||||
border: 1px solid $text-muted;
|
||||
|
||||
&:focus {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.input-group-append,
|
||||
.input-group-append .btn + .btn {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.btn-addon:disabled {
|
||||
opacity: 1;
|
||||
color: rgba($dark, .5)
|
||||
}
|
||||
|
||||
.input-group-prepend {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
29
resources/styles/_map.scss
Normal file
@ -0,0 +1,29 @@
|
||||
.map__label-box {
|
||||
@extend .ui-popup;
|
||||
|
||||
padding: .5rem;
|
||||
background: white;
|
||||
transform-origin: 50% 50%;
|
||||
transform: translateX(-50%);
|
||||
min-width: max-content;
|
||||
|
||||
font-size: 9pt;
|
||||
|
||||
font-weight: bold;
|
||||
align-items: center;
|
||||
|
||||
@include active {
|
||||
transform: translateX(-50%) scale(1.1);
|
||||
}
|
||||
|
||||
@include flex-with-spacing(.5rem);
|
||||
}
|
||||
|
||||
.map__icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
img.map__icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
@ -53,6 +53,16 @@
|
||||
|
||||
.stop__destination {
|
||||
@extend .favourite__stop;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.destination__line {
|
||||
@extend .line__symbol;
|
||||
}
|
||||
|
||||
.destination__lines li {
|
||||
display: inline-block;
|
||||
@include spacing;
|
||||
}
|
||||
|
||||
.finder__stop {
|
||||
|
@ -60,11 +60,10 @@ $trip-visited: rgba($dark, .3);
|
||||
display: block;
|
||||
height: $trip-line-width;
|
||||
background: $dark;
|
||||
width: 50%;
|
||||
width: calc(50% - #{$trip-stop-marker-size / 2});
|
||||
position: absolute;
|
||||
top: $trip-stop-marker-spacing + ($trip-stop-marker-size) / 2;
|
||||
transform: translateY(-50%);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
&::after {
|
||||
|
@ -1,11 +1,15 @@
|
||||
$border-radius: 0;
|
||||
$border-radius: 0;
|
||||
$border-radius-lg: $border-radius;
|
||||
$border-radius-sm: $border-radius;
|
||||
|
||||
$danger: #cd2e12;
|
||||
|
||||
@import "~bootstrap/scss/functions";
|
||||
@import "~bootstrap/scss/variables";
|
||||
|
||||
$primary: #005ea8;
|
||||
$primary-gradient: linear-gradient(120deg, #0083c5 10%, #005ea8 90%);
|
||||
$danger-gradient: linear-gradient(120deg, $danger 10%, darken($danger, 10%) 90%);
|
||||
|
||||
$custom-control-indicator-checked-bg: $dark;
|
||||
$custom-control-indicator-active-bg: $dark;
|
||||
@ -43,6 +47,13 @@ $grid-gutter-width: $spacer * 2;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin spacing($spacing: .25em) {
|
||||
margin-left: $spacing;
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin no-scrollbars {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* Internet Explorer 10+ */
|
||||
@ -53,22 +64,64 @@ $grid-gutter-width: $spacer * 2;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin active {
|
||||
&:hover, &:active, &:focus, #{&}--active {
|
||||
@content
|
||||
}
|
||||
}
|
||||
|
||||
@mixin flex-with-spacing($spacing) {
|
||||
display: flex;
|
||||
|
||||
& > *:not(:last-child) {
|
||||
margin-right: $spacing;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin position($position, $top: inherit, $right: inherit, $bottom: inherit, $left: inherit) {
|
||||
$right: if($right == inherit, $top, $right);
|
||||
$bottom: if($bottom == inherit, $top, $bottom);
|
||||
$left: if($left == inherit, $right, $left);
|
||||
|
||||
position: $position;
|
||||
|
||||
top: $top;
|
||||
right: $right;
|
||||
left: $left;
|
||||
bottom: $bottom;
|
||||
}
|
||||
|
||||
@import "common";
|
||||
@import "stop";
|
||||
@import "departure";
|
||||
@import "line";
|
||||
@import "controls";
|
||||
@import "popper";
|
||||
@import "animations";
|
||||
@import "form";
|
||||
@import "favourites";
|
||||
@import "trip";
|
||||
@import "dragscroll";
|
||||
@import "map";
|
||||
|
||||
@import "ui/switch";
|
||||
@import "ui/popup";
|
||||
@import "ui/modal";
|
||||
|
||||
@import "page/provider-picker";
|
||||
|
||||
html, body {
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: url("../images/background.png") repeat-x center bottom 63px;
|
||||
|
||||
&.contains-modal {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1 1 auto;
|
||||
@ -108,6 +161,7 @@ body {
|
||||
font-size: small;
|
||||
color: $text-muted;
|
||||
text-align: right;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
footer {
|
||||
@ -143,6 +197,7 @@ body {
|
||||
@include media-breakpoint-up('md') {
|
||||
#app {
|
||||
padding-top: 4rem;
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
body footer > * {
|
||||
|
62
resources/styles/page/_provider-picker.scss
Normal file
@ -0,0 +1,62 @@
|
||||
.provider__name {
|
||||
font-size: .9em;
|
||||
color: $gray-800;
|
||||
}
|
||||
|
||||
.provider__short-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.provider-picker {
|
||||
@extend .ui-popup;
|
||||
padding: 1rem;
|
||||
margin: 3rem;
|
||||
}
|
||||
|
||||
.provider-picker__heading {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.provider-picker__providers {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.provider-picker__provider {
|
||||
font-size: 1rem;
|
||||
|
||||
.provider {
|
||||
margin: 0 -1rem;
|
||||
padding: .5rem 1rem;
|
||||
|
||||
&:hover {
|
||||
background: $gray-100;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.provider {
|
||||
@include flex-with-spacing(.5rem);
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-down('sm') {
|
||||
.provider-picker {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 1.5rem;
|
||||
}
|
||||
|
||||
.provider-picker__providers {
|
||||
max-height: 170px;
|
||||
}
|
||||
}
|
79
resources/styles/ui/_modal.scss
Normal file
@ -0,0 +1,79 @@
|
||||
.ui-backdrop {
|
||||
@include position(fixed, 0);
|
||||
background: rgba(black, .75);
|
||||
padding: $spacer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior-y: contain;
|
||||
z-index: 10000;
|
||||
|
||||
&::after {
|
||||
height: $spacer;
|
||||
display: block;
|
||||
content: "";
|
||||
width: 1px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
$dialog-margin: 2rem;
|
||||
$dialog-sizes: (
|
||||
medium: 480px,
|
||||
small: 320px,
|
||||
large: 640px,
|
||||
)
|
||||
;
|
||||
|
||||
.ui-modal {
|
||||
padding: $dialog-margin;
|
||||
background: white;
|
||||
margin: auto;
|
||||
box-shadow: rgba(black, .7) 0 1px 3px;
|
||||
border-radius: 1px;
|
||||
box-sizing: content-box;
|
||||
|
||||
&.ui-modal--slim {
|
||||
padding: $dialog-margin / 2;
|
||||
}
|
||||
|
||||
@each $size, $width in $dialog-sizes {
|
||||
&.ui-modal--#{$size} {
|
||||
width: $width;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ui-modal__close {
|
||||
margin-right: -$dialog-margin;
|
||||
padding: $dialog-margin $dialog-margin 0;
|
||||
margin-top: -$dialog-margin;
|
||||
}
|
||||
|
||||
.ui-modal__header {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.ui-modal__title {
|
||||
font-weight: bold;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.ui-modal__top-bar {
|
||||
display: flex;
|
||||
margin-bottom: $dialog-margin * 0.75;
|
||||
}
|
||||
|
||||
@include media-breakpoint-down('sm') {
|
||||
.ui-dialog {
|
||||
padding: $dialog-margin / 2;
|
||||
}
|
||||
|
||||
@each $size, $width in $dialog-sizes {
|
||||
.ui-modal.ui-modal--#{$size} {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
@ -54,7 +54,7 @@
|
||||
@mixin triangle-left($size, $color, $border: none) { @include triangle(left, $size, $color, $border); }
|
||||
@mixin triangle-right($size, $color, $border: none) { @include triangle(right, $size, $color, $border); }
|
||||
|
||||
.popper {
|
||||
.ui-popup {
|
||||
$arrow-base: 8px;
|
||||
$arrow-color: white;
|
||||
$arrow-border: rgba(black, 0.2);
|
||||
@ -74,20 +74,28 @@
|
||||
|
||||
border-radius: 2px;
|
||||
|
||||
.popper__arrow {
|
||||
.ui-popup__arrow {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
&.popper--no-padding {
|
||||
&.ui-popup--no-padding {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.popper__heading {
|
||||
*.ui-popup__header {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.ui-popup__heading {
|
||||
font-size: $font-size-sm;
|
||||
font-weight: bold;
|
||||
margin-bottom: .5rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin placement($placement) {
|
||||
@ -101,7 +109,7 @@
|
||||
&[x-placement*="#{$placement}"] {
|
||||
margin-#{map-get($opposite, $placement)}: $arrow-base;
|
||||
|
||||
.popper__arrow {
|
||||
.ui-popup__arrow {
|
||||
#{map-get($opposite, $placement)}: 0;
|
||||
@include triangle(map-get($opposite, $placement), $arrow-base, $arrow-color, $arrow-border);
|
||||
}
|
||||
@ -115,11 +123,11 @@
|
||||
@include placement("bottom");
|
||||
}
|
||||
|
||||
&.popper--arrow {
|
||||
&.ui-popup--arrow {
|
||||
@include arrows;
|
||||
}
|
||||
|
||||
&.popper--tooltip {
|
||||
&.ui-popup--tooltip {
|
||||
background: $dark;
|
||||
color: white;
|
||||
padding: .5rem .75rem;
|
||||
@ -128,14 +136,14 @@
|
||||
min-width: 0;
|
||||
box-shadow: none;
|
||||
|
||||
&.popper--arrow {
|
||||
&.ui-popup--arrow {
|
||||
$arrow-color: $dark;
|
||||
$arrow-border: none;
|
||||
$arrow-base: 6px;
|
||||
|
||||
@include arrows;
|
||||
|
||||
.popper__arrow::before {
|
||||
.ui-popup__arrow::before {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
@ -143,7 +151,7 @@
|
||||
}
|
||||
|
||||
@include media-breakpoint-down('sm') {
|
||||
.popper {
|
||||
.ui-popup {
|
||||
margin-left: $spacer;
|
||||
margin-right: $spacer;
|
||||
}
|
48
resources/styles/ui/_switch.scss
Normal file
@ -0,0 +1,48 @@
|
||||
$ui-switch-marker-size: .7rem;
|
||||
$ui-switch-spacing: 1px;
|
||||
$ui-switch-duration: 150ms;
|
||||
$ui-switch-width-factor: 2.25;
|
||||
|
||||
.ui-switch {
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.ui-switch__checkbox {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ui-switch__track {
|
||||
border: 1px solid $dark;
|
||||
border-radius: $ui-switch-marker-size;
|
||||
padding: $ui-switch-spacing;
|
||||
width: $ui-switch-width-factor * $ui-switch-marker-size;
|
||||
height: $ui-switch-marker-size;
|
||||
position: relative;
|
||||
box-sizing: content-box;
|
||||
background: white;
|
||||
transition: background-color $ui-switch-duration ease-in-out;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ui-switch__thumb {
|
||||
border-radius: 100%;
|
||||
width: $ui-switch-marker-size;
|
||||
height: $ui-switch-marker-size;
|
||||
background: $dark;
|
||||
position: absolute;
|
||||
transition: all $ui-switch-duration ease-in-out;
|
||||
transition-property: background-color, left;
|
||||
margin-left: $ui-switch-spacing;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.ui-switch--checked {
|
||||
.ui-switch__thumb {
|
||||
background: white;
|
||||
left: ($ui-switch-width-factor - 1) * $ui-switch-marker-size;
|
||||
}
|
||||
|
||||
.ui-switch__track {
|
||||
background: $dark;
|
||||
}
|
||||
}
|
@ -6,10 +6,6 @@ import "leaflet/dist/leaflet.css";
|
||||
|
||||
import Popper from 'popper.js';
|
||||
import * as $ from "jquery";
|
||||
|
||||
window['$'] = window['jQuery'] = $;
|
||||
window['Popper'] = Popper;
|
||||
|
||||
// dependencies
|
||||
import Vue from "vue";
|
||||
import Vuex from 'vuex';
|
||||
@ -18,46 +14,61 @@ import VueDragscroll from 'vue-dragscroll';
|
||||
import { Plugin as VueFragment } from 'vue-fragment';
|
||||
import { Workbox } from "workbox-window";
|
||||
|
||||
import { migrate } from "./store/migrations";
|
||||
import { Component } from "vue-property-decorator";
|
||||
import * as VueMoment from "vue-moment";
|
||||
import * as moment from 'moment';
|
||||
import 'moment/locale/pl'
|
||||
|
||||
window['$'] = window['jQuery'] = $;
|
||||
window['Popper'] = Popper;
|
||||
|
||||
Vue.use(Vuex);
|
||||
Vue.use(PortalVue);
|
||||
Vue.use(VueDragscroll);
|
||||
Vue.use(VueFragment);
|
||||
Vue.use(VueMoment, { moment });
|
||||
|
||||
declare module 'vue/types/vue' {
|
||||
interface Vue {
|
||||
$isTouch: boolean;
|
||||
$hasSlot: (slot: string) => string;
|
||||
}
|
||||
}
|
||||
|
||||
Vue.prototype.$isTouch = 'ontouchstart' in window || navigator.msMaxTouchPoints > 0;
|
||||
Vue.prototype.$hasSlot = function (this: Vue, slot: string): boolean {
|
||||
return !!this.$slots[slot] || !!this.$scopedSlots[slot];
|
||||
}
|
||||
|
||||
Component.registerHooks(['removed']);
|
||||
|
||||
// async dependencies
|
||||
(async function () {
|
||||
const { migrate } = await import('./store/migrations');
|
||||
|
||||
await migrate("vuex");
|
||||
|
||||
const [ components, { default: store } ] = await Promise.all([
|
||||
import('./components'),
|
||||
import('./store'),
|
||||
import('./font-awesome'),
|
||||
import('./filters'),
|
||||
import('bootstrap'),
|
||||
] as const);
|
||||
|
||||
// here goes "public" API
|
||||
window['czydojade'] = Object.assign({
|
||||
state: {}
|
||||
}, window['czydojade'], {
|
||||
components,
|
||||
application: new components.Application({ el: '#app' })
|
||||
const appRoot = document.getElementById('app');
|
||||
|
||||
store.replaceState({
|
||||
...store.state,
|
||||
provider: window['data']?.provider,
|
||||
});
|
||||
|
||||
store.dispatch('messages/update');
|
||||
store.dispatch('load', window['czydojade'].state);
|
||||
// here goes "public" API
|
||||
window['app'] = Object.assign({
|
||||
state: {}
|
||||
}, window['app'], {
|
||||
components,
|
||||
application: appRoot ? new components.Application({ el: '#app' }) : new components.PageProviderList({ el: '#provider-picker' }),
|
||||
});
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
const wb = new Workbox("/service-worker.js");
|
||||
|
@ -1,10 +1,10 @@
|
||||
import Vue from 'vue'
|
||||
import store from '../store'
|
||||
import { Component, Watch } from "vue-property-decorator";
|
||||
import { Mutation, Action } from 'vuex-class'
|
||||
import { ObtainPayload } from "../store/departures";
|
||||
import { Action, Mutation } from 'vuex-class'
|
||||
import { Stop } from "../model";
|
||||
import { PopperComponent } from "./utils";
|
||||
import { DeparturesSettingsState } from "../store/settings/departures";
|
||||
import { MessagesSettingsState } from "../store/settings/messages";
|
||||
|
||||
@Component({ store })
|
||||
export class Application extends Vue {
|
||||
@ -19,17 +19,6 @@ export class Application extends Vue {
|
||||
picker: 'search'
|
||||
};
|
||||
|
||||
private autorefresh = {
|
||||
messages: {
|
||||
active: true,
|
||||
interval: 60
|
||||
},
|
||||
departures: {
|
||||
active: true,
|
||||
interval: 10
|
||||
}
|
||||
};
|
||||
|
||||
private intervals = { messages: null, departures: null };
|
||||
|
||||
get messages() {
|
||||
@ -58,36 +47,61 @@ export class Application extends Vue {
|
||||
this.$el.classList.remove('not-ready');
|
||||
}
|
||||
|
||||
created() {
|
||||
this.$store.dispatch('messages/update');
|
||||
this.$store.dispatch('load', window['app'].state);
|
||||
|
||||
this.initDeparturesRefreshInterval();
|
||||
this.initMessagesRefreshInterval();
|
||||
}
|
||||
|
||||
private initDeparturesRefreshInterval() {
|
||||
const departuresAutorefreshCallback = () => {
|
||||
const {autorefresh, autorefreshInterval} = this.$store.state['departures-settings'] as DeparturesSettingsState;
|
||||
|
||||
if (this.intervals.departures) {
|
||||
clearInterval(this.intervals.departures);
|
||||
}
|
||||
|
||||
if (autorefresh) {
|
||||
this.intervals.departures = setInterval(() => this.updateDepartures(), Math.max(5, autorefreshInterval) * 1000)
|
||||
}
|
||||
};
|
||||
|
||||
this.$store.watch(({"departures-settings": state}) => state.autorefresh, departuresAutorefreshCallback);
|
||||
this.$store.watch(({"departures-settings": state}) => state.autorefreshInterval, departuresAutorefreshCallback);
|
||||
|
||||
departuresAutorefreshCallback();
|
||||
}
|
||||
|
||||
private initMessagesRefreshInterval() {
|
||||
const messagesAutorefreshCallback = () => {
|
||||
const {autorefresh, autorefreshInterval} = this.$store.state['messages-settings'] as MessagesSettingsState;
|
||||
|
||||
if (this.intervals.messages) {
|
||||
clearInterval(this.intervals.messages);
|
||||
}
|
||||
|
||||
if (autorefresh) {
|
||||
this.intervals.messages = setInterval(() => this.updateMessages(), Math.max(5, autorefreshInterval) * 1000)
|
||||
}
|
||||
};
|
||||
|
||||
this.$store.watch(({"messages-settings": state}) => state.autorefresh, messagesAutorefreshCallback);
|
||||
this.$store.watch(({"messages-settings": state}) => state.autorefreshInterval, messagesAutorefreshCallback);
|
||||
|
||||
messagesAutorefreshCallback();
|
||||
}
|
||||
|
||||
@Action('messages/update') updateMessages: () => void;
|
||||
@Action('departures/update') updateDepartures: (payload: ObtainPayload) => void;
|
||||
@Action('departures/update') updateDepartures: () => void;
|
||||
|
||||
@Mutation add: (stops: Stop[]) => void;
|
||||
@Mutation remove: (stop: Stop) => void;
|
||||
@Mutation clear: () => void;
|
||||
|
||||
@Watch('stops')
|
||||
onStopUpdate(this: any, stops) {
|
||||
this.updateDepartures({ stops });
|
||||
}
|
||||
|
||||
@Watch('autorefresh', { immediate: true, deep: true })
|
||||
onAutorefreshUpdate(settings) {
|
||||
if (this.intervals.messages) {
|
||||
clearInterval(this.intervals.messages);
|
||||
this.intervals.messages = null;
|
||||
}
|
||||
|
||||
if (this.intervals.departures) {
|
||||
clearInterval(this.intervals.departures);
|
||||
this.intervals.messages = null;
|
||||
}
|
||||
|
||||
if (settings.messages.active) {
|
||||
this.intervals.messages = setInterval(() => this.updateMessages(), Math.max(5, settings.messages.interval) * 1000);
|
||||
}
|
||||
|
||||
if (settings.departures.active) {
|
||||
this.intervals.departures = setInterval(() => this.updateDepartures({ stops: this.stops }), Math.max(5, settings.departures.interval) * 1000);
|
||||
}
|
||||
onStopUpdate() {
|
||||
this.updateDepartures();
|
||||
}
|
||||
}
|
||||
|
@ -1,28 +1,25 @@
|
||||
import Vue from 'vue'
|
||||
import { Departure, Stop } from "../model";
|
||||
import { Departure } from "../model";
|
||||
import { Component, Prop, Watch } from "vue-property-decorator";
|
||||
import { namespace } from 'vuex-class';
|
||||
import store from '../store'
|
||||
import store, { Departures, DeparturesSettings } from '../store'
|
||||
import { Trip } from "../model/trip";
|
||||
import urls from "../urls";
|
||||
import { Jsonified } from "../utils";
|
||||
import * as moment from "moment";
|
||||
|
||||
const { State } = namespace('departures');
|
||||
|
||||
@Component({ template: require("../../components/departures.html"), store })
|
||||
export class DeparturesComponent extends Vue {
|
||||
@State departures: Departure[];
|
||||
|
||||
@Prop(Array)
|
||||
stops: Stop[];
|
||||
@Departures.State departures: Departure[];
|
||||
}
|
||||
|
||||
@Component({ template: require("../../components/departures/departure.html") })
|
||||
@Component({ template: require("../../components/departures/departure.html"), store })
|
||||
export class DepartureComponent extends Vue {
|
||||
@Prop(Object) departure: Departure;
|
||||
scheduledTrip: Trip = null;
|
||||
|
||||
@DeparturesSettings.State
|
||||
relativeTimes: boolean;
|
||||
|
||||
showTrip: boolean = false;
|
||||
|
||||
processTrip(trip: Jsonified<Trip>): Trip {
|
||||
@ -46,6 +43,10 @@ export class DepartureComponent extends Vue {
|
||||
return this.departure.estimated || this.departure.scheduled;
|
||||
}
|
||||
|
||||
get timeLeft() {
|
||||
return moment.duration(this.time.diff(moment()));
|
||||
}
|
||||
|
||||
@Watch('showTrip')
|
||||
async downloadTrips() {
|
||||
if (this.showTrip != true || this.trip != null) {
|
||||
|
@ -1,8 +1,7 @@
|
||||
import Vue from 'vue'
|
||||
import { Component, Prop } from 'vue-property-decorator'
|
||||
import { namespace, State, Mutation } from "vuex-class";
|
||||
import { Component, Watch } from 'vue-property-decorator'
|
||||
import { Mutation, State } from "vuex-class";
|
||||
import { Favourite } from "../store/favourites";
|
||||
import { SavedState } from "../store/root";
|
||||
import { Stop } from "../model";
|
||||
import * as uuid from "uuid";
|
||||
import { Favourites } from "../store";
|
||||
@ -34,8 +33,15 @@ export class FavouritesAdderComponent extends Vue {
|
||||
private name = "";
|
||||
private errors = { name: [] };
|
||||
|
||||
private confirmation = false;
|
||||
|
||||
@Favourites.Mutation add: (favourite: Favourite) => void;
|
||||
|
||||
@Watch('name')
|
||||
handleNameChange() {
|
||||
this.confirmation = false;
|
||||
}
|
||||
|
||||
async save() {
|
||||
const favourite: Favourite = createFavouriteEntry(this.name, this.stops);
|
||||
|
||||
@ -54,8 +60,9 @@ export class FavouritesAdderComponent extends Vue {
|
||||
errors.name.push("Musisz podać nazwę.");
|
||||
}
|
||||
|
||||
if (this.$store.state.favourites.favourites.filter(other => other.name == favourite.name).length > 0) {
|
||||
if (this.$store.state.favourites.favourites.filter(other => other.name == favourite.name).length > 0 && !this.confirmation) {
|
||||
errors.name.push("Istnieje już zapisana grupa przystanków o takiej nazwie.");
|
||||
this.confirmation = true;
|
||||
}
|
||||
|
||||
this.errors = errors;
|
||||
|
@ -9,3 +9,9 @@ export * from './map'
|
||||
export * from './app'
|
||||
export * from './favourites'
|
||||
export * from './trip'
|
||||
export * from './ui'
|
||||
export * from './settings'
|
||||
export * from "./page"
|
||||
|
||||
export { Departures } from "../store";
|
||||
export { Messages } from "../store";
|
||||
|
@ -6,6 +6,9 @@ import { Line } from "../model";
|
||||
export class LineComponent extends Vue {
|
||||
@Prop(Object)
|
||||
public line: Line;
|
||||
|
||||
@Prop(Boolean)
|
||||
public simple: boolean;
|
||||
}
|
||||
|
||||
Vue.component('LineSymbol', LineComponent);
|
@ -1,4 +1,4 @@
|
||||
import { LMap, LTileLayer, LMarker } from 'vue2-leaflet';
|
||||
import { LControl, LIcon, LMap, LMarker, LPopup, LTileLayer } from 'vue2-leaflet';
|
||||
import Vue from 'vue';
|
||||
|
||||
import * as L from 'leaflet'
|
||||
@ -48,5 +48,8 @@ Vue.component('LMap', LMap);
|
||||
Vue.component('LTileLayer', LTileLayer);
|
||||
Vue.component('LVectorLayer', LVectorLayer);
|
||||
Vue.component('LMarker', LMarker);
|
||||
Vue.component('LControl', LControl);
|
||||
Vue.component('LPopup', LPopup)
|
||||
Vue.component('LIcon', LIcon);
|
||||
|
||||
export { LMap, LTileLayer, LMarker } from 'vue2-leaflet';
|
||||
export { LMap, LTileLayer, LMarker, LIcon, LControl, LPopup } from 'vue2-leaflet';
|
||||
|
@ -1,22 +1,26 @@
|
||||
import Vue from 'vue';
|
||||
import { Component } from "vue-property-decorator";
|
||||
import { Message } from "../model/message";
|
||||
import { faInfoCircle, faExclamationTriangle, faQuestionCircle } from "@fortawesome/pro-light-svg-icons";
|
||||
import { namespace } from 'vuex-class';
|
||||
import store from '../store'
|
||||
|
||||
const { State } = namespace('messages');
|
||||
import store, { Messages, MessagesSettings } from '../store'
|
||||
|
||||
@Component({ template: require("../../components/messages.html"), store })
|
||||
export class MessagesComponent extends Vue {
|
||||
@State messages: Message[];
|
||||
@Messages.State('messages')
|
||||
public allMessages: Message[];
|
||||
|
||||
public icon(message: Message) {
|
||||
switch (message.type) {
|
||||
case "breakdown": return faExclamationTriangle;
|
||||
case "info": return faInfoCircle;
|
||||
case "unknown": return faQuestionCircle;
|
||||
}
|
||||
@MessagesSettings.State
|
||||
public displayedEntriesCount: number;
|
||||
|
||||
public showAll: boolean = false;
|
||||
|
||||
get messages() {
|
||||
return this.showAll
|
||||
? this.allMessages
|
||||
: this.allMessages.slice(0, this.displayedEntriesCount);
|
||||
}
|
||||
|
||||
get nonDisplayedCount(): number {
|
||||
return Math.max(this.allMessages.length - this.displayedEntriesCount, 0);
|
||||
}
|
||||
|
||||
public type(message: Message) {
|
||||
|
1
resources/ts/components/page/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./providers"
|
26
resources/ts/components/page/providers.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import Vue from 'vue'
|
||||
import { Component } from 'vue-property-decorator'
|
||||
import { Provider } from "../../model";
|
||||
import { Jsonified } from "../../utils";
|
||||
import * as moment from 'moment';
|
||||
|
||||
@Component({
|
||||
template: require('../../../components/page/providers.html'),
|
||||
})
|
||||
export class PageProviderList extends Vue {
|
||||
private providers: Provider[] = [];
|
||||
|
||||
async created() {
|
||||
const response = await fetch('/api/v1/providers');
|
||||
const result = await response.json() as Jsonified<Provider>[];
|
||||
|
||||
this.providers = result.map<Provider>(provider => {
|
||||
return {
|
||||
...provider,
|
||||
lastUpdate: provider.lastUpdate && moment(provider.lastUpdate)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Vue.component('PageProviderList', PageProviderList);
|
@ -1,8 +1,8 @@
|
||||
import Component from "vue-class-component";
|
||||
import Vue from "vue";
|
||||
import { Stop, StopGroup, StopGroups } from "../model";
|
||||
import { Destination, Line, StopWithDestinations as Stop, StopGroup, StopGroups } from "../model";
|
||||
import { Prop, Watch } from "vue-property-decorator";
|
||||
import { ensureArray, FetchingState, filter, map, time } from "../utils";
|
||||
import { FetchingState, filter, map, match, unique } from "../utils";
|
||||
import { debounce } from "../decorators";
|
||||
import urls from '../urls';
|
||||
|
||||
@ -18,6 +18,32 @@ export class PickerStopComponent extends Vue {
|
||||
get showMap() {
|
||||
return this.inMap || this.map;
|
||||
}
|
||||
|
||||
get destinations() {
|
||||
const compactLines = destination => ({
|
||||
...destination,
|
||||
lines: Object.entries(groupLinesByType(destination.lines || [])).map(([type, lines]) => ({
|
||||
type: type,
|
||||
symbol: joinedSymbol(lines),
|
||||
night: lines.every(line => line.night),
|
||||
fast: lines.every(line => line.fast),
|
||||
})),
|
||||
all: destination.lines
|
||||
});
|
||||
|
||||
const groupLinesByType = (lines: Line[]) => lines.reduce<{ [kind: string]: Line[]}>((groups, line) => ({
|
||||
...groups,
|
||||
[line.type]: [ ...(groups[line.type] || []), line ]
|
||||
}), {});
|
||||
|
||||
const joinedSymbol = match<string, [Line[]]>(
|
||||
[lines => lines.length === 1, lines => lines[0].symbol],
|
||||
[lines => lines.length === 2, ([first, second]) => `${first.symbol}, ${second.symbol}`],
|
||||
[lines => lines.length > 2, ([first]) => `${first.symbol}…`],
|
||||
);
|
||||
|
||||
return unique(this.stop.destinations || [], destination => destination.stop && destination.stop.name).map(compactLines);
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
@ -38,7 +64,7 @@ export class FinderComponent extends Vue {
|
||||
get filtered(): StopGroups {
|
||||
const groups = map(
|
||||
this.found,
|
||||
(group: StopGroup, name: string) =>
|
||||
(group: StopGroup) =>
|
||||
group.filter(stop => !this.blacklist.some(blacklisted => blacklisted.id === stop.id))
|
||||
) as StopGroups;
|
||||
|
||||
@ -54,7 +80,7 @@ export class FinderComponent extends Vue {
|
||||
|
||||
this.state = 'fetching';
|
||||
|
||||
const response = await fetch(urls.prepare(urls.stops.grouped, { name: this.filter }));
|
||||
const response = await fetch(urls.prepare(urls.stops.grouped, { name: this.filter, 'include-destinations': true }));
|
||||
|
||||
if (response.ok) {
|
||||
this.found = (await response.json()).reduce((accumulator, { name, stops }) => Object.assign(accumulator, { [name]: stops }), {});
|
||||
|
24
resources/ts/components/settings/departures.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { Component, Prop } from "vue-property-decorator";
|
||||
import store, { DeparturesSettings } from "../../store";
|
||||
import Vue from "vue";
|
||||
import { DeparturesSettingsState } from "../../store/settings/departures";
|
||||
|
||||
@Component({ template: require("../../../components/settings/departures.html"), store })
|
||||
export class SettingsDepartures extends Vue {
|
||||
@DeparturesSettings.State
|
||||
public autorefresh: boolean;
|
||||
|
||||
@DeparturesSettings.State
|
||||
public relativeTimes: boolean;
|
||||
|
||||
@DeparturesSettings.State
|
||||
public autorefreshInterval: number;
|
||||
|
||||
@DeparturesSettings.State
|
||||
public displayedEntriesCount: number;
|
||||
|
||||
@DeparturesSettings.Mutation
|
||||
public update: (state: Partial<DeparturesSettingsState>) => void;
|
||||
}
|
||||
|
||||
Vue.component('SettingsDepartures', SettingsDepartures);
|
2
resources/ts/components/settings/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./departures"
|
||||
export * from "./messages"
|
21
resources/ts/components/settings/messages.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { Component } from "vue-property-decorator";
|
||||
import store, { MessagesSettings } from "../../store";
|
||||
import Vue from "vue";
|
||||
import { MessagesSettingsState } from "../../store/settings/messages";
|
||||
|
||||
@Component({template: require("../../../components/settings/messages.html"), store})
|
||||
export class SettingsMessages extends Vue {
|
||||
@MessagesSettings.State
|
||||
public autorefresh: boolean;
|
||||
|
||||
@MessagesSettings.State
|
||||
public autorefreshInterval: number;
|
||||
|
||||
@MessagesSettings.State
|
||||
public displayedEntriesCount: number;
|
||||
|
||||
@MessagesSettings.Mutation
|
||||
public update: (state: Partial<MessagesSettingsState>) => void;
|
||||
}
|
||||
|
||||
Vue.component('SettingsMessages', SettingsMessages);
|
275
resources/ts/components/ui/dialog.ts
Normal file
@ -0,0 +1,275 @@
|
||||
import Vue from "vue";
|
||||
import { Component, Prop, Watch } from "vue-property-decorator";
|
||||
import Popper, { Placement } from "popper.js";
|
||||
import { defaultBreakpoints } from "../../filters";
|
||||
|
||||
/**
|
||||
* How popup will be presented to user:
|
||||
* - "modal" - modal window
|
||||
* - "popup" - simple popup
|
||||
*/
|
||||
export type DialogBehaviour = "modal" | "popup";
|
||||
|
||||
let openModalCounter: number = 0;
|
||||
|
||||
function computeZIndexOfElement(element: HTMLElement): number {
|
||||
let current = element;
|
||||
|
||||
while (true) {
|
||||
const zIndex = window.getComputedStyle(current).zIndex;
|
||||
|
||||
if (zIndex !== "auto") {
|
||||
return parseInt(zIndex);
|
||||
}
|
||||
|
||||
if (!current.parentElement) {
|
||||
break;
|
||||
}
|
||||
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Component({
|
||||
inheritAttrs: false,
|
||||
template: require('../../../components/ui/dialog.html'),
|
||||
})
|
||||
export default class UiDialog extends Vue {
|
||||
@Prop({ type: String, default: "popup" })
|
||||
private behaviour: DialogBehaviour;
|
||||
|
||||
@Prop({ type: String })
|
||||
private mobileBehaviour: DialogBehaviour;
|
||||
|
||||
@Prop([String, HTMLElement])
|
||||
public reference: string | HTMLElement;
|
||||
|
||||
@Prop(Object)
|
||||
public refs: string;
|
||||
|
||||
@Prop({ type: String, default: "auto" })
|
||||
public placement: Placement;
|
||||
|
||||
@Prop(Boolean)
|
||||
public arrow: boolean;
|
||||
|
||||
@Prop({ type: Boolean, default: true })
|
||||
public responsive: boolean;
|
||||
|
||||
@Prop(String)
|
||||
public title: string;
|
||||
|
||||
private isMobile: boolean = false;
|
||||
|
||||
/** Inherited class hack */
|
||||
private staticClass: string[] = [];
|
||||
|
||||
private zIndex: number = 1000;
|
||||
|
||||
private _focusOutEvent;
|
||||
private _resizeEvent;
|
||||
|
||||
private _popper;
|
||||
|
||||
get attrs() {
|
||||
return {
|
||||
...this.$attrs,
|
||||
"class": this.staticClass
|
||||
}
|
||||
}
|
||||
|
||||
get currentBehaviour(): DialogBehaviour {
|
||||
if (!this.mobileBehaviour) {
|
||||
return this.behaviour;
|
||||
}
|
||||
|
||||
return this.isMobile ? this.mobileBehaviour : this.behaviour;
|
||||
}
|
||||
|
||||
get hasFooter() {
|
||||
return this.$hasSlot('footer')
|
||||
}
|
||||
|
||||
get hasHeader() {
|
||||
return this.$hasSlot('header')
|
||||
}
|
||||
|
||||
private getReferenceElement() {
|
||||
const isInWrapper = this.$parent.$options.name == 'portalTarget';
|
||||
|
||||
if (typeof this.reference === 'string') {
|
||||
if (this.refs) {
|
||||
return this.refs[this.reference];
|
||||
}
|
||||
|
||||
if (isInWrapper) {
|
||||
return this.$parent.$parent.$refs[this.reference];
|
||||
}
|
||||
|
||||
return this.$parent.$refs[this.reference];
|
||||
}
|
||||
|
||||
if (this.reference instanceof HTMLElement) {
|
||||
return this.reference;
|
||||
}
|
||||
|
||||
return isInWrapper ? this.$parent.$el : this.$el.parentElement;
|
||||
}
|
||||
|
||||
focusOut(event: MouseEvent) {
|
||||
if (this.$el.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit('leave', event);
|
||||
}
|
||||
|
||||
mounted() {
|
||||
this.zIndex = computeZIndexOfElement(this.getReferenceElement()) + 100;
|
||||
|
||||
this.handleWindowResize();
|
||||
|
||||
if (this.behaviour === 'popup') {
|
||||
this.mountPopper();
|
||||
}
|
||||
|
||||
this.staticClass = Array.from(this.$el.classList).filter(cls => ["ui-backdrop", "ui-popup", "ui-popup--arrow"].indexOf(cls) === -1);
|
||||
|
||||
window.addEventListener('resize', this._resizeEvent = this.handleWindowResize.bind(this));
|
||||
|
||||
this._activated();
|
||||
}
|
||||
|
||||
private _activated() {
|
||||
if (this.behaviour === 'modal') {
|
||||
this.mountModal();
|
||||
}
|
||||
}
|
||||
|
||||
private _deactivated() {
|
||||
if (this.behaviour === 'modal') {
|
||||
this.dismountModal();
|
||||
}
|
||||
}
|
||||
|
||||
private mountModal() {
|
||||
if (openModalCounter === 0) {
|
||||
document.body.style.paddingRight = `${window.screen.width - document.body.clientWidth}px`
|
||||
document.body.classList.add('contains-modal');
|
||||
}
|
||||
|
||||
openModalCounter++;
|
||||
}
|
||||
|
||||
private dismountModal() {
|
||||
openModalCounter--;
|
||||
|
||||
if (openModalCounter === 0) {
|
||||
document.body.style.paddingRight = "";
|
||||
document.body.classList.remove('contains-modal');
|
||||
}
|
||||
}
|
||||
|
||||
activated() {
|
||||
this._activated();
|
||||
}
|
||||
|
||||
deactivated() {
|
||||
this._deactivated();
|
||||
}
|
||||
|
||||
private mountPopper() {
|
||||
const reference = this.getReferenceElement();
|
||||
|
||||
this._popper = new Popper(reference, this.$el, {
|
||||
placement: this.placement,
|
||||
modifiers: {
|
||||
arrow: { enabled: this.arrow, element: this.$refs['arrow'] as Element },
|
||||
responsive: {
|
||||
enabled: this.responsive,
|
||||
order: 890,
|
||||
fn(data) {
|
||||
if (window.innerWidth < 560) {
|
||||
data.instance.options.placement = 'top';
|
||||
data.styles.transform = `translate3d(0, ${ data.offsets.popper.top }px, 0)`;
|
||||
data.styles.right = '0';
|
||||
data.styles.left = '0';
|
||||
data.styles.width = 'auto';
|
||||
data.arrowStyles.left = `${ data.offsets.popper.left + data.offsets.arrow.left }px`;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.$nextTick(() => {
|
||||
this._popper && this._popper.update();
|
||||
document.addEventListener('click', this._focusOutEvent = this.focusOut.bind(this), { capture: true });
|
||||
});
|
||||
}
|
||||
|
||||
private removePopper() {
|
||||
this._popper.destroy()
|
||||
this._popper = null;
|
||||
}
|
||||
|
||||
updated() {
|
||||
if (this._popper) {
|
||||
this._popper.update();
|
||||
}
|
||||
}
|
||||
|
||||
beforeDestroy() {
|
||||
this._focusOutEvent && document.removeEventListener('click', this._focusOutEvent, { capture: true });
|
||||
|
||||
this._deactivated()
|
||||
}
|
||||
|
||||
removed() {
|
||||
if (this._popper) {
|
||||
this.removePopper();
|
||||
}
|
||||
}
|
||||
|
||||
private handleBackdropClick(ev: Event) {
|
||||
const target = ev.target as HTMLElement;
|
||||
|
||||
if (target.classList.contains("ui-backdrop")) {
|
||||
this.$emit('leave');
|
||||
}
|
||||
}
|
||||
|
||||
private handleCloseClick() {
|
||||
this.$emit('leave');
|
||||
this.$emit('close');
|
||||
}
|
||||
|
||||
private handleWindowResize() {
|
||||
this.isMobile = screen.width < defaultBreakpoints.md;
|
||||
}
|
||||
|
||||
@Watch('currentBehaviour')
|
||||
private handleBehaviourChange(newBehaviour: DialogBehaviour, oldBehaviour: DialogBehaviour) {
|
||||
if (oldBehaviour === 'popup') {
|
||||
this.removePopper();
|
||||
}
|
||||
|
||||
if (newBehaviour === 'popup') {
|
||||
this.$nextTick(() => this.mountPopper());
|
||||
}
|
||||
|
||||
if (newBehaviour === 'modal') {
|
||||
this.mountModal();
|
||||
}
|
||||
|
||||
if (oldBehaviour === 'modal') {
|
||||
this.dismountModal();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vue.component("ui-dialog", UiDialog);
|
148
resources/ts/components/ui/icon.ts
Normal file
@ -0,0 +1,148 @@
|
||||
import Vue from 'vue'
|
||||
import { Component, Prop } from 'vue-property-decorator'
|
||||
import { IconDefinition, library } from "@fortawesome/fontawesome-svg-core"
|
||||
import { Dictionary } from "../../utils";
|
||||
import {
|
||||
faBullhorn,
|
||||
faCheck,
|
||||
faCheckDouble,
|
||||
faChevronCircleUp,
|
||||
faChevronDown,
|
||||
faChevronUp,
|
||||
faClock,
|
||||
faCog,
|
||||
faExclamationTriangle,
|
||||
faHourglassHalf,
|
||||
faInfoCircle,
|
||||
faMapMarkerAlt,
|
||||
faMoon,
|
||||
faQuestionCircle,
|
||||
faQuestionSquare,
|
||||
faSearch,
|
||||
faSign,
|
||||
faStar,
|
||||
faSync,
|
||||
faTimes,
|
||||
faTrashAlt
|
||||
} from "@fortawesome/pro-light-svg-icons";
|
||||
import {
|
||||
faClock as faClockBold,
|
||||
faCodeCommit,
|
||||
faMinus,
|
||||
faPlus,
|
||||
faSpinnerThird
|
||||
} from "@fortawesome/pro-regular-svg-icons";
|
||||
import { faExclamationTriangle as faSolidExclamationTriangle, faWalking } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { fac } from "../../icons";
|
||||
import { FontAwesomeIcon, FontAwesomeLayers, FontAwesomeLayersText } from "@fortawesome/vue-fontawesome";
|
||||
|
||||
type IconDescription = { icon: IconDefinition, [prop: string]: any }
|
||||
|
||||
type SimpleIcon = {
|
||||
type: 'simple',
|
||||
} & IconDescription;
|
||||
|
||||
type StackedIcon = {
|
||||
type: 'stacked',
|
||||
icons: IconDescription[],
|
||||
}
|
||||
|
||||
export type Icon = SimpleIcon | StackedIcon;
|
||||
|
||||
const simple = (icon: IconDefinition, props: any = {}): SimpleIcon => ({
|
||||
icon, ...props, type: "simple"
|
||||
});
|
||||
|
||||
const stack = (icons: IconDescription[]): StackedIcon => ({ type: "stacked", icons });
|
||||
|
||||
const lineTypeIcons = Object
|
||||
.values(fac)
|
||||
.map<[string, Icon]>(icon => [`line-${ icon.iconName }`, simple(icon)])
|
||||
.reduce((acc, [icon, definition]) => ({ ...acc, [icon]: definition }), {})
|
||||
|
||||
const messageTypeIcons: Dictionary<Icon> = {
|
||||
'message-breakdown': simple(faExclamationTriangle),
|
||||
'message-info': simple(faInfoCircle),
|
||||
'message-unknown': simple(faQuestionCircle),
|
||||
};
|
||||
|
||||
const definitions = {
|
||||
'favourite': simple(faStar),
|
||||
'unknown': simple(faQuestionSquare),
|
||||
'add': simple(faCheck),
|
||||
'add-all': simple(faCheckDouble),
|
||||
'remove-stop': simple(faTimes),
|
||||
'delete': simple(faTrashAlt),
|
||||
'messages': simple(faBullhorn),
|
||||
'timetable': simple(faClock),
|
||||
'settings': simple(faCog),
|
||||
'refresh': simple(faSync),
|
||||
'chevron-down': simple(faChevronDown),
|
||||
'chevron-up': simple(faChevronUp),
|
||||
'search': simple(faSearch),
|
||||
'info': simple(faInfoCircle),
|
||||
'warning': simple(faExclamationTriangle),
|
||||
'night': simple(faMoon),
|
||||
'fast': simple(faWalking),
|
||||
'track': simple(faCodeCommit),
|
||||
'info-hide': simple(faChevronCircleUp),
|
||||
'map': simple(faMapMarkerAlt),
|
||||
'stop': simple(faSign),
|
||||
'spinner': simple(faSpinnerThird, { spin: true }),
|
||||
'increment': simple(faPlus, { "fixed-width": true }),
|
||||
'decrement': simple(faMinus, { "fixed-width": true }),
|
||||
'relative-time': simple(faHourglassHalf),
|
||||
'departure-warning': stack([
|
||||
{ icon: faClockBold },
|
||||
{ icon: faSolidExclamationTriangle, transform: "shrink-5 down-4 right-6" }
|
||||
]),
|
||||
'close': simple(faTimes),
|
||||
...lineTypeIcons,
|
||||
...messageTypeIcons,
|
||||
};
|
||||
|
||||
export type PredefinedIcon = keyof typeof definitions;
|
||||
|
||||
const extractAllIcons = (icons: Icon[]) => icons.map(icon => {
|
||||
switch (icon.type) {
|
||||
case "simple":
|
||||
return [icon.icon];
|
||||
case "stacked":
|
||||
return icon.icons.map(stacked => stacked.icon);
|
||||
}
|
||||
}).reduce((acc, cur) => [...acc, ...cur]);
|
||||
|
||||
library.add(...extractAllIcons(Object.values(definitions)));
|
||||
|
||||
@Component({
|
||||
template: require('../../../components/ui/icon.html'),
|
||||
components: {
|
||||
fa: FontAwesomeIcon,
|
||||
faLayers: FontAwesomeLayers,
|
||||
faText: FontAwesomeLayersText,
|
||||
}
|
||||
})
|
||||
export class UiIcon extends Vue {
|
||||
@Prop({
|
||||
type: [String, Object],
|
||||
validator: value => typeof value === "object" || Object.keys(definitions).includes(value),
|
||||
required: true,
|
||||
})
|
||||
icon: PredefinedIcon | IconDefinition;
|
||||
|
||||
get definition(): Icon {
|
||||
return typeof this.icon === "string"
|
||||
? definitions[this.icon] || definitions['unknown']
|
||||
: { icon: this.icon as IconDefinition, type: "simple" };
|
||||
}
|
||||
|
||||
get attrs() {
|
||||
return { ...this.definition, ...this.$attrs };
|
||||
}
|
||||
|
||||
get type() {
|
||||
return this.definition.type;
|
||||
}
|
||||
}
|
||||
|
||||
Vue.component('UiIcon', UiIcon);
|
4
resources/ts/components/ui/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './switch';
|
||||
export * from './icon';
|
||||
export * from './numeric-input'
|
||||
export * from './dialog'
|
53
resources/ts/components/ui/numeric-input.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import Vue from 'vue'
|
||||
import { Component, Prop } from 'vue-property-decorator'
|
||||
import * as uuid from "uuid";
|
||||
|
||||
@Component({
|
||||
template: require('../../../components/ui/numeric.html'),
|
||||
inheritAttrs: false
|
||||
})
|
||||
export class UiNumericInput extends Vue {
|
||||
@Prop({
|
||||
type: String,
|
||||
default: () => `uuid-${uuid.v4()}`
|
||||
})
|
||||
id: string;
|
||||
|
||||
@Prop(Number)
|
||||
value: number;
|
||||
|
||||
@Prop({ type: Number, default: 1 })
|
||||
step: number;
|
||||
|
||||
@Prop({ type: Number, default: -Infinity })
|
||||
min: number;
|
||||
|
||||
@Prop({ type: Number, default: Infinity })
|
||||
max: number;
|
||||
|
||||
update(ev) {
|
||||
this.$emit('input', this.clamp(Number.parseInt(ev.target.value)));
|
||||
}
|
||||
|
||||
increment() {
|
||||
this.$emit('input', this.clamp(this.value + this.step));
|
||||
}
|
||||
|
||||
decrement() {
|
||||
this.$emit('input', this.clamp(this.value - this.step));
|
||||
}
|
||||
|
||||
clamp(value: number) {
|
||||
return Math.max(Math.min(value, this.max), this.min);
|
||||
}
|
||||
|
||||
get canIncrement(): boolean {
|
||||
return this.max - this.value > Number.EPSILON * 2;
|
||||
}
|
||||
|
||||
get canDecrement(): boolean {
|
||||
return this.value - this.min > Number.EPSILON * 2;
|
||||
}
|
||||
}
|
||||
|
||||
Vue.component('UiNumericInput', UiNumericInput);
|
24
resources/ts/components/ui/switch.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import Vue from 'vue'
|
||||
import { Component, Prop } from 'vue-property-decorator'
|
||||
import * as uuid from "uuid";
|
||||
|
||||
@Component({
|
||||
template: require('../../../components/ui/switch.html'),
|
||||
inheritAttrs: false
|
||||
})
|
||||
export class UiSwitch extends Vue {
|
||||
@Prop({
|
||||
type: String,
|
||||
default: () => `uuid-${uuid.v4()}`
|
||||
})
|
||||
id: string;
|
||||
|
||||
@Prop(Boolean)
|
||||
value: boolean;
|
||||
|
||||
update(ev) {
|
||||
this.$emit('input', !this.value);
|
||||
}
|
||||
}
|
||||
|
||||
Vue.component('UiSwitch', UiSwitch);
|
@ -1,112 +1,6 @@
|
||||
import Vue from 'vue';
|
||||
import { Component, Prop, Watch } from "vue-property-decorator";
|
||||
import Popper, { Placement } from "popper.js";
|
||||
import { Portal } from "portal-vue";
|
||||
import vueRemovedHookMixin from "vue-removed-hook-mixin";
|
||||
|
||||
@Component({
|
||||
template: require("../../components/popper.html"),
|
||||
mixins: [ vueRemovedHookMixin ]
|
||||
})
|
||||
export class PopperComponent extends Vue {
|
||||
@Prop([ String, HTMLElement ])
|
||||
public reference: string | HTMLElement;
|
||||
|
||||
@Prop(Object)
|
||||
public refs: string;
|
||||
|
||||
@Prop({ type: String, default: "auto" })
|
||||
public placement: Placement;
|
||||
|
||||
@Prop(Boolean)
|
||||
public arrow: boolean;
|
||||
|
||||
@Prop({ type: Boolean, default: true })
|
||||
public responsive: boolean;
|
||||
|
||||
private _event;
|
||||
private _popper;
|
||||
|
||||
private getReferenceElement() {
|
||||
const isInPortal = this.$parent.$options.name == 'portalTarget';
|
||||
|
||||
if (typeof this.reference === 'string') {
|
||||
if (this.refs) {
|
||||
return this.refs[this.reference];
|
||||
}
|
||||
|
||||
if (isInPortal) {
|
||||
return this.$parent.$parent.$refs[this.reference];
|
||||
}
|
||||
|
||||
return this.$parent.$refs[this.reference];
|
||||
}
|
||||
|
||||
if (this.reference instanceof HTMLElement) {
|
||||
return this.reference;
|
||||
}
|
||||
|
||||
return isInPortal ? this.$parent.$el : this.$el.parentElement;
|
||||
}
|
||||
|
||||
focusOut(event: MouseEvent) {
|
||||
if (this.$el.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit('leave', event);
|
||||
}
|
||||
|
||||
mounted() {
|
||||
const reference = this.getReferenceElement();
|
||||
|
||||
this._popper = new Popper(reference, this.$el, {
|
||||
placement: this.placement,
|
||||
modifiers: {
|
||||
arrow: { enabled: this.arrow, element: this.$refs['arrow'] as Element },
|
||||
responsive: {
|
||||
enabled: this.responsive,
|
||||
order: 890,
|
||||
fn(data) {
|
||||
if (window.innerWidth < 560) {
|
||||
data.instance.options.placement = 'top';
|
||||
data.styles.transform = `translate3d(0, ${data.offsets.popper.top}px, 0)`;
|
||||
data.styles.right = '0';
|
||||
data.styles.left = '0';
|
||||
data.styles.width = 'auto';
|
||||
data.arrowStyles.left = `${data.offsets.popper.left + data.offsets.arrow.left}px`;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.$nextTick(() => {
|
||||
this._popper.update();
|
||||
document.addEventListener('click', this._event = this.focusOut.bind(this), { capture: true });
|
||||
});
|
||||
}
|
||||
|
||||
updated() {
|
||||
this._popper.update();
|
||||
}
|
||||
|
||||
@Watch('visible')
|
||||
private onVisibilityUpdate() {
|
||||
this._popper.update();
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
}
|
||||
|
||||
beforeDestroy() {
|
||||
this._event && document.removeEventListener('click', this._event, { capture: true });
|
||||
}
|
||||
|
||||
removed() {
|
||||
this._popper.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
@Component({ template: require('../../components/fold.html') })
|
||||
export class FoldComponent extends Vue {
|
||||
@ -152,6 +46,11 @@ export class LazyComponent extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
Vue.component('Popper', PopperComponent);
|
||||
Vue.component('Fold', FoldComponent);
|
||||
Vue.component('Lazy', LazyComponent);
|
||||
|
||||
// https://github.com/vuejs/vue/issues/7829
|
||||
Vue.component('Empty', {
|
||||
functional: true,
|
||||
render: (h, { data }) => h('template', data, '')
|
||||
});
|
||||
|
@ -2,6 +2,14 @@ import { set, signed } from "./utils";
|
||||
import Vue from 'vue';
|
||||
import { condition } from "./decorators";
|
||||
|
||||
export const defaultBreakpoints = {
|
||||
'xs': 0,
|
||||
'sm': 576,
|
||||
'md': 768,
|
||||
'lg': 1024,
|
||||
'xl': 1200,
|
||||
}
|
||||
|
||||
Vue.filter('signed', signed);
|
||||
|
||||
Vue.directive('hover', {
|
||||
@ -61,13 +69,7 @@ Vue.directive('autofocus', {
|
||||
|
||||
Vue.directive('responsive', {
|
||||
inserted(el, binding) {
|
||||
const breakpoints = typeof binding.value === 'object' ? binding.value : {
|
||||
'xs': 0,
|
||||
'sm': 576,
|
||||
'md': 768,
|
||||
'lg': 1024,
|
||||
'xl': 1200,
|
||||
};
|
||||
const breakpoints = typeof binding.value === 'object' ? binding.value : defaultBreakpoints;
|
||||
|
||||
const resize = binding['resize'] = () => {
|
||||
const width = el.scrollWidth;
|
||||
|
@ -1,16 +0,0 @@
|
||||
import Vue from 'vue'
|
||||
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
|
||||
import { far } from "@fortawesome/pro-regular-svg-icons";
|
||||
import { fas } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { fal } from "@fortawesome/pro-light-svg-icons";
|
||||
import { fac } from "./icons";
|
||||
|
||||
import { FontAwesomeIcon, FontAwesomeLayers, FontAwesomeLayersText } from '@fortawesome/vue-fontawesome'
|
||||
|
||||
library.add(far, fas, fal, fac);
|
||||
|
||||
Vue.component('fa', FontAwesomeIcon);
|
||||
Vue.component('fa-layers', FontAwesomeLayers);
|
||||
Vue.component('fa-text', FontAwesomeLayersText);
|
4
resources/ts/model/common.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface Location {
|
||||
lat: number,
|
||||
lng: number,
|
||||
}
|
@ -3,3 +3,5 @@ export * from './departure'
|
||||
export * from './line'
|
||||
export * from './error'
|
||||
export * from './identity'
|
||||
export * from './common'
|
||||
export * from './provider'
|
||||
|
11
resources/ts/model/provider.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Moment } from "moment";
|
||||
import { Location } from "./common";
|
||||
|
||||
export interface Provider {
|
||||
id: string;
|
||||
name: string;
|
||||
shortName: string;
|
||||
attribution?: string;
|
||||
lastUpdate?: Moment;
|
||||
location: Location;
|
||||
}
|
@ -1,14 +1,22 @@
|
||||
import { Line } from "./line";
|
||||
import { Location } from "./common";
|
||||
|
||||
export interface Stop {
|
||||
id: any;
|
||||
name: string;
|
||||
description?: string;
|
||||
location?: {
|
||||
lat: number,
|
||||
lng: number,
|
||||
};
|
||||
location?: Location;
|
||||
onDemand?: boolean;
|
||||
variant?: string;
|
||||
destinations?: Stop[];
|
||||
}
|
||||
|
||||
export interface StopWithDestinations extends Stop{
|
||||
destinations?: Destination[];
|
||||
}
|
||||
|
||||
export type Destination = {
|
||||
stop: Stop;
|
||||
lines: Line[]
|
||||
}
|
||||
|
||||
export type StopGroup = Stop[];
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { FetchingState } from "../utils";
|
||||
import { Moment } from "moment";
|
||||
import { Module, MutationTree } from "vuex";
|
||||
import { RootState } from "./root";
|
||||
import * as moment from "moment";
|
||||
|
||||
export interface CommonState {
|
||||
|
@ -10,10 +10,6 @@ export interface DeparturesState extends CommonState {
|
||||
departures: Departure[],
|
||||
}
|
||||
|
||||
export interface ObtainPayload {
|
||||
stops: Stop[]
|
||||
}
|
||||
|
||||
export const departures: Module<DeparturesState, RootState> = {
|
||||
namespaced: true,
|
||||
state: {
|
||||
@ -29,10 +25,15 @@ export const departures: Module<DeparturesState, RootState> = {
|
||||
...common.mutations
|
||||
},
|
||||
actions: {
|
||||
async update({ commit }, { stops }: ObtainPayload) {
|
||||
async update({ commit }) {
|
||||
const count = this.state['departures-settings'].displayedEntriesCount;
|
||||
const stops = this.state.stops;
|
||||
|
||||
commit('fetching');
|
||||
|
||||
const response = await fetch(urls.prepare(urls.departures, {
|
||||
stop: stops.map(stop => stop.id),
|
||||
limit: count || 8,
|
||||
}));
|
||||
|
||||
if (!response.ok) {
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { RootState, SavedState } from "./root";
|
||||
import { Module, Plugin, Store } from "vuex";
|
||||
import * as utils from "../utils";
|
||||
import { RootState } from "./root";
|
||||
import { Module } from "vuex";
|
||||
import { Stop } from "../model";
|
||||
import { except } from "../utils";
|
||||
|
||||
export interface Favourite {
|
||||
id: string;
|
||||
name: string;
|
||||
name: string;
|
||||
stops: Stop[];
|
||||
}
|
||||
|
||||
@ -20,7 +20,13 @@ const favourites: Module<FavouritesState, RootState> = {
|
||||
},
|
||||
mutations: {
|
||||
add(state, favourite: Favourite) {
|
||||
state.favourites.push(favourite);
|
||||
const existing = state.favourites.find((current: Favourite) => current.name === favourite.name);
|
||||
|
||||
if (!existing) {
|
||||
state.favourites.push(favourite);
|
||||
}
|
||||
|
||||
Object.assign(existing, except(favourite, ["id"]));
|
||||
},
|
||||
remove(state, favourite: Favourite) {
|
||||
state.favourites = state.favourites.filter(f => f != favourite);
|
||||
@ -28,12 +34,4 @@ const favourites: Module<FavouritesState, RootState> = {
|
||||
}
|
||||
};
|
||||
|
||||
export const localStorageSaver = (path: string, key: string): Plugin<any> => (store: Store<any>) => {
|
||||
utils.set(store.state, path, JSON.parse(window.localStorage.getItem(key) || '[]'));
|
||||
|
||||
store.subscribe((mutation, state) => {
|
||||
window.localStorage.setItem(key, JSON.stringify(utils.get(state, path)));
|
||||
})
|
||||
};
|
||||
|
||||
export default favourites;
|
||||
|
@ -2,9 +2,11 @@ import Vuex from 'vuex';
|
||||
|
||||
import messages, { MessagesState } from './messages';
|
||||
import departures, { DeparturesState } from './departures'
|
||||
import favourites, { FavouritesState, localStorageSaver } from './favourites'
|
||||
import favourites, { FavouritesState } from './favourites'
|
||||
import departureSettings, { DeparturesSettingsState } from "./settings/departures";
|
||||
import messagesSettings, { MessagesSettingsState } from "./settings/messages";
|
||||
|
||||
import { state, mutations, actions, RootState } from "./root";
|
||||
import { actions, mutations, RootState, state } from "./root";
|
||||
import VuexPersistence from "vuex-persist";
|
||||
import { namespace } from "vuex-class";
|
||||
|
||||
@ -12,10 +14,12 @@ export type State = {
|
||||
messages: MessagesState;
|
||||
departures: DeparturesState;
|
||||
favourites: FavouritesState;
|
||||
"departures-settings": DeparturesSettingsState,
|
||||
"messages-settings": MessagesSettingsState,
|
||||
} & RootState;
|
||||
|
||||
const localStoragePersist = new VuexPersistence<State>({
|
||||
reducer: state => ({ favourites: state.favourites })
|
||||
modules: ['favourites', 'departures-settings', 'messages-settings'],
|
||||
});
|
||||
|
||||
const sessionStoragePersist = new VuexPersistence<State>({
|
||||
@ -23,12 +27,16 @@ const sessionStoragePersist = new VuexPersistence<State>({
|
||||
storage: window.sessionStorage
|
||||
});
|
||||
|
||||
const store = new Vuex.Store({
|
||||
const store = new Vuex.Store<RootState>({
|
||||
state, mutations, actions,
|
||||
modules: { messages, departures, favourites },
|
||||
modules: {
|
||||
messages,
|
||||
departures,
|
||||
favourites,
|
||||
'departures-settings': departureSettings,
|
||||
'messages-settings': messagesSettings,
|
||||
},
|
||||
plugins: [
|
||||
// todo: remove after some time
|
||||
localStorageSaver('favourites.favourites', 'favourites'),
|
||||
localStoragePersist.plugin,
|
||||
sessionStoragePersist.plugin,
|
||||
]
|
||||
@ -37,3 +45,7 @@ const store = new Vuex.Store({
|
||||
export default store;
|
||||
|
||||
export const Favourites = namespace('favourites');
|
||||
export const DeparturesSettings = namespace('departures-settings');
|
||||
export const MessagesSettings = namespace('messages-settings');
|
||||
export const Departures = namespace('departures');
|
||||
export const Messages = namespace('messages');
|
||||
|
@ -5,6 +5,7 @@ import { ensureArray } from "../utils";
|
||||
|
||||
export interface RootState {
|
||||
stops: Stop[],
|
||||
provider: any,
|
||||
}
|
||||
|
||||
export interface SavedState {
|
||||
@ -13,7 +14,8 @@ export interface SavedState {
|
||||
}
|
||||
|
||||
export const state: RootState = {
|
||||
stops: []
|
||||
stops: [],
|
||||
provider: null,
|
||||
};
|
||||
|
||||
export const mutations: MutationTree<RootState> = {
|
||||
|
26
resources/ts/store/settings/departures.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { ActionContext, Module } from "vuex";
|
||||
import { RootState } from "../root";
|
||||
|
||||
export type DeparturesSettingsState = {
|
||||
autorefresh: boolean;
|
||||
autorefreshInterval?: number;
|
||||
displayedEntriesCount?: number;
|
||||
relativeTimes: boolean,
|
||||
}
|
||||
|
||||
const departureSettings: Module<DeparturesSettingsState, RootState> = {
|
||||
namespaced: true,
|
||||
state: {
|
||||
autorefresh: true,
|
||||
relativeTimes: false,
|
||||
autorefreshInterval: 10,
|
||||
displayedEntriesCount: 10
|
||||
},
|
||||
mutations: {
|
||||
update(state: DeparturesSettingsState, patch: Partial<DeparturesSettingsState>) {
|
||||
Object.assign(state, patch);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default departureSettings;
|
24
resources/ts/store/settings/messages.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { Module } from "vuex";
|
||||
import { RootState } from "../root";
|
||||
|
||||
export type MessagesSettingsState = {
|
||||
autorefresh: boolean;
|
||||
autorefreshInterval?: number;
|
||||
displayedEntriesCount?: number;
|
||||
}
|
||||
|
||||
const messagesSettings: Module<MessagesSettingsState, RootState> = {
|
||||
namespaced: true,
|
||||
state: {
|
||||
autorefresh: true,
|
||||
autorefreshInterval: 60,
|
||||
displayedEntriesCount: 2
|
||||
},
|
||||
mutations: {
|
||||
update(state: MessagesSettingsState, patch: Partial<MessagesSettingsState>) {
|
||||
Object.assign(state, patch);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default messagesSettings;
|
@ -1,3 +1,5 @@
|
||||
import store from "./store";
|
||||
|
||||
export type UrlParams = {
|
||||
[name: string]: any
|
||||
}
|
||||
@ -8,7 +10,11 @@ export function query(params: UrlParams = { }) {
|
||||
function *simplify(name: string, param: any): IterableIterator<ParamValuePair> {
|
||||
if (typeof param === 'string') {
|
||||
yield [ name, param ];
|
||||
} else if (typeof param === 'number') {
|
||||
} else if (typeof param === 'boolean') {
|
||||
if (param) {
|
||||
yield [ name, '1' ];
|
||||
}
|
||||
} else if (typeof param === 'number') {
|
||||
yield [ name, param.toString() ];
|
||||
} else if (param instanceof Array) {
|
||||
for (let entry of param) {
|
||||
@ -57,5 +63,5 @@ export default {
|
||||
tracks: `${base}/stops/{id}/tracks`
|
||||
},
|
||||
trip: `${base}/trips/{id}`,
|
||||
prepare: (url: string, params: UrlParams = { }) => prepare(url, Object.assign({}, { provider: window['data'].provider }, params))
|
||||
prepare: (url: string, params: UrlParams = { }) => prepare(url, Object.assign({}, { provider: store.state.provider }, params))
|
||||
}
|
||||
|
@ -38,6 +38,14 @@ export function filter<T, KT extends keyof T>(source: T, filter: (value: T[KT],
|
||||
return result;
|
||||
}
|
||||
|
||||
export function except<T>(source: T, keys: (keyof T)[]) {
|
||||
return filter(source, (_, key) => !keys.includes(key))
|
||||
}
|
||||
|
||||
export function only<T>(source: T, keys: (keyof T)[]) {
|
||||
return filter(source, (_, key) => keys.includes(key))
|
||||
}
|
||||
|
||||
export function signed(number: number): string {
|
||||
return number > 0 ? `+${number}` : number.toString();
|
||||
}
|
||||
@ -75,3 +83,42 @@ export function time<T>(action: () => T, name?: string) {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export const identity = a => a;
|
||||
|
||||
export function unique<T, U>(array: T[], criterion: (item: T) => U = identity) {
|
||||
const result: T[] = [];
|
||||
const known = new Set<U>();
|
||||
|
||||
const entries = array.map(item => [ criterion(item), item ]) as [ U, T ][];
|
||||
|
||||
for (const [ key, item ] of entries) {
|
||||
if (known.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
known.add(key);
|
||||
result.push(item);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
type Pattern<TResult, TArgs extends any[]> = [
|
||||
(...args: TArgs) => boolean,
|
||||
((...args: TArgs) => TResult) | TResult,
|
||||
]
|
||||
|
||||
export function match<TResult, TArgs extends any[]>(...patterns: Pattern<TResult, TArgs>[]): (...args: TArgs) => TResult {
|
||||
return (...args: TArgs) => {
|
||||
for (let [pattern, action] of patterns) {
|
||||
if (pattern(...args)) {
|
||||
return typeof action === "function" ? (action as (...args: TArgs) => TResult)(...args) : action;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`No pattern matches args: ${JSON.stringify(args)}`);
|
||||
}
|
||||
}
|
||||
|
||||
match.default = (...args: any[]) => true;
|
||||
|
@ -1,6 +1,6 @@
|
||||
<?xml version="1.0"?>
|
||||
<rulset name="Kadet.CzyDojade">
|
||||
<description>Czy Dojadę ruleset</description>
|
||||
<rulset name="CoJedzie">
|
||||
<description>Co Jedzie ruleset</description>
|
||||
|
||||
<arg name="colors"/>
|
||||
<arg name="parallel" value="75"/>
|
||||
|
@ -5,6 +5,10 @@ namespace App\Controller\Api\v1;
|
||||
|
||||
use App\Controller\Controller;
|
||||
use App\Model\Departure;
|
||||
use App\Modifier\FieldFilter;
|
||||
use App\Modifier\IdFilter;
|
||||
use App\Modifier\Limit;
|
||||
use App\Modifier\With;
|
||||
use App\Provider\DepartureRepository;
|
||||
use App\Provider\StopRepository;
|
||||
use App\Service\SerializerContextFactory;
|
||||
@ -32,11 +36,11 @@ class DeparturesController extends Controller
|
||||
* @SWG\Schema(type="array", @SWG\Items(ref=@Model(type=Departure::class)))
|
||||
* )
|
||||
*/
|
||||
public function stop(DepartureRepository $departures, StopRepository $stops, $stop)
|
||||
public function stop(DepartureRepository $departures, StopRepository $stops, $stop, Request $request)
|
||||
{
|
||||
$stop = $stops->getById($stop);
|
||||
$stop = $stops->first(new IdFilter($stop));
|
||||
|
||||
return $this->json($departures->getForStop($stop));
|
||||
return $this->json($departures->current(collect($stop), ...$this->getModifiersFromRequest($request)));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -64,16 +68,21 @@ class DeparturesController extends Controller
|
||||
*/
|
||||
public function stops(DepartureRepository $departures, StopRepository $stops, Request $request)
|
||||
{
|
||||
$stops = $stops
|
||||
->getManyById($request->query->get('stop'))
|
||||
->flatMap(ref([ $departures, 'getForStop' ]))
|
||||
->sortBy(property('departure'));
|
||||
$stops = $stops->all(new IdFilter($request->query->get('stop', [])));
|
||||
$result = $departures->current($stops, ...$this->getModifiersFromRequest($request));
|
||||
|
||||
return $this->json(
|
||||
$stops->values()->slice(0, (int)$request->query->get('limit', 8)),
|
||||
$result->values()->slice(0, (int)$request->query->get('limit', 8)),
|
||||
200,
|
||||
[],
|
||||
$this->serializerContextFactory->create(Departure::class, ['Default'])
|
||||
);
|
||||
}
|
||||
|
||||
private function getModifiersFromRequest(Request $request)
|
||||
{
|
||||
if ($request->query->has('limit')) {
|
||||
yield Limit::count($request->query->getInt('limit'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
31
src/Controller/Api/v1/ProviderController.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Api\v1;
|
||||
|
||||
use App\Controller\Controller;
|
||||
use App\Service\Converter;
|
||||
use App\Service\ProviderResolver;
|
||||
use Swagger\Annotations as SWG;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use function Kadet\Functional\ref;
|
||||
|
||||
/**
|
||||
* @Route("/providers")
|
||||
* @SWG\Tag(name="Providers")
|
||||
*/
|
||||
class ProviderController extends Controller
|
||||
{
|
||||
/**
|
||||
* @Route("/", methods={"GET"})
|
||||
*/
|
||||
public function index(ProviderResolver $resolver, Converter $converter)
|
||||
{
|
||||
$providers = $resolver
|
||||
->all()
|
||||
->map(ref([$converter, 'convert']))
|
||||
->values()
|
||||
->toArray()
|
||||
;
|
||||
return $this->json($providers);
|
||||
}
|
||||
}
|
@ -1,15 +1,17 @@
|
||||
<?php
|
||||
|
||||
|
||||
namespace App\Controller\Api\v1;
|
||||
|
||||
use App\Controller\Controller;
|
||||
use App\Model\Stop;
|
||||
use App\Model\Track;
|
||||
use App\Model\StopGroup;
|
||||
use App\Model\TrackStop;
|
||||
use App\Modifier\FieldFilter;
|
||||
use App\Modifier\IdFilter;
|
||||
use App\Modifier\RelatedFilter;
|
||||
use App\Modifier\With;
|
||||
use App\Provider\StopRepository;
|
||||
use App\Provider\TrackRepository;
|
||||
use App\Service\Proxy\ReferenceFactory;
|
||||
use Nelmio\ApiDocBundle\Annotation\Model;
|
||||
use Swagger\Annotations as SWG;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@ -38,7 +40,8 @@ class StopsController extends Controller
|
||||
* name="id",
|
||||
* in="query",
|
||||
* type="array",
|
||||
* description="Stop identificators to retrieve at once. Can be used to bulk load data. If not specified will return all data.",
|
||||
* description="Stop identificators to retrieve at once. Can be used to bulk load data. If not specified will
|
||||
* return all data.",
|
||||
* @SWG\Items(type="string")
|
||||
* )
|
||||
*
|
||||
@ -46,16 +49,9 @@ class StopsController extends Controller
|
||||
*/
|
||||
public function index(Request $request, StopRepository $stops)
|
||||
{
|
||||
switch (true) {
|
||||
case $request->query->has('id'):
|
||||
$result = $stops->getManyById($request->query->get('id'));
|
||||
break;
|
||||
$modifiers = $this->getModifiersFromRequest($request);
|
||||
|
||||
default:
|
||||
$result = $stops->getAll();
|
||||
}
|
||||
|
||||
return $this->json($result->all());
|
||||
return $this->json($stops->all(...$modifiers)->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
@ -76,16 +72,9 @@ class StopsController extends Controller
|
||||
*/
|
||||
public function groups(Request $request, StopRepository $stops)
|
||||
{
|
||||
switch (true) {
|
||||
case $request->query->has('name'):
|
||||
$result = $stops->findByName($request->query->get('name'));
|
||||
break;
|
||||
$modifiers = $this->getModifiersFromRequest($request);
|
||||
|
||||
default:
|
||||
$result = $stops->getAll();
|
||||
}
|
||||
|
||||
return $this->json(static::group($result)->all());
|
||||
return $this->json(static::group($stops->all(...$modifiers))->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
@ -106,7 +95,7 @@ class StopsController extends Controller
|
||||
*/
|
||||
public function one(Request $request, StopRepository $stops, $id)
|
||||
{
|
||||
return $this->json($stops->getById($id));
|
||||
return $this->json($stops->first(new IdFilter($id), new With("destinations")));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -115,21 +104,12 @@ class StopsController extends Controller
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns specific stop referenced via identificator.",
|
||||
* @SWG\Schema(type="object", properties={
|
||||
* @SWG\Property(property="track", type="object", ref=@Model(type=Track::class)),
|
||||
* @SWG\Property(property="order", type="integer", minimum="0")
|
||||
* })
|
||||
* @SWG\Schema(ref=@Model(type=TrackStop::class))
|
||||
* )
|
||||
*
|
||||
* @SWG\Tag(name="Tracks")
|
||||
*/
|
||||
public function tracks(ReferenceFactory $reference, TrackRepository $tracks, $id)
|
||||
public function tracks(TrackRepository $tracks, $id)
|
||||
{
|
||||
$stop = $reference->get(Stop::class, $id);
|
||||
|
||||
return $this->json($tracks->getByStop($stop)->map(function ($tuple) {
|
||||
return array_combine(['track', 'order'], $tuple);
|
||||
}));
|
||||
return $this->json($tracks->stops(new RelatedFilter(Stop::reference($id))));
|
||||
}
|
||||
|
||||
public static function group(Collection $stops)
|
||||
@ -145,4 +125,19 @@ class StopsController extends Controller
|
||||
return $group;
|
||||
})->values();
|
||||
}
|
||||
|
||||
private function getModifiersFromRequest(Request $request)
|
||||
{
|
||||
if ($request->query->has('name')) {
|
||||
yield FieldFilter::contains('name', $request->query->get('name'));
|
||||
}
|
||||
|
||||
if ($request->query->has('id')) {
|
||||
yield new IdFilter($request->query->get('id'));
|
||||
}
|
||||
|
||||
if ($request->query->has('include-destinations')) {
|
||||
yield new With("destinations");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,18 +3,24 @@
|
||||
namespace App\Controller\Api\v1;
|
||||
|
||||
use App\Controller\Controller;
|
||||
use App\Model\Line;
|
||||
use App\Model\Stop;
|
||||
use App\Model\Track;
|
||||
use App\Modifier\IdFilter;
|
||||
use App\Modifier\RelatedFilter;
|
||||
use App\Provider\TrackRepository;
|
||||
use App\Service\IterableUtils;
|
||||
use Nelmio\ApiDocBundle\Annotation\Model;
|
||||
use Swagger\Annotations as SWG;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use function App\Functions\encapsulate;
|
||||
use function Kadet\Functional\ref;
|
||||
|
||||
/**
|
||||
* @Route("/tracks")
|
||||
* @SWG\Tag(name="Tracks")
|
||||
*/
|
||||
class TracksController extends Controller
|
||||
{
|
||||
@ -23,48 +29,69 @@ class TracksController extends Controller
|
||||
* response=200,
|
||||
* description="Returns all tracks for specific provider, e.g. ZTM Gdańsk.",
|
||||
* )
|
||||
* @SWG\Tag(name="Tracks")
|
||||
* @Route("/", methods={"GET"})
|
||||
*/
|
||||
public function index(Request $request, TrackRepository $repository)
|
||||
{
|
||||
switch (true) {
|
||||
case $request->query->has('stop'):
|
||||
return $this->byStop($request, $repository);
|
||||
case $request->query->has('line'):
|
||||
return $this->byLine($request, $repository);
|
||||
case $request->query->has('id'):
|
||||
return $this->byId($request, $repository);
|
||||
default:
|
||||
throw new BadRequestHttpException(
|
||||
sprintf(
|
||||
'At least one parameter of %s must be set.',
|
||||
implode(', ', ['stop', 'line', 'id'])
|
||||
)
|
||||
);
|
||||
$modifiers = $this->getModifiersFromRequest($request);
|
||||
|
||||
return $this->json($repository->all(...$modifiers));
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route("/stops", methods={"GET"})
|
||||
* @Route("/{track}/stops", methods={"GET"})
|
||||
*/
|
||||
public function stops(Request $request, TrackRepository $repository)
|
||||
{
|
||||
$modifiers = $this->getStopsModifiersFromRequest($request);
|
||||
|
||||
return $this->json($repository->stops(...$modifiers));
|
||||
}
|
||||
|
||||
private function getModifiersFromRequest(Request $request)
|
||||
{
|
||||
if ($request->query->has('stop')) {
|
||||
$stop = encapsulate($request->query->get('stop'));
|
||||
$stop = collect($stop)->map([Stop::class, 'reference']);
|
||||
|
||||
yield new RelatedFilter($stop, Stop::class);
|
||||
}
|
||||
|
||||
if ($request->query->has('line')) {
|
||||
$line = encapsulate($request->query->get('line'));
|
||||
$line = collect($line)->map([Line::class, 'reference']);
|
||||
|
||||
yield new RelatedFilter($line, Line::class);
|
||||
}
|
||||
|
||||
if ($request->query->has('id')) {
|
||||
$id = encapsulate($request->query->get('id'));
|
||||
|
||||
yield new IdFilter($id);
|
||||
}
|
||||
}
|
||||
|
||||
private function byId(Request $request, TrackRepository $repository)
|
||||
private function getStopsModifiersFromRequest(Request $request)
|
||||
{
|
||||
$id = encapsulate($request->query->get('id'));
|
||||
if ($request->query->has('stop')) {
|
||||
$stop = encapsulate($request->query->get('stop'));
|
||||
$stop = collect($stop)->map(ref([Stop::class, 'reference']));
|
||||
|
||||
return $this->json($repository->getManyById($id));
|
||||
}
|
||||
yield new RelatedFilter($stop);
|
||||
}
|
||||
|
||||
private function byStop(Request $request, TrackRepository $repository)
|
||||
{
|
||||
$stop = $request->query->get('stop');
|
||||
$stop = array_map([Stop::class, 'reference'], encapsulate($stop));
|
||||
if ($request->query->has('track') || $request->attributes->has('track')) {
|
||||
$track = $request->get('track');
|
||||
$track = Track::reference($track);
|
||||
|
||||
return $this->json($repository->getByStop($stop));
|
||||
}
|
||||
yield new RelatedFilter($track);
|
||||
}
|
||||
|
||||
private function byLine(Request $request, TrackRepository $repository)
|
||||
{
|
||||
$line = $request->query->get('line');
|
||||
$line = array_map([Stop::class, 'reference'], encapsulate($line));
|
||||
if ($request->query->has('id')) {
|
||||
$id = encapsulate($request->query->get('id'));
|
||||
|
||||
return $this->json($repository->getByLine($line));
|
||||
yield new IdFilter($id);
|
||||
}
|
||||
}
|
||||
}
|
@ -4,6 +4,8 @@ namespace App\Controller\Api\v1;
|
||||
|
||||
use App\Controller\Controller;
|
||||
use App\Model\Trip;
|
||||
use App\Modifier\IdFilter;
|
||||
use App\Modifier\With;
|
||||
use App\Provider\TripRepository;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
@ -14,11 +16,11 @@ use Symfony\Component\Routing\Annotation\Route;
|
||||
class TripController extends Controller
|
||||
{
|
||||
/**
|
||||
* @Route("/{id}")
|
||||
* @Route("/{id}", methods={"GET"})
|
||||
*/
|
||||
public function one($id, TripRepository $repository)
|
||||
{
|
||||
$trip = $repository->getById($id);
|
||||
$trip = $repository->first(new IdFilter($id), new With('schedule'));
|
||||
|
||||
return $this->json($trip, Response::HTTP_OK, [], $this->serializerContextFactory->create(Trip::class));
|
||||
}
|
||||
|
@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
|
||||
use App\Provider\Provider;
|
||||
use App\Service\ProviderResolver;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@ -18,25 +17,32 @@ class MainController extends Controller
|
||||
return $this->render('choose.html.twig', ['providers' => $resolver->all()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route("/{provider}/manifest.json", name="provider_manifest")
|
||||
* @Route("/manifest.json", name="main_manifest")
|
||||
*/
|
||||
public function manifest(?Provider $provider = null)
|
||||
{
|
||||
$response = $this->render('manifest.json.twig', ['provider' => $provider]);
|
||||
$response->headers->set('Content-Type', 'application/json');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route("/{provider}", name="app")
|
||||
*/
|
||||
public function app(Provider $provider, Request $request)
|
||||
{
|
||||
$state = json_decode($request->query->get('state', '{}'), true) ?: [];
|
||||
$state = array_merge([
|
||||
'version' => 1,
|
||||
'stops' => [],
|
||||
], $state);
|
||||
$state = array_merge(
|
||||
[
|
||||
'version' => 1,
|
||||
'stops' => [],
|
||||
],
|
||||
$state
|
||||
);
|
||||
|
||||
return $this->render('app.html.twig', compact('state', 'provider'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route("/{provider}/manifest.json", name="webapp_manifest")
|
||||
*/
|
||||
public function manifest(Provider $provider)
|
||||
{
|
||||
return $this->render('manifest.json.twig', ['provider' => $provider]);
|
||||
}
|
||||
}
|
@ -45,16 +45,18 @@ class TrackEntity implements Entity, Fillable
|
||||
|
||||
/**
|
||||
* Stops in track
|
||||
* @var StopInTrack[]|Collection
|
||||
* @ORM\OneToMany(targetEntity=StopInTrack::class, fetch="LAZY", mappedBy="track", cascade={"persist"})
|
||||
*
|
||||
* @var TrackStopEntity[]|Collection
|
||||
* @ORM\OneToMany(targetEntity=TrackStopEntity::class, fetch="LAZY", mappedBy="track", cascade={"persist"})
|
||||
* @ORM\OrderBy({"order": "ASC"})
|
||||
*/
|
||||
private $stopsInTrack;
|
||||
|
||||
/**
|
||||
* Final stop in this track.
|
||||
* @var StopInTrack
|
||||
* @ORM\OneToOne(targetEntity=StopInTrack::class, fetch="LAZY")
|
||||
*
|
||||
* @var TrackStopEntity
|
||||
* @ORM\OneToOne(targetEntity=TrackStopEntity::class, fetch="LAZY")
|
||||
*/
|
||||
private $final;
|
||||
|
||||
@ -114,7 +116,7 @@ class TrackEntity implements Entity, Fillable
|
||||
$this->final = $this->stopsInTrack->last();
|
||||
}
|
||||
|
||||
public function getFinal(): StopInTrack
|
||||
public function getFinal(): TrackStopEntity
|
||||
{
|
||||
return $this->final;
|
||||
}
|
||||
|
@ -14,7 +14,7 @@ use Doctrine\ORM\Mapping as ORM;
|
||||
* @ORM\UniqueConstraint(name="stop_in_track_idx", columns={"stop_id", "track_id", "sequence"})
|
||||
* })
|
||||
*/
|
||||
class StopInTrack implements Fillable, Referable
|
||||
class TrackStopEntity implements Fillable, Referable
|
||||
{
|
||||
use FillTrait, ReferableEntityTrait;
|
||||
|
@ -4,6 +4,7 @@ namespace App\Entity;
|
||||
|
||||
use App\Model\Fillable;
|
||||
use App\Model\FillTrait;
|
||||
use App\Model\Referable;
|
||||
use App\Model\Trip;
|
||||
use App\Service\IdUtils;
|
||||
use Carbon\Carbon;
|
||||
@ -14,21 +15,28 @@ use JMS\Serializer\Tests\Fixtures\Discriminator\Car;
|
||||
* @ORM\Entity
|
||||
* @ORM\Table("trip_stop")
|
||||
*/
|
||||
class TripStopEntity implements Fillable
|
||||
class TripStopEntity implements Fillable, Referable
|
||||
{
|
||||
use FillTrait;
|
||||
use FillTrait, ReferableEntityTrait;
|
||||
|
||||
/**
|
||||
* Identifier for stop coming from provider
|
||||
*
|
||||
* @ORM\Column(type="integer")
|
||||
* @ORM\Id
|
||||
* @ORM\GeneratedValue
|
||||
*/
|
||||
private $id;
|
||||
|
||||
/**
|
||||
* @var StopEntity
|
||||
* @ORM\ManyToOne(targetEntity=StopEntity::class, fetch="EAGER")
|
||||
* @ORM\Id
|
||||
*/
|
||||
private $stop;
|
||||
|
||||
/**
|
||||
* @var TripEntity
|
||||
* @ORM\ManyToOne(targetEntity=TripEntity::class, fetch="EAGER", inversedBy="stops")
|
||||
* @ORM\Id
|
||||
*/
|
||||
private $trip;
|
||||
|
||||
@ -37,7 +45,6 @@ class TripStopEntity implements Fillable
|
||||
* @var int
|
||||
*
|
||||
* @ORM\Column(name="sequence", type="integer")
|
||||
* @ORM\Id
|
||||
*/
|
||||
private $order;
|
||||
|
||||
|
34
src/Event/HandleDatabaseModifierEvent.php
Normal file
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Event;
|
||||
|
||||
use App\Event\HandleModifierEvent;
|
||||
use App\Modifier\Modifier;
|
||||
use App\Provider\Repository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
class HandleDatabaseModifierEvent extends HandleModifierEvent
|
||||
{
|
||||
private $builder;
|
||||
|
||||
public function __construct(
|
||||
Modifier $modifier,
|
||||
Repository $repository,
|
||||
QueryBuilder $builder,
|
||||
array $meta = []
|
||||
) {
|
||||
parent::__construct($modifier, $repository, $meta);
|
||||
|
||||
$this->builder = $builder;
|
||||
}
|
||||
|
||||
public function getBuilder(): QueryBuilder
|
||||
{
|
||||
return $this->builder;
|
||||
}
|
||||
|
||||
public function replaceBuilder(QueryBuilder $builder): void
|
||||
{
|
||||
$this->builder = $builder;
|
||||
}
|
||||
}
|
35
src/Event/HandleModifierEvent.php
Normal file
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Event;
|
||||
|
||||
use App\Modifier\Modifier;
|
||||
use App\Provider\Repository;
|
||||
|
||||
class HandleModifierEvent
|
||||
{
|
||||
private $repository;
|
||||
private $modifier;
|
||||
private $meta = [];
|
||||
|
||||
public function __construct(Modifier $modifier, Repository $repository, array $meta = [])
|
||||
{
|
||||
$this->repository = $repository;
|
||||
$this->modifier = $modifier;
|
||||
$this->meta = $meta;
|
||||
}
|
||||
|
||||
public function getModifier(): Modifier
|
||||
{
|
||||
return $this->modifier;
|
||||
}
|
||||
|
||||
public function getRepository()
|
||||
{
|
||||
return $this->repository;
|
||||
}
|
||||
|
||||
public function getMeta(): array
|
||||
{
|
||||
return $this->meta;
|
||||
}
|
||||
}
|