mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-04-10 15:05:35 -04:00
chore: Nuxt 4 upgrade (#7426)
This commit is contained in:
13
frontend/app/components/global/AdvancedOnly.vue
Normal file
13
frontend/app/components/global/AdvancedOnly.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<slot v-if="advanced" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Renderless component that only renders if the user is logged in.
|
||||
* and has advanced options toggled.
|
||||
*/
|
||||
const auth = useMealieAuth();
|
||||
|
||||
const advanced = auth.user.value?.advanced || false;
|
||||
</script>
|
||||
85
frontend/app/components/global/AppButtonCopy.vue
Normal file
85
frontend/app/components/global/AppButtonCopy.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<v-tooltip
|
||||
ref="copyToolTip"
|
||||
v-model="show"
|
||||
location="top"
|
||||
:open-on-hover="false"
|
||||
:open-on-click="true"
|
||||
close-delay="500"
|
||||
transition="slide-y-transition"
|
||||
>
|
||||
<template #activator="{ props: hoverProps }">
|
||||
<v-btn
|
||||
variant="flat"
|
||||
:icon="icon"
|
||||
:color="color"
|
||||
retain-focus-on-click
|
||||
:class="btnClass"
|
||||
:disabled="copyText !== '' ? false : true"
|
||||
v-bind="hoverProps"
|
||||
@click="textToClipboard()"
|
||||
>
|
||||
<v-icon>{{ $globals.icons.contentCopy }}</v-icon>
|
||||
{{ icon ? "" : $t("general.copy") }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<span v-if="!isSupported || copiedSuccess !== null">
|
||||
<v-icon start>
|
||||
{{ $globals.icons.clipboardCheck }}
|
||||
</v-icon>
|
||||
<slot v-if="!isSupported"> {{ $t("general.your-browser-does-not-support-clipboard") }} </slot>
|
||||
<slot v-else> {{ copiedSuccess ? $t("general.copied_message") : $t("general.clipboard-copy-failure") }} </slot>
|
||||
</span>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
|
||||
const props = defineProps({
|
||||
copyText: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
icon: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
btnClass: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const { copy, copied, isSupported } = useClipboard();
|
||||
const show = ref(false);
|
||||
const copiedSuccess = ref<boolean | null>(null);
|
||||
|
||||
async function textToClipboard() {
|
||||
if (isSupported.value) {
|
||||
await copy(props.copyText);
|
||||
if (copied.value) {
|
||||
copiedSuccess.value = true;
|
||||
console.info(`Copied\n${props.copyText}`);
|
||||
}
|
||||
else {
|
||||
copiedSuccess.value = false;
|
||||
console.error("Copy failed: ", copied.value);
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.warn("Clipboard is currently not supported by your browser. Ensure you're on a secure (https) site.");
|
||||
}
|
||||
|
||||
show.value = true;
|
||||
setTimeout(() => {
|
||||
show.value = false;
|
||||
}, 3000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
154
frontend/app/components/global/AppButtonUpload.vue
Normal file
154
frontend/app/components/global/AppButtonUpload.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<v-form ref="form">
|
||||
<input
|
||||
ref="uploader"
|
||||
class="d-none"
|
||||
type="file"
|
||||
:accept="accept"
|
||||
:multiple="multiple"
|
||||
@change="onFileChanged"
|
||||
>
|
||||
<slot v-bind="{ isSelecting, onButtonClick }">
|
||||
<v-btn
|
||||
:loading="isSelecting"
|
||||
:small="small"
|
||||
:color="color"
|
||||
:variant="textBtn ? 'text' : 'elevated'"
|
||||
:disabled="disabled"
|
||||
@click="onButtonClick"
|
||||
>
|
||||
<v-icon start>
|
||||
{{ effIcon }}
|
||||
</v-icon>
|
||||
{{ text ? text : defaultText }}
|
||||
</v-btn>
|
||||
</slot>
|
||||
</v-form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUserApi } from "~/composables/api";
|
||||
|
||||
const UPLOAD_EVENT = "uploaded";
|
||||
|
||||
const props = defineProps({
|
||||
small: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
post: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
url: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
fileName: {
|
||||
type: String,
|
||||
default: "archive",
|
||||
},
|
||||
textBtn: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
accept: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: "info",
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "uploaded", payload: File | File[] | unknown | null): void;
|
||||
}>();
|
||||
|
||||
const selectedFiles = ref<File[]>([]);
|
||||
const uploader = ref<HTMLInputElement | null>(null);
|
||||
const isSelecting = ref(false);
|
||||
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
const effIcon = props.icon ? props.icon : $globals.icons.upload;
|
||||
|
||||
const defaultText = i18n.t("general.upload");
|
||||
|
||||
const api = useUserApi();
|
||||
async function upload() {
|
||||
if (selectedFiles.value.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSelecting.value = true;
|
||||
|
||||
if (!props.post) {
|
||||
emit(UPLOAD_EVENT, props.multiple ? selectedFiles.value : selectedFiles.value[0]);
|
||||
isSelecting.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.multiple && selectedFiles.value.length > 1) {
|
||||
console.warn("Multiple file uploads are not supported by the API.");
|
||||
return;
|
||||
}
|
||||
|
||||
const file = selectedFiles.value[0];
|
||||
const formData = new FormData();
|
||||
formData.append(props.fileName, file);
|
||||
|
||||
try {
|
||||
const response = await api.upload.file(props.url, formData);
|
||||
if (response) {
|
||||
emit(UPLOAD_EVENT, response);
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e);
|
||||
emit(UPLOAD_EVENT, null);
|
||||
}
|
||||
|
||||
isSelecting.value = false;
|
||||
}
|
||||
|
||||
function onFileChanged(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
|
||||
if (target.files !== null && target.files.length > 0) {
|
||||
selectedFiles.value = Array.from(target.files);
|
||||
upload();
|
||||
}
|
||||
}
|
||||
|
||||
function onButtonClick() {
|
||||
isSelecting.value = true;
|
||||
window.addEventListener(
|
||||
"focus",
|
||||
() => {
|
||||
isSelecting.value = false;
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
uploader.value?.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
101
frontend/app/components/global/AppLoader.vue
Normal file
101
frontend/app/components/global/AppLoader.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div
|
||||
class="mx-auto my-3 justify-center"
|
||||
style="display: flex;"
|
||||
>
|
||||
<div style="display: inline;">
|
||||
<v-progress-circular
|
||||
:width="size.width"
|
||||
:size="size.size"
|
||||
color="primary-lighten-2"
|
||||
indeterminate
|
||||
>
|
||||
<div class="text-center">
|
||||
<v-icon
|
||||
:size="size.icon"
|
||||
color="primary-lighten-2"
|
||||
>
|
||||
{{ $globals.icons.primary }}
|
||||
</v-icon>
|
||||
<div
|
||||
v-if="large"
|
||||
class="text-small"
|
||||
>
|
||||
<slot>
|
||||
{{ (small || tiny) ? "" : waitingText }}
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</v-progress-circular>
|
||||
<div
|
||||
v-if="!large"
|
||||
class="text-small"
|
||||
>
|
||||
<slot>
|
||||
{{ (small || tiny) ? "" : waitingTextCalculated }}
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
tiny: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
small: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
medium: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
large: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
waitingText: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const size = computed(() => {
|
||||
if (props.tiny) {
|
||||
return {
|
||||
width: 2,
|
||||
icon: 0,
|
||||
size: 25,
|
||||
};
|
||||
}
|
||||
if (props.small) {
|
||||
return {
|
||||
width: 2,
|
||||
icon: 30,
|
||||
size: 50,
|
||||
};
|
||||
}
|
||||
else if (props.large) {
|
||||
return {
|
||||
width: 4,
|
||||
icon: 120,
|
||||
size: 200,
|
||||
};
|
||||
}
|
||||
return {
|
||||
width: 3,
|
||||
icon: 75,
|
||||
size: 125,
|
||||
};
|
||||
});
|
||||
|
||||
const i18n = useI18n();
|
||||
const waitingTextCalculated = props.waitingText == null ? i18n.t("general.loading-recipes") : props.waitingText;
|
||||
</script>
|
||||
48
frontend/app/components/global/AppLogo.vue
Normal file
48
frontend/app/components/global/AppLogo.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="icon-container">
|
||||
<v-divider class="icon-divider" />
|
||||
<v-avatar
|
||||
:class="['pa-2', 'icon-avatar']"
|
||||
color="primary"
|
||||
:size="size"
|
||||
>
|
||||
<slot>
|
||||
<svg
|
||||
class="icon-white"
|
||||
viewBox="0 0 24 24"
|
||||
:style="{ width: size + 'px', height: size + 'px' }"
|
||||
>
|
||||
<path
|
||||
d="M8.1,13.34L3.91,9.16C2.35,7.59 2.35,5.06 3.91,3.5L10.93,10.5L8.1,13.34M13.41,13L20.29,19.88L18.88,21.29L12,14.41L5.12,21.29L3.71,19.88L13.36,10.22L13.16,10C12.38,9.23 12.38,7.97 13.16,7.19L17.5,2.82L18.43,3.74L15.19,7L16.15,7.94L19.39,4.69L20.31,5.61L17.06,8.85L18,9.81L21.26,6.56L22.18,7.5L17.81,11.84C17.03,12.62 15.77,12.62 15,11.84L14.78,11.64L13.41,13Z"
|
||||
/>
|
||||
</svg>
|
||||
</slot>
|
||||
</v-avatar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 75 });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.icon-white {
|
||||
fill: white;
|
||||
}
|
||||
.icon-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
margin-top: 2.5rem;
|
||||
}
|
||||
.icon-divider {
|
||||
width: 100%;
|
||||
margin-bottom: -2.5rem;
|
||||
}
|
||||
.icon-avatar {
|
||||
border-color: rgba(0, 0, 0, 0.12);
|
||||
border: 2px;
|
||||
}
|
||||
</style>
|
||||
31
frontend/app/components/global/AppToolbar.vue
Normal file
31
frontend/app/components/global/AppToolbar.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<v-toolbar
|
||||
color="transparent"
|
||||
flat
|
||||
>
|
||||
<BaseButton
|
||||
color="null"
|
||||
rounded
|
||||
secondary
|
||||
@click="$router.go(-1)"
|
||||
>
|
||||
<template #icon>
|
||||
{{ $globals.icons.arrowLeftBold }}
|
||||
</template>
|
||||
{{ $t('general.back') }}
|
||||
</BaseButton>
|
||||
<slot />
|
||||
</v-toolbar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
back: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
||||
202
frontend/app/components/global/AutoForm.vue
Normal file
202
frontend/app/components/global/AutoForm.vue
Normal file
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<v-form v-model="isValid" validate-on="input">
|
||||
<v-card
|
||||
:color="color"
|
||||
:dark="dark"
|
||||
flat
|
||||
:width="width"
|
||||
class="my-2"
|
||||
>
|
||||
<v-row no-gutters>
|
||||
<template v-for="(inputField, index) in items" :key="index">
|
||||
<v-col
|
||||
v-if="inputField.section"
|
||||
:cols="12"
|
||||
class="px-2"
|
||||
>
|
||||
<v-divider
|
||||
class="my-2"
|
||||
/>
|
||||
<v-card-title
|
||||
class="pl-0"
|
||||
>
|
||||
{{ inputField.section }}
|
||||
</v-card-title>
|
||||
<v-card-text
|
||||
v-if="inputField.sectionDetails"
|
||||
class="pl-0 mt-0 pt-0"
|
||||
>
|
||||
{{ inputField.sectionDetails }}
|
||||
</v-card-text>
|
||||
</v-col>
|
||||
<v-col
|
||||
:cols="inputField.cols || 12"
|
||||
class="px-2"
|
||||
>
|
||||
<!-- Check Box -->
|
||||
<v-checkbox
|
||||
v-if="inputField.type === fieldTypes.BOOLEAN"
|
||||
v-model="model[inputField.varName]"
|
||||
:name="inputField.varName"
|
||||
:readonly="fieldState[inputField.varName]?.readonly"
|
||||
:disabled="fieldState[inputField.varName]?.disabled"
|
||||
:hint="inputField.hint"
|
||||
:hide-details="!inputField.hint"
|
||||
:persistent-hint="!!inputField.hint"
|
||||
density="comfortable"
|
||||
validate-on="input"
|
||||
>
|
||||
<template #label>
|
||||
<span class="ml-4">
|
||||
{{ inputField.label }}
|
||||
</span>
|
||||
</template>
|
||||
</v-checkbox>
|
||||
|
||||
<!-- Text Field -->
|
||||
<v-text-field
|
||||
v-else-if="inputField.type === fieldTypes.TEXT || inputField.type === fieldTypes.PASSWORD"
|
||||
v-model="model[inputField.varName]"
|
||||
:readonly="fieldState[inputField.varName]?.readonly"
|
||||
:disabled="fieldState[inputField.varName]?.disabled"
|
||||
:type="inputField.type === fieldTypes.PASSWORD ? 'password' : 'text'"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
density="comfortable"
|
||||
:label="inputField.label"
|
||||
:name="inputField.varName"
|
||||
:hint="inputField.hint || ''"
|
||||
:rules="!(inputField.disableUpdate && updateMode) ? inputField.rules || [] : []"
|
||||
validate-on="input"
|
||||
/>
|
||||
|
||||
<!-- Text Area -->
|
||||
<v-textarea
|
||||
v-else-if="inputField.type === fieldTypes.TEXT_AREA"
|
||||
v-model="model[inputField.varName]"
|
||||
:readonly="fieldState[inputField.varName]?.readonly"
|
||||
:disabled="fieldState[inputField.varName]?.disabled"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
rows="3"
|
||||
auto-grow
|
||||
density="comfortable"
|
||||
:label="inputField.label"
|
||||
:name="inputField.varName"
|
||||
:hint="inputField.hint || ''"
|
||||
:rules="!(inputField.disableUpdate && updateMode) ? inputField.rules || [] : []"
|
||||
validate-on="input"
|
||||
/>
|
||||
|
||||
<!-- Number Input -->
|
||||
<v-number-input
|
||||
v-else-if="inputField.type === fieldTypes.NUMBER"
|
||||
v-model="model[inputField.varName]"
|
||||
variant="underlined"
|
||||
:control-variant="inputField.numberInputConfig?.controlVariant"
|
||||
density="comfortable"
|
||||
:label="inputField.label"
|
||||
:name="inputField.varName"
|
||||
:min="inputField.numberInputConfig?.min"
|
||||
:max="inputField.numberInputConfig?.max"
|
||||
:precision="inputField.numberInputConfig?.precision"
|
||||
:hint="inputField.hint"
|
||||
:hide-details="!inputField.hint"
|
||||
:persistent-hint="!!inputField.hint"
|
||||
:rules="!(inputField.disableUpdate && updateMode) ? inputField.rules || [] : []"
|
||||
validate-on="input"
|
||||
/>
|
||||
|
||||
<!-- Option Select -->
|
||||
<v-select
|
||||
v-else-if="inputField.type === fieldTypes.SELECT"
|
||||
v-model="model[inputField.varName]"
|
||||
:readonly="fieldState[inputField.varName]?.readonly"
|
||||
:disabled="fieldState[inputField.varName]?.disabled"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
:label="inputField.label"
|
||||
:name="inputField.varName"
|
||||
:items="inputField.options"
|
||||
item-title="text"
|
||||
:item-value="inputField.selectReturnValue || 'text'"
|
||||
:return-object="false"
|
||||
:hint="inputField.hint"
|
||||
density="comfortable"
|
||||
persistent-hint
|
||||
:rules="!(inputField.disableUpdate && updateMode) ? inputField.rules || [] : []"
|
||||
validate-on="input"
|
||||
/>
|
||||
|
||||
<!-- Color Picker -->
|
||||
<div
|
||||
v-else-if="inputField.type === fieldTypes.COLOR"
|
||||
class="d-flex"
|
||||
style="width: 100%"
|
||||
>
|
||||
<InputColor v-model="model[inputField.varName]" />
|
||||
</div>
|
||||
</v-col>
|
||||
</template>
|
||||
</v-row>
|
||||
</v-card>
|
||||
</v-form>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { fieldTypes } from "@/composables/forms";
|
||||
import type { AutoFormItems } from "~/types/auto-forms";
|
||||
|
||||
// Use defineModel for v-model
|
||||
const model = defineModel<Record<string, any> | any[]>({
|
||||
type: [Object, Array],
|
||||
required: true,
|
||||
});
|
||||
const isValid = defineModel("isValid", { type: Boolean, default: false });
|
||||
|
||||
const props = defineProps({
|
||||
updateMode: {
|
||||
default: false,
|
||||
type: Boolean,
|
||||
},
|
||||
items: {
|
||||
default: null,
|
||||
type: Array as () => AutoFormItems,
|
||||
},
|
||||
width: {
|
||||
type: [Number, String],
|
||||
default: "max",
|
||||
},
|
||||
color: {
|
||||
default: null,
|
||||
type: String,
|
||||
},
|
||||
dark: {
|
||||
default: false,
|
||||
type: Boolean,
|
||||
},
|
||||
disabledFields: {
|
||||
default: null,
|
||||
type: Array as () => string[],
|
||||
},
|
||||
readonlyFields: {
|
||||
default: null,
|
||||
type: Array as () => string[],
|
||||
},
|
||||
});
|
||||
|
||||
// Combined state map for readonly and disabled fields
|
||||
const fieldState = computed<Record<string, { readonly: boolean; disabled: boolean }>>(() => {
|
||||
const map: Record<string, { readonly: boolean; disabled: boolean }> = {};
|
||||
(props.items || []).forEach((field: any) => {
|
||||
const base = (field.disableUpdate && props.updateMode) || (!props.updateMode && field.disableCreate);
|
||||
map[field.varName] = {
|
||||
readonly: base || !!props.readonlyFields?.includes(field.varName),
|
||||
disabled: base || !!props.disabledFields?.includes(field.varName),
|
||||
};
|
||||
});
|
||||
return map;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
26
frontend/app/components/global/BannerExperimental.vue
Normal file
26
frontend/app/components/global/BannerExperimental.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<BannerWarning
|
||||
:title="$t('banner-experimental.title')"
|
||||
:description="$t('banner-experimental.description')"
|
||||
>
|
||||
<template
|
||||
v-if="issue"
|
||||
#default
|
||||
>
|
||||
<a
|
||||
:href="issue"
|
||||
target="_blank"
|
||||
>{{ $t("banner-experimental.issue-link-text") }}</a>
|
||||
</template>
|
||||
</BannerWarning>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
issue: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
35
frontend/app/components/global/BannerWarning.vue
Normal file
35
frontend/app/components/global/BannerWarning.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<v-alert
|
||||
border="start"
|
||||
variant="tonal"
|
||||
type="warning"
|
||||
elevation="2"
|
||||
:icon="$globals.icons.alert"
|
||||
>
|
||||
<b v-if="title">{{ title }}</b>
|
||||
<div v-if="description">
|
||||
{{ description }}
|
||||
</div>
|
||||
<div
|
||||
v-if="$slots.default"
|
||||
class="py-2"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</v-alert>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "",
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
199
frontend/app/components/global/BaseButton.vue
Normal file
199
frontend/app/components/global/BaseButton.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<v-btn
|
||||
:color="color || btnAttrs.color"
|
||||
:size="small ? 'small' : 'default'"
|
||||
:x-small="xSmall"
|
||||
:loading="loading"
|
||||
:disabled="disabled"
|
||||
:variant="disabled ? 'tonal' : btnStyle.outlined ? 'outlined' : btnStyle.text ? 'text' : 'elevated'"
|
||||
:to="to"
|
||||
v-bind="$attrs"
|
||||
@click="download ? downloadFile() : undefined"
|
||||
>
|
||||
<v-icon
|
||||
v-if="!iconRight"
|
||||
start
|
||||
>
|
||||
<slot name="icon">
|
||||
{{ icon || btnAttrs.icon }}
|
||||
</slot>
|
||||
</v-icon>
|
||||
<slot name="default">
|
||||
{{ text || btnAttrs.text }}
|
||||
</slot>
|
||||
<v-icon
|
||||
v-if="iconRight"
|
||||
end
|
||||
>
|
||||
<slot name="icon">
|
||||
{{ icon || btnAttrs.icon }}
|
||||
</slot>
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUserApi } from "~/composables/api";
|
||||
|
||||
const props = defineProps({
|
||||
cancel: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
create: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
update: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
edit: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
save: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
delete: {
|
||||
type: Boolean,
|
||||
default: false },
|
||||
download: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
downloadUrl: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
small: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
xSmall: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
secondary: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
minor: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
to: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
iconRight: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
|
||||
const buttonOptions = {
|
||||
create: {
|
||||
text: i18n.t("general.create"),
|
||||
icon: $globals.icons.createAlt,
|
||||
color: "success",
|
||||
},
|
||||
update: {
|
||||
text: i18n.t("general.update"),
|
||||
icon: $globals.icons.edit,
|
||||
color: "success",
|
||||
},
|
||||
save: {
|
||||
text: i18n.t("general.save"),
|
||||
icon: $globals.icons.save,
|
||||
color: "success",
|
||||
},
|
||||
edit: {
|
||||
text: i18n.t("general.edit"),
|
||||
icon: $globals.icons.edit,
|
||||
color: "info",
|
||||
},
|
||||
delete: {
|
||||
text: i18n.t("general.delete"),
|
||||
icon: $globals.icons.delete,
|
||||
color: "error",
|
||||
},
|
||||
cancel: {
|
||||
text: i18n.t("general.cancel"),
|
||||
icon: $globals.icons.close,
|
||||
color: "grey",
|
||||
},
|
||||
download: {
|
||||
text: i18n.t("general.download"),
|
||||
icon: $globals.icons.download,
|
||||
color: "info",
|
||||
},
|
||||
};
|
||||
|
||||
const btnAttrs = computed(() => {
|
||||
if (props.delete) {
|
||||
return buttonOptions.delete;
|
||||
}
|
||||
if (props.update) {
|
||||
return buttonOptions.update;
|
||||
}
|
||||
if (props.edit) {
|
||||
return buttonOptions.edit;
|
||||
}
|
||||
if (props.cancel) {
|
||||
return buttonOptions.cancel;
|
||||
}
|
||||
if (props.save) {
|
||||
return buttonOptions.save;
|
||||
}
|
||||
if (props.download) {
|
||||
return buttonOptions.download;
|
||||
}
|
||||
return buttonOptions.create;
|
||||
});
|
||||
|
||||
const buttonStyles = {
|
||||
defaults: { text: false, outlined: false },
|
||||
secondary: { text: false, outlined: true },
|
||||
minor: { text: true, outlined: false },
|
||||
};
|
||||
|
||||
const btnStyle = computed(() => {
|
||||
if (props.secondary) {
|
||||
return buttonStyles.secondary;
|
||||
}
|
||||
if (props.minor || props.cancel) {
|
||||
return buttonStyles.minor;
|
||||
}
|
||||
return buttonStyles.defaults;
|
||||
});
|
||||
|
||||
const api = useUserApi();
|
||||
function downloadFile() {
|
||||
api.utils.download(props.downloadUrl);
|
||||
}
|
||||
</script>
|
||||
102
frontend/app/components/global/BaseButtonGroup.vue
Normal file
102
frontend/app/components/global/BaseButtonGroup.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<v-item-group>
|
||||
<template v-for="btn in buttons">
|
||||
<v-menu
|
||||
v-if="btn.children"
|
||||
:key="'menu-' + btn.event"
|
||||
active-class="pa-0"
|
||||
offset-y
|
||||
top
|
||||
start
|
||||
:style="stretch ? 'width: 100%;' : ''"
|
||||
>
|
||||
<template #activator="{ props: hoverProps }">
|
||||
<v-btn
|
||||
tile
|
||||
:large="large"
|
||||
icon
|
||||
variant="plain"
|
||||
v-bind="hoverProps"
|
||||
>
|
||||
<v-icon>
|
||||
{{ btn.icon }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<template
|
||||
v-for="(child, idx) in btn.children"
|
||||
:key="idx"
|
||||
>
|
||||
<v-list-item
|
||||
density="compact"
|
||||
@click="$emit(child.event)"
|
||||
>
|
||||
<v-list-item-title>{{ child.text }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-divider
|
||||
v-if="child.divider"
|
||||
:key="`divider-${idx}`"
|
||||
class="my-1"
|
||||
/>
|
||||
</template>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-tooltip
|
||||
v-else
|
||||
:key="'btn-' + btn.event"
|
||||
open-delay="200"
|
||||
transition="slide-y-reverse-transition"
|
||||
density="compact"
|
||||
location="bottom"
|
||||
content-class="text-caption"
|
||||
>
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-btn
|
||||
tile
|
||||
icon
|
||||
:color="btn.color"
|
||||
:large="large"
|
||||
:disabled="btn.disabled"
|
||||
:style="stretch ? `width: ${maxButtonWidth};` : ''"
|
||||
variant="plain"
|
||||
v-bind="tooltipProps"
|
||||
@click="$emit(btn.event)"
|
||||
>
|
||||
<v-icon> {{ btn.icon }} </v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<span>{{ btn.text }}</span>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
</v-item-group>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
export interface ButtonOption {
|
||||
icon?: string;
|
||||
color?: string;
|
||||
text: string;
|
||||
event: string;
|
||||
children?: ButtonOption[];
|
||||
disabled?: boolean;
|
||||
divider?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
buttons: {
|
||||
type: Array as () => ButtonOption[],
|
||||
required: true,
|
||||
},
|
||||
large: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
stretch: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const maxButtonWidth = computed(() => `${100 / props.buttons.length}%`);
|
||||
</script>
|
||||
47
frontend/app/components/global/BaseCardSectionTitle.vue
Normal file
47
frontend/app/components/global/BaseCardSectionTitle.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<v-card
|
||||
color="background"
|
||||
flat
|
||||
class="pb-2"
|
||||
:class="{
|
||||
'mt-8': section,
|
||||
}"
|
||||
>
|
||||
<v-card-title class="text-h5 pl-0 py-0" style="font-weight: normal;">
|
||||
<v-icon
|
||||
v-if="icon"
|
||||
size="small"
|
||||
start
|
||||
>
|
||||
{{ icon }}
|
||||
</v-icon>
|
||||
{{ title }}
|
||||
</v-card-title>
|
||||
<v-card-text
|
||||
v-if="$slots.default"
|
||||
class="pt-2 pl-0"
|
||||
>
|
||||
<p class="pb-0 mb-0">
|
||||
<slot />
|
||||
</p>
|
||||
</v-card-text>
|
||||
<v-divider class="mt-1 mb-3" />
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
section: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
219
frontend/app/components/global/BaseDialog.vue
Normal file
219
frontend/app/components/global/BaseDialog.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<div>
|
||||
<slot
|
||||
name="activator"
|
||||
v-bind="{ open }"
|
||||
/>
|
||||
<v-dialog
|
||||
v-model="dialog"
|
||||
:width="width"
|
||||
:max-width="maxWidth ?? undefined"
|
||||
:content-class="top ? 'top-dialog' : undefined"
|
||||
:fullscreen="$vuetify.display.xs"
|
||||
@keydown.enter="submitOnEnter"
|
||||
@click:outside="emit('cancel')"
|
||||
@keydown.esc="emit('cancel')"
|
||||
>
|
||||
<v-card height="100%" :loading="loading">
|
||||
<template #loader="{ isActive }">
|
||||
<v-progress-linear
|
||||
:active="isActive"
|
||||
indeterminate
|
||||
/>
|
||||
</template>
|
||||
<v-toolbar
|
||||
dark
|
||||
density="comfortable"
|
||||
:color="color"
|
||||
class="px-3 position-relative top-0 left-0 w-100"
|
||||
>
|
||||
<v-icon size="large">
|
||||
{{ icon }}
|
||||
</v-icon>
|
||||
<v-toolbar-title class="headline">
|
||||
{{ title }}
|
||||
</v-toolbar-title>
|
||||
</v-toolbar>
|
||||
|
||||
<div>
|
||||
<slot v-bind="{ submitEvent }" />
|
||||
</div>
|
||||
|
||||
<v-spacer />
|
||||
<v-divider class="mx-2" />
|
||||
|
||||
<v-card-actions>
|
||||
<slot name="card-actions">
|
||||
<v-btn
|
||||
variant="text"
|
||||
color="grey"
|
||||
@click="
|
||||
dialog = false;
|
||||
emit('cancel');
|
||||
"
|
||||
>
|
||||
{{ $t("general.cancel") }}
|
||||
</v-btn>
|
||||
<v-spacer />
|
||||
|
||||
<slot name="custom-card-action" />
|
||||
<BaseButton
|
||||
v-if="canDelete"
|
||||
delete
|
||||
@click="deleteEvent"
|
||||
/>
|
||||
<BaseButton
|
||||
v-if="canConfirm"
|
||||
:color="color"
|
||||
type="submit"
|
||||
:disabled="submitDisabled"
|
||||
@click="
|
||||
emit('confirm');
|
||||
dialog = false;
|
||||
"
|
||||
>
|
||||
<template #icon>
|
||||
{{ $globals.icons.check }}
|
||||
</template>
|
||||
{{ $t("general.confirm") }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-if="canSubmit"
|
||||
type="submit"
|
||||
:disabled="submitDisabled || loading"
|
||||
@click="submitEvent"
|
||||
>
|
||||
{{ submitText }}
|
||||
<template
|
||||
v-if="submitIcon"
|
||||
#icon
|
||||
>
|
||||
{{ submitIcon }}
|
||||
</template>
|
||||
</BaseButton>
|
||||
</slot>
|
||||
</v-card-actions>
|
||||
|
||||
<div
|
||||
v-if="$slots['below-actions']"
|
||||
class="pb-4"
|
||||
>
|
||||
<slot name="below-actions" />
|
||||
</div>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useNuxtApp } from "#app";
|
||||
|
||||
interface DialogProps {
|
||||
modelValue: boolean;
|
||||
color?: string;
|
||||
title?: string;
|
||||
icon?: string | null;
|
||||
width?: number | string;
|
||||
maxWidth?: number | string | null;
|
||||
loading?: boolean;
|
||||
top?: boolean | null;
|
||||
submitIcon?: string | null;
|
||||
submitText?: string;
|
||||
submitDisabled?: boolean;
|
||||
keepOpen?: boolean;
|
||||
// actions
|
||||
canDelete?: boolean;
|
||||
canConfirm?: boolean;
|
||||
canSubmit?: boolean;
|
||||
disableSubmitOnEnter?: boolean;
|
||||
}
|
||||
|
||||
interface DialogEmits {
|
||||
(e: "update:modelValue", value: boolean): void;
|
||||
(e: "submit" | "cancel" | "confirm" | "delete" | "close"): void;
|
||||
}
|
||||
|
||||
// Using TypeScript interface with withDefaults for props
|
||||
const props = withDefaults(defineProps<DialogProps>(), {
|
||||
color: "primary",
|
||||
title: "Modal Title",
|
||||
icon: null,
|
||||
width: "500",
|
||||
maxWidth: null,
|
||||
loading: false,
|
||||
top: null,
|
||||
submitIcon: null,
|
||||
submitText: () => useNuxtApp().$i18n.t("general.create"),
|
||||
submitDisabled: false,
|
||||
keepOpen: false,
|
||||
canDelete: false,
|
||||
canConfirm: false,
|
||||
canSubmit: false,
|
||||
disableSubmitOnEnter: false,
|
||||
});
|
||||
const emit = defineEmits<DialogEmits>();
|
||||
|
||||
const dialog = computed({
|
||||
get: () => props.modelValue,
|
||||
set: val => emit("update:modelValue", val),
|
||||
});
|
||||
|
||||
const submitted = ref(false);
|
||||
|
||||
const determineClose = computed(() => {
|
||||
return submitted.value && !props.loading && !props.keepOpen;
|
||||
});
|
||||
|
||||
watch(determineClose, (shouldClose) => {
|
||||
if (shouldClose) {
|
||||
submitted.value = false;
|
||||
dialog.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
watch(dialog, (val) => {
|
||||
if (val) submitted.value = false;
|
||||
if (!val) emit("close");
|
||||
});
|
||||
|
||||
function submitEvent() {
|
||||
emit("submit");
|
||||
submitted.value = true;
|
||||
}
|
||||
|
||||
function submitOnEnter() {
|
||||
if (props.disableSubmitOnEnter) {
|
||||
return;
|
||||
}
|
||||
|
||||
submitEvent();
|
||||
}
|
||||
|
||||
function deleteEvent() {
|
||||
emit("delete");
|
||||
submitted.value = true;
|
||||
}
|
||||
|
||||
function open() {
|
||||
dialog.value = true;
|
||||
logDeprecatedProp("open");
|
||||
}
|
||||
|
||||
/* function close() {
|
||||
dialog.value = false;
|
||||
logDeprecatedProp("close");
|
||||
} */
|
||||
|
||||
function logDeprecatedProp(val: string) {
|
||||
console.warn(
|
||||
`[BaseDialog] The method '${val}' is deprecated. Please use v-model="value" to manage state instead.`,
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.top-dialog {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
}
|
||||
</style>
|
||||
24
frontend/app/components/global/BaseDivider.vue
Normal file
24
frontend/app/components/global/BaseDivider.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<v-divider
|
||||
:width="width"
|
||||
:class="color"
|
||||
:style="`border-width: ${thickness} !important`"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
width: {
|
||||
type: String,
|
||||
default: "100px",
|
||||
},
|
||||
thickness: {
|
||||
type: String,
|
||||
default: "2px",
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: "accent",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
17
frontend/app/components/global/BaseExpansionPanels.vue
Normal file
17
frontend/app/components/global/BaseExpansionPanels.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<v-expansion-panels v-model="open">
|
||||
<slot />
|
||||
</v-expansion-panels>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
startOpen?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
startOpen: false,
|
||||
});
|
||||
|
||||
const open = ref(props.startOpen ? [0] : []);
|
||||
</script>
|
||||
180
frontend/app/components/global/BaseOverflowButton.vue
Normal file
180
frontend/app/components/global/BaseOverflowButton.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<v-menu offset-y>
|
||||
<template #activator="{ props: hoverProps }">
|
||||
<v-btn
|
||||
color="primary"
|
||||
v-bind="{ ...hoverProps, ...$attrs }"
|
||||
:class="btnClass"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<v-icon
|
||||
v-if="activeObj.icon"
|
||||
start
|
||||
>
|
||||
{{ activeObj.icon }}
|
||||
</v-icon>
|
||||
{{ mode === MODES.model ? activeObj.text : btnText }}
|
||||
<v-icon end>
|
||||
{{ $globals.icons.chevronDown }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<!-- Model -->
|
||||
<v-list
|
||||
v-if="mode === MODES.model"
|
||||
v-model:selected="itemGroup"
|
||||
density="compact"
|
||||
>
|
||||
<template v-for="(item, index) in items">
|
||||
<div
|
||||
v-if="!item.hide"
|
||||
:key="index"
|
||||
>
|
||||
<v-list-item @click="setValue(item)">
|
||||
<template
|
||||
v-if="item.icon"
|
||||
#prepend
|
||||
>
|
||||
<v-icon>{{ item.icon }}</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ item.text }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-divider
|
||||
v-if="item.divider"
|
||||
:key="`divider-${index}`"
|
||||
class="my-1"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</v-list>
|
||||
<!-- Links -->
|
||||
<v-list
|
||||
v-else-if="mode === MODES.link"
|
||||
v-model:selected="itemGroup"
|
||||
density="compact"
|
||||
>
|
||||
<template v-for="(item, index) in items">
|
||||
<div
|
||||
v-if="!item.hide"
|
||||
:key="index"
|
||||
>
|
||||
<v-list-item :to="item.to">
|
||||
<template
|
||||
v-if="item.icon"
|
||||
#prepend
|
||||
>
|
||||
<v-icon>{{ item.icon }}</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ item.text }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-divider
|
||||
v-if="item.divider"
|
||||
:key="`divider-${index}`"
|
||||
class="my-1"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</v-list>
|
||||
<!-- Event -->
|
||||
<v-list
|
||||
v-else-if="mode === MODES.event"
|
||||
density="compact"
|
||||
>
|
||||
<template v-for="(item, index) in items">
|
||||
<div
|
||||
v-if="!item.hide"
|
||||
:key="index"
|
||||
>
|
||||
<v-list-item @click="$emit(item.event)">
|
||||
<template
|
||||
v-if="item.icon"
|
||||
#prepend
|
||||
>
|
||||
<v-icon>{{ item.icon }}</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ item.text }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-divider
|
||||
v-if="item.divider"
|
||||
:key="`divider-${index}`"
|
||||
class="my-1"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const MODES = {
|
||||
model: "model",
|
||||
link: "link",
|
||||
event: "event",
|
||||
};
|
||||
|
||||
type modes = "model" | "link" | "event";
|
||||
|
||||
export interface MenuItem {
|
||||
text: string;
|
||||
icon?: string;
|
||||
to?: string;
|
||||
value?: string;
|
||||
event?: string;
|
||||
divider?: boolean;
|
||||
hide?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
type: String as () => modes,
|
||||
default: "model",
|
||||
},
|
||||
items: {
|
||||
type: Array as () => MenuItem[],
|
||||
required: true,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
btnClass: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "",
|
||||
},
|
||||
btnText: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: function () {
|
||||
return useI18n().t("general.actions");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const modelValue = defineModel({
|
||||
type: String,
|
||||
required: false,
|
||||
default: "",
|
||||
});
|
||||
|
||||
const activeObj = ref<MenuItem>({
|
||||
text: "DEFAULT",
|
||||
value: "",
|
||||
});
|
||||
|
||||
let startIndex = 0;
|
||||
props.items.forEach((item, index) => {
|
||||
if (item.value === modelValue.value) {
|
||||
startIndex = index;
|
||||
|
||||
activeObj.value = item;
|
||||
}
|
||||
});
|
||||
const itemGroup = ref(startIndex);
|
||||
|
||||
function setValue(v: MenuItem) {
|
||||
modelValue.value = v.value || "";
|
||||
activeObj.value = v;
|
||||
}
|
||||
</script>
|
||||
40
frontend/app/components/global/BasePageTitle.vue
Normal file
40
frontend/app/components/global/BasePageTitle.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="mt-4">
|
||||
<section class="d-flex flex-column align-center">
|
||||
<slot name="header" />
|
||||
<h2 class="text-h5">
|
||||
<slot name="title">
|
||||
👋 Here's a Title
|
||||
</slot>
|
||||
</h2>
|
||||
|
||||
<h3 class="subtitle-1">
|
||||
<slot />
|
||||
</h3>
|
||||
</section>
|
||||
<section class="d-flex">
|
||||
<slot name="content" />
|
||||
</section>
|
||||
<v-divider
|
||||
v-if="divider"
|
||||
class="my-4"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
divider: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.subtitle-1 {
|
||||
font-size: 1rem;
|
||||
font-weight: normal;
|
||||
color: var(--v-text-caption);
|
||||
}
|
||||
</style>
|
||||
34
frontend/app/components/global/ButtonLink.vue
Normal file
34
frontend/app/components/global/ButtonLink.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
class="rounded-xl my-1 mx-1"
|
||||
:to="to"
|
||||
>
|
||||
<v-icon
|
||||
v-if="icon != ''"
|
||||
start
|
||||
>
|
||||
{{ icon }}
|
||||
</v-icon>
|
||||
{{ text }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
to: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
64
frontend/app/components/global/ContextMenu.vue
Normal file
64
frontend/app/components/global/ContextMenu.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<v-menu
|
||||
offset-y
|
||||
start
|
||||
:bottom="!menuTop"
|
||||
:nudge-bottom="!menuTop ? '5' : '0'"
|
||||
:top="menuTop"
|
||||
:nudge-top="menuTop ? '5' : '0'"
|
||||
allow-overflow
|
||||
close-delay="125"
|
||||
content-class="d-print-none"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
:class="{ 'rounded-circle': fab }"
|
||||
:small="fab"
|
||||
:color="color"
|
||||
:icon="!fab"
|
||||
dark
|
||||
v-bind="props"
|
||||
@click.prevent
|
||||
>
|
||||
<v-icon>{{ $globals.icons.dotsVertical }}</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-for="(item, index) in items"
|
||||
:key="index"
|
||||
@click="$emit(item.event)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon :color="item.color ? item.color : undefined">
|
||||
{{ item.icon }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ item.title }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ContextMenuItem } from "~/composables/use-context-presents";
|
||||
|
||||
defineProps({
|
||||
items: {
|
||||
type: Array as () => ContextMenuItem[],
|
||||
required: true,
|
||||
},
|
||||
menuTop: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
fab: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: "grey-darken-2",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
232
frontend/app/components/global/CrudTable.vue
Normal file
232
frontend/app/components/global/CrudTable.vue
Normal file
@@ -0,0 +1,232 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-card-actions class="flex-wrap">
|
||||
<v-menu
|
||||
v-if="tableConfig.hideColumns"
|
||||
offset-y
|
||||
bottom
|
||||
nudge-bottom="6"
|
||||
:close-on-content-click="false"
|
||||
>
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-btn
|
||||
color="accent"
|
||||
variant="elevated"
|
||||
v-bind="activatorProps"
|
||||
>
|
||||
<v-icon>
|
||||
{{ $globals.icons.cog }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-card>
|
||||
<v-card-text>
|
||||
<v-checkbox
|
||||
v-for="itemValue in localHeaders"
|
||||
:key="itemValue.text + itemValue.show"
|
||||
v-model="itemValue.show"
|
||||
density="compact"
|
||||
flat
|
||||
inset
|
||||
:label="itemValue.text"
|
||||
hide-details
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
<BaseOverflowButton
|
||||
v-if="bulkActions.length > 0"
|
||||
:disabled="selected.length < 1"
|
||||
mode="event"
|
||||
color="info"
|
||||
variant="elevated"
|
||||
:items="bulkActions"
|
||||
v-on="bulkActionListener"
|
||||
/>
|
||||
<slot name="button-row" />
|
||||
</v-card-actions>
|
||||
<div class="mx-2 clip-width">
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
variant="underlined"
|
||||
:label="$t('search.search')"
|
||||
/>
|
||||
</div>
|
||||
<v-data-table
|
||||
v-model="selected"
|
||||
return-object
|
||||
:headers="activeHeaders"
|
||||
:show-select="bulkActions.length > 0"
|
||||
:sort-by="sortBy"
|
||||
:items="data || []"
|
||||
:items-per-page="15"
|
||||
:search="search"
|
||||
class="elevation-2"
|
||||
>
|
||||
<template
|
||||
v-for="header in headersWithoutActions"
|
||||
#[`item.${header.value}`]="{ item }"
|
||||
>
|
||||
<slot
|
||||
:name="'item.' + header.value"
|
||||
v-bind="{ item }"
|
||||
>
|
||||
{{ item[header.value] }}
|
||||
</slot>
|
||||
</template>
|
||||
<template #[`item.actions`]="{ item }">
|
||||
<BaseButtonGroup
|
||||
:buttons="[
|
||||
{
|
||||
icon: $globals.icons.edit,
|
||||
text: $t('general.edit'),
|
||||
event: 'edit',
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.delete,
|
||||
text: $t('general.delete'),
|
||||
event: 'delete',
|
||||
},
|
||||
]"
|
||||
@delete="$emit('delete-one', item)"
|
||||
@edit="$emit('edit-one', item)"
|
||||
/>
|
||||
</template>
|
||||
</v-data-table>
|
||||
<v-card-actions class="justify-end">
|
||||
<slot name="button-bottom" />
|
||||
<BaseButton
|
||||
color="info"
|
||||
@click="downloadAsJson(data, 'export.json')"
|
||||
>
|
||||
<template #icon>
|
||||
{{ $globals.icons.download }}
|
||||
</template>
|
||||
{{ $t("general.download") }}
|
||||
</BaseButton>
|
||||
</v-card-actions>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { downloadAsJson } from "~/composables/use-utils";
|
||||
|
||||
export interface TableConfig {
|
||||
hideColumns: boolean;
|
||||
canExport: boolean;
|
||||
}
|
||||
|
||||
export interface TableHeaders {
|
||||
text: string;
|
||||
value: string;
|
||||
show: boolean;
|
||||
align?: "start" | "center" | "end";
|
||||
sortable?: boolean;
|
||||
sort?: (a: any, b: any) => number;
|
||||
}
|
||||
|
||||
export interface BulkAction {
|
||||
icon: string;
|
||||
text: string;
|
||||
event: string;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
tableConfig: {
|
||||
type: Object as () => TableConfig,
|
||||
default: () => ({
|
||||
hideColumns: false,
|
||||
canExport: false,
|
||||
}),
|
||||
},
|
||||
headers: {
|
||||
type: Array as () => TableHeaders[],
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Array as () => any[],
|
||||
required: true,
|
||||
},
|
||||
bulkActions: {
|
||||
type: Array as () => BulkAction[],
|
||||
default: () => [],
|
||||
},
|
||||
initialSort: {
|
||||
type: String,
|
||||
default: "id",
|
||||
},
|
||||
initialSortDesc: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "delete-one" | "edit-one", item: any): void;
|
||||
(e: "bulk-action", event: string, items: any[]): void;
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const sortBy = computed<{ key: string; order: "asc" | "desc" }[]>(() => [{
|
||||
key: props.initialSort,
|
||||
order: props.initialSortDesc ? "desc" : "asc",
|
||||
}]);
|
||||
|
||||
// ===========================================================
|
||||
// Reactive Headers
|
||||
// Create a local reactive copy of headers that we can modify
|
||||
const localHeaders = ref([...props.headers]);
|
||||
|
||||
// Watch for changes in props.headers and update local copy
|
||||
watch(() => props.headers, (newHeaders) => {
|
||||
localHeaders.value = [...newHeaders];
|
||||
}, { deep: true });
|
||||
|
||||
const filteredHeaders = computed<string[]>(() => {
|
||||
return localHeaders.value.filter(header => header.show).map(header => header.value);
|
||||
});
|
||||
|
||||
const headersWithoutActions = computed(() =>
|
||||
localHeaders.value
|
||||
.filter(header => filteredHeaders.value.includes(header.value))
|
||||
.map(header => ({
|
||||
...header,
|
||||
title: i18n.t(header.text),
|
||||
})),
|
||||
);
|
||||
|
||||
const activeHeaders = computed(() => [
|
||||
...headersWithoutActions.value,
|
||||
{ title: "", value: "actions", show: true, align: "end" },
|
||||
]);
|
||||
|
||||
const selected = ref<any[]>([]);
|
||||
|
||||
// ===========================================================
|
||||
// Bulk Action Event Handler
|
||||
|
||||
const bulkActionListener = computed(() => {
|
||||
const handlers: { [key: string]: () => void } = {};
|
||||
|
||||
props.bulkActions.forEach((action) => {
|
||||
handlers[action.event] = () => {
|
||||
emit("bulk-action", action.event, selected.value);
|
||||
// clear selection
|
||||
selected.value = [];
|
||||
};
|
||||
});
|
||||
|
||||
return handlers;
|
||||
});
|
||||
|
||||
const search = ref("");
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.clip-width {
|
||||
max-width: 400px;
|
||||
}
|
||||
.v-btn--disabled {
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
</style>
|
||||
16
frontend/app/components/global/DevDumpJson.vue
Normal file
16
frontend/app/components/global/DevDumpJson.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<pre>
|
||||
{{ prettyJson }}
|
||||
</pre>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const prettyJson = JSON.stringify(props.data, null, 2);
|
||||
</script>
|
||||
30
frontend/app/components/global/DocLink.vue
Normal file
30
frontend/app/components/global/DocLink.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<v-btn
|
||||
size="x-small"
|
||||
:href="href"
|
||||
color="primary"
|
||||
target="_blank"
|
||||
>
|
||||
<v-icon
|
||||
start
|
||||
size="small"
|
||||
>
|
||||
{{ $globals.icons.folderOutline }}
|
||||
</v-icon>
|
||||
{{ $t("about.docs") }}
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
link: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const href = computed(() => {
|
||||
// TODO: dynamically set docs link based off env
|
||||
return `https://docs.mealie.io${props.link}`;
|
||||
});
|
||||
</script>
|
||||
76
frontend/app/components/global/DropZone.vue
Normal file
76
frontend/app/components/global/DropZone.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div
|
||||
ref="el"
|
||||
:class="isOverDropZone ? 'over' : ''"
|
||||
>
|
||||
<div
|
||||
v-if="isOverDropZone"
|
||||
class="overlay"
|
||||
/>
|
||||
<div
|
||||
v-if="isOverDropZone"
|
||||
class="absolute text-container"
|
||||
>
|
||||
<p class="text-center drop-text">
|
||||
{{ $t("recipe.drop-image") }}
|
||||
</p>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDropZone } from "@vueuse/core";
|
||||
|
||||
defineProps({
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["drop"]);
|
||||
|
||||
const el = ref<HTMLDivElement>();
|
||||
|
||||
function onDrop(files: File[] | null) {
|
||||
if (files) {
|
||||
emit("drop", files);
|
||||
}
|
||||
}
|
||||
|
||||
const { isOverDropZone } = useDropZone(el, files => onDrop(files));
|
||||
</script>
|
||||
|
||||
<style lang="css">
|
||||
.over {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
.overlay {
|
||||
position: absolute;
|
||||
filter: blur(2px);
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.309);
|
||||
}
|
||||
|
||||
.text-container {
|
||||
z-index: 10;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.drop-text {
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
45
frontend/app/components/global/HelpIcon.vue
Normal file
45
frontend/app/components/global/HelpIcon.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="text-center">
|
||||
<v-menu
|
||||
top
|
||||
offset-y
|
||||
:right="right"
|
||||
:left="!right"
|
||||
open-on-hover
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
:size="small ? 'small' : undefined"
|
||||
icon
|
||||
v-bind="props"
|
||||
variant="flat"
|
||||
@click.stop
|
||||
>
|
||||
<v-icon :small="small">
|
||||
{{ $globals.icons.help }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-card max-width="300px">
|
||||
<v-card-text>
|
||||
<slot />
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
small: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
right: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
152
frontend/app/components/global/ImageCropper.vue
Normal file
152
frontend/app/components/global/ImageCropper.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<v-card class="ma-0 pt-2" :elevation="4">
|
||||
<v-card-text>
|
||||
<!-- Controls Row (Menu) -->
|
||||
<v-row class="mb-2 mx-1">
|
||||
<v-btn
|
||||
color="error"
|
||||
:icon="$globals.icons.delete"
|
||||
:disabled="submitted"
|
||||
@click="$emit('delete')"
|
||||
/>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
v-if="changed"
|
||||
class="mr-2"
|
||||
color="success"
|
||||
:icon="$globals.icons.save"
|
||||
:disabled="submitted"
|
||||
@click="save"
|
||||
/>
|
||||
<v-menu offset-y :close-on-content-click="false" location="bottom center">
|
||||
<template #activator="{ props: slotProps }">
|
||||
<v-btn color="info" v-bind="slotProps" :icon="$globals.icons.edit" :disabled="submitted" />
|
||||
</template>
|
||||
<v-list class="mt-1">
|
||||
<template v-for="(row, keyRow) in controls" :key="keyRow">
|
||||
<v-list-item-group>
|
||||
<v-list-item
|
||||
v-for="(control, keyControl) in row"
|
||||
:key="keyControl"
|
||||
:disabled="submitted"
|
||||
@click="control.callback()"
|
||||
>
|
||||
<v-list-item-icon>
|
||||
<v-icon :color="control.color" :icon="control.icon" />
|
||||
</v-list-item-icon>
|
||||
</v-list-item>
|
||||
</v-list-item-group>
|
||||
</template>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-row>
|
||||
|
||||
<!-- Image Row -->
|
||||
<Cropper
|
||||
ref="cropper"
|
||||
class="cropper"
|
||||
:src="img"
|
||||
:default-size="defaultSize"
|
||||
:style="`height: ${cropperHeight}; width: ${cropperWidth};`"
|
||||
@change="changed = changed + 1"
|
||||
@ready="changed = -1"
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Cropper } from "vue-advanced-cropper";
|
||||
import "vue-advanced-cropper/dist/style.css";
|
||||
|
||||
defineProps({
|
||||
img: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
cropperHeight: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
cropperWidth: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
submitted: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "save", item: Blob): void;
|
||||
(e: "delete"): void;
|
||||
}>();
|
||||
|
||||
const cropper = ref<any>(null);
|
||||
const changed = ref(0);
|
||||
const { $globals } = useNuxtApp();
|
||||
|
||||
type Control = {
|
||||
color: string;
|
||||
icon: string;
|
||||
callback: CallableFunction;
|
||||
};
|
||||
|
||||
function flip(hortizontal: boolean, vertical?: boolean) {
|
||||
if (!cropper.value) return;
|
||||
cropper.value.flip(hortizontal, vertical);
|
||||
changed.value = changed.value + 1;
|
||||
}
|
||||
|
||||
function rotate(angle: number) {
|
||||
if (!cropper.value) return;
|
||||
cropper.value.rotate(angle);
|
||||
changed.value = changed.value + 1;
|
||||
}
|
||||
|
||||
const controls = ref<Control[][]>([
|
||||
[
|
||||
{
|
||||
color: "info",
|
||||
icon: $globals.icons.flipHorizontal,
|
||||
callback: () => flip(true, false),
|
||||
},
|
||||
{
|
||||
color: "info",
|
||||
icon: $globals.icons.flipVertical,
|
||||
callback: () => flip(false, true),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
color: "info",
|
||||
icon: $globals.icons.rotateLeft,
|
||||
callback: () => rotate(-90),
|
||||
},
|
||||
{
|
||||
color: "info",
|
||||
icon: $globals.icons.rotateRight,
|
||||
callback: () => rotate(90),
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
function save() {
|
||||
if (!cropper.value) return;
|
||||
const { canvas } = cropper.value.getResult();
|
||||
if (!canvas) return;
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
emit("save", blob);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function defaultSize({ imageSize, visibleArea }: any) {
|
||||
return {
|
||||
width: (visibleArea || imageSize).width,
|
||||
height: (visibleArea || imageSize).height,
|
||||
};
|
||||
}
|
||||
</script>
|
||||
66
frontend/app/components/global/InputColor.vue
Normal file
66
frontend/app/components/global/InputColor.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<v-text-field
|
||||
v-model="modelValue"
|
||||
:label="$t('general.color')"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-btn
|
||||
class="elevation-0"
|
||||
size="small"
|
||||
height="30px"
|
||||
width="30px"
|
||||
:color="modelValue || 'grey'"
|
||||
@click="setRandomHex"
|
||||
>
|
||||
<v-icon color="white">
|
||||
{{ $globals.icons.refreshCircle }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<template #append>
|
||||
<v-menu
|
||||
v-model="menu"
|
||||
start
|
||||
nudge-left="30"
|
||||
nudge-top="20"
|
||||
:close-on-content-click="false"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-icon v-bind="props">
|
||||
{{ $globals.icons.formatColorFill }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-card>
|
||||
<v-card-text class="pa-0">
|
||||
<v-color-picker
|
||||
v-model="modelValue"
|
||||
flat
|
||||
hide-inputs
|
||||
show-swatches
|
||||
swatches-max-height="200"
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const modelValue = defineModel({
|
||||
type: String,
|
||||
required: true,
|
||||
});
|
||||
|
||||
const menu = ref(false);
|
||||
|
||||
function getRandomHex() {
|
||||
return "#000000".replace(/0/g, function () {
|
||||
return (~~(Math.random() * 16)).toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
function setRandomHex() {
|
||||
modelValue.value = getRandomHex();
|
||||
}
|
||||
</script>
|
||||
89
frontend/app/components/global/InputLabelType.vue
Normal file
89
frontend/app/components/global/InputLabelType.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<v-autocomplete
|
||||
ref="autocompleteRef"
|
||||
v-model="itemVal"
|
||||
v-bind="$attrs"
|
||||
v-model:search="searchInput"
|
||||
item-title="name"
|
||||
return-object
|
||||
:items="filteredItems"
|
||||
:prepend-icon="icon || $globals.icons.tags"
|
||||
auto-select-first
|
||||
clearable
|
||||
color="primary"
|
||||
hide-details
|
||||
:custom-filter="() => true"
|
||||
@keyup.enter="emitCreate"
|
||||
>
|
||||
<template
|
||||
v-if="create"
|
||||
#append-item
|
||||
>
|
||||
<div class="px-2">
|
||||
<BaseButton
|
||||
block
|
||||
size="small"
|
||||
@click="emitCreate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MultiPurposeLabelSummary } from "~/lib/api/types/labels";
|
||||
import type { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
|
||||
import { useSearch } from "~/composables/use-search";
|
||||
|
||||
// v-model for the selected item
|
||||
const modelValue = defineModel<MultiPurposeLabelSummary | IngredientFood | IngredientUnit | null>({ default: () => null });
|
||||
|
||||
// support v-model:item-id binding
|
||||
const itemId = defineModel<string | undefined>("item-id", { default: undefined });
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array as () => Array<MultiPurposeLabelSummary | IngredientFood | IngredientUnit>,
|
||||
required: true,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: undefined,
|
||||
},
|
||||
create: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "create", val: string): void;
|
||||
}>();
|
||||
|
||||
const autocompleteRef = ref<HTMLInputElement>();
|
||||
|
||||
// Use the search composable
|
||||
const { search: searchInput, filtered: filteredItems } = useSearch(computed(() => props.items));
|
||||
|
||||
const itemVal = computed({
|
||||
get: () => {
|
||||
if (!modelValue.value || Object.keys(modelValue.value).length === 0) {
|
||||
return null;
|
||||
}
|
||||
return modelValue.value;
|
||||
},
|
||||
set: (val) => {
|
||||
itemId.value = val?.id || "";
|
||||
modelValue.value = val;
|
||||
},
|
||||
});
|
||||
|
||||
function emitCreate() {
|
||||
if (props.items.some(item => item.name === searchInput.value)) {
|
||||
return;
|
||||
}
|
||||
emit("create", searchInput.value);
|
||||
autocompleteRef.value?.blur();
|
||||
}
|
||||
</script>
|
||||
68
frontend/app/components/global/LanguageDialog.vue
Normal file
68
frontend/app/components/global/LanguageDialog.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
v-model="modelValue"
|
||||
:icon="$globals.icons.translate"
|
||||
:title="$t('language-dialog.choose-language')"
|
||||
>
|
||||
<v-card-text>
|
||||
{{ $t("language-dialog.select-description") }}
|
||||
<v-autocomplete
|
||||
v-model="selectedLocale"
|
||||
:items="locales"
|
||||
:custom-filter="normalizeFilter"
|
||||
item-title="name"
|
||||
item-value="value"
|
||||
class="my-3"
|
||||
hide-details
|
||||
variant="outlined"
|
||||
@update:model-value="onLocaleSelect"
|
||||
>
|
||||
<template #item="{ item, props }">
|
||||
<div
|
||||
v-bind="props"
|
||||
class="px-2 py-2"
|
||||
>
|
||||
<v-list-item-title> {{ item.raw.name }} </v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
{{ item.raw.progress }}% {{ $t("language-dialog.translated") }}
|
||||
</v-list-item-subtitle>
|
||||
</div>
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
<i18n-t keypath="language-dialog.how-to-contribute-description">
|
||||
<template #read-the-docs-link>
|
||||
<a
|
||||
href="https://docs.mealie.io/contributors/translating/"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t("language-dialog.read-the-docs") }}
|
||||
</a>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useLocales } from "~/composables/use-locales";
|
||||
import { normalizeFilter } from "~/composables/use-utils";
|
||||
|
||||
const modelValue = defineModel<boolean>({ default: () => false });
|
||||
|
||||
const { locales: LOCALES, locale, i18n } = useLocales();
|
||||
|
||||
const selectedLocale = ref(locale.value);
|
||||
const onLocaleSelect = (value: string) => {
|
||||
if (value && locales.some(l => l.value === value)) {
|
||||
locale.value = value as any;
|
||||
}
|
||||
};
|
||||
|
||||
watch(locale, () => {
|
||||
modelValue.value = false; // Close dialog when locale changes
|
||||
});
|
||||
|
||||
const locales = LOCALES.filter(lc =>
|
||||
i18n.locales.value.map(i18nLocale => i18nLocale.code).includes(lc.value as any),
|
||||
);
|
||||
</script>
|
||||
74
frontend/app/components/global/MarkdownEditor.vue
Normal file
74
frontend/app/components/global/MarkdownEditor.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="displayPreview"
|
||||
class="d-flex justify-end"
|
||||
>
|
||||
<BaseButtonGroup
|
||||
:buttons="[
|
||||
{
|
||||
icon: previewState ? $globals.icons.edit : $globals.icons.eye,
|
||||
text: previewState ? $t('general.edit') : $t('markdown-editor.preview-markdown-button-label'),
|
||||
event: 'toggle',
|
||||
},
|
||||
]"
|
||||
@toggle="previewState = !previewState"
|
||||
/>
|
||||
</div>
|
||||
<v-textarea
|
||||
v-if="!previewState"
|
||||
v-bind="textarea"
|
||||
v-model="modelValue"
|
||||
:class="label == '' ? '' : 'mt-5'"
|
||||
:label="label"
|
||||
auto-grow
|
||||
density="compact"
|
||||
rows="4"
|
||||
variant="underlined"
|
||||
/>
|
||||
<SafeMarkdown
|
||||
v-else
|
||||
:source="modelValue"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
preview: {
|
||||
type: Boolean,
|
||||
default: undefined,
|
||||
},
|
||||
displayPreview: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
textarea: {
|
||||
type: Object as () => unknown,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "input:preview", value: boolean): void;
|
||||
}>();
|
||||
|
||||
const modelValue = defineModel<string>("modelValue");
|
||||
|
||||
const fallbackPreview = ref(false);
|
||||
const previewState = computed({
|
||||
get: () => props.preview ?? fallbackPreview.value,
|
||||
set: (val: boolean) => {
|
||||
if (props.preview) {
|
||||
emit("input:preview", val);
|
||||
}
|
||||
else {
|
||||
fallbackPreview.value = val;
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
47
frontend/app/components/global/RecipeJsonEditor.vue
Normal file
47
frontend/app/components/global/RecipeJsonEditor.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<JsonEditorVue
|
||||
:model-value="modelValue"
|
||||
v-bind="$attrs"
|
||||
:style="{ height }"
|
||||
:stringified="false"
|
||||
@change="onChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import JsonEditorVue from "json-editor-vue";
|
||||
|
||||
const modelValue = defineModel<object>("modelValue", { default: () => ({}) });
|
||||
defineProps({
|
||||
height: {
|
||||
type: String,
|
||||
default: "1500px",
|
||||
},
|
||||
});
|
||||
|
||||
function parseEvent(event: any): object {
|
||||
if (!event) {
|
||||
return modelValue.value || {};
|
||||
}
|
||||
try {
|
||||
if (event.json) {
|
||||
return event.json;
|
||||
}
|
||||
else if (event.text) {
|
||||
return JSON.parse(event.text);
|
||||
}
|
||||
else {
|
||||
return event;
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return modelValue.value || {};
|
||||
}
|
||||
}
|
||||
function onChange(event: any) {
|
||||
const parsed = parseEvent(event);
|
||||
if (parsed !== modelValue.value) {
|
||||
modelValue.value = parsed;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
72
frontend/app/components/global/ReportTable.vue
Normal file
72
frontend/app/components/global/ReportTable.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="items"
|
||||
item-key="id"
|
||||
class="elevation-0"
|
||||
:items-per-page="50"
|
||||
@click:row="($event, { item }) => handleRowClick(item)"
|
||||
>
|
||||
<template #[`item.category`]="{ item }">
|
||||
{{ capitalize(item.category) }}
|
||||
</template>
|
||||
<template #[`item.timestamp`]="{ item }">
|
||||
{{ $d(Date.parse(item.timestamp!), "long") }}
|
||||
</template>
|
||||
<template #[`item.status`]="{ item }">
|
||||
{{ capitalize(item.status!) }}
|
||||
</template>
|
||||
<template #[`item.actions`]="{ item }">
|
||||
<v-btn
|
||||
icon
|
||||
@click.stop="deleteReport(item.id)"
|
||||
>
|
||||
<v-icon>{{ $globals.icons.delete }}</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ReportSummary } from "~/lib/api/types/reports";
|
||||
|
||||
defineProps({
|
||||
items: {
|
||||
type: Array as () => Array<ReportSummary>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "delete", id: string): void;
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
const headers = [
|
||||
{ title: i18n.t("category.category"), value: "category", key: "category" },
|
||||
{ title: i18n.t("general.name"), value: "name", key: "name" },
|
||||
{ title: i18n.t("general.timestamp"), value: "timestamp", key: "timestamp" },
|
||||
{ title: i18n.t("general.status"), value: "status", key: "status" },
|
||||
{ title: i18n.t("general.delete"), value: "actions", key: "actions" },
|
||||
];
|
||||
|
||||
function handleRowClick(item: ReportSummary) {
|
||||
if (item.status === "in-progress") {
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(`/group/reports/${item.id}`);
|
||||
}
|
||||
|
||||
function capitalize(str: string) {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
function deleteReport(id: string) {
|
||||
emit("delete", id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
88
frontend/app/components/global/SafeMarkdown.vue
Normal file
88
frontend/app/components/global/SafeMarkdown.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<!-- eslint-disable-next-line vue/no-v-html is safe here because all HTML is sanitized with DOMPurify in setup() -->
|
||||
<div v-html="value" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
import { marked } from "marked";
|
||||
|
||||
enum DOMPurifyHook {
|
||||
UponSanitizeAttribute = "uponSanitizeAttribute",
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
source: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const ALLOWED_STYLE_TAGS = [
|
||||
"background-color", "color", "font-style", "font-weight", "text-decoration", "text-align",
|
||||
];
|
||||
|
||||
function sanitizeMarkdown(rawHtml: string | null | undefined): string {
|
||||
if (!rawHtml) {
|
||||
return "";
|
||||
}
|
||||
|
||||
DOMPurify.addHook(DOMPurifyHook.UponSanitizeAttribute, (node, data) => {
|
||||
if (data.attrName === "style") {
|
||||
const styles = data.attrValue.split(";").filter((style) => {
|
||||
const [property] = style.split(":");
|
||||
return ALLOWED_STYLE_TAGS.includes(property.trim().toLowerCase());
|
||||
});
|
||||
data.attrValue = styles.join(";");
|
||||
}
|
||||
});
|
||||
|
||||
const sanitized = DOMPurify.sanitize(rawHtml, {
|
||||
ALLOWED_TAGS: [
|
||||
"strong", "em", "b", "i", "u", "p", "code", "pre", "samp", "kbd", "var", "sub", "sup", "dfn", "cite",
|
||||
"small", "address", "hr", "br", "id", "div", "span", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"ul", "ol", "li", "dl", "dt", "dd", "abbr", "a", "img", "blockquote", "iframe",
|
||||
"del", "ins", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "colgroup",
|
||||
],
|
||||
ALLOWED_ATTR: [
|
||||
"href", "src", "alt", "height", "width", "class", "allow", "title", "allowfullscreen", "frameborder",
|
||||
"scrolling", "cite", "datetime", "name", "abbr", "target", "border", "start", "style",
|
||||
],
|
||||
});
|
||||
|
||||
Object.values(DOMPurifyHook).forEach((hook) => {
|
||||
DOMPurify.removeHook(hook);
|
||||
});
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
const value = computed(() => {
|
||||
const rawHtml = marked.parse(props.source || "", { async: false, breaks: true });
|
||||
return sanitizeMarkdown(rawHtml);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(table) {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(th),
|
||||
:deep(td) {
|
||||
border: 1px solid;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
:deep(th) {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
:deep(ul),
|
||||
:deep(ol) {
|
||||
margin: 8px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
</style>
|
||||
56
frontend/app/components/global/StatsCards.vue
Normal file
56
frontend/app/components/global/StatsCards.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<v-card
|
||||
:min-width="minWidth"
|
||||
:to="to"
|
||||
:hover="to ? true : false"
|
||||
>
|
||||
<div class="d-flex flex-no-wrap">
|
||||
<v-avatar
|
||||
class="ml-3 mr-0 mt-3"
|
||||
color="primary"
|
||||
size="36"
|
||||
>
|
||||
<v-icon
|
||||
color="white"
|
||||
class="pa-1"
|
||||
size="x-large"
|
||||
>
|
||||
{{ activeIcon }}
|
||||
</v-icon>
|
||||
</v-avatar>
|
||||
<div>
|
||||
<v-card-title class="text-subtitle-1 pt-2 pb-2">
|
||||
<slot name="title" />
|
||||
</v-card-title>
|
||||
<v-card-subtitle class="pb-2">
|
||||
<slot name="value" />
|
||||
</v-card-subtitle>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
icon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
minWidth: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
to: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const { $globals } = useNuxtApp();
|
||||
|
||||
const activeIcon = computed(() => {
|
||||
return props.icon ?? $globals.icons.primary;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
27
frontend/app/components/global/ToggleState.vue
Normal file
27
frontend/app/components/global/ToggleState.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<component :is="tag">
|
||||
<slot
|
||||
name="activator"
|
||||
v-bind="{ toggle, modelValue }"
|
||||
/>
|
||||
<slot v-bind="{ modelValue, toggle }" />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const modelValue = defineModel({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
});
|
||||
|
||||
defineProps({
|
||||
tag: {
|
||||
type: String,
|
||||
default: "div",
|
||||
},
|
||||
});
|
||||
|
||||
const toggle = () => {
|
||||
modelValue.value = !modelValue.value;
|
||||
};
|
||||
</script>
|
||||
44
frontend/app/components/global/WakelockSwitch.vue
Normal file
44
frontend/app/components/global/WakelockSwitch.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="wakeIsSupported"
|
||||
class="d-print-none d-flex px-2"
|
||||
:class="$vuetify.display.smAndDown ? 'justify-center' : 'justify-end'"
|
||||
>
|
||||
<v-switch
|
||||
v-model="wakeLock"
|
||||
color="primary"
|
||||
:label="$t('recipe.screen-awake')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useWakeLock } from "@vueuse/core";
|
||||
|
||||
const { isSupported: wakeIsSupported, isActive, request, release } = useWakeLock();
|
||||
const wakeLock = computed({
|
||||
get: () => isActive.value,
|
||||
set: () => {
|
||||
if (isActive.value) {
|
||||
unlockScreen();
|
||||
}
|
||||
else {
|
||||
lockScreen();
|
||||
}
|
||||
},
|
||||
});
|
||||
async function lockScreen() {
|
||||
if (wakeIsSupported) {
|
||||
console.debug("Wake Lock Requested");
|
||||
await request("screen");
|
||||
}
|
||||
}
|
||||
async function unlockScreen() {
|
||||
if (wakeIsSupported || isActive) {
|
||||
console.debug("Wake Lock Released");
|
||||
await release();
|
||||
}
|
||||
}
|
||||
onMounted(() => lockScreen());
|
||||
onUnmounted(() => unlockScreen());
|
||||
</script>
|
||||
Reference in New Issue
Block a user