ESLint: Auto format and fix some warnings / errors.
This commit is contained in:
parent
90ac67efbe
commit
867be21f68
32 changed files with 737 additions and 489 deletions
frontend/src/components
|
@ -1,5 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
</script>
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<div class="spinner-container">
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import {computed, defineProps} from "vue";
|
||||
import { computed, defineProps } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
page: {
|
||||
|
@ -17,10 +17,17 @@ const props = defineProps({
|
|||
});
|
||||
|
||||
const firstItem = computed(() => {
|
||||
return Math.min(props.totalItems, (props.page - 1) * props.itemsPerPage + 1)
|
||||
return Math.min(
|
||||
props.totalItems,
|
||||
(props.page - 1) * props.itemsPerPage + 1
|
||||
);
|
||||
});
|
||||
const lastItem = computed(() => Math.min(props.totalItems, firstItem.value + props.itemsPerPage - 1));
|
||||
const lastPage = computed(() => Math.ceil(props.totalItems / props.itemsPerPage));
|
||||
const lastItem = computed(() =>
|
||||
Math.min(props.totalItems, firstItem.value + props.itemsPerPage - 1)
|
||||
);
|
||||
const lastPage = computed(() =>
|
||||
Math.ceil(props.totalItems / props.itemsPerPage)
|
||||
);
|
||||
const pages = computed(() => {
|
||||
const pages: number[] = [];
|
||||
if (lastPage.value <= 2) {
|
||||
|
@ -48,11 +55,13 @@ const showFirstEllipsis = computed(
|
|||
() => pages.value.length > 0 && pages.value[0] > 2
|
||||
);
|
||||
const showLastEllipsis = computed(
|
||||
() => pages.value.length > 0 && pages.value[pages.value.length - 1] < lastPage.value - 1
|
||||
() =>
|
||||
pages.value.length > 0 &&
|
||||
pages.value[pages.value.length - 1] < lastPage.value - 1
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "changePage", page: number): void,
|
||||
(e: "changePage", page: number): void;
|
||||
}>();
|
||||
|
||||
function toPage(page: number): void {
|
||||
|
@ -60,7 +69,9 @@ function toPage(page: number): void {
|
|||
}
|
||||
|
||||
// noinspection JSIncompatibleTypesComparison
|
||||
const classes = computed(() => (p: number) => p === props.page ? ["current-page"] : []);
|
||||
const classes = computed(
|
||||
() => (p: number) => p === props.page ? ["current-page"] : []
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -77,9 +88,22 @@ const classes = computed(() => (p: number) => p === props.page ? ["current-page"
|
|||
<li v-if="page > 1" @click="toPage(page - 1)">‹</li>
|
||||
<li :class="classes(1)" @click="toPage(1)">1</li>
|
||||
<li v-if="showFirstEllipsis" class="ellipsis">…</li>
|
||||
<li v-for="page in pages" :class="classes(page)" @click="toPage(page)">{{ page }}</li>
|
||||
<li
|
||||
v-for="page in pages"
|
||||
v-bind:key="page"
|
||||
:class="classes(page)"
|
||||
@click="toPage(page)"
|
||||
>
|
||||
{{ page }}
|
||||
</li>
|
||||
<li v-if="showLastEllipsis" class="ellipsis">…</li>
|
||||
<li v-if="lastPage > 1" :class="classes(lastPage)" @click="toPage(lastPage)">{{ lastPage }}</li>
|
||||
<li
|
||||
v-if="lastPage > 1"
|
||||
:class="classes(lastPage)"
|
||||
@click="toPage(lastPage)"
|
||||
>
|
||||
{{ lastPage }}
|
||||
</li>
|
||||
<li v-if="page < lastPage" @click="toPage(page + 1)">›</li>
|
||||
</ul>
|
||||
</nav>
|
|
@ -1,18 +1,18 @@
|
|||
<script setup lang="ts">
|
||||
import Spinner from "@/components/Spinner.vue";
|
||||
import ActivityIndicator from "@/components/ActivityIndicator.vue";
|
||||
|
||||
const props = defineProps({
|
||||
loading: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="{ 'loading-container': true, loading: loading }">
|
||||
<Spinner class="spinner" v-if="loading" />
|
||||
<div class="content" v-if="!loading">
|
||||
<div :class="{ 'loading-container': true, loading: props.loading }">
|
||||
<ActivityIndicator v-if="props.loading" />
|
||||
<div class="content" v-if="!props.loading">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -31,5 +31,4 @@ const props = defineProps({
|
|||
height: 10em;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
import {computed, defineProps} from "vue";
|
||||
import type {ComponentVariant, NodesFilter} from "@/types";
|
||||
import type {RouteName} from "@/router";
|
||||
import router, {route} from "@/router";
|
||||
import { computed, defineProps } from "vue";
|
||||
import type { ComponentVariant, NodesFilter } from "@/types";
|
||||
import type { RouteName } from "@/router";
|
||||
import { route } from "@/router";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
|
@ -24,8 +24,11 @@ const linkTarget = computed(() => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink :to="linkTarget" :class="['statistics-card', 'statistics-card-' + variant]">
|
||||
<i :class="['fa', 'fa-' + icon]" aria-hidden="true"/>
|
||||
<RouterLink
|
||||
:to="linkTarget"
|
||||
:class="['statistics-card', 'statistics-card-' + variant]"
|
||||
>
|
||||
<i :class="['fa', 'fa-' + icon]" aria-hidden="true" />
|
||||
<dl>
|
||||
<dt>{{ title }}</dt>
|
||||
<dd>{{ value }}</dd>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import type {ButtonSize, ComponentVariant} from "@/types";
|
||||
import type { ButtonSize, ComponentVariant } from "@/types";
|
||||
|
||||
interface Props {
|
||||
variant: ComponentVariant;
|
||||
|
@ -7,10 +7,10 @@ interface Props {
|
|||
icon: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "click"): void,
|
||||
(e: "click"): void;
|
||||
}>();
|
||||
|
||||
function onClick() {
|
||||
|
@ -19,8 +19,8 @@ function onClick() {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<button :class="[size, variant]" @click="onClick">
|
||||
<i :class="['fa', `fa-${icon}`]" aria-hidden="true"/>
|
||||
<button :class="[props.size, props.variant]" @click="onClick">
|
||||
<i :class="['fa', `fa-${props.icon}`]" aria-hidden="true" />
|
||||
<slot></slot>
|
||||
</button>
|
||||
</template>
|
||||
|
@ -44,7 +44,8 @@ button {
|
|||
border-color: $color;
|
||||
color: map-get($variant-text-colors, $variant);
|
||||
|
||||
&:hover, &:active {
|
||||
&:hover,
|
||||
&:active {
|
||||
background-color: $page-background-color;
|
||||
border-color: $color;
|
||||
color: $color;
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
import type {ButtonSize, ComponentAlignment} from "@/types";
|
||||
import type { ButtonSize, ComponentAlignment } from "@/types";
|
||||
|
||||
interface Props {
|
||||
buttonSize: ButtonSize;
|
||||
align: ComponentAlignment;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const props = defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['button-group', buttonSize, align]">
|
||||
<div :class="['button-group', props.buttonSize, props.align]">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import type {ButtonSize, ComponentVariant} from "@/types";
|
||||
import type {Route} from "@/router";
|
||||
import type { ButtonSize, ComponentVariant } from "@/types";
|
||||
import type { Route } from "@/router";
|
||||
import router from "@/router";
|
||||
import ActionButton from "@/components/form/ActionButton.vue";
|
||||
|
||||
|
@ -19,15 +19,9 @@ async function onClick() {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<ActionButton
|
||||
:variant="variant"
|
||||
:size="size"
|
||||
:icon="icon"
|
||||
@click="onClick"
|
||||
>
|
||||
<slot/>
|
||||
<ActionButton :variant="variant" :size="size" :icon="icon" @click="onClick">
|
||||
<slot />
|
||||
</ActionButton>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
</style>
|
||||
<style scoped lang="scss"></style>
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
import {type ComponentInternalInstance, ref, type Slot} from "vue";
|
||||
import { type ComponentInternalInstance, ref } from "vue";
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "submit"): void
|
||||
(e: "submit"): void;
|
||||
}>();
|
||||
|
||||
const validationComponents = ref<ComponentInternalInstance[]>([]);
|
||||
|
@ -10,14 +10,16 @@ const validationComponents = ref<ComponentInternalInstance[]>([]);
|
|||
defineExpose({
|
||||
registerValidationComponent(component: ComponentInternalInstance): void {
|
||||
validationComponents.value.push(component);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function validate(): boolean {
|
||||
let valid = true;
|
||||
for (const component of validationComponents.value) {
|
||||
if (!(component.exposed?.validate)) {
|
||||
throw new Error(`Component has no exposed validate() method: ${component.type.__name}`);
|
||||
if (!component.exposed?.validate) {
|
||||
throw new Error(
|
||||
`Component has no exposed validate() method: ${component.type.__name}`
|
||||
);
|
||||
}
|
||||
valid = component.exposed.validate() && valid;
|
||||
}
|
||||
|
@ -32,14 +34,12 @@ function onSubmit() {
|
|||
}
|
||||
// TODO: Else scroll to first error and focus input.
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<slot/>
|
||||
<slot />
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
</style>
|
||||
<style lang="scss"></style>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import {getCurrentInstance, onMounted, ref} from "vue";
|
||||
import {type Constraint, forConstraint} from "@/shared/validation/validator";
|
||||
import { getCurrentInstance, onMounted, ref } from "vue";
|
||||
import { type Constraint, forConstraint } from "@/shared/validation/validator";
|
||||
|
||||
interface Props {
|
||||
modelValue?: string;
|
||||
|
@ -30,7 +30,9 @@ function registerValidationComponent() {
|
|||
}
|
||||
parent = parent.parent;
|
||||
}
|
||||
throw new Error("Could not find matching ValidationForm for ValidationFormInpunt.");
|
||||
throw new Error(
|
||||
"Could not find matching ValidationForm for ValidationFormInpunt."
|
||||
);
|
||||
}
|
||||
|
||||
function onInput() {
|
||||
|
@ -39,7 +41,7 @@ function onInput() {
|
|||
}
|
||||
const element = input.value;
|
||||
if (!element) {
|
||||
console.warn("Could not get referenced input element.")
|
||||
console.warn("Could not get referenced input element.");
|
||||
return;
|
||||
}
|
||||
emit("update:modelValue", element.value);
|
||||
|
@ -48,7 +50,7 @@ function onInput() {
|
|||
function validate(): boolean {
|
||||
const element = input.value;
|
||||
if (!element) {
|
||||
console.warn("Could not get referenced input element.")
|
||||
console.warn("Could not get referenced input element.");
|
||||
return false;
|
||||
}
|
||||
valid.value = forConstraint(props.constraint, false)(element.value);
|
||||
|
@ -57,7 +59,7 @@ function validate(): boolean {
|
|||
}
|
||||
|
||||
defineExpose({
|
||||
validate
|
||||
validate,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
|
@ -68,7 +70,7 @@ onMounted(() => {
|
|||
<template>
|
||||
<div>
|
||||
<label>
|
||||
{{label}}:
|
||||
{{ label }}:
|
||||
<input
|
||||
ref="input"
|
||||
:value="modelValue"
|
||||
|
@ -78,7 +80,7 @@ onMounted(() => {
|
|||
/>
|
||||
</label>
|
||||
<div class="validation-error" v-if="!valid">
|
||||
{{validationError}}
|
||||
{{ validationError }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
import ValidationForm from "@/components/form/ValidationForm.vue";
|
||||
import {useNodeStore} from "@/stores/node";
|
||||
import { useNodeStore } from "@/stores/node";
|
||||
import ButtonGroup from "@/components/form/ButtonGroup.vue";
|
||||
import ActionButton from "@/components/form/ActionButton.vue";
|
||||
import NodePreviewCard from "@/components/nodes/NodePreviewCard.vue";
|
||||
import type {Hostname, StoredNode} from "@/types";
|
||||
import {ButtonSize, ComponentAlignment, ComponentVariant} from "@/types";
|
||||
import router, {route, RouteName} from "@/router";
|
||||
import {computed, nextTick, ref} from "vue";
|
||||
import {ApiError} from "@/utils/Api";
|
||||
import type { Hostname, StoredNode } from "@/types";
|
||||
import { ButtonSize, ComponentAlignment, ComponentVariant } from "@/types";
|
||||
import router, { route, RouteName } from "@/router";
|
||||
import { computed, nextTick, ref } from "vue";
|
||||
import { ApiError } from "@/utils/Api";
|
||||
import ErrorCard from "@/components/ErrorCard.vue";
|
||||
import {useConfigStore} from "@/stores/config";
|
||||
import { useConfigStore } from "@/stores/config";
|
||||
|
||||
interface Props {
|
||||
node: StoredNode;
|
||||
|
@ -24,7 +24,7 @@ const email = computed(() => configStore.getConfig?.community.contactEmail);
|
|||
const errorDeletingNode = ref<boolean>(false);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "delete", hostname: Hostname): void
|
||||
(e: "delete", hostname: Hostname): void;
|
||||
}>();
|
||||
|
||||
async function onSubmit() {
|
||||
|
@ -61,23 +61,31 @@ async function onAbort() {
|
|||
<h2>Soll der Knoten wirklich gelöscht werden?</h2>
|
||||
|
||||
<p>
|
||||
Soll der Knoten <strong>{{ props.node.hostname }}</strong> wirklich endgültig gelöscht werden?
|
||||
Du kannst ihn selbstverständlich später jederzeit erneut anmelden!
|
||||
Soll der Knoten <strong>{{ props.node.hostname }}</strong> wirklich
|
||||
endgültig gelöscht werden? Du kannst ihn selbstverständlich später
|
||||
jederzeit erneut anmelden!
|
||||
</p>
|
||||
|
||||
<ErrorCard v-if="errorDeletingNode">
|
||||
Beim Löschen des Knotens ist ein Fehler aufgetreten. Bitte probiere es später nochmal. Sollte dieses Problem
|
||||
weiter bestehen, so wende dich bitte per E-Mail an <a v-if="email" :href="`mailto:${email}`">{{ email }}</a>.
|
||||
Beim Löschen des Knotens ist ein Fehler aufgetreten. Bitte probiere
|
||||
es später nochmal. Sollte dieses Problem weiter bestehen, so wende
|
||||
dich bitte per E-Mail an
|
||||
<a v-if="email" :href="`mailto:${email}`">{{ email }}</a
|
||||
>.
|
||||
</ErrorCard>
|
||||
|
||||
<NodePreviewCard class="preview" :node="props.node"/>
|
||||
<NodePreviewCard class="preview" :node="props.node" />
|
||||
|
||||
<ButtonGroup :align="ComponentAlignment.CENTER" :button-size="ButtonSize.SMALL">
|
||||
<ButtonGroup
|
||||
:align="ComponentAlignment.CENTER"
|
||||
:button-size="ButtonSize.SMALL"
|
||||
>
|
||||
<ActionButton
|
||||
type="submit"
|
||||
icon="trash"
|
||||
:variant="ComponentVariant.WARNING"
|
||||
:size="ButtonSize.MEDIUM">
|
||||
:size="ButtonSize.MEDIUM"
|
||||
>
|
||||
Knoten löschen
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
|
@ -85,7 +93,8 @@ async function onAbort() {
|
|||
icon="times"
|
||||
:variant="ComponentVariant.SECONDARY"
|
||||
:size="ButtonSize.MEDIUM"
|
||||
@click="onAbort">
|
||||
@click="onAbort"
|
||||
>
|
||||
Abbrechen
|
||||
</ActionButton>
|
||||
</ButtonGroup>
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
<script setup lang="ts">
|
||||
import {useConfigStore} from "@/stores/config";
|
||||
import {useNodeStore} from "@/stores/node";
|
||||
import {computed, nextTick, ref} from "vue";
|
||||
import { useConfigStore } from "@/stores/config";
|
||||
import { useNodeStore } from "@/stores/node";
|
||||
import { computed, nextTick, ref } from "vue";
|
||||
import CONSTRAINTS from "@/shared/validation/constraints";
|
||||
import ActionButton from "@/components/form/ActionButton.vue";
|
||||
import type {StoredNode, Token} from "@/types";
|
||||
import {ButtonSize, ComponentAlignment, ComponentVariant} from "@/types";
|
||||
import type { StoredNode, Token } from "@/types";
|
||||
import { ButtonSize, ComponentAlignment, ComponentVariant } from "@/types";
|
||||
import ErrorCard from "@/components/ErrorCard.vue";
|
||||
import ButtonGroup from "@/components/form/ButtonGroup.vue";
|
||||
import ValidationForm from "@/components/form/ValidationForm.vue";
|
||||
import ValidationFormInput from "@/components/form/ValidationFormInput.vue";
|
||||
import {route, RouteName} from "@/router";
|
||||
import { route, RouteName } from "@/router";
|
||||
import RouteButton from "@/components/form/RouteButton.vue";
|
||||
import {ApiError} from "@/utils/Api";
|
||||
import { ApiError } from "@/utils/Api";
|
||||
|
||||
const configStore = useConfigStore();
|
||||
const email = computed(() => configStore.getConfig?.community.contactEmail);
|
||||
|
@ -57,15 +57,18 @@ async function onSubmit() {
|
|||
|
||||
<div>
|
||||
<p>
|
||||
Um die Daten Deines Knotens zu löschen, benötigen wir den passenden Token (eine 16-stellige Folge aus
|
||||
Ziffern und Buchstaben). Diesen hast Du beim ersten Anmelden Deines Knotens erhalten. Sinn des Tokens
|
||||
ist,
|
||||
Dich davor zu schützen, dass Dritte unbefugt Deine Daten einsehen oder ändern können.
|
||||
Um die Daten Deines Knotens zu löschen, benötigen wir den
|
||||
passenden Token (eine 16-stellige Folge aus Ziffern und
|
||||
Buchstaben). Diesen hast Du beim ersten Anmelden Deines Knotens
|
||||
erhalten. Sinn des Tokens ist, Dich davor zu schützen, dass
|
||||
Dritte unbefugt Deine Daten einsehen oder ändern können.
|
||||
</p>
|
||||
<p>
|
||||
<strong>
|
||||
Solltest Du den Token nicht mehr haben, wende Dich einfach per E-Mail an
|
||||
<a v-if="email" :href="`mailto:${email}`">{{ email }}</a>.
|
||||
Solltest Du den Token nicht mehr haben, wende Dich einfach
|
||||
per E-Mail an
|
||||
<a v-if="email" :href="`mailto:${email}`">{{ email }}</a
|
||||
>.
|
||||
</strong>
|
||||
</p>
|
||||
|
||||
|
@ -74,9 +77,11 @@ async function onSubmit() {
|
|||
</ErrorCard>
|
||||
|
||||
<ErrorCard v-if="generalError">
|
||||
Beim Abrufen des Knotens ist ein Fehler aufgetreten. Bitte probiere es später nochmal. Sollte dieses
|
||||
Problem weiter bestehen, so wende dich bitte per E-Mail an
|
||||
<a v-if="email" :href="`mailto:${email}`">{{ email }}</a>.
|
||||
Beim Abrufen des Knotens ist ein Fehler aufgetreten. Bitte
|
||||
probiere es später nochmal. Sollte dieses Problem weiter
|
||||
bestehen, so wende dich bitte per E-Mail an
|
||||
<a v-if="email" :href="`mailto:${email}`">{{ email }}</a
|
||||
>.
|
||||
</ErrorCard>
|
||||
|
||||
<fieldset>
|
||||
|
@ -87,12 +92,16 @@ async function onSubmit() {
|
|||
:constraint="CONSTRAINTS.token"
|
||||
validation-error="Das Token ist ein 16-stelliger Wert bestehend aus 0-9 und a-f."
|
||||
/>
|
||||
<ButtonGroup :align="ComponentAlignment.RIGHT" :button-size="ButtonSize.SMALL">
|
||||
<ButtonGroup
|
||||
:align="ComponentAlignment.RIGHT"
|
||||
:button-size="ButtonSize.SMALL"
|
||||
>
|
||||
<ActionButton
|
||||
type="submit"
|
||||
icon="trash"
|
||||
:variant="ComponentVariant.WARNING"
|
||||
:size="ButtonSize.SMALL">
|
||||
:size="ButtonSize.SMALL"
|
||||
>
|
||||
Knoten löschen
|
||||
</ActionButton>
|
||||
<RouteButton
|
||||
|
@ -100,7 +109,8 @@ async function onSubmit() {
|
|||
icon="times"
|
||||
:variant="ComponentVariant.SECONDARY"
|
||||
:size="ButtonSize.SMALL"
|
||||
:route="route(RouteName.HOME)">
|
||||
:route="route(RouteName.HOME)"
|
||||
>
|
||||
Abbrechen
|
||||
</RouteButton>
|
||||
</ButtonGroup>
|
||||
|
@ -108,9 +118,11 @@ async function onSubmit() {
|
|||
|
||||
<p>
|
||||
<em>
|
||||
Hinweis: Nach dem Löschen kann der Knoten ggf. weiterhin in der Knotenkarte angezeigt werden. Dies
|
||||
ist dann der Fall, wenn der Knoten eingeschaltet ist und in Reichweite eines anderen aktiven Knotens
|
||||
steht. Die angezeigten Daten sind dann die während der Einrichtung des Knotens im Config-Mode
|
||||
Hinweis: Nach dem Löschen kann der Knoten ggf. weiterhin in
|
||||
der Knotenkarte angezeigt werden. Dies ist dann der Fall,
|
||||
wenn der Knoten eingeschaltet ist und in Reichweite eines
|
||||
anderen aktiven Knotens steht. Die angezeigten Daten sind
|
||||
dann die während der Einrichtung des Knotens im Config-Mode
|
||||
(Konfigurationsoberfläche des Routers) hinterlegten.
|
||||
</em>
|
||||
</p>
|
||||
|
@ -118,5 +130,4 @@ async function onSubmit() {
|
|||
</ValidationForm>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
||||
<style lang="scss" scoped></style>
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import ButtonGroup from "@/components/form/ButtonGroup.vue";
|
||||
import type {Hostname} from "@/types";
|
||||
import {ButtonSize, ComponentAlignment, ComponentVariant} from "@/types";
|
||||
import {useConfigStore} from "@/stores/config";
|
||||
import {computed} from "vue";
|
||||
import type { Hostname } from "@/types";
|
||||
import { ButtonSize, ComponentAlignment, ComponentVariant } from "@/types";
|
||||
import { useConfigStore } from "@/stores/config";
|
||||
import { computed } from "vue";
|
||||
import RouteButton from "@/components/form/RouteButton.vue";
|
||||
import {route, RouteName} from "@/router";
|
||||
import { route, RouteName } from "@/router";
|
||||
|
||||
const configStore = useConfigStore();
|
||||
const email = computed(() => configStore.getConfig?.community.contactEmail);
|
||||
|
@ -22,38 +22,48 @@ const props = defineProps<Props>();
|
|||
<div>
|
||||
<h1>Erledigt!</h1>
|
||||
<p>
|
||||
Die Daten Deines Freifunk-Knotens sind gelöscht worden. Es kann jetzt noch bis zu 20 Minuten dauern,
|
||||
bis die Änderungen überall wirksam werden und sich in der
|
||||
<a :href="configStore.getConfig?.map.mapUrl" target="_blank">Knotenkarte</a> auswirken.
|
||||
Die Daten Deines Freifunk-Knotens sind gelöscht worden. Es kann
|
||||
jetzt noch bis zu 20 Minuten dauern, bis die Änderungen überall
|
||||
wirksam werden und sich in der
|
||||
<a :href="mapUrl" target="_blank">Knotenkarte</a> auswirken.
|
||||
</p>
|
||||
|
||||
<div class="summary">
|
||||
<div class="deleted-node">
|
||||
<i class="fa fa-trash-o" aria-hidden="true"/> <span class="node">{{ hostname }}</span>
|
||||
<i class="fa fa-trash-o" aria-hidden="true" />
|
||||
<span class="node">{{ props.hostname }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<em>
|
||||
Hinweis: Nach dem Löschen kann der Knoten ggf. weiterhin als online in der Knotenkarte angezeigt werden.
|
||||
Dies ist dann der Fall, wenn der Knoten eingeschaltet ist und in Reichweite eines anderen aktiven
|
||||
Knotens steht. Die angezeigten Daten sind dann die während der Einrichtung des Knotens im Config-Mode
|
||||
(Konfigurationsoberfläche des Routers) hinterlegten. Außerdem kann es nach dem Löschen noch etwa einen
|
||||
Monat dauern, bis der Knoten in der Karte auch nicht mehr als offline erscheint.
|
||||
Hinweis: Nach dem Löschen kann der Knoten ggf. weiterhin als
|
||||
online in der Knotenkarte angezeigt werden. Dies ist dann der
|
||||
Fall, wenn der Knoten eingeschaltet ist und in Reichweite eines
|
||||
anderen aktiven Knotens steht. Die angezeigten Daten sind dann
|
||||
die während der Einrichtung des Knotens im Config-Mode
|
||||
(Konfigurationsoberfläche des Routers) hinterlegten. Außerdem
|
||||
kann es nach dem Löschen noch etwa einen Monat dauern, bis der
|
||||
Knoten in der Karte auch nicht mehr als offline erscheint.
|
||||
</em>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Bei Fragen wende Dich gerne an
|
||||
<a :href="`mailto:${ email }`">{{ email }}</a>.
|
||||
<a :href="`mailto:${email}`">{{ email }}</a
|
||||
>.
|
||||
</p>
|
||||
|
||||
<ButtonGroup :align="ComponentAlignment.CENTER" :button-size="ButtonSize.MEDIUM">
|
||||
<ButtonGroup
|
||||
:align="ComponentAlignment.CENTER"
|
||||
:button-size="ButtonSize.MEDIUM"
|
||||
>
|
||||
<RouteButton
|
||||
icon="reply"
|
||||
:variant="ComponentVariant.SECONDARY"
|
||||
:size="ButtonSize.MEDIUM"
|
||||
:route="route(RouteName.HOME)">
|
||||
:route="route(RouteName.HOME)"
|
||||
>
|
||||
Zurück zum Anfang
|
||||
</RouteButton>
|
||||
</ButtonGroup>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import type {StoredNode} from "@/types";
|
||||
import {MonitoringState} from "@/types";
|
||||
import type { StoredNode } from "@/types";
|
||||
import { MonitoringState } from "@/types";
|
||||
|
||||
interface Props {
|
||||
node: StoredNode;
|
||||
|
@ -12,36 +12,43 @@ const props = defineProps<Props>();
|
|||
<template>
|
||||
<div class="node-preview-card">
|
||||
<h3>
|
||||
<i class="fa fa-dot-circle-o" aria-hidden="true" title="Knotenname:" />
|
||||
{{ node.hostname }}
|
||||
<i
|
||||
class="fa fa-dot-circle-o"
|
||||
aria-hidden="true"
|
||||
title="Knotenname:"
|
||||
/>
|
||||
{{ props.node.hostname }}
|
||||
</h3>
|
||||
|
||||
<div class="field">
|
||||
<strong>Token:</strong> <code>{{ node.token }}</code>
|
||||
<strong>Token:</strong> <code>{{ props.node.token }}</code>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<i class="fa fa-map-marker" aria-hidden="true" title="Standort:" />
|
||||
{{ node.coords || "nicht angegeben" }}
|
||||
{{ props.node.coords || "nicht angegeben" }}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<strong>MAC-Adresse:</strong> <code>{{ node.mac }}</code>
|
||||
<strong>MAC-Adresse:</strong> <code>{{ props.node.mac }}</code>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<strong>VPN-Schlüssel:</strong> <code>{{ node.key || "nicht angegeben" }}</code>
|
||||
<strong>VPN-Schlüssel:</strong>
|
||||
<code>{{ props.node.key || "nicht angegeben" }}</code>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<strong>Monitoring:</strong>
|
||||
<span v-if="node.monitoringState === MonitoringState.PENDING">
|
||||
<span v-if="props.node.monitoringState === MonitoringState.PENDING">
|
||||
Bestätigung ausstehend
|
||||
</span>
|
||||
<span v-if="node.monitoringState === MonitoringState.ACTIVE">
|
||||
<span v-if="props.node.monitoringState === MonitoringState.ACTIVE">
|
||||
aktiv
|
||||
</span>
|
||||
<span v-if="node.monitoringState === MonitoringState.DISABLED">
|
||||
<span
|
||||
v-if="props.node.monitoringState === MonitoringState.DISABLED"
|
||||
>
|
||||
nicht aktiv
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
@ -10,8 +10,8 @@ import {
|
|||
type SearchTerm,
|
||||
type UnixTimestampMilliseconds,
|
||||
} from "@/types";
|
||||
import {computed, nextTick, onMounted, ref, watch} from "vue";
|
||||
import {useConfigStore} from "@/stores/config";
|
||||
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
||||
import { useConfigStore } from "@/stores/config";
|
||||
|
||||
interface Props {
|
||||
searchTerm: SearchTerm;
|
||||
|
@ -20,7 +20,7 @@ interface Props {
|
|||
|
||||
const SEARCH_THROTTLE_DELAY_MS = 500;
|
||||
|
||||
const FILTER_LABELS: Record<string, string | Map<any, any>> = {
|
||||
const FILTER_LABELS: Record<string, string | Map<string | boolean, any>> = {
|
||||
hasKey: new Map([
|
||||
[true, "Mit VPN-Schlüssel"],
|
||||
[false, "Ohne VPN-Schlüssel"],
|
||||
|
@ -40,10 +40,10 @@ const FILTER_LABELS: Record<string, string | Map<any, any>> = {
|
|||
[OnlineState.ONLINE, "online"],
|
||||
[OnlineState.OFFLINE, "offline"],
|
||||
]),
|
||||
}
|
||||
};
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "updateFilter", filter: NodesFilter, searchTerm: SearchTerm): void,
|
||||
(e: "updateFilter", filter: NodesFilter, searchTerm: SearchTerm): void;
|
||||
}>();
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
@ -53,15 +53,18 @@ const suggestedFiltersExpanded = ref(false);
|
|||
const configStore = useConfigStore();
|
||||
|
||||
type Filter = {
|
||||
field: string,
|
||||
value: any,
|
||||
field: string;
|
||||
value: any;
|
||||
};
|
||||
const selectedFilters = ref<Filter[]>([]);
|
||||
|
||||
function selectedFilterIndex(filter: Filter): number {
|
||||
for (let i = 0; i < selectedFilters.value.length; i += 1) {
|
||||
const selectedFilter = selectedFilters.value[i];
|
||||
if (selectedFilter.field === filter.field && selectedFilter.value === filter.value) {
|
||||
if (
|
||||
selectedFilter.field === filter.field &&
|
||||
selectedFilter.value === filter.value
|
||||
) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
@ -80,6 +83,12 @@ function selectedFilterIndexForField(field: string): number {
|
|||
return -1;
|
||||
}
|
||||
|
||||
function pushFilter(filterGroup: Filter[], filter: Filter): void {
|
||||
if (selectedFilterIndex(filter) < 0) {
|
||||
filterGroup.push(filter);
|
||||
}
|
||||
}
|
||||
|
||||
const suggestedFilters = computed<Filter[][]>(() => {
|
||||
const cfg = configStore.getConfig;
|
||||
const sites = cfg?.community.sites || [];
|
||||
|
@ -88,18 +97,12 @@ const suggestedFilters = computed<Filter[][]>(() => {
|
|||
const filterGroups: Filter[][] = [];
|
||||
|
||||
for (const field of Object.keys(NODES_FILTER_FIELDS)) {
|
||||
const filterGroup: Filter[] = []
|
||||
|
||||
function pushFilter(filter: Filter): void {
|
||||
if (selectedFilterIndex(filter) < 0) {
|
||||
filterGroup.push(filter);
|
||||
}
|
||||
}
|
||||
const filterGroup: Filter[] = [];
|
||||
|
||||
switch (field) {
|
||||
case "site":
|
||||
for (const site of sites) {
|
||||
pushFilter({
|
||||
pushFilter(filterGroup, {
|
||||
field: "site",
|
||||
value: site,
|
||||
});
|
||||
|
@ -108,25 +111,26 @@ const suggestedFilters = computed<Filter[][]>(() => {
|
|||
|
||||
case "domain":
|
||||
for (const domain of domains) {
|
||||
pushFilter({
|
||||
pushFilter(filterGroup, {
|
||||
field: "domain",
|
||||
value: domain,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
default: {
|
||||
const labels = FILTER_LABELS[field];
|
||||
if (!isMap(labels)) {
|
||||
throw new Error(`Missing case for field ${field}.`);
|
||||
}
|
||||
|
||||
for (const value of labels.keys()) {
|
||||
pushFilter({
|
||||
pushFilter(filterGroup, {
|
||||
field,
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filterGroups.push(filterGroup);
|
||||
|
@ -152,7 +156,7 @@ function updateSelectedFilters() {
|
|||
const filter = props.filter as Record<string, any>;
|
||||
selectedFilters.value = [];
|
||||
for (const field of Object.keys(NODES_FILTER_FIELDS)) {
|
||||
if (filter.hasOwnProperty(field)) {
|
||||
if (Object.prototype.hasOwnProperty.call(filter, field)) {
|
||||
addSelectedFilter({
|
||||
field,
|
||||
value: filter[field],
|
||||
|
@ -165,7 +169,7 @@ watch(props, updateSelectedFilters);
|
|||
onMounted(updateSelectedFilters);
|
||||
|
||||
function renderFilter(filter: Filter): string {
|
||||
if (!FILTER_LABELS.hasOwnProperty(filter.field)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(FILTER_LABELS, filter.field)) {
|
||||
throw new Error(`Filter has no translation: ${filter.field}`);
|
||||
}
|
||||
|
||||
|
@ -175,7 +179,11 @@ function renderFilter(filter: Filter): string {
|
|||
}
|
||||
|
||||
if (!label.has(filter.value)) {
|
||||
throw new Error(`Filter ${filter.field} has no translation for value: ${filter.value}(${typeof filter.value})`);
|
||||
throw new Error(
|
||||
`Filter ${filter.field} has no translation for value: ${
|
||||
filter.value
|
||||
}(${typeof filter.value})`
|
||||
);
|
||||
}
|
||||
|
||||
return label.get(filter.value);
|
||||
|
@ -204,7 +212,8 @@ function buildNodesFilter(): NodesFilter {
|
|||
return nodesFilter;
|
||||
}
|
||||
|
||||
let lastSearchTimestamp: UnixTimestampMilliseconds = 0 as UnixTimestampMilliseconds;
|
||||
let lastSearchTimestamp: UnixTimestampMilliseconds =
|
||||
0 as UnixTimestampMilliseconds;
|
||||
let searchTimeout: NodeJS.Timeout | undefined = undefined;
|
||||
let lastSearchTerm: SearchTerm = "" as SearchTerm;
|
||||
|
||||
|
@ -217,17 +226,18 @@ function doSearch(): void {
|
|||
|
||||
function doThrottledSearch(): void {
|
||||
if (lastSearchTerm === input.value.value) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Share utils.
|
||||
const now: UnixTimestampMilliseconds = Date.now() as UnixTimestampMilliseconds;
|
||||
const now: UnixTimestampMilliseconds =
|
||||
Date.now() as UnixTimestampMilliseconds;
|
||||
if (now - SEARCH_THROTTLE_DELAY_MS >= lastSearchTimestamp) {
|
||||
lastSearchTimestamp = now;
|
||||
doSearch();
|
||||
} else if (!searchTimeout) {
|
||||
searchTimeout = setTimeout(() => {
|
||||
searchTimeout = undefined
|
||||
searchTimeout = undefined;
|
||||
doThrottledSearch();
|
||||
}, SEARCH_THROTTLE_DELAY_MS);
|
||||
}
|
||||
|
@ -235,19 +245,25 @@ function doThrottledSearch(): void {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="{ 'nodes-filter-panel': true, 'focus': hasFocus}" @click="focusInput">
|
||||
<div
|
||||
:class="{ 'nodes-filter-panel': true, focus: hasFocus }"
|
||||
@click="focusInput"
|
||||
>
|
||||
<div class="nodes-filter-input">
|
||||
<div class="selected-filters">
|
||||
<span
|
||||
v-for="filter in selectedFilters"
|
||||
v-bind:key="`${filter.field}:${filter.value}`"
|
||||
class="selected-filter"
|
||||
@click="removeSelectedFilter(filter)"
|
||||
:title="renderFilter(filter)">
|
||||
:title="renderFilter(filter)"
|
||||
>
|
||||
{{ renderFilter(filter) }}
|
||||
<i
|
||||
class="fa fa-times remove-filter"
|
||||
aria-hidden="true"
|
||||
title="Filter entfernen"/>
|
||||
title="Filter entfernen"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
|
@ -258,21 +274,28 @@ function doThrottledSearch(): void {
|
|||
maxlength="64"
|
||||
type="search"
|
||||
:value="searchTerm"
|
||||
placeholder="Knoten durchsuchen..."/>
|
||||
<i class="fa fa-search search" @click="doSearch()"/>
|
||||
placeholder="Knoten durchsuchen..."
|
||||
/>
|
||||
<i class="fa fa-search search" @click="doSearch()" />
|
||||
</div>
|
||||
<div class="suggested-filters" v-if="suggestedFiltersExpanded">
|
||||
<div class="suggested-filter-group" v-for="filterGroup in suggestedFilters">
|
||||
<div
|
||||
class="suggested-filter-group"
|
||||
v-for="filterGroup in suggestedFilters"
|
||||
>
|
||||
<span
|
||||
class="suggested-filter"
|
||||
v-for="filter in filterGroup"
|
||||
v-bind:key="filter.field"
|
||||
@click="addSelectedFilter(filter)"
|
||||
:title="renderFilter(filter)">
|
||||
:title="renderFilter(filter)"
|
||||
>
|
||||
{{ renderFilter(filter) }}
|
||||
<i
|
||||
class="fa fa-plus add-filter"
|
||||
aria-hidden="true"
|
||||
title="Filter hinzufügen"/>
|
||||
title="Filter hinzufügen"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -281,14 +304,16 @@ function doThrottledSearch(): void {
|
|||
v-if="suggestedFiltersExpanded"
|
||||
class="toggle-suggested-filters"
|
||||
href="javascript:"
|
||||
@click="showSuggestedFilters(false)">
|
||||
@click="showSuggestedFilters(false)"
|
||||
>
|
||||
Erweiterte Suche ausblenden
|
||||
</a>
|
||||
<a
|
||||
v-if="!suggestedFiltersExpanded"
|
||||
class="toggle-suggested-filters"
|
||||
href="javascript:"
|
||||
@click="showSuggestedFilters(true)">
|
||||
@click="showSuggestedFilters(true)"
|
||||
>
|
||||
Erweiterte Suche einblenden
|
||||
</a>
|
||||
</template>
|
||||
|
@ -329,12 +354,14 @@ function doThrottledSearch(): void {
|
|||
user-select: none;
|
||||
white-space: nowrap;
|
||||
|
||||
.add-filter, .remove-filter {
|
||||
.add-filter,
|
||||
.remove-filter {
|
||||
margin-left: 0.25em;
|
||||
}
|
||||
}
|
||||
|
||||
.selected-filters, .suggested-filter-group {
|
||||
.selected-filters,
|
||||
.suggested-filter-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
</script>
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import {useConfigStore} from "@/stores/config";
|
||||
import {useVersionStore} from "@/stores/version";
|
||||
import { useConfigStore } from "@/stores/config";
|
||||
import { useVersionStore } from "@/stores/version";
|
||||
|
||||
const configStore = useConfigStore();
|
||||
const versionStore = useVersionStore();
|
||||
|
@ -12,17 +12,24 @@ const versionStore = useVersionStore();
|
|||
<a href="https://github.com/freifunkhamburg/ffffng" target="_blank">
|
||||
<i class="fa fa-code" aria-hidden="true" /> Source Code
|
||||
</a>
|
||||
<a href="https://github.com/freifunkhamburg/ffffng/issues" target="_blank">
|
||||
<a
|
||||
href="https://github.com/freifunkhamburg/ffffng/issues"
|
||||
target="_blank"
|
||||
>
|
||||
<i class="fa fa-bug" aria-hidden="true" /> Fehler melden
|
||||
</a>
|
||||
<a
|
||||
v-if="configStore.getConfig.legal.privacyUrl"
|
||||
:href="configStore.getConfig.legal.privacyUrl"
|
||||
target="_blank">Datenschutz</a>
|
||||
target="_blank"
|
||||
>Datenschutz</a
|
||||
>
|
||||
<a
|
||||
v-if="configStore.getConfig.legal.imprintUrl"
|
||||
:href="configStore.getConfig.legal.imprintUrl"
|
||||
target="_blank">Impressum</a>
|
||||
target="_blank"
|
||||
>Impressum</a
|
||||
>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import {useConfigStore} from "@/stores/config";
|
||||
import {route, RouteName} from "@/router";
|
||||
import { useConfigStore } from "@/stores/config";
|
||||
import { route, RouteName } from "@/router";
|
||||
|
||||
const configStore = useConfigStore();
|
||||
</script>
|
||||
|
@ -18,12 +18,13 @@ const configStore = useConfigStore();
|
|||
|
||||
<h1>
|
||||
<RouterLink :to="route(RouteName.HOME)">
|
||||
{{ configStore.getConfig.community.name }} – Knotenverwaltung
|
||||
{{ configStore.getConfig.community.name }} –
|
||||
Knotenverwaltung
|
||||
</RouterLink>
|
||||
</h1>
|
||||
|
||||
<RouterLink class="admin-link" :to="route(RouteName.ADMIN)">
|
||||
<i class="fa fa-wrench" aria-hidden="true"/> Admin-Panel
|
||||
<i class="fa fa-wrench" aria-hidden="true" /> Admin-Panel
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</header>
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
<script lang="ts">
|
||||
import {type Component, defineComponent, type PropType} from "vue";
|
||||
import {type EnumValue, SortDirection} from "@/types";
|
||||
import { type Component, defineComponent, type PropType } from "vue";
|
||||
import { type EnumValue, SortDirection } from "@/types";
|
||||
|
||||
type Props<SortField> = {
|
||||
field: PropType<EnumValue<SortField>>,
|
||||
currentField: PropType<EnumValue<SortField>>,
|
||||
currentDirection: PropType<SortDirection>,
|
||||
field: PropType<EnumValue<SortField>>;
|
||||
currentField: PropType<EnumValue<SortField>>;
|
||||
currentDirection: PropType<SortDirection>;
|
||||
};
|
||||
|
||||
type SortTH<SortField> = Component<Props<SortField>>;
|
||||
|
@ -21,7 +21,9 @@ function defineGenericComponent<SortField>(): SortTH<SortField> {
|
|||
props,
|
||||
computed: {
|
||||
sortDirection: function () {
|
||||
return this.field === this.currentField ? this.currentDirection : undefined;
|
||||
return this.field === this.currentField
|
||||
? this.currentDirection
|
||||
: undefined;
|
||||
},
|
||||
isAscending: function () {
|
||||
return this.sortDirection === SortDirection.ASCENDING;
|
||||
|
@ -33,11 +35,13 @@ function defineGenericComponent<SortField>(): SortTH<SortField> {
|
|||
methods: {
|
||||
onClick(): void {
|
||||
this.$emit(
|
||||
'sort',
|
||||
"sort",
|
||||
this.field,
|
||||
this.isAscending ? SortDirection.DESCENDING : SortDirection.ASCENDING
|
||||
this.isAscending
|
||||
? SortDirection.DESCENDING
|
||||
: SortDirection.ASCENDING
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -55,7 +59,7 @@ export default component;
|
|||
<template>
|
||||
<th>
|
||||
<a href="javascript:" title="Sortieren" @click="onClick">
|
||||
<slot/>
|
||||
<slot />
|
||||
<i v-if="sortDirection && isAscending" class="fa fa-chevron-down" />
|
||||
<i v-if="sortDirection && !isAscending" class="fa fa-chevron-up" />
|
||||
</a>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue