chore: Nuxt 4 upgrade (#7426)

This commit is contained in:
Kuchenpirat
2026-04-08 17:25:41 +02:00
committed by GitHub
parent 70a251a331
commit d3e41582ae
561 changed files with 1840 additions and 2750 deletions

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>