diff options
Diffstat (limited to 'resources')
| -rw-r--r-- | resources/assets/stylesheets/scss/header.scss | 1 | ||||
| -rw-r--r-- | resources/assets/stylesheets/scss/responsive.scss | 6 | ||||
| -rw-r--r-- | resources/vue/apps/short-urls/ShortUrl.vue | 84 | ||||
| -rw-r--r-- | resources/vue/apps/short-urls/ShortUrlLink.vue | 112 | ||||
| -rw-r--r-- | resources/vue/apps/short-urls/ShortUrlList.vue | 226 | ||||
| -rw-r--r-- | resources/vue/directives/autofocus.ts | 4 | ||||
| -rw-r--r-- | resources/vue/store/pinia/shortUrlsStore.js | 61 |
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 + }; +}); |
