Compare commits

..

No commits in common. "main" and "@flaschengeist/api-v1.0.0-alpha.2" have entirely different histories.

105 changed files with 11211 additions and 1111 deletions

View File

@ -17,11 +17,11 @@ module.exports = {
project: resolve(__dirname, './tsconfig.json'), project: resolve(__dirname, './tsconfig.json'),
tsconfigRootDir: __dirname, tsconfigRootDir: __dirname,
ecmaVersion: 2019, // Allows for the parsing of modern ECMAScript features ecmaVersion: 2019, // Allows for the parsing of modern ECMAScript features
sourceType: 'module', // Allows for the use of imports sourceType: 'module' // Allows for the use of imports
}, },
env: { env: {
browser: true, browser: true
}, },
// Rules order is important, please avoid shuffling them // Rules order is important, please avoid shuffling them
@ -44,7 +44,7 @@ module.exports = {
// https://github.com/prettier/eslint-config-prettier#installation // https://github.com/prettier/eslint-config-prettier#installation
// usage with Prettier, provided by 'eslint-config-prettier'. // usage with Prettier, provided by 'eslint-config-prettier'.
'plugin:prettier/recommended', 'prettier', //'plugin:prettier/recommended'
], ],
plugins: [ plugins: [
@ -54,6 +54,10 @@ module.exports = {
// https://eslint.vuejs.org/user-guide/#why-doesn-t-it-work-on-vue-file // https://eslint.vuejs.org/user-guide/#why-doesn-t-it-work-on-vue-file
// required to lint *.vue files // required to lint *.vue files
'vue', 'vue',
// https://github.com/typescript-eslint/typescript-eslint/issues/389#issuecomment-509292674
// Prettier has not been included as plugin to avoid performance impact
// add it as an extension for your IDE
], ],
globals: { globals: {
@ -66,26 +70,23 @@ module.exports = {
__QUASAR_SSR_PWA__: true, __QUASAR_SSR_PWA__: true,
process: true, process: true,
Capacitor: true, Capacitor: true,
chrome: true, chrome: true
}, },
// add your custom rules here // add your custom rules here
rules: { rules: {
// VueStuff // VueStuff
// Defaults to error on eslint-plugin-vue 8.0.3, but let us be not too strict with names
'vue/multi-word-component-names': 'off', 'vue/multi-word-component-names': 'off',
// Rejects on promises should always be of the Error type (and allow empty rejects as well) // Misc
'prefer-promise-reject-errors': ['error', { allowEmptyReject: true }], 'prefer-promise-reject-errors': ["error", {"allowEmptyReject": true}],
// Allow " if ' is contained inside the string, so we can avoid escaping // TypeScript
quotes: ['error', 'single', { avoidEscape: true }], quotes: ['warn', 'single', { avoidEscape: true }],
// TypeScript, let us be not too strict
'@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off',
// allow debugger during development only // allow debugger during development only
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off'
}, }
}; }

3
.gitignore vendored
View File

@ -4,7 +4,6 @@ node_modules
# We use yarn, so ignore npm # We use yarn, so ignore npm
package-lock.json package-lock.json
yarn.lock
# Quasar core related directories # Quasar core related directories
.quasar .quasar
@ -18,8 +17,6 @@ yarn.lock
# Capacitor related directories and files # Capacitor related directories and files
/src-capacitor/www /src-capacitor/www
/src-capacitor/android
/src-capacitor/ios
/src-capacitor/node_modules /src-capacitor/node_modules
# BEX related directories and files # BEX related directories and files

View File

@ -1,3 +0,0 @@
yarn-error.log
.woodpecker/

View File

@ -3,6 +3,6 @@
module.exports = { module.exports = {
plugins: [ plugins: [
// to edit target browsers: use "browserslist" field in package.json // to edit target browsers: use "browserslist" field in package.json
require('autoprefixer'), require('autoprefixer')
], ]
}; }

View File

@ -1,15 +0,0 @@
pipeline:
deploy:
when:
event: tag
tag: "@flaschengeist/api-v*"
image: node:lts-alpine
commands:
- cd api
- echo "//registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN" > .npmrc
- yarn publish --non-interactive
secrets: [ node_auth_token ]
depends_on:
- lint

View File

@ -1,9 +0,0 @@
pipeline:
lint:
when:
branch: [main, develop]
image: node:lts-alpine
commands:
- yarn install
- yarn lint

View File

@ -1,6 +1,4 @@
# Flaschengeist (frontend) # Flaschengeist (frontend)
![status-badge](https://ci.os-sc.org/api/badges/Flaschengeist/flaschengeist-frontend/status.svg)
Modular student club administration system, licensed under the MIT license. Modular student club administration system, licensed under the MIT license.
@ -10,9 +8,9 @@ Modular student club administration system, licensed under the MIT license.
``` ```
"engines": { "engines": {
"node": ">= 14.18.1", "node": ">= 12.22.1",
"npm": ">= 6.14.12", "npm": ">= 6.14.12",
"yarn": ">= 1.22.0" "yarn": ">= 1.21.1"
} }
``` ```
@ -20,9 +18,9 @@ So on debian (buster and bullseye) you will need to install node.js and yarn bes
```bash ```bash
pushd ~/opt pushd ~/opt
wget https://nodejs.org/dist/latest-v16.x/node-v16.13.0-linux-x64.tar.xz wget https://nodejs.org/dist/v16.2.0/node-v16.2.0-linux-x64.tar.xz
tar -xJf node-v16.13.0-linux-x64.tar.xz tar -xJf node-v16.2.0-linux-x64.tar.xz
export PATH="$(pwd)/node-v16.13.0-linux-x64/bin":"$PATH" export PATH="$(pwd)/node-v16.2.0-linux-x64/bin":"$PATH"
npm i -g yarn npm i -g yarn
npm i -g @quasar/cli npm i -g @quasar/cli
popd popd
@ -76,13 +74,6 @@ This access needs to be configured in `src/config.ts'->config.baseURL
yarn quasar build yarn quasar build
``` ```
### Notes on mobile apps (Cordova)
For mobile applications older web engines should or must be supported,
as manufaturer often do not update their phones, so for building cordova apps set the `BROWSERSLIST_ENV` environment variable to
`BROWSERSLIST_ENV=cordova`.
This will produce ECDMAscript compatible with iOS 13+ and Android Webview 76 (relased October 2019).
## Development ## Development
Please refer to our [development wiki](https://flaschengeist.dev/Flaschengeist/flaschengeist/wiki/Development). Please refer to out [development wiki](https://flaschengeist.dev/Flaschengeist/flaschengeist/wiki/Development).

View File

@ -54,7 +54,7 @@ export default defineComponent({
label: { type: String, default: 'Datum' }, label: { type: String, default: 'Datum' },
readonly: Boolean, readonly: Boolean,
rules: { rules: {
type: Array as PropType<Validator<Date>[]>, type: Array as PropType<Validator[]>,
default: () => [], default: () => [],
}, },
}, },
@ -62,22 +62,7 @@ export default defineComponent({
setup(props, { emit, attrs }) { setup(props, { emit, attrs }) {
const customRules = computed(() => [ const customRules = computed(() => [
props.type == 'date' ? stringIsDate : props.type == 'time' ? stringIsTime : stringIsDateTime, props.type == 'date' ? stringIsDate : props.type == 'time' ? stringIsTime : stringIsDateTime,
(value?: string) => { ...props.rules,
if (props.rules.length > 0 && !!value) {
let date: Date | undefined = undefined;
if (props.type == 'date') date = modifyDate(value);
else if (props.type == 'time') date = modifyTime(value);
else {
const split = value.split(' ');
date = modifyTime(split[1], modifyDate(split[0]));
}
for (const rule of props.rules) {
const r = rule(date);
if (typeof r === 'string') return r;
}
return true;
}
},
]); ]);
const clearable = computed(() => const clearable = computed(() =>

View File

@ -1,46 +0,0 @@
<template>
<q-avatar>
<slot :avatar-u-r-l="avatarURL(modelValue)">
<q-img :src="avatarURL(modelValue)" style="min-width: 100%; min-height: 100%">
<template #error>
<img :src="fallback" style="height: 100%" />
</template>
</q-img>
</slot>
</q-avatar>
</template>
<script lang="ts">
import { PropType, defineComponent } from 'vue';
import { avatarURL } from '@flaschengeist/api';
/**
* Display an avatar for an user
*
* Slots:
* default - scope: {avatarURL}
*/
export default defineComponent({
name: 'UserAvatar',
props: {
modelValue: {
type: [Object, String] as PropType<FG.User | string>,
required: true,
},
showZoom: {
type: Boolean,
default: false,
},
fallback: {
type: String,
default: 'no-image.svg',
},
},
emits: ['error'],
setup() {
return {
avatarURL,
};
},
});
</script>

View File

@ -1,5 +1,4 @@
import IsoDateInput from './IsoDateInput.vue'; import IsoDateInput from './IsoDateInput.vue';
import PasswordInput from './PasswordInput.vue'; import PasswordInput from './PasswordInput.vue';
import UserAvatar from './UserAvatar.vue';
export { IsoDateInput, PasswordInput, UserAvatar }; export { IsoDateInput, PasswordInput };

View File

@ -4,7 +4,4 @@ export * from './src/stores/';
export * from './src/utils/datetime'; export * from './src/utils/datetime';
export * from './src/utils/permission'; export * from './src/utils/permission';
export * from './src/utils/persistent';
export * from './src/utils/user';
export * from './src/utils/validators'; export * from './src/utils/validators';
export * from './src/utils/misc';

View File

@ -1,6 +1,6 @@
{ {
"license": "MIT", "license": "MIT",
"version": "1.0.0", "version": "1.0.0-alpha.2",
"name": "@flaschengeist/api", "name": "@flaschengeist/api",
"author": "Tim Gröger <flaschengeist@wu5.de>", "author": "Tim Gröger <flaschengeist@wu5.de>",
"homepage": "https://flaschengeist.dev/Flaschengeist", "homepage": "https://flaschengeist.dev/Flaschengeist",
@ -8,16 +8,27 @@
"bugs": { "bugs": {
"url": "https://flaschengeist.dev/Flaschengeist/flaschengeist/issues" "url": "https://flaschengeist.dev/Flaschengeist/flaschengeist/issues"
}, },
"scripts": {
"format": "prettier --config ./package.json --write '{,!(node_modules)/**/}*.ts'",
"lint": "eslint --ext .js,.ts,.vue ./src"
},
"main": "./src/index.ts", "main": "./src/index.ts",
"peerDependencies": { "peerDependencies": {
"@quasar/app-webpack": "^3.7.2", "@quasar/app": "^3.0.0-beta.26",
"flaschengeist": "^2.0.0", "flaschengeist": "^2.0.0-alpha.1",
"pinia": "^2.0.8" "pinia": "^2.0.0-alpha.19"
}, },
"devDependencies": { "devDependencies": {
"@flaschengeist/types": "^1.0.0", "@flaschengeist/types": "^1.0.0-alpha.4",
"@types/node": "^14.18.0", "@types/node": "^12.20.37",
"typescript": "^4.5.4" "@typescript-eslint/eslint-plugin": "^5.3.1",
"@typescript-eslint/parser": "^5.3.1",
"eslint": "^8.2.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-vue": "^8.0.3",
"eslint-webpack-plugin": "^3.1.0",
"prettier": "^2.4.1",
"typescript": "^4.4.4"
}, },
"prettier": { "prettier": {
"singleQuote": true, "singleQuote": true,

View File

@ -8,7 +8,7 @@ import { AxiosError } from 'axios';
*/ */
export function isAxiosError(error: unknown, status?: number) { export function isAxiosError(error: unknown, status?: number) {
// Check if it is an axios error (with axios 1.0 `error instanceof AxiosError` will be possible) // Check if it is an axios error (with axios 1.0 `error instanceof AxiosError` will be possible)
if (typeof error !== 'object' || !error || !('isAxiosError' in error)) return false; if (!error || typeof error !== 'object' || !('isAxiosError' in <object>error)) return false;
// Check status code if status was given // Check status code if status was given
if (status !== undefined) if (status !== undefined)
return ( return (

View File

@ -1,31 +1,28 @@
import { LocalStorage, SessionStorage } from 'quasar';
import { FG_Plugin } from '@flaschengeist/types'; import { FG_Plugin } from '@flaschengeist/types';
import { fixSession, useSessionStore, useUserStore } from '.'; import { useSessionStore, useUserStore } from '.';
import { AxiosResponse } from 'axios'; import { AxiosResponse } from 'axios';
import { api } from '../internal'; import { api } from '../internal';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { PersistentStorage } from '../utils/persistent';
import { LocalStorage, SessionStorage } from 'quasar'; function loadCurrentSession() {
function reviveSession() { const session = LocalStorage.getItem<FG.Session>('session');
return PersistentStorage.get<FG.Session>('fg_session').then((s) => fixSession(s || undefined)); if (session) session.expires = new Date(session.expires);
return session || undefined;
} }
function clearPersistant() { function loadUser() {
void PersistentStorage.remove('fg_session'); const user = SessionStorage.getItem<FG.User>('user');
} if (user && user.birthday) user.birthday = new Date(user.birthday);
return user || undefined;
export function saveSession(session?: FG.Session) {
if (session === undefined) return clearPersistant();
PersistentStorage.set('fg_session', session).catch(() =>
console.error('Could not save token to storage')
);
} }
export const useMainStore = defineStore({ export const useMainStore = defineStore({
id: 'main', id: 'main',
state: () => ({ state: () => ({
session: undefined as FG.Session | undefined, session: loadCurrentSession(),
user: undefined as FG.User | undefined, user: loadUser(),
notifications: [] as Array<FG_Plugin.Notification>, notifications: [] as Array<FG_Plugin.Notification>,
shortcuts: [] as Array<FG_Plugin.MenuLink>, shortcuts: [] as Array<FG_Plugin.MenuLink>,
}), }),
@ -38,10 +35,6 @@ export const useMainStore = defineStore({
if (this.user === undefined) throw 'Not logged in, this should not be called'; if (this.user === undefined) throw 'Not logged in, this should not be called';
return this.user; return this.user;
}, },
currentSession(): FG.Session {
if (this.session === undefined) throw 'Not logged in, this should not be called';
return this.session;
},
permissions(): string[] { permissions(): string[] {
return this.user?.permissions || []; return this.user?.permissions || [];
}, },
@ -52,29 +45,29 @@ export const useMainStore = defineStore({
* Updates session and loads current user * Updates session and loads current user
*/ */
async init() { async init() {
if (this.session) {
const sessionStore = useSessionStore(); const sessionStore = useSessionStore();
const session = await sessionStore.getSession(this.session.token);
if (session) {
this.session = session;
const userStore = useUserStore(); const userStore = useUserStore();
const user = await userStore.getUser(this.session.userid);
try { if (user) {
this.session = await reviveSession(); this.user = user;
if (this.session !== undefined) { SessionStorage.set('user', user);
this.session = await sessionStore.getSession(this.session.token); }
if (this.session !== undefined) this.user = await userStore.getUser(this.session.userid);
} }
} catch (error) {
console.warn('Could not load token from storage', error);
} }
}, },
async login(userid: string, password: string) { async login(userid: string, password: string) {
const userStore = useUserStore();
try { try {
const { data } = await api.post<FG.Session>('/auth', { userid, password }); const { data } = await api.post<FG.Session>('/auth', { userid, password });
this.session = fixSession(data); this.session = data;
this.user = await userStore.getUser(data.userid, true); this.session.expires = new Date(this.session.expires);
LocalStorage.set('session', this.session);
return true; return true;
} catch ({ response }) { } catch ({ response }) {
this.handleLoggedOut();
return (<AxiosResponse | undefined>response)?.status || false; return (<AxiosResponse | undefined>response)?.status || false;
} }
}, },
@ -110,22 +103,24 @@ export const useMainStore = defineStore({
}, },
async loadNotifications(flaschengeist: FG_Plugin.Flaschengeist) { async loadNotifications(flaschengeist: FG_Plugin.Flaschengeist) {
const { data } = await api.get<FG.Notification[]>('/notifications', { const params =
params:
this.notifications.length > 0 this.notifications.length > 0
? { from: this.notifications[this.notifications.length - 1].time } ? { from: this.notifications[this.notifications.length - 1].time }
: {}, : {};
}); const { data } = await api.get<FG.Notification[]>('/notifications', { params: params });
const notifications: FG_Plugin.Notification[] = [];
const notes = [] as FG_Plugin.Notification[];
data.forEach((n) => { data.forEach((n) => {
n.time = new Date(n.time); n.time = new Date(n.time);
const plugin = flaschengeist?.plugins.filter((p) => p.id === n.plugin)[0]; notifications.push(
if (!plugin) console.debug('Could not find a parser for this notification', n); (flaschengeist?.plugins.filter((p) => p.name === n.plugin)[0]?.notification)(
else notes.push(plugin.notification(n)); /*||
translateNotification*/
n
)
);
}); });
this.notifications.push(...notes); this.notifications.push(...notifications);
return notes; return notifications;
}, },
async removeNotification(id: number) { async removeNotification(id: number) {
@ -150,11 +145,13 @@ export const useMainStore = defineStore({
async setShortcuts() { async setShortcuts() {
await api.put(`users/${this.currentUser.userid}/shortcuts`, this.shortcuts); await api.put(`users/${this.currentUser.userid}/shortcuts`, this.shortcuts);
}, },
handleLoggedOut() { handleLoggedOut() {
this.$reset();
void clearPersistant();
LocalStorage.clear(); LocalStorage.clear();
this.$patch({
session: undefined,
user: undefined,
});
SessionStorage.clear(); SessionStorage.clear();
}, },
}, },

View File

@ -3,10 +3,6 @@ import { defineStore } from 'pinia';
import { api } from '../internal'; import { api } from '../internal';
import { isAxiosError, useMainStore } from '.'; import { isAxiosError, useMainStore } from '.';
export function fixSession(s?: FG.Session) {
return !s ? s : Object.assign(s, { expires: new Date(s.expires) });
}
export const useSessionStore = defineStore({ export const useSessionStore = defineStore({
id: 'sessions', id: 'sessions',
@ -19,13 +15,15 @@ export const useSessionStore = defineStore({
return await api return await api
.get(`/auth/${token}`) .get(`/auth/${token}`)
.then(({ data }: AxiosResponse<FG.Session>) => data) .then(({ data }: AxiosResponse<FG.Session>) => data)
.catch(() => undefined); .catch(() => null);
}, },
async getSessions() { async getSessions() {
try { try {
const { data } = await api.get<FG.Session[]>('/auth'); const { data } = await api.get<FG.Session[]>('/auth');
data.forEach(fixSession); data.forEach((session) => {
session.expires = new Date(session.expires);
});
const mainStore = useMainStore(); const mainStore = useMainStore();
const currentSession = data.find((session) => { const currentSession = data.find((session) => {
@ -57,7 +55,7 @@ export const useSessionStore = defineStore({
async updateSession(lifetime: number, token: string) { async updateSession(lifetime: number, token: string) {
try { try {
const { data } = await api.put<FG.Session>(`auth/${token}`, { value: lifetime }); const { data } = await api.put<FG.Session>(`auth/${token}`, { value: lifetime });
fixSession(data); data.expires = new Date(data.expires);
const mainStore = useMainStore(); const mainStore = useMainStore();
if (mainStore.session?.token == data.token) mainStore.session = data; if (mainStore.session?.token == data.token) mainStore.session = data;

View File

@ -1,195 +1,78 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { api } from '../internal'; import { api } from '../internal';
import { isAxiosError, useMainStore } from '.'; import { isAxiosError, useMainStore } from '.';
import { DisplayNameMode } from '@flaschengeist/users';
export function fixUser(u?: FG.User) {
return !u ? u : Object.assign(u, { birthday: u.birthday ? new Date(u.birthday) : undefined });
}
/**
* Check if state is outdated / dirty
* Value is considered outdated after 15 minutes
* @param updated Time of last updated (in milliseconds see Date.now())
* @returns True if outdated, false otherwise
*/
function isDirty(updated: number) {
return Date.now() - updated > 15 * 60 * 1000;
}
export const useUserStore = defineStore({ export const useUserStore = defineStore({
id: 'users', id: 'users',
state: () => ({ state: () => ({
roles: [] as FG.Role[], roles: [] as FG.Role[],
users: [] as FG.User[],
permissions: [] as FG.Permission[], permissions: [] as FG.Permission[],
userSettings: {} as FG.UserSettings, _dirty_users: true,
// list of all users, include deleted ones, use `users` getter for list of active ones _dirty_roles: true,
_users: [] as FG.User[],
// Internal flags for deciding if lists need to force-loaded
_dirty_users: 0,
_dirty_roles: 0,
}), }),
getters: { getters: {},
users(state) {
const u = state._users.filter((u) => !u.deleted);
switch (this.userSettings['display_name']) {
case DisplayNameMode.FIRSTNAME_LASTNAME || DisplayNameMode.FIRSTNAME:
u.sort((a, b) => {
const a_lastname = a.lastname.toLowerCase();
const b_lastname = b.lastname.toLowerCase();
const a_firstname = a.firstname.toLowerCase();
const b_firstname = b.firstname.toLowerCase();
if (a_firstname === b_firstname) {
return a_lastname < b_lastname ? -1 : 1;
}
return a_firstname < b_firstname ? -1 : 1;
});
break;
case <string>DisplayNameMode.DISPLAYNAME:
u.sort((a, b) => {
const a_displayname = a.display_name.toLowerCase();
const b_displayname = b.display_name.toLowerCase();
return a_displayname < b_displayname ? -1 : 1;
});
break;
default:
u.sort((a, b) => {
const a_lastname = a.lastname.toLowerCase();
const b_lastname = b.lastname.toLowerCase();
const a_firstname = a.firstname.toLowerCase();
const b_firstname = b.firstname.toLowerCase();
if (a_lastname === b_lastname) {
return a_firstname < b_firstname ? -1 : 1;
}
return a_lastname < b_lastname ? -1 : 1;
});
}
return u;
},
},
actions: { actions: {
/** Simply filter all users by ID */
findUser(userid: string) { findUser(userid: string) {
return this._users.find((user) => user.userid === userid); return this.users.find((user) => user.userid === userid);
}, },
/** Retrieve user by ID
* @param userid ID of user to retrieve
* @param force If set to true the user is loaded from backend even when a local copy is available
* @returns Retrieved user (Promise) or raise an error
* @throws Probably an AxiosError if loading failed
*/
async getUser(userid: string, force = false) { async getUser(userid: string, force = false) {
const idx = this._users.findIndex((user) => user.userid === userid); const idx = this.users.findIndex((user) => user.userid === userid);
if (force || idx === -1 || isDirty(this._dirty_users)) { if (force || this._dirty_users || idx === -1) {
try { try {
const { data } = await api.get<FG.User>(`/users/${userid}`); const { data } = await api.get<FG.User>(`/users/${userid}`);
fixUser(data); if (data.birthday) data.birthday = new Date(data.birthday);
if (idx === -1) this._users.push(data); if (idx === -1) this.users.push(data);
else this._users[idx] = data; else this.users[idx] = data;
return data; return data;
} catch (error) { } catch (error) {
// Ignore 404, throw all other // Ignore 404, throw all other
if (!isAxiosError(error, 404)) throw error; if (!isAxiosError(error, 404)) throw error;
} }
} else { } else {
return this._users[idx]; return this.users[idx];
} }
}, },
/** Retrieve list of all users
* @param force If set to true a fresh users list is loaded from backend even when a local copy is available
* @returns Array of retrieved users (Promise)
* @throws Probably an AxiosError if loading failed
*/
async getUsers(force = false) { async getUsers(force = false) {
if (force || isDirty(this._dirty_users)) { if (force || this._dirty_users) {
const { data } = await api.get<FG.User[]>('/users'); const { data } = await api.get<FG.User[]>('/users');
data.forEach(fixUser); data.forEach((user) => {
this._users = data; if (user.birthday) user.birthday = new Date(user.birthday);
this._dirty_users = Date.now(); });
this.users = data;
this._dirty_users = false;
} }
return this._users; return this.users;
}, },
/** Save modifications of user on backend
* @param user Modified user to save
* @throws Probably an AxiosError if request failed (404 = Invalid userid, 400 = Invalid data)
*/
async updateUser(user: FG.User) { async updateUser(user: FG.User) {
await api.put(`/users/${user.userid}`, user); await api.put(`/users/${user.userid}`, user);
// Modifcation accepted by backend
// Save modifications back to our users list
const idx = this._users.findIndex((u) => u.userid === user.userid);
if (idx > -1) this._users[idx] = user;
// If user was current user, save modifications back to the main store
const mainStore = useMainStore(); const mainStore = useMainStore();
if (user.userid === mainStore.user?.userid) mainStore.user = user; if (user.userid === mainStore.user?.userid) mainStore.user = user;
this._dirty_users = true;
}, },
/** Register a new user
* @param user User to register (id not set)
* @returns The registered user (id set)
* @throws Probably an AxiosError if request failed
*/
async createUser(user: FG.User) { async createUser(user: FG.User) {
const { data } = await api.post<FG.User>('/users', user); const { data } = await api.post<FG.User>('/users', user);
this._users.push(<FG.User>fixUser(data)); this.users.push(data);
return data; return data;
}, },
/** Delete an user async uploadAvatar(user: FG.User, file: string | File) {
* Throws if failed and resolves void if succeed
*
* @param user User or ID of user to delete
* @throws Probably an AxiosError if request failed
*/
async deleteUser(user: FG.User | string) {
if (typeof user === 'object') user = user.userid;
await api.delete(`/users/${user}`);
this._users = this._users.filter((u) => u.userid != user);
},
/** Upload an avatar for an user
* Throws if failed and resolves void if succeed
*
* @param user User or ID of user
* @param file Avatar file to upload
* @throws Probably an AxiosError if request failed
*/
async uploadAvatar(user: FG.User | string, file: string | File) {
if (typeof user === 'object') user = user.userid;
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
await api.post(`/users/${user}/avatar`, formData, { await api.post(`/users/${user.userid}/avatar`, formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
}, },
}); });
}, },
/** Delete avatar of an user
* @param user User or ID of user
* @throws Probably an AxiosError if request failed
*/
async deleteAvatar(user: FG.User | string) {
if (typeof user === 'object') user = user.userid;
await api.delete(`/users/${user}/avatar`);
},
/** Retrieve list of all permissions
* @param force If set to true a fresh list is loaded from backend even when a local copy is available
* @returns Array of retrieved permissions (Promise)
* @throws Probably an AxiosError if request failed
*/
async getPermissions(force = false) { async getPermissions(force = false) {
if (force || this.permissions.length === 0) { if (force || this.permissions.length === 0) {
const { data } = await api.get<FG.Permission[]>('/roles/permissions'); const { data } = await api.get<FG.Permission[]>('/roles/permissions');
@ -198,81 +81,31 @@ export const useUserStore = defineStore({
return this.permissions; return this.permissions;
}, },
/** Retrieve list of all roles
* @param force If set to true a fresh list is loaded from backend even when a local copy is available
* @returns Array of retrieved roles (Promise)
* @throws Probably an AxiosError if request failed
*/
async getRoles(force = false) { async getRoles(force = false) {
if (force || isDirty(this._dirty_roles)) { if (force || this._dirty_roles) {
const { data } = await api.get<FG.Role[]>('/roles'); const { data } = await api.get<FG.Role[]>('/roles');
this.roles = data; this.roles = data;
this._dirty_roles = Date.now(); this._dirty_roles = false;
} }
return this.roles; return this.roles;
}, },
/** Save modifications of role on the backend
* @param role role to save
* @throws Probably an AxiosError if request failed
*/
async updateRole(role: FG.Role) { async updateRole(role: FG.Role) {
await api.put(`/roles/${role.id}`, role); await api.put(`/roles/${role.id}`, role);
const idx = this.roles.findIndex((r) => r.id === role.id); const idx = this.roles.findIndex((r) => r.id === role.id);
if (idx != -1) this.roles[idx] = role; if (idx != -1) this.roles[idx] = role;
else this._dirty_roles = 0; this._dirty_roles = true;
}, },
/** Create a new role
* @param role Role to create (ID not set)
* @returns Created role (ID set)
* @throws Probably an AxiosError if request failed
*/
async newRole(role: FG.Role) { async newRole(role: FG.Role) {
const { data } = await api.post<FG.Role>('/roles', role); const { data } = await api.post<FG.Role>('/roles', role);
this.roles.push(data); this.roles.push(data);
return data; return data;
}, },
/** Delete a role
* @param role Role or ID of role to delete
* @throws Probably an AxiosError if request failed (409 if role still in use)
*/
async deleteRole(role: FG.Role | number) { async deleteRole(role: FG.Role | number) {
if (typeof role === 'object') role = role.id; await api.delete(`/roles/${typeof role === 'number' ? role : role.id}`);
await api.delete(`/roles/${role}`); this.roles = this.roles.filter((r) => r.id !== (typeof role == 'number' ? role : role.id));
this.roles = this.roles.filter((r) => r.id !== role);
},
/** Get Settings for display name mode
* @param force If set to true a fresh list is loaded from backend even when a local copy is available
* @throws Probably an AxiosError if request failed
* @returns Settings for display name mode
*/
async getDisplayNameModeSetting(force = false): Promise<string> {
const mainStore = useMainStore();
if (force) {
const { data } = await api.get<{ data: string }>(
`users/${mainStore.currentUser.userid}/setting/display_name_mode`
);
this.userSettings['display_name'] = data.data;
}
return this.userSettings['display_name'];
},
/** Set Settings for display name mode
* @param mode New display name mode
* @throws Probably an AxiosError if request failed
* @returns Settings for display name mode
*/
async setDisplayNameModeSetting(mode: string): Promise<string> {
const mainStore = useMainStore();
await api.put(`users/${mainStore.currentUser.userid}/setting/display_name_mode`, {
data: mode,
});
this.userSettings['display_name'] = mode;
return mode;
}, },
}, },
}); });

View File

@ -17,23 +17,12 @@ export function formatDateTime(
return dateTimeFormat.format(date); return dateTimeFormat.format(date);
} }
export function asDate(date?: Date, placeholder = '') { export function asDate(date?: Date) {
return date ? formatDateTime(date, true) : placeholder; return date ? formatDateTime(date, true) : '';
} }
export function asHour(date?: Date, placeholder = '') { export function asHour(date?: Date) {
return date ? formatDateTime(date, false, true) : placeholder; return date ? formatDateTime(date, false, true) : '';
}
export function formatStartEnd(start: Date, end?: Date) {
const today = asDate(new Date());
const startDate = asDate(start);
const endDate = end ? asDate(end) : '';
return (
(today !== startDate ? `${startDate}, ` : '') +
asHour(start) +
(end ? ' - ' + (endDate !== startDate ? `${endDate}, ` : '') + asHour(end) : '')
);
} }
export function startOfWeek(date: Date, startMonday = true) { export function startOfWeek(date: Date, startMonday = true) {

View File

@ -1,3 +0,0 @@
export function clone<T>(o: T): T {
return <T>JSON.parse(JSON.stringify(o));
}

View File

@ -1,35 +0,0 @@
import { LocalStorage, Platform } from 'quasar';
import { Preferences } from '@capacitor/preferences';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type PersitentTypes = Date | RegExp | number | boolean | string | object;
export class PersistentStorage {
static clear() {
if (Platform.is.capacitor) return Preferences.clear();
else return Promise.resolve(LocalStorage.clear());
}
static remove(key: string) {
if (Platform.is.capacitor) return Preferences.remove({ key: key });
else return Promise.resolve(LocalStorage.remove(key));
}
static set(key: string, value: PersitentTypes) {
if (Platform.is.capacitor) return Preferences.set({ key, value: JSON.stringify(value) });
else return Promise.resolve(LocalStorage.set(key, value));
}
static get<T extends PersitentTypes>(key: string) {
if (Platform.is.capacitor)
return Preferences.get({ key }).then((v) =>
v.value === null ? null : (JSON.parse(v.value) as T)
);
else return Promise.resolve(LocalStorage.getItem<T>(key));
}
static keys() {
if (Platform.is.capacitor) return Preferences.keys().then((v) => v.keys);
else return Promise.resolve(LocalStorage.getAllKeys());
}
}

View File

@ -1,6 +0,0 @@
import { api } from '../internal';
export function avatarURL(user: FG.User | string, thumbnail = true) {
if (typeof user === 'object') user = user.userid;
return `${api.defaults?.baseURL || ''}/users/${user}/avatar${thumbnail ? '?thumbnail' : ''}`;
}

View File

@ -1,4 +1,4 @@
export type Validator<T = unknown> = (value?: T | null) => boolean | string; export type Validator = (value: unknown) => boolean | string;
export function notEmpty(val: unknown) { export function notEmpty(val: unknown) {
return !!val || 'Feld darf nicht leer sein!'; return !!val || 'Feld darf nicht leer sein!';

View File

@ -3,7 +3,14 @@
"target": "esnext", "target": "esnext",
"compilerOptions": { "compilerOptions": {
"baseUrl": "./", "baseUrl": "./",
"lib": ["es2020", "dom"], "lib": [
"types": ["@flaschengeist/types", "@quasar/app", "node"] "es2020",
"dom"
],
"types": [
"@flaschengeist/types",
"@quasar/app",
"node"
]
} }
} }

View File

@ -1,4 +1,6 @@
/* eslint-env node */ /* eslint-env node */
module.exports = { module.exports = {
presets: ['@quasar/babel-preset-app'], presets: [
}; '@quasar/babel-preset-app'
]
}

View File

@ -1,7 +1,7 @@
{ {
"private": true, "private": true,
"license": "MIT", "license": "MIT",
"version": "2.1.0", "version": "2.0.0-alpha.1",
"productName": "flaschengeist-frontend", "productName": "flaschengeist-frontend",
"name": "flaschengeist", "name": "flaschengeist",
"author": "Tim Gröger <flaschengeist@wu5.de>", "author": "Tim Gröger <flaschengeist@wu5.de>",
@ -11,41 +11,33 @@
"url": "https://flaschengeist.dev/Flaschengeist/flaschengeist/issues" "url": "https://flaschengeist.dev/Flaschengeist/flaschengeist/issues"
}, },
"scripts": { "scripts": {
"format": "prettier --config ./package.json --write '{,!(node_modules|dist|.*)/**/}*.{js,ts,vue}'", "format": "prettier --config ./package.json --write '{,!(node_modules)/**/}*.ts'",
"lint": "eslint --ext .js,.ts,.vue ./src ./api" "lint": "eslint --ext .js,.ts,.vue ./src ./api"
}, },
"dependencies": { "dependencies": {
"@flaschengeist/api": "^1.0.0", "@flaschengeist/api": "file:./api",
"@flaschengeist/balance": "^1.0.0", "@flaschengeist/users": "^1.0.0-alpha.1",
"@flaschengeist/pricelist-old": "^1.0.0", "axios": "^0.21.1",
"@flaschengeist/schedule": "^1.0.0", "cordova": "^10.0.0",
"@flaschengeist/users": "^1.0.0", "pinia": "^2.0.0-rc.6",
"axios": "^1.4.0", "quasar": "^2.0.4"
"pinia": "^2.0.8",
"quasar": "^2.11.10",
"vue": "^3.0.0",
"vue-router": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@capacitor/core": "^5.0.0", "@flaschengeist/types": "^1.0.0-alpha.4",
"@capacitor/preferences": "^5.0.0", "@quasar/app": "^3.1.0",
"@flaschengeist/types": "^1.0.0", "@quasar/extras": "^1.10.12",
"@quasar/app-webpack": "^3.7.2", "@types/node": "^12.20.21",
"@quasar/extras": "^1.16.3",
"@types/node": "^14.18.0",
"@types/webpack": "^5.28.0", "@types/webpack": "^5.28.0",
"@types/webpack-env": "^1.16.3", "@types/webpack-env": "^1.16.2",
"@typescript-eslint/eslint-plugin": "^5.8.0", "@typescript-eslint/eslint-plugin": "^4.29.3",
"@typescript-eslint/parser": "^5.8.0", "@typescript-eslint/parser": "^4.29.3",
"@vue/devtools": "^6.5.0", "eslint": "^7.32.0",
"eslint": "^8.5.0",
"eslint-config-prettier": "^8.3.0", "eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0", "eslint-plugin-vue": "^7.17.0",
"eslint-plugin-vue": "^9.14.1", "eslint-webpack-plugin": "^3.0.1",
"eslint-webpack-plugin": "^4.0.1", "modify-source-webpack-plugin": "^3.0.0",
"modify-source-webpack-plugin": "^4.1.0", "prettier": "^2.3.2",
"prettier": "^2.5.1", "typescript": "~4.3.5",
"typescript": "^4.5.4",
"vuedraggable": "^4.1.0" "vuedraggable": "^4.1.0"
}, },
"prettier": { "prettier": {
@ -54,25 +46,19 @@
"printWidth": 100, "printWidth": 100,
"arrowParens": "always" "arrowParens": "always"
}, },
"browserslist": { "browserslist": [
"defaults": [ "last 10 Chrome versions",
"Firefox esr", "last 10 Firefox versions",
"last 6 Chrome versions",
"last 4 Firefox versions",
"last 4 Edge versions", "last 4 Edge versions",
"last 4 Safari versions", "last 4 Safari versions",
"last 4 ChromeAndroid versions", "last 8 Android versions",
"last 1 FirefoxAndroid versions" "last 1 ChromeAndroid versions",
"last 1 FirefoxAndroid versions",
"last 6 iOS versions"
], ],
"cordova": [
"iOS >= 13.0",
"Android >= 76",
"ChromeAndroid >= 76"
]
},
"engines": { "engines": {
"node": ">= 14.18.1", "node": ">= 12.22.1",
"npm": ">= 6.14.12", "npm": ">= 6.14.12",
"yarn": ">= 1.22.0" "yarn": ">= 1.21.1"
} }
} }

View File

@ -3,4 +3,4 @@ module.exports = [
// '@flaschengeist/balance', // '@flaschengeist/balance',
// '@flaschengeist/schedule', // '@flaschengeist/schedule',
// '@flaschengeist/pricelist', // '@flaschengeist/pricelist',
'@flaschengeist/schedule', ]

View File

@ -8,23 +8,10 @@
/* eslint-env node */ /* eslint-env node */
/* eslint-disable @typescript-eslint/no-var-requires */ /* eslint-disable @typescript-eslint/no-var-requires */
const ESLintPlugin = require('eslint-webpack-plugin'); const ESLintPlugin = require('eslint-webpack-plugin')
const { ModifySourcePlugin, ReplaceOperation } = require('modify-source-webpack-plugin'); const { ModifySourcePlugin } = require('modify-source-webpack-plugin')
const { configure } = require('quasar/wrappers'); const { configure } = require('quasar/wrappers')
const operation = () => {
const custom_plgns = require('./plugin.config.js');
const required_plgns = require('./src/vendor-plugin.config.js');
const plugins = [...custom_plgns, ...required_plgns].map(
(v) => `import("${v}").catch(() => "${v}")`
);
const replace = new ReplaceOperation(
'all',
`\\/\\* *INSERT_PLUGIN_LIST *\\*\\/`,
`${plugins.join(', ')}`
);
return replace;
};
module.exports = configure(function (/* ctx */) { module.exports = configure(function (/* ctx */) {
return { return {
@ -35,7 +22,7 @@ module.exports = configure(function (/* ctx */) {
enabled: true, enabled: true,
files: './src/**/*.{ts,tsx,js,jsx,vue}', files: './src/**/*.{ts,tsx,js,jsx,vue}',
}, },
}, }
}, },
// https://quasar.dev/quasar-cli/prefetch-feature // https://quasar.dev/quasar-cli/prefetch-feature
@ -44,7 +31,7 @@ module.exports = configure(function (/* ctx */) {
// app boot file (/src/boot) // app boot file (/src/boot)
// --> boot files are part of "main.js" // --> boot files are part of "main.js"
// https://quasar.dev/quasar-cli/boot-files // https://quasar.dev/quasar-cli/boot-files
boot: ['axios', 'store', 'plugins', 'login', 'init'], boot: ['axios', 'store', 'plugins', 'loading', 'login'],
// https://quasar.dev/quasar-cli/quasar-conf-js#Property%3A-css // https://quasar.dev/quasar-cli/quasar-conf-js#Property%3A-css
css: ['app.scss'], css: ['app.scss'],
@ -53,10 +40,10 @@ module.exports = configure(function (/* ctx */) {
extras: [ extras: [
// 'eva-icons', // 'eva-icons',
// 'fontawesome-v5', // 'fontawesome-v5',
// 'ionicons-v5', // 'ionicons-v4',
// 'line-awesome', // 'line-awesome',
// 'material-icons', // 'material-icons',
'mdi-v7', 'mdi-v5',
// 'themify', // 'themify',
// 'roboto-font-latin-ext', // this or either 'roboto-font', NEVER both! // 'roboto-font-latin-ext', // this or either 'roboto-font', NEVER both!
@ -66,9 +53,10 @@ module.exports = configure(function (/* ctx */) {
// Full list of options: https://quasar.dev/quasar-cli/quasar-conf-js#Property%3A-build // Full list of options: https://quasar.dev/quasar-cli/quasar-conf-js#Property%3A-build
build: { build: {
vueRouterMode: 'history', // available values: 'hash', 'history' vueRouterMode: 'history', // available values: 'hash', 'history'
//publicPath: 'flaschengeist2',
// transpile: false, // transpile: false,
// Add dependencies for transpiling with Babel (Array of string/regex) // Add dependencies for transpiling with Babel (Array of string/regex)
// (from node_modules, which are by default not transpiled). // (from node_modules, which are by default not transpiled).
// Applies only if "transpile" is set to true. // Applies only if "transpile" is set to true.
@ -84,28 +72,32 @@ module.exports = configure(function (/* ctx */) {
// https://quasar.dev/quasar-cli/handling-webpack // https://quasar.dev/quasar-cli/handling-webpack
// "chain" is a webpack-chain object https://github.com/neutrinojs/webpack-chain // "chain" is a webpack-chain object https://github.com/neutrinojs/webpack-chain
chainWebpack (chain) { chainWebpack (chain) {
chain.plugin('eslint-webpack-plugin').use(ESLintPlugin, [ chain.plugin('eslint-webpack-plugin')
{ .use(ESLintPlugin, [{
extensions: [ 'ts', 'js', 'vue' ], extensions: [ 'ts', 'js', 'vue' ],
exclude: ['node_modules', 'src-capacitor'], exclude: 'node_modules'
}, }])
]); chain.plugin('modify-source-webpack-plugin')
chain.plugin('modify-source-webpack-plugin').use(ModifySourcePlugin, [ .use(ModifySourcePlugin, [{
{
rules: [ rules: [
{ {
test: /plugins\.ts$/, test: /plugins\.ts$/,
operations: [operation()], modify: (src, filename) => {
}, const custom_plgns = require('./plugin.config.js')
], const required_plgns = require('./src/vendor-plugin.config.js')
}, return src.replace(/\/\* *INSERT_PLUGIN_LIST *\*\//,
]); [...custom_plgns, ...required_plgns].map(v => `import("${v}").then(v => success(v)).catch(() => failure("${v}"))`)
.join(','))
}
}
]
}])
chain.merge({ chain.merge({
snapshot: { snapshot: {
managedPaths: [], managedPaths: []
}, }
}); })
}, }
}, },
// Full list of options: https://quasar.dev/quasar-cli/quasar-conf-js#Property%3A-devServer // Full list of options: https://quasar.dev/quasar-cli/quasar-conf-js#Property%3A-devServer
@ -113,20 +105,20 @@ module.exports = configure(function (/* ctx */) {
https: false, https: false,
port: 8080, port: 8080,
open: false, // opens browser window automatically open: false, // opens browser window automatically
watchFiles: { paths: ['/node_modules/@flaschengeist/**/*'] }, watchFiles: {paths: ['/node_modules/@flaschengeist/**/*']}
}, },
// https://quasar.dev/quasar-cli/quasar-conf-js#Property%3A-framework // https://quasar.dev/quasar-cli/quasar-conf-js#Property%3A-framework
framework: { framework: {
iconSet: 'mdi-v6', // Quasar icon set iconSet: 'mdi-v5', // Quasar icon set
lang: 'de', // Quasar language pack lang: 'de', // Quasar language pack
config: { config: {
dark: 'auto', dark: 'auto',
loadingBar: { loadingBar: {
position: 'top', position: 'top',
color: 'warning', color: 'warning',
size: '5px', size: '5px'
}, }
}, },
// For special cases outside of where the auto-import stategy can have an impact // For special cases outside of where the auto-import stategy can have an impact
@ -137,7 +129,13 @@ module.exports = configure(function (/* ctx */) {
// directives: [], // directives: [],
// Quasar plugins // Quasar plugins
plugins: ['LocalStorage', 'SessionStorage', 'Dialog', 'Loading', 'Notify', 'LoadingBar'], plugins: [
'LocalStorage',
'SessionStorage',
'Loading',
'Notify',
'LoadingBar'
]
}, },
// animations: 'all', // --- includes all animations // animations: 'all', // --- includes all animations
@ -146,7 +144,7 @@ module.exports = configure(function (/* ctx */) {
// https://quasar.dev/quasar-cli/developing-ssr/configuring-ssr // https://quasar.dev/quasar-cli/developing-ssr/configuring-ssr
ssr: { ssr: {
pwa: false, pwa: false
}, },
// https://quasar.dev/quasar-cli/developing-pwa/configuring-pwa // https://quasar.dev/quasar-cli/developing-pwa/configuring-pwa
@ -165,20 +163,20 @@ module.exports = configure(function (/* ctx */) {
{ {
src: 'flaschengeist-logo.svg', src: 'flaschengeist-logo.svg',
sizes: 'any', sizes: 'any',
type: 'image/svg+xml', type: 'image/svg+xml'
}, },
{ {
src: 'favicon-128x128.png', src: 'favicon-128x128.png',
sizes: '128x128', sizes: '128x128',
type: 'image/png', type: 'image/png'
}, },
{ {
src: 'favicon-256x256.png', src: 'favicon-256x256.png',
sizes: '256x256', sizes: '256x256',
type: 'image/png', type: 'image/png'
},
],
}, },
]
}
}, },
// Full list of options: https://quasar.dev/quasar-cli/developing-cordova-apps/configuring-cordova // Full list of options: https://quasar.dev/quasar-cli/developing-cordova-apps/configuring-cordova
@ -188,7 +186,7 @@ module.exports = configure(function (/* ctx */) {
// Full list of options: https://quasar.dev/quasar-cli/developing-capacitor-apps/configuring-capacitor // Full list of options: https://quasar.dev/quasar-cli/developing-capacitor-apps/configuring-capacitor
capacitor: { capacitor: {
hideSplashscreen: true, hideSplashscreen: true
}, },
// Full list of options: https://quasar.dev/quasar-cli/developing-electron-apps/configuring-electron // Full list of options: https://quasar.dev/quasar-cli/developing-electron-apps/configuring-electron
@ -197,11 +195,13 @@ module.exports = configure(function (/* ctx */) {
packager: { packager: {
// https://github.com/electron-userland/electron-packager/blob/master/docs/api.md#options // https://github.com/electron-userland/electron-packager/blob/master/docs/api.md#options
// OS X / Mac App Store // OS X / Mac App Store
// appBundleId: '', // appBundleId: '',
// appCategoryType: '', // appCategoryType: '',
// osxSign: '', // osxSign: '',
// protocol: 'myapp://path', // protocol: 'myapp://path',
// Windows only // Windows only
// win32metadata: { ... } // win32metadata: { ... }
}, },
@ -209,7 +209,7 @@ module.exports = configure(function (/* ctx */) {
builder: { builder: {
// https://www.electron.build/configuration/configuration // https://www.electron.build/configuration/configuration
appId: 'flaschengeist-frontend', appId: 'flaschengeist-frontend'
}, },
// More info: https://quasar.dev/quasar-cli/developing-electron-apps/node-integration // More info: https://quasar.dev/quasar-cli/developing-electron-apps/node-integration
@ -218,10 +218,7 @@ module.exports = configure(function (/* ctx */) {
extendWebpack (/* cfg */) { extendWebpack (/* cfg */) {
// do something with Electron main process Webpack cfg // do something with Electron main process Webpack cfg
// chainWebpack also available besides this extendWebpack // chainWebpack also available besides this extendWebpack
}, }
}, }
bin: { }
linuxAndroidStudio: '/home/crimsen/.local/share/JetBrains/Toolbox/scripts/studio',
},
};
}); });

View File

@ -1,13 +0,0 @@
{
"appId": "dev.flaschengeist",
"appName": "flaschengeist-frontend",
"bundledWebRuntime": false,
"npmClient": "yarn",
"webDir": "www",
"android": {
"minWebViewVersion": 71
},
"ios": {
"allowsLinkPreview": false
}
}

View File

@ -1,35 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Quasar</title>
<meta charset="utf-8">
<meta name="description" content="Quasar Capacitor App">
<meta name="format-detection" content="telephone=no">
<meta name="msapplication-tap-highlight" content="no">
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, viewport-fit=cover">
<style>
.page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
text-align: center;
}
</style>
</head>
<body>
<div class="page">
<div>
This file will be auto-generated. Do not edit.
</div>
<div>
Run "quasar dev" or "quasar build" with Capacitor mode.
</div>
</div>
</body>
</html>

View File

@ -1,16 +0,0 @@
{
"name": "flaschengeist",
"version": "2.0.0",
"description": "Modular student club administration system",
"author": "Tim Gröger <flaschengeist@wu5.de>",
"private": true,
"dependencies": {
"@capacitor/android": "^5.0.0-beta.0",
"@capacitor/app": "^5.0.0",
"@capacitor/cli": "^5.0.0",
"@capacitor/core": "^5.0.0",
"@capacitor/ios": "^5.0.0",
"@capacitor/preferences": "^5.0.0",
"@capacitor/splash-screen": "^5.0.0"
}
}

8
src-cordova/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
.DS_Store
# Generated by package manager
node_modules/
# Generated by Cordova
/plugins/
/platforms/

76
src-cordova/config.xml Normal file
View File

@ -0,0 +1,76 @@
<?xml version='1.0' encoding='utf-8'?>
<widget id="de.wu5.flaschengeist" version="2.0.0-alpha.1" xmlns="http://www.w3.org/ns/widgets" xmlns:cdv="http://cordova.apache.org/ns/1.0">
<name>Flaschengeist</name>
<description>Modular student club administration system</description>
<author email="dev@cordova.apache.org" href="http://cordova.io">
Apache Cordova Team
</author>
<content src="index.html" />
<access origin="*" />
<allow-intent href="http://*/*" />
<allow-intent href="https://*/*" />
<allow-intent href="tel:*" />
<allow-intent href="sms:*" />
<allow-intent href="mailto:*" />
<allow-intent href="geo:*" />
<platform name="android">
<allow-intent href="market:*" />
<icon density="ldpi" src="res/android/ldpi.png" />
<icon density="mdpi" src="res/android/mdpi.png" />
<icon density="hdpi" src="res/android/hdpi.png" />
<icon density="xhdpi" src="res/android/xhdpi.png" />
<icon density="xxhdpi" src="res/android/xxhdpi.png" />
<icon density="xxxhdpi" src="res/android/xxxhdpi.png" />
<splash density="land-ldpi" src="res/screen/android/splash-land-ldpi.png" />
<splash density="port-ldpi" src="res/screen/android/splash-port-ldpi.png" />
<splash density="land-mdpi" src="res/screen/android/splash-land-mdpi.png" />
<splash density="port-mdpi" src="res/screen/android/splash-port-mdpi.png" />
<splash density="land-hdpi" src="res/screen/android/splash-land-hdpi.png" />
<splash density="port-hdpi" src="res/screen/android/splash-port-hdpi.png" />
<splash density="land-xhdpi" src="res/screen/android/splash-land-xhdpi.png" />
<splash density="port-xhdpi" src="res/screen/android/splash-port-xhdpi.png" />
<splash density="land-xxhdpi" src="res/screen/android/splash-land-xxhdpi.png" />
<splash density="port-xxhdpi" src="res/screen/android/splash-port-xxhdpi.png" />
<splash density="land-xxxhdpi" src="res/screen/android/splash-land-xxxhdpi.png" />
<splash density="port-xxxhdpi" src="res/screen/android/splash-port-xxxhdpi.png" />
</platform>
<platform name="ios">
<allow-intent href="itms:*" />
<allow-intent href="itms-apps:*" />
<icon height="57" src="res/ios/icon.png" width="57" />
<icon height="114" src="res/ios/icon@2x.png" width="114" />
<icon height="40" src="res/ios/icon-20@2x.png" width="40" />
<icon height="60" src="res/ios/icon-20@3x.png" width="60" />
<icon height="29" src="res/ios/icon-29.png" width="29" />
<icon height="58" src="res/ios/icon-29@2x.png" width="58" />
<icon height="87" src="res/ios/icon-29@3x.png" width="87" />
<icon height="80" src="res/ios/icon-40@2x.png" width="80" />
<icon height="120" src="res/ios/icon-60@2x.png" width="120" />
<icon height="180" src="res/ios/icon-60@3x.png" width="180" />
<icon height="20" src="res/ios/icon-20.png" width="20" />
<icon height="40" src="res/ios/icon-40.png" width="40" />
<icon height="50" src="res/ios/icon-50.png" width="50" />
<icon height="100" src="res/ios/icon-50@2x.png" width="100" />
<icon height="72" src="res/ios/icon-72.png" width="72" />
<icon height="144" src="res/ios/icon-72@2x.png" width="144" />
<icon height="76" src="res/ios/icon-76.png" width="76" />
<icon height="152" src="res/ios/icon-76@2x.png" width="152" />
<icon height="167" src="res/ios/icon-83.5@2x.png" width="167" />
<icon height="1024" src="res/ios/icon-1024.png" width="1024" />
<icon height="48" src="res/ios/icon-24@2x.png" width="48" />
<icon height="55" src="res/ios/icon-27.5@2x.png" width="55" />
<icon height="88" src="res/ios/icon-44@2x.png" width="88" />
<icon height="172" src="res/ios/icon-86@2x.png" width="172" />
<icon height="196" src="res/ios/icon-98@2x.png" width="196" />
<splash src="res/screen/ios/Default@2x~iphone~anyany.png" />
<splash src="res/screen/ios/Default@2x~iphone~comany.png" />
<splash src="res/screen/ios/Default@2x~iphone~comcom.png" />
<splash src="res/screen/ios/Default@3x~iphone~anyany.png" />
<splash src="res/screen/ios/Default@3x~iphone~anycom.png" />
<splash src="res/screen/ios/Default@3x~iphone~comany.png" />
<splash src="res/screen/ios/Default@2x~ipad~anyany.png" />
<splash src="res/screen/ios/Default@2x~ipad~comany.png" />
</platform>
<allow-navigation href="about:*" />
<preference name="SplashMaintainAspectRatio" value="true" />
</widget>

View File

@ -5,6 +5,6 @@ import 'quasar/dist/types/feature-flag';
declare module 'quasar/dist/types/feature-flag' { declare module 'quasar/dist/types/feature-flag' {
interface QuasarFeatureFlags { interface QuasarFeatureFlags {
capacitor: true; cordova: true;
} }
} }

1957
src-cordova/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
src-cordova/package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "de.wu5.flaschengeist",
"displayName": "Flaschengeist",
"version": "1.0.0",
"description": "A sample Apache Cordova application that responds to the deviceready event.",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"ecosystem:cordova"
],
"author": "Apache Cordova Team",
"license": "Apache-2.0",
"devDependencies": {
"cordova-android": "^9.0.0",
"cordova-ios": "^6.1.1",
"cordova-plugin-splashscreen": "^6.0.0",
"cordova-plugin-whitelist": "^1.3.4"
},
"cordova": {
"plugins": {
"cordova-plugin-whitelist": {},
"cordova-plugin-splashscreen": {}
},
"platforms": [
"ios",
"android"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 913 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 702 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1022 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 801 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 969 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 529 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1015 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 702 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 885 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 996 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@ -1,71 +1,20 @@
/**
* This boot file registers interceptors for axios
*/
import { useMainStore, api } from '@flaschengeist/api'; import { useMainStore, api } from '@flaschengeist/api';
import { LocalStorage, Notify } from 'quasar';
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
import { boot } from 'quasar/wrappers'; import { boot } from 'quasar/wrappers';
import config from 'src/config'; import config from 'src/config';
import { clone } from '@flaschengeist/api';
/**
* Minify data sent to backend server
*
* Drop unneeded entities which can be identified by ID.
*
* @param obj Object to minify
* @param cloned If this entity is already cloned (JSON En+Decoded)
* @returns Minified object (some types are converted, like a Date object is now a ISO string)
*/
function minify(entity: unknown, cloned = false) {
if (!cloned) entity = clone(entity);
if (typeof entity === 'object') {
const obj = entity as { [index: string]: unknown };
for (const prop in obj) {
if (obj.hasOwnProperty(prop) && !!obj[prop]) {
if (Array.isArray(obj[prop])) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
obj[prop] = (<Array<unknown>>obj[prop]).map((v) => minify(v, true));
} else if (
typeof obj[prop] === 'object' &&
Object.keys(<object>obj[prop]).includes('id') &&
typeof (<{ id: unknown }>obj[prop])['id'] === 'number' &&
!isNaN((<{ id: number }>obj[prop])['id'])
) {
obj[prop] = (<{ id: unknown }>obj[prop])['id'];
}
}
}
return obj;
}
return entity;
}
export default boot(({ router }) => { export default boot(({ router }) => {
// Persisted value is read in plugins.ts boot file! api.defaults.baseURL = LocalStorage.getItem<string>('baseURL') || config.baseURL;
if (api.defaults.baseURL === undefined) api.defaults.baseURL = config.baseURL;
/*** /***
* Intercept requests * Intercept requests and insert Token if available
* - insert Token if available
* - minify JSON requests
*/ */
api.interceptors.request.use((config) => { api.interceptors.request.use((config) => {
const store = useMainStore(); const store = useMainStore();
if (store.session?.token) { if (store.session?.token) {
config.headers = Object.assign(config.headers || {}, { config.headers = { Authorization: 'Bearer ' + store.session.token };
Authorization: `Bearer ${store.session.token}`,
});
} }
// Minify JSON requests
if (
!!config.data &&
(config.headers === undefined ||
config.headers['Content-Type'] === undefined ||
config.headers['Content-Type'] === 'application/json')
)
config.data = minify(config.data);
return config; return config;
}); });
@ -94,11 +43,12 @@ export default boot(({ router }) => {
query: { redirect: next }, query: { redirect: next },
}); });
} else if (e.response && e.response.status == 401) { } else if (e.response && e.response.status == 401) {
store.handleLoggedOut(); void store.handleLoggedOut();
if (current.name != 'login') { if (current.name !== 'login') {
await router.push({ await router.push({
name: 'login', name: 'login',
query: { redirect: current.fullPath }, params: { logout: 'logout' },
query: { redirect: current.path },
}); });
} }
} }
@ -107,3 +57,19 @@ export default boot(({ router }) => {
} }
); );
}); });
export { api };
export const setBaseURL = (url: string) => {
LocalStorage.set('baseURL', url);
api.defaults.baseURL = url;
Notify.create({
message: 'Serveraddresse gespeichert',
position: 'bottom',
caption: `${url}`,
color: 'positive',
});
setTimeout(() => {
window.location.reload();
}, 5000);
};

View File

@ -1,97 +0,0 @@
/**
* This boot file initalizes the store from persistent storage and load all plugins
*/
import {
PersistentStorage,
api,
isAxiosError,
saveSession,
useMainStore,
} from '@flaschengeist/api';
import { Notify, Platform } from 'quasar';
import { loadPlugins } from './plugins';
import { boot } from 'quasar/wrappers';
import routes from 'src/router/routes';
async function loadBaseUrl() {
try {
const url = await PersistentStorage.get<string>('baseURL');
if (url !== null) api.defaults.baseURL = url;
} catch (e) {
console.warn('Could not load BaseURL', e);
}
}
// eslint-disable-next-line
class BackendError extends Error { }
/**
* Loading backend information
* @returns Backend object or null
*/
async function getBackend() {
const { data } = await api.get<FG.Backend>('/');
if (!data || typeof data !== 'object' || !('plugins' in data))
throw new BackendError('Invalid backend response received');
return data;
}
/**
* Boot file for loading baseURL + Session from PersistentStorage + loading and initializing all plugins
*/
export default boot(async ({ app, router }) => {
const store = useMainStore();
// FIRST(!) get the base URL
await loadBaseUrl();
// Init the store, load current session and user, if available
try {
await store.init();
} finally {
// Any changes on the session is written back to the persistent store
store.$subscribe((mutation, state) => {
saveSession(state.session);
});
}
// Load all plugins
try {
// Fetch backend data
const backend = await getBackend();
// Load enabled plugins
const flaschengeist = await loadPlugins(backend, routes);
// Add loaded routes to router
flaschengeist.routes.forEach((route) => router.addRoute(route));
// save plugins in VM-variable
app.provide('flaschengeist', flaschengeist);
} catch (error) {
// Handle errors from loading the backend information
if (error instanceof BackendError || isAxiosError(error)) {
router.isReady().finally(() => {
// if (Platform.is.capacitor) void router.push({ name: 'setup_backend' });
if (Platform.is.capacitor) {
//void router.push({ name: 'setup_backend' })
Notify.create({
type: 'negative',
message:
'Backend nicht erreichbar! Prüfe deine Internetverbindung oder probiere es später nochmal.',
timeout: 0,
icon: 'mdi-alert-circle-outline',
closeBtn: true,
});
} else void router.push({ name: 'offline', params: { refresh: 1 } });
});
} else if (typeof error === 'string') {
// Handle plugin not found errors
void router.push({ name: 'error' });
Notify.create({
type: 'negative',
message: `Fehler beim Laden: Bitte wende dich an den Admin (${error})!`,
timeout: 10000,
progress: true,
});
} else {
console.error('Unknown error in init.ts:', error);
}
}
});

14
src/boot/loading.ts Normal file
View File

@ -0,0 +1,14 @@
import { boot } from 'quasar/wrappers';
import { Loading } from 'quasar';
//import DarkCircularProgress from 'components/loading/DarkCircularProgress.vue';
// "async" is optional;
// more info on params: https://quasar.dev/quasar-cli/cli-documentation/boot-files#Anatomy-of-a-boot-file
export default boot(() => {
Loading.setDefaults({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// spinner: DarkCircularProgress,
// TODO : Das funktioniert wohl erstmal nicht mehr... gibt ne exception
});
});

View File

@ -1,33 +1,42 @@
/**
* This boot file registers login / authentification related axios interceptors
*/
import { useMainStore, hasPermissions } from '@flaschengeist/api'; import { useMainStore, hasPermissions } from '@flaschengeist/api';
import { boot } from 'quasar/wrappers'; import { boot } from 'quasar/wrappers';
import { RouteRecord } from 'vue-router';
export default boot(({ router }) => { export default boot(({ router }) => {
/** router.beforeResolve((to, from, next) => {
* Login guard
* Check if user tries to access the secured area and validates token
*/
router.beforeEach((to, from) => {
const store = useMainStore(); const store = useMainStore();
// Skip loops if (to.path == from.path) return next();
if (to.name == 'login' && from.name == 'login') return false;
// Secured area '/in/...' requires to be authenticated if (to.path.startsWith('/main')) {
if (to.path.startsWith('/in') && (!store.session || store.session.expires <= new Date())) { // Secured area (LOGIN REQUIRED)
store.handleLoggedOut(); // Check login is ok
return { name: 'login' }; if (!store.session || store.session.expires <= new Date()) {
void store.handleLoggedOut();
return next({ name: 'login', query: { redirect: to.fullPath } });
}
// Check if special permissions are required
if (
to.matched.every((record: RouteRecord) => {
if (!('meta' in record) || !('permissions' in record.meta)) return true;
if ((<{ permissions: FG.Permission[] }>record.meta).permissions) {
return hasPermissions((<{ permissions: FG.Permission[] }>record.meta).permissions);
}
})
) {
return next();
} else {
return next({ name: 'login', query: { redirect: to.fullPath } });
}
} else {
if (to.name == 'login' && store.user && !to.params['logout']) {
// Called login while already logged in
return next({ name: 'dashboard' });
} else {
// We are on the non secured area
return next();
}
} }
}); });
/**
* Permission guard
* Check permissions for route, cancel navigation on errors
*/
router.beforeResolve((to) => {
if (!!to.meta.permissions && !hasPermissions(<FG.Permission[]>to.meta.permissions))
return false;
});
}); });

View File

@ -1,5 +1,10 @@
import { FG_Plugin } from '@flaschengeist/types'; import { Notify } from 'quasar';
import { api } from 'src/boot/axios';
import { boot } from 'quasar/wrappers';
import routes from 'src/router/routes';
import { AxiosResponse } from 'axios';
import { RouteRecordRaw } from 'vue-router'; import { RouteRecordRaw } from 'vue-router';
import { FG_Plugin } from '@flaschengeist/types';
/**************************************************** /****************************************************
******** Internal area for some magic ************** ******** Internal area for some magic **************
@ -16,10 +21,41 @@ function validatePlugin(plugin: FG_Plugin.Plugin) {
); );
} }
/* eslint-disable */
// This functions are used by webpack magic
// Called when import promise resolved
function success(value: ImportPlgn, path: string) {
if (validatePlugin(value.default)) PLUGINS.plugins.set(value.default.id, value.default);
else failure(path);
}
// Called when import promise rejected
function failure(path = 'unknown') {
console.error(`Plugin ${path} could not be found and not imported`);
}
/* eslint-enable */
// Here does some magic happens, WebPack will automatically replace the following comment with the import statements // Here does some magic happens, WebPack will automatically replace the following comment with the import statements
const PLUGINS = <Array<Promise<ImportPlgn>>>[ const PLUGINS = {
context: <Array<() => Promise<ImportPlgn>>>[
/*INSERT_PLUGIN_LIST*/ /*INSERT_PLUGIN_LIST*/
]; ],
plugins: new Map<string, FG_Plugin.Plugin>(),
};
interface BackendPlugin {
permissions: string[];
version: string;
}
interface BackendPlugins {
[key: string]: BackendPlugin;
}
interface Backend {
plugins: BackendPlugins;
version: string;
}
export { Backend };
// Handle Notifications // Handle Notifications
export const translateNotification = (note: FG.Notification): FG_Plugin.Notification => note; export const translateNotification = (note: FG.Notification): FG_Plugin.Notification => note;
@ -192,10 +228,10 @@ function combineShortcuts(target: FG_Plugin.Shortcut[], source: FG_Plugin.MenuRo
function loadPlugin( function loadPlugin(
loadedPlugins: FG_Plugin.Flaschengeist, loadedPlugins: FG_Plugin.Flaschengeist,
plugin: FG_Plugin.Plugin, plugin: FG_Plugin.Plugin,
backend: FG.Backend backend: Backend
) { ) {
// Check if already loaded // Check if already loaded
if (loadedPlugins.plugins.findIndex((p) => p.id === plugin.id) !== -1) return true; if (loadedPlugins.plugins.findIndex((p) => p.name === plugin.name) !== -1) return true;
// Check backend dependencies // Check backend dependencies
if ( if (
@ -206,7 +242,7 @@ function loadPlugin(
true) /* validate the version, semver440 from python is... tricky on node*/ true) /* validate the version, semver440 from python is... tricky on node*/
) )
) { ) {
console.error(`Plugin ${plugin.id}: Backend modules not satisfied`); console.error(`Plugin ${plugin.name}: Backend modules not satisfied`);
return false; return false;
} }
@ -230,18 +266,11 @@ function loadPlugin(
} }
if (plugin.widgets.length > 0) { if (plugin.widgets.length > 0) {
plugin.widgets.forEach((widget) => (widget.name = plugin.id + '.' + widget.name)); plugin.widgets.forEach((widget) => (widget.name = plugin.name + '_' + widget.name));
Array.prototype.push.apply(loadedPlugins.widgets, plugin.widgets); Array.prototype.push.apply(loadedPlugins.widgets, plugin.widgets);
} }
if (!plugin.settingWidgets) plugin.settingWidgets = [];
if (plugin.settingWidgets.length > 0) {
plugin.settingWidgets.forEach((widget) => (widget.name = plugin.id + '.' + widget.name));
Array.prototype.push.apply(loadedPlugins.settingWidgets, plugin.settingWidgets);
}
loadedPlugins.plugins.push({ loadedPlugins.plugins.push({
id: plugin.id,
name: plugin.name, name: plugin.name,
version: plugin.version, version: plugin.version,
notification: plugin.notification?.bind({}) || translateNotification, notification: plugin.notification?.bind({}) || translateNotification,
@ -250,46 +279,73 @@ function loadPlugin(
return true; return true;
} }
export async function loadPlugins(backend: FG.Backend, baseRoutes: RouteRecordRaw[]) { /**
* Loading backend information
* @returns Backend object or null
*/
async function getBackend() {
try {
const { data }: AxiosResponse<Backend> = await api.get('/');
return data;
} catch (e) {
console.warn(e);
return null;
}
}
/**
* Boot file, load all required plugins, check for dependencies
*/
export default boot(async ({ router, app }) => {
const backend = await getBackend();
if (!backend || typeof backend !== 'object' || !('plugins' in backend)) {
console.log('Backend error');
router.isReady().finally(() => void router.push({ name: 'offline', params: { refresh: 1 } }));
return;
}
const loadedPlugins: FG_Plugin.Flaschengeist = { const loadedPlugins: FG_Plugin.Flaschengeist = {
routes: baseRoutes, routes,
plugins: [], plugins: [],
menuLinks: [], menuLinks: [],
shortcuts: [], shortcuts: [],
outerShortcuts: [], outerShortcuts: [],
widgets: [], widgets: [],
settingWidgets: [],
}; };
// Wait for all plugins to be loaded const BreakError = {};
const results = await Promise.allSettled(PLUGINS); try {
PLUGINS.plugins.forEach((plugin, name) => {
if (!loadPlugin(loadedPlugins, plugin, backend)) {
void router.push({ name: 'error' });
// Check if loaded successfully Notify.create({
results.forEach((result) => { type: 'negative',
if (result.status === 'rejected') { message: `Fehler beim Laden: Bitte wende dich an den Admin (error: PNF-${name}!`,
throw <string>result.reason; timeout: 10000,
} else { progress: true,
if ( });
!( throw BreakError;
validatePlugin(result.value.default) &&
loadPlugin(loadedPlugins, result.value.default, backend)
)
)
throw result.value.default.id;
} }
}); });
} catch (e) {
if (e !== BreakError) throw e;
}
// Sort widgets by priority // Sort widgets by priority
/** @todo Remove priority with first beta */ /** @todo Remove priority with first beta */
loadedPlugins.widgets.sort( loadedPlugins.widgets.sort(
(a, b) => <number>(b.order || b.priority) - <number>(a.order || a.priority) (a, b) => <number>(b.order || b.priority) - <number>(a.order || a.priority)
); );
/** @todo Can be cleaned up with first beta */ /** @todo Can be cleaned up with first beta */
loadedPlugins.menuLinks.sort((a, b) => { loadedPlugins.menuLinks.sort((a, b) => {
const diff = a.order && b.order ? b.order - a.order : 0; const diff = a.order && b.order ? b.order - a.order : 0;
return diff ? diff : a.title.toString().localeCompare(b.title.toString()); return diff ? diff : a.title.toString().localeCompare(b.title.toString());
}); });
return loadedPlugins; // Add loaded routes to router
} loadedPlugins.routes.forEach((route) => router.addRoute(route));
// save plugins in VM-variable
app.provide('flaschengeist', loadedPlugins);
});

View File

@ -1,9 +1,9 @@
/** import { useMainStore, pinia } from '@flaschengeist/api';
* This boot file installs the global pinia instance
*/
import { pinia } from '@flaschengeist/api';
import { boot } from 'quasar/wrappers'; import { boot } from 'quasar/wrappers';
export default boot(({ app }) => { export default boot(({ app }) => {
app.use(pinia); app.use(pinia);
const store = useMainStore();
void store.init();
}); });

View File

@ -1,52 +1,34 @@
<template> <template>
<q-card <q-card
bordered bordered
class="row q-ma-xs q-pa-xs"
style="position: relative; min-height: 3em" style="position: relative; min-height: 3em"
:class="{ 'cursor-pointer': modelValue.link }" :class="{ 'cursor-pointer': modelValue.link }"
@click="click" @click="click"
> >
<div class="col-12 text-weight-light">{{ dateString }}</div>
<div :id="`ntfctn-${modelValue.id}`" class="col-12">{{ modelValue.text }}</div>
<q-btn <q-btn
round round
dense dense
icon="mdi-trash-can" icon="mdi-close"
size="sm" size="sm"
color="negative" color="negative"
class="q-ma-xs" class="q-ma-xs"
title="Löschen" style="position: absolute; top: 0; right: 0"
style="position: absolute; top: 0; right: 0; z-index: 999" @click="remove"
@click.stop.prevent="dismiss"
/> />
<q-card-section class="q-pa-xs">
<div class="text-overline">{{ dateString }}</div>
<q-item style="padding: 1px">
<q-item-section v-if="modelValue.icon" side
><q-icon color="primary" :name="modelValue.icon"
/></q-item-section>
<q-item-section>{{ modelValue.text }}</q-item-section>
</q-item>
</q-card-section>
<q-card-actions v-if="modelValue.reject || modelValue.accept">
<q-btn <q-btn
v-if="modelValue.accept" v-if="modelValue.accept !== undefined"
round
dense
icon="mdi-check" icon="mdi-check"
size="sm"
color="positive" color="positive"
label="Annehmen" class="q-ma-xs"
flat style="position: absolute; top: 0; right: 3em"
dense @click="accept"
size="sm"
@click.stop.prevent="accept"
/> />
<q-btn
v-if="modelValue.reject"
icon="mdi-close"
color="negative"
label="Ablehnen"
flat
dense
size="sm"
@click.stop.prevent="reject"
/>
</q-card-actions>
</q-card> </q-card>
</template> </template>
@ -81,19 +63,13 @@ export default defineComponent({
else emit('remove', props.modelValue.id); else emit('remove', props.modelValue.id);
} }
function reject() { function remove() {
if (typeof props.modelValue.reject === 'function') if (typeof props.modelValue.reject === 'function')
void props.modelValue.reject().finally(() => emit('remove', props.modelValue.id)); void props.modelValue.reject().finally(() => emit('remove', props.modelValue.id));
else emit('remove', props.modelValue.id); else emit('remove', props.modelValue.id);
} }
function dismiss() { return { accept, click, dateString, remove };
if (typeof props.modelValue.dismiss === 'function')
void props.modelValue.dismiss().finally(() => emit('remove', props.modelValue.id));
else emit('remove', props.modelValue.id);
}
return { accept, click, dateString, dismiss, reject };
}, },
}); });
</script> </script>

View File

@ -27,7 +27,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, PropType } from 'vue'; import { computed, defineComponent, PropType } from 'vue';
import { hasPermissions } from '@flaschengeist/api'; import { hasPermissions } from '@flaschengeist/api';
import { FG_Plugin } from '@flaschengeist/types'; import { FG_Plugin } from '@flaschengeist/types';
@ -43,12 +43,12 @@ export default defineComponent({
emits: { emits: {
addShortCut: (val: FG_Plugin.MenuLink) => val.link, addShortCut: (val: FG_Plugin.MenuLink) => val.link,
}, },
setup(_, { emit }) { setup(props, { emit }) {
function isGranted(val: FG_Plugin.MenuLink) { function isGranted(val: FG_Plugin.MenuLink) {
return hasPermissions(val.permissions || []); return computed(() => hasPermissions(val.permissions || []));
} }
function getTitle(entry: FG_Plugin.MenuLink) { function getTitle(entry: FG_Plugin.MenuLink) {
return typeof entry.title === 'function' ? entry.title() : entry.title; return computed(() => (typeof entry.title === 'function' ? entry.title() : entry.title)).value;
} }
function addShortCut(val: FG_Plugin.MenuLink) { function addShortCut(val: FG_Plugin.MenuLink) {

View File

@ -2,7 +2,7 @@
<q-layout view="hHh Lpr lff"> <q-layout view="hHh Lpr lff">
<q-header elevated class="bg-primary text-white"> <q-header elevated class="bg-primary text-white">
<q-toolbar> <q-toolbar>
<q-btn dense flat round icon="mdi-menu" @click="openMenu(true)" /> <q-btn dense flat round icon="mdi-menu" @click="openMenu" />
<q-toolbar-title> <q-toolbar-title>
<router-link :to="{ name: 'dashboard' }" style="text-decoration: none; color: inherit"> <router-link :to="{ name: 'dashboard' }" style="text-decoration: none; color: inherit">
@ -18,12 +18,8 @@
<q-badge color="negative" floating> <q-badge color="negative" floating>
{{ notifications.length }} {{ notifications.length }}
</q-badge> </q-badge>
<q-menu max-height="400px" style="min-width: 290px" class="q-pa-xs"> <q-menu style="max-height: 400px; overflow: auto">
<q-btn <q-btn v-if="useNative && noPermission" label="Benachrichtigungen erlauben" @click="requestPermission" />
v-if="useNative && noPermission"
label="Benachrichtigungen erlauben"
@click="requestPermission"
/>
<template v-if="notifications.length > 0"> <template v-if="notifications.length > 0">
<Notification <Notification
v-for="(notification, index) in notifications" v-for="(notification, index) in notifications"
@ -40,26 +36,12 @@
<shortcut-link :shortcut="element" context @delete-shortcut="deleteShortcut" /> <shortcut-link :shortcut="element" context @delete-shortcut="deleteShortcut" />
</template> </template>
</drag> </drag>
<q-btn <q-btn flat round dense icon="mdi-exit-to-app" @click="logout()" />
v-if="!platform.is.capacitor"
flat
round
dense
icon="mdi-exit-to-app"
@click="logout()"
/>
</q-toolbar> </q-toolbar>
</q-header> </q-header>
<q-drawer <q-drawer v-model="leftDrawer" side="left" bordered :mini="leftDrawerMini" @click.capture="openMenu">
v-model="leftDrawer"
side="left"
bordered
:mini="leftDrawerMini"
@click.capture="openMenuMini"
>
<!-- Plugins --> <!-- Plugins -->
<q-scroll-area class="fit">
<essential-expansion-link <essential-expansion-link
v-for="(entry, index) in mainLinks" v-for="(entry, index) in mainLinks"
:key="'plugin' + index" :key="'plugin' + index"
@ -67,34 +49,7 @@
@add-short-cut="addShortcut" @add-short-cut="addShortcut"
/> />
<q-separator /> <q-separator />
<essential-link <essential-link v-for="(entry, index) in essentials" :key="'essential' + index" :entry="entry" />
v-for="(entry, index) in essentials"
:key="'essential' + index"
:entry="entry"
/>
<div v-if="platform.is.capacitor">
<q-separator />
<q-item clickable tag="a" target="self" @click="logout">
<q-item-section avatar>
<q-icon name="mdi-exit-to-app" />
</q-item-section>
<q-item-section>
<q-item-label>Logout</q-item-label>
</q-item-section>
</q-item>
</div>
</q-scroll-area>
<div class="q-mini-drawer-hide absolute" style="top: 15px; right: -17px">
<q-btn
dense
round
unelevated
color="accent"
icon="mdi-chevron-left"
@click="openMenuMini(true)"
/>
</div>
</q-drawer> </q-drawer>
<q-page-container> <q-page-container>
<router-view /> <router-view />
@ -108,7 +63,7 @@ import EssentialLink from 'src/components/navigation/EssentialLink.vue';
import ShortcutLink from 'src/components/navigation/ShortcutLink.vue'; import ShortcutLink from 'src/components/navigation/ShortcutLink.vue';
import Notification from 'src/components/Notification.vue'; import Notification from 'src/components/Notification.vue';
import { defineComponent, ref, inject, computed, onBeforeMount, onBeforeUnmount } from 'vue'; import { defineComponent, ref, inject, computed, onBeforeMount, onBeforeUnmount } from 'vue';
import { Screen, Platform } from 'quasar'; import { Screen } from 'quasar';
import config from 'src/config'; import config from 'src/config';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useMainStore } from '@flaschengeist/api'; import { useMainStore } from '@flaschengeist/api';
@ -136,8 +91,8 @@ export default defineComponent({
const router = useRouter(); const router = useRouter();
const mainStore = useMainStore(); const mainStore = useMainStore();
const flaschengeist = inject<FG_Plugin.Flaschengeist>('flaschengeist'); const flaschengeist = inject<FG_Plugin.Flaschengeist>('flaschengeist');
const leftDrawer = ref(!Platform.is.mobile); const leftDrawer = ref(true);
const leftDrawerMini = ref(true); const leftDrawerMini = ref(false);
const mainLinks = flaschengeist?.menuLinks || []; const mainLinks = flaschengeist?.menuLinks || [];
const notifications = computed(() => mainStore.notifications.slice().reverse()); const notifications = computed(() => mainStore.notifications.slice().reverse());
const polling = ref(NaN); const polling = ref(NaN);
@ -150,11 +105,9 @@ export default defineComponent({
void mainStore.getShortcuts(); void mainStore.getShortcuts();
}); });
onBeforeUnmount(() => window.clearInterval(polling.value)); onBeforeUnmount(() => window.clearInterval(polling.value));
/*
function openMenu(event: { target: HTMLInputElement }) { function openMenu(event: { target: HTMLInputElement }) {
console.log(event.target.nodeName); if (event.target.nodeName === 'DIV') leftDrawerMini.value = false;
if (event.target.nodeName === 'DIV' || event.target.nodeName === 'I')
leftDrawerMini.value = false;
else { else {
if (!leftDrawer.value || leftDrawerMini.value) { if (!leftDrawer.value || leftDrawerMini.value) {
leftDrawer.value = true; leftDrawer.value = true;
@ -165,13 +118,7 @@ export default defineComponent({
} }
} }
} }
*/
function openMenu(value = !leftDrawer.value) {
leftDrawer.value = value;
}
function openMenuMini(value = !leftDrawerMini.value) {
leftDrawerMini.value = value;
}
function logout() { function logout() {
void router.push({ name: 'login', params: { logout: 'logout' } }); void router.push({ name: 'login', params: { logout: 'logout' } });
void mainStore.logout(); void mainStore.logout();
@ -182,15 +129,11 @@ export default defineComponent({
} }
function requestPermission() { function requestPermission() {
void window.Notification.requestPermission().then( void window.Notification.requestPermission().then((p) => (noPermission.value = p !== 'granted'));
(p) => (noPermission.value = p !== 'granted')
);
} }
function pollNotification() { function pollNotification() {
void mainStore void mainStore.loadNotifications(<FG_Plugin.Flaschengeist>flaschengeist).then((notifications) => {
.loadNotifications(<FG_Plugin.Flaschengeist>flaschengeist)
.then((notifications) => {
if (useNative && !noPermission.value) if (useNative && !noPermission.value)
notifications.forEach( notifications.forEach(
(notif) => (notif) =>
@ -234,14 +177,12 @@ export default defineComponent({
notifications, notifications,
noPermission, noPermission,
openMenu, openMenu,
openMenuMini,
remove, remove,
requestPermission, requestPermission,
useNative, useNative,
shortCuts, shortCuts,
addShortcut, addShortcut,
deleteShortcut, deleteShortcut,
platform: Platform,
}; };
}, },
}); });

View File

@ -2,9 +2,9 @@
<q-page <q-page
padding padding
style="grid-auto-rows: 1fr" style="grid-auto-rows: 1fr"
class="row justify-center content-center items-center q-col-gutter-lg" class="fit row justify-around items-start q-col-gutter-sm"
> >
<div v-for="(item, index) in widgets" :key="index" class="full-height col-sm-6 col-xs-12"> <div v-for="(item, index) in widgets" :key="index" class="col-4 full-height col-sm-6 col-xs-12">
<component :is="item.widget" /> <component :is="item.widget" />
</div> </div>
</q-page> </q-page>
@ -16,12 +16,12 @@ import { hasPermissions } from '@flaschengeist/api';
import { FG_Plugin } from '@flaschengeist/types'; import { FG_Plugin } from '@flaschengeist/types';
export default defineComponent({ export default defineComponent({
name: 'PageDashboard', name: 'Dashboard',
setup() { setup() {
const flaschengeist = inject<FG_Plugin.Flaschengeist>('flaschengeist'); const flaschengeist = inject<FG_Plugin.Flaschengeist>('flaschengeist');
const widgets = computed(() => const widgets = computed(() => {
flaschengeist?.widgets.filter((widget) => hasPermissions(widget.permissions)) return flaschengeist?.widgets.filter((widget) => hasPermissions(widget.permissions));
); });
return { return {
widgets, widgets,

View File

@ -2,9 +2,9 @@
<q-page padding class="fit row justify-center items-center content-center"> <q-page padding class="fit row justify-center items-center content-center">
<q-card class="col-xs-11 col-sm-8 col-md-6 col-lg-4 justify-center items-center content-center"> <q-card class="col-xs-11 col-sm-8 col-md-6 col-lg-4 justify-center items-center content-center">
<q-toolbar class="bg-primary text-white"> <q-toolbar class="bg-primary text-white">
<q-toolbar-title>{{ backendSetup ? 'Servereinrichtung' : 'Login' }}</q-toolbar-title> <q-toolbar-title> Login </q-toolbar-title>
</q-toolbar> </q-toolbar>
<div v-if="!backendSetup">
<q-card-section> <q-card-section>
<q-form @submit="doLogin"> <q-form @submit="doLogin">
<q-input <q-input
@ -44,22 +44,21 @@
</q-card-section> </q-card-section>
<div class="row justify-end"> <div class="row justify-end">
<q-btn <q-btn
v-if="$q.platform.is.capacitor || $q.platform.is.electron" v-if="quasar.platform.is.cordova || quasar.platform.is.electron"
flat flat
round round
:icon="showBackendSetup ? 'mdi-menu-up' : 'mdi-menu-down'" icon="mdi-menu-down"
@click="showBackendSetup = !showBackendSetup" @click="openServerSettings"
/> />
</div> </div>
</div> <q-slide-transition v-if="quasar.platform.is.cordova || quasar.platform.is.electron">
<q-slide-transition v-if="$q.platform.is.capacitor"> <div v-show="visible">
<div v-show="showBackendSetup || backendSetup">
<q-separator /> <q-separator />
<q-card-section> <q-card-section>
<q-form ref="ServerSettingsForm" class="q-gutter-md" @submit="changeBackend"> <q-form ref="ServerSettingsForm" class="q-gutter-md" @submit="changeUrl">
<div class="text-h6">Backend einrichten</div> <div class="text-h6">Servereinstellung</div>
<q-input v-model="server" filled label="Server" dense /> <q-input v-model="server" filled label="Server" dense />
<q-btn dense color="primary" label="Speichern" type="submit" /> <q-btn size="xs" dense color="primary" label="Speichern" type="submit" />
</q-form> </q-form>
</q-card-section> </q-card-section>
</div> </div>
@ -70,45 +69,36 @@
<script lang="ts"> <script lang="ts">
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { Loading, Notify, useQuasar } from 'quasar'; import { Loading, Notify } from 'quasar';
import { api, notEmpty, PersistentStorage, useMainStore } from '@flaschengeist/api'; import { notEmpty, useMainStore, useUserStore } from '@flaschengeist/api';
import { PasswordInput } from '@flaschengeist/api/components'; import { PasswordInput } from '@flaschengeist/api/components';
import { defineComponent, onMounted, ref } from 'vue'; import { defineComponent, ref } from 'vue';
import { useSessionStore } from 'app/api'; import { setBaseURL, api } from 'boot/axios';
import { useQuasar } from 'quasar';
export default defineComponent({ export default defineComponent({
name: 'PageLogin', name: 'Login',
components: { PasswordInput }, components: { PasswordInput },
props: { setup() {
backendSetup: {
type: Boolean,
default: false,
},
},
setup(props) {
const mainRoute = { name: 'dashboard' };
const mainStore = useMainStore(); const mainStore = useMainStore();
const sessionStore = useSessionStore(); const userStore = useUserStore();
const mainRoute = { name: 'dashboard' };
const router = useRouter(); const router = useRouter();
const quasar = useQuasar();
onMounted(() => {
if (mainStore.session) void router.replace(mainRoute);
});
/* Stuff for the real login page */ /* Stuff for the real login page */
const userid = ref(''); const userid = ref('');
const password = ref(''); const password = ref('');
const server = ref(api.defaults.baseURL); const server = ref<string | undefined>(api.defaults.baseURL);
const showBackendSetup = ref(!!props.backendSetup); const visible = ref(false);
const quasar = useQuasar();
function changeBackend() { function openServerSettings() {
visible.value = !visible.value;
}
function changeUrl() {
if (server.value) { if (server.value) {
void PersistentStorage.set('baseURL', server.value).then(() => { setBaseURL(server.value);
showBackendSetup.value = false;
void router.go(0);
});
} }
} }
@ -117,31 +107,10 @@ export default defineComponent({
const status = await mainStore.login(userid.value, password.value); const status = await mainStore.login(userid.value, password.value);
if (status === true) { if (status === true) {
// On capacitor we set the lifetime to at least two weeks to not annoy users. mainStore.user = (await userStore.getUser(userid.value, true)) || undefined;
if (quasar.platform.is.capacitor) const x = router.currentRoute.value.query['redirect'];
await sessionStore.updateSession(14 * 24 * 60 * 60, mainStore.currentSession.token); void router.push(typeof x === 'string' ? { path: x } : mainRoute);
// Redirect user to previous page, if any.
// there are different redirects possible:
// 1. when explicitely entered
// a) http://localhost:8080/ -> should be redirected to mainRoute
// b) http://localhost:8080/in/user/settings -> should be redirected to in/user/settings
// 2. when automatically logged out:
// http://localhost:8080/login?redirect=/in/user/settings
// -> should be redirected to in/user/settings
var redirect;
if (router.currentRoute.value.redirectedFrom) {
redirect = router.currentRoute.value.redirectedFrom.path;
if (redirect === '/') {
redirect = mainRoute;
}
} else if ('redirect' in router.currentRoute.value.query) {
redirect = { path: router.currentRoute.value.query.redirect as string };
} else { } else {
redirect = mainRoute;
}
void router.push(redirect);
} else {
// Login failed, notify and reset form
password.value = ''; password.value = '';
if (status === 401) { if (status === 401) {
Notify.create({ Notify.create({
@ -176,8 +145,7 @@ export default defineComponent({
Notify.create({ Notify.create({
group: false, group: false,
type: 'ongoing', type: 'ongoing',
message: message: 'Sollte der Benutzername korrekt und vorhanden sein, erhälst du jetzt eine E-Mail.',
'Sollte der Benutzername korrekt und vorhanden sein, erhälst du jetzt eine E-Mail.',
timeout: 10000, timeout: 10000,
progress: true, progress: true,
actions: [{ icon: 'mdi-close', color: 'white' }], actions: [{ icon: 'mdi-close', color: 'white' }],
@ -186,14 +154,16 @@ export default defineComponent({
} }
return { return {
changeBackend, changeUrl,
doLogin, doLogin,
doReset, doReset,
notEmpty, notEmpty,
openServerSettings,
password, password,
userid,
server, server,
showBackendSetup, userid,
visible,
quasar,
}; };
}, },
}); });

View File

@ -39,15 +39,14 @@ import { defineComponent, ref, onUnmounted } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
export default defineComponent({ export default defineComponent({
name: 'PageOffline', name: 'Offline',
setup() { setup() {
const router = useRouter(); const router = useRouter();
const reload = ref(10); const reload = ref(10);
const ival = setInterval(() => { const ival = setInterval(() => {
reload.value -= 1; reload.value -= 1;
if (reload.value <= 0) { if (reload.value <= 0) {
if (router.currentRoute.value.params && 'refresh' in router.currentRoute.value.params) if (router.currentRoute.value.params && 'refresh' in router.currentRoute.value.params) router.go(0);
router.go(0);
const path = router.currentRoute.value.query.redirect; const path = router.currentRoute.value.query.redirect;
void router.replace(path ? { path: <string>path } : { name: 'login' }); void router.replace(path ? { path: <string>path } : { name: 'login' });
} }

View File

@ -38,7 +38,7 @@
import { defineComponent } from 'vue'; import { defineComponent } from 'vue';
export default defineComponent({ export default defineComponent({
name: 'PagePluginError', name: 'PluginError',
}); });
</script> </script>

View File

@ -40,7 +40,7 @@ import { Loading, Notify } from 'quasar';
import { defineComponent, ref } from 'vue'; import { defineComponent, ref } from 'vue';
export default defineComponent({ export default defineComponent({
name: 'PageReset', // name: 'PageName'
setup() { setup() {
const mainStore = useMainStore(); const mainStore = useMainStore();
const router = useRouter(); const router = useRouter();

View File

@ -92,7 +92,7 @@ const developers = [
}, },
]; ];
export default defineComponent({ export default defineComponent({
name: 'PageAbout', name: 'About',
components: { Developer }, components: { Developer },
setup() { setup() {
const plugins = inject<FG_Plugin.Flaschengeist>('flaschengeist')?.plugins || []; const plugins = inject<FG_Plugin.Flaschengeist>('flaschengeist')?.plugins || [];

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