Merge pull request #2 from Spythere/development

v1.1.0
This commit is contained in:
Spythere
2026-04-13 21:00:54 +02:00
committed by GitHub
45 changed files with 1496 additions and 6175 deletions
-17
View File
@@ -1,17 +0,0 @@
/* eslint-env node */
module.exports = {
root: true,
extends: [
"plugin:vue/vue3-essential",
"eslint:recommended",
"@vue/eslint-config-typescript",
"@vue/eslint-config-prettier/skip-formatting",
],
rules: {
"vue/multi-word-component-names": "off",
},
parserOptions: {
ecmaVersion: "latest",
},
};
@@ -0,0 +1,17 @@
name: Deploy to Firebase Hosting on merge
on:
push:
branches:
- main-old
jobs:
build_and_deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: yarn && yarn build
- uses: FirebaseExtended/action-hosting-deploy@v0
with:
repoToken: ${{ secrets.GITHUB_TOKEN }}
firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_PRAGOTRON_TD2 }}
channelId: live
projectId: pragotron-td2
@@ -0,0 +1,14 @@
name: Deploy to Firebase Hosting on PR
'on': pull_request
jobs:
build_and_preview:
if: '${{ github.event.pull_request.head.repo.full_name == github.repository }}'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- run: yarn && yarn build
- uses: FirebaseExtended/action-hosting-deploy@v0
with:
repoToken: '${{ secrets.GITHUB_TOKEN }}'
firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_PRAGOTRON_TD2 }}'
projectId: pragotron-td2
+23
View File
@@ -0,0 +1,23 @@
name: Build & Deploy to VPS
on:
push:
branches:
- main
env:
PROJECT_NAME: pragotron-td2
jobs:
build_and_deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Build the app
run: yarn && yarn build
- name: Setup SSH key for connection with the server
run: |
mkdir -p ~/.ssh
echo "${{ secrets.VPS_SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa && chmod 600 ~/.ssh/id_rsa
- name: Send new files
run: rsync -avP -e "ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_rsa -p 2022" ./dist/ ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:/var/www/$PROJECT_NAME --delete
-2
View File
@@ -13,8 +13,6 @@ dist-ssr
*.local *.local
# Editor directories and files # Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea .idea
.DS_Store .DS_Store
*.suo *.suo
View File
+9
View File
@@ -0,0 +1,9 @@
{
"recommendations": [
"Vue.volar",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"oxc.oxc-vscode",
"esbenp.prettier-vscode"
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"tsconfig.json": "tsconfig.*.json, env.d.ts, typed-router.d.ts",
"vite.config.*": "jsconfig*, vitest.config.*, cypress.config.*, playwright.config.*",
"package.json": "package-lock.json, pnpm*, .yarnrc*, yarn*, .eslint*, eslint*, .oxlint*, oxlint*, .oxfmt*, .prettier*, prettier*, .editorconfig"
},
"editor.codeActionsOnSave": {
"source.fixAll": "explicit"
},
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
+3 -22
View File
@@ -1,24 +1,5 @@
# pragotron-td2 # [PRAGOTRON TD2](https://pragotron-td2.spythere.eu/)
## Project setup A website with a replica of a Czechoslovakian station platform information display (commonly known as a "pragotron") showing active scenery's timetables in the Train Driver 2 simulator.
```
npm install
```
### Compiles and hot-reloads for development ## https://pragotron-td2.spythere.eu/
```
npm run serve
```
### Compiles and minifies for production
```
npm run build
```
### Lints and fixes files
```
npm run lint
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).
Vendored
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
-3923
View File
File diff suppressed because it is too large Load Diff
+17 -16
View File
@@ -1,33 +1,34 @@
{ {
"name": "pragotron-td2", "name": "pragotron-td2",
"private": true, "private": true,
"version": "1.0.1", "version": "1.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc --noEmit && vite build", "build": "vue-tsc --noEmit && vite build",
"preview": "vite preview", "preview": "yarn build && vite preview",
"deploy": "yarn build && firebase deploy --only hosting", "deploy": "yarn build && firebase deploy --only hosting",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore", "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/" "format": "prettier --write src/"
}, },
"dependencies": { "dependencies": {
"axios": "^1.6.2", "pinia": "^3.0.4",
"pinia": "^2.1.7", "sass": "^1.87.0",
"sass": "^1.69.5",
"vue": "^3.3.11", "vue": "^3.3.11",
"vue-router": "4.2.5" "vue-i18n": "11",
"vue-router": "5.0.4"
}, },
"devDependencies": { "devDependencies": {
"@types/vue-router": "^2.0.0", "@tsconfig/node24": "^24.0.4",
"@vitejs/plugin-vue": "^4.5.2", "@types/node": "^25.6.0",
"@vue/eslint-config-prettier": "^8.0.0", "@vitejs/plugin-vue": "^6.0.5",
"@vue/eslint-config-typescript": "^12.0.0", "@vue/tsconfig": "^0.9.1",
"eslint": "^8.55.0", "prettier": "3.8.2",
"eslint-plugin-vue": "^9.19.2", "typescript": "~6.0.0",
"prettier": "^3.1.1", "vite": "^8.0.3",
"typescript": "^5.3.3", "vue-tsc": "^3.2.6"
"vite": "^5.0.7", },
"vue-tsc": "^2.0.29" "engines": {
"node": "^20.19.0 || >=22.12.0"
} }
} }
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-globe-icon lucide-globe"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>

After

Width:  |  Height:  |  Size: 338 B

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 295 B

+21 -5
View File
@@ -3,7 +3,6 @@
<Navbar :version="version" /> <Navbar :version="version" />
<main> <main>
<!-- <button @click="testAudio">test audio</button> -->
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
<keep-alive> <keep-alive>
<component :is="Component" :key="$route.path"></component> <component :is="Component" :key="$route.path"></component>
@@ -19,7 +18,7 @@ import { defineComponent } from 'vue';
import packageInfo from '../package.json'; import packageInfo from '../package.json';
import { useApiStore } from './stores/apiStore'; import { useApiStore } from './stores/apiStore';
import Navbar from './components/Navbar.vue'; import Navbar from './components/Navbar.vue';
import { useMainStore } from './stores/mainStore'; import { useMainStore, type AppLocale } from './stores/mainStore';
export default defineComponent({ export default defineComponent({
components: { Navbar }, components: { Navbar },
@@ -41,6 +40,24 @@ export default defineComponent({
(this.mainStore.filters as any)[key] = settingsObj[key]; (this.mainStore.filters as any)[key] = settingsObj[key];
}); });
} }
},
loadLang() {
const storageLang = window.localStorage.getItem('language');
if (storageLang) {
this.mainStore.changeLocale(storageLang as AppLocale);
return;
}
if (!window.navigator.language) return;
const naviLanguage = window.navigator.language.toString();
if (!naviLanguage.startsWith('pl')) {
this.mainStore.changeLocale('en');
return;
}
} }
}, },
@@ -49,6 +66,7 @@ export default defineComponent({
this.apiStore.fetchActiveData(); this.apiStore.fetchActiveData();
this.loadLocalSettings(); this.loadLocalSettings();
this.loadLang();
}, },
mounted() { mounted() {
@@ -60,7 +78,7 @@ export default defineComponent({
</script> </script>
<style lang="scss"> <style lang="scss">
@import './styles.scss'; @use '@/styles/styles';
.app_content { .app_content {
text-align: center; text-align: center;
@@ -69,11 +87,9 @@ export default defineComponent({
grid-template-rows: auto 1fr; grid-template-rows: auto 1fr;
min-height: 100vh; min-height: 100vh;
overflow-x: hidden;
} }
main { main {
padding: 1em;
overflow-x: hidden; overflow-x: hidden;
} }
-125
View File
@@ -1,125 +0,0 @@
<template>
<div class="dropdown" v-click-outside="() => (store.optionsOpen = false)">
<button class="btn--image" @click="store.optionsOpen = !store.optionsOpen">
<img src="/options.svg" alt="options" />
</button>
<transition name="dropdown-anim">
<div class="dropdown-body" v-if="store.optionsOpen">
<h3>Opcje</h3>
<hr />
<div
style="
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5em;
margin: 0.5em 0;
"
>
<label>
<input type="checkbox" v-model="store.filters.nonPassenger" />
Relacje niepasażerskie
</label>
<label>
<input type="checkbox" v-model="store.filters.terminating" />
Relacje kończące bieg
</label>
<label>
<input type="checkbox" v-model="store.filters.soundsEnabled" />
Dźwięki
</label>
</div>
<div v-if="isPragotronOpen" style="margin: 0.5em 0">
<label for="checkpoint">
Posterunek:
<select id="checkpoint" v-model="store.selectedCheckpointName">
<option v-for="cp in store.selectedStation?.stationCheckpoints" :value="cp" :key="cp">
{{ cp }}
</option>
</select>
</label>
</div>
<div tabindex="0" @focus="() => (store.optionsOpen = false)"></div>
</div>
</transition>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { useMainStore } from '../stores/mainStore';
export default defineComponent({
data: () => ({
store: useMainStore()
}),
computed: {
isPragotronOpen() {
return this.$route.path == '/board';
}
},
watch: {
'store.filters': {
deep: true,
handler(filters: typeof this.store.filters) {
window.localStorage.setItem('settings', JSON.stringify(filters));
}
}
}
});
</script>
<style lang="scss" scoped>
img {
max-width: 2em;
}
h3 {
font-size: 1.2em;
margin: 0;
}
.dropdown-bg {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 100;
}
.dropdown-body {
position: absolute;
top: 100%;
right: 0;
padding: 0.25em;
transform: translateY(0.5em);
width: 500px;
max-width: calc(100% - 0.5em);
z-index: 105;
background-color: #000000e1;
}
.dropdown-anim {
&-enter-active,
&-leave-active {
transition: all 90ms ease-out;
}
&-enter-from,
&-leave-to {
transform: translateY(20px);
opacity: 0;
}
}
</style>
+102
View File
@@ -0,0 +1,102 @@
<template>
<div class="filters-dropdown">
<div class="wrapper">
<div class="checkboxes">
<label>
<input type="checkbox" v-model="mainStore.filters.nonPassenger" />
{{ $t('options.checkbox-non-passenger') }}
</label>
<label>
<input type="checkbox" v-model="mainStore.filters.terminating" />
{{ $t('options.checkbox-terminating') }}
</label>
<label>
<input type="checkbox" v-model="mainStore.filters.soundsEnabled" />
{{ $t('options.checkbox-sounds') }}
</label>
</div>
<hr />
<div class="selectors">
<label for="station" v-if="apiStore.activeData">
{{ $t('options.station-name') }}
<select id="station" v-model="mainStore.selectedStationName" @change="selectStation">
<option
v-for="scenery in sceneriesOnline"
:value="scenery.stationName"
:key="scenery.stationName"
>
{{ scenery.stationName }}
</option>
</select>
</label>
<label for="checkpoint">
{{ $t('options.checkpoint-name') }}
<select id="checkpoint" v-model="mainStore.selectedCheckpointName">
<option
v-for="checkpointName in mainStore.selectedStation?.stationCheckpoints"
:value="checkpointName"
:key="checkpointName"
>
{{ checkpointName }}
</option>
</select>
</label>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { useMainStore } from '../stores/mainStore';
import { useApiStore } from '../stores/apiStore';
const mainStore = useMainStore();
const apiStore = useApiStore();
const emits = defineEmits(['stationChanged']);
function selectStation() {
emits('stationChanged');
}
const sceneriesOnline = computed(() => {
if (!apiStore.activeData) return [];
return apiStore.activeData.activeSceneries
.filter((station) => {
return station.region == mainStore.region && station.isOnline;
}, [])
.sort((s1, s2) => s1.stationName.localeCompare(s2.stationName));
});
</script>
<style lang="scss" scoped>
.filters-dropdown {
position: absolute;
top: 100%;
right: 0;
width: 100%;
max-width: 400px;
height: auto;
z-index: 100;
}
.wrapper {
background-color: rgba(0, 0, 0, 0.95);
padding: 0.5em;
border-radius: 0.5em 0 0.5em 0.5em;
}
.checkboxes label,
.selectors label {
display: block;
text-align: left;
padding: 0.25em;
}
</style>
+41 -15
View File
@@ -1,12 +1,17 @@
<template> <template>
<nav class="navbar"> <nav class="navbar">
<div class="navbar-body"> <div class="navbar-body">
<router-link class="brand" to="/"> <router-link class="brand-link" to="/">
Pragotron TD2 <span class="text--accent">v{{ version }}</span> <sup>by Spythere</sup> <span>Pragotron TD2</span>
<span class="text--accent"> v{{ version }}</span>
<sup> by Spythere</sup>
</router-link> </router-link>
<div class="options"> <div class="lang-switcher">
<Dropdown /> <button @click="switchLanguage">
<img src="/icon-globe.svg" alt="globe icon" />
{{ store.locale.toUpperCase() == 'PL' ? 'POL' : 'ENG' }}
</button>
</div> </div>
</div> </div>
</nav> </nav>
@@ -15,10 +20,8 @@
<script lang="ts"> <script lang="ts">
import { defineComponent } from 'vue'; import { defineComponent } from 'vue';
import { useMainStore } from '../stores/mainStore'; import { useMainStore } from '../stores/mainStore';
import Dropdown from './Dropdown.vue';
export default defineComponent({ export default defineComponent({
components: { Dropdown },
props: { props: {
version: String version: String
}, },
@@ -26,21 +29,21 @@ export default defineComponent({
return { return {
store: useMainStore() store: useMainStore()
}; };
},
methods: {
switchLanguage() {
this.store.changeLocale(this.store.locale == 'pl' ? 'en' : 'pl');
}
} }
}); });
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import '../styles.scss'; @use '@/styles/theme';
nav.navbar { nav.navbar {
background-color: $accentBg; background-color: theme.$accentBg;
padding: 0 0.5em; padding: 0 0.5em;
sup {
font-size: 0.8em;
color: $dimmedText;
}
} }
.navbar-body { .navbar-body {
@@ -53,10 +56,33 @@ nav.navbar {
position: relative; position: relative;
margin: 0 auto; margin: 0 auto;
max-width: 1400px; max-width: 1500px;
font-weight: bold;
} }
.brand { .brand-link {
font-size: 1.25em; font-size: 1.25em;
sup {
font-size: 0.6em;
color: theme.$dimmedText;
}
}
.lang-switcher button {
display: flex;
align-items: center;
gap: 0.5em;
padding: 0.25em 0.5em;
border-radius: 0.5em;
color: white;
font-size: 1em;
img {
width: 1.25em;
}
} }
</style> </style>
-7
View File
@@ -1,7 +0,0 @@
import axios from 'axios';
const http = axios.create({
baseURL: 'https://stacjownik.spythere.eu'
});
export default http;
+20
View File
@@ -0,0 +1,20 @@
import enLang from './locales/en.json';
import plLang from './locales/pl.json';
import { createI18n } from 'vue-i18n';
const i18n = createI18n({
locale: 'pl',
legacy: false,
warnHtmlMessage: false,
fallbackLocale: 'pl',
messages: {
en: enLang,
pl: plLang
},
enableLegacy: false
});
export default i18n;
+22
View File
@@ -0,0 +1,22 @@
{
"home": {
"header": "Choose region and scenery to open the pragotron view",
"data-loading": "Loading active sceneries list...",
"no-available-data": "No active sceneries"
},
"pragotron": {
"header-1": "HOUR",
"header-2": "TRAIN",
"header-3": "VIA",
"header-4": "TERMINATING AT",
"header-5": "DELAYED"
},
"options": {
"btn-title": "OPTIONS",
"checkbox-non-passenger": "Non-passenger trains",
"checkbox-terminating": "Terminating trains",
"checkbox-sounds": "Sounds",
"checkpoint-name": "Checkpoint:",
"station-name": "Scenery:"
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"home": {
"header": "Wybierz region i scenerię, aby otworzyć widok pragotronu",
"data-loading": "Ładowanie listy aktywnych scenerii...",
"no-available-data": "Brak aktywnych scenerii"
},
"pragotron": {
"header-1": "GODZ.",
"header-2": "POCIĄG",
"header-3": "PRZEZ",
"header-4": "DO STACJI",
"header-5": "OPÓŹNIONY"
},
"options": {
"btn-title": "OPCJE",
"checkbox-non-passenger": "Relacje niepasażerskie",
"checkbox-terminating": "Relacje kończące bieg",
"checkbox-sounds": "Dźwięki",
"checkpoint-name": "Posterunek:",
"station-name": "Sceneria:"
}
}
+3 -1
View File
@@ -1,7 +1,8 @@
import { createApp, Directive } from 'vue'; import { createApp, type Directive } from 'vue';
import App from './App.vue'; import App from './App.vue';
import router from './router'; import router from './router';
import { createPinia } from 'pinia'; import { createPinia } from 'pinia';
import i18n from './i18n';
const pinia = createPinia(); const pinia = createPinia();
@@ -20,5 +21,6 @@ const clickOutsideDirective: Directive = {
createApp(App) createApp(App)
.use(router) .use(router)
.use(pinia) .use(pinia)
.use(i18n)
.directive('click-outside', clickOutsideDirective) .directive('click-outside', clickOutsideDirective)
.mount('#app'); .mount('#app');
+1 -1
View File
@@ -1,4 +1,4 @@
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'; import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
import HomeView from './views/HomeView.vue'; import HomeView from './views/HomeView.vue';
import PragotronView from './views/PragotronView.vue'; import PragotronView from './views/PragotronView.vue';
+9 -8
View File
@@ -1,6 +1,5 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { API } from '../typings/api'; import type { API } from '../typings/api';
import http from '../http';
export enum DataStatus { export enum DataStatus {
LOADING = 0, LOADING = 0,
@@ -11,6 +10,8 @@ export enum DataStatus {
export const useApiStore = defineStore('api', { export const useApiStore = defineStore('api', {
state() { state() {
return { return {
baseURL: 'https://stacjownik.spythere.eu',
activeData: undefined as API.ActiveData.Response | undefined, activeData: undefined as API.ActiveData.Response | undefined,
stationData: undefined as API.Sceneries.Response | undefined, stationData: undefined as API.Sceneries.Response | undefined,
@@ -24,11 +25,11 @@ export const useApiStore = defineStore('api', {
actions: { actions: {
async fetchActiveData() { async fetchActiveData() {
try { try {
const response = (await http.get<API.ActiveData.Response | undefined>('api/getActiveData')) const response = await fetch(`${this.baseURL}/api/getActiveData`);
.data; const responseData: API.ActiveData.Response = await response.json();
this.dataStatuses.activeData = DataStatus.LOADED; this.dataStatuses.activeData = DataStatus.LOADED;
this.activeData = response; this.activeData = responseData;
} catch (error) { } catch (error) {
this.dataStatuses.activeData = DataStatus.ERROR; this.dataStatuses.activeData = DataStatus.ERROR;
@@ -38,11 +39,11 @@ export const useApiStore = defineStore('api', {
async fetchSceneriesData() { async fetchSceneriesData() {
try { try {
const response = (await http.get<API.Sceneries.Response | undefined>('api/getSceneries')) const response = await fetch(`${this.baseURL}/api/getSceneries`);
.data; const responseData: API.Sceneries.Response = await response.json();
this.dataStatuses.stationData = DataStatus.LOADED; this.dataStatuses.stationData = DataStatus.LOADED;
this.stationData = response; this.stationData = responseData;
} catch (error) { } catch (error) {
this.dataStatuses.stationData = DataStatus.ERROR; this.dataStatuses.stationData = DataStatus.ERROR;
+14 -2
View File
@@ -1,6 +1,9 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import ISceneryData from '../types/ISceneryData';
import { useApiStore } from './apiStore'; import { useApiStore } from './apiStore';
import type ISceneryData from '../typings/common';
import i18n from '../i18n';
export type AppLocale = 'pl' | 'en';
export enum Region { export enum Region {
PL1 = 'eu', PL1 = 'eu',
@@ -30,10 +33,19 @@ export const useMainStore = defineStore('main', {
soundsEnabled: false soundsEnabled: false
}, },
selectedStationName: '', selectedStationName: '',
selectedCheckpointName: '' selectedCheckpointName: '',
locale: 'pl' as AppLocale
}; };
}, },
actions: {
changeLocale(locale: AppLocale) {
this.locale = locale;
window.localStorage.setItem('language', locale);
i18n.global.locale.value = locale;
}
},
getters: { getters: {
selectedStation(state): ISceneryData | undefined { selectedStation(state): ISceneryData | undefined {
const apiStore = useApiStore(); const apiStore = useApiStore();
+15
View File
@@ -0,0 +1,15 @@
@font-face {
font-display: swap;
font-family: 'Monda';
font-style: normal;
font-weight: 400;
src: url('/fonts/monda-regular.woff2') format('woff2');
}
@font-face {
font-display: swap;
font-family: 'Monda';
font-style: normal;
font-weight: 700;
src: url('/fonts/monda-700.woff2') format('woff2');
}
+11
View File
@@ -0,0 +1,11 @@
@mixin smallScreen() {
@media only screen and (max-width: 700px) {
@content;
}
}
@mixin midScreen() {
@media only screen and (max-width: 1400px) {
@content;
}
}
+15 -12
View File
@@ -1,16 +1,19 @@
@import url('https://fonts.googleapis.com/css2?family=Monda:wght@400;700&display=swap'); @use 'fonts';
@import 'theme.scss'; @use 'theme';
@use 'responsive';
body, body,
html { html {
background: $primaryBg; background: theme.$primaryBg;
min-height: 100vh; min-height: 100vh;
color: $primaryText; color: theme.$primaryText;
padding: 0; padding: 0;
margin: 0; margin: 0;
font-size: 16px; @include responsive.smallScreen {
font-size: calc(0.65rem + 0.85vw);
}
} }
*, *,
@@ -40,14 +43,14 @@ button {
font-size: 1em; font-size: 1em;
color: white; color: white;
background-color: #1b1b1b; background-color: #0e0e0e;
&:hover { &:hover {
background-color: #252525; background-color: #1a1a1a;
} }
&:focus-visible { &:focus-visible {
color: $accentText; color: theme.$accentText;
} }
&.btn--image { &.btn--image {
@@ -59,7 +62,7 @@ button {
} }
&:focus-visible { &:focus-visible {
outline: 1px solid $accentText; outline: 1px solid theme.$accentText;
} }
} }
} }
@@ -67,14 +70,14 @@ button {
// Input radio // Input radio
.g-selector { .g-selector {
label { label {
background-color: #202020; background-color: #0e0e0e;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
justify-content: center; justify-content: center;
&:hover { &:hover {
background-color: #2b2b2b; background-color: #1a1a1a;
} }
span { span {
@@ -89,7 +92,7 @@ button {
position: absolute; position: absolute;
&:checked + span { &:checked + span {
color: $accentText; color: theme.$accentText;
} }
&:focus-visible + span { &:focus-visible + span {
+1 -1
View File
@@ -2,7 +2,7 @@ $primaryText: white;
$accentText: gold; $accentText: gold;
$dimmedText: #ddd; $dimmedText: #ddd;
$primaryBg: #333; $primaryBg: #2b2b2b;
$secondaryBg: #aaa; $secondaryBg: #aaa;
$accentBg: #327ea5; $accentBg: #327ea5;
-24
View File
@@ -1,24 +0,0 @@
export interface IOnlineStation {
dispatcherId: number;
dispatcherName: string;
dispatcherIsSupporter: boolean;
stationName: string;
stationHash: string;
region: string;
maxUsers: number;
currentUsers: number;
spawn: number;
lastSeen: any;
dispatcherExp: number;
nameFromHeader: string;
spawnString: string;
networkConnectionString: string;
isOnline: number;
dispatcherRate: number;
}
export interface IOnlineStationsResponse {
success: boolean;
respCode: number;
message: IOnlineStation[];
}
-5
View File
@@ -1,5 +0,0 @@
export default interface ISceneryData {
stationName: string;
nameAbbreviation: string;
stationCheckpoints: string[];
}
-17
View File
@@ -1,17 +0,0 @@
export interface ISceneryResponse {
id: string;
name: string;
SUP: boolean;
authors: string;
availability: string;
backupJSON: string;
checkpoints: string;
controlType: string;
lines: string;
project: string;
reqLevel: number;
routes: string;
signalType: string;
supportersOnly?: boolean;
url: string;
}
-37
View File
@@ -1,37 +0,0 @@
export enum RowIndex {
HourLeading = 0,
HourSecondary = 1,
MinuteLeading = 2,
MinuteSecondary = 3,
TrainNumber = 4,
RouteVia = 5,
RouteTo = 6,
}
interface ITableRowValues {
routeTo: string;
routeVia: string;
dateDigits: string[];
trainNumber: string;
// routeTo, routeVia, date1, date2, date3, date4, trainNumber
currentRowIndexes: [number, number, number, number, number, number, number];
}
export interface ITableRow {
trainNumber: string;
timetableId: number;
routeTo: string;
routeVia: string;
checkpointName: string;
arrivalTimestamp: number;
departureTimestamp: number;
delayMinutes: number;
date?: Date;
dateDigits: string[];
tableValues: ITableRowValues;
}
-53
View File
@@ -1,53 +0,0 @@
export interface ITimetableStop {
stopName: string;
stopNameRAW: string;
stopType: string;
stopDistance: number;
pointId: string;
comments?: string;
mainStop: boolean;
arrivalLine: string | null;
arrivalTimestamp: number;
arrivalRealTimestamp: number;
arrivalDelay: number;
departureLine: string | null;
departureTimestamp: number;
departureRealTimestamp: number;
departureDelay: number;
beginsHere: boolean;
terminatesHere: boolean;
confirmed: number;
stopped: number;
stopTime?: number;
}
export interface ITrainTimetable {
SKR: number;
TWR: number;
category: string;
stopList: ITimetableStop[];
route: string;
timetableId: number;
sceneries: string[];
}
export interface ITrainResponse {
trainNo: number;
mass: number;
speed: number;
length: number;
distance: number;
stockString: string;
driverName: string;
driverId: number;
driverIsSupporter: boolean;
currentStationHash: string;
currentStationName: string;
signal: string;
connectedTrack: string;
online: number;
lastSeen: any;
region: string;
isTimeout: boolean;
timetable: ITrainTimetable;
}
+44
View File
@@ -0,0 +1,44 @@
export default interface ISceneryData {
stationName: string;
nameAbbreviation: string;
stationCheckpoints: string[];
}
/* Table Rows */
export interface ITableRow {
trainNumber: string;
timetableId: number;
routeTo: string;
routeVia: string;
checkpointName: string;
arrivalTimestamp: number;
departureTimestamp: number;
delayMinutes: number;
date?: Date;
dateDigits: string[];
tableValues: ITableRowValues;
}
export enum RowIndex {
HourLeading = 0,
HourSecondary = 1,
MinuteLeading = 2,
MinuteSecondary = 3,
TrainNumber = 4,
RouteVia = 5,
RouteTo = 6
}
interface ITableRowValues {
routeTo: string;
routeVia: string;
dateDigits: string[];
trainNumber: string;
// routeTo, routeVia, date1, date2, date3, date4, trainNumber
currentRowIndexes: [number, number, number, number, number, number, number];
}
+5 -6
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="home-view"> <div class="home-view">
<div> <div>
<h1 style="margin: 0">Wybierz region i scenerię, aby otworzyć widok pragotronu</h1> <h1 style="margin: 0">{{ $t('home.header') }}</h1>
<div class="region-selector g-selector"> <div class="region-selector g-selector">
<label v-for="region in regions" :key="region"> <label v-for="region in regions" :key="region">
@@ -17,14 +17,12 @@
</div> </div>
<div class="scenery-selector"> <div class="scenery-selector">
<!-- <p style="margin: 0.5em; color: #ccc">Widoczne jedynie scenerie aktywne na serwerze PL1</p> -->
<transition name="list-anim" tag="div" mode="out-in"> <transition name="list-anim" tag="div" mode="out-in">
<h3 v-if="apiStore.dataStatuses.activeData == DataStatus.LOADING"> <h3 v-if="apiStore.dataStatuses.activeData == DataStatus.LOADING">
Ładowanie listy aktywnych scenerii... {{ $t('home.data-loading') }}
</h3> </h3>
<h3 v-else-if="sceneriesOnline.length == 0">Brak aktywnych scenerii</h3> <h3 v-else-if="sceneriesOnline.length == 0">{{ $t('home.no-available-data') }}</h3>
<ul v-else class="scenery-list" :key="mainStore.region"> <ul v-else class="scenery-list" :key="mainStore.region">
<li v-for="station in sceneriesOnline" :key="station.stationName"> <li v-for="station in sceneriesOnline" :key="station.stationName">
@@ -90,7 +88,8 @@ export default defineComponent({
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
margin-top: 3em;
padding: 1em;
} }
.region-selector { .region-selector {
+113 -46
View File
@@ -1,17 +1,30 @@
<template> <template>
<div class="pragotron"> <div class="pragotron">
<div class="pragotron_content"> <div class="pragotron_content">
<div class="pragotron_options">
<div v-click-outside="() => (isFiltersDropdownOpen = false)">
<button class="options-btn" @click="isFiltersDropdownOpen = !isFiltersDropdownOpen">
<img src="/icon-options.svg" width="20" alt="" />
{{ $t('options.btn-title') }}
</button>
<transition name="filters-anim">
<filters-dropdown @stationChanged="selectStation()" v-if="isFiltersDropdownOpen" />
</transition>
</div>
</div>
<div class="wrapper" ref="pragotron"> <div class="wrapper" ref="pragotron">
<div class="top-pane"> <div class="top-pane">
<span class="title"> <span class="title">
<div>{{ mainStore.selectedCheckpointName.toUpperCase() }}</div> <div>{{ mainStore.selectedCheckpointName.toUpperCase() }}</div>
</span> </span>
<div class="headers"> <div class="headers">
<span>GODZ.</span> <span>{{ $t('pragotron.header-1') }}</span>
<span>POCIĄG</span> <span>{{ $t('pragotron.header-2') }}</span>
<span>PRZEZ</span> <span>{{ $t('pragotron.header-3') }}</span>
<span>DO STACJI</span> <span>{{ $t('pragotron.header-4') }}</span>
<span>OPÓŹNIONY</span> <span>{{ $t('pragotron.header-5') }}</span>
</div> </div>
</div> </div>
<div class="table"> <div class="table">
@@ -85,9 +98,10 @@
<script lang="ts"> <script lang="ts">
import { defineComponent } from 'vue'; import { defineComponent } from 'vue';
import { ITableRow, RowIndex } from '../types/ITableRow';
import { useMainStore } from '../stores/mainStore'; import { useMainStore } from '../stores/mainStore';
import { useApiStore } from '../stores/apiStore'; import { useApiStore } from '../stores/apiStore';
import { RowIndex, type ITableRow } from '../typings/common';
import FiltersDropdown from '../components/FiltersDropdown.vue';
const departureInfoEmptyObj: ITableRow = { const departureInfoEmptyObj: ITableRow = {
timetableId: -1, timetableId: -1,
@@ -117,6 +131,8 @@ const departureInfoEmptyObj: ITableRow = {
}; };
export default defineComponent({ export default defineComponent({
components: { FiltersDropdown },
props: { props: {
stationName: { stationName: {
type: String, type: String,
@@ -136,12 +152,16 @@ export default defineComponent({
includeNonPassenger: true, includeNonPassenger: true,
includeArrivals: true, includeArrivals: true,
isFiltersDropdownOpen: false,
isAnimationRunning: true, isAnimationRunning: true,
lastRefreshTime: 0, lastRefreshTime: 0,
animatingStatus: 'init' as 'init' | 'running' | 'complete', animatingStatus: 'init' as 'init' | 'running' | 'complete',
departureTable: new Array(7).fill(0).map(() => ({ ...departureInfoEmptyObj })) as ITableRow[], departureTable: Array.from({ length: 7 })
.fill(0)
.map(() => ({ ...departureInfoEmptyObj })) as ITableRow[],
departureRoutes: [''], departureRoutes: [''],
dateDigits: ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ''], dateDigits: ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ''],
trainNumbersSet: new Set<string>(['']), trainNumbersSet: new Set<string>(['']),
@@ -152,19 +172,9 @@ export default defineComponent({
currentRowAnimating: 0 currentRowAnimating: 0
}), }),
async created() {
// this.selectDefaultCheckpoint();
window.addEventListener('resize', () => {
this.resizeTable();
});
},
activated() { activated() {
this.mainStore.selectedStationName = this.stationName; this.mainStore.selectedStationName = this.stationName;
this.resizeTable();
this.selectDefaultCheckpoint(); this.selectDefaultCheckpoint();
this.shuffleRoutes(); this.shuffleRoutes();
@@ -232,6 +242,13 @@ export default defineComponent({
} }
}, },
'mainStore.filters': {
deep: true,
handler: function (val) {
window.localStorage.setItem('settings', JSON.stringify(val));
}
},
'apiStore.activeData'(_val, prevVal) { 'apiStore.activeData'(_val, prevVal) {
if (prevVal == undefined) { if (prevVal == undefined) {
this.selectDefaultCheckpoint(); this.selectDefaultCheckpoint();
@@ -308,7 +325,6 @@ export default defineComponent({
if (!this.departureRoutes.includes(routeVia)) this.departureRoutes.push(routeVia); if (!this.departureRoutes.includes(routeVia)) this.departureRoutes.push(routeVia);
if (!this.departureRoutes.includes(routeTo)) this.departureRoutes.push(routeTo); if (!this.departureRoutes.includes(routeTo)) this.departureRoutes.push(routeTo);
this.trainNumbersSet.add(`${timetable.category} ${train.trainNo}`); this.trainNumbersSet.add(`${timetable.category} ${train.trainNo}`);
return list; return list;
@@ -325,24 +341,25 @@ export default defineComponent({
}, },
methods: { methods: {
resizeTable() {
const elRef = this.$refs['pragotron'] as HTMLElement;
if (!elRef) return;
const scale = Math.min(
window.innerWidth / elRef.clientWidth,
window.innerHeight / elRef.clientHeight,
1
);
elRef.style.transform = `scale(${scale})`;
},
selectDefaultCheckpoint() { selectDefaultCheckpoint() {
this.mainStore.selectedCheckpointName = this.mainStore.selectedCheckpointName =
this.mainStore.selectedStation?.stationCheckpoints[0] || this.stationName; this.mainStore.selectedStation?.stationCheckpoints[0] || this.stationName;
}, },
selectStation() {
console.log('xd');
this.$router.push({
path: '/board',
query: {
name: this.mainStore.selectedStationName,
region: this.mainStore.region
}
});
this.selectDefaultCheckpoint();
this.shuffleRoutes();
},
abbrevStationName(name: string) { abbrevStationName(name: string) {
return name.toUpperCase(); return name.toUpperCase();
}, },
@@ -398,10 +415,13 @@ export default defineComponent({
}); });
if (dep.trainNumber != dep.tableValues.trainNumber) { if (dep.trainNumber != dep.tableValues.trainNumber) {
dep.tableValues.trainNumber = Array.from(this.trainNumbersSet)[dep.tableValues.currentRowIndexes[RowIndex.TrainNumber]] dep.tableValues.trainNumber = Array.from(this.trainNumbersSet)[
dep.tableValues.currentRowIndexes[RowIndex.TrainNumber]
];
dep.tableValues.currentRowIndexes[RowIndex.TrainNumber] = dep.tableValues.currentRowIndexes[RowIndex.TrainNumber] =
(dep.tableValues.currentRowIndexes[RowIndex.TrainNumber] + 1) % this.trainNumbersSet.size; (dep.tableValues.currentRowIndexes[RowIndex.TrainNumber] + 1) %
this.trainNumbersSet.size;
isCurrentTickAnimating = true; isCurrentTickAnimating = true;
} }
@@ -444,36 +464,83 @@ export default defineComponent({
} }
} }
.filters-anim {
&-enter-active,
&-leave-active {
transition: all 100ms ease-in-out;
}
&-enter-from,
&-leave-to {
opacity: 0;
transform: translateY(15px);
}
}
/* ************** */ /* ************** */
.pragotron_content { .pragotron {
display: flex; display: flex;
justify-content: center; justify-content: center;
padding: 1em; padding: 1em;
} }
.pragotron_options {
position: relative;
display: flex;
justify-content: flex-end;
width: 100%;
}
.options-btn {
display: flex;
align-items: center;
gap: 0.25em;
font-weight: bold;
padding: 0.25em 0.5em;
border-radius: 0.5em 0.5em 0 0;
}
.pragotron_content {
display: flex;
justify-content: center;
align-items: flex-start;
flex-direction: column;
width: 100%;
max-width: 1500px;
}
.wrapper { .wrapper {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 1400px;
min-height: 700px;
padding: 2em;
transform-origin: top; width: 100%;
min-height: 50em;
overflow: auto;
@media only screen and (max-width: 1500px) {
font-size: calc(0.3em + 0.65vw);
}
@media only screen and (max-width: 800px) {
font-size: 8px;
}
} }
.top-pane > .headers, .top-pane > .headers,
.row-content { .row-content {
display: grid; display: grid;
grid-template-columns: 1fr 1fr 2fr 2fr 1fr; grid-template-columns: 1fr 1fr 2fr 2fr 1fr;
gap: 0 10px; gap: 0 1em;
padding: 0 10px; padding: 0 1em;
} }
.top-pane { .top-pane {
background-color: white; background-color: white;
color: black; color: black;
height: 180px; height: 12em;
min-width: 700px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -481,24 +548,24 @@ export default defineComponent({
.title { .title {
padding: 0; padding: 0;
font-size: 3.5em; font-size: 3.5em;
} }
.headers { .headers {
text-align: center; text-align: center;
font-size: 1.35em; font-size: 1.35em;
} }
} }
.table { .table {
background: white;
flex-grow: 1; flex-grow: 1;
display: grid; display: grid;
grid-template-rows: repeat(7, 1fr); grid-template-rows: repeat(7, 1fr);
gap: 5px 0; gap: 5px 0;
background: white;
min-width: 700px;
} }
.row { .row {
@@ -519,10 +586,10 @@ export default defineComponent({
} }
.departure-date { .departure-date {
background: black; background: #010101;
span { span {
background: black; background: #010101;
height: 2em; height: 2em;
line-height: 2em; line-height: 2em;
flex-grow: 2; flex-grow: 2;
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
// Extra safety for array and object lookups, but may have false positives.
"noUncheckedIndexedAccess": false,
// Path mapping for cleaner imports.
"paths": {
"@/*": ["./src/*"]
},
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
}
}
+9 -16
View File
@@ -1,18 +1,11 @@
{ {
"compilerOptions": { "files": [],
"target": "ESNext", "references": [
"useDefineForClassFields": true, {
"module": "ESNext", "path": "./tsconfig.node.json"
"moduleResolution": "Node", },
"strict": true, {
"jsx": "preserve", "path": "./tsconfig.app.json"
"sourceMap": true, }
"resolveJsonModule": true, ]
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ESNext", "DOM"],
"skipLibCheck": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
} }
+10 -7
View File
@@ -1,9 +1,12 @@
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
{ {
"extends": "@tsconfig/node24/tsconfig.json",
"include": ["vite.config.*"],
"compilerOptions": { "compilerOptions": {
"composite": true, "module": "preserve",
"module": "ESNext", "moduleResolution": "bundler",
"moduleResolution": "Node", "types": ["node"],
"allowSyntheticDefaultImports": true "noEmit": true,
}, "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
"include": ["vite.config.ts"] }
} }
+17 -5
View File
@@ -1,7 +1,19 @@
import { defineConfig } from 'vite' import { fileURLToPath, URL } from 'node:url';
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/ import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
// https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [vue()] plugins: [vue()],
})
preview: {
port: 4000
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
});
+885 -1802
View File
File diff suppressed because it is too large Load Diff