summaryrefslogtreecommitdiff
path: root/pse-dashboard/src/components
diff options
context:
space:
mode:
authorOrangerot <purple@orangerot.dev>2024-06-19 00:14:49 +0200
committerOrangerot <purple@orangerot.dev>2024-06-27 12:11:14 +0200
commit5b8851b6c268d0e93c158908fbfae9f8473db5ff (patch)
tree7010eb85d86fa2da06ea4ffbcdb01a685d502ae8 /pse-dashboard/src/components
Initial commitHEADmain
Diffstat (limited to 'pse-dashboard/src/components')
-rw-r--r--pse-dashboard/src/components/DashboardLayout.vue10
-rw-r--r--pse-dashboard/src/components/EpisodeEntry.vue53
-rw-r--r--pse-dashboard/src/components/ErrorLog.vue36
-rw-r--r--pse-dashboard/src/components/FloatingLabelInput.vue35
-rw-r--r--pse-dashboard/src/components/FormLayout.vue24
-rw-r--r--pse-dashboard/src/components/HelpModal.vue44
-rw-r--r--pse-dashboard/src/components/LastUpdate.vue46
-rw-r--r--pse-dashboard/src/components/LoadingConditional.vue18
-rw-r--r--pse-dashboard/src/components/NavBar.vue134
-rw-r--r--pse-dashboard/src/components/PasswordInput.vue45
-rw-r--r--pse-dashboard/src/components/PasswordValidator.vue112
-rw-r--r--pse-dashboard/src/components/ProgressTime.vue23
-rw-r--r--pse-dashboard/src/components/SubscriptionEntry.vue118
-rw-r--r--pse-dashboard/src/components/index.js30
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,
+}
+