Compare commits

...

48 Commits

Author SHA1 Message Date
Kacper Donat
f49c7287fd #59 - Add ability to replace favourite with confirmation. 2020-10-20 21:10:56 +02:00
Kacper Donat
f2bdeb0004 #59 - add proper style for primary button 2020-10-19 23:11:37 +02:00
Kacper Donat
9420764e9b #62 - Fix some tiny errors related to background 2020-10-02 21:08:38 +02:00
Kacper Donat
ccbe88a532 #18 - Refine modal system 2020-10-02 20:00:24 +02:00
Kacper Donat
ceaa13bf2a #18 - Basic Modal system 2020-10-02 20:00:24 +02:00
Kacper Donat
dcd3962220 #62 - Add background 2020-10-01 22:04:16 +02:00
Kacper Donat
c541cf1a11 #57 - Replace google analytics with google tag manager 2020-09-19 19:26:18 +02:00
Kacper Donat
ec479e5d77 Unify provider and main webapp manifest (manifest.json) 2020-09-18 22:47:22 +02:00
Kacper Donat
3abfa6dac3 Provide better maskable icon 2020-09-18 22:16:29 +02:00
Kacper Donat
bb81077d1c Add basic noscript warning 2020-09-18 21:58:29 +02:00
Kacper Donat
4e86e127d8 Add support for maskable and monochrome icons 2020-09-18 21:57:38 +02:00
Kacper Donat
7b7b59ce23 #40 - (Hot) Fix Aggregate Converter loop 2020-05-03 00:03:54 +02:00
Kacper Donat
0c5ffbb567 #40 - Fix passing provider to Vue 2020-05-02 23:51:50 +02:00
Kacper Donat
af6a2a0756 #40 - Fix typo in provider picker header 2020-05-02 23:39:27 +02:00
Kacper Donat
2659acdc58 #40 - Add map based provider picker 2020-05-02 23:33:15 +02:00
Kacper Donat
03058f251b #40 - Add provider DTO and API endpoint 2020-05-02 00:58:03 +02:00
Kacper Donat
4b9a9c54d8 #48 - Fix bug with non serializing recursive departure 2020-03-21 15:41:49 +01:00
Kacper Donat
057a7d6d01 #41 - Move messaging settings to vuex store 2020-03-19 20:22:50 +01:00
Kacper Donat
984cb37c8f #33 - Relative departure times 2020-03-19 17:28:26 +01:00
Kacper Donat
7a54569820 #47 - Extract departures settings to its own component and store module 2020-03-18 22:05:40 +01:00
Kacper Donat
c781ca3dbc #47 - UI - Add UiNumericInput component 2020-03-18 16:33:06 +01:00
Kacper Donat
7d0e141d4e Merge branch '35_new_repository_pattern_with_filters_and_modifiers' 2020-03-16 21:33:15 +01:00
Kacper Donat
720327424a #35 - Add paginator to properly handle limits 2020-03-16 20:32:15 +01:00
Kacper Donat
db30d69cdb #35 - Add Limit support for departure repository 2020-03-15 22:35:12 +01:00
Kacper Donat
f3a6c3e8eb Add blackfire service for profiling 2020-03-15 17:30:08 +01:00
Kacper Donat
9506881792 #35 - WIP - Add scheduled stop repository 2020-03-15 12:50:38 +01:00
Kacper Donat
b609b81ddf #35 - Extract HandlerProvider to own class 2020-03-14 17:19:43 +01:00
Kacper Donat
5703816498 Fix situation when there are multiple first stops on same trip 2020-03-14 12:37:11 +01:00
Kacper Donat
129a4dc588 Fix situation when there are multiple first stops on same trip 2020-03-14 12:36:24 +01:00
Kacper Donat
7fa5124577 #35 - Add support for multiple related objects filtering 2020-02-23 17:12:23 +01:00
Kacper Donat
e8a31f60d1 Various little fixes and improvements 2020-02-23 15:23:14 +01:00
Kacper Donat
42ee6e091d #35 - Track stops fluent quering 2020-02-20 17:33:31 +01:00
Kacper Donat
f4e3ee6f55 #35 - Add stop filter for tracks 2020-02-18 22:45:16 +01:00
Kacper Donat
9d0e4fdb2a #35 - Rewrite Track Repository into fluent pattern 2020-02-17 21:48:51 +01:00
Kacper Donat
c7dc90bfc5 #35 - Move trip and operator repository to fluent pattern 2020-02-16 21:59:38 +01:00
Kacper Donat
d72fcf777f #35 - Make StopRepository more fluent 2020-02-16 21:09:07 +01:00
Kacper Donat
fd4bcc9c70 #35 - Remove unnecessary methods from LineRepository 2020-02-12 20:16:26 +01:00
Kacper Donat
d7fb3032b7 #35 - Add service subscriber to database repository 2020-02-12 20:08:56 +01:00
Kacper Donat
16882ee06f #35 - fluent repository prototype 2020-02-11 22:48:30 +01:00
Kacper Donat
8ebf5ac137 #22 - Fix missing icon in favourites adder 2020-02-11 20:12:33 +01:00
Kacper Donat
749fd773b6 #12 - rebrand to 'Co Jedzie' as 'Czy Dojade' is taken 2020-02-10 22:38:49 +01:00
Kacper Donat
d6850773b6 #22 - Create icon library 2020-02-09 21:59:33 +01:00
Kacper Donat
8d5bdc061d Restyle inputs 2020-02-09 15:56:29 +01:00
Kacper Donat
0fc0dd07e4 #32 - Switch instead of checkbox 2020-02-09 14:05:46 +01:00
Kacper Donat
493c3852d8 #32 - Switch instead of checkbox 2020-02-09 13:56:22 +01:00
Kacper Donat
449efd7536 Fixed build errors 2020-02-08 21:50:20 +01:00
Kacper Donat
f2f7b19380 #38 - Add lines to destinations
This is useful when we have few different stops, with same destinations and different lines.
2020-02-08 18:36:29 +01:00
Kacper Donat
0895b412d2 #38 - Display only unique destinations 2020-02-08 15:14:46 +01:00
171 changed files with 5381 additions and 1807 deletions

View File

@ -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

View File

@ -1,5 +1,5 @@
{
"name": "kadet/czydojade",
"name": "kadet/cojedzie",
"type": "project",
"license": "MIT",
"require": {

View File

@ -1,4 +1,8 @@
jms_serializer:
default_context:
serialization:
serialize_null: true
visitors:
xml_serialization:
format_output: '%kernel.debug%'

View File

@ -1,7 +1,7 @@
nelmio_api_doc:
documentation:
info:
title: Czy Dojadę?
title: Co Jedzie?
version: 0.1.0
parameters:
provider:

View File

@ -3,4 +3,4 @@ twig:
debug: '%kernel.debug%'
strict_variables: '%kernel.debug%'
globals:
ga_tracking: "%env(GOOGLE_ANALYTICS)%"
gtm_tracking: "%env(GTM_TAG)%"

View File

@ -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'

View File

@ -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

View File

@ -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

View File

@ -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;
}

View File

@ -1,2 +1 @@
XDEBUG_CONFIG=remote_host=172.17.0.1 remote_port=9001
PHP_IDE_CONFIG=serverName=czydojade

View File

@ -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

View File

@ -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",

View File

@ -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"
}]
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View 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>

View File

@ -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>

View File

@ -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>

View 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>

View 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>

View File

@ -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='&copy; <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>

View File

@ -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>

View 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>

View 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>

View 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>

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
resources/images/background.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

1313
resources/images/logo-cojedzie.ai Executable file

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

BIN
resources/images/logo-superhi.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.7 KiB

BIN
resources/images/logo.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 43 KiB

View File

@ -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;

View File

@ -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;
}
}
}

View File

@ -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;
}

View 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;
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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 > * {

View 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;
}
}

View 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;
}
}
}

View File

@ -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;
}

View 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;
}
}

View File

@ -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");

View File

@ -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();
}
}

View File

@ -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) {

View File

@ -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;

View File

@ -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";

View File

@ -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);

View File

@ -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';

View File

@ -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) {

View File

@ -0,0 +1 @@
export * from "./providers"

View 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);

View File

@ -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 }), {});

View 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);

View File

@ -0,0 +1,2 @@
export * from "./departures"
export * from "./messages"

View 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);

View 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);

View 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);

View File

@ -0,0 +1,4 @@
export * from './switch';
export * from './icon';
export * from './numeric-input'
export * from './dialog'

View 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);

View 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);

View File

@ -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, '')
});

View File

@ -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;

View File

@ -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);

View File

@ -0,0 +1,4 @@
export interface Location {
lat: number,
lng: number,
}

View File

@ -3,3 +3,5 @@ export * from './departure'
export * from './line'
export * from './error'
export * from './identity'
export * from './common'
export * from './provider'

View 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;
}

View File

@ -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[];

View File

@ -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 {

View File

@ -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) {

View File

@ -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;

View File

@ -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');

View File

@ -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> = {

View 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;

View 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;

View File

@ -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))
}

View File

@ -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;

View File

@ -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"/>

View File

@ -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'));
}
}
}

View 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);
}
}

View File

@ -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");
}
}
}

View File

@ -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);
}
}
}

View File

@ -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));
}

View File

@ -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]);
}
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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;

View 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;
}
}

View 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;
}
}

Some files were not shown because too many files have changed in this diff Show More