add basic support for favourites
This commit is contained in:
parent
4221fab3d1
commit
f72c2ad15e
19
resources/components/favourites.html
Normal file
19
resources/components/favourites.html
Normal file
@ -0,0 +1,19 @@
|
||||
<div class="favourites">
|
||||
<ul class="list-underlined" v-if="favourites.length > 0">
|
||||
<li v-for="favourite in favourites" class="flex">
|
||||
<a href="#" @click="choose(favourite)" class="btn btn-action pl-0 flex-grow-1 text-left">
|
||||
<span class="icon">
|
||||
<fa :icon="['fal', 'star']"></fa>
|
||||
</span>
|
||||
<span class="text">{{ favourite.name }}</span>
|
||||
</a>
|
||||
<button class="btn btn-action" @click="remove(favourite)">
|
||||
<fa :icon="['fal', 'trash-alt']"></fa>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="alert alert-info" v-else>
|
||||
<fa :icon="['fal', 'info-circle']"></fa>
|
||||
Brak zapisanych zespołów przystanków
|
||||
</div>
|
||||
</div>
|
6
resources/components/favourites/save.html
Normal file
6
resources/components/favourites/save.html
Normal file
@ -0,0 +1,6 @@
|
||||
<div class="input-group">
|
||||
<input class="form-control form-control-sm" placeholder="nazwa" v-model="name"/>
|
||||
<button class="btn btn-sm btn-dark" @click="save">
|
||||
<fa :icon="['fal', 'check']"></fa>
|
||||
</button>
|
||||
</div>
|
@ -1,7 +1,7 @@
|
||||
<div class="finder">
|
||||
<input class="form-control" v-model="filter" placeholder="Zacznij pisać nazwę aby szukać..."/>
|
||||
|
||||
<div v-if="state === 'fetching'">
|
||||
<div v-if="state === 'fetching'" class="text-center p-4">
|
||||
<fa icon="spinner-third" pulse/>
|
||||
</div>
|
||||
<div class="finder__stops" v-else-if="filter.length > 2 && Object.keys(filtered).length > 0">
|
||||
|
@ -1,4 +1,4 @@
|
||||
<div class="popper" :class="{ 'popper--arrow': arrow, 'd-none': !show }" v-hover="hovered">
|
||||
<div class="popper" :class="{ 'popper--arrow': arrow, 'd-none': !show }" @focusin="focused = true" @focusout="focused = false" v-hover="hovered">
|
||||
<div class="popper__arrow" ref="arrow" v-if="arrow"></div>
|
||||
<lazy v-if="lazy" :activate="show">
|
||||
<slot></slot>
|
||||
|
38
resources/styles/_animations.scss
Normal file
38
resources/styles/_animations.scss
Normal file
@ -0,0 +1,38 @@
|
||||
@mixin vue-animation($name, $animation: .5s ease-in-out) {
|
||||
.#{$name}-enter-active {
|
||||
animation: #{$name}-in $animation;
|
||||
animation-fill-mode: backwards;
|
||||
}
|
||||
|
||||
.#{$name}-leave-active {
|
||||
animation: #{$name}-in $animation reverse;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
@keyframes #{$name}-in {
|
||||
@content
|
||||
}
|
||||
}
|
||||
|
||||
@include vue-animation(fade, 250ms ease-in-out) {
|
||||
0% {
|
||||
opacity: 0
|
||||
}
|
||||
100% {
|
||||
opacity: 1
|
||||
}
|
||||
}
|
||||
|
||||
.transition-box {
|
||||
@include clearfix;
|
||||
|
||||
> * {
|
||||
width: 100%;
|
||||
float: left;
|
||||
min-height: 2px;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-left: -100%;
|
||||
}
|
||||
}
|
||||
}
|
@ -87,7 +87,7 @@
|
||||
background: none;
|
||||
}
|
||||
|
||||
.btn {
|
||||
> .btn {
|
||||
margin-top: -.5rem;
|
||||
margin-bottom: -.5rem;
|
||||
}
|
||||
@ -97,3 +97,15 @@
|
||||
svg.svg-inline--fa {
|
||||
transform: rotate(360deg)
|
||||
}
|
||||
|
||||
.btn-unstyled {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.icon {
|
||||
padding: .5rem 0.75rem;
|
||||
}
|
@ -4,11 +4,17 @@
|
||||
|
||||
color: black;
|
||||
|
||||
&:hover, &:active, &:focus {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid rgba($blue, .2);
|
||||
}
|
||||
}
|
||||
|
||||
display: inline-block;
|
||||
|
||||
&.btn-outline-action {
|
||||
@extend .btn-outline-dark;
|
||||
}
|
||||
|
@ -113,6 +113,4 @@
|
||||
@include placement("top");
|
||||
@include placement("bottom");
|
||||
}
|
||||
|
||||
animation: ease-in fade-in 150ms
|
||||
}
|
||||
|
@ -43,6 +43,7 @@ $container-max-widths: map-merge($container-max-widths, ( xl: 1320px ));
|
||||
@import "line";
|
||||
@import "controls";
|
||||
@import "popper";
|
||||
@import "animations";
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
|
@ -25,11 +25,14 @@ Vue.use(Vuex);
|
||||
import('bootstrap'),
|
||||
]);
|
||||
|
||||
// here goes "public" API
|
||||
window['czydojade'] = Object.assign({
|
||||
state: {}
|
||||
}, window['czydojade'], {
|
||||
components,
|
||||
application: new components.Application({ el: '#app' })
|
||||
});
|
||||
|
||||
store.dispatch('messages/update');
|
||||
store.dispatch('load', window['czydojade'].state);
|
||||
|
||||
// here goes "public" API
|
||||
window['czydojade'] = Object.assign({}, window['czydojade'], {
|
||||
components, application: new components.Application({ el: '#app' })
|
||||
});
|
||||
})();
|
||||
|
@ -4,6 +4,7 @@ import { Component, Watch } from "vue-property-decorator";
|
||||
import { Mutation, Action } from 'vuex-class'
|
||||
import { ObtainPayload } from "../store/departures";
|
||||
import { Stop } from "../model";
|
||||
import { PopperComponent } from "./utils";
|
||||
|
||||
@Component({ store })
|
||||
export class Application extends Vue {
|
||||
@ -11,9 +12,11 @@ export class Application extends Vue {
|
||||
messages: true
|
||||
};
|
||||
|
||||
private settings = {
|
||||
private visibility = {
|
||||
messages: false,
|
||||
departures: false
|
||||
departures: false,
|
||||
save: false,
|
||||
picker: 'search'
|
||||
};
|
||||
|
||||
private autorefresh = {
|
||||
@ -55,23 +58,19 @@ export class Application extends Vue {
|
||||
this.$el.classList.remove('not-ready');
|
||||
}
|
||||
|
||||
@Action('messages/update') updateMessages: () => void;
|
||||
@Action('messages/update') updateMessages: () => void;
|
||||
@Action('departures/update') updateDepartures: (payload: ObtainPayload) => void;
|
||||
|
||||
@Mutation add: (stops: Stop[]) => void;
|
||||
@Mutation remove: (stop: Stop) => void;
|
||||
@Mutation clear: () => void;
|
||||
|
||||
save() {
|
||||
this.$store.dispatch('save').then(x => console.log(x));
|
||||
}
|
||||
|
||||
@Watch('stops')
|
||||
onStopUpdate(this: any, stops) {
|
||||
this.updateDepartures({ stops });
|
||||
}
|
||||
|
||||
@Watch('settings', { immediate: true, deep: true })
|
||||
@Watch('autorefresh', { immediate: true, deep: true })
|
||||
onAutorefreshUpdate(settings) {
|
||||
if (this.intervals.messages) {
|
||||
clearInterval(this.intervals.messages);
|
||||
|
39
resources/ts/components/favourites.ts
Normal file
39
resources/ts/components/favourites.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import Vue from 'vue'
|
||||
import { Component } from 'vue-property-decorator'
|
||||
import { namespace } from "vuex-class";
|
||||
import { Favourite } from "../store/favourites";
|
||||
import { SavedState } from "../store/root";
|
||||
|
||||
const { State, Mutation } = namespace('favourites');
|
||||
|
||||
@Component({ template: require('../../components/favourites.html' )})
|
||||
export class FavouritesComponent extends Vue {
|
||||
@State favourites: Favourite[];
|
||||
@Mutation remove: (fav: Favourite) => void;
|
||||
|
||||
choose(favourite: Favourite) {
|
||||
this.$store.dispatch('load', favourite.state);
|
||||
}
|
||||
}
|
||||
|
||||
@Component({ template: require('../../components/favourites/save.html' )})
|
||||
export class FavouritesAdderComponent extends Vue {
|
||||
private name = "";
|
||||
|
||||
@Mutation add: (fav: Favourite) => void;
|
||||
|
||||
async save() {
|
||||
const state = await this.$store.dispatch('save') as SavedState;
|
||||
const name = this.name;
|
||||
|
||||
const favourite: Favourite = { name, state };
|
||||
|
||||
this.add(favourite);
|
||||
this.name = '';
|
||||
|
||||
this.$emit('saved', favourite);
|
||||
}
|
||||
}
|
||||
|
||||
Vue.component('Favourites', FavouritesComponent);
|
||||
Vue.component('FavouritesAdder', FavouritesAdderComponent);
|
@ -5,4 +5,5 @@ export * from './departures'
|
||||
export * from './stop'
|
||||
export * from './messages'
|
||||
export * from './map'
|
||||
export * from './app'
|
||||
export * from './app'
|
||||
export * from './favourites'
|
||||
|
@ -20,11 +20,12 @@ export class PopperComponent extends Vue {
|
||||
public lazy: boolean;
|
||||
|
||||
public hovered: boolean = false;
|
||||
public focused: boolean = false;
|
||||
|
||||
private _popper;
|
||||
|
||||
get show() {
|
||||
return this.visible || this.hovered;
|
||||
return this.visible || this.hovered || this.focused;
|
||||
}
|
||||
|
||||
mounted() {
|
||||
|
@ -30,7 +30,7 @@ Vue.directive('hover', {
|
||||
el.addEventListener('click', activate);
|
||||
el.addEventListener('keydown', keyboard);
|
||||
el.addEventListener('mouseleave', deactivate);
|
||||
el.addEventListener('focusout', deactivate);
|
||||
// el.addEventListener('focusout', deactivate);
|
||||
},
|
||||
unbind(el, binding) {
|
||||
if (typeof binding['events'] !== 'undefined') {
|
||||
@ -40,7 +40,7 @@ Vue.directive('hover', {
|
||||
el.removeEventListener('click', activate);
|
||||
el.removeEventListener('keydown', keyboard);
|
||||
el.removeEventListener('mouseleave', deactivate);
|
||||
el.removeEventListener('focusout', deactivate);
|
||||
// el.removeEventListener('focusout', deactivate);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
37
resources/ts/store/favourites.ts
Normal file
37
resources/ts/store/favourites.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { RootState, SavedState } from "./root";
|
||||
import { Module, Plugin, Store } from "vuex";
|
||||
import * as utils from "../utils";
|
||||
|
||||
export interface Favourite {
|
||||
name: string;
|
||||
state: SavedState;
|
||||
}
|
||||
|
||||
export interface FavouritesState {
|
||||
favourites: Favourite[];
|
||||
}
|
||||
|
||||
const favourites: Module<FavouritesState, RootState> = {
|
||||
namespaced: true,
|
||||
state: {
|
||||
favourites: []
|
||||
},
|
||||
mutations: {
|
||||
add(state, favourite: Favourite) {
|
||||
state.favourites.push(favourite);
|
||||
},
|
||||
remove(state, favourite: Favourite) {
|
||||
state.favourites = state.favourites.filter(f => f != favourite);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
@ -1,10 +1,15 @@
|
||||
import Vuex from 'vuex';
|
||||
|
||||
import messages from './messages';
|
||||
import departures from './departures';
|
||||
import departures from './departures'
|
||||
import favourites, { localStorageSaver } from './favourites'
|
||||
|
||||
import { state, mutations, actions } from "./root";
|
||||
|
||||
export default new Vuex.Store({
|
||||
state, mutations, actions,
|
||||
modules: { messages, departures }
|
||||
modules: { messages, departures, favourites },
|
||||
plugins: [
|
||||
localStorageSaver('favourites.favourites', 'favourites'),
|
||||
]
|
||||
})
|
@ -17,9 +17,10 @@ export const state: RootState = {
|
||||
};
|
||||
|
||||
export const mutations: MutationTree<RootState> = {
|
||||
add: (state, stops) => state.stops = [...state.stops, ...ensureArray(stops)],
|
||||
remove: (state, stop) => state.stops = state.stops.filter(s => s != stop),
|
||||
clear: (state) => state.stops = [],
|
||||
add: (state, stops) => state.stops = [...state.stops, ...ensureArray(stops)],
|
||||
replace: (state, stops) => state.stops = stops,
|
||||
remove: (state, stop) => state.stops = state.stops.filter(s => s != stop),
|
||||
clear: (state) => state.stops = [],
|
||||
};
|
||||
|
||||
export const actions: ActionTree<RootState, undefined> = {
|
||||
@ -28,7 +29,7 @@ export const actions: ActionTree<RootState, undefined> = {
|
||||
const response = await fetch(urls.prepare(urls.stops.all, { id: stops }));
|
||||
|
||||
if (response.ok) {
|
||||
commit('updateStops', await response.json());
|
||||
commit('replace', await response.json());
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -5,6 +5,7 @@ namespace App\Controller;
|
||||
|
||||
use App\Provider\Provider;
|
||||
use App\Service\ProviderResolver;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
class MainController extends Controller
|
||||
@ -20,9 +21,15 @@ class MainController extends Controller
|
||||
/**
|
||||
* @Route("/{provider}", name="app")
|
||||
*/
|
||||
public function app(Provider $provider)
|
||||
public function app(Provider $provider, Request $request)
|
||||
{
|
||||
return $this->render('app.html.twig', ['provider' => $provider]);
|
||||
$state = json_decode($request->query->get('state', '{}'), true) ?: [];
|
||||
$state = array_merge([
|
||||
'version' => 1,
|
||||
'stops' => []
|
||||
], $state);
|
||||
|
||||
return $this->render('app.html.twig', compact('state', 'provider'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
18
src/Service/VersionExtension.php
Normal file
18
src/Service/VersionExtension.php
Normal file
@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFunction;
|
||||
|
||||
class VersionExtension extends AbstractExtension
|
||||
{
|
||||
public function getFunctions()
|
||||
{
|
||||
return [
|
||||
new TwigFunction('version', function () {
|
||||
return substr(`git rev-parse HEAD`, 0, 8) ?: '1.0-dev';
|
||||
})
|
||||
];
|
||||
}
|
||||
}
|
@ -11,17 +11,17 @@
|
||||
<fa :icon="['fal', 'bullhorn']" fixed-width class="mr-2"></fa>
|
||||
Komunikaty <span class="ml-2 badge badge-pill badge-dark">{{ '{{ messages.count }}' }}</span>
|
||||
</h2>
|
||||
<button class="btn btn-action flex-space-left" ref="settings-messages" v-hover="settings.messages">
|
||||
<button class="btn btn-action flex-space-left" ref="settings-messages" v-hover="visibility.messages">
|
||||
<fa :icon="['fal', 'cog']" fixed-width></fa>
|
||||
</button>
|
||||
<button class="btn btn-action" @click="updateMessages" ref="btn-messages-refresh">
|
||||
<fa :icon="['fal', 'sync']" :spin="messages.state === 'fetching'" fixed-width></fa>
|
||||
</button>
|
||||
<button class="btn btn-action" @click="sections.messages = !sections.messages" fixed-width>
|
||||
<button class="btn btn-action" @click="sections.messages = !sections.messages">
|
||||
<fa :icon="['fal', sections.messages ? 'chevron-up' : 'chevron-down']" fixed-width/>
|
||||
</button>
|
||||
|
||||
<popper reference="settings-messages" :visible="settings.messages" arrow placement="left-start">
|
||||
<popper reference="settings-messages" :visible="visibility.messages" arrow placement="left-start">
|
||||
<h3 class="popper__heading flex">
|
||||
<fa :icon="['far', 'cog']"></fa>
|
||||
<label class="text" for="messages-auto-refresh">autoodświeżanie</label>
|
||||
@ -47,14 +47,14 @@
|
||||
<span class="text">Odjazdy</span>
|
||||
</h2>
|
||||
|
||||
<button class="btn btn-action flex-space-left" ref="settings-departures" v-hover="settings.departures">
|
||||
<button class="btn btn-action flex-space-left" ref="settings-departures" v-hover="visibility.departures">
|
||||
<fa :icon="['fal', 'cog']" fixed-width></fa>
|
||||
</button>
|
||||
<button class="btn btn-action" @click="updateDepartures({ stops })">
|
||||
<fa :icon="['fal', 'sync']" :spin="departures.state === 'fetching'" fixed-width></fa>
|
||||
</button>
|
||||
|
||||
<popper reference="settings-departures" :visible="settings.departures" arrow placement="left-start">
|
||||
<popper reference="settings-departures" :visible="visibility.departures" arrow placement="left-start">
|
||||
<h3 class="popper__heading flex">
|
||||
<fa :icon="['far', 'sync']" fixed-width></fa>
|
||||
<label class="text" for="messages-auto-refresh">autoodświeżanie</label>
|
||||
@ -87,6 +87,17 @@
|
||||
<button class="btn btn-action flex-space-left" @click="clear">
|
||||
<fa :icon="['fal', 'trash-alt']" fixed-width></fa>
|
||||
</button>
|
||||
<button class="btn btn-action" @click="visibility.save = true" @focusout="visibility.save = false" ref="save">
|
||||
<fa :icon="['fal', 'star']" fixed-width></fa>
|
||||
</button>
|
||||
|
||||
<popper reference="save" :visible="visibility.save" arrow>
|
||||
<h3 class="popper__heading flex">
|
||||
<fa :icon="['far', 'star']" fixed-width></fa>
|
||||
<span class="text">Dodaj do ulubionych</span>
|
||||
</h3>
|
||||
<favourites-adder></favourites-adder>
|
||||
</popper>
|
||||
</header>
|
||||
|
||||
<ul class="picker__stops list-underlined">
|
||||
@ -99,11 +110,34 @@
|
||||
</ul>
|
||||
</section>
|
||||
<section class="section picker">
|
||||
<h2 class="section__title">
|
||||
<fa :icon="['fal', 'search']" fixed-width></fa>
|
||||
Wybierz przystanki
|
||||
</h2>
|
||||
<stop-finder @select="add" :blacklist="stops"/>
|
||||
<header class="section__title flex">
|
||||
<template v-if="visibility.picker === 'search'">
|
||||
<h2 class="flex-grow-1">
|
||||
<fa :icon="['fal', 'search']" fixed-width class="mr-1"></fa>
|
||||
Wybierz przystanki
|
||||
</h2>
|
||||
<button class="btn btn-action" @click="visibility.picker = 'favourites'">
|
||||
<fa :icon="['fal', 'star']" fixed-witdth></fa>
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<h2 class="flex-grow-1">
|
||||
<fa :icon="['fal', 'star']" fixed-width class="mr-1"></fa>
|
||||
Zapisane
|
||||
</h2>
|
||||
<button class="btn btn-action" @click="visibility.picker = 'search'">
|
||||
<fa :icon="['fal', 'search']" fixed-witdth></fa>
|
||||
</button>
|
||||
</template>
|
||||
</header>
|
||||
<div class="transition-box">
|
||||
<transition name="fade">
|
||||
<keep-alive>
|
||||
<stop-finder @select="add" :blacklist="stops" v-if="visibility.picker === 'search'"></stop-finder>
|
||||
<favourites v-else-if="visibility.picker === 'favourites'"></favourites>
|
||||
</keep-alive>
|
||||
</transition>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@ -113,6 +147,9 @@
|
||||
<script>
|
||||
window.data = {
|
||||
provider: {{ provider.identifier|json_encode|raw }}
|
||||
}
|
||||
};
|
||||
|
||||
window.czydojade = {};
|
||||
window.czydojade.state = {{ state|json_encode|raw }};
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
@ -26,6 +26,10 @@
|
||||
</main>
|
||||
<footer class="container">
|
||||
{% block footer %}
|
||||
<span>
|
||||
<img src="{{ asset('images/logo.png') }}" alt="czydojade logo"/>
|
||||
v. {{ version() }}
|
||||
</span>
|
||||
<span class="copyright flex flex-space-left">
|
||||
brought to you by
|
||||
<a href="https://kadet.net"><img src="{{ asset('images/kadet-net-logo.png') }}" alt="kadet.net logo" class="mx-1"/></a>
|
||||
@ -35,14 +39,6 @@
|
||||
</footer>
|
||||
|
||||
{% block javascripts %}{% endblock %}
|
||||
<script>
|
||||
window.czydojade = {
|
||||
state: {{ {
|
||||
version: 1,
|
||||
stops: app.request.query.get('stop', [])
|
||||
}|json_encode|raw }}
|
||||
};
|
||||
</script>
|
||||
<script src="{{ asset('bundle.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
Loading…
Reference in New Issue
Block a user