add basic support for favourites

This commit is contained in:
Kacper Donat 2018-10-07 20:18:14 +02:00
parent 4221fab3d1
commit f72c2ad15e
22 changed files with 273 additions and 49 deletions

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -113,6 +113,4 @@
@include placement("top");
@include placement("bottom");
}
animation: ease-in fade-in 150ms
}

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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