diff options
Diffstat (limited to 'pse-dashboard/src/components')
-rw-r--r-- | pse-dashboard/src/components/DashboardLayout.vue | 10 | ||||
-rw-r--r-- | pse-dashboard/src/components/EpisodeEntry.vue | 53 | ||||
-rw-r--r-- | pse-dashboard/src/components/ErrorLog.vue | 36 | ||||
-rw-r--r-- | pse-dashboard/src/components/FloatingLabelInput.vue | 35 | ||||
-rw-r--r-- | pse-dashboard/src/components/FormLayout.vue | 24 | ||||
-rw-r--r-- | pse-dashboard/src/components/HelpModal.vue | 44 | ||||
-rw-r--r-- | pse-dashboard/src/components/LastUpdate.vue | 46 | ||||
-rw-r--r-- | pse-dashboard/src/components/LoadingConditional.vue | 18 | ||||
-rw-r--r-- | pse-dashboard/src/components/NavBar.vue | 134 | ||||
-rw-r--r-- | pse-dashboard/src/components/PasswordInput.vue | 45 | ||||
-rw-r--r-- | pse-dashboard/src/components/PasswordValidator.vue | 112 | ||||
-rw-r--r-- | pse-dashboard/src/components/ProgressTime.vue | 23 | ||||
-rw-r--r-- | pse-dashboard/src/components/SubscriptionEntry.vue | 118 | ||||
-rw-r--r-- | pse-dashboard/src/components/index.js | 30 |
14 files changed, 728 insertions, 0 deletions
diff --git a/pse-dashboard/src/components/DashboardLayout.vue b/pse-dashboard/src/components/DashboardLayout.vue new file mode 100644 index 0000000..55695ae --- /dev/null +++ b/pse-dashboard/src/components/DashboardLayout.vue @@ -0,0 +1,10 @@ +<script setup> +</script> +<template> + <div class="container p-5 my-5 mt-2"> + <slot /> + </div> +</template> +<style scoped> +</style> + diff --git a/pse-dashboard/src/components/EpisodeEntry.vue b/pse-dashboard/src/components/EpisodeEntry.vue new file mode 100644 index 0000000..c651484 --- /dev/null +++ b/pse-dashboard/src/components/EpisodeEntry.vue @@ -0,0 +1,53 @@ +<script setup> +import { LastUpdate, ProgressTime } from '@/components' + +const props = defineProps({ + action: { + type: Object, + default: undefined + } +}); + +</script> +<template> + <a + href="#" + class="list-group-item list-group-item-action py-3" + aria-current="true" + > + <!-- title with timestamp --> + <div class="d-flex gap-3"> + <i + class="fa fa-podcast rounded-circle flex-shring-0" + style="font-size: 32px" + /> + <div class="d-flex gap-2 w-100 justify-content-between"> + <div> + <h6 class="mb-0">{{ action.title }}</h6> + <p class="mb-0 opacity-75">{{ action.description }}</p> + </div> + <small class="opacity-50 text-nowrap"> + <LastUpdate :iso="action.timestamp" /> + </small> + </div> + </div> + + <!-- Progress bar with Progress time --> + <div class="d-flex"> + <ProgressTime :unix="action.position" /> + <div + class="progress flex-grow-1 m-2" + style="height:10px; " + > + <div + class="progress-bar" + :style="{width: 100*action.position/action.total + '%'}" + /> + </div> + <ProgressTime :unix="action.total" /> + </div> + </a> +</template> +<style> +</style> + diff --git a/pse-dashboard/src/components/ErrorLog.vue b/pse-dashboard/src/components/ErrorLog.vue new file mode 100644 index 0000000..3c36359 --- /dev/null +++ b/pse-dashboard/src/components/ErrorLog.vue @@ -0,0 +1,36 @@ +<script setup> +import { Logger } from '@/logger.js' + +const icon = { + success: "fa-trophy", + info: "fa-circle-exclamation", + warning: "fa-triangle-exclamation", + danger: "fa-fire", +}; + +</script> +<template> + <div class="row"> + <div class="col-sm-12 col-md-6 col-lg-5 position-fixed bottom-0 end-0 my-1"> + <div + v-for="(item, index) in Logger.items" + :key="index" + class="alert alert-dismissible d-flex align-items-center" + :class="'alert-' + item.type" + > + <button + type="button" + class="btn-close" + @click="Logger.delete(item)" + /> + <i + class="fs-3 me-3 fa" + :class="icon[item.type]" + /> {{ item.message }} + </div> + </div> + </div> +</template> +<style scoped> +</style> + diff --git a/pse-dashboard/src/components/FloatingLabelInput.vue b/pse-dashboard/src/components/FloatingLabelInput.vue new file mode 100644 index 0000000..6f718fd --- /dev/null +++ b/pse-dashboard/src/components/FloatingLabelInput.vue @@ -0,0 +1,35 @@ +<script setup> +defineProps({ + type: { + type: String, + default: "text" + }, + label: { + type: String, + default: "Text" + }, + modelValue: { + type: String, + default: "" + } +}); +defineEmits(['update:modelValue']); + +</script> +<template> + <div class="form-floating form-input"> + <input + :id="$.uid" + :type="type" + class="form-control" + :value="modelValue" + :placeholder="label" + required + @input="$emit('update:modelValue', $event.target.value)" + > + <label :for="$.uid">{{ label }}</label> + </div> +</template> +<style scoped> +</style> + diff --git a/pse-dashboard/src/components/FormLayout.vue b/pse-dashboard/src/components/FormLayout.vue new file mode 100644 index 0000000..549339f --- /dev/null +++ b/pse-dashboard/src/components/FormLayout.vue @@ -0,0 +1,24 @@ +<script setup> +</script> +<template> + <!-- centers the form --> + <div class="container my-5 text-center"> + <div class="row"> + <div class="col-sm-9 col-md-7 col-lg-5 mx-auto"> + <!-- Logo --> + <img + class="mb-4" + src="../assets/logo.svg" + alt="" + width="250" + height="120" + > + + <slot /> + </div> + </div> + </div> +</template> +<style scoped> +</style> + diff --git a/pse-dashboard/src/components/HelpModal.vue b/pse-dashboard/src/components/HelpModal.vue new file mode 100644 index 0000000..b4f45ef --- /dev/null +++ b/pse-dashboard/src/components/HelpModal.vue @@ -0,0 +1,44 @@ +<script setup> +</script> +<template> + <div + id="help" + class="modal fade" + > + <div class="modal-dialog modal-dialog-centered modal-dialog-scrollable"> + <div class="modal-content"> + <!-- Modal Header --> + <div class="modal-header"> + <h5 class="modal-title"> + {{ $t("message.help") }} + </h5> + <button + type="button" + class="btn-close" + data-bs-dismiss="modal" + /> + </div> + + <!-- Modal Body --> + <div + class="modal-body" + v-html="$t('message.helpModal')" + /> + + <!-- Modal Footer --> + <div class="modal-footer"> + <button + type="button" + class="btn btn-secondary" + data-bs-dismiss="modal" + > + {{ $t("message.close") }} + </button> + </div> + </div> + </div> + </div> +</template> +<style scoped> +</style> + diff --git a/pse-dashboard/src/components/LastUpdate.vue b/pse-dashboard/src/components/LastUpdate.vue new file mode 100644 index 0000000..b30b254 --- /dev/null +++ b/pse-dashboard/src/components/LastUpdate.vue @@ -0,0 +1,46 @@ +<script setup> +import { computed } from 'vue' +import { useI18n } from 'vue-i18n'; +import dayjs from 'dayjs/esm' +import relativeTime from 'dayjs/esm/plugin/relativeTime' +import utc from 'dayjs/esm/plugin/utc' +import 'dayjs/esm/locale/de.js' +dayjs.extend(relativeTime) +dayjs.extend(utc) + +const props = defineProps({ + iso: { + type: String, + default: "" + }, + unix: { + type: Number, + default: 0 + } +}); + +const { locale } = useI18n(); + +const tzOffset = (new Date()).getTimezoneOffset(); + +// compute difference from given iso string and now whenever data changes +const lastUpdate = computed(() => { + let lastUpdateTime; + + if (props.iso) { + lastUpdateTime = dayjs(props.iso).utc(true); + } else { + // lastUpdateTime = dayjs(props.unix * 1000); + lastUpdateTime = dayjs(props.unix * 1000).utcOffset(tzOffset).utc(true); + } + + return lastUpdateTime.locale(locale.value).fromNow(); +}); + +</script> +<template> + {{ lastUpdate }} +</template> +<style scoped> +</style> + diff --git a/pse-dashboard/src/components/LoadingConditional.vue b/pse-dashboard/src/components/LoadingConditional.vue new file mode 100644 index 0000000..2bf233c --- /dev/null +++ b/pse-dashboard/src/components/LoadingConditional.vue @@ -0,0 +1,18 @@ +<script setup> +defineProps({ + waitingFor: Boolean +}); + +</script> +<template> + <slot v-if="waitingFor" /> + <div + v-else + class="text-center" + > + <div class="spinner-border text-center" /> + </div> +</template> +<style scoped> +</style> + diff --git a/pse-dashboard/src/components/NavBar.vue b/pse-dashboard/src/components/NavBar.vue new file mode 100644 index 0000000..2cc36e5 --- /dev/null +++ b/pse-dashboard/src/components/NavBar.vue @@ -0,0 +1,134 @@ +<script setup> +import { store } from '@/store.js'; +</script> +<template> + <nav class="navbar navbar-expand-sm bg-light"> + <div class="container"> + <!-- brand --> + <router-link + class="navbar-brand" + to="/subscriptions" + > + <img + src="@/assets/logo.svg" + alt="" + width="200" + height="45" + > + </router-link> + + <!-- mobile view --> + <button + class="navbar-toggler" + type="button" + data-bs-toggle="collapse" + data-bs-target="#mynavbar" + > + <span class="navbar-toggler-icon" /> + </button> + <div + id="mynavbar" + class="collapse navbar-collapse" + > + <!-- routes --> + <div class="me-auto"> + <ul + v-if="store.isLoggedIn" + class="navbar-nav" + > + <li class="nav-item"> + <router-link + to="/subscriptions" + class="nav-link" + > + {{ $t("message.podcast", {n:2}) }} + </router-link> + </li> + <li class="nav-item"> + <router-link + to="/episodes" + class="nav-link" + > + {{ $t("message.mostRecentlyHeared") }} + </router-link> + </li> + </ul> + </div> + + <!-- right side of navbar --> + <div class="d-flex navbar-nav"> + <!-- change language --> + <!-- https://vue-i18n.intlify.dev/guide/essentials/scope.html --> + <div class="nav-item dropdown"> + <a + class="nav-link dropdown-toggle" + href="#" + role="button" + data-bs-toggle="dropdown" + > + <i class="fa fa-language" /> {{ $i18n.locale }} + </a> + <ul class="dropdown-menu"> + <li + v-for="locale in $i18n.availableLocales" + :key="locale" + > + <a + class="dropdown-item" + :class="{active: $i18n.locale == locale}" + @click="$i18n.locale = locale" + >{{ locale }}</a> + </li> + </ul> + </div> + + <!-- Help Modal --> + <a + href="#" + class="nav-link" + data-bs-toggle="modal" + data-bs-target="#help" + >{{ $t("message.help") }}</a> + + <!-- User Account --> + <div + v-if="store.isLoggedIn" + class="nav-item dropdown" + > + <a + href="#" + class="nav-link dropdown-toggle" + role="button" + data-bs-toggle="dropdown" + > + {{ store.username || "John Doe" }} <i class="m-1 fa fa-user" /> + </a> + <ul class="dropdown-menu"> + <li> + <router-link + to="/settings" + class="dropdown-item" + > + {{ $t("message.settings") }} + </router-link> + </li> + <li><hr class="dropdown-divider"></li> + <li> + <router-link + to="/login" + class="dropdown-item" + @click="store.logout()" + > + {{ $t("message.logout") }} + </router-link> + </li> + </ul> + </div> + </div> + </div> + </div> + </nav> +</template> +<style scoped> +</style> + diff --git a/pse-dashboard/src/components/PasswordInput.vue b/pse-dashboard/src/components/PasswordInput.vue new file mode 100644 index 0000000..599f438 --- /dev/null +++ b/pse-dashboard/src/components/PasswordInput.vue @@ -0,0 +1,45 @@ +<script setup> +import { ref } from 'vue'; +import { FloatingLabelInput } from '@/components'; + +defineProps({ + modelValue: { + type: String, + default: "" + }, + label: { + type: String, + default: "Password" + } +}); + +defineEmits(['update:modelValue']) +const isPasswordVisible = ref(false); +</script> +<template> + <div class="input-group form-input"> + <FloatingLabelInput + :type="isPasswordVisible ? 'text' : 'password'" + :label="label" + :model-value="modelValue" + @update:model-value="newValue => + $emit('update:modelValue', newValue)" + /> + + <label class="btn btn-outline-secondary d-flex align-items-center password-visible"> + <input + v-model="isPasswordVisible" + type="checkbox" + class="btn-check" + autocomplete="off" + > + <i + class="fa" + :class="isPasswordVisible ? 'fa-eye-slash' : 'fa-eye'" + /> + </label> + </div> +</template> +<style scoped> +</style> + diff --git a/pse-dashboard/src/components/PasswordValidator.vue b/pse-dashboard/src/components/PasswordValidator.vue new file mode 100644 index 0000000..a269426 --- /dev/null +++ b/pse-dashboard/src/components/PasswordValidator.vue @@ -0,0 +1,112 @@ +<script setup> +import { PasswordInput } from '@/components'; +import { computed, watch, ref } from 'vue'; +import { useI18n } from 'vue-i18n'; + +const props = defineProps({ + modelValue: { + type: Object, + default: undefined + } +}); + +const emit = defineEmits(['update:modelValue']); + +const { t } = useI18n(); + +const password1 = ref(""); +const password2 = ref(""); + +// Überprüft, ob Passwort und Wiederholungspasswort identisch sind +const passwordMatch = computed(() => { + return password1.value === password2.value; +}); + +watch(() => props.modelValue, (newVal) => { + if ( newVal.password == "" ) { + password1.value = ""; + password2.value = ""; + } +}); + +// Überprüft, ob Passwort lang genug ist +const passwordLength = computed(() => { + return password1.value.length >= 8; +}); + +// Überprüft, ob das Passwort ein Sonderzeichen enthält +const passwordSpecialChar = computed(() => { + const specialCharRegex = /[!@#$%^&*()_+\-=\[\]{});':"`\\|,.<>\/?$§€°~`´]/; + return specialCharRegex.test(password1.value); +}); + +// Überprüft, ob das Passwort Zahlen enthält +const passwordNumbers = computed(() => { + const numbersRegex = /[0-9]/; + return numbersRegex.test(password1.value); +}); + +// Überprüft ob das Passwort Groß- und Kleinschreibung enthält +const passwordUpperLower = computed(() => { + const upperLowerRegex = /^(?=.*[A-Z])(?=.*[a-z]).+$/; + return upperLowerRegex.test(password1.value); +}); + +// Wahr, falls das Passwort alle Eigenschaften für ein sicheres Passwort erfüllt +const isPasswordValidArray = computed(() => [ + { rule: passwordLength, text: t("passwordRequirements.passwordLength", 8) }, + { rule: passwordSpecialChar, text: t("passwordRequirements.passwordSpecialChar") }, + { rule: passwordNumbers, text: t("passwordRequirements.passwordNumbers") }, + { rule: passwordUpperLower, text: t("passwordRequirements.passwordUpperLower") }, + { rule: passwordMatch, text: t("passwordRequirements.passwordMatch") }, +]); + +watch(password1, emitPassword); +watch(password2, emitPassword); + +// emit password object with password and valid whenever passwort1 or 2 changes +function emitPassword() { + emit('update:modelValue', { + password: password1.value, + valid: isPasswordValidArray.value.every(({rule}) => rule.value) + }); +}; + +// Nimmt eine Funktion entgegen, +// falls diese wahr auswertet, wird ein Häckchen Emoji zurückgegeben, +// falls diese falsch auswertet, wird ein Kreuz Emoji zurückgegeben +function returnEmoji(fn) { + return fn ? '✅' : '❌'; +}; + +</script> +<template> + <!-- Eingabefeld für Passwort --> + <PasswordInput + v-model="password1" + :label="$t('form.password')" + /> + + <!-- Eingabefeld für Passwortwiederholung --> + <PasswordInput + v-model="password2" + :label="$t('message.repeatPassword')" + /> + + <!-- Liste mit allen Anforderungen für ein sicheres Passwort --> + <!-- Falls Anforderung erfüllt, wird ein Häckchen Emoji vor der Anforderung angezeigt, --> + <!-- falls nicht, ein Kreuz --> + <ul> + <li + v-for="(rule, index) in isPasswordValidArray" + :key="index" + class="d-flex" + > + {{ returnEmoji(rule.rule.value) }} {{ rule.text }} + </li> + </ul> +</template> +<style> + +</style> + diff --git a/pse-dashboard/src/components/ProgressTime.vue b/pse-dashboard/src/components/ProgressTime.vue new file mode 100644 index 0000000..61bd421 --- /dev/null +++ b/pse-dashboard/src/components/ProgressTime.vue @@ -0,0 +1,23 @@ +<script setup> +import { computed } from 'vue' +import dayjs from 'dayjs/esm' +import utc from 'dayjs/esm/plugin/utc' +dayjs.extend(utc) + +const props = defineProps({ + unix: { + type: Number, + default: 0 + } +}); + +// compute hours, minutes and seconds from unix-seconds whenever data changes +const progressTime = computed(() => dayjs.utc(props.unix * 1000).format("HH:mm:ss")); + +</script> +<template> + {{ progressTime }} +</template> +<style scoped> +</style> + diff --git a/pse-dashboard/src/components/SubscriptionEntry.vue b/pse-dashboard/src/components/SubscriptionEntry.vue new file mode 100644 index 0000000..db3b45c --- /dev/null +++ b/pse-dashboard/src/components/SubscriptionEntry.vue @@ -0,0 +1,118 @@ +<script setup> +import { useLogger } from '@/logger.js' +import { LastUpdate, ProgressTime } from '@/components' + +const props = defineProps({ + sub: { + type: Object, + default: undefined + } +}); + +defineEmits(['unsubscribe']); + +const { copiedPodcast, copiedPodcastError } = useLogger(); + +// share or copy the url of the podcast to clipboard +async function sharePodcast() { + const shareData = { + title: props.sub.title, + url: props.sub.url + }; + + // share API + try { + await navigator.share(shareData); + return; + } catch (err) { + console.error(err); + } + + // clipboard API + try { + await navigator.clipboard.writeText(shareData.url) + copiedPodcast(); + return; + } catch (err) { + console.error(err); + } + + copiedPodcastError(); +} + +</script> +<template> + <div class="card"> + <!-- title and timestamp --> + <div + class="card-header d-flex gap-3 py-3" + data-bs-toggle="collapse" + :data-bs-target="'#e' + $.uid" + > + <!-- Podcast Icon --> + <i + class="fa fa-podcast rounded-circle flex-shring-0" + style="font-size: 32px" + /> + + <div class="d-flex gap-2 w-100 justify-content-between"> + <!-- Title --> + <div> + <h6 class="mb-0"> + {{ sub.title || sub.url }} + </h6> + </div> + <div class="text-nowrap"> + <!-- Timestamp --> + <small class="opacity-50"> + <LastUpdate :unix="sub.timestamp * 1" /> + </small> + + <!-- Trash Button to unscubscribe from podcast --> + <!-- opens modal and emits unsubscribe event --> + <button + class="btn mx-2 btn-danger" + data-bs-toggle="modal" + data-bs-target="#delete-subs" + @click="$emit('unsubscribe', props.sub)" + > + <i class="fa fa-trash-can" /> + </button> + + <!-- Share Button (@click.stop should stops card to open but doesn't) --> + <button + class="btn ml-2 btn-secondary" + @click="sharePodcast" + > + <i class="fa fa-share" /> + </button> + </div> + </div> + </div> + + <!-- episode list --> + <div + :id="'e' + $.uid" + class="collapse" + data-bs-parent="#episodes-accordion" + > + <div class="card-body"> + <ol> + <li + v-for="(episode, index) in sub.episodes" + :key="index" + > + {{ episode.title }} + <span class="opacity-50 float-end"> + <ProgressTime :unix="episode.position" />/ + <ProgressTime :unix="episode.total" /> + </span> + </li> + </ol> + </div> + </div> + </div> +</template> +<style scoped> +</style> + diff --git a/pse-dashboard/src/components/index.js b/pse-dashboard/src/components/index.js new file mode 100644 index 0000000..d78c3d7 --- /dev/null +++ b/pse-dashboard/src/components/index.js @@ -0,0 +1,30 @@ +import DashboardLayout from './DashboardLayout.vue' +import EpisodeEntry from './EpisodeEntry.vue' +import ErrorLog from './ErrorLog.vue' +import FloatingLabelInput from './FloatingLabelInput.vue' +import FormLayout from './FormLayout.vue' +import HelpModal from './HelpModal.vue' +import LastUpdate from './LastUpdate.vue' +import LoadingConditional from './LoadingConditional.vue' +import NavBar from './NavBar.vue' +import PasswordInput from './PasswordInput.vue' +import PasswordValidator from './PasswordValidator.vue' +import ProgressTime from './ProgressTime.vue' +import SubscriptionEntry from './SubscriptionEntry.vue' + +export { + DashboardLayout, + EpisodeEntry, + ErrorLog, + FloatingLabelInput, + FormLayout, + HelpModal, + LastUpdate, + LoadingConditional, + NavBar, + PasswordInput, + PasswordValidator, + ProgressTime, + SubscriptionEntry, +} + |