initial commit

This commit is contained in:
Kacper Donat 2018-09-02 19:23:38 +02:00
commit 4111a0cfd8
90 changed files with 9910 additions and 0 deletions

8
.env.dist Normal file
View File

@ -0,0 +1,8 @@
# This file is a "template" of which env vars need to be defined for your application
# 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
###> symfony/framework-bundle ###
APP_ENV=dev
APP_SECRET=1bdf86cdc78fba654e4f2c309c6bbdbd
###< symfony/framework-bundle ###

16
.gitignore vendored Normal file
View File

@ -0,0 +1,16 @@
###> symfony/framework-bundle ###
/.env
/public/bundles/
/var/
/vendor/
###< symfony/framework-bundle ###
###> symfony/web-server-bundle ###
/.web-server-pid
###< symfony/web-server-bundle ###
/node_modules/
/.idea/
/public/*
!/public/index.php

39
bin/console Executable file
View File

@ -0,0 +1,39 @@
#!/usr/bin/env php
<?php
use App\Kernel;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Debug\Debug;
use Symfony\Component\Dotenv\Dotenv;
set_time_limit(0);
require __DIR__.'/../vendor/autoload.php';
if (!class_exists(Application::class)) {
throw new \RuntimeException('You need to add "symfony/framework-bundle" as a Composer dependency.');
}
if (!isset($_SERVER['APP_ENV'])) {
if (!class_exists(Dotenv::class)) {
throw new \RuntimeException('APP_ENV environment variable is not defined. You need to define environment variables for configuration or add "symfony/dotenv" as a Composer dependency to load variables from a .env file.');
}
(new Dotenv())->load(__DIR__.'/../.env');
}
$input = new ArgvInput();
$env = $input->getParameterOption(['--env', '-e'], $_SERVER['APP_ENV'] ?? 'dev', true);
$debug = (bool) ($_SERVER['APP_DEBUG'] ?? ('prod' !== $env)) && !$input->hasParameterOption('--no-debug', true);
if ($debug) {
umask(0000);
if (class_exists(Debug::class)) {
Debug::enable();
}
}
$kernel = new Kernel($env, $debug);
$application = new Application($kernel);
$application->run($input);

76
composer.json Normal file
View File

@ -0,0 +1,76 @@
{
"name": "kadet/czydojade",
"type": "project",
"license": "MIT",
"require": {
"php": "^7.1.3",
"ext-ctype": "*",
"ext-iconv": "*",
"ext-json": "*",
"nesbot/carbon": "^1.33",
"sensio/framework-extra-bundle": "^5.2",
"symfony/console": "*",
"symfony/flex": "^1.1",
"symfony/framework-bundle": "*",
"symfony/serializer-pack": "^1.0",
"symfony/twig-bundle": "*",
"symfony/yaml": "*",
"tightenco/collect": "^5.6"
},
"require-dev": {
"symfony/dotenv": "*",
"symfony/web-server-bundle": "*",
"kadet/functional": "dev-master"
},
"config": {
"preferred-install": {
"*": "dist"
},
"sort-packages": true,
"secure-http": false
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"replace": {
"symfony/polyfill-ctype": "*",
"symfony/polyfill-iconv": "*",
"symfony/polyfill-php71": "*",
"symfony/polyfill-php70": "*",
"symfony/polyfill-php56": "*"
},
"scripts": {
"auto-scripts": {
"cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd"
},
"post-install-cmd": [
"@auto-scripts"
],
"post-update-cmd": [
"@auto-scripts"
]
},
"conflict": {
"symfony/symfony": "*"
},
"extra": {
"symfony": {
"allow-contrib": false,
"require": "4.1.*"
}
},
"repositories": [
{
"type": "vcs",
"url": "http://git.kadet.net/kadet/functional-php.git"
}
]
}

3167
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

8
config/bundles.php Normal file
View File

@ -0,0 +1,8 @@
<?php
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Symfony\Bundle\WebServerBundle\WebServerBundle::class => ['dev' => true],
Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle::class => ['all' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
];

View File

@ -0,0 +1,3 @@
framework:
router:
strict_requirements: true

View File

@ -0,0 +1,30 @@
framework:
secret: '%env(APP_SECRET)%'
#default_locale: en
#csrf_protection: true
#http_method_override: true
# Enables session support. Note that the session will ONLY be started if you read or write from it.
# Remove or comment this section to explicitly disable session support.
session:
handler_id: ~
#esi: true
#fragments: true
php_errors:
log: true
cache:
# Put the unique name of your app here: the prefix seed
# is used to compute stable namespaces for cache keys.
#prefix_seed: your_vendor_name/app_name
# The app cache caches to the filesystem by default.
# Other options include:
# Redis
#app: cache.adapter.redis
#default_redis_provider: redis://localhost
# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
#app: cache.adapter.apcu

View File

@ -0,0 +1,3 @@
framework:
router:
strict_requirements: ~

View File

@ -0,0 +1,3 @@
sensio_framework_extra:
router:
annotations: false

View File

@ -0,0 +1,4 @@
framework:
test: true
session:
storage_id: session.storage.mock_file

View File

@ -0,0 +1,7 @@
framework:
default_locale: '%locale%'
translator:
paths:
- '%kernel.project_dir%/translations'
fallbacks:
- '%locale%'

View File

@ -0,0 +1,4 @@
twig:
paths: ['%kernel.project_dir%/templates']
debug: '%kernel.debug%'
strict_variables: '%kernel.debug%'

3
config/routes.yaml Normal file
View File

@ -0,0 +1,3 @@
#index:
# path: /
# controller: App\Controller\DefaultController::index

View File

@ -0,0 +1,3 @@
controllers:
resource: ../../src/Controller/
type: annotation

View File

@ -0,0 +1,3 @@
_errors:
resource: '@TwigBundle/Resources/config/routing/errors.xml'
prefix: /_error

40
config/services.yaml Normal file
View File

@ -0,0 +1,40 @@
# This file is the entry point to configure your own services.
# Files in the packages/ subdirectory configure your dependencies.
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration
parameters:
locale: 'en'
services:
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
public: false # Allows optimizing the container by removing unused services; this also means
# fetching services directly from the container via $container->get() won't work.
# The best practice is to be explicit about your dependencies anyway.
# 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,Migrations,Tests,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
App\Controller\:
resource: '../src/Controller'
tags: ['controller.service_arguments']
App\Provider\:
resource: '../src/Provider'
public: true
serializer.datetime_normalizer:
class: Symfony\Component\Serializer\Normalizer\DateTimeNormalizer
arguments: [!php/const DateTime::ATOM]
tags: [serializer.normalizer]
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones

36
package.json Normal file
View File

@ -0,0 +1,36 @@
{
"name": "czydojade",
"version": "1.0.0",
"author": "Kacper Donat <kadet1090@gmail.com>",
"license": "MIT",
"devDependencies": {
"webpack": "^4.17.0",
"webpack-cli": "^3.1.0"
"@fortawesome/fontawesome-svg-core": "^1.2.4",
"@fortawesome/pro-light-svg-icons": "^5.3.1",
"@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/jquery": "^3.3.6",
"@types/moment": "^2.13.0",
"@types/popper.js": "^1.11.0",
"bootstrap": "^4.1.3",
"css-loader": "^1.0.0",
"file-loader": "^2.0.0",
"jquery": "^3.3.1",
"moment": "^2.22.2",
"node-sass": "^4.9.3",
"popper.js": "^1.14.4",
"raw-loader": "^0.5.1",
"sass-loader": "^7.1.0",
"style-loader": "^0.22.1",
"ts-loader": "^4.5.0",
"typescript": "^3.0.1",
"vue": "^2.5.17",
"vue-class-component": "^6.2.0",
"vue-property-decorator": "^7.0.0",
"xmldom": "^0.1.27",
"xpath": "^0.0.27"
}
}

39
public/index.php Normal file
View File

@ -0,0 +1,39 @@
<?php
use App\Kernel;
use Symfony\Component\Debug\Debug;
use Symfony\Component\Dotenv\Dotenv;
use Symfony\Component\HttpFoundation\Request;
require __DIR__.'/../vendor/autoload.php';
// The check is to ensure we don't use .env in production
if (!isset($_SERVER['APP_ENV'])) {
if (!class_exists(Dotenv::class)) {
throw new \RuntimeException('APP_ENV environment variable is not defined. You need to define environment variables for configuration or add "symfony/dotenv" as a Composer dependency to load variables from a .env file.');
}
(new Dotenv())->load(__DIR__.'/../.env');
}
$env = $_SERVER['APP_ENV'] ?? 'dev';
$debug = (bool) ($_SERVER['APP_DEBUG'] ?? ('prod' !== $env));
if ($debug) {
umask(0000);
Debug::enable();
}
if ($trustedProxies = $_SERVER['TRUSTED_PROXIES'] ?? false) {
Request::setTrustedProxies(explode(',', $trustedProxies), Request::HEADER_X_FORWARDED_ALL ^ Request::HEADER_X_FORWARDED_HOST);
}
if ($trustedHosts = $_SERVER['TRUSTED_HOSTS'] ?? false) {
Request::setTrustedHosts(explode(',', $trustedHosts));
}
$kernel = new Kernel($env, $debug);
$request = Request::createFromGlobals();
$response = $kernel->handle($request);
$response->send();
$kernel->terminate($request, $response);

View File

@ -0,0 +1,47 @@
<div class="departures">
<div class="departures__actions">
<div class="departures__auto-refresh">
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input" v-model="autoRefresh" :id="`autorefresh_${_uid}`">
<label class="custom-control-label" :for="`autorefresh_${_uid}`">auto odświeżanie:</label>
</div>
<div v-if="autoRefresh" class="d-flex align-items-center">
<input v-model="interval" class="form-control form-control-sm mx-1"/>
s
</div>
</div>
<button @click="update" class="flex-space-left btn btn-sm btn-outline-action">
<fa :icon="['far', 'sync']" /> odśwież
</button>
</div>
<ul class="departures__list list-underlined">
<li class="departure" v-for="departure in departures">
<div class="departure__line line">
<div class="line__symbol d-flex align-items-center">
<fa :icon="['fac', departure.line.type]" fixed-width class="mr-1"/>
{{ departure.line.symbol }}
</div>
<div class="line__display">{{ departure.display }}</div>
</div>
<div class="departure__time">
<span class="departure__scheduled" v-if="departure.scheduled.format('HH:mm') !== departure.estimated.format('HH:mm')">
{{ 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">
{{ departure.delay|signed }}s
</span>
<span class="departure__estimated">{{ departure.estimated.format('HH:mm') }}</span>
</div>
<stop :stop="departure.stop" class="departure__stop">
<template slot="actions">
<fa :icon="['fal', 'sign']" fixed-width class="mr-1"/>
</template>
</stop>
</li>
</ul>
</div>

View File

@ -0,0 +1,42 @@
<div class="finder">
<input class="form-control" v-model="filter" />
<div v-if="state === 'fetching'">
<fa icon="spinner-third" pulse/>
</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">
<div class="stop-group__header">
<button class="btn btn-action">
<fa :icon="['fal', 'chevron-down']"></fa>
</button>
<h3 class="stop-group__name">{{ name }}</h3>
<div class="actions flex-space-left">
<button class="btn btn-action" @click="group.forEach(select)">
<fa :icon="['fal', 'check-double']"></fa>
wybierz wszystkie
</button>
</div>
</div>
<ul class="stop-group__stops list-unstyled">
<li v-for="stop in group" :key="stop.id">
<stop :stop="stop">
<template slot="actions">
<button @click="select(stop, $event)" class="btn btn-action">
<fa :icon="['fal', 'check']" />
</button>
</template>
</stop>
</li>
</ul>
</div>
</div>
<div class="alert alert-warning" v-else-if="filter.length > 2">
Nie znaleziono więcej przystanków, spełniających te kryteria.
</div>
<div class="alert alert-info" v-else>
Wprowadź zapytanie powyżej, aby wyszukać przystanek.
</div>
</div>

View File

@ -0,0 +1,17 @@
<div class="picker">
<departures :stops="stops"/>
<ul class="picker__stops list-underlined">
<li v-for="stop in stops" :key="stop.id">
<stop :stop="stop">
<template slot="actions">
<button @click="remove(stop)" class="btn btn-action">
<fa :icon="['fal', 'times']" />
</button>
</template>
</stop>
</li>
</ul>
<stop-finder @select="add" :blacklist="stops"/>
</div>

View File

@ -0,0 +1,19 @@
<div class="stop">
<div class="stop__actions">
<slot name="actions"/>
</div>
<span class="stop__name">{{ stop.name }}</span>
<span class="stop__description badge badge-dark" v-if="stop.variant">{{ stop.variant }}</span>
<slot/>
<div class="stop__actions flex-space-left">
<button class="btn btn-action">
<fa :icon="['fal', 'info-circle']"/>
</button>
<button class="btn btn-action">
<fa :icon="['fal', 'map-marked-alt']"/>
</button>
</div>
</div>

View File

@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><title>bus</title><path d="M128,384a32,32,0,1,0-32-32A32,32,0,0,0,128,384Zm256,0a32,32,0,1,0-32-32A32,32,0,0,0,384,384ZM488,128h-8V80c0-44.8-99.2-80-224-80S32,35.2,32,80v48H24A24,24,0,0,0,0,152v80a24,24,0,0,0,24,24h8V416a32,32,0,0,0,32,32v32a32,32,0,0,0,32,32h48a32,32,0,0,0,32-32V448H336v32a32,32,0,0,0,32,32h48a32,32,0,0,0,32-32V448a32,32,0,0,0,32-32V256h8a24,24,0,0,0,24-24V152A24,24,0,0,0,488,128ZM144,480H96V448h48Zm272,0H368V448h48Zm32-64H64V288H448Zm0-160H64V128H448Zm0-160H64V80.31C67.31,67,131.41,32,256,32S444.69,67,448,80.31Z"/></svg>

After

Width:  |  Height:  |  Size: 640 B

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><title>metro</title><path d="M258,320a16,16,0,1,1-16,16,16,16,0,0,1,16-16m0-32a48,48,0,1,0,48,48A48,48,0,0,0,258,288ZM354,0H162C98,0,34,43,34,96V352c0,47.17,50.66,86.39,106.9,94.47L85.62,501.76A6,6,0,0,0,89.86,512h25.8a12,12,0,0,0,8.48-3.51L184.63,448H331.37l60.49,60.48a12,12,0,0,0,8.48,3.52h25.8a6,6,0,0,0,4.24-10.24L375.1,446.47C431.34,438.39,482,399.17,482,352V96C482,43,419,0,354,0ZM66,128H450v96H66Zm96-96H354c58.24,0,96,37.88,96,64H66C66,63.7,113.55,32,162,32ZM354,416H162c-48.45,0-96-31.7-96-64V256H450v96C450,384.3,402.45,416,354,416Z"/></svg>

After

Width:  |  Height:  |  Size: 647 B

View File

@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><title>train</title><path d="M144,384a48,48,0,1,0-48-48A48,48,0,0,0,144,384Zm0-64a16,16,0,1,1-16,16A16,16,0,0,1,144,320Zm224,64a48,48,0,1,0-48-48A48,48,0,0,0,368,384Zm0-64a16,16,0,1,1-16,16A16,16,0,0,1,368,320ZM352,0H160C96,0,32,43,32,96V352c0,47.17,50.66,86.39,106.9,94.47L83.62,501.76A6,6,0,0,0,87.86,512h25.8a12,12,0,0,0,8.48-3.51L182.63,448H329.37l60.49,60.48a12,12,0,0,0,8.48,3.52h25.8a6,6,0,0,0,4.24-10.24L373.1,446.47C429.34,438.39,480,399.17,480,352V96C480,43,417,0,352,0ZM64,128H240v96H64ZM448,352c0,32.3-47.55,64-96,64H160c-48.45,0-96-31.7-96-64V256H448Zm0-128H272V128H448ZM64,96c0-32.3,47.55-64,96-64H352c58.24,0,96,37.88,96,64Z"/></svg>

After

Width:  |  Height:  |  Size: 743 B

View File

@ -0,0 +1 @@
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><title>tram</title><path d="M352,384a32,32,0,1,0-32-32A32,32,0,0,0,352,384ZM456,128H445c-9.24-23.77-39.56-43.48-89.25-54.49l26.77-26.77a257.81,257.81,0,0,1,33.11,11.12,8.16,8.16,0,0,0,8.95-1.75l11.86-11.87a8.17,8.17,0,0,0-2.27-13.16C393.42,12.07,329.1,0,256,0S118.59,12.07,77.85,31.08a8.16,8.16,0,0,0-2.28,13.16L87.44,56.11a8.16,8.16,0,0,0,9,1.76,256.5,256.5,0,0,1,33.1-11.13l26.77,26.77c-49.69,11-80,30.72-89.25,54.49H56a24,24,0,0,0-24,24v80a24,24,0,0,0,24,24h8V416a32,32,0,0,0,32,32h41.37L83.62,501.76A6,6,0,0,0,87.86,512h25.8a12,12,0,0,0,8.48-3.51L182.63,448H329.37l60.49,60.48a12,12,0,0,0,8.48,3.52h25.8a6,6,0,0,0,4.24-10.24L374.63,448H416a32,32,0,0,0,32-32V256h8a24,24,0,0,0,24-24V152A24,24,0,0,0,456,128ZM256,32a551.75,551.75,0,0,1,89.21,6.79L316.83,67.17A547.06,547.06,0,0,0,256,64a547.06,547.06,0,0,0-60.83,3.17L166.79,38.79A551.75,551.75,0,0,1,256,32Zm0,64c87.19,0,129.08,17.14,147.52,32h-295C126.92,113.14,168.81,96,256,96ZM416,416H96V288H416Zm0-160H96V160H416ZM160,384a32,32,0,1,0-32-32A32,32,0,0,0,160,384Z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><title>trolleybus</title><path d="M128,384a32,32,0,1,0-32-32A32,32,0,0,0,128,384Zm256,0a32,32,0,1,0-32-32A32,32,0,0,0,384,384ZM488,160h-8V128C480,92.28,417,62.67,328.13,52.13L370,10.24A6,6,0,0,0,365.77,0H340a12,12,0,0,0-8.48,3.51L286.3,48.7Q271.45,48,256,48c-19.76,0-38.88.88-57.07,2.55l40.3-40.31A6,6,0,0,0,235,0h-25.8a12,12,0,0,0-8.48,3.51L146.19,58C77.75,71.58,32,97.58,32,128v32H24A24,24,0,0,0,0,184v48a24,24,0,0,0,24,24h8V416a32,32,0,0,0,32,32v32a32,32,0,0,0,32,32h48a32,32,0,0,0,32-32V448H336v32a32,32,0,0,0,32,32h48a32,32,0,0,0,32-32V448a32,32,0,0,0,32-32V256h8a24,24,0,0,0,24-24V184A24,24,0,0,0,488,160ZM256,80c123.65,0,187.72,34.47,191.94,48H64.06C68.28,114.47,132.35,80,256,80ZM144,480H96V448h48Zm272,0H368V448h48Zm32-64H64V288H448Zm0-160H64V160H448Z"/></svg>

After

Width:  |  Height:  |  Size: 864 B

View File

@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><title>unknown</title><path d="M488,128h-8V80c0-44.8-99.2-80-224-80S32,35.2,32,80v48H24A24,24,0,0,0,0,152v80a24,24,0,0,0,24,24h8V416a32,32,0,0,0,32,32v32a32,32,0,0,0,32,32h48a32,32,0,0,0,32-32V448H336v32a32,32,0,0,0,32,32h48a32,32,0,0,0,32-32V448a32,32,0,0,0,32-32V256h8a24,24,0,0,0,24-24V152A24,24,0,0,0,488,128ZM144,480H96V448h48Zm272,0H368V448h48ZM448,96h0v32h0l0,128h0v32h0V416H64V288h0V256h0V128h0V96h0V80.31C67.31,67,131.41,32,256,32S444.69,67,448,80.31ZM254.48,308a28,28,0,1,0,28,28A28,28,0,0,0,254.48,308Zm-.33-224c-44.82,0-70,17.51-91.23,44.74a12,12,0,0,0,2.64,17.18l13.14,9.15a12,12,0,0,0,16.24-2.38C209.89,133.65,225,124,254.15,124c39.68,0,57.44,20.2,57.44,40.21,0,43.79-77.44,37.07-77.44,107.41V272a12,12,0,0,0,12,12h16a12,12,0,0,0,12-12v-.38c0-43.56,77.44-40,77.44-107.41C351.59,113.75,306.47,84,254.15,84Z"/></svg>

After

Width:  |  Height:  |  Size: 923 B

View File

@ -0,0 +1,15 @@
.list-underlined {
@extend .list-unstyled;
li {
border-bottom: 1px solid $text-muted;
}
}
.flex-space-left {
margin-left: auto;
}
.flex-space-right {
margin-right: auto;
}

View File

@ -0,0 +1,11 @@
.btn {
&.btn-action {
@extend .btn-link;
color: black;
}
&.btn-outline-action {
@extend .btn-outline-dark;
}
}

View File

@ -0,0 +1,53 @@
.departure {
display: flex;
align-items: center;
flex-wrap: wrap;
.departure__line {
flex: 3 0;
}
.departure__stop {
width: 100%;
}
.departure__time {
width: 9rem;
text-align: right;
.departure__scheduled {
text-decoration: line-through;
}
}
}
.departures__actions {
display: flex;
align-items: center;
.departures__auto-refresh {
display: flex;
align-items: center;
}
.form-control {
width: auto;
}
}
@include media-breakpoint-up(lg) {
.departure__time {
order: 2;
}
.departure__stop {
flex: 2 0;
width: auto;
}
}
@include media-breakpoint-up(sm) {
.departure__time {
margin-left: auto;
}
}

View File

@ -0,0 +1,8 @@
.line {
display: flex;
align-items: center;
.line__symbol {
min-width: 4rem;
}
}

View File

@ -0,0 +1,23 @@
.stop, .stop-group__header {
display: flex;
align-items: center;
}
.stop__name {
}
.stop__description {
margin: 0 .5rem;
}
.stop__actions {
display: flex;
}
.stop-group__name {
font-size: $font-size-base;
font-weight: bold;
margin-bottom: 0;
}

View File

@ -0,0 +1,17 @@
$border-radius: 2px;
$border-radius-lg: $border-radius;
$border-radius-sm: $border-radius;
@import "~bootstrap/scss/functions";
@import "~bootstrap/scss/variables";
$custom-control-indicator-checked-bg: $dark;
$custom-control-indicator-active-bg: $dark;
@import "~bootstrap/scss/bootstrap";
@import "common";
@import "stop";
@import "departure";
@import "line";
@import "controls";

View File

@ -0,0 +1,13 @@
const dom = require('xmldom');
const xpath = require('xpath');
module.exports = function svgIconLoader(source) {
const parser = new dom.DOMParser();
const svg = parser.parseFromString(source, 'image/svg+xml');
const result = xpath.useNamespaces({
'svg': 'http://www.w3.org/2000/svg'
})('string(//svg:path/@d)', svg);
return result;
};

26
resources/ts/app.ts Normal file
View File

@ -0,0 +1,26 @@
/// <reference path="types/popper.js.d.ts"/>
/// <reference path="types/webpack.d.ts"/>
import '../styles/main.scss'
import './font-awesome'
import './filters'
import * as Popper from 'popper.js';
import * as $ from "jquery";
import * as components from './components';
window['$'] = window['jQuery'] = $;
window['popper'] = Popper;
// dependencies
import 'bootstrap'
import Vue from 'vue';
// here goes "public" API
window['czydojade'] = {
components
};
window['app'] = new Vue({ el: '#app' });

View File

@ -0,0 +1,55 @@
import Vue from 'vue'
import { Departure, Stop } from "../model";
import { Component, Prop, Watch } from "vue-property-decorator";
import urls from '../urls';
import template = require("../../components/departures.html");
import moment = require("moment");
import { Jsonified } from "../utils";
import { debounce } from "../decorators";
@Component({template})
export class Departures extends Vue {
private _intervalId: number;
autoRefresh: boolean = false;
departures: Departure[] = [];
interval: number = 20;
@Prop(Array)
stops: Stop[];
@Watch('stops')
@debounce(300)
async update() {
const response = await fetch(urls.prepare(urls.departures, {
stop: this.stops.map(stop => stop.id),
provider: 'gdansk'
}));
if (response.ok) {
const departures = await response.json() as Jsonified<Departure>[];
this.departures = departures.map(departure => {
departure.scheduled = moment.parseZone(departure.scheduled);
departure.estimated = moment.parseZone(departure.estimated);
return departure as Departure;
});
}
}
@Watch('interval')
@Watch('autoRefresh')
private setupAutoRefresh() {
if (this._intervalId) {
window.clearInterval(this._intervalId);
this._intervalId = undefined;
}
if (this.autoRefresh) {
this._intervalId = window.setInterval(() => this.update(), this.interval * 1000);
}
}
}
Vue.component('Departures', Departures);

View File

@ -0,0 +1,2 @@
export * from './picker'
export * from './departures'

View File

@ -0,0 +1,99 @@
import Component from "vue-class-component";
import Vue from "vue";
import { Stop, StopGroup, StopGroups } from "../model";
import urls from '../urls';
import picker = require("../../components/picker.html");
import finder = require('../../components/finder.html');
import stop = require('../../components/stop.html');
import { Prop, Watch } from "vue-property-decorator";
import { filter, map } from "../utils";
import { debounce, throttle } from "../decorators";
import { Departures } from "./departures";
@Component({ template: picker })
export class PickerComponent extends Vue {
protected stops?: Stop[] = [{
"id": 2001,
"name": "Dworzec Główny",
"description": null,
"location": [54.35544, 18.64565],
"variant": "01",
"onDemand": false
}, {
"id": 2002,
"name": "Dworzec Główny",
"description": null,
"location": [54.35541, 18.64548],
"variant": "02",
"onDemand": false
}];
private remove(stop: Stop) {
this.stops = this.stops.filter(s => s != stop);
}
private add(stop: Stop) {
this.stops.push(stop);
}
}
type FinderState = 'fetching' | 'ready' | 'error';
@Component({ template: finder })
export class FinderComponent extends Vue {
protected found?: StopGroups = {};
public state: FinderState = 'ready';
public filter: string = "";
@Prop({default: [], type: Array})
public blacklist: Stop[];
get filtered(): StopGroups {
const groups = map(
this.found,
(group: StopGroup, name: string) =>
group.filter(stop => !this.blacklist.some(blacklisted => blacklisted.id == stop.id))
) as StopGroups;
return filter(groups, group => group.length > 0);
}
@Watch('filter')
@debounce(400)
async fetch() {
if (this.filter.length < 3) {
return;
}
this.state = 'fetching';
const response = await fetch(urls.prepare(urls.stops.search, {
name: this.filter,
provider: 'gdansk'
}));
if (response.ok) {
this.found = await response.json();
this.state = 'ready';
} else {
this.state = 'error';
}
}
private select(stop) {
this.$emit('select', stop);
}
}
@Component({ template: stop })
export class StopComponent extends Vue {
@Prop(Object)
public stop: Stop;
}
Vue.component('StopPicker', PickerComponent);
Vue.component('StopFinder', FinderComponent);
Vue.component('Stop', StopComponent);

View File

@ -0,0 +1,45 @@
export interface Decorator<TArgs extends any[], FArgs extends any[], TRet, FRet> {
decorate(f: (...farg: FArgs) => FRet, ...args: TArgs): (...farg: FArgs) => TRet;
(...args: TArgs): (target, name: string | symbol, descriptor: TypedPropertyDescriptor<(...farg: FArgs) => FRet>) => void;
}
export function decorator<TArgs extends any[], FArgs extends any[], TRet, FRet>
(decorate: (f: (...fargs: FArgs) => FRet, ...args: TArgs) => (...fargs: FArgs) => TRet)
: Decorator<TArgs, FArgs, TRet, FRet> {
const factory = function (this: Decorator<TArgs, FArgs, TRet, FRet>, ...args: TArgs) {
return (target, name: string | symbol, descriptor: PropertyDescriptor) => {
descriptor.value = decorate(descriptor.value, ...args);
}
} as Decorator<TArgs, FArgs, TRet, FRet>;
factory.decorate = decorate;
return factory;
}
export const throttle = decorator(function (decorated, time: number) {
let timeout;
return function (this: any, ...args) {
if (typeof timeout === 'undefined') {
timeout = window.setTimeout(() => {
decorated.call(this, ...args);
timeout = undefined;
}, time);
}
}
});
export const debounce = decorator(function (decorated, time: number, max: number = time * 3) {
let timeout;
return function (this: any, ...args) {
if (typeof timeout !== 'undefined') {
window.clearTimeout(timeout);
}
timeout = window.setTimeout(() => {
timeout = undefined;
decorated.call(this, ...args);
}, time);
}
});

4
resources/ts/filters.ts Normal file
View File

@ -0,0 +1,4 @@
import { signed } from "./utils";
import Vue from 'vue';
Vue.filter('signed', signed);

View File

@ -0,0 +1,14 @@
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 } from '@fortawesome/vue-fontawesome'
library.add(far, fas, fal, fac);
Vue.component('fa', FontAwesomeIcon);

48
resources/ts/icons.ts Normal file
View File

@ -0,0 +1,48 @@
import { IconPack, IconDefinition } from '@fortawesome/fontawesome-svg-core';
import bus = require("../icons/light/bus.svg");
import tram = require("../icons/light/tram.svg");
import trolleybus = require("../icons/light/trolleybus.svg");
import metro = require("../icons/light/metro.svg");
import train = require("../icons/light/train.svg");
import unknown = require("../icons/light/unknown.svg");
export const faBus: IconDefinition = <any>{
prefix: 'fac',
iconName: 'bus',
icon: [ 512, 512, [], null, bus]
};
export const faTram = <any>{
prefix: 'fac',
iconName: 'tram',
icon: [ 512, 512, [], null, tram]
};
export const faTrain = <any>{
prefix: 'fac',
iconName: 'train',
icon: [ 512, 512, [], null, train]
};
export const faTrolleybus = <any>{
prefix: 'fac',
iconName: 'trolleybus',
icon: [ 512, 512, [], null, trolleybus]
};
export const faMetro = <any>{
prefix: 'fac',
iconName: 'metro',
icon: [ 512, 512, [], null, metro]
};
export const faUnknown = <any>{
prefix: 'fac',
iconName: 'unknown',
icon: [ 512, 512, [], null, unknown]
};
export const fac: IconPack = {
faBus, faTram, faTrain, faTrolleybus, faMetro, faUnknown
};

View File

@ -0,0 +1,14 @@
import { Stop } from "./stop";
import { Line } from "./line";
import { Moment } from "moment";
export interface Departure {
display: string;
estimated: Moment;
scheduled: Moment;
stop: Stop;
line: Line;
delay: number;
vehicle?: string;
}

View File

@ -0,0 +1,3 @@
export * from './stop'
export * from './departure'
export * from './line'

View File

@ -0,0 +1,8 @@
export type LineType = "tram" | "bus" | "trolleybus" | "train" | "other";
export interface Line {
id: any;
symbol: string;
variant?: string;
type: LineType;
}

View File

@ -0,0 +1,14 @@
export interface Stop {
id: any;
name: string;
description?: string;
location?: [ number, number ];
onDemand?: boolean;
variant?: string;
}
export type StopGroup = Stop[];
export type StopGroups = {
[name: string]: StopGroup;
}

131
resources/ts/types/popper.js.d.ts vendored Normal file
View File

@ -0,0 +1,131 @@
declare class Popper {
static modifiers: (Popper.BaseModifier & { name: string })[];
static placements: Popper.Placement[];
static Defaults: Popper.PopperOptions;
options: Popper.PopperOptions;
constructor(reference: Element | Popper.ReferenceObject, popper: Element, options?: Popper.PopperOptions);
destroy(): void;
update(): void;
scheduleUpdate(): void;
enableEventListeners(): void;
disableEventListeners(): void;
}
declare namespace Popper {
export type Position = 'top' | 'right' | 'bottom' | 'left';
export type Placement = 'auto-start'
| 'auto'
| 'auto-end'
| 'top-start'
| 'top'
| 'top-end'
| 'right-start'
| 'right'
| 'right-end'
| 'bottom-end'
| 'bottom'
| 'bottom-start'
| 'left-end'
| 'left'
| 'left-start';
export type Boundary = 'scrollParent' | 'viewport' | 'window';
export type Behavior = 'flip' | 'clockwise' | 'counterclockwise';
export type ModifierFn = (data: Data, options: Object) => Data;
export interface BaseModifier {
order?: number;
enabled?: boolean;
fn?: ModifierFn;
}
export interface Modifiers {
shift?: BaseModifier;
offset?: BaseModifier & {
offset?: number | string,
};
preventOverflow?: BaseModifier & {
priority?: Position[],
padding?: number,
boundariesElement?: Boundary | Element,
escapeWithReference?: boolean
};
keepTogether?: BaseModifier;
arrow?: BaseModifier & {
element?: string | Element,
};
flip?: BaseModifier & {
behavior?: Behavior | Position[],
padding?: number,
boundariesElement?: Boundary | Element,
};
inner?: BaseModifier;
hide?: BaseModifier;
applyStyle?: BaseModifier & {
onLoad?: Function,
gpuAcceleration?: boolean,
};
computeStyle?: BaseModifier & {
gpuAcceleration?: boolean;
x?: 'bottom' | 'top',
y?: 'left' | 'right'
};
[name: string]: (BaseModifier & Record<string, any>) | undefined;
}
export interface Offset {
top: number;
left: number;
width: number;
height: number;
}
export interface Data {
instance: Popper;
placement: Placement;
originalPlacement: Placement;
flipped: boolean;
hide: boolean;
arrowElement: Element;
styles: CSSStyleDeclaration;
boundaries: Object;
offsets: {
popper: Offset,
reference: Offset,
arrow: {
top: number,
left: number,
},
};
}
export interface PopperOptions {
placement?: Placement;
positionFixed?: boolean;
eventsEnabled?: boolean;
modifiers?: Modifiers;
removeOnDestroy?: boolean;
onCreate?(data: Data): void;
onUpdate?(data: Data): void;
}
export interface ReferenceObject {
clientHeight: number;
clientWidth: number;
getBoundingClientRect(): ClientRect;
}
}
declare module "popper.js" {
export = Popper;
}

9
resources/ts/types/webpack.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
declare module "*.html" {
const content: string;
export = content;
}
declare module "*.svg" {
const content: string;
export = content;
}

56
resources/ts/urls.ts Normal file
View File

@ -0,0 +1,56 @@
export type UrlParams = {
[name: string]: any
}
type ParamValuePair = [string, string];
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') {
yield [ name, param.toString() ];
} else if (param instanceof Array) {
for (let entry of param) {
yield* simplify(`${name}[]`, entry);
}
} else if (typeof param === "object") {
for (let [key, entry] of Object.entries(param)) {
yield* simplify(`${name}[${key}]`, entry);
}
}
}
let simplified: ParamValuePair[] = [];
for (const [key, entry] of Object.entries(params)) {
for (const pair of simplify(key, entry)) {
simplified.push(pair);
}
}
return Object.values(simplified).map(entry => entry.map(encodeURIComponent).join('=')).join('&');
}
export function prepare(url: string, params: UrlParams = { }) {
const regex = /\{([\w-]+)\}/gi;
let group;
while (group = regex.exec(url)) {
const name = group[1];
url = url.replace(new RegExp(`\{${name}\}`, 'gi'), params[name]);
delete params[name];
}
return Object.keys(params).length > 0 ? `${url}?${query(params)}` : url;
}
export default {
departures: '/{provider}/departures',
stops: {
all: '/{provider}/stops',
search: '/{provider}/stops/search',
get: '/{provider}/stop/{id}'
},
prepare
}

37
resources/ts/utils.ts Normal file
View File

@ -0,0 +1,37 @@
type Simplify<T, K = any> = string |
T extends string ? string :
T extends number ? number :
T extends boolean ? boolean :
T extends Array<K> ? Array<K> :
T extends Object ? Object : any;
export type Jsonified<T> = { [K in keyof T]: Simplify<T[K]> }
export type Optionalify<T> = { [K in keyof T]?: T[K] }
export type Index = string | symbol | number;
export function map<T extends {}, KT extends keyof T, R extends { [KR in keyof T] }>(source: T, mapper: (value: T[KT], key: KT) => R[KT]): R {
const result: R = {} as R;
for (const [key, value] of Object.entries(source)) {
result[key] = mapper(value as T[KT], key as KT);
}
return result;
}
export function filter<T, KT extends keyof T>(source: T, filter: (value: T[KT], key: KT) => boolean): Optionalify<T> {
const result: Optionalify<T> = {};
for (const [key, value] of Object.entries(source)) {
if (filter(value as T[KT], key as KT)) {
result[key] = value;
}
}
return result;
}
export function signed(number: number): string {
return number > 0 ? `+${number}` : number.toString();
}

0
src/Controller/.gitignore vendored Normal file
View File

View File

@ -0,0 +1,11 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\Controller as SymfonyController;
abstract class Controller extends SymfonyController
{
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Controller;
use App\Model\Departure;
use App\Provider\DepartureRepository;
use App\Provider\StopRepository;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
/**
* Class DeparturesController
*
* @Route("/{provider}/departures")
*/
class DeparturesController extends Controller
{
/**
* @Route("/{id}")
*/
public function stop(DepartureRepository $departures, StopRepository $stops, $id)
{
$stop = $stops->getById($id);
return $this->json($departures->getForStop($stop));
}
/**
* @Route("/")
*/
public function stops(DepartureRepository $departures, StopRepository $stops, Request $request)
{
$stops = collect($request->query->get('stop'))
->map([ $stops, 'getById' ])
->flatMap([ $departures, 'getForStop' ])
->sortBy(function (Departure $departure) {
return $departure->getEstimated();
});
return $this->json($stops->values());
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Controller;
use Symfony\Component\Routing\Annotation\Route;
class HomepageController extends Controller
{
/**
* @Route("/", name="home")
*/
public function homepage()
{
return $this->render('base.html.twig');
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Controller;
use App\Provider\StopRepository;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
/**
* Class StopsController
*
* @package App\Controller
* @Route("/{provider}/stops")
*/
class StopsController extends Controller
{
/**
* @Route("/", methods={"GET"})
*/
public function index(Request $request, StopRepository $stops)
{
$result = $request->query->has('id')
? $stops->getManyById($request->query->get('id'))
: $stops->getAllGroups();
return $this->json($result->all());
}
/**
* @Route("/search", methods={"GET"})
*/
public function find(Request $request, StopRepository $stops)
{
$result = $request->query->has('name')
? $stops->findGroupsByName($request->query->get('name'))
: $stops->getAllGroups();
return $this->json($result->all());
}
/**
* @Route("/{id}", methods={"GET"})
*/
public function one(Request $request, StopRepository $stops, $id)
{
return $this->json($stops->getById($id));
}
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Exception;
class NonExistentServiceException extends \Exception
{
}

61
src/Kernel.php Normal file
View File

@ -0,0 +1,61 @@
<?php
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\Config\Loader\LoaderInterface;
use Symfony\Component\Config\Resource\FileResource;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
use Symfony\Component\Routing\RouteCollectionBuilder;
class Kernel extends BaseKernel
{
use MicroKernelTrait;
const CONFIG_EXTS = '.{php,xml,yaml,yml}';
public function getCacheDir()
{
return $this->getProjectDir().'/var/cache/'.$this->environment;
}
public function getLogDir()
{
return $this->getProjectDir().'/var/log';
}
public function registerBundles()
{
$contents = require $this->getProjectDir().'/config/bundles.php';
foreach ($contents as $class => $envs) {
if (isset($envs['all']) || isset($envs[$this->environment])) {
yield new $class();
}
}
}
protected function configureContainer(ContainerBuilder $container, LoaderInterface $loader)
{
$container->addResource(new FileResource($this->getProjectDir().'/config/bundles.php'));
// Feel free to remove the "container.autowiring.strict_mode" parameter
// if you are using symfony/dependency-injection 4.0+ as it's the default behavior
$container->setParameter('container.autowiring.strict_mode', true);
$container->setParameter('container.dumper.inline_class_loader', true);
$confDir = $this->getProjectDir().'/config';
$loader->load($confDir.'/{packages}/*'.self::CONFIG_EXTS, 'glob');
$loader->load($confDir.'/{packages}/'.$this->environment.'/**/*'.self::CONFIG_EXTS, 'glob');
$loader->load($confDir.'/{services}'.self::CONFIG_EXTS, 'glob');
$loader->load($confDir.'/{services}_'.$this->environment.self::CONFIG_EXTS, 'glob');
}
protected function configureRoutes(RouteCollectionBuilder $routes)
{
$confDir = $this->getProjectDir().'/config';
$routes->import($confDir.'/{routes}/*'.self::CONFIG_EXTS, '/', 'glob');
$routes->import($confDir.'/{routes}/'.$this->environment.'/**/*'.self::CONFIG_EXTS, '/', 'glob');
$routes->import($confDir.'/{routes}'.self::CONFIG_EXTS, '/', 'glob');
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Model;
abstract class AbstractModel implements Fillable, Referable
{
use FillTrait;
private $id;
protected function setId($id)
{
$this->id = $id;
}
/**
* @return mixed
*/
public function getId()
{
return $this->id;
}
}

116
src/Model/Departure.php Normal file
View File

@ -0,0 +1,116 @@
<?php
namespace App\Model;
use Carbon\Carbon;
class Departure implements Fillable
{
use FillTrait;
/**
* Information about line
*
* @var \App\Model\Line
*/
private $line;
/**
* Information about stop
* @var \App\Model\Stop
*/
private $stop;
/**
* Vehicle identification
* @var string|null
*/
private $vehicle;
/**
* Displayed destination
* @var string|null
*/
private $display;
/**
* Estimated time of departure, null if case of no realtime data
* @var Carbon|null
*/
private $estimated;
/**
* Scheduled time of departure
* @var Carbon
*/
private $scheduled;
public function getLine(): Line
{
return $this->line;
}
public function setLine(Line $line): void
{
$this->line = $line;
}
public function getVehicle(): ?string
{
return $this->vehicle;
}
public function setVehicle(?string $vehicle): void
{
$this->vehicle = $vehicle;
}
public function getDisplay(): ?string
{
return $this->display;
}
public function setDisplay(?string $display): void
{
$this->display = $display;
}
public function getEstimated(): ?Carbon
{
return $this->estimated;
}
public function setEstimated(?Carbon $estimated): void
{
$this->estimated = $estimated;
}
public function getScheduled(): Carbon
{
return $this->scheduled;
}
public function setScheduled(Carbon $scheduled): void
{
$this->scheduled = $scheduled;
}
public function getStop(): Stop
{
return $this->stop;
}
public function setStop(Stop $stop): void
{
$this->stop = $stop;
}
public function getDelay(): ?int
{
return $this->getEstimated()
? $this->getScheduled()->diffInSeconds($this->getEstimated(), false)
: null;
}
}

34
src/Model/FillTrait.php Normal file
View File

@ -0,0 +1,34 @@
<?php
namespace App\Model;
trait FillTrait
{
public function fill(array $vars = [])
{
foreach ($vars as $name => $value) {
switch (true) {
case method_exists($this, $setter = 'set' . strtoupper($name)):
$this->{$setter}($value);
break;
case property_exists($this, $name) && (new \ReflectionProperty($this, $name))->isPublic():
$this->$name = $value;
break;
}
}
}
public static function createFromArray(array $vars = [], ...$args)
{
$object = empty($args)
? (new \ReflectionClass(static::class))->newInstanceWithoutConstructor()
: new static(...$args);
$object->fill($vars);
return $object;
}
}

11
src/Model/Fillable.php Normal file
View File

@ -0,0 +1,11 @@
<?php
namespace App\Model;
interface Fillable
{
public function fill(array $vars = []);
public static function createFromArray(array $vars = [], ...$args);
}

81
src/Model/Line.php Normal file
View File

@ -0,0 +1,81 @@
<?php
namespace App\Model;
class Line implements Fillable, Referable
{
use FillTrait, ReferenceTrait;
const TYPE_TRAM = 'tram';
const TYPE_BUS = 'bus';
const TYPE_TRAIN = 'train';
const TYPE_METRO = 'metro';
const TYPE_TROLLEYBUS = 'trolleybus';
const TYPE_UNKNOWN = 'unknown';
/**
* Some kind of identification for provider
* @var mixed
*/
private $id;
/**
* Line symbol, for example '10', or 'A'
* @var string
*/
private $symbol;
/**
* Line variant, for example 'a'
* @var string|null
*/
private $variant;
/**
* Line type tram, bus or whatever.
* @var string
*/
private $type;
public function getId()
{
return $this->id;
}
public function setId($id): void
{
$this->id = $id;
}
public function getSymbol(): string
{
return $this->symbol;
}
public function setSymbol(string $symbol): void
{
$this->symbol = $symbol;
}
public function getVariant(): ?string
{
return $this->variant;
}
public function setVariant(?string $variant): void
{
$this->variant = $variant;
}
public function getType(): string
{
return $this->type;
}
public function setType(string $type): void
{
$this->type = $type;
}
}

58
src/Model/Message.php Normal file
View File

@ -0,0 +1,58 @@
<?php
namespace App\Model;
class Message implements Fillable
{
use FillTrait;
const TYPE_INFO = 'info';
const TYPE_BREAKDOWN = 'breakdown';
const TYPE_UNKNOWN = 'unknown';
/**
* Message content.
* @var string
*/
public $message;
/**
* Message type, see TYPE_* constants
* @var
*/
public $type = self::TYPE_UNKNOWN;
/**
* @return string
*/
public function getMessage(): string
{
return $this->message;
}
/**
* @param string $message
*/
public function setMessage(string $message): void
{
$this->message = $message;
}
/**
* @return mixed
*/
public function getType()
{
return $this->type;
}
/**
* @param mixed $type
*/
public function setType($type): void
{
$this->type = $type;
}
}

10
src/Model/Referable.php Normal file
View File

@ -0,0 +1,10 @@
<?php
namespace App\Model;
interface Referable
{
public function getId();
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Model;
trait ReferenceTrait
{
abstract protected function setId($id);
public static function reference($id)
{
$reference = new static();
$reference->setId($id);
return $reference;
}
}

142
src/Model/Stop.php Normal file
View File

@ -0,0 +1,142 @@
<?php
namespace App\Model;
class Stop implements Fillable, Referable
{
use FillTrait, ReferenceTrait;
/**
* Some unique stop identification
* @var mixed
*/
private $id;
/**
* Stop name
* @var string
*/
private $name;
/**
* Optional stop description
* @var string|null
*/
private $description;
/**
* Optional stop variant - for example number of shed
* @var string|null
*/
private $variant;
/**
* Tuple of lat and long
* @var [float, float]
*/
private $location;
/**
* True if stop is available only on demand
* @var bool
*/
private $onDemand;
/**
* @return mixed
*/
public function getId()
{
return $this->id;
}
/**
* @param mixed $id
*/
public function setId($id): void
{
$this->id = $id;
}
/**
* @return string
*/
public function getName(): string
{
return $this->name;
}
/**
* @param string $name
*/
public function setName(string $name): void
{
$this->name = $name;
}
/**
* @return null|string
*/
public function getDescription(): ?string
{
return $this->description;
}
/**
* @param null|string $description
*/
public function setDescription(?string $description): void
{
$this->description = $description;
}
/**
* @return mixed
*/
public function getLocation()
{
return $this->location;
}
/**
* @param mixed $location
*/
public function setLocation($location): void
{
$this->location = $location;
}
/**
* @return null|string
*/
public function getVariant(): ?string
{
return $this->variant;
}
/**
* @param null|string $variant
*/
public function setVariant(?string $variant): void
{
$this->variant = $variant;
}
/**
* @return bool
*/
public function isOnDemand(): bool
{
return $this->onDemand;
}
/**
* @param bool $onDemand
*/
public function setOnDemand(bool $onDemand): void
{
$this->onDemand = $onDemand;
}
}

32
src/Model/StopGroup.php Normal file
View File

@ -0,0 +1,32 @@
<?php
namespace App\Model;
use Tightenco\Collect\Support\Collection;
class StopGroup extends Collection
{
/**
* Name of stop group
* @var string
*/
private $name;
/**
* @return string
*/
public function getName(): string
{
return $this->name;
}
/**
* @param string $name
*/
public function setName(string $name): void
{
$this->name = $name;
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Provider;
use App\Model\Stop;
use Tightenco\Collect\Support\Collection;
interface DepartureRepository extends Repository
{
public function getForStop(Stop $stop): Collection;
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Provider;
use App\Model\Line;
use Tightenco\Collect\Support\Collection;
interface LineRepository extends Repository
{
public function getAll(): Collection;
public function getById($id): ?Line;
public function getManyById($ids): Collection;
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Provider;
use App\Model\Stop;
use Tightenco\Collect\Support\Collection;
interface MessageRepository
{
public function getAll(): Collection;
public function getForStop(Stop $stop): Collection;
}

11
src/Provider/Provider.php Normal file
View File

@ -0,0 +1,11 @@
<?php
namespace App\Provider;
interface Provider
{
public function getDepartureRepository(): DepartureRepository;
public function getLineRepository(): LineRepository;
public function getStopRepository(): StopRepository;
public function getMessageRepository(): MessageRepository;
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Provider;
interface Repository
{
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Provider;
use App\Model\Stop;
use Tightenco\Collect\Support\Collection;
interface StopRepository extends Repository
{
public function getAllGroups(): Collection;
public function getById($id): ?Stop;
public function getManyById($ids): Collection;
public function findGroupsByName(string $name): Collection;
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Provider\ZtmGdansk;
use App\Model\Departure;
use App\Model\Line;
use App\Model\Stop;
use App\Provider\DepartureRepository;
use Carbon\Carbon;
use Tightenco\Collect\Support\Collection;
class ZtmGdanskDepartureRepository implements DepartureRepository
{
const ESTIMATES_URL = 'http://87.98.237.99:88/delays';
private $lines;
/**
* ZtmGdanskDepartureRepository constructor.
*
* @param $lines
*/
public function __construct(ZtmGdanskLineRepository $lines)
{
$this->lines = $lines;
}
public function getForStop(Stop $stop): Collection
{
try {
$estimates = json_decode(file_get_contents(static::ESTIMATES_URL . "?stopId=" . $stop->getId()), true)['delay'];
return collect($estimates)->map(function ($delay) use ($stop) {
$scheduled = new Carbon($delay['theoreticalTime']);
$estimated = (clone $scheduled)->addSeconds($delay['delayInSeconds']);
return Departure::createFromArray([
'scheduled' => $scheduled,
'estimated' => $estimated,
'stop' => $stop,
'display' => trim($delay['headsign']),
'vehicle' => $delay['vehicleCode'],
'line' => Line::createFromArray([
'id' => $delay['id'],
'symbol' => $delay['routeId'],
'type' => $delay['routeId'] > 1 && $delay['routeId'] <= 12 ? Line::TYPE_TRAM : Line::TYPE_BUS
])
]);
})->values();
} catch (\Throwable $error) {
return collect();
}
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Provider\ZtmGdansk;
use App\Model\Line;
use App\Provider\LineRepository;
use Tightenco\Collect\Support\Collection;
class ZtmGdanskLineRepository implements LineRepository
{
public function getAll(): Collection
{
// TODO: Implement getAll() method.
}
public function getById($id): Line
{
// TODO: Implement getById() method.
}
public function getManyById($ids): Collection
{
// TODO: Implement getManyById() method.
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Provider\ZtmGdansk;
use App\Model\Stop;
use App\Provider\MessageRepository;
use Tightenco\Collect\Support\Collection;
class ZtmGdanskMessageRepository implements MessageRepository
{
public function getAll(): Collection
{
return collect();
}
public function getForStop(Stop $stop): Collection
{
return collect();
}
}

View File

@ -0,0 +1,108 @@
<?php
namespace App\Provider\ZtmGdansk;
use App\Model\Stop;
use App\Model\StopGroup;
use App\Provider\StopRepository;
use Symfony\Component\Cache\Adapter\AdapterInterface;
use Tightenco\Collect\Support\Collection;
class ZtmGdanskStopRepository implements StopRepository
{
const STOPS_URL = 'http://91.244.248.19/dataset/c24aa637-3619-4dc2-a171-a23eec8f2172/resource/cd4c08b5-460e-40db-b920-ab9fc93c1a92/download/stops.json';
private $cache;
/**
* ZtmGdanskStopRepository constructor.
*/
public function __construct(AdapterInterface $cache)
{
$this->cache = $cache;
}
public function getAllGroups(): Collection
{
$stops = $this->getAllStops();
return $this->group($stops);
}
public function findGroupsByName(string $name): Collection
{
if (empty($name)) {
return collect();
}
$stops = $this->getAllStops();
$stops = $stops->filter(function (Stop $stop) use ($name) {
return stripos($stop->getName(), $name) !== false;
});
return $this->group($stops);
}
public function getAllStops(): Collection
{
static $stops = null;
if ($stops === null) {
$stops = collect($this->queryZtmApi())->map(function ($stop) {
return Stop::createFromArray([
'id' => $stop['stopId'],
'name' => trim($stop['stopName'] ?? $stop['stopDesc']),
'variant' => trim($stop['zoneName'] == 'Gdańsk' ? $stop['subName'] : null),
'location' => [$stop['stopLat'], $stop['stopLon']],
'onDemand' => (bool)$stop['onDemand'],
]);
})->keyBy(function (Stop $stop) {
return $stop->getId();
})->sort(function (Stop $a, Stop $b) {
return (int)$a->getVariant() <=> (int)$b->getVariant();
});
}
return $stops;
}
public function getById($id): ?Stop
{
return $this->getAllStops()->get($id);
}
public function getManyById($ids): Collection
{
$stops = $this->getAllStops();
return collect($ids)->mapWithKeys(function ($id) use ($stops) {
return [$id => $stops[$id]];
});
}
private function queryZtmApi()
{
$item = $this->cache->getItem('ztm-gdansk.stops');
if (!$item->isHit()) {
$stops = json_decode(file_get_contents(static::STOPS_URL), true);
$item->expiresAfter(24 * 60 * 60);
$item->set($stops[date('Y-m-d')]['stops']);
$this->cache->save($item);
}
return $item->get();
}
private function group(Collection $stops)
{
return $stops->groupBy(function (Stop $stop) {
return $stop->getName();
})->map(function ($group, $key) {
$group = new StopGroup($group);
$group->setName($key);
return $group;
});
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Provider;
use App\Provider\ZtmGdansk\{ZtmGdanskDepartureRepository,
ZtmGdanskLineRepository,
ZtmGdanskMessageRepository,
ZtmGdanskStopRepository};
class ZtmGdanskProvider implements Provider
{
private $lines;
private $departures;
private $stops;
private $messages;
public function __construct(
ZtmGdanskLineRepository $lines,
ZtmGdanskDepartureRepository $departures,
ZtmGdanskStopRepository $stops,
ZtmGdanskMessageRepository $messages
) {
$this->lines = $lines;
$this->departures = $departures;
$this->stops = $stops;
$this->messages = $messages;
}
public function getDepartureRepository(): DepartureRepository
{
return $this->departures;
}
public function getLineRepository(): LineRepository
{
return $this->lines;
}
public function getStopRepository(): StopRepository
{
return $this->stops;
}
public function getMessageRepository(): MessageRepository
{
return $this->messages;
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Service;
use App\Exception\NonExistentServiceException;
use App\Provider\Provider;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Sensio\Bundle\FrameworkExtraBundle\Request\ParamConverter\ParamConverterInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ProviderParameterConverter implements ParamConverterInterface
{
private $resolver;
/**
* ProviderParameterConverter constructor.
*
* @param $resolver
*/
public function __construct(ProviderResolver $resolver)
{
$this->resolver = $resolver;
}
public function apply(Request $request, ParamConverter $configuration)
{
$provider = $request->get('provider');
try {
$request->attributes->set('provider', $this->resolver->resolve($provider));
} catch (NonExistentServiceException $exception) {
throw new NotFoundHttpException("There is no such provider as '$provider'.", $exception);
}
}
public function supports(ParamConverter $configuration)
{
return $configuration->getName() === 'provider' && is_a($configuration->getClass(), Provider::class, true);
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Service;
use App\Exception\NonExistentServiceException;
use App\Provider\Provider;
use App\Provider\ZtmGdanskProvider;
class ProviderResolver
{
private const PROVIDER = [
'gdansk' => ZtmGdanskProvider::class
];
/**
* @var \Symfony\Component\DependencyInjection\ContainerInterface
*/
private $container;
/**
* ProviderResolver constructor.
*
* @param \Symfony\Component\DependencyInjection\ContainerInterface $container
*/
public function __construct(\Symfony\Component\DependencyInjection\ContainerInterface $container)
{
$this->container = $container;
}
/**\
* @param string $name
*
* @return \App\Provider\Provider
* @throws \App\Exception\NonExistentServiceException
*/
public function resolve(string $name): Provider
{
if (!array_key_exists($name, static::PROVIDER)) {
throw new NonExistentServiceException();
}
return $this->container->get(static::PROVIDER[$name]);
}
}

View File

@ -0,0 +1,77 @@
<?php
namespace App\Service;
use App\Exception\NonExistentServiceException;
use App\Provider\DepartureRepository;
use App\Provider\LineRepository;
use App\Provider\StopRepository;
use const Kadet\Functional\_;
use function Kadet\Functional\any;
use function Kadet\Functional\curry;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Sensio\Bundle\FrameworkExtraBundle\Request\ParamConverter\ParamConverterInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class RepositoryParameterConverter implements ParamConverterInterface
{
private $resolver;
/**
* ProviderParameterConverter constructor.
*
* @param $resolver
*/
public function __construct(ProviderResolver $resolver)
{
$this->resolver = $resolver;
}
public function apply(Request $request, ParamConverter $configuration)
{
if (!$request->attributes->has('provider')) {
return false;
}
$provider = $request->attributes->get('provider');
try {
$provider = $this->resolver->resolve($provider);
$class = $configuration->getClass();
switch (true) {
case is_a($class, StopRepository::class, true):
$request->attributes->set($configuration->getName(), $provider->getStopRepository());
break;
case is_a($class, LineRepository::class, true):
$request->attributes->set($configuration->getName(), $provider->getLineRepository());
break;
case is_a($class, DepartureRepository::class, true):
$request->attributes->set($configuration->getName(), $provider->getDepartureRepository());
break;
default:
return false;
}
return true;
} catch (NonExistentServiceException $exception) {
throw new NotFoundHttpException("There is no such provider as '$provider'.", $exception);
}
}
public function supports(ParamConverter $configuration)
{
$instance = curry('is_a', 3)(_, _, true);
return any(
$instance(StopRepository::class),
$instance(LineRepository::class),
$instance(DepartureRepository::class)
)($configuration->getClass());
}
}

206
symfony.lock Normal file
View File

@ -0,0 +1,206 @@
{
"doctrine/annotations": {
"version": "1.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "master",
"version": "1.0",
"ref": "cb4152ebcadbe620ea2261da1a1c5a9b8cea7672"
}
},
"doctrine/cache": {
"version": "v1.7.1"
},
"doctrine/collections": {
"version": "v1.5.0"
},
"doctrine/common": {
"version": "v2.9.0"
},
"doctrine/event-manager": {
"version": "v1.0.0"
},
"doctrine/inflector": {
"version": "v1.3.0"
},
"doctrine/lexer": {
"version": "v1.0.1"
},
"doctrine/persistence": {
"version": "v1.0.0"
},
"doctrine/reflection": {
"version": "v1.0.0"
},
"kadet/functional": {
"version": "dev-master"
},
"nesbot/carbon": {
"version": "1.33.0"
},
"phpdocumentor/reflection-common": {
"version": "1.0.1"
},
"phpdocumentor/reflection-docblock": {
"version": "4.3.0"
},
"phpdocumentor/type-resolver": {
"version": "0.4.0"
},
"psr/cache": {
"version": "1.0.1"
},
"psr/container": {
"version": "1.0.0"
},
"psr/log": {
"version": "1.0.2"
},
"psr/simple-cache": {
"version": "1.0.1"
},
"sensio/framework-extra-bundle": {
"version": "5.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "master",
"version": "5.2",
"ref": "fb7e19da7f013d0d422fa9bce16f5c510e27609b"
}
},
"symfony/cache": {
"version": "v4.1.3"
},
"symfony/config": {
"version": "v4.1.3"
},
"symfony/console": {
"version": "3.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "master",
"version": "3.3",
"ref": "e3868d2f4a5104f19f844fe551099a00c6562527"
}
},
"symfony/debug": {
"version": "v4.1.3"
},
"symfony/dependency-injection": {
"version": "v4.1.3"
},
"symfony/dotenv": {
"version": "v4.1.3"
},
"symfony/event-dispatcher": {
"version": "v4.1.3"
},
"symfony/filesystem": {
"version": "v4.1.3"
},
"symfony/finder": {
"version": "v4.1.3"
},
"symfony/flex": {
"version": "1.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "master",
"version": "1.0",
"ref": "e921bdbfe20cdefa3b82f379d1cd36df1bc8d115"
}
},
"symfony/framework-bundle": {
"version": "3.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "master",
"version": "3.3",
"ref": "87c585d24de9f43bca80ebcfd5cf5cb39445d95f"
}
},
"symfony/http-foundation": {
"version": "v4.1.3"
},
"symfony/http-kernel": {
"version": "v4.1.3"
},
"symfony/inflector": {
"version": "v4.1.3"
},
"symfony/polyfill-mbstring": {
"version": "v1.9.0"
},
"symfony/polyfill-php72": {
"version": "v1.9.0"
},
"symfony/process": {
"version": "v4.1.3"
},
"symfony/property-access": {
"version": "v4.1.3"
},
"symfony/property-info": {
"version": "v4.1.3"
},
"symfony/routing": {
"version": "4.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "master",
"version": "4.0",
"ref": "cda8b550123383d25827705d05a42acf6819fe4e"
}
},
"symfony/serializer": {
"version": "v4.1.3"
},
"symfony/serializer-pack": {
"version": "v1.0.1"
},
"symfony/translation": {
"version": "3.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "master",
"version": "3.3",
"ref": "6bcd6c570c017ea6ae5a7a6a027c929fd3542cd8"
}
},
"symfony/twig-bridge": {
"version": "v4.1.3"
},
"symfony/twig-bundle": {
"version": "3.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "master",
"version": "3.3",
"ref": "f75ac166398e107796ca94cc57fa1edaa06ec47f"
}
},
"symfony/var-dumper": {
"version": "v4.1.3"
},
"symfony/web-server-bundle": {
"version": "3.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "master",
"version": "3.3",
"ref": "dae9b39fd6717970be7601101ce5aa960bf53d9a"
}
},
"symfony/yaml": {
"version": "v4.1.3"
},
"tightenco/collect": {
"version": "v5.6.33"
},
"twig/twig": {
"version": "v2.5.0"
},
"webmozart/assert": {
"version": "1.3.0"
}
}

17
templates/base.html.twig Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{% block title %}Welcome!{% endblock %}</title>
{% block stylesheets %}{% endblock %}
</head>
<body>
{% block body %}
<main id="app" class="container">
<stop-picker></stop-picker>
</main>
{% endblock %}
<script src="bundle.js"></script>
{% block javascripts %}{% endblock %}
</body>
</html>

0
translations/.gitignore vendored Normal file
View File

12
tsconfig.json Normal file
View File

@ -0,0 +1,12 @@
{
"compilerOptions": {
"lib": ["dom", "es2015", "es2016.array.include", "es2017.object"],
"experimentalDecorators": true,
"target": "es5",
"sourceMap": true,
"noImplicitThis": true,
"moduleResolution": "node",
"downlevelIteration": true
},
"files": ["resources/ts/app.ts"]
}

57
webpack.config.js Normal file
View File

@ -0,0 +1,57 @@
const path = require('path');
const config = {
entry: {
main: ['./resources/ts/app.ts'],
},
output: {
path: path.resolve('./public/'),
filename: "bundle.js",
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
alias: {
'vue$': 'vue/dist/vue.esm.js'
}
},
module: {
rules: [{
test: /\.svg$/,
include: [
path.resolve('./resources/icons')
],
use: ['raw-loader', {
loader: path.resolve('./resources/svg-icon-loader.js')
}]
},{
test: /\.s[ac]ss$/,
use: ["style-loader", "css-loader?sourceMap", "sass-loader?sourceMap"]
}, {
test: /\.css$/,
use: ["style-loader", "css-loader"]
}, {
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/
}, {
test: /\.(png|svg|jpg|gif)$/,
use: 'file-loader',
exclude: [
path.resolve('./resources/icons')
]
}, {
test: /\.(woff|woff2|eot|ttf|otf)$/,
use: 'file-loader'
}, {
test: /\.html?$/,
use: 'raw-loader'
}]
},
};
module.exports = (env, argv) => {
if (argv.mode === 'development') {
config.devtool = 'inline-source-map';
}
return config;
};

3597
yarn.lock Normal file

File diff suppressed because it is too large Load Diff