Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • hansegucker/AlekSIS-Core
  • pinguin/AlekSIS-Core
  • AlekSIS/official/AlekSIS-Core
  • sunweaver/AlekSIS-Core
  • sggua/AlekSIS-Core
  • edward/AlekSIS-Core
  • magicfelix/AlekSIS-Core
7 results
Show changes
Commits on Source (70)
Showing
with 694 additions and 379 deletions
......@@ -3,12 +3,38 @@ module.exports = {
"eslint:recommended",
"plugin:vue/strongly-recommended",
"prettier",
"plugin:@intlify/vue-i18n/recommended",
],
rules: {
"no-unused-vars": "warn",
"vue/no-unused-vars": "off",
"vue/multi-word-component-names": "off",
"@intlify/vue-i18n/key-format-style": [
"error",
"snake_case",
{
splitByDots: false,
},
],
// "@intlify/vue-i18n/no-unused-keys": ["warn", {}],
"@intlify/vue-i18n/no-raw-text": [
"error",
{
ignoreNodes: ["v-icon"],
ignorePattern: "^[-–—·#:()\\[\\]&\\.\\s]+$",
},
],
},
settings: {
"vue-i18n": {
localeDir: "./aleksis/core/assets/messages/*.{json}",
messageSyntaxVersion: "^8.0.0",
},
},
env: {
es2021: true,
},
parserOptions: {
ecmaVersion: "latest",
},
};
import Vue from "vue";
import VueRouter from "@/vue-router";
import Vuetify from "@/vuetify";
import "@/@mdi/font/css/materialdesignicons.css";
import "@/vuetify/dist/vuetify.min.css";
import { ApolloClient, HttpLink, from } from "@/apollo-boost";
import VueApollo from "@/vue-apollo";
import { InMemoryCache } from "@/apollo-cache-inmemory";
import { onError } from "@/apollo-link-error";
import { RetryLink } from "@/apollo-link-retry";
import { persistCache, LocalStorageWrapper } from "@/apollo3-cache-persist";
import gql from "@/graphql-tag";
import gqlSnackbarItems from "./snackbarItems.graphql";
import "./css/global.scss";
import VueI18n from "@/vue-i18n";
import dateTimeFormats from "./dateTimeFormats.js";
import routes from "./routes.js";
Vue.use(Vuetify);
const vuetify = new Vuetify({
icons: {
iconfont: "mdi", // default - only for display purposes
values: {
cancel: "mdi-close-circle-outline",
delete: "mdi-close-circle-outline",
success: "mdi-check-circle-outline",
info: "mdi-information-outline",
warning: "mdi-alert-outline",
error: "mdi-alert-octagon-outline",
prev: "mdi-chevron-left",
next: "mdi-chevron-right",
checkboxOn: "mdi-checkbox-marked-outline",
checkboxIndeterminate: "mdi-minus-box-outline",
edit: "mdi-pencil-outline",
preferences: "mdi-cog-outline",
},
},
});
Vue.use(VueI18n);
const i18n = new VueI18n({
locale: "en",
fallbackLocale: "en",
messages: {},
dateTimeFormats,
});
// Using this function, apps can register their locale files
i18n.registerLocale = function (messages) {
for (let locale in messages) {
i18n.mergeLocaleMessage(locale, messages[locale]);
}
};
Vue.use(VueApollo);
Vue.use(VueRouter);
export const typeDefs = gql`
type snackbarItem {
id: ID!
messageKey: String!
color: String!
read: Boolean!
}
type globalState {
contentLoading: Boolean!
browserTitle: String
}
type Mutation {
checkSnackbarItem(id: ID!): Boolean
setLoading(state: Boolean!): Boolean
}
`;
const cache = new InMemoryCache();
const resolvers = {
Mutation: {
// eslint-disable-next-line no-unused-vars
checkSnackbarItem: (_, { id }, { cache }) => {
const data = cache.readQuery({ query: gqlSnackbarItems });
const currentItem = data.snackbarItems.find((item) => item.id === id);
currentItem.read = !currentItem.read;
cache.writeQuery({ query: gqlSnackbarItems, data });
return currentItem.read;
},
// eslint-disable-next-line
setLoading: (_, { state }, {}) => {
return true;
},
},
};
function addErrorSnackbarItem(messageKey) {
let uuid = crypto.randomUUID();
cache.writeQuery({
query: gqlSnackbarItems,
data: {
snackbarItems: [
{
__typename: "snackbarItem",
id: uuid,
messageKey: messageKey,
color: "red",
read: false,
},
],
},
variables: {
id: uuid,
},
});
}
const retryLink = new RetryLink();
const errorLink = onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors) addErrorSnackbarItem("graphql.snackbar_error_message");
if (networkError)
addErrorSnackbarItem("network_errors.snackbar_error_message");
});
const httpLink = new HttpLink({
uri: window.location.origin + "/graphql/",
});
// FIXME: has to be async to guarantee that no entries are cached before the cache is persisted.
persistCache({
cache: cache,
storage: new LocalStorageWrapper(window.localStorage),
});
const apolloClient = new ApolloClient({
typeDefs,
resolvers,
cache,
link: from([retryLink, errorLink, httpLink]),
});
import App from "./App.vue";
import MessageBox from "./components/MessageBox.vue";
Vue.component(MessageBox.name, MessageBox); // Load MessageBox globally as other components depend on it
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: apolloClient,
});
const router = new VueRouter({
mode: "history",
routes,
});
if (document.getElementById("sentry_settings") !== null) {
const Sentry = import("@sentry/vue");
const { BrowserTracing } = import("@sentry/tracing");
const sentry_settings = JSON.parse(
document.getElementById("sentry_settings").textContent
);
Sentry.init({
Vue,
dsn: sentry_settings.dsn,
environment: sentry_settings.environment,
tracesSampleRate: sentry_settings.traces_sample_rate,
logError: true,
integrations: [
new BrowserTracing({
routingInstrumentation: Sentry.vueRouterInstrumentation(router),
}),
],
});
}
const app = new Vue({
el: "#app",
apolloProvider,
vuetify: vuetify,
render: (h) => h(App),
data: () => ({
showCacheAlert: false,
contentLoading: false,
}),
router,
i18n,
});
// eslint-disable-next-line no-unused-vars
router.beforeEach((to, from, next) => {
app.contentLoading = true;
next();
});
// eslint-disable-next-line no-unused-vars
router.afterEach((to, from) => {
Vue.nextTick(() => {
app.contentLoading = false;
});
});
window.app = app;
window.router = router;
window.i18n = i18n;
import("./messages.js");
/*
* Configuration for Apollo provider, client, and caches.
*/
import { ApolloClient, HttpLink, from } from "@/apollo-boost";
import { RetryLink } from "@/apollo-link-retry";
import { persistCache, LocalStorageWrapper } from "@/apollo3-cache-persist";
import { InMemoryCache } from "@/apollo-cache-inmemory";
import gql from "@/graphql-tag";
import gqlSnackbarItems from "../components/app/snackbarItems.graphql";
// Cache for GraphQL query results in memory and persistent across sessions
const cache = new InMemoryCache();
await persistCache({
cache: cache,
storage: new LocalStorageWrapper(window.localStorage),
});
/**
* Type definitions for Apollo/s local state management
* cf. https://www.apollographql.com/docs/react/local-state/local-state-management/
*/
const typeDefs = gql`
type snackbarItem {
id: ID!
messageKey: String!
color: String!
read: Boolean!
}
type globalState {
contentLoading: Boolean!
browserTitle: String
}
type Mutation {
toggleSnackbarItem(id: ID!): Boolean
}
`;
/** Resolvers for local state management */
const resolvers = {
Mutation: {
/** Toggle snackbar item read or unread, given its ID */
toggleSnackbarItem: (_, { id }, { cache }) => {
const data = cache.readQuery({ query: gqlSnackbarItems });
const currentItem = data.snackbarItems.find((item) => item.id === id);
currentItem.read = !currentItem.read;
cache.writeQuery({ query: gqlSnackbarItems, data });
return currentItem.read;
},
},
};
/**
* Utility function to add a snackbar on GraphQL errors.
*/
function addErrorSnackbarItem(messageKey) {
let uuid = crypto.randomUUID();
cache.writeQuery({
query: gqlSnackbarItems,
data: {
snackbarItems: [
{
__typename: "snackbarItem",
id: uuid,
messageKey: messageKey,
color: "red",
read: false,
},
],
},
variables: {
id: uuid,
},
});
}
/**
* Construct the GraphQL endpoint URI.
*
* @returns The URI of the GraphQL endpoint on the AlekSIS server
*/
function getGraphqlURL() {
const settings = JSON.parse(
document.getElementById("frontend_settings").textContent
);
const base = settings.urls.base || window.location.origin;
return new URL(settings.urls.graphql, base);
}
// Define Apollo links for handling query operations.
const links = [
// Automatically retry failed queries
new RetryLink(),
// Finally, the HTTP link to the real backend (Django)
new HttpLink({
uri: getGraphqlURL(),
}),
];
/** Upstream Apollo GraphQL client */
const apolloClient = new ApolloClient({
typeDefs,
resolvers,
cache,
link: from(links),
});
const apolloOpts = {
defaultClient: apolloClient,
defaultOptions: {
$query: {
skip: (vm) => {
// We only want to run this query when background activity is on and we are not reported offline
return !vm.$root.backgroundActive || vm.$root.offline;
},
error: ({ graphQLErrors, networkError }, vm) => {
if (graphQLErrors) {
// Add a snackbar on all errors returned by the GraphQL endpoint
console.error("A GraphQL query failed on the server");
addErrorSnackbarItem("graphql.snackbar_error_message");
}
if (networkError) {
// Set app offline globally on network errors
// This will cause the offline logic to kick in, starting a ping check or
// similar recovery strategies depending on the app/navigator state
console.error(
"Network error during GraphQL query, setting offline state"
);
vm.$root.offline = true;
}
},
},
},
};
export default apolloOpts;
/** Date.time formats for VueI18n */
const dateTimeFormats = {
en: {
short: {
......
/*
* Configuration for VueI18n
*/
import dateTimeFormats from "./dateTimeFormats.js";
const i18nOpts = {
locale: "en",
fallbackLocale: "en",
messages: {},
dateTimeFormats,
};
export default i18nOpts;
/*
* Configuration for Vue router
*/
import routes from "../routes.js";
const routerOpts = {
mode: "history",
routes,
};
export default routerOpts;
/*
* Configuration for Vuetify
*/
import "@/@mdi/font/css/materialdesignicons.css";
import "@/vuetify/dist/vuetify.min.css";
import "../css/global.scss";
const vuetifyOpts = {
icons: {
iconfont: "mdi", // default - only for display purposes
values: {
cancel: "mdi-close-circle-outline",
delete: "mdi-close-circle-outline",
success: "mdi-check-circle-outline",
info: "mdi-information-outline",
warning: "mdi-alert-outline",
error: "mdi-alert-octagon-outline",
prev: "mdi-chevron-left",
next: "mdi-chevron-right",
checkboxOn: "mdi-checkbox-marked-outline",
checkboxIndeterminate: "mdi-minus-box-outline",
edit: "mdi-pencil-outline",
preferences: "mdi-cog-outline",
},
},
};
export default vuetifyOpts;
<!--
Base component to load legacy views from Django.
It loads the legacy view into an iframe and attaches some utility
code to it. The legacy application and the new Vue application can
communicate with each other through a message channel.
This helps during the migration from the pure SSR Django application
in AlekSIS 2.x to the pure Vue and GraphQL based application.
It will be removed once legacy view get unsupported.
-->
<template>
<div class="position: relative;">
<iframe
......@@ -27,15 +39,18 @@ export default {
},
},
methods: {
/** Receives a message from the legacy app inside the iframe */
receiveMessage(event) {
if (!event.data.height) {
return;
if (event.data.height) {
// The iframe communicated us its render height
// Set iframe to full height to prevent an inner scroll bar
this.iFrameHeight = event.data.height;
this.$root.contentLoading = false;
}
this.$root.contentLoading = false;
this.iFrameHeight = event.data.height;
},
/** Handle iframe data after inner page loaded */
load() {
// Write new location of iframe back to Vue Router and title of iframe to SPA window
// Write new location of iframe back to Vue Router
const location = this.$refs.contentIFrame.contentWindow.location;
const url = new URL(location);
const path = url.pathname.replace(/^\/django/, "");
......@@ -48,25 +63,22 @@ export default {
this.$router.push(path);
}
// Show loader if iframe starts to change it's content, even if the $route stays the same
// Show loader if iframe starts to change its content, even if the $route stays the same
this.$refs.contentIFrame.contentWindow.onpagehide = () => {
this.$root.contentLoading = true;
};
// Write title of iframe to SPA window
const title = this.$refs.contentIFrame.contentWindow.document.title;
document.title = title;
},
},
watch: {
$route() {
this.$root.contentLoading = true;
this.$root.$setPageTitle(title);
},
},
mounted() {
// Subscribe to message channel to receive height from iframe
window.addEventListener("message", this.receiveMessage);
},
beforeDestroy() {
window.removeEventListener("message", this.receiveMessage);
this.$root.contentLoading = false;
},
name: "LegacyBaseTemplate",
};
......
<template>
<div class="d-flex justify-center align-center progress-container">
<v-progress-circular
indeterminate
color="primary"
:size="60"
></v-progress-circular>
</div>
</template>
<script>
export default {
name: "Loading",
props: {
splash: {
type: Boolean,
default: false,
},
},
};
</script>
<style scoped>
.progress-container {
position: fixed;
top: 10%;
right: 0;
left: 0;
bottom: 10%;
z-index: 1000;
}
</style>
<!-- Parent template for Vue router views -->
<template>
<router-view></router-view>
</template>
......
<script>
export default {
name: "SidenavSearch",
data() {
return {
q: "",
};
},
};
</script>
<template>
<ApolloQuery
:query="require('./searchSnippets.graphql')"
:variables="{
q,
}"
:skip="!q"
>
<template #default="{ result: { error, data }, isLoading, query }">
<v-autocomplete
:prepend-icon="'mdi-magnify'"
append-icon=""
@click:prepend="$router.push(`/search/?q=${q}`)"
@keydown.enter="$router.push(`/search/?q=${q}`)"
single-line
clearable
:loading="!!isLoading"
id="search"
type="search"
enterkeyhint="search"
:label="$t('actions.search')"
:search-input.sync="q"
flat
solo
cache-items
hide-no-data
hide-details
menu-props="closeOnContentClick"
:items="data ? data.searchSnippets : undefined"
>
<template #item="{ item }">
<v-list-item @click="$router.push(item.obj.absoluteUrl.substring(7))">
<v-list-item-icon v-if="item.obj.icon">
<v-icon>{{ "mdi-" + item.obj.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title> {{ item.obj.name }}</v-list-item-title>
<v-list-item-subtitle>{{ item.text }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</template>
</v-autocomplete>
</template>
</ApolloQuery>
</template>
<!-- General information about AlekSIS as a whole -->
<template>
<v-row class="mb-3">
<v-col cols="12">
......@@ -11,14 +13,14 @@
{{ $t("about.about_aleksis_2") }}
</p>
</v-card-text>
<v-spacer></v-spacer>
<v-spacer />
<v-card-actions>
<v-btn text color="primary" href="https://aleksis.org/">{{
$t("about.website_of_aleksis")
}}</v-btn>
<v-btn text color="primary" href="https://edugit.org/AlekSIS/">{{
$t("about.source_code")
}}</v-btn>
<v-btn text color="primary" href="https://aleksis.org/">
{{ $t("about.website_of_aleksis") }}
</v-btn>
<v-btn text color="primary" href="https://edugit.org/AlekSIS/">
{{ $t("about.source_code") }}
</v-btn>
</v-card-actions>
</v-card>
</v-col>
......@@ -30,19 +32,19 @@
{{ $t("about.licence_information_1") }}
</p>
<p>
<v-chip color="green" text-color="white" small>{{
$t("about.free_open_source_licence")
}}</v-chip>
<v-chip color="orange" text-color="white" small>{{
$t("about.other_licence")
}}</v-chip>
<v-chip color="green" text-color="white" small>
{{ $t("about.free_open_source_licence") }}
</v-chip>
<v-chip color="orange" text-color="white" small>
{{ $t("about.other_licence") }}
</v-chip>
</p>
</v-card-text>
<v-spacer></v-spacer>
<v-spacer />
<v-card-actions>
<v-btn text color="primary" href="https://eupl.eu">{{
$t("about.full_licence_text")
}}</v-btn>
<v-btn text color="primary" href="https://eupl.eu">
{{ $t("about.full_licence_text") }}
</v-btn>
<v-btn
text
color="primary"
......
<!-- Information card for one AlekSIS app -->
<template>
<v-col cols="12" md="6" lg="6" xl="4" class="d-flex align-stretch">
<v-card :id="app.name" class="d-flex flex-column flex-grow-1">
......@@ -13,9 +15,9 @@
<v-row v-if="app.licence" class="mb-2">
<v-col cols="6">
{{ $t("about.licenced_under") }} <br />
<strong class="text-body-1 black--text">{{
app.licence.verboseName
}}</strong>
<strong class="text-body-1 black--text">
{{ app.licence.verboseName }}
</strong>
</v-col>
<v-col cols="6">
{{ $t("about.licence_type") }} <br />
......@@ -71,11 +73,11 @@
</v-row>
</v-card-text>
<v-spacer></v-spacer>
<v-spacer />
<v-card-actions v-if="app.urls.length !== 0">
<v-btn text color="primary" @click="reveal = true">
Show copyright
{{ $t("about.show_copyright") }}
</v-btn>
<v-btn
v-for="url in app.urls"
......@@ -97,10 +99,10 @@
<v-row>
<v-col cols="12" v-if="app.copyrights.length !== 0">
<span v-for="(copyright, index) in app.copyrights" :key="index">
Copyright © {{ copyright.years }}
<a :href="'mailto:' + copyright.email">{{
copyright.name
}}</a>
{{ "Copyright ©" + copyright.years }}
<a :href="'mailto:' + copyright.email">
{{ copyright.name }}
</a>
<br />
</span>
</v-col>
......@@ -108,13 +110,16 @@
</v-card-text>
<v-spacer></v-spacer>
<v-card-actions class="pt-0">
<v-btn text color="primary" @click="reveal = false"> Close </v-btn>
<v-btn text color="primary" @click="reveal = false">{{
$t("actions.close")
}}</v-btn>
</v-card-actions>
</v-card>
</v-expand-transition>
</v-card>
</v-col>
</template>
<script>
export default {
name: "InstalledAppCard",
......
<!-- List of all installed AlekSIS apps, as discovered from the server -->
<template>
<ApolloQuery :query="require('./installedApps.graphql')">
<template #default="{ result: { error, data }, isLoading }">
<v-row v-if="isLoading">
<v-col
v-for="idx in 3"
:key="idx"
cols="12"
md="6"
lg="6"
xl="4"
class="d-flex align-stretch"
>
<v-card class="d-flex flex-column flex-grow-1 pa-4">
<v-skeleton-loader
type="heading, actions, text@5"
></v-skeleton-loader>
</v-card>
</v-col>
</v-row>
<v-row v-if="data.installedApps">
<installed-app-card
v-for="app in data.installedApps"
:key="app.name"
:app="app"
/>
</v-row>
</template>
</ApolloQuery>
<div>
<v-row v-if="$apollo.queries.installedApps.loading">
<v-col
v-for="idx in 3"
:key="idx"
cols="12"
md="6"
lg="6"
xl="4"
class="d-flex align-stretch"
>
<v-card class="d-flex flex-column flex-grow-1 pa-4">
<v-skeleton-loader
type="heading, actions, text@5"
></v-skeleton-loader>
</v-card>
</v-col>
</v-row>
<v-row v-if="installedApps">
<installed-app-card
v-for="app in installedApps"
:key="app.name"
:app="app"
/>
</v-row>
</div>
</template>
<script>
import InstalledAppCard from "./InstalledAppCard.vue";
import gqlInstalledApps from "./installedApps.graphql";
export default {
name: "InstalledAppsList",
components: { InstalledAppCard },
apollo: {
installedApps: {
query: gqlInstalledApps,
},
},
};
</script>
<template>
<v-menu offset-y>
<template #activator="{ on, attrs }">
<v-avatar v-bind="attrs" v-on="on">
<img
v-if="
systemProperties.sitePreferences.accountPersonPreferPhoto &&
whoAmI.person.photo &&
whoAmI.person.photo.url
"
:src="whoAmI.person.photo.url"
:alt="whoAmI.person.fullName"
:title="whoAmI.person.fullName"
/>
<img
v-else-if="whoAmI.person.avatarUrl"
:src="whoAmI.person.avatarUrl"
:alt="whoAmI.person.fullName + '(' + $t('person.avatar') + ')'"
:title="whoAmI.person.fullName + '(' + $t('person.avatar') + ')'"
/>
<v-icon v-else>mdi-person</v-icon>
</v-avatar>
</template>
<v-list>
<v-subheader>
{{
$t(
whoAmI && whoAmI.isImpersonate
? "person.impersonation.impersonating"
: "person.logged_in_as"
)
}}
{{ whoAmI.person.fullName ? whoAmI.person.fullName : whoAmI.username }}
</v-subheader>
<v-list-item
v-if="whoAmI && whoAmI.isImpersonate"
:to="{ name: 'impersonate.stop', query: { next: $route.path } }"
>
<v-list-item-icon>
<v-icon> mdi-stop</v-icon>
</v-list-item-icon>
<v-list-item-title>
{{ $t("person.impersonation.stop") }}
</v-list-item-title>
</v-list-item>
<div v-for="menuItem in accountMenu" :key="menuItem.name">
<v-divider v-if="menuItem.divider"></v-divider>
<v-list-item
:to="{ name: menuItem.name }"
:target="menuItem.newTab ? '_blank' : '_self'"
>
<v-list-item-icon>
<v-icon v-if="menuItem.icon">{{ menuItem.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-title>{{ $t(menuItem.titleKey) }}</v-list-item-title>
</v-list-item>
</div>
</v-list>
</v-menu>
</template>
<script>
export default {
name: "AccountMenu",
props: {
accountMenu: {
type: Array,
required: true,
},
systemProperties: {
type: Object,
required: true,
},
whoAmI: {
type: Object,
required: true,
},
},
};
</script>
<style></style>
<!--
Main App component.
This component contains the outer app UI of AlekSIS and all behaviour
that is always on-screen, independent of the specific page.
-->
<template>
<v-app v-cloak>
<loading v-if="$apollo.loading && !systemProperties"> </loading>
<splash v-if="$apollo.loading && !systemProperties" splash />
<div v-else>
<v-navigation-drawer app v-model="drawer">
<v-list nav dense shaped>
<v-list-item class="logo">
<a
id="logo-container"
@click="$router.push({ name: 'dashboard' })"
class="brand-logo"
>
<brand-logo
:site-preferences="systemProperties.sitePreferences"
/>
</a>
</v-list-item>
<v-list-item class="search">
<sidenav-search />
</v-list-item>
<v-list-item-group :value="$route.name" v-if="sideNavMenu">
<div v-for="menuItem in sideNavMenu" :key="menuItem.name">
<v-list-group
v-if="menuItem.subMenu.length > 0"
href="#!"
:prepend-icon="menuItem.icon"
:value="$route.matched.slice(-2).shift().name === menuItem.name"
>
<template #activator>
<v-list-item-title
>{{ $t(menuItem.titleKey) }}
</v-list-item-title>
</template>
<v-list-item
v-for="subMenuItem in menuItem.subMenu"
exact
:to="{ name: subMenuItem.name }"
:target="subMenuItem.newTab ? '_blank' : '_self'"
:key="subMenuItem.name"
:value="subMenuItem.name"
>
<v-list-item-icon>
<v-icon v-if="subMenuItem.icon"
>{{ subMenuItem.icon }}
</v-icon>
</v-list-item-icon>
<v-list-item-title
>{{ $t(subMenuItem.titleKey) }}
</v-list-item-title>
</v-list-item>
</v-list-group>
<v-list-item
v-else
exact
:to="{ name: menuItem.name }"
:target="menuItem.newTab ? '_blank' : '_self'"
:value="menuItem.name"
>
<v-list-item-icon>
<v-icon v-if="menuItem.icon">{{ menuItem.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-title>{{
$t(menuItem.titleKey)
}}</v-list-item-title>
</v-list-item>
</div>
</v-list-item-group>
<v-list-item v-else>
<v-skeleton-loader class="ma-2" type="list-item-avatar" />
</v-list-item>
</v-list>
<template #append>
<div class="pa-4 d-flex justify-center align-center">
<v-spacer />
<language-form
:available-languages="systemProperties.availableLanguages"
/>
<v-spacer />
</div>
</template>
</v-navigation-drawer>
<side-nav
v-model="drawer"
:system-properties="systemProperties"
:side-nav-menu="sideNavMenu"
></side-nav>
<v-app-bar
app
:color="$vuetify.theme.dark ? undefined : 'primary white--text'"
......@@ -113,80 +47,20 @@
</v-btn>
<div v-if="whoAmI && whoAmI.isAuthenticated" class="d-flex">
<notification-list v-if="!whoAmI.person.isDummy" />
<v-menu offset-y>
<template #activator="{ on, attrs }">
<v-avatar v-bind="attrs" v-on="on">
<img
v-if="
systemProperties.sitePreferences.accountPersonPreferPhoto &&
whoAmI.person.photo &&
whoAmI.person.photo.url
"
:src="whoAmI.person.photo.url"
:alt="whoAmI.person.fullName"
:title="whoAmI.person.fullName"
/>
<img
v-else-if="whoAmI.person.avatarUrl"
:src="whoAmI.person.avatarUrl"
:alt="
whoAmI.person.fullName + '(' + $t('person.avatar') + ')'
"
:title="
whoAmI.person.fullName + '(' + $t('person.avatar') + ')'
"
/>
<v-icon v-else>mdi-person</v-icon>
</v-avatar>
</template>
<v-list>
<v-subheader>
{{
$t(
whoAmI && whoAmI.isImpersonate
? "person.impersonation.impersonating"
: "person.logged_in_as"
)
}}
{{
whoAmI.person.fullName
? whoAmI.person.fullName
: whoAmI.username
}}
</v-subheader>
<v-list-item
v-if="whoAmI && whoAmI.isImpersonate"
:to="{ name: 'impersonate.stop', query: { next: $route.path } }"
>
<v-list-item-icon>
<v-icon> mdi-stop </v-icon>
</v-list-item-icon>
<v-list-item-title>
{{ $t("person.impersonation.stop") }}
</v-list-item-title>
</v-list-item>
<div v-for="menuItem in accountMenu" :key="menuItem.name">
<v-divider v-if="menuItem.divider"></v-divider>
<v-list-item
:to="{ name: menuItem.name }"
:target="menuItem.newTab ? '_blank' : '_self'"
>
<v-list-item-icon>
<v-icon v-if="menuItem.icon">{{ menuItem.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-title
>{{ $t(menuItem.titleKey) }}
</v-list-item-title>
</v-list-item>
</div>
</v-list>
</v-menu>
<account-menu
:account-menu="accountMenu"
:system-properties="systemProperties"
:who-am-i="whoAmI"
></account-menu>
</div>
</v-app-bar>
<v-main>
<v-container>
<broadcast-channel-notification channel-name="cache-or-not" />
<broadcast-channel-notification channel-name="offline-fallback" />
<message-box type="warning" v-if="$root.offline">
{{ $t("network_errors.offline_notification") }}
</message-box>
<message-box
type="error"
......@@ -212,24 +86,15 @@
<router-view
v-if="
!$route.meta.permission ||
(permissionResults &&
permissionResults.find(
(p) => p.name === $route.meta.permission
) &&
permissionResults.find((p) => p.name === $route.meta.permission)
.result)
!$route.meta.permission || checkPermission($route.meta.permission)
"
/>
<message-box
type="error"
v-else-if="
permissionResults &&
permissionResults.find(
(p) => p.name === $route.meta.permission
) &&
permissionResults.find((p) => p.name === $route.meta.permission)
.result === false
whoAmI &&
!$apollo.queries.whoAmI.loading &&
!checkPermission($route.meta.permission)
"
>
{{ $t("base.no_permission") }}
......@@ -287,6 +152,7 @@
class="white--text text-decoration-none"
>{{ $t("base.about_aleksis") }}
</router-link>
<!-- eslint-disable-next-line -->
<span>© The AlekSIS Team</span>
</div>
</v-col>
......@@ -339,25 +205,22 @@
</template>
<script>
import BroadcastChannelNotification from "./components/BroadcastChannelNotification.vue";
import LanguageForm from "./components/LanguageForm.vue";
import NotificationList from "./components/notifications/NotificationList.vue";
import SidenavSearch from "./components/SidenavSearch.vue";
import CeleryProgressBottom from "./components/celery_progress/CeleryProgressBottom.vue";
import Loading from "./components/Loading.vue";
import BrandLogo from "./components/BrandLogo.vue";
import SnackbarItem from "./components/SnackbarItem.vue";
import BroadcastChannelNotification from "./BroadcastChannelNotification.vue";
import AccountMenu from "./AccountMenu.vue";
import NotificationList from "../notifications/NotificationList.vue";
import CeleryProgressBottom from "../celery_progress/CeleryProgressBottom.vue";
import Splash from "./Splash.vue";
import SideNav from "./SideNav.vue";
import SnackbarItem from "./SnackbarItem.vue";
import gqlWhoAmI from "./whoAmI.graphql";
import gqlMessages from "./messages.graphql";
import gqlSystemProperties from "./systemProperties.graphql";
import gqlCustomMenu from "./customMenu.graphql";
import gqlGlobalPermissions from "./globalPermissions.graphql";
import gqlSnackbarItems from "./snackbarItems.graphql";
import useRegisterSWMixin from "./mixins/useRegisterSW";
// import { VOffline } from "@/v-offline";
import useRegisterSWMixin from "../../mixins/useRegisterSW";
import offlineMixin from "../../mixins/offline";
import menusMixin from "../../mixins/menus";
export default {
data() {
......@@ -366,88 +229,18 @@ export default {
whoAmI: null,
systemProperties: null,
messages: null,
footerMenu: null,
permissionResults: null,
permissionNames: [],
sideNavMenu: null,
accountMenu: null,
snackbarItems: null,
};
},
methods: {
getPermissionNames() {
let permArray = [];
for (const route of this.$router.getRoutes()) {
if (
route.meta &&
route.meta["permission"] &&
!(route.meta["permission"] in permArray)
) {
permArray.push(route.meta["permission"]);
}
}
this.$data.permissionNames = permArray;
},
buildMenu(routes, menuKey, permissionResults) {
let menu = {};
// Top-level entries
for (const route of routes) {
if (
route.name &&
route.meta &&
route.meta[menuKey] &&
!route.parent &&
(route.meta.permission
? permissionResults.find((p) => p.name === route.meta.permission) &&
permissionResults.find((p) => p.name === route.meta.permission)
.result
: true)
) {
let menuItem = {
...route.meta,
name: route.name,
path: route.path,
subMenu: [],
};
menu[menuItem.name] = menuItem;
}
}
// Sub menu entries
for (const route of routes) {
if (
route.name &&
route.meta &&
route.meta[menuKey] &&
route.parent &&
route.parent.name &&
route.parent.name in menu &&
(route.meta.permission
? permissionResults.find((p) => p.name === route.meta.permission) &&
permissionResults.find((p) => p.name === route.meta.permission)
.result
: true)
) {
let menuItem = {
...route.meta,
name: route.name,
path: route.path,
subMenu: [],
};
menu[route.parent.name].subMenu.push(menuItem);
}
}
return Object.values(menu);
},
},
apollo: {
systemProperties: gqlSystemProperties,
whoAmI: {
query: gqlWhoAmI,
variables() {
return {
permissions: this.permissionNames,
};
},
pollInterval: 10000,
},
snackbarItems: {
......@@ -458,24 +251,6 @@ export default {
query: gqlMessages,
pollInterval: 1000,
},
footerMenu: {
query: gqlCustomMenu,
variables() {
return {
name: "footer",
};
},
update: (data) => data.customMenuByName,
},
permissionResults: {
query: gqlGlobalPermissions,
variables() {
return {
permissions: this.$data.permissionNames,
};
},
update: (data) => data.globalPermissionsByName,
},
},
watch: {
systemProperties: function (newProperties) {
......@@ -490,43 +265,24 @@ export default {
this.$vuetify.theme.themes.dark.secondary =
newProperties.sitePreferences.themeSecondary;
},
whoAmI: function (user) {
this.$vuetify.theme.dark =
user.person && user.person.preferences.themeDesignMode === "dark";
this.$apollo.queries.permissionResults.refetch();
},
permissionResults: {
handler(newResults) {
this.$data.accountMenu = this.buildMenu(
this.$router.getRoutes(),
"inAccountMenu",
newResults
);
this.$data.sideNavMenu = this.buildMenu(
this.$router.getRoutes(),
"inMenu",
newResults
);
whoAmI: {
handler() {
this.buildMenus();
},
deep: true,
},
},
mounted() {
this.$router.onReady(this.getPermissionNames);
},
name: "App",
components: {
BrandLogo,
AccountMenu,
BroadcastChannelNotification,
LanguageForm,
NotificationList,
SidenavSearch,
CeleryProgressBottom,
Loading,
Splash,
SideNav,
SnackbarItem,
// VOffline,
},
mixins: [useRegisterSWMixin],
mixins: [useRegisterSWMixin, offlineMixin, menusMixin],
};
</script>
......
<template>
<img
:src="sitePreferences.themeLogo.url"
:alt="sitePreferences.generalTitle + ' – Logo'"
:alt="sitePreferences.generalTitle + ' – ' + $t('base.logo')"
class="fullsize"
/>
</template>
......
......@@ -5,9 +5,9 @@
>
<h1 class="text-h2">{{ $t("network_errors.error_404") }}</h1>
<div>{{ $t("network_errors.page_not_found") }}</div>
<v-btn color="secondary" :to="{ name: 'dashboard' }">{{
$t("network_errors.take_me_back")
}}</v-btn>
<v-btn color="secondary" :to="{ name: 'dashboard' }">
{{ $t("network_errors.take_me_back") }}
</v-btn>
</div>
</template>
......
......@@ -8,6 +8,7 @@
menu-props="auto"
outlined
color="primary"
hide-details="auto"
single-line
return-object
dense
......