aboutsummaryrefslogtreecommitdiff
path: root/resources
diff options
context:
space:
mode:
Diffstat (limited to 'resources')
-rw-r--r--resources/assets/stylesheets/scss/header.scss1
-rw-r--r--resources/assets/stylesheets/scss/responsive.scss6
-rw-r--r--resources/vue/apps/short-urls/ShortUrl.vue84
-rw-r--r--resources/vue/apps/short-urls/ShortUrlLink.vue112
-rw-r--r--resources/vue/apps/short-urls/ShortUrlList.vue226
-rw-r--r--resources/vue/directives/autofocus.ts4
-rw-r--r--resources/vue/store/pinia/shortUrlsStore.js61
7 files changed, 487 insertions, 7 deletions
diff --git a/resources/assets/stylesheets/scss/header.scss b/resources/assets/stylesheets/scss/header.scss
index b06c31f..0a9d977 100644
--- a/resources/assets/stylesheets/scss/header.scss
+++ b/resources/assets/stylesheets/scss/header.scss
@@ -36,6 +36,7 @@
#header-links {
flex: 0 1 auto;
+ justify-items: flex-end;
justify-self: flex-end;
> ul {
diff --git a/resources/assets/stylesheets/scss/responsive.scss b/resources/assets/stylesheets/scss/responsive.scss
index 56acc3c..e72ce27 100644
--- a/resources/assets/stylesheets/scss/responsive.scss
+++ b/resources/assets/stylesheets/scss/responsive.scss
@@ -304,7 +304,7 @@ $sidebarOut: -330px;
#header-links {
> ul {
- > li:not(#responsive-toggle-fullscreen):not(#responsive-toggle-focusmode):not(.helpbar-container) {
+ > li:not(#responsive-toggle-fullscreen):not(#responsive-toggle-focusmode):not(.helpbar-container):not(#responsive-create-shortlink) {
display: none;
}
@@ -876,10 +876,6 @@ html:not(.responsive-display):not(.fullscreen-mode) {
}
- #header-links {
- display: none;
- }
-
#background-desktop {
display: none;
}
diff --git a/resources/vue/apps/short-urls/ShortUrl.vue b/resources/vue/apps/short-urls/ShortUrl.vue
new file mode 100644
index 0000000..953ae37
--- /dev/null
+++ b/resources/vue/apps/short-urls/ShortUrl.vue
@@ -0,0 +1,84 @@
+<template>
+
+ <studip-message-box v-if="isInContext"
+ type="warning"
+ :hideClose="true"
+ >
+ {{ $gettext('Der Link verweist auf den Inhalt einer zugangsbeschränkten Veranstaltung und ist '
+ + 'eventuell nicht für alle Personen im System erreichbar.') }}
+ </studip-message-box>
+
+ <form class="default" v-if="editing !== null">
+ <LabelRequired
+ id="short-url-path"
+ :label="$gettext('Zielseite')"
+ >
+ <input type="text"
+ name="path"
+ id="short-url-path"
+ v-model="editing.attributes.path"
+ readonly
+ />
+ </LabelRequired>
+
+ <LabelRequired
+ id="short-url-alias"
+ :label="$gettext('Kürzel')"
+ >
+ <input
+ type="text"
+ name="alias"
+ id="short-url-alias"
+ v-model="editing.attributes.alias"
+ @input="validateAlias"
+ maxlength="255"
+ @keydown.enter="triggerSave"
+ v-autofocus
+ />
+ </LabelRequired>
+
+ <LabelRequired
+ id="short-url-title"
+ :label="$gettext('Titel des Linkziels')"
+ >
+ <input
+ type="text"
+ name="title"
+ id="short-url-title"
+ v-model="editing.attributes.title"
+ maxlength="255"
+ @keydown.enter="triggerSave"
+ />
+ </LabelRequired>
+ </form>
+</template>
+
+<script setup>
+import {ref} from 'vue';
+import {$gettext} from "../../../assets/javascripts/lib/gettext";
+import StudipMessageBox from '../../components/StudipMessageBox';
+import LabelRequired from '../../components/forms/LabelRequired';
+
+const props = defineProps({
+ shortLink: {
+ type: Object,
+ required: true
+ },
+ isInContext: {
+ type: Boolean,
+ default: false
+ }
+});
+const emit = defineEmits(['save']);
+const editing = ref(props.shortLink);
+
+function triggerSave() {
+ emit('save', editing.value);
+}
+
+function validateAlias() {
+ const pattern = /[^a-zA-Z0-9-]/g;
+ editing.value.attributes.alias = editing.value.attributes.alias.replace(pattern, '').slice(0, 256);
+}
+
+</script>
diff --git a/resources/vue/apps/short-urls/ShortUrlLink.vue b/resources/vue/apps/short-urls/ShortUrlLink.vue
new file mode 100644
index 0000000..f2a168a
--- /dev/null
+++ b/resources/vue/apps/short-urls/ShortUrlLink.vue
@@ -0,0 +1,112 @@
+<template>
+ <button class="in-navigation as-link"
+ @click.prevent="openDialog()"
+ :title="$gettext('Link zu dieser Seite erstellen')"
+ >
+ <studip-icon v-if="withIcon" shape="share" role="info_alt"/>
+ <template v-else>
+ {{ $gettext('Link zur Seite') }}
+ </template>
+ </button>
+ <studip-dialog
+ v-if="showDialog"
+ :title="$gettext('Link zur Seite erzeugen')"
+ :confirmText="$gettext('Link kopieren & speichern')"
+ confirmClass="accept"
+ :closeText="$gettext('Abbrechen')"
+ closeClass="cancel"
+ :height="isInContext ? 420 : 360"
+ @close="closeDialog"
+ @confirm="save"
+ >
+ <template #dialogContent>
+ <short-url :shortLink="newLink" :isInContext="isInContext"/>
+ </template>
+ </studip-dialog>
+</template>
+
+<script setup>
+import {onMounted, ref} from 'vue';
+import {$gettext} from "../../../assets/javascripts/lib/gettext";
+import ShortUrl from './ShortUrl';
+import {useShortUrlsStore} from '../../store/pinia/shortUrlsStore';
+import Sqids from "sqids";
+
+defineProps({
+ isInContext: {
+ type: Boolean,
+ default: false
+ },
+ withIcon: {
+ type: Boolean,
+ default: false
+ }
+});
+
+const store = useShortUrlsStore();
+const showDialog = ref(false);
+const newLink = ref(null);
+
+function openDialog() {
+ showDialog.value = true;
+}
+
+async function save() {
+ await store.storeShortUrl(newLink.value);
+ closeDialog();
+}
+
+function closeDialog() {
+ showDialog.value = false;
+}
+
+function getPageTitle() {
+ const contextTitle = document.getElementById('context-title');
+ let text = document.getElementById('page-title').textContent.trim();
+
+ // We are inside some context (course, institute, ...), include the context title
+ if (contextTitle) {
+ // Courses have some <span>s with different parts of the title
+ const children = contextTitle.querySelectorAll('.course-type, .course-name, .course-semester');
+
+ if (children.length > 0) {
+ text = [...children].map((node) => node.textContent.trim()).join(' ')
+ + ' - '
+ + text;
+ } else {
+ text = contextTitle.textContent.trim() + ' - ' + text;
+ }
+ }
+
+ return text;
+}
+
+onMounted(() => {
+ const sqids = new Sqids();
+ const randomNumbers = Array.from({length: 3}, () => Math.floor(Math.random() * 1000));
+ const alias = sqids.encode(randomNumbers);
+
+ newLink.value = {
+ attributes: {
+ alias: alias,
+ path: window.location.href.replace(/^.*?dispatch\.php/, 'dispatch.php'),
+ title: getPageTitle()
+ }
+ };
+ document.getElementById('dummy-create-short-url')?.parentNode.remove();
+ document.getElementById('responsive-create-shortlink-dummy')?.remove();
+});
+</script>
+
+<style scoped>
+.in-navigation {
+ color: var(--white);
+ margin-left: 6px;
+ margin-right: 6px;
+
+ &:hover {
+ color: var(--white);
+ text-decoration: underline;
+ }
+}
+</style>
diff --git a/resources/vue/apps/short-urls/ShortUrlList.vue b/resources/vue/apps/short-urls/ShortUrlList.vue
new file mode 100644
index 0000000..a575772
--- /dev/null
+++ b/resources/vue/apps/short-urls/ShortUrlList.vue
@@ -0,0 +1,226 @@
+<template>
+ <studip-progress-indicator v-if="loading" :size="32"/>
+
+ <table v-else-if="store.getShortUrls().length > 0" class="default sortable-table">
+ <colgroup>
+ <col>
+ <col>
+ <col>
+ <col style="width: 20px">
+ </colgroup>
+ <thead>
+ <tr class="sortable">
+ <th :class="getOrderClasses('alias')">
+ <button
+ @click.prevent="changeOrder('alias')"
+ :title="orderDir === 'asc'
+ ? $gettext('Sortiere absteigend nach Kürzel')
+ : $gettext('Sortiere aufsteigend nach Kürzel')"
+ class="as-link"
+ >
+ {{ $gettext('Kürzel') }}
+ </button>
+ </th>
+ <th :class="getOrderClasses('title')">
+ <button
+ @click.prevent="changeOrder('title')"
+ :title="orderDir === 'asc'
+ ? $gettext('Sortiere absteigend nach Titel der Zielseite')
+ : $gettext('Sortiere aufsteigend nach Titel der Zielseite')"
+ class="as-link"
+ >
+ {{ $gettext('Titel der Zielseite') }}
+ </button>
+ </th>
+ <th :class="getOrderClasses('chdate')">
+ <button
+ @click.prevent="changeOrder('chdate')"
+ :title="orderDir === 'asc'
+ ? $gettext('Sortiere absteigend nach Änderungsdatum')
+ : $gettext('Sortiere aufsteigend nach Änderungsdatum')"
+ class="as-link"
+ >
+ {{ $gettext('Erstellt/Geändert') }}
+ </button>
+ </th>
+ <th>{{ $gettext('Aktionen') }}</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="(shortUrl, index) in sortEntries(store.getShortUrls())" :key="index">
+ <td>
+ <button class="as-link copy-link"
+ :title="$gettext('In die Zwischenablage kopieren')"
+ @click.prevent="copyToClipboard(shortUrl.attributes.alias)">
+ <studip-icon shape="clipboard" />
+ </button>
+ <a :href="store.getShortUrl(shortUrl.attributes.alias)" :title="$gettext('Titel des Kurzlinks')">
+ {{ shortUrl.attributes.alias }}
+ </a>
+ </td>
+ <td>
+ {{ shortUrl.attributes.title }}
+ </td>
+ <td>
+ {{ formatDate(shortUrl.attributes.chdate) }}
+ </td>
+ <td class="actions">
+ <studip-action-menu
+ :items="actionMenuItems"
+ @qrcode="createQrCode(shortUrl)"
+ @edit="editShortUrl(shortUrl)"
+ @delete="store.deleteShortUrl(shortUrl.id)"
+ @copy="copyToClipboard(shortUrl.attributes.alias)"
+ />
+ </td>
+ </tr>
+ </tbody>
+ </table>
+
+ <studip-message-box v-else type="info">
+ {{ $gettext('Es wurden keine Kurzlinks gefunden.') }}
+ </studip-message-box>
+
+ <div id="qrcode"
+ v-if="qrUrl"
+ ref="qrcode"
+ >
+ <qrcode-svg
+ :value="qrUrl"
+ :size="600"></qrcode-svg>
+ </div>
+
+ <studip-dialog
+ v-if="currentlyEditing !== null"
+ :title="$gettext('Kurzlink bearbeiten')"
+ :confirmText="$gettext('Aktualisieren')"
+ confirmClass="accept"
+ :closeText="$gettext('Schließen')"
+ closeClass="cancel"
+ :height="420"
+ @close="closeEditDialog"
+ @confirm="save"
+ >
+ <template #dialogContent>
+ <shortUrl :shortLink="currentlyEditing"/>
+ </template>
+ </studip-dialog>
+</template>
+
+<script setup>
+import {ref, onMounted, nextTick} from 'vue';
+import {useShortUrlsStore} from '../../store/pinia/shortUrlsStore';
+import {$gettext} from "../../../assets/javascripts/lib/gettext";
+import StudipProgressIndicator from '../../components/StudipProgressIndicator.vue';
+import StudipDialog from '../../components/StudipDialog';
+import StudipActionMenu from '../../components/StudipActionMenu';
+import ShortUrl from './ShortUrl';
+import QrcodeSvg from 'qrcode.vue';
+
+const loading = ref(true);
+const store = useShortUrlsStore();
+const qrUrl = ref(null);
+const qrcode = ref(null);
+const orderBy = ref('chdate');
+const orderDir = ref('desc');
+const currentlyEditing = ref(null);
+const actionMenuItems = [
+ {label: $gettext('In die Zwischenablage kopieren'), icon: 'clipboard', emit: 'copy'},
+ {label: $gettext('QR-Code herunterladen'), icon: 'code-qr', emit: 'qrcode'},
+ {label: $gettext('Bearbeiten'), icon: 'edit', emit: 'edit'},
+ {label: $gettext('Löschen'), icon: 'trash', emit: 'delete'}
+];
+
+const formatDate = (datestring) => {
+ const date = new Date(datestring);
+ const formatter = new Intl.DateTimeFormat(String.locale, {
+ dateStyle: 'short',
+ timeStyle: 'short'
+ });
+
+ // We need to get rid of the comma separating date and time
+ const parts = formatter.formatToParts(date);
+ return parts.filter(p => p.type !== 'literal')
+ .map(p => p.value)
+ .join(' ');
+}
+
+const getAliasLink = (alias) => {
+ return STUDIP.URLHelper.getURL('dispatch.php/u/r/' + alias, {}, true);
+}
+
+const copyToClipboard = (alias) => {
+ const shortUrl = getAliasLink(alias);
+ navigator.clipboard.writeText(shortUrl);
+ STUDIP.Report.success($gettext('Sie finden den Link in der Zwischenablage'));
+}
+
+/*
+ * Create a QR code and trigger download as png.
+ */
+const createQrCode = (shortLink) => {
+ qrUrl.value = getAliasLink(shortLink.attributes.alias);
+ nextTick().then(() => {
+ const png = qrcode.value.querySelector('canvas').toDataURL('image/png');
+ const link = document.createElement('a');
+ link.download = 'shortlink-' + shortLink.attributes.alias + '.png';
+ link.href = png;
+ link.click();
+ qrUrl.value = null;
+ });
+}
+
+const editShortUrl = (url) => {
+ currentlyEditing.value = url;
+}
+
+const closeEditDialog = () => {
+ currentlyEditing.value = null;
+}
+
+const save = async () => {
+ await store.storeShortUrl(currentlyEditing.value);
+ closeEditDialog();
+}
+
+const sortEntries = (entries) => {
+ const sorted = [...entries].toSorted((a, b) => {
+ return orderDir.value === 'asc'
+ ? a.attributes[orderBy.value].localeCompare(b.attributes[orderBy.value])
+ : b.attributes[orderBy.value].localeCompare(a.attributes[orderBy.value]);
+ });
+ return sorted;
+}
+
+const getOrderClasses = (by) => {
+ if (by !== orderBy.value) {
+ return [];
+ }
+ return orderDir.value === 'asc' ? ['sortasc'] : ['sortdesc'];
+}
+
+const changeOrder = (by) => {
+ if (orderBy.value === by) {
+ orderDir.value = orderDir.value === 'asc' ? 'desc' : 'asc';
+ } else {
+ orderBy.value = by;
+ orderDir.value = 'asc';
+ }
+}
+
+onMounted(() => {
+ store.initialize().then(() => {
+ loading.value = false;
+ });
+});
+</script>
+
+<style scoped>
+.copy-link {
+ vertical-align: middle;
+}
+#qrcode {
+ display: none;
+ visibility: hidden;
+}
+</style>
diff --git a/resources/vue/directives/autofocus.ts b/resources/vue/directives/autofocus.ts
index 5995629..e4c3d3f 100644
--- a/resources/vue/directives/autofocus.ts
+++ b/resources/vue/directives/autofocus.ts
@@ -1,13 +1,13 @@
// Shamelessly copied from https://github.com/byteboomers/vue-autofocus-directive
-import {DirectiveBinding} from "vue";
+import {DirectiveBinding, nextTick} from "vue";
function focusElement(el: HTMLElement, binding: DirectiveBinding) : void {
if (binding.value !== undefined && !binding.value) {
return;
}
- el.focus()
+ nextTick().then(() => el.focus());
}
export default {
diff --git a/resources/vue/store/pinia/shortUrlsStore.js b/resources/vue/store/pinia/shortUrlsStore.js
new file mode 100644
index 0000000..6b972c2
--- /dev/null
+++ b/resources/vue/store/pinia/shortUrlsStore.js
@@ -0,0 +1,61 @@
+import {defineStore} from 'pinia';
+import {ref} from 'vue';
+import {$gettext} from '../../../assets/javascripts/lib/gettext';
+
+export const useShortUrlsStore = defineStore('shortUrls', () => {
+ let shortUrls = ref([]);
+
+ async function initialize() {
+ await STUDIP.jsonapi.withPromises().get('short-urls')
+ .then(response => {
+ shortUrls.value = response.data;
+ })
+ .catch(error => STUDIP.Report.error($gettext('Fehler beim Laden der Kurzlinks'), error));
+ }
+
+ function getShortUrls() {
+ return shortUrls.value;
+ }
+
+ function getShortUrl(id) {
+ return shortUrls.value.find(item => item.id === id);
+ }
+
+ function storeShortUrl(shortUrl) {
+ const index = shortUrls.value.findIndex(item => item.id === shortUrl.id);
+
+ // Not found in store, create a new entry.
+ if (index === -1) {
+ STUDIP.jsonapi.withPromises().post('short-urls', {data: {data: shortUrl}})
+ .then(response => {
+ shortUrls.value.push(response.data)
+ STUDIP.Report.success($gettext('Der Kurzlink wurde gespeichert.'));
+ })
+ .catch(error => STUDIP.Report.error($gettext('Fehler beim Erstellen des Kurzlinks'), error));
+
+ } else {
+ STUDIP.jsonapi.withPromises().patch(`short-urls/${shortUrl.id}`, {data: {data: shortUrl}})
+ .then(response => {
+ shortUrls.value[index] = response.data;
+ STUDIP.Report.success($gettext('Der Kurzlink wurde gespeichert.'));
+ })
+ .catch(error => {
+ STUDIP.Report.error($gettext('Fehler beim Speichern des Kurzlinks'), error)
+ });
+ }
+ }
+
+ function deleteShortUrl(id) {
+ STUDIP.jsonapi.withPromises().delete(`short-urls/${id}`)
+ .then(() => shortUrls.value.splice(shortUrls.value.findIndex(item => item.id === id), 1))
+ .catch(error => STUDIP.Report.error($gettext('Fehler beim Löschen des Kurzlinks'), error));
+ }
+
+ return {
+ initialize,
+ getShortUrl,
+ getShortUrls,
+ storeShortUrl,
+ deleteShortUrl
+ };
+});